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:cantSplitare 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:
- indexes the fonts installed on the machine (cached between runs),
- matches family, weight, italic and width (so Arial Narrow is not silently replaced by Arial),
- consults Windows' own font substitution table when present,
- 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,
- swaps in a font per character when a glyph is missing, so
•,→,€,漢and friends never come out as empty boxes, - 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
hheaascent + 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:tblIndwas 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)
| File | Size | Uploaded | |
|---|---|---|---|
| word2pdf-0.1.0.tar.gz | 152.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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