diff --git a/README.md b/README.md index d1c30f3..883dabf 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. 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). +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 shows no number, and h2 through h6 map to heading 1 through 5. The title does carry the numbering's invisible level 0, so every h1 restarts the count below it: a file holding several documents, each opening with an h1, numbers each of them from 1. 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). @@ -66,7 +66,7 @@ Markdown references like `[@sec-payment]` become MDHTML `a` elements marked with The word before the number comes from the reference's type, the id up to its first `-`: `sec` maps to Section/Sections out of the box, and `reftypes=dict(exh=('Exhibit', 'Exhibits'))` adds more. `[Clause @sec-x]` overrides the word for one reference; `[-@sec-x]` suppresses it. Grouped references use a `span` marked with `data-refs` and join as "Sections 3.1 and 4.2" with one field per number. They are never collapsed into ranges, because "3.1-3.3" is static text whose meaning silently changes when a clause is inserted. A reference whose target id does not exist, or whose type has no prefix defined when one is needed, raises rather than warning: a lawyer's document must not open showing "Error! Reference source not found." -Number fields need numbered headings. If your reference docx already numbers its heading styles (most firm templates do), nothing more is required, and the converter leaves that numbering alone. Otherwise pass `number_headings='legal'` for 1. / (a) / (i) numbering or `'decimal'` for 1 / 1.1 / 1.1.1 (the names index `styles.SCHEMES`), or pass your own scheme as a `{lvlText: numFmt}` dict, one entry per heading level. A reference-list entry ending in `.xml` is a third route: a raw file of `w:style`, `w:abstractNum`, and `w:num` elements contributing styles and numbering together, with ids remapped to avoid collisions. +Number fields need numbered headings. If your reference docx already numbers its heading styles (most firm templates do), nothing more is required, and the converter leaves that numbering alone. Otherwise pass `number_headings='legal'` for 1. / (a) / (i) numbering or `'decimal'` for 1. / 1.1. / 1.1.1. (the names index mdhtml's `SCHEMES`), or pass your own scheme as a `{lvlText: numFmt}` dict, one entry per heading level from h1 down, the same shape as mdhtml's: level 0 is the h1 title with an empty lvlText, and `%2` is the h2 counter. A reference-list entry ending in `.xml` is a third route: a raw file of `w:style`, `w:abstractNum`, and `w:num` elements contributing styles and numbering together, with ids remapped to avoid collisions. Figures and captioned tables number themselves with SEQ fields: a figure renders as its image plus a "Figure 1: caption" paragraph below (caption style), a table caption as "Table 1: caption" above the table, both live. When the element has an id, the label-and-number span is bookmarked, so `[@fig-plot]` inserts a live "Figure 1" (no extra prefix word; the label is part of the bookmarked text) and `[-@tbl-stages]` the bare number via a second number-only bookmark. `fig` and `tbl` are built-in reftypes alongside `sec`, and their label words come from the same table. Mixed-type groups render each item with its own singular prefix ("Figure 1 and Table 2"); same-type groups pluralize once. Reference targets must be things that get bookmarks - headings, paragraphs, figures, and tables with ids - and a ref to anything else is an error at conversion time. diff --git a/mdhtml2docx/mdhtml2docx.py b/mdhtml2docx/mdhtml2docx.py index a0fe60c..68c2144 100644 --- a/mdhtml2docx/mdhtml2docx.py +++ b/mdhtml2docx/mdhtml2docx.py @@ -24,6 +24,7 @@ def _sid(key): return style_id(STYLE_MAP[key]) BLOCK_TAGS = set(('address article aside blockquote details dialog div dl fieldset figure footer form h1 h2 h3 h4 h5 h6 ' 'header hgroup hr main menu nav ol p pre search section table ul').split()) INERT_TAGS = {'base', 'link', 'meta', 'style', 'template', 'title'} +HEADING_STYLE_IDS = ['Title'] + [f'Heading{i}' for i in range(1, 6)] # h1-h6, one per numbering level: the Title carries level 0 def _tag(el): return el.name def _get(el, key, default=None): return el.attrs.get(key, default) @@ -732,7 +733,8 @@ def numbering_xml(self): txt, fmt = self.scheme[i] if i < len(self.scheme) else (f'%{i+1}.', 'decimal') 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:pStyle', {'w:val': HEADING_STYLE_IDS[i]}) if i < 6 else None, + E('w:suff', {'w:val': 'nothing'}) if not txt else None, # an empty number (the title's) takes no tab either E('w:lvlText', {'w:val': txt}), E('w:lvlJc', {'w:val': 'left'}))) root.append(an) for e in self.xabs: root.append(e) @@ -818,10 +820,12 @@ def content_types(self, extra_parts): PPR_PRE_NUMPR = {'pStyle', 'keepNext', 'keepLines', 'pageBreakBefore', 'framePr', 'widowControl'} def _number_heading_styles(self): - "Patch w:numPr into Heading1-6 styles, binding them to the generated heading numbering" - for i in range(6): - st = self.sroot.find(f'{{{W}}}style[@{{{W}}}styleId="Heading{i + 1}"]') - if st is None: continue + "Patch w:numPr into the Title and Heading1-5 styles, binding them to the generated heading numbering; the Title's level 0 is what restarts the count at every h1" + for i, sid in enumerate(HEADING_STYLE_IDS): + st = self.sroot.find(f'{{{W}}}style[@{{{W}}}styleId="{sid}"]') + if st is None: + if i == 0: self.warn('no Title style in the reference: an h1 will not restart the heading numbering') + continue ppr = st.find(qn('w:pPr')) if ppr is None: ppr = E('w:pPr') @@ -926,7 +930,7 @@ def mdhtml2docx(mdhtml, dest, reference=None, base=None, reftypes=None, number_h 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; h1 is the unnumbered document title (Title style), so scheme level 1 is h2. Template value instructions are dropped + numbers the headings via a multilevel list so `\\w` fields resolve; scheme level 0 is the h1 document title (Title style), whose empty lvlText shows nothing and whose counter restarts the levels below. Template value instructions are dropped unless `tmpl` is given; other operations remain visible markers. `tmpl` is a callable taking the semantic instruction dict (`mdhtml.export.tmpl_node`: `op`, `value`, `form`) 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/tests/test_convert.py b/tests/test_convert.py index 88367ec..f3b4aed 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -100,7 +100,7 @@ def test_default_reference(): def test_h1_is_title(tmp_path): - "h1 is the document title: Title style, unnumbered; number_headings schemes bind from h2 (Heading1)" + "h1 is the document title: Title style, no visible number, and the invisible level 0 that restarts the scheme, which binds from h2 (Heading1)" out = tmp_path/'t.docx' warns = mdhtml2docx('

