Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
23f4aa2
Add PPTX Merger skill
hisandeepangara Aug 7, 2026
a95f1a9
Merge branch 'main' into add-pptx-merger-skill
hisandeepangara Aug 7, 2026
94ab9ca
Potential fix for pull request finding
hisandeepangara Aug 7, 2026
5d0bd09
Potential fix for pull request finding
hisandeepangara Aug 7, 2026
f8bf958
Potential fix for pull request finding
hisandeepangara Aug 7, 2026
17c2920
Potential fix for pull request finding
hisandeepangara Aug 7, 2026
3c4dc41
Improve file reading logic in b64_to_pptx.py
hisandeepangara Aug 7, 2026
d6ae1bc
Create README for PPTX Merger
hisandeepangara Aug 7, 2026
27cf8d4
Update README.md
hisandeepangara Aug 7, 2026
1ace937
Update b64_to_pptx.py
hisandeepangara Aug 7, 2026
bb4738a
Update verify_pptx.py
hisandeepangara Aug 7, 2026
3efa489
Update pptx_merge.py
hisandeepangara Aug 7, 2026
6f22db3
Potential fix for pull request finding
hisandeepangara Aug 7, 2026
f7ff04a
Update SKILL.md
hisandeepangara Aug 7, 2026
ca73907
Update README.md
hisandeepangara Aug 7, 2026
1631268
Potential fix for pull request finding
hisandeepangara Aug 7, 2026
6af1129
Update verify_pptx.py
hisandeepangara Aug 7, 2026
1532842
Enhance PPTX validation checks in pptx_to_b64.py
hisandeepangara Aug 7, 2026
0084fcb
Merge branch 'microsoft:main' into add-pptx-merger-skill
hisandeepangara Aug 11, 2026
bbe1ad2
Update pptx_merge.py
hisandeepangara Aug 11, 2026
16b4be6
Copy full relationship graph for appended parts and preserve part-loc…
hisandeepangara Aug 11, 2026
a1dae09
Enhance PPTX verification and error handling
hisandeepangara Aug 11, 2026
e5cc98e
Potential fix for pull request finding
hisandeepangara Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions submissions/pptx-merger/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# PPTX Merger

A four-stage, corruption-safe pipeline that merges two or more base64-delivered
PowerPoint decks into a single `.pptx` that PowerPoint opens with no repair or
"can't read" error. Built for the Copilot Studio sandbox flow where decks arrive
as base64 from a SharePoint connector, are merged inside the sandbox, and handed
back out.

## The pipeline

```
base64 in ──▶ 1. ingest ──▶ 2. merge ──▶ 3. validate ──▶ 4. export ──▶ base64 out
(verify) (build) (render gate) (encode)
```

## Requirements

- **`lxml`** — merge and validate steps
(`pip install lxml --break-system-packages` if not already in the sandbox).
- **LibreOffice (`soffice`)** — the render gate in step 3. If unavailable,
validation runs structural-only and says so.
- Ingest and export use the Python standard library only.
- No network calls; the skill never uploads or downloads anything itself.

## Files

```
SKILL.md # agent-facing skill definition (frontmatter + instructions)
scripts/b64_to_pptx.py # 1. ingest — base64 → verified .pptx
scripts/pptx_merge.py # 2. merge → single .pptx
scripts/verify_pptx.py # 3. validate — structural + real render
scripts/pptx_to_b64.py # 4. export — verified .pptx → base64
```

## Testing

Verified end to end on two multi-master sample decks: the full ingest → merge →
validate → export chain passes, the delivered file renders (6 pages), and the
exported base64 begins with `UEsD`. The ingest gate was tested against the exact
`U+FFFD` corruption pattern and rejects it rather than producing a broken deck.

## License

