CoolFace
Apppublic

FaizanMirZa77/FormatX

sourceHugging Faceupdated 13d agoView on Hugging Face
0likes
template_parser.py1396 linesDownload Raw Back to root
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"),

Showing the first 1,200 of 1396 lines. Download the file for the rest.