feat(md2doc): heading and paragraph renderers with inline formatting

This commit is contained in:
2026-07-28 10:24:12 +03:00
parent 322eaad887
commit 36b6a16fd8
2 changed files with 200 additions and 0 deletions
+91
View File
@@ -232,6 +232,97 @@ def attach_list_numbering(paragraph, num_id: int, ilvl: int):
pPr.append(numPr)
# ---------------------------------------------------------------------------
# Element renderers (walk BeautifulSoup DOM → emit docx elements)
# ---------------------------------------------------------------------------
def _warn_skip_image(node):
"""Warn to stderr when an <img> is encountered (out of scope per spec §10).
Defined early so _render_paragraph (used in Task 4) and convert_md_to_doc
(Task 7) can both reference it without forward-declaration issues.
"""
src = node.get("src", "(no src)")
print(f"Warning: image skipped (out of scope): {src}", file=sys.stderr)
def _add_run_shading(run, fill: str):
"""Apply background shading to a run (for inline <code> highlight).
Inserts w:shd inside w:rPr.
"""
rPr = run._r.get_or_add_rPr()
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), fill)
rPr.append(shd)
def _render_heading(doc, node, level: int, style: dict):
"""Render <h1>-<h4> as Word Heading paragraphs.
H1 also gets a bottom paragraph border (per md2pdf pattern).
Applies COMMON['keep_with_next_headings'] so headings don't orphan at page bottom.
Applies COMMON['page_break_before_h1'] if True (style-overridable).
"""
text = node.get_text(strip=True)
p = doc.add_heading(level=level)
p.add_run(text)
# Apply style-specific color
color_hex = style.get("heading_color", "000000")
for run in p.runs:
run.font.color.rgb = RGBColor.from_string(color_hex)
# Wire COMMON config: keep heading with next paragraph
if COMMON.get("keep_with_next_headings", True):
p.paragraph_format.keep_with_next = True
# H1 page-break-before if style overrides COMMON default
if level == 1 and style.get("page_break_before_h1", COMMON.get("page_break_before_h1", False)):
p.paragraph_format.page_break_before = True
if level == 1:
add_paragraph_bottom_border(p, color=color_hex, size="12")
def _render_paragraph(doc, node, style: dict):
"""Render <p> as a Word paragraph, preserving inline formatting (strong, em, code, a).
Also handles <img> children — prints stderr warning and skips (spec §10).
"""
p = doc.add_paragraph()
code_fill = style.get("code_fill", "f5f5f5")
def _add_runs(element):
for child in element.children:
if isinstance(child, str):
p.add_run(child)
elif child.name == "strong" or child.name == "b":
r = p.add_run(child.get_text())
r.bold = True
elif child.name == "em" or child.name == "i":
r = p.add_run(child.get_text())
r.italic = True
elif child.name == "code":
r = p.add_run(child.get_text())
r.font.name = "Menlo"
_add_run_shading(r, fill=code_fill)
elif child.name == "a":
r = p.add_run(child.get_text())
r.underline = True
r.font.color.rgb = RGBColor.from_string(style.get("link_color", "0000EE"))
elif child.name == "img":
_warn_skip_image(child)
else:
# Unknown inline — recurse
_add_runs(child)
_add_runs(node)
def convert_md_to_doc(md_file: Path, output_file: Path, style: str = "elegant") -> Path:
"""Convert a single markdown file to DOCX.
+109
View File
@@ -0,0 +1,109 @@
"""Tests for heading and paragraph renderers — verify the EFFECT (text + style land)."""
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
from docx import Document
from bs4 import BeautifulSoup
from md2doc import STYLES, _render_heading, _render_paragraph
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def get_doc_xml(doc):
tmp = Path(__file__).parent / "_tmp_render.docx"
doc.save(tmp)
try:
with zipfile.ZipFile(tmp) as z:
return ET.fromstring(z.read("word/document.xml"))
finally:
tmp.unlink()
def test_render_h1_uses_heading1_style_and_text():
doc = Document()
style = STYLES["elegant"]
node = BeautifulSoup("<h1>My Title</h1>", "html.parser").find("h1")
_render_heading(doc, node, level=1, style=style)
root = get_doc_xml(doc)
pStyles = [p.text for p in root.iter(f"{{{W_NS}}}pStyle")]
assert "Heading1" in pStyles
texts = [t.text for t in root.iter(f"{{{W_NS}}}t")]
assert "My Title" in texts
def test_render_h1_has_bottom_border():
doc = Document()
style = STYLES["elegant"]
node = BeautifulSoup("<h1>Title</h1>", "html.parser").find("h1")
_render_heading(doc, node, level=1, style=style)
root = get_doc_xml(doc)
bottoms = list(root.iter(f"{{{W_NS}}}bottom"))
assert len(bottoms) >= 1, "h1 should have bottom border"
def test_render_h1_applies_style_heading_color():
"""Heading color should come from STYLES[style]['heading_color']."""
doc = Document()
style = STYLES["report"]
node = BeautifulSoup("<h1>Title</h1>", "html.parser").find("h1")
_render_heading(doc, node, level=1, style=style)
root = get_doc_xml(doc)
colors = [c.get(f"{{{W_NS}}}val") for c in root.iter(f"{{{W_NS}}}color")]
assert "1e3a8a" in colors, f"expected heading color 1e3a8a from 'report' style, got {colors}"
def test_render_heading_applies_keep_with_next():
"""COMMON['keep_with_next_headings'] should set paragraph_format.keep_with_next=True."""
doc = Document()
style = STYLES["elegant"]
node = BeautifulSoup("<h2>Section</h2>", "html.parser").find("h2")
_render_heading(doc, node, level=2, style=style)
root = get_doc_xml(doc)
keep_next = list(root.iter(f"{{{W_NS}}}keepNext"))
assert len(keep_next) >= 1, "expected w:keepNext for heading"
def test_render_h2_no_bottom_border():
"""Only h1 gets border; h2/h3/h4 don't."""
doc = Document()
style = STYLES["elegant"]
node = BeautifulSoup("<h2>Subtitle</h2>", "html.parser").find("h2")
_render_heading(doc, node, level=2, style=style)
root = get_doc_xml(doc)
bottoms = list(root.iter(f"{{{W_NS}}}bottom"))
assert len(bottoms) == 0
def test_render_paragraph_emits_text():
doc = Document()
style = STYLES["elegant"]
node = BeautifulSoup("<p>Hello world</p>", "html.parser").find("p")
_render_paragraph(doc, node, style=style)
root = get_doc_xml(doc)
texts = [t.text for t in root.iter(f"{{{W_NS}}}t")]
assert "Hello world" in texts
def test_render_paragraph_handles_bold_inline():
doc = Document()
style = STYLES["elegant"]
node = BeautifulSoup("<p>This is <strong>bold</strong> text</p>", "html.parser").find("p")
_render_paragraph(doc, node, style=style)
root = get_doc_xml(doc)
b_el = list(root.iter(f"{{{W_NS}}}b"))
assert len(b_el) >= 1
def test_render_paragraph_handles_inline_code_with_shading():
"""Inline <code> should have monospace font AND run-level shading."""
doc = Document()
style = STYLES["elegant"]
node = BeautifulSoup("<p>See <code>foo()</code> here</p>", "html.parser").find("p")
_render_paragraph(doc, node, style=style)
root = get_doc_xml(doc)
rPr_list = list(root.iter(f"{{{W_NS}}}rPr"))
shd_in_runs = [r.find(f"{{{W_NS}}}shd") for r in rPr_list
if r.find(f"{{{W_NS}}}shd") is not None]
assert len(shd_in_runs) >= 1, "expected w:shd inside a w:rPr for inline code"