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
8 changes: 8 additions & 0 deletions DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,11 @@ pytest -q -m slow
Table rows stay on one page by default. A row that does not fit in the remaining space moves to the next page. Tables can span pages. Rows taller than a page can still split.

Add `{: keep-rows=false}` directly below a Markdown table to allow its rows to split. `{: keep-rows=true}` explicitly selects the default. The converter writes Word's `cantSplit` setting on each row. The false override disables that setting even when a table style enables it.

## Included documents

The Markdown-to-MDHTML stage appends the scope to local IDs in an include. With `scope="__mic"`, `sec-setup` becomes `sec-setup__mic`. Links within the include use the suffixed ID. A link outside the include can use that full ID. Each include needs a unique scope suffix. MDHTML input must already contain the suffixed IDs and links. The DOCX converter uses those IDs without applying scopes.

Each document starts with an h1 title. Word's existing heading numbering restarts after each h1. All included documents use the same numbering scheme selected for the export.

Grouped references retain their type across scopes. References to `sec-setup__mic` and `sec-setup__speaker` share the prefix "Sections". Paragraph IDs in lists remain available as Word bookmarks. Long bookmark names are shortened to fit Word's limit.
5 changes: 3 additions & 2 deletions mdhtml2docx/mdhtml2docx.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
we generate word/document.xml (plus footnotes/numbering/media parts as needed) into a copy of its
archive. Block and inline walkers mirror the MDHTML element inventory; STYLE_MAP names every
style we emit."""
import posixpath, re, zipfile
import hashlib, posixpath, re, zipfile
from copy import deepcopy
from pathlib import Path
from fast5ever import Comment, Element, Node, Text
Expand Down Expand Up @@ -190,6 +190,7 @@ def bkname(self, id):
if id not in self._bknames:
nm = re.sub(r'\W', '_', id)
if not nm[:1].isalpha(): nm = 'B' + nm
if len(nm) > 38: nm = nm[:27] + '_' + hashlib.sha256(id.encode()).hexdigest()[:10]
while nm in self._bknames.values(): nm += '_'
self._bknames[id] = nm
return self._bknames[id]
Expand Down Expand Up @@ -415,7 +416,7 @@ def li(self, li, nid, ilvl):
for kind, val in self.li_parts(li):
if kind == 'inline': out.append(self.para(self.group_runs(val, {}), 'list', numpr if not out else cont))
elif _tag(val) in ('ul', 'ol'): out += self.list_el(val, ilvl + 1)
elif _tag(val) == 'p': out.append(self.para(self.runs(val, {}), 'list', numpr if not out else cont))
elif _tag(val) == 'p': out.append(self.para(self.bookmark(val, self.runs(val, {})), 'list', numpr if not out else cont))
else: out += self.block(val, 'list')
return out or [self.para([], 'list', numpr)]

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ classifiers = [
"Programming Language :: Python :: 3 :: Only",
]

dependencies = ["lxml", "mdhtml>=0.1.39", "fast5ever>=0.1.5"]
dependencies = ["lxml", "mdhtml>=0.1.40", "fast5ever>=0.1.5"]

[project.optional-dependencies]
dev = [
Expand Down
32 changes: 32 additions & 0 deletions tests/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,3 +560,35 @@ def test_panel_contract(tmp_path, state):
assert 'label' in text and 'Body text' in text and 'More body' in text
assert '## Actual heading' in text # h1 maps to Word's Title; h3 maps to Heading 2
assert not any(line.startswith('#') and 'label' in line for line in text.splitlines())

def test_list_paragraph_bookmarks(tmp_path):
from lxml import etree
out = tmp_path/'list-refs.docx'
md = '1. First item.\n {: #sec-first}\n\n2. Second item.\n {: #sec-second}\n\nSee [@sec-first].'
teq(mdhtml2docx(md2mdhtml(md), out), [])
with zipfile.ZipFile(out) as z: root = etree.fromstring(z.read('word/document.xml'))
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
names = root.xpath('//w:bookmarkStart/@w:name', namespaces=ns)
assert {'sec_first', 'sec_second'} <= set(names)
teq(fast_checks(out), 'valid')


def test_scoped_reference_group(tmp_path):
from lxml import etree
out = tmp_path/'scopes.docx'
md = '\n\n'.join(f'::: {{.include scope="{scope}"}}\n# {title}\n\n## Setup {{#sec-setup}}\n\n'
'See [@sec-setup].\n:::' for scope, title in [('__mic', 'Microphone'), ('__speaker', 'Speaker')])
md += '\n\nCompare [@sec-setup__mic; @sec-setup__speaker].'
src = md2mdhtml(md)
assert 'id="sec-setup__mic"' in src and 'id="sec-setup__speaker"' in src
assert 'id="sec-setup"' not in src
teq(mdhtml2docx(src, out, number_headings='decimal'), [])
teq(fast_checks(out), 'valid')
with zipfile.ZipFile(out) as z: root = etree.fromstring(z.read('word/document.xml'))
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
assert {'sec_setup__mic', 'sec_setup__speaker'} <= set(root.xpath('//w:bookmarkStart/@w:name', namespaces=ns))
group = root.xpath('//w:p', namespaces=ns)[-1]
assert 'Sections ' in ''.join(group.xpath('.//w:t/text()', namespaces=ns))
fields = group.xpath('.//w:fldSimple/@w:instr', namespaces=ns)
assert any('REF sec_setup__mic ' in f for f in fields)
assert any('REF sec_setup__speaker ' in f for f in fields)