Metadata-Version: 2.4
Name: word2pdf
Version: 0.1.0
Summary: Convert .docx to PDF in pure Python - no Word, no LibreOffice, no external binaries
Author: surenjanath
License-Expression: MIT
Project-URL: Homepage, https://surenjanath.github.io/Word2PDF/
Project-URL: Source, https://github.com/surenjanath/Word2PDF
Project-URL: Issues, https://github.com/surenjanath/Word2PDF/issues
Project-URL: Changelog, https://github.com/surenjanath/Word2PDF/blob/main/CHANGELOG.md
Keywords: docx,pdf,word,openxml,convert,report,template,docx2pdf,document,reportlab
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Office/Business
Classifier: Topic :: Printing
Classifier: Topic :: Text Processing :: Markup :: XML
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: reportlab>=3.6
Provides-Extra: images
Requires-Dist: pillow>=9.0; extra == "images"
Provides-Extra: templates
Requires-Dist: jinja2>=3.0; extra == "templates"
Provides-Extra: dev
Requires-Dist: pillow>=9.0; extra == "dev"
Requires-Dist: jinja2>=3.0; extra == "dev"
Requires-Dist: python-docx>=1.0; extra == "dev"
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pymupdf>=1.23; extra == "dev"
Dynamic: license-file

<div align="center">

<img src="https://raw.githubusercontent.com/surenjanath/Word2PDF/main/assets/logo.png" alt="Word2PDF" width="120">

# Word2PDF

**Convert `.docx` to PDF in pure Python.**

[![PyPI](https://img.shields.io/pypi/v/word2pdf?color=3A6FB8)](https://pypi.org/project/word2pdf/)
[![Python](https://img.shields.io/badge/python-3.9%2B-3A6FB8)](https://www.python.org/)
[![Dependency](https://img.shields.io/badge/dependency-reportlab-1D3C6E)](https://pypi.org/project/reportlab/)
[![Tests](https://github.com/surenjanath/Word2PDF/actions/workflows/ci.yml/badge.svg)](https://github.com/surenjanath/Word2PDF/actions/workflows/ci.yml)
[![vs. Word](https://img.shields.io/badge/vs.%20Word-page--for--page-C3271B)](#how-the-layout-matches-word)
[![License](https://img.shields.io/badge/license-MIT-444444)](LICENSE)

[Documentation](https://surenjanath.github.io/Word2PDF/) ·
[Changelog](CHANGELOG.md) ·
[Issues](https://github.com/surenjanath/Word2PDF/issues)

</div>

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

```bash
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:

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

## Use it

```python
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:

```python
from word2pdf import Converter

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

From the shell:

```bash
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 }}`.

```python
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|default('-') }}` | filters |
| `{%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:

```python
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.

```python
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

```bash
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:

```bash
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.
