pptx-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pptx_cli.core.io as io
6
+ import pptx_cli.models.manifest as manifest_models
7
+
8
+ MANIFEST_FILE = "manifest.yaml"
9
+ ANNOTATIONS_FILE = "annotations.yaml"
10
+ REPORT_FILE = "reports/init-report.json"
11
+ SCHEMA_FILE = "manifest.schema.json"
12
+ TEMPLATE_COPY_FILE = "assets/source-template.pptx"
13
+
14
+
15
+ def manifest_path(manifest_dir: Path) -> Path:
16
+ return manifest_dir / MANIFEST_FILE
17
+
18
+
19
+ def annotations_path(manifest_dir: Path) -> Path:
20
+ return manifest_dir / ANNOTATIONS_FILE
21
+
22
+
23
+ def report_path(manifest_dir: Path) -> Path:
24
+ return manifest_dir / REPORT_FILE
25
+
26
+
27
+ def schema_path(manifest_dir: Path) -> Path:
28
+ return manifest_dir / SCHEMA_FILE
29
+
30
+
31
+ def template_copy_path(manifest_dir: Path) -> Path:
32
+ return manifest_dir / TEMPLATE_COPY_FILE
33
+
34
+
35
+ def load_manifest(manifest_dir: Path) -> manifest_models.ManifestDocument:
36
+ payload = io.load_json_or_yaml(manifest_path(manifest_dir))
37
+ return manifest_models.ManifestDocument.model_validate(payload)
38
+
39
+
40
+ def load_annotations(manifest_dir: Path) -> manifest_models.AnnotationsDocument:
41
+ payload = io.load_json_or_yaml(annotations_path(manifest_dir))
42
+ return manifest_models.AnnotationsDocument.model_validate(payload)
43
+
44
+
45
+ def load_deck_spec(spec_path: Path) -> manifest_models.DeckSpec:
46
+ payload = io.load_json_or_yaml(spec_path)
47
+ return manifest_models.DeckSpec.model_validate(payload)
48
+
49
+
50
+ def write_manifest_package(
51
+ manifest_dir: Path,
52
+ manifest: manifest_models.ManifestDocument,
53
+ annotations: manifest_models.AnnotationsDocument,
54
+ init_report: dict[str, object],
55
+ ) -> None:
56
+ io.write_yaml(manifest_path(manifest_dir), manifest.model_dump(mode="json", exclude_none=True))
57
+ io.write_json(
58
+ schema_path(manifest_dir),
59
+ manifest_models.ManifestDocument.model_json_schema(),
60
+ )
61
+ io.write_yaml(
62
+ annotations_path(manifest_dir),
63
+ annotations.model_dump(mode="json", exclude_none=True),
64
+ )
65
+ io.write_json(report_path(manifest_dir), init_report)
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass(slots=True, frozen=True)
11
+ class RuntimeContext:
12
+ request_id: str
13
+ started_at: float
14
+ llm_mode: bool
15
+
16
+ @property
17
+ def duration_ms(self) -> int:
18
+ return int((time.perf_counter() - self.started_at) * 1000)
19
+
20
+
21
+ def build_runtime_context() -> RuntimeContext:
22
+ return RuntimeContext(
23
+ request_id=f"req_{uuid.uuid4().hex[:12]}",
24
+ started_at=time.perf_counter(),
25
+ llm_mode=os.getenv("LLM", "").lower() == "true",
26
+ )
27
+
28
+
29
+ def stdout_is_tty() -> bool:
30
+ return sys.stdout.isatty()
@@ -0,0 +1,595 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ import shutil
6
+ import zipfile
7
+ from collections import Counter
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import lxml.etree as etree
13
+ from pptx import Presentation
14
+ from pptx.enum.shapes import PP_PLACEHOLDER
15
+
16
+ from pptx_cli.core.ids import slugify, uniquify
17
+ from pptx_cli.core.io import atomic_write_bytes, ensure_directory
18
+ from pptx_cli.core.manifest_store import template_copy_path
19
+ from pptx_cli.models.manifest import (
20
+ AnnotationsDocument,
21
+ AssetRef,
22
+ CompatibilityFinding,
23
+ CompatibilityReport,
24
+ InitReport,
25
+ LayoutAnnotation,
26
+ LayoutContract,
27
+ ManifestDocument,
28
+ MasterContract,
29
+ PlaceholderContract,
30
+ ProtectedElement,
31
+ TemplateInfo,
32
+ ThemeModel,
33
+ )
34
+
35
+ _DRAWINGML_NS = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
36
+ _PLACEHOLDER_TYPE_NAMES = {item.value: item.name.lower() for item in PP_PLACEHOLDER}
37
+ _IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".tif", ".tiff"}
38
+ _MEDIA_SUFFIXES = {".mp4", ".wmv", ".avi", ".mov", ".mp3", ".wav", ".m4v"}
39
+ _NUMBER_WORDS = {
40
+ "one": 1,
41
+ "two": 2,
42
+ "three": 3,
43
+ "four": 4,
44
+ "five": 5,
45
+ "six": 6,
46
+ "seven": 7,
47
+ "eight": 8,
48
+ "nine": 9,
49
+ "ten": 10,
50
+ }
51
+
52
+
53
+ def sha256_bytes(payload: bytes) -> str:
54
+ return f"sha256:{hashlib.sha256(payload).hexdigest()}"
55
+
56
+
57
+ def sha256_file(path: Path) -> str:
58
+ return sha256_bytes(path.read_bytes())
59
+
60
+
61
+ def _placeholder_type_name(value: int) -> str:
62
+ return _PLACEHOLDER_TYPE_NAMES.get(value, f"placeholder_{value}")
63
+
64
+
65
+ def _supports_content_types(placeholder_type: str, shape_name: str) -> list[str]:
66
+ lower_name = shape_name.lower()
67
+ if "logo" in lower_name or "progress bar" in lower_name:
68
+ return []
69
+ if placeholder_type in {"pic", "bitmap", "media_clip", "org_chart", "clip_art"}:
70
+ return ["image"]
71
+ if placeholder_type in {"body", "obj", "content", "text"}:
72
+ return ["text", "markdown-text", "image", "table", "chart"]
73
+ return ["text", "markdown-text"]
74
+
75
+
76
+ def _logical_placeholder_name(source_name: str, placeholder_type: str, placeholder_idx: int) -> str:
77
+ lower_name = source_name.lower()
78
+ if "title" in lower_name and "subtitle" not in lower_name:
79
+ return "title"
80
+ if "subtitle" in lower_name:
81
+ return "subtitle"
82
+ if "date" in lower_name:
83
+ return "date"
84
+ if "source" in lower_name:
85
+ return "source"
86
+ if "description" in lower_name:
87
+ suffix = "".join(ch for ch in source_name if ch.isdigit())
88
+ return f"description_{suffix}" if suffix else "description"
89
+ if "picture" in lower_name:
90
+ return "picture"
91
+ if "content" in lower_name:
92
+ suffix = "".join(ch for ch in source_name if ch.isdigit())
93
+ return f"content_{suffix}" if suffix else "content"
94
+ return f"{slugify(placeholder_type)}_{placeholder_idx}"
95
+
96
+
97
+ def _shape_fingerprint(shape: Any) -> str:
98
+ element = getattr(shape, "_element", None)
99
+ xml = etree.tostring(element) if element is not None else shape.name.encode("utf-8")
100
+ return sha256_bytes(xml)
101
+
102
+
103
+ def _guidance_lines(shape: Any) -> list[str]:
104
+ raw_text = getattr(shape, "text", "")
105
+ normalized = raw_text.replace("\v", "\n")
106
+ return [line.strip() for line in normalized.splitlines() if line.strip()]
107
+
108
+
109
+ def _parse_max_lines(lines: list[str]) -> int | None:
110
+ for line in lines:
111
+ match = re.search(r"max\s+(?P<count>\d+|[A-Za-z]+)\s+lines?", line, re.IGNORECASE)
112
+ if match is None:
113
+ continue
114
+ count_value = match.group("count").lower()
115
+ if count_value.isdigit():
116
+ return int(count_value)
117
+ return _NUMBER_WORDS.get(count_value)
118
+ return None
119
+
120
+
121
+ def _parse_suggested_font_size(text: str) -> float | None:
122
+ match = re.search(r"(?P<size>\d+(?:\.\d+)?)\s*pt", text, re.IGNORECASE)
123
+ if match is None:
124
+ return None
125
+ return float(match.group("size"))
126
+
127
+
128
+ def _parse_suggested_font_family(text: str) -> str | None:
129
+ match = re.search(
130
+ r"font size\s+(?P<family>.+?)\s+\d+(?:\.\d+)?\s*pt",
131
+ text,
132
+ re.IGNORECASE,
133
+ )
134
+ if match is None:
135
+ return None
136
+ return match.group("family").strip()
137
+
138
+
139
+ def _extract_text_defaults(shape: Any) -> dict[str, Any]:
140
+ guidance_lines = _guidance_lines(shape)
141
+ guidance_text = "\n".join(guidance_lines)
142
+ text_frame = getattr(shape, "text_frame", None)
143
+ alignment = None
144
+ paragraph_count = 0
145
+ if text_frame is not None:
146
+ paragraph_count = len(text_frame.paragraphs)
147
+ if text_frame.paragraphs:
148
+ paragraph_alignment = text_frame.paragraphs[0].alignment
149
+ alignment = paragraph_alignment.name.lower() if paragraph_alignment else None
150
+
151
+ defaults = {
152
+ "guidance_text": guidance_text or None,
153
+ "guidance_lines": guidance_lines,
154
+ "max_lines": _parse_max_lines(guidance_lines),
155
+ "suggested_font_size_pt": _parse_suggested_font_size(guidance_text),
156
+ "suggested_font_family": _parse_suggested_font_family(guidance_text),
157
+ "paragraph_count": paragraph_count,
158
+ "alignment": alignment,
159
+ }
160
+ return {key: value for key, value in defaults.items() if value not in (None, [], "")}
161
+
162
+
163
+ def _extract_theme(zip_file: zipfile.ZipFile) -> ThemeModel:
164
+ theme_candidates = [
165
+ name
166
+ for name in zip_file.namelist()
167
+ if name.startswith("ppt/theme/") and name.endswith(".xml")
168
+ ]
169
+ if not theme_candidates:
170
+ return ThemeModel()
171
+
172
+ payload = zip_file.read(theme_candidates[0])
173
+ root = etree.fromstring(payload)
174
+ theme_name = root.get("name")
175
+ color_scheme = root.find(".//a:clrScheme", namespaces=_DRAWINGML_NS)
176
+ font_scheme = root.find(".//a:fontScheme", namespaces=_DRAWINGML_NS)
177
+
178
+ colors: dict[str, str] = {}
179
+ if color_scheme is not None:
180
+ for child in color_scheme:
181
+ child_value = None
182
+ if len(child):
183
+ inner = child[0]
184
+ child_value = inner.get("val") or inner.get("lastClr")
185
+ if child_value is not None:
186
+ colors[etree.QName(child).localname] = child_value
187
+
188
+ fonts: dict[str, str] = {}
189
+ if font_scheme is not None:
190
+ latin_nodes = font_scheme.xpath(".//a:latin", namespaces=_DRAWINGML_NS)
191
+ for index, node in enumerate(latin_nodes, start=1):
192
+ typeface = node.get("typeface")
193
+ if typeface:
194
+ fonts[f"latin_{index}"] = typeface
195
+
196
+ return ThemeModel(name=theme_name, colors=colors, fonts=fonts, effects={})
197
+
198
+
199
+ def _layout_description(layout_name: str) -> str | None:
200
+ lower_name = layout_name.lower()
201
+ if "front page" in lower_name:
202
+ return "Front-page style layout extracted from the source template."
203
+ if "breaker" in lower_name:
204
+ return "Section divider layout extracted from the source template."
205
+ if "agenda" in lower_name:
206
+ return "Agenda layout extracted from the source template."
207
+ if "title" in lower_name and "content" in lower_name:
208
+ return "Content layout extracted from the source template."
209
+ if "blank" in lower_name:
210
+ return "Minimal layout with locked brand elements only."
211
+ return None
212
+
213
+
214
+ def _is_protected_placeholder(shape_name: str) -> bool:
215
+ lower_name = shape_name.lower()
216
+ return "logo" in lower_name or "progress bar" in lower_name
217
+
218
+
219
+ def _build_layouts(
220
+ prs: Any,
221
+ ) -> tuple[list[MasterContract], list[LayoutContract], list[LayoutAnnotation]]:
222
+ master_ids: list[MasterContract] = []
223
+ layouts: list[LayoutContract] = []
224
+ annotations: list[LayoutAnnotation] = []
225
+ layout_ids_seen: set[str] = set()
226
+
227
+ master_id_map: dict[int, str] = {}
228
+ for master_index, master in enumerate(prs.slide_masters):
229
+ master_id = f"master-{master_index + 1}"
230
+ master_id_map[id(master)] = master_id
231
+ master_ids.append(
232
+ MasterContract(
233
+ id=master_id,
234
+ name=f"Slide Master {master_index + 1}",
235
+ layout_ids=[],
236
+ )
237
+ )
238
+
239
+ for layout_index, layout in enumerate(prs.slide_layouts):
240
+ layout_id = uniquify(slugify(layout.name), layout_ids_seen)
241
+ master_id = master_id_map.get(id(layout.slide_master), "master-1")
242
+ logical_names_seen: set[str] = set()
243
+ placeholders: list[PlaceholderContract] = []
244
+ protected_elements: list[ProtectedElement] = []
245
+
246
+ for shape in layout.shapes:
247
+ if getattr(shape, "is_placeholder", False):
248
+ placeholder_format = shape.placeholder_format
249
+ placeholder_type = _placeholder_type_name(int(placeholder_format.type))
250
+ if _is_protected_placeholder(shape.name):
251
+ protected_elements.append(
252
+ ProtectedElement(
253
+ element_id=f"{layout_id}-protected-{placeholder_format.idx}",
254
+ element_type=placeholder_type,
255
+ name=shape.name,
256
+ left_emu=int(shape.left),
257
+ top_emu=int(shape.top),
258
+ width_emu=int(shape.width),
259
+ height_emu=int(shape.height),
260
+ fingerprint=_shape_fingerprint(shape),
261
+ )
262
+ )
263
+ continue
264
+
265
+ logical_name = uniquify(
266
+ _logical_placeholder_name(shape.name, placeholder_type, placeholder_format.idx),
267
+ logical_names_seen,
268
+ )
269
+ supported_content_types = _supports_content_types(placeholder_type, shape.name)
270
+ placeholders.append(
271
+ PlaceholderContract(
272
+ logical_name=logical_name,
273
+ source_name=shape.name,
274
+ placeholder_idx=int(placeholder_format.idx),
275
+ placeholder_type=placeholder_type,
276
+ guidance_text=("\n".join(_guidance_lines(shape)) or None),
277
+ guidance_lines=_guidance_lines(shape),
278
+ supported_content_types=supported_content_types,
279
+ left_emu=int(shape.left),
280
+ top_emu=int(shape.top),
281
+ width_emu=int(shape.width),
282
+ height_emu=int(shape.height),
283
+ required=logical_name == "title",
284
+ text_defaults=_extract_text_defaults(shape),
285
+ inheritance_chain=[master_id, layout_id],
286
+ )
287
+ )
288
+ continue
289
+
290
+ protected_elements.append(
291
+ ProtectedElement(
292
+ element_id=f"{layout_id}-static-{len(protected_elements) + 1}",
293
+ element_type=str(getattr(shape, "shape_type", "shape")),
294
+ name=shape.name,
295
+ left_emu=int(shape.left),
296
+ top_emu=int(shape.top),
297
+ width_emu=int(shape.width),
298
+ height_emu=int(shape.height),
299
+ fingerprint=_shape_fingerprint(shape),
300
+ )
301
+ )
302
+
303
+ layouts.append(
304
+ LayoutContract(
305
+ id=layout_id,
306
+ name=layout.name,
307
+ source_master_id=master_id,
308
+ source_layout_index=layout_index,
309
+ source_layout_name=layout.name,
310
+ description=_layout_description(layout.name),
311
+ preview_path=f"previews/layouts/{layout_id}.png",
312
+ placeholders=placeholders,
313
+ protected_static_elements=protected_elements,
314
+ validation_rules={
315
+ "required_placeholders": [
316
+ item.logical_name for item in placeholders if item.required
317
+ ],
318
+ "protected_elements_locked": True,
319
+ },
320
+ )
321
+ )
322
+ annotations.append(LayoutAnnotation(layout_id=layout_id))
323
+
324
+ for layout in layouts:
325
+ for master in master_ids:
326
+ if layout.source_master_id == master.id:
327
+ master.layout_ids.append(layout.id)
328
+ break
329
+
330
+ return master_ids, layouts, annotations
331
+
332
+
333
+ def _copy_template_and_assets(template: Path, output_dir: Path) -> list[AssetRef]:
334
+ assets: list[AssetRef] = []
335
+ template_destination = template_copy_path(output_dir)
336
+ ensure_directory(template_destination.parent)
337
+ atomic_write_bytes(template_destination, template.read_bytes())
338
+ assets.append(
339
+ AssetRef(
340
+ id="source-template",
341
+ kind="template",
342
+ path=str(template_destination.relative_to(output_dir)),
343
+ source_path=str(template),
344
+ sha256=sha256_file(template),
345
+ size_bytes=template.stat().st_size,
346
+ )
347
+ )
348
+
349
+ with zipfile.ZipFile(template) as zip_file:
350
+ for part_name in zip_file.namelist():
351
+ if part_name.endswith("/"):
352
+ continue
353
+ payload = zip_file.read(part_name)
354
+ relative_target: Path | None = None
355
+ kind: str | None = None
356
+ if part_name.startswith("ppt/media/"):
357
+ suffix = Path(part_name).suffix.lower()
358
+ kind = "media" if suffix in _MEDIA_SUFFIXES else "image"
359
+ asset_dir = "media" if kind == "media" else "images"
360
+ relative_target = Path("assets") / asset_dir / Path(part_name).name
361
+ elif part_name.startswith("ppt/embeddings/"):
362
+ kind = "embedded"
363
+ relative_target = Path("assets") / "embedded" / Path(part_name).name
364
+ elif part_name.startswith("ppt/theme/"):
365
+ kind = "theme"
366
+ relative_target = Path("assets") / "theme" / Path(part_name).name
367
+
368
+ if relative_target is None or kind is None:
369
+ continue
370
+
371
+ destination = output_dir / relative_target
372
+ ensure_directory(destination.parent)
373
+ atomic_write_bytes(destination, payload)
374
+ assets.append(
375
+ AssetRef(
376
+ id=slugify(Path(part_name).stem),
377
+ kind=kind, # type: ignore[arg-type]
378
+ path=str(relative_target).replace("\\", "/"),
379
+ source_path=part_name,
380
+ sha256=sha256_bytes(payload),
381
+ size_bytes=len(payload),
382
+ )
383
+ )
384
+
385
+ return assets
386
+
387
+
388
+ def _compatibility_findings(template: Path) -> list[CompatibilityFinding]:
389
+ findings: list[CompatibilityFinding] = []
390
+ with zipfile.ZipFile(template) as zip_file:
391
+ names = zip_file.namelist()
392
+ if any(name.startswith("ppt/embeddings/") for name in names):
393
+ findings.append(
394
+ CompatibilityFinding(
395
+ code="WARN_UNSUPPORTED_EMBEDDINGS",
396
+ severity="warning",
397
+ message=(
398
+ "Embedded OLE objects were found; advanced embedded-object "
399
+ "fidelity is best-effort in v1."
400
+ ),
401
+ )
402
+ )
403
+ if any(Path(name).suffix.lower() in _MEDIA_SUFFIXES for name in names):
404
+ findings.append(
405
+ CompatibilityFinding(
406
+ code="WARN_UNSUPPORTED_MEDIA",
407
+ severity="warning",
408
+ message=(
409
+ "Audio or video media were found; advanced media fidelity "
410
+ "is out of scope for v1."
411
+ ),
412
+ )
413
+ )
414
+
415
+ xml_tag_counts = Counter[str]()
416
+ for name in names:
417
+ if not name.endswith(".xml"):
418
+ continue
419
+ payload = zip_file.read(name)
420
+ if b"<p:transition" in payload:
421
+ xml_tag_counts["transition"] += 1
422
+ if b"<p:anim" in payload or b"<p:animClr" in payload:
423
+ xml_tag_counts["animation"] += 1
424
+
425
+ if xml_tag_counts["transition"]:
426
+ findings.append(
427
+ CompatibilityFinding(
428
+ code="WARN_UNSUPPORTED_TRANSITIONS",
429
+ severity="warning",
430
+ message=(
431
+ "Slide transitions were detected; transitions are not "
432
+ "preserved by the v1 fidelity contract."
433
+ ),
434
+ details={"count": xml_tag_counts["transition"]},
435
+ )
436
+ )
437
+ if xml_tag_counts["animation"]:
438
+ findings.append(
439
+ CompatibilityFinding(
440
+ code="WARN_UNSUPPORTED_ANIMATIONS",
441
+ severity="warning",
442
+ message=(
443
+ "Animations were detected; animations are not preserved by "
444
+ "the v1 fidelity contract."
445
+ ),
446
+ details={"count": xml_tag_counts["animation"]},
447
+ )
448
+ )
449
+
450
+ if not findings:
451
+ findings.append(
452
+ CompatibilityFinding(
453
+ code="INFO_TEMPLATE_ANALYZED",
454
+ severity="info",
455
+ message=(
456
+ "Template analysis completed with no known unsupported constructs detected."
457
+ ),
458
+ )
459
+ )
460
+ return findings
461
+
462
+
463
+ def _part_fingerprints(template: Path) -> dict[str, str]:
464
+ with zipfile.ZipFile(template) as zip_file:
465
+ interesting = [
466
+ name
467
+ for name in zip_file.namelist()
468
+ if name == "ppt/presentation.xml"
469
+ or name.startswith("ppt/theme/")
470
+ or name.startswith("ppt/slideMasters/")
471
+ or name.startswith("ppt/slideLayouts/")
472
+ ]
473
+ return {name: sha256_bytes(zip_file.read(name)) for name in sorted(interesting)}
474
+
475
+
476
+ def _presentation_metadata(prs: Any, theme: ThemeModel) -> dict[str, Any]:
477
+ slide_count = len(prs.slides)
478
+ return {
479
+ "page_size": {
480
+ "width_emu": int(prs.slide_width),
481
+ "height_emu": int(prs.slide_height),
482
+ },
483
+ "slide_count": slide_count,
484
+ "theme": theme.model_dump(mode="json", exclude_none=True),
485
+ }
486
+
487
+
488
+ def build_manifest_package(
489
+ template: Path,
490
+ output_dir: Path,
491
+ ) -> tuple[ManifestDocument, AnnotationsDocument, InitReport]:
492
+ prs = Presentation(str(template))
493
+ with zipfile.ZipFile(template) as zip_file:
494
+ theme = _extract_theme(zip_file)
495
+ masters, layouts, annotations = _build_layouts(prs)
496
+ assets = _copy_template_and_assets(template, output_dir)
497
+ findings = _compatibility_findings(template)
498
+ has_errors = any(item.severity == "error" for item in findings)
499
+ has_warnings = any(item.severity == "warning" for item in findings)
500
+ if has_errors:
501
+ compatibility_status = "error"
502
+ elif has_warnings:
503
+ compatibility_status = "warn"
504
+ else:
505
+ compatibility_status = "ok"
506
+ manifest = ManifestDocument(
507
+ template=TemplateInfo(
508
+ name=template.stem,
509
+ source_file=template.name,
510
+ source_hash=sha256_file(template),
511
+ extracted_at=datetime.now(UTC),
512
+ stored_template_path="assets/source-template.pptx",
513
+ ),
514
+ presentation=_presentation_metadata(prs, theme),
515
+ masters=masters,
516
+ layouts=layouts,
517
+ assets=assets,
518
+ rules={
519
+ "default_policy_mode": "warn",
520
+ "strict_supported": True,
521
+ "supported_placeholder_content_types": [
522
+ "text",
523
+ "image",
524
+ "table",
525
+ "chart",
526
+ "markdown-text",
527
+ ],
528
+ "safe_writes": True,
529
+ },
530
+ capabilities={
531
+ "preview_rendering": False,
532
+ "wrapper_generation": True,
533
+ "deck_build": True,
534
+ "slide_create": True,
535
+ "validation": True,
536
+ "manifest_diff": True,
537
+ },
538
+ compatibility_report=CompatibilityReport(status=compatibility_status, findings=findings),
539
+ fingerprints=_part_fingerprints(template),
540
+ )
541
+ annotations_document = AnnotationsDocument(layouts=annotations)
542
+ placeholder_count = sum(len(layout.placeholders) for layout in layouts)
543
+ init_report = InitReport(
544
+ template=str(template),
545
+ output_dir=str(output_dir),
546
+ manifest_path=str(output_dir / "manifest.yaml"),
547
+ findings=findings,
548
+ assets_copied=len(assets),
549
+ layout_count=len(layouts),
550
+ placeholder_count=placeholder_count,
551
+ )
552
+ return manifest, annotations_document, init_report
553
+
554
+
555
+ def plan_manifest_writes(template: Path, output_dir: Path) -> list[dict[str, str]]:
556
+ targets = [
557
+ output_dir / "manifest.yaml",
558
+ output_dir / "manifest.schema.json",
559
+ output_dir / "annotations.yaml",
560
+ output_dir / "reports/init-report.json",
561
+ output_dir / "assets/source-template.pptx",
562
+ output_dir / "previews/layouts/.keep",
563
+ output_dir / "fingerprints/parts.json",
564
+ ]
565
+ changes: list[dict[str, str]] = []
566
+ for target in targets:
567
+ operation = "replace" if target.exists() else "create"
568
+ changes.append({"target": str(target), "operation": operation})
569
+ return changes
570
+
571
+
572
+ def ensure_manifest_directories(output_dir: Path) -> None:
573
+ for relative_path in [
574
+ Path("assets/images"),
575
+ Path("assets/media"),
576
+ Path("assets/embedded"),
577
+ Path("assets/theme"),
578
+ Path("previews/layouts"),
579
+ Path("fingerprints"),
580
+ Path("reports"),
581
+ ]:
582
+ ensure_directory(output_dir / relative_path)
583
+
584
+
585
+ def write_fingerprints(output_dir: Path, manifest: ManifestDocument) -> None:
586
+ from pptx_cli.core.io import write_json
587
+
588
+ write_json(output_dir / "fingerprints/parts.json", manifest.fingerprints)
589
+ keep_file = output_dir / "previews/layouts/.keep"
590
+ if not keep_file.exists():
591
+ keep_file.write_text("preview rendering deferred in v1\n", encoding="utf-8")
592
+
593
+
594
+ def copy_output_tree(source: Path, destination: Path) -> None:
595
+ shutil.copytree(source, destination, dirs_exist_ok=True)