markdown-docx 0.3.0__tar.gz → 0.3.1__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.
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/.github/workflows/ci.yml +10 -2
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/AGENTS.md +1 -1
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/CHANGELOG.md +7 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/PKG-INFO +24 -7
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/README.md +22 -5
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/docs/public-api-capabilities.md +8 -3
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/pyproject.toml +3 -2
- markdown_docx-0.3.1/sample/assets/showcase-page-1.png +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/showcase.docx +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/showcase.md +2 -2
- markdown_docx-0.3.1/src/markdown_docx/__init__.py +1 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/assets/syntax.json +8 -3
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/parser.py +2 -2
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/skill.py +2 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/styles.py +3 -14
- markdown_docx-0.3.1/src/markdown_docx/theme_fonts.py +78 -0
- markdown_docx-0.3.1/tests/test_font_rendering.py +158 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_public_api_boundary.py +4 -2
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_public_api_capabilities.py +21 -3
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_renderer_text.py +2 -2
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_showcase.py +2 -2
- markdown_docx-0.3.1/tests/test_theme_fonts.py +307 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/uv.lock +122 -16
- markdown_docx-0.3.0/sample/assets/showcase-page-1.png +0 -0
- markdown_docx-0.3.0/src/markdown_docx/__init__.py +0 -1
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/.github/workflows/publish-pypi.yml +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/.gitignore +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/LICENSE +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/PLAN.md +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/docs/skill-management.md +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-lunch-chase.png +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-run-finish.png +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-run-start.png +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-trio-cameo.png +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-trio-inline.png +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-wagon-rescue.png +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/illustration-prompts.md +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/word-icon.png +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/word-workflow.png +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/scripts/Export-DocxPdf.ps1 +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/scripts/build_default_template.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/scripts/build_qa_templates.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/scripts/build_showcase_assets.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/scripts/smoke_skill.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/assets/default.docx +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/assets.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/cli.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/errors.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/hyperlinks.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/images.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/markdown_body.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/metadata.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/models.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/renderer.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/template.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/conftest.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_cli.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_hyperlinks.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_metadata.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_parser.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_renderer_images.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_renderer_lists.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_renderer_sections.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_renderer_tables.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_skill.py +0 -0
- {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_template.py +0 -0
|
@@ -76,12 +76,20 @@ jobs:
|
|
|
76
76
|
enable-cache: true
|
|
77
77
|
|
|
78
78
|
- name: Install LibreOffice and Poppler
|
|
79
|
-
run: sudo apt-get update && sudo apt-get install -y libreoffice-writer poppler-utils
|
|
79
|
+
run: sudo apt-get update && sudo apt-get install -y libreoffice-writer poppler-utils fonts-liberation
|
|
80
|
+
|
|
81
|
+
- name: Install locked test dependencies
|
|
82
|
+
run: uv sync --locked --all-groups
|
|
83
|
+
|
|
84
|
+
- name: Verify rendered theme fonts and theme changes
|
|
85
|
+
env:
|
|
86
|
+
MARKDOWN_DOCX_FONT_RENDERER: libreoffice
|
|
87
|
+
run: uv run pytest tests/test_font_rendering.py
|
|
80
88
|
|
|
81
89
|
- name: Render showcase DOCX
|
|
82
90
|
run: |
|
|
83
91
|
mkdir -p .visual
|
|
84
|
-
|
|
92
|
+
uv run --locked markdown-docx sample/showcase.md .visual/showcase.docx
|
|
85
93
|
|
|
86
94
|
- name: Convert showcase to PDF
|
|
87
95
|
run: libreoffice --headless --convert-to pdf --outdir .visual .visual/showcase.docx
|
|
@@ -11,7 +11,7 @@ This repository contains `markdown-docx`, a strict Python CLI that converts cons
|
|
|
11
11
|
## Core rules
|
|
12
12
|
|
|
13
13
|
- Use only supported public `python-docx` APIs in production code, except for the isolated hyperlink helper.
|
|
14
|
-
- Direct OOXML
|
|
14
|
+
- Direct OOXML changes are allowed only in `src/markdown_docx/hyperlinks.py` for native hyperlinks. Replace that helper when a supported public API becomes available. Theme font handling must use the public APIs in `ps-python-docx` 1.3.0.
|
|
15
15
|
- Preserve normal Markdown meaning and keep Word metadata in invisible reserved HTML comments.
|
|
16
16
|
- Reject unsupported behavior with stable, line-aware diagnostics.
|
|
17
17
|
- Treat Word sections as layout boundaries. Headings never create sections.
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
- Use ps-python-docx 1.3.0 public theme APIs. Remove theme-related OOXML access from production code.
|
|
6
|
+
- Apply body and heading font overrides to Word's actual theme fonts. Preserve theme inheritance in mapped styles and linked character styles instead of applying literal fonts to individual headings.
|
|
7
|
+
- Preserve unspecified theme slots and unrelated theme settings. Keep code in its explicit monospace font. Handle missing themes and diagnose malformed themes and conflicting style roles.
|
|
8
|
+
- Add saved-package font cascade tests and layout-engine regression checks that verify PDF fonts before and after changing the theme. Exercise Aptos and Aptos Display with Word and Liberation fonts in visual CI.
|
|
9
|
+
|
|
3
10
|
## 0.3.0
|
|
4
11
|
|
|
5
12
|
- Convert Markdown links into native, clickable, editable Word hyperlinks in paragraphs, headings, blockquotes, lists, and table cells.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: markdown-docx
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Convert constrained Markdown documents into editable Word files.
|
|
5
5
|
Project-URL: Homepage, https://github.com/pseudosavant/markdown-docx
|
|
6
6
|
Project-URL: Repository, https://github.com/pseudosavant/markdown-docx
|
|
@@ -26,7 +26,7 @@ Requires-Dist: httpx<1,>=0.27
|
|
|
26
26
|
Requires-Dist: markdown-it-py<5,>=3.0
|
|
27
27
|
Requires-Dist: packaging>=24.0
|
|
28
28
|
Requires-Dist: pillow<13,>=10.0
|
|
29
|
-
Requires-Dist: python-docx==1.
|
|
29
|
+
Requires-Dist: ps-python-docx==1.3.0
|
|
30
30
|
Requires-Dist: pyyaml<7,>=6.0
|
|
31
31
|
Description-Content-Type: text/markdown
|
|
32
32
|
|
|
@@ -171,7 +171,7 @@ Document metadata sets document-wide defaults:
|
|
|
171
171
|
| `orientation` | Select portrait or landscape orientation |
|
|
172
172
|
| `margins` | Set the top, right, bottom, and left margins |
|
|
173
173
|
| `styles` | Map Markdown constructs to Word style names |
|
|
174
|
-
| `fonts` | Set body
|
|
174
|
+
| `fonts` | Set theme body and heading fonts, plus a fixed monospace font |
|
|
175
175
|
|
|
176
176
|
Content directives control the following block:
|
|
177
177
|
|
|
@@ -201,8 +201,8 @@ document:
|
|
|
201
201
|
bottom: 0.75in
|
|
202
202
|
left: 0.75in
|
|
203
203
|
fonts:
|
|
204
|
-
body:
|
|
205
|
-
headings:
|
|
204
|
+
body: Aptos
|
|
205
|
+
headings: Aptos Display
|
|
206
206
|
monospace: Consolas
|
|
207
207
|
-->
|
|
208
208
|
|
|
@@ -215,6 +215,12 @@ Named page sizes are `letter`, `legal`, and `a4`. Custom page sizes use nominal
|
|
|
215
215
|
|
|
216
216
|
The `styles` mapping assigns Word paragraph and table styles to semantic Markdown constructs. Ordered and unordered list styles are arrays, with one Word style for each supported nesting depth.
|
|
217
217
|
|
|
218
|
+
`fonts.body` and `fonts.headings` set the document theme's Latin body and heading fonts. Word's theme font settings show these choices. Mapped paragraph styles and their linked character styles inherit from the theme, so a later theme font change updates existing text and new paragraphs. Font names are not applied to individual body or heading runs. Body overrides also update the default paragraph style and default run font reference. Omitted body or heading overrides preserve that part of the template theme and its styles.
|
|
219
|
+
|
|
220
|
+
`fonts.monospace` stays explicit for inline code and code blocks because Word has no monospace theme font slot. Templates must use distinct styles for simultaneously overridden body, heading, and code roles. Conflicting mappings report `template_font_style_conflict`. A template without a theme receives the packaged theme when body or heading overrides are requested. A malformed theme reports `template_theme_invalid`.
|
|
221
|
+
|
|
222
|
+
Theme colors, effects, East Asian fonts, complex script fonts, and supplemental script mappings are preserved. These overrides do not embed or install fonts. Word may substitute fonts that are unavailable on the rendering machine.
|
|
223
|
+
|
|
218
224
|
### Sections and page breaks
|
|
219
225
|
|
|
220
226
|
A section directive starts a next-page Word section before the following content block:
|
|
@@ -361,7 +367,7 @@ Read the [**project documentation**](https://example.com/docs "Read the guide").
|
|
|
361
367
|
Contact [the team](mailto:team@example.com).
|
|
362
368
|
```
|
|
363
369
|
|
|
364
|
-
The source alt text for images remains meaningful Markdown content, but `python-docx` 1.
|
|
370
|
+
The source alt text for images remains meaningful Markdown content, but `ps-python-docx` 1.3.0 has no public API for embedding it in a Word drawing. A rendered document containing images reports `image_alt_text_not_embedded` in its warning list.
|
|
365
371
|
|
|
366
372
|
## Automation and safety
|
|
367
373
|
|
|
@@ -464,7 +470,18 @@ uvx --refresh --from . markdown-docx sample\showcase.md sample\showcase.docx --f
|
|
|
464
470
|
|
|
465
471
|
## Design and compatibility
|
|
466
472
|
|
|
467
|
-
|
|
473
|
+
Both development installs and published wheels use `ps-python-docx==1.3.0` from PyPI. It retains the `docx` import package and replaces the upstream `python-docx` distribution. Use `uv sync --locked --all-groups` and `uv run markdown-docx` when working from source. CI and the release workflow test a clean wheel installation from PyPI.
|
|
474
|
+
|
|
475
|
+
Production code uses the public `docx` APIs provided by `ps-python-docx` 1.3.0. Theme fonts, style references, linked styles, and document defaults use public library APIs. The only OOXML exception is `src/markdown_docx/hyperlinks.py` for native hyperlink creation. Replace that helper when a supported creation API becomes available. Tests enforce this boundary and inspect saved theme data, style inheritance, and effective font selection. See [public API capabilities](docs/public-api-capabilities.md) for the decision record.
|
|
476
|
+
|
|
477
|
+
The visual CI job also renders font regression documents through LibreOffice using installed Liberation fonts. It verifies the fonts recorded in the PDF, then changes only the theme and checks that the rendered fonts follow it. To run the same check through installed Microsoft Word with Aptos and Aptos Display on Windows:
|
|
478
|
+
|
|
479
|
+
```powershell
|
|
480
|
+
$env:MARKDOWN_DOCX_FONT_RENDERER="word"
|
|
481
|
+
uv run pytest tests/test_font_rendering.py
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
This optional check uses `uvx office-export` and requires the named fonts to be available to Word. A substituted font fails the test.
|
|
468
485
|
|
|
469
486
|
Word is the primary compatibility target. LibreOffice Writer is used as a visual smoke-test engine. Differences in pagination or font metrics can occur between layout engines.
|
|
470
487
|
|
|
@@ -139,7 +139,7 @@ Document metadata sets document-wide defaults:
|
|
|
139
139
|
| `orientation` | Select portrait or landscape orientation |
|
|
140
140
|
| `margins` | Set the top, right, bottom, and left margins |
|
|
141
141
|
| `styles` | Map Markdown constructs to Word style names |
|
|
142
|
-
| `fonts` | Set body
|
|
142
|
+
| `fonts` | Set theme body and heading fonts, plus a fixed monospace font |
|
|
143
143
|
|
|
144
144
|
Content directives control the following block:
|
|
145
145
|
|
|
@@ -169,8 +169,8 @@ document:
|
|
|
169
169
|
bottom: 0.75in
|
|
170
170
|
left: 0.75in
|
|
171
171
|
fonts:
|
|
172
|
-
body:
|
|
173
|
-
headings:
|
|
172
|
+
body: Aptos
|
|
173
|
+
headings: Aptos Display
|
|
174
174
|
monospace: Consolas
|
|
175
175
|
-->
|
|
176
176
|
|
|
@@ -183,6 +183,12 @@ Named page sizes are `letter`, `legal`, and `a4`. Custom page sizes use nominal
|
|
|
183
183
|
|
|
184
184
|
The `styles` mapping assigns Word paragraph and table styles to semantic Markdown constructs. Ordered and unordered list styles are arrays, with one Word style for each supported nesting depth.
|
|
185
185
|
|
|
186
|
+
`fonts.body` and `fonts.headings` set the document theme's Latin body and heading fonts. Word's theme font settings show these choices. Mapped paragraph styles and their linked character styles inherit from the theme, so a later theme font change updates existing text and new paragraphs. Font names are not applied to individual body or heading runs. Body overrides also update the default paragraph style and default run font reference. Omitted body or heading overrides preserve that part of the template theme and its styles.
|
|
187
|
+
|
|
188
|
+
`fonts.monospace` stays explicit for inline code and code blocks because Word has no monospace theme font slot. Templates must use distinct styles for simultaneously overridden body, heading, and code roles. Conflicting mappings report `template_font_style_conflict`. A template without a theme receives the packaged theme when body or heading overrides are requested. A malformed theme reports `template_theme_invalid`.
|
|
189
|
+
|
|
190
|
+
Theme colors, effects, East Asian fonts, complex script fonts, and supplemental script mappings are preserved. These overrides do not embed or install fonts. Word may substitute fonts that are unavailable on the rendering machine.
|
|
191
|
+
|
|
186
192
|
### Sections and page breaks
|
|
187
193
|
|
|
188
194
|
A section directive starts a next-page Word section before the following content block:
|
|
@@ -329,7 +335,7 @@ Read the [**project documentation**](https://example.com/docs "Read the guide").
|
|
|
329
335
|
Contact [the team](mailto:team@example.com).
|
|
330
336
|
```
|
|
331
337
|
|
|
332
|
-
The source alt text for images remains meaningful Markdown content, but `python-docx` 1.
|
|
338
|
+
The source alt text for images remains meaningful Markdown content, but `ps-python-docx` 1.3.0 has no public API for embedding it in a Word drawing. A rendered document containing images reports `image_alt_text_not_embedded` in its warning list.
|
|
333
339
|
|
|
334
340
|
## Automation and safety
|
|
335
341
|
|
|
@@ -432,7 +438,18 @@ uvx --refresh --from . markdown-docx sample\showcase.md sample\showcase.docx --f
|
|
|
432
438
|
|
|
433
439
|
## Design and compatibility
|
|
434
440
|
|
|
435
|
-
|
|
441
|
+
Both development installs and published wheels use `ps-python-docx==1.3.0` from PyPI. It retains the `docx` import package and replaces the upstream `python-docx` distribution. Use `uv sync --locked --all-groups` and `uv run markdown-docx` when working from source. CI and the release workflow test a clean wheel installation from PyPI.
|
|
442
|
+
|
|
443
|
+
Production code uses the public `docx` APIs provided by `ps-python-docx` 1.3.0. Theme fonts, style references, linked styles, and document defaults use public library APIs. The only OOXML exception is `src/markdown_docx/hyperlinks.py` for native hyperlink creation. Replace that helper when a supported creation API becomes available. Tests enforce this boundary and inspect saved theme data, style inheritance, and effective font selection. See [public API capabilities](docs/public-api-capabilities.md) for the decision record.
|
|
444
|
+
|
|
445
|
+
The visual CI job also renders font regression documents through LibreOffice using installed Liberation fonts. It verifies the fonts recorded in the PDF, then changes only the theme and checks that the rendered fonts follow it. To run the same check through installed Microsoft Word with Aptos and Aptos Display on Windows:
|
|
446
|
+
|
|
447
|
+
```powershell
|
|
448
|
+
$env:MARKDOWN_DOCX_FONT_RENDERER="word"
|
|
449
|
+
uv run pytest tests/test_font_rendering.py
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
This optional check uses `uvx office-export` and requires the named fonts to be available to Word. A substituted font fails the test.
|
|
436
453
|
|
|
437
454
|
Word is the primary compatibility target. LibreOffice Writer is used as a visual smoke-test engine. Differences in pagination or font metrics can occur between layout engines.
|
|
438
455
|
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# Public `python-docx` capability matrix
|
|
2
2
|
|
|
3
|
-
`markdown-docx` pins `python-docx` 1.
|
|
3
|
+
`markdown-docx` pins `ps-python-docx` 1.3.0, which retains the `docx` import package. The executable probe is `tests/test_public_api_capabilities.py`.
|
|
4
4
|
|
|
5
|
-
| Capability | Public API in 1.
|
|
5
|
+
| Capability | Public API in fork 1.3.0 | Current behavior |
|
|
6
6
|
| --- | --- | --- |
|
|
7
7
|
| Open and save blank DOCX templates | Yes | Supported |
|
|
8
8
|
| Enumerate and validate styles | Yes | Supported |
|
|
9
9
|
| Change paragraph style fonts | Yes | Supported |
|
|
10
|
+
| Set document theme fonts and theme inheritance | Yes | Uses `Document.theme_fonts`, `Font.theme_font`, `Styles.default_font`, and `Style.linked_style` |
|
|
10
11
|
| Add sections and set page geometry | Yes | Supported |
|
|
11
12
|
| Add explicit page breaks | Yes | Supported |
|
|
12
13
|
| Apply list paragraph styles | Yes | Supported within configured depth |
|
|
@@ -19,7 +20,11 @@
|
|
|
19
20
|
|
|
20
21
|
The public text API documents hyperlink reading but exposes no `add_hyperlink` method. The authorized exception in `AGENTS.md` permits `src/markdown_docx/hyperlinks.py` to create hyperlink elements and move runs into them. It registers external URL relationships and uses public APIs for run formatting and the Hyperlink character style. Existing template hyperlink styles are preserved. Optional link titles become tooltips. Empty destinations and document-local bookmark links are rejected with `unsupported_feature`. This helper is temporary and should be replaced when a supported upstream creation API becomes available.
|
|
21
22
|
|
|
22
|
-
`tests/test_public_api_boundary.py` confines private and OOXML access to
|
|
23
|
+
`tests/test_public_api_boundary.py` confines private and OOXML access to the hyperlink helper, including checking the theme font adapter. `tests/test_hyperlinks.py` checks saved relationships, text, formatting, titles, supported block contexts, and template styling. The dependency pin and these checks bound the compatibility risk of using library internals.
|
|
24
|
+
|
|
25
|
+
The public theme API implements the requirement that explicit Markdown font overrides appear in Word's actual theme settings. `theme_fonts.py` now contains only Markdown style mapping and diagnostics. It assigns the major and minor Latin typefaces through `Document.theme_fonts`, binds mapped and linked styles through `Font.theme_font`, and sets `Styles.default_font` for body inheritance. The library owns theme creation and XML changes. Font names are never assigned to individual heading runs. Other formatting and script fonts are preserved. Code keeps its explicit monospace font. Malformed themes and conflicting style roles retain their stable template diagnostics.
|
|
26
|
+
|
|
27
|
+
`tests/test_theme_fonts.py` checks serialized theme definitions, references, effective font resolution, partial overrides, custom mappings, and preservation. `tests/test_font_rendering.py` verifies actual PDF font names after layout and again after a theme change. The default suite runs without Office. CI enables the layout test with LibreOffice and Liberation fonts. The optional Word run uses Aptos and Aptos Display and fails on font substitution.
|
|
23
28
|
|
|
24
29
|
The public drawing API exposes inline shape dimensions and type but no alt-text property. The hyperlink exception does not authorize direct XML changes for image alt text or other features.
|
|
25
30
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "markdown-docx"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Convert constrained Markdown documents into editable Word files."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -31,7 +31,7 @@ dependencies = [
|
|
|
31
31
|
"packaging>=24.0",
|
|
32
32
|
"Pillow>=10.0,<13",
|
|
33
33
|
"PyYAML>=6.0,<7",
|
|
34
|
-
"python-docx==1.
|
|
34
|
+
"ps-python-docx==1.3.0",
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
[project.urls]
|
|
@@ -53,6 +53,7 @@ addopts = "--basetemp=.pytest-tmp"
|
|
|
53
53
|
[dependency-groups]
|
|
54
54
|
dev = [
|
|
55
55
|
"mypy>=1.15",
|
|
56
|
+
"pdfplumber>=0.11,<1",
|
|
56
57
|
"pytest>=8.0",
|
|
57
58
|
"ruff==0.16.1",
|
|
58
59
|
"twine>=6.1",
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "markdown-docx",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"text": "markdown-docx 0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"text": "markdown-docx 0.3.1 syntax\n\nSupported Markdown:\n ATX headings, paragraphs, emphasis, strong text, inline code, hard line breaks, fenced code blocks, blockquotes, ordered and unordered lists, pipe tables, links, and images.\n\nLinks use [link text](https://example.com) and become native Word hyperlinks. Labels preserve inline formatting and optional titles become tooltips. Reference links, angle-bracket autolinks, mailto links, relative file links, and linked inline images are supported wherever their content is allowed. Relative file links resolve from the output document. Destinations are not fetched. Bare URLs remain plain text. Heading bookmarks are not generated.\n\nMetadata comments:\n document metadata must be first\n section metadata starts a next-page section\n <!-- markdown-docx: page-break --> inserts a page break\n table metadata must immediately precede a pipe table\n image metadata must immediately precede a standalone image\n\nFonts: document.fonts.body and document.fonts.headings set Latin theme fonts. Mapped styles inherit from those theme slots. Omitted overrides preserve template settings. document.fonts.monospace stays explicit for code. Fonts are not embedded or installed.\n\nLengths use in, cm, mm, or pt. Image widths may also use percentages.\n\nUnsupported in 0.3.1:\n empty link destinations, document-local bookmark links, raw HTML, task lists, footnotes, horizontal rules, indented code blocks, multi-paragraph list items, DOTX, floating images, headers and footers from Markdown, page-number fields, and direct OOXML features.",
|
|
5
5
|
"page_sizes": ["letter", "legal", "a4", "custom"],
|
|
6
6
|
"orientations": ["portrait", "landscape"],
|
|
7
7
|
"length_units": ["in", "cm", "mm", "pt"],
|
|
8
8
|
"directives": {
|
|
9
9
|
"document": {
|
|
10
10
|
"keys": ["page_size", "orientation", "margins", "styles", "fonts"],
|
|
11
|
-
"placement": "first non-whitespace content"
|
|
11
|
+
"placement": "first non-whitespace content",
|
|
12
|
+
"fonts": {
|
|
13
|
+
"body": "nonempty font name for the minor Latin theme font",
|
|
14
|
+
"headings": "nonempty font name for the major Latin theme font",
|
|
15
|
+
"monospace": "nonempty fixed font name for code, default Consolas"
|
|
16
|
+
}
|
|
12
17
|
},
|
|
13
18
|
"section": {
|
|
14
19
|
"keys": ["page_size", "orientation", "margins"],
|
|
@@ -264,7 +264,7 @@ def _consume_blockquote(tokens: list[Token], index: int, input_path: str) -> tup
|
|
|
264
264
|
index += 1
|
|
265
265
|
while index < len(tokens) and tokens[index].type != "blockquote_close":
|
|
266
266
|
if tokens[index].type != "paragraph_open":
|
|
267
|
-
_unsupported("Blockquotes may contain paragraphs only in 0.3.
|
|
267
|
+
_unsupported("Blockquotes may contain paragraphs only in 0.3.1.", tokens[index], input_path)
|
|
268
268
|
paragraph, index = _consume_paragraph(tokens, index, input_path)
|
|
269
269
|
if any(fragment.kind == "image" for fragment in paragraph.fragments):
|
|
270
270
|
_unsupported("Images nested in blockquotes are not supported.", opening, input_path)
|
|
@@ -299,7 +299,7 @@ def _consume_list(
|
|
|
299
299
|
if start is not None and int(start) != 1:
|
|
300
300
|
raise ParseError(
|
|
301
301
|
"ordered_list_start_unsupported",
|
|
302
|
-
"Ordered lists must begin with 1 in 0.3.
|
|
302
|
+
"Ordered lists must begin with 1 in 0.3.1.",
|
|
303
303
|
line=_token_line(opening),
|
|
304
304
|
input_path=input_path,
|
|
305
305
|
)
|
|
@@ -81,6 +81,8 @@ Start a next-page section with a `section` comment. Insert an explicit page brea
|
|
|
81
81
|
|
|
82
82
|
Run `uvx markdown-docx --syntax` for every accepted key and value.
|
|
83
83
|
|
|
84
|
+
Set `document.fonts.body` and `document.fonts.headings` to change Word's Latin theme fonts. For example, use Aptos for body and Aptos Display for headings. Mapped styles inherit from these theme slots, so later theme changes update the document. Omitted overrides preserve the corresponding template fonts. `document.fonts.monospace` remains a fixed font for code. Fonts are not embedded or installed. Use distinct styles for body, heading, and code roles when overriding their fonts.
|
|
85
|
+
|
|
84
86
|
## Supported Markdown
|
|
85
87
|
|
|
86
88
|
Use ATX headings, paragraphs, emphasis, strong text, inline code, hard line breaks, fenced code blocks, blockquotes, lists, pipe tables, links, and local or remote images. Write `[link text](https://example.com)` for native editable Word hyperlinks. Link labels preserve formatting and optional titles become tooltips. Links work in paragraphs, headings, quotes, lists, and table cells. Reference links, angle-bracket autolinks, mailto links, relative file links, and linked inline images are supported wherever their content is allowed. Relative file links resolve from the output document. Destinations are not fetched. Bare URLs remain plain text. Empty destinations and document-local links such as `[heading](#heading)` are rejected. Heading bookmarks are not generated. Raw HTML, task lists, footnotes, horizontal rules, indented code, multi-paragraph list items, and unsupported nested block content are rejected.
|
|
@@ -5,6 +5,7 @@ from docx.enum.style import WD_STYLE_TYPE
|
|
|
5
5
|
|
|
6
6
|
from markdown_docx.errors import TemplateError
|
|
7
7
|
from markdown_docx.models import DocumentOptions
|
|
8
|
+
from markdown_docx.theme_fonts import apply_theme_fonts
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def validate_styles(document: DocumentObject, options: DocumentOptions) -> None:
|
|
@@ -22,20 +23,8 @@ def validate_styles(document: DocumentObject, options: DocumentOptions) -> None:
|
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def apply_font_overrides(document: DocumentObject, options: DocumentOptions) -> None:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
body_names = {
|
|
28
|
-
options.styles.paragraph,
|
|
29
|
-
options.styles.blockquote,
|
|
30
|
-
*options.styles.ordered_list,
|
|
31
|
-
*options.styles.unordered_list,
|
|
32
|
-
}
|
|
33
|
-
for name in body_names:
|
|
34
|
-
document.styles[name].font.name = fonts.body
|
|
35
|
-
if fonts.headings:
|
|
36
|
-
for name in options.styles.headings.values():
|
|
37
|
-
document.styles[name].font.name = fonts.headings
|
|
38
|
-
document.styles[options.styles.code_block].font.name = fonts.monospace
|
|
26
|
+
document.styles[options.styles.code_block].font.name = options.fonts.monospace
|
|
27
|
+
apply_theme_fonts(document, options)
|
|
39
28
|
|
|
40
29
|
|
|
41
30
|
def _require_style(document: DocumentObject, name: str, expected_type: WD_STYLE_TYPE) -> None:
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Map Markdown font roles using public python-docx theme and style APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from docx.document import Document as DocumentObject
|
|
8
|
+
from docx.enum.style import WD_STYLE_TYPE
|
|
9
|
+
from docx.styles.style import CharacterStyle
|
|
10
|
+
|
|
11
|
+
from markdown_docx.errors import TemplateError
|
|
12
|
+
from markdown_docx.models import DocumentOptions
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def apply_theme_fonts(document: DocumentObject, options: DocumentOptions) -> None:
|
|
16
|
+
"""Set Latin theme fonts and references without formatting individual runs.
|
|
17
|
+
|
|
18
|
+
Preserve the template's East Asian, complex script, and supplemental fonts.
|
|
19
|
+
Monospace remains an explicit font because Word has no monospace theme slot.
|
|
20
|
+
"""
|
|
21
|
+
code_style = document.styles[options.styles.code_block]
|
|
22
|
+
code_style.font.theme_font = None
|
|
23
|
+
|
|
24
|
+
if not (options.fonts.body or options.fonts.headings):
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
assignments: dict[str, tuple[CharacterStyle, Literal["major", "minor"]]] = {}
|
|
28
|
+
|
|
29
|
+
def assign(style: CharacterStyle, role: Literal["major", "minor"]) -> None:
|
|
30
|
+
style_id = style.style_id
|
|
31
|
+
if style_id is None:
|
|
32
|
+
raise TemplateError("template_style_invalid", "Font styles must have a style ID.")
|
|
33
|
+
if style_id == code_style.style_id or (style_id in assignments and assignments[style_id][1] != role):
|
|
34
|
+
raise TemplateError(
|
|
35
|
+
"template_font_style_conflict",
|
|
36
|
+
"Body, heading, and code font roles must use distinct styles.",
|
|
37
|
+
details={"style_id": style_id},
|
|
38
|
+
)
|
|
39
|
+
assignments[style_id] = (style, role)
|
|
40
|
+
|
|
41
|
+
body_names = {
|
|
42
|
+
options.styles.paragraph,
|
|
43
|
+
options.styles.blockquote,
|
|
44
|
+
*options.styles.ordered_list,
|
|
45
|
+
*options.styles.unordered_list,
|
|
46
|
+
}
|
|
47
|
+
if options.fonts.body:
|
|
48
|
+
default_style = document.styles.default(WD_STYLE_TYPE.PARAGRAPH)
|
|
49
|
+
if default_style is not None:
|
|
50
|
+
body_names.add(default_style.name)
|
|
51
|
+
roles: tuple[tuple[set[str], str | None, Literal["major", "minor"]], ...] = (
|
|
52
|
+
(body_names, options.fonts.body, "minor"),
|
|
53
|
+
(set(options.styles.headings.values()), options.fonts.headings, "major"),
|
|
54
|
+
)
|
|
55
|
+
for names, font, role in roles:
|
|
56
|
+
if not font:
|
|
57
|
+
continue
|
|
58
|
+
for name in sorted(names):
|
|
59
|
+
style = document.styles[name]
|
|
60
|
+
assign(style, role)
|
|
61
|
+
linked = style.linked_style
|
|
62
|
+
if linked is not None and linked.type == WD_STYLE_TYPE.CHARACTER:
|
|
63
|
+
assign(linked, role)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
fonts = document.theme_fonts
|
|
67
|
+
if options.fonts.body:
|
|
68
|
+
fonts.minor_latin = options.fonts.body
|
|
69
|
+
if options.fonts.headings:
|
|
70
|
+
fonts.major_latin = options.fonts.headings
|
|
71
|
+
fonts.name = "markdown-docx"
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise TemplateError("template_theme_invalid", f"Cannot update template theme fonts: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
for style, role in assignments.values():
|
|
76
|
+
style.font.theme_font = role
|
|
77
|
+
if options.fonts.body:
|
|
78
|
+
document.styles.default_font.theme_font = "minor"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Opt-in layout-engine checks. LibreOffice runs in CI and Word runs on Windows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from zipfile import ZipFile
|
|
11
|
+
|
|
12
|
+
import pdfplumber
|
|
13
|
+
import pytest
|
|
14
|
+
from docx import Document
|
|
15
|
+
|
|
16
|
+
from markdown_docx.parser import parse_document
|
|
17
|
+
from markdown_docx.renderer import render_docx
|
|
18
|
+
from markdown_docx.styles import apply_font_overrides
|
|
19
|
+
|
|
20
|
+
RENDERER = os.environ.get("MARKDOWN_DOCX_FONT_RENDERER", "")
|
|
21
|
+
pytestmark = pytest.mark.skipif(not RENDERER, reason="Set MARKDOWN_DOCX_FONT_RENDERER to word or libreoffice")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalized_font(name: str) -> str:
|
|
25
|
+
return re.sub(r"[^a-z]", "", name.split("+")[-1].lower())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def export_pdf(source: Path, tmp_path: Path) -> Path:
|
|
29
|
+
output = source.with_suffix(".pdf")
|
|
30
|
+
if RENDERER == "word":
|
|
31
|
+
command = ["uvx", "office-export", str(source), "--to", "pdf", "--output", str(output), "--json"]
|
|
32
|
+
elif RENDERER == "libreoffice":
|
|
33
|
+
profile = (tmp_path / "lo-profile").as_uri()
|
|
34
|
+
command = [
|
|
35
|
+
"libreoffice",
|
|
36
|
+
f"-env:UserInstallation={profile}",
|
|
37
|
+
"--headless",
|
|
38
|
+
"--convert-to",
|
|
39
|
+
"pdf",
|
|
40
|
+
"--outdir",
|
|
41
|
+
str(tmp_path),
|
|
42
|
+
str(source),
|
|
43
|
+
]
|
|
44
|
+
else:
|
|
45
|
+
raise AssertionError(f"Unknown font renderer: {RENDERER}")
|
|
46
|
+
result = subprocess.run(command, capture_output=True, text=True, timeout=180, check=False)
|
|
47
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
48
|
+
if RENDERER == "word":
|
|
49
|
+
details = json.loads(result.stdout)
|
|
50
|
+
assert details["ok"], details
|
|
51
|
+
assert all(warning["code"] == "owned_office_process_forced" for warning in details["warnings"]), details
|
|
52
|
+
assert output.is_file(), result.stdout + result.stderr
|
|
53
|
+
return output
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def assert_pdf_fonts(path: Path, body: str, headings: str, monospace: str) -> None:
|
|
57
|
+
expected = {f"Heading{level}": headings for level in range(1, 7)}
|
|
58
|
+
expected.update(
|
|
59
|
+
dict.fromkeys(
|
|
60
|
+
[
|
|
61
|
+
"BodyPlain",
|
|
62
|
+
"BodyBold",
|
|
63
|
+
"BodyItalic",
|
|
64
|
+
"BodyBoth",
|
|
65
|
+
"BodyLink",
|
|
66
|
+
"QuoteText",
|
|
67
|
+
"BulletText",
|
|
68
|
+
"NumberText",
|
|
69
|
+
"TableHeader",
|
|
70
|
+
"TableCell",
|
|
71
|
+
],
|
|
72
|
+
body,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
expected.update({"InlineCode": monospace, "BlockCode": monospace, "HeadingCode": monospace})
|
|
76
|
+
found = {}
|
|
77
|
+
with pdfplumber.open(path) as pdf:
|
|
78
|
+
for page in pdf.pages:
|
|
79
|
+
for word in page.extract_words(extra_attrs=["fontname"]):
|
|
80
|
+
text = word["text"]
|
|
81
|
+
if text in expected:
|
|
82
|
+
expected_family = normalized_font(expected[text])
|
|
83
|
+
actual = normalized_font(word["fontname"])
|
|
84
|
+
suffix = actual.removeprefix(expected_family)
|
|
85
|
+
assert suffix in {
|
|
86
|
+
"",
|
|
87
|
+
"regular",
|
|
88
|
+
"bold",
|
|
89
|
+
"italic",
|
|
90
|
+
"bolditalic",
|
|
91
|
+
"mt",
|
|
92
|
+
"boldmt",
|
|
93
|
+
"italicmt",
|
|
94
|
+
"bolditalicmt",
|
|
95
|
+
}, (text, expected[text], word["fontname"])
|
|
96
|
+
found[text] = word["fontname"]
|
|
97
|
+
assert set(found) == set(expected), f"Missing rendered text: {set(expected) - set(found)}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_rendered_fonts_follow_theme_and_subsequent_theme_change(tmp_path: Path) -> None:
|
|
101
|
+
if RENDERER == "word":
|
|
102
|
+
body, headings, mono = "Aptos", "Aptos Display", "Consolas"
|
|
103
|
+
next_body, next_headings = "Verdana", "Georgia"
|
|
104
|
+
else:
|
|
105
|
+
body, headings, mono = "Liberation Sans", "Liberation Serif", "Liberation Mono"
|
|
106
|
+
next_body, next_headings = headings, body
|
|
107
|
+
source = f"""<!-- markdown-docx
|
|
108
|
+
document:
|
|
109
|
+
fonts:
|
|
110
|
+
body: {body}
|
|
111
|
+
headings: {headings}
|
|
112
|
+
monospace: {mono}
|
|
113
|
+
-->
|
|
114
|
+
|
|
115
|
+
# Heading1 `HeadingCode`
|
|
116
|
+
|
|
117
|
+
## Heading2
|
|
118
|
+
|
|
119
|
+
### Heading3
|
|
120
|
+
|
|
121
|
+
#### Heading4
|
|
122
|
+
|
|
123
|
+
##### Heading5
|
|
124
|
+
|
|
125
|
+
###### Heading6
|
|
126
|
+
|
|
127
|
+
BodyPlain **BodyBold** *BodyItalic* ***BodyBoth*** [BodyLink](https://example.com) `InlineCode`
|
|
128
|
+
|
|
129
|
+
> QuoteText
|
|
130
|
+
|
|
131
|
+
- BulletText
|
|
132
|
+
|
|
133
|
+
1. NumberText
|
|
134
|
+
|
|
135
|
+
| TableHeader |
|
|
136
|
+
| --- |
|
|
137
|
+
| TableCell |
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
BlockCode
|
|
141
|
+
```
|
|
142
|
+
"""
|
|
143
|
+
model = parse_document(source, input_path=tmp_path / "fonts.md", source_name="fonts.md")
|
|
144
|
+
output = tmp_path / "fonts.docx"
|
|
145
|
+
render_docx(model, output, template_path=None, base_dir=tmp_path, allow_remote_images=False)
|
|
146
|
+
assert_pdf_fonts(export_pdf(output, tmp_path), body, headings, mono)
|
|
147
|
+
|
|
148
|
+
document = Document(output)
|
|
149
|
+
model.options.fonts.body = next_body
|
|
150
|
+
model.options.fonts.headings = next_headings
|
|
151
|
+
apply_font_overrides(document, model.options)
|
|
152
|
+
changed = tmp_path / "changed-theme.docx"
|
|
153
|
+
document.save(changed)
|
|
154
|
+
with ZipFile(output) as before, ZipFile(changed) as after:
|
|
155
|
+
for part in ("word/document.xml", "word/styles.xml"):
|
|
156
|
+
assert before.read(part) == after.read(part), f"Retheming changed {part}"
|
|
157
|
+
assert before.read("word/theme/theme1.xml") != after.read("word/theme/theme1.xml")
|
|
158
|
+
assert_pdf_fonts(export_pdf(changed, tmp_path), next_body, next_headings, mono)
|
|
@@ -3,10 +3,12 @@ from __future__ import annotations
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
def
|
|
6
|
+
def test_private_docx_and_ooxml_apis_are_isolated_to_authorized_helpers() -> None:
|
|
7
7
|
package_dir = Path(__file__).parents[1] / "src" / "markdown_docx"
|
|
8
8
|
forbidden = (
|
|
9
9
|
"docx.oxml",
|
|
10
|
+
"docx.opc.oxml",
|
|
11
|
+
"._blob",
|
|
10
12
|
"._element",
|
|
11
13
|
"._p",
|
|
12
14
|
"._r",
|
|
@@ -16,7 +18,7 @@ def test_private_docx_and_ooxml_apis_are_isolated_to_hyperlink_helper() -> None:
|
|
|
16
18
|
"._part",
|
|
17
19
|
)
|
|
18
20
|
for path in package_dir.rglob("*.py"):
|
|
19
|
-
if path
|
|
21
|
+
if path.name in {"hyperlinks.py"}:
|
|
20
22
|
continue
|
|
21
23
|
source = path.read_text(encoding="utf-8")
|
|
22
24
|
for marker in forbidden:
|