deckicorn 0.2.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.
- deckicorn-0.2.0.dist-info/METADATA +97 -0
- deckicorn-0.2.0.dist-info/RECORD +25 -0
- deckicorn-0.2.0.dist-info/WHEEL +5 -0
- deckicorn-0.2.0.dist-info/entry_points.txt +2 -0
- deckicorn-0.2.0.dist-info/top_level.txt +5 -0
- deckicorn_cli/main.py +155 -0
- deckicorn_md/__init__.py +12 -0
- deckicorn_md/compiler.py +286 -0
- deckicorn_md/parser.py +161 -0
- deckicorn_project/__init__.py +15 -0
- deckicorn_project/manifest.py +157 -0
- deckicorn_project/packaging.py +183 -0
- deckicorn_project/pyinstaller/hooks/hook-arcade.py +54 -0
- deckicorn_runtime/__init__.py +11 -0
- deckicorn_runtime/arcade_app.py +270 -0
- deckicorn_runtime/bounds_overlay.py +116 -0
- deckicorn_runtime/console.py +51 -0
- deckicorn_runtime/coords.py +73 -0
- deckicorn_runtime/navigation.py +76 -0
- deckicorn_runtime/preview_options.py +146 -0
- deckicorn_runtime/resources.py +26 -0
- deckicorn_spec/__init__.py +22 -0
- deckicorn_spec/io.py +50 -0
- deckicorn_spec/models.py +184 -0
- deckicorn_spec/validate.py +100 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: deckicorn
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Spec-driven presentation runtime
|
|
5
|
+
Project-URL: Documentation, https://deckicorn.org
|
|
6
|
+
Project-URL: Repository, https://gitlab.com/libesys/deckicorn/deckicorn-cli
|
|
7
|
+
Project-URL: Changelog, https://deckicorn.org/blog
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: pydantic>=2.0
|
|
11
|
+
Requires-Dist: PyYAML>=6.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pre-commit>=4.0; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
16
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
17
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
18
|
+
Provides-Extra: docs
|
|
19
|
+
Requires-Dist: sphinx>=7.4; extra == "docs"
|
|
20
|
+
Requires-Dist: sphinx-rtd-theme>=2.0; extra == "docs"
|
|
21
|
+
Provides-Extra: runtime
|
|
22
|
+
Requires-Dist: arcade<4,>=3.0; extra == "runtime"
|
|
23
|
+
Provides-Extra: packaging
|
|
24
|
+
Requires-Dist: pyinstaller>=6.0; extra == "packaging"
|
|
25
|
+
|
|
26
|
+
# Deckicorn
|
|
27
|
+
|
|
28
|
+
Spec-driven presentation runtime: compile Markdown decks + layout YAML into a canonical presentation spec, then render with a 2D (Arcade) runtime.
|
|
29
|
+
|
|
30
|
+
## Documentation
|
|
31
|
+
|
|
32
|
+
- **Public site:** [deckicorn.org](https://deckicorn.org) (Docusaurus + Sphinx API reference)
|
|
33
|
+
- ADR overview: [`docs/README-deckicorn-adr.md`](docs/README-deckicorn-adr.md)
|
|
34
|
+
- Presentation spec: [`docs/SPEC-presentation-spec.md`](docs/SPEC-presentation-spec.md)
|
|
35
|
+
- Deckicorn MD dialect: [`docs/SPEC-deckicorn-md-dialect.md`](docs/SPEC-deckicorn-md-dialect.md)
|
|
36
|
+
- Deck project manifest: [`docs/SPEC-deckicorn-project-manifest.md`](docs/SPEC-deckicorn-project-manifest.md)
|
|
37
|
+
- Implementation plan: [`plan/deckicorn-implementation-plan.md`](plan/deckicorn-implementation-plan.md)
|
|
38
|
+
- AI workflow rules: [`tools/ai-rules/`](tools/ai-rules/)
|
|
39
|
+
|
|
40
|
+
## Git workflow
|
|
41
|
+
|
|
42
|
+
Day-to-day work happens on **`develop`**; **`master`** is ff-merged when Development CI is green (A2C `development-workflow.md`).
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git checkout develop
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install -e ".[dev]"
|
|
52
|
+
pre-commit install
|
|
53
|
+
pre-commit install --hook-type commit-msg
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
From a deck project directory (see `examples/deckicorn-hello/deckicorn.yaml`):
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
cd examples/deckicorn-hello
|
|
60
|
+
deckicorn build
|
|
61
|
+
deckicorn preview # requires pip install -e ".[runtime]"
|
|
62
|
+
deckicorn preview --show-bounds
|
|
63
|
+
deckicorn package --clean # requires pip install -e ".[runtime,packaging]"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Ad hoc paths (without `deckicorn.yaml`):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
deckicorn build examples/deckicorn-hello/deck.md \
|
|
70
|
+
--layout examples/deckicorn-hello/layout-default.yaml \
|
|
71
|
+
--out examples/deckicorn-hello/build/deck-spec.yaml
|
|
72
|
+
deckicorn run examples/deckicorn-hello/build/deck-spec.yaml
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Changelog and pre-commit
|
|
76
|
+
|
|
77
|
+
This repository follows A2C changelog policy (`tools/ai-rules/rules/11-changelog-policy.md`):
|
|
78
|
+
|
|
79
|
+
- Maintain `CHANGELOG.md` in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
|
|
80
|
+
- Curate `## [Unreleased]` as release-relevant work lands.
|
|
81
|
+
- Stage `CHANGELOG.md` with any commit that changes other files (`check-changelog` hook).
|
|
82
|
+
|
|
83
|
+
Version source: root `VERSION` (kept in sync with `pyproject.toml` and `deckicorn_spec.__version__` at release time).
|
|
84
|
+
|
|
85
|
+
Release workflow: [docs/release-pipeline.md](docs/release-pipeline.md).
|
|
86
|
+
|
|
87
|
+
## Project layout
|
|
88
|
+
|
|
89
|
+
| Path | Purpose |
|
|
90
|
+
|------|---------|
|
|
91
|
+
| `docs/` | ADRs and specs (canonical) |
|
|
92
|
+
| `inputs/` | Read-only intake copies of source docs |
|
|
93
|
+
| `deckicorn-spec` (`src/deckicorn_spec`) | Presentation spec models and validation |
|
|
94
|
+
| `deckicorn-md` (`src/deckicorn_md`) | Deckicorn MD parser and compiler |
|
|
95
|
+
| `deckicorn-runtime` (`src/deckicorn_runtime`) | Presentation runtime |
|
|
96
|
+
| `examples/` | Documented deck projects (`deckicorn-hello/`, …) |
|
|
97
|
+
| `tests/fixtures/decks/` | Broken manifest fixtures for CI and unit tests |
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
deckicorn_cli/main.py,sha256=Ww19G53xeiupDYXmJecAMb3z-RJJWBKFZf-mZo2mT3Q,5294
|
|
2
|
+
deckicorn_md/__init__.py,sha256=HgbQlIweC0Z8ntFD8VPKQhGhalLpc9SMs3glkgBB9C4,350
|
|
3
|
+
deckicorn_md/compiler.py,sha256=9RtrrEhI1Nr1EcuxQUp-vykTwHTFCaq7_G4fNANXvEo,8851
|
|
4
|
+
deckicorn_md/parser.py,sha256=71mDbyvkYJ8l3vBdZwVuYM25gW6aGpt9yDTz_b9Fjvk,4941
|
|
5
|
+
deckicorn_project/__init__.py,sha256=2kID3nocuDEbjjv6aVU17OxnPzxfgTperJpssqtW_Po,282
|
|
6
|
+
deckicorn_project/manifest.py,sha256=Nd3mHNDDsDUl4dsTb4K_mUoqQuL6MKT92gCf7OpSh6M,5220
|
|
7
|
+
deckicorn_project/packaging.py,sha256=3J2rC7ReyBG1zaspzAlAheyVNQiJ4aWa-ngzP17oS6U,5643
|
|
8
|
+
deckicorn_project/pyinstaller/hooks/hook-arcade.py,sha256=31WfAMKo1ttR9ihxR9rBbzmk-mtXEoe6gqI6ZKydIIA,1629
|
|
9
|
+
deckicorn_runtime/__init__.py,sha256=ASB4zVOpKzA30fiVOx6JYRsUWSa6kaE6T3nnsZB0NWM,359
|
|
10
|
+
deckicorn_runtime/arcade_app.py,sha256=1lMzt-6gbiDDLm-5dnt7CbxMvh4ixHYjS4dH2s1RMro,8451
|
|
11
|
+
deckicorn_runtime/bounds_overlay.py,sha256=-g1zBhQipaJO7LnxrlP2l2ieY0OYTPbS287jwslK4NE,3020
|
|
12
|
+
deckicorn_runtime/console.py,sha256=ttSzzf4PE6Rta_csDRSoQIckIMpa57PV0bEe1ApIYHM,1705
|
|
13
|
+
deckicorn_runtime/coords.py,sha256=W0bx5TUapfu47Sdoka73A7PsYkctkf4JabtvUFql8g4,2365
|
|
14
|
+
deckicorn_runtime/navigation.py,sha256=-_gcjuDMI0R0U3Q2VdDsCDv3K1hPt8SD9dYlCBeD4T8,2443
|
|
15
|
+
deckicorn_runtime/preview_options.py,sha256=KtbSwTJJjiNJT7YvJdzxrs_lf8ReiIeHvafq4PlxTcI,4747
|
|
16
|
+
deckicorn_runtime/resources.py,sha256=LF6pc_UlhKaBQcb19QE2kDLk-h3dnRh61-Oxt3dFayk,790
|
|
17
|
+
deckicorn_spec/__init__.py,sha256=ihOkfrYgsNVlI5XS18Wbaf74tPA9lLwp6E3RfOGFtt0,558
|
|
18
|
+
deckicorn_spec/io.py,sha256=Q6eUl4ynHYmv-KQ9Us0dGMXe_PnBGr6Cbs_5j6jfBX8,1421
|
|
19
|
+
deckicorn_spec/models.py,sha256=N7QPQ13YwJS93dBu2argryGGMwNlXeL8yvm3pmwbpnw,4507
|
|
20
|
+
deckicorn_spec/validate.py,sha256=kGR4J5nmHEcR8FbchSIMU9rEnwFhU0MVgWINL8md4iE,4156
|
|
21
|
+
deckicorn-0.2.0.dist-info/METADATA,sha256=C59GBx2diwRjkillTabyBmM7gQWCprJ4UL8RtKCCS5Q,3689
|
|
22
|
+
deckicorn-0.2.0.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
|
|
23
|
+
deckicorn-0.2.0.dist-info/entry_points.txt,sha256=oOh-1CGVwkb3De8yuRanveJZ3tOzQnBo8OIy9fybu3M,54
|
|
24
|
+
deckicorn-0.2.0.dist-info/top_level.txt,sha256=jONsaW23CidLtM0_3tm4ab6HUjAObzxLK96BqlZ7x68,78
|
|
25
|
+
deckicorn-0.2.0.dist-info/RECORD,,
|
deckicorn_cli/main.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Deckicorn toolchain CLI (ADR-019)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from deckicorn_md.compiler import compile_files
|
|
9
|
+
from deckicorn_project.manifest import (
|
|
10
|
+
MANIFEST_NAME,
|
|
11
|
+
ensure_paths_exist,
|
|
12
|
+
load_project,
|
|
13
|
+
resolve_project_root,
|
|
14
|
+
)
|
|
15
|
+
from deckicorn_project.packaging import package_presentation
|
|
16
|
+
from deckicorn_runtime.console import run_presentation_file as run_console_presentation_file
|
|
17
|
+
from deckicorn_runtime.preview_options import add_preview_arguments
|
|
18
|
+
from deckicorn_spec.io import save_presentation_file
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _add_project_arg(parser: argparse.ArgumentParser) -> None:
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--project",
|
|
24
|
+
type=Path,
|
|
25
|
+
default=None,
|
|
26
|
+
help="Deck project root containing deckicorn.yaml (default: current directory)",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_resolved(args: argparse.Namespace):
|
|
31
|
+
root = resolve_project_root(args.project)
|
|
32
|
+
return load_project(root)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _manifest_available(project: Path | None = None) -> bool:
|
|
36
|
+
root = (project or Path.cwd()).resolve()
|
|
37
|
+
return (root / MANIFEST_NAME).is_file()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cmd_build(args: argparse.Namespace) -> int:
|
|
41
|
+
if args.md is not None:
|
|
42
|
+
if not args.layout or not args.out:
|
|
43
|
+
raise SystemExit("build requires --layout and --out when not using a deck project")
|
|
44
|
+
md_path = Path(args.md)
|
|
45
|
+
layout_path = Path(args.layout)
|
|
46
|
+
out_path = Path(args.out)
|
|
47
|
+
else:
|
|
48
|
+
project = _load_resolved(args)
|
|
49
|
+
ensure_paths_exist(project)
|
|
50
|
+
md_path = project.content
|
|
51
|
+
layout_path = project.layout
|
|
52
|
+
out_path = project.build_spec
|
|
53
|
+
|
|
54
|
+
presentation = compile_files(md_path, layout_path)
|
|
55
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
save_presentation_file(presentation, out_path)
|
|
57
|
+
print(f"Wrote {out_path}")
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
62
|
+
if args.spec is not None:
|
|
63
|
+
spec_path = Path(args.spec)
|
|
64
|
+
else:
|
|
65
|
+
project = _load_resolved(args)
|
|
66
|
+
ensure_paths_exist(project, need_spec=True)
|
|
67
|
+
spec_path = project.build_spec
|
|
68
|
+
|
|
69
|
+
run_console_presentation_file(str(spec_path))
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cmd_preview(args: argparse.Namespace) -> int:
|
|
74
|
+
from deckicorn_runtime.arcade_app import run_arcade_presentation
|
|
75
|
+
from deckicorn_runtime.preview_options import preview_options_from_args, profile_bounds_defaults
|
|
76
|
+
|
|
77
|
+
bounds_style = profile_bounds_defaults("minimal")
|
|
78
|
+
if args.spec is not None:
|
|
79
|
+
spec_path = Path(args.spec)
|
|
80
|
+
asset_root = spec_path.parent
|
|
81
|
+
else:
|
|
82
|
+
project = _load_resolved(args)
|
|
83
|
+
ensure_paths_exist(project, need_spec=True)
|
|
84
|
+
spec_path = project.build_spec
|
|
85
|
+
asset_root = project.root
|
|
86
|
+
bounds_style = project.preview_bounds
|
|
87
|
+
|
|
88
|
+
options = preview_options_from_args(args, bounds=bounds_style)
|
|
89
|
+
run_arcade_presentation(
|
|
90
|
+
spec_path,
|
|
91
|
+
asset_root=asset_root,
|
|
92
|
+
width=options.width,
|
|
93
|
+
height=options.height,
|
|
94
|
+
show_element_bounds=options.show_bounds,
|
|
95
|
+
bounds_style=options.bounds,
|
|
96
|
+
)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cmd_package(args: argparse.Namespace) -> int:
|
|
101
|
+
project = _load_resolved(args)
|
|
102
|
+
binary = package_presentation(project, clean=args.clean)
|
|
103
|
+
print(f"Built {binary}")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main(argv: list[str] | None = None) -> int:
|
|
108
|
+
parser = argparse.ArgumentParser(prog="deckicorn", description="Deckicorn toolchain CLI")
|
|
109
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
110
|
+
|
|
111
|
+
build = sub.add_parser("build", help="Compile MD + layout into presentation spec YAML")
|
|
112
|
+
build.add_argument("md", nargs="?", help="Deckicorn MD deck file")
|
|
113
|
+
build.add_argument("--layout", help="Layout overlay YAML")
|
|
114
|
+
build.add_argument("--out", help="Output presentation spec YAML")
|
|
115
|
+
_add_project_arg(build)
|
|
116
|
+
build.set_defaults(func=_cmd_build)
|
|
117
|
+
|
|
118
|
+
run = sub.add_parser("run", help="Run a presentation spec (console mode)")
|
|
119
|
+
run.add_argument("spec", nargs="?", help="Presentation spec YAML")
|
|
120
|
+
_add_project_arg(run)
|
|
121
|
+
run.set_defaults(func=_cmd_run)
|
|
122
|
+
|
|
123
|
+
preview = sub.add_parser("preview", help="Preview a presentation spec in an Arcade window")
|
|
124
|
+
preview.add_argument("spec", nargs="?", help="Presentation spec YAML")
|
|
125
|
+
add_preview_arguments(preview)
|
|
126
|
+
_add_project_arg(preview)
|
|
127
|
+
preview.set_defaults(func=_cmd_preview)
|
|
128
|
+
|
|
129
|
+
package = sub.add_parser("package", help="Package a deck project into a standalone executable")
|
|
130
|
+
package.add_argument("--clean", action="store_true", help="Remove prior build artifacts first")
|
|
131
|
+
_add_project_arg(package)
|
|
132
|
+
package.set_defaults(func=_cmd_package)
|
|
133
|
+
|
|
134
|
+
args = parser.parse_args(argv)
|
|
135
|
+
|
|
136
|
+
if args.command == "build" and args.md is None and not _manifest_available(args.project):
|
|
137
|
+
parser.error(
|
|
138
|
+
"build requires a deck MD path, or run from a directory containing deckicorn.yaml "
|
|
139
|
+
"(or pass --project)",
|
|
140
|
+
)
|
|
141
|
+
if (
|
|
142
|
+
args.command in {"run", "preview"}
|
|
143
|
+
and getattr(args, "spec", None) is None
|
|
144
|
+
and not _manifest_available(args.project)
|
|
145
|
+
):
|
|
146
|
+
parser.error(
|
|
147
|
+
f"{args.command} requires a spec path, or run from a directory containing "
|
|
148
|
+
"deckicorn.yaml (or pass --project)",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return args.func(args)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
raise SystemExit(main())
|
deckicorn_md/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Deckicorn MD parser and compiler (ADR-013)."""
|
|
2
|
+
|
|
3
|
+
from deckicorn_md.compiler import compile_files, compile_presentation
|
|
4
|
+
from deckicorn_md.parser import ContentDeck, parse_deckicorn_md, parse_deckicorn_md_file
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ContentDeck",
|
|
8
|
+
"compile_files",
|
|
9
|
+
"compile_presentation",
|
|
10
|
+
"parse_deckicorn_md",
|
|
11
|
+
"parse_deckicorn_md_file",
|
|
12
|
+
]
|
deckicorn_md/compiler.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Compile content AST + layout overlay into presentation spec (ADR-013, ADR-016)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from deckicorn_md.parser import ContentDeck, ContentSlide, parse_deckicorn_md
|
|
11
|
+
from deckicorn_spec.models import (
|
|
12
|
+
Action,
|
|
13
|
+
Assets,
|
|
14
|
+
Element,
|
|
15
|
+
Interaction,
|
|
16
|
+
Layout,
|
|
17
|
+
LayoutRegion,
|
|
18
|
+
Meta,
|
|
19
|
+
Presentation,
|
|
20
|
+
Size3,
|
|
21
|
+
Slide,
|
|
22
|
+
SlideBackground,
|
|
23
|
+
Style,
|
|
24
|
+
Theme,
|
|
25
|
+
Trigger,
|
|
26
|
+
Vec3,
|
|
27
|
+
)
|
|
28
|
+
from deckicorn_spec.validate import validate_presentation
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_layout_overlay(path: str | Path) -> dict[str, Any]:
|
|
32
|
+
return yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_region(
|
|
36
|
+
position: dict[str, float],
|
|
37
|
+
size: dict[str, float],
|
|
38
|
+
width: float,
|
|
39
|
+
height: float,
|
|
40
|
+
) -> tuple[Vec3, Size3]:
|
|
41
|
+
"""Convert pixel top-left coords to normalized bottom-left (ADR-018)."""
|
|
42
|
+
x = float(position.get("x", 0))
|
|
43
|
+
y = float(position.get("y", 0))
|
|
44
|
+
w = float(size.get("w", 0))
|
|
45
|
+
h = float(size.get("h", 0))
|
|
46
|
+
return (
|
|
47
|
+
Vec3(x=x / width, y=(height - y - h) / height, z=float(position.get("z", 0))),
|
|
48
|
+
Size3(w=w / width, h=h / height),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _overlay_layouts(overlay: dict[str, Any]) -> list[Layout]:
|
|
53
|
+
coords = overlay.get("coords", {})
|
|
54
|
+
width = float(coords.get("width", 1920))
|
|
55
|
+
height = float(coords.get("height", 1080))
|
|
56
|
+
layouts: list[Layout] = []
|
|
57
|
+
for item in overlay.get("layouts", []):
|
|
58
|
+
regions: dict[str, LayoutRegion] = {}
|
|
59
|
+
for name, region in item.get("regions", {}).items():
|
|
60
|
+
pos, size = _normalize_region(region["position"], region["size"], width, height)
|
|
61
|
+
regions[name] = LayoutRegion(
|
|
62
|
+
position=pos,
|
|
63
|
+
size=size,
|
|
64
|
+
style=region.get("style"),
|
|
65
|
+
)
|
|
66
|
+
background = None
|
|
67
|
+
if "background" in item:
|
|
68
|
+
bg = item["background"]
|
|
69
|
+
background = SlideBackground(
|
|
70
|
+
color=bg.get("color"),
|
|
71
|
+
image=bg.get("image"),
|
|
72
|
+
image_mode=bg.get("image_mode"),
|
|
73
|
+
)
|
|
74
|
+
layouts.append(
|
|
75
|
+
Layout(
|
|
76
|
+
id=item["id"],
|
|
77
|
+
name=item.get("name"),
|
|
78
|
+
description=item.get("description"),
|
|
79
|
+
background=background,
|
|
80
|
+
regions=regions,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return layouts
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _overlay_styles(overlay: dict[str, Any]) -> dict[str, Style]:
|
|
87
|
+
raw = overlay.get("styles", {})
|
|
88
|
+
return {name: Style.model_validate(values) for name, values in raw.items()}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _overlay_assets(overlay: dict[str, Any]) -> Assets:
|
|
92
|
+
raw = overlay.get("assets", {})
|
|
93
|
+
return Assets.model_validate(raw)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _overlay_theme(overlay: dict[str, Any]) -> Theme | None:
|
|
97
|
+
theme_data = overlay.get("theme")
|
|
98
|
+
if not theme_data:
|
|
99
|
+
return None
|
|
100
|
+
mapped = dict(theme_data)
|
|
101
|
+
font = mapped.pop("font", None)
|
|
102
|
+
if font:
|
|
103
|
+
mapped.setdefault("font_family", font.get("body"))
|
|
104
|
+
mapped.setdefault("heading_font_family", font.get("heading"))
|
|
105
|
+
return Theme.model_validate(mapped)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _text_element(
|
|
109
|
+
element_id: str,
|
|
110
|
+
region: LayoutRegion,
|
|
111
|
+
text: str,
|
|
112
|
+
style: str | None,
|
|
113
|
+
) -> Element:
|
|
114
|
+
return Element(
|
|
115
|
+
id=element_id,
|
|
116
|
+
type="text",
|
|
117
|
+
position=region.position,
|
|
118
|
+
size=region.size,
|
|
119
|
+
style=style or region.style,
|
|
120
|
+
props={"text": text, "wrap": True},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _body_text(slide: ContentSlide) -> str:
|
|
125
|
+
return "\n".join(line for line in slide.body_lines if line is not None).strip()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _compile_title_slide(
|
|
129
|
+
slide: ContentSlide,
|
|
130
|
+
deck: ContentDeck,
|
|
131
|
+
layout: Layout,
|
|
132
|
+
defaults: dict[str, Any],
|
|
133
|
+
) -> Slide:
|
|
134
|
+
bindings = defaults.get("bindings", {})
|
|
135
|
+
elements: list[Element] = []
|
|
136
|
+
|
|
137
|
+
title_region = layout.regions.get(bindings.get("deck_title", "title"))
|
|
138
|
+
if title_region and slide.title:
|
|
139
|
+
elements.append(_text_element("title", title_region, slide.title, title_region.style))
|
|
140
|
+
|
|
141
|
+
subtitle_region = layout.regions.get(bindings.get("deck_subtitle", "subtitle"))
|
|
142
|
+
subtitle_text = slide.subtitle or deck.meta.title
|
|
143
|
+
if subtitle_region and subtitle_text:
|
|
144
|
+
elements.append(
|
|
145
|
+
_text_element("subtitle", subtitle_region, subtitle_text, subtitle_region.style)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
meta_region = layout.regions.get(bindings.get("deck_meta", "meta"))
|
|
149
|
+
if meta_region and slide.meta_lines:
|
|
150
|
+
elements.append(
|
|
151
|
+
_text_element(
|
|
152
|
+
"meta",
|
|
153
|
+
meta_region,
|
|
154
|
+
"\n".join(slide.meta_lines),
|
|
155
|
+
meta_region.style,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
background = layout.background
|
|
160
|
+
return Slide(
|
|
161
|
+
id=slide.meta.id or "title",
|
|
162
|
+
title=slide.title,
|
|
163
|
+
layout=layout.id,
|
|
164
|
+
background=background,
|
|
165
|
+
elements=elements,
|
|
166
|
+
interactions=[_default_prev_interaction(), _default_next_interaction()],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _compile_content_slide(
|
|
171
|
+
slide: ContentSlide,
|
|
172
|
+
layout: Layout,
|
|
173
|
+
defaults: dict[str, Any],
|
|
174
|
+
page_number: int,
|
|
175
|
+
deck_title: str,
|
|
176
|
+
) -> Slide:
|
|
177
|
+
bindings = defaults.get("bindings", {})
|
|
178
|
+
elements: list[Element] = []
|
|
179
|
+
|
|
180
|
+
header_region = layout.regions.get(bindings.get("slide_title", "header"))
|
|
181
|
+
if header_region and slide.title:
|
|
182
|
+
elements.append(_text_element("header", header_region, slide.title, header_region.style))
|
|
183
|
+
|
|
184
|
+
body_region = layout.regions.get(bindings.get("slide_body", "body"))
|
|
185
|
+
body = _body_text(slide)
|
|
186
|
+
if body_region and body:
|
|
187
|
+
elements.append(_text_element("body", body_region, body, body_region.style))
|
|
188
|
+
|
|
189
|
+
footer_region = layout.regions.get(bindings.get("slide_footer", "footer"))
|
|
190
|
+
footer_text = f"{deck_title} - Page {page_number}"
|
|
191
|
+
if footer_region:
|
|
192
|
+
elements.append(_text_element("footer", footer_region, footer_text, footer_region.style))
|
|
193
|
+
|
|
194
|
+
return Slide(
|
|
195
|
+
id=slide.meta.id or f"slide-{page_number}",
|
|
196
|
+
title=slide.title,
|
|
197
|
+
layout=layout.id,
|
|
198
|
+
background=layout.background,
|
|
199
|
+
elements=elements,
|
|
200
|
+
interactions=[_default_prev_interaction(), _default_next_interaction()],
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _default_prev_interaction() -> Interaction:
|
|
205
|
+
return Interaction(
|
|
206
|
+
id="prev_on_left",
|
|
207
|
+
trigger=Trigger(type="key", key="LEFT"),
|
|
208
|
+
action=Action(type="goto_slide", target="prev"),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _default_next_interaction() -> Interaction:
|
|
213
|
+
return Interaction(
|
|
214
|
+
id="next_on_right",
|
|
215
|
+
trigger=Trigger(type="key", key="RIGHT"),
|
|
216
|
+
action=Action(type="goto_slide", target="next"),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _resolve_layout(
|
|
221
|
+
layout_id: str | None,
|
|
222
|
+
layouts_by_id: dict[str, Layout],
|
|
223
|
+
overlay_defaults: dict[str, Any],
|
|
224
|
+
) -> tuple[Layout, dict[str, Any]]:
|
|
225
|
+
if layout_id == "title_slide" or layout_id == overlay_defaults.get("title_slide", {}).get(
|
|
226
|
+
"layout"
|
|
227
|
+
):
|
|
228
|
+
key = "title_slide"
|
|
229
|
+
else:
|
|
230
|
+
key = "content_slide"
|
|
231
|
+
defaults = overlay_defaults.get(key, {})
|
|
232
|
+
resolved_id = layout_id or defaults.get("layout", "content")
|
|
233
|
+
layout = layouts_by_id.get(resolved_id)
|
|
234
|
+
if layout is None:
|
|
235
|
+
raise ValueError(f"Unknown layout id: {resolved_id}")
|
|
236
|
+
return layout, defaults
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def compile_presentation(
|
|
240
|
+
deck: ContentDeck,
|
|
241
|
+
layout_overlay: dict[str, Any],
|
|
242
|
+
) -> Presentation:
|
|
243
|
+
"""Apply layout overlay to content AST and produce a presentation spec."""
|
|
244
|
+
layouts = _overlay_layouts(layout_overlay)
|
|
245
|
+
layouts_by_id = {layout.id: layout for layout in layouts}
|
|
246
|
+
overlay_defaults = layout_overlay.get("defaults", {})
|
|
247
|
+
|
|
248
|
+
slides: list[Slide] = []
|
|
249
|
+
content_page = 0
|
|
250
|
+
for slide in deck.slides:
|
|
251
|
+
layout_id = slide.meta.layout
|
|
252
|
+
layout, defaults = _resolve_layout(layout_id, layouts_by_id, overlay_defaults)
|
|
253
|
+
if layout.id == overlay_defaults.get("title_slide", {}).get("layout", "title_slide"):
|
|
254
|
+
slides.append(_compile_title_slide(slide, deck, layout, defaults))
|
|
255
|
+
else:
|
|
256
|
+
content_page += 1
|
|
257
|
+
slides.append(
|
|
258
|
+
_compile_content_slide(
|
|
259
|
+
slide,
|
|
260
|
+
layout,
|
|
261
|
+
defaults,
|
|
262
|
+
content_page,
|
|
263
|
+
deck.meta.title or deck.meta.id,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
presentation = Presentation(
|
|
268
|
+
version="0.1.0",
|
|
269
|
+
meta=Meta(
|
|
270
|
+
id=deck.meta.id,
|
|
271
|
+
title=deck.meta.title,
|
|
272
|
+
),
|
|
273
|
+
theme=_overlay_theme(layout_overlay),
|
|
274
|
+
layouts=layouts,
|
|
275
|
+
styles=_overlay_styles(layout_overlay),
|
|
276
|
+
assets=_overlay_assets(layout_overlay),
|
|
277
|
+
slides=slides,
|
|
278
|
+
)
|
|
279
|
+
validate_presentation(presentation)
|
|
280
|
+
return presentation
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def compile_files(md_path: str | Path, layout_path: str | Path) -> Presentation:
|
|
284
|
+
deck = parse_deckicorn_md(Path(md_path).read_text(encoding="utf-8"))
|
|
285
|
+
overlay = _load_layout_overlay(layout_path)
|
|
286
|
+
return compile_presentation(deck, overlay)
|