From 21b3a7a77d81e1b2c699e91d8061356776e443e7 Mon Sep 17 00:00:00 2001 From: Sebastian Petrescu Date: Tue, 28 Jul 2026 10:27:02 +0300 Subject: [PATCH] feat(md2doc): table, pre, blockquote, hr renderers --- md2doc.py | 65 ++++++++++++++++++++++++++++++++ tests/test_md2doc_blocks.py | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tests/test_md2doc_blocks.py diff --git a/md2doc.py b/md2doc.py index e6c62db..c3f6fbc 100644 --- a/md2doc.py +++ b/md2doc.py @@ -365,6 +365,71 @@ def _render_list(doc, node, style: dict, level: int = 0, num_state: dict = None) _render_list(doc, nested, style=style, level=level + 1, num_state=num_state) +def _render_table(doc, node, style: dict): + """Render with header row shading.""" + header_fill = style.get("table_header_fill", "1e3a8a") + + rows = node.find_all("tr") + if not rows: + return + + cols = max(len(row.find_all(["th", "td"])) for row in rows) + table = doc.add_table(rows=len(rows), cols=cols) + table.style = 'Table Grid' + + for i, row in enumerate(rows): + cells = row.find_all(["th", "td"]) + is_header = row.parent.name == "thead" or any(c.name == "th" for c in cells) + for j, cell in enumerate(cells): + if j >= cols: + break + tc = table.rows[i].cells[j] + tc.text = cell.get_text(strip=True) + if is_header: + add_cell_shading(tc, fill=header_fill) + + +def _render_pre(doc, node, style: dict): + """Render
 (code block) — shaded background + monospace font.
+
+    Preserves newlines via add_break() between lines (Word ignores literal \\n in runs).
+    Leading whitespace on each line is preserved as literal spaces (monospace font
+    keeps alignment). Trailing empty line from markdown's final \\n is dropped.
+    """
+    code_fill = style.get("code_fill", "f7f8fa")
+    text = node.get_text()
+    lines = text.split("\n")
+    while lines and lines[-1] == "":
+        lines.pop()
+
+    p = doc.add_paragraph()
+    for i, line in enumerate(lines):
+        if i > 0:
+            p.add_run().add_break()
+        run = p.add_run(line)
+        run.font.name = "Menlo"
+        run.font.size = Pt(8)
+    add_paragraph_shading(p, fill=code_fill)
+
+
+def _render_blockquote(doc, node, style: dict):
+    """Render 
— left indent + italic + shading.""" + quote_fill = style.get("blockquote_fill", "f7f8fa") + text = node.get_text(strip=True) + + p = doc.add_paragraph() + p.paragraph_format.left_indent = Cm(1) + run = p.add_run(text) + run.italic = True + add_paragraph_shading(p, fill=quote_fill) + + +def _render_hr(doc, node, style: dict): + """Render
as empty paragraph with bottom border.""" + p = doc.add_paragraph() + add_paragraph_bottom_border(p, color="d8d8d8", size="6") + + 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_blocks.py b/tests/test_md2doc_blocks.py new file mode 100644 index 0000000..9d37725 --- /dev/null +++ b/tests/test_md2doc_blocks.py @@ -0,0 +1,74 @@ +"""Tests for block-level element renderers.""" +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_table, _render_pre, _render_blockquote, _render_hr + +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def get_doc_xml(doc): + tmp = Path(__file__).parent / "_tmp_blocks.docx" + doc.save(tmp) + try: + with zipfile.ZipFile(tmp) as z: + return ET.fromstring(z.read("word/document.xml")) + finally: + tmp.unlink() + + +def test_table_renders_with_header_shading(): + html = """ +
+ + + + +
NameValue
A1
+ """ + doc = Document() + node = BeautifulSoup(html, "html.parser").find("table") + _render_table(doc, node, style=STYLES["elegant"]) + root = get_doc_xml(doc) + trs = list(root.iter(f"{{{W_NS}}}tr")) + assert len(trs) == 2 + shds = list(root.iter(f"{{{W_NS}}}shd")) + assert len(shds) >= 2 # at least 2 header cells + + +def test_pre_renders_with_shading(): + html = '
print("hello")
' + doc = Document() + node = BeautifulSoup(html, "html.parser").find("pre") + _render_pre(doc, node, style=STYLES["elegant"]) + root = get_doc_xml(doc) + shds = list(root.iter(f"{{{W_NS}}}shd")) + assert len(shds) >= 1 + texts = [t.text for t in root.iter(f"{{{W_NS}}}t")] + assert any('print' in t for t in texts) + + +def test_blockquote_renders_with_indent_and_italic(): + html = "
A quoted passage
" + doc = Document() + node = BeautifulSoup(html, "html.parser").find("blockquote") + _render_blockquote(doc, node, style=STYLES["elegant"]) + root = get_doc_xml(doc) + italic_el = list(root.iter(f"{{{W_NS}}}i")) + assert len(italic_el) >= 1 + indents = list(root.iter(f"{{{W_NS}}}ind")) + assert len(indents) >= 1 + + +def test_hr_renders_as_paragraph_with_bottom_border(): + html = "
" + doc = Document() + node = BeautifulSoup(html, "html.parser").find("hr") + _render_hr(doc, node, style=STYLES["elegant"]) + root = get_doc_xml(doc) + bottoms = list(root.iter(f"{{{W_NS}}}bottom")) + assert len(bottoms) >= 1