licos-dev-cli 0.3.7__tar.gz → 0.3.9__tar.gz

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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-dev-cli
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: LICOS Dev CLI - generate files and call model capabilities
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: click>=8.1
7
- Requires-Dist: licos-dev-sdk>=0.3.7
7
+ Requires-Dist: licos-dev-sdk>=0.3.9
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-dev-cli"
7
- version = "0.3.7"
7
+ version = "0.3.9"
8
8
  description = "LICOS Dev CLI - generate files and call model capabilities"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
- "licos-dev-sdk>=0.3.7",
11
+ "licos-dev-sdk>=0.3.9",
12
12
  "click>=8.1",
13
13
  ]
14
14
 
@@ -8,6 +8,8 @@ import os
8
8
  from pathlib import Path
9
9
  import click
10
10
 
11
+ from .ppt_master import ppt_master
12
+
11
13
  # ── Helpers ──────────────────────────────────────────────────────────────────
12
14
 
13
15
  def _read_input(input_path: str | None, content: str | None) -> str:
@@ -95,6 +97,9 @@ def cli():
95
97
  pass
96
98
 
97
99
 
100
+ cli.add_command(ppt_master)
101
+
102
+
98
103
  @cli.command("credit-check")
99
104
  @click.option("--estimated-points", default=1, type=click.IntRange(min=1), show_default=True)
100
105
  @click.option("--resource-type", default="MODEL_TOOL", show_default=True)
@@ -394,6 +399,51 @@ def pptx_deck(input_path, content, filename, output_dir, asset_base_path):
394
399
  _echo_path(path)
395
400
 
396
401
 
402
+ @cli.command("pptx-native")
403
+ @click.option(
404
+ "-i",
405
+ "--input",
406
+ "input_path",
407
+ required=True,
408
+ type=click.Path(exists=True, dir_okay=False),
409
+ help="Native canvas JSON specification file",
410
+ )
411
+ @click.option("-f", "--filename", required=True, help="Output filename without extension")
412
+ @click.option("-o", "--output-dir", default=None, help="Output directory")
413
+ @click.option(
414
+ "--asset-base-path",
415
+ default=None,
416
+ type=click.Path(file_okay=False),
417
+ help="Base directory for relative image paths; defaults to the JSON file directory",
418
+ )
419
+ def pptx_native(input_path, filename, output_dir, asset_base_path):
420
+ """Generate a free-form PPTX whose text, shapes, tables, and charts remain editable."""
421
+ from licos_dev_sdk import create_pptx_deck
422
+
423
+ spec = _read_json(input_path, None)
424
+ if not isinstance(spec, dict):
425
+ raise click.BadParameter("presentation specification must be a JSON object")
426
+ slides = spec.get("slides")
427
+ if not isinstance(slides, list) or not slides:
428
+ raise click.BadParameter("presentation specification must contain slides")
429
+ invalid = [
430
+ index
431
+ for index, slide in enumerate(slides)
432
+ if not isinstance(slide, dict)
433
+ or str(slide.get("type", "")).strip().lower() != "canvas"
434
+ ]
435
+ if invalid:
436
+ raise click.BadParameter(f"pptx-native requires type=canvas for every slide; invalid indexes: {invalid}")
437
+ base_path = asset_base_path or str(Path(input_path).resolve().parent)
438
+ path = create_pptx_deck(
439
+ spec,
440
+ filename,
441
+ output_dir=output_dir,
442
+ asset_base_path=base_path,
443
+ )
444
+ _echo_path(path)
445
+
446
+
397
447
  @cli.command("pptx-inspect")
398
448
  @click.option("-f", "--file", "file_path", required=True, type=click.Path(exists=True, dir_okay=False))
399
449
  @click.option("--min-slides", type=click.IntRange(min=1), default=None)
