# -*- 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