Document generation
Create PDFs with pdfspine — render Markdown to a PDF with markdown_to_pdf, open images as PDF documents, and serialize any document to PDF bytes with convert_to_pdf.
Beyond reading and editing existing PDFs, pdfspine can create them: render Markdown to a PDF, open an image as a one-page PDF, and serialize any open document back to PDF bytes.
Markdown → PDF
pdfspine.markdown_to_pdf() renders CommonMark plus the GFM extensions (tables,
strikethrough, task lists) to a new PDF Document through a self-authored,
deterministic pure-Rust layout engine. The same input and options always produce
the same PDF bytes. This is a pdfspine original extension — it is not part of
the PyMuPDF surface.
import pdfspine
doc = pdfspine.markdown_to_pdf("# Title\n\nHello **world**.")
doc.save("hello.pdf")The first argument is Markdown text or a file path. It is treated as a file
only when it names an existing .md / .markdown / .txt / suffix-less file
(the file's parent directory then becomes the image base directory); anything
else — including a path that does not exist — is used verbatim as Markdown.
doc = pdfspine.markdown_to_pdf("README.md")
doc.save("readme.pdf")Coverage includes headings H1–H6, paragraphs with bold / italic / inline-code /
links, nested ordered / unordered / task lists, nested blockquotes, fenced code
blocks, horizontal rules, GFM tables (measured column widths, row-by-row
cross-page pagination), and images from local paths or data: URIs.
Fonts and CJK
The body and heading face defaults to the built-in Base-14 set (nothing
embedded). Pass font= — a TTF/OTF path or its bytes — to replace it; the face
is embedded once, subset to the glyphs used. cjk_font= is a per-character
fallback for characters the active face cannot encode, such as Chinese;
without it, CJK characters degrade to ? (never an error).
doc = pdfspine.markdown_to_pdf(
"report.md",
font="fonts/Inter.ttf",
cjk_font="fonts/NotoSansCJK.ttf",
)
doc.save("report.pdf")Options
Every argument after the first is keyword-only.
| Option | Type | Default | Meaning |
|---|---|---|---|
font | path / bytes | Base-14 | Body + heading face (embedded, subset). |
cjk_font | path / bytes | none | Per-character CJK fallback face. |
base_dir | path | file's parent, else none | Base directory for relative image paths. |
page_width | float (pt) | 595.32 (A4) | Page width. |
page_height | float (pt) | 841.92 (A4) | Page height. |
margins | float or 4-tuple | 72 | All four sides, or (top, right, bottom, left). |
links | bool | True | Write clickable URI/internal-link annotations (0.8.0). |
toc | bool | True | Write heading hierarchy to the PDF outline (0.8.0). |
body_font_size | float (pt) | 11 | Body size; headings scale from it. |
Images () load from local paths and data: URIs only — remote URLs
are rejected and no network access ever happens.
markdown_to_pdf returns an open pdfspine.Document, so you can keep editing it
(add pages, insert text, set metadata) before saving, or get the bytes directly
with doc.tobytes().
Links and outline (0.8.0)
doc = pdfspine.markdown_to_pdf("# Title\n\n[Website](https://example.com)")
print(doc.get_toc())
print(doc[0].get_links())Both passes are enabled by default. Internal heading links resolve to matching
heading anchors; unresolved anchors get no annotation. Heading level jumps are
normalized for the outline. Use links=False, toc=False to disable both passes.
This does not download remote images or follow links.
Images → PDF
pdfspine.open() transparently converts a raster image — from a path or
in-memory bytes — into a single-page PDF Document at open time:
doc = pdfspine.open("scan.png") # image by path
doc = pdfspine.open(stream=image_bytes) # image by bytes
doc = pdfspine.open(stream=image_bytes, filetype="png")
assert doc.page_count == 1 and doc.is_pdfconvert_to_pdf
Document.convert_to_pdf() serializes the open document to PDF bytes. Because
pdfspine documents are already PDF-backed (an image opened via open() is
converted to a single-page PDF at open time), it returns bytes that reparse
cleanly via open(). It mirrors PyMuPDF's doc.convert_to_pdf and accepts the
from_page / to_page / rotate arguments for compatibility:
doc = pdfspine.open("scan.png")
pdf_bytes = doc.convert_to_pdf() # -> bytes, starts with b"%PDF-"
reparsed = pdfspine.open(stream=pdf_bytes) # reparses cleanly
# fitz-compatible signature:
pdf_bytes = doc.convert_to_pdf(from_page=0, to_page=-1, rotate=0)A genuinely non-image, non-PDF input raises pdfspine.PdfUnsupportedError.