Skip to content
Draft
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
6 changes: 6 additions & 0 deletions DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
37 changes: 33 additions & 4 deletions mdhtml2docx/mdhtml2docx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]

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

Expand Down
24 changes: 24 additions & 0 deletions tests/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<ol><li>Outer<ol><li>Middle<ol><li><p>Lead</p>'
html += ''.join(f'<p>({c}) Continuation</p>' for c in 'ABCD')
html += '</li></ol></li></ol></li></ol>'
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')