diff --git a/md2doc.py b/md2doc.py index 361952a..16c4a88 100644 --- a/md2doc.py +++ b/md2doc.py @@ -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. diff --git a/tests/test_md2doc_helpers.py b/tests/test_md2doc_helpers.py new file mode 100644 index 0000000..1969ac6 --- /dev/null +++ b/tests/test_md2doc_helpers.py @@ -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 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 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"