| 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 | |
| 15 | Examples: |
| 16 | python3 scripts/beautify_inventory.py projects/x/analysis/<stem>.slide_library.json \ |
| 17 | --images projects/x/images/image_manifest.json -o projects/x/analysis/beautify_inventory.json |
| 18 | |
| 19 | Dependencies: |
| 20 | None (standard library only). |
| 21 | |
| 22 | See workflows/profiles/beautify-pptx.md Step 4 for how the inventory is consumed. |
| 23 | """ |
| 24 | |
| 25 | from __future__ import annotations |
| 26 | |
| 27 | import argparse |
| 28 | import json |
| 29 | import sys |
| 30 | from pathlib import Path |
| 31 | from typing import Optional |
| 32 | |
| 33 | from console_encoding import configure_utf8_stdio |
| 34 | |
| 35 | configure_utf8_stdio() |
| 36 | |
| 37 | |
| 38 | def _images_by_slide(manifest: list) -> dict[int, list[dict]]: |
| 39 | """Map slide_index -> [image entries on that slide], from ppt_to_md occurrences.""" |
| 40 | by_slide: dict[int, list[dict]] = {} |
| 41 | for entry in manifest: |
| 42 | filename = entry.get("filename") |
| 43 | for occ in entry.get("occurrences", []): |
| 44 | idx = occ.get("slide_index") |
| 45 | if idx is None: |
| 46 | continue |
| 47 | by_slide.setdefault(idx, []).append({ |
| 48 | "filename": filename, |
| 49 | "shape_name": occ.get("shape_name"), |
| 50 | "pixel_width": entry.get("pixel_width"), |
| 51 | "pixel_height": entry.get("pixel_height"), |
| 52 | "display_ratio": occ.get("display_ratio"), |
| 53 | "display_left_emu": occ.get("display_left_emu"), |
| 54 | "display_top_emu": occ.get("display_top_emu"), |
| 55 | "display_width_emu": occ.get("display_width_emu"), |
| 56 | "display_height_emu": occ.get("display_height_emu"), |
| 57 | "usage_count": entry.get("usage_count"), |
| 58 | }) |
| 59 | return by_slide |
| 60 | |
| 61 | |
| 62 | def _table_cells(table: dict) -> list[list[str]]: |
| 63 | """Row-major 2D grid of cell text from a slide_library table.""" |
| 64 | grid = [] |
| 65 | for row in table.get("rows", []): |
| 66 | grid.append([c.get("text", "") for c in row.get("cells", [])]) |
| 67 | return grid |
| 68 | |
| 69 | |
| 70 | def build_inventory(slide_library: dict, images_by_slide: dict[int, list[dict]]) -> dict: |
| 71 | """Join slide_library slides with per-slide images into one ledger.""" |
| 72 | slides_out = [] |
| 73 | for slide in slide_library.get("slides", []): |
| 74 | idx = slide.get("slide_index") |
| 75 | text_blocks = [ |
| 76 | { |
| 77 | "slot_id": s.get("slot_id"), |
| 78 | "role": s.get("role"), |
| 79 | "text": s.get("text", ""), |
| 80 | "paragraph_count": s.get("paragraph_count"), |
| 81 | "geometry": s.get("geometry"), |
| 82 | } |
| 83 | for s in slide.get("slots", []) |
| 84 | ] |
| 85 | tables = [ |
| 86 | { |
| 87 | "table_id": t.get("table_id"), |
| 88 | "row_count": t.get("row_count"), |
| 89 | "column_count": t.get("column_count"), |
| 90 | "cells": _table_cells(t), # 2D text grid — convenient for diff |
| 91 | "rows": t.get("rows", []), # raw row/col cells — preserves merged / multi-header fidelity |
| 92 | } |
| 93 | for t in slide.get("tables", []) |
| 94 | ] |
| 95 | charts = [ |
| 96 | { |
| 97 | "chart_id": c.get("chart_id"), |
| 98 | "chart_type": c.get("chart_type"), |
| 99 | "category_count": c.get("category_count"), |
| 100 | "series_count": c.get("series_count"), |
| 101 | "categories": c.get("categories", []), # frozen data |
| 102 | "series": c.get("series", []), # frozen data (name + values) |
| 103 | } |
| 104 | for c in slide.get("charts", []) |
| 105 | ] |
| 106 | diagrams = [ |
| 107 | { |
| 108 | "diagram_id": diagram.get("diagram_id"), |
| 109 | "shape_name": diagram.get("shape_name"), |
| 110 | "geometry": diagram.get("geometry"), |
| 111 | "layout": diagram.get("layout", {}), |
| 112 | "root_ids": diagram.get("root_ids", []), |
| 113 | "nodes": diagram.get("nodes", []), |
| 114 | "connections": diagram.get("connections", []), |
| 115 | "status": diagram.get("status"), |
| 116 | "warnings": diagram.get("warnings", []), |
| 117 | } |
| 118 | for diagram in slide.get("diagrams", []) |
| 119 | ] |
| 120 | slides_out.append({ |
| 121 | "slide_index": idx, |
| 122 | "page_type": slide.get("page_type"), |
| 123 | "text_blocks": text_blocks, |
| 124 | "tables": tables, |
| 125 | "charts": charts, |
| 126 | "diagrams": diagrams, |
| 127 | "images": images_by_slide.get(idx, []), |
| 128 | "ignored": [], # agent fills: hidden shapes, master-only text, image crop/rotation |
| 129 | "needs_confirmation": [], # agent fills: combo/dual-axis charts, merged-cell tables, overcrowded pages |
| 130 | }) |
| 131 | |
| 132 | return { |
| 133 | "schema": "beautify_inventory.v1", |
| 134 | "source": slide_library.get("source_pptx"), |
| 135 | "slide_count": slide_library.get("slide_count", len(slides_out)), |
| 136 | "canvas_px": slide_library.get("canvas_px"), |
| 137 | "slides": slides_out, |
| 138 | } |
| 139 | |
| 140 | |
| 141 | def build_parser() -> argparse.ArgumentParser: |
| 142 | parser = argparse.ArgumentParser( |
| 143 | description="Merge slide_library.json + image_manifest.json into a per-slide beautify inventory.", |
| 144 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 145 | ) |
| 146 | parser.add_argument("slide_library", help="slide_library.json from `template_fill_pptx.py analyze`") |
| 147 | parser.add_argument("--images", help="image_manifest.json from `ppt_to_md.py` (optional)") |
| 148 | parser.add_argument("-o", "--output", help="Write JSON here (default: stdout)") |
| 149 | return parser |
| 150 | |
| 151 | |
| 152 | def main(argv: Optional[list[str]] = None) -> int: |
| 153 | args = build_parser().parse_args(argv) |
| 154 | |
| 155 | lib_path = Path(args.slide_library) |
| 156 | if not lib_path.is_file(): |
| 157 | print(f"[ERROR] slide_library not found: {lib_path}", file=sys.stderr) |
| 158 | return 1 |
| 159 | slide_library = json.loads(lib_path.read_text(encoding="utf-8")) |
| 160 | |
| 161 | manifest: list = [] |
| 162 | if args.images: |
| 163 | img_path = Path(args.images) |
| 164 | if not img_path.is_file(): |
| 165 | print(f"[ERROR] image manifest not found: {img_path}", file=sys.stderr) |
| 166 | return 1 |
| 167 | manifest = json.loads(img_path.read_text(encoding="utf-8")) |
| 168 | |
| 169 | inventory = build_inventory(slide_library, _images_by_slide(manifest)) |
| 170 | |
| 171 | payload = json.dumps(inventory, ensure_ascii=False, indent=2) |
| 172 | if args.output: |
| 173 | out = Path(args.output) |
| 174 | out.parent.mkdir(parents=True, exist_ok=True) |
| 175 | out.write_text(payload + "\n", encoding="utf-8") |
| 176 | print(f"[OK] inventory written to: {out}", file=sys.stderr) |
| 177 | else: |
| 178 | print(payload) |
| 179 | return 0 |
| 180 | |
| 181 | |
| 182 | if __name__ == "__main__": |
| 183 | raise SystemExit(main()) |
| 184 |