Skip to main content
Word2PDF

Word2PDF

Convert .docx to PDF in pure Python.

PyPI Python Dependency Tests vs. Word License

Documentation · Changelog · Issues

No Microsoft Word, no LibreOffice, no soffice subprocess, no Windows-only COM automation, no external binaries at all — so the same code runs on Windows, macOS, Linux, Docker, AWS Lambda and anywhere else Python runs.

It reads WordprocessingML directly, lays the document out itself, and draws the result with ReportLab. Layout is modelled on how Word actually does it, and the output is checked page-by-page against Word's own PDF export:

document pages text match dx dy
work instruction: headings, lists, landscape annex of diagrams, tables 4/4 100% 0.06 pt 0.52 pt
application form: label/value tables, tick boxes, signature block 2/2 100% 0.07 pt 0.87 pt
account statement: transactions table, decimal tabs, totals 1/1 100% 0.07 pt 0.77 pt
headings, lists, tables, image, header/footer 3/3 100% 0.08 pt 2.19 pt
benefit statement: tables over three pages, Verdana 3/3 97% 0.13 pt 5.68 pt
feature soup: merged cells, nested tables, leaders, columns, hyperlinks 3/3 99% 0.09 pt 2.36 pt
spacing and line-height probes 2/2 100% 0.07 pt 0.17 pt
baseline and font-metric probes 2/2 100% 0.06 pt 0.83 pt
paragraph-spacing probes 1/1 100% 0.06 pt 0.17 pt
table geometry probes 1/1 100% 0.05 pt 0.10 pt
page-top spacing probes 13/13 100% 0.46 pt 0.09 pt
table indent probes 1/1 100% 0.06 pt 0.10 pt
section and column probes 2/2 100% 0.04 pt 1.86 pt

Page counts match exactly; dx/dy are the median distance between a word in this PDF and the same word in Word's, in points (1 pt = 1/72 inch). The text match is measured on extracted words, so it counts extraction-order quirks (a table read column-first, leader dots glued to a heading) as differences even when the pages look the same.

Every document measured here is generated, by tests/make_fixtures.py and tests/make_samples.py: the corpus contains no real documents, and the names, figures and addresses in it are invented. Reproduce the table with python tests/fidelity.py on a machine with Word installed.

Three things are known to be out:

  • A table row never splits across a page. Word fills the page and carries the rest of the row over; this engine moves the whole row to the next page, which is what the benefit statement's 5.68 pt is. Rows marked w:cantSplit are correct either way.
  • A justified line can break one word early. In the sample letter one line takes a word Word squeezes onto the line before it.
  • A header set in a font with a large line gap sits about 2 pt low (Verdana in the benefit statement; Calibri and Arial headers land within 0.1 pt).

Install

pip install word2pdf                        # core: ReportLab only
pip install "word2pdf[images,templates]"    # + Pillow (JPEG/TIFF, cropping) and Jinja2

Python 3.9+, and the only hard dependency is reportlab. Nothing is compiled, so the same wheel runs on Windows, macOS, Linux, Docker and AWS Lambda.

Straight from the repository instead:

pip install git+https://github.com/surenjanath/Word2PDF.git

Use it

from word2pdf import convert

convert('report.docx', 'report.pdf')          # file to file
pdf_bytes = convert('report.docx')            # file to bytes (web responses)
convert(uploaded_bytes, 'out.pdf')            # bytes/stream in, file out

Reuse one converter for batches — the font index stays warm:

from word2pdf import Converter

converter = Converter()
for name in filenames:
    converter.convert(name, name.replace('.docx', '.pdf'))

From the shell:

word2pdf report.docx                     # -> report.pdf
word2pdf *.docx -d out/                  # batch into a directory
word2pdf invoice.docx -o inv.pdf --data values.json
word2pdf report.docx --image-quality 85 --image-max-dpi 200   # smaller file
python -m word2pdf --help

Dynamic documents (templates)

