From 23f4aa26d9970db49d30b1a467f340f27a761ba7 Mon Sep 17 00:00:00 2001 From: Sandeep Angara Date: Fri, 7 Aug 2026 13:01:58 +0530 Subject: [PATCH 01/21] Add PPTX Merger skill --- submissions/pptx-merger/SKILL.md | 137 ++++ submissions/pptx-merger/metadata.json | 9 + .../pptx-merger/scripts/b64_to_pptx.py | 136 ++++ submissions/pptx-merger/scripts/pptx_merge.py | 591 ++++++++++++++++++ .../pptx-merger/scripts/pptx_to_b64.py | 78 +++ .../pptx-merger/scripts/verify_pptx.py | 169 +++++ 6 files changed, 1120 insertions(+) create mode 100644 submissions/pptx-merger/SKILL.md create mode 100644 submissions/pptx-merger/metadata.json create mode 100644 submissions/pptx-merger/scripts/b64_to_pptx.py create mode 100644 submissions/pptx-merger/scripts/pptx_merge.py create mode 100644 submissions/pptx-merger/scripts/pptx_to_b64.py create mode 100644 submissions/pptx-merger/scripts/verify_pptx.py diff --git a/submissions/pptx-merger/SKILL.md b/submissions/pptx-merger/SKILL.md new file mode 100644 index 00000000..b9041f08 --- /dev/null +++ b/submissions/pptx-merger/SKILL.md @@ -0,0 +1,137 @@ +--- +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. + +## Delivering the result (Copilot Studio) + +Keep the out-of-the-box SharePoint tools as-is. The base64 from step 4 is what you +hand to your upload path. **Never read, print, chunk, or reconstruct the base64 in +the model** — pass it as a variable reference from export straight into the upload +action. The recommended upload is a Power Automate flow whose input is a +`contentBase64` string and whose *Create file* action sets File Content to +`base64ToBinary(triggerBody()?['contentBase64'])`, so the decode happens +server-side and the bytes never travel through the model by value. Return the +file's `webUrl` (and its `Length`, to compare against the exported byte count as a +final integrity check) and give the user the link. + +## 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. + +## Requirements + +`lxml` for the merge and validate steps (`pip install lxml --break-system-packages` +if not already present in the sandbox). LibreOffice (`soffice`) for 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. 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..dd7003b4 --- /dev/null +++ b/submissions/pptx-merger/scripts/b64_to_pptx.py @@ -0,0 +1,136 @@ +#!/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) + if p.exists() and p.is_file(): + raw = p.read_text(encoding="utf-8", errors="strict") + else: + raw = 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: + txt = p.read_text(encoding="utf-8", errors="strict") if (p.exists() and p.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..cf2e60bc --- /dev/null +++ b/submissions/pptx-merger/scripts/pptx_merge.py @@ -0,0 +1,591 @@ +#!/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 ...] + +This is the corrected merge stage. It fixes, in addition to the earlier +media/chart/notes/layout-chain issues, the two defects that made every prior +output unopenable: + + * [Content_Types].xml and every .rels part are now 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. + + * Every appended now receives its required unique unsigned + integer `id` attribute (>= 2147483648). Omitting it is a schema violation on + any multi-master merge (the normal case). +""" + +import os +import re +import sys +import json +import shutil +import tempfile +import zipfile +from pathlib import Path + +from lxml import etree + + +# ── 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" + +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) + +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. (Fix C) + 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 str_replace_file(path: Path, replacements: dict): + if not replacements: + return + text = path.read_text(encoding="utf-8", errors="replace") + for old, new in replacements.items(): + text = text.replace(old, new) + path.write_text(text, encoding="utf-8") + +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). (Fix B) + self._gid_counter = 2147483648 + + def merge(self, inputs, output: Path): + srcs = [] + for i, inp in enumerate(inputs): + d = self.tmp / f"s{i}" + with zipfile.ZipFile(inp) as z: + z.extractall(d) + srcs.append(d) + + shutil.copytree(srcs[0], self.out) + 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) + theme_num += len(theme_map) + + master_map, layout_map = self._copy_masters_and_layouts( + src, master_num, layout_num, theme_map, media_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(): + 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)) # (Fix B) required, globally unique id + el.set(f"{{{NS_R}}}id", new_rid) + + old_to_new_slides = {} + for slide_fname in self._ordered_slides(src): + slide_num += 1 + new_slide_fname = f"slide{slide_num}.xml" + old_to_new_slides[slide_fname] = new_slide_fname + src_slide = src / "ppt" / "slides" / slide_fname + if not src_slide.exists(): + continue + out_slide = self.out / "ppt" / "slides" / new_slide_fname + shutil.copy2(src_slide, out_slide) + + str_replace_file(out_slide, { + f"../media/{o}": f"../media/{n}" for o, n in media_map.items() + }) + + src_rels = src / "ppt" / "slides" / "_rels" / f"{slide_fname}.rels" + new_slide_rels, rid_remap = self._remap_slide_rels( + read_rels(src_rels), layout_map, media_map, chart_map + ) + str_replace_file(out_slide, { + f'r:id="{o}"': f'r:id="{n}"' for o, n in rid_remap.items() + }) + str_replace_file(out_slide, { + f'r:embed="{o}"': f'r:embed="{n}"' for o, n in rid_remap.items() + }) + + 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) + + 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 ── + def _count_parts(self, base: Path): + ppt = base / "ppt" + n = lambda g: sum(1 for _ in ppt.glob(g)) + return (n("slides/slide[0-9]*.xml"), n("slideLayouts/slideLayout[0-9]*.xml"), + n("slideMasters/slideMaster[0-9]*.xml"), n("theme/theme[0-9]*.xml")) + + def _count_notes(self, base: Path): + d = base / "ppt" / "notesSlides" + return sum(1 for _ in d.glob("notesSlide[0-9]*.xml")) if d.exists() else 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 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 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 + crels = read_rels(sc / "_rels" / f"{f.name}.rels") + ncr = [] + for r in crels: + nr = dict(r) + 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): + 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(): + (od / "_rels").mkdir(exist_ok=True) + shutil.copy2(sr, od / "_rels" / f"{new}.rels") + n += 1 + return tm + + def _copy_masters_and_layouts(self, src, master_start, layout_start, theme_map, media_map): + 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 + smrels = read_rels(sm / "_rels" / f"{smf.name}.rels") + this_layouts, new_mrels, rid_set = {}, [], set() + for r in smrels: + nr_id = next_rid(rid_set); rid_set.add(nr_id) + nr = dict(r); nr["Id"] = nr_id + if r["Type"] == RT_SLIDE_LAYOUT: + old_l = Path(r["Target"]).name + new_l = f"slideLayout{l_num}.xml" + this_layouts[old_l] = new_l + layout_map[old_l] = new_l + nr["Target"] = f"../slideLayouts/{new_l}" + l_num += 1 + elif r["Type"] == RT_THEME: + old_t = Path(r["Target"]).name + if old_t in theme_map: + nr["Target"] = f"../theme/{theme_map[old_t]}" + new_mrels.append(nr) + shutil.copy2(smf, om / new_master) + str_replace_file(om / new_master, + {f"../media/{o}": f"../media/{n}" for o, n in media_map.items()}) + # unique sldLayoutId ints (Fix #8 from prior round) + mxml = parse_xml(om / new_master) + lst = mxml.find(f".//{{{NS_P}}}sldLayoutIdLst") + if lst is not None: + for el in lst: + 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) + str_replace_file(ol / new_l, + {f"../media/{o}": f"../media/{n}" for o, n in media_map.items()}) + lrels = read_rels(sl / "_rels" / f"{old_l}.rels") + nlr, lset = [], set() + for lr in lrels: + rid = next_rid(lset); lset.add(rid) + nr = dict(lr); nr["Id"] = rid + if lr["Type"] == RT_SLIDE_MASTER: + nr["Target"] = f"../slideMasters/{new_master}" + elif lr["Type"] == RT_THEME: + ot = Path(lr["Target"]).name + if ot in theme_map: + nr["Target"] = f"../theme/{theme_map[ot]}" + nlr.append(nr) + (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 _remap_slide_rels(self, src_rels, layout_map, media_map, chart_map, + part_folder="ppt/slides"): + new_rels, rid_set, rid_remap = [], set(), {} + for r in src_rels: + rid = next_rid(rid_set); rid_set.add(rid) + rid_remap[r["Id"]] = rid + nr = dict(r); nr["Id"] = rid + target = nr.get("Target", "") + if target.startswith("/") and nr.get("TargetMode", "") != "External": + target = abs_to_rel(target, part_folder) + nr["Target"] = target + if r["Type"] == RT_SLIDE_LAYOUT: + oln = Path(target).name + if oln in layout_map: + nr["Target"] = f"../slideLayouts/{layout_map[oln]}" + elif r["Type"] == RT_CHART: + ocn = Path(target).name + if ocn in chart_map: + nr["Target"] = f"../charts/{chart_map[ocn]}" + elif "../media/" in target or r["Type"] == RT_IMAGE: + omn = Path(target).name + if omn in media_map: + nr["Target"] = f"../media/{media_map[omn]}" + new_rels.append(nr) + return new_rels, rid_remap + + def _copy_notes(self, src, old_to_new, notes_start): + 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) + nrels = read_rels(sn / "_rels" / f"{nf}.rels") + nnr = [] + for r in nrels: + nr = dict(r) + if r["Type"] == RT_SLIDE: + nr["Target"] = f"../slides/{new_slide}" + nnr.append(nr) + 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. (Fix A) + 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 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..a62403d6 --- /dev/null +++ b/submissions/pptx-merger/scripts/pptx_to_b64.py @@ -0,0 +1,78 @@ +#!/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: + if z.testzip() is not None or "[Content_Types].xml" not in z.namelist(): + 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..59863c67 --- /dev/null +++ b/submissions/pptx-merger/scripts/verify_pptx.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +verify_pptx.py — Hard gate: a PPTX is "good" only if it actually opens. + +Two prior rounds passed a structural validator yet produced files PowerPoint +could not open, because that validator checks neither the content-types +namespace prefix nor whether the package truly loads. This stage closes that +gap: it runs the structural checks AND performs a real render conversion with +LibreOffice. If the render step is unavailable in the sandbox it degrades to +structural-only and says so, rather than claiming success it can't prove. + +Usage: + python verify_pptx.py [--render] [--json] + +--render forces the render check on (it is on by default when soffice exists). +Exit code 0 only if every enabled check passes. +""" + +import sys +import json +import shutil +import zipfile +import argparse +import subprocess +import tempfile +from pathlib import Path + +from lxml import etree + +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" + + +def structural_checks(path: Path): + errors, warnings = [], [] + with zipfile.ZipFile(path) as z: + bad = z.testzip() + if bad is not None: + errors.append(f"ZIP CRC error in {bad}") + names = set(z.namelist()) + + if "[Content_Types].xml" not in names: + errors.append("[Content_Types].xml missing") + else: + ct_bytes = z.read("[Content_Types].xml") + root = etree.fromstring(ct_bytes) + # 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.") + declared = {ov.get("PartName") for ov in root.findall(f"{{{NS_CT}}}Override")} + defaults = {d.get("Extension", "").lower() for d in root.findall(f"{{{NS_CT}}}Default")} + + # Every part referenced by a .rels must exist; no absolute internal targets. + for n in names: + if n.endswith(".rels"): + root = etree.fromstring(z.read(n)) + part_folder = "/".join(n.split("/")[:-2]) # strip _rels/.rels + for rel in root: + tgt = rel.get("Target", "") + mode = rel.get("TargetMode", "") + if mode == "External": + continue + if tgt.startswith("/"): + errors.append(f"{n}: absolute internal Target '{tgt}' " + "(needs relative path or TargetMode=External)") + + # presentation.xml sanity: masters and slides have required ids. + if "ppt/presentation.xml" in names: + pr = etree.fromstring(z.read("ppt/presentation.xml")) + gids = [] + ml = pr.find(f".//{{{NS_P}}}sldMasterIdLst") + if ml is not None: + for e in ml: + if not e.get("id"): + errors.append("sldMasterId missing required 'id' attribute") + else: + gids.append(e.get("id")) + # collect layout ids from every master for global-uniqueness check + for n in names: + if n.startswith("ppt/slideMasters/slideMaster") and n.endswith(".xml"): + m = etree.fromstring(z.read(n)) + 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 + + +def render_check(path: Path): + soffice = shutil.which("soffice") or shutil.which("libreoffice") + if not soffice: + return None, "soffice not available; render check skipped" + with tempfile.TemporaryDirectory() as td: + prof = Path(td) / "profile" + outdir = Path(td) / "out" + outdir.mkdir() + try: + 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" + 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" + return False, "LibreOffice could not load the file" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("pptx") + ap.add_argument("--render", action="store_true", + help="Force render check (default: on when soffice exists).") + ap.add_argument("--no-render", action="store_true") + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + path = Path(args.pptx) + result = {"ok": False, "stage": "validate", "file": str(path), + "structural_errors": [], "render": None} + + if not path.exists(): + result["structural_errors"] = ["file does not exist"] + print(json.dumps(result, indent=2) if args.json else "file does not exist") + return 2 + + errors, warnings = structural_checks(path) + result["structural_errors"] = errors + result["warnings"] = warnings + + do_render = not args.no_render + if do_render: + ok, msg = render_check(path) + result["render"] = {"passed": ok, "detail": msg} + if ok is False: + errors = errors + [f"render: {msg}"] + + result["ok"] = len(errors) == 0 and result.get("render", {}) is not None \ + and result["render"].get("passed") in (True, None) \ + if do_render else (len(errors) == 0) + # Simpler, explicit final decision: + render_failed = do_render and result["render"] and result["render"]["passed"] is False + result["ok"] = (len(errors) == 0) and (not render_failed) + + if args.json: + print(json.dumps(result, indent=2)) + else: + if result["ok"]: + r = result["render"]["detail"] if result["render"] else "structural only" + print(f"PASS — {r}") + else: + print("FAIL") + for e in errors: + print(f" - {e}") + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 94ab9cab5bfb38e47d718b1462b5774385df8309 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:13:39 +0530 Subject: [PATCH 02/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- submissions/pptx-merger/scripts/verify_pptx.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/submissions/pptx-merger/scripts/verify_pptx.py b/submissions/pptx-merger/scripts/verify_pptx.py index 59863c67..67e6c7cf 100644 --- a/submissions/pptx-merger/scripts/verify_pptx.py +++ b/submissions/pptx-merger/scripts/verify_pptx.py @@ -138,9 +138,8 @@ def main() -> int: result["structural_errors"] = errors result["warnings"] = warnings - do_render = not args.no_render - if do_render: - ok, msg = render_check(path) + soffice_present = shutil.which("soffice") or shutil.which("libreoffice") + do_render = args.render or (soffice_present and not args.no_render) result["render"] = {"passed": ok, "detail": msg} if ok is False: errors = errors + [f"render: {msg}"] From 5d0bd09d440b70d64d3fa784cb3b9f62bb12db9f Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:17:30 +0530 Subject: [PATCH 03/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- submissions/pptx-merger/scripts/verify_pptx.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/submissions/pptx-merger/scripts/verify_pptx.py b/submissions/pptx-merger/scripts/verify_pptx.py index 67e6c7cf..ed4392c2 100644 --- a/submissions/pptx-merger/scripts/verify_pptx.py +++ b/submissions/pptx-merger/scripts/verify_pptx.py @@ -140,15 +140,21 @@ def main() -> int: soffice_present = shutil.which("soffice") or shutil.which("libreoffice") do_render = args.render or (soffice_present and not args.no_render) + + if do_render: + ok, msg = render_check(path) result["render"] = {"passed": ok, "detail": msg} if ok is False: errors = errors + [f"render: {msg}"] + else: + if args.no_render: + result["render"] = {"passed": None, "detail": "render disabled (--no-render)"} + elif not soffice_present: + result["render"] = {"passed": None, "detail": "soffice not available; render check skipped"} + else: + result["render"] = {"passed": None, "detail": "render check skipped"} - result["ok"] = len(errors) == 0 and result.get("render", {}) is not None \ - and result["render"].get("passed") in (True, None) \ - if do_render else (len(errors) == 0) - # Simpler, explicit final decision: - render_failed = do_render and result["render"] and result["render"]["passed"] is False + render_failed = do_render and result["render"]["passed"] is False result["ok"] = (len(errors) == 0) and (not render_failed) if args.json: From f8bf9585dd6122bd51842ce10764e46b8a626cf5 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:23:41 +0530 Subject: [PATCH 04/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- submissions/pptx-merger/scripts/pptx_merge.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/submissions/pptx-merger/scripts/pptx_merge.py b/submissions/pptx-merger/scripts/pptx_merge.py index cf2e60bc..20548af8 100644 --- a/submissions/pptx-merger/scripts/pptx_merge.py +++ b/submissions/pptx-merger/scripts/pptx_merge.py @@ -64,8 +64,13 @@ # ── XML helpers ───────────────────────────────────────────────────────────── def _parser(): - return etree.XMLParser(remove_blank_text=False, recover=True) - + 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() From 17c29202a8812b7c92759468398bb3e3a581a1a2 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:29:03 +0530 Subject: [PATCH 05/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- submissions/pptx-merger/scripts/pptx_merge.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/submissions/pptx-merger/scripts/pptx_merge.py b/submissions/pptx-merger/scripts/pptx_merge.py index 20548af8..5dda64a2 100644 --- a/submissions/pptx-merger/scripts/pptx_merge.py +++ b/submissions/pptx-merger/scripts/pptx_merge.py @@ -137,6 +137,11 @@ def merge(self, inputs, output: Path): for i, inp in enumerate(inputs): d = self.tmp / f"s{i}" with zipfile.ZipFile(inp) as z: + base = d.resolve() + 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) From 3c4dc4123fc255d64fa1be2bc40a33be37ee3693 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:18:33 +0530 Subject: [PATCH 06/21] Improve file reading logic in b64_to_pptx.py Refactor file existence check and error handling for reading text. --- submissions/pptx-merger/scripts/b64_to_pptx.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/submissions/pptx-merger/scripts/b64_to_pptx.py b/submissions/pptx-merger/scripts/b64_to_pptx.py index dd7003b4..3d0f1f8c 100644 --- a/submissions/pptx-merger/scripts/b64_to_pptx.py +++ b/submissions/pptx-merger/scripts/b64_to_pptx.py @@ -47,7 +47,11 @@ def _has_replacement_chars(arg: str) -> bool: # Its presence means corruption already happened upstream of this script. p = Path(arg) try: - txt = p.read_text(encoding="utf-8", errors="strict") if (p.exists() and p.is_file()) else arg + 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 From d6ae1bccdf1f4c0f8479bbbaae3447ac5ac05930 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:21:37 +0530 Subject: [PATCH 07/21] Create README for PPTX Merger Added a README file explaining the PPTX Merger functionality. --- submissions/pptx-merger/README.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 submissions/pptx-merger/README.md diff --git a/submissions/pptx-merger/README.md b/submissions/pptx-merger/README.md new file mode 100644 index 00000000..fb2d57e6 --- /dev/null +++ b/submissions/pptx-merger/README.md @@ -0,0 +1,5 @@ +# 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 From 27cf8d41a9bf69bc162444c02b9358b03cc5f2d4 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:37:28 +0530 Subject: [PATCH 08/21] Update README.md --- submissions/pptx-merger/README.md | 83 +++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/submissions/pptx-merger/README.md b/submissions/pptx-merger/README.md index fb2d57e6..02e59f13 100644 --- a/submissions/pptx-merger/README.md +++ b/submissions/pptx-merger/README.md @@ -1,5 +1,80 @@ -# PPTX Merger — corruption-safe deck merge for agent sandboxes +# PPTX Merger -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 +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) +``` + +### 1. Ingest — `scripts/b64_to_pptx.py` + +Decodes the connector's base64 (ASCII, corruption-proof in transit) to bytes, +writes them with a **binary** handle, and proves the result is a readable OOXML +package before anything else runs. + + +### 2. Merge — `scripts/pptx_merge.py` + +- writes `[Content_Types].xml` and all `.rels` in the **default (unprefixed)** + OPC namespace (the prefixed form is the classic "can't read this file" cause); +- gives every `` its required `id`, drawing master and layout IDs + from one shared counter so they are globally unique together; +- normalizes absolute OPC targets (e.g. `/ppt/charts/chart1.xml`) to relative + paths; +- copies media, charts, and embeddings with a source-index prefix so identical + filenames across decks can't collide; +- re-points slide → layout → master → theme chains and carries speaker notes; +- rebuilds `[Content_Types].xml` from what is actually on disk. + +### 3. Validate — `scripts/verify_pptx.py` + +The "does it actually open" gate. Structural checks **plus** a real LibreOffice +render — a structural pass alone can still leave a file PowerPoint won't open. + +Checks ZIP integrity, default-namespace `[Content_Types].xml`, no absolute +internal `.rels` targets, present `sldMasterId` IDs, globally-unique master/layout +IDs, and a successful render. 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 — `scripts/pptx_to_b64.py` + +Encodes the verified file back to base64 for the return trip, refusing to export +anything that isn't a valid PPTX and verifying a decode round-trip. + +## 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. From 1ace93730824c32e4d46665f88ed925ea1f9e868 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:42:11 +0530 Subject: [PATCH 09/21] Update b64_to_pptx.py --- submissions/pptx-merger/scripts/b64_to_pptx.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/submissions/pptx-merger/scripts/b64_to_pptx.py b/submissions/pptx-merger/scripts/b64_to_pptx.py index 3d0f1f8c..dbdf1cb8 100644 --- a/submissions/pptx-merger/scripts/b64_to_pptx.py +++ b/submissions/pptx-merger/scripts/b64_to_pptx.py @@ -31,10 +31,11 @@ def _load_b64_text(arg: str) -> str: p = Path(arg) - if p.exists() and p.is_file(): - raw = p.read_text(encoding="utf-8", errors="strict") - else: - raw = 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: From bb4738ad28461ec25dbb854e6265213949f48895 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:59:16 +0530 Subject: [PATCH 10/21] Update verify_pptx.py --- .../pptx-merger/scripts/verify_pptx.py | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/submissions/pptx-merger/scripts/verify_pptx.py b/submissions/pptx-merger/scripts/verify_pptx.py index ed4392c2..d79ab3c0 100644 --- a/submissions/pptx-merger/scripts/verify_pptx.py +++ b/submissions/pptx-merger/scripts/verify_pptx.py @@ -25,7 +25,12 @@ import tempfile from pathlib import Path -from lxml import etree +try: + from lxml import etree +except ImportError: + import sys + sys.exit("ERROR: lxml is required. Install it with: pip install lxml") +_SAFE_PARSER = etree.XMLParser(resolve_entities=False, no_network=True) NS_CT = "http://schemas.openxmlformats.org/package/2006/content-types" NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main" @@ -54,9 +59,11 @@ def structural_checks(path: Path): defaults = {d.get("Extension", "").lower() for d in root.findall(f"{{{NS_CT}}}Default")} # Every part referenced by a .rels must exist; no absolute internal targets. + + zip_names = set(names) for n in names: if n.endswith(".rels"): - root = etree.fromstring(z.read(n)) + root = etree.fromstring(z.read(n), _SAFE_PARSER) part_folder = "/".join(n.split("/")[:-2]) # strip _rels/.rels for rel in root: tgt = rel.get("Target", "") @@ -65,11 +72,15 @@ def structural_checks(path: Path): continue if tgt.startswith("/"): errors.append(f"{n}: absolute internal Target '{tgt}' " - "(needs relative path or TargetMode=External)") + "(needs relative path or TargetMode=External)") + continue + resolved = "/".join(filter(None, [part_folder, tgt])).lstrip("/") + if resolved not in zip_names: + errors.append(f"{n}: Target '{tgt}' resolves to '{resolved}' which is missing from ZIP") # presentation.xml sanity: masters and slides have required ids. if "ppt/presentation.xml" in names: - pr = etree.fromstring(z.read("ppt/presentation.xml")) + pr = etree.fromstring(z.read("ppt/presentation.xml"), _SAFE_PARSER) gids = [] ml = pr.find(f".//{{{NS_P}}}sldMasterIdLst") if ml is not None: @@ -141,7 +152,10 @@ def main() -> int: soffice_present = shutil.which("soffice") or shutil.which("libreoffice") do_render = args.render or (soffice_present and not args.no_render) - if do_render: + if args.render and not soffice_present: + errors = errors + ["render: soffice/libreoffice not found but --render was explicitly requested"] + result["render"] = {"passed": False, "detail": "soffice not available"} + elif do_render: ok, msg = render_check(path) result["render"] = {"passed": ok, "detail": msg} if ok is False: @@ -154,6 +168,7 @@ def main() -> int: else: result["render"] = {"passed": None, "detail": "render check skipped"} + render_failed = do_render and result["render"]["passed"] is False result["ok"] = (len(errors) == 0) and (not render_failed) From 3efa4894ede3875d26703271f39694e27edca108 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:00:45 +0530 Subject: [PATCH 11/21] Update pptx_merge.py --- submissions/pptx-merger/scripts/pptx_merge.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/submissions/pptx-merger/scripts/pptx_merge.py b/submissions/pptx-merger/scripts/pptx_merge.py index 5dda64a2..dc192330 100644 --- a/submissions/pptx-merger/scripts/pptx_merge.py +++ b/submissions/pptx-merger/scripts/pptx_merge.py @@ -29,8 +29,11 @@ import zipfile from pathlib import Path -from lxml import etree - +try: + from lxml import etree +except ImportError: + import sys + sys.exit("ERROR: lxml is required. Install it with: pip install lxml") # ── Namespaces ────────────────────────────────────────────────────────────── NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main" From 6f22db3536b5af818b6663691c16b590df887b28 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:04:59 +0530 Subject: [PATCH 12/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- submissions/pptx-merger/scripts/verify_pptx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submissions/pptx-merger/scripts/verify_pptx.py b/submissions/pptx-merger/scripts/verify_pptx.py index d79ab3c0..fcafd28d 100644 --- a/submissions/pptx-merger/scripts/verify_pptx.py +++ b/submissions/pptx-merger/scripts/verify_pptx.py @@ -49,7 +49,7 @@ def structural_checks(path: Path): errors.append("[Content_Types].xml missing") else: ct_bytes = z.read("[Content_Types].xml") - root = etree.fromstring(ct_bytes) + root = etree.fromstring(ct_bytes, _SAFE_PARSER) # The Types element MUST be in the default (unprefixed) namespace. if root.prefix is not None: errors.append( From f7ff04a55ac7c15707749049dfe0137bf00e0321 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:32:58 +0530 Subject: [PATCH 13/21] Update SKILL.md --- submissions/pptx-merger/SKILL.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/submissions/pptx-merger/SKILL.md b/submissions/pptx-merger/SKILL.md index b9041f08..65bfc8eb 100644 --- a/submissions/pptx-merger/SKILL.md +++ b/submissions/pptx-merger/SKILL.md @@ -105,17 +105,6 @@ 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. -## Delivering the result (Copilot Studio) - -Keep the out-of-the-box SharePoint tools as-is. The base64 from step 4 is what you -hand to your upload path. **Never read, print, chunk, or reconstruct the base64 in -the model** — pass it as a variable reference from export straight into the upload -action. The recommended upload is a Power Automate flow whose input is a -`contentBase64` string and whose *Create file* action sets File Content to -`base64ToBinary(triggerBody()?['contentBase64'])`, so the decode happens -server-side and the bytes never travel through the model by value. Return the -file's `webUrl` (and its `Length`, to compare against the exported byte count as a -final integrity check) and give the user the link. ## Suggested agent step order @@ -128,10 +117,3 @@ final integrity check) and give the user the link. 5. `pptx_to_b64.py merged.pptx --out merged.b64`. 6. Upload via the flow (passing the base64 by reference) and return the link. -## Requirements - -`lxml` for the merge and validate steps (`pip install lxml --break-system-packages` -if not already present in the sandbox). LibreOffice (`soffice`) for 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. From ca73907c46e8de3766b2fc0c9bda02999a449417 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:33:37 +0530 Subject: [PATCH 14/21] Update README.md --- submissions/pptx-merger/README.md | 36 ------------------------------- 1 file changed, 36 deletions(-) diff --git a/submissions/pptx-merger/README.md b/submissions/pptx-merger/README.md index 02e59f13..d69dc4b7 100644 --- a/submissions/pptx-merger/README.md +++ b/submissions/pptx-merger/README.md @@ -13,42 +13,6 @@ base64 in ──▶ 1. ingest ──▶ 2. merge ──▶ 3. validate ──▶ (verify) (build) (render gate) (encode) ``` -### 1. Ingest — `scripts/b64_to_pptx.py` - -Decodes the connector's base64 (ASCII, corruption-proof in transit) to bytes, -writes them with a **binary** handle, and proves the result is a readable OOXML -package before anything else runs. - - -### 2. Merge — `scripts/pptx_merge.py` - -- writes `[Content_Types].xml` and all `.rels` in the **default (unprefixed)** - OPC namespace (the prefixed form is the classic "can't read this file" cause); -- gives every `` its required `id`, drawing master and layout IDs - from one shared counter so they are globally unique together; -- normalizes absolute OPC targets (e.g. `/ppt/charts/chart1.xml`) to relative - paths; -- copies media, charts, and embeddings with a source-index prefix so identical - filenames across decks can't collide; -- re-points slide → layout → master → theme chains and carries speaker notes; -- rebuilds `[Content_Types].xml` from what is actually on disk. - -### 3. Validate — `scripts/verify_pptx.py` - -The "does it actually open" gate. Structural checks **plus** a real LibreOffice -render — a structural pass alone can still leave a file PowerPoint won't open. - -Checks ZIP integrity, default-namespace `[Content_Types].xml`, no absolute -internal `.rels` targets, present `sldMasterId` IDs, globally-unique master/layout -IDs, and a successful render. 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 — `scripts/pptx_to_b64.py` - -Encodes the verified file back to base64 for the return trip, refusing to export -anything that isn't a valid PPTX and verifying a decode round-trip. - ## Requirements - **`lxml`** — merge and validate steps From 1631268a393764dd781409d6d4d0937625f920b3 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:38:59 +0530 Subject: [PATCH 15/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- submissions/pptx-merger/scripts/verify_pptx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submissions/pptx-merger/scripts/verify_pptx.py b/submissions/pptx-merger/scripts/verify_pptx.py index fcafd28d..d6bc33c0 100644 --- a/submissions/pptx-merger/scripts/verify_pptx.py +++ b/submissions/pptx-merger/scripts/verify_pptx.py @@ -92,7 +92,7 @@ def structural_checks(path: Path): # collect layout ids from every master for global-uniqueness check for n in names: if n.startswith("ppt/slideMasters/slideMaster") and n.endswith(".xml"): - m = etree.fromstring(z.read(n)) + m = etree.fromstring(z.read(n), _SAFE_PARSER) ll = m.find(f".//{{{NS_P}}}sldLayoutIdLst") if ll is not None: for e in ll: From 6af11296598d3177b05286f7337ef553acc78dee Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:51:14 +0530 Subject: [PATCH 16/21] Update verify_pptx.py --- submissions/pptx-merger/scripts/verify_pptx.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/submissions/pptx-merger/scripts/verify_pptx.py b/submissions/pptx-merger/scripts/verify_pptx.py index d6bc33c0..85060823 100644 --- a/submissions/pptx-merger/scripts/verify_pptx.py +++ b/submissions/pptx-merger/scripts/verify_pptx.py @@ -63,8 +63,12 @@ def structural_checks(path: Path): zip_names = set(names) for n in names: if n.endswith(".rels"): - root = etree.fromstring(z.read(n), _SAFE_PARSER) - part_folder = "/".join(n.split("/")[:-2]) # strip _rels/.rels + try: + root = etree.fromstring(z.read(n), _SAFE_PARSER) + except etree.XMLSyntaxError as exc: + errors.append(f"{n}: malformed XML — {exc}") + continue + part_folder = "/".join(n.split("/")[:-2]) for rel in root: tgt = rel.get("Target", "") mode = rel.get("TargetMode", "") @@ -74,7 +78,8 @@ def structural_checks(path: Path): errors.append(f"{n}: absolute internal Target '{tgt}' " "(needs relative path or TargetMode=External)") continue - resolved = "/".join(filter(None, [part_folder, tgt])).lstrip("/") + import posixpath + resolved = posixpath.normpath(part_folder + "/" + tgt).lstrip("/") if resolved not in zip_names: errors.append(f"{n}: Target '{tgt}' resolves to '{resolved}' which is missing from ZIP") From 15328425de2e6dbd36ff021cd1ed91b7aa872e8c Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:54:35 +0530 Subject: [PATCH 17/21] Enhance PPTX validation checks in pptx_to_b64.py --- submissions/pptx-merger/scripts/pptx_to_b64.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/submissions/pptx-merger/scripts/pptx_to_b64.py b/submissions/pptx-merger/scripts/pptx_to_b64.py index a62403d6..a08a0b1b 100644 --- a/submissions/pptx-merger/scripts/pptx_to_b64.py +++ b/submissions/pptx-merger/scripts/pptx_to_b64.py @@ -40,8 +40,14 @@ def main() -> int: # Confirm we are exporting a real package, not something already broken. try: with zipfile.ZipFile(path) as z: - if z.testzip() is not None or "[Content_Types].xml" not in z.namelist(): + 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"]) From bbe1ad2019d99b76bfed6c6385f792132a3a4560 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:21:11 +0530 Subject: [PATCH 18/21] Update pptx_merge.py --- submissions/pptx-merger/scripts/pptx_merge.py | 298 +++++++++++------- 1 file changed, 178 insertions(+), 120 deletions(-) diff --git a/submissions/pptx-merger/scripts/pptx_merge.py b/submissions/pptx-merger/scripts/pptx_merge.py index dc192330..ab884187 100644 --- a/submissions/pptx-merger/scripts/pptx_merge.py +++ b/submissions/pptx-merger/scripts/pptx_merge.py @@ -5,25 +5,41 @@ Usage: python pptx_merge.py output.pptx input1.pptx input2.pptx [input3.pptx ...] -This is the corrected merge stage. It fixes, in addition to the earlier -media/chart/notes/layout-chain issues, the two defects that made every prior -output unopenable: - - * [Content_Types].xml and every .rels part are now serialised in the DEFAULT +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. - * Every appended now receives its required unique unsigned - integer `id` attribute (>= 2147483648). Omitting it is a schema violation on - any multi-master merge (the normal case). + * Zip entry paths are validated before extraction (Zip Slip). """ import os import re import sys -import json import shutil import tempfile import zipfile @@ -32,7 +48,6 @@ try: from lxml import etree except ImportError: - import sys sys.exit("ERROR: lxml is required. Install it with: pip install lxml") # ── Namespaces ────────────────────────────────────────────────────────────── @@ -48,6 +63,11 @@ 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" @@ -74,15 +94,19 @@ def _parser(): 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 [] @@ -93,8 +117,9 @@ def read_rels(rels_path: Path): for r in root ] + def build_rels_xml(rels): - # DEFAULT namespace — no prefix. (Fix C) + # 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") @@ -105,19 +130,13 @@ def build_rels_xml(rels): 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 str_replace_file(path: Path, replacements: dict): - if not replacements: - return - text = path.read_text(encoding="utf-8", errors="replace") - for old, new in replacements.items(): - text = text.replace(old, new) - path.write_text(text, encoding="utf-8") def abs_to_rel(target: str, part_folder: str) -> str: if not target.startswith("/"): @@ -132,15 +151,58 @@ 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). (Fix B) + # (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: - base = d.resolve() for info in z.infolist(): dest = (d / info.filename).resolve() if not str(dest).startswith(str(base) + os.sep): @@ -148,7 +210,7 @@ def merge(self, inputs, output: Path): z.extractall(d) srcs.append(d) - shutil.copytree(srcs[0], self.out) + shutil.copytree(srcs[0], self.out, dirs_exist_ok=True) self._fix_base_rels() self._sync_id_counters(self.out) @@ -169,11 +231,11 @@ def merge(self, inputs, output: Path): 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) + 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 + src, master_num, layout_num, theme_map, media_map, chart_map ) master_num += len(master_map) layout_num += len(layout_map) @@ -185,6 +247,7 @@ def merge(self, inputs, output: Path): 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({ @@ -193,34 +256,34 @@ def merge(self, inputs, output: Path): }) el = etree.SubElement(master_id_lst, f"{{{NS_P}}}sldMasterId") self._gid_counter += 1 - el.set("id", str(self._gid_counter)) # (Fix B) required, globally unique id + 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): - slide_num += 1 - new_slide_fname = f"slide{slide_num}.xml" - old_to_new_slides[slide_fname] = new_slide_fname 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) - str_replace_file(out_slide, { - f"../media/{o}": f"../media/{n}" for o, n in media_map.items() - }) - src_rels = src / "ppt" / "slides" / "_rels" / f"{slide_fname}.rels" - new_slide_rels, rid_remap = self._remap_slide_rels( - read_rels(src_rels), layout_map, media_map, chart_map + new_slide_rels = self.remap_rels( + read_rels(src_rels), "ppt/slides", + media_map=media_map, chart_map=chart_map, + overrides=None, ) - str_replace_file(out_slide, { - f'r:id="{o}"': f'r:id="{n}"' for o, n in rid_remap.items() - }) - str_replace_file(out_slide, { - f'r:embed="{o}"': f'r:embed="{n}"' for o, n in rid_remap.items() - }) + 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) @@ -238,7 +301,7 @@ def merge(self, inputs, output: Path): "Target": f"slides/{new_slide_fname}", "TargetMode": "", }) - notes_num = self._copy_notes(src, old_to_new_slides, notes_num) + 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) @@ -255,7 +318,18 @@ def merge(self, inputs, output: Path): print(f"OK merged {len(inputs)} files -> {output}") - # ── helpers ── + # ── 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" n = lambda g: sum(1 for _ in ppt.glob(g)) @@ -309,7 +383,8 @@ def _fix_base_rels(self): 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} + 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 [] @@ -323,7 +398,7 @@ def _copy_media(self, src: Path, src_idx: int): om = self.out / "ppt" / "media" om.mkdir(parents=True, exist_ok=True) mp = {} - for f in sm.iterdir(): + for f in sorted(sm.iterdir()): if f.is_file(): new = f"s{src_idx}_{f.name}" shutil.copy2(f, om / new) @@ -338,7 +413,7 @@ def _copy_charts_and_deps(self, src: Path, src_idx: int): chart_map, embed_map = {}, {} if se.exists(): oe.mkdir(parents=True, exist_ok=True) - for f in se.iterdir(): + for f in sorted(se.iterdir()): if f.is_file(): new = f"s{src_idx}_{f.name}" shutil.copy2(f, oe / new) @@ -350,19 +425,19 @@ def _copy_charts_and_deps(self, src: Path, src_idx: int): new = f"s{src_idx}_{f.name}" shutil.copy2(f, oc / new) chart_map[f.name] = new - crels = read_rels(sc / "_rels" / f"{f.name}.rels") ncr = [] - for r in crels: - nr = dict(r) - oldn = Path(r.get("Target", "")).name - if oldn in embed_map: - nr["Target"] = f"../embeddings/{embed_map[oldn]}" + 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): + 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) @@ -376,12 +451,20 @@ def _copy_themes(self, src: Path, theme_start: int): 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) - shutil.copy2(sr, od / "_rels" / f"{new}.rels") + 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): + 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" @@ -391,92 +474,68 @@ def _copy_masters_and_layouts(self, src, master_start, layout_start, theme_map, 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 - smrels = read_rels(sm / "_rels" / f"{smf.name}.rels") - this_layouts, new_mrels, rid_set = {}, [], set() - for r in smrels: - nr_id = next_rid(rid_set); rid_set.add(nr_id) - nr = dict(r); nr["Id"] = nr_id - if r["Type"] == RT_SLIDE_LAYOUT: + + 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 = f"slideLayout{l_num}.xml" + 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 - layout_map[old_l] = new_l + nr = dict(r) # Id preserved nr["Target"] = f"../slideLayouts/{new_l}" - l_num += 1 - elif r["Type"] == RT_THEME: - old_t = Path(r["Target"]).name - if old_t in theme_map: - nr["Target"] = f"../theme/{theme_map[old_t]}" - new_mrels.append(nr) + 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) - str_replace_file(om / new_master, - {f"../media/{o}": f"../media/{n}" for o, n in media_map.items()}) - # unique sldLayoutId ints (Fix #8 from prior round) + 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) - str_replace_file(ol / new_l, - {f"../media/{o}": f"../media/{n}" for o, n in media_map.items()}) - lrels = read_rels(sl / "_rels" / f"{old_l}.rels") - nlr, lset = [], set() - for lr in lrels: - rid = next_rid(lset); lset.add(rid) - nr = dict(lr); nr["Id"] = rid - if lr["Type"] == RT_SLIDE_MASTER: - nr["Target"] = f"../slideMasters/{new_master}" - elif lr["Type"] == RT_THEME: - ot = Path(lr["Target"]).name - if ot in theme_map: - nr["Target"] = f"../theme/{theme_map[ot]}" - nlr.append(nr) + 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 _remap_slide_rels(self, src_rels, layout_map, media_map, chart_map, - part_folder="ppt/slides"): - new_rels, rid_set, rid_remap = [], set(), {} - for r in src_rels: - rid = next_rid(rid_set); rid_set.add(rid) - rid_remap[r["Id"]] = rid - nr = dict(r); nr["Id"] = rid - target = nr.get("Target", "") - if target.startswith("/") and nr.get("TargetMode", "") != "External": - target = abs_to_rel(target, part_folder) - nr["Target"] = target - if r["Type"] == RT_SLIDE_LAYOUT: - oln = Path(target).name - if oln in layout_map: - nr["Target"] = f"../slideLayouts/{layout_map[oln]}" - elif r["Type"] == RT_CHART: - ocn = Path(target).name - if ocn in chart_map: - nr["Target"] = f"../charts/{chart_map[ocn]}" - elif "../media/" in target or r["Type"] == RT_IMAGE: - omn = Path(target).name - if omn in media_map: - nr["Target"] = f"../media/{media_map[omn]}" - new_rels.append(nr) - return new_rels, rid_remap - - def _copy_notes(self, src, old_to_new, notes_start): + def _copy_notes(self, src, old_to_new, notes_start, media_map=None): sn = src / "ppt" / "notesSlides" if not sn.exists(): return notes_start @@ -502,14 +561,13 @@ def _copy_notes(self, src, old_to_new, notes_start): n += 1 new_nf = f"notesSlide{n}.xml" shutil.copy2(snp, on / new_nf) - nrels = read_rels(sn / "_rels" / f"{nf}.rels") - nnr = [] - for r in nrels: - nr = dict(r) - if r["Type"] == RT_SLIDE: - nr["Target"] = f"../slides/{new_slide}" - nnr.append(nr) + 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) @@ -529,7 +587,7 @@ 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. (Fix A) + # DEFAULT namespace, rebuilt fresh so no prefix can leak. root = etree.Element(f"{{{NS_CT}}}Types", nsmap={None: NS_CT}) known_defaults = set() @@ -573,7 +631,7 @@ def add(glob_pat, prefix, ctype): mdir = self.out / "ppt" / "media" if mdir.exists(): - for f in mdir.iterdir(): + 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") From 16b4be6afbb5ffb2bc9fbabb13e08d6a3217ecf9 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:31:20 +0530 Subject: [PATCH 19/21] Copy full relationship graph for appended parts and preserve part-local rel IDs From a1dae09803850e16aa3caddd30514ce3e8d4a25c Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:47:11 +0530 Subject: [PATCH 20/21] Enhance PPTX verification and error handling Updated the verify_pptx.py script to enhance verification levels and improve error handling. Added support for load checks and refined argument parsing for better usability. --- .../pptx-merger/scripts/verify_pptx.py | 392 +++++++++++++----- 1 file changed, 278 insertions(+), 114 deletions(-) diff --git a/submissions/pptx-merger/scripts/verify_pptx.py b/submissions/pptx-merger/scripts/verify_pptx.py index 85060823..0e26341b 100644 --- a/submissions/pptx-merger/scripts/verify_pptx.py +++ b/submissions/pptx-merger/scripts/verify_pptx.py @@ -2,192 +2,356 @@ """ verify_pptx.py — Hard gate: a PPTX is "good" only if it actually opens. -Two prior rounds passed a structural validator yet produced files PowerPoint -could not open, because that validator checks neither the content-types -namespace prefix nor whether the package truly loads. This stage closes that -gap: it runs the structural checks AND performs a real render conversion with -LibreOffice. If the render step is unavailable in the sandbox it degrades to -structural-only and says so, rather than claiming success it can't prove. +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 [--render] [--json] + python verify_pptx.py [--allow-no-render] [--json] ---render forces the render check on (it is on by default when soffice exists). -Exit code 0 only if every enabled check passes. + --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 sys +import argparse import json +import posixpath import shutil -import zipfile -import argparse import subprocess +import sys import tempfile +import zipfile from pathlib import Path try: from lxml import etree except ImportError: - import sys sys.exit("ERROR: lxml is required. Install it with: pip install lxml") -_SAFE_PARSER = etree.XMLParser(resolve_entities=False, no_network=True) -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" +_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 = [], [] - with zipfile.ZipFile(path) as z: + + 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: - ct_bytes = z.read("[Content_Types].xml") - root = etree.fromstring(ct_bytes, _SAFE_PARSER) - # The Types element MUST be in the default (unprefixed) namespace. + 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"[Content_Types].xml uses namespace prefix '{root.prefix}:' — " + f"{n} uses namespace prefix '{root.prefix}:' — " "must be the default namespace or the package will not open.") - declared = {ov.get("PartName") for ov in root.findall(f"{{{NS_CT}}}Override")} - defaults = {d.get("Extension", "").lower() for d in root.findall(f"{{{NS_CT}}}Default")} - - # Every part referenced by a .rels must exist; no absolute internal targets. + 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") - zip_names = set(names) - for n in names: - if n.endswith(".rels"): + if source_part in names and source_part.endswith(".xml"): try: - root = etree.fromstring(z.read(n), _SAFE_PARSER) + part_root = etree.fromstring(z.read(source_part), _SAFE_PARSER) except etree.XMLSyntaxError as exc: - errors.append(f"{n}: malformed XML — {exc}") + errors.append(f"{source_part}: malformed XML — {exc}") continue - part_folder = "/".join(n.split("/")[:-2]) - for rel in root: - tgt = rel.get("Target", "") - mode = rel.get("TargetMode", "") - if mode == "External": - continue - if tgt.startswith("/"): - errors.append(f"{n}: absolute internal Target '{tgt}' " - "(needs relative path or TargetMode=External)") - continue - import posixpath - resolved = posixpath.normpath(part_folder + "/" + tgt).lstrip("/") - if resolved not in zip_names: - errors.append(f"{n}: Target '{tgt}' resolves to '{resolved}' which is missing from ZIP") + 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" in names: - pr = etree.fromstring(z.read("ppt/presentation.xml"), _SAFE_PARSER) - gids = [] - ml = pr.find(f".//{{{NS_P}}}sldMasterIdLst") - if ml is not None: - for e in ml: - if not e.get("id"): - errors.append("sldMasterId missing required 'id' attribute") - else: - gids.append(e.get("id")) - # collect layout ids from every master for global-uniqueness check - for n in 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)}") + 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 -def render_check(path: Path): - soffice = shutil.which("soffice") or shutil.which("libreoffice") - if not soffice: - return None, "soffice not available; render check skipped" +# ── 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: - subprocess.run( + 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" + 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" - return False, "LibreOffice could not load the file" + 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() + ap = argparse.ArgumentParser( + description="Verify a PPTX. Render is required unless --allow-no-render.") ap.add_argument("pptx") - ap.add_argument("--render", action="store_true", - help="Force render check (default: on when soffice exists).") - ap.add_argument("--no-render", action="store_true") + 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), - "structural_errors": [], "render": None} + 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"] - print(json.dumps(result, indent=2) if args.json else "file does not exist") - return 2 + return emit(2) errors, warnings = structural_checks(path) - result["structural_errors"] = errors + result["structural_errors"] = list(errors) result["warnings"] = warnings + if errors and errors[0].startswith("not a valid ZIP"): + return emit(2) - soffice_present = shutil.which("soffice") or shutil.which("libreoffice") - do_render = args.render or (soffice_present and not args.no_render) - - if args.render and not soffice_present: - errors = errors + ["render: soffice/libreoffice not found but --render was explicitly requested"] - result["render"] = {"passed": False, "detail": "soffice not available"} - elif do_render: - ok, msg = render_check(path) - result["render"] = {"passed": ok, "detail": msg} - if ok is False: - errors = errors + [f"render: {msg}"] - else: - if args.no_render: - result["render"] = {"passed": None, "detail": "render disabled (--no-render)"} - elif not soffice_present: - result["render"] = {"passed": None, "detail": "soffice not available; render check skipped"} - else: - result["render"] = {"passed": None, "detail": "render check skipped"} - - - render_failed = do_render and result["render"]["passed"] is False - result["ok"] = (len(errors) == 0) and (not render_failed) + 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) - if args.json: - print(json.dumps(result, indent=2)) + 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: - if result["ok"]: - r = result["render"]["detail"] if result["render"] else "structural only" - print(f"PASS — {r}") + 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: - print("FAIL") - for e in errors: - print(f" - {e}") - return 0 if result["ok"] else 1 + 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__": From e5cc98ee100f6c28e83dad49cf8e1d71150bb482 Mon Sep 17 00:00:00 2001 From: Angara V L Gopala Sandeep <45325566+hisandeepangara@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:53:36 +0530 Subject: [PATCH 21/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- submissions/pptx-merger/scripts/pptx_merge.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/submissions/pptx-merger/scripts/pptx_merge.py b/submissions/pptx-merger/scripts/pptx_merge.py index ab884187..f447556a 100644 --- a/submissions/pptx-merger/scripts/pptx_merge.py +++ b/submissions/pptx-merger/scripts/pptx_merge.py @@ -332,13 +332,26 @@ def _remap_layout_target(r, layout_map): def _count_parts(self, base: Path): ppt = base / "ppt" - n = lambda g: sum(1 for _ in ppt.glob(g)) - return (n("slides/slide[0-9]*.xml"), n("slideLayouts/slideLayout[0-9]*.xml"), - n("slideMasters/slideMaster[0-9]*.xml"), n("theme/theme[0-9]*.xml")) + + 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" - return sum(1 for _ in d.glob("notesSlide[0-9]*.xml")) if d.exists() else 0 + 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}