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"