feat(md2doc): table, pre, blockquote, hr renderers

This commit is contained in:
2026-07-28 10:27:02 +03:00
parent 5fd05ba2be
commit 21b3a7a77d
2 changed files with 139 additions and 0 deletions
+65
View File
@@ -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 <table> 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 <pre> (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 <blockquote> — 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 <hr> 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.
+74
View File
@@ -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 = """
<table>
<thead><tr><th>Name</th><th>Value</th></tr></thead>
<tbody>
<tr><td>A</td><td>1</td></tr>
</tbody>
</table>
"""
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 = '<pre><code>print("hello")</code></pre>'
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 = "<blockquote>A quoted passage</blockquote>"
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 = "<hr/>"
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