FaizanMirZa77/FormatX
0
1"""2template_parser.py3──────────────────4Extracts a TemplateProfile and TemplateDigest from a template DOCX.5 6TemplateProfile — classic named-style definitions (used by assembler).7TemplateDigest — Option-D semantic digest: named styles + actual rendered8 body paragraphs as ground truth. Passed to Claude so it9 can understand visual intent even when the template has no10 proper Word styles.11 12Key design points:13 - Filters out garbage styles (from diagrams, textboxes, shapes)14 - Body sample captures manually-formatted paragraphs with their raw15 font/size/bold/spacing so style-less templates are fully readable16 - RTL detection is conservative — requires strong majority17 - Style inheritance chain has cycle protection18 - has_real_styles flag tells assembler whether to trust style names or19 rely on fallback properties from the digest20"""21 22import io23import zipfile24import statistics25import logging26from docx import Document27from docx.enum.text import WD_ALIGN_PARAGRAPH28from docx.oxml.ns import qn29from models import (30 TemplateProfile, TemplateStyleDefinition,31 TemplateDigest, TemplateBodyParagraph, TemplatePaletteEntry,32)33 34log = logging.getLogger("formatx.template")35 36_RTL_FONTS = [37 "naskh", "nastaleeq", "arabic", "urdu", "farsi", "persian",38 "amiri", "scheherazade", "lateef", "jameel", "nafees",39 "alvi", "mehr", "fajer",40]41 42_RTL_UNICODE_RANGES = range(0x0600, 0x06FF + 1)43 44# Styles that are internal Word styles, not useful for body text formatting45# These often come from diagrams, SmartArt, textboxes, etc.46_SKIP_STYLE_KEYWORDS = [47 "placeholder", "balloon", "annotation", "comment", "revision",48 "index", "macro", "html", "xml", "endnote", "footnote",49 "message header", "salutation", "closing", "signature",50 "document map", "normal (web)", "html preformatted",51]52 53# Styles we always want to keep even if they look unusual54_ALWAYS_KEEP = {55 "normal", "heading 1", "heading 2", "heading 3", "heading 4",56 "heading 5", "heading 6", "title", "subtitle", "body text",57 "body text 2", "body text 3", "caption", "list bullet",58 "list number", "list paragraph", "quote", "intense quote",59 "no spacing",60}61 62 63def _emu_to_cm(emu) -> float | None:64 try:65 return round(emu / 914400 * 2.54, 2)66 except Exception:67 return None68 69 70def _color_to_hex(color) -> str | None:71 try:72 if color and color.rgb:73 return f"#{color.rgb}"74 except Exception:75 pass76 return None77 78 79def _alignment_name(alignment) -> str:80 if alignment is None:81 return "left"82 mapping = {83 WD_ALIGN_PARAGRAPH.LEFT: "left",84 WD_ALIGN_PARAGRAPH.RIGHT: "right",85 WD_ALIGN_PARAGRAPH.CENTER: "center",86 WD_ALIGN_PARAGRAPH.JUSTIFY: "justify",87 }88 return mapping.get(alignment, "left")89 90 91# ── Theme-font and alignment helpers (shared with document_assembler) ─────────92 93def _read_theme_fonts(template_bytes: bytes) -> dict:94 """95 Open the DOCX zip and read word/theme/theme1.xml to extract the concrete96 font names for the major (heading) and minor (body) Latin font slots.97 98 Returns {"major": str|None, "minor": str|None}.99 Catches ALL exceptions and returns {"major": None, "minor": None} so the100 caller always gets a valid dict regardless of template quality.101 102 This must be called ONCE per endpoint call and the result passed down —103 never call it per-style or per-paragraph.104 """105 result = {"major": None, "minor": None}106 try:107 DRAW_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"108 with zipfile.ZipFile(io.BytesIO(template_bytes), "r") as zf:109 if "word/theme/theme1.xml" not in zf.namelist():110 return result111 xml_bytes = zf.read("word/theme/theme1.xml")112 113 import xml.etree.ElementTree as ET114 root = ET.fromstring(xml_bytes)115 116 major_el = root.find(f".//{{{DRAW_NS}}}majorFont/{{{DRAW_NS}}}latin")117 minor_el = root.find(f".//{{{DRAW_NS}}}minorFont/{{{DRAW_NS}}}latin")118 119 if major_el is not None:120 result["major"] = major_el.get("typeface") or None121 if minor_el is not None:122 result["minor"] = minor_el.get("typeface") or None123 except Exception:124 pass125 return result126 127 128def _resolve_theme_font(raw_name: str | None, theme_fonts: dict) -> str | None:129 """130 Resolve a raw w:rFonts value to a concrete font name.131 132 Mapping rules:133 None or "+mjLatin" → theme_fonts["major"]134 "+mnLatin" → theme_fonts["minor"]135 any other value → unchanged passthrough136 137 This is a pure function with no side effects — safe to call on every138 f.name result unconditionally.139 """140 if raw_name is None or raw_name == "+mjLatin":141 return theme_fonts.get("major")142 if raw_name == "+mnLatin":143 return theme_fonts.get("minor")144 return raw_name145 146 147def _xml_alignment_from_style_chain(style, doc=None) -> str | None:148 """149 Walk the style's basedOn chain reading w:pPr/w:jc @w:val directly from150 XML — bypassing python-docx's pf.alignment which returns None when no151 explicit w:jc element exists even if one is present in an ancestor.152 153 Maps "both" → "justify" (Word's internal name for justified text).154 Returns the first non-None value found, or None if the whole chain has155 no explicit alignment.156 157 doc: optional Document object — when provided, uses _lookup_style_best158 to resolve each parent by name, avoiding the stub-first problem in159 templates with duplicate style definitions.160 161 Includes a visited-set for cycle protection.162 """163 _JC_TO_ALIGN = {164 "left": "left",165 "right": "right",166 "center": "center",167 "both": "justify",168 "justify": "justify",169 }170 visited = set()171 current = style172 while current is not None:173 sid = getattr(current, "style_id", None) or current.name174 if sid in visited:175 break176 visited.add(sid)177 try:178 ppr = current.element.find(qn("w:pPr"))179 if ppr is not None:180 jc = ppr.find(qn("w:jc"))181 if jc is not None:182 val = jc.get(qn("w:val"), "")183 mapped = _JC_TO_ALIGN.get(val)184 if mapped:185 return mapped186 except Exception:187 pass188 189 # Walk to parent: if doc is available, resolve by name using190 # _lookup_style_best to avoid getting the empty stub for duplicate styles.191 if doc is not None:192 try:193 based_el = current.element.find(qn("w:basedOn"))194 if based_el is not None:195 parent_id = based_el.get(qn("w:val"))196 if parent_id:197 # Find by styleId198 parent = next(199 (s for s in doc.styles200 if getattr(s, "style_id", None) == parent_id201 and s.element.find(qn("w:pPr")) is not None),202 None203 )204 if parent is None:205 # Fall back to any match with that styleId206 parent = next(207 (s for s in doc.styles208 if getattr(s, "style_id", None) == parent_id),209 None210 )211 current = parent212 continue213 except Exception:214 pass215 current = None216 else:217 current = current.base_style218 return None219 220 221def _role_default_alignment(style_name: str) -> str | None:222 """223 Return a role-based last-resort alignment default for well-known style names.224 225 Applied ONLY when both pf.alignment and _xml_alignment_from_style_chain226 return None — i.e. the template truly has no alignment information at all.227 228 Mapping:229 heading 1, heading 2, title → "center"230 normal, body text → "justify"231 anything else → None (no assumption)232 """233 name = style_name.lower()234 if name in ("heading 1", "heading 2", "title"):235 return "center"236 if name in ("normal", "body text"):237 return "justify"238 return None239 240 241def _lookup_style_best(doc: Document, style_name: str):242 """243 Look up a style by name, preferring the instance that has actual paragraph244 formatting set over one that is empty.245 246 Some DOCX files contain duplicate style definitions (e.g. one empty stub247 and one with full formatting). python-docx exposes all of them via248 doc.styles iteration. Always taking the first match can return the empty249 stub. This helper returns the last match that has pPr/rPr content, or250 the last match overall if none have content — ensuring we always get the251 most-defined version of the style.252 """253 candidates = [s for s in doc.styles if s.name == style_name]254 if not candidates:255 return None256 if len(candidates) == 1:257 return candidates[0]258 # Prefer the candidate that has pPr with at least one child (real formatting)259 for s in reversed(candidates):260 try:261 from docx.oxml.ns import qn as _qn262 ppr = s.element.find(_qn("w:pPr"))263 if ppr is not None and len(ppr) > 0:264 return s265 except Exception:266 pass267 # Fall back to last candidate268 return candidates[-1]269 270 271 272 if not font_name:273 return False274 return any(rtl in font_name.lower() for rtl in _RTL_FONTS)275 276 277def _style_is_rtl(style) -> bool:278 try:279 ppr = style.element.find(qn("w:pPr"))280 if ppr is not None:281 bidi = ppr.find(qn("w:bidi"))282 if bidi is not None:283 val = bidi.get(qn("w:val"), "1")284 return val != "0"285 except Exception:286 pass287 return False288 289 290def _is_useful_style(style) -> bool:291 """292 Filter out styles that come from diagrams, shapes, SmartArt, textboxes.293 These produce garbage font definitions that confuse the AI mapper.294 """295 name_lower = style.name.lower()296 297 # Always keep core styles298 if name_lower in _ALWAYS_KEEP:299 return True300 301 # Skip known garbage style keywords302 for kw in _SKIP_STYLE_KEYWORDS:303 if kw in name_lower:304 return False305 306 # Skip styles with no font info AND no size — likely diagram artifacts307 try:308 f = style.font309 pf = style.paragraph_format310 has_font = bool(f.name or f.size or f.bold is not None)311 has_spacing = bool(pf.space_before or pf.space_after)312 # A style with absolutely nothing defined is useless313 if not has_font and not has_spacing and style.base_style is None:314 return False315 except Exception:316 pass317 318 return True319 320 321def _extract_style_definition(style, theme_fonts: dict | None = None, doc=None) -> TemplateStyleDefinition:322 """323 Extract full formatting definition from a DOCX style object.324 Walks the style inheritance chain with cycle protection.325 326 theme_fonts: dict from _read_theme_fonts() — used to resolve +mjLatin/+mnLatin327 theme font pointers to concrete font names. Pass None to skip.328 doc: Document object — when provided, uses _lookup_style_best for parent329 resolution to handle templates with duplicate style definitions.330 """331 if theme_fonts is None:332 theme_fonts = {"major": None, "minor": None}333 334 font_name = None335 font_size = None336 font_color = None337 bold = False338 italic = False339 alignment = None340 rtl = False341 space_before = None342 space_after = None343 line_spacing = None344 345 visited = set()346 current = style347 348 while current is not None:349 # Cycle protection350 style_id = getattr(current, "style_id", None) or current.name351 if style_id in visited:352 break353 visited.add(style_id)354 355 try:356 f = current.font357 pf = current.paragraph_format358 359 # Font: use _resolve_theme_font to handle +mjLatin/+mnLatin pointers360 if font_name is None:361 raw = f.name362 # python-docx may return None when the only font set is a theme363 # pointer; fall back to reading the raw XML attribute directly.364 if raw is None:365 try:366 rpr = current.element.find(qn("w:rPr"))367 if rpr is not None:368 rf = rpr.find(qn("w:rFonts"))369 if rf is not None:370 raw = (rf.get(qn("w:ascii"))371 or rf.get(qn("w:hAnsi"))372 or rf.get(qn("w:cs")))373 except Exception:374 pass375 resolved = _resolve_theme_font(raw, theme_fonts)376 if resolved:377 font_name = resolved378 379 if font_size is None and f.size: font_size = round(f.size.pt, 1)380 if font_color is None:381 c = _color_to_hex(f.color)382 if c:383 font_color = c384 if not bold and f.bold: bold = True385 if not italic and f.italic: italic = True386 if alignment is None and pf.alignment is not None:387 alignment = _alignment_name(pf.alignment)388 if not rtl and _style_is_rtl(current):389 rtl = True390 if space_before is None and pf.space_before:391 space_before = round(pf.space_before.pt, 1)392 if space_after is None and pf.space_after:393 space_after = round(pf.space_after.pt, 1)394 395 # Line spacing: read directly from XML per-node (w:spacing/@w:line396 # with @w:lineRule="auto"). We avoid pf.line_spacing because it397 # returns an EMU-scale Length object which cannot be reliably398 # converted to a multiplier without knowing the base line height.399 if line_spacing is None:400 try:401 ppr_node = current.element.find(qn("w:pPr"))402 if ppr_node is not None:403 sp_node = ppr_node.find(qn("w:spacing"))404 if sp_node is not None:405 lr = sp_node.get(qn("w:lineRule"), "")406 if lr == "auto":407 lv = sp_node.get(qn("w:line"))408 if lv:409 line_spacing = round(int(lv) / 240, 4)410 except Exception:411 pass412 413 if font_name and _is_rtl_font(font_name):414 rtl = True415 except Exception:416 pass417 418 current = current.base_style419 420 # Alignment fallback: XML walk only — no role-based default in the parser.421 # Role-based defaults (center for headings, justify for body) are applied422 # only in the assembler at render time, never baked into the palette.423 if alignment is None:424 alignment = _xml_alignment_from_style_chain(style, doc)425 426 # line_spacing is fully resolved inside the while loop via direct XML reads.427 # No post-loop fallback needed.428 429 return TemplateStyleDefinition(430 name = style.name,431 font_name = font_name,432 font_size = font_size,433 font_color = font_color,434 bold = bold,435 italic = italic,436 alignment = alignment,437 rtl = rtl,438 space_before = space_before,439 space_after = space_after,440 line_spacing = line_spacing,441 )442 443 444def _detect_document_rtl(doc: Document) -> bool:445 """446 Conservative RTL detection.447 Requires a strong majority of text to be RTL — avoids false positives448 on bilingual documents (e.g. English doc with some Urdu quotes).449 """450 # Check document-level bidi setting451 try:452 settings = doc.settings.element453 bidi_el = settings.find(qn("w:bidi"))454 if bidi_el is not None:455 return True456 except Exception:457 pass458 459 # Sample up to 30 non-empty paragraphs460 total = 0461 rtl_count = 0462 for para in doc.paragraphs:463 text = para.text.strip()464 if not text or len(text) < 3:465 continue466 total += 1467 if total > 30:468 break469 rtl_chars = sum(1 for c in text if ord(c) in _RTL_UNICODE_RANGES)470 if rtl_chars / len(text) > 0.5: # majority of chars are RTL471 rtl_count += 1472 473 if total == 0:474 return False475 476 # Require at least 60% of sampled paragraphs to be RTL477 return (rtl_count / total) >= 0.6478 479 480def _has_real_table_style(doc: Document) -> bool:481 """482 Returns True if the template has any useful table style available.483 Includes "Table Grid" — it is a valid style, not just a default placeholder.484 "Table Normal" is the Word default with no formatting — excluded.485 Any other named table style is considered useful.486 """487 table_style_names = {s.name.lower() for s in doc.styles if s.type.name == "TABLE"}488 # "table normal" is the invisible default — not useful489 excluded = {"table normal", "normal table"}490 useful = table_style_names - excluded491 return bool(useful)492 493 494def _detect_table_borders(doc: Document) -> bool:495 """496 Returns True if the template's tables actually have visible borders.497 498 Strategy:499 1. Check the first real table in the document — its tblBorders XML is the500 most reliable signal of what the template author intended.501 2. Fall back to checking the best available table style definition for502 border XML if no tables exist in the template body.503 3. Default True — bordered tables are the academic norm, and a false504 positive (borders added when not wanted) is less harmful than a false505 negative (borders missing when they should be there).506 """507 # Check actual tables in the document first508 try:509 for table in doc.tables:510 tbl_pr = table._tbl.find(qn("w:tblPr"))511 if tbl_pr is not None:512 borders = tbl_pr.find(qn("w:tblBorders"))513 if borders is not None:514 # Check that at least one border side has a non-"none" val515 for child in borders:516 val = child.get(qn("w:val"), "")517 if val and val.lower() not in ("none", "nil", ""):518 return True519 return False # borders element exists but all sides are none520 # No tblBorders on this table — check next521 except Exception:522 pass523 524 # No tables in template body — check table style definitions525 try:526 for style in doc.styles:527 if style.type.name != "TABLE":528 continue529 if style.name.lower() in ("table normal", "normal table"):530 continue531 try:532 tbl_pr = style.element.find(qn("w:tblPr"))533 if tbl_pr is not None:534 borders = tbl_pr.find(qn("w:tblBorders"))535 if borders is not None:536 for child in borders:537 val = child.get(qn("w:val"), "")538 if val and val.lower() not in ("none", "nil", ""):539 return True540 except Exception:541 continue542 except Exception:543 pass544 545 # Default to True — bordered tables are the safer academic default546 return True547 548 549def _has_figure_caption_style(doc: Document) -> bool:550 for style in doc.styles:551 try:552 if "caption" in style.name.lower() and style.type.name == "PARAGRAPH":553 return True554 except Exception:555 pass556 return False557 558 559def _detect_table_header_fill(doc: Document) -> str | None:560 """561 Extract the background fill color from the first row of the first table562 in the template. Returns a 6-char hex string like "1F3864", or None.563 564 This is used to replicate header row shading when assembling tab-delimited565 tables from raw documents.566 """567 try:568 for table in doc.tables:569 if not table.rows:570 continue571 for cell in table.rows[0].cells:572 tc_pr = cell._tc.find(qn("w:tcPr"))573 if tc_pr is None:574 continue575 shd = tc_pr.find(qn("w:shd"))576 if shd is None:577 continue578 fill = shd.get(qn("w:fill"), "")579 # Skip white, auto, and empty fills — not real header shading580 if fill and fill.upper() not in ("FFFFFF", "AUTO", ""):581 return fill.upper()582 except Exception:583 pass584 return None585 586 587def _get_header_footer_text(doc: Document) -> tuple[str | None, str | None]:588 WNS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"589 header_text = None590 footer_text = None591 try:592 for section in doc.sections:593 if not section.header.is_linked_to_previous:594 t = " ".join(p.text for p in section.header.paragraphs).strip()595 has_drawing = section.header._element.find(f".//{{{WNS}}}drawing") is not None596 if t or has_drawing:597 header_text = t[:200] if t else "[image-only header]"598 if not section.footer.is_linked_to_previous:599 t = " ".join(p.text for p in section.footer.paragraphs).strip()600 has_drawing = section.footer._element.find(f".//{{{WNS}}}drawing") is not None601 if t or has_drawing:602 footer_text = t[:200] if t else "[image-only footer]"603 except Exception:604 pass605 return header_text, footer_text606 607 608def _get_best_styles(doc: Document, theme_fonts: dict | None = None) -> list[TemplateStyleDefinition]:609 """610 Extract only useful paragraph styles, filtered and deduplicated.611 Prioritizes styles that are actually used in the document body.612 613 theme_fonts: passed through to _extract_style_definition for theme-font resolution.614 """615 if theme_fonts is None:616 theme_fonts = {"major": None, "minor": None}617 618 # Find which styles are actually used in the document619 used_style_names = set()620 try:621 for para in doc.paragraphs:622 if para.style:623 used_style_names.add(para.style.name)624 for table in doc.tables:625 for row in table.rows:626 for cell in row.cells:627 for para in cell.paragraphs:628 if para.style:629 used_style_names.add(para.style.name)630 except Exception:631 pass632 633 style_defs = []634 seen_names = set()635 636 # First pass: add styles that are actually used in the document637 for style in doc.styles:638 try:639 if style.type.name != "PARAGRAPH":640 continue641 if style.hidden:642 continue643 if style.name in seen_names:644 continue645 if not _is_useful_style(style):646 continue647 if style.name in used_style_names or style.name.lower() in _ALWAYS_KEEP:648 best = _lookup_style_best(doc, style.name) or style649 style_defs.append(_extract_style_definition(best, theme_fonts, doc))650 seen_names.add(style.name)651 except Exception:652 continue653 654 # Second pass: add remaining useful styles not yet included (up to 30 total)655 for style in doc.styles:656 try:657 if len(style_defs) >= 30:658 break659 if style.type.name != "PARAGRAPH":660 continue661 if style.hidden:662 continue663 if style.name in seen_names:664 continue665 if not _is_useful_style(style):666 continue667 best = _lookup_style_best(doc, style.name) or style668 style_defs.append(_extract_style_definition(best, theme_fonts, doc))669 seen_names.add(style.name)670 except Exception:671 continue672 673 return style_defs674 675 676def extract_template_styles(template_bytes: bytes) -> TemplateProfile:677 """678 Extract a clean TemplateProfile from template DOCX bytes.679 """680 log.info(f"[TEMPLATE] Starting template extraction, size={len(template_bytes)} bytes")681 doc = Document(io.BytesIO(template_bytes))682 section = doc.sections[0]683 684 # Read theme fonts once — passed down to all style/font helpers685 theme_fonts = _read_theme_fonts(template_bytes)686 log.info(f"[TEMPLATE] Theme fonts resolved: {theme_fonts}")687 688 page_width = _emu_to_cm(section.page_width)689 page_height = _emu_to_cm(section.page_height)690 margin_top = _emu_to_cm(section.top_margin)691 margin_bottom = _emu_to_cm(section.bottom_margin)692 margin_left = _emu_to_cm(section.left_margin)693 margin_right = _emu_to_cm(section.right_margin)694 695 log.info(f"[TEMPLATE] Page layout: {page_width}×{page_height} cm, margins T={margin_top} B={margin_bottom} L={margin_left} R={margin_right}")696 697 rtl = _detect_document_rtl(doc)698 log.info(f"[TEMPLATE] RTL detected: {rtl}")699 700 style_defs = _get_best_styles(doc, theme_fonts)701 log.info(f"[TEMPLATE] Extracted {len(style_defs)} styles: {[s.name for s in style_defs]}")702 703 # Ensure "Normal" is always present as fallback704 if not any(s.name == "Normal" for s in style_defs):705 for style in doc.styles:706 try:707 if style.name == "Normal" and style.type.name == "PARAGRAPH":708 style_defs.insert(0, _extract_style_definition(style, theme_fonts, doc))709 log.info("[TEMPLATE] Injected 'Normal' style as fallback")710 break711 except Exception:712 pass713 714 has_table_style = _has_real_table_style(doc)715 has_figure_caption = _has_figure_caption_style(doc)716 table_has_borders = _detect_table_borders(doc)717 table_header_fill = _detect_table_header_fill(doc)718 header_text, footer_text = _get_header_footer_text(doc)719 720 log.info(721 f"[TEMPLATE] Capabilities: has_table_style={has_table_style}, "722 f"has_figure_caption={has_figure_caption}, "723 f"table_has_borders={table_has_borders}, "724 f"table_header_fill={table_header_fill!r}, "725 f"has_header={header_text is not None}, has_footer={footer_text is not None}"726 )727 728 return TemplateProfile(729 page_width = page_width,730 page_height = page_height,731 margin_top = margin_top,732 margin_bottom = margin_bottom,733 margin_left = margin_left,734 margin_right = margin_right,735 rtl = rtl,736 styles = style_defs,737 has_table_style = has_table_style,738 has_figure_caption = has_figure_caption,739 table_has_borders = table_has_borders,740 table_header_fill = table_header_fill,741 has_header = header_text is not None,742 has_footer = footer_text is not None,743 )744 745 746# ── Template Digest (Option-D) ────────────────────────────────────────────────747 748def _resolve_para_props(para, doc: Document, theme_fonts: dict | None = None) -> dict:749 """750 Resolve the full rendered properties of a template paragraph by walking:751 1. Inline run properties (highest priority)752 2. Paragraph's own pPr / rPr753 3. Named style chain754 4. Document defaults (w:docDefaults)755 756 theme_fonts: dict from _read_theme_fonts() — resolves +mjLatin/+mnLatin.757 758 Returns a dict with: font, size, bold, italic, align, space_before,759 space_after, color — all as Python primitives or None.760 """761 if theme_fonts is None:762 theme_fonts = {"major": None, "minor": None}763 font_name = None764 font_size = None765 bold = None766 italic = None767 color = None768 align = None769 space_before = None770 space_after = None771 772 # ── 1. Inline run properties (first non-None run wins) ────────773 try:774 runs = [r for r in para.runs if r.text.strip()]775 for r in runs:776 if font_name is None and r.font.name:777 font_name = r.font.name778 if font_size is None and r.font.size:779 font_size = round(r.font.size.pt, 1)780 if bold is None and r.bold is not None:781 bold = r.bold782 if italic is None and r.italic is not None:783 italic = r.italic784 try:785 if color is None and r.font.color and r.font.color.rgb:786 color = f"#{r.font.color.rgb}"787 except Exception:788 pass789 except Exception:790 pass791 792 # ── 2. Paragraph-level alignment and spacing ──────────────────793 try:794 pf = para.paragraph_format795 if align is None and pf.alignment is not None:796 align = _alignment_name(pf.alignment)797 if space_before is None and pf.space_before is not None:798 space_before = round(pf.space_before.pt, 1)799 if space_after is None and pf.space_after is not None:800 space_after = round(pf.space_after.pt, 1)801 except Exception:802 pass803 804 # ── 3. Named style chain ──────────────────────────────────────805 style_name = para.style.name if para.style else "Normal"806 visited = set()807 try:808 # Use _lookup_style_best to handle duplicate style definitions809 current = _lookup_style_best(doc, style_name)810 except Exception:811 current = None812 813 while current is not None:814 sid = getattr(current, "style_id", None) or current.name815 if sid in visited:816 break817 visited.add(sid)818 try:819 f = current.font820 pf = current.paragraph_format821 if font_name is None:822 raw = f.name823 if raw is None:824 try:825 rpr = current.element.find(qn("w:rPr"))826 if rpr is not None:827 rf = rpr.find(qn("w:rFonts"))828 if rf is not None:829 raw = (rf.get(qn("w:ascii"))830 or rf.get(qn("w:hAnsi"))831 or rf.get(qn("w:cs")))832 except Exception:833 pass834 resolved = _resolve_theme_font(raw, theme_fonts)835 if resolved:836 font_name = resolved837 if font_size is None and f.size: font_size = round(f.size.pt, 1)838 if bold is None and f.bold is not None: bold = f.bold839 if italic is None and f.italic is not None: italic = f.italic840 if align is None and pf.alignment is not None:841 align = _alignment_name(pf.alignment)842 if space_before is None and pf.space_before is not None:843 space_before = round(pf.space_before.pt, 1)844 if space_after is None and pf.space_after is not None:845 space_after = round(pf.space_after.pt, 1)846 try:847 if color is None and f.color and f.color.rgb:848 color = f"#{f.color.rgb}"849 except Exception:850 pass851 except Exception:852 pass853 current = current.base_style854 855 # Alignment fallback: XML walk (no role-based default here — body sample is observational)856 if align is None:857 style_obj = _lookup_style_best(doc, para.style.name if para.style else "Normal")858 if style_obj is not None:859 align = _xml_alignment_from_style_chain(style_obj, doc)860 861 # ── 4. Document defaults fallback ─────────────────────────────862 if font_name is None or font_size is None:863 try:864 WNS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"865 doc_defaults = doc.element.find(f".//{{{WNS}}}docDefaults")866 if doc_defaults is not None:867 rpr_default = doc_defaults.find(f".//{{{WNS}}}rPrDefault")868 if rpr_default is not None:869 rpr = rpr_default.find(f"{{{WNS}}}rPr")870 if rpr is not None:871 fonts_el = rpr.find(f"{{{WNS}}}rFonts")872 sz_el = rpr.find(f"{{{WNS}}}sz")873 if fonts_el is not None and font_name is None:874 font_name = (fonts_el.get(f"{{{WNS}}}ascii")875 or fonts_el.get(f"{{{WNS}}}hAnsi")876 or fonts_el.get(f"{{{WNS}}}cs"))877 if sz_el is not None and font_size is None:878 val = sz_el.get(f"{{{WNS}}}val")879 if val:880 font_size = int(val) / 2 # half-points → points881 except Exception:882 pass883 884 return {885 "font": font_name,886 "size": font_size,887 "bold": bool(bold) if bold is not None else False,888 "italic": bool(italic) if italic is not None else False,889 "align": align,890 "space_before": space_before,891 "space_after": space_after,892 "color": color,893 }894 895 896def _is_meaningful_para(para) -> bool:897 """Return True if this paragraph has actual text content (not just whitespace)."""898 text = para.text.strip()899 return bool(text) and len(text) >= 2900 901 902def _extract_body_sample(doc: Document, max_samples: int = 20, theme_fonts: dict | None = None) -> list[TemplateBodyParagraph]:903 """904 Extract up to max_samples rendered paragraphs from the template body.905 906 Strategy:907 - Skip TOC-like entries (dot-leader lines)908 - Deduplicate by (style_name, size, bold) fingerprint to avoid 50 identical body paras909 - Prioritise variety: headings, body text, captions, list items910 - Each unique visual appearance is captured once911 912 This gives Claude ground-truth examples of what each visual role looks like913 in this specific template, even if the template has no proper Word styles.914 """915 if theme_fonts is None:916 theme_fonts = {"major": None, "minor": None}917 import re as _re918 _TOC_RE = _re.compile(r"^.{2,80}[.\s]{3,}\s*[\divxIVX\d]{1,6}\s*$")919 920 samples : list[TemplateBodyParagraph] = []921 seen_fingerprints: set[tuple] = set()922 923 for para in doc.paragraphs:924 if len(samples) >= max_samples:925 break926 if not _is_meaningful_para(para):927 continue928 text = para.text.strip()929 if _TOC_RE.match(text):930 continue931 932 style_name = para.style.name if para.style else "Normal"933 props = _resolve_para_props(para, doc, theme_fonts)934 935 # Fingerprint: (style_name, size, bold, italic, align)936 fp = (style_name, props["size"], props["bold"], props["italic"], props["align"])937 if fp in seen_fingerprints:938 continue939 seen_fingerprints.add(fp)940 941 samples.append(TemplateBodyParagraph(942 style_name = style_name,943 text_snippet = text[:80],944 font = props["font"],945 size = props["size"],946 bold = props["bold"],947 italic = props["italic"],948 align = props["align"],949 space_before = props["space_before"],950 space_after = props["space_after"],951 color = props["color"],952 ))953 954 log.info(f"[TEMPLATE] Body sample: {len(samples)} unique visual appearances captured")955 return samples956 957 958def _has_real_named_styles(style_defs: list[TemplateStyleDefinition]) -> bool:959 """960 Returns True if the template has meaningful named styles beyond just 'Normal'.961 962 A template is considered to have real styles when at least one style other963 than Normal/Default has a font_size or bold=True — indicating the author964 used Word's style system intentionally.965 """966 for s in style_defs:967 if s.name.lower() in ("normal", "default paragraph font"):968 continue969 if s.font_size or s.bold:970 return True971 return False972 973 974def _resolve_style_run_props_local(975 doc: Document, style_name: str, theme_fonts: dict | None = None976) -> tuple[str | None, float | None, bool | None, bool | None, str | None]:977 """978 Walk the style inheritance chain and return (font_name, size_pt, bold, italic, color_hex).979 Lightweight version of the assembler's _resolve_style_run_props, used by the palette builder.980 981 theme_fonts: dict from _read_theme_fonts() — resolves +mjLatin/+mnLatin pointers.982 """983 if theme_fonts is None:984 theme_fonts = {"major": None, "minor": None}985 986 visited = set()987 font_name = None988 font_size = None989 bold = None990 italic = None991 color_hex = None992 993 current = _lookup_style_best(doc, style_name)994 while current is not None:995 sid = getattr(current, "style_id", None) or current.name996 if sid in visited:997 break998 visited.add(sid)999 try:1000 f = current.font1001 if font_name is None:1002 raw = f.name1003 if raw is None:1004 try:1005 rpr = current.element.find(qn("w:rPr"))1006 if rpr is not None:1007 rf = rpr.find(qn("w:rFonts"))1008 if rf is not None:1009 raw = (rf.get(qn("w:ascii"))1010 or rf.get(qn("w:hAnsi"))1011 or rf.get(qn("w:cs")))1012 except Exception:1013 pass1014 resolved = _resolve_theme_font(raw, theme_fonts)1015 if resolved:1016 font_name = resolved1017 if font_size is None and f.size: font_size = round(f.size.pt, 1)1018 if bold is None and f.bold is not None: bold = f.bold1019 if italic is None and f.italic is not None: italic = f.italic1020 if color_hex is None:1021 try:1022 if f.color and f.color.rgb:1023 color_hex = f"#{f.color.rgb}"1024 except Exception:1025 pass1026 except Exception:1027 pass1028 current = current.base_style1029 1030 return font_name, font_size, bold, italic, color_hex1031 1032 1033def _build_palette(1034 doc: Document,1035 style_defs: list,1036 body_sample: list[TemplateBodyParagraph],1037 theme_fonts: dict | None = None,1038) -> list[TemplatePaletteEntry]:1039 """1040 Build a canonical visual-role palette from the template without AI.1041 1042 Algorithm:1043 1. Resolve effective rendered properties for every meaningful paragraph.1044 2. Group paragraphs by visual fingerprint (font, round(size), bold, italic, align).1045 3. For each group with ≥2 paragraphs infer a semantic role using style-name1046 heuristics first, then size/bold fallback.1047 4. Deduplicate — if two groups map to the same role keep the1048 larger/bolder one.1049 5. Guarantee a "body" role always exists.1050 1051 Returns a list of TemplatePaletteEntry (5-10 entries typical).1052 """1053 if theme_fonts is None:1054 theme_fonts = {"major": None, "minor": None}1055 import re as _re1056 1057 # ── 1. Collect effective props for all meaningful paragraphs ──1058 records: list[dict] = [] # {props dict + style_name}1059 for para in doc.paragraphs:1060 if not _is_meaningful_para(para):1061 continue1062 style_name = para.style.name if para.style else "Normal"1063 props = _resolve_para_props(para, doc, theme_fonts)1064 props["_style_name"] = style_name1065 records.append(props)1066 1067 if not records:1068 return []1069 1070 # ── 2. Group by fingerprint ───────────────────────────────────1071 from collections import Counter, defaultdict1072 groups: dict[tuple, list[dict]] = defaultdict(list)1073 for r in records:1074 fp = (1075 r["font"],1076 round(r["size"] or 0),1077 r["bold"],1078 r["italic"],1079 r["align"],1080 )1081 groups[fp].append(r)1082 1083 # Keep groups with ≥2 paragraphs; lower threshold for short templates1084 significant = {fp: recs for fp, recs in groups.items() if len(recs) >= 2}1085 if not significant:1086 significant = dict(groups)1087 1088 # ── 3. Infer roles ────────────────────────────────────────────1089 # Compute median size for size-based role inference1090 all_sizes = [r["size"] for r in records if r["size"]]1091 median_size = statistics.median(all_sizes) if all_sizes else 12.01092 1093 def _infer_role(fp: tuple, recs: list[dict]) -> str:1094 """Return role string for a group."""1095 # Count style names used in this group; pick the plurality1096 style_counts = Counter(r["_style_name"] for r in recs)1097 best_style_name = style_counts.most_common(1)[0][0].lower()1098 1099 # Style-name rules (highest priority)1100 if "title" == best_style_name:1101 return "title"1102 if "heading 1" in best_style_name:1103 return "heading1"1104 if "heading 2" in best_style_name:1105 return "heading2"1106 if "heading 3" in best_style_name:1107 return "heading3"1108 if "heading 4" in best_style_name:1109 return "heading4"1110 if "heading 5" in best_style_name or "heading 6" in best_style_name:1111 return "heading4"1112 if "caption" in best_style_name:1113 return "caption"1114 if "list" in best_style_name:1115 return "list"1116 if "subtitle" in best_style_name:1117 return "heading2"1118 1119 # Check for list numbering via XML on any para in the group1120 has_numbering = any(1121 para.paragraph_format.element.find(1122 "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}numPr"1123 ) is not None1124 for para in doc.paragraphs1125 if _is_meaningful_para(para)1126 and para.style1127 and para.style.name.lower() == style_counts.most_common(1)[0][0].lower()1128 ) if True else False1129 if has_numbering:1130 return "list"1131 1132 # Size-based fallback1133 _, size_rounded, bold, italic, _ = fp1134 actual_size = size_rounded # already rounded1135 1136 if actual_size == 0:1137 return "body"1138 1139 if italic and actual_size <= median_size:1140 return "caption"1141 if actual_size > median_size + 4 and bold:1142 return "heading1"1143 if actual_size > median_size + 2 and bold:1144 return "heading2"1145 if bold and actual_size >= median_size:1146 return "heading3"1147 return "body"1148 1149 # ── 4. Build raw palette entries ──────────────────────────────1150 raw_entries: list[tuple[str, tuple, list[dict]]] = [] # (role, fp, recs)1151 for fp, recs in significant.items():1152 role = _infer_role(fp, recs)1153 raw_entries.append((role, fp, recs))1154 1155 # ── 5. Deduplicate roles — keep the largest/boldest per role ──1156 role_candidates: dict[str, list[tuple[tuple, list[dict]]]] = defaultdict(list)1157 for role, fp, recs in raw_entries:1158 role_candidates[role].append((fp, recs))1159 1160 def _fp_score(fp: tuple, recs: list[dict]) -> float:1161 """Higher = more prominent formatting."""1162 _, size_rounded, bold, italic, _ = fp1163 return (size_rounded or 0) * 10 + (5 if bold else 0) + len(recs) * 0.11164 1165 palette_entries: list[TemplatePaletteEntry] = []1166 for role, candidates in role_candidates.items():1167 # Pick the one with the highest score1168 best_fp, best_recs = max(candidates, key=lambda x: _fp_score(x[0], x[1]))1169 _, size_rounded, bold, italic, align = best_fp1170 1171 # Best style name for this group1172 style_counts = Counter(r["_style_name"] for r in best_recs)1173 best_style_name = style_counts.most_common(1)[0][0]1174 1175 # Map style_name to a proper Word heading style if we're using a size heuristic1176 # (i.e. the style is Normal/Body Text but we inferred heading from size)1177 style_lower = best_style_name.lower()1178 if role == "heading1" and "heading" not in style_lower and "title" not in style_lower:1179 best_style_name = "Heading 1"1180 elif role == "heading2" and "heading" not in style_lower:1181 best_style_name = "Heading 2"1182 elif role == "heading3" and "heading" not in style_lower:1183 best_style_name = "Heading 3"1184 elif role == "heading4" and "heading" not in style_lower:1185 best_style_name = "Heading 4"1186 elif role == "title" and "title" not in style_lower:1187 best_style_name = "Title"1188 1189 # Resolve full props from the most representative record1190 rep = best_recs[0]1191 palette_entries.append(TemplatePaletteEntry(1192 role = role,1193 style_name = best_style_name,1194 font = rep.get("font"),1195 size = rep.get("size"),1196 bold = bool(bold),1197 italic = bool(italic),1198 align = rep.get("align"),1199 space_before = rep.get("space_before"),1200 space_after = rep.get("space_after"),