diff --git a/DEV.md b/DEV.md index 3b5ba7f..c08312c 100644 --- a/DEV.md +++ b/DEV.md @@ -30,3 +30,9 @@ 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. + +## Paragraph pagination + +A paragraph may use `{: keep-with-next=true}` to stay with the next paragraph. `{: keep-with-next=false}` overrides an inherited setting. A `.keep-together` div keeps its consecutive paragraphs together. Keep complete lists and fenced blocks within one notebook note because notes render independently. + +A page-break-only paragraph after a table is transferred to the following paragraph to avoid an empty page. Every list continuation paragraph retains the list indentation. diff --git a/mdhtml2docx/mdhtml2docx.py b/mdhtml2docx/mdhtml2docx.py index ebf3815..307bdd7 100644 --- a/mdhtml2docx/mdhtml2docx.py +++ b/mdhtml2docx/mdhtml2docx.py @@ -413,9 +413,9 @@ def li(self, li, nid, ilvl): cont = [E('w:ind', {'w:left': 720 * (ilvl + 1)})] out = [] 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)) + if kind == 'inline': out.append(self.para(self.group_runs(val, {}), 'list', numpr if not out else deepcopy(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.runs(val, {}), 'list', numpr if not out else deepcopy(cont))) else: out += self.block(val, 'list') return out or [self.para([], 'list', numpr)] @@ -575,6 +575,8 @@ def _block(self, el, style, sid): tag = _tag(el) if tag == 'p': ex = self.qindent() if style == 'blockquote' else None + if (keep := _get(el, 'keep-with-next')) is not None: + ex = [E('w:keepNext', {'w:val': int(keep != 'false')}), *(ex or [])] psid = self.custom_style(el, 'paragraph') or sid use = 'firstpara' if self.first and style == 'body' and not psid else style self.first = False @@ -600,7 +602,16 @@ def _block(self, el, style, sid): runs = self.bookmark(title, self.runs(title, {'b': True})) if title is not None else self.text_runs(label, {'b': True}) return ([self.para(runs, style)] if runs else []) + self.block_nodes(body, style, sid) if el.has_class('math') and el.has_class('display'): return [E('w:p', E('m:oMathPara', self.omath(el)))] - return self.blocks(el, style, self.custom_style(el, 'paragraph') or sid) + blocks = self.blocks(el, style, self.custom_style(el, 'paragraph') or sid) + if el.has_class('keep-together'): + for p in blocks[:-1]: + if p.tag != qn('w:p'): continue + ppr = p.find(qn('w:pPr')) + if ppr is None: + ppr = E('w:pPr') + p.insert(0, ppr) + if ppr.find(qn('w:keepNext')) is None: ppr.insert(1 if ppr.find(qn('w:pStyle')) is not None else 0, E('w:keepNext')) + return blocks if tag in BLOCK_TAGS and any(_tag(c) in BLOCK_TAGS for c in el.element_children): return self.blocks(el, style, sid) # unknown container: recurse self.warn(f'unhandled block <{tag}>; emitted as plain paragraph') @@ -704,7 +715,25 @@ def document(self, body_blocks): "word/document.xml bytes: our blocks + the template's sectPr" root = etree.Element(qn('w:document'), nsmap=NS) body = etree.SubElement(root, qn('w:body')) - for b in body_blocks: body.append(b) + skipped = set() + for i in range(len(body_blocks) - 2, -1, -1): + if i < 2 or body_blocks[i-2].tag != qn('w:tbl'): continue + spacer = body_blocks[i-1] + if spacer.tag != qn('w:p') or len(spacer): continue + b, nxt = body_blocks[i:i+2] + br = b.find('w:r/w:br', NS) + if br is None or br.get(qn('w:type')) != 'page' or nxt.tag != qn('w:p'): continue + if any(e.tag not in {qn('w:p'), qn('w:pPr'), qn('w:pStyle'), qn('w:r'), qn('w:rPr'), qn('w:br')} for e in b.iter()): continue + ppr = nxt.find(qn('w:pPr')) + if ppr is None: + ppr = E('w:pPr') + nxt.insert(0, ppr) + pos = next((j for j, e in enumerate(ppr) if etree.QName(e).localname not in {'pStyle', 'keepNext', 'keepLines'}), len(ppr)) + ppr.insert(pos, E('w:pageBreakBefore')) + skipped.add(i) + if i and body_blocks[i-1].tag == qn('w:p') and not len(body_blocks[i-1]): skipped.add(i-1) + for i, b in enumerate(body_blocks): + if i not in skipped: body.append(b) body.append(self.sectpr) return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) diff --git a/tests/test_convert.py b/tests/test_convert.py index 1467ec2..a8f8460 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -560,3 +560,27 @@ 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_continuation_paragraphs_keep_their_indent(tmp_path): + from lxml import etree + out = tmp_path/'continuations.docx' + html = '
  1. Outer
    1. Middle
      1. Lead

        ' + html += ''.join(f'

        ({c}) Continuation

        ' for c in 'ABCD') + html += '
' + teq(mdhtml2docx(html, out), []) + with zipfile.ZipFile(out) as z: root = etree.fromstring(z.read('word/document.xml')) + ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'} + paras = root.xpath('//w:p[w:r/w:t[contains(.,"Continuation")]]', namespaces=ns) + teq([p.xpath('w:pPr/w:ind/@w:left', namespaces=ns) for p in paras], [['2160']] * 4) + teq(fast_checks(out), 'valid') + + +def test_paragraph_keep_with_next_attribute(tmp_path): + from lxml import etree + out = tmp_path/'keep-next.docx' + md = 'Closing paragraph.\n{: keep-with-next=true}\n\nNotice.\n{: keep-with-next=false}' + 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'} + teq(root.xpath('//w:p/w:pPr/w:keepNext/@w:val', namespaces=ns), ['1', '0']) + teq(fast_checks(out), 'valid')