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 this page