EXHIBIT A: Assignment Agreement

\n

Confidentiality

\n' '

Confidential Information

\n' @@ -112,9 +112,28 @@ def test_h1_is_title(tmp_path): 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 + assert m and '' in m.group(0) # the title holds level 0: invisible, but it restarts the count 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 + assert m and '' in m.group(0) # the scheme's first level is markdown h2 + num = zipfile.ZipFile(out).read('word/numbering.xml').decode() + lvl0 = re.search(r'.*?', num, re.S).group(0) + for s in ('w:val="Title"', '', ''): tt(s, lvl0, in_) + tt('', num, in_) # legal's '%1.' shifted onto Word level 2 (h2) + tt('', num, in_) # and '(%2)' onto level 3 (h3) + + +def test_title_restarts_numbering(tmp_path): + "Two documents in one file: the second h1 sends the counters back to 1 through Word's own multilevel rule" + out = tmp_path/'t.docx' + warns = mdhtml2docx('

T

A

B

T2

C

D

' + '

See and .

', out, number_headings='decimal') + teq(warns, []) + teq(fast_checks(out), 'valid') + doc = zipfile.ZipFile(out).read('word/document.xml').decode() + teq(doc.count(''), 2) + for s in (r'REF sec_b \w \h', r'REF sec_d \w \h'): tt(s, doc, in_) # numbers come from Word, one counter set, restarted by the Title level + num = zipfile.ZipFile(out).read('word/numbering.xml').decode() + for s in ('', ''): tt(s, num, in_) # decimal, shifted def test_tables(tmp_path): out = tmp_path/'t.docx' @@ -350,7 +369,7 @@ def test_xrefs(tmp_path): for s in (r'REF sec_pay \w \h', r'REF sec_intro \w \h', r'PAGEREF sec_pay \h', 'Section ', 'Sections ', 'Clause ', ' and ', 'Payment terms'): tt(s, doc, in_) num = zipfile.ZipFile(out).read('word/numbering.xml').decode() - for s in ('lowerLetter', '(%2)', 'Heading1'): tt(s, num, in_) + for s in ('lowerLetter', '(%3)', 'Heading1'): tt(s, num, in_) # legal's '(%2)' sits on Word level 3 under the Title level tt('updateFields', zipfile.ZipFile(out).read('word/settings.xml').decode(), in_) tt('w:numPr', zipfile.ZipFile(out).read('word/styles.xml').decode(), in_) tfail(lambda: mdhtml2docx('

', out), contains='#nope')