Spaces:
Running
Running
Download utils/docx_builder.py from sqhcn/SmartPaper: direct link, hf CLI and curl.
- Browser
- Download file 5.13 kB
-
https://huggingface.co/spaces/sqhcn/SmartPaper/resolve/refs%2Fpr%2F2/utils/docx_builder.py
- Command line
-
hf download hf://spaces/sqhcn/SmartPaper@refs/pr/2/utils/docx_builder.py
-
curl -L -o docx_builder.py https://huggingface.co/spaces/sqhcn/SmartPaper/resolve/refs%2Fpr%2F2/utils/docx_builder.py
5.13 kB
| # -*- 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 | |