From 5450ebedb768c769b496a726824617c80a3322c9 Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Fri, 14 Aug 2026 12:52:22 -0400 Subject: [PATCH 1/3] h1 as Title, house default reference, span REF targets Markdown h1 maps to Word's unnumbered Title style; h2-h6 map to Heading1-5, so number_headings schemes bind from h2, with numbers flush at the margin and heading text stair-stepping 0.25" per level. The bundled reference gains the house look: TNR 11, 1.5 spacing, justified prose, Centered style, page-number footer (tools/createref.py applies it, with a no-seed restyle path). Span ids become REF targets, so a span inside a title gives short cross-reference text. Split from title-and-house-reference, leaving the numbered_lists feature and li REF targets behind: this branch is what the fasttract template-headings branch depends on. --- README.md | 2 +- mdhtml2docx/convert.py | 17 +++-- mdhtml2docx/styles.py | 2 +- mdhtml2docx/templates/reference.docx | Bin 11321 -> 11680 bytes tests/test_convert.py | 39 ++++++++-- tools/createref.py | 110 ++++++++++++++++++++++++--- 6 files changed, 145 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 5755f90..837ea8d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Code blocks with a language are syntax colored when [fastpylight](https://github ## Styling -The generated document uses named styles, never inline formatting, so appearance is controlled by restyling. Prose paragraphs get Body Text (First Paragraph directly after a heading or similar block, following pandoc's convention), and the other styles are the ones you would expect: heading 1 through 6, Quote, Source Code, Verbatim Char, Hyperlink, List Paragraph, Compact (table cells), Definition Term, Definition, caption, footnote styles, and Table Grid (plus the author-selectable Borderless Table). +The generated document uses named styles, never inline formatting, so appearance is controlled by restyling. A markdown h1 is the document title: it gets Word's Title style and never joins heading numbering, and h2 through h6 map to heading 1 through 5. Prose paragraphs get Body Text (First Paragraph directly after a heading or similar block, following pandoc's convention), and the other styles are the ones you would expect: Quote, Source Code, Verbatim Char, Hyperlink, List Paragraph, Compact (table cells), Definition Term, Definition, caption, footnote styles, and Table Grid (plus the author-selectable Borderless Table). Pass `reference='mydoc.docx'` to use your own document's styles instead of the built-in template, exactly like pandoc's `--reference-doc`. `reference` may also be a list: the first entry supplies the document (page setup, fonts, and all base styles), and each later entry contributes just its styles, replacing same-named earlier ones - either another `.docx`, or a fastpylight theme name such as `'dracula'`, which generates the code-color styles on the fly. The default is the built-in template plus `'github_light'`; pass a bare reference for plain uncolored code, or `mdhtml2docx.styles.theme_ref('dracula', 'dracula.docx')` to write a theme's styles as a standalone docx you can inspect or tweak. A `custom-style="Name"` attribute (from `{custom-style="Name"}` in Markdown) applies that style from your reference doc; if the style is missing, a stub is injected and a warning returned. A plain class like `{.note}` applies a style only when your reference doc defines one named `note`, and is otherwise ignored. Both work on tables too: a table whose `custom-style` or class names a table style in the reference doc uses it in place of Table Grid - the built-in template ships `Borderless Table` (no gridlines, for signature blocks and other layout tables). diff --git a/mdhtml2docx/convert.py b/mdhtml2docx/convert.py index 21cc27a..28ffd10 100644 --- a/mdhtml2docx/convert.py +++ b/mdhtml2docx/convert.py @@ -207,12 +207,12 @@ def ref_prefix(self, el, fmt, plural=False): def ref_fld(self, el, fmt): """REF/PAGEREF field for a cross-reference `a`, with a cached placeholder Word replaces on update. - Heading/paragraph targets number via `\\w`; caption targets return their bookmarked 'Label N' text + Heading, paragraph, and span targets number via `\\w`; caption targets return their bookmarked 'Label N' text (or the number-only `_n` bookmark for bare/leaf/rel refs), so `\\w` never applies to them.""" tgt = (_get(el, 'href') or '#')[1:] tokens = ref_tokens(_get(el, 'data-ref')) if tgt not in self.reftarget: - raise ValueError(f'cross-reference target #{tgt} not found (targets are headings, paragraphs, figures, and tables with ids)') + raise ValueError(f'cross-reference target #{tgt} not found (targets are headings, paragraphs, spans, figures, and tables with ids)') kind = ref_variant(tokens) nm, self.has_fields = self.bkname(tgt), True if kind == 'page': instr, cached = rf' PAGEREF {nm} \h ', '#' @@ -247,11 +247,11 @@ def custom_style(self, el, kind): return next((self.refstyles[c.lower()] for c in _classes(el) if c.lower() in self.refstyles), None) def span(self, el, fmt): - "Inline span: math -> inline m:oMath zone (linear source, dialect-agnostic), custom style -> rStyle, else transparent" + "Inline span: math -> inline m:oMath zone (linear source, dialect-agnostic), custom style -> rStyle, else transparent; an id becomes a bookmark (REF target)" if _get(el, 'data-refs') is not None: return self.ref_group(el, fmt) if 'math' in _classes(el): return [self.omath(el)] - if sid := self.custom_style(el, 'character'): return self.runs(el, fmt | {'rstyle': sid}) - return self.runs(el, fmt) + if sid := self.custom_style(el, 'character'): return self.bookmark(el, self.runs(el, fmt | {'rstyle': sid})) + return self.bookmark(el, self.runs(el, fmt)) def omath(self, el): "An m:oMath zone holding `el`'s text as linear-format math runs" @@ -728,7 +728,8 @@ def numbering_xml(self): an.append(E('w:lvl', {'w:ilvl': i}, # chkstyle: ignore-node E('w:start', {'w:val': 1}), E('w:numFmt', {'w:val': fmt}), E('w:pStyle', {'w:val': f'Heading{i + 1}'}) if i < 6 else None, - E('w:lvlText', {'w:val': txt}), E('w:lvlJc', {'w:val': 'left'}))) + E('w:lvlText', {'w:val': txt}), E('w:lvlJc', {'w:val': 'left'}), + E('w:pPr', E('w:ind', {'w:left': 360 + 360 * i, 'w:hanging': 360 + 360 * i})))) # number at the margin, text stair-stepped per level root.append(an) for e in self.xabs: root.append(e) for e in tnums: root.append(e) @@ -851,7 +852,7 @@ def harvest_footnotes(self, els): self.fndefs.update({_get(li, 'id'): li for sec in fn for li in _walk(sec) if _tag(li) == 'li' and _get(li, 'id')}) return body - BOOKMARKABLE = {'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'} + BOOKMARKABLE = {'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span'} def to_docx(self, mdhtml, dest): root = parse_frag(mdhtml) @@ -923,7 +924,7 @@ def convert(mdhtml, dest, reference=None, base=None, reftypes=None, number_headi resolve against `base` ('.'). Cross-references (`data-ref` anchors from Markdown `[@sec-x]`) become live REF fields; `reftypes` maps type tokens to (singular, plural) prefix words beyond the built-in `sec`, and `number_headings` (a styles.SCHEMES name such as 'legal', or a {lvlText: numFmt} dict, one entry per heading level) - numbers the headings via a multilevel list so `\\w` fields resolve. Template tokens are dropped + numbers the headings via a multilevel list so `\\w` fields resolve; h1 is the unnumbered document title (Title style), so scheme level 1 is h2. Template tokens are dropped unless `tmpl` is given: a callable taking the token node dict (`mdhtml.export.tmpl_node`: `body`, `syntax`, `form`, `kind`, `name`, `inverted`) and returning a str for a literal text run, `('field', instr)` for a live field, `('control', name)` for an interactive plain-text content diff --git a/mdhtml2docx/styles.py b/mdhtml2docx/styles.py index 722aeda..98c309b 100644 --- a/mdhtml2docx/styles.py +++ b/mdhtml2docx/styles.py @@ -5,7 +5,7 @@ STYLE_MAP = dict( # chkstyle: ignore-node body='Body Text', firstpara='First Paragraph', blockquote='Quote', codeblock='Source Code', codeinline='Verbatim Char', - h1='heading 1', h2='heading 2', h3='heading 3', h4='heading 4', h5='heading 5', h6='heading 6', + h1='Title', h2='heading 1', h3='heading 2', h4='heading 3', h5='heading 4', h6='heading 5', compact='Compact', hyperlink='Hyperlink', list='List Paragraph', dt='Definition Term', dd='Definition', caption='caption', footnotetext='footnote text', footnoteref='footnote reference', table='Table Grid') diff --git a/mdhtml2docx/templates/reference.docx b/mdhtml2docx/templates/reference.docx index 8b19395aaf0e35bca732920017692d6bf68556e3..e1483d6af13478385267d7ec6ff29613b795c712 100644 GIT binary patch delta 6468 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 delta 5967 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 Quoted line.', 'def f(x):', 'return x'): tt(s, md, in_) - # pandoc resolves the anchor against the heading bookmark and rewrites it to the heading's - # auto-identifier, so this line proves the internal link wiring survived the round trip - tt('[the title](#title)', md, in_) + # the internal link survived the round trip: pandoc keeps the raw bookmark anchor, since a + # Title-styled target is not a heading (heading targets get rewritten to auto-identifiers) + tt('[the title](#top)', md, in_) def test_lists(tmp_path): @@ -89,6 +90,34 @@ def test_lists(tmp_path): tt('- [ ] todo', lines, in_) +def test_default_reference(): + "The bundled reference carries the house look: TNR 11 defaults, justified prose, Title and Centered styles, a page-number footer" + z = zipfile.ZipFile(ref_path()) + styles = z.read('word/styles.xml').decode() + for s in ('w:styleId="Title"', 'w:styleId="Centered"', 'Times New Roman'): tt(s, styles, in_) + tt('w:val="both"', re.search(r']*w:styleId="Normal".*?', styles, re.S).group(0), in_) + tt(' PAGE ', z.read('word/footer1.xml').decode(), in_) + tt('footer1.xml', z.read('word/_rels/document.xml.rels').decode(), in_) + tt('footerReference', z.read('word/document.xml').decode(), in_) + + +def test_h1_is_title(tmp_path): + "h1 is the document title: Title style, unnumbered; number_headings schemes bind from h2 (Heading1)" + out = tmp_path/'t.docx' + warns = convert('

