返回 ppt-master
beautify_inventory.py
根目录 / skills / ppt-master / scripts / beautify_inventory.py
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
335 lines PYTHON