feat(md2doc): heading and paragraph renderers with inline formatting
This commit is contained in:
@@ -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 <img> 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 <code> 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 <h1>-<h4> 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 <p> as a Word paragraph, preserving inline formatting (strong, em, code, a).
|
||||
|
||||
Also handles <img> 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user