feat(md2doc): XML helpers — borders, shading, multi-level numbering

This commit is contained in:
2026-07-28 10:23:18 +03:00
parent e3176a789e
commit 322eaad887
2 changed files with 286 additions and 0 deletions
+148
View File
@@ -84,6 +84,154 @@ def _bulletize(text: str) -> str:
return "\n".join(out)
# ---------------------------------------------------------------------------
# XML manipulation helpers
# Spec ref: docs/specs/2026-07-27-md2doc-design.md §4.1 — verified empirically
# that python-docx 1.2 has no public API for borders/shading/numbering, so
# we manipulate the OOXML directly via OxmlElement.
# ---------------------------------------------------------------------------
def add_paragraph_bottom_border(paragraph, color: str = "000000", size: str = "6"):
"""Attach a bottom border to a paragraph (e.g., Heading 1 underline).
Spec §4.1 H1: SURVIVED — verified via XML inspection.
"""
pPr = paragraph._p.get_or_add_pPr()
pBdr = pPr.find(qn('w:pBdr'))
if pBdr is None:
pBdr = OxmlElement('w:pBdr')
pPr.append(pBdr)
bottom = OxmlElement('w:bottom')
bottom.set(qn('w:val'), 'single')
bottom.set(qn('w:sz'), size)
bottom.set(qn('w:space'), '1')
bottom.set(qn('w:color'), color)
pBdr.append(bottom)
def add_paragraph_shading(paragraph, fill: str):
"""Apply background shading to a paragraph (code, blockquote).
Spec §4.1 H2: SURVIVED.
"""
pPr = paragraph._p.get_or_add_pPr()
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), fill)
pPr.append(shd)
def add_cell_shading(cell, fill: str):
"""Apply background shading to a table cell (header row).
Spec §4.1 H4: SURVIVED. Inserts w:shd inside w:tcPr.
"""
tcPr = cell._tc.get_or_add_tcPr()
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), fill)
tcPr.append(shd)
def register_multilevel_numbering(doc, levels: int = 3, kind: str = "bullet") -> int:
"""Register a multi-level numbering definition in the document.
Returns the numId to reference from paragraphs.
Spec §4.1 H3 Route B: SURVIVED at XML level. Mandatory because style-name
approach ('List Bullet 2') produces flat lists in Word (single-level
abstractNums in default template).
Args:
doc: python-docx Document
levels: number of nesting levels to define (1=flat, 3=three levels deep)
kind: 'bullet' or 'decimal' for numbered lists
"""
numbering = doc.part.numbering_part.element
existing_abstract_ids = [
int(an.get(qn('w:abstractNumId')))
for an in numbering.findall(qn('w:abstractNum'))
]
abstract_num_id = max(existing_abstract_ids, default=-1) + 1
abstract_num = OxmlElement('w:abstractNum')
abstract_num.set(qn('w:abstractNumId'), str(abstract_num_id))
multi_level = OxmlElement('w:multiLevelType')
multi_level.set(qn('w:val'), 'hybridMultilevel')
abstract_num.append(multi_level)
for ilvl in range(levels):
lvl = OxmlElement('w:lvl')
lvl.set(qn('w:ilvl'), str(ilvl))
lvl.set(qn('w:tplc'), '0409000F' if kind == "bullet" else '04070001')
start = OxmlElement('w:start')
start.set(qn('w:val'), '1')
lvl.append(start)
numFmt = OxmlElement('w:numFmt')
numFmt.set(qn('w:val'), 'bullet' if kind == "bullet" else 'decimal')
lvl.append(numFmt)
lvl_text = OxmlElement('w:lvlText')
lvl_text.set(qn('w:val'), '' if kind == "bullet" else f'%{ilvl + 1}.')
lvl.append(lvl_text)
lvl_jc = OxmlElement('w:lvlJc')
lvl_jc.set(qn('w:val'), 'left')
lvl.append(lvl_jc)
pPr = OxmlElement('w:pPr')
ind = OxmlElement('w:ind')
ind.set(qn('w:left'), str(720 * (ilvl + 1)))
ind.set(qn('w:hanging'), '360')
pPr.append(ind)
lvl.append(pPr)
abstract_num.append(lvl)
numbering.insert(0, abstract_num)
existing_num_ids = [
int(n.get(qn('w:numId')))
for n in numbering.findall(qn('w:num'))
]
num_id = max(existing_num_ids, default=0) + 1
num = OxmlElement('w:num')
num.set(qn('w:numId'), str(num_id))
abstract_ref = OxmlElement('w:abstractNumId')
abstract_ref.set(qn('w:val'), str(abstract_num_id))
num.append(abstract_ref)
numbering.append(num)
return num_id
def attach_list_numbering(paragraph, num_id: int, ilvl: int):
"""Attach numbering to a paragraph at the given indent level.
Spec §4.1 H3 Route B. Must be paired with register_multilevel_numbering.
"""
pPr = paragraph._p.get_or_add_pPr()
existing = pPr.find(qn('w:numPr'))
if existing is not None:
pPr.remove(existing)
numPr = OxmlElement('w:numPr')
ilvl_el = OxmlElement('w:ilvl')
ilvl_el.set(qn('w:val'), str(ilvl))
numId_el = OxmlElement('w:numId')
numId_el.set(qn('w:val'), str(num_id))
numPr.append(ilvl_el)
numPr.append(numId_el)
pPr.append(numPr)
def convert_md_to_doc(md_file: Path, output_file: Path, style: str = "elegant") -> Path:
"""Convert a single markdown file to DOCX.
+138
View File
@@ -0,0 +1,138 @@
"""Tests for XML helper functions — borders, shading, numbering.
These prove the EFFECT (XML structure lands in the right place), not the call.
Each test unzips the docx and inspects word/document.xml directly.
"""
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
import pytest
from docx import Document
from docx.oxml import OxmlElement
from md2doc import (
add_paragraph_bottom_border,
add_paragraph_shading,
add_cell_shading,
attach_list_numbering,
register_multilevel_numbering,
)
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def get_doc_xml(doc):
tmp = Path(__file__).parent / "_tmp_helper_test.docx"
doc.save(tmp)
try:
with zipfile.ZipFile(tmp) as z:
xml_bytes = z.read("word/document.xml")
return ET.fromstring(xml_bytes)
finally:
tmp.unlink()
def find_all(root, tag):
return root.findall(f".//{{{W_NS}}}{tag}")
def test_add_paragraph_bottom_border_inserts_pBdr():
"""H1: paragraph bottom border lands inside w:pPr as w:pBdr/w:bottom."""
doc = Document()
p = doc.add_paragraph()
add_paragraph_bottom_border(p, color="000000", size="12")
root = get_doc_xml(doc)
pBdr = find_all(root, "pBdr")
bottom = find_all(root, "bottom")
assert len(pBdr) >= 1, "w:pBdr missing"
assert len(bottom) >= 1, "w:bottom missing"
b = bottom[0]
assert b.get(f"{{{W_NS}}}color") == "000000"
assert b.get(f"{{{W_NS}}}sz") == "12"
assert b.get(f"{{{W_NS}}}val") == "single"
def test_add_paragraph_shading_inserts_shd():
"""H2: paragraph shading w:shd lands inside w:pPr with correct fill."""
doc = Document()
p = doc.add_paragraph()
add_paragraph_shading(p, fill="D9D9D9")
root = get_doc_xml(doc)
shd = find_all(root, "shd")
assert len(shd) >= 1, "w:shd missing"
assert shd[0].get(f"{{{W_NS}}}fill") == "D9D9D9"
assert shd[0].get(f"{{{W_NS}}}val") == "clear"
def test_add_cell_shading_inserts_shd_in_tcPr():
"""H4: cell shading w:shd lands inside w:tcPr."""
doc = Document()
table = doc.add_table(rows=2, cols=2)
cell = table.rows[0].cells[0]
add_cell_shading(cell, fill="4472C4")
root = get_doc_xml(doc)
tcPr_list = find_all(root, "tcPr")
shd_in_tc = [tc.find(f"{{{W_NS}}}shd") for tc in tcPr_list
if tc.find(f"{{{W_NS}}}shd") is not None]
assert len(shd_in_tc) >= 1, "w:shd missing from w:tcPr"
assert shd_in_tc[0].get(f"{{{W_NS}}}fill") == "4472C4"
def test_register_multilevel_numbering_creates_abstractNum_with_3_levels():
"""H3 Route B: register creates ONE abstractNum with 3 w:lvl elements (ilvl 0/1/2).
BEHAVIORAL: traces num_id → w:num → w:abstractNumId → w:abstractNum, then
asserts THAT abstractNum (not all 9 default abstractNums in the template)
has exactly the levels we registered. A `return 1` no-op mutation must FAIL.
"""
doc = Document()
num_id = register_multilevel_numbering(doc, levels=3, kind="bullet")
assert isinstance(num_id, int)
tmp = Path(__file__).parent / "_tmp_num.docx"
doc.save(tmp)
try:
with zipfile.ZipFile(tmp) as z:
num_xml = z.read("word/numbering.xml")
root = ET.fromstring(num_xml)
our_num = None
for num_el in root.findall(f"{{{W_NS}}}num"):
if num_el.get(f"{{{W_NS}}}numId") == str(num_id):
our_num = num_el
break
assert our_num is not None, f"No <w:num> with numId={num_id} (function did nothing)"
abstract_ref = our_num.find(f"{{{W_NS}}}abstractNumId")
assert abstract_ref is not None, "missing w:abstractNumId in our w:num"
abstract_id = abstract_ref.get(f"{{{W_NS}}}val")
our_abstract = None
for an in root.findall(f"{{{W_NS}}}abstractNum"):
if an.get(f"{{{W_NS}}}abstractNumId") == abstract_id:
our_abstract = an
break
assert our_abstract is not None, f"No <w:abstractNum> with id={abstract_id}"
lvls = our_abstract.findall(f"{{{W_NS}}}lvl")
ilvls = sorted(int(l.get(f"{{{W_NS}}}ilvl")) for l in lvls)
assert ilvls == [0, 1, 2], f"expected ilvl=[0,1,2] in OUR abstractNum, got {ilvls}"
finally:
tmp.unlink()
def test_attach_list_numbering_sets_numPr_with_ilvl():
"""H3 Route B: paragraph gets w:numPr with w:ilvl and w:numId."""
doc = Document()
num_id = register_multilevel_numbering(doc, levels=3, kind="bullet")
p = doc.add_paragraph("nested item")
attach_list_numbering(p, num_id=num_id, ilvl=1)
root = get_doc_xml(doc)
numPr = find_all(root, "numPr")
ilvl = find_all(root, "ilvl")
numId_xml = find_all(root, "numId")
assert len(numPr) >= 1, "w:numPr missing"
assert len(ilvl) >= 1, "w:ilvl missing"
assert ilvl[0].get(f"{{{W_NS}}}val") == "1"
assert len(numId_xml) >= 1, "w:numId missing"