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.
- word2pdf-0.1.0/CHANGELOG.md +36 -0
- word2pdf-0.1.0/LICENSE +21 -0
- word2pdf-0.1.0/MANIFEST.in +10 -0
- word2pdf-0.1.0/PKG-INFO +418 -0
- word2pdf-0.1.0/README.md +376 -0
- word2pdf-0.1.0/assets/logo.png +0 -0
- word2pdf-0.1.0/assets/logo.svg +44 -0
- word2pdf-0.1.0/examples/batch_and_web.py +36 -0
- word2pdf-0.1.0/examples/convert_file.py +12 -0
- word2pdf-0.1.0/examples/invoice_from_template.py +47 -0
- word2pdf-0.1.0/pyproject.toml +64 -0
- word2pdf-0.1.0/setup.cfg +4 -0
- word2pdf-0.1.0/tests/compare.py +101 -0
- word2pdf-0.1.0/tests/fidelity.py +66 -0
- word2pdf-0.1.0/tests/lines.py +39 -0
- word2pdf-0.1.0/tests/make_fixtures.py +221 -0
- word2pdf-0.1.0/tests/make_kitchen.py +191 -0
- word2pdf-0.1.0/tests/make_probe.py +64 -0
- word2pdf-0.1.0/tests/make_probe2.py +72 -0
- word2pdf-0.1.0/tests/make_probe3.py +62 -0
- word2pdf-0.1.0/tests/make_probe4.py +100 -0
- word2pdf-0.1.0/tests/make_probe5.py +141 -0
- word2pdf-0.1.0/tests/make_probe6.py +156 -0
- word2pdf-0.1.0/tests/make_samples.py +474 -0
- word2pdf-0.1.0/tests/preview.py +20 -0
- word2pdf-0.1.0/tests/probe_tc.py +51 -0
- word2pdf-0.1.0/tests/probe_widths.py +63 -0
- word2pdf-0.1.0/tests/reference.py +51 -0
- word2pdf-0.1.0/tests/test_convert.py +262 -0
- word2pdf-0.1.0/tests/test_units.py +138 -0
- word2pdf-0.1.0/word2pdf/__init__.py +18 -0
- word2pdf-0.1.0/word2pdf/__main__.py +4 -0
- word2pdf-0.1.0/word2pdf/api.py +143 -0
- word2pdf-0.1.0/word2pdf/cli.py +155 -0
- word2pdf-0.1.0/word2pdf/document/__init__.py +24 -0
- word2pdf-0.1.0/word2pdf/document/model.py +235 -0
- word2pdf-0.1.0/word2pdf/document/numbering.py +313 -0
- word2pdf-0.1.0/word2pdf/document/parser.py +957 -0
- word2pdf-0.1.0/word2pdf/document/props.py +484 -0
- word2pdf-0.1.0/word2pdf/document/styles.py +352 -0
- word2pdf-0.1.0/word2pdf/fonts/__init__.py +11 -0
- word2pdf-0.1.0/word2pdf/fonts/manager.py +607 -0
- word2pdf-0.1.0/word2pdf/fonts/ttf.py +321 -0
- word2pdf-0.1.0/word2pdf/layout/__init__.py +16 -0
- word2pdf-0.1.0/word2pdf/layout/boxes.py +245 -0
- word2pdf-0.1.0/word2pdf/layout/engine.py +998 -0
- word2pdf-0.1.0/word2pdf/layout/flow.py +799 -0
- word2pdf-0.1.0/word2pdf/oxml/__init__.py +19 -0
- word2pdf-0.1.0/word2pdf/oxml/package.py +167 -0
- word2pdf-0.1.0/word2pdf/oxml/xml.py +165 -0
- word2pdf-0.1.0/word2pdf/py.typed +0 -0
- word2pdf-0.1.0/word2pdf/render/__init__.py +4 -0
- word2pdf-0.1.0/word2pdf/render/pdf.py +282 -0
- word2pdf-0.1.0/word2pdf/templates/__init__.py +5 -0
- word2pdf-0.1.0/word2pdf/templates/docx.py +221 -0
- word2pdf-0.1.0/word2pdf/templates/engine.py +348 -0
- word2pdf-0.1.0/word2pdf.egg-info/PKG-INFO +418 -0
- word2pdf-0.1.0/word2pdf.egg-info/SOURCES.txt +60 -0
- word2pdf-0.1.0/word2pdf.egg-info/dependency_links.txt +1 -0
- word2pdf-0.1.0/word2pdf.egg-info/entry_points.txt +2 -0
- word2pdf-0.1.0/word2pdf.egg-info/requires.txt +14 -0
- 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.
|
word2pdf-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/word2pdf/)
|
|
52
|
+
[](https://www.python.org/)
|
|
53
|
+
[](https://pypi.org/project/reportlab/)
|
|
54
|
+
[](https://github.com/surenjanath/Word2PDF/actions/workflows/ci.yml)
|
|
55
|
+
[](#how-the-layout-matches-word)
|
|
56
|
+
[](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.
|