Design the document in Word, drop placeholders into the text, and fill it at conversion time. Word often splits a placeholder across several runs while you type; the engine stitches those back together before rendering, so {{ customer.name }} works even if Word stored it as {{ cus + tomer.n + ame }}.

convert('invoice.docx', 'invoice.pdf', context={
    'company': 'Northwind Example Ltd',
    'customer': {'name': 'A. Customer'},
    'lines': [
        {'description': 'Annual subscription', 'qty': 1, 'amount': '1,440.00'},
        {'description': 'Service fee',       'qty': 1, 'amount': '12.00'},
    ],
    'total': '1,452.00',
    'note': 'Payable within 30 days.',
})

Inside the .docx:

syntax what it does
{{ value }}, {{ a.b }}, {{ items[0] }} insert a value
`{{ name upper }}, {{ v
{%tr for row in rows %} … {%tr endfor %} repeat the table row that holds the tag
{%p for x in xs %} … {%p endfor %} repeat whole paragraphs
{% if cond %} … {% else %} … {% endif %} conditional text

{%tr %}/{%p %}/{%tc %} move the control tag outside the row, paragraph or cell it sits in, so the markup itself never shows up in the PDF. Placeholders in headers, footers and footnotes are filled too.

Jinja2 is used when installed (full expression support); otherwise a small built-in engine handles variables, for, if/elif/else, loop.index and the common filters. Force the built-in one with jinja=False. Values are always XML-escaped, and \n in a value becomes a real line break.

Keep the filled document as well:

from word2pdf import DocxTemplate

tpl = DocxTemplate('invoice.docx')
print(sorted(tpl.variables()))          # what does this template expect?
open('filled.docx', 'wb').write(tpl.render(context))

What it supports

Text — bold, italic, underline (single/double/dotted/dashed, custom colour), strikethrough and double strikethrough, superscript and subscript, small caps, all caps, character spacing, character scaling, baseline shift, highlighting, text shading, theme colours with tint/shade, hidden text, symbols, non-breaking hyphens and spaces.

Paragraphs — left/centre/right/justified alignment (including Word's compress-to-fit justification), left/right/first-line/hanging indents, space before/after with Word's collapsing rule, exact/at-least/multiple line spacing, contextual spacing, keep-with-next, keep-lines, widow/orphan control, page-break-before, borders and shading, and tab stops (left, centre, right, decimal, bar) with dot, hyphen and underscore leaders.

Lists — multilevel numbering from numbering.xml: decimal, leading zero, upper/lower roman, upper/lower letters, ordinals, cardinal and ordinal text, bullets, legal numbering, level restarts and overrides, custom level text like %1.%2, and Symbol/Wingdings bullets mapped to Unicode so they render on machines without those fonts.

Tables — table styles with conditional formatting (first/last row, first/last column, row and column banding), cell borders and shading, merged cells (horizontal and vertical), nested tables, cell margins, vertical alignment, fixed and autofit widths, table indent and alignment, row heights, repeating header rows across pages, and rows that cannot split.

Page setup — multiple sections, page size and orientation, margins, gutters, headers and footers (default/first/even), page borders, multiple columns with separators, page numbering (PAGE, NUMPAGES, restart, roman/letter formats), and section breaks (next page, continuous, even, odd).

Objects — inline and floating images (PNG/JPEG/GIF/BMP/TIFF) with cropping and rotation, anchored drawings positioned relative to the page, margin, column or paragraph, images behind or in front of text, text boxes, VML shapes, footnotes and endnotes with their separator, hyperlinks (external and internal), bookmarks, and PDF outline entries built from heading levels.

Fields — PAGE, NUMPAGES, DATE, TIME, HYPERLINK, REF and friends; anything else falls back to the result Word cached in the file.

Fonts

The real reason most converters look wrong is font substitution. This one:

  1. indexes the fonts installed on the machine (cached between runs),
  2. matches family, weight, italic and width (so Arial Narrow is not silently replaced by Arial),
  3. consults Windows' own font substitution table when present,
  4. falls back the way Word does — to the document's default font — so a document written in a font you do not have breaks lines where Word breaks them,
  5. swaps in a font per character when a glyph is missing, so •, →, €, 漢 and friends never come out as empty boxes,
  6. synthesises bold and italic when only the regular face exists.
convert('doc.docx', 'doc.pdf',
        font_dirs=['/opt/fonts'],        # extra directories to search
        font_fallback='closest')         # 'word' (default) or 'closest'

font_fallback='closest' prefers the nearest match by width and style (Zurich Cn BT → Arial Narrow) instead of Word's document-default rule — nicer looking, but it will not line up with a Word export.

How the layout matches Word

Word does not lay text out in exact points, and copying its quirks is what gets the last few points of accuracy:

  • 600 dpi device grid — Word rounds font sizes and line heights to 1/600", which is why an 11 pt run is drawn at 11.04 pt. Line heights are snapped the same way here (disable with device_grid=0).
  • Advance vs. drawing — Word measures text with the nominal size but draws with the snapped one; this engine measures the same way, so line breaks land on the same words.
  • Line boxes — height comes from the font's hhea ascent + descent + line gap; the baseline sits the OS/2 win-descent above the bottom of the box, and extra leading from multiple line spacing goes below the text.
  • Paragraph spacing collapses — Word keeps max(space-after, space-before) between two paragraphs, not the sum, and at the top of a page or column it usually drops space-before entirely (the exceptions are two bullets down). (paragraph_spacing='add' restores the additive behaviour.)
  • Justification compresses — rather than push a word to the next line, Word squeezes the line with negative character spacing, up to about a quarter of its white space. Same here, which keeps paragraph shapes identical.
  • Tables — before Word 2013 w:tblInd was measured to the cell's text rather than the border, so the table hangs a cell margin into the margin; from compatibility mode 15 on it means the border itself, and the file says which. Cell borders take room inside the row, and an empty paragraph after a nested table has no height.
  • Space before at the top of a page — dropped when the paragraph simply flowed there, and dropped after a hard page break too, but kept where a section starts; even there Word collapses it against the space-after of the paragraph that ended the previous section, which was already spent on the page before.
  • Section marks — the empty paragraph carrying a next-page section break does not earn a page of its own: the next section starts a page anyway, so an empty mark that does not fit hangs past the bottom margin instead.
  • Super- and subscript — drawn at 65% of the size, but the line box keeps the full one, so a line of nothing but superscript is as tall as its neighbours.
  • Styles — the full cascade: document defaults → table style (with conditional formatting) → numbering → paragraph style chain → character style → direct formatting, with the default paragraph style applying only to paragraphs that actually inherit from it.

Options

option default meaning
context None template values; filling is skipped when omitted
jinja True use Jinja2 when it is installed
font_dirs None extra font directories
default_font document's own fallback family
font_fallback 'word' 'word' or 'closest'
device_grid 72/600 Word's rounding grid; 0 for exact points
paragraph_spacing 'collapse' 'collapse' or 'add'
image_quality None JPEG quality for recompression; off by default
image_max_dpi None downsample images above this resolution
compress True compress PDF streams
title, author document properties PDF metadata
font_cache True cache the font index on disk

convert() returns a Result (pages, warnings, missing_fonts, output) when writing to a path, or the PDF bytes when the target is omitted.

Limitations

  • .doc (Word 97-2003) is not supported — only Open XML .docx. The error message says so when you hand it one.
  • Text wrapping around floating images is not implemented: anchored objects are placed correctly but text flows under them rather than around them.
  • EMF/WMF vector images (old clip art, some Visio/Equation objects) are skipped; everything else on the page still renders.
  • Charts and SmartArt render only if the file also stores them as a picture.
  • Right-to-left scripts, vertical text and complex shaping (Arabic, Devanagari) are laid out left-to-right without contextual shaping.
  • Fields that need a recalculated value (TOC, SEQ, formulas) use the result Word last saved in the document.
  • Tracked changes render as accepted; comments are not drawn.
  • A table row is never split across a page: a row that does not fit moves to the next page whole, where Word would fill the page first.

Development

pip install -e ".[dev]"
python tests/make_fixtures.py      # the .docx fixtures (needs python-docx)
python tests/make_samples.py       # the longer sample documents
python -m pytest                   # 42 tests, no Word required

Both corpora are generated and gitignored, so the repository holds no documents of its own: make_fixtures.py writes one file per rule under test, and make_samples.py writes whole documents - a work instruction, a form, a statement, a letter - made up of invented content.

On a Windows machine with Word installed you can check fidelity against the real thing:

python tests/reference.py tests/fixtures/basic.docx out/reference/basic.pdf
python -m word2pdf tests/fixtures/basic.docx -o out/basic.pdf
python tests/compare.py out/basic.pdf out/reference/basic.pdf
python tests/lines.py  out/basic.pdf out/reference/basic.pdf 0   # baseline diff

tests/make_probe*.py build the small documents used to derive Word's layout rules (spacing, baselines, table geometry); tests/preview.py renders pages to PNG for eyeballing.

Architecture

One folder per stage of the pipeline; each __init__.py states what that layer is for and re-exports its surface, so a layer is used without reaching into its modules.

word2pdf/
    api.py          the public API: Converter, convert(), Result
    cli.py          the command line
    oxml/           the Open XML layer
        package.py    zip container, parts and relationships
        xml.py        namespaces, element helpers, unit conversions
    fonts/          which face a run gets, and how wide its text is
        ttf.py        minimal TrueType reader (names, metrics, cmap)
        manager.py    font index, substitution, glyph fallback, measurement
    document/       WordprocessingML -> a style-resolved model
        props.py      typed property bags and their merge rules
        styles.py     styles.xml + theme: the cascade
        numbering.py  numbering.xml: definitions, counters, label formatting
        model.py      the shapes the rest of the engine sees
        parser.py     the reading itself
    layout/         model -> boxes -> pages
        boxes.py      what is measured, and the drawing operations
        engine.py     measuring (all the Word-matching rules)
        flow.py       boxes -> pages, and pages -> drawing operations
    render/
        pdf.py        drawing operations -> PDF, via ReportLab
    templates/      {{ placeholders }} in .docx
        docx.py       stitching split runs, relocating control tags
        engine.py     Jinja2, or the built-in expression engine

Each layer only knows about the one below it: document never mentions a page, layout never touches XML, and render makes no layout decisions - which is what lets every stage be measured against Word on its own.

Licence

MIT.

Metadata

Release files for word2pdf 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for word2pdf 0.1.0
File Size Uploaded
word2pdf-0.1.0.tar.gz 152.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for word2pdf 0.1.0
File Interpreter ABI Platform
word2pdf-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 241.4 kB

Release files / word2pdf-0.1.0.tar.gz

Download URL word2pdf-0.1.0.tar.gz
Size 152.8 kB
Tags Source
SHA-256 checksum
How to use checksums
8ee60b130081667d582f48a283c6fb9d3b8cbd7af0959242eed1765330e9fc88
BLAKE2b-256 checksum
How to use checksums
78633366159d05cd16fca32f7902f4efb572e23faa6597fd82d92f8a3701f36c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.

Transparency log

Release files / word2pdf-0.1.0-py3-none-any.whl

Download URL word2pdf-0.1.0-py3-none-any.whl
Size 88.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4711a24f401c82db7e8e98933a70fc5ab857afed6ef6b07a436c4f8621c9f37f
BLAKE2b-256 checksum
How to use checksums
2801c56cb7d331c00750254a5a965502472d86a742839327f52de5dbd710ca2b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page