wechat-robot-skills/skills/docx/scripts/_tracked_replace.py

98 lines
4.2 KiB
Python

"""Tracked replacements for contiguous plain-text runs, retaining each run's style."""
from __future__ import annotations
import re
import zipfile
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from _docx_common import parse_xml_bytes, qn
class TrackedReplacement:
def __init__(self, source: Path, author: str):
self.author = author.strip()
if not self.author:
raise ValueError("修订作者不能为空")
self.date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
self.next_id = 0
self.count = 0
with zipfile.ZipFile(source) as archive:
for name in archive.namelist():
if name.startswith("word/") and name.endswith(".xml"):
root = parse_xml_bytes(archive.read(name), label=name)
for element in root.iter():
value = element.get(qn("id"), "")
if value.isdecimal():
self.next_id = max(self.next_id, int(value) + 1)
def _revision(self, name: str) -> Any:
from docx.oxml import OxmlElement
element = OxmlElement(f"w:{name}")
element.set(qn("id"), str(self.next_id))
element.set(qn("author"), self.author)
element.set(qn("date"), self.date)
self.next_id += 1
return element
@staticmethod
def _fragment(run: Any, paragraph: Any, text: str, *, deleted: bool = False) -> Any:
from docx.text.run import Run
element = deepcopy(run._r)
Run(element, paragraph).text = text
if deleted:
for node in element.findall(qn("t")):
node.tag = qn("delText")
return element
def replace(self, paragraph: Any, matches: list[re.Match[str]], replacement: str) -> None:
if any(char in replacement for char in "\n\r\t"):
raise ValueError("修订替换仅支持段落内文本;换行或制表符需要单独编辑")
# Edits run backwards. Earlier offsets remain valid in the remaining plain runs.
for match in reversed(matches):
start, end = match.span()
runs = paragraph.runs
spans = []
offset = 0
for run in runs:
spans.append((offset, offset + len(run.text)))
offset += len(run.text)
first = next(i for i, (_, b) in enumerate(spans) if start < b)
last = next(i for i, (a, b) in enumerate(spans) if a < end <= b)
selected = runs[first:last + 1]
children = list(paragraph._p)
left = children.index(selected[0]._r)
right = children.index(selected[-1]._r)
if children[left:right + 1] != [run._r for run in selected]:
raise ValueError("修订目标跨越书签、批注或其他非文本节点,请缩小替换范围")
if any(child.tag not in {qn("rPr"), qn("t")} for run in selected for child in run._r):
raise ValueError("修订目标包含域、图片、换行或其他复杂节点,请使用精确批注或缩小范围")
prefix = selected[0].text[:start - spans[first][0]]
suffix = selected[-1].text[end - spans[last][0]:]
nodes = []
if prefix:
nodes.append(self._fragment(selected[0], paragraph, prefix))
deletion = self._revision("del")
for i in range(first, last + 1):
a, b = spans[i]
text = runs[i].text[max(start - a, 0):min(end, b) - a]
if text:
deletion.append(self._fragment(runs[i], paragraph, text, deleted=True))
nodes.append(deletion)
if replacement:
insertion = self._revision("ins")
insertion.append(self._fragment(selected[0], paragraph, replacement))
nodes.append(insertion)
if suffix:
nodes.append(self._fragment(selected[-1], paragraph, suffix))
for run in selected:
paragraph._p.remove(run._r)
for index, node in enumerate(nodes, left):
paragraph._p.insert(index, node)
self.count += 1