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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user