EXHIBIT A: Assignment Agreement

\n

Confidentiality

\n' + '

Confidential Information

\n' + '

See and .

', out, number_headings='legal') + teq(warns, []) + teq(fast_checks(out), 'valid') + doc = zipfile.ZipFile(out).read('word/document.xml').decode() + for s in ('', '', '', + r'REF sec_conf \w \h', 'REF ttl \\h'): tt(s, doc, in_) + styles = zipfile.ZipFile(out).read('word/styles.xml').decode() + m = re.search(r']*w:styleId="Title".*?', styles, re.S) + assert m and 'numPr' not in m.group(0) # the title never joins the numbering + m = re.search(r']*w:styleId="Heading1".*?', styles, re.S) + assert m and 'numPr' in m.group(0) # the scheme's level 1 is markdown h2 + def test_tables(tmp_path): out = tmp_path/'t.docx' warns = convert( diff --git a/tools/createref.py b/tools/createref.py index 778ba32..88ab738 100644 --- a/tools/createref.py +++ b/tools/createref.py @@ -4,12 +4,15 @@ The seed (`_data/empty.docx`, a fresh empty document saved by Word 16.111) supplies theme, fonts, settings, and Word's own modern definitions for the styles we keep; this script strips styles.xml to exactly what STYLE_MAP needs, patches Quote for blockquote semantics (left indent, not Word's -centering), authors the definitions Word leaves latent, scrubs personal metadata, and self-verifies: -fast_checks == 'valid' and every STYLE_MAP name defined. See meta/STATUS.md, template section.""" +centering), authors the definitions Word leaves latent, scrubs personal metadata, applies the house look (see the house section below), and self-verifies: +fast_checks == 'valid' and every STYLE_MAP name defined. See meta/STATUS.md, template section. +Without the seed, running this script re-applies the house look to the committed template instead.""" import zipfile from lxml import etree from mdhtml2docx.styles import STYLE_MAP, style_id from mdhtml2docx.validate import fast_checks +from mdhtml2docx.wml import E, R +from pathlib import Path W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' @@ -23,6 +26,11 @@ def w(tag): return f'{{{W}}}{tag}' # Styles Word keeps latent (definitions live inside Word, absent from the file), authored here. # Built-in names are canonical (lowercase for heading/caption/footnote families); custom ones marked so. NEW_STYLES = r''' + + + + + @@ -112,17 +120,99 @@ def scrub_props(xml): if e is not None: e.text = 'mdhtml2docx' return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) -def build(seed='_data/empty.docx', out='mdhtml2docx/templates/reference.docx'): - z = zipfile.ZipFile(seed) +# ---- house look ---------------------------------------------------------- +# The default reference is opinionated: serif book typography with justified prose and a +# page-number footer. Callers wanting another look pass their own reference docx. + +FONT, SIZE, LINE, SPACE_AFTER = 'Times New Roman', 11, 1.5, 11 # typeface, body points, line spacing multiple, space below paragraphs (points) + +def _hp(points): return int(round(points * 2)) +def _fonts(): return E('w:rFonts', {'w:ascii': FONT, 'w:hAnsi': FONT, 'w:eastAsia': FONT, 'w:cs': FONT}) +def _spacing(): return E('w:spacing', {'w:line': int(240 * LINE), 'w:lineRule': 'auto', 'w:after': SPACE_AFTER * 20}) +def _jc(val): return E('w:jc', {'w:val': val}) + +def _restyle(root, sid, ppr=None, rpr=None): + "Replace style `sid`'s paragraph and run properties, dropping whatever it had" + s = root.find(f'{w("style")}[@{w("styleId")}="{sid}"]') + for t in ('pPr', 'rPr'): + if (e := s.find(w(t))) is not None: s.remove(e) + for e in (ppr, rpr): + if e is not None: s.append(e) + +def house_styles(xml): + "House typography: `FONT` at `SIZE`pt for everything, justified prose, bold black headings at body size, plus the Centered block style" + root = etree.fromstring(xml) + rpd = root.find(f'{w("docDefaults")}/{w("rPrDefault")}') + rpd.replace(rpd.find(w('rPr')), E('w:rPr', _fonts(), E('w:sz', {'w:val': _hp(SIZE)}), + E('w:szCs', {'w:val': _hp(SIZE)}), E('w:lang', {'w:val': 'en-US'}))) + _restyle(root, 'Normal', E('w:pPr', _spacing(), E('w:ind', {'w:firstLine': 0}), _jc('both'))) + for i in range(6): + rpr = E('w:rPr', _fonts(), E('w:b'), E('w:color', {'w:val': 'auto'}), E('w:sz', {'w:val': _hp(SIZE)}), E('w:szCs', {'w:val': _hp(SIZE)})) + _restyle(root, f'Heading{i + 1}', E('w:pPr', _spacing(), _jc('both'), E('w:outlineLvl', {'w:val': i})), rpr) + if root.find(f'{w("style")}[@{w("styleId")}="Centered"]') is None: + root.append(E('w:style', {'w:type': 'paragraph', 'w:customStyle': 1, 'w:styleId': 'Centered'}, + E('w:name', {'w:val': 'Centered'}), E('w:basedOn', {'w:val': 'BodyText'}), E('w:next', {'w:val': 'BodyText'}), + E('w:qFormat'), E('w:pPr', E('w:ind', {'w:firstLine': 0}), _jc('center')))) + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) + +def footer_xml(): + "word/footer1.xml: a centered page-number field" + root = etree.Element(w('ftr'), nsmap=dict(w=W)) + root.append(E('w:p', E('w:pPr', _jc('center')), E('w:fldSimple', {'w:instr': ' PAGE '}, E('w:r', E('w:t', '1'))))) + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) + +CTNS = 'http://schemas.openxmlformats.org/package/2006/content-types' +RNS = 'http://schemas.openxmlformats.org/package/2006/relationships' + +def add_footer(parts): + "Wire the page-number footer into archive `parts` (name -> bytes), unless one is already present" + if 'word/footer1.xml' in parts: return + parts['word/footer1.xml'] = footer_xml() + ct = etree.fromstring(parts['[Content_Types].xml']) + etree.SubElement(ct, f'{{{CTNS}}}Override', PartName='/word/footer1.xml', + ContentType='application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml') + parts['[Content_Types].xml'] = etree.tostring(ct, xml_declaration=True, encoding='UTF-8', standalone=True) + rels = etree.fromstring(parts['word/_rels/document.xml.rels']) + rid = f"rId{max((int(r.get('Id')[3:]) for r in rels if r.get('Id', '').startswith('rId')), default=0) + 1}" + etree.SubElement(rels, f'{{{RNS}}}Relationship', Id=rid, + Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer', Target='footer1.xml') + parts['word/_rels/document.xml.rels'] = etree.tostring(rels, xml_declaration=True, encoding='UTF-8', standalone=True) + droot = etree.fromstring(parts['word/document.xml']) + sect = droot.find(f'{w("body")}/{w("sectPr")}') + fr = E('w:footerReference', {'w:type': 'default'}) + fr.set(f'{{{R}}}id', rid) + sect.insert(0, fr) + parts['word/document.xml'] = etree.tostring(droot, xml_declaration=True, encoding='UTF-8', standalone=True) + +def house(parts): + "Apply the house look to reference `parts` in place" + parts['word/styles.xml'] = house_styles(parts['word/styles.xml']) + add_footer(parts) + +def _parts(path): + with zipfile.ZipFile(path) as z: return {i.filename: z.read(i.filename) for i in z.infolist()} + +def _write(parts, out): with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zo: - for i in z.infolist(): - data = z.read(i.filename) - if i.filename == 'word/styles.xml': data = build_styles(data) - elif i.filename == 'docProps/core.xml': data = scrub_props(data) - zo.writestr(i.filename, data) + for name, data in parts.items(): zo.writestr(name, data) + +def build(seed='_data/empty.docx', out='mdhtml2docx/templates/reference.docx'): + parts = _parts(seed) + parts['word/styles.xml'] = build_styles(parts['word/styles.xml']) + parts['docProps/core.xml'] = scrub_props(parts['docProps/core.xml']) + house(parts) + _write(parts, out) verify(out) print(f'{out}: ok') +def restyle(path='mdhtml2docx/templates/reference.docx'): + "Re-apply the house look to the committed template, for machines without the seed" + parts = _parts(path) + house(parts) + _write(parts, path) + verify(path) + print(f'{path}: restyled') + def verify(path): "The template must pass fast_checks and define (not leave latent) every STYLE_MAP style" r = fast_checks(path) @@ -135,4 +225,4 @@ def verify(path): badid = {n for n in STYLE_MAP.values() if style_id(n) not in ids} assert not badid, f'style_id mismatch for: {badid}' -if __name__ == '__main__': build() +if __name__ == '__main__': build() if Path('_data/empty.docx').exists() else restyle() From e2ff324be708e189a73ac457b34210b6642377e5 Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Fri, 14 Aug 2026 14:25:27 -0400 Subject: [PATCH 2/3] Build the reference from a committed Word-web seed The seed (_data/empty.docx) is now in the repo: an empty Word-for-the-web document carrying theme, fonts, settings, empty header/footer slots, and materialized definitions for the kept built-ins (Heading 1-6, Quote, Title, ListParagraph) - the reproduction ritual is documented in the script docstring. Title joins KEEP (restyled to house: centered, 14pt) instead of being authored; the linked Char twins leave KEEP (web Word writes styles unlinked). The house pass folds into build_styles, the footer becomes a content transform of the seed's own footer part (deleting the four-part wiring code and restyle()), sectPr references are reordered to schema position (web Word appends them), and constants are lowercase. verify() now also checks the page-number footer. --- _data/empty.docx | Bin 0 -> 13951 bytes mdhtml2docx/templates/reference.docx | Bin 11680 -> 11822 bytes tests/test_convert.py | 4 +- tools/createref.py | 193 +++++++++++---------------- 4 files changed, 83 insertions(+), 114 deletions(-) create mode 100644 _data/empty.docx diff --git a/_data/empty.docx b/_data/empty.docx new file mode 100644 index 0000000000000000000000000000000000000000..292ef49cb294364758b8fa3f29cba14ad2493462 GIT binary patch literal 13951 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 literal 11680 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]*w:styleId="Normal".*?', styles, re.S).group(0), in_) - tt(' PAGE ', z.read('word/footer1.xml').decode(), in_) - tt('footer1.xml', z.read('word/_rels/document.xml.rels').decode(), in_) + tt(' PAGE ', z.read('word/footer.xml').decode(), in_) + tt('footer.xml', z.read('word/_rels/document.xml.rels').decode(), in_) tt('footerReference', z.read('word/document.xml').decode(), in_) diff --git a/tools/createref.py b/tools/createref.py index 88ab738..36bd1e1 100644 --- a/tools/createref.py +++ b/tools/createref.py @@ -1,42 +1,49 @@ #!/usr/bin/env python """Generate the committed template `mdhtml2docx/templates/reference.docx` from a seed archive. -The seed (`_data/empty.docx`, a fresh empty document saved by Word 16.111) supplies theme, fonts, -settings, and Word's own modern definitions for the styles we keep; this script strips styles.xml -to exactly what STYLE_MAP needs, patches Quote for blockquote semantics (left indent, not Word's -centering), authors the definitions Word leaves latent, scrubs personal metadata, applies the house look (see the house section below), and self-verifies: -fast_checks == 'valid' and every STYLE_MAP name defined. See meta/STATUS.md, template section. -Without the seed, running this script re-applies the house look to the committed template instead.""" +The seed (`_data/empty.docx`) is an empty document saved by Word for the web: it supplies theme, +fonts, settings, an empty header and footer slot, and Word's own definitions for the built-in +styles we keep. The seed supplies what Word's UI can materialize; this script authors the rest. +To reproduce the seed: in a blank Word (web) document, apply Heading 1-6, Quote, and Title each +to a throwaway line and make one line a bulleted list (Word then writes out their definitions), +delete all text and set the last paragraph back to Normal, insert an empty header and footer, +and download a copy. + +Building strips styles.xml to exactly what STYLE_MAP needs, patches Quote for blockquote +semantics (left indent, not Word's centering), applies the house look (Times New Roman 11pt, +1.5 spacing, justified prose, bold black headings, a centered Title, a page-number footer), +authors the definitions Word leaves latent, scrubs personal metadata, and self-verifies: +fast_checks == 'valid' and every STYLE_MAP name defined.""" import zipfile from lxml import etree from mdhtml2docx.styles import STYLE_MAP, style_id from mdhtml2docx.validate import fast_checks -from mdhtml2docx.wml import E, R -from pathlib import Path +from mdhtml2docx.wml import E W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' def w(tag): return f'{{{W}}}{tag}' -# Styles the seed defines that we keep as Word authored them (plus linked Char twins, which -# w:link references require). Everything else the seed defines is dropped. -KEEP = {'Normal', 'DefaultParagraphFont', 'TableNormal', 'NoList', 'Quote', 'QuoteChar', 'ListParagraph', - *[f'Heading{n}' for n in range(1, 7)], *[f'Heading{n}Char' for n in range(1, 7)]} +# Styles the seed defines that we keep (restyled below where the house look differs from Word's). +KEEP = {'Normal', 'DefaultParagraphFont', 'TableNormal', 'NoList', 'Quote', 'ListParagraph', 'Title', + 'Header', 'Footer', *[f'Heading{n}' for n in range(1, 7)]} -# Styles Word keeps latent (definitions live inside Word, absent from the file), authored here. +font, size, line, space_after = 'Times New Roman', 11, 1.5, 11 # typeface, body points, line spacing multiple, space below paragraphs (points) + +# Styles the seed cannot supply, authored here: our own custom styles, plus built-ins that +# Word keeps latent and the web UI has no path to materialize (caption, footnote text, ...). # Built-in names are canonical (lowercase for heading/caption/footnote families); custom ones marked so. NEW_STYLES = r''' - - - - - + + + + @@ -96,39 +103,9 @@ def w(tag): return f'{{{W}}}{tag}' ''' -def build_styles(xml): - "Strip the seed's styles.xml to KEEP, fix Quote, append the authored definitions" - root = etree.fromstring(xml) - for s in list(root.iter(w('style'))): - if s.get(w('styleId')) not in KEEP: root.remove(s) - q = next(s for s in root.iter(w('style')) if s.get(w('styleId')) == 'Quote') - qp = q.find(w('pPr')) - qp.remove(qp.find(w('jc'))) - etree.SubElement(qp, w('ind')).set(w('left'), '720') - for s in root.iter(w('style')): - if s.get(w('styleId')) in ('Quote', *[f'Heading{n}' for n in range(1, 7)]): - s.find(w('next')).set(w('val'), 'FirstParagraph') # typing after these continues our prose chain - for s in etree.fromstring(NEW_STYLES.encode()): root.append(s) - return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) - -def scrub_props(xml): - "Replace personal creator/lastModifiedBy in docProps/core.xml" - root = etree.fromstring(xml) - for tag in ('{http://purl.org/dc/elements/1.1/}creator', - '{http://schemas.openxmlformats.org/package/2006/metadata/core-properties}lastModifiedBy'): - e = root.find(tag) - if e is not None: e.text = 'mdhtml2docx' - return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) - -# ---- house look ---------------------------------------------------------- -# The default reference is opinionated: serif book typography with justified prose and a -# page-number footer. Callers wanting another look pass their own reference docx. - -FONT, SIZE, LINE, SPACE_AFTER = 'Times New Roman', 11, 1.5, 11 # typeface, body points, line spacing multiple, space below paragraphs (points) - def _hp(points): return int(round(points * 2)) -def _fonts(): return E('w:rFonts', {'w:ascii': FONT, 'w:hAnsi': FONT, 'w:eastAsia': FONT, 'w:cs': FONT}) -def _spacing(): return E('w:spacing', {'w:line': int(240 * LINE), 'w:lineRule': 'auto', 'w:after': SPACE_AFTER * 20}) +def _fonts(): return E('w:rFonts', {'w:ascii': font, 'w:hAnsi': font, 'w:eastAsia': font, 'w:cs': font}) +def _spacing(): return E('w:spacing', {'w:line': int(240 * line), 'w:lineRule': 'auto', 'w:after': space_after * 20}) def _jc(val): return E('w:jc', {'w:val': val}) def _restyle(root, sid, ppr=None, rpr=None): @@ -139,90 +116,82 @@ def _restyle(root, sid, ppr=None, rpr=None): for e in (ppr, rpr): if e is not None: s.append(e) -def house_styles(xml): - "House typography: `FONT` at `SIZE`pt for everything, justified prose, bold black headings at body size, plus the Centered block style" +def build_styles(xml): + "Strip the seed's styles.xml to KEEP, patch Quote, apply the house look, append the authored definitions" root = etree.fromstring(xml) + for s in list(root.iter(w('style'))): + if s.get(w('styleId')) not in KEEP: root.remove(s) + q = next(s for s in root.iter(w('style')) if s.get(w('styleId')) == 'Quote') + qp = q.find(w('pPr')) + qp.remove(qp.find(w('jc'))) + etree.SubElement(qp, w('ind')).set(w('left'), '720') + for s in root.iter(w('style')): + if s.get(w('styleId')) in ('Quote', 'Title', *[f'Heading{n}' for n in range(1, 7)]): + s.find(w('next')).set(w('val'), 'FirstParagraph') # typing after these continues our prose chain rpd = root.find(f'{w("docDefaults")}/{w("rPrDefault")}') - rpd.replace(rpd.find(w('rPr')), E('w:rPr', _fonts(), E('w:sz', {'w:val': _hp(SIZE)}), - E('w:szCs', {'w:val': _hp(SIZE)}), E('w:lang', {'w:val': 'en-US'}))) + rpd.replace(rpd.find(w('rPr')), E('w:rPr', _fonts(), E('w:sz', {'w:val': _hp(size)}), + E('w:szCs', {'w:val': _hp(size)}), E('w:lang', {'w:val': 'en-US'}))) _restyle(root, 'Normal', E('w:pPr', _spacing(), E('w:ind', {'w:firstLine': 0}), _jc('both'))) for i in range(6): - rpr = E('w:rPr', _fonts(), E('w:b'), E('w:color', {'w:val': 'auto'}), E('w:sz', {'w:val': _hp(SIZE)}), E('w:szCs', {'w:val': _hp(SIZE)})) + rpr = E('w:rPr', _fonts(), E('w:b'), E('w:color', {'w:val': 'auto'}), E('w:sz', {'w:val': _hp(size)}), E('w:szCs', {'w:val': _hp(size)})) _restyle(root, f'Heading{i + 1}', E('w:pPr', _spacing(), _jc('both'), E('w:outlineLvl', {'w:val': i})), rpr) - if root.find(f'{w("style")}[@{w("styleId")}="Centered"]') is None: - root.append(E('w:style', {'w:type': 'paragraph', 'w:customStyle': 1, 'w:styleId': 'Centered'}, - E('w:name', {'w:val': 'Centered'}), E('w:basedOn', {'w:val': 'BodyText'}), E('w:next', {'w:val': 'BodyText'}), - E('w:qFormat'), E('w:pPr', E('w:ind', {'w:firstLine': 0}), _jc('center')))) + _restyle(root, 'Title', E('w:pPr', _jc('center')), E('w:rPr', E('w:sz', {'w:val': _hp(14)}), E('w:szCs', {'w:val': _hp(14)}))) + for s in etree.fromstring(NEW_STYLES.encode()): root.append(s) return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) -def footer_xml(): - "word/footer1.xml: a centered page-number field" - root = etree.Element(w('ftr'), nsmap=dict(w=W)) +def footer_content(xml): + "word/footer.xml: replace the seed's empty scaffold with a centered page-number field" + root = etree.fromstring(xml) + for e in list(root): root.remove(e) root.append(E('w:p', E('w:pPr', _jc('center')), E('w:fldSimple', {'w:instr': ' PAGE '}, E('w:r', E('w:t', '1'))))) return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) -CTNS = 'http://schemas.openxmlformats.org/package/2006/content-types' -RNS = 'http://schemas.openxmlformats.org/package/2006/relationships' - -def add_footer(parts): - "Wire the page-number footer into archive `parts` (name -> bytes), unless one is already present" - if 'word/footer1.xml' in parts: return - parts['word/footer1.xml'] = footer_xml() - ct = etree.fromstring(parts['[Content_Types].xml']) - etree.SubElement(ct, f'{{{CTNS}}}Override', PartName='/word/footer1.xml', - ContentType='application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml') - parts['[Content_Types].xml'] = etree.tostring(ct, xml_declaration=True, encoding='UTF-8', standalone=True) - rels = etree.fromstring(parts['word/_rels/document.xml.rels']) - rid = f"rId{max((int(r.get('Id')[3:]) for r in rels if r.get('Id', '').startswith('rId')), default=0) + 1}" - etree.SubElement(rels, f'{{{RNS}}}Relationship', Id=rid, - Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer', Target='footer1.xml') - parts['word/_rels/document.xml.rels'] = etree.tostring(rels, xml_declaration=True, encoding='UTF-8', standalone=True) - droot = etree.fromstring(parts['word/document.xml']) - sect = droot.find(f'{w("body")}/{w("sectPr")}') - fr = E('w:footerReference', {'w:type': 'default'}) - fr.set(f'{{{R}}}id', rid) - sect.insert(0, fr) - parts['word/document.xml'] = etree.tostring(droot, xml_declaration=True, encoding='UTF-8', standalone=True) - -def house(parts): - "Apply the house look to reference `parts` in place" - parts['word/styles.xml'] = house_styles(parts['word/styles.xml']) - add_footer(parts) - -def _parts(path): - with zipfile.ZipFile(path) as z: return {i.filename: z.read(i.filename) for i in z.infolist()} - -def _write(parts, out): - with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zo: - for name, data in parts.items(): zo.writestr(name, data) + +def doc_content(xml): + "word/document.xml: move the sectPr's header/footerReference first, as the schema requires (web Word appends them last)" + root = etree.fromstring(xml) + sect = root.find(f"{w('body')}/{w('sectPr')}") + refs = [e for e in sect if etree.QName(e).localname in ('headerReference', 'footerReference')] + for i, e in enumerate(refs): + sect.remove(e) + sect.insert(i, e) + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) + +def scrub_props(xml): + "Replace personal creator/lastModifiedBy in docProps/core.xml" + root = etree.fromstring(xml) + for tag in ('{http://purl.org/dc/elements/1.1/}creator', + '{http://schemas.openxmlformats.org/package/2006/metadata/core-properties}lastModifiedBy'): + e = root.find(tag) + if e is not None: e.text = 'mdhtml2docx' + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) def build(seed='_data/empty.docx', out='mdhtml2docx/templates/reference.docx'): - parts = _parts(seed) - parts['word/styles.xml'] = build_styles(parts['word/styles.xml']) - parts['docProps/core.xml'] = scrub_props(parts['docProps/core.xml']) - house(parts) - _write(parts, out) + z = zipfile.ZipFile(seed) + with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zo: + for i in z.infolist(): + data = z.read(i.filename) + if i.filename == 'word/styles.xml': data = build_styles(data) + elif i.filename == 'word/footer.xml': data = footer_content(data) + elif i.filename == 'word/document.xml': data = doc_content(data) + elif i.filename == 'docProps/core.xml': data = scrub_props(data) + zo.writestr(i.filename, data) verify(out) print(f'{out}: ok') -def restyle(path='mdhtml2docx/templates/reference.docx'): - "Re-apply the house look to the committed template, for machines without the seed" - parts = _parts(path) - house(parts) - _write(parts, path) - verify(path) - print(f'{path}: restyled') - def verify(path): - "The template must pass fast_checks and define (not leave latent) every STYLE_MAP style" + "The template must pass fast_checks, define (not leave latent) every STYLE_MAP style, and carry the page-number footer" r = fast_checks(path) assert r == 'valid', r - root = etree.fromstring(zipfile.ZipFile(path).read('word/styles.xml')) + z = zipfile.ZipFile(path) + root = etree.fromstring(z.read('word/styles.xml')) names = {s.find(w('name')).get(w('val')) for s in root.iter(w('style'))} missing = set(STYLE_MAP.values()) - names assert not missing, f'STYLE_MAP styles not defined: {missing}' ids = {s.get(w('styleId')) for s in root.iter(w('style'))} badid = {n for n in STYLE_MAP.values() if style_id(n) not in ids} assert not badid, f'style_id mismatch for: {badid}' + assert b'PAGE' in z.read('word/footer.xml'), 'footer lacks its page-number field' + assert z.read('word/document.xml').decode().count('footerReference') == 1, 'expected exactly one footerReference' -if __name__ == '__main__': build() if Path('_data/empty.docx').exists() else restyle() +if __name__ == '__main__': build() From e6a25c537e6d983c83254c650dd1902859821d3b Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Fri, 14 Aug 2026 14:46:51 -0400 Subject: [PATCH 3/3] Trim createref prose: docstring back to its original shape, drop comments that restate code --- mdhtml2docx/templates/reference.docx | Bin 11822 -> 11822 bytes tools/createref.py | 24 ++++++++---------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/mdhtml2docx/templates/reference.docx b/mdhtml2docx/templates/reference.docx index a4af5d9945bd7c8f3ed04ee7be46883481de5b06..dd53425db1cd681041a11c2fa06889efd66d58fd 100644 GIT binary patch delta 208 zcmZ1%vo3}wz?+#xgn@y9gW*c)MxHcArYohJOBkEL^be*YCJ;Y|bt(&pX6CBq2hk0Z zs%#*7xx6u0-5rG&V0FioIl%l2Dkfk)n>s6)&#U19ro%NSf#}JvwQN9?rnW7J%Fwn0 uQ7a(4=i1gFo{Ekmh)UOS08uM-z$#zpn1gtVx?m-75Nfur2T1U#t|tIP@J)~a delta 208 zcmZ1%vo3}wz?+#xgn@y9gF&HqBTpJ5lS1+462>Ml{e!893B=D~oyr2DnYpU@L3D$p zDjSGiE^iE0cSqp`SluyY4lw_MiV2v{rp^lH^J;j2>2S?SAbRp^EgKM}scj3QGPLbL u)CvgixwbWkr=sHsqSAF7K-5Ydu*w%Y<{+M;E?7w%gqp4E0TO(w>j?lubvqOQ diff --git a/tools/createref.py b/tools/createref.py index 36bd1e1..feabc9e 100644 --- a/tools/createref.py +++ b/tools/createref.py @@ -1,19 +1,12 @@ #!/usr/bin/env python """Generate the committed template `mdhtml2docx/templates/reference.docx` from a seed archive. -The seed (`_data/empty.docx`) is an empty document saved by Word for the web: it supplies theme, -fonts, settings, an empty header and footer slot, and Word's own definitions for the built-in -styles we keep. The seed supplies what Word's UI can materialize; this script authors the rest. -To reproduce the seed: in a blank Word (web) document, apply Heading 1-6, Quote, and Title each -to a throwaway line and make one line a bulleted list (Word then writes out their definitions), -delete all text and set the last paragraph back to Normal, insert an empty header and footer, -and download a copy. - -Building strips styles.xml to exactly what STYLE_MAP needs, patches Quote for blockquote -semantics (left indent, not Word's centering), applies the house look (Times New Roman 11pt, -1.5 spacing, justified prose, bold black headings, a centered Title, a page-number footer), -authors the definitions Word leaves latent, scrubs personal metadata, and self-verifies: -fast_checks == 'valid' and every STYLE_MAP name defined.""" +The seed (`_data/empty.docx`, an empty document saved by Word for the web, with header/footer +inserted and each kept style applied once so Word writes out its definitions) supplies theme, +fonts, settings, and Word's own definitions for the styles we keep; this script strips styles.xml +to exactly what STYLE_MAP needs, patches Quote for blockquote semantics (left indent, not Word's +centering), applies the house look, authors the definitions Word leaves latent, scrubs personal +metadata, and self-verifies: fast_checks == 'valid' and every STYLE_MAP name defined.""" import zipfile from lxml import etree from mdhtml2docx.styles import STYLE_MAP, style_id @@ -28,10 +21,9 @@ def w(tag): return f'{{{W}}}{tag}' KEEP = {'Normal', 'DefaultParagraphFont', 'TableNormal', 'NoList', 'Quote', 'ListParagraph', 'Title', 'Header', 'Footer', *[f'Heading{n}' for n in range(1, 7)]} -font, size, line, space_after = 'Times New Roman', 11, 1.5, 11 # typeface, body points, line spacing multiple, space below paragraphs (points) +font, size, line, space_after = 'Times New Roman', 11, 1.5, 11 -# Styles the seed cannot supply, authored here: our own custom styles, plus built-ins that -# Word keeps latent and the web UI has no path to materialize (caption, footnote text, ...). +# Styles the seed cannot supply: our custom styles, plus built-ins the web UI cannot materialize. # Built-in names are canonical (lowercase for heading/caption/footnote families); custom ones marked so. NEW_STYLES = r'''