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/__init__.py +18 -0
- mdmint/__main__.py +6 -0
- mdmint/admonitions.py +70 -0
- mdmint/assets.py +84 -0
- mdmint/backends.py +244 -0
- mdmint/cli.py +164 -0
- mdmint/core.py +425 -0
- mdmint/docxtemplate.py +1250 -0
- mdmint/mathrender.py +114 -0
- mdmint/mermaid.py +149 -0
- mdmint/mkdocs.py +310 -0
- mdmint/render.py +343 -0
- mdmint/resources/default.css +122 -0
- mdmint-0.2.0.dist-info/METADATA +196 -0
- mdmint-0.2.0.dist-info/RECORD +20 -0
- mdmint-0.2.0.dist-info/WHEEL +5 -0
- mdmint-0.2.0.dist-info/entry_points.txt +2 -0
- mdmint-0.2.0.dist-info/licenses/LICENSE +201 -0
- mdmint-0.2.0.dist-info/licenses/NOTICE +5 -0
- mdmint-0.2.0.dist-info/top_level.txt +1 -0
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))
|