FaizanMirZa77/FormatX
0
1"""2document_assembler.py3─────────────────────4Builds the final formatted document from:5 - template_bytes : DOCX template (provides page layout + style definitions)6 - raw_blocks : structured content extracted from raw document7 - style_mappings : AI decisions (which style → which block)8 - profile : template profile (RTL, margins, capabilities)9 10Fixes applied:11 - Explicit font/size stamped on every run (resolves inheritance issues in12 LibreOffice PDF conversion and non-Word viewers)13 - TOC blocks → real Word TOC field (auto-regenerates on open)14 - TOF blocks → real Word Table of Figures field15 - Figure placeholder includes dimensions and caption16 - Alignment always applied explicitly (was missing for non-preserved blocks)17 - Page breaks inserted between blocks that started new pages18 - TOC/TOF flags are local variables (was module-level globals — unsafe under19 concurrent FastAPI requests)20 - success_count only increments when something is actually written (was21 incrementing for skipped duplicate TOC/TOF entries, causing spurious breaks)22"""23 24import io25import os26import copy27import logging28import subprocess29import tempfile30from docx import Document31from docx.oxml.ns import qn32from docx.oxml import OxmlElement33from docx.shared import Pt, Emu, RGBColor, Cm34from docx.enum.text import WD_ALIGN_PARAGRAPH35from models import RawBlock, BlockType, StyleMapping, TemplateProfile36from docx_utils import _apply_rtl_to_paragraph, _apply_rtl_to_run37from template_parser import (38 _read_theme_fonts,39 _resolve_theme_font,40 _xml_alignment_from_style_chain,41 _role_default_alignment,42)43 44log = logging.getLogger("formatx.assembler")45 46 47_ALIGNMENT_MAP = {48 "left": WD_ALIGN_PARAGRAPH.LEFT,49 "right": WD_ALIGN_PARAGRAPH.RIGHT,50 "center": WD_ALIGN_PARAGRAPH.CENTER,51 "justify": WD_ALIGN_PARAGRAPH.JUSTIFY,52}53 54 55# ── Style helpers ─────────────────────────────────────────────────────────────56 57def _get_available_styles(doc: Document) -> set[str]:58 return {s.name for s in doc.styles if s.type.name == "PARAGRAPH"}59 60 61def _lookup_style(doc: Document, style_name: str):62 """63 Look up a style object by name, preferring the instance with actual64 formatting over empty stub definitions.65 66 Some DOCX files have duplicate style entries (one empty, one with content).67 Always taking the first match can return the empty stub, causing alignment68 and font to resolve to None when the real definition has values.69 Returns the last instance that has pPr content, or the last instance overall.70 """71 candidates = [s for s in doc.styles if s.name == style_name]72 if not candidates:73 return None74 if len(candidates) == 1:75 return candidates[0]76 for s in reversed(candidates):77 try:78 ppr = s.element.find(qn("w:pPr"))79 if ppr is not None and len(ppr) > 0:80 return s81 except Exception:82 pass83 return candidates[-1]84 85 86def _safe_style(style_name: str, available: set[str]) -> str:87 if style_name in available:88 return style_name89 lower = style_name.lower()90 for s in available:91 if s.lower() == lower:92 return s93 for s in available:94 if lower in s.lower() or s.lower() in lower:95 return s96 return "Normal" if "Normal" in available else next(iter(available), "Normal")97 98 99def _get_best_table_style(doc: Document) -> str:100 available = {s.name for s in doc.styles if s.type.name == "TABLE"}101 for candidate in ["Table Grid", "Light Shading", "Light List", "Medium Shading 1"]:102 if candidate in available:103 return candidate104 return "Table Grid"105 106 107def _resolve_style_spacing(108 doc: Document, style_name: str109) -> tuple[object | None, object | None, object | None]:110 """111 Walk the style inheritance chain to find the first explicitly set112 space_before, space_after, and alignment values.113 Returns (space_before, space_after, alignment) — each may be None.114 """115 visited = set()116 space_before = None117 space_after = None118 alignment = None119 120 try:121 style = _lookup_style(doc, style_name)122 if style is None:123 return None, None, None124 except Exception:125 return None, None, None126 127 while style is not None:128 sid = getattr(style, "style_id", None) or style.name129 if sid in visited:130 break131 visited.add(sid)132 try:133 pf = style.paragraph_format134 if space_before is None and pf.space_before is not None:135 space_before = pf.space_before136 if space_after is None and pf.space_after is not None:137 space_after = pf.space_after138 if alignment is None and pf.alignment is not None:139 alignment = pf.alignment140 except Exception:141 pass142 style = style.base_style143 144 # Alignment fallback: read w:jc directly from XML if pf.alignment gave nothing145 if alignment is None:146 try:147 style_obj = _lookup_style(doc, style_name)148 if style_obj is not None:149 alignment_str = _xml_alignment_from_style_chain(style_obj, doc)150 if alignment_str is not None:151 alignment = _ALIGNMENT_MAP.get(alignment_str)152 except Exception:153 pass154 155 return space_before, space_after, alignment156 157 158def _resolve_style_line_spacing(doc: Document, style_name: str) -> float | None:159 """160 Walk the style inheritance chain and return the first explicitly set161 line spacing multiplier (e.g. 1.5 for 1.5× spacing), or None if no162 explicit value is found anywhere in the chain.163 164 Reads w:pPr/w:spacing/@w:line directly from XML (only when @w:lineRule=="auto").165 We avoid pf.line_spacing because it returns an EMU-scale Length object whose166 integer value cannot be reliably divided by 240 to get a multiplier.167 """168 visited = set()169 try:170 style = _lookup_style(doc, style_name)171 if style is None:172 return None173 except Exception:174 return None175 176 while style is not None:177 sid = getattr(style, "style_id", None) or style.name178 if sid in visited:179 break180 visited.add(sid)181 182 # Read w:pPr/w:spacing/@w:line directly — only valid for lineRule="auto"183 try:184 ppr_el = style.element.find(qn("w:pPr"))185 if ppr_el is not None:186 spacing_el = ppr_el.find(qn("w:spacing"))187 if spacing_el is not None:188 line_rule = spacing_el.get(qn("w:lineRule"), "")189 if line_rule == "auto":190 line_val = spacing_el.get(qn("w:line"))191 if line_val:192 return round(int(line_val) / 240, 4)193 except Exception:194 pass195 196 style = style.base_style197 198 return None199 200 201def _resolve_style_run_props(202 doc: Document, style_name: str, theme_fonts: dict | None = None203) -> tuple[str | None, float | None, bool | None, bool | None, str | None]:204 """205 Walk the style inheritance chain and return the first explicitly set values for:206 (font_name, font_size_pt, bold, italic, color_hex)207 208 theme_fonts: dict from _read_theme_fonts() — resolves +mjLatin/+mnLatin pointers.209 210 bold/italic are tri-state: True, False, or None (not set anywhere in chain).211 color_hex is "#RRGGBB" or None.212 213 Falls back to w:docDefaults for font_name and font_size if the chain has nothing.214 This gives LibreOffice and non-Word viewers fully explicit run properties so215 they don't have to walk the inheritance chain themselves.216 """217 if theme_fonts is None:218 theme_fonts = {"major": None, "minor": None}219 220 visited = set()221 font_name = None222 font_size = None223 bold = None224 italic = None225 color_hex = None226 227 try:228 style = _lookup_style(doc, style_name)229 if style is None:230 return None, None, None, None, None231 except Exception:232 return None, None, None, None, None233 234 while style is not None:235 sid = getattr(style, "style_id", None) or style.name236 if sid in visited:237 break238 visited.add(sid)239 try:240 f = style.font241 if font_name is None:242 raw = f.name243 # python-docx may return None when the only font set is a theme244 # pointer; fall back to reading the raw XML attribute directly.245 if raw is None:246 try:247 rpr = style.element.find(qn("w:rPr"))248 if rpr is not None:249 rf = rpr.find(qn("w:rFonts"))250 if rf is not None:251 raw = (rf.get(qn("w:ascii"))252 or rf.get(qn("w:hAnsi"))253 or rf.get(qn("w:cs")))254 except Exception:255 pass256 resolved = _resolve_theme_font(raw, theme_fonts)257 if resolved:258 font_name = resolved259 if font_size is None and f.size:260 font_size = f.size.pt261 if bold is None and f.bold is not None:262 bold = f.bold263 if italic is None and f.italic is not None:264 italic = f.italic265 if color_hex is None:266 try:267 if f.color and f.color.rgb:268 color_hex = f"#{f.color.rgb}"269 except Exception:270 pass271 except Exception:272 pass273 style = style.base_style274 275 # Fall back to document-level defaults (w:docDefaults → w:rPrDefault → w:rPr)276 if font_name is None or font_size is None:277 try:278 WNS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"279 doc_defaults = doc.element.find(f".//{{{WNS}}}docDefaults")280 if doc_defaults is not None:281 rpr_default = doc_defaults.find(f".//{{{WNS}}}rPrDefault")282 if rpr_default is not None:283 rpr = rpr_default.find(f"{{{WNS}}}rPr")284 if rpr is not None:285 fonts_el = rpr.find(f"{{{WNS}}}rFonts")286 sz_el = rpr.find(f"{{{WNS}}}sz")287 if fonts_el is not None and font_name is None:288 font_name = (fonts_el.get(f"{{{WNS}}}ascii")289 or fonts_el.get(f"{{{WNS}}}hAnsi")290 or fonts_el.get(f"{{{WNS}}}cs"))291 if sz_el is not None and font_size is None:292 val = sz_el.get(f"{{{WNS}}}val")293 if val:294 font_size = int(val) / 2 # half-points → points295 except Exception:296 pass297 298 return font_name, font_size, bold, italic, color_hex299 300 301# ── Body clearing ─────────────────────────────────────────────────────────────302 303def _clear_body(doc: Document) -> None:304 body = doc.element.body305 to_remove = [child for child in body if child.tag != qn("w:sectPr")]306 for el in to_remove:307 body.remove(el)308 309 310def _hdr_ftr_has_relationships(element) -> bool:311 """312 Returns True if a header/footer XML element contains any relationship313 references (r:embed, r:id) — i.e. images or hyperlinks.314 These relationship IDs are part-scoped and won't resolve in the output315 document after a deep-copy, so we skip copying such parts to avoid316 broken image references in the output.317 """318 REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"319 for el in element.iter():320 for attr in el.attrib:321 if f"{{{REL_NS}}}" in attr:322 return True323 return False324 325 326def _copy_hdr_ftr_part(src_part, dst_part, label: str) -> None:327 """328 Copy XML content from one header/footer part to another.329 330 Text, page-number fields, and hyperlinks are always copied — they are safe331 to deep-copy because they carry no unresolvable part references.332 333 Image/drawing elements (<w:drawing>) are stripped after copying because334 their relationship IDs (r:embed) point to image parts that don't exist in335 the output document, which would produce broken references in Word.336 337 After copying, replaces any literal [PAGE] / [NUMPAGES] placeholder text338 with real Word PAGE / NUMPAGES field codes so page numbers render correctly.339 """340 import copy341 if src_part.is_linked_to_previous:342 return343 344 has_images = _hdr_ftr_has_relationships(src_part._element)345 346 # Always copy — text, fields, and hyperlinks transfer safely.347 dst_body = dst_part._element348 for child in list(dst_body):349 dst_body.remove(child)350 for child in src_part._element:351 dst_body.append(copy.deepcopy(child))352 353 if has_images:354 # Strip only drawing elements — their image part references won't355 # resolve in the output document after a deep-copy.356 WNS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"357 drawings = dst_body.findall(f".//{{{WNS}}}drawing")358 for drawing in drawings:359 parent = drawing.getparent()360 if parent is not None:361 parent.remove(drawing)362 log.warning(363 f"[ASSEMBLER] {label}: {len(drawings)} image(s) stripped "364 f"(relationship IDs cannot be remapped after deep-copy). "365 f"Text content preserved."366 )367 368 # Replace literal [PAGE] / [NUMPAGES] placeholders with real Word fields369 _replace_page_placeholders(dst_part)370 371 372def _make_field_run(para_el, field_name: str):373 """374 Build a sequence of three XML elements that form a Word simple field:375 <w:r><w:fldChar w:fldCharType="begin"/></w:r>376 <w:r><w:instrText> PAGE </w:instrText></w:r>377 <w:r><w:fldChar w:fldCharType="end"/></w:r>378 Returns them as a list to be inserted into the paragraph element.379 """380 WNS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"381 382 def _run_with(child_el):383 r = OxmlElement("w:r")384 r.append(child_el)385 return r386 387 begin = OxmlElement("w:fldChar")388 begin.set(qn("w:fldCharType"), "begin")389 390 instr = OxmlElement("w:instrText")391 instr.set(qn("xml:space"), "preserve")392 instr.text = f" {field_name} "393 394 end = OxmlElement("w:fldChar")395 end.set(qn("w:fldCharType"), "end")396 397 return [_run_with(begin), _run_with(instr), _run_with(end)]398 399 400def _replace_page_placeholders(hdr_ftr_part) -> None:401 """402 Scan all paragraphs in a header/footer part.403 For any run whose text contains [PAGE] or [NUMPAGES], split the run404 around the placeholder and insert real Word field codes in its place.405 Handles mixed text like "Page [PAGE] of [NUMPAGES]" correctly.406 """407 import re as _re408 WNS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"409 410 _PLACEHOLDER_RE = _re.compile(r"(\[PAGE\]|\[NUMPAGES\])")411 _FIELD_MAP = {"[PAGE]": "PAGE", "[NUMPAGES]": "NUMPAGES"}412 413 replaced = 0414 for para in hdr_ftr_part.paragraphs:415 para_el = para._element416 runs = para_el.findall(f"{{{WNS}}}r")417 for run_el in runs:418 t_el = run_el.find(f"{{{WNS}}}t")419 if t_el is None or not t_el.text:420 continue421 text = t_el.text422 if not _PLACEHOLDER_RE.search(text):423 continue424 425 # Split text around placeholders and build replacement nodes426 parts = _PLACEHOLDER_RE.split(text)427 # parts alternates: [literal, placeholder, literal, placeholder, ...]428 new_nodes = []429 for part in parts:430 if part in _FIELD_MAP:431 new_nodes.extend(_make_field_run(para_el, _FIELD_MAP[part]))432 replaced += 1433 elif part:434 # Plain text run — clone the original run's rPr if present435 import copy as _copy436 new_r = OxmlElement("w:r")437 rpr = run_el.find(f"{{{WNS}}}rPr")438 if rpr is not None:439 new_r.append(_copy.deepcopy(rpr))440 new_t = OxmlElement("w:t")441 new_t.set(qn("xml:space"), "preserve")442 new_t.text = part443 new_r.append(new_t)444 new_nodes.append(new_r)445 446 # Insert new nodes before the original run, then remove it447 parent = run_el.getparent()448 idx = list(parent).index(run_el)449 for offset, node in enumerate(new_nodes):450 parent.insert(idx + offset, node)451 parent.remove(run_el)452 453 if replaced:454 log.info(f"[ASSEMBLER] Replaced {replaced} page placeholder(s) with Word field codes")455 456 457def _copy_headers_footers(src_doc: Document, dst_doc: Document) -> None:458 """459 Copy header and footer XML from the template document into the output document.460 Iterates over all sections and copies linked header/footer parts.461 462 NOTE: Headers/footers containing images or hyperlinks are intentionally463 skipped — their relationship IDs are part-scoped and cannot be safely464 remapped via deep-copy alone. Text-only headers/footers copy correctly.465 """466 try:467 src_sections = src_doc.sections468 dst_sections = dst_doc.sections469 470 for i, (src_sec, dst_sec) in enumerate(zip(src_sections, dst_sections)):471 sec_label = f"section {i + 1}"472 473 # ── Headers ───────────────────────────────────────────474 try:475 _copy_hdr_ftr_part(src_sec.header, dst_sec.header, f"Default header ({sec_label})")476 except Exception as e:477 log.debug(f"[ASSEMBLER] Default header copy failed: {e}")478 479 try:480 src_hdr = src_sec.first_page_header481 dst_hdr = dst_sec.first_page_header482 if src_hdr and dst_hdr:483 _copy_hdr_ftr_part(src_hdr, dst_hdr, f"First page header ({sec_label})")484 except Exception as e:485 log.debug(f"[ASSEMBLER] First page header copy failed: {e}")486 487 try:488 src_hdr = src_sec.even_page_header489 dst_hdr = dst_sec.even_page_header490 if src_hdr and dst_hdr:491 _copy_hdr_ftr_part(src_hdr, dst_hdr, f"Even page header ({sec_label})")492 except Exception as e:493 log.debug(f"[ASSEMBLER] Even page header copy failed: {e}")494 495 # ── Footers ───────────────────────────────────────────496 try:497 _copy_hdr_ftr_part(src_sec.footer, dst_sec.footer, f"Default footer ({sec_label})")498 except Exception as e:499 log.debug(f"[ASSEMBLER] Default footer copy failed: {e}")500 501 try:502 src_ftr = src_sec.first_page_footer503 dst_ftr = dst_sec.first_page_footer504 if src_ftr and dst_ftr:505 _copy_hdr_ftr_part(src_ftr, dst_ftr, f"First page footer ({sec_label})")506 except Exception as e:507 log.debug(f"[ASSEMBLER] First page footer copy failed: {e}")508 509 try:510 src_ftr = src_sec.even_page_footer511 dst_ftr = dst_sec.even_page_footer512 if src_ftr and dst_ftr:513 _copy_hdr_ftr_part(src_ftr, dst_ftr, f"Even page footer ({sec_label})")514 except Exception as e:515 log.debug(f"[ASSEMBLER] Even page footer copy failed: {e}")516 517 # ── sectPr flags ──────────────────────────────────────518 # Copy w:titlePg and w:evenAndOddHeaders from the template's sectPr519 # to the output's sectPr. Without w:titlePg, Word ignores the520 # first-page header XML part entirely (Bug 2).521 for flag_name in ("w:titlePg", "w:evenAndOddHeaders"):522 try:523 src_sectpr = src_sec._sectPr524 dst_sectpr = dst_sec._sectPr525 src_flag = src_sectpr.find(qn(flag_name))526 if src_flag is None:527 continue # flag absent in template — do not add it528 # Idempotent write: remove any existing copy first529 existing = dst_sectpr.find(qn(flag_name))530 if existing is not None:531 dst_sectpr.remove(existing)532 dst_sectpr.append(copy.deepcopy(src_flag))533 log.debug(534 f"[ASSEMBLER] Copied {flag_name} from template "535 f"sectPr to output sectPr ({sec_label})"536 )537 except Exception as e:538 log.debug(539 f"[ASSEMBLER] {flag_name} copy failed ({sec_label}): {e}"540 )541 542 except Exception as e:543 log.warning(f"[ASSEMBLER] Header/footer copy failed: {e}")544 545 546def _apply_margins(doc: Document, profile: TemplateProfile) -> None:547 """548 Write TemplateProfile margin values to every section of the output document.549 550 Guard: if any of the four margin fields is None, log a debug message and551 return immediately — the existing section margins are left untouched.552 """553 if any(v is None for v in (554 profile.margin_top,555 profile.margin_bottom,556 profile.margin_left,557 profile.margin_right,558 )):559 log.debug("[ASSEMBLER] _apply_margins: one or more margin fields is None — skipping")560 return561 562 for section in doc.sections:563 section.top_margin = Cm(profile.margin_top)564 section.bottom_margin = Cm(profile.margin_bottom)565 section.left_margin = Cm(profile.margin_left)566 section.right_margin = Cm(profile.margin_right)567 568 log.debug(569 "[ASSEMBLER] _apply_margins: set margins "570 f"top={profile.margin_top} bottom={profile.margin_bottom} "571 f"left={profile.margin_left} right={profile.margin_right} cm"572 )573 574 575def _apply_line_spacing(para, multiplier: float) -> None:576 """577 Apply a line spacing multiplier to a paragraph's w:pPr/w:spacing XML.578 multiplier: e.g. 1.5 → w:line=360, w:lineRule="auto"579 Guard: caller must ensure multiplier is not None and > 0.580 """581 ppr = para._element.find(qn("w:pPr"))582 if ppr is None:583 ppr = OxmlElement("w:pPr")584 para._element.insert(0, ppr)585 spacing = ppr.find(qn("w:spacing"))586 if spacing is None:587 spacing = OxmlElement("w:spacing")588 ppr.append(spacing)589 line_val = int(multiplier * 240)590 spacing.set(qn("w:line"), str(line_val))591 spacing.set(qn("w:lineRule"), "auto")592 593 594# ── Page break insertion ──────────────────────────────────────────────────────595 596def _insert_page_break(doc: Document) -> None:597 """Insert an explicit page break as a standalone paragraph with no spacing."""598 # Use "No Spacing" style if available to avoid Normal's space_after gap599 available_styles = {s.name for s in doc.styles if s.type.name == "PARAGRAPH"}600 style = "No Spacing" if "No Spacing" in available_styles else "Normal"601 para = doc.add_paragraph(style=style)602 pf = para.paragraph_format603 pf.space_before = Pt(0)604 pf.space_after = Pt(0)605 run = para.add_run()606 br = OxmlElement("w:br")607 br.set(qn("w:type"), "page")608 run._element.append(br)609 # Explicitly zero out spacing via XML to override any style inheritance610 ppr = para._element.find(qn("w:pPr"))611 if ppr is None:612 ppr = OxmlElement("w:pPr")613 para._element.insert(0, ppr)614 spacing = ppr.find(qn("w:spacing"))615 if spacing is None:616 spacing = OxmlElement("w:spacing")617 ppr.append(spacing)618 spacing.set(qn("w:before"), "0")619 spacing.set(qn("w:after"), "0")620 621 622# ── TOC / TOF field insertion ─────────────────────────────────────────────────623 624def _insert_toc_field(doc: Document, available: set[str]) -> None:625 """626 Insert a Word TOC field that auto-regenerates when the document is opened.627 Uses heading levels 1-3.628 """629 try:630 style = "Normal" if "Normal" in available else next(iter(available), "Normal")631 para = doc.add_paragraph(style=style)632 run = para.add_run()633 fld = OxmlElement("w:fldChar")634 fld.set(qn("w:fldCharType"), "begin")635 run._element.append(fld)636 637 run2 = para.add_run()638 instr = OxmlElement("w:instrText")639 instr.set(qn("xml:space"), "preserve")640 instr.text = ' TOC \\o "1-3" \\h \\z \\u '641 run2._element.append(instr)642 643 run3 = para.add_run()644 fld2 = OxmlElement("w:fldChar")645 fld2.set(qn("w:fldCharType"), "separate")646 run3._element.append(fld2)647 648 run4 = para.add_run()649 run4.text = "[Press Ctrl+A then F9 in Microsoft Word to generate the Table of Contents]"650 run4.italic = True651 652 run5 = para.add_run()653 fld3 = OxmlElement("w:fldChar")654 fld3.set(qn("w:fldCharType"), "end")655 run5._element.append(fld3)656 657 log.debug("[ASSEMBLER] TOC field inserted")658 except Exception as e:659 log.warning(f"[ASSEMBLER] TOC field insertion failed: {e}")660 try:661 p = doc.add_paragraph(style="Normal")662 p.add_run("[Press Ctrl+A then F9 in Microsoft Word to generate the Table of Contents]").italic = True663 except Exception:664 pass665 666 667def _insert_tof_field(doc: Document, available: set[str]) -> None:668 """Insert a Word Table of Figures field."""669 try:670 style = "Normal" if "Normal" in available else next(iter(available), "Normal")671 para = doc.add_paragraph(style=style)672 run = para.add_run()673 fld = OxmlElement("w:fldChar")674 fld.set(qn("w:fldCharType"), "begin")675 run._element.append(fld)676 677 run2 = para.add_run()678 instr = OxmlElement("w:instrText")679 instr.set(qn("xml:space"), "preserve")680 instr.text = ' TOC \\h \\z \\c "Figure" '681 run2._element.append(instr)682 683 run3 = para.add_run()684 fld2 = OxmlElement("w:fldChar")685 fld2.set(qn("w:fldCharType"), "separate")686 run3._element.append(fld2)687 688 run4 = para.add_run()689 run4.text = "[Press Ctrl+A then F9 in Microsoft Word to generate the Table of Figures]"690 run4.italic = True691 692 run5 = para.add_run()693 fld3 = OxmlElement("w:fldChar")694 fld3.set(qn("w:fldCharType"), "end")695 run5._element.append(fld3)696 697 log.debug("[ASSEMBLER] TOF field inserted")698 except Exception as e:699 log.warning(f"[ASSEMBLER] TOF field insertion failed: {e}")700 try:701 p = doc.add_paragraph(style="Normal")702 p.add_run("[Press Ctrl+A then F9 in Microsoft Word to generate the Table of Figures]").italic = True703 except Exception:704 pass705 706 707# ── Paragraph insertion ───────────────────────────────────────────────────────708 709# Matches the same text-pattern list markers as document_extractor._TEXT_LIST_RE710import re as _re_module711_LIST_MARKER_STRIP_RE = _re_module.compile(712 r"^\s*(?:"713 r"[\u2022\u2023\u2024\u2025\u2043\u25AA\u25AB\u25CF\u25CB\u25E6\u00B7]\s*"714 r"|\u2013\s*|\u2014\s*"715 r"|[-\*\+]\s+"716 r"|\d{1,2}[\.\)]\s+"717 r"|[a-zA-Z][\.\)]\s+"718 r"|\(\d{1,2}\)\s+"719 r"|\([a-zA-Z]\)\s+"720 r"|[ivxlIVXL]{1,4}[\.\)]\s+"721 r")"722)723 724 725# Detects numbered list prefixes: "1.", "1)", "(1)", "a.", "a)", "i.", "iv)" etc.726_NUMBERED_PREFIX_RE = _re_module.compile(727 r"^\s*(?:\d{1,2}[\.\)]|\([0-9]+\)|[a-zA-Z][\.\)]|\([a-zA-Z]\)|[ivxlIVXL]{1,4}[\.\)])\s"728)729_BULLET_PREFIX_RE = _re_module.compile(730 r"^\s*(?:[\u2022\u2023\u2024\u2025\u2043\u25AA\u25AB\u25CF\u25CB\u25E6\u00B7]"731 r"|\u2013|\u2014|[-\*\+])\s"732)733 734 735def _detect_list_type(text: str) -> str:736 """Return 'decimal' for numbered lists, 'bullet' for bullet lists."""737 if _NUMBERED_PREFIX_RE.match(text):738 return "decimal"739 return "bullet"740 741 742def _apply_numbering_to_para(para, doc: Document, list_level: int = 0,743 list_type: str = "bullet") -> bool:744 """745 Apply real Word numbering (w:numPr) to a paragraph.746 747 list_type: "bullet" → • style, "decimal" → 1. 2. 3. style748 749 Strategy:750 1. Look for an existing abstractNum of the right type — reuse its numId.751 2. If none found, create a minimal abstract/concrete numbering pair.752 3. Set w:numPr on the paragraph's pPr.753 754 Returns True if numbering was applied, False on failure.755 """756 WNS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"757 758 try:759 numbering_part = doc.part.numbering_part760 numbering_el = numbering_part._element761 except Exception:762 return False # no numbering part, can't create one safely763 764 target_fmt = "bullet" if list_type == "bullet" else "decimal"765 766 # ── Find an existing numId of the right format ────────────────767 existing_num_id = None768 try:769 for abstract_num in numbering_el.findall(f"{{{WNS}}}abstractNum"):770 lvl0 = abstract_num.find(f".//{{{WNS}}}lvl[@{{{WNS}}}ilvl='0']")771 if lvl0 is None:772 lvl0 = abstract_num.find(f".//{{{WNS}}}lvl")773 if lvl0 is None:774 continue775 num_fmt_el = lvl0.find(f"{{{WNS}}}numFmt")776 if num_fmt_el is None:777 continue778 fmt_val = num_fmt_el.get(f"{{{WNS}}}val", "")779 if fmt_val != target_fmt:780 continue781 # Found matching abstractNum — find its concrete num782 abstract_num_id = abstract_num.get(f"{{{WNS}}}abstractNumId")783 for num_el in numbering_el.findall(f"{{{WNS}}}num"):784 ref = num_el.find(f"{{{WNS}}}abstractNumId")785 if ref is not None and ref.get(f"{{{WNS}}}val") == abstract_num_id:786 existing_num_id = num_el.get(f"{{{WNS}}}numId")787 break788 if existing_num_id:789 break790 except Exception:791 pass792 793 # ── Create a new numbering definition if none found ──────────794 if not existing_num_id:795 try:796 import lxml.etree as etree797 798 existing_abstract_ids = [799 int(e.get(f"{{{WNS}}}abstractNumId", 0))800 for e in numbering_el.findall(f"{{{WNS}}}abstractNum")801 ]802 next_abstract_id = max(existing_abstract_ids, default=-1) + 1803 existing_num_ids = [804 int(e.get(f"{{{WNS}}}numId", 0))805 for e in numbering_el.findall(f"{{{WNS}}}num")806 ]807 next_num_id = max(existing_num_ids, default=0) + 1808 809 if list_type == "decimal":810 abstract_xml = (811 f'<w:abstractNum xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'812 f' w:abstractNumId="{next_abstract_id}">'813 f'<w:multiLevelType w:val="hybridMultilevel"/>'814 f'<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/>'815 f'<w:lvlText w:val="%1."/><w:lvlJc w:val="left"/>'816 f'<w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>'817 f'<w:lvl w:ilvl="1"><w:start w:val="1"/><w:numFmt w:val="lowerLetter"/>'818 f'<w:lvlText w:val="%2."/><w:lvlJc w:val="left"/>'819 f'<w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl>'820 f'<w:lvl w:ilvl="2"><w:start w:val="1"/><w:numFmt w:val="lowerRoman"/>'821 f'<w:lvlText w:val="%3."/><w:lvlJc w:val="left"/>'822 f'<w:pPr><w:ind w:left="2160" w:hanging="360"/></w:pPr></w:lvl>'823 f'</w:abstractNum>'824 )825 else: # bullet826 abstract_xml = (827 f'<w:abstractNum xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'828 f' w:abstractNumId="{next_abstract_id}">'829 f'<w:multiLevelType w:val="hybridMultilevel"/>'830 f'<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/>'831 f'<w:lvlText w:val="•"/><w:lvlJc w:val="left"/>'832 f'<w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr>'833 f'<w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol"/></w:rPr></w:lvl>'834 f'<w:lvl w:ilvl="1"><w:start w:val="1"/><w:numFmt w:val="bullet"/>'835 f'<w:lvlText w:val="o"/><w:lvlJc w:val="left"/>'836 f'<w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr>'837 f'<w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr></w:lvl>'838 f'<w:lvl w:ilvl="2"><w:start w:val="1"/><w:numFmt w:val="bullet"/>'839 f'<w:lvlText w:val=""/><w:lvlJc w:val="left"/>'840 f'<w:pPr><w:ind w:left="2160" w:hanging="360"/></w:pPr>'841 f'<w:rPr><w:rFonts w:ascii="Wingdings" w:hAnsi="Wingdings"/></w:rPr></w:lvl>'842 f'</w:abstractNum>'843 )844 845 concrete_xml = (846 f'<w:num xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'847 f' w:numId="{next_num_id}">'848 f'<w:abstractNumId w:val="{next_abstract_id}"/>'849 f'</w:num>'850 )851 852 abstract_el = etree.fromstring(abstract_xml.encode())853 concrete_el = etree.fromstring(concrete_xml.encode())854 855 first_num = numbering_el.find(f"{{{WNS}}}num")856 if first_num is not None:857 first_num.addprevious(abstract_el)858 abstract_el.addnext(concrete_el)859 else:860 numbering_el.append(abstract_el)861 numbering_el.append(concrete_el)862 863 existing_num_id = str(next_num_id)864 log.debug(f"[ASSEMBLER] Created {list_type} numbering: abstractNumId={next_abstract_id}, numId={next_num_id}")865 except Exception as e:866 log.debug(f"[ASSEMBLER] Failed to create numbering definition: {e}")867 return False868 869 # ── Apply numPr to the paragraph ─────────────────────────────870 try:871 ppr = para._element.find(qn("w:pPr"))872 if ppr is None:873 ppr = OxmlElement("w:pPr")874 para._element.insert(0, ppr)875 876 existing_numpr = ppr.find(qn("w:numPr"))877 if existing_numpr is not None:878 ppr.remove(existing_numpr)879 880 num_pr = OxmlElement("w:numPr")881 ilvl_el = OxmlElement("w:ilvl")882 num_id_el = OxmlElement("w:numId")883 ilvl_el.set(qn("w:val"), str(min(list_level, 2)))884 num_id_el.set(qn("w:val"), str(existing_num_id))885 num_pr.append(ilvl_el)886 num_pr.append(num_id_el)887 ppr.append(num_pr)888 return True889 except Exception as e:890 log.debug(f"[ASSEMBLER] Failed to set numPr: {e}")891 return False892 893def _set_para_alignment_xml(para, alignment_enum) -> None:894 """895 Write paragraph alignment directly into pPr/w:jc XML.896 897 python-docx's para.alignment setter works but can be silently ignored898 if the paragraph style already has a w:jc element that takes precedence899 in some viewers. Writing directly to pPr guarantees the value is set900 as a paragraph-level override that always wins over style inheritance.901 """902 _JC_MAP = {903 WD_ALIGN_PARAGRAPH.LEFT: "left",904 WD_ALIGN_PARAGRAPH.RIGHT: "right",905 WD_ALIGN_PARAGRAPH.CENTER: "center",906 WD_ALIGN_PARAGRAPH.JUSTIFY: "both",907 }908 val = _JC_MAP.get(alignment_enum)909 if val is None:910 return911 ppr = para._element.find(qn("w:pPr"))912 if ppr is None:913 ppr = OxmlElement("w:pPr")914 para._element.insert(0, ppr)915 jc = ppr.find(qn("w:jc"))916 if jc is None:917 jc = OxmlElement("w:jc")918 ppr.append(jc)919 jc.set(qn("w:val"), val)920 921 922def _write_runs(923 para,924 block,925 base_font: str | None,926 base_size: float | None,927 base_bold: bool | None,928 base_italic: bool | None,929 rtl: bool,930) -> None:931 """932 Write paragraph content as one or more runs.933 934 If block.inline_runs is populated (DOCX source with mixed formatting),935 each span becomes its own run with its own bold/italic/font overrides936 applied on top of the base style values.937 938 If inline_runs is None (PDF source or uniform paragraph), a single run939 is written using the base style values.940 941 base_bold / base_italic come from the resolved style chain — they are942 the paragraph-level defaults. A span's bold/italic of None means943 "inherit from paragraph", True/False means explicit override.944 """945 from docx.shared import RGBColor as _RGBColor946 947 def _apply_run_props(run, span_bold, span_italic, span_font, span_size):948 # Font name: span override → base style → nothing949 fn = span_font or base_font950 if fn:951 # Set all four rFonts attributes so every renderer picks up the font.952 # Word uses 'ascii'/'hAnsi' for Western text; LibreOffice and other953 # viewers also check 'eastAsia' and 'cs' (complex script / RTL).954 # Setting only run.font.name writes just w:rFonts ascii, which is955 # insufficient — we write all four via XML to be safe.956 rpr = run._element.get_or_add_rPr()957 rfonts = rpr.find(qn("w:rFonts"))958 if rfonts is None:959 rfonts = OxmlElement("w:rFonts")960 rpr.insert(0, rfonts)961 for attr in (qn("w:ascii"), qn("w:hAnsi"), qn("w:eastAsia"), qn("w:cs")):962 rfonts.set(attr, fn)963 # Font size: span override → base style → nothing964 # Use explicit None check so a 0.0 span_size doesn't fall back to base_size965 sz = span_size if span_size is not None else base_size966 if sz:967 run.font.size = Pt(sz)968 # Bold: span explicit → base style → leave unset969 b = span_bold if span_bold is not None else base_bold970 if b is not None:971 run.bold = b972 # Italic: span explicit → base style → leave unset973 i = span_italic if span_italic is not None else base_italic974 if i is not None:975 run.italic = i976 if rtl:977 _apply_rtl_to_run(run, True)978 979 if block.inline_runs:980 for span in block.inline_runs:981 run = para.add_run(span.text)982 _apply_run_props(run, span.bold, span.italic, span.font_name, span.font_size)983 else:984 run = para.add_run(block.text)985 _apply_run_props(run, None, None, None, None)986 987 988def _add_text_paragraph(989 doc: Document,990 block: RawBlock,991 mapping: StyleMapping,992 available: set[str],993 theme_fonts: dict | None = None,994) -> None:995 if theme_fonts is None:996 theme_fonts = {"major": None, "minor": None}997 preserve = mapping.preserve_original998 999 if preserve:1000 safe = "Normal"1001 else:1002 safe = _safe_style(mapping.style, available)1003 1004 try:1005 # Use style object directly to bypass python-docx's name lookup,1006 # which breaks on templates with duplicate style definitions.1007 style_obj = _lookup_style(doc, safe)1008 para = doc.add_paragraph()1009 if style_obj is not None:1010 para.style = style_obj1011 except Exception:1012 para = doc.add_paragraph()1013 1014 if preserve and block.original_style:1015 os_data = block.original_style1016 # Preserve mode: write runs then stamp original formatting.1017 # Use explicit None check for bold/italic so False is preserved, not treated as "inherit".1018 raw_bold = os_data.get("bold")1019 raw_italic = os_data.get("italic")1020 _write_runs(1021 para, block,1022 base_font = os_data.get("font_name"),1023 base_size = float(os_data["font_size"]) if os_data.get("font_size") else None,1024 base_bold = bool(raw_bold) if raw_bold is not None else None,1025 base_italic = bool(raw_italic) if raw_italic is not None else None,1026 rtl = bool(os_data.get("rtl")),1027 )1028 al = _ALIGNMENT_MAP.get(os_data.get("alignment", "left"))1029 if al is not None:1030 _set_para_alignment_xml(para, al)1031 else:1032 # ── Resolve full run properties from style chain ──────────1033 resolved_font, resolved_size, style_bold, style_italic, style_color = \1034 _resolve_style_run_props(doc, safe, theme_fonts)1035 1036 # ── Option-D fallback property overlay ───────────────────1037 # When use_fallback=True, the mapper derived explicit properties from1038 # the template body sample (handles style-less templates).1039 # Fallback values take priority over style-chain values for the1040 # specific properties that were set, but only when they are not None.1041 if mapping.use_fallback:1042 if mapping.fallback_font is not None: resolved_font = mapping.fallback_font1043 if mapping.fallback_size is not None: resolved_size = mapping.fallback_size1044 if mapping.fallback_bold is not None: style_bold = mapping.fallback_bold1045 if mapping.fallback_italic is not None: style_italic = mapping.fallback_italic1046 1047 # For blocks that have an explicit font size in the raw document1048 # (e.g. cover page title at 20pt styled as Normal), preserve that1049 # size rather than overriding with the style's default size.1050 # This keeps cover page visual hierarchy intact when the template1051 # uses Normal + font size variation rather than heading styles.1052 # Only apply when: block has a font_size AND it differs meaningfully1053 # from the style's resolved size AND the block has no inline_runs1054 # (inline_runs carry their own per-span sizes already).1055 effective_size = resolved_size1056 if (block.font_size1057 and not block.inline_runs1058 and not mapping.use_fallback # fallback already set the intended size1059 and resolved_size is not None1060 and abs(block.font_size - resolved_size) > 0.5):1061 effective_size = block.font_size1062 1063 _write_runs(1064 para, block,1065 base_font = resolved_font,1066 base_size = effective_size,1067 base_bold = style_bold,1068 base_italic = style_italic,1069 rtl = (mapping.rtl_override if mapping.rtl_override is not None else block.rtl),1070 )1071 1072 # Stamp color ONLY when it is an explicit palette-driven override.1073 # style_color (resolved from the style inheritance chain) is intentionally1074 # NOT applied here: if the style defines a color, Word already renders it1075 # through style inheritance without explicit run-level stamping. Stamping1076 # style_color explicitly onto every run causes template theme colors (e.g. a1077 # blue Normal style) to bleed into body text even when the template's actual1078 # rendered paragraphs show black text.1079 effective_color = (1080 mapping.fallback_color if (mapping.use_fallback and mapping.fallback_color)1081 else None # never stamp style_color — let style inheritance handle it1082 )1083 if effective_color:1084 try:1085 h = effective_color.lstrip("#")1086 if len(h) == 6:1087 rgb = RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))1088 for r in para.runs:1089 r.font.color.rgb = rgb1090 except Exception:1091 pass1092 1093 # ── Spacing + Alignment ───────────────────────────────────1094 style_space_before, style_space_after, style_alignment = \1095 _resolve_style_spacing(doc, safe)1096 1097 # Fallback spacing overrides style chain when use_fallback=True1098 if mapping.use_fallback and mapping.fallback_space_before is not None:1099 para.paragraph_format.space_before = Pt(mapping.fallback_space_before)1100 elif style_space_before is not None:1101 para.paragraph_format.space_before = style_space_before1102 1103 if mapping.use_fallback and mapping.fallback_space_after is not None:1104 para.paragraph_format.space_after = Pt(mapping.fallback_space_after)1105 elif style_space_after is not None:1106 para.paragraph_format.space_after = style_space_after1107 1108 # ── Alignment ────────────────────────────────────────────1109 # Priority for template-based formatting (use_fallback=True):1110 # 1. Explicit palette alignment (e.g. caption=right, title=center)1111 # 2. Style-chain alignment from the matched template style1112 # 3. Nothing — do NOT fall back to the raw block's alignment.1113 # The raw doc's alignment is irrelevant once a template style1114 # is applied. If neither palette nor style chain set alignment,1115 # the paragraph inherits Word's built-in rendering for that style1116 # (e.g. Title center-aligns without an explicit w:jc element).1117 # Writing w:jc left from the raw doc's block.alignment overrides1118 # that inherited rendering and breaks the template's visual intent.1119 #1120 # Exception: preserve_original mode — block.alignment wins (handled above).1121 fb_al = _ALIGNMENT_MAP.get(mapping.fallback_align) if (mapping.use_fallback and mapping.fallback_align) else None1122 1123 if fb_al is not None:1124 # Explicit palette alignment — always use it1125 _set_para_alignment_xml(para, fb_al)1126 elif style_alignment is not None:1127 # Template style defines alignment — use it1128 _set_para_alignment_xml(para, style_alignment)1129 elif not mapping.use_fallback:1130 # Last resort: role-based default for non-fallback paths1131 # (use_fallback=True paths already have palette-driven fallback_align)1132 role_align_str = _role_default_alignment(safe.lower())1133 role_al = _ALIGNMENT_MAP.get(role_align_str) if role_align_str else None1134 if role_al is not None:1135 _set_para_alignment_xml(para, role_al)1136 # else: nothing — paragraph inherits style's rendered alignment1137 1138 # ── Line spacing ──────────────────────────────────────────────1139 # Priority: Option-D fallback → style-chain resolution1140 line_spacing_multiplier = None1141 if mapping.use_fallback and mapping.fallback_line_spacing is not None:1142 line_spacing_multiplier = mapping.fallback_line_spacing1143 else:1144 line_spacing_multiplier = _resolve_style_line_spacing(doc, safe)1145 1146 if line_spacing_multiplier is not None and line_spacing_multiplier > 0:1147 _apply_line_spacing(para, line_spacing_multiplier)1148 1149 # RTL paragraph-level bidi1150 rtl = mapping.rtl_override if mapping.rtl_override is not None else block.rtl1151 if rtl:1152 _apply_rtl_to_paragraph(para, True)1153 elif mapping.rtl_override is False:1154 _apply_rtl_to_paragraph(para, False)1155 1156 # List indentation — apply left indent for list items so they are visually1157 # distinct from body paragraphs. Only applied when the template doesn't1158 # already define indentation via a list style (preserve_original skips this).1159 # Alignment: use the template's list style alignment if explicitly defined,1160 # otherwise fall back to LEFT (never inherit JUSTIFY from Normal via the chain,1161 # as justified text with a number/bullet prefix looks wrong).1162 if block.block_type == BlockType.LIST_ITEM and not mapping.preserve_original:1163 from docx.shared import Cm as _Cm1164 1165 # Resolve available styles and the target style object ONCE.1166 _avail = _get_available_styles(doc)1167 _safe = _safe_style(mapping.style, _avail)1168 lst_style_obj = None1169 try:1170 lst_style_obj = _lookup_style(doc, _safe)1171 except Exception:1172 pass1173 1174 # ── Convert text-pattern list items to real numPr bullets ──1175 # Text-pattern list items (classified via regex, not real numPr XML) have1176 # heuristic_confidence="low". Real numPr items have confidence="high".1177 # Only strip marker + inject numPr for the low-confidence text-pattern ones.1178 is_text_pattern_list = (block.heuristic_confidence == "low" and block.is_list)1179 if is_text_pattern_list:1180 # Detect whether it's a numbered or bullet list from the text prefix1181 list_type = _detect_list_type(block.text)1182 1183 # Strip the leading marker from the first run1184 for run in para.runs:1185 stripped = _LIST_MARKER_STRIP_RE.sub("", run.text, count=1)1186 if stripped != run.text:1187 run.text = stripped1188 break1189 1190 # Apply real Word numbering of the correct type1191 numbering_applied = _apply_numbering_to_para(1192 para, doc, block.list_level, list_type=list_type1193 )1194 if numbering_applied:1195 log.debug(1196 f"[ASSEMBLER] Block {block.id}: converted text-pattern "1197 f"{list_type} list → real numPr"1198 )1199 else:1200 para.paragraph_format.left_indent = _Cm(0.75 + block.list_level * 0.5)