scripts/docx_validate.py
scripts/docx_validate.pyBrowse 12 files
1,594 tokens
6,200 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32# MIT License. Part of the Hermes docx skill.3"""Health-check a .docx package and report issues as JSON.4 5Usage: docx_validate.py file.docx6 7Checks (health-check tier, NOT full XSD schema validation):8 - the file is a readable zip and python-docx can open it9 - required package parts exist ([Content_Types].xml, document.xml)10 - every relationship in every .rels file resolves to a part in the11 package (dangling image/hyperlink/etc. rels are reported; external12 targets such as hyperlinks are skipped)13 - r:embed / r:id references in document.xml resolve to relationships14 - embedded images are non-empty and start with known magic bytes15 (PNG/JPEG/GIF/BMP/TIFF/EMF/WMF/SVG); no PIL required16 - paragraph and run style ids referenced by the document exist in17 styles.xml18 19Output: {"ok": bool, "issues": [{"severity": "error"|"warning", ...}]}20Exit code 1 when any error-severity issue is found (warnings exit 0).21"""22from __future__ import annotations23 24import argparse25import json26import posixpath27import sys28import zipfile29 30from lxml import etree31 32W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"33R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"34PR = "http://schemas.openxmlformats.org/package/2006/relationships"35 36IMAGE_MAGIC = (37 b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff", b"GIF87a", b"GIF89a",38 b"BM", b"II*\x00", b"MM\x00*",39 b"\x01\x00\x00\x00", # EMF40 b"\xd7\xcd\xc6\x9a", b"\x01\x00\x09\x00", # WMF variants41 b"<?xml", b"<svg",42)43 44 45def _issue(issues, severity, code, detail):46 issues.append({"severity": severity, "code": code, "detail": detail})47 48 49def _rel_target(base_part: str, target: str) -> str:50 base_dir = posixpath.dirname(base_part)51 return posixpath.normpath(posixpath.join(base_dir, target)).lstrip("/")52 53 54def validate(path: str) -> dict:55 issues: list[dict] = []56 57 try:58 zf = zipfile.ZipFile(path)59 except (OSError, zipfile.BadZipFile) as exc:60 _issue(issues, "error", "not-a-zip", str(exc))61 return {"ok": False, "issues": issues}62 63 names = set(zf.namelist())64 bad = zf.testzip()65 if bad is not None:66 _issue(issues, "error", "corrupt-member", f"CRC check failed: {bad}")67 68 for required in ("[Content_Types].xml", "word/document.xml"):69 if required not in names:70 _issue(issues, "error", "missing-part",71 f"required part absent: {required}")72 if issues and any(i["severity"] == "error" for i in issues):73 return {"ok": False, "issues": issues}74 75 # --- relationships resolve ------------------------------------------76 rel_ids_by_source: dict[str, dict] = {}77 for rels_name in [n for n in names if n.endswith(".rels")]:78 try:79 root = etree.fromstring(zf.read(rels_name))80 except etree.XMLSyntaxError as exc:81 _issue(issues, "error", "bad-rels-xml", f"{rels_name}: {exc}")82 continue83 source_part = posixpath.normpath(84 posixpath.join(posixpath.dirname(rels_name), ".."))85 source_part = "" if source_part == "." else source_part86 ids = {}87 for rel in root.iter(f"{{{PR}}}Relationship"):88 rid, target = rel.get("Id"), rel.get("Target", "")89 mode = rel.get("TargetMode", "Internal")90 ids[rid] = target91 if mode == "External":92 continue93 resolved = _rel_target(source_part + "/x" if source_part94 else "x", target)95 if resolved not in names:96 _issue(issues, "error", "dangling-rel",97 f"{rels_name}: {rid} -> {target} (missing part)")98 rel_ids_by_source[source_part or "_package"] = ids99 100 # --- r:id / r:embed references in document.xml -----------------------101 doc_root = etree.fromstring(zf.read("word/document.xml"))102 doc_rels = rel_ids_by_source.get("word", {})103 for el in doc_root.iter():104 for attr in (f"{{{R}}}id", f"{{{R}}}embed", f"{{{R}}}link"):105 rid = el.get(attr)106 if rid and rid not in doc_rels:107 _issue(issues, "error", "unresolved-reference",108 f"document.xml references {rid} with no relationship")109 110 # --- embedded images decode ------------------------------------------111 for name in [n for n in names if n.startswith("word/media/")]:112 data = zf.read(name)113 if not data:114 _issue(issues, "error", "empty-image", name)115 elif not any(data.startswith(m) for m in IMAGE_MAGIC):116 _issue(issues, "warning", "unknown-image-format",117 f"{name}: unrecognized magic bytes")118 119 # --- styles referenced exist ------------------------------------------120 defined = set()121 if "word/styles.xml" in names:122 styles_root = etree.fromstring(zf.read("word/styles.xml"))123 defined = {s.get(f"{{{W}}}styleId")124 for s in styles_root.iter(f"{{{W}}}style")}125 for tag, attr in ((f"{{{W}}}pStyle", f"{{{W}}}val"),126 (f"{{{W}}}rStyle", f"{{{W}}}val"),127 (f"{{{W}}}tblStyle", f"{{{W}}}val")):128 for el in doc_root.iter(tag):129 sid = el.get(attr)130 if sid and sid not in defined:131 _issue(issues, "error", "missing-style",132 f"style id referenced but not defined: {sid}")133 134 # --- python-docx can open it ------------------------------------------135 try:136 from docx import Document137 Document(path)138 except Exception as exc: # noqa: BLE001 - triage tool, report anything139 _issue(issues, "error", "python-docx-open-failed", str(exc))140 141 ok = not any(i["severity"] == "error" for i in issues)142 return {"ok": ok, "issues": issues}143 144 145def main() -> int:146 ap = argparse.ArgumentParser(147 description="Health-check a .docx (not XSD schema validation).")148 ap.add_argument("path", help="the .docx file to check")149 args = ap.parse_args()150 report = validate(args.path)151 print(json.dumps(report, ensure_ascii=False))152 return 0 if report["ok"] else 1153 154 155if __name__ == "__main__":156 sys.exit(main())157