CoolFace
Apppublic

FaizanMirZa77/FormatX

sourceHugging Faceupdated 13d agoView on Hugging Face
0likes
document_assembler.py1912 linesDownload Raw Back to root
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="&#x2022;"/><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="&#xF0A7;"/><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)

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