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
+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"