scripts/docx_common.py
scripts/docx_common.pyBrowse 12 files
754 tokens
3,364 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32# MIT License. Shared helpers for the docx skill scripts.3"""Shared helpers: paragraph iteration and run-preserving text replacement."""4from __future__ import annotations5 6 7def iter_all_paragraphs(doc, include_headers_footers: bool = True):8 """Yield every paragraph in body, tables (recursively), headers, footers."""9 yield from _iter_container(doc)10 if include_headers_footers:11 for section in doc.sections:12 for part in (13 section.header, section.footer,14 section.first_page_header, section.first_page_footer,15 section.even_page_header, section.even_page_footer,16 ):17 if part is not None:18 yield from _iter_container(part)19 20 21def _iter_container(container):22 for para in container.paragraphs:23 yield para24 for table in container.tables:25 yield from _iter_table(table)26 27 28def _iter_table(table):29 for row in table.rows:30 for cell in row.cells:31 for para in cell.paragraphs:32 yield para33 for nested in cell.tables:34 yield from _iter_table(nested)35 36 37def iter_part_roots(doc):38 """Yield the XML root of the body plus every header/footer part."""39 yield doc.element.body40 seen = set()41 for section in doc.sections:42 for part in (43 section.header, section.footer,44 section.first_page_header, section.first_page_footer,45 section.even_page_header, section.even_page_footer,46 ):47 if part is not None and id(part._element) not in seen:48 seen.add(id(part._element))49 yield part._element50 51 52def replace_in_paragraph(para, old: str, new: str) -> int:53 """Replace `old` with `new` in a paragraph, preserving run formatting.54 55 Strategy: first replace occurrences fully contained in a single run56 (formatting fully preserved). If the needle spans multiple runs, the57 matched runs are collapsed: the replacement inherits the formatting of58 the run where the match starts. Returns number of replacements made.59 """60 if not old or old not in para.text:61 return 062 count = 063 # Pass 1: within-run replacements.64 for run in para.runs:65 if old in run.text:66 count += run.text.count(old)67 run.text = run.text.replace(old, new)68 # Pass 2: cross-run occurrences.69 while old in para.text:70 runs = para.runs71 # Map paragraph text offsets to (run_index, offset_in_run).72 full = "".join(r.text for r in runs)73 start = full.find(old)74 if start < 0:75 break76 end = start + len(old)77 pos = 078 spans = [] # (run_idx, cut_start, cut_end) portions inside the match79 for i, r in enumerate(runs):80 r_start, r_end = pos, pos + len(r.text)81 if r_end > start and r_start < end:82 spans.append((i, max(start, r_start) - r_start,83 min(end, r_end) - r_start))84 pos = r_end85 first = True86 for i, cs, ce in spans:87 t = runs[i].text88 if first:89 runs[i].text = t[:cs] + new + t[ce:]90 first = False91 else:92 runs[i].text = t[:cs] + t[ce:]93 count += 194 return count95