Shared under the CAT Agent Skills repository's MIT license.
119 changes: 119 additions & 0 deletions submissions/pptx-merger/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
---
name: pptx-merger
description: >
Use this skill whenever the task is to combine, concatenate, or merge two or
more PowerPoint decks into one .pptx that opens with no repair or "can't read"
error — especially when the source files arrive as base64 from a SharePoint
connector or HTTP response. Triggers: "merge decks", "combine presentations",
"append slides from", "join pptx files", "merge these QBR decks". It runs four
ordered steps — ingest base64 to a verified file, merge, validate by real
render, and export back to base64 for return/upload. Do NOT use it to build
slides from scratch.
---

# PPTX Merger — corruption-safe deck merge for agent sandboxes

Combine PowerPoint decks into a single file that opens cleanly in PowerPoint.
The skill is built for the case where decks arrive as **base64** (e.g. from the
SharePoint *Get file content using path* connector) and must be merged inside a
sandbox and handed back out. It exists because the naive path corrupts files in
two places: binary bytes get destroyed when pushed through a text/UTF-8 codec,
and careless merging produces packages PowerPoint refuses to open. Each of the
four scripts does one job, verifies its own output, and fails loudly rather than
passing bad data forward.

## The pipeline

```
base64 in ──▶ 1. ingest ──▶ 2. merge ──▶ 3. validate ──▶ 4. export ──▶ base64 out
(verify) (build) (render gate) (encode)
```

Run the four scripts in order, in a shared working directory. Treat any non-zero
exit as a hard stop and report the reason — never deliver an unvalidated file.

### 1. Ingest — base64 → verified .pptx on disk

The connector's base64 is ASCII and survives any text pipeline. Corruption only
happens when the *binary* is decoded through a UTF-8 codec (every byte ≥ 0x80
becomes U+FFFD, inflating and destroying the ZIP). This step decodes the base64
to bytes and writes them with a **binary** handle, then proves the result is a
readable OOXML package before anything else runs.

```bash
python scripts/b64_to_pptx.py <base64_input> <output.pptx> [--expected-bytes N] [--json]
```

