From 36b6a16fd8496d00cce8cecd1086d7cdff20d72d Mon Sep 17 00:00:00 2001 From: Sebastian Petrescu Date: Tue, 28 Jul 2026 10:24:12 +0300 Subject: [PATCH] feat(md2doc): heading and paragraph renderers with inline formatting --- md2doc.py | 91 +++++++++++++++++++++++++++ tests/test_md2doc_renderers.py | 109 +++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 tests/test_md2doc_renderers.py diff --git a/md2doc.py b/md2doc.py index 16c4a88..e5e3f88 100644 --- a/md2doc.py +++ b/md2doc.py @@ -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 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 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

-

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

as a Word paragraph, preserving inline formatting (strong, em, code, a). + + Also handles 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. diff --git a/tests/test_md2doc_renderers.py b/tests/test_md2doc_renderers.py new file mode 100644 index 0000000..1944831 --- /dev/null +++ b/tests/test_md2doc_renderers.py @@ -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("

My Title

", "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("

Title

", "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("

Title

", "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("

Section

", "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("

Subtitle

", "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("

Hello world

", "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("

This is bold text

", "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 should have monospace font AND run-level shading.""" + doc = Document() + style = STYLES["elegant"] + node = BeautifulSoup("

See foo() here

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