word2pdf 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. word2pdf-0.1.0/CHANGELOG.md +36 -0
  2. word2pdf-0.1.0/LICENSE +21 -0
  3. word2pdf-0.1.0/MANIFEST.in +10 -0
  4. word2pdf-0.1.0/PKG-INFO +418 -0
  5. word2pdf-0.1.0/README.md +376 -0
  6. word2pdf-0.1.0/assets/logo.png +0 -0
  7. word2pdf-0.1.0/assets/logo.svg +44 -0
  8. word2pdf-0.1.0/examples/batch_and_web.py +36 -0
  9. word2pdf-0.1.0/examples/convert_file.py +12 -0
  10. word2pdf-0.1.0/examples/invoice_from_template.py +47 -0
  11. word2pdf-0.1.0/pyproject.toml +64 -0
  12. word2pdf-0.1.0/setup.cfg +4 -0
  13. word2pdf-0.1.0/tests/compare.py +101 -0
  14. word2pdf-0.1.0/tests/fidelity.py +66 -0
  15. word2pdf-0.1.0/tests/lines.py +39 -0
  16. word2pdf-0.1.0/tests/make_fixtures.py +221 -0
  17. word2pdf-0.1.0/tests/make_kitchen.py +191 -0
  18. word2pdf-0.1.0/tests/make_probe.py +64 -0
  19. word2pdf-0.1.0/tests/make_probe2.py +72 -0
  20. word2pdf-0.1.0/tests/make_probe3.py +62 -0
  21. word2pdf-0.1.0/tests/make_probe4.py +100 -0
  22. word2pdf-0.1.0/tests/make_probe5.py +141 -0
  23. word2pdf-0.1.0/tests/make_probe6.py +156 -0
  24. word2pdf-0.1.0/tests/make_samples.py +474 -0
  25. word2pdf-0.1.0/tests/preview.py +20 -0
  26. word2pdf-0.1.0/tests/probe_tc.py +51 -0
  27. word2pdf-0.1.0/tests/probe_widths.py +63 -0
  28. word2pdf-0.1.0/tests/reference.py +51 -0
  29. word2pdf-0.1.0/tests/test_convert.py +262 -0
  30. word2pdf-0.1.0/tests/test_units.py +138 -0
  31. word2pdf-0.1.0/word2pdf/__init__.py +18 -0
  32. word2pdf-0.1.0/word2pdf/__main__.py +4 -0
  33. word2pdf-0.1.0/word2pdf/api.py +143 -0
  34. word2pdf-0.1.0/word2pdf/cli.py +155 -0
  35. word2pdf-0.1.0/word2pdf/document/__init__.py +24 -0
  36. word2pdf-0.1.0/word2pdf/document/model.py +235 -0
  37. word2pdf-0.1.0/word2pdf/document/numbering.py +313 -0
  38. word2pdf-0.1.0/word2pdf/document/parser.py +957 -0
  39. word2pdf-0.1.0/word2pdf/document/props.py +484 -0
  40. word2pdf-0.1.0/word2pdf/document/styles.py +352 -0
  41. word2pdf-0.1.0/word2pdf/fonts/__init__.py +11 -0
  42. word2pdf-0.1.0/word2pdf/fonts/manager.py +607 -0
  43. word2pdf-0.1.0/word2pdf/fonts/ttf.py +321 -0
  44. word2pdf-0.1.0/word2pdf/layout/__init__.py +16 -0
  45. word2pdf-0.1.0/word2pdf/layout/boxes.py +245 -0
  46. word2pdf-0.1.0/word2pdf/layout/engine.py +998 -0
  47. word2pdf-0.1.0/word2pdf/layout/flow.py +799 -0
  48. word2pdf-0.1.0/word2pdf/oxml/__init__.py +19 -0
  49. word2pdf-0.1.0/word2pdf/oxml/package.py +167 -0
  50. word2pdf-0.1.0/word2pdf/oxml/xml.py +165 -0
  51. word2pdf-0.1.0/word2pdf/py.typed +0 -0
  52. word2pdf-0.1.0/word2pdf/render/__init__.py +4 -0
  53. word2pdf-0.1.0/word2pdf/render/pdf.py +282 -0
  54. word2pdf-0.1.0/word2pdf/templates/__init__.py +5 -0
  55. word2pdf-0.1.0/word2pdf/templates/docx.py +221 -0
  56. word2pdf-0.1.0/word2pdf/templates/engine.py +348 -0
  57. word2pdf-0.1.0/word2pdf.egg-info/PKG-INFO +418 -0
  58. word2pdf-0.1.0/word2pdf.egg-info/SOURCES.txt +60 -0
  59. word2pdf-0.1.0/word2pdf.egg-info/dependency_links.txt +1 -0
  60. word2pdf-0.1.0/word2pdf.egg-info/entry_points.txt +2 -0
  61. word2pdf-0.1.0/word2pdf.egg-info/requires.txt +14 -0
  62. word2pdf-0.1.0/word2pdf.egg-info/top_level.txt +1 -0
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [semantic versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## 0.1.0 - 2026-09-22
8
+
9
+ First release.
10
+
11
+ ### Added
12
+
13
+ - `convert()`, `convert_to_bytes()`, `convert_many()` and a reusable
14
+ `Converter` that keeps the font index warm between documents.
15
+ - A `word2pdf` command line, and `python -m word2pdf`.
16
+ - `.docx` templates: `{{ placeholders }}`, `{%tr %}` row loops, `{%p %}`
17
+ paragraph loops and conditionals, filled with Jinja2 when it is installed
18
+ and with a built-in engine when it is not.
19
+ - Layout modelled on Word's own: the 600 dpi device grid, its paragraph
20
+ spacing collapse, compress-to-fit justification, and its font substitution
21
+ and per-character glyph fallback.
22
+ - Tables with styles and conditional formatting, merged cells, nested tables
23
+ and repeating header rows; sections, columns, headers and footers, footnotes
24
+ and endnotes; inline and floating images; fields, hyperlinks and bookmarks.
25
+ - Output is checked page by page against Word's own PDF export by
26
+ `tests/fidelity.py`; the corpus it measures is generated, never real
27
+ documents.
28
+
29
+ ### Known limitations
30
+
31
+ - A table row is never split across a page: a row that does not fit moves to
32
+ the next page whole, where Word would fill the page first.
33
+ - Text does not wrap around floating images; anchored objects are placed
34
+ correctly but text flows under them.
35
+ - EMF/WMF vector images and DrawingML shapes are skipped.
36
+ - `.doc` (Word 97-2003) is not supported.
word2pdf-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 surenjanath
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include assets/logo.svg
5
+ include assets/logo.png
6
+ recursive-include tests *.py
7
+ recursive-include examples *.py
8
+ prune out
9
+ prune samples
10
+ prune docs
@@ -0,0 +1,418 @@
1
+ Metadata-Version: 2.4
2
+ Name: word2pdf
3
+ Version: 0.1.0
4
+ Summary: Convert .docx to PDF in pure Python - no Word, no LibreOffice, no external binaries
5
+ Author: surenjanath
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://surenjanath.github.io/Word2PDF/
8
+ Project-URL: Source, https://github.com/surenjanath/Word2PDF
9
+ Project-URL: Issues, https://github.com/surenjanath/Word2PDF/issues
10
+ Project-URL: Changelog, https://github.com/surenjanath/Word2PDF/blob/main/CHANGELOG.md
11
+ Keywords: docx,pdf,word,openxml,convert,report,template,docx2pdf,document,reportlab
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: Implementation :: CPython
23
+ Classifier: Topic :: Office/Business
24
+ Classifier: Topic :: Printing
25
+ Classifier: Topic :: Text Processing :: Markup :: XML
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.9
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: reportlab>=3.6
31
+ Provides-Extra: images
32
+ Requires-Dist: pillow>=9.0; extra == "images"
33
+ Provides-Extra: templates
34
+ Requires-Dist: jinja2>=3.0; extra == "templates"
35
+ Provides-Extra: dev
36
+ Requires-Dist: pillow>=9.0; extra == "dev"
37
+ Requires-Dist: jinja2>=3.0; extra == "dev"
38
+ Requires-Dist: python-docx>=1.0; extra == "dev"
39
+ Requires-Dist: pytest>=7.0; extra == "dev"
40
+ Requires-Dist: pymupdf>=1.23; extra == "dev"
41
+ Dynamic: license-file
42
+
43
+ <div align="center">
44
+
45
+ <img src="https://raw.githubusercontent.com/surenjanath/Word2PDF/main/assets/logo.png" alt="Word2PDF" width="120">
46
+
47
+ # Word2PDF
48
+
49
+ **Convert `.docx` to PDF in pure Python.**
50
+
51
+ [![PyPI](https://img.shields.io/pypi/v/word2pdf?color=3A6FB8)](https://pypi.org/project/word2pdf/)
52
+ [![Python](https://img.shields.io/badge/python-3.9%2B-3A6FB8)](https://www.python.org/)
53
+ [![Dependency](https://img.shields.io/badge/dependency-reportlab-1D3C6E)](https://pypi.org/project/reportlab/)
54
+ [![Tests](https://github.com/surenjanath/Word2PDF/actions/workflows/ci.yml/badge.svg)](https://github.com/surenjanath/Word2PDF/actions/workflows/ci.yml)
55
+ [![vs. Word](https://img.shields.io/badge/vs.%20Word-page--for--page-C3271B)](#how-the-layout-matches-word)
56
+ [![License](https://img.shields.io/badge/license-MIT-444444)](LICENSE)
57
+
58
+ [Documentation](https://surenjanath.github.io/Word2PDF/) ·
59
+ [Changelog](CHANGELOG.md) ·
60
+ [Issues](https://github.com/surenjanath/Word2PDF/issues)
61
+
62
+ </div>
63
+
64
+ No Microsoft Word, no LibreOffice, no `soffice` subprocess, no Windows-only COM
65
+ automation, no external binaries at all — so the same code runs on Windows,
66
+ macOS, Linux, Docker, AWS Lambda and anywhere else Python runs.
67
+
68
+ It reads WordprocessingML directly, lays the document out itself, and draws the
69
+ result with ReportLab. Layout is modelled on how Word actually does it, and the
70
+ output is checked page-by-page against Word's own PDF export:
71
+
72
+ | document | pages | text match | dx | dy |
73
+ |---|---|---|---|---|
74
+ | work instruction: headings, lists, landscape annex of diagrams, tables | 4/4 | 100% | 0.06 pt | 0.52 pt |
75
+ | application form: label/value tables, tick boxes, signature block | 2/2 | 100% | 0.07 pt | 0.87 pt |
76
+ | account statement: transactions table, decimal tabs, totals | 1/1 | 100% | 0.07 pt | 0.77 pt |
77
+ | headings, lists, tables, image, header/footer | 3/3 | 100% | 0.08 pt | 2.19 pt |
78
+ | benefit statement: tables over three pages, Verdana | 3/3 | 97% | 0.13 pt | 5.68 pt |
79
+ | feature soup: merged cells, nested tables, leaders, columns, hyperlinks | 3/3 | 99% | 0.09 pt | 2.36 pt |
80
+ | spacing and line-height probes | 2/2 | 100% | 0.07 pt | 0.17 pt |
81
+ | baseline and font-metric probes | 2/2 | 100% | 0.06 pt | 0.83 pt |
82
+ | paragraph-spacing probes | 1/1 | 100% | 0.06 pt | 0.17 pt |
83
+ | table geometry probes | 1/1 | 100% | 0.05 pt | 0.10 pt |
84
+ | page-top spacing probes | 13/13 | 100% | 0.46 pt | 0.09 pt |
85
+ | table indent probes | 1/1 | 100% | 0.06 pt | 0.10 pt |
86
+ | section and column probes | 2/2 | 100% | 0.04 pt | 1.86 pt |
87
+
88
+ Page counts match exactly; `dx`/`dy` are the median distance between a word in
89
+ this PDF and the same word in Word's, in points (1 pt = 1/72 inch). The text
90
+ match is measured on extracted words, so it counts extraction-order quirks (a
91
+ table read column-first, leader dots glued to a heading) as differences even
92
+ when the pages look the same.
93
+
94
+ Every document measured here is generated, by `tests/make_fixtures.py` and
95
+ `tests/make_samples.py`: the corpus contains no real documents, and the names,
96
+ figures and addresses in it are invented. Reproduce the table with
97
+ `python tests/fidelity.py` on a machine with Word installed.
98
+
99
+ Three things are known to be out:
100
+
101
+ - **A table row never splits across a page.** Word fills the page and carries
102
+ the rest of the row over; this engine moves the whole row to the next page,
103
+ which is what the benefit statement's 5.68 pt is. Rows marked
104
+ `w:cantSplit` are correct either way.
105
+ - **A justified line can break one word early.** In the sample letter one line
106
+ takes a word Word squeezes onto the line before it.
107
+ - **A header set in a font with a large line gap sits about 2 pt low** (Verdana
108
+ in the benefit statement; Calibri and Arial headers land within 0.1 pt).
109
+
110
+ ---
111
+
112
+ ## Install
113
+
114
+ ```bash
115
+ pip install word2pdf # core: ReportLab only
116
+ pip install "word2pdf[images,templates]" # + Pillow (JPEG/TIFF, cropping) and Jinja2
117
+ ```
118
+
119
+ Python 3.9+, and the only hard dependency is `reportlab`. Nothing is compiled,
120
+ so the same wheel runs on Windows, macOS, Linux, Docker and AWS Lambda.
121
+
122
+ Straight from the repository instead:
123
+
124
+ ```bash
125
+ pip install git+https://github.com/surenjanath/Word2PDF.git
126
+ ```
127
+
128
+ ## Use it
129
+
130
+ ```python
131
+ from word2pdf import convert
132
+
133
+ convert('report.docx', 'report.pdf') # file to file
134
+ pdf_bytes = convert('report.docx') # file to bytes (web responses)
135
+ convert(uploaded_bytes, 'out.pdf') # bytes/stream in, file out
136
+ ```
137
+
138
+ Reuse one converter for batches — the font index stays warm:
139
+
140
+ ```python
141
+ from word2pdf import Converter
142
+
143
+ converter = Converter()
144
+ for name in filenames:
145
+ converter.convert(name, name.replace('.docx', '.pdf'))
146
+ ```
147
+
148
+ From the shell:
149
+
150
+ ```bash
151
+ word2pdf report.docx # -> report.pdf
152
+ word2pdf *.docx -d out/ # batch into a directory
153
+ word2pdf invoice.docx -o inv.pdf --data values.json
154
+ word2pdf report.docx --image-quality 85 --image-max-dpi 200 # smaller file
155
+ python -m word2pdf --help
156
+ ```
157
+
158
+ ## Dynamic documents (templates)
159
+
160
+ Design the document in Word, drop placeholders into the text, and fill it at
161
+ conversion time. Word often splits a placeholder across several runs while you
162
+ type; the engine stitches those back together before rendering, so
163
+ `{{ customer.name }}` works even if Word stored it as `{{ cus` + `tomer.n` + `ame }}`.
164
+
165
+ ```python
166
+ convert('invoice.docx', 'invoice.pdf', context={
167
+ 'company': 'Northwind Example Ltd',
168
+ 'customer': {'name': 'A. Customer'},
169
+ 'lines': [
170
+ {'description': 'Annual subscription', 'qty': 1, 'amount': '1,440.00'},
171
+ {'description': 'Service fee', 'qty': 1, 'amount': '12.00'},
172
+ ],
173
+ 'total': '1,452.00',
174
+ 'note': 'Payable within 30 days.',
175
+ })
176
+ ```
177
+
178
+ Inside the .docx:
179
+
180
+ | syntax | what it does |
181
+ |---|---|
182
+ | `{{ value }}`, `{{ a.b }}`, `{{ items[0] }}` | insert a value |
183
+ | `{{ name|upper }}`, `{{ v|default('-') }}` | filters |
184
+ | `{%tr for row in rows %}` … `{%tr endfor %}` | repeat the **table row** that holds the tag |
185
+ | `{%p for x in xs %}` … `{%p endfor %}` | repeat whole **paragraphs** |
186
+ | `{% if cond %}` … `{% else %}` … `{% endif %}` | conditional text |
187
+
188
+ `{%tr %}`/`{%p %}`/`{%tc %}` move the control tag outside the row, paragraph or
189
+ cell it sits in, so the markup itself never shows up in the PDF. Placeholders in
190
+ headers, footers and footnotes are filled too.
191
+
192
+ Jinja2 is used when installed (full expression support); otherwise a small
193
+ built-in engine handles variables, `for`, `if/elif/else`, `loop.index` and the
194
+ common filters. Force the built-in one with `jinja=False`. Values are always
195
+ XML-escaped, and `\n` in a value becomes a real line break.
196
+
197
+ Keep the filled document as well:
198
+
199
+ ```python
200
+ from word2pdf import DocxTemplate
201
+
202
+ tpl = DocxTemplate('invoice.docx')
203
+ print(sorted(tpl.variables())) # what does this template expect?
204
+ open('filled.docx', 'wb').write(tpl.render(context))
205
+ ```
206
+
207
+ ## What it supports
208
+
209
+ **Text** — bold, italic, underline (single/double/dotted/dashed, custom colour),
210
+ strikethrough and double strikethrough, superscript and subscript, small caps,
211
+ all caps, character spacing, character scaling, baseline shift, highlighting,
212
+ text shading, theme colours with tint/shade, hidden text, symbols, non-breaking
213
+ hyphens and spaces.
214
+
215
+ **Paragraphs** — left/centre/right/justified alignment (including Word's
216
+ compress-to-fit justification), left/right/first-line/hanging indents,
217
+ space before/after with Word's collapsing rule, exact/at-least/multiple line
218
+ spacing, contextual spacing, keep-with-next, keep-lines, widow/orphan control,
219
+ page-break-before, borders and shading, and tab stops (left, centre, right,
220
+ decimal, bar) with dot, hyphen and underscore leaders.
221
+
222
+ **Lists** — multilevel numbering from `numbering.xml`: decimal, leading zero,
223
+ upper/lower roman, upper/lower letters, ordinals, cardinal and ordinal text,
224
+ bullets, legal numbering, level restarts and overrides, custom level text like
225
+ `%1.%2`, and Symbol/Wingdings bullets mapped to Unicode so they render on
226
+ machines without those fonts.
227
+
228
+ **Tables** — table styles with conditional formatting (first/last row,
229
+ first/last column, row and column banding), cell borders and shading, merged
230
+ cells (horizontal and vertical), nested tables, cell margins, vertical
231
+ alignment, fixed and autofit widths, table indent and alignment, row heights,
232
+ repeating header rows across pages, and rows that cannot split.
233
+
234
+ **Page setup** — multiple sections, page size and orientation, margins, gutters,
235
+ headers and footers (default/first/even), page borders, multiple columns with
236
+ separators, page numbering (`PAGE`, `NUMPAGES`, restart, roman/letter formats),
237
+ and section breaks (next page, continuous, even, odd).
238
+
239
+ **Objects** — inline and floating images (PNG/JPEG/GIF/BMP/TIFF) with cropping
240
+ and rotation, anchored drawings positioned relative to the page, margin, column
241
+ or paragraph, images behind or in front of text, text boxes, VML shapes,
242
+ footnotes and endnotes with their separator, hyperlinks (external and internal),
243
+ bookmarks, and PDF outline entries built from heading levels.
244
+
245
+ **Fields** — `PAGE`, `NUMPAGES`, `DATE`, `TIME`, `HYPERLINK`, `REF` and friends;
246
+ anything else falls back to the result Word cached in the file.
247
+
248
+ ## Fonts
249
+
250
+ The real reason most converters look wrong is font substitution. This one:
251
+
252
+ 1. indexes the fonts installed on the machine (cached between runs),
253
+ 2. matches family, weight, italic and width (so *Arial Narrow* is not silently
254
+ replaced by *Arial*),
255
+ 3. consults Windows' own font substitution table when present,
256
+ 4. falls back the way Word does — to the document's default font — so a document
257
+ written in a font you do not have breaks lines where Word breaks them,
258
+ 5. swaps in a font per character when a glyph is missing, so `•`, `→`, `€`, `漢`
259
+ and friends never come out as empty boxes,
260
+ 6. synthesises bold and italic when only the regular face exists.
261
+
262
+ ```python
263
+ convert('doc.docx', 'doc.pdf',
264
+ font_dirs=['/opt/fonts'], # extra directories to search
265
+ font_fallback='closest') # 'word' (default) or 'closest'
266
+ ```
267
+
268
+ `font_fallback='closest'` prefers the nearest match by width and style
269
+ (*Zurich Cn BT* → *Arial Narrow*) instead of Word's document-default rule — nicer
270
+ looking, but it will not line up with a Word export.
271
+
272
+ ## How the layout matches Word
273
+
274
+ Word does not lay text out in exact points, and copying its quirks is what gets
275
+ the last few points of accuracy:
276
+
277
+ - **600 dpi device grid** — Word rounds font sizes and line heights to 1/600",
278
+ which is why an 11 pt run is drawn at 11.04 pt. Line heights are snapped the
279
+ same way here (disable with `device_grid=0`).
280
+ - **Advance vs. drawing** — Word measures text with the nominal size but draws
281
+ with the snapped one; this engine measures the same way, so line breaks land
282
+ on the same words.
283
+ - **Line boxes** — height comes from the font's `hhea` ascent + descent + line
284
+ gap; the baseline sits the OS/2 win-descent above the bottom of the box, and
285
+ extra leading from multiple line spacing goes *below* the text.
286
+ - **Paragraph spacing collapses** — Word keeps `max(space-after, space-before)`
287
+ between two paragraphs, not the sum, and at the top of a page or column it
288
+ usually drops space-before entirely (the exceptions are two bullets down).
289
+ (`paragraph_spacing='add'` restores the additive behaviour.)
290
+ - **Justification compresses** — rather than push a word to the next line, Word
291
+ squeezes the line with negative character spacing, up to about a quarter of
292
+ its white space. Same here, which keeps paragraph shapes identical.
293
+ - **Tables** — before Word 2013 `w:tblInd` was measured to the cell's text
294
+ rather than the border, so the table hangs a cell margin into the margin;
295
+ from compatibility mode 15 on it means the border itself, and the file says
296
+ which. Cell borders take room inside the row, and an empty paragraph after a
297
+ nested table has no height.
298
+ - **Space before at the top of a page** — dropped when the paragraph simply
299
+ flowed there, and dropped after a hard page break too, but kept where a
300
+ section starts; even there Word collapses it against the space-after of the
301
+ paragraph that ended the previous section, which was already spent on the
302
+ page before.
303
+ - **Section marks** — the empty paragraph carrying a next-page section break
304
+ does not earn a page of its own: the next section starts a page anyway, so an
305
+ empty mark that does not fit hangs past the bottom margin instead.
306
+ - **Super- and subscript** — drawn at 65% of the size, but the line box keeps
307
+ the full one, so a line of nothing but superscript is as tall as its
308
+ neighbours.
309
+ - **Styles** — the full cascade: document defaults → table style (with
310
+ conditional formatting) → numbering → paragraph style chain → character style
311
+ → direct formatting, with the default paragraph style applying only to
312
+ paragraphs that actually inherit from it.
313
+
314
+ ## Options
315
+
316
+ | option | default | meaning |
317
+ |---|---|---|
318
+ | `context` | `None` | template values; filling is skipped when omitted |
319
+ | `jinja` | `True` | use Jinja2 when it is installed |
320
+ | `font_dirs` | `None` | extra font directories |
321
+ | `default_font` | document's own | fallback family |
322
+ | `font_fallback` | `'word'` | `'word'` or `'closest'` |
323
+ | `device_grid` | `72/600` | Word's rounding grid; `0` for exact points |
324
+ | `paragraph_spacing` | `'collapse'` | `'collapse'` or `'add'` |
325
+ | `image_quality` | `None` | JPEG quality for recompression; off by default |
326
+ | `image_max_dpi` | `None` | downsample images above this resolution |
327
+ | `compress` | `True` | compress PDF streams |
328
+ | `title`, `author` | document properties | PDF metadata |
329
+ | `font_cache` | `True` | cache the font index on disk |
330
+
331
+ `convert()` returns a `Result` (`pages`, `warnings`, `missing_fonts`, `output`)
332
+ when writing to a path, or the PDF `bytes` when the target is omitted.
333
+
334
+ ## Limitations
335
+
336
+ - **`.doc` (Word 97-2003) is not supported** — only Open XML `.docx`. The error
337
+ message says so when you hand it one.
338
+ - Text wrapping *around* floating images is not implemented: anchored objects
339
+ are placed correctly but text flows under them rather than around them.
340
+ - EMF/WMF vector images (old clip art, some Visio/Equation objects) are skipped;
341
+ everything else on the page still renders.
342
+ - Charts and SmartArt render only if the file also stores them as a picture.
343
+ - Right-to-left scripts, vertical text and complex shaping (Arabic, Devanagari)
344
+ are laid out left-to-right without contextual shaping.
345
+ - Fields that need a recalculated value (`TOC`, `SEQ`, formulas) use the result
346
+ Word last saved in the document.
347
+ - Tracked changes render as accepted; comments are not drawn.
348
+ - A table row is never split across a page: a row that does not fit moves to
349
+ the next page whole, where Word would fill the page first.
350
+
351
+ ## Development
352
+
353
+ ```bash
354
+ pip install -e ".[dev]"
355
+ python tests/make_fixtures.py # the .docx fixtures (needs python-docx)
356
+ python tests/make_samples.py # the longer sample documents
357
+ python -m pytest # 42 tests, no Word required
358
+ ```
359
+
360
+ Both corpora are generated and gitignored, so the repository holds no documents
361
+ of its own: `make_fixtures.py` writes one file per rule under test, and
362
+ `make_samples.py` writes whole documents - a work instruction, a form, a
363
+ statement, a letter - made up of invented content.
364
+
365
+ On a Windows machine with Word installed you can check fidelity against the real
366
+ thing:
367
+
368
+ ```bash
369
+ python tests/reference.py tests/fixtures/basic.docx out/reference/basic.pdf
370
+ python -m word2pdf tests/fixtures/basic.docx -o out/basic.pdf
371
+ python tests/compare.py out/basic.pdf out/reference/basic.pdf
372
+ python tests/lines.py out/basic.pdf out/reference/basic.pdf 0 # baseline diff
373
+ ```
374
+
375
+ `tests/make_probe*.py` build the small documents used to derive Word's layout
376
+ rules (spacing, baselines, table geometry); `tests/preview.py` renders pages to
377
+ PNG for eyeballing.
378
+
379
+ ### Architecture
380
+
381
+ One folder per stage of the pipeline; each `__init__.py` states what that layer
382
+ is for and re-exports its surface, so a layer is used without reaching into its
383
+ modules.
384
+
385
+ ```
386
+ word2pdf/
387
+ api.py the public API: Converter, convert(), Result
388
+ cli.py the command line
389
+ oxml/ the Open XML layer
390
+ package.py zip container, parts and relationships
391
+ xml.py namespaces, element helpers, unit conversions
392
+ fonts/ which face a run gets, and how wide its text is
393
+ ttf.py minimal TrueType reader (names, metrics, cmap)
394
+ manager.py font index, substitution, glyph fallback, measurement
395
+ document/ WordprocessingML -> a style-resolved model
396
+ props.py typed property bags and their merge rules
397
+ styles.py styles.xml + theme: the cascade
398
+ numbering.py numbering.xml: definitions, counters, label formatting
399
+ model.py the shapes the rest of the engine sees
400
+ parser.py the reading itself
401
+ layout/ model -> boxes -> pages
402
+ boxes.py what is measured, and the drawing operations
403
+ engine.py measuring (all the Word-matching rules)
404
+ flow.py boxes -> pages, and pages -> drawing operations
405
+ render/
406
+ pdf.py drawing operations -> PDF, via ReportLab
407
+ templates/ {{ placeholders }} in .docx
408
+ docx.py stitching split runs, relocating control tags
409
+ engine.py Jinja2, or the built-in expression engine
410
+ ```
411
+
412
+ Each layer only knows about the one below it: `document` never mentions a page,
413
+ `layout` never touches XML, and `render` makes no layout decisions - which is
414
+ what lets every stage be measured against Word on its own.
415
+
416
+ ## Licence
417
+
418
+ MIT.