diff --git a/DEV.md b/DEV.md index 3b5ba7f..2692273 100644 --- a/DEV.md +++ b/DEV.md @@ -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. diff --git a/mdhtml2docx/mdhtml2docx.py b/mdhtml2docx/mdhtml2docx.py index ebf3815..dec53ed 100644 --- a/mdhtml2docx/mdhtml2docx.py +++ b/mdhtml2docx/mdhtml2docx.py @@ -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 @@ -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] @@ -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)] diff --git a/pyproject.toml b/pyproject.toml index 9c1fc86..17ad292 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/test_convert.py b/tests/test_convert.py index 1467ec2..550a245 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -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)