diff --git a/submissions/pptx-merger/README.md b/submissions/pptx-merger/README.md new file mode 100644 index 00000000..d69dc4b7 --- /dev/null +++ b/submissions/pptx-merger/README.md @@ -0,0 +1,44 @@ +# PPTX Merger + +A four-stage, corruption-safe pipeline that merges two or more base64-delivered +PowerPoint decks into a single `.pptx` that PowerPoint opens with no repair or +"can't read" error. Built for the Copilot Studio sandbox flow where decks arrive +as base64 from a SharePoint connector, are merged inside the sandbox, and handed +back out. + +## The pipeline + +``` +base64 in ──▶ 1. ingest ──▶ 2. merge ──▶ 3. validate ──▶ 4. export ──▶ base64 out + (verify) (build) (render gate) (encode) +``` + +## Requirements + +- **`lxml`** — merge and validate steps + (`pip install lxml --break-system-packages` if not already in the sandbox). +- **LibreOffice (`soffice`)** — the render gate in step 3. If unavailable, + validation runs structural-only and says so. +- Ingest and export use the Python standard library only. +- No network calls; the skill never uploads or downloads anything itself. + +## Files + +``` +SKILL.md # agent-facing skill definition (frontmatter + instructions) +scripts/b64_to_pptx.py # 1. ingest — base64 → verified .pptx +scripts/pptx_merge.py # 2. merge → single .pptx +scripts/verify_pptx.py # 3. validate — structural + real render +scripts/pptx_to_b64.py # 4. export — verified .pptx → base64 +``` + +## Testing + +Verified end to end on two multi-master sample decks: the full ingest → merge → +validate → export chain passes, the delivered file renders (6 pages), and the +exported base64 begins with `UEsD`. The ingest gate was tested against the exact +`U+FFFD` corruption pattern and rejects it rather than producing a broken deck. + +## License + +Shared under the CAT Agent Skills repository's MIT license. diff --git a/submissions/pptx-merger/SKILL.md b/submissions/pptx-merger/SKILL.md new file mode 100644 index 00000000..65bfc8eb --- /dev/null +++ b/submissions/pptx-merger/SKILL.md @@ -0,0 +1,119 @@ +--- +name: pptx-merger +description: > + Use this skill whenever the task is to combine, concatenate, or merge two or + more PowerPoint decks into one .pptx that opens with no repair or "can't read" + error — especially when the source files arrive as base64 from a SharePoint + connector or HTTP response. Triggers: "merge decks", "combine presentations", + "append slides from", "join pptx files", "merge these QBR decks". It runs four + ordered steps — ingest base64 to a verified file, merge, validate by real + render, and export back to base64 for return/upload. Do NOT use it to build + slides from scratch. +--- + +# PPTX Merger — corruption-safe deck merge for agent sandboxes + +Combine PowerPoint decks into a single file that opens cleanly in PowerPoint. +The skill is built for the case where decks arrive as **base64** (e.g. from the +SharePoint *Get file content using path* connector) and must be merged inside a +sandbox and handed back out. It exists because the naive path corrupts files in +two places: binary bytes get destroyed when pushed through a text/UTF-8 codec, +and careless merging produces packages PowerPoint refuses to open. Each of the +four scripts does one job, verifies its own output, and fails loudly rather than +passing bad data forward. + +## The pipeline + +``` +base64 in ──▶ 1. ingest ──▶ 2. merge ──▶ 3. validate ──▶ 4. export ──▶ base64 out + (verify) (build) (render gate) (encode) +``` + +Run the four scripts in order, in a shared working directory. Treat any non-zero +exit as a hard stop and report the reason — never deliver an unvalidated file. + +### 1. Ingest — base64 → verified .pptx on disk + +The connector's base64 is ASCII and survives any text pipeline. Corruption only +happens when the *binary* is decoded through a UTF-8 codec (every byte ≥ 0x80 +becomes U+FFFD, inflating and destroying the ZIP). This step decodes the base64 +to bytes and writes them with a **binary** handle, then proves the result is a +readable OOXML package before anything else runs. + +```bash +python scripts/b64_to_pptx.py [--expected-bytes N] [--json] +``` + +`` is a path to a file containing the base64 text **or** the literal +base64 string. Pass `--expected-bytes` with the connector-reported size for an +exact-size assertion. Run once per source deck: + +```bash +python scripts/b64_to_pptx.py deck1.b64 in1.pptx --json +python scripts/b64_to_pptx.py deck2.b64 in2.pptx --json +``` + +Gates (any failure → non-zero exit): input must be clean ASCII base64 (rejects if +it already contains U+FFFD — meaning corruption happened upstream), strict base64 +decode, binary write, optional exact-size check, and a final "is this a valid +PPTX ZIP" check. A correct base64 PPTX always begins with `UEsD` (the ZIP magic); +if the string starts with anything else the source is not clean base64. + +### 2. Merge — combine verified decks + +```bash +python scripts/pptx_merge.py output.pptx input1.pptx input2.pptx [input3.pptx ...] +``` + +First argument is the output path; the rest are inputs merged in order. It fixes +every defect that makes merged decks unopenable: `[Content_Types].xml` and all +`.rels` are written in the **default (unprefixed) OPC namespace** (a prefixed +`` is the classic "PowerPoint can't read this file" cause); every +`` gets its required `id`, with master and layout IDs drawn from +one shared counter so they are globally unique together; absolute OPC targets are +normalised to relative paths; media, charts, and embeddings are copied with a +source-index prefix so identical filenames across decks cannot collide; slide → +layout → master → theme chains are re-pointed; speaker notes are carried across; +and `[Content_Types].xml` is rebuilt from what is actually on disk. + +### 3. Validate — the "does it actually open" gate + +A structural check alone is not enough — it can pass a file PowerPoint still +won't open. This step runs the structural checks **and** performs a real +LibreOffice render. Nothing is delivered unless this passes. + +```bash +python scripts/verify_pptx.py [--json] [--no-render] +``` + +Checks: ZIP integrity; `[Content_Types].xml` present and in the default namespace +(a prefix is reported as an error); no `.rels` uses an absolute internal target; +every `sldMasterId` has an `id`; master/layout IDs are globally unique; and a real +render produces a non-empty PDF. Exit `0` only when every enabled check passes. If +LibreOffice is absent the render check is skipped and clearly flagged rather than +claimed as a pass. + +### 4. Export — verified .pptx → base64 for the return trip + +The merged file must leave the sandbox the same way inputs came in: as base64. + +```bash +python scripts/pptx_to_b64.py [--out file.txt] [--json] +``` + +Use `--out` to write the base64 to a file (recommended for large decks). It +refuses to export anything that is not a valid PPTX and verifies a decode +round-trip so a truncated encoding is caught here, not in the user's PowerPoint. + + +## Suggested agent step order + +1. Resolve each file and get its content as base64 (`$content` from *Get file + content using path* is already base64 — use it verbatim, do not re-encode). +2. `b64_to_pptx.py` per deck → verified `inN.pptx`. If any returns `ok: false`, + stop: the source is corrupt and merging cannot help. +3. `pptx_merge.py` → `merged.pptx`. +4. `verify_pptx.py merged.pptx` → must pass, or stop and report the errors. +5. `pptx_to_b64.py merged.pptx --out merged.b64`. +6. Upload via the flow (passing the base64 by reference) and return the link. + diff --git a/submissions/pptx-merger/metadata.json b/submissions/pptx-merger/metadata.json new file mode 100644 index 00000000..1d16dc99 --- /dev/null +++ b/submissions/pptx-merger/metadata.json @@ -0,0 +1,9 @@ +{ + "name": "PPTX Merger", + "description": "Merge two or more base64-delivered PowerPoint decks into a single file that PowerPoint opens cleanly — decode safely, merge, validate by real render, and export back to base64 for upload.", + "platforms": ["Copilot Studio"], + "tags": ["pptx", "powerpoint", "merge", "sharepoint", "documents", "python"], + "author": "Sandeep Angara", + "authorUrl": "https://github.com/hisandeepangara", + "version": "1.0.0" +} diff --git a/submissions/pptx-merger/scripts/b64_to_pptx.py b/submissions/pptx-merger/scripts/b64_to_pptx.py new file mode 100644 index 00000000..dbdf1cb8 --- /dev/null +++ b/submissions/pptx-merger/scripts/b64_to_pptx.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +b64_to_pptx.py — Materialise a base64 string into a binary .pptx on disk, safely. + +This is the stage that fixes the upstream corruption. The SharePoint connector +returns file content as base64 (pure ASCII), which survives any text pipeline. +The ONLY way that data gets destroyed is if some layer decodes the *binary* +through a UTF-8 text codec. This script never does that: it base64-decodes to +raw bytes and writes them with a binary handle ('wb'), then proves the result is +an intact OOXML ZIP before anyone downstream is allowed to touch it. + +Usage: + python b64_to_pptx.py [--expected-bytes N] [--json] + + may be: + * a path to a file containing the base64 text, or + * the literal base64 string (auto-detected if it isn't an existing path). + +Exit code is 0 only if the written file is a valid, readable PPTX package. +On any integrity failure it exits non-zero and prints a diagnosis, so the agent +fails loudly instead of passing corrupted bytes to the merge stage. +""" + +import sys +import json +import base64 +import zipfile +import argparse +from pathlib import Path + + +def _load_b64_text(arg: str) -> str: + p = Path(arg) + try: + is_file = p.exists() and p.is_file() + except OSError: + is_file = False + raw = p.read_text(encoding="utf-8", errors="strict") if is_file else arg + # Strip whitespace/newlines and an optional data-URI prefix. + raw = raw.strip() + if raw.startswith("data:") and "," in raw: + raw = raw.split(",", 1)[1] + return "".join(raw.split()) + + +def _has_replacement_chars(arg: str) -> bool: + # If the caller passed a file, a genuine base64 string can't contain U+FFFD. + # Its presence means corruption already happened upstream of this script. + p = Path(arg) + try: + is_file = p.exists() and p.is_file() + except OSError: + is_file = False + try: + txt = p.read_text(encoding="utf-8", errors="strict") if is_file else arg + except UnicodeDecodeError: + return True + return "\ufffd" in txt + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("b64_input") + ap.add_argument("output") + ap.add_argument("--expected-bytes", type=int, default=None, + help="If provided, assert the decoded size matches exactly.") + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + result = {"ok": False, "output": args.output, "stage": "ingest"} + + # Guard 1: the base64 text itself must be clean ASCII. + if _has_replacement_chars(args.b64_input): + result["error"] = ("Input already contains U+FFFD replacement characters — " + "the binary was corrupted upstream of this script (a text/UTF-8 " + "codec touched the bytes before base64 reached here). Fix the " + "connector output so it stays base64/ASCII end to end.") + print(json.dumps(result, indent=2) if args.json else result["error"]) + return 3 + + try: + b64 = _load_b64_text(args.b64_input) + except UnicodeDecodeError: + result["error"] = "Input file is not valid UTF-8 text; it is not clean base64." + print(json.dumps(result, indent=2) if args.json else result["error"]) + return 3 + + # Guard 2: decode strictly. validate=True rejects stray non-alphabet bytes. + try: + data = base64.b64decode(b64, validate=True) + except Exception as e: # noqa: BLE001 + result["error"] = f"base64 decode failed: {e}" + print(json.dumps(result, indent=2) if args.json else result["error"]) + return 4 + + # Binary write — never a text codec. + out = Path(args.output) + out.parent.mkdir(parents=True, exist_ok=True) + with open(out, "wb") as fh: + fh.write(data) + + size = out.stat().st_size + result["bytes"] = size + + # Guard 3: exact-size check if the connector reported a size. + if args.expected_bytes is not None and size != args.expected_bytes: + result["error"] = (f"Size mismatch: wrote {size} bytes, expected " + f"{args.expected_bytes}. Bytes were altered in transit.") + print(json.dumps(result, indent=2) if args.json else result["error"]) + return 5 + + # Guard 4: it must be a real, readable OOXML ZIP with the PPTX marker part. + try: + with zipfile.ZipFile(out) as z: + bad = z.testzip() + if bad is not None: + raise zipfile.BadZipFile(f"CRC error in {bad}") + names = set(z.namelist()) + if "[Content_Types].xml" not in names: + raise KeyError("[Content_Types].xml missing — not an OOXML package") + if not any(n.startswith("ppt/") for n in names): + raise KeyError("no ppt/ parts — not a PowerPoint package") + result["entries"] = len(names) + result["slides"] = sum( + 1 for n in names + if n.startswith("ppt/slides/slide") and n.endswith(".xml") + ) + except Exception as e: # noqa: BLE001 + result["error"] = (f"Decoded bytes are not a valid PPTX: {e}. " + "This means the file was corrupted before it reached ingest.") + print(json.dumps(result, indent=2) if args.json else result["error"]) + return 6 + + result["ok"] = True + result["message"] = f"Wrote intact PPTX: {size} bytes, {result['slides']} slides." + print(json.dumps(result, indent=2) if args.json else result["message"]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/submissions/pptx-merger/scripts/pptx_merge.py b/submissions/pptx-merger/scripts/pptx_merge.py new file mode 100644 index 00000000..f447556a --- /dev/null +++ b/submissions/pptx-merger/scripts/pptx_merge.py @@ -0,0 +1,675 @@ +#!/usr/bin/env python3 +""" +pptx_merge.py — Merge PPTX files without PowerPoint repair/open errors. + +Usage: + python pptx_merge.py output.pptx input1.pptx input2.pptx [input3.pptx ...] + +Relationship handling +--------------------- +Relationship IDs (`r:id`, `r:embed`, `r:link`, ...) are *part-local*: a slide, +master or layout resolves them against its own `_rels/.rels` and nothing +else in the package sees them. Appended parts therefore KEEP their original +relationship IDs verbatim, which means the copied XML stays valid without any +rewriting. Only `ppt/_rels/presentation.xml.rels` gets fresh IDs, because that +part's ID space is genuinely shared between the destination deck and everything +appended into it. + +What *does* get rewritten is the `Target` of each relationship, since part +names are package-global and get renamed on copy (`image1.png` -> +`s1_image1.png`, `slideLayout2.xml` -> `slideLayout9.xml`, and so on). Targets +are remapped in every `.rels` we emit — slides, masters, layouts, themes, +charts and notes — so renamed media, themes and charts stay reachable from +appended masters and layouts. + +Note that `sldMasterId/@id` and `sldLayoutId/@id` are a different thing from +`r:id`: they are globally unique unsigned integers (>= 2147483648) and ARE +renumbered from a shared counter. + +Also handled here: + + * [Content_Types].xml and every .rels part are serialised in the DEFAULT + (unprefixed) OPC namespace. A prefixed `` makes PowerPoint and + LibreOffice refuse to open the package ("can't read" / "source could not be + loaded"). Rebuilding with nsmap={None: NS} guarantees the required form and + also self-heals inputs that arrive prefixed. + + * Zip entry paths are validated before extraction (Zip Slip). +""" + +import os +import re +import sys +import shutil +import tempfile +import zipfile +from pathlib import Path + +try: + from lxml import etree +except ImportError: + sys.exit("ERROR: lxml is required. Install it with: pip install lxml") + +# ── Namespaces ────────────────────────────────────────────────────────────── +NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main" +NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +NS_REL = "http://schemas.openxmlformats.org/package/2006/relationships" +NS_CT = "http://schemas.openxmlformats.org/package/2006/content-types" + +RT_SLIDE = f"{NS_R}/slide" +RT_SLIDE_LAYOUT = f"{NS_R}/slideLayout" +RT_SLIDE_MASTER = f"{NS_R}/slideMaster" +RT_THEME = f"{NS_R}/theme" +RT_IMAGE = f"{NS_R}/image" +RT_CHART = f"{NS_R}/chart" +RT_NOTES = f"{NS_R}/notesSlide" +RT_AUDIO = f"{NS_R}/audio" +RT_VIDEO = f"{NS_R}/video" +RT_MEDIA = "http://schemas.microsoft.com/office/2007/relationships/media" + +MEDIA_RELS = {RT_IMAGE, RT_AUDIO, RT_VIDEO, RT_MEDIA} + +CT_SLIDE = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml" +CT_SLIDE_LAYOUT = "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml" +CT_SLIDE_MASTER = "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml" +CT_THEME = "application/vnd.openxmlformats-officedocument.theme+xml" +CT_CHART = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml" +CT_NOTES_SLIDE = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml" + +MEDIA_CT = { + "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", + "bmp": "image/bmp", "tiff": "image/tiff", "wmf": "image/x-wmf", "emf": "image/x-emf", + "svg": "image/svg+xml", "mp4": "video/mp4", "avi": "video/avi", "mov": "video/quicktime", + "mp3": "audio/mpeg", "wav": "audio/wav", "m4v": "video/mp4", "bin": None, + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +} + + +# ── XML helpers ───────────────────────────────────────────────────────────── +def _parser(): + return etree.XMLParser( + remove_blank_text=False, + recover=True, + resolve_entities=False, + load_dtd=False, + no_network=True, + ) + + +def parse_xml(path: Path): + return etree.parse(str(path), _parser()).getroot() + + +def write_xml(root, path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + etree.ElementTree(root).write( + str(path), xml_declaration=True, encoding="UTF-8", standalone=True + ) + + +def read_rels(rels_path: Path): + if not rels_path.exists(): + return [] + root = parse_xml(rels_path) + return [ + {"Id": r.get("Id", ""), "Type": r.get("Type", ""), + "Target": r.get("Target", ""), "TargetMode": r.get("TargetMode", "")} + for r in root + ] + + +def build_rels_xml(rels): + # DEFAULT namespace — no prefix. + root = etree.Element(f"{{{NS_REL}}}Relationships", nsmap={None: NS_REL}) + for r in rels: + el = etree.SubElement(root, f"{{{NS_REL}}}Relationship") + el.set("Id", r["Id"]) + el.set("Type", r["Type"]) + el.set("Target", r["Target"]) + if r.get("TargetMode"): + el.set("TargetMode", r["TargetMode"]) + return root + + +def next_rid(used: set) -> str: + n = 1 + while f"rId{n}" in used: + n += 1 + return f"rId{n}" + + +def abs_to_rel(target: str, part_folder: str) -> str: + if not target.startswith("/"): + return target + rel = os.path.relpath(target.lstrip("/"), part_folder) + return rel.replace("\\", "/") + + +# ── Merger ────────────────────────────────────────────────────────────────── +class PptxMerger: + def __init__(self, tmp: Path): + self.tmp = tmp + self.out = tmp / "out" + # Single global ID space shared by sldMasterId and sldLayoutId + # (PowerPoint requires these to be globally unique together). + self._gid_counter = 2147483648 + + # ── target remapping ──────────────────────────────────────────────── + @staticmethod + def remap_rel(r, part_folder, media_map=None, chart_map=None, theme_map=None, + overrides=None): + """Return a copy of relationship `r` with its Target rewritten for the + merged package. + + The relationship Id is deliberately left untouched: rel IDs are + part-local, so preserving them keeps every r:id / r:embed / r:link + reference in the copied XML valid with no rewriting of the part itself. + + `overrides` maps a relationship Type to a fixed replacement Target and + wins over the name-based maps. + """ + nr = dict(r) # Id preserved verbatim + if nr.get("TargetMode", "") == "External": + return nr + + target = nr.get("Target", "") + if target.startswith("/"): + target = abs_to_rel(target, part_folder) + nr["Target"] = target + + rtype = r.get("Type", "") + if overrides and rtype in overrides: + nr["Target"] = overrides[rtype] + return nr + + name = Path(target).name + if theme_map and rtype == RT_THEME and name in theme_map: + nr["Target"] = f"../theme/{theme_map[name]}" + elif chart_map and rtype == RT_CHART and name in chart_map: + nr["Target"] = f"../charts/{chart_map[name]}" + elif media_map and (rtype in MEDIA_RELS or "/media/" in target): + if name in media_map: + nr["Target"] = f"../media/{media_map[name]}" + return nr + + def remap_rels(self, rels, part_folder, **kw): + return [self.remap_rel(r, part_folder, **kw) for r in rels] + + # ── main ──────────────────────────────────────────────────────────── + def merge(self, inputs, output: Path): + srcs = [] + for i, inp in enumerate(inputs): + d = self.tmp / f"s{i}" + d.mkdir(parents=True, exist_ok=True) + base = d.resolve() + with zipfile.ZipFile(inp) as z: + for info in z.infolist(): + dest = (d / info.filename).resolve() + if not str(dest).startswith(str(base) + os.sep): + sys.exit(f"Unsafe path in PPTX zip entry: {info.filename}") + z.extractall(d) + srcs.append(d) + + shutil.copytree(srcs[0], self.out, dirs_exist_ok=True) + self._fix_base_rels() + self._sync_id_counters(self.out) + + prs_path = self.out / "ppt" / "presentation.xml" + prs_rels_path = self.out / "ppt" / "_rels" / "presentation.xml.rels" + prs_root = parse_xml(prs_path) + prs_rels = read_rels(prs_rels_path) + + sld_id_lst = prs_root.find(f".//{{{NS_P}}}sldIdLst") + if sld_id_lst is None: + sld_id_lst = etree.SubElement(prs_root, f"{{{NS_P}}}sldIdLst") + + max_sld_id = max((int(el.get("id", 0)) for el in sld_id_lst), default=255) + slide_num, layout_num, master_num, theme_num = self._count_parts(self.out) + notes_num = self._count_notes(self.out) + prs_rid_set = {r["Id"] for r in prs_rels} + + for src_idx, src in enumerate(srcs[1:], start=1): + media_map = self._copy_media(src, src_idx) + chart_map = self._copy_charts_and_deps(src, src_idx) + theme_map = self._copy_themes(src, theme_num, media_map) + theme_num += len(theme_map) + + master_map, layout_map = self._copy_masters_and_layouts( + src, master_num, layout_num, theme_map, media_map, chart_map + ) + master_num += len(master_map) + layout_num += len(layout_map) + + master_id_lst = prs_root.find(f".//{{{NS_P}}}sldMasterIdLst") + if master_id_lst is None: + # Must precede sldIdLst per schema; insert at position 0. + master_id_lst = etree.Element(f"{{{NS_P}}}sldMasterIdLst") + prs_root.insert(0, master_id_lst) + + for _, new_master_fname in master_map.items(): + # presentation.xml.rels IS a shared ID space -> new Id here. + new_rid = next_rid(prs_rid_set) + prs_rid_set.add(new_rid) + prs_rels.append({ + "Id": new_rid, "Type": RT_SLIDE_MASTER, + "Target": f"slideMasters/{new_master_fname}", "TargetMode": "", + }) + el = etree.SubElement(master_id_lst, f"{{{NS_P}}}sldMasterId") + self._gid_counter += 1 + el.set("id", str(self._gid_counter)) # required, globally unique + el.set(f"{{{NS_R}}}id", new_rid) + + old_to_new_slides = {} + for slide_fname in self._ordered_slides(src): + src_slide = src / "ppt" / "slides" / slide_fname + if not src_slide.exists(): + continue + + slide_num += 1 + new_slide_fname = f"slide{slide_num}.xml" + old_to_new_slides[slide_fname] = new_slide_fname + + out_slide = self.out / "ppt" / "slides" / new_slide_fname + out_slide.parent.mkdir(parents=True, exist_ok=True) + # Copied byte-for-byte: rel IDs are preserved, so every r:id / + # r:embed / r:link inside still resolves. + shutil.copy2(src_slide, out_slide) + + src_rels = src / "ppt" / "slides" / "_rels" / f"{slide_fname}.rels" + new_slide_rels = self.remap_rels( + read_rels(src_rels), "ppt/slides", + media_map=media_map, chart_map=chart_map, + overrides=None, + ) + new_slide_rels = [ + self._remap_layout_target(r, layout_map) for r in new_slide_rels + ] + + out_rels_dir = self.out / "ppt" / "slides" / "_rels" + out_rels_dir.mkdir(parents=True, exist_ok=True) + write_xml(build_rels_xml(new_slide_rels), + out_rels_dir / f"{new_slide_fname}.rels") + + max_sld_id += 1 + new_prs_rid = next_rid(prs_rid_set) + prs_rid_set.add(new_prs_rid) + sld_el = etree.SubElement(sld_id_lst, f"{{{NS_P}}}sldId") + sld_el.set("id", str(max_sld_id)) + sld_el.set(f"{{{NS_R}}}id", new_prs_rid) + prs_rels.append({ + "Id": new_prs_rid, "Type": RT_SLIDE, + "Target": f"slides/{new_slide_fname}", "TargetMode": "", + }) + + notes_num = self._copy_notes(src, old_to_new_slides, notes_num, media_map) + + write_xml(prs_root, prs_path) + write_xml(build_rels_xml(prs_rels), prs_rels_path) + self._rebuild_content_types() + + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zout: + # [Content_Types].xml first is conventional and harmless. + ct = self.out / "[Content_Types].xml" + if ct.exists(): + zout.write(ct, "[Content_Types].xml") + for f in sorted(self.out.rglob("*")): + if f.is_file() and f.name != "[Content_Types].xml": + zout.write(f, f.relative_to(self.out)) + + print(f"OK merged {len(inputs)} files -> {output}") + + # ── helpers ───────────────────────────────────────────────────────── + @staticmethod + def _remap_layout_target(r, layout_map): + if r.get("Type") != RT_SLIDE_LAYOUT or r.get("TargetMode") == "External": + return r + name = Path(r.get("Target", "")).name + if name in layout_map: + nr = dict(r) + nr["Target"] = f"../slideLayouts/{layout_map[name]}" + return nr + return r + + def _count_parts(self, base: Path): + ppt = base / "ppt" + + def max_num(glob_pat: str, prefix: str) -> int: + rx = re.compile(rf"^{re.escape(prefix)}(\d+)\.xml$") + nums = (int(m.group(1)) for f in ppt.glob(glob_pat) if (m := rx.match(f.name))) + return max(nums, default=0) + + return ( + max_num("slides/slide[0-9]*.xml", "slide"), + max_num("slideLayouts/slideLayout[0-9]*.xml", "slideLayout"), + max_num("slideMasters/slideMaster[0-9]*.xml", "slideMaster"), + max_num("theme/theme[0-9]*.xml", "theme"), + ) + + def _count_notes(self, base: Path): + d = base / "ppt" / "notesSlides" + if not d.exists(): + return 0 + rx = re.compile(r"^notesSlide(\d+)\.xml$") + nums = (int(m.group(1)) for f in d.glob("notesSlide[0-9]*.xml") if (m := rx.match(f.name))) + return max(nums, default=0) + + def _sync_id_counters(self, base: Path): + ns = {"p": NS_P} + prs = base / "ppt" / "presentation.xml" + if prs.exists(): + root = parse_xml(prs) + ml = root.find(".//p:sldMasterIdLst", ns) + if ml is not None: + for el in ml: + try: + self._gid_counter = max(self._gid_counter, int(el.get("id", 0))) + except ValueError: + pass + md = base / "ppt" / "slideMasters" + if md.exists(): + for mf in md.glob("slideMaster*.xml"): + root = parse_xml(mf) + ll = root.find(".//p:sldLayoutIdLst", ns) + if ll is not None: + for el in ll: + try: + self._gid_counter = max(self._gid_counter, int(el.get("id", 0))) + except ValueError: + pass + + def _fix_base_rels(self): + for rels_path in sorted(self.out.rglob("*.rels")): + root = parse_xml(rels_path) + changed = False + try: + part_folder = str(rels_path.parent.parent.relative_to(self.out)).replace("\\", "/") + except ValueError: + part_folder = "." + for rel in root: + t = rel.get("Target", "") + if t.startswith("/") and rel.get("TargetMode", "") != "External": + rel.set("Target", abs_to_rel(t, part_folder)) + changed = True + if changed: + write_xml(root, rels_path) + + def _ordered_slides(self, src: Path): + prs_root = parse_xml(src / "ppt" / "presentation.xml") + prs_rels = read_rels(src / "ppt" / "_rels" / "presentation.xml.rels") + rid_to_slide = {r["Id"]: Path(r["Target"]).name + for r in prs_rels if r["Type"] == RT_SLIDE} + lst = prs_root.find(f".//{{{NS_P}}}sldIdLst") + if lst is None: + return [] + return [rid_to_slide[el.get(f"{{{NS_R}}}id")] for el in lst + if el.get(f"{{{NS_R}}}id") in rid_to_slide] + + def _copy_media(self, src: Path, src_idx: int): + sm = src / "ppt" / "media" + if not sm.exists(): + return {} + om = self.out / "ppt" / "media" + om.mkdir(parents=True, exist_ok=True) + mp = {} + for f in sorted(sm.iterdir()): + if f.is_file(): + new = f"s{src_idx}_{f.name}" + shutil.copy2(f, om / new) + mp[f.name] = new + return mp + + def _copy_charts_and_deps(self, src: Path, src_idx: int): + sc = src / "ppt" / "charts" + se = src / "ppt" / "embeddings" + oc = self.out / "ppt" / "charts" + oe = self.out / "ppt" / "embeddings" + chart_map, embed_map = {}, {} + if se.exists(): + oe.mkdir(parents=True, exist_ok=True) + for f in sorted(se.iterdir()): + if f.is_file(): + new = f"s{src_idx}_{f.name}" + shutil.copy2(f, oe / new) + embed_map[f.name] = new + if not sc.exists(): + return chart_map + oc.mkdir(parents=True, exist_ok=True) + for f in sorted(sc.glob("chart*.xml")): + new = f"s{src_idx}_{f.name}" + shutil.copy2(f, oc / new) + chart_map[f.name] = new + ncr = [] + for r in read_rels(sc / "_rels" / f"{f.name}.rels"): + nr = dict(r) # Id preserved + if nr.get("TargetMode", "") != "External": + oldn = Path(r.get("Target", "")).name + if oldn in embed_map: + nr["Target"] = f"../embeddings/{embed_map[oldn]}" + ncr.append(nr) + (oc / "_rels").mkdir(exist_ok=True) + write_xml(build_rels_xml(ncr), oc / "_rels" / f"{new}.rels") + return chart_map + + def _copy_themes(self, src: Path, theme_start: int, media_map): + sd = src / "ppt" / "theme" + od = self.out / "ppt" / "theme" + od.mkdir(parents=True, exist_ok=True) + tm = {} + if not sd.exists(): + return tm + n = theme_start + 1 + for f in sorted(sd.glob("theme*.xml")): + new = f"theme{n}.xml" + shutil.copy2(f, od / new) + tm[f.name] = new + sr = sd / "_rels" / f"{f.name}.rels" + if sr.exists(): + # Themes can reference media (background fills); remap targets + # while preserving Ids so the theme XML's r:embed still resolves. + (od / "_rels").mkdir(exist_ok=True) + write_xml( + build_rels_xml(self.remap_rels(read_rels(sr), "ppt/theme", + media_map=media_map)), + od / "_rels" / f"{new}.rels", + ) + n += 1 + return tm + + def _copy_masters_and_layouts(self, src, master_start, layout_start, + theme_map, media_map, chart_map=None): + chart_map = chart_map or {} + sm = src / "ppt" / "slideMasters" + sl = src / "ppt" / "slideLayouts" + om = self.out / "ppt" / "slideMasters" + ol = self.out / "ppt" / "slideLayouts" + om.mkdir(parents=True, exist_ok=True) + ol.mkdir(parents=True, exist_ok=True) + master_map, layout_map = {}, {} + if not sm.exists(): + return master_map, layout_map + + m_num = master_start + 1 + l_num = layout_start + 1 + + for smf in sorted(sm.glob("slideMaster*.xml")): + new_master = f"slideMaster{m_num}.xml" + master_map[smf.name] = new_master + + this_layouts, new_mrels = {}, [] + for r in read_rels(sm / "_rels" / f"{smf.name}.rels"): + if r["Type"] == RT_SLIDE_LAYOUT and r.get("TargetMode") != "External": + old_l = Path(r["Target"]).name + new_l = layout_map.get(old_l) + if new_l is None: + new_l = f"slideLayout{l_num}.xml" + l_num += 1 + layout_map[old_l] = new_l + this_layouts[old_l] = new_l + nr = dict(r) # Id preserved + nr["Target"] = f"../slideLayouts/{new_l}" + new_mrels.append(nr) + else: + new_mrels.append(self.remap_rel( + r, "ppt/slideMasters", + media_map=media_map, chart_map=chart_map, theme_map=theme_map, + )) + + # Copied byte-for-byte. No string surgery on the XML is needed: rel + # IDs are preserved so r:id/r:embed still resolve, and media paths + # never appear in master XML (only in its .rels). + shutil.copy2(smf, om / new_master) + + mxml = parse_xml(om / new_master) + lst = mxml.find(f".//{{{NS_P}}}sldLayoutIdLst") + if lst is not None: + for el in lst: + # @id is the globally unique int and IS renumbered. + # @r:id is the part-local rel ref and is left alone. + self._gid_counter += 1 + el.set("id", str(self._gid_counter)) + write_xml(mxml, om / new_master) + + (om / "_rels").mkdir(exist_ok=True) + write_xml(build_rels_xml(new_mrels), om / "_rels" / f"{new_master}.rels") + + for old_l, new_l in this_layouts.items(): + slf = sl / old_l + if not slf.exists(): + continue + shutil.copy2(slf, ol / new_l) + nlr = self.remap_rels( + read_rels(sl / "_rels" / f"{old_l}.rels"), "ppt/slideLayouts", + media_map=media_map, chart_map=chart_map, theme_map=theme_map, + overrides={RT_SLIDE_MASTER: f"../slideMasters/{new_master}"}, + ) + (ol / "_rels").mkdir(exist_ok=True) + write_xml(build_rels_xml(nlr), ol / "_rels" / f"{new_l}.rels") + + m_num += 1 + return master_map, layout_map + + def _copy_notes(self, src, old_to_new, notes_start, media_map=None): + sn = src / "ppt" / "notesSlides" + if not sn.exists(): + return notes_start + on = self.out / "ppt" / "notesSlides" + on.mkdir(parents=True, exist_ok=True) + (on / "_rels").mkdir(exist_ok=True) + ssr = src / "ppt" / "slides" / "_rels" + slide_to_notes = {} + if ssr.exists(): + for rf in ssr.glob("slide*.xml.rels"): + for r in read_rels(rf): + if r["Type"] == RT_NOTES: + slide_to_notes[rf.stem] = Path(r["Target"]).name + break + n = notes_start + for old_slide, new_slide in old_to_new.items(): + nf = slide_to_notes.get(old_slide) + if not nf: + continue + snp = sn / nf + if not snp.exists(): + continue + n += 1 + new_nf = f"notesSlide{n}.xml" + shutil.copy2(snp, on / new_nf) + nnr = self.remap_rels( + read_rels(sn / "_rels" / f"{nf}.rels"), "ppt/notesSlides", + media_map=media_map, + overrides={RT_SLIDE: f"../slides/{new_slide}"}, + ) + write_xml(build_rels_xml(nnr), on / "_rels" / f"{new_nf}.rels") + + osr = self.out / "ppt" / "slides" / "_rels" / f"{new_slide}.rels" + if osr.exists(): + srels = read_rels(osr) + updated = False + for r in srels: + if r["Type"] == RT_NOTES: + r["Target"] = f"../notesSlides/{new_nf}" + updated = True + if not updated: + rs = {r["Id"] for r in srels} + srels.append({"Id": next_rid(rs), "Type": RT_NOTES, + "Target": f"../notesSlides/{new_nf}", "TargetMode": ""}) + write_xml(build_rels_xml(srels), osr) + return n + + def _rebuild_content_types(self): + ct_path = self.out / "[Content_Types].xml" + existing = parse_xml(ct_path) if ct_path.exists() else None + + # DEFAULT namespace, rebuilt fresh so no prefix can leak. + root = etree.Element(f"{{{NS_CT}}}Types", nsmap={None: NS_CT}) + + known_defaults = set() + if existing is not None: + for d in existing.findall(f"{{{NS_CT}}}Default"): + ext = d.get("Extension", "").lower() + if ext and ext not in known_defaults: + el = etree.SubElement(root, f"{{{NS_CT}}}Default") + el.set("Extension", d.get("Extension")) + el.set("ContentType", d.get("ContentType")) + known_defaults.add(ext) + + regen = {CT_SLIDE, CT_SLIDE_LAYOUT, CT_SLIDE_MASTER, CT_THEME, CT_CHART, CT_NOTES_SLIDE} + if existing is not None: + for ov in existing.findall(f"{{{NS_CT}}}Override"): + if ov.get("ContentType") not in regen: + el = etree.SubElement(root, f"{{{NS_CT}}}Override") + el.set("PartName", ov.get("PartName")) + el.set("ContentType", ov.get("ContentType")) + + def add(glob_pat, prefix, ctype): + base = self.out / "ppt" + for f in sorted(base.glob(glob_pat)): + el = etree.SubElement(root, f"{{{NS_CT}}}Override") + el.set("PartName", f"/ppt/{prefix}/{f.name}") + el.set("ContentType", ctype) + + add("slides/slide[0-9]*.xml", "slides", CT_SLIDE) + add("slideLayouts/slideLayout[0-9]*.xml", "slideLayouts", CT_SLIDE_LAYOUT) + add("slideMasters/slideMaster[0-9]*.xml", "slideMasters", CT_SLIDE_MASTER) + add("theme/theme[0-9]*.xml", "theme", CT_THEME) + add("notesSlides/notesSlide[0-9]*.xml", "notesSlides", CT_NOTES_SLIDE) + + cdir = self.out / "ppt" / "charts" + if cdir.exists(): + for f in sorted(cdir.glob("*.xml")): + if re.match(r"^(s\d+_)?chart\d+\.xml$", f.name): + el = etree.SubElement(root, f"{{{NS_CT}}}Override") + el.set("PartName", f"/ppt/charts/{f.name}") + el.set("ContentType", CT_CHART) + + mdir = self.out / "ppt" / "media" + if mdir.exists(): + for f in sorted(mdir.iterdir()): + ext = f.suffix.lower().lstrip(".") + if ext and ext not in known_defaults and MEDIA_CT.get(ext): + el = etree.SubElement(root, f"{{{NS_CT}}}Default") + el.set("Extension", ext) + el.set("ContentType", MEDIA_CT[ext]) + known_defaults.add(ext) + + write_xml(root, ct_path) + + +def main(): + if len(sys.argv) < 4: + print(__doc__) + sys.exit(1) + output = Path(sys.argv[1]) + inputs = [Path(p) for p in sys.argv[2:]] + missing = [str(p) for p in inputs if not p.exists()] + if missing: + sys.exit(f"Files not found: {', '.join(missing)}") + tmp = Path(tempfile.mkdtemp(prefix="pptx_merge_")) + try: + PptxMerger(tmp).merge(inputs, output) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/submissions/pptx-merger/scripts/pptx_to_b64.py b/submissions/pptx-merger/scripts/pptx_to_b64.py new file mode 100644 index 00000000..a08a0b1b --- /dev/null +++ b/submissions/pptx-merger/scripts/pptx_to_b64.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +pptx_to_b64.py — Encode a binary .pptx back to base64 for the return trip. + +The merged file must leave the sandbox the same way inputs came in: as base64 +(ASCII), never as raw binary through a text channel. This encodes the file and +optionally verifies a round-trip (decode == original bytes) so a truncated or +altered encoding is caught here rather than in the user's PowerPoint. + +Usage: + python pptx_to_b64.py [--out ] [--json] + +With --out, the base64 is written to that file (recommended for large decks so +the string never has to be inlined). Without it, the base64 is printed to stdout. +""" + +import sys +import json +import base64 +import zipfile +import argparse +from pathlib import Path + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("pptx") + ap.add_argument("--out", default=None) + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + path = Path(args.pptx) + meta = {"ok": False, "stage": "export", "file": str(path)} + + if not path.exists(): + meta["error"] = "input file does not exist" + print(json.dumps(meta, indent=2) if args.json else meta["error"]) + return 2 + + # Confirm we are exporting a real package, not something already broken. + try: + with zipfile.ZipFile(path) as z: + names = z.namelist() + if ( + z.testzip() is not None + or "[Content_Types].xml" not in names + or not any(n.startswith("ppt/") for n in names) + ): + raise zipfile.BadZipFile("not a valid PPTX package") + + except Exception as e: # noqa: BLE001 + meta["error"] = f"refusing to export invalid package: {e}" + print(json.dumps(meta, indent=2) if args.json else meta["error"]) + return 3 + + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + + # Round-trip guard. + if base64.b64decode(b64) != raw: + meta["error"] = "round-trip mismatch (encoder produced altered bytes)" + print(json.dumps(meta, indent=2) if args.json else meta["error"]) + return 4 + + meta["ok"] = True + meta["bytes"] = len(raw) + meta["b64_len"] = len(b64) + + if args.out: + Path(args.out).write_text(b64, encoding="ascii") + meta["out"] = args.out + print(json.dumps(meta, indent=2) if args.json else + f"Wrote base64 ({len(b64)} chars) for {len(raw)} byte deck to {args.out}") + else: + if args.json: + meta["base64"] = b64 + print(json.dumps(meta, indent=2)) + else: + print(b64) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/submissions/pptx-merger/scripts/verify_pptx.py b/submissions/pptx-merger/scripts/verify_pptx.py new file mode 100644 index 00000000..0e26341b --- /dev/null +++ b/submissions/pptx-merger/scripts/verify_pptx.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +""" +verify_pptx.py — Hard gate: a PPTX is "good" only if it actually opens. + +Verification levels +------------------- +Two levels exist, and the tool always reports which one it achieved: + + render structural + package load + a real LibreOffice conversion. + This is the only level that proves the file opens. + + structural structural + package load, no conversion. A strong signal, but + NOT proof that PowerPoint will open the file. + +Render is REQUIRED by default. If LibreOffice is not present, verification +FAILS with a non-zero exit — it does not quietly downgrade and report success, +because a pass that cannot distinguish "opens" from "was never opened" is worse +than no check at all. + +A caller who knowingly accepts the weaker guarantee must ask for it explicitly +with --allow-no-render. That run exits 0 on success but reports +`verified_by: structural`, and the human-readable output says in plain words +that the file is not render-verified. Callers gating a delivery should require +`verified_by == "render"` rather than just `ok == true`. + +Note that `soffice` is not guaranteed to exist in the Copilot Studio container. +Install it in the image if you need the render guarantee there; otherwise run +with --allow-no-render and accept that the file has not been proven to open. + +Usage: + python verify_pptx.py [--allow-no-render] [--json] + + --allow-no-render Downgrade to structural verification when LibreOffice + is unavailable, instead of failing. + --require-render Explicit form of the default; fails if soffice is + missing. Kept for callers that want it spelled out. + --json Machine-readable result on stdout. + +Exit codes: + 0 verification passed at the level reported in `verified_by` + 1 verification failed + 2 bad invocation (file missing, unreadable, or not a ZIP) +""" + +import argparse +import json +import posixpath +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +try: + from lxml import etree +except ImportError: + sys.exit("ERROR: lxml is required. Install it with: pip install lxml") + +_SAFE_PARSER = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False) + +NS_CT = "http://schemas.openxmlformats.org/package/2006/content-types" +NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main" +NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +NS_REL = "http://schemas.openxmlformats.org/package/2006/relationships" + + +def rels_path_for(part: str) -> str: + d, b = posixpath.split(part) + return posixpath.join(d, "_rels", b + ".rels") + + +def part_for_rels(rels_name: str) -> str: + """'ppt/slides/_rels/slide1.xml.rels' -> 'ppt/slides/slide1.xml'""" + d, b = posixpath.split(rels_name) + return posixpath.join(posixpath.dirname(d), b[:-len(".rels")]) + + +# ── structural ────────────────────────────────────────────────────────────── +def structural_checks(path: Path): + errors, warnings = [], [] + + try: + z = zipfile.ZipFile(path) + except zipfile.BadZipFile as exc: + return [f"not a valid ZIP/OPC package — {exc}"], warnings + + with z: + bad = z.testzip() + if bad is not None: + errors.append(f"ZIP CRC error in {bad}") + names = set(z.namelist()) + + defaults, overrides = {}, {} + if "[Content_Types].xml" not in names: + errors.append("[Content_Types].xml missing") + else: + try: + root = etree.fromstring(z.read("[Content_Types].xml"), _SAFE_PARSER) + except etree.XMLSyntaxError as exc: + errors.append(f"[Content_Types].xml: malformed XML — {exc}") + root = None + if root is not None: + # The Types element MUST be in the default (unprefixed) namespace. + if root.prefix is not None: + errors.append( + f"[Content_Types].xml uses namespace prefix '{root.prefix}:' — " + "must be the default namespace or the package will not open.") + for d in root.findall(f"{{{NS_CT}}}Default"): + ext = (d.get("Extension") or "").lower() + if ext: + defaults[ext] = d.get("ContentType") + for ov in root.findall(f"{{{NS_CT}}}Override"): + pn = (ov.get("PartName") or "").lstrip("/") + if pn: + overrides[pn] = ov.get("ContentType") + if pn and pn not in names: + errors.append(f"[Content_Types].xml: Override for missing part '/{pn}'") + + # Every part needs a declared content type. + for n in sorted(names): + if n == "[Content_Types].xml" or n.endswith("/"): + continue + base = posixpath.basename(n) + ext = base.rsplit(".", 1)[-1].lower() if "." in base else "" + if n not in overrides and ext not in defaults: + errors.append(f"{n}: no content type declared (no Override, no Default '{ext}')") + + # Every internal relationship Target must resolve to a part that exists, + # and every r:* reference in a part must resolve in that part's .rels. + for n in sorted(names): + if not n.endswith(".rels"): + continue + try: + root = etree.fromstring(z.read(n), _SAFE_PARSER) + except etree.XMLSyntaxError as exc: + errors.append(f"{n}: malformed XML — {exc}") + continue + if root.prefix is not None: + errors.append( + f"{n} uses namespace prefix '{root.prefix}:' — " + "must be the default namespace or the package will not open.") + source_part = part_for_rels(n) + part_folder = posixpath.dirname(source_part) + seen_ids = set() + for rel in root: + rid = rel.get("Id", "") + if rid in seen_ids: + errors.append(f"{n}: duplicate relationship Id '{rid}'") + seen_ids.add(rid) + if rel.get("TargetMode", "") == "External": + continue + tgt = rel.get("Target", "") + if tgt.startswith("/"): + errors.append(f"{n}: absolute internal Target '{tgt}' " + "(needs relative path or TargetMode=External)") + continue + resolved = posixpath.normpath(posixpath.join(part_folder, tgt)) + if resolved not in names: + errors.append(f"{n}: Target '{tgt}' resolves to " + f"'{resolved}' which is missing from the package") + + if source_part in names and source_part.endswith(".xml"): + try: + part_root = etree.fromstring(z.read(source_part), _SAFE_PARSER) + except etree.XMLSyntaxError as exc: + errors.append(f"{source_part}: malformed XML — {exc}") + continue + for el in part_root.iter(): + for k, v in el.attrib.items(): + if not k.startswith("{" + NS_R + "}"): + continue + if isinstance(v, str) and v.startswith("rId") and v not in seen_ids: + errors.append( + f"{source_part}: @{etree.QName(k).localname}='{v}' " + f"has no matching relationship in {n}") + + # presentation.xml sanity: masters and slides have required ids. + if "ppt/presentation.xml" not in names: + errors.append("ppt/presentation.xml missing") + else: + try: + pr = etree.fromstring(z.read("ppt/presentation.xml"), _SAFE_PARSER) + except etree.XMLSyntaxError as exc: + errors.append(f"ppt/presentation.xml: malformed XML — {exc}") + pr = None + if pr is not None: + gids = [] + ml = pr.find(f".//{{{NS_P}}}sldMasterIdLst") + if ml is None or len(ml) == 0: + errors.append("presentation.xml has no slide masters") + else: + for e in ml: + if not e.get("id"): + errors.append("sldMasterId missing required 'id' attribute") + else: + gids.append(e.get("id")) + sl = pr.find(f".//{{{NS_P}}}sldIdLst") + if sl is None or len(sl) == 0: + warnings.append("presentation.xml lists no slides") + for n in sorted(names): + if n.startswith("ppt/slideMasters/slideMaster") and n.endswith(".xml"): + m = etree.fromstring(z.read(n), _SAFE_PARSER) + ll = m.find(f".//{{{NS_P}}}sldLayoutIdLst") + if ll is not None: + for e in ll: + if e.get("id"): + gids.append(e.get("id")) + dupes = {x for x in gids if gids.count(x) > 1} + if dupes: + errors.append(f"Duplicate global master/layout IDs: {sorted(dupes)}") + + return errors, warnings + + +# ── package load ──────────────────────────────────────────────────────────── +def load_check(path: Path): + """Open the package with a real OPC consumer. + + This is not a render, but it is a genuine load: python-pptx resolves the + content types, walks the relationship graph and instantiates every slide, + layout and master, so most packaging faults surface here. Pure Python, so + it is available wherever the merge scripts themselves run. + """ + try: + from pptx import Presentation + except ImportError: + return None, "python-pptx not installed; load check skipped" + try: + prs = Presentation(str(path)) + n_slides = len(prs.slides._sldIdLst) + n_masters = len(prs.slide_masters) + n_layouts = sum(len(m.slide_layouts) for m in prs.slide_masters) + for slide in prs.slides: + _ = [sh.shape_type for sh in slide.shapes] + return True, (f"loaded {n_slides} slides, {n_masters} masters, " + f"{n_layouts} layouts") + except Exception as exc: # noqa: BLE001 — report anything + return False, f"{type(exc).__name__}: {exc}" + + +# ── render ────────────────────────────────────────────────────────────────── +def find_soffice(): + return shutil.which("soffice") or shutil.which("libreoffice") + + +def render_check(path: Path, soffice: str): + with tempfile.TemporaryDirectory() as td: + prof = Path(td) / "profile" + outdir = Path(td) / "out" + outdir.mkdir() + try: + proc = subprocess.run( + [soffice, "--headless", f"-env:UserInstallation=file://{prof}", + "--convert-to", "pdf", "--outdir", str(outdir), str(path)], + capture_output=True, timeout=180, + ) + except subprocess.TimeoutExpired: + return False, "render timed out after 180s" + pdfs = list(outdir.glob("*.pdf")) + if pdfs and pdfs[0].stat().st_size > 0: + return True, f"rendered to {pdfs[0].stat().st_size} byte PDF" + detail = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip() + return False, f"LibreOffice could not load the file{': ' + detail if detail else ''}" + + +# ── main ──────────────────────────────────────────────────────────────────── +def main() -> int: + ap = argparse.ArgumentParser( + description="Verify a PPTX. Render is required unless --allow-no-render.") + ap.add_argument("pptx") + ap.add_argument("--allow-no-render", action="store_true", + help="Downgrade to structural verification when LibreOffice " + "is unavailable, instead of failing.") + ap.add_argument("--require-render", action="store_true", + help="Explicit form of the default behaviour.") + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + path = Path(args.pptx) + result = { + "ok": False, + "stage": "validate", + "file": str(path), + "verified_by": None, # "render" | "structural" | None + "render_required": not args.allow_no_render, + "structural_errors": [], + "warnings": [], + "load": None, + "render": None, + } + + def emit(code): + if args.json: + print(json.dumps(result, indent=2)) + else: + if result["ok"]: + if result["verified_by"] == "render": + print(f"PASS (render-verified) — {result['render']['detail']}") + else: + print("PASS (structural only) — NOT render-verified; " + "this file has not been proven to open.") + print(f" load: {result['load']['detail']}") + else: + print("FAIL") + # load and render failures are already folded into + # structural_errors, so this list is the complete picture. + for e in result["structural_errors"]: + print(f" - {e}") + for w in result["warnings"]: + print(f" ! {w}") + return code + + if not path.exists(): + result["structural_errors"] = ["file does not exist"] + return emit(2) + + errors, warnings = structural_checks(path) + result["structural_errors"] = list(errors) + result["warnings"] = warnings + if errors and errors[0].startswith("not a valid ZIP"): + return emit(2) + + load_ok, load_msg = load_check(path) + result["load"] = {"passed": load_ok, "detail": load_msg} + if load_ok is False: + errors.append(f"load: {load_msg}") + elif load_ok is None: + warnings.append(load_msg) + + soffice = find_soffice() + if soffice: + render_ok, render_msg = render_check(path, soffice) + result["render"] = {"passed": render_ok, "detail": render_msg} + if render_ok is False: + errors.append(f"render: {render_msg}") + else: + msg = ("LibreOffice (soffice/libreoffice) not found — the file cannot be " + "proven to open in this environment") + if args.allow_no_render: + result["render"] = {"passed": None, "detail": msg + " (accepted via --allow-no-render)"} + warnings.append("render check skipped: " + msg) + else: + result["render"] = {"passed": False, "detail": msg} + errors.append( + "render: " + msg + ". Install LibreOffice in the container, or " + "re-run with --allow-no-render to accept structural verification only.") + + result["structural_errors"] = errors + result["ok"] = not errors + if result["ok"]: + result["verified_by"] = "render" if (result["render"] + and result["render"]["passed"]) else "structural" + return emit(0 if result["ok"] else 1) + + +if __name__ == "__main__": + sys.exit(main())