# -*- coding: utf-8 -*- import io import mistune from bs4 import BeautifulSoup from docx import Document from docx.shared import Pt from config import APP_CONFIG class DocxBuildError(RuntimeError): """Raised when markdown cannot be rendered into a valid DOCX document.""" class DocxRenderer(mistune.HTMLRenderer): def __init__(self): super().__init__() self.doc = Document() self.h_counters = [0] * 6 self.is_first_h1 = True try: style = self.doc.styles['Normal'] style.font.name = APP_CONFIG["docx_styles"]["font_name"] style.font.size = Pt(APP_CONFIG["docx_styles"]["font_size_pt"]) except: pass def _render_rich_text(self, p, html_text): if not html_text.strip(): return soup = BeautifulSoup(html_text, 'html.parser') for element in soup.children: if isinstance(element, str): p.add_run(element) elif element.name in ['strong', 'b']: p.add_run(element.get_text()).bold = True elif element.name in ['em', 'i']: p.add_run(element.get_text()).italic = True elif element.name == 'code': run = p.add_run(element.get_text()) run.font.name = APP_CONFIG["docx_styles"]["code_font"] run.font.size = Pt(APP_CONFIG["docx_styles"]["code_size_pt"]) else: p.add_run(element.get_text()) def heading(self, text, level): clean_text = BeautifulSoup(text, 'html.parser').get_text().strip() lower_text = clean_text.lower() skip_keywords = APP_CONFIG["blacklists"]["meta_sections"] if any(kw in lower_text for kw in skip_keywords) or (level == 1 and self.is_first_h1): if level == 1: self.is_first_h1 = False self.doc.add_heading(clean_text, level=1) return '' if level == 1: self.is_first_h1 = False idx = level - 1 if 0 <= idx < 6: for j in range(idx): if self.h_counters[j] == 0: self.h_counters[j] = 1 self.h_counters[idx] += 1 for i in range(idx + 1, 6): self.h_counters[i] = 0 num_str = ".".join(map(str, self.h_counters[0:idx+1])) self.doc.add_heading(f"{num_str} {clean_text}", level=level) else: self.doc.add_heading(clean_text, level=level) return '' def paragraph(self, text): if text.strip(): self._render_rich_text(self.doc.add_paragraph(), text) return '' def block_code(self, code, info=None): run = self.doc.add_paragraph().add_run(code) run.font.name = APP_CONFIG["docx_styles"]["code_font"] run.font.size = Pt(APP_CONFIG["docx_styles"]["code_size_pt"]) return '' def block_quote(self, text): p = self.doc.add_paragraph() self._render_rich_text(p, text) try: p.style = 'Intense Quote' if 'Intense Quote' in self.doc.styles else 'Normal' except: pass return '' def list(self, text, ordered, **attrs): return text def list_item(self, text): if text.strip(): p = self.doc.add_paragraph() p.style = 'List Bullet' self._render_rich_text(p, text) return '' def table(self, text): soup = BeautifulSoup(text, 'html.parser') rows = soup.find_all('tr') if not rows: return '' max_cols = max(len(row.find_all(['td', 'th'])) for row in rows) if max_cols == 0: return '' table = self.doc.add_table(rows=len(rows), cols=max_cols) try: table.style = 'Table Grid' except: pass for i, row in enumerate(rows): for j, cell in enumerate(row.find_all(['td', 'th'])): if j < max_cols: table.cell(i, j).text = cell.get_text(strip=True) self.doc.add_paragraph() return '' def thematic_break(self): self.doc.add_paragraph('_' * 50) return '' def image(self, src, alt="", title=None): self.doc.add_paragraph(f"[Image: {alt or src}]") return '' def text_to_docx(markdown_content): """Render markdown strictly; never disguise renderer failures as success.""" try: renderer = DocxRenderer() mistune.create_markdown(renderer=renderer, plugins=['table'])(str(markdown_content or '')) doc = renderer.doc if not doc.paragraphs and not doc.tables: raise DocxBuildError("DOCX renderer produced no document content.") stream = io.BytesIO() doc.save(stream) stream.seek(0) return stream except DocxBuildError: raise except Exception as exc: raise DocxBuildError(f"DOCX rendering failed: {exc}") from exc