Claude Cursor Skill

pptx-reference-deck-analysis

Use when analyzing a reference PPTX for read-only structure, theme, typography, layout rhythm, diagnostics, derived template catalogs, or safe OOXML package inspection.

LLM Mart · 0 points · 9 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download wshobson-agents-plugins_pptx-deck-creation_skills_pptx-reference-deck-analysis-554237f.zip · 10 KB
Part of wshobson/agents — 170 skills

Install

skills CLI npx skills add https://github.com/wshobson/agents/tree/main/plugins/pptx-deck-creation/skills/pptx-reference-deck-analysis
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install wshobson-agents@llmmart
Git git clone https://github.com/wshobson/agents.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole wshobson/agents collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

PPTX Reference Deck Analysis

Inspect a reference deck as design evidence. This skill never copies, clones, or mutates a source deck.

Contract

Implement required extraction on demand with a small task-local python-pptx script. Capture only the information required for the new deck:

  • compact prompt context: slide count and size, text summaries, shape counts, styles, brand signals, template use, and layout rhythm;
  • full extraction: summary, slides, and read-only layout_tree evidence;
  • folder diagnostics: one result per deck plus a manifest;
  • style-master analysis: colors, fonts, size distribution, master/layout use, and flow patterns;
  • derived template catalog: zero-based source indices, layout roles, usable regions, placeholders, visual structures, and constraints.

Rules

  • Keep the source deck read-only and independently author every target-slide coordinate.
  • Use the bundled OOXML utilities only for raw themes, relationships, notes, comments, animations, media, masters, or layouts that high-level extraction cannot expose.
  • Record inspected parts and parsing exceptions in the analysis manifest.
  • Do not use extracted content, fonts, images, or proprietary assets in a generated deck without explicit permission and license evidence.

OOXML package inspection

Install defusedxml from requirements.txt before using the bundled utilities.

  1. Run scripts/inspect.py <deck.pptx> for a compact JSON report of slide order, text, theme tokens, relationships, notes, comments, animations, and media.
  2. Run scripts/validate_package.py <deck.pptx> --output <report.json> for malformed XML, broken internal relationships, content-type gaps, duplicate layout links, and orphaned parts.
  3. Run scripts/unpack.py <deck.pptx> <output-dir> only when raw-package evidence is necessary.
  4. Resolve relationship targets relative to the .rels owner; never infer slide order from filenames.

Safety

  • Never modify a supplied deck or blindly copy package parts into a new deck.
  • Run the scripts from a trusted workspace; they reject path traversal, symlinks, oversized members, and archive bombs.
  • Parse untrusted XML with defusedxml; do not enable entity expansion, DTD loading, or network access.
  • Treat theme colors as tokens unless fully resolved against the color scheme.

See references/reference-deck-analysis.md for output shapes, references/reference-deck-analysis-patterns.md for documentation-only patterns, and references/ooxml-parsing.md for package part maps.

