check_office.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. #!/usr/bin/env python3
  2. """Read-only OOXML checks and structural summaries; no rendering or formula evaluation.
  3. Run with INPUT.docx, INPUT.pptx, or INPUT.xlsx. Optional --contains assertions
  4. check extracted text; DOCX excludes comments, glossary text, and unreferenced parts or notes.
  5. --count checks slides or sheets. JSON escapes non-ASCII
  6. characters and goes to stdout and optionally --out. Exit 0 means the requested
  7. structural checks passed, 1 means a document, assertion, or report write failed, and 2 means
  8. invalid command-line arguments.
  9. """
  10. from __future__ import annotations
  11. import argparse
  12. import json
  13. import posixpath
  14. import sys
  15. import zipfile
  16. import zlib
  17. from pathlib import Path
  18. from urllib.parse import unquote, urlsplit
  19. from xml.etree import ElementTree as ET
  20. W_NAMESPACES = (
  21. "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
  22. "http://purl.oclc.org/ooxml/wordprocessingml/main",
  23. )
  24. A_NAMESPACES = (
  25. "http://schemas.openxmlformats.org/drawingml/2006/main",
  26. "http://purl.oclc.org/ooxml/drawingml/main",
  27. )
  28. P_NAMESPACES = (
  29. "http://schemas.openxmlformats.org/presentationml/2006/main",
  30. "http://purl.oclc.org/ooxml/presentationml/main",
  31. )
  32. S_NAMESPACES = (
  33. "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
  34. "http://purl.oclc.org/ooxml/spreadsheetml/main",
  35. )
  36. R_NAMESPACES = (
  37. "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
  38. "http://purl.oclc.org/ooxml/officeDocument/relationships",
  39. )
  40. MAIN_PARTS = {
  41. ".docx": ("word/document.xml", "wordprocessingml.document.main+xml"),
  42. ".pptx": ("ppt/presentation.xml", "presentationml.presentation.main+xml"),
  43. ".xlsx": ("xl/workbook.xml", "spreadsheetml.sheet.main+xml"),
  44. }
  45. def namespace(root: ET.Element, supported: tuple[str, ...], part: str) -> str:
  46. """Return the main XML namespace after checking its OOXML variant."""
  47. uri = root.tag[1:].split("}", 1)[0] if root.tag.startswith("{") else ""
  48. if uri not in supported:
  49. raise ValueError(f"{part} uses unsupported XML namespace: {uri or '(none)'}")
  50. return "{" + uri + "}"
  51. def relationship_id(node: ET.Element, part: str) -> str:
  52. """Read an office-document relationship id from Transitional or Strict OOXML."""
  53. for uri in R_NAMESPACES:
  54. value = node.get("{" + uri + "}id")
  55. if value is not None:
  56. return value
  57. raise ValueError(f"{part} has a reference without a relationship id")
  58. def relationship_types(kind: str) -> set[str]:
  59. """Return the Transitional and Strict relationship type names for one role."""
  60. return {f"{uri}/{kind}" for uri in R_NAMESPACES}
  61. def iter_namespaces(root: ET.Element, namespaces: tuple[str, ...], local_name: str):
  62. """Iterate matching elements across Transitional and Strict namespaces."""
  63. for uri in namespaces:
  64. yield from root.iter("{" + uri + "}" + local_name)
  65. def relationship_target(part: str, target: str) -> str:
  66. """Resolve a package relationship without fetching external resources."""
  67. path = unquote(urlsplit(target).path)
  68. return posixpath.normpath(path.lstrip("/") if path.startswith("/") else posixpath.join(posixpath.dirname(part), path))
  69. def relationships(part: str, xml: dict[str, ET.Element], types: set[str] | None = None) -> dict[str, str]:
  70. path = posixpath.join(posixpath.dirname(part), "_rels", posixpath.basename(part) + ".rels")
  71. root = xml.get(path)
  72. if root is None:
  73. return {}
  74. return {
  75. rel.attrib["Id"]: relationship_target(part, rel.attrib["Target"])
  76. for rel in root if rel.get("TargetMode") != "External" and (types is None or rel.get("Type") in types)
  77. }
  78. def related_xml(part: str, reference: str, links: dict[str, str], xml: dict[str, ET.Element]) -> ET.Element:
  79. """Read a related XML part with diagnostics naming its source and reference."""
  80. if reference not in links:
  81. raise ValueError(f"{part} references missing relationship: {reference}")
  82. target = links[reference]
  83. if target not in xml:
  84. raise ValueError(f"{part} relationship {reference} targets a non-XML member: {target}")
  85. return xml[target]
  86. def inspect_docx(xml: dict[str, ET.Element]) -> tuple[dict, str]:
  87. part = "word/document.xml"
  88. root = xml[part]
  89. w = namespace(root, W_NAMESPACES, part)
  90. body = root.find(f"{w}body")
  91. if body is None:
  92. raise ValueError("word/document.xml has no document body")
  93. tables = []
  94. for table in body.iter(f"{w}tbl"):
  95. grid = table.findall(f"{w}tblGrid/{w}gridCol")
  96. rows = table.findall(f"{w}tr")
  97. # Merged cells span logical grid columns; counting physical cells loses them.
  98. columns = len(grid) if grid else max((sum(
  99. int(cell.find(f"{w}tcPr/{w}gridSpan").get(f"{w}val", "1"))
  100. if cell.find(f"{w}tcPr/{w}gridSpan") is not None else 1
  101. for cell in row.findall(f"{w}tc")
  102. ) for row in rows), default=0)
  103. tables.append({"rows": len(rows), "columns": columns})
  104. sections = []
  105. for section in body.iter(f"{w}sectPr"):
  106. size = section.find(f"{w}pgSz")
  107. margins = section.find(f"{w}pgMar")
  108. sections.append({
  109. "page_twips": {} if size is None else {key.removeprefix(w): value for key, value in size.attrib.items()},
  110. "margins_twips": {} if margins is None else {key.removeprefix(w): value for key, value in margins.attrib.items()},
  111. })
  112. text_parts = [body]
  113. for kind in ("header", "footer"):
  114. links = relationships(part, xml, relationship_types(kind))
  115. for section in body.iter(f"{w}sectPr"):
  116. for reference in section.findall(f"{w}{kind}Reference"):
  117. text_parts.append(related_xml(part, relationship_id(reference, part), links, xml))
  118. for kind in ("footnote", "endnote"):
  119. references = {node.attrib[f"{w}id"] for node in body.iter(f"{w}{kind}Reference")}
  120. if not references:
  121. continue
  122. links = relationships(part, xml, relationship_types(f"{kind}s"))
  123. for reference in links:
  124. tree = related_xml(part, reference, links, xml)
  125. text_parts.extend(note for note in tree.findall(f"{w}{kind}") if note.get(f"{w}id") in references)
  126. text = "\n".join("".join(node.text or "" for node in paragraph.iter(f"{w}t")) for tree in text_parts
  127. for paragraph in tree.iter(f"{w}p"))
  128. return {"paragraphs": len(list(body.iter(f"{w}p"))), "tables": tables, "sections": sections}, text
  129. def inspect_pptx(xml: dict[str, ET.Element]) -> tuple[dict, str]:
  130. part = "ppt/presentation.xml"
  131. links = relationships(part, xml)
  132. root = xml[part]
  133. p = namespace(root, P_NAMESPACES, part)
  134. slides = root.findall(f"{p}sldIdLst/{p}sldId")
  135. texts = []
  136. for slide in slides:
  137. reference = relationship_id(slide, part)
  138. tree = related_xml(part, reference, links, xml)
  139. texts.append("\n".join("".join(node.text or "" for node in iter_namespaces(paragraph, A_NAMESPACES, "t"))
  140. for paragraph in iter_namespaces(tree, A_NAMESPACES, "p")))
  141. return {"slides": len(slides)}, "\n".join(texts)
  142. def inspect_xlsx(xml: dict[str, ET.Element]) -> tuple[dict, str]:
  143. part = "xl/workbook.xml"
  144. links = relationships(part, xml)
  145. root = xml[part]
  146. s = namespace(root, S_NAMESPACES, part)
  147. sheets = []
  148. texts = []
  149. shared = xml.get("xl/sharedStrings.xml")
  150. shared_s = s if shared is None else namespace(shared, S_NAMESPACES, "xl/sharedStrings.xml")
  151. shared_strings = [] if shared is None else [
  152. "".join(node.text or "" for node in item.iter(f"{shared_s}t")) for item in shared.iter(f"{shared_s}si")
  153. ]
  154. for sheet in root.findall(f"{s}sheets/{s}sheet"):
  155. reference = relationship_id(sheet, part)
  156. tree = related_xml(part, reference, links, xml)
  157. sheet_s = namespace(tree, S_NAMESPACES, links[reference])
  158. cells = list(tree.iter(f"{sheet_s}c"))
  159. formulas = sum(cell.find(f"{sheet_s}f") is not None for cell in cells)
  160. sheets.append({"name": sheet.attrib["name"], "cells": len(cells), "formulas": formulas})
  161. for cell in cells:
  162. if cell.get("t") == "s":
  163. value = cell.findtext(f"{sheet_s}v", "")
  164. location = f"{links[reference]} cell {cell.get('r', '(no reference)')}"
  165. try:
  166. index = int(value)
  167. except ValueError as error:
  168. raise ValueError(f"{location}: invalid shared string index {value!r}") from error
  169. if not 0 <= index < len(shared_strings):
  170. raise ValueError(f"{location}: shared string index out of range: {index}")
  171. texts.append(shared_strings[index])
  172. texts.extend("".join(node.text or "" for node in cell.iter(f"{sheet_s}t")) for cell in cells)
  173. texts.extend(cell.findtext(f"{sheet_s}v", "") for cell in cells if cell.get("t") == "str")
  174. texts.append(sheet.attrib["name"])
  175. return {"sheets": sheets, "formulas_evaluated": False}, "\n".join(texts)
  176. def inspect(path: Path) -> tuple[dict, str]:
  177. """Validate package members and relationships before inspecting the main part."""
  178. main, content_type = MAIN_PARTS[path.suffix.lower()]
  179. with zipfile.ZipFile(path) as archive:
  180. members = archive.namelist()
  181. if len(members) != len(set(members)):
  182. raise ValueError("ZIP contains duplicate member names")
  183. corrupt = archive.testzip()
  184. if corrupt is not None:
  185. raise ValueError(f"ZIP member failed its CRC check: {corrupt}")
  186. xml = {name: ET.fromstring(archive.read(name)) for name in members
  187. if name.endswith((".xml", ".rels"))}
  188. if main not in xml:
  189. raise ValueError(f"missing main part: {main}")
  190. types = xml.get("[Content_Types].xml")
  191. if types is None or not any(node.get("PartName") == "/" + main
  192. and node.get("ContentType", "").endswith(content_type) for node in types):
  193. raise ValueError(f"[Content_Types].xml does not declare {main} as {path.suffix.lower()}")
  194. for name, tree in xml.items():
  195. if not name.endswith(".rels"):
  196. continue
  197. source = "" if name == "_rels/.rels" else posixpath.join(posixpath.dirname(posixpath.dirname(name)), posixpath.basename(name)[:-5])
  198. for rel in tree:
  199. if rel.get("TargetMode") == "External":
  200. continue
  201. target = relationship_target(source, rel.attrib["Target"])
  202. if target not in members:
  203. raise ValueError(f"{name} references missing package member: {target}")
  204. if path.suffix.lower() == ".docx":
  205. return inspect_docx(xml)
  206. if path.suffix.lower() == ".pptx":
  207. return inspect_pptx(xml)
  208. return inspect_xlsx(xml)
  209. def main() -> int:
  210. parser = argparse.ArgumentParser(description=__doc__)
  211. parser.add_argument("input", type=Path)
  212. parser.add_argument("--out", type=Path)
  213. parser.add_argument("--contains", action="append", default=[], metavar="TEXT")
  214. parser.add_argument("--count", type=int, help="expected slide or sheet count")
  215. args = parser.parse_args()
  216. if args.input.suffix.lower() not in MAIN_PARTS:
  217. parser.error("input must be .docx, .pptx, or .xlsx; converting the filename does not convert its contents")
  218. if args.count is not None and (args.count < 0 or args.input.suffix.lower() == ".docx"):
  219. parser.error("--count must be non-negative and applies only to slides or sheets")
  220. if args.out is not None and args.out.resolve() == args.input.resolve():
  221. parser.error("--out must differ from the input document")
  222. checks = []
  223. summary = {}
  224. try:
  225. summary, text = inspect(args.input)
  226. checks.append({"id": "package", "status": "pass"})
  227. for required in args.contains:
  228. checks.append({"id": "contains", "status": "pass" if required in text else "fail", "text": required})
  229. if args.count is not None:
  230. actual = summary.get("slides", len(summary.get("sheets", [])))
  231. checks.append({"id": "count", "status": "pass" if actual == args.count else "fail", "expected": args.count, "actual": actual})
  232. except (OSError, ValueError, KeyError, RuntimeError, ET.ParseError, zipfile.BadZipFile, zlib.error) as error:
  233. checks.append({"id": "package", "status": "fail", "detail": str(error)})
  234. failed = any(check["status"] == "fail" for check in checks)
  235. report = {"format": args.input.suffix.lower()[1:], "verdict": "fail" if failed else "pass", "checks": checks, "summary": summary}
  236. output = json.dumps(report, ensure_ascii=True, indent=2) + "\n"
  237. if args.out is not None:
  238. try:
  239. args.out.parent.mkdir(parents=True, exist_ok=True)
  240. args.out.write_text(output, encoding="utf-8")
  241. except OSError as error:
  242. failed = True
  243. report["verdict"] = "fail"
  244. checks.append({"id": "output", "status": "fail", "detail": str(error)})
  245. output = json.dumps(report, ensure_ascii=True, indent=2) + "\n"
  246. sys.stdout.write(output)
  247. return 1 if failed else 0
  248. if __name__ == "__main__":
  249. sys.exit(main())