95 lines
3.3 KiB
Python
95 lines
3.3 KiB
Python
#!/usr/bin/env python3
|
|
# MIT License. Shared helpers for the docx skill scripts.
|
|
"""Shared helpers: paragraph iteration and run-preserving text replacement."""
|
|
from __future__ import annotations
|
|
|
|
|
|
def iter_all_paragraphs(doc, include_headers_footers: bool = True):
|
|
"""Yield every paragraph in body, tables (recursively), headers, footers."""
|
|
yield from _iter_container(doc)
|
|
if include_headers_footers:
|
|
for section in doc.sections:
|
|
for part in (
|
|
section.header, section.footer,
|
|
section.first_page_header, section.first_page_footer,
|
|
section.even_page_header, section.even_page_footer,
|
|
):
|
|
if part is not None:
|
|
yield from _iter_container(part)
|
|
|
|
|
|
def _iter_container(container):
|
|
for para in container.paragraphs:
|
|
yield para
|
|
for table in container.tables:
|
|
yield from _iter_table(table)
|
|
|
|
|
|
def _iter_table(table):
|
|
for row in table.rows:
|
|
for cell in row.cells:
|
|
for para in cell.paragraphs:
|
|
yield para
|
|
for nested in cell.tables:
|
|
yield from _iter_table(nested)
|
|
|
|
|
|
def iter_part_roots(doc):
|
|
"""Yield the XML root of the body plus every header/footer part."""
|
|
yield doc.element.body
|
|
seen = set()
|
|
for section in doc.sections:
|
|
for part in (
|
|
section.header, section.footer,
|
|
section.first_page_header, section.first_page_footer,
|
|
section.even_page_header, section.even_page_footer,
|
|
):
|
|
if part is not None and id(part._element) not in seen:
|
|
seen.add(id(part._element))
|
|
yield part._element
|
|
|
|
|
|
def replace_in_paragraph(para, old: str, new: str) -> int:
|
|
"""Replace `old` with `new` in a paragraph, preserving run formatting.
|
|
|
|
Strategy: first replace occurrences fully contained in a single run
|
|
(formatting fully preserved). If the needle spans multiple runs, the
|
|
matched runs are collapsed: the replacement inherits the formatting of
|
|
the run where the match starts. Returns number of replacements made.
|
|
"""
|
|
if not old or old not in para.text:
|
|
return 0
|
|
count = 0
|
|
# Pass 1: within-run replacements.
|
|
for run in para.runs:
|
|
if old in run.text:
|
|
count += run.text.count(old)
|
|
run.text = run.text.replace(old, new)
|
|
# Pass 2: cross-run occurrences.
|
|
while old in para.text:
|
|
runs = para.runs
|
|
# Map paragraph text offsets to (run_index, offset_in_run).
|
|
full = "".join(r.text for r in runs)
|
|
start = full.find(old)
|
|
if start < 0:
|
|
break
|
|
end = start + len(old)
|
|
pos = 0
|
|
spans = [] # (run_idx, cut_start, cut_end) portions inside the match
|
|
for i, r in enumerate(runs):
|
|
r_start, r_end = pos, pos + len(r.text)
|
|
if r_end > start and r_start < end:
|
|
spans.append((i, max(start, r_start) - r_start,
|
|
min(end, r_end) - r_start))
|
|
pos = r_end
|
|
first = True
|
|
for i, cs, ce in spans:
|
|
t = runs[i].text
|
|
if first:
|
|
runs[i].text = t[:cs] + new + t[ce:]
|
|
first = False
|
|
else:
|
|
runs[i].text = t[:cs] + t[ce:]
|
|
count += 1
|
|
return count
|