#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import shutil import stat import subprocess import tempfile import zipfile from datetime import date, datetime, time from pathlib import Path, PurePosixPath from typing import Any, Callable, NoReturn, Optional from urllib.parse import quote DOCX_INPUT_SUFFIXES = {".docx", ".dotx"} WORD_INPUT_SUFFIXES = DOCX_INPUT_SUFFIXES | {".doc"} DOCX_OUTPUT_SUFFIXES = {".docx", ".dotx"} DOCUMENT_OUTPUT_SUFFIXES = DOCX_OUTPUT_SUFFIXES | {".pdf", ".txt", ".md"} MAX_ARCHIVE_MEMBERS = 20_000 MAX_ARCHIVE_UNCOMPRESSED_BYTES = 512 * 1024 * 1024 MAX_MEMBER_BYTES = 128 * 1024 * 1024 W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types" XML_NS = "http://www.w3.org/XML/1998/namespace" NS = {"w": W_NS, "r": R_NS} class SkillArgumentParser(argparse.ArgumentParser): def error(self, message: str) -> NoReturn: raise ValueError(f"参数错误:{message}") def emit(payload: dict[str, Any]) -> None: print(json.dumps(payload, ensure_ascii=False, default=json_default)) def json_default(value: Any) -> Any: if isinstance(value, (datetime, date, time)): return value.isoformat() if isinstance(value, Path): return str(value) return str(value) def failure_message(exc: Exception) -> str: if isinstance(exc, (FileExistsError, FileNotFoundError)): return str(exc) if isinstance(exc, PermissionError): return "文件处理失败:没有目标路径的访问权限" if isinstance(exc, subprocess.TimeoutExpired): return f"外部程序执行超时({exc.timeout} 秒)" if isinstance(exc, subprocess.CalledProcessError): stderr = (exc.stderr or "").strip() return f"外部程序执行失败:{stderr or exc}" if isinstance(exc, zipfile.BadZipFile): return "Word 文件不是有效的 OOXML ZIP 压缩包" if isinstance(exc, OSError): return f"文件处理失败:{exc}" return str(exc) def run_cli(action: Callable[[], dict[str, Any]]) -> int: try: result = action() except Exception as exc: emit({"ok": False, "error": failure_message(exc)}) return 1 emit({"ok": True, **result}) return 0 def input_file(value: str, suffixes: Optional[set[str]] = None) -> Path: path = Path(value).expanduser().resolve() if not path.exists(): raise FileNotFoundError(f"输入文件不存在:{path}") if not path.is_file(): raise ValueError(f"输入路径不是文件:{path}") if path.stat().st_size <= 0: raise ValueError(f"输入文件为空:{path}") if suffixes is not None and path.suffix.lower() not in suffixes: expected = "、".join(sorted(suffixes)) raise ValueError(f"不支持的输入格式 {path.suffix};允许:{expected}") return path def output_file( value: str, suffixes: Optional[set[str]] = None, *, overwrite: bool = False, ) -> Path: path = Path(value).expanduser().resolve() if suffixes is not None and path.suffix.lower() not in suffixes: expected = "、".join(sorted(suffixes)) raise ValueError(f"不支持的输出格式 {path.suffix};允许:{expected}") if path.exists() and not overwrite: raise FileExistsError(f"目标文件已存在:{path}") if path.exists() and not path.is_file(): raise ValueError(f"目标路径不是文件:{path}") path.parent.mkdir(parents=True, exist_ok=True) return path def output_directory(value: str) -> Path: path = Path(value).expanduser().resolve() if path.exists() and not path.is_dir(): raise ValueError(f"输出路径不是目录:{path}") path.mkdir(parents=True, exist_ok=True) return path def publish_file(source: Path, destination: Path, *, overwrite: bool) -> None: destination.parent.mkdir(parents=True, exist_ok=True) if overwrite: os.replace(source, destination) return try: os.link(source, destination) except FileExistsError as exc: raise FileExistsError(f"目标文件已存在:{destination}") from exc except OSError: if destination.exists(): raise FileExistsError(f"目标文件已存在:{destination}") shutil.copy2(source, destination) finally: if source.exists(): source.unlink() def load_json_argument( inline_value: Optional[str], file_value: Optional[str], *, label: str, max_bytes: int = 2 * 1024 * 1024, ) -> dict[str, Any]: if bool(inline_value) == bool(file_value): raise ValueError(f"{label} 必须且只能通过内联 JSON 或 JSON 文件提供一次") if file_value: source = input_file(file_value, {".json"}) if source.stat().st_size > max_bytes: raise ValueError(f"{label} JSON 文件超过 {max_bytes} 字节限制") raw = source.read_text(encoding="utf-8") else: raw = inline_value or "" if len(raw.encode("utf-8")) > max_bytes: raise ValueError(f"{label} 内联 JSON 超过 {max_bytes} 字节限制") try: parsed = json.loads(raw) except json.JSONDecodeError as exc: raise ValueError( f"{label} JSON 无效:第 {exc.lineno} 行第 {exc.colno} 列,{exc.msg}" ) from exc if not isinstance(parsed, dict): raise ValueError(f"{label} JSON 顶层必须是对象") return parsed def find_program(*names: str) -> str: for name in names: resolved = shutil.which(name) if resolved: return resolved raise FileNotFoundError(f"运行环境缺少命令:{' / '.join(names)}") def office_profile_uri(profile: Path) -> str: return "file://" + quote(str(profile.resolve()), safe="/:") def run_program( args: list[str], *, timeout: int, cwd: Optional[Path] = None, env: Optional[dict[str, str]] = None, ) -> subprocess.CompletedProcess[str]: if timeout < 1 or timeout > 900: raise ValueError("timeout 必须在 1 到 900 秒之间") completed = subprocess.run( args, cwd=str(cwd) if cwd else None, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout, check=False, ) if completed.returncode != 0: raise subprocess.CalledProcessError( completed.returncode, args, output=completed.stdout, stderr=completed.stderr, ) return completed def run_soffice_convert( source: Path, *, target_format: str, output_dir: Path, timeout: int, filter_name: Optional[str] = None, ) -> tuple[Path, dict[str, str]]: soffice = find_program("soffice", "libreoffice") output_dir.mkdir(parents=True, exist_ok=True) profile = Path(tempfile.mkdtemp(prefix="docx-soffice-profile-")) try: cache_dir = profile / "cache" cache_dir.mkdir() process_env = os.environ.copy() process_env["XDG_CACHE_HOME"] = str(cache_dir) convert_arg = target_format if filter_name: convert_arg = f"{target_format}:{filter_name}" completed = run_program( [ soffice, f"-env:UserInstallation={office_profile_uri(profile)}", "--headless", "--nologo", "--nodefault", "--nolockcheck", "--nofirststartwizard", "--convert-to", convert_arg, "--outdir", str(output_dir), str(source), ], timeout=timeout, env=process_env, ) expected = output_dir / f"{source.stem}.{target_format}" if not expected.exists(): candidates = sorted(output_dir.glob(f"{source.stem}.*")) if len(candidates) == 1: expected = candidates[0] else: raise RuntimeError( "LibreOffice 未生成预期文件;" f"stdout={completed.stdout[-1000:]!r} " f"stderr={completed.stderr[-1000:]!r}" ) return expected, { "stdout": completed.stdout[-2000:], "stderr": completed.stderr[-2000:], } finally: shutil.rmtree(profile, ignore_errors=True) def _safe_archive_name(name: str) -> PurePosixPath: candidate = PurePosixPath(name) if ( candidate.is_absolute() or not candidate.parts or ".." in candidate.parts or candidate.parts[0].endswith(":") ): raise ValueError(f"OOXML 压缩包含不安全路径:{name}") return candidate def _zip_member_is_symlink(info: zipfile.ZipInfo) -> bool: mode = (info.external_attr >> 16) & 0xFFFF return stat.S_ISLNK(mode) def inspect_archive(path: Path) -> dict[str, Any]: total_size = 0 xml_count = 0 media_count = 0 with zipfile.ZipFile(path) as archive: infos = archive.infolist() if len(infos) > MAX_ARCHIVE_MEMBERS: raise ValueError( f"OOXML 压缩包成员过多:{len(infos)} > {MAX_ARCHIVE_MEMBERS}" ) seen: set[str] = set() duplicates: list[str] = [] for info in infos: _safe_archive_name(info.filename) if _zip_member_is_symlink(info): raise ValueError(f"OOXML 压缩包含符号链接:{info.filename}") if info.file_size > MAX_MEMBER_BYTES: raise ValueError(f"OOXML 成员过大:{info.filename}") total_size += info.file_size if total_size > MAX_ARCHIVE_UNCOMPRESSED_BYTES: raise ValueError("OOXML 解压后总大小超过安全限制") if info.filename in seen: duplicates.append(info.filename) seen.add(info.filename) if info.filename.endswith((".xml", ".rels")): xml_count += 1 if info.filename.startswith("word/media/") and not info.is_dir(): media_count += 1 required = { "[Content_Types].xml", "_rels/.rels", "word/document.xml", } missing = sorted(required - seen) return { "member_count": len(infos), "uncompressed_bytes": total_size, "xml_part_count": xml_count, "media_count": media_count, "duplicate_members": duplicates, "missing_required_parts": missing, } def safe_extract_docx(source: Path, destination: Path) -> dict[str, Any]: archive_info = inspect_archive(source) if archive_info["missing_required_parts"]: raise ValueError( "Word 文件缺少必要部件:" + "、".join(archive_info["missing_required_parts"]) ) destination.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(source) as archive: for info in archive.infolist(): relative = _safe_archive_name(info.filename) target = destination.joinpath(*relative.parts) resolved = target.resolve() if destination.resolve() not in resolved.parents and resolved != destination.resolve(): raise ValueError(f"OOXML 成员逃逸输出目录:{info.filename}") if info.is_dir(): target.mkdir(parents=True, exist_ok=True) continue target.parent.mkdir(parents=True, exist_ok=True) with archive.open(info, "r") as source_handle, target.open("wb") as target_handle: shutil.copyfileobj(source_handle, target_handle) return archive_info def pack_docx_directory(source_dir: Path, destination: Path) -> dict[str, Any]: source_dir = source_dir.expanduser().resolve() if not source_dir.exists() or not source_dir.is_dir(): raise ValueError(f"待打包目录不存在或不是目录:{source_dir}") required = { source_dir / "[Content_Types].xml", source_dir / "_rels" / ".rels", source_dir / "word" / "document.xml", } missing = sorted(str(path.relative_to(source_dir)) for path in required if not path.is_file()) if missing: raise ValueError("待打包目录缺少必要部件:" + "、".join(missing)) files: list[Path] = [] total_size = 0 for path in sorted(source_dir.rglob("*")): if path.is_symlink(): raise ValueError(f"待打包目录包含符号链接:{path}") if path.is_file(): total_size += path.stat().st_size if path.stat().st_size > MAX_MEMBER_BYTES: raise ValueError(f"待打包文件过大:{path}") if total_size > MAX_ARCHIVE_UNCOMPRESSED_BYTES: raise ValueError("待打包文件总大小超过安全限制") files.append(path) if len(files) > MAX_ARCHIVE_MEMBERS: raise ValueError("待打包文件数量超过安全限制") with zipfile.ZipFile( destination, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6, ) as archive: for path in files: archive.write(path, path.relative_to(source_dir).as_posix()) return inspect_archive(destination) def rewrite_docx_parts( source: Path, destination: Path, replacements: dict[str, bytes], ) -> None: inspect_archive(source) with zipfile.ZipFile(source, "r") as incoming, zipfile.ZipFile( destination, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6, ) as outgoing: existing = set() for info in incoming.infolist(): existing.add(info.filename) payload = replacements.get(info.filename) if payload is None: payload = incoming.read(info.filename) outgoing.writestr(info, payload) for name, payload in replacements.items(): if name not in existing: outgoing.writestr(name, payload) def qn(local_name: str) -> str: return f"{{{W_NS}}}{local_name}" def parse_xml_bytes(payload: bytes, *, label: str = "XML") -> Any: try: from defusedxml import ElementTree as ET from defusedxml.common import DefusedXmlException try: return ET.fromstring(payload) except (ET.ParseError, DefusedXmlException, ValueError) as exc: raise ValueError(f"{label} 解析失败:{exc}") from exc except ModuleNotFoundError: from lxml import etree parser = etree.XMLParser( resolve_entities=False, no_network=True, recover=False, huge_tree=False, remove_comments=False, ) try: return etree.fromstring(payload, parser=parser) except (etree.XMLSyntaxError, ValueError) as exc: raise ValueError(f"{label} 解析失败:{exc}") from exc