wechat-robot-skills/skills/docx/scripts/validate_document.py
2026-07-26 02:45:00 +08:00

387 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import posixpath
import shutil
import tempfile
import zipfile
from pathlib import Path, PurePosixPath
from typing import Any, Optional
from urllib.parse import unquote
from _docx_common import (
CONTENT_TYPES_NS,
DOCX_INPUT_SUFFIXES,
REL_NS,
SkillArgumentParser,
W_NS,
input_file,
inspect_archive,
parse_xml_bytes,
run_cli,
run_soffice_convert,
)
def _parse_xml_parts(
archive: zipfile.ZipFile,
) -> tuple[dict[str, Any], list[dict[str, str]]]:
roots: dict[str, Any] = {}
issues: list[dict[str, str]] = []
for name in archive.namelist():
if not name.endswith((".xml", ".rels")):
continue
try:
roots[name] = parse_xml_bytes(archive.read(name), label=name)
except ValueError as exc:
issues.append(
{
"type": "xml_parse_error",
"part": name,
"message": str(exc),
}
)
return roots, issues
def _relationship_source(rels_name: str) -> PurePosixPath:
path = PurePosixPath(rels_name)
if rels_name == "_rels/.rels":
return PurePosixPath("")
if path.parent.name != "_rels" or not path.name.endswith(".rels"):
return PurePosixPath("")
source_name = path.name[: -len(".rels")]
return path.parent.parent / source_name
def _resolve_target(source_part: PurePosixPath, target: str) -> Optional[str]:
if not target or target.startswith("#"):
return None
target = unquote(target).replace("\\", "/")
if target.startswith("/"):
normalized = posixpath.normpath(target.lstrip("/"))
else:
base = str(source_part.parent) if str(source_part) else ""
normalized = posixpath.normpath(posixpath.join(base, target))
if normalized == ".." or normalized.startswith("../"):
raise ValueError("关系目标逃逸 OOXML 包根目录")
return normalized.lstrip("./")
def _validate_relationships(
roots: dict[str, Any],
member_names: set[str],
) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
relationship_tag = f"{{{REL_NS}}}Relationship"
for name, root in roots.items():
if not name.endswith(".rels"):
continue
source = _relationship_source(name)
seen_ids: set[str] = set()
for relationship in root.findall(relationship_tag):
relationship_id = relationship.attrib.get("Id", "")
if not relationship_id:
issues.append(
{
"type": "relationship_missing_id",
"part": name,
"message": "Relationship 缺少 Id",
}
)
elif relationship_id in seen_ids:
issues.append(
{
"type": "duplicate_relationship_id",
"part": name,
"message": relationship_id,
}
)
seen_ids.add(relationship_id)
if relationship.attrib.get("TargetMode") == "External":
continue
try:
target = _resolve_target(
source,
relationship.attrib.get("Target", ""),
)
except ValueError as exc:
issues.append(
{
"type": "unsafe_relationship_target",
"part": name,
"message": str(exc),
}
)
continue
if target and target not in member_names:
issues.append(
{
"type": "missing_relationship_target",
"part": name,
"message": target,
}
)
return issues
def _validate_content_types(
roots: dict[str, Any],
member_names: set[str],
) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
root = roots.get("[Content_Types].xml")
if root is None:
return issues
override_tag = f"{{{CONTENT_TYPES_NS}}}Override"
seen_parts: set[str] = set()
for override in root.findall(override_tag):
part = override.attrib.get("PartName", "").lstrip("/")
if not part:
issues.append(
{
"type": "content_type_missing_part",
"part": "[Content_Types].xml",
"message": "Override 缺少 PartName",
}
)
continue
if part in seen_parts:
issues.append(
{
"type": "duplicate_content_type_override",
"part": "[Content_Types].xml",
"message": part,
}
)
seen_parts.add(part)
if part not in member_names:
issues.append(
{
"type": "missing_content_type_part",
"part": "[Content_Types].xml",
"message": part,
}
)
return issues
def _validate_comments(roots: dict[str, Any]) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
comments_root = roots.get("word/comments.xml")
document_root = roots.get("word/document.xml")
if document_root is None:
return issues
comment_ids: set[str] = set()
if comments_root is not None:
for comment in comments_root.iter(f"{{{W_NS}}}comment"):
comment_id = comment.attrib.get(f"{{{W_NS}}}id")
if comment_id:
comment_ids.add(comment_id)
starts: list[str] = []
ends: list[str] = []
references: list[str] = []
for element in document_root.iter():
comment_id = element.attrib.get(f"{{{W_NS}}}id")
if element.tag == f"{{{W_NS}}}commentRangeStart" and comment_id:
starts.append(comment_id)
elif element.tag == f"{{{W_NS}}}commentRangeEnd" and comment_id:
ends.append(comment_id)
elif element.tag == f"{{{W_NS}}}commentReference" and comment_id:
references.append(comment_id)
for comment_id in sorted(set(starts + ends + references)):
if comment_id not in comment_ids:
issues.append(
{
"type": "undefined_comment_reference",
"part": "word/document.xml",
"message": comment_id,
}
)
if starts.count(comment_id) != ends.count(comment_id):
issues.append(
{
"type": "unbalanced_comment_range",
"part": "word/document.xml",
"message": comment_id,
}
)
if references.count(comment_id) == 0:
issues.append(
{
"type": "missing_comment_reference",
"part": "word/document.xml",
"message": comment_id,
}
)
return issues
def _revision_metadata(roots: dict[str, Any]) -> dict[str, Any]:
revision_tags = {
f"{{{W_NS}}}ins",
f"{{{W_NS}}}del",
f"{{{W_NS}}}moveFrom",
f"{{{W_NS}}}moveTo",
}
total = 0
missing_author = 0
missing_date = 0
authors: set[str] = set()
for name, root in roots.items():
if not name.startswith("word/") or not name.endswith(".xml"):
continue
for element in root.iter():
if element.tag not in revision_tags:
continue
total += 1
author = element.attrib.get(f"{{{W_NS}}}author")
date = element.attrib.get(f"{{{W_NS}}}date")
if author:
authors.add(author)
else:
missing_author += 1
if not date:
missing_date += 1
return {
"total": total,
"authors": sorted(authors),
"missing_author_count": missing_author,
"missing_date_count": missing_date,
}
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="校验 DOCX 压缩包、XML、关系、批注和 LibreOffice 可打开性。"
)
parser.add_argument("--input", required=True)
parser.add_argument(
"--check-convert",
action="store_true",
help="额外用 LibreOffice 转 PDF验证文档可渲染",
)
parser.add_argument("--timeout", type=int, default=120)
return parser
def main() -> dict[str, Any]:
from docx import Document
args = build_parser().parse_args()
source = input_file(args.input, DOCX_INPUT_SUFFIXES)
archive_info = inspect_archive(source)
issues: list[dict[str, str]] = []
for part in archive_info["missing_required_parts"]:
issues.append(
{
"type": "missing_required_part",
"part": part,
"message": part,
}
)
for part in archive_info["duplicate_members"]:
issues.append(
{
"type": "duplicate_zip_member",
"part": part,
"message": part,
}
)
with zipfile.ZipFile(source) as archive:
member_names = set(archive.namelist())
roots, xml_issues = _parse_xml_parts(archive)
issues.extend(xml_issues)
issues.extend(_validate_relationships(roots, member_names))
issues.extend(_validate_content_types(roots, member_names))
issues.extend(_validate_comments(roots))
revisions = _revision_metadata(roots)
if revisions["missing_author_count"]:
issues.append(
{
"type": "revision_missing_author",
"part": "word/*.xml",
"message": str(revisions["missing_author_count"]),
}
)
if revisions["missing_date_count"]:
issues.append(
{
"type": "revision_missing_date",
"part": "word/*.xml",
"message": str(revisions["missing_date_count"]),
}
)
document_summary: dict[str, Any] = {}
try:
document = Document(str(source))
document_summary = {
"paragraph_count": len(document.paragraphs),
"table_count": len(document.tables),
"section_count": len(document.sections),
"comment_count": len(document.comments),
}
except Exception as exc:
issues.append(
{
"type": "python_docx_open_error",
"part": str(source),
"message": str(exc),
}
)
render_check: Optional[dict[str, Any]] = None
if args.check_convert:
try:
from pypdf import PdfReader
with tempfile.TemporaryDirectory(prefix="docx-validate-") as temp_name:
temp_dir = Path(temp_name)
staged = temp_dir / "document.docx"
shutil.copy2(source, staged)
pdf, office_output = run_soffice_convert(
staged,
target_format="pdf",
output_dir=temp_dir / "pdf",
timeout=args.timeout,
)
page_count = len(PdfReader(str(pdf)).pages)
if page_count < 1:
raise ValueError("转换结果没有页面")
render_check = {
"success": True,
"page_count": page_count,
"office_stdout": office_output["stdout"],
"office_stderr": office_output["stderr"],
}
except Exception as exc:
issues.append(
{
"type": "libreoffice_render_error",
"part": str(source),
"message": str(exc),
}
)
render_check = {"success": False, "error": str(exc)}
return {
"path": str(source),
"status": "valid" if not issues else "invalid",
"issue_count": len(issues),
"issues": issues[:200],
"issues_truncated": max(0, len(issues) - 200),
"archive": archive_info,
"document": document_summary,
"tracked_changes": revisions,
"render_check": render_check,
"requires_visual_review": True,
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))