pdfspine
docspine

PDF export

Render a .docx document to PDF with Document.to_pdf and save_pdf — streaming pagination, per-section page geometry, table fidelity, font maps, and degradation warnings.

Document.to_pdf() renders a parsed document to PDF with a streaming flow layout and real pagination — content flows and breaks across pages the way Word lays it out.

API

class Document:
    def to_pdf(self, *, font_map: dict[str, str] | None = None) -> bytes: ...
    def save_pdf(
        self, path: str | os.PathLike[str], *, font_map: dict[str, str] | None = None
    ) -> None: ...
import docspine

doc = docspine.open("report.docx")

pdf_bytes = doc.to_pdf()             # -> bytes, starts with b"%PDF-"
doc.save_pdf("report.pdf")          # writes the same bytes to a file

Per-section page geometry

Page size, margins, and orientation come from each sectPr, so a document whose sections mix paper sizes and orientations paginates correctly — for example a US-Letter portrait section followed by an A4 landscape one:

# section 1 -> 612 × 792 pt   (US Letter, portrait)
# section 2 -> 841.9 × 595.3 pt (A4, landscape)
pdf = docspine.open("mixed-sections.docx").to_pdf()

Section breaks and explicit page breaks (w:br w:type="page") both start a new page.

Fonts and the font_map

The renderer resolves styles.xml + theme effective fonts (including the east-Asian eastAsia slot for CJK) and embeds each used face once, subset. Pass font_map to override family resolution — each entry maps a requested family to a font file path or another family name:

pdf = doc.to_pdf(font_map={"宋体": "Songti SC"})

Substitution warns, never fails

When a requested font is missing, docspine substitutes a fallback (e.g. Calibri → Liberation Sans) and emits a UserWarning through warnings.warn whose message names the substitute family — one warning per unique kind, not one per run. Other degradations (an unsupported multi-column layout, a table row taller than the page body, custom w:tabs tab stops, text-wrap around an anchored image, a document-internal w:hyperlink bookmark, and the between-edge or intra-paragraph-break cases of paragraph borders / shading) warn the same way. Export never raises for these; it degrades and warns.

import warnings

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    pdf = doc.to_pdf(font_map={"Missing Family": "Liberation Serif"})

for w in caught:
    print(w.category.__name__, w.message)   # UserWarning ...

What the renderer covers

FeatureSupport
PaginationStreaming flow layout; content breaks across pages.
Section geometryPer-section page size / margins / orientation from sectPr.
Stylesstyles.xml inheritance (docDefaults → basedOn → direct) plus theme.
NumberingList counters via the per-numId, per-level numbering engine.
TablesPer-edge borders, shading, cell margins, gridSpan / vMerge merges, and whole-row moves across page breaks. Cell vertical alignment renders — w:vAlign center / bottom offset the cell content after the row height settles.
Paragraph borders & shadingA paragraph's w:pBdr (top / right / bottom / left) edges and w:shd fill are drawn (visible in get_drawings); the border @w:space folds into padding. The w:between edge and intra-paragraph page-break cases still degrade with a warning.
IndentsHanging and first-line indents.
Tab stopsA w:tab advances to the next tab stop. The interval is settings.xml's w:defaultTabStop (0.5″ Word default when absent); custom per-paragraph w:tabs stops degrade to the default interval with a warning.
HyperlinksExternal w:hyperlink runs render as /Link URI annotations (readable via pdfspine get_links). Document-internal anchor bookmarks are stored but not drawn as links, with a one-time warning.
Run stylingUnderline styles, highlight, and strikethrough.
CJKEast-Asian eastAsia font slot resolved for CJK runs.
ImagesInline pictures are rendered as block images at their wp:extent size, downscaled to the column width (never upscaled). Anchored (wp:anchor) pictures are absolutely positioned at their posOffset on the section's first page. JPEG passes through; PNG / BMP / GIF / WEBP / TIFF decode on emission.

Embedded pictures render. Each inline picture (with usable media bytes and a wp:extent size) is placed in the flow as a block image at the point its paragraph occurs; a picture that is the whole paragraph takes no extra blank line. Floating (wp:anchor) raster pictures are absolutely positioned — the picture is drawn as a page overlay at its wp:positionH / wp:positionV posOffset (relative to the page or the section margins) on the section's first page. Text does not wrap around it — that emits a one-time FloatingNoWrap warning (per-line exclusion rectangles remain out of v1). Vector-format (EMF / WMF) or byte-missing anchored pictures fall back to the inline-placeholder or skip paths; a picture with missing media bytes or no size is skipped with a UserWarning (its bytes stay available at parse level via image_bytes()).

Rendering is deterministic per font environment: the same document and the same installed fonts produce the same PDF bytes.

On main, unreleased

These export changes are merged on main after 0.5.1 and are not yet on PyPI:

  • Paragraph borders and shading use pdf-typeset's native paragraph borders instead of a one-cell table wrapper. Bordered or shaded paragraphs that split across pages, including in-paragraph page breaks, are drawn on every page; dashed and dashSmallGap render as dashes, and matching adjacent paragraphs share one box. w:between is still not drawn and warns once.
  • Superscript / subscript render with a real baseline shift (glyphs at 0.65×, raised or lowered) instead of only shrinking.
  • w:spacing and w:position (character spacing and manual baseline shift) are parsed, cascaded and rendered. Over-condensed paragraphs fall back to zero spacing with a signed-spacing-fallback warning.

Documents that use none of these render byte-identical PDFs.

On this page