Spaces:
Running
Running
File size: 13,100 Bytes
3913f6f e10b390 939527c 3913f6f 6ce52dc 90a5217 b15f880 90a5217 b15f880 f665ab9 3913f6f 90a5217 3913f6f 90a5217 f665ab9 3913f6f 939527c e0a8d80 3913f6f e0a8d80 3913f6f e0a8d80 3913f6f 939527c e0a8d80 3913f6f 90a5217 3913f6f e0a8d80 2e3717c 69fee5e e0a8d80 3913f6f e0a8d80 67cd891 3913f6f e0a8d80 3913f6f e0a8d80 3913f6f 67cd891 939527c 3913f6f e0a8d80 3913f6f e0a8d80 3913f6f e0a8d80 3913f6f e10b390 e0a8d80 e10b390 3913f6f e10b390 e0a8d80 e10b390 f665ab9 e10b390 f665ab9 e10b390 e0a8d80 e10b390 e0a8d80 e10b390 e0a8d80 e10b390 3913f6f 67cd891 e10b390 3913f6f e10b390 3913f6f e0a8d80 3913f6f e0a8d80 3913f6f f665ab9 3913f6f f665ab9 90a5217 f665ab9 90a5217 f665ab9 6ce52dc 3913f6f 6ce52dc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | # -*- 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
|