#!/usr/bin/env python3 from __future__ import annotations import argparse import os import shutil import subprocess import tempfile import zipfile from pathlib import Path from typing import Any from _docx_common import ( DOCX_INPUT_SUFFIXES, SkillArgumentParser, find_program, input_file, inspect_archive, office_profile_uri, output_file, parse_xml_bytes, publish_file, rewrite_docx_parts, run_cli, run_program, ) MACRO = """ Sub AcceptAllTrackedChanges() Dim frame As Object Dim dispatcher As Object frame = ThisComponent.CurrentController.Frame dispatcher = createUnoService("com.sun.star.frame.DispatchHelper") dispatcher.executeDispatch(frame, ".uno:AcceptAllTrackedChanges", "", 0, Array()) ThisComponent.store() ThisComponent.close(True) End Sub """ SCRIPT_XLB = """ """ SCRIPT_XLC = """ """ def _timeout_text(value: Any) -> str: if value is None: return "" if isinstance(value, bytes): return value.decode("utf-8", errors="replace")[-2000:] return str(value)[-2000:] def _unwrap(element: Any) -> None: parent = element.getparent() if parent is None: return index = parent.index(element) for child in list(element): element.remove(child) parent.insert(index, child) index += 1 parent.remove(element) def _remove(element: Any) -> None: parent = element.getparent() if parent is not None: parent.remove(element) def _accept_revisions_in_xml(payload: bytes) -> tuple[bytes, int]: from lxml import etree namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" ns = {"w": namespace} parser = etree.XMLParser( resolve_entities=False, no_network=True, recover=False, huge_tree=False, remove_comments=False, ) root = etree.fromstring(payload, parser=parser) changes = 0 # 接受被删除的表格行和单元格;插入标记稍后作为普通 w:ins 解包。 for row in list(root.xpath(".//w:tr[w:trPr/w:del]", namespaces=ns)): _remove(row) changes += 1 for cell in list(root.xpath(".//w:tc[w:tcPr/w:cellDel]", namespaces=ns)): _remove(cell) changes += 1 # 删除段落标记表示把当前段落与下一段合并。 while True: paragraphs = root.xpath(".//w:p[w:pPr/w:rPr/w:del]", namespaces=ns) if not paragraphs: break paragraph = paragraphs[0] marker = paragraph.find("./w:pPr/w:rPr/w:del", namespaces=ns) next_paragraph = paragraph.getnext() if ( next_paragraph is not None and next_paragraph.tag == f"{{{namespace}}}p" ): for child in list(next_paragraph): if child.tag == f"{{{namespace}}}pPr": continue next_paragraph.remove(child) paragraph.append(child) _remove(next_paragraph) if marker is not None: _remove(marker) changes += 1 # 删除修订内容和移动来源。 for expression in (".//w:del", ".//w:moveFrom"): for element in list(root.xpath(expression, namespaces=ns)): _remove(element) changes += 1 # 保留插入内容和移动目标,去掉外层修订容器。 for expression in (".//w:ins", ".//w:moveTo"): for element in list(root.xpath(expression, namespaces=ns)): _unwrap(element) changes += 1 removable_names = { "pPrChange", "rPrChange", "tblPrChange", "tblGridChange", "trPrChange", "tcPrChange", "sectPrChange", "numberingChange", "cellIns", "cellDel", "cellMerge", "moveFromRangeStart", "moveFromRangeEnd", "moveToRangeStart", "moveToRangeEnd", "customXmlInsRangeStart", "customXmlInsRangeEnd", "customXmlDelRangeStart", "customXmlDelRangeEnd", } for local_name in removable_names: for element in list( root.xpath(f".//w:{local_name}", namespaces=ns) ): _remove(element) changes += 1 # malformed producers sometimes leave delText outside w:del; accepted view treats it as text. for element in root.xpath(".//w:delText", namespaces=ns): element.tag = f"{{{namespace}}}t" changes += 1 return ( etree.tostring( root, xml_declaration=True, encoding="UTF-8", standalone=True, ), changes, ) def _accept_with_ooxml(source: Path, destination: Path) -> int: replacements: dict[str, bytes] = {} changes = 0 with zipfile.ZipFile(source) as archive: for name in archive.namelist(): if not name.startswith("word/") or not name.endswith(".xml"): continue rewritten, part_changes = _accept_revisions_in_xml(archive.read(name)) if part_changes: replacements[name] = rewritten changes += part_changes rewrite_docx_parts(source, destination, replacements) return changes def _revision_count(path: Path) -> int: revision_tags = { "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + name for name in ( "ins", "del", "moveFrom", "moveTo", "pPrChange", "rPrChange", "tblPrChange", "trPrChange", "tcPrChange", "sectPrChange", ) } count = 0 with zipfile.ZipFile(path) as archive: for name in archive.namelist(): if not name.startswith("word/") or not name.endswith(".xml"): continue root = parse_xml_bytes(archive.read(name), label=name) count += sum(1 for element in root.iter() if element.tag in revision_tags) return count def build_parser() -> argparse.ArgumentParser: parser = SkillArgumentParser( description="通过固定 LibreOffice 宏接受 Word 文档中的全部修订。" ) parser.add_argument("--input", required=True) parser.add_argument("--output", required=True) parser.add_argument("--timeout", type=int, default=90) parser.add_argument("--overwrite", action="store_true") return parser def main() -> dict[str, Any]: from docx import Document args = build_parser().parse_args() source = input_file(args.input, DOCX_INPUT_SUFFIXES) destination = output_file( args.output, {".docx"}, overwrite=args.overwrite, ) if source == destination: raise ValueError("输出路径不能与输入文件相同") before = _revision_count(source) with tempfile.TemporaryDirectory(prefix="docx-accept-") as temp_name: temp_dir = Path(temp_name) staged = temp_dir / "accepted.docx" shutil.copy2(source, staged) stdout = "" stderr = "" timed_out_after_save = False macro_failed = False engine = "copy" if before: engine = "libreoffice" soffice = find_program("soffice", "libreoffice") profile = temp_dir / "profile" profile.mkdir() cache_dir = profile / "cache" cache_dir.mkdir() process_env = os.environ.copy() process_env["XDG_CACHE_HOME"] = str(cache_dir) try: run_program( [ soffice, f"-env:UserInstallation={office_profile_uri(profile)}", "--headless", "--nologo", "--nodefault", "--nolockcheck", "--nofirststartwizard", "--terminate_after_init", ], timeout=min(args.timeout, 30), env=process_env, ) except Exception: # 部分 LibreOffice 构建不支持 terminate_after_init;后续宏调用仍可初始化配置。 pass macro_dir = profile / "user" / "basic" / "Standard" macro_dir.mkdir(parents=True, exist_ok=True) (macro_dir / "Module1.xba").write_text(MACRO, encoding="utf-8") (macro_dir / "script.xlb").write_text( SCRIPT_XLB, encoding="utf-8", ) (profile / "user" / "basic" / "script.xlc").write_text( SCRIPT_XLC, encoding="utf-8", ) command = [ soffice, f"-env:UserInstallation={office_profile_uri(profile)}", "--headless", "--nologo", "--nodefault", "--nolockcheck", "--nofirststartwizard", "vnd.sun.star.script:Standard.Module1.AcceptAllTrackedChanges" "?language=Basic&location=application", str(staged), ] try: completed = subprocess.run( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=min(args.timeout, 30), check=False, env=process_env, ) stdout = completed.stdout[-2000:] stderr = completed.stderr[-2000:] if completed.returncode != 0: macro_failed = True except subprocess.TimeoutExpired as exc: stdout = _timeout_text(exc.stdout) stderr = _timeout_text(exc.stderr) timed_out_after_save = True after = _revision_count(staged) if after: fallback = temp_dir / "accepted-ooxml.docx" _accept_with_ooxml(staged, fallback) shutil.copy2(fallback, staged) engine = "ooxml-fallback" after = _revision_count(staged) if after: raise RuntimeError( f"接受修订后仍检测到 {after} 个修订标记;未发布结果" ) archive = inspect_archive(staged) Document(str(staged)) publish_source = temp_dir / "publish.docx" shutil.copy2(staged, publish_source) publish_file(publish_source, destination, overwrite=args.overwrite) return { "path": str(destination), "source": str(source), "revision_markers_before": before, "revision_markers_after": after, "status": "success", "engine": engine, "libreoffice_macro_failed": macro_failed, "office_timed_out_after_save": timed_out_after_save, "office_stdout": stdout, "office_stderr": stderr, "archive": archive, "requires_visual_review": True, } if __name__ == "__main__": raise SystemExit(run_cli(main))