返回 ppt-master
pptx_intake.py
根目录 / skills / ppt-master / scripts / pptx_intake.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - PPTX Intake Enrichment
4
5 Extract reusable PPTX intake facts into a standard analysis bundle. This is a
6 read-only companion to `ppt_to_md.py`: Markdown remains the content source,
7 while this bundle provides canvas, visual identity, slide geometry, tables,
8 native chart data, and SmartArt structure for downstream workflows.
9
10 Usage:
11 python3 scripts/pptx_intake.py <source.pptx> -o <output_dir>
12
13 Examples:
14 python3 scripts/pptx_intake.py deck.pptx -o projects/demo/analysis
15
16 Dependencies:
17 None beyond the repository scripts used for PPTX parsing.
18 """
19
20 from __future__ import annotations
21
22 import argparse
23 from contextlib import contextmanager
24 import json
25 import os
26 from pathlib import Path
27 import sys
28 import tempfile
29 from typing import Any
30
31 try:
32 import fcntl
33 except ImportError: # pragma: no cover - Windows
34 fcntl = None
35
36 try:
37 import msvcrt
38 except ImportError: # pragma: no cover - POSIX
39 msvcrt = None
40
41 _SCRIPTS_DIR = Path(__file__).resolve().parent
42 if str(_SCRIPTS_DIR) not in sys.path:
43 sys.path.insert(0, str(_SCRIPTS_DIR))
44
45 from console_encoding import configure_utf8_stdio # noqa: E402
46 from beautify_identity import extract_identity # noqa: E402
47 from template_fill_pptx.analyzer import analyze_pptx # noqa: E402
48
49 configure_utf8_stdio()
50
51
52 def _write_json(path: Path, payload: dict[str, Any]) -> None:
53 path.parent.mkdir(parents=True, exist_ok=True)
54 fd, temp_name = tempfile.mkstemp(
55 prefix=f".{path.name}.",
56 suffix=".tmp",
57 dir=str(path.parent),
58 )
59 try:
60 with os.fdopen(fd, "w", encoding="utf-8") as handle:
61 json.dump(payload, handle, ensure_ascii=False, indent=2)
62 handle.write("\n")
63 os.replace(temp_name, path)
64 except Exception:
65 try:
66 os.unlink(temp_name)
67 except OSError:
68 pass
69 raise
70
71
72 def _chart_summary(slide_library: dict[str, Any]) -> dict[str, Any]:
73 charts: list[dict[str, Any]] = []
74 total_series = 0
75 multi_plot_count = 0
76 for slide in slide_library.get("slides", []):
77 for chart in slide.get("charts", []):
78 series_count = int(chart.get("series_count") or 0)
79 plot_types = chart.get("plot_types") or []
80 if len(plot_types) > 1:
81 multi_plot_count += 1
82 total_series += series_count
83 charts.append(
84 {
85 "slide_index": slide.get("slide_index"),
86 "chart_id": chart.get("chart_id"),
87 "chart_type": chart.get("chart_type"),
88 "plot_types": plot_types,
89 "category_count": chart.get("category_count", 0),
90 "series_count": series_count,
91 "series_names": [
92 series.get("name")
93 for series in chart.get("series", [])
94 if series.get("name")
95 ],
96 }
97 )
98 return {
99 "chart_count": len(charts),
100 "series_count": total_series,
101 "multi_plot_chart_count": multi_plot_count,
102 "charts": charts,
103 }
104
105
106 def _table_summary(slide_library: dict[str, Any]) -> dict[str, Any]:
107 tables: list[dict[str, Any]] = []
108 for slide in slide_library.get("slides", []):
109 for table in slide.get("tables", []):
110 tables.append(
111 {
112 "slide_index": slide.get("slide_index"),
113 "table_id": table.get("table_id"),
114 "row_count": table.get("row_count", 0),
115 "column_count": table.get("column_count", 0),
116 }
117 )
118 return {"table_count": len(tables), "tables": tables}
119
120
121 def _diagram_summary(slide_library: dict[str, Any]) -> dict[str, Any]:
122 diagrams: list[dict[str, Any]] = []
123 text_item_count = 0
124 unreadable_count = 0
125 warning_count = 0
126 slides_with_diagrams: set[int] = set()
127 for slide in slide_library.get("slides", []):
128 slide_index = slide.get("slide_index")
129 for diagram in slide.get("diagrams", []):
130 node_count = int(diagram.get("node_count") or 0)
131 text_count = int(diagram.get("text_count") or 0)
132 text_item_count += text_count
133 if not diagram.get("text_extracted"):
134 unreadable_count += 1
135 if diagram.get("status") != "ok" or diagram.get("warnings"):
136 warning_count += 1
137 if isinstance(slide_index, int):
138 slides_with_diagrams.add(slide_index)
139 diagrams.append(
140 {
141 "slide_index": slide_index,
142 "diagram_id": diagram.get("diagram_id"),
143 "layout": diagram.get("layout", {}),
144 "node_count": node_count,
145 "text_count": text_count,
146 "connection_count": int(diagram.get("connection_count") or 0),
147 "max_depth": int(diagram.get("max_depth") or 0),
148 "text_extracted": bool(diagram.get("text_extracted")),
149 "has_persisted_drawing": bool(diagram.get("has_persisted_drawing")),
150 "status": diagram.get("status"),
151 "warnings": diagram.get("warnings", []),
152 }
153 )
154 return {
155 "diagram_count": len(diagrams),
156 "text_item_count": text_item_count,
157 "unreadable_count": unreadable_count,
158 "warning_count": warning_count,
159 "slides_with_diagrams": sorted(slides_with_diagrams),
160 "diagrams": diagrams,
161 }
162
163
164 def build_source_profile(
165 pptx_path: Path,
166 identity: dict[str, Any],
167 slide_library: dict[str, Any],
168 stem: str | None = None,
169 ) -> dict[str, Any]:
170 """Build the Strategist-facing per-deck digest over the raw intake artifacts.
171
172 `stem` is the source-file stem used to prefix the per-deck artifact files so
173 several decks can coexist in one `analysis/` folder. Defaults to the pptx stem.
174 """
175 stem = stem or pptx_path.stem
176 return {
177 "schema": "pptx_intake_profile.v1",
178 "stem": stem,
179 "source_pptx": str(pptx_path),
180 "slide_count": slide_library.get("slide_count", identity.get("slide_count", 0)),
181 "usage_contract": {
182 "standard_generation": (
183 "Use identity and slide-library fields as source facts and recommendation "
184 "candidates only; do not preserve original page count, order, or coordinates "
185 "unless the user selected the beautify profile or Fill Native PPTX route."
186 ),
187 "beautify": (
188 "Promote source text, page order, page count, colors, fonts, and font sizes "
189 "into locked constraints after user confirmation."
190 ),
191 "template_fill": (
192 "Use slide slots, tables, charts, diagrams, and geometry as the native PPTX "
193 "fill contract; diagrams are inventory-only and remain unchanged."
194 ),
195 },
196 "artifacts": {
197 "identity": f"{stem}.identity.json",
198 "slide_library": f"{stem}.slide_library.json",
199 },
200 "canvas": identity.get("canvas", {}),
201 "identity": {
202 "theme_palette": (identity.get("theme") or {}).get("palette", {}),
203 "theme_fonts": (identity.get("theme") or {}).get("fonts", {}),
204 "theme_sizes": (identity.get("theme") or {}).get("sizes", {}),
205 "observed_colors": (identity.get("observed") or {}).get("colors", []),
206 "observed_fonts": (identity.get("observed") or {}).get("fonts", {}),
207 "observed_sizes_pt": (identity.get("observed") or {}).get("sizes_pt", []),
208 "layout_sizes_pt": identity.get("layout_sizes_pt", []),
209 },
210 "structure": {
211 "canvas_px": slide_library.get("canvas_px", {}),
212 "page_types": [
213 {
214 "slide_index": slide.get("slide_index"),
215 "page_type": slide.get("page_type"),
216 "slot_count": len(slide.get("slots", [])),
217 "diagram_count": len(slide.get("diagrams", [])),
218 }
219 for slide in slide_library.get("slides", [])
220 ],
221 },
222 "tables": _table_summary(slide_library),
223 "charts": _chart_summary(slide_library),
224 "diagrams": _diagram_summary(slide_library),
225 }
226
227
228 SOURCE_INDEX_NAME = "source_profile.json"
229
230
231 @contextmanager
232 def _source_index_lock(output_dir: Path):
233 """Serialize source-index bundle publication with a persistent lock file."""
234 output_dir.mkdir(parents=True, exist_ok=True)
235 lock_path = output_dir / f"{SOURCE_INDEX_NAME}.lock"
236 with lock_path.open("a+b") as lock_file:
237 if fcntl is not None:
238 fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
239 try:
240 yield
241 finally:
242 fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
243 return
244
245 if msvcrt is not None:
246 lock_file.seek(0, os.SEEK_END)
247 if lock_file.tell() == 0:
248 lock_file.write(b"\0")
249 lock_file.flush()
250 lock_file.seek(0)
251 msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
252 try:
253 yield
254 finally:
255 lock_file.seek(0)
256 msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
257 return
258
259 raise RuntimeError(
260 "Cannot safely update source_profile.json: no supported file-lock API"
261 )
262
263
264 def _load_source_index(index_path: Path) -> dict[str, Any]:
265 """Load and validate an existing multi-deck source index."""
266 if not index_path.exists():
267 return {}
268 if not index_path.is_file():
269 raise RuntimeError(f"Source index is not a file: {index_path}")
270
271 try:
272 loaded = json.loads(index_path.read_text(encoding="utf-8"))
273 except (json.JSONDecodeError, UnicodeError) as exc:
274 raise RuntimeError(
275 f"Source index contains invalid JSON and was left unchanged: {index_path}"
276 ) from exc
277 except OSError as exc:
278 raise RuntimeError(f"Cannot read source index: {index_path}: {exc}") from exc
279
280 if not isinstance(loaded, dict) or not isinstance(loaded.get("decks"), list):
281 raise RuntimeError(
282 f"Source index must be a JSON object with a decks array and was left unchanged: "
283 f"{index_path}"
284 )
285 for index, deck in enumerate(loaded["decks"]):
286 if not isinstance(deck, dict):
287 raise RuntimeError(
288 f"Source index decks[{index}] must be an object and was left unchanged: "
289 f"{index_path}"
290 )
291 return loaded
292
293
294 def _upsert_source_index_unlocked(
295 output_dir: Path,
296 digest: dict[str, Any],
297 ) -> Path:
298 index_path = output_dir / SOURCE_INDEX_NAME
299 index = _load_source_index(index_path)
300 stem = digest.get("stem")
301 decks = [d for d in index.get("decks", []) if d.get("stem") != stem]
302 decks.append(digest)
303 decks.sort(key=lambda d: str(d.get("stem", "")))
304 index = {
305 "schema": "pptx_intake_index.v1",
306 "deck_count": len(decks),
307 "decks": decks,
308 }
309 _write_json(index_path, index)
310 return index_path
311
312
313 def upsert_source_index(output_dir: Path, digest: dict[str, Any]) -> Path:
314 """Merge one deck digest into the serialized multi-deck source index.
315
316 The index stays the single must-read entry for the Strategist: it inlines every
317 deck's digest under `decks[]`, so a one-deck project is a one-entry index and a
318 multi-deck project lists each source deck self-containedly. Re-importing a deck
319 with the same stem replaces its entry in place.
320 """
321 with _source_index_lock(output_dir):
322 return _upsert_source_index_unlocked(output_dir, digest)
323
324
325 def run_intake(pptx_path: Path, output_dir: Path) -> dict[str, Path]:
326 """Write `<stem>.identity.json`, `<stem>.slide_library.json`, and merge the
327 deck's digest into the single multi-deck index `source_profile.json`."""
328 output_dir.mkdir(parents=True, exist_ok=True)
329 stem = pptx_path.stem
330 identity = extract_identity(pptx_path)
331 slide_library = analyze_pptx(pptx_path)
332 digest = build_source_profile(pptx_path, identity, slide_library, stem)
333
334 identity_path = output_dir / f"{stem}.identity.json"
335 slide_library_path = output_dir / f"{stem}.slide_library.json"
336 with _source_index_lock(output_dir):
337 _write_json(identity_path, identity)
338 _write_json(slide_library_path, slide_library)
339 profile_path = _upsert_source_index_unlocked(output_dir, digest)
340 return {
341 "identity": identity_path,
342 "slide_library": slide_library_path,
343 "source_profile": profile_path,
344 }
345
346
347 def build_parser() -> argparse.ArgumentParser:
348 parser = argparse.ArgumentParser(
349 description="Extract standard PPTX intake analysis artifacts.",
350 formatter_class=argparse.RawDescriptionHelpFormatter,
351 )
352 parser.add_argument("source", help="Source PPTX / PPTM / PPSX / PPSM / POTX / POTM file")
353 parser.add_argument("-o", "--output-dir", required=True, help="Output project analysis directory")
354 return parser
355
356
357 def main(argv: list[str] | None = None) -> int:
358 parser = build_parser()
359 args = parser.parse_args(argv)
360 source = Path(args.source).expanduser().resolve()
361 if not source.is_file():
362 print(f"Error: source not found: {source}", file=sys.stderr)
363 return 1
364 try:
365 outputs = run_intake(source, Path(args.output_dir).expanduser().resolve())
366 except (RuntimeError, KeyError, ValueError) as exc:
367 print(f"Error: PPTX intake failed: {exc}", file=sys.stderr)
368 return 1
369 print(f"PPTX intake -> {Path(args.output_dir).expanduser().resolve()}", file=sys.stderr)
370 for name, path in outputs.items():
371 print(f" {name}: {path}", file=sys.stderr)
372 return 0
373
374
375 if __name__ == "__main__":
376 raise SystemExit(main())
377
377 lines PYTHON