#!/usr/bin/env python3 """ PPT Master - Beautify Inventory Builder Mechanically merge a source deck's extracts into one per-slide ledger for the beautify-pptx profile: text blocks + tables + charts + SmartArt structure (from a `template_fill_pptx.py analyze` slide_library.json) joined with the images bound to each slide (from a `ppt_to_md.py` image_manifest.json). The deterministic join only — `ignored` and `needs_confirmation` are emitted empty for the agent to fill with judgment (hidden shapes, combo charts, overcrowded pages, ...). Usage: python3 scripts/beautify_inventory.py [--images ] [-o inventory.json] python3 scripts/beautify_inventory.py --summary python3 scripts/beautify_inventory.py --page N [--with-geometry] Examples: python3 scripts/beautify_inventory.py projects/x/analysis/.slide_library.json \ --images projects/x/images/image_manifest.json -o projects/x/analysis/beautify_inventory.json python3 scripts/beautify_inventory.py projects/x/analysis/beautify_inventory.json --summary python3 scripts/beautify_inventory.py projects/x/analysis/beautify_inventory.json --page 7 Dependencies: None (standard library only). See workflows/profiles/beautify-pptx.md Step 4 for how the inventory is consumed. """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any, Optional from console_encoding import configure_utf8_stdio configure_utf8_stdio() _GEOMETRY_KEYS = { "geometry", "display_ratio", "display_left_emu", "display_top_emu", "display_width_emu", "display_height_emu", } def _images_by_slide(manifest: list) -> dict[int, list[dict]]: """Map slide_index -> [image entries on that slide], from ppt_to_md occurrences.""" by_slide: dict[int, list[dict]] = {} for entry in manifest: filename = entry.get("filename") for occ in entry.get("occurrences", []): idx = occ.get("slide_index") if idx is None: continue by_slide.setdefault(idx, []).append({ "filename": filename, "shape_name": occ.get("shape_name"), "pixel_width": entry.get("pixel_width"), "pixel_height": entry.get("pixel_height"), "display_ratio": occ.get("display_ratio"), "display_left_emu": occ.get("display_left_emu"), "display_top_emu": occ.get("display_top_emu"), "display_width_emu": occ.get("display_width_emu"), "display_height_emu": occ.get("display_height_emu"), "usage_count": entry.get("usage_count"), }) return by_slide def _table_cells(table: dict) -> list[list[str]]: """Row-major 2D grid of cell text from a slide_library table.""" grid = [] for row in table.get("rows", []): grid.append([c.get("text", "") for c in row.get("cells", [])]) return grid def build_inventory(slide_library: dict, images_by_slide: dict[int, list[dict]]) -> dict: """Join slide_library slides with per-slide images into one ledger.""" slides_out = [] for slide in slide_library.get("slides", []): idx = slide.get("slide_index") text_blocks = [ { "slot_id": s.get("slot_id"), "role": s.get("role"), "text": s.get("text", ""), "paragraph_count": s.get("paragraph_count"), "geometry": s.get("geometry"), } for s in slide.get("slots", []) ] tables = [ { "table_id": t.get("table_id"), "row_count": t.get("row_count"), "column_count": t.get("column_count"), "cells": _table_cells(t), # 2D text grid — convenient for diff "rows": t.get("rows", []), # raw row/col cells — preserves merged / multi-header fidelity } for t in slide.get("tables", []) ] charts = [ { "chart_id": c.get("chart_id"), "chart_type": c.get("chart_type"), "category_count": c.get("category_count"), "series_count": c.get("series_count"), "categories": c.get("categories", []), # frozen data "series": c.get("series", []), # frozen data (name + values) } for c in slide.get("charts", []) ] diagrams = [ { "diagram_id": diagram.get("diagram_id"), "shape_name": diagram.get("shape_name"), "geometry": diagram.get("geometry"), "layout": diagram.get("layout", {}), "root_ids": diagram.get("root_ids", []), "nodes": diagram.get("nodes", []), "connections": diagram.get("connections", []), "status": diagram.get("status"), "warnings": diagram.get("warnings", []), } for diagram in slide.get("diagrams", []) ] slides_out.append({ "slide_index": idx, "page_type": slide.get("page_type"), "text_blocks": text_blocks, "tables": tables, "charts": charts, "diagrams": diagrams, "images": images_by_slide.get(idx, []), "ignored": [], # agent fills: hidden shapes, master-only text, image crop/rotation "needs_confirmation": [], # agent fills: combo/dual-axis charts, merged-cell tables, overcrowded pages }) return { "schema": "beautify_inventory.v1", "source": slide_library.get("source_pptx"), "slide_count": slide_library.get("slide_count", len(slides_out)), "canvas_px": slide_library.get("canvas_px"), "slides": slides_out, } def _view_payload(inventory: dict, view: str, slides: list[dict]) -> dict: """Wrap projected slides with the canonical deck-level facts.""" return { "schema": "beautify_inventory.view.v1", "inventory_schema": inventory.get("schema", "beautify_inventory.v1"), "view": view, "source": inventory.get("source"), "slide_count": inventory.get("slide_count", len(inventory.get("slides", []))), "canvas_px": inventory.get("canvas_px"), "slides": slides, } def _summary_view(inventory: dict) -> dict: """Project the whole roster into compact per-slide counts and review flags.""" slides = [] for slide in inventory.get("slides", []): slides.append({ "slide_index": slide.get("slide_index"), "page_type": slide.get("page_type"), "text_block_count": len(slide.get("text_blocks", [])), "table_count": len(slide.get("tables", [])), "chart_count": len(slide.get("charts", [])), "diagram_count": len(slide.get("diagrams", [])), "image_count": len(slide.get("images", [])), "ignored": slide.get("ignored", []), "needs_confirmation": slide.get("needs_confirmation", []), }) return _view_payload(inventory, "summary", slides) def _without_geometry(value: Any) -> Any: """Remove explicit source-layout geometry while preserving content and data.""" if isinstance(value, dict): return { key: _without_geometry(item) for key, item in value.items() if key not in _GEOMETRY_KEYS } if isinstance(value, list): return [_without_geometry(item) for item in value] return value def _page_view(inventory: dict, slide_index: int, with_geometry: bool) -> Optional[dict]: """Project one slide by its canonical slide_index.""" for slide in inventory.get("slides", []): if slide.get("slide_index") != slide_index: continue projected = slide if with_geometry else _without_geometry(slide) return _view_payload(inventory, "page", [projected]) return None def _positive_int(value: str) -> int: """Parse a positive slide index for argparse.""" parsed = int(value) if parsed < 1: raise argparse.ArgumentTypeError("page must be a positive integer") return parsed def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Build a beautify inventory from a slide library, or print a read-only " "model view from a canonical beautify inventory." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "input_json", help="slide_library.json in builder mode, or beautify_inventory.v1 JSON in a view mode", ) parser.add_argument( "--images", help="image_manifest.json for a slide_library input (optional)", ) parser.add_argument( "-o", "--output", help="Write the full built inventory here (builder mode only; default: stdout)", ) view_group = parser.add_mutually_exclusive_group() view_group.add_argument( "--summary", action="store_true", help="Print deck facts, per-slide object counts, and review flags to stdout", ) view_group.add_argument( "--page", type=_positive_int, metavar="N", help="Print one slide's frozen content/data by slide_index to stdout", ) parser.add_argument( "--with-geometry", action="store_true", help="Retain source-layout geometry in a --page view", ) return parser def main(argv: Optional[list[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) view_requested = args.summary or args.page is not None if view_requested and args.output: parser.error("--summary/--page write to stdout and cannot be combined with --output") if args.with_geometry and args.page is None: parser.error("--with-geometry requires --page") input_path = Path(args.input_json) if not input_path.is_file(): print(f"[ERROR] input JSON not found: {input_path}", file=sys.stderr) return 1 input_data = json.loads(input_path.read_text(encoding="utf-8")) input_schema = input_data.get("schema") if input_schema == "beautify_inventory.view.v1": parser.error( "beautify_inventory.view.v1 is a read-only stdout projection and " "cannot be used as input" ) is_inventory = input_schema == "beautify_inventory.v1" if is_inventory and not view_requested: parser.error( "beautify_inventory.v1 input requires --summary or --page; " "builder mode requires slide_library.json" ) if is_inventory and args.images: parser.error("--images cannot be combined with a beautify_inventory.v1 input") if view_requested and not is_inventory: parser.error("--summary/--page require a canonical beautify_inventory.v1 input") manifest: list = [] if args.images and not is_inventory: img_path = Path(args.images) if not img_path.is_file(): print(f"[ERROR] image manifest not found: {img_path}", file=sys.stderr) return 1 manifest = json.loads(img_path.read_text(encoding="utf-8")) inventory = input_data if is_inventory else build_inventory( input_data, _images_by_slide(manifest), ) if args.summary: print(json.dumps(_summary_view(inventory), ensure_ascii=False, indent=2)) return 0 if args.page is not None: page_view = _page_view(inventory, args.page, args.with_geometry) if page_view is None: available = ", ".join( str(slide.get("slide_index")) for slide in inventory.get("slides", []) ) print( f"[ERROR] slide_index {args.page} not found; available: {available or 'none'}", file=sys.stderr, ) return 1 print(json.dumps(page_view, ensure_ascii=False, indent=2)) return 0 payload = json.dumps(inventory, ensure_ascii=False, indent=2) if args.output: out = Path(args.output) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(payload + "\n", encoding="utf-8") print(f"[OK] inventory written to: {out}", file=sys.stderr) else: print(payload) return 0 if __name__ == "__main__": raise SystemExit(main())