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.
- {licos_dev_cli-0.3.7 → licos_dev_cli-0.3.9}/PKG-INFO +2 -2
- {licos_dev_cli-0.3.7 → licos_dev_cli-0.3.9}/pyproject.toml +2 -2
- {licos_dev_cli-0.3.7 → licos_dev_cli-0.3.9}/src/licos_dev_cli/main.py +50 -0
- licos_dev_cli-0.3.9/src/licos_dev_cli/ppt_master.py +586 -0
- licos_dev_cli-0.3.9/tests/test_ppt_master_cli.py +125 -0
- {licos_dev_cli-0.3.7 → licos_dev_cli-0.3.9}/.gitignore +0 -0
- {licos_dev_cli-0.3.7 → licos_dev_cli-0.3.9}/src/licos_dev_cli/__init__.py +0 -0
- {licos_dev_cli-0.3.7 → licos_dev_cli-0.3.9}/tests/test_cli_output_paths.py +0 -0
- {licos_dev_cli-0.3.7 → licos_dev_cli-0.3.9}/tests/test_credit_check.py +0 -0
- {licos_dev_cli-0.3.7 → licos_dev_cli-0.3.9}/tests/test_document_read_cli.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: licos-dev-cli
|
|
3
|
-
Version: 0.3.
|
|
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
|
+
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
|
+
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|