Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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.

Expand Down
16 changes: 10 additions & 6 deletions mdhtml2docx/mdhtml2docx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions tests/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<h1 id="ttl">EXHIBIT A: Assignment Agreement</h1>\n<h2 id="sec-conf">Confidentiality</h2>\n'
'<h3>Confidential Information</h3>\n'
Expand All @@ -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:style [^>]*w:styleId="Title".*?</w:style>', styles, re.S)
assert m and 'numPr' not in m.group(0) # the title never joins the numbering
assert m and '<w:ilvl w:val="0"/>' in m.group(0) # the title holds level 0: invisible, but it restarts the count
m = re.search(r'<w:style [^>]*w:styleId="Heading1".*?</w:style>', styles, re.S)
assert m and 'numPr' in m.group(0) # the scheme's level 1 is markdown h2
assert m and '<w:ilvl w:val="1"/>' 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'<w:lvl w:ilvl="0">.*?</w:lvl>', num, re.S).group(0)
for s in ('w:val="Title"', '<w:suff w:val="nothing"/>', '<w:lvlText w:val=""/>'): tt(s, lvl0, in_)
tt('<w:lvlText w:val="%2."/>', num, in_) # legal's '%1.' shifted onto Word level 2 (h2)
tt('<w:lvlText w:val="(%3)"/>', 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('<h1>T</h1><h2 id="sec-a">A</h2><h3 id="sec-b">B</h3><h1>T2</h1><h2 id="sec-c">C</h2><h3 id="sec-d">D</h3>'
'<p>See <a href="#sec-b" data-ref=""></a> and <a href="#sec-d" data-ref=""></a>.</p>', out, number_headings='decimal')
teq(warns, [])
teq(fast_checks(out), 'valid')
doc = zipfile.ZipFile(out).read('word/document.xml').decode()
teq(doc.count('<w:pStyle w:val="Title"/>'), 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 ('<w:lvlText w:val="%2."/>', '<w:lvlText w:val="%2.%3."/>'): tt(s, num, in_) # decimal, shifted

def test_tables(tmp_path):
out = tmp_path/'t.docx'
Expand Down Expand Up @@ -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('<p><a href="#nope" data-ref=""></a></p>', out), contains='#nope')
Expand Down