Files (agents)
  • references
    • ooxml-parsing.md 1.1 KB
      # OOXML Parsing Reference
      
      A `.pptx` is an Open Packaging Conventions ZIP archive. Resolve its relationship graph; do not assume sequential filenames.
      
      | Need | Parts |
      | --- | --- |
      | Slide order | `ppt/presentation.xml`, `ppt/_rels/presentation.xml.rels` |
      | Slide text and shapes | Slide parts resolved from presentation relationships (commonly `ppt/slides/slideN.xml`) |
      | Layout, notes, images, charts | `ppt/slides/_rels/slideN.xml.rels` |
      | Template geometry | `ppt/slideLayouts/`, `ppt/slideMasters/` |
      | Colors and fonts | Theme parts resolved from presentation/master relationships (commonly under `ppt/theme/`) |
      | Notes and comments | `ppt/notesSlides/`, `ppt/comments/` |
      | Media and embeddings | `ppt/media/`, `ppt/embeddings/` |
      
      Use PresentationML, DrawingML, Office relationship, and package relationship namespaces. A read-only result should retain slide number, resolved relationship target, concatenated text, shape counts, notes, relationship types, and OOXML-only markers. Preserve theme tokens when a color uses scheme or system values rather than inventing a resolved RGB value.
      
    • reference-deck-analysis-patterns.md 1.2 KB
      # Reference-Deck Analysis Patterns
      
      Use these patterns only as documentation when writing a temporary analysis script with `python-pptx`; do not package them as an importable extraction library.
      
      ```python
      from collections.abc import Iterable, Iterator
      from typing import Any
      
      EMU_PER_INCH = 914400
      
      def inches(value: int | None) -> float:
          return round(int(value or 0) / EMU_PER_INCH, 4)
      
      def bbox(shape: Any) -> dict[str, float]:
          return {
              "x": inches(getattr(shape, "left", 0)),
              "y": inches(getattr(shape, "top", 0)),
              "width": inches(getattr(shape, "width", 0)),
              "height": inches(getattr(shape, "height", 0)),
          }
      
      def iter_shapes(shapes: Iterable[Any]) -> Iterator[Any]:
          for shape in shapes:
              yield shape
              if hasattr(shape, "shapes"):
                  yield from iter_shapes(shape.shapes)
      ```
      
      For style analysis, count colors and fonts while recursively walking shapes. For extraction, create a root group that covers the slide, capture shape bboxes and kind-specific content, and resolve notes/media through package relationships when required. Wrap optional `python-pptx` properties in exception handling because fills, lines, colors, image data, tables, and charts can be absent or unsupported.
      
    • reference-deck-analysis.md 1.5 KB
      # Reference-Deck Analysis Recipes
      
      Inspect existing `.pptx` files only to derive evidence for a distinct deck. Do not ship runtime modules from this reference; implement a small task-local analysis when needed.
      
      ## Prompt context
      
      Return `slide_count`, `slide_size`, style and brand signals, template/layout evidence, and short title/text summaries with shape counts.
      
      ## Full extraction
      
      Return a read-only `summary`, per-slide `layout_tree` evidence, and OOXML markers required for inspection. Keep asset references and proprietary content out of a new deck unless explicitly approved.
      
      ## Style master
      
      Summarize palette, accent colors, typography, font-size distribution, master/layout usage, and dominant flow patterns.
      
      ## Derived reference-template catalog
      
      The catalog is a view over the analysis, not a copy plan. List every source slide by zero-based index and record its layout role, visual description, usable regions, placeholder roles, visual structures, and content-fit constraints.
      
      ```json
      {
        "source_deck": "reference.pptx",
        "slide_count": 12,
        "slides": [{
          "source_index": 0,
          "layout_role": "cover",
          "description": "Dark cover with title and subtitle regions",
          "regions": ["title", "subtitle", "supporting visual"],
          "placeholder_roles": ["ctrTitle", "subTitle"],
          "visual_structures": ["full-bleed color field", "corner motif"],
          "reuse_constraints": ["best for one title and one short subtitle"]
        }]
      }
      ```
      
      Use the catalog as inspiration, then author every target slide with independent coordinates.
      
  • scripts
    • inspect.py 5.9 KB
      #!/usr/bin/env python3
      """Read a PPTX OOXML package into a compact, JSON-safe inspection report."""
      from __future__ import annotations
      import json
      import posixpath
      import stat
      import sys
      import zipfile
      from collections import Counter
      from pathlib import Path, PurePosixPath
      from defusedxml import ElementTree as ET
      
      P = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
      A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
      R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
      PR = "{http://schemas.openxmlformats.org/package/2006/relationships}"
      MAX_MEMBERS = 5_000
      MAX_MEMBER_SIZE = 100 * 1024 * 1024
      MAX_TOTAL_SIZE = 512 * 1024 * 1024
      MAX_COMPRESSION_RATIO = 1_000
      
      
      def _write_stdout(value: str) -> None:
          """Write UTF-8 JSON without depending on the console code page."""
          sys.stdout.buffer.write(value.encode("utf-8"))
      
      
      def _workspace_path(value: str) -> Path:
          root = Path.cwd().resolve()
          path = Path(value).expanduser().resolve()
          if not path.is_relative_to(root):
              raise ValueError(f"Path escapes the current workspace: {value}")
          return path
      
      def _validate_archive(archive: zipfile.ZipFile) -> None:
          members = archive.infolist()
          if len(members) > MAX_MEMBERS:
              raise ValueError("Archive contains too many entries")
          total = 0
          for member in members:
              if stat.S_ISLNK(member.external_attr >> 16):
                  raise ValueError(f"Archive contains a symlink: {member.filename}")
              if member.file_size > MAX_MEMBER_SIZE:
                  raise ValueError(f"Archive entry is too large: {member.filename}")
              total += member.file_size
              if total > MAX_TOTAL_SIZE:
                  raise ValueError("Archive uncompressed size is too large")
              if member.compress_size and member.file_size / member.compress_size > MAX_COMPRESSION_RATIO:
                  raise ValueError(f"Suspicious compression ratio: {member.filename}")
      
      def _source_part(rels_name: str) -> str | None:
          path = PurePosixPath(rels_name)
          if str(path) == "_rels/.rels":
              return ""
          if path.parent.name != "_rels" or not path.name.endswith(".rels"):
              return None
          return str(path.parent.parent / path.name.removesuffix(".rels"))
      
      
      def _part_target(rels_name: str, target: str) -> str | None:
          source = _source_part(rels_name)
          if source is None:
              return None
          if target.startswith("/"):
              resolved = posixpath.normpath(target.lstrip("/"))
          else:
              resolved = posixpath.normpath(posixpath.join(posixpath.dirname(source), target))
          return resolved if resolved not in {"", ".", ".."} and not resolved.startswith("../") else None
      
      def _xml(archive: zipfile.ZipFile, name: str):
          return ET.fromstring(archive.read(name))
      
      def _relationships(archive: zipfile.ZipFile, name: str) -> dict[str, dict[str, str]]:
          if name not in archive.namelist():
              return {}
          relationships = {}
          for item in _xml(archive, name).findall(f"{PR}Relationship"):
              mode = item.get("TargetMode", "Internal")
              target = item.get("Target", "")
              relationships[item.get("Id", "")] = {
                  "type": item.get("Type", "").rsplit("/", 1)[-1],
                  "target": target if mode == "External" else _part_target(name, target),
                  "mode": mode,
              }
          return relationships
      
      def inspect(path: str) -> dict:
          with zipfile.ZipFile(path) as archive:
              _validate_archive(archive)
              names = set(archive.namelist())
              presentation_rels = _relationships(archive, "ppt/_rels/presentation.xml.rels")
              presentation = _xml(archive, "ppt/presentation.xml")
              theme_name = "ppt/theme/theme1.xml"
              theme = _xml(archive, theme_name) if theme_name in names else None
              colors = [] if theme is None else [{"name": item.tag.removeprefix(A), "value": next((child.get("val") for child in item), None)} for item in theme.findall(f".//{A}clrScheme/*")]
              fonts = {} if theme is None else {key: node.get("typeface") for key, node in (("major_latin", theme.find(f".//{A}majorFont/{A}latin")), ("minor_latin", theme.find(f".//{A}minorFont/{A}latin"))) if node is not None}
              slides = []
              for index, item in enumerate(presentation.findall(f".//{P}sldId"), start=1):
                  rel = presentation_rels.get(item.get(f"{R}id", ""), {})
                  slide_name = rel.get("target", "")
                  if slide_name not in names:
                      slides.append({"index": index, "part": slide_name, "error": "missing slide part"})
                      continue
                  slide = _xml(archive, slide_name)
                  rels_name = f"{PurePosixPath(slide_name).parent}/_rels/{PurePosixPath(slide_name).name}.rels"
                  rels = _relationships(archive, rels_name)
                  text = "".join(node.text or "" for node in slide.findall(f".//{A}t"))
                  shape_counts = Counter(node.tag.removeprefix(P) for node in slide.findall(f".//{P}spTree/*"))
                  slides.append({"index": index, "part": slide_name, "slide_id": item.get("id"), "hidden": item.get("show") == "0", "text": text, "shape_counts": dict(shape_counts), "relationships": list(rels.values()), "has_transition": slide.find(f"{P}transition") is not None, "has_timing": slide.find(f"{P}timing") is not None})
              return {"deck": path, "slide_count": len(slides), "theme": {"colors": colors, "fonts": fonts}, "slides": slides, "media": sorted(name for name in names if name.startswith("ppt/media/")), "notes_parts": sorted(name for name in names if name.startswith("ppt/notesSlides/notesSlide")), "comment_parts": sorted(name for name in names if "/comments" in name.lower())}
      
      def main(argv: list[str] | None = None) -> None:
          argv = sys.argv[1:] if argv is None else argv
          if len(argv) != 1:
              raise SystemExit("Usage: python inspect.py <deck.pptx>")
          path = _workspace_path(argv[0])
          if not path.is_file():
              raise SystemExit(f"Input package does not exist: {path}")
          _write_stdout(json.dumps(inspect(str(path)), ensure_ascii=False, indent=2) + "\n")
      
      if __name__ == "__main__":
          main()
      
    • unpack.py 3 KB
      #!/usr/bin/env python3
      """Safely unpack an Office ZIP package and pretty-print XML for inspection."""
      from __future__ import annotations
      import shutil
      import stat
      import sys
      import zipfile
      from pathlib import Path
      from defusedxml import minidom
      
      MAX_MEMBERS = 5_000
      MAX_MEMBER_SIZE = 100 * 1024 * 1024
      MAX_TOTAL_SIZE = 512 * 1024 * 1024
      MAX_COMPRESSION_RATIO = 1_000
      
      def _workspace_path(value: str) -> Path:
          root = Path.cwd().resolve()
          path = Path(value).expanduser().resolve()
          if not path.is_relative_to(root):
              raise ValueError(f"Path escapes the current workspace: {value}")
          return path
      
      def _validate_members(archive: zipfile.ZipFile, source: Path, output: Path) -> list[zipfile.ZipInfo]:
          members = archive.infolist()
          if len(members) > MAX_MEMBERS:
              raise ValueError("Archive contains too many entries")
          total = 0
          for member in members:
              if stat.S_ISLNK(member.external_attr >> 16):
                  raise ValueError(f"Archive contains a symlink: {member.filename}")
              target = (output / member.filename).resolve()
              if not target.is_relative_to(output):
                  raise ValueError(f"Unsafe archive entry: {member.filename}")
              if target == source:
                  raise ValueError(f"Archive entry would overwrite input package: {member.filename}")
              if member.file_size > MAX_MEMBER_SIZE:
                  raise ValueError(f"Archive entry is too large: {member.filename}")
              total += member.file_size
              if total > MAX_TOTAL_SIZE:
                  raise ValueError("Archive uncompressed size is too large")
              if member.compress_size and member.file_size / member.compress_size > MAX_COMPRESSION_RATIO:
                  raise ValueError(f"Suspicious compression ratio: {member.filename}")
          return members
      
      def unpack(source: Path, output: Path) -> None:
          if output == source:
              raise ValueError("output directory must not be the input package")
          output.mkdir(parents=True, exist_ok=True)
          with zipfile.ZipFile(source) as archive:
              for member in _validate_members(archive, source, output):
                  target = output / member.filename
                  if member.is_dir():
                      target.mkdir(parents=True, exist_ok=True)
                      continue
                  target.parent.mkdir(parents=True, exist_ok=True)
                  with archive.open(member) as reader, target.open("wb") as writer:
                      shutil.copyfileobj(reader, writer)
          for path in [*output.rglob("*.xml"), *output.rglob("*.rels")]:
              document = minidom.parseString(path.read_bytes())
              path.write_bytes(document.toprettyxml(indent="  ", encoding="utf-8"))
      
      def main(argv: list[str] | None = None) -> None:
          argv = sys.argv[1:] if argv is None else argv
          if len(argv) != 2:
              raise SystemExit("Usage: python unpack.py <office_file> <output_dir>")
          source, output = (_workspace_path(value) for value in argv)
          if not source.is_file():
              raise SystemExit(f"Input package does not exist: {source}")
          unpack(source, output)
      
      if __name__ == "__main__":
          main()
      
    • validate_package.py 9.5 KB
      #!/usr/bin/env python3
      """Validate PPTX package integrity without modifying the archive."""
      from __future__ import annotations
      import argparse
      import json
      import posixpath
      import stat
      import sys
      import zipfile
      from collections import Counter
      from pathlib import Path, PurePosixPath
      from typing import Any
      from defusedxml import ElementTree as ET
      
      P = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
      R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
      PR = "{http://schemas.openxmlformats.org/package/2006/relationships}"
      CT = "{http://schemas.openxmlformats.org/package/2006/content-types}"
      SLIDE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"
      MAX_MEMBERS, MAX_MEMBER_SIZE, MAX_TOTAL_SIZE, MAX_COMPRESSION_RATIO = 5_000, 100 * 1024 * 1024, 512 * 1024 * 1024, 1_000
      
      
      def _write_stdout(value: str) -> None:
          """Write UTF-8 JSON without depending on the console code page."""
          sys.stdout.buffer.write(value.encode("utf-8"))
      
      
      def _workspace_path(value: str) -> Path:
          root = Path.cwd().resolve()
          path = Path(value).expanduser().resolve()
          if not path.is_relative_to(root):
              raise ValueError(f"Path escapes the current workspace: {value}")
          return path
      
      def _validate_archive(archive: zipfile.ZipFile) -> None:
          members, total = archive.infolist(), 0
          if len(members) > MAX_MEMBERS:
              raise ValueError("Archive contains too many entries")
          for member in members:
              if stat.S_ISLNK(member.external_attr >> 16):
                  raise ValueError(f"Archive contains a symlink: {member.filename}")
              if member.file_size > MAX_MEMBER_SIZE:
                  raise ValueError(f"Archive entry is too large: {member.filename}")
              total += member.file_size
              if total > MAX_TOTAL_SIZE:
                  raise ValueError("Archive uncompressed size is too large")
              if member.compress_size and member.file_size / member.compress_size > MAX_COMPRESSION_RATIO:
                  raise ValueError(f"Suspicious compression ratio: {member.filename}")
      
      def _source_part(rels_name: str) -> str | None:
          path = PurePosixPath(rels_name)
          if str(path) == "_rels/.rels":
              return ""
          if path.parent.name != "_rels" or not path.name.endswith(".rels"):
              return None
          return str(path.parent.parent / path.name.removesuffix(".rels"))
      
      def _target(rels_name: str, value: str) -> str | None:
          source = _source_part(rels_name)
          if source is None:
              return None
          if value.startswith("/"):
              target = posixpath.normpath(value.lstrip("/"))
          else:
              target = posixpath.normpath(posixpath.join(posixpath.dirname(source), value))
          return target if target not in {"", ".", ".."} and not target.startswith("../") else None
      
      def _content_types(archive: zipfile.ZipFile) -> tuple[dict[str, str], dict[str, str]]:
          root = ET.fromstring(archive.read("[Content_Types].xml"))
          return (
              {item.get("Extension", "").lower(): item.get("ContentType", "") for item in root.findall(f"{CT}Default")},
              {item.get("PartName", "").lstrip("/"): item.get("ContentType", "") for item in root.findall(f"{CT}Override")},
          )
      
      def validate(path: str) -> dict[str, Any]:
          errors: list[dict[str, str]] = []
          warnings: list[dict[str, str]] = []
          with zipfile.ZipFile(path) as archive:
              _validate_archive(archive)
              names = {item.filename for item in archive.infolist() if not item.is_dir()}
              for name in sorted(name for name in names if name.endswith((".xml", ".rels"))):
                  try:
                      ET.fromstring(archive.read(name))
                  except Exception as exc:
                      errors.append({"part": name, "check": "xml_well_formed", "message": str(exc)})
              if "[Content_Types].xml" not in names:
                  errors.append({"part": "[Content_Types].xml", "check": "content_types", "message": "missing content types part"})
                  defaults, overrides = {}, {}
              else:
                  try:
                      defaults, overrides = _content_types(archive)
                  except Exception as exc:
                      errors.append({"part": "[Content_Types].xml", "check": "content_types", "message": str(exc)})
                      defaults, overrides = {}, {}
              referenced: set[str] = set()
              relationships: dict[str, dict[str, dict[str, str]]] = {}
              for rels_name in sorted(name for name in names if name.endswith(".rels")):
                  try:
                      root = ET.fromstring(archive.read(rels_name))
                  except Exception:
                      continue
                  rels: dict[str, dict[str, str]] = {}
                  for rel in root.findall(f"{PR}Relationship"):
                      rel_id, target, mode = rel.get("Id", ""), rel.get("Target", ""), rel.get("TargetMode", "Internal")
                      rels[rel_id] = {"type": rel.get("Type", ""), "target": target, "mode": mode}
                      if mode != "External":
                          resolved = _target(rels_name, target)
                          if not resolved or resolved not in names:
                              errors.append({"part": rels_name, "check": "internal_relationship", "message": f"{rel_id} targets missing or unsafe part: {target}"})
                          else:
                              referenced.add(resolved)
                  relationships[rels_name] = rels
              presentation, declared_slides = "ppt/presentation.xml", set()
              presentation_rels = relationships.get("ppt/_rels/presentation.xml.rels", {})
              if presentation not in names:
                  errors.append({"part": presentation, "check": "slide_order", "message": "missing presentation part"})
              else:
                  try:
                      root = ET.fromstring(archive.read(presentation))
                      ids = [item.get("id", "") for item in root.findall(f".//{P}sldId")]
                      for value, count in Counter(ids).items():
                          if value and count > 1:
                              errors.append({"part": presentation, "check": "slide_id_unique", "message": f"duplicate slide id: {value}"})
                      for item in root.findall(f".//{P}sldId"):
                          rel_id = item.get(f"{R}id", "")
                          rel = presentation_rels.get(rel_id)
                          if rel is None or not rel["type"].endswith("/slide"):
                              errors.append({"part": presentation, "check": "slide_relationship", "message": f"slide id references missing/non-slide relationship: {rel_id}"})
                          else:
                              target = _target("ppt/_rels/presentation.xml.rels", rel["target"])
                              if target:
                                  declared_slides.add(target)
                  except Exception as exc:
                      errors.append({"part": presentation, "check": "slide_order", "message": str(exc)})
              for slide in sorted(name for name in names if name.startswith("ppt/slides/slide") and name.endswith(".xml")):
                  if overrides.get(slide) != SLIDE_CONTENT_TYPE:
                      errors.append({"part": slide, "check": "content_type", "message": "missing or incorrect slide content type override"})
                  if slide not in declared_slides:
                      warnings.append({"part": slide, "check": "unlisted_slide", "message": "slide part is not listed in presentation.xml"})
                  rels_name = f"{PurePosixPath(slide).parent}/_rels/{PurePosixPath(slide).name}.rels"
                  layouts = [item for item in relationships.get(rels_name, {}).values() if item["type"].endswith("/slideLayout")]
                  if len(layouts) != 1:
                      errors.append({"part": rels_name, "check": "slide_layout_relationship", "message": f"expected exactly one slideLayout relationship, found {len(layouts)}"})
              for check, candidates in {"orphaned_media": (item for item in names if item.startswith("ppt/media/")), "orphaned_notes": (item for item in names if item.startswith("ppt/notesSlides/notesSlide") and item.endswith(".xml"))}.items():
                  for name in sorted(candidates):
                      if name not in referenced:
                          warnings.append({"part": name, "check": check, "message": "part has no inbound internal relationship"})
          return {"deck": path, "ok": not errors, "error_count": len(errors), "warning_count": len(warnings), "errors": errors, "warnings": warnings}
      
      def main(argv: list[str] | None = None) -> int:
          parser = argparse.ArgumentParser(description="Validate PPTX package integrity without modifying it.")
          parser.add_argument("deck", help="PPTX file inside the current workspace")
          parser.add_argument("--output", help="Optional JSON report path inside the current workspace")
          args = parser.parse_args(argv)
          try:
              deck = _workspace_path(args.deck)
              if not deck.is_file() or deck.suffix.lower() != ".pptx":
                  raise ValueError("deck must be an existing .pptx file")
              report = validate(str(deck))
              payload = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
              if args.output:
                  output = _workspace_path(args.output)
                  if output == deck:
                      raise ValueError("output must not overwrite the input deck")
                  output.parent.mkdir(parents=True, exist_ok=True)
                  output.write_text(payload, encoding="utf-8")
              _write_stdout(payload)
              return 0 if report["ok"] else 1
          except (OSError, ValueError, zipfile.BadZipFile) as exc:
              _write_stdout(
                  json.dumps(
                      {"ok": False, "errors": [{"check": "input", "message": str(exc)}]},
                      ensure_ascii=False,
                      indent=2,
                  )
                  + "\n"
              )
              return 2
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
  • requirements.txt 19 B
    defusedxml>=0.7,<1
    
  • SKILL.md 2.7 KB
    ---
    name: pptx-reference-deck-analysis
    description: "Use when analyzing a reference PPTX for read-only structure, theme, typography, layout rhythm, diagnostics, derived template catalogs, or safe OOXML package inspection."
    ---
    
    # PPTX Reference Deck Analysis
    
    Inspect a reference deck as design evidence. This skill never copies, clones, or mutates a source deck.
    
    ## Contract
    
    Implement required extraction on demand with a small task-local `python-pptx` script. Capture only the information required for the new deck:
    
    - compact prompt context: slide count and size, text summaries, shape counts, styles, brand signals, template use, and layout rhythm;
    - full extraction: `summary`, slides, and read-only `layout_tree` evidence;
    - folder diagnostics: one result per deck plus a manifest;
    - style-master analysis: colors, fonts, size distribution, master/layout use, and flow patterns;
    - derived template catalog: zero-based source indices, layout roles, usable regions, placeholders, visual structures, and constraints.
    
    ## Rules
    
    - Keep the source deck read-only and independently author every target-slide coordinate.
    - Use the bundled OOXML utilities only for raw themes, relationships, notes, comments, animations, media, masters, or layouts that high-level extraction cannot expose.
    - Record inspected parts and parsing exceptions in the analysis manifest.
    - Do not use extracted content, fonts, images, or proprietary assets in a generated deck without explicit permission and license evidence.
    
    ## OOXML package inspection
    
    Install `defusedxml` from `requirements.txt` before using the bundled utilities.
    
    1. Run `scripts/inspect.py <deck.pptx>` for a compact JSON report of slide order, text, theme tokens, relationships, notes, comments, animations, and media.
    2. Run `scripts/validate_package.py <deck.pptx> --output <report.json>` for malformed XML, broken internal relationships, content-type gaps, duplicate layout links, and orphaned parts.
    3. Run `scripts/unpack.py <deck.pptx> <output-dir>` only when raw-package evidence is necessary.
    4. Resolve relationship targets relative to the `.rels` owner; never infer slide order from filenames.
    
    ### Safety
    
    - Never modify a supplied deck or blindly copy package parts into a new deck.
    - Run the scripts from a trusted workspace; they reject path traversal, symlinks, oversized members, and archive bombs.
    - Parse untrusted XML with `defusedxml`; do not enable entity expansion, DTD loading, or network access.
    - Treat theme colors as tokens unless fully resolved against the color scheme.
    
    See `references/reference-deck-analysis.md` for output shapes, `references/reference-deck-analysis-patterns.md` for documentation-only patterns, and `references/ooxml-parsing.md` for package part maps.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related