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,80 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pptx_cli.core.manifest_store import load_annotations, load_manifest
7
+ from pptx_cli.core.validation import ValidationError
8
+
9
+
10
+ def doctor(manifest_dir: Path) -> dict[str, Any]:
11
+ manifest = load_manifest(manifest_dir)
12
+ report = manifest.compatibility_report.model_dump(mode="json")
13
+ report["manifest"] = str(manifest_dir)
14
+ return report
15
+
16
+
17
+ def list_layouts(manifest_dir: Path) -> dict[str, Any]:
18
+ manifest = load_manifest(manifest_dir)
19
+ annotations = load_annotations(manifest_dir)
20
+ annotations_by_layout = {item.layout_id: item for item in annotations.layouts}
21
+ return {
22
+ "manifest": str(manifest_dir),
23
+ "count": len(manifest.layouts),
24
+ "layouts": [
25
+ {
26
+ "id": layout.id,
27
+ "name": layout.name,
28
+ "aliases": (
29
+ annotations_by_layout[layout.id].aliases
30
+ if layout.id in annotations_by_layout
31
+ else []
32
+ ),
33
+ "description": layout.description,
34
+ "preview_path": layout.preview_path,
35
+ "placeholder_count": len(layout.placeholders),
36
+ "source_layout_index": layout.source_layout_index,
37
+ }
38
+ for layout in manifest.layouts
39
+ ],
40
+ }
41
+
42
+
43
+ def show_layout(manifest_dir: Path, layout_id: str) -> dict[str, Any]:
44
+ manifest = load_manifest(manifest_dir)
45
+ layout = next(
46
+ (item for item in manifest.layouts if item.id == layout_id or item.name == layout_id),
47
+ None,
48
+ )
49
+ if layout is None:
50
+ raise ValidationError("ERR_VALIDATION_LAYOUT_UNKNOWN", f"Unknown layout: {layout_id}")
51
+ return layout.model_dump(mode="json")
52
+
53
+
54
+ def list_placeholders(manifest_dir: Path, layout_id: str) -> dict[str, Any]:
55
+ manifest = load_manifest(manifest_dir)
56
+ layout = next(
57
+ (item for item in manifest.layouts if item.id == layout_id or item.name == layout_id),
58
+ None,
59
+ )
60
+ if layout is None:
61
+ raise ValidationError("ERR_VALIDATION_LAYOUT_UNKNOWN", f"Unknown layout: {layout_id}")
62
+ return {
63
+ "layout_id": layout.id,
64
+ "layout_name": layout.name,
65
+ "placeholders": [item.model_dump(mode="json") for item in layout.placeholders],
66
+ }
67
+
68
+
69
+ def show_theme(manifest_dir: Path) -> dict[str, Any]:
70
+ manifest = load_manifest(manifest_dir)
71
+ return manifest.presentation.get("theme", {})
72
+
73
+
74
+ def list_assets(manifest_dir: Path) -> dict[str, Any]:
75
+ manifest = load_manifest(manifest_dir)
76
+ return {
77
+ "manifest": str(manifest_dir),
78
+ "count": len(manifest.assets),
79
+ "assets": [asset.model_dump(mode="json") for asset in manifest.assets],
80
+ }
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pptx_cli.core.manifest_store import load_manifest
7
+ from pptx_cli.core.validation import diff_manifests
8
+ from pptx_cli.models.manifest import ManifestDocument
9
+
10
+
11
+ def manifest_diff(left_dir: Path, right_dir: Path) -> dict[str, Any]:
12
+ left = load_manifest(left_dir)
13
+ right = load_manifest(right_dir)
14
+ diff = diff_manifests(left, right)
15
+ return {
16
+ "left": str(left_dir),
17
+ "right": str(right_dir),
18
+ **diff.model_dump(mode="json"),
19
+ }
20
+
21
+
22
+ def manifest_schema() -> dict[str, Any]:
23
+ return ManifestDocument.model_json_schema()
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pptx_cli.core.manifest_store import load_manifest
7
+ from pptx_cli.core.validation import validate_deck
8
+
9
+
10
+ def validate_command(manifest_dir: Path, deck_path: Path, *, strict: bool) -> dict[str, Any]:
11
+ manifest = load_manifest(manifest_dir)
12
+ result = validate_deck(manifest_dir, manifest, deck_path, strict=strict)
13
+ return result.model_dump(mode="json")
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pptx_cli.core.ids import slugify
7
+ from pptx_cli.core.io import atomic_write_text, ensure_directory
8
+ from pptx_cli.core.manifest_store import load_manifest
9
+
10
+
11
+ def wrapper_generate(manifest_dir: Path, output_dir: Path, *, dry_run: bool) -> dict[str, Any]:
12
+ manifest = load_manifest(manifest_dir)
13
+ package_name = f"{slugify(manifest.template.name).replace('-', '_')}_wrapper"
14
+ cli_name = f"{slugify(manifest.template.name)}-pptx"
15
+ files = {
16
+ output_dir / "pyproject.toml": _pyproject(package_name, cli_name),
17
+ output_dir / "README.md": _readme(cli_name),
18
+ output_dir
19
+ / "src"
20
+ / package_name
21
+ / "__init__.py": '"""Generated wrapper for pptx-cli."""\n',
22
+ output_dir / "src" / package_name / "cli.py": _cli_module(package_name),
23
+ }
24
+ changes = [
25
+ {
26
+ "target": str(path),
27
+ "operation": "replace" if path.exists() else "create",
28
+ "artifact_type": "python-source" if path.suffix == ".py" else "project-file",
29
+ }
30
+ for path in files
31
+ ]
32
+ if not dry_run:
33
+ for path, content in files.items():
34
+ ensure_directory(path.parent)
35
+ atomic_write_text(path, content)
36
+ return {
37
+ "dry_run": dry_run,
38
+ "manifest": str(manifest_dir),
39
+ "out": str(output_dir),
40
+ "package_name": package_name,
41
+ "cli_name": cli_name,
42
+ "changes": changes,
43
+ "summary": {"artifacts": len(files)},
44
+ }
45
+
46
+
47
+ def _pyproject(package_name: str, cli_name: str) -> str:
48
+ distribution_name = package_name.replace("_", "-")
49
+ return (
50
+ "[build-system]\n"
51
+ 'requires = ["hatchling>=1.27.0"]\n'
52
+ 'build-backend = "hatchling.build"\n\n'
53
+ "[project]\n"
54
+ f'name = "{distribution_name}"\n'
55
+ 'version = "0.1.0"\n'
56
+ 'description = "Generated wrapper for pptx-cli"\n'
57
+ 'requires-python = ">=3.12"\n'
58
+ 'dependencies = ["pptx-cli>=0.1.0", "typer>=0.15.1"]\n\n'
59
+ "[project.scripts]\n"
60
+ f'{cli_name} = "{package_name}.cli:main"\n\n'
61
+ "[tool.hatch.build.targets.wheel]\n"
62
+ f'packages = ["src/{package_name}"]\n'
63
+ )
64
+
65
+
66
+ def _readme(cli_name: str) -> str:
67
+ return (
68
+ f"# {cli_name}\n\n"
69
+ "Generated template wrapper for `pptx-cli`.\n\n"
70
+ "This wrapper delegates to the shared engine and expects a colocated manifest "
71
+ "package or a manually supplied path.\n"
72
+ )
73
+
74
+
75
+ def _cli_module(package_name: str) -> str:
76
+ return (
77
+ "from __future__ import annotations\n\n"
78
+ "from pathlib import Path\n"
79
+ "from typing import Annotated\n\n"
80
+ "import typer\n\n"
81
+ "from pptx_cli.cli import emit_json\n"
82
+ "from pptx_cli.commands.compose import deck_build, slide_create\n"
83
+ "from pptx_cli.commands.inspect import list_layouts\n"
84
+ "from pptx_cli.commands.validate import validate_command\n"
85
+ "from pptx_cli.core.runtime import build_runtime_context\n"
86
+ "from pptx_cli.models.envelope import Envelope, Metrics\n\n"
87
+ 'app = typer.Typer(help="Generated wrapper CLI for a specific manifest package.")\n\n'
88
+ "\n"
89
+ "def _default_manifest() -> Path:\n"
90
+ ' return Path(__file__).resolve().parents[2] / "manifest"\n\n'
91
+ "\n"
92
+ '@app.command("guide")\n'
93
+ "def guide() -> None:\n"
94
+ " runtime = build_runtime_context()\n"
95
+ " envelope = Envelope(\n"
96
+ " request_id=runtime.request_id,\n"
97
+ " ok=True,\n"
98
+ ' command="guide.show",\n'
99
+ " result={\n"
100
+ ' "wrapper": True,\n'
101
+ ' "commands": [\n'
102
+ ' "layouts list",\n'
103
+ ' "slide create",\n'
104
+ ' "deck build",\n'
105
+ ' "validate",\n'
106
+ " ],\n"
107
+ ' "manifest": str(_default_manifest()),\n'
108
+ " },\n"
109
+ " warnings=[],\n"
110
+ " errors=[],\n"
111
+ " metrics=Metrics(duration_ms=runtime.duration_ms),\n"
112
+ " )\n"
113
+ " emit_json(envelope)\n\n"
114
+ "\n"
115
+ '@app.command("layouts-list")\n'
116
+ "def layouts_list(\n"
117
+ ' manifest: Annotated[Path, typer.Option("--manifest")] = _default_manifest(),\n'
118
+ ") -> None:\n"
119
+ " runtime = build_runtime_context()\n"
120
+ " result = list_layouts(manifest)\n"
121
+ " envelope = Envelope(\n"
122
+ " request_id=runtime.request_id,\n"
123
+ " ok=True,\n"
124
+ ' command="layouts.list",\n'
125
+ " result=result,\n"
126
+ " warnings=[],\n"
127
+ " errors=[],\n"
128
+ " metrics=Metrics(duration_ms=runtime.duration_ms),\n"
129
+ " )\n"
130
+ " emit_json(envelope)\n\n"
131
+ "\n"
132
+ '@app.command("slide-create")\n'
133
+ "def slide_create_command(\n"
134
+ ' layout: Annotated[str, typer.Option("--layout")],\n'
135
+ ' out: Annotated[Path, typer.Option("--out")],\n'
136
+ ' set_values: Annotated[list[str] | None, typer.Option("--set")] = None,\n'
137
+ ' manifest: Annotated[Path, typer.Option("--manifest")] = _default_manifest(),\n'
138
+ ") -> None:\n"
139
+ " runtime = build_runtime_context()\n"
140
+ " result = slide_create(manifest, layout, list(set_values or []), out, dry_run=False)\n"
141
+ " envelope = Envelope(\n"
142
+ " request_id=runtime.request_id,\n"
143
+ " ok=True,\n"
144
+ ' command="slide.create",\n'
145
+ " result=result,\n"
146
+ " warnings=[],\n"
147
+ " errors=[],\n"
148
+ " metrics=Metrics(duration_ms=runtime.duration_ms),\n"
149
+ " )\n"
150
+ " emit_json(envelope)\n\n"
151
+ "\n"
152
+ '@app.command("deck-build")\n'
153
+ "def deck_build_command(\n"
154
+ ' spec: Annotated[Path, typer.Option("--spec")],\n'
155
+ ' out: Annotated[Path, typer.Option("--out")],\n'
156
+ ' manifest: Annotated[Path, typer.Option("--manifest")] = _default_manifest(),\n'
157
+ ") -> None:\n"
158
+ " runtime = build_runtime_context()\n"
159
+ " result = deck_build(manifest, spec, out, dry_run=False)\n"
160
+ " envelope = Envelope(\n"
161
+ " request_id=runtime.request_id,\n"
162
+ " ok=True,\n"
163
+ ' command="deck.build",\n'
164
+ " result=result,\n"
165
+ " warnings=[],\n"
166
+ " errors=[],\n"
167
+ " metrics=Metrics(duration_ms=runtime.duration_ms),\n"
168
+ " )\n"
169
+ " emit_json(envelope)\n\n"
170
+ "\n"
171
+ '@app.command("validate")\n'
172
+ "def validate_command_wrapper(\n"
173
+ ' deck: Annotated[Path, typer.Option("--deck")],\n'
174
+ ' manifest: Annotated[Path, typer.Option("--manifest")] = _default_manifest(),\n'
175
+ ") -> None:\n"
176
+ " runtime = build_runtime_context()\n"
177
+ " result = validate_command(manifest, deck, strict=False)\n"
178
+ " envelope = Envelope(\n"
179
+ " request_id=runtime.request_id,\n"
180
+ ' ok=result["ok"],\n'
181
+ ' command="validate.run",\n'
182
+ " result=result,\n"
183
+ " warnings=[],\n"
184
+ " errors=[],\n"
185
+ " metrics=Metrics(duration_ms=runtime.duration_ms),\n"
186
+ " )\n"
187
+ " emit_json(envelope)\n\n"
188
+ "\n"
189
+ "def main() -> None:\n"
190
+ " app()\n"
191
+ )
@@ -0,0 +1 @@
1
+ """Core runtime helpers."""
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pptx import Presentation
10
+ from pptx.chart.data import ChartData
11
+ from pptx.enum.chart import XL_CHART_TYPE
12
+
13
+ from pptx_cli.core.io import load_json_or_yaml
14
+ from pptx_cli.core.manifest_store import template_copy_path
15
+ from pptx_cli.models.manifest import DeckSpec, LayoutContract, ManifestDocument, SlideSpec
16
+
17
+
18
+ class CompositionError(ValueError):
19
+ code: str
20
+
21
+ def __init__(self, code: str, message: str) -> None:
22
+ super().__init__(message)
23
+ self.code = code
24
+
25
+
26
+ def resolve_layout(manifest: ManifestDocument, layout_id: str) -> LayoutContract:
27
+ for layout in manifest.layouts:
28
+ if layout.id == layout_id or layout.name == layout_id or layout_id in layout.aliases:
29
+ return layout
30
+ raise CompositionError("ERR_VALIDATION_LAYOUT_UNKNOWN", f"Unknown layout: {layout_id}")
31
+
32
+
33
+ def parse_set_arguments(items: list[str]) -> dict[str, Any]:
34
+ parsed: dict[str, Any] = {}
35
+ for item in items:
36
+ if "=" not in item:
37
+ raise CompositionError("ERR_VALIDATION_SET_FORMAT", f"Invalid --set entry: {item}")
38
+ key, raw_value = item.split("=", 1)
39
+ parsed[key] = _load_inline_or_file_value(raw_value)
40
+ return parsed
41
+
42
+
43
+ def _load_inline_or_file_value(raw_value: str) -> Any:
44
+ if raw_value.startswith("@"):
45
+ path = Path(raw_value[1:])
46
+ if not path.exists():
47
+ raise CompositionError("ERR_IO_NOT_FOUND", f"Referenced content file not found: {path}")
48
+ if path.suffix.lower() in {".json", ".yaml", ".yml"}:
49
+ return load_json_or_yaml(path)
50
+ if path.suffix.lower() in {".md", ".txt"}:
51
+ kind = "markdown-text" if path.suffix.lower() == ".md" else "text"
52
+ return {
53
+ "kind": kind,
54
+ "value": path.read_text(encoding="utf-8"),
55
+ }
56
+ return {"kind": "image", "path": str(path)}
57
+
58
+ if raw_value.startswith("{") or raw_value.startswith("["):
59
+ try:
60
+ return json.loads(raw_value)
61
+ except json.JSONDecodeError:
62
+ return raw_value
63
+ return raw_value
64
+
65
+
66
+ def create_single_slide_spec(layout: str, content: dict[str, Any]) -> DeckSpec:
67
+ return DeckSpec(slides=[SlideSpec(layout=layout, content=content)])
68
+
69
+
70
+ def build_presentation(manifest_dir: Path, manifest: ManifestDocument, spec: DeckSpec) -> Any:
71
+ template_path = manifest_dir / manifest.template.stored_template_path
72
+ if not template_path.exists():
73
+ template_path = template_copy_path(manifest_dir)
74
+ prs = Presentation(str(template_path))
75
+ _remove_all_slides(prs)
76
+
77
+ for slide_spec in spec.slides:
78
+ layout_contract = resolve_layout(manifest, slide_spec.layout)
79
+ slide_layout = prs.slide_layouts[layout_contract.source_layout_index]
80
+ slide = prs.slides.add_slide(slide_layout)
81
+ _populate_slide(slide, layout_contract, slide_spec.content)
82
+
83
+ _apply_deck_metadata(prs, spec.metadata)
84
+ return prs
85
+
86
+
87
+ def save_presentation(prs: Any, output_path: Path) -> None:
88
+ output_path.parent.mkdir(parents=True, exist_ok=True)
89
+ with tempfile.NamedTemporaryFile(
90
+ delete=False,
91
+ suffix=output_path.suffix,
92
+ dir=output_path.parent,
93
+ ) as handle:
94
+ temp_path = Path(handle.name)
95
+ try:
96
+ prs.save(str(temp_path))
97
+ os.replace(temp_path, output_path)
98
+ finally:
99
+ if temp_path.exists():
100
+ temp_path.unlink(missing_ok=True)
101
+
102
+
103
+ def _remove_all_slides(prs: Any) -> None:
104
+ slide_id_list = list(prs.slides._sldIdLst)
105
+ for slide_id in slide_id_list:
106
+ relationship_id = slide_id.rId
107
+ prs.part.drop_rel(relationship_id)
108
+ prs.slides._sldIdLst.remove(slide_id)
109
+
110
+
111
+ def _apply_deck_metadata(prs: Any, metadata: dict[str, Any]) -> None:
112
+ core_properties = prs.core_properties
113
+ title = metadata.get("title")
114
+ author = metadata.get("author")
115
+ if isinstance(title, str):
116
+ core_properties.title = title
117
+ if isinstance(author, str):
118
+ core_properties.author = author
119
+
120
+
121
+ def _populate_slide(slide: Any, layout: LayoutContract, content: dict[str, Any]) -> None:
122
+ expected = {placeholder.logical_name: placeholder for placeholder in layout.placeholders}
123
+ unknown_keys = sorted(set(content) - set(expected))
124
+ if unknown_keys:
125
+ raise CompositionError(
126
+ "ERR_VALIDATION_PLACEHOLDER_UNKNOWN",
127
+ f"Unknown placeholders for layout {layout.id}: {', '.join(unknown_keys)}",
128
+ )
129
+
130
+ missing_required = [
131
+ name
132
+ for name, placeholder in expected.items()
133
+ if placeholder.required and name not in content
134
+ ]
135
+ if missing_required:
136
+ raise CompositionError(
137
+ "ERR_VALIDATION_PLACEHOLDER_REQUIRED",
138
+ f"Missing required placeholders for layout {layout.id}: {', '.join(missing_required)}",
139
+ )
140
+
141
+ for key, value in content.items():
142
+ placeholder = expected[key]
143
+ shape = _find_slide_placeholder(slide, placeholder.placeholder_idx)
144
+ if shape is None:
145
+ raise CompositionError(
146
+ "ERR_INTERNAL_PLACEHOLDER_MISSING",
147
+ f"Placeholder {key} was not found on generated slide",
148
+ )
149
+ _apply_content_value(shape, placeholder.supported_content_types, value)
150
+
151
+
152
+ def _find_slide_placeholder(slide: Any, placeholder_idx: int) -> Any | None:
153
+ for shape in slide.placeholders:
154
+ if shape.placeholder_format.idx == placeholder_idx:
155
+ return shape
156
+ return None
157
+
158
+
159
+ def _apply_content_value(shape: Any, supported_types: list[str], value: Any) -> None:
160
+ content = _normalize_content_value(value)
161
+ kind = content["kind"]
162
+ if kind not in supported_types:
163
+ raise CompositionError(
164
+ "ERR_VALIDATION_CONTENT_TYPE",
165
+ f"Content type {kind!r} is not supported for this placeholder",
166
+ )
167
+
168
+ if kind in {"text", "markdown-text"}:
169
+ _apply_text(shape, str(content["value"]), markdown=kind == "markdown-text")
170
+ return
171
+ if kind == "image":
172
+ image_path = Path(str(content["path"]))
173
+ if not image_path.exists():
174
+ raise CompositionError("ERR_IO_NOT_FOUND", f"Image not found: {image_path}")
175
+ shape.insert_picture(str(image_path))
176
+ return
177
+ if kind == "table":
178
+ _apply_table(shape, content)
179
+ return
180
+ if kind == "chart":
181
+ _apply_chart(shape, content)
182
+ return
183
+
184
+ raise CompositionError("ERR_VALIDATION_CONTENT_TYPE", f"Unsupported content type: {kind}")
185
+
186
+
187
+ def _normalize_content_value(value: Any) -> dict[str, Any]:
188
+ if isinstance(value, dict) and "kind" in value:
189
+ return value
190
+ if isinstance(value, str):
191
+ return {"kind": "text", "value": value}
192
+ if isinstance(value, (int, float, bool)):
193
+ return {"kind": "text", "value": str(value)}
194
+ if isinstance(value, list):
195
+ return {"kind": "markdown-text", "value": "\n".join(f"- {item}" for item in value)}
196
+ raise CompositionError("ERR_VALIDATION_CONTENT_TYPE", f"Unsupported content payload: {value!r}")
197
+
198
+
199
+ def _apply_text(shape: Any, text: str, *, markdown: bool) -> None:
200
+ value = _markdown_to_text(text) if markdown else text
201
+ text_frame = shape.text_frame
202
+ text_frame.clear()
203
+ lines = value.splitlines() or [""]
204
+ first_paragraph = text_frame.paragraphs[0]
205
+ first_paragraph.text = lines[0]
206
+ for line in lines[1:]:
207
+ paragraph = text_frame.add_paragraph()
208
+ paragraph.text = line
209
+
210
+
211
+ def _markdown_to_text(markdown: str) -> str:
212
+ cleaned_lines: list[str] = []
213
+ for raw_line in markdown.splitlines():
214
+ line = raw_line.strip()
215
+ for prefix in ("# ", "## ", "### ", "- ", "* ", "> "):
216
+ if line.startswith(prefix):
217
+ line = line[len(prefix) :]
218
+ cleaned_lines.append(line.replace("**", "").replace("__", "").replace("`", ""))
219
+ return "\n".join(cleaned_lines).strip()
220
+
221
+
222
+ def _apply_table(shape: Any, content: dict[str, Any]) -> None:
223
+ columns = content.get("columns", [])
224
+ rows = content.get("rows", [])
225
+ if not isinstance(columns, list) or not isinstance(rows, list):
226
+ raise CompositionError(
227
+ "ERR_VALIDATION_TABLE_PAYLOAD",
228
+ "Table payload requires 'columns' and 'rows' lists",
229
+ )
230
+
231
+ row_count = len(rows) + (1 if columns else 0)
232
+ col_count = len(columns) if columns else (len(rows[0]) if rows else 0)
233
+ if row_count <= 0 or col_count <= 0:
234
+ raise CompositionError(
235
+ "ERR_VALIDATION_TABLE_PAYLOAD",
236
+ "Table payload must contain at least one row and one column",
237
+ )
238
+
239
+ graphic_frame = shape.insert_table(row_count, col_count)
240
+ table = graphic_frame.table
241
+ row_offset = 0
242
+ if columns:
243
+ for column_index, heading in enumerate(columns):
244
+ table.cell(0, column_index).text = str(heading)
245
+ row_offset = 1
246
+ for row_index, row in enumerate(rows, start=row_offset):
247
+ for column_index, cell in enumerate(row):
248
+ table.cell(row_index, column_index).text = str(cell)
249
+
250
+
251
+ def _apply_chart(shape: Any, content: dict[str, Any]) -> None:
252
+ chart_data = ChartData()
253
+ categories = content.get("categories", [])
254
+ series = content.get("series", [])
255
+ chart_type_name = str(content.get("chart_type", "column_clustered")).upper()
256
+ if not isinstance(categories, list) or not isinstance(series, list):
257
+ raise CompositionError(
258
+ "ERR_VALIDATION_CHART_PAYLOAD",
259
+ "Chart payload requires 'categories' and 'series' lists",
260
+ )
261
+
262
+ chart_data.categories = categories
263
+ for series_entry in series:
264
+ if not isinstance(series_entry, dict):
265
+ raise CompositionError(
266
+ "ERR_VALIDATION_CHART_PAYLOAD",
267
+ "Each chart series must be an object",
268
+ )
269
+ chart_data.add_series(
270
+ str(series_entry.get("name", "Series")),
271
+ series_entry.get("values", []),
272
+ )
273
+
274
+ if not hasattr(XL_CHART_TYPE, chart_type_name):
275
+ raise CompositionError(
276
+ "ERR_VALIDATION_CHART_PAYLOAD",
277
+ f"Unsupported chart_type: {chart_type_name.lower()}",
278
+ )
279
+ chart_type = getattr(XL_CHART_TYPE, chart_type_name)
280
+ shape.insert_chart(chart_type, chart_data)
pptx_cli/core/ids.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ _SLUG_RE = re.compile(r"[^a-z0-9]+")
6
+
7
+
8
+ def slugify(value: str) -> str:
9
+ normalized = _SLUG_RE.sub("-", value.strip().lower()).strip("-")
10
+ return normalized or "item"
11
+
12
+
13
+ def uniquify(candidate: str, existing: set[str]) -> str:
14
+ if candidate not in existing:
15
+ existing.add(candidate)
16
+ return candidate
17
+
18
+ index = 2
19
+ while f"{candidate}-{index}" in existing:
20
+ index += 1
21
+
22
+ unique_value = f"{candidate}-{index}"
23
+ existing.add(unique_value)
24
+ return unique_value
pptx_cli/core/io.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+
12
+ def ensure_directory(path: Path) -> None:
13
+ path.mkdir(parents=True, exist_ok=True)
14
+
15
+
16
+ def atomic_write_text(path: Path, content: str) -> None:
17
+ ensure_directory(path.parent)
18
+ temp_path: Path | None = None
19
+ try:
20
+ with tempfile.NamedTemporaryFile(
21
+ "w",
22
+ encoding="utf-8",
23
+ dir=path.parent,
24
+ delete=False,
25
+ ) as handle:
26
+ handle.write(content)
27
+ handle.flush()
28
+ os.fsync(handle.fileno())
29
+ temp_path = Path(handle.name)
30
+ os.replace(temp_path, path)
31
+ except BaseException:
32
+ if temp_path is not None and temp_path.exists():
33
+ temp_path.unlink(missing_ok=True)
34
+ raise
35
+
36
+
37
+ def atomic_write_bytes(path: Path, content: bytes) -> None:
38
+ ensure_directory(path.parent)
39
+ with tempfile.NamedTemporaryFile("wb", dir=path.parent, delete=False) as handle:
40
+ handle.write(content)
41
+ handle.flush()
42
+ os.fsync(handle.fileno())
43
+ temp_path = Path(handle.name)
44
+ os.replace(temp_path, path)
45
+
46
+
47
+ def write_json(path: Path, payload: Any) -> None:
48
+ atomic_write_text(path, json.dumps(payload, indent=2, sort_keys=False) + "\n")
49
+
50
+
51
+ def write_yaml(path: Path, payload: Any) -> None:
52
+ atomic_write_text(path, yaml.safe_dump(payload, sort_keys=False, allow_unicode=True))
53
+
54
+
55
+ def load_json_or_yaml(path: Path) -> Any:
56
+ suffix = path.suffix.lower()
57
+ text = path.read_text(encoding="utf-8")
58
+ if suffix == ".json":
59
+ return json.loads(text)
60
+ return yaml.safe_load(text)