feat(md2doc): table, pre, blockquote, hr renderers
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user