| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Beautify Inventory Builder |
| 4 | |
| 5 | Mechanically merge a source deck's extracts into one per-slide ledger for the |
| 6 | beautify-pptx profile: text blocks + tables + charts + SmartArt structure (from a |
| 7 | `template_fill_pptx.py analyze` slide_library.json) joined with the images |
| 8 | bound to each slide (from a `ppt_to_md.py` image_manifest.json). The deterministic |
| 9 | join only — `ignored` and `needs_confirmation` are emitted empty for the agent |
| 10 | to fill with judgment (hidden shapes, combo charts, overcrowded pages, ...). |
| 11 | |
| 12 | Usage: |
| 13 | python3 scripts/beautify_inventory.py <slide_library.json> [--images <image_manifest.json>] [-o inventory.json] |
| 14 | python3 scripts/beautify_inventory.py <inventory.json> --summary |
| 15 | python3 scripts/beautify_inventory.py <inventory.json> --page N [--with-geometry] |
| 16 | |
| 17 | Examples: |
| 18 | python3 scripts/beautify_inventory.py projects/x/analysis/<stem>.slide_library.json \ |
| 19 | --images projects/x/images/image_manifest.json -o projects/x/analysis/beautify_inventory.json |
| 20 | python3 scripts/beautify_inventory.py projects/x/analysis/beautify_inventory.json --summary |
| 21 | python3 scripts/beautify_inventory.py projects/x/analysis/beautify_inventory.json --page 7 |
| 22 | |
| 23 | Dependencies: |
| 24 | None (standard library only). |
| 25 | |
| 26 | See workflows/profiles/beautify-pptx.md Step 4 for how the inventory is consumed. |
| 27 | """ |
| 28 | |
| 29 | from __future__ import annotations |
| 30 | |
| 31 | import argparse |
| 32 | import json |
| 33 | import sys |
| 34 | from pathlib import Path |
| 35 | from typing import Any, Optional |
| 36 | |
| 37 | from console_encoding import configure_utf8_stdio |
| 38 | |
| 39 | configure_utf8_stdio() |
| 40 | |
| 41 | |
| 42 | _GEOMETRY_KEYS = { |
| 43 | "geometry", |
| 44 | "display_ratio", |
| 45 | "display_left_emu", |
| 46 | "display_top_emu", |
| 47 | "display_width_emu", |
| 48 | "display_height_emu", |
| 49 | } |
| 50 | |
| 51 | |
| 52 | def _images_by_slide(manifest: list) -> dict[int, list[dict]]: |
| 53 | """Map slide_index -> [image entries on that slide], from ppt_to_md occurrences.""" |
| 54 | by_slide: dict[int, list[dict]] = {} |
| 55 | for entry in manifest: |
| 56 | filename = entry.get("filename") |
| 57 | for occ in entry.get("occurrences", []): |
| 58 | idx = occ.get("slide_index") |
| 59 | if idx is None: |
| 60 | continue |
| 61 | by_slide.setdefault(idx, []).append({ |
| 62 | "filename": filename, |
| 63 | "shape_name": occ.get("shape_name"), |
| 64 | "pixel_width": entry.get("pixel_width"), |
| 65 | "pixel_height": entry.get("pixel_height"), |
| 66 | "display_ratio": occ.get("display_ratio"), |
| 67 | "display_left_emu": occ.get("display_left_emu"), |
| 68 | "display_top_emu": occ.get("display_top_emu"), |
| 69 | "display_width_emu": occ.get("display_width_emu"), |
| 70 | "display_height_emu": occ.get("display_height_emu"), |
| 71 | "usage_count": entry.get("usage_count"), |
| 72 | }) |
| 73 | return by_slide |
| 74 | |
| 75 | |
| 76 | def _table_cells(table: dict) -> list[list[str]]: |
| 77 | """Row-major 2D grid of cell text from a slide_library table.""" |
| 78 | grid = [] |
| 79 | for row in table.get("rows", []): |
| 80 | grid.append([c.get("text", "") for c in row.get("cells", [])]) |
| 81 | return grid |
| 82 | |
| 83 | |
| 84 | def build_inventory(slide_library: dict, images_by_slide: dict[int, list[dict]]) -> dict: |
| 85 | """Join slide_library slides with per-slide images into one ledger.""" |
| 86 | slides_out = [] |
| 87 | for slide in slide_library.get("slides", []): |
| 88 | idx = slide.get("slide_index") |
| 89 | text_blocks = [ |
| 90 | { |
| 91 | "slot_id": s.get("slot_id"), |
| 92 | "role": s.get("role"), |
| 93 | "text": s.get("text", ""), |
| 94 | "paragraph_count": s.get("paragraph_count"), |
| 95 | "geometry": s.get("geometry"), |
| 96 | } |
| 97 | for s in slide.get("slots", []) |
| 98 | ] |
| 99 | tables = [ |
| 100 | { |
| 101 | "table_id": t.get("table_id"), |
| 102 | "row_count": t.get("row_count"), |
| 103 | "column_count": t.get("column_count"), |
| 104 | "cells": _table_cells(t), # 2D text grid — convenient for diff |
| 105 | "rows": t.get("rows", []), # raw row/col cells — preserves merged / multi-header fidelity |
| 106 | } |
| 107 | for t in slide.get("tables", []) |
| 108 | ] |
| 109 | charts = [ |
| 110 | { |
| 111 | "chart_id": c.get("chart_id"), |
| 112 | "chart_type": c.get("chart_type"), |
| 113 | "category_count": c.get("category_count"), |
| 114 | "series_count": c.get("series_count"), |
| 115 | "categories": c.get("categories", []), # frozen data |
| 116 | "series": c.get("series", []), # frozen data (name + values) |
| 117 | } |
| 118 | for c in slide.get("charts", []) |
| 119 | ] |
| 120 | diagrams = [ |
| 121 | { |
| 122 | "diagram_id": diagram.get("diagram_id"), |
| 123 | "shape_name": diagram.get("shape_name"), |
| 124 | "geometry": diagram.get("geometry"), |
| 125 | "layout": diagram.get("layout", {}), |
| 126 | "root_ids": diagram.get("root_ids", []), |
| 127 | "nodes": diagram.get("nodes", []), |
| 128 | "connections": diagram.get("connections", []), |
| 129 | "status": diagram.get("status"), |
| 130 | "warnings": diagram.get("warnings", []), |
| 131 | } |
| 132 | for diagram in slide.get("diagrams", []) |
| 133 | ] |
| 134 | slides_out.append({ |
| 135 | "slide_index": idx, |
| 136 | "page_type": slide.get("page_type"), |
| 137 | "text_blocks": text_blocks, |
| 138 | "tables": tables, |
| 139 | "charts": charts, |
| 140 | "diagrams": diagrams, |
| 141 | "images": images_by_slide.get(idx, []), |
| 142 | "ignored": [], # agent fills: hidden shapes, master-only text, image crop/rotation |
| 143 | "needs_confirmation": [], # agent fills: combo/dual-axis charts, merged-cell tables, overcrowded pages |
| 144 | }) |
| 145 | |
| 146 | return { |
| 147 | "schema": "beautify_inventory.v1", |
| 148 | "source": slide_library.get("source_pptx"), |
| 149 | "slide_count": slide_library.get("slide_count", len(slides_out)), |
| 150 | "canvas_px": slide_library.get("canvas_px"), |
| 151 | "slides": slides_out, |
| 152 | } |
| 153 | |
| 154 | |
| 155 | def _view_payload(inventory: dict, view: str, slides: list[dict]) -> dict: |
| 156 | """Wrap projected slides with the canonical deck-level facts.""" |
| 157 | return { |
| 158 | "schema": "beautify_inventory.view.v1", |
| 159 | "inventory_schema": inventory.get("schema", "beautify_inventory.v1"), |
| 160 | "view": view, |
| 161 | "source": inventory.get("source"), |
| 162 | "slide_count": inventory.get("slide_count", len(inventory.get("slides", []))), |
| 163 | "canvas_px": inventory.get("canvas_px"), |
| 164 | "slides": slides, |
| 165 | } |
| 166 | |
| 167 | |
| 168 | def _summary_view(inventory: dict) -> dict: |
| 169 | """Project the whole roster into compact per-slide counts and review flags.""" |
| 170 | slides = [] |
| 171 | for slide in inventory.get("slides", []): |
| 172 | slides.append({ |
| 173 | "slide_index": slide.get("slide_index"), |
| 174 | "page_type": slide.get("page_type"), |
| 175 | "text_block_count": len(slide.get("text_blocks", [])), |
| 176 | "table_count": len(slide.get("tables", [])), |
| 177 | "chart_count": len(slide.get("charts", [])), |
| 178 | "diagram_count": len(slide.get("diagrams", [])), |
| 179 | "image_count": len(slide.get("images", [])), |
| 180 | "ignored": slide.get("ignored", []), |
| 181 | "needs_confirmation": slide.get("needs_confirmation", []), |
| 182 | }) |
| 183 | return _view_payload(inventory, "summary", slides) |
| 184 | |
| 185 | |
| 186 | def _without_geometry(value: Any) -> Any: |
| 187 | """Remove explicit source-layout geometry while preserving content and data.""" |
| 188 | if isinstance(value, dict): |
| 189 | return { |
| 190 | key: _without_geometry(item) |
| 191 | for key, item in value.items() |
| 192 | if key not in _GEOMETRY_KEYS |
| 193 | } |
| 194 | if isinstance(value, list): |
| 195 | return [_without_geometry(item) for item in value] |
| 196 | return value |
| 197 | |
| 198 | |
| 199 | def _page_view(inventory: dict, slide_index: int, with_geometry: bool) -> Optional[dict]: |
| 200 | """Project one slide by its canonical slide_index.""" |
| 201 | for slide in inventory.get("slides", []): |
| 202 | if slide.get("slide_index") != slide_index: |
| 203 | continue |
| 204 | projected = slide if with_geometry else _without_geometry(slide) |
| 205 | return _view_payload(inventory, "page", [projected]) |
| 206 | return None |
| 207 | |
| 208 | |
| 209 | def _positive_int(value: str) -> int: |
| 210 | """Parse a positive slide index for argparse.""" |
| 211 | parsed = int(value) |
| 212 | if parsed < 1: |
| 213 | raise argparse.ArgumentTypeError("page must be a positive integer") |
| 214 | return parsed |
| 215 | |
| 216 | |
| 217 | def build_parser() -> argparse.ArgumentParser: |
| 218 | parser = argparse.ArgumentParser( |
| 219 | description=( |
| 220 | "Build a beautify inventory from a slide library, or print a read-only " |
| 221 | "model view from a canonical beautify inventory." |
| 222 | ), |
| 223 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 224 | ) |
| 225 | parser.add_argument( |
| 226 | "input_json", |
| 227 | help="slide_library.json in builder mode, or beautify_inventory.v1 JSON in a view mode", |
| 228 | ) |
| 229 | parser.add_argument( |
| 230 | "--images", |
| 231 | help="image_manifest.json for a slide_library input (optional)", |
| 232 | ) |
| 233 | parser.add_argument( |
| 234 | "-o", |
| 235 | "--output", |
| 236 | help="Write the full built inventory here (builder mode only; default: stdout)", |
| 237 | ) |
| 238 | view_group = parser.add_mutually_exclusive_group() |
| 239 | view_group.add_argument( |
| 240 | "--summary", |
| 241 | action="store_true", |
| 242 | help="Print deck facts, per-slide object counts, and review flags to stdout", |
| 243 | ) |
| 244 | view_group.add_argument( |
| 245 | "--page", |
| 246 | type=_positive_int, |
| 247 | metavar="N", |
| 248 | help="Print one slide's frozen content/data by slide_index to stdout", |
| 249 | ) |
| 250 | parser.add_argument( |
| 251 | "--with-geometry", |
| 252 | action="store_true", |
| 253 | help="Retain source-layout geometry in a --page view", |
| 254 | ) |
| 255 | return parser |
| 256 | |
| 257 | |
| 258 | def main(argv: Optional[list[str]] = None) -> int: |
| 259 | parser = build_parser() |
| 260 | args = parser.parse_args(argv) |
| 261 | |
| 262 | view_requested = args.summary or args.page is not None |
| 263 | if view_requested and args.output: |
| 264 | parser.error("--summary/--page write to stdout and cannot be combined with --output") |
| 265 | if args.with_geometry and args.page is None: |
| 266 | parser.error("--with-geometry requires --page") |
| 267 | |
| 268 | input_path = Path(args.input_json) |
| 269 | if not input_path.is_file(): |
| 270 | print(f"[ERROR] input JSON not found: {input_path}", file=sys.stderr) |
| 271 | return 1 |
| 272 | input_data = json.loads(input_path.read_text(encoding="utf-8")) |
| 273 | |
| 274 | input_schema = input_data.get("schema") |
| 275 | if input_schema == "beautify_inventory.view.v1": |
| 276 | parser.error( |
| 277 | "beautify_inventory.view.v1 is a read-only stdout projection and " |
| 278 | "cannot be used as input" |
| 279 | ) |
| 280 | is_inventory = input_schema == "beautify_inventory.v1" |
| 281 | if is_inventory and not view_requested: |
| 282 | parser.error( |
| 283 | "beautify_inventory.v1 input requires --summary or --page; " |
| 284 | "builder mode requires slide_library.json" |
| 285 | ) |
| 286 | if is_inventory and args.images: |
| 287 | parser.error("--images cannot be combined with a beautify_inventory.v1 input") |
| 288 | if view_requested and not is_inventory: |
| 289 | parser.error("--summary/--page require a canonical beautify_inventory.v1 input") |
| 290 | |
| 291 | manifest: list = [] |
| 292 | if args.images and not is_inventory: |
| 293 | img_path = Path(args.images) |
| 294 | if not img_path.is_file(): |
| 295 | print(f"[ERROR] image manifest not found: {img_path}", file=sys.stderr) |
| 296 | return 1 |
| 297 | manifest = json.loads(img_path.read_text(encoding="utf-8")) |
| 298 | |
| 299 | inventory = input_data if is_inventory else build_inventory( |
| 300 | input_data, |
| 301 | _images_by_slide(manifest), |
| 302 | ) |
| 303 | |
| 304 | if args.summary: |
| 305 | print(json.dumps(_summary_view(inventory), ensure_ascii=False, indent=2)) |
| 306 | return 0 |
| 307 | if args.page is not None: |
| 308 | page_view = _page_view(inventory, args.page, args.with_geometry) |
| 309 | if page_view is None: |
| 310 | available = ", ".join( |
| 311 | str(slide.get("slide_index")) |
| 312 | for slide in inventory.get("slides", []) |
| 313 | ) |
| 314 | print( |
| 315 | f"[ERROR] slide_index {args.page} not found; available: {available or 'none'}", |
| 316 | file=sys.stderr, |
| 317 | ) |
| 318 | return 1 |
| 319 | print(json.dumps(page_view, ensure_ascii=False, indent=2)) |
| 320 | return 0 |
| 321 | |
| 322 | payload = json.dumps(inventory, ensure_ascii=False, indent=2) |
| 323 | if args.output: |
| 324 | out = Path(args.output) |
| 325 | out.parent.mkdir(parents=True, exist_ok=True) |
| 326 | out.write_text(payload + "\n", encoding="utf-8") |
| 327 | print(f"[OK] inventory written to: {out}", file=sys.stderr) |
| 328 | else: |
| 329 | print(payload) |
| 330 | return 0 |
| 331 | |
| 332 | |
| 333 | if __name__ == "__main__": |
| 334 | raise SystemExit(main()) |
| 335 |