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.
- pptx_cli/__init__.py +5 -0
- pptx_cli/__main__.py +4 -0
- pptx_cli/cli.py +372 -0
- pptx_cli/commands/__init__.py +1 -0
- pptx_cli/commands/compose.py +73 -0
- pptx_cli/commands/guide.py +157 -0
- pptx_cli/commands/init.py +52 -0
- pptx_cli/commands/inspect.py +80 -0
- pptx_cli/commands/manifest_ops.py +23 -0
- pptx_cli/commands/validate.py +13 -0
- pptx_cli/commands/wrapper.py +191 -0
- pptx_cli/core/__init__.py +1 -0
- pptx_cli/core/composition.py +280 -0
- pptx_cli/core/ids.py +24 -0
- pptx_cli/core/io.py +60 -0
- pptx_cli/core/manifest_store.py +65 -0
- pptx_cli/core/runtime.py +30 -0
- pptx_cli/core/template.py +595 -0
- pptx_cli/core/validation.py +215 -0
- pptx_cli/core/versioning.py +47 -0
- pptx_cli/models/__init__.py +1 -0
- pptx_cli/models/envelope.py +50 -0
- pptx_cli/models/manifest.py +175 -0
- pptx_cli-1.0.0.dist-info/METADATA +505 -0
- pptx_cli-1.0.0.dist-info/RECORD +28 -0
- pptx_cli-1.0.0.dist-info/WHEEL +4 -0
- pptx_cli-1.0.0.dist-info/entry_points.txt +2 -0
- pptx_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
pptx_cli/core/runtime.py
ADDED
|
@@ -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)
|