Converts .docx to PDF without Word, LibreOffice or any binary — and lays the page out the way Word does, down to fractions of a point.
Why it exists
No COM automation, no soffice subprocess, no headless install to babysit. The same wheel runs on Windows, macOS, Linux, Docker and Lambda.
ReportLab draws the result; everything above it — the OPC container, WordprocessingML, the style cascade, fonts, layout — is this package.
Every release is compared with Word's own PDF export, page by page, word by word, and the distances are in the table below.
Against Word's own export
| Document | Pages | Text | dx | dy |
|---|---|---|---|---|
| Work instruction — landscape annex, diagrams, tables | 4/4 | 100% | 0.06 | 0.52 |
| Application form — label/value tables, tick boxes | 2/2 | 100% | 0.07 | 0.87 |
| Account statement — transactions, decimal tabs | 1/1 | 100% | 0.07 | 0.77 |
| Headings, lists, tables, image, header/footer | 3/3 | 100% | 0.08 | 2.19 |
| Feature soup — merged cells, nested tables, columns | 3/3 | 99% | 0.09 | 2.36 |
| Benefit statement — tables over three pages | 3/3 | 97% | 0.13 | 5.68 |
| Baseline and font-metric probes | 2/2 | 100% | 0.06 | 0.83 |
| Page-top spacing probes | 13/13 | 100% | 0.46 | 0.09 |
| Table geometry and indent probes | 2/2 | 100% | 0.05 | 0.10 |
Every document in that corpus is generated by the test suite — the repository holds no real documents. Reproduce it with python tests/fidelity.py on a machine that has Word installed.
Use it
from word2pdf import convert
convert('report.docx', 'report.pdf')
pdf = convert('report.docx') # bytes
convert(uploaded_bytes, 'out.pdf')
# one warm font index for a batch
from word2pdf import Converter
c = Converter()
for name in filenames:
c.convert(name, name[:-5] + '.pdf')
word2pdf report.docx
word2pdf *.docx -d out/
word2pdf invoice.docx --data values.json
word2pdf big.docx --image-quality 85
python -m word2pdf --help
Dynamic documents
Word splits a placeholder across runs as you type. The engine stitches those back together first, so {{ customer.name }} works even when the file stored it as three fragments.
convert('invoice.docx', 'invoice.pdf', context={
'company': 'Northwind Example Ltd',
'customer': {'name': 'A. Customer'},
'lines': [
{'description': 'Annual subscription',
'qty': 1, 'amount': '1,440.00'},
],
'total': '1,452.00',
})
| In the .docx | Does |
|---|---|
| {{ value }} | insert a value |
| {{ a.b }}, {{ items[0] }} | nested and indexed |
| {{ name|upper }} | filters |
| {%tr for r in rows %} | repeat the table row |
| {%p for x in xs %} | repeat the paragraph |
| {% if cond %} | conditional text |
Jinja2 is used when it is installed; otherwise a built-in engine covers variables, loops, conditionals and the common filters. Headers, footers and footnotes are filled too.
What it handles
Known limits