Spaces:
Running
Running
Download utils/docx_builder.py from sqhcn/SmartPaper: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/sqhcn/SmartPaper/resolve/main/utils/docx_builder.py
- Command line
-
hf download hf://spaces/sqhcn/SmartPaper/utils/docx_builder.py
-
curl -L -o docx_builder.py https://huggingface.co/spaces/sqhcn/SmartPaper/resolve/main/utils/docx_builder.py
13.1 kB
| # -*- coding: utf-8 -*- | |
| import io | |
| import mistune | |
| from bs4 import BeautifulSoup | |
| from docx import Document | |
| from docx.shared import Pt, Inches | |
| from docx.enum.section import WD_SECTION, WD_ORIENT | |
| from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT | |
| from docx.oxml import OxmlElement | |
| from docx.oxml.ns import qn | |
| from config import APP_CONFIG | |
| class DocxBuildError(RuntimeError): | |
| """Raised when markdown cannot be rendered into a valid DOCX document.""" | |
| def _manuscript_heading_structure(markdown_content, *, qualitative_appendix=False): | |
| """Derive unnumbered heading roles from Markdown structure, not language. | |
| Production manuscripts have an optional title/front-matter H1 followed by | |
| numbered body H1s and a final References H1. ASQ additionally has a | |
| penultimate Appendix H1. The roles are identified by position and the | |
| existing semantic ASQ packaging flag, so translated wording is irrelevant. | |
| """ | |
| tokens = mistune.create_markdown(renderer="ast", plugins=["table"])(str(markdown_content or "")) | |
| headings = [] | |
| for token_index, token in enumerate(tokens): | |
| if not isinstance(token, dict) or token.get("type") != "heading": | |
| continue | |
| level = int((token.get("attrs") or {}).get("level") or 0) | |
| headings.append((token_index, level)) | |
| h1 = [(token_index, level) for token_index, level in headings if level == 1] | |
| if not h1: | |
| return {"first_h1_is_title": False, "unnumbered_h1_ordinals": set()} | |
| first_h1_is_title = True | |
| unnumbered = set() | |
| if len(h1) >= 2: | |
| unnumbered.add(len(h1)) # final References H1 | |
| if qualitative_appendix and len(h1) >= 3: | |
| unnumbered.add(len(h1) - 1) # ASQ Appendix H1 | |
| return { | |
| "first_h1_is_title": first_h1_is_title, | |
| "unnumbered_h1_ordinals": unnumbered, | |
| } | |
| def _table_column_count(token): | |
| if not isinstance(token, dict) or token.get("type") != "table": | |
| return 0 | |
| for child in token.get("children") or []: | |
| if isinstance(child, dict) and child.get("type") == "table_head": | |
| return len(child.get("children") or []) | |
| return 0 | |
| def _qualitative_appendix_table_ordinal(markdown_content): | |
| """Return the semantic evidence-table ordinal for an ASQ manuscript. | |
| The qualitative Appendix is a structural business object: the unique | |
| six-column Markdown table between the penultimate and final level-1 | |
| headings (Appendix and References in the frozen manuscript architecture). | |
| Reader-facing header wording is intentionally irrelevant so EN/ZH/RU/KK | |
| translations retain the same table identity. | |
| """ | |
| tokens = mistune.create_markdown(renderer="ast", plugins=["table"])(str(markdown_content or "")) | |
| h1_positions = [ | |
| index for index, token in enumerate(tokens) | |
| if isinstance(token, dict) | |
| and token.get("type") == "heading" | |
| and int((token.get("attrs") or {}).get("level") or 0) == 1 | |
| ] | |
| if len(h1_positions) < 2: | |
| return None | |
| appendix_start, references_start = h1_positions[-2], h1_positions[-1] | |
| table_ordinal = -1 | |
| candidates = [] | |
| for index, token in enumerate(tokens): | |
| if not isinstance(token, dict) or token.get("type") != "table": | |
| continue | |
| table_ordinal += 1 | |
| if appendix_start < index < references_start and _table_column_count(token) == 6: | |
| candidates.append(table_ordinal) | |
| if len(candidates) != 1: | |
| return None | |
| return candidates[0] | |
| class DocxRenderer(mistune.HTMLRenderer): | |
| def __init__(self, *, qualitative_appendix_table_ordinal=None, semantic_qualitative_appendix=False, first_h1_is_title=False, unnumbered_h1_ordinals=None): | |
| super().__init__() | |
| self.doc = Document() | |
| self.h_counters = [0] * 6 | |
| self.h1_ordinal = 0 | |
| self.first_h1_is_title = bool(first_h1_is_title) | |
| self.unnumbered_h1_ordinals = set(unnumbered_h1_ordinals or set()) | |
| self.table_ordinal = -1 | |
| self.qualitative_appendix_table_ordinal = qualitative_appendix_table_ordinal | |
| self.semantic_qualitative_appendix = bool(semantic_qualitative_appendix) | |
| 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() | |
| level = int(level or 0) | |
| if level == 1: | |
| self.h1_ordinal += 1 | |
| if (self.first_h1_is_title and self.h1_ordinal == 1) or self.h1_ordinal in self.unnumbered_h1_ordinals: | |
| self.doc.add_heading(clean_text, level=1) | |
| return '' | |
| elif self.first_h1_is_title and self.h1_ordinal == 1: | |
| # Abstract/Keywords and any other front-matter child headings are | |
| # structurally unnumbered regardless of language. | |
| self.doc.add_heading(clean_text, level=level) | |
| return '' | |
| 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 '' | |
| self.table_ordinal += 1 | |
| header_labels = [cell.get_text(strip=True) for cell in rows[0].find_all(['td', 'th'])] | |
| qualitative_headers = [ | |
| 'Higher-order Theme', 'Empirical Concept', 'Process Stage', | |
| 'Case/Actor', 'Evidence Role', 'Representative Quote', | |
| ] | |
| if self.semantic_qualitative_appendix: | |
| qualitative_evidence_table = ( | |
| self.qualitative_appendix_table_ordinal is not None | |
| and self.table_ordinal == self.qualitative_appendix_table_ordinal | |
| ) | |
| else: | |
| # Legacy direct-render compatibility. Production ASQ packaging uses | |
| # the semantic structural identity above and never depends on labels. | |
| qualitative_evidence_table = header_labels == qualitative_headers | |
| if qualitative_evidence_table: | |
| previous = self.doc.sections[-1] | |
| landscape = self.doc.add_section(WD_SECTION.NEW_PAGE) | |
| landscape.orientation = WD_ORIENT.LANDSCAPE | |
| landscape.page_width, landscape.page_height = previous.page_height, previous.page_width | |
| table = self.doc.add_table(rows=len(rows), cols=max_cols) | |
| try: | |
| table.style = 'Table Grid' | |
| except: | |
| pass | |
| if qualitative_evidence_table: | |
| table.autofit = False | |
| widths = [1.15, 1.10, 0.85, 0.85, 0.75, 3.80] if qualitative_evidence_table else [] | |
| if qualitative_evidence_table: | |
| for grid_col, width in zip(table._tbl.tblGrid.gridCol_lst, widths): | |
| grid_col.set(qn('w:w'), str(int(width * 1440))) | |
| for i, row in enumerate(rows): | |
| for j, cell in enumerate(row.find_all(['td', 'th'])): | |
| if j >= max_cols: | |
| continue | |
| target = table.cell(i, j) | |
| target.text = cell.get_text(strip=True) | |
| if qualitative_evidence_table: | |
| target.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.TOP | |
| target.width = Inches(widths[j]) | |
| tc_pr = target._tc.get_or_add_tcPr() | |
| tc_w = tc_pr.first_child_found_in('w:tcW') | |
| if tc_w is not None: | |
| tc_w.set(qn('w:type'), 'dxa') | |
| tc_w.set(qn('w:w'), str(int(widths[j] * 1440))) | |
| for paragraph in target.paragraphs: | |
| paragraph.paragraph_format.space_before = Pt(0) | |
| paragraph.paragraph_format.space_after = Pt(0) | |
| for run in paragraph.runs: | |
| run.font.size = Pt(9) | |
| if qualitative_evidence_table and table.rows: | |
| header_pr = table.rows[0]._tr.get_or_add_trPr() | |
| header = OxmlElement('w:tblHeader') | |
| header.set(qn('w:val'), 'true') | |
| header_pr.append(header) | |
| for row in table.rows: | |
| tr_pr = row._tr.get_or_add_trPr() | |
| cant_split = OxmlElement('w:cantSplit') | |
| tr_pr.append(cant_split) | |
| self.doc.add_paragraph() | |
| if qualitative_evidence_table: | |
| current = self.doc.sections[-1] | |
| portrait = self.doc.add_section(WD_SECTION.NEW_PAGE) | |
| portrait.orientation = WD_ORIENT.PORTRAIT | |
| portrait.page_width, portrait.page_height = current.page_height, current.page_width | |
| 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, *, qualitative_appendix=False): | |
| """Render markdown strictly; never disguise renderer failures as success. | |
| ``qualitative_appendix=True`` is an internal semantic contract supplied by | |
| ASQ packaging. It binds the deterministic evidence table by manuscript | |
| structure rather than reader-facing English/Chinese/Russian/Kazakh labels. | |
| """ | |
| try: | |
| semantic_qualitative_appendix = bool(qualitative_appendix) | |
| qualitative_table_ordinal = None | |
| if semantic_qualitative_appendix: | |
| qualitative_table_ordinal = _qualitative_appendix_table_ordinal(markdown_content) | |
| if qualitative_table_ordinal is None: | |
| raise DocxBuildError( | |
| "Qualitative Appendix semantic table identity is missing or ambiguous." | |
| ) | |
| heading_structure = _manuscript_heading_structure( | |
| markdown_content, qualitative_appendix=semantic_qualitative_appendix | |
| ) | |
| renderer = DocxRenderer( | |
| qualitative_appendix_table_ordinal=qualitative_table_ordinal, | |
| semantic_qualitative_appendix=semantic_qualitative_appendix, | |
| first_h1_is_title=heading_structure["first_h1_is_title"], | |
| unnumbered_h1_ordinals=heading_structure["unnumbered_h1_ordinals"], | |
| ) | |
| 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 | |