@@ -0,0 +1,586 @@
1
+ """Controlled CLI facade for the bundled PPT Master engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import xml.etree.ElementTree as ET
14
+ from pathlib import Path
15
+ from urllib.parse import unquote, urlsplit
16
+
17
+ import click
18
+
19
+
20
+ UPSTREAM_REVISION = "ebd74d1f1d61a686f0f80e10abde5029fc4beeca"
21
+
22
+
23
+ def _engine_home() -> Path:
24
+ from licos_dev_sdk import PptMasterRuntimeError, ensure_ppt_master_home
25
+
26
+ try:
27
+ return ensure_ppt_master_home()
28
+ except PptMasterRuntimeError as exc:
29
+ raise click.ClickException(str(exc)) from exc
30
+
31
+
32
+ def _run_script(script_name: str, arguments: list[str]) -> None:
33
+ root = _engine_home()
34
+ script = (root / "scripts" / script_name).resolve()
35
+ scripts_root = (root / "scripts").resolve()
36
+ if scripts_root not in script.parents or not script.is_file():
37
+ raise click.ClickException(f"Unsupported PPT Master script: {script_name}")
38
+
39
+ env = os.environ.copy()
40
+ env.setdefault("PYTHONUTF8", "1")
41
+ env.setdefault("PYTHONIOENCODING", "utf-8")
42
+ env["LICOS_PPT_MASTER_HOME"] = str(root)
43
+ completed = subprocess.run(
44
+ [sys.executable, str(script), *arguments],
45
+ env=env,
46
+ check=False,
47
+ capture_output=True,
48
+ text=True,
49
+ encoding="utf-8",
50
+ errors="replace",
51
+ )
52
+ if completed.stdout:
53
+ _echo_process_output(completed.stdout)
54
+ if completed.stderr:
55
+ _echo_process_output(completed.stderr, err=True)
56
+ if completed.returncode:
57
+ raise click.exceptions.Exit(completed.returncode)
58
+
59
+
60
+ def _echo_process_output(value: str, *, err: bool = False) -> None:
61
+ stream = sys.stderr if err else sys.stdout
62
+ encoding = getattr(stream, "encoding", None) or "utf-8"
63
+ safe_value = value.encode(encoding, errors="backslashreplace").decode(encoding)
64
+ click.echo(safe_value, err=err, nl=not safe_value.endswith("\n"))
65
+
66
+
67
+ def _option(arguments: list[str], name: str, value: object | None) -> None:
68
+ if value is not None:
69
+ arguments.extend([name, str(value)])
70
+
71
+
72
+ def _flag(arguments: list[str], name: str, enabled: bool) -> None:
73
+ if enabled:
74
+ arguments.append(name)
75
+
76
+
77
+ @click.group("ppt-master")
78
+ def ppt_master() -> None:
79
+ """Use the bundled advanced PowerPoint workflow and native PPTX engine."""
80
+ if os.name == "nt":
81
+ for stream in (sys.stdout, sys.stderr):
82
+ reconfigure = getattr(stream, "reconfigure", None)
83
+ if callable(reconfigure):
84
+ reconfigure(encoding="utf-8", errors="backslashreplace")
85
+
86
+
87
+ @ppt_master.command("info")
88
+ def info() -> None:
89
+ """Show the installed engine revision and runtime capabilities."""
90
+ root = _engine_home()
91
+ payload = {
92
+ "engine": "ppt-master",
93
+ "revision": UPSTREAM_REVISION,
94
+ "home": str(root),
95
+ "routes": ["generate", "create-template", "fill-native", "enhance-native"],
96
+ "style_count": len(_load_catalog(root, "style")),
97
+ "deck_count": len(_load_catalog(root, "deck")),
98
+ }
99
+ click.echo(json.dumps(payload, ensure_ascii=False, indent=2))
100
+
101
+
102
+ def _load_catalog(root: Path, kind: str) -> dict[str, object]:
103
+ paths = {
104
+ "brand": root / "templates" / "brands" / "brands_index.json",
105
+ "style": root / "templates" / "styles" / "styles_index.json",
106
+ "layout": root / "templates" / "layouts" / "layouts_index.json",
107
+ "deck": root / "templates" / "decks" / "decks_index.json",
108
+ }
109
+ index_path = paths[kind]
110
+ if not index_path.is_file():
111
+ return {}
112
+ data = json.loads(index_path.read_text(encoding="utf-8"))
113
+ if not isinstance(data, dict):
114
+ raise click.ClickException(f"Invalid {kind} catalog: {index_path}")
115
+ return data
116
+
117
+
118
+ @ppt_master.command("catalog")
119
+ @click.option("--kind", type=click.Choice(["brand", "style", "layout", "deck"]), default="style")
120
+ def catalog(kind: str) -> None:
121
+ """List registered brand, style, layout, or deck workspaces."""
122
+ entries = _load_catalog(_engine_home(), kind)
123
+ click.echo(json.dumps({"kind": kind, "items": entries}, ensure_ascii=False, indent=2))
124
+
125
+
126
+ def _template_workspace(root: Path, kind: str, template_id: str) -> Path:
127
+ directory = {
128
+ "brand": "brands",
129
+ "style": "styles",
130
+ "layout": "layouts",
131
+ "deck": "decks",
132
+ }[kind]
133
+ catalog_entries = _load_catalog(root, kind)
134
+ if template_id not in catalog_entries:
135
+ raise click.ClickException(f"Unknown registered {kind} template: {template_id}")
136
+ workspace = (root / "templates" / directory / template_id).resolve()
137
+ expected_parent = (root / "templates" / directory).resolve()
138
+ if expected_parent not in workspace.parents or not workspace.is_dir():
139
+ raise click.ClickException(f"Invalid registered {kind} workspace: {workspace}")
140
+ return workspace
141
+
142
+
143
+ def _spec_with_provenance(source: Path, kind: str, template_id: str) -> str:
144
+ text = source.read_text(encoding="utf-8")
145
+ marker = f"> **Installed from**: bundled {kind} `{template_id}` ({UPSTREAM_REVISION[:8]})"
146
+ lines = text.splitlines()
147
+ heading_index = next((index for index, line in enumerate(lines) if line.startswith("# ")), None)
148
+ if heading_index is None:
149
+ return f"{marker}\n\n{text}"
150
+ lines[heading_index + 1 : heading_index + 1] = ["", marker]
151
+ return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
152
+
153
+
154
+ @ppt_master.command("install-template")
155
+ @click.option("--kind", type=click.Choice(["brand", "style", "layout", "deck"]), required=True)
156
+ @click.option("--id", "template_id", required=True)
157
+ @click.option("--project", required=True, type=click.Path(exists=True, file_okay=False))
158
+ def install_template(kind: str, template_id: str, project: str) -> None:
159
+ """Install one registered template workspace into a PPT project."""
160
+ root = _engine_home()
161
+ workspace = _template_workspace(root, kind, template_id)
162
+ source_templates = workspace / "templates"
163
+ source_spec = source_templates / "design_spec.md"
164
+ if not source_spec.is_file():
165
+ raise click.ClickException(f"Template workspace is missing design_spec.md: {workspace}")
166
+
167
+ _run_script("svg_quality_checker.py", [str(source_templates), "--template-mode"])
168
+
169
+ project_root = Path(project).resolve()
170
+ target_templates = project_root / "templates"
171
+ mappings: list[tuple[Path, Path]] = []
172
+ target_spec = target_templates / f"design_spec.{kind}.{template_id}.md"
173
+ if kind in {"layout", "deck"}:
174
+ mappings.extend(
175
+ (source, target_templates / source.relative_to(source_templates))
176
+ for source in source_templates.rglob("*")
177
+ if source.is_file() and source != source_spec
178
+ )
179
+ for asset_dir in ("images", "icons"):
180
+ source_assets = workspace / asset_dir
181
+ if source_assets.is_dir():
182
+ for source in source_assets.rglob("*"):
183
+ if not source.is_file():
184
+ continue
185
+ relative = source.relative_to(source_assets)
186
+ mappings.append((source, project_root / asset_dir / relative))
187
+ if kind in {"layout", "deck"}:
188
+ mappings.append((source, target_templates / asset_dir / relative))
189
+
190
+ destinations = [target_spec, *(destination for _, destination in mappings)]
191
+ collisions = [str(path) for path in destinations if path.exists()]
192
+ if collisions:
193
+ raise click.ClickException("Template install would overwrite existing files: " + ", ".join(collisions))
194
+
195
+ target_spec.parent.mkdir(parents=True, exist_ok=True)
196
+ target_spec.write_text(_spec_with_provenance(source_spec, kind, template_id), encoding="utf-8")
197
+ for source, destination in mappings:
198
+ destination.parent.mkdir(parents=True, exist_ok=True)
199
+ shutil.copy2(source, destination)
200
+
201
+ payload = {
202
+ "kind": kind,
203
+ "id": template_id,
204
+ "project": str(project_root),
205
+ "installed_spec": str(target_spec),
206
+ "installed_files": 1 + len(mappings),
207
+ }
208
+ click.echo(json.dumps(payload, ensure_ascii=False, indent=2))
209
+
210
+
211
+ @ppt_master.command("init")
212
+ @click.option("--name", required=True, help="ASCII-safe project name.")
213
+ @click.option("--projects-root", required=True, type=click.Path(file_okay=False))
214
+ @click.option("--format", "canvas_format", default=None, help="Registered canvas such as ppt169.")
215
+ @click.option("--quick", is_flag=True, help="Create a lockless quick-generation workspace.")
216
+ def init_project(name: str, projects_root: str, canvas_format: str | None, quick: bool) -> None:
217
+ """Initialize an advanced PPT project workspace."""
218
+ arguments = ["init", name, "--dir", projects_root]
219
+ _option(arguments, "--format", canvas_format)
220
+ _flag(arguments, "--quick-generate", quick)
221
+ _run_script("project_manager.py", arguments)
222
+
223
+
224
+ @ppt_master.command("project-info")
225
+ @click.option("--project", required=True, type=click.Path(exists=True, file_okay=False))
226
+ def project_info(project: str) -> None:
227
+ """Read advanced PPT project metadata."""
228
+ _run_script("project_manager.py", ["info", project])
229
+
230
+
231
+ @ppt_master.command("import-sources")
232
+ @click.option("--project", required=True, type=click.Path(exists=True, file_okay=False))
233
+ @click.option(
234
+ "--source",
235
+ "sources",
236
+ required=True,
237
+ multiple=True,
238
+ help="Source file, directory, or URL. Repeat for multiple sources.",
239
+ )
240
+ @click.option("--move/--copy", "move_sources", default=False)
241
+ def import_sources(project: str, sources: tuple[str, ...], move_sources: bool) -> None:
242
+ """Import source material into an advanced PPT project."""
243
+ arguments = ["import-sources", project, *sources, "--move" if move_sources else "--copy"]
244
+ _run_script("project_manager.py", arguments)
245
+
246
+
247
+ @ppt_master.command("scaffold-spec")
248
+ @click.option("--project", required=True, type=click.Path(exists=True, file_okay=False))
249
+ def scaffold_spec(project: str) -> None:
250
+ """Create the versioned design specification scaffold."""
251
+ _run_script("project_manager.py", ["scaffold-spec", project])
252
+
253
+
254
+ @ppt_master.command("scaffold-lock")
255
+ @click.option("--project", required=True, type=click.Path(exists=True, file_okay=False))
256
+ def scaffold_lock(project: str) -> None:
257
+ """Create the versioned confirmed-design lock scaffold."""
258
+ _run_script("project_manager.py", ["scaffold-lock", project])
259
+
260
+
261
+ @ppt_master.command("project-validate")
262
+ @click.option("--project", required=True, type=click.Path(exists=True, file_okay=False))
263
+ def project_validate(project: str) -> None:
264
+ """Validate advanced PPT project structure and artifacts."""
265
+ _run_script("project_manager.py", ["validate", project])
266
+
267
+
268
+ @ppt_master.command("quality")
269
+ @click.option("--project", required=True, type=click.Path(exists=True))
270
+ @click.option("--format", "canvas_format", default=None)
271
+ @click.option("--stage", type=click.Choice(["first-page", "final"]), default="final")
272
+ @click.option("--quick", is_flag=True)
273
+ @click.option("--template-mode", is_flag=True)
274
+ @click.option("--json-output", default=None, type=click.Path(dir_okay=False))
275
+ def quality(
276
+ project: str,
277
+ canvas_format: str | None,
278
+ stage: str,
279
+ quick: bool,
280
+ template_mode: bool,
281
+ json_output: str | None,
282
+ ) -> None:
283
+ """Run PPT Master SVG and project quality checks."""
284
+ arguments = [project, "--stage", stage, "--json"]
285
+ _option(arguments, "--format", canvas_format)
286
+ _flag(arguments, "--quick-generate", quick)
287
+ _flag(arguments, "--template-mode", template_mode)
288
+ if json_output:
289
+ arguments.extend(["--json-output", json_output])
290
+ _run_script("svg_quality_checker.py", arguments)
291
+
292
+
293
+ @ppt_master.command("export")
294
+ @click.option("--project", required=True, type=click.Path(exists=True, file_okay=False))
295
+ @click.option("--output", required=True, type=click.Path(dir_okay=False))
296
+ @click.option("--source", default=None, type=click.Path(exists=True, file_okay=False))
297
+ @click.option("--format", "canvas_format", default=None)
298
+ @click.option("--quick", is_flag=True)
299
+ @click.option("--roundtrip", is_flag=True)
300
+ @click.option("--native-charts-and-tables", is_flag=True)
301
+ @click.option("--structure", type=click.Choice(["flat", "structured"]), default=None)
302
+ @click.option("--transition", default=None)
303
+ @click.option("--transition-duration", type=float, default=None)
304
+ @click.option("--animation", default=None, help="PowerPoint animation preset.")
305
+ @click.option("--animation-duration", type=float, default=None)
306
+ @click.option("--with-notes/--no-notes", default=None)
307
+ def export(
308
+ project: str,
309
+ output: str,
310
+ source: str | None,
311
+ canvas_format: str | None,
312
+ quick: bool,
313
+ roundtrip: bool,
314
+ native_charts_and_tables: bool,
315
+ structure: str | None,
316
+ transition: str | None,
317
+ transition_duration: float | None,
318
+ animation: str | None,
319
+ animation_duration: float | None,
320
+ with_notes: bool | None,
321
+ ) -> None:
322
+ """Export authored SVG pages to an editable native PPTX."""
323
+ arguments = [project, "--output", output]
324
+ _option(arguments, "--source", source)
325
+ _option(arguments, "--format", canvas_format)
326
+ _flag(arguments, "--quick-generate", quick)
327
+ _flag(arguments, "--roundtrip", roundtrip)
328
+ _flag(arguments, "--native-charts-and-tables", native_charts_and_tables)
329
+ _option(arguments, "--pptx-structure", structure)
330
+ _option(arguments, "--transition", transition)
331
+ _option(arguments, "--transition-duration", transition_duration)
332
+ _option(arguments, "--animation", animation)
333
+ _option(arguments, "--animation-duration", animation_duration)
334
+ if with_notes is not None:
335
+ arguments.append("--with-notes" if with_notes else "--no-notes")
336
+ _run_script("svg_to_pptx.py", arguments)
337
+
338
+
339
+ @ppt_master.command("delivery-check")
340
+ @click.option("--file", "file_path", required=True, type=click.Path(exists=True, dir_okay=False))
341
+ def delivery_check(file_path: str) -> None:
342
+ """Inspect a finished PPTX for delivery risks without modifying it."""
343
+ _run_script("pptx_delivery_check.py", [file_path])
344
+
345
+
346
+ @ppt_master.command("intake")
347
+ @click.option("--file", "file_path", required=True, type=click.Path(exists=True, dir_okay=False))
348
+ @click.option("--output-dir", required=True, type=click.Path(file_okay=False))
349
+ def intake(file_path: str, output_dir: str) -> None:
350
+ """Extract standard analysis artifacts from an existing PPTX."""
351
+ _run_script("pptx_intake.py", [file_path, "--output-dir", output_dir])
352
+
353
+
354
+ @ppt_master.group("template")
355
+ def template_group() -> None:
356
+ """Analyze and fill native PowerPoint templates."""
357
+
358
+
359
+ @template_group.command("analyze")
360
+ @click.option("--file", "file_path", required=True, type=click.Path(exists=True, dir_okay=False))
361
+ @click.option("--output", required=True, type=click.Path(dir_okay=False))
362
+ def template_analyze(file_path: str, output: str) -> None:
363
+ _run_script("template_fill_pptx.py", ["analyze", file_path, "--output", output])
364
+
365
+
366
+ @template_group.command("scaffold")
367
+ @click.option("--library", required=True, type=click.Path(exists=True, dir_okay=False))
368
+ @click.option("--output", required=True, type=click.Path(dir_okay=False))
369
+ @click.option("--slides", default=None, help="Source slide list such as 1,3,5-7.")
370
+ @click.option("--include-empty", is_flag=True)
371
+ def template_scaffold(library: str, output: str, slides: str | None, include_empty: bool) -> None:
372
+ arguments = ["scaffold", library, "--output", output]
373
+ _option(arguments, "--slides", slides)
374
+ _flag(arguments, "--include-empty", include_empty)
375
+ _run_script("template_fill_pptx.py", arguments)
376
+
377
+
378
+ @template_group.command("check-plan")
379
+ @click.option("--library", required=True, type=click.Path(exists=True, dir_okay=False))
380
+ @click.option("--plan", required=True, type=click.Path(exists=True, dir_okay=False))
381
+ @click.option("--output", default=None, type=click.Path(dir_okay=False))
382
+ def template_check_plan(library: str, plan: str, output: str | None) -> None:
383
+ arguments = ["check-plan", library, plan]
384
+ _option(arguments, "--output", output)
385
+ _run_script("template_fill_pptx.py", arguments)
386
+
387
+
388
+ @template_group.command("apply")
389
+ @click.option("--file", "file_path", required=True, type=click.Path(exists=True, dir_okay=False))
390
+ @click.option("--plan", required=True, type=click.Path(exists=True, dir_okay=False))
391
+ @click.option("--output", required=True, type=click.Path(dir_okay=False))
392
+ @click.option("--transition", default="keep")
393
+ @click.option("--transition-duration", type=float, default=0.5)
394
+ @click.option("--force", is_flag=True)
395
+ def template_apply(
396
+ file_path: str,
397
+ plan: str,
398
+ output: str,
399
+ transition: str,
400
+ transition_duration: float,
401
+ force: bool,
402
+ ) -> None:
403
+ arguments = [
404
+ "apply",
405
+ file_path,
406
+ plan,
407
+ "--output",
408
+ output,
409
+ "--transition",
410
+ transition,
411
+ "--transition-duration",
412
+ str(transition_duration),
413
+ ]
414
+ _flag(arguments, "--force", force)
415
+ _run_script("template_fill_pptx.py", arguments)
416
+
417
+
418
+ @template_group.command("validate")
419
+ @click.option("--project", required=True, type=click.Path(exists=True, file_okay=False))
420
+ def template_validate(project: str) -> None:
421
+ _run_script("template_fill_pptx.py", ["validate", project])
422
+
423
+
424
+ @template_group.command("preview")
425
+ @click.option("--workspace", required=True, type=click.Path(exists=True, file_okay=False))
426
+ @click.option("--output", default=None, type=click.Path(dir_okay=False))
427
+ @click.option("--force", is_flag=True)
428
+ @click.option("--visual-only", is_flag=True)
429
+ def template_preview(
430
+ workspace: str,
431
+ output: str | None,
432
+ force: bool,
433
+ visual_only: bool,
434
+ ) -> None:
435
+ """Export a project-scoped template workspace as a review PPTX."""
436
+ workspace_root = Path(workspace).resolve()
437
+ output_path = str(Path(output).resolve()) if output else None
438
+ spec = workspace_root / "templates" / "design_spec.md"
439
+ if spec.is_file():
440
+ _run_template_preview(workspace_root, output_path, force, visual_only)
441
+ return
442
+
443
+ templates = workspace_root / "templates"
444
+ layout_specs = sorted(templates.glob("design_spec.layout.*.md"))
445
+ deck_specs = sorted(templates.glob("design_spec.deck.*.md"))
446
+ candidates = layout_specs or deck_specs
447
+ if len(candidates) != 1:
448
+ raise click.ClickException(
449
+ "Project-scoped template preview requires exactly one active layout or deck spec"
450
+ )
451
+
452
+ with tempfile.TemporaryDirectory(prefix="licos-ppt-template-preview-") as temporary:
453
+ preview_root = Path(temporary) / "workspace"
454
+ shutil.copytree(workspace_root, preview_root)
455
+ shutil.copy2(
456
+ preview_root / "templates" / candidates[0].name,
457
+ preview_root / "templates" / "design_spec.md",
458
+ )
459
+ for qualified_spec in (preview_root / "templates").glob("design_spec.*.*.md"):
460
+ qualified_spec.unlink()
461
+ _inline_local_preview_images(preview_root)
462
+ _run_template_preview(preview_root, output_path, force, visual_only)
463
+
464
+
465
+ def _run_template_preview(
466
+ workspace: Path,
467
+ output: str | None,
468
+ force: bool,
469
+ visual_only: bool,
470
+ ) -> None:
471
+ arguments = [str(workspace)]
472
+ _option(arguments, "--output", output)
473
+ _flag(arguments, "--force", force)
474
+ _flag(arguments, "--visual-only", visual_only)
475
+ _run_script("template_preview_pptx.py", arguments)
476
+
477
+
478
+ def _inline_local_preview_images(workspace: Path) -> None:
479
+ """Keep project-local images valid when upstream moves SVGs for review."""
480
+ svg_namespace = "http://www.w3.org/2000/svg"
481
+ xlink_namespace = "http://www.w3.org/1999/xlink"
482
+ ET.register_namespace("", svg_namespace)
483
+ ET.register_namespace("xlink", xlink_namespace)
484
+ workspace_root = workspace.resolve()
485
+ href_keys = ("href", f"{{{xlink_namespace}}}href")
486
+
487
+ for svg_path in sorted((workspace / "templates").glob("*.svg")):
488
+ tree = ET.parse(svg_path)
489
+ changed = False
490
+ for element in tree.getroot().iter():
491
+ if element.tag.rsplit("}", 1)[-1] != "image":
492
+ continue
493
+ href_key = next((key for key in href_keys if element.get(key)), None)
494
+ if href_key is None:
495
+ continue
496
+ href = (element.get(href_key) or "").strip()
497
+ if not href or href.startswith("data:") or href.startswith("#"):
498
+ continue
499
+ parsed = urlsplit(href)
500
+ if parsed.scheme not in {"", "file"}:
501
+ continue
502
+ decoded = unquote(parsed.path if parsed.scheme else href.split("?", 1)[0].split("#", 1)[0])
503
+ source = Path(decoded) if parsed.scheme == "file" else svg_path.parent / decoded
504
+ source = source.resolve()
505
+ try:
506
+ source.relative_to(workspace_root)
507
+ except ValueError as exc:
508
+ raise click.ClickException(
509
+ f"Template image escapes its workspace: {href}"
510
+ ) from exc
511
+ if not source.is_file():
512
+ raise click.ClickException(f"Template image does not exist: {source}")
513
+ media_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
514
+ encoded = base64.b64encode(source.read_bytes()).decode("ascii")
515
+ element.set(href_key, f"data:{media_type};base64,{encoded}")
516
+ changed = True
517
+ if changed:
518
+ tree.write(svg_path, encoding="utf-8", xml_declaration=True)
519
+
520
+
521
+ @ppt_master.group("enhance")
522
+ def enhance_group() -> None:
523
+ """Enhance an existing native PPTX without rebuilding visible slides."""
524
+
525
+
526
+ def _enhance_motion(arguments: list[str], transition: str | None, duration: float | None) -> None:
527
+ _option(arguments, "--transition", transition)
528
+ _option(arguments, "--transition-duration", duration)
529
+
530
+
531
+ @enhance_group.command("init")
532
+ @click.option("--file", "file_path", required=True, type=click.Path(exists=True, dir_okay=False))
533
+ @click.option("--project-dir", required=True, type=click.Path(file_okay=False))
534
+ @click.option("--name", default=None)
535
+ @click.option("--transition", default=None)
536
+ @click.option("--transition-duration", type=float, default=None)
537
+ def enhance_init(
538
+ file_path: str,
539
+ project_dir: str,
540
+ name: str | None,
541
+ transition: str | None,
542
+ transition_duration: float | None,
543
+ ) -> None:
544
+ arguments = ["init", file_path, "--project-dir", project_dir]
545
+ _option(arguments, "--name", name)
546
+ _enhance_motion(arguments, transition, transition_duration)
547
+ _run_script("native_enhance_pptx.py", arguments)
548
+
549
+
550
+ @enhance_group.command("plan")
551
+ @click.option("--project", required=True, type=click.Path(exists=True, file_okay=False))
552
+ @click.option("--transition", default=None)
553
+ @click.option("--transition-duration", type=float, default=None)
554
+ def enhance_plan(project: str, transition: str | None, transition_duration: float | None) -> None:
555
+ arguments = ["plan", project]
556
+ _enhance_motion(arguments, transition, transition_duration)
557
+ _run_script("native_enhance_pptx.py", arguments)
558
+
559
+
560
+ @enhance_group.command("apply")
561
+ @click.option("--project", required=True, type=click.Path(exists=True, file_okay=False))
562
+ @click.option("--output", required=True, type=click.Path(dir_okay=False))
563
+ @click.option("--overwrite", is_flag=True)
564
+ @click.option("--force", is_flag=True)
565
+ @click.option("--transition", default=None)
566
+ @click.option("--transition-duration", type=float, default=None)
567
+ def enhance_apply(
568
+ project: str,
569
+ output: str,
570
+ overwrite: bool,
571
+ force: bool,
572
+ transition: str | None,
573
+ transition_duration: float | None,
574
+ ) -> None:
575
+ arguments = ["apply", project, "--output", output]
576
+ _flag(arguments, "--overwrite", overwrite)
577
+ _flag(arguments, "--force", force)
578
+ _enhance_motion(arguments, transition, transition_duration)
579
+ _run_script("native_enhance_pptx.py", arguments)
580
+
581
+
582
+ @enhance_group.command("validate")
583
+ @click.option("--project", required=True, type=click.Path(exists=True, file_okay=False))
584
+ @click.option("--materials", type=click.Choice(["all", "notes"]), default="all")
585
+ def enhance_validate(project: str, materials: str) -> None:
586
+ _run_script("native_enhance_pptx.py", ["validate", project, "--materials", materials])
@@ -0,0 +1,125 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from click.testing import CliRunner
5
+
6
+ from licos_dev_cli.main import cli
7
+ from licos_dev_cli.ppt_master import _inline_local_preview_images
8
+
9
+
10
+ def _fake_engine(tmp_path: Path) -> Path:
11
+ root = tmp_path / "ppt-master"
12
+ scripts = root / "scripts"
13
+ scripts.mkdir(parents=True)
14
+ (root / "templates" / "styles").mkdir(parents=True)
15
+ (root / "templates" / "decks").mkdir(parents=True)
16
+ (root / "workflows").mkdir()
17
+ (root / "LICENSE").write_text("MIT", encoding="utf-8")
18
+ (root / "templates" / "styles" / "styles_index.json").write_text(
19
+ json.dumps({"operating-review": {"summary": "Review"}}),
20
+ encoding="utf-8",
21
+ )
22
+ (root / "templates" / "decks" / "decks_index.json").write_text("{}", encoding="utf-8")
23
+ return root
24
+
25
+
26
+ def test_ppt_master_info_reads_catalog(tmp_path: Path) -> None:
27
+ root = _fake_engine(tmp_path)
28
+ result = CliRunner().invoke(cli, ["ppt-master", "info"], env={"LICOS_PPT_MASTER_HOME": str(root)})
29
+
30
+ assert result.exit_code == 0, result.output
31
+ payload = json.loads(result.output)
32
+ assert payload["revision"].startswith("ebd74d1f")
33
+ assert payload["style_count"] == 1
34
+ assert payload["routes"] == ["generate", "create-template", "fill-native", "enhance-native"]
35
+
36
+
37
+ def test_ppt_master_export_forwards_structured_arguments(tmp_path: Path) -> None:
38
+ root = _fake_engine(tmp_path)
39
+ project = tmp_path / "project"
40
+ project.mkdir()
41
+ output = tmp_path / "deck.pptx"
42
+ script = root / "scripts" / "svg_to_pptx.py"
43
+ script.write_text(
44
+ "import json, sys\nprint(json.dumps(sys.argv[1:]))\n",
45
+ encoding="utf-8",
46
+ )
47
+
48
+ result = CliRunner().invoke(
49
+ cli,
50
+ [
51
+ "ppt-master",
52
+ "export",
53
+ "--project",
54
+ str(project),
55
+ "--output",
56
+ str(output),
57
+ "--quick",
58
+ "--structure",
59
+ "flat",
60
+ "--transition",
61
+ "fade",
62
+ ],
63
+ env={"LICOS_PPT_MASTER_HOME": str(root)},
64
+ )
65
+
66
+ assert result.exit_code == 0, result.output
67
+ arguments = json.loads(result.output)
68
+ assert arguments[:2] == [str(project), "--output"]
69
+ assert str(output) in arguments
70
+ assert "--quick-generate" in arguments
71
+ assert arguments[arguments.index("--pptx-structure") + 1] == "flat"
72
+ assert arguments[arguments.index("--transition") + 1] == "fade"
73
+
74
+
75
+ def test_ppt_master_quality_always_writes_machine_receipt(tmp_path: Path) -> None:
76
+ root = _fake_engine(tmp_path)
77
+ project = tmp_path / "project"
78
+ project.mkdir()
79
+ script = root / "scripts" / "svg_quality_checker.py"
80
+ script.write_text(
81
+ "import json, sys\nprint(json.dumps(sys.argv[1:]))\n",
82
+ encoding="utf-8",
83
+ )
84
+
85
+ result = CliRunner().invoke(
86
+ cli,
87
+ ["ppt-master", "quality", "--project", str(project), "--quick"],
88
+ env={"LICOS_PPT_MASTER_HOME": str(root)},
89
+ )
90
+
91
+ assert result.exit_code == 0, result.output
92
+ arguments = json.loads(result.output)
93
+ assert "--json" in arguments
94
+ assert "--quick-generate" in arguments
95
+
96
+
97
+ def test_ppt_master_rejects_missing_runtime(tmp_path: Path) -> None:
98
+ result = CliRunner().invoke(
99
+ cli,
100
+ ["ppt-master", "info"],
101
+ env={"LICOS_PPT_MASTER_HOME": str(tmp_path / "missing")},
102
+ )
103
+
104
+ assert result.exit_code != 0
105
+ assert "runtime is incomplete" in result.output
106
+
107
+
108
+ def test_template_preview_inlines_project_images(tmp_path: Path) -> None:
109
+ workspace = tmp_path / "workspace"
110
+ templates = workspace / "templates"
111
+ images = workspace / "images"
112
+ templates.mkdir(parents=True)
113
+ images.mkdir()
114
+ (images / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\n")
115
+ svg = templates / "cover.svg"
116
+ svg.write_text(
117
+ '<svg xmlns="http://www.w3.org/2000/svg">'
118
+ '<image href="../images/logo.png" width="10" height="10"/>'
119
+ "</svg>",
120
+ encoding="utf-8",
121
+ )
122
+
123
+ _inline_local_preview_images(workspace)
124
+
125
+ assert "data:image/png;base64," in svg.read_text(encoding="utf-8")
File without changes