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.
Files changed (66) hide show
  1. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/.github/workflows/ci.yml +10 -2
  2. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/AGENTS.md +1 -1
  3. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/CHANGELOG.md +7 -0
  4. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/PKG-INFO +24 -7
  5. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/README.md +22 -5
  6. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/docs/public-api-capabilities.md +8 -3
  7. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/pyproject.toml +3 -2
  8. markdown_docx-0.3.1/sample/assets/showcase-page-1.png +0 -0
  9. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/showcase.docx +0 -0
  10. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/showcase.md +2 -2
  11. markdown_docx-0.3.1/src/markdown_docx/__init__.py +1 -0
  12. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/assets/syntax.json +8 -3
  13. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/parser.py +2 -2
  14. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/skill.py +2 -0
  15. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/styles.py +3 -14
  16. markdown_docx-0.3.1/src/markdown_docx/theme_fonts.py +78 -0
  17. markdown_docx-0.3.1/tests/test_font_rendering.py +158 -0
  18. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_public_api_boundary.py +4 -2
  19. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_public_api_capabilities.py +21 -3
  20. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_renderer_text.py +2 -2
  21. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_showcase.py +2 -2
  22. markdown_docx-0.3.1/tests/test_theme_fonts.py +307 -0
  23. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/uv.lock +122 -16
  24. markdown_docx-0.3.0/sample/assets/showcase-page-1.png +0 -0
  25. markdown_docx-0.3.0/src/markdown_docx/__init__.py +0 -1
  26. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/.github/workflows/publish-pypi.yml +0 -0
  27. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/.gitignore +0 -0
  28. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/LICENSE +0 -0
  29. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/PLAN.md +0 -0
  30. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/docs/skill-management.md +0 -0
  31. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-lunch-chase.png +0 -0
  32. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-run-finish.png +0 -0
  33. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-run-start.png +0 -0
  34. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-trio-cameo.png +0 -0
  35. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-trio-inline.png +0 -0
  36. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/dog-wagon-rescue.png +0 -0
  37. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/illustration-prompts.md +0 -0
  38. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/word-icon.png +0 -0
  39. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/sample/assets/word-workflow.png +0 -0
  40. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/scripts/Export-DocxPdf.ps1 +0 -0
  41. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/scripts/build_default_template.py +0 -0
  42. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/scripts/build_qa_templates.py +0 -0
  43. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/scripts/build_showcase_assets.py +0 -0
  44. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/scripts/smoke_skill.py +0 -0
  45. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/assets/default.docx +0 -0
  46. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/assets.py +0 -0
  47. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/cli.py +0 -0
  48. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/errors.py +0 -0
  49. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/hyperlinks.py +0 -0
  50. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/images.py +0 -0
  51. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/markdown_body.py +0 -0
  52. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/metadata.py +0 -0
  53. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/models.py +0 -0
  54. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/renderer.py +0 -0
  55. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/src/markdown_docx/template.py +0 -0
  56. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/conftest.py +0 -0
  57. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_cli.py +0 -0
  58. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_hyperlinks.py +0 -0
  59. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_metadata.py +0 -0
  60. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_parser.py +0 -0
  61. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_renderer_images.py +0 -0
  62. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_renderer_lists.py +0 -0
  63. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_renderer_sections.py +0 -0
  64. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_renderer_tables.py +0 -0
  65. {markdown_docx-0.3.0 → markdown_docx-0.3.1}/tests/test_skill.py +0 -0
  66. {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
- uvx --from . markdown-docx sample/showcase.md .visual/showcase.docx
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 creation is allowed only in `src/markdown_docx/hyperlinks.py` to create native hyperlinks. Replace this helper when a supported public API becomes available.
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.0
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.2.0
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, heading, and monospace fonts |
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: Calibri
205
- headings: Calibri
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.2.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.
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
- Production code uses documented public `python-docx` APIs with one isolated exception. `src/markdown_docx/hyperlinks.py` creates native hyperlink OOXML because `python-docx` 1.2.0 has no public hyperlink creation API. Run formatting, styles, relationship registration, and document saving use library APIs. Replace this helper when upstream supports hyperlink creation. Tests enforce the boundary and inspect generated package XML read-only. See [public API capabilities](docs/public-api-capabilities.md) for the complete decision record.
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, heading, and monospace fonts |
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: Calibri
173
- headings: Calibri
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.2.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.
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
- Production code uses documented public `python-docx` APIs with one isolated exception. `src/markdown_docx/hyperlinks.py` creates native hyperlink OOXML because `python-docx` 1.2.0 has no public hyperlink creation API. Run formatting, styles, relationship registration, and document saving use library APIs. Replace this helper when upstream supports hyperlink creation. Tests enforce the boundary and inspect generated package XML read-only. See [public API capabilities](docs/public-api-capabilities.md) for the complete decision record.
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.2.0. The executable probe is `tests/test_public_api_capabilities.py`.
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.2.0 | Current behavior |
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 that helper. `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.
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.0"
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.2.0",
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",
@@ -22,8 +22,8 @@ document:
22
22
  unordered_list: [List Bullet, List Bullet 2, List Bullet 3]
23
23
  table: Table Grid
24
24
  fonts:
25
- body: Calibri
26
- headings: Calibri
25
+ body: Aptos
26
+ headings: Aptos Display
27
27
  monospace: Consolas
28
28
  -->
29
29
 
@@ -0,0 +1 @@
1
+ __version__ = "0.3.1"
@@ -1,14 +1,19 @@
1
1
  {
2
2
  "format": "markdown-docx",
3
- "version": "0.3.0",
4
- "text": "markdown-docx 0.3.0 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\nLengths use in, cm, mm, or pt. Image widths may also use percentages.\n\nUnsupported in 0.3.0:\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.",
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.0.", tokens[index], input_path)
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.0.",
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
- fonts = options.fonts
26
- if fonts.body:
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 test_private_docx_and_ooxml_apis_are_isolated_to_hyperlink_helper() -> None:
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 == package_dir / "hyperlinks.py":
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: