SmartPaper / utils /docx_builder.py
GuXiaopeng's picture
R6A-R4-R6 four-language qualitative appendix semantic identity and final DOCX layout gate (#43)
f665ab9
Raw History Blame
11.7 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."""
_UNNUMBERED_REFERENCE_HEADINGS = frozenset({
"references",
"参考文献",
"литература",
"список литературы",
"пайдаланылған әдебиеттер",
})
def _normalize_heading_label(text):
"""Normalize a heading for exact front/back-matter label matching."""
value = " ".join(str(text or "").replace("\u00a0", " ").split()).casefold()
return value.rstrip(" ::")
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):
super().__init__()
self.doc = Document()
self.h_counters = [0] * 6
self.is_first_h1 = True
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()
lower_text = clean_text.lower()
normalized_text = _normalize_heading_label(clean_text)
skip_keywords = APP_CONFIG["blacklists"]["meta_sections"]
exact_reference_heading = normalized_text in _UNNUMBERED_REFERENCE_HEADINGS
if exact_reference_heading or 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 ''
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."
)
renderer = DocxRenderer(
qualitative_appendix_table_ordinal=qualitative_table_ordinal,
semantic_qualitative_appendix=semantic_qualitative_appendix,
)
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