mdmint 0.2.0__py3-none-any.whl

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.
mdmint/docxtemplate.py ADDED
@@ -0,0 +1,1250 @@
1
+ """HTML fragment → DOCX built on a Word template (``--template``).
2
+
3
+ The default DOCX path (htmldocx) writes into a blank document and cannot use a
4
+ template's styles: it hard-codes fonts, fakes list levels with indents and drops
5
+ blockquotes, footnotes and internal links. For branded output we instead walk the
6
+ same HTML fragment ourselves (BeautifulSoup) and emit paragraphs that carry the
7
+ *template's* named styles, so all look-and-feel comes from the template:
8
+
9
+ * the template body (cover, contents page…) is kept, ``{{PLACEHOLDER}}`` text in
10
+ body, headers and footers is filled from document metadata, and content is
11
+ appended at the end — or where a ``{{CONTENT}}`` paragraph sits;
12
+ * each element maps to a style *role* resolved against the template with
13
+ fallbacks (``Code Block`` → ``HTML Preformatted`` → …), so any template works
14
+ and richer templates simply look better. ``styles`` overrides the mapping;
15
+ * lists use real Word numbering (levels, restart per list), footnotes become real
16
+ Word footnotes, headings get bookmarks so in-document links work, figures and
17
+ tables get numbered captions.
18
+
19
+ Style names that the Meaningful Data template defines (``Part Title``,
20
+ ``Appendix Heading``, ``Callout Note``…) are the first candidates; the markdown
21
+ conventions are those of ``md_to_docx.py`` from that template bundle.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import datetime
28
+ import io
29
+ import logging
30
+ import os
31
+ import re
32
+ import zipfile
33
+ from collections.abc import Iterator, Mapping, Sequence
34
+ from pathlib import Path
35
+ from typing import TYPE_CHECKING, TypedDict, Union, cast
36
+
37
+ from bs4 import BeautifulSoup
38
+ from bs4.element import Comment, NavigableString, PageElement, Tag
39
+ from docx import Document
40
+ from docx.enum.style import WD_STYLE_TYPE
41
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
42
+ from docx.opc.constants import CONTENT_TYPE as CT
43
+ from docx.opc.constants import RELATIONSHIP_TYPE as RT
44
+ from docx.opc.packuri import PackURI
45
+ from docx.oxml import OxmlElement, parse_xml
46
+ from docx.oxml.ns import nsdecls, qn
47
+ from docx.parts.story import StoryPart
48
+ from docx.shared import Emu, Pt
49
+ from docx.text.paragraph import Paragraph
50
+
51
+ from . import admonitions
52
+
53
+ if TYPE_CHECKING:
54
+ from docx.document import Document as DocxDocument
55
+ from docx.oxml.text.paragraph import CT_P
56
+ from docx.oxml.xmlchemy import BaseOxmlElement
57
+ from docx.styles.style import BaseStyle, CharacterStyle, ParagraphStyle, _TableStyle
58
+ from docx.table import Table, _Cell
59
+
60
+ log = logging.getLogger("mdmint")
61
+
62
+ # role -> candidate style names, first match in the template wins.
63
+ _ROLES = {
64
+ "part": ["Part Title"],
65
+ "appendix": ["Appendix Heading"],
66
+ "code": ["Code Block", "Source Code", "HTML Preformatted", "Macro Text"],
67
+ "inline_code": ["Inline Code", "Verbatim Char", "HTML Code"],
68
+ "quote": ["Callout", "Quote", "Block Text"],
69
+ "note": ["Callout Note", "Callout", "Intense Quote", "Quote"],
70
+ "warning": ["Callout Warning", "Callout", "Intense Quote", "Quote"],
71
+ "figure": ["Figure", "Captioned Figure"],
72
+ "caption": ["Caption"],
73
+ "table_heading": ["Table Heading"],
74
+ "table_text": ["Table Text"],
75
+ "list_bullet": ["List Bullet"],
76
+ "list_number": ["List Number"],
77
+ "list_continue": ["List Continue", "List Paragraph"],
78
+ "hyperlink": ["Hyperlink"],
79
+ "toc_heading": ["TOC Heading"],
80
+ "footnote_text": ["Footnote Text", "Reference"],
81
+ "footnote_ref": ["Footnote Reference", "Reference Mark"],
82
+ }
83
+ _WARNING_TYPES = {"warning", "failure", "danger", "bug"}
84
+ _GH_MARKER = re.compile(r"^\s*\[!(\w+)\][ \t]*\n?")
85
+ _GH_TYPES = {
86
+ "NOTE": "note",
87
+ "TIP": "note",
88
+ "IMPORTANT": "note",
89
+ "WARNING": "warning",
90
+ "CAUTION": "warning",
91
+ }
92
+ _ALIGN = {
93
+ "left": WD_ALIGN_PARAGRAPH.LEFT,
94
+ "center": WD_ALIGN_PARAGRAPH.CENTER,
95
+ "right": WD_ALIGN_PARAGRAPH.RIGHT,
96
+ "justify": WD_ALIGN_PARAGRAPH.JUSTIFY,
97
+ }
98
+ _BLOCK_TAGS = {
99
+ "p",
100
+ "h1",
101
+ "h2",
102
+ "h3",
103
+ "h4",
104
+ "h5",
105
+ "h6",
106
+ "ul",
107
+ "ol",
108
+ "pre",
109
+ "blockquote",
110
+ "table",
111
+ "div",
112
+ "nav",
113
+ "hr",
114
+ "section",
115
+ "figure",
116
+ "details",
117
+ "summary",
118
+ "dl",
119
+ "center",
120
+ "article",
121
+ "aside",
122
+ }
123
+ _PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
124
+ _CONTENT_MARKERS = {"CONTENT", "BODY"}
125
+ _CONTENT_ANCHOR = re.compile(r"\s*\{\{\s*(CONTENT|BODY)\s*\}\}\s*", re.IGNORECASE)
126
+ _LEN = re.compile(r"([\d.]+)\s*(pt|px|in|cm|mm)?")
127
+ _MONO = "Courier New"
128
+ _WORD_TABLE_STYLE = re.compile(
129
+ r"^(normal table|table normal|table grid|table \w+ \d|(light|medium|dark|colou?rful)"
130
+ r" |plain table|grid table|list table|table (simple|classic|colorful|columns|contemporary"
131
+ r"|elegant|list|professional|subtle|theme|web|3d))",
132
+ re.IGNORECASE,
133
+ )
134
+ _LIST_INDENT_PT = 18
135
+
136
+
137
+ # ------------------------------------------------------------------ template
138
+
139
+
140
+ def _open_template(path: Path) -> DocxDocument:
141
+ """Open a .docx/.dotx as a python-docx Document (templates need a content-type
142
+ swap, since python-docx only opens the *document* main part type)."""
143
+ if path.suffix.lower() not in (".dotx", ".dotm"):
144
+ return Document(os.fspath(path))
145
+ buf = io.BytesIO()
146
+ with zipfile.ZipFile(path) as zin, zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zout:
147
+ for item in zin.infolist():
148
+ data = zin.read(item.filename)
149
+ if item.filename == "[Content_Types].xml":
150
+ data = re.sub(
151
+ rb"wordprocessingml\.template\.main\+xml",
152
+ b"wordprocessingml.document.main+xml",
153
+ data,
154
+ )
155
+ data = data.replace(
156
+ b"application/vnd.ms-word.template.macroEnabledTemplate.main+xml",
157
+ b"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
158
+ )
159
+ zout.writestr(item, data)
160
+ buf.seek(0)
161
+ return Document(buf)
162
+
163
+
164
+ def _story_elements(doc: DocxDocument) -> Iterator[BaseOxmlElement]:
165
+ """Root elements of the body and of every header/footer part."""
166
+ yield doc.element.body
167
+ for rel in doc.part.rels.values():
168
+ if rel.reltype in (RT.HEADER, RT.FOOTER) and not rel.is_external:
169
+ element = getattr(rel.target_part, "element", None)
170
+ if element is not None:
171
+ yield element
172
+
173
+
174
+ def fill_placeholders(doc: DocxDocument, meta: Mapping[str, object]) -> list[str]:
175
+ """Replace ``{{KEY}}`` text with ``meta['key']`` in body, headers and footers.
176
+
177
+ Returns the placeholder names found that had no value (they are blanked).
178
+ ``{{CONTENT}}`` / ``{{BODY}}`` are content anchors and left alone.
179
+ """
180
+ values = {str(k).upper(): "" if v is None else str(v) for k, v in meta.items()}
181
+ missing: list[str] = []
182
+
183
+ def sub(m: re.Match[str]) -> str:
184
+ key = m.group(1).upper()
185
+ if key in _CONTENT_MARKERS:
186
+ return m.group(0)
187
+ if key not in values and key not in missing:
188
+ missing.append(key)
189
+ return values.get(key, "")
190
+
191
+ for root in _story_elements(doc):
192
+ for p in root.iter(qn("w:p")):
193
+ texts = list(p.iter(qn("w:t")))
194
+ if not texts or "{{" not in "".join(t.text or "" for t in texts):
195
+ continue
196
+ for t in texts: # common case: each placeholder sits in one run
197
+ if t.text and "{{" in t.text:
198
+ t.text = _PLACEHOLDER.sub(sub, t.text)
199
+ joined = "".join(t.text or "" for t in texts)
200
+ if _PLACEHOLDER.search(joined): # split across runs: collapse into the first
201
+ texts[0].text = _PLACEHOLDER.sub(sub, joined)
202
+ for t in texts[1:]:
203
+ t.text = ""
204
+
205
+ cp = doc.core_properties
206
+ if meta.get("title"):
207
+ cp.title = str(meta["title"])
208
+ if meta.get("author"):
209
+ cp.author = str(meta["author"])
210
+ if meta.get("subtitle"):
211
+ cp.subject = str(meta["subtitle"])
212
+ return missing
213
+
214
+
215
+ def _settings_insert(settings: BaseOxmlElement, el: BaseOxmlElement, before: Sequence[str]) -> None:
216
+ """Insert *el* into settings.xml respecting the schema's element order."""
217
+ for name in before:
218
+ found = settings.find(qn(name))
219
+ if found is not None:
220
+ found.addprevious(el)
221
+ return
222
+ settings.append(el)
223
+
224
+
225
+ _AFTER_UPDATE_FIELDS = (
226
+ "w:hdrShapeDefaults",
227
+ "w:footnotePr",
228
+ "w:endnotePr",
229
+ "w:compat",
230
+ "w:docVars",
231
+ "w:rsids",
232
+ "w:themeFontLang",
233
+ "w:clrSchemeMapping",
234
+ "w:shapeDefaults",
235
+ "w:decimalSymbol",
236
+ "w:listSeparator",
237
+ )
238
+
239
+
240
+ def _enable_update_fields(doc: DocxDocument) -> None:
241
+ settings = doc.settings.element
242
+ if settings.find(qn("w:updateFields")) is None:
243
+ el = OxmlElement("w:updateFields")
244
+ el.set(qn("w:val"), "true")
245
+ _settings_insert(settings, el, _AFTER_UPDATE_FIELDS)
246
+
247
+
248
+ # ------------------------------------------------------------------ styles
249
+
250
+
251
+ class _Styles:
252
+ """Resolve style roles against the template, with user overrides."""
253
+
254
+ def __init__(self, doc: DocxDocument, overrides: Mapping[str, str] | None) -> None:
255
+ self.doc = doc
256
+ self.by_name: dict[str, BaseStyle] = {}
257
+ for s in doc.styles:
258
+ try:
259
+ name = s.name
260
+ except Exception: # noqa: S112 # pragma: no cover - malformed style entries
261
+ continue
262
+ if name is not None:
263
+ self.by_name.setdefault(name.lower(), s)
264
+ self.overrides = {k: v for k, v in (overrides or {}).items() if v}
265
+ self._cache: dict[str, BaseStyle | None] = {}
266
+
267
+ def named(self, name: str) -> BaseStyle | None:
268
+ return self.by_name.get(name.lower())
269
+
270
+ def get(self, role: str) -> BaseStyle | None:
271
+ if role in self._cache:
272
+ return self._cache[role]
273
+ style: BaseStyle | None = None
274
+ if role in self.overrides:
275
+ style = self.named(self.overrides[role])
276
+ if style is None:
277
+ log.warning(
278
+ "template: style %r (for %s) not found; using default",
279
+ self.overrides[role],
280
+ role,
281
+ )
282
+ if style is None:
283
+ if role.startswith("heading"):
284
+ level = int(role[len("heading") :])
285
+ for lvl in range(level, 0, -1):
286
+ style = self.named(f"Heading {lvl}")
287
+ if style is not None:
288
+ break
289
+ elif role == "table":
290
+ # The template's own table style: the first one that isn't one of
291
+ # Word's stock styles (which templates often carry unused).
292
+ style = next(
293
+ (
294
+ s
295
+ for s in self.doc.styles
296
+ if s.type == WD_STYLE_TYPE.TABLE
297
+ and s.name
298
+ and not _WORD_TABLE_STYLE.match(s.name)
299
+ ),
300
+ None,
301
+ ) or self.named("Table Grid")
302
+ else:
303
+ style = next(
304
+ (self.named(n) for n in _ROLES.get(role, []) if self.named(n) is not None), None
305
+ )
306
+ self._cache[role] = style
307
+ return style
308
+
309
+ def ensure_footnote_styles(self) -> tuple[ParagraphStyle, CharacterStyle]:
310
+ """Footnote styles, created (minimal) if the template has none."""
311
+ styles = self.doc.styles
312
+ if self.get("footnote_text") is None:
313
+ text_st = cast(
314
+ "ParagraphStyle", styles.add_style("Footnote Text", WD_STYLE_TYPE.PARAGRAPH)
315
+ )
316
+ text_st.base_style = styles["Normal"]
317
+ text_st.font.size = Pt(9)
318
+ text_st.paragraph_format.space_before = Pt(0)
319
+ text_st.paragraph_format.space_after = Pt(2)
320
+ text_st.paragraph_format.line_spacing = 1.0
321
+ self._cache["footnote_text"] = text_st
322
+ if self.get("footnote_ref") is None:
323
+ ref_st = cast(
324
+ "CharacterStyle", styles.add_style("Footnote Reference", WD_STYLE_TYPE.CHARACTER)
325
+ )
326
+ ref_st.font.superscript = True
327
+ self._cache["footnote_ref"] = ref_st
328
+ return (
329
+ cast("ParagraphStyle", self.get("footnote_text")),
330
+ cast("CharacterStyle", self.get("footnote_ref")),
331
+ )
332
+
333
+
334
+ # ------------------------------------------------------------------ helpers
335
+
336
+
337
+ def _length_pt(value: str | None) -> float | None:
338
+ if not value:
339
+ return None
340
+ m = _LEN.match(value.strip())
341
+ if not m:
342
+ return None
343
+ num, unit = float(m.group(1)), (m.group(2) or "px")
344
+ return num * {"pt": 1, "px": 0.75, "in": 72, "cm": 72 / 2.54, "mm": 72 / 25.4}[unit]
345
+
346
+
347
+ def _attr(tag: Tag, name: str) -> str:
348
+ """An attribute's value as text ("" when absent)."""
349
+ value = tag.get(name)
350
+ if value is None:
351
+ return ""
352
+ return value if isinstance(value, str) else " ".join(value)
353
+
354
+
355
+ def _css(tag: Tag) -> dict[str, str]:
356
+ out: dict[str, str] = {}
357
+ for decl in _attr(tag, "style").split(";"):
358
+ if ":" in decl:
359
+ k, v = decl.split(":", 1)
360
+ out[k.strip().lower()] = v.strip()
361
+ return out
362
+
363
+
364
+ def _classes(tag: Tag) -> list[str]:
365
+ cls = tag.get("class")
366
+ if not cls:
367
+ return []
368
+ return cls.split() if isinstance(cls, str) else list(cls)
369
+
370
+
371
+ def _is_blank(node: PageElement) -> bool:
372
+ return isinstance(node, NavigableString) and not node.strip()
373
+
374
+
375
+ def _trim_el(p_el: BaseOxmlElement, left: bool = True) -> None:
376
+ """Strip leading/trailing whitespace from a paragraph's text runs (HTML
377
+ whitespace collapsing leaves stray spaces at the edges)."""
378
+ ts = list(p_el.iter(qn("w:t")))
379
+ for seq, strip in (((ts if left else []), str.lstrip), (reversed(ts), str.rstrip)):
380
+ for t in seq:
381
+ t.text = strip(t.text or "")
382
+ if t.text:
383
+ break
384
+
385
+
386
+ def _text_el(text: str) -> BaseOxmlElement:
387
+ t = OxmlElement("w:t")
388
+ t.set(qn("xml:space"), "preserve")
389
+ t.text = text
390
+ return t
391
+
392
+
393
+ class _Fmt(dict[str, Union[bool, str]]):
394
+ """Inline formatting state (bold, italic, code…) — copied on nesting."""
395
+
396
+ def with_(self, **kw: bool | str) -> _Fmt:
397
+ new = _Fmt(self)
398
+ new.update(kw)
399
+ return new
400
+
401
+
402
+ class _PartParent:
403
+ """Minimal parent so a python-docx Paragraph can live in a footnote."""
404
+
405
+ def __init__(self, part: StoryPart) -> None:
406
+ self.part = part
407
+
408
+
409
+ class _ListCtx(TypedDict):
410
+ """State of one open list while its items are written."""
411
+
412
+ ordered: bool
413
+ num: int | None # Word numbering instance, None without numbering
414
+ first: bool # the next text starts a new item
415
+ max_lvl: int # deepest level that numbering defines
416
+
417
+
418
+ # ------------------------------------------------------------------ writer
419
+
420
+
421
+ class TemplateWriter:
422
+ def __init__(self, doc: DocxDocument, styles: _Styles) -> None:
423
+ self.doc = doc
424
+ self.st = styles
425
+ sec = doc.sections[-1]
426
+ width = (sec.page_width or 0) - (sec.left_margin or 0) - (sec.right_margin or 0)
427
+ height = (sec.page_height or 0) - (sec.top_margin or 0) - (sec.bottom_margin or 0)
428
+ self.text_width = int(width)
429
+ self.max_height = int(height * 0.8)
430
+ self.container: DocxDocument | _Cell = doc
431
+ self.reuse_p: Paragraph | None = None # empty paragraph to fill first (table cells)
432
+ self.default_style: BaseStyle | None = None # paragraph style for plain text
433
+ self.quote_style: BaseStyle | None = None
434
+ self.list_stack: list[_ListCtx] = []
435
+ self.last_list_p: Paragraph | None = None
436
+ self.after_table = False
437
+ self.pending_break = False
438
+ self.bookmarks: dict[str, str] = {}
439
+ self._bm_id = 9000
440
+ self.counters: dict[str, int] = {}
441
+ self.footnote_src: dict[str, Tag] = {}
442
+ self._fn_root: BaseOxmlElement | None = None
443
+ self._fn_part: StoryPart | None = None
444
+ self._fn_next = 1
445
+ self.has_toc = any(
446
+ "TOC" in (t.text or "") for t in doc.element.body.iter(qn("w:instrText"))
447
+ )
448
+ self._bullet_num = self._style_num(self.st.get("list_bullet"))
449
+ self._number_abstract = self._abstract_for(self._style_num(self.st.get("list_number")))
450
+
451
+ # ---- document-level --------------------------------------------------
452
+
453
+ def write(self, html_body: str) -> None:
454
+ soup = BeautifulSoup(html_body, "html.parser")
455
+ for c in soup.find_all(string=lambda s: isinstance(s, Comment)):
456
+ if "pagebreak" in c.replace("-", "").replace("_", "").lower():
457
+ c.replace_with(soup.new_tag("div", attrs={"class": "mdmint-page-break"}))
458
+ else:
459
+ c.extract()
460
+ self._collect_footnotes(soup)
461
+ for i, h in enumerate(soup.find_all(re.compile(r"^h[1-6]$"))):
462
+ h_id = _attr(h, "id")
463
+ if h_id:
464
+ self.bookmarks.setdefault(h_id, f"_mdmint{i}")
465
+ self.blocks(soup)
466
+
467
+ def _collect_footnotes(self, soup: BeautifulSoup) -> None:
468
+ for sec in soup.find_all("section", class_="footnotes"):
469
+ for li in sec.find_all("li", class_="footnote-item"):
470
+ for back in li.find_all("a", class_="footnote-backref"):
471
+ back.decompose()
472
+ li_id = _attr(li, "id")
473
+ if li_id:
474
+ self.footnote_src[li_id] = li
475
+ sec.extract()
476
+ for hr in soup.find_all("hr", class_="footnotes-sep"):
477
+ hr.decompose()
478
+
479
+ # ---- paragraphs -------------------------------------------------------
480
+
481
+ def _para(self, style: BaseStyle | None = None) -> Paragraph:
482
+ if self.reuse_p is not None:
483
+ p, self.reuse_p = self.reuse_p, None
484
+ else:
485
+ p = self.container.add_paragraph()
486
+ if style is not None:
487
+ p.style = cast("ParagraphStyle", style)
488
+ if self.container is self.doc:
489
+ if self.after_table:
490
+ p.paragraph_format.space_before = Pt(12)
491
+ self.after_table = False
492
+ if self.pending_break:
493
+ p.paragraph_format.page_break_before = True
494
+ self.pending_break = False
495
+ return p
496
+
497
+ def _text_para(self) -> Paragraph:
498
+ """A paragraph for running text, styled for the current list/quote context."""
499
+ if self.list_stack:
500
+ ctx, depth = self.list_stack[-1], len(self.list_stack) - 1
501
+ if ctx["first"]:
502
+ ctx["first"] = False
503
+ base = "List Number" if ctx["ordered"] else "List Bullet"
504
+ deeper = self.st.named(f"{base} {depth + 1}") if depth else None
505
+ if ctx["num"] is not None and (depth <= ctx["max_lvl"] or deeper is None):
506
+ p = self._para(self.st.get("list_number" if ctx["ordered"] else "list_bullet"))
507
+ self._numpr(p, ctx["num"], min(depth, ctx["max_lvl"]))
508
+ else:
509
+ # No multi-level numbering: Word's stock templates carry one
510
+ # single-level style per depth ("List Bullet 2", …) instead.
511
+ p = self._para(deeper or self.st.named(base))
512
+ else:
513
+ p = self._para(self.st.get("list_continue"))
514
+ p.paragraph_format.left_indent = Pt(_LIST_INDENT_PT * (depth + 1))
515
+ self.last_list_p = p
516
+ return p
517
+ return self._para(self.quote_style or self.default_style)
518
+
519
+ @staticmethod
520
+ def _trim(p: Paragraph) -> None:
521
+ _trim_el(p._p)
522
+
523
+ # ---- numbering --------------------------------------------------------
524
+
525
+ def _style_num(self, style: BaseStyle | None) -> int | None:
526
+ if style is None:
527
+ return None
528
+ ppr = style.element.find(qn("w:pPr"))
529
+ numpr = ppr.find(qn("w:numPr")) if ppr is not None else None
530
+ numid = numpr.find(qn("w:numId")) if numpr is not None else None
531
+ return int(numid.get(qn("w:val"))) if numid is not None else None
532
+
533
+ def _numbering(self) -> BaseOxmlElement:
534
+ return self.doc.part.numbering_part.element
535
+
536
+ def _abstract_for(self, num_id: int | None) -> str | None:
537
+ if num_id is None:
538
+ return None
539
+ for n in self._numbering().findall(qn("w:num")):
540
+ if n.get(qn("w:numId")) == str(num_id):
541
+ a = n.find(qn("w:abstractNumId"))
542
+ return a.get(qn("w:val")) if a is not None else None
543
+ return None
544
+
545
+ def _max_level(self, num_id: int | None) -> int:
546
+ abstract = self._abstract_for(num_id)
547
+ for a in self._numbering().findall(qn("w:abstractNum")):
548
+ if a.get(qn("w:abstractNumId")) == abstract:
549
+ return max(0, len(a.findall(qn("w:lvl"))) - 1)
550
+ return 0
551
+
552
+ def _new_num(self, start: int) -> int | None:
553
+ """A fresh numbering instance so each ordered list restarts at *start*."""
554
+ if self._number_abstract is None:
555
+ return None
556
+ numbering = self._numbering()
557
+ num_id = (
558
+ max((int(n.get(qn("w:numId"))) for n in numbering.findall(qn("w:num"))), default=0) + 1
559
+ )
560
+ num = OxmlElement("w:num")
561
+ num.set(qn("w:numId"), str(num_id))
562
+ a = OxmlElement("w:abstractNumId")
563
+ a.set(qn("w:val"), self._number_abstract)
564
+ num.append(a)
565
+ for lvl in range(9):
566
+ o = OxmlElement("w:lvlOverride")
567
+ o.set(qn("w:ilvl"), str(lvl))
568
+ s = OxmlElement("w:startOverride")
569
+ s.set(qn("w:val"), str(start if lvl == 0 else 1))
570
+ o.append(s)
571
+ num.append(o)
572
+ numbering.append(num)
573
+ return num_id
574
+
575
+ @staticmethod
576
+ def _numpr(p: Paragraph, num_id: int, ilvl: int) -> None:
577
+ numpr = p._p.get_or_add_pPr().get_or_add_numPr()
578
+ numpr.get_or_add_ilvl().val = ilvl
579
+ numpr.get_or_add_numId().val = num_id
580
+
581
+ # ---- blocks -----------------------------------------------------------
582
+
583
+ def blocks(self, parent: Tag) -> None:
584
+ pending: list[PageElement] = []
585
+ for node in list(parent.children):
586
+ if (
587
+ isinstance(node, Tag)
588
+ and node.name in _BLOCK_TAGS
589
+ or (isinstance(node, Tag) and node.name == "img" and not pending)
590
+ ):
591
+ self._flush(pending)
592
+ pending = []
593
+ self.block(node)
594
+ else:
595
+ pending.append(node)
596
+ self._flush(pending)
597
+
598
+ def _flush(self, nodes: Sequence[PageElement]) -> None:
599
+ if all(_is_blank(n) for n in nodes):
600
+ return
601
+ p = self._text_para()
602
+ for n in nodes:
603
+ self.inline(p, n, _Fmt())
604
+ self._trim(p)
605
+
606
+ def block(self, node: Tag) -> None:
607
+ name, cls = node.name, _classes(node)
608
+ if re.fullmatch(r"h[1-6]", name):
609
+ self.heading(node, int(name[1]), cls)
610
+ elif name == "p":
611
+ self.paragraph(node)
612
+ elif name in ("ul", "ol"):
613
+ self.list_(node)
614
+ elif name == "pre":
615
+ self.code(node)
616
+ elif name == "blockquote":
617
+ self.blockquote(node)
618
+ elif name == "table":
619
+ if "admonition" in cls:
620
+ self.admonition(node, cls)
621
+ else:
622
+ self.table(node)
623
+ elif name == "hr":
624
+ self.rule()
625
+ elif name == "nav" and "toc" in cls:
626
+ self.toc()
627
+ elif name == "div" and "mdmint-page-break" in cls:
628
+ self.pending_break = True
629
+ elif name == "img":
630
+ self.figure(node)
631
+ else:
632
+ self.blocks(node)
633
+
634
+ def heading(self, node: Tag, level: int, cls: list[str]) -> None:
635
+ if "part" in cls:
636
+ style = self.st.get("part") or self.st.get("heading1")
637
+ elif "appendix" in cls:
638
+ style = self.st.get("appendix") or self.st.get("heading1")
639
+ else:
640
+ style = self.st.get(f"heading{level}")
641
+ p = self._para(style)
642
+ bookmark = self.bookmarks.get(_attr(node, "id"))
643
+ if bookmark is not None:
644
+ self._bookmark(p, bookmark)
645
+ for child in node.children:
646
+ self.inline(p, child, _Fmt())
647
+ self._trim(p)
648
+
649
+ def paragraph(self, node: Tag) -> None:
650
+ kids = [c for c in node.children if not _is_blank(c)]
651
+ if kids and all(isinstance(c, Tag) and c.name in ("img", "br") for c in kids):
652
+ for img in (c for c in kids if isinstance(c, Tag) and c.name == "img"):
653
+ self.figure(img, diagram="mermaid-diagram" in _classes(node))
654
+ return
655
+ text = node.get_text()
656
+ nxt = node.find_next_sibling()
657
+ if text.startswith("Table: ") and isinstance(nxt, Tag) and nxt.name == "table":
658
+ first = cast(NavigableString, next(node.strings))
659
+ first.replace_with(first.replace("Table: ", "", 1))
660
+ self.caption("Table", node).paragraph_format.keep_with_next = True
661
+ return
662
+ p = self._text_para()
663
+ for child in node.children:
664
+ self.inline(p, child, _Fmt())
665
+ self._trim(p)
666
+
667
+ def list_(self, node: Tag) -> None:
668
+ ordered = node.name == "ol"
669
+ if ordered:
670
+ try:
671
+ start = int(_attr(node, "start") or 1)
672
+ except ValueError:
673
+ start = 1
674
+ num = self._new_num(start)
675
+ else:
676
+ num = self._bullet_num
677
+ self.list_stack.append(
678
+ {
679
+ "ordered": ordered,
680
+ "num": num,
681
+ "first": True,
682
+ "max_lvl": self._max_level(num) if num is not None else 0,
683
+ }
684
+ )
685
+ for li in node.find_all("li", recursive=False):
686
+ self.list_stack[-1]["first"] = True
687
+ self.blocks(li)
688
+ self.list_stack.pop()
689
+ if not self.list_stack and self.last_list_p is not None:
690
+ self.last_list_p.paragraph_format.space_after = Pt(9)
691
+ self.last_list_p = None
692
+
693
+ def code(self, node: Tag) -> None:
694
+ style = self.st.get("code")
695
+ p = self._para(style)
696
+ if self.list_stack:
697
+ p.paragraph_format.left_indent = Pt(_LIST_INDENT_PT * len(self.list_stack) + 8)
698
+ lines = node.get_text().rstrip("\n").split("\n")
699
+ for i, line in enumerate(lines):
700
+ run = p.add_run(line)
701
+ if style is None:
702
+ run.font.name = _MONO
703
+ if i < len(lines) - 1:
704
+ run.add_break()
705
+
706
+ def _with_quote(self, role: str, content: Tag) -> None:
707
+ prev, self.quote_style = self.quote_style, self.st.get(role)
708
+ try:
709
+ self.blocks(content)
710
+ finally:
711
+ self.quote_style = prev
712
+
713
+ def blockquote(self, node: Tag) -> None:
714
+ role = "quote"
715
+ first = next((s for s in node.strings if s.strip()), None)
716
+ if first is not None:
717
+ m = _GH_MARKER.match(first)
718
+ if m:
719
+ role = _GH_TYPES.get(m.group(1).upper(), "quote")
720
+ cast(NavigableString, first).replace_with(first[m.end() :])
721
+ self._with_quote(role, node)
722
+
723
+ def admonition(self, node: Tag, cls: list[str]) -> None:
724
+ kind = admonitions.resolve(cls[1] if len(cls) > 1 else "note")
725
+ role = "warning" if kind in _WARNING_TYPES else "quote" if kind == "quote" else "note"
726
+ cell = node.find(["td", "th"])
727
+ if isinstance(cell, Tag):
728
+ self._with_quote(role, cell)
729
+
730
+ def rule(self) -> None:
731
+ p = self._para()
732
+ bdr = parse_xml(
733
+ f'<w:pBdr {nsdecls("w")}><w:bottom w:val="single" w:sz="6" '
734
+ 'w:space="1" w:color="auto"/></w:pBdr>'
735
+ )
736
+ p._p.get_or_add_pPr().insert_element_before(
737
+ bdr,
738
+ "w:shd",
739
+ "w:tabs",
740
+ "w:suppressAutoHyphens",
741
+ "w:spacing",
742
+ "w:ind",
743
+ "w:contextualSpacing",
744
+ "w:jc",
745
+ "w:outlineLvl",
746
+ "w:rPr",
747
+ "w:sectPr",
748
+ "w:pPrChange",
749
+ )
750
+
751
+ def toc(self) -> None:
752
+ if self.has_toc:
753
+ return # the template already carries a Word TOC field
754
+ self.has_toc = True
755
+ heading = self._para(self.st.get("toc_heading"))
756
+ heading.add_run("Contents").bold = self.st.get("toc_heading") is None
757
+ p = self._para()
758
+ self._field(
759
+ p,
760
+ ' TOC \\o "1-3" \\h \\z \\u ',
761
+ "Update this field (F9) to build the table of contents.",
762
+ )
763
+ _enable_update_fields(self.doc)
764
+
765
+ # ---- tables -----------------------------------------------------------
766
+
767
+ def table(self, node: Tag) -> None:
768
+ rows: list[tuple[bool, list[Tag]]] = []
769
+ for group in node.find_all(["thead", "tbody", "tfoot", "tr"], recursive=False):
770
+ trs = [group] if group.name == "tr" else group.find_all("tr", recursive=False)
771
+ for tr in trs:
772
+ cells = list(tr.find_all(["th", "td"], recursive=False))
773
+ header = group.name == "thead" or (
774
+ bool(cells) and all(c.name == "th" for c in cells)
775
+ )
776
+ rows.append((header, cells))
777
+ rows = [r for r in rows if r[1]]
778
+ if not rows:
779
+ return
780
+ ncols = max(len(c) for _, c in rows)
781
+ if self.pending_break and self.container is self.doc:
782
+ self._para() # carries the page break the table itself can't
783
+ tbl = self.container.add_table(rows=len(rows), cols=ncols)
784
+ style = self.st.get("table")
785
+ if style is not None:
786
+ tbl.style = cast("_TableStyle", style)
787
+ tblw = tbl._tbl.tblPr.find(qn("w:tblW"))
788
+ if tblw is None:
789
+ tblw = OxmlElement("w:tblW")
790
+ tbl._tbl.tblPr.append(tblw)
791
+ tblw.set(qn("w:type"), "pct")
792
+ tblw.set(qn("w:w"), "5000")
793
+ self._column_widths(tbl, rows, ncols)
794
+
795
+ head_style, text_style = self.st.get("table_heading"), self.st.get("table_text")
796
+ saved = (
797
+ self.container,
798
+ self.reuse_p,
799
+ self.default_style,
800
+ self.quote_style,
801
+ self.list_stack,
802
+ self.pending_break,
803
+ )
804
+ try:
805
+ for r_idx, (is_head, cells) in enumerate(rows):
806
+ row = tbl.rows[r_idx]
807
+ if is_head:
808
+ h = OxmlElement("w:tblHeader")
809
+ h.set(qn("w:val"), "true")
810
+ row._tr.get_or_add_trPr().append(h)
811
+ for c_idx in range(ncols):
812
+ cell = row.cells[c_idx]
813
+ self.container, self.reuse_p = cell, cell.paragraphs[0]
814
+ self.default_style = head_style if is_head else text_style
815
+ self.quote_style, self.list_stack, self.pending_break = None, [], False
816
+ if c_idx >= len(cells):
817
+ self.reuse_p.style = cast("ParagraphStyle | None", self.default_style)
818
+ continue
819
+ src = cells[c_idx]
820
+ bold = is_head and head_style is None
821
+ if any(isinstance(c, Tag) and c.name in _BLOCK_TAGS for c in src.children):
822
+ self.blocks(src)
823
+ else:
824
+ p = self._para(self.default_style)
825
+ for child in src.children:
826
+ self.inline(p, child, _Fmt(bold=bold))
827
+ self._trim(p)
828
+ align = _css(src).get("text-align", "")
829
+ if align in _ALIGN:
830
+ for p in cell.paragraphs:
831
+ p.alignment = _ALIGN[align]
832
+ finally:
833
+ (
834
+ self.container,
835
+ self.reuse_p,
836
+ self.default_style,
837
+ self.quote_style,
838
+ self.list_stack,
839
+ self.pending_break,
840
+ ) = saved
841
+ self.after_table = True
842
+
843
+ def _column_widths(
844
+ self, tbl: Table, rows: Sequence[tuple[bool, list[Tag]]], ncols: int
845
+ ) -> None:
846
+ """Share the text width between columns by content.
847
+
848
+ Each column first gets enough room for its longest word (so nothing breaks
849
+ mid-word), then the rest is shared in proportion to how much text the
850
+ column holds. If even the minimums don't fit, they are scaled down.
851
+ """
852
+ char = Pt(5.5) # rough average glyph width at table text size
853
+ padding = Pt(14) # cell margins
854
+ need = [0] * ncols # longest word, in characters
855
+ volume = [1.0] * ncols # typical cell length, capped
856
+ for _, cells in rows:
857
+ for i, c in enumerate(cells):
858
+ text = c.get_text().strip()
859
+ need[i] = max(need[i], max((len(w) for w in text.split()), default=0))
860
+ volume[i] += min(len(text), 80)
861
+ mins = [min(n, 30) * char + padding for n in need]
862
+ spare = self.text_width - sum(mins)
863
+ if spare <= 0:
864
+ widths = [self.text_width * m / sum(mins) for m in mins]
865
+ else:
866
+ widths = [m + spare * v / sum(volume) for m, v in zip(mins, volume)]
867
+ for i, col in enumerate(tbl.columns):
868
+ width = Emu(int(widths[i]))
869
+ col.width = width
870
+ for cell in col.cells:
871
+ cell.width = width
872
+
873
+ # ---- figures & captions ----------------------------------------------
874
+
875
+ def figure(self, img: Tag, diagram: bool = False) -> None:
876
+ style = self.st.get("figure")
877
+ p = self._para(style)
878
+ if style is None:
879
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
880
+ self._picture(p, img)
881
+ cls = _classes(img)
882
+ caption = _attr(img, "title") or _attr(img, "alt")
883
+ if (
884
+ caption
885
+ and not diagram
886
+ and not any(c.startswith("math") for c in cls)
887
+ and caption != "mermaid diagram"
888
+ ):
889
+ self.caption("Figure", text=caption)
890
+
891
+ def caption(self, kind: str, node: Tag | None = None, text: str = "") -> Paragraph:
892
+ n = self.counters[kind] = self.counters.get(kind, 0) + 1
893
+ p = self._para(self.st.get("caption"))
894
+ self._run(p._p, f"{kind} ", _Fmt(bold=True))
895
+ self._field(p, f" SEQ {kind} \\* ARABIC ", str(n), bold=True)
896
+ self._run(p._p, ". ", _Fmt(bold=True))
897
+ if node is not None:
898
+ for child in node.children:
899
+ self.inline(p, child, _Fmt())
900
+ else:
901
+ self._run(p._p, text, _Fmt())
902
+ self._trim(p)
903
+ return p
904
+
905
+ def _picture(self, p: Paragraph, img: Tag) -> None:
906
+ from docx.image.exceptions import UnrecognizedImageError
907
+
908
+ src = _attr(img, "src")
909
+ alt = _attr(img, "alt") or os.path.basename(src)
910
+ source: io.BytesIO | str | None = None
911
+ if src.startswith("data:") and ";base64," in src:
912
+ source = io.BytesIO(base64.b64decode(src.split(",", 1)[1]))
913
+ elif src and os.path.isfile(src):
914
+ source = src
915
+ if source is None or src.lower().endswith(".svg"):
916
+ self._run(p._p, f"[image: {alt}]", _Fmt(italic=True))
917
+ return
918
+ try:
919
+ shape = p.add_run().add_picture(source)
920
+ except (UnrecognizedImageError, ValueError, OSError) as exc:
921
+ log.warning("template: cannot embed image %s (%s)", src, exc)
922
+ self._run(p._p, f"[image: {alt}]", _Fmt(italic=True))
923
+ return
924
+ css = _css(img)
925
+ w_pt, h_pt = (
926
+ _length_pt(css.get("width") or _attr(img, "width")),
927
+ _length_pt(css.get("height") or _attr(img, "height")),
928
+ )
929
+ w: int = shape.width
930
+ h: int = shape.height
931
+ if w_pt and h_pt:
932
+ w, h = Pt(w_pt), Pt(h_pt)
933
+ elif w_pt:
934
+ w, h = Pt(w_pt), int(h * Pt(w_pt) / w)
935
+ elif h_pt:
936
+ w, h = int(w * Pt(h_pt) / h), Pt(h_pt)
937
+ max_w = self.text_width - (
938
+ Pt(_LIST_INDENT_PT * len(self.list_stack)) if self.list_stack else 0
939
+ )
940
+ scale = min(1.0, max_w / w, self.max_height / h) if w and h else 1.0
941
+ shape.width, shape.height = Emu(int(w * scale)), Emu(int(h * scale))
942
+
943
+ # ---- inline -----------------------------------------------------------
944
+
945
+ def inline(
946
+ self, p: Paragraph, node: PageElement, fmt: _Fmt, target: BaseOxmlElement | None = None
947
+ ) -> None:
948
+ target = p._p if target is None else target
949
+ if isinstance(node, Comment):
950
+ return
951
+ if isinstance(node, NavigableString):
952
+ text = re.sub(r"\s+", " ", str(node))
953
+ if text:
954
+ self._run(target, text, fmt)
955
+ return
956
+ if not isinstance(node, Tag): # pragma: no cover - children are tags or strings
957
+ return
958
+ name = node.name
959
+ if name in ("strong", "b"):
960
+ fmt = fmt.with_(bold=True)
961
+ elif name in ("em", "i", "cite"):
962
+ fmt = fmt.with_(italic=True)
963
+ elif name in ("s", "del", "strike"):
964
+ fmt = fmt.with_(strike=True)
965
+ elif name in ("u", "ins"):
966
+ fmt = fmt.with_(underline=True)
967
+ elif name in ("code", "kbd", "samp", "tt"):
968
+ fmt = fmt.with_(code=True)
969
+ elif name == "sub":
970
+ fmt = fmt.with_(vert="subscript")
971
+ elif name == "sup":
972
+ if "footnote-ref" in _classes(node):
973
+ self._footnote_ref(p, node, target)
974
+ return
975
+ fmt = fmt.with_(vert="superscript")
976
+ elif name == "br":
977
+ r = OxmlElement("w:r")
978
+ r.append(OxmlElement("w:br"))
979
+ target.append(r)
980
+ return
981
+ elif name == "img":
982
+ self._picture(p, node)
983
+ return
984
+ elif name == "input" and _attr(node, "type").lower() == "checkbox":
985
+ self._run(
986
+ target, "☒" if node.has_attr("checked") else "☐", fmt
987
+ ) # label text brings its own space
988
+ return
989
+ elif name == "a" and _attr(node, "href"):
990
+ link = self._hyperlink(p, target, _attr(node, "href"))
991
+ if link is not None:
992
+ for child in node.children:
993
+ self.inline(p, child, fmt.with_(link=True), link)
994
+ return
995
+ for child in node.children:
996
+ self.inline(p, child, fmt, target)
997
+
998
+ def _run(self, target: BaseOxmlElement, text: str, fmt: _Fmt) -> None:
999
+ r = OxmlElement("w:r")
1000
+ rpr = OxmlElement("w:rPr")
1001
+ rstyle: BaseStyle | None = None
1002
+ if fmt.get("code"):
1003
+ rstyle = self.st.get("inline_code")
1004
+ if rstyle is None:
1005
+ fonts = OxmlElement("w:rFonts")
1006
+ for a in ("w:ascii", "w:hAnsi", "w:cs"):
1007
+ fonts.set(qn(a), _MONO)
1008
+ rpr.append(fonts)
1009
+ elif fmt.get("link"):
1010
+ rstyle = self.st.get("hyperlink")
1011
+ if rstyle is not None:
1012
+ rs = OxmlElement("w:rStyle")
1013
+ rs.set(qn("w:val"), rstyle.style_id)
1014
+ rpr.insert(0, rs)
1015
+ for key, tag in (("bold", "w:b"), ("italic", "w:i"), ("strike", "w:strike")):
1016
+ if fmt.get(key):
1017
+ rpr.append(OxmlElement(tag))
1018
+ if fmt.get("link") and rstyle is None:
1019
+ color = OxmlElement("w:color")
1020
+ color.set(qn("w:val"), "0563C1")
1021
+ rpr.append(color)
1022
+ if fmt.get("underline") or (fmt.get("link") and rstyle is None):
1023
+ u = OxmlElement("w:u")
1024
+ u.set(qn("w:val"), "single")
1025
+ rpr.append(u)
1026
+ if fmt.get("vert"):
1027
+ v = OxmlElement("w:vertAlign")
1028
+ v.set(qn("w:val"), fmt["vert"])
1029
+ rpr.append(v)
1030
+ if len(rpr):
1031
+ r.append(rpr)
1032
+ r.append(_text_el(text))
1033
+ target.append(r)
1034
+
1035
+ def _hyperlink(
1036
+ self, p: Paragraph, target: BaseOxmlElement, href: str
1037
+ ) -> BaseOxmlElement | None:
1038
+ h = OxmlElement("w:hyperlink")
1039
+ if href.startswith("#"):
1040
+ name = self.bookmarks.get(href[1:])
1041
+ if name is None:
1042
+ return None # target isn't a heading we bookmarked: plain text
1043
+ h.set(qn("w:anchor"), name)
1044
+ h.set(qn("w:history"), "1")
1045
+ else:
1046
+ h.set(qn("r:id"), p.part.relate_to(href, RT.HYPERLINK, is_external=True))
1047
+ target.append(h)
1048
+ return h
1049
+
1050
+ def _bookmark(self, p: Paragraph, name: str) -> None:
1051
+ self._bm_id += 1
1052
+ start = OxmlElement("w:bookmarkStart")
1053
+ start.set(qn("w:id"), str(self._bm_id))
1054
+ start.set(qn("w:name"), name)
1055
+ end = OxmlElement("w:bookmarkEnd")
1056
+ end.set(qn("w:id"), str(self._bm_id))
1057
+ p._p.append(start)
1058
+ p._p.append(end)
1059
+
1060
+ def _field(self, p: Paragraph, instr: str, result: str, bold: bool = False) -> None:
1061
+ def run(child: BaseOxmlElement) -> None:
1062
+ r = OxmlElement("w:r")
1063
+ if bold:
1064
+ rpr = OxmlElement("w:rPr")
1065
+ rpr.append(OxmlElement("w:b"))
1066
+ r.append(rpr)
1067
+ r.append(child)
1068
+ p._p.append(r)
1069
+
1070
+ begin = OxmlElement("w:fldChar")
1071
+ begin.set(qn("w:fldCharType"), "begin")
1072
+ run(begin)
1073
+ it = OxmlElement("w:instrText")
1074
+ it.set(qn("xml:space"), "preserve")
1075
+ it.text = instr
1076
+ run(it)
1077
+ sep = OxmlElement("w:fldChar")
1078
+ sep.set(qn("w:fldCharType"), "separate")
1079
+ run(sep)
1080
+ t = OxmlElement("w:t")
1081
+ t.set(qn("xml:space"), "preserve")
1082
+ t.text = result
1083
+ run(t)
1084
+ end = OxmlElement("w:fldChar")
1085
+ end.set(qn("w:fldCharType"), "end")
1086
+ run(end)
1087
+
1088
+ # ---- footnotes --------------------------------------------------------
1089
+
1090
+ def _footnotes(self) -> tuple[BaseOxmlElement, StoryPart]:
1091
+ """The footnotes root element and its part, created on first use."""
1092
+ if self._fn_root is not None and self._fn_part is not None:
1093
+ return self._fn_root, self._fn_part
1094
+ part: StoryPart | None = None
1095
+ for rel in self.doc.part.rels.values():
1096
+ if rel.reltype == RT.FOOTNOTES and not rel.is_external:
1097
+ found = rel.target_part
1098
+ if isinstance(found, StoryPart):
1099
+ part = found
1100
+ else: # loaded as an opaque blob part
1101
+ part = StoryPart(
1102
+ found.partname, found.content_type, parse_xml(found.blob), found.package
1103
+ )
1104
+ rel._target = part
1105
+ break
1106
+ if part is None:
1107
+ xml = (
1108
+ f"<w:footnotes {nsdecls('w', 'r')}>"
1109
+ '<w:footnote w:type="separator" w:id="-1"><w:p><w:pPr>'
1110
+ '<w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/>'
1111
+ "</w:pPr><w:r><w:separator/></w:r></w:p></w:footnote>"
1112
+ '<w:footnote w:type="continuationSeparator" w:id="0"><w:p><w:pPr>'
1113
+ '<w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/>'
1114
+ "</w:pPr><w:r><w:continuationSeparator/></w:r></w:p></w:footnote>"
1115
+ "</w:footnotes>"
1116
+ )
1117
+ part = StoryPart(
1118
+ PackURI("/word/footnotes.xml"),
1119
+ CT.WML_FOOTNOTES,
1120
+ parse_xml(xml),
1121
+ self.doc.part.package,
1122
+ )
1123
+ self.doc.part.relate_to(part, RT.FOOTNOTES)
1124
+ settings = self.doc.settings.element
1125
+ if settings.find(qn("w:footnotePr")) is None:
1126
+ pr = parse_xml(
1127
+ f'<w:footnotePr {nsdecls("w")}><w:footnote w:id="-1"/>'
1128
+ '<w:footnote w:id="0"/></w:footnotePr>'
1129
+ )
1130
+ _settings_insert(settings, pr, _AFTER_UPDATE_FIELDS[2:])
1131
+ root = part.element
1132
+ self._fn_part, self._fn_root = part, root
1133
+ ids = [int(f.get(qn("w:id"))) for f in root.findall(qn("w:footnote"))]
1134
+ self._fn_next = max(ids + [0]) + 1
1135
+ return root, part
1136
+
1137
+ def _footnote_ref(self, p: Paragraph, sup: Tag, target: BaseOxmlElement) -> None:
1138
+ a = sup.find("a")
1139
+ href = _attr(a, "href") if isinstance(a, Tag) else ""
1140
+ src = self.footnote_src.get(href[1:])
1141
+ if src is None:
1142
+ self._run(target, sup.get_text(), _Fmt(vert="superscript"))
1143
+ return
1144
+ root, part = self._footnotes()
1145
+ text_style, ref_style = self.st.ensure_footnote_styles()
1146
+ fid = self._fn_next
1147
+ self._fn_next += 1
1148
+
1149
+ def ref_run(tag: str) -> BaseOxmlElement:
1150
+ r = OxmlElement("w:r")
1151
+ rpr = OxmlElement("w:rPr")
1152
+ rs = OxmlElement("w:rStyle")
1153
+ rs.set(qn("w:val"), ref_style.style_id)
1154
+ rpr.append(rs)
1155
+ r.append(rpr)
1156
+ el = OxmlElement(tag)
1157
+ if tag == "w:footnoteReference":
1158
+ el.set(qn("w:id"), str(fid))
1159
+ r.append(el)
1160
+ return r
1161
+
1162
+ fn = OxmlElement("w:footnote")
1163
+ fn.set(qn("w:id"), str(fid))
1164
+ root.append(fn)
1165
+ parent = _PartParent(part)
1166
+ tab = bool(len(text_style.paragraph_format.tab_stops)) # style lays out "1<tab>text"
1167
+ blocks = [c for c in src.children if isinstance(c, Tag)] or [src]
1168
+ for i, block in enumerate(blocks):
1169
+ p_el = cast("CT_P", OxmlElement("w:p"))
1170
+ ppr = OxmlElement("w:pPr")
1171
+ ps = OxmlElement("w:pStyle")
1172
+ ps.set(qn("w:val"), text_style.style_id)
1173
+ ppr.append(ps)
1174
+ p_el.append(ppr)
1175
+ fn.append(p_el)
1176
+ fp = Paragraph(p_el, parent)
1177
+ if i == 0:
1178
+ p_el.append(ref_run("w:footnoteRef"))
1179
+ r = OxmlElement("w:r")
1180
+ r.append(OxmlElement("w:tab") if tab else _text_el(" "))
1181
+ p_el.append(r)
1182
+ children = block.children if block.name == "p" else [block]
1183
+ for child in children:
1184
+ self.inline(fp, child, _Fmt())
1185
+ _trim_el(p_el, left=False)
1186
+ target.append(ref_run("w:footnoteReference"))
1187
+
1188
+
1189
+ # ------------------------------------------------------------------ entry
1190
+
1191
+
1192
+ def _body_has_content(body: BaseOxmlElement, upto: BaseOxmlElement | None = None) -> bool:
1193
+ for el in body:
1194
+ if el is upto:
1195
+ break
1196
+ if el.tag == qn("w:tbl") or el.find(".//" + qn("w:drawing")) is not None:
1197
+ return True
1198
+ if el.tag == qn("w:p") and "".join(t.text or "" for t in el.iter(qn("w:t"))).strip():
1199
+ return True
1200
+ return False
1201
+
1202
+
1203
+ def to_docx(
1204
+ body: str,
1205
+ out_path: str | os.PathLike[str],
1206
+ template: str | os.PathLike[str],
1207
+ meta: Mapping[str, object] | None = None,
1208
+ styles: Mapping[str, str] | None = None,
1209
+ ) -> None:
1210
+ """Write *body* (an HTML fragment) into a copy of *template* at *out_path*."""
1211
+ template = Path(template)
1212
+ if not template.is_file():
1213
+ raise FileNotFoundError(f"DOCX template not found: {template}")
1214
+ doc = _open_template(template)
1215
+ meta = dict(meta or {})
1216
+ meta.setdefault("date", datetime.date.today().strftime("%B %Y"))
1217
+ missing = fill_placeholders(doc, meta)
1218
+ if missing:
1219
+ log.warning(
1220
+ "template: no value for %s (left blank; set with --meta KEY=VALUE)",
1221
+ ", ".join("{{%s}}" % m for m in missing),
1222
+ )
1223
+
1224
+ body_el = doc.element.body
1225
+ marker = next(
1226
+ (
1227
+ p
1228
+ for p in body_el.iterchildren(qn("w:p"))
1229
+ if _CONTENT_ANCHOR.fullmatch("".join(t.text or "" for t in p.iter(qn("w:t"))))
1230
+ ),
1231
+ None,
1232
+ )
1233
+ before = set(body_el) # holding the proxies keeps their identity stable
1234
+
1235
+ writer = TemplateWriter(doc, _Styles(doc, styles))
1236
+ writer.pending_break = _body_has_content(body_el, marker)
1237
+ writer.write(body)
1238
+
1239
+ if marker is not None: # move the new content to the {{CONTENT}} anchor
1240
+ new = [el for el in body_el if el not in before and el.tag != qn("w:sectPr")]
1241
+ for el in new:
1242
+ marker.addprevious(el)
1243
+ if marker.find(qn("w:pPr") + "/" + qn("w:sectPr")) is not None:
1244
+ for r in marker.findall(qn("w:r")):
1245
+ marker.remove(r)
1246
+ else:
1247
+ body_el.remove(marker)
1248
+ if writer.has_toc:
1249
+ _enable_update_fields(doc)
1250
+ doc.save(os.fspath(out_path))