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,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)
|