feat(md2doc): XML helpers — borders, shading, multi-level numbering
This commit is contained in:
@@ -84,6 +84,154 @@ def _bulletize(text: str) -> str:
|
|||||||
return "\n".join(out)
|
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:
|
def convert_md_to_doc(md_file: Path, output_file: Path, style: str = "elegant") -> Path:
|
||||||
"""Convert a single markdown file to DOCX.
|
"""Convert a single markdown file to DOCX.
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user