#!/usr/bin/env python3 """Checks a saved DOCX, XLSX or PPTX without LibreOffice.""" import datetime import math import posixpath import re import shutil import subprocess import sys import tempfile import zipfile import xml.etree.ElementTree as ET W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" S = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}" P = "{http://schemas.openxmlformats.org/presentationml/2006/main}" A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" REL = "{http://schemas.openxmlformats.org/package/2006/relationships}" failures = [] def check(ok, what): if not ok: failures.append(what) return ok def xml(archive, name): return ET.fromstring(archive.read(name)) def texts(element, tag): return "".join(node.text or "" for node in element.iter(tag)) def rels(archive, part): """A part's relationships: id to (type's last word, target part).""" folder, name = posixpath.split(part) path = posixpath.join(folder, "_rels", name + ".rels") if path not in archive.namelist(): return {} found = {} for rel in xml(archive, path).iter(REL + "Relationship"): target = rel.get("Target") if rel.get("TargetMode") != "External": target = posixpath.normpath(posixpath.join(folder, target)).lstrip("/") found[rel.get("Id")] = (rel.get("Type").rsplit("/", 1)[-1], target) return found def related(archive, part, kind): """The parts `part` relates to as `kind`; each must exist.""" targets = [target for got, target in rels(archive, part).values() if got == kind] for target in targets: check(target in archive.namelist(), f"{part} relates to a missing {target}") return [target for target in targets if target in archive.namelist()] def main_part(archive): parts = related(archive, "", "officeDocument") check(len(parts) == 1, "one main part") return parts[0] if parts else None def on(element): """An OOXML on/off element: absent value means on.""" return element is not None and element.get(W + "val", "true") in ("true", "1", "on") def before_references(body): """Each comment reference's id, with its paragraph's text before it.""" placed = {} for paragraph in body.iter(W + "p"): seen = "" for node in paragraph.iter(): if node.tag == W + "t": seen += node.text or "" elif node.tag == W + "commentReference": placed[node.get(W + "id")] = seen return placed def contract(path): """Typed text, a comment, a tracked insertion, both sessions.""" with zipfile.ZipFile(path) as archive: document = main_part(archive) if not document: return body = xml(archive, document) paragraphs = [texts(p, W + "t") for p in body.iter(W + "p")] whole = "\n".join(paragraphs) for typed in ["Alpha 甲", "Hello 世界", "Gamma"]: check(typed in whole, f"document text has {typed!r}: {paragraphs}") inserted = "".join(texts(ins, W + "t") for ins in body.iter(W + "ins")) for tracked in ["Beta", "Gamma"]: check(tracked in inserted, f"{tracked} is a tracked insertion, not {inserted!r}") placed = before_references(body) comments = related(archive, document, "comments") if check(len(comments) == 1, "the document relates to one comment part"): said = {c.get(W + "id"): texts(c, W + "t") for c in xml(archive, comments[0]).iter(W + "comment")} ours = [id_ for id_, text in said.items() if "Check alpha" in text] check(len(ours) == 1, f"one comment says Check alpha: {said}") for id_ in ours: at = placed.get(id_, "") check(at.rstrip().endswith("Alpha 甲"), f"comment {id_} follows Alpha 甲, not {at[-20:]!r}") # Made at a point: no range markers. ranged = [n for n in body.iter() if n.tag in (W + "commentRangeStart", W + "commentRangeEnd") and n.get(W + "id") == id_] check(not ranged, f"comment {id_} stays a point, not a range") settings = related(archive, document, "settings") if check(len(settings) == 1, "the document relates to its settings"): check(on(xml(archive, settings[0]).find(W + "trackRevisions")), "tracking stays on") # Apple's own DOCX reader, a second independent opinion. if shutil.which("textutil"): shown = subprocess.run(["textutil", "-convert", "txt", "-stdout", path], capture_output=True, text=True, check=True).stdout check("Alpha 甲" in shown and "Hello 世界" in shown, f"textutil reads the text: {shown!r}") def cells(archive): """The workbook's first declared sheet, through its relationship.""" book = main_part(archive) if not book: return {} shared = [] for part in related(archive, book, "sharedStrings"): shared = [texts(si, S + "t") for si in xml(archive, part).iter(S + "si")] sheets = list(xml(archive, book).iter(S + "sheet")) if not check(sheets, "the workbook lists a sheet"): return {} target = rels(archive, book).get(sheets[0].get(R + "id")) if not check(target and target[1] in archive.namelist(), f"the first sheet's part exists: {target}"): return {} found = {} for c in xml(archive, target[1]).iter(S + "c"): kind = c.get("t", "n") f, v = c.find(S + "f"), c.find(S + "v") value = v.text if v is not None else None if kind == "s" and value is not None: value = shared[int(value)] elif kind == "inlineStr": value = texts(c, S + "t") found[c.get("r")] = (kind, f.text if f is not None else None, value) return found def sheet(path): """Inputs, formulas and cached results against Python's own sums.""" h1, h2 = 7, 5 serial = (datetime.date(2024, 2, 29) - datetime.date(1899, 12, 30)).days # Exact: integers, dates, errors, text. Floats: the case's tolerance. expected = { "H1": (None, "n", h1, 0), "H2": (None, "n", h2, 0), "H3": ("H1*H2", "n", h1 * h2, 0), "H4": ("H1/3", "n", h1 / 3, 1e-12), "H5": ("DATE(2024,2,29)", "n", serial, 0), "H6": ("1/0", "e", "#DIV/0!", None), "H7": ('"Ely"&"-"&TEXT(H1,"0")', "str", f"Ely-{h1}", None), "H8": ("SUM(H1:H2)", "n", h1 + h2, 0), "H9": ("H3+1", "n", h1 * h2 + 1, 0), } with zipfile.ZipFile(path) as archive: found = cells(archive) for ref, (formula, kind, value, tolerance) in expected.items(): if not check(ref in found, f"{ref} is there"): continue got_kind, got_formula, got = found[ref] check(got_formula == formula, f"{ref} formula {got_formula!r}, not {formula!r}") check(got_kind == kind, f"{ref} type {got_kind!r}, not {kind!r}") if tolerance is None: check(got == value, f"{ref} is {got!r}, not {value!r}") elif tolerance == 0: check(got is not None and float(got) == value and float(got).is_integer(), f"{ref} is {got!r}, not {value}") else: check(got is not None and math.isclose(float(got), value, rel_tol=0, abs_tol=tolerance), f"{ref} is {got!r}, not {value} within {tolerance}") def notes_body(notes): """The text of a notes page's body placeholder, the editable notes.""" found = [] for shape in notes.iter(P + "sp"): holder = shape.find(f"{P}nvSpPr/{P}nvPr/{P}ph") if holder is not None and holder.get("type") == "body": found.append(texts(shape, A + "t")) return " ".join(found) def deck(path): """Slides in order, a hidden slide, notes on their slide.""" with zipfile.ZipFile(path) as archive: show = main_part(archive) if not show: return links = rels(archive, show) order = [sid.get(R + "id") for sid in xml(archive, show).iter(P + "sldId")] slides = [] for rid in order: target = links.get(rid) if check(target and target[1] in archive.namelist(), f"slide {rid} has its part: {target}"): slides.append(target[1]) check(len(order) == 3, f"three slides, not {len(order)}") if not slides: return check(len(set(slides)) == len(slides), f"each slide its own part: {slides}") # The fixture: First, the inserted blank, the hidden Second. said = [texts(xml(archive, part), A + "t").strip() for part in slides] check(said == ["First", "", "Second"], f"slides in order say {said}") hidden = [xml(archive, part).get("show") == "0" for part in slides] check(hidden == [False, False, True], f"only the last slide is hidden: {hidden}") spoken = [" ".join(notes_body(xml(archive, n)) for n in related(archive, part, "notesSlide")) for part in slides] check("Speaker 甲" in spoken[0], f"slide one's notes say Speaker 甲, not {spoken[0]!r}") check(not any("Speaker" in text for text in spoken[1:]), f"only slide one has the notes: {spoken}") CASES = {"contract": contract, "sheet": sheet, "deck": deck} # Damage each check must catch: part, pattern, replacement. MUTATIONS = { "contract": [ ("word/document.xml", r"]*/>", ""), ("word/settings.xml", r"", ''), ("word/document.xml", r'(?s)((?:(?!).)*)', r'\1'), ("word/document.xml", r'(?s)^(.*?]*>)', r'\1'), ("word/document.xml", r'(?s)]*>((?:(?!).)*Gamma(?:(?!).)*)', r"\1"), ], "sheet": [ ("xl/worksheets/sheet1.xml", r'(]*>)7()', r"\g<1>70\g<2>"), ("xl/_rels/workbook.xml.rels", r"worksheets/sheet1\.xml", "worksheets/missing.xml"), ], "deck": [ ("ppt/_rels/presentation.xml.rels", r"slides/slide2\.xml", "slides/missing.xml"), ("ppt/_rels/presentation.xml.rels", r"slides/slide2\.xml", "slides/slide1.xml"), ("ppt/presentation.xml", r'(?s)r:id="rId5"(.*?)r:id="rId6"', r'r:id="rId6"\1r:id="rId5"'), ("ppt/notesSlides/notesSlide1.xml", r']*notesSlide\"[^>]*/>", ""), ], } def selftest(case, path): """Each mutation of a good file must fail the check.""" global failures missed = [] for part, pattern, replacement in MUTATIONS[case]: with zipfile.ZipFile(path) as archive: entries = {name: archive.read(name) for name in archive.namelist()} text = entries[part].decode() changed, count = re.subn(pattern, replacement, text) if count == 0: missed.append(f"{part}: {pattern} found nothing to change") continue entries[part] = changed.encode() with tempfile.NamedTemporaryFile(suffix=path[-5:]) as copy: with zipfile.ZipFile(copy.name, "w") as archive: for name, body in entries.items(): archive.writestr(name, body) failures = [] CASES[case](copy.name) if not failures: missed.append(f"{part}: {pattern} passed") failures = [f"a mutation went unnoticed: {what}" for what in missed] if __name__ == "__main__": if sys.argv[1] == "selftest": selftest(sys.argv[2], sys.argv[3]) else: CASES[sys.argv[1]](sys.argv[2]) for failure in failures: print(f"FAIL: {failure}") sys.exit(1 if failures else 0)