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/_data/empty.docx b/_data/empty.docx new file mode 100644 index 0000000..292ef49 Binary files /dev/null and b/_data/empty.docx differ 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 8b19395..dd53425 100644 Binary files a/mdhtml2docx/templates/reference.docx and b/mdhtml2docx/templates/reference.docx differ diff --git a/tests/test_convert.py b/tests/test_convert.py index aa59579..2159a66 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -1,4 +1,4 @@ -import base64, pytest, subprocess, zipfile +import base64, pytest, re, subprocess, zipfile from pathlib import Path from fastcore.test import test_eq as teq, test as tt, test_fail as tfail @@ -9,6 +9,7 @@ from mdhtml.tools import SAMPLE_MD, sample_md from mdhtml2docx.convert import convert, mustache_fields from mdhtml2docx.validate import fast_checks +from mdhtml2docx.styles import ref_path def pandoc(path, to='markdown'): @@ -63,11 +64,11 @@ def test_basic_blocks(tmp_path): teq(warns, []) teq(fast_checks(out), 'valid') md = pandoc(out) - for s in ('# Title', '## Sub *title*', '`f(x)`', '[fast.ai](https://fast.ai/)', + for s in ('# Sub *title*', '`f(x)`', '[fast.ai](https://fast.ai/)', # no '# Title': the h1 is Title style, which pandoc lifts to metadata (its bookmark still resolves below) '> 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/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_) + + +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..feabc9e 100644 --- a/tools/createref.py +++ b/tools/createref.py @@ -1,26 +1,29 @@ #!/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 +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), 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), 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 from mdhtml2docx.validate import fast_checks +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 + +# 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''' @@ -29,6 +32,10 @@ def w(tag): return f'{{{W}}}{tag}' + + + + @@ -88,8 +95,21 @@ def w(tag): return f'{{{W}}}{tag}' ''' +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 build_styles(xml): - "Strip the seed's styles.xml to KEEP, fix Quote, append the authored definitions" + "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) @@ -98,11 +118,37 @@ def build_styles(xml): 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)]): + 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'}))) + _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) + _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_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) + + +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) @@ -118,21 +164,26 @@ def build(seed='_data/empty.docx', out='mdhtml2docx/templates/reference.docx'): 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 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()