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.
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (79.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ deckicorn = deckicorn_cli.main:main
@@ -0,0 +1,5 @@
1
+ deckicorn_cli
2
+ deckicorn_md
3
+ deckicorn_project
4
+ deckicorn_runtime
5
+ deckicorn_spec
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())
@@ -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
+ ]
@@ -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)