Skip to content
Open
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
2 changes: 1 addition & 1 deletion 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. 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).

Expand Down
Binary file added _data/empty.docx
Binary file not shown.
17 changes: 9 additions & 8 deletions mdhtml2docx/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ', '#'
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This applies indention only to header text instead of their numbers. It looks better to @PiotrCzapla and I than indenting both, but happy to change it to the more standard indention of both the text and number

root.append(an)
for e in self.xabs: root.append(e)
for e in tnums: root.append(e)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mdhtml2docx/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
Binary file modified mdhtml2docx/templates/reference.docx
Binary file not shown.
39 changes: 34 additions & 5 deletions tests/test_convert.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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'):
Expand Down Expand Up @@ -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):
Expand All @@ -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:style [^>]*w:styleId="Normal".*?</w:style>', 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('<h1 id="ttl">EXHIBIT A: Assignment Agreement</h1>\n<h2 id="sec-conf">Confidentiality</h2>\n'
'<h3>Confidential Information</h3>\n'
'<p>See <a href="#sec-conf" data-ref=""></a> and <a href="#ttl" data-ref="bare text"></a>.</p>', out, number_headings='legal')
teq(warns, [])
teq(fast_checks(out), 'valid')
doc = zipfile.ZipFile(out).read('word/document.xml').decode()
for s in ('<w:pStyle w:val="Title"/>', '<w:pStyle w:val="Heading1"/>', '<w:pStyle w:val="Heading2"/>',
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
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

def test_tables(tmp_path):
out = tmp_path/'t.docx'
warns = convert(
Expand Down
77 changes: 64 additions & 13 deletions tools/createref.py
Original file line number Diff line number Diff line change
@@ -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'''<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="BodyText">
Expand All @@ -29,6 +32,10 @@ def w(tag): return f'{{{W}}}{tag}'
<w:style w:type="paragraph" w:customStyle="1" w:styleId="FirstParagraph">
<w:name w:val="First Paragraph"/><w:basedOn w:val="BodyText"/><w:next w:val="BodyText"/><w:uiPriority w:val="1"/><w:qFormat/>
</w:style>
<w:style w:type="paragraph" w:customStyle="1" w:styleId="Centered">
<w:name w:val="Centered"/><w:basedOn w:val="BodyText"/><w:next w:val="BodyText"/><w:qFormat/>
<w:pPr><w:ind w:firstLine="0"/><w:jc w:val="center"/></w:pPr>
</w:style>
<w:style w:type="paragraph" w:customStyle="1" w:styleId="Compact">
<w:name w:val="Compact"/><w:basedOn w:val="BodyText"/><w:uiPriority w:val="1"/><w:qFormat/>
<w:pPr><w:spacing w:before="0" w:after="0"/></w:pPr>
Expand Down Expand Up @@ -88,8 +95,21 @@ def w(tag): return f'{{{W}}}{tag}'
</w:style>
</w:styles>'''

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)
Expand All @@ -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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build() reorders the seed's sectPr header/footer references (doc_content). Web Word appends them last, and the strict ECMA-376 sequence wants them first; without the reorder, the template and every generated document fail schema validation. We kept the transform instead of hand-editing the seed so that a freshly downloaded seed always builds. If web Word ever fixes its serialization, the transform becomes a no-op.


def scrub_props(xml):
"Replace personal creator/lastModifiedBy in docProps/core.xml"
root = etree.fromstring(xml)
Expand All @@ -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()