`<base64_input>` 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
`<ns0:Types>` is the classic "PowerPoint can't read this file" cause); every
`<p:sldMasterId>` 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 <file.pptx> [--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 <input.pptx> [--out file.txt] [--json]
```

Use `--out` to write the base64 to a file (recommended for large decks). It
refuses to export anything that is not a valid PPTX and verifies a decode
round-trip so a truncated encoding is caught here, not in the user's PowerPoint.


## Suggested agent step order

1. Resolve each file and get its content as base64 (`$content` from *Get file
content using path* is already base64 — use it verbatim, do not re-encode).
2. `b64_to_pptx.py` per deck → verified `inN.pptx`. If any returns `ok: false`,
stop: the source is corrupt and merging cannot help.
3. `pptx_merge.py` → `merged.pptx`.
4. `verify_pptx.py merged.pptx` → must pass, or stop and report the errors.
5. `pptx_to_b64.py merged.pptx --out merged.b64`.
6. Upload via the flow (passing the base64 by reference) and return the link.

9 changes: 9 additions & 0 deletions submissions/pptx-merger/metadata.json
Original file line number Diff line number Diff line change
@@ -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"
}
141 changes: 141 additions & 0 deletions submissions/pptx-merger/scripts/b64_to_pptx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
b64_to_pptx.py — Materialise a base64 string into a binary .pptx on disk, safely.

This is the stage that fixes the upstream corruption. The SharePoint connector
returns file content as base64 (pure ASCII), which survives any text pipeline.
The ONLY way that data gets destroyed is if some layer decodes the *binary*
through a UTF-8 text codec. This script never does that: it base64-decodes to
raw bytes and writes them with a binary handle ('wb'), then proves the result is
an intact OOXML ZIP before anyone downstream is allowed to touch it.

Usage:
python b64_to_pptx.py <base64_input> <output.pptx> [--expected-bytes N] [--json]

<base64_input> may be:
* a path to a file containing the base64 text, or
* the literal base64 string (auto-detected if it isn't an existing path).

Exit code is 0 only if the written file is a valid, readable PPTX package.
On any integrity failure it exits non-zero and prints a diagnosis, so the agent
fails loudly instead of passing corrupted bytes to the merge stage.
"""

import sys
import json
import base64
import zipfile
import argparse
from pathlib import Path


def _load_b64_text(arg: str) -> str:
p = Path(arg)
try:
is_file = p.exists() and p.is_file()
except OSError:
is_file = False
raw = p.read_text(encoding="utf-8", errors="strict") if is_file else arg
# Strip whitespace/newlines and an optional data-URI prefix.
raw = raw.strip()
if raw.startswith("data:") and "," in raw:
raw = raw.split(",", 1)[1]
return "".join(raw.split())


def _has_replacement_chars(arg: str) -> bool:
# If the caller passed a file, a genuine base64 string can't contain U+FFFD.
# Its presence means corruption already happened upstream of this script.
p = Path(arg)
try:
is_file = p.exists() and p.is_file()
except OSError:
is_file = False
try:
txt = p.read_text(encoding="utf-8", errors="strict") if is_file else arg
except UnicodeDecodeError:
return True
return "\ufffd" in txt


def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("b64_input")
ap.add_argument("output")
ap.add_argument("--expected-bytes", type=int, default=None,
help="If provided, assert the decoded size matches exactly.")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()

result = {"ok": False, "output": args.output, "stage": "ingest"}

# Guard 1: the base64 text itself must be clean ASCII.
if _has_replacement_chars(args.b64_input):
result["error"] = ("Input already contains U+FFFD replacement characters — "
"the binary was corrupted upstream of this script (a text/UTF-8 "
"codec touched the bytes before base64 reached here). Fix the "
"connector output so it stays base64/ASCII end to end.")
print(json.dumps(result, indent=2) if args.json else result["error"])
return 3

try:
b64 = _load_b64_text(args.b64_input)
except UnicodeDecodeError:
result["error"] = "Input file is not valid UTF-8 text; it is not clean base64."
print(json.dumps(result, indent=2) if args.json else result["error"])
return 3

# Guard 2: decode strictly. validate=True rejects stray non-alphabet bytes.
try:
data = base64.b64decode(b64, validate=True)
except Exception as e: # noqa: BLE001
result["error"] = f"base64 decode failed: {e}"
print(json.dumps(result, indent=2) if args.json else result["error"])
return 4

# Binary write — never a text codec.
out = Path(args.output)
out.parent.mkdir(parents=True, exist_ok=True)
with open(out, "wb") as fh:
fh.write(data)

size = out.stat().st_size
result["bytes"] = size

# Guard 3: exact-size check if the connector reported a size.
if args.expected_bytes is not None and size != args.expected_bytes:
result["error"] = (f"Size mismatch: wrote {size} bytes, expected "
f"{args.expected_bytes}. Bytes were altered in transit.")
print(json.dumps(result, indent=2) if args.json else result["error"])
return 5

# Guard 4: it must be a real, readable OOXML ZIP with the PPTX marker part.
try:
with zipfile.ZipFile(out) as z:
bad = z.testzip()
if bad is not None:
raise zipfile.BadZipFile(f"CRC error in {bad}")
names = set(z.namelist())
if "[Content_Types].xml" not in names:
raise KeyError("[Content_Types].xml missing — not an OOXML package")
if not any(n.startswith("ppt/") for n in names):
raise KeyError("no ppt/ parts — not a PowerPoint package")
result["entries"] = len(names)
result["slides"] = sum(
1 for n in names
if n.startswith("ppt/slides/slide") and n.endswith(".xml")
)
except Exception as e: # noqa: BLE001
result["error"] = (f"Decoded bytes are not a valid PPTX: {e}. "
"This means the file was corrupted before it reached ingest.")
print(json.dumps(result, indent=2) if args.json else result["error"])
return 6

result["ok"] = True
result["message"] = f"Wrote intact PPTX: {size} bytes, {result['slides']} slides."
print(json.dumps(result, indent=2) if args.json else result["message"])
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading