archi-cli 0.1.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,650 @@
1
+ """Render slide decks from a .archimate model to standalone HTML.
2
+
3
+ A deck is a TOML file (decks/*.toml) telling one linear story: title,
4
+ sections, embedded views, prose and bullets, closing. View slides embed
5
+ the real diagram via render_html.diagram_canvas, so the architecture
6
+ content always comes from the model; the deck file only carries the
7
+ narrative. Every deck renders to a single self-contained HTML file:
8
+ inline CSS and vanilla JS, with the pinned NLDD CSS from the CDN as the
9
+ only external reference (the deck uses NLDD design tokens, not web
10
+ components, so the component bundle is not loaded). Output is
11
+ deterministic: no timestamps (the optional title date comes verbatim
12
+ from the deck file), stable ordering, marker comment first line.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import html
17
+ import tomllib
18
+ from pathlib import Path
19
+
20
+ from .model import ModelError
21
+ from .render import MARKER, is_descendant, slugify, write_if_changed
22
+ from .render_html import (DIAGRAM_CSS, FAVICON, NLDD_CSS, absolute_boxes,
23
+ diagram_canvas, layer_css, legend_html)
24
+
25
+ FOCUS_MARGIN = 24 # canvas px around the focused subtree
26
+ FOCUS_MAX_SCALE = 2.2 # zoom cap when focusing; 1.4 for the full view
27
+
28
+ RIJKSBLAUW = "#154273"
29
+ GOUD = "#ffb612"
30
+
31
+ DECK_KEYS = {"title", "slug", "speaker", "affiliation", "date", "lead",
32
+ "slides"}
33
+ SLIDE_KEYS = {
34
+ "title": {"type", "title", "lead", "notes"},
35
+ "section": {"type", "title", "lead", "notes"},
36
+ "view": {"type", "view", "focus", "title", "intro", "notes"},
37
+ "text": {"type", "title", "lead", "body", "notes"},
38
+ "bullets": {"type", "title", "lead", "bullets", "gov", "notes"},
39
+ "closing": {"type", "title", "lead", "link", "notes"},
40
+ }
41
+ REQUIRED_SLIDE_KEYS = {
42
+ "section": ("title",),
43
+ "view": ("view",),
44
+ "text": ("body",),
45
+ "bullets": ("title", "bullets"),
46
+ "closing": ("title",),
47
+ }
48
+
49
+ DECK_CSS = """\
50
+ html, body { margin: 0; height: 100%; overflow: hidden; }
51
+ body { background: #154273;
52
+ font-family: var(--primitives-font-family-sans-serif,
53
+ system-ui, sans-serif); color: #ffffff; }
54
+ .slide { position: fixed; inset: 0; box-sizing: border-box;
55
+ display: flex; flex-direction: column; justify-content: center;
56
+ padding: 3rem clamp(2.5rem, 9vw, 11rem); overflow: hidden;
57
+ opacity: 0; visibility: hidden; transition: opacity 0.35s ease;
58
+ background: #154273; color: #ffffff; }
59
+ .slide.active { opacity: 1; visibility: visible; }
60
+ .slide h1, .slide h2 {
61
+ font-family: var(--primitives-font-family-serif, Georgia, serif);
62
+ font-weight: 700; line-height: 1.08; margin: 0; }
63
+ .accent { width: 72px; height: 6px; background: #ffb612;
64
+ border-radius: 3px; margin-bottom: 1.6rem; flex: none; }
65
+ .lead { font-size: clamp(1.2rem, 2.2vw, 1.9rem); line-height: 1.35;
66
+ max-width: 60ch; margin: 1.2rem 0 0; opacity: 0.92; }
67
+ .slide-title h1 { font-size: clamp(2.6rem, 6.5vw, 6rem); }
68
+ .byline { margin-top: 3rem; display: flex; flex-wrap: wrap;
69
+ gap: 0.4rem 1.6rem; font-size: 1.05rem; opacity: 0.85; }
70
+ .byline .speaker { font-weight: 600; }
71
+ .slide-section h2 { font-size: clamp(2.4rem, 5.5vw, 5rem); }
72
+ .slide-bullets h2, .slide-text h2 {
73
+ font-size: clamp(2rem, 4vw, 3.4rem); }
74
+ ul.bullets { margin: 2rem 0 0; padding: 0; list-style: none;
75
+ display: grid; gap: 1.1rem; max-width: 62ch;
76
+ font-size: clamp(1.15rem, 2vw, 1.7rem); line-height: 1.4; }
77
+ ul.bullets li { padding-left: 1.6rem; position: relative; }
78
+ ul.bullets li::before { content: ""; position: absolute; left: 0;
79
+ top: 0.55em; width: 0.55rem; height: 0.55rem; border-radius: 50%;
80
+ background: #ffb612; }
81
+ .gov-pill { display: inline-flex; align-self: flex-start;
82
+ margin-top: 2.2rem; padding: 0.35rem 0.9rem; border-radius: 999px;
83
+ border: 1px solid #ffb612; color: #ffb612; font-size: 0.85rem;
84
+ font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
85
+ .gov-note { margin: 0.7rem 0 0; font-size: 1.05rem; opacity: 0.9;
86
+ max-width: 60ch; }
87
+ .prose { margin: 1.6rem 0 0; display: grid; gap: 0.9rem;
88
+ font-size: clamp(1.05rem, 1.8vw, 1.45rem); line-height: 1.5;
89
+ max-width: 65ch; }
90
+ .prose p { margin: 0; }
91
+ /* extra bottom padding keeps the footer clear of the fixed chrome */
92
+ .slide-view { padding: 2.2rem clamp(2rem, 5vw, 4.5rem) 3.2rem; }
93
+ .view-head { flex: none; display: flex; align-items: baseline;
94
+ gap: 0.3rem 1.6rem; flex-wrap: wrap; margin-bottom: 1rem; }
95
+ .slide-view h2 { font-size: clamp(1.5rem, 2.6vw, 2.4rem); }
96
+ .slide-view .intro { margin: 0; font-size: 0.95rem; line-height: 1.4;
97
+ color: rgb(255 255 255 / 0.85); max-width: 100ch; flex: 1 1 30ch; }
98
+ /* the diagram sits on a light card inside the Rijksblauw slide, so a
99
+ view reads as part of the deck instead of a separate page */
100
+ .view-card { flex: 1; min-height: 0; display: flex;
101
+ flex-direction: column; background: #ffffff; border-radius: 14px;
102
+ padding: 10px 10px 0; box-shadow: 0 12px 40px rgb(0 0 0 / 0.35); }
103
+ /* the fit wrapper positions and scales the fixed-size diagram; the
104
+ transform itself is computed client-side (fitDiagrams) */
105
+ .view-fit { flex: 1; position: relative; overflow: hidden;
106
+ min-height: 0; border-radius: 8px; }
107
+ .view-fit .diagram { position: absolute; left: 0; top: 0;
108
+ transition: transform 1.1s cubic-bezier(0.22, 1, 0.36, 1); }
109
+ /* focus slides dim everything outside the focused subtree */
110
+ .box.dim { opacity: 0.22; }
111
+ .edge.dim { opacity: 0.12; }
112
+ .view-foot { flex: none; display: flex; justify-content: flex-end;
113
+ padding-top: 0.5rem; }
114
+ .view-open { font-size: 0.8rem; color: rgb(255 255 255 / 0.7);
115
+ white-space: nowrap; }
116
+ .slide-closing { align-items: center; text-align: center; }
117
+ .slide-closing .accent { margin-left: auto; margin-right: auto; }
118
+ .slide-closing h2 { font-size: clamp(2.6rem, 6vw, 5.5rem); }
119
+ .slide-closing .lead { margin-left: auto; margin-right: auto; }
120
+ .closing-link { margin-top: 2.5rem; font-size: 1.15rem; }
121
+ .closing-link a { color: #ffb612; }
122
+ .slide .notes { display: none; }
123
+ .hint { position: fixed; left: 1.1rem; bottom: 1rem; z-index: 10;
124
+ font-size: 0.75rem; color: #ffffff; opacity: 0.75;
125
+ background: rgb(21 66 115 / 0.85); padding: 0.25rem 0.7rem;
126
+ border-radius: 999px; }
127
+ .chrome { position: fixed; right: 1.1rem; bottom: 1rem; z-index: 10;
128
+ display: flex; align-items: center; gap: 0.6rem; }
129
+ .counter {
130
+ font-family: var(--primitives-font-family-monospace, monospace);
131
+ font-size: 0.8rem; letter-spacing: 0.06em; color: #ffffff;
132
+ background: rgb(21 66 115 / 0.85); padding: 0.25rem 0.7rem;
133
+ border-radius: 999px; }
134
+ .counter.autoplay::before { content: "\\25B6 "; color: #ffb612; }
135
+ .nav-btn { width: 2rem; height: 2rem; border-radius: 50%;
136
+ border: 1px solid rgb(255 255 255 / 0.4); cursor: pointer;
137
+ background: rgb(21 66 115 / 0.85); color: #ffffff;
138
+ font-size: 1rem; line-height: 1; }
139
+ .nav-btn:hover, .nav-btn:focus-visible { border-color: #ffb612; }
140
+ .notes-panel { position: fixed; right: 1.1rem; bottom: 3.6rem;
141
+ z-index: 12; width: min(420px, 80vw); max-height: 40vh;
142
+ overflow: auto; background: #ffffff; color: #202030;
143
+ border-radius: 12px; padding: 1rem 1.2rem;
144
+ box-shadow: 0 8px 30px rgb(0 0 0 / 0.35); font-size: 0.95rem; }
145
+ .notes-panel h3 { margin: 0 0 0.5rem; font-size: 0.8rem;
146
+ text-transform: uppercase; letter-spacing: 0.05em; color: #55555e; }
147
+ .progress { position: fixed; left: 0; right: 0; bottom: 0; height: 4px;
148
+ z-index: 11; background: rgb(255 255 255 / 0.15); }
149
+ .progress-fill { height: 100%; width: 0; background: #ffb612;
150
+ transition: width 0.3s ease; }
151
+ @page { size: A4 landscape; margin: 0; }
152
+ @media print {
153
+ html, body { overflow: visible; background: #ffffff; }
154
+ .slide { position: relative; inset: auto; height: 100vh;
155
+ opacity: 1; visibility: visible; transition: none;
156
+ break-after: page; page-break-after: always; }
157
+ .hint, .chrome, .notes-panel, .progress { display: none; }
158
+ }
159
+ @media (prefers-reduced-motion: reduce) {
160
+ .slide, .progress-fill, .view-fit .diagram { transition: none; }
161
+ }
162
+ """
163
+
164
+ DECK_JS = """\
165
+ (function () {
166
+ 'use strict';
167
+ var slides = Array.prototype.slice.call(
168
+ document.querySelectorAll('.slide'));
169
+ var total = slides.length;
170
+ var counter = document.querySelector('.counter');
171
+ var fill = document.querySelector('.progress-fill');
172
+ var notesPanel = document.querySelector('.notes-panel');
173
+ var notesBody = notesPanel.querySelector('.notes-body');
174
+ var current = 0;
175
+ var autoplayTimer = null;
176
+
177
+ function pad(n) { return (n < 10 ? '0' : '') + n; }
178
+
179
+ var reduceMotion = window.matchMedia(
180
+ '(prefers-reduced-motion: reduce)').matches;
181
+
182
+ function fitTransform(fit, x, y, w, h, cap) {
183
+ var fw = fit.clientWidth;
184
+ var fh = fit.clientHeight;
185
+ if (!fw || !fh || !w || !h) { return null; }
186
+ var s = Math.min(fw / w, fh / h, cap);
187
+ return 'translate(' + ((fw - w * s) / 2 - x * s).toFixed(1) + 'px, ' +
188
+ ((fh - h * s) / 2 - y * s).toFixed(1) + 'px) scale(' +
189
+ s.toFixed(4) + ')';
190
+ }
191
+
192
+ function applyFit(slide, zoomIn) {
193
+ var fit = slide.querySelector('.view-fit');
194
+ if (!fit) { return; }
195
+ var diagram = fit.querySelector('.diagram');
196
+ var full = fitTransform(fit, 0, 0,
197
+ parseFloat(fit.getAttribute('data-w')),
198
+ parseFloat(fit.getAttribute('data-h')), 1.4);
199
+ if (!diagram || !full) { return; }
200
+ var focus = fit.hasAttribute('data-fx') ? fitTransform(fit,
201
+ parseFloat(fit.getAttribute('data-fx')),
202
+ parseFloat(fit.getAttribute('data-fy')),
203
+ parseFloat(fit.getAttribute('data-fw')),
204
+ parseFloat(fit.getAttribute('data-fh')), 2.2) : null;
205
+ diagram.style.transformOrigin = '0 0';
206
+ if (zoomIn && focus && !reduceMotion) {
207
+ // start at the full view, then glide into the focused area
208
+ diagram.style.transition = 'none';
209
+ diagram.style.transform = full;
210
+ void diagram.offsetWidth;
211
+ diagram.style.transition = '';
212
+ diagram.style.transform = focus;
213
+ } else {
214
+ diagram.style.transition = 'none';
215
+ diagram.style.transform = focus || full;
216
+ void diagram.offsetWidth;
217
+ diagram.style.transition = '';
218
+ }
219
+ }
220
+
221
+ function fitDiagrams() {
222
+ slides.forEach(function (slide) { applyFit(slide, false); });
223
+ }
224
+
225
+ function updateNotes() {
226
+ var notes = slides[current].querySelector('.notes');
227
+ notesBody.innerHTML = notes ? notes.innerHTML
228
+ : '<em>Geen notities bij deze slide.</em>';
229
+ }
230
+
231
+ function show(i) {
232
+ current = Math.max(0, Math.min(total - 1, i));
233
+ slides.forEach(function (slide, j) {
234
+ slide.classList.toggle('active', j === current);
235
+ });
236
+ applyFit(slides[current], true);
237
+ counter.textContent = pad(current + 1) + '/' + pad(total);
238
+ fill.style.width = ((current + 1) / total * 100) + '%';
239
+ if (('#' + (current + 1)) !== location.hash) {
240
+ history.replaceState(null, '', '#' + (current + 1));
241
+ }
242
+ updateNotes();
243
+ }
244
+
245
+ function step(delta) {
246
+ var next = current + delta;
247
+ if (next >= total) { stopAutoplay(); return; }
248
+ show(next);
249
+ }
250
+
251
+ function startAutoplay() {
252
+ autoplayTimer = setInterval(function () { step(1); }, 6000);
253
+ counter.classList.add('autoplay');
254
+ }
255
+
256
+ function stopAutoplay() {
257
+ if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
258
+ counter.classList.remove('autoplay');
259
+ }
260
+
261
+ function toggleFullscreen() {
262
+ if (document.fullscreenElement) { document.exitFullscreen(); }
263
+ else { document.documentElement.requestFullscreen(); }
264
+ }
265
+
266
+ function fromHash() {
267
+ var n = parseInt(location.hash.slice(1), 10);
268
+ return isNaN(n) ? 0 : n - 1;
269
+ }
270
+
271
+ document.addEventListener('keydown', function (event) {
272
+ if (event.altKey || event.ctrlKey || event.metaKey) { return; }
273
+ switch (event.key) {
274
+ case 'ArrowRight': case 'PageDown': case ' ':
275
+ event.preventDefault(); step(1); break;
276
+ case 'ArrowLeft': case 'PageUp':
277
+ event.preventDefault(); step(-1); break;
278
+ case 'Home': event.preventDefault(); show(0); break;
279
+ case 'End': event.preventDefault(); show(total - 1); break;
280
+ case 'f': toggleFullscreen(); break;
281
+ case 'n': notesPanel.hidden = !notesPanel.hidden; break;
282
+ case 'a': autoplayTimer ? stopAutoplay() : startAutoplay(); break;
283
+ }
284
+ });
285
+
286
+ Array.prototype.forEach.call(
287
+ document.querySelectorAll('.nav-btn'), function (button) {
288
+ button.addEventListener('click', function () {
289
+ step(parseInt(button.getAttribute('data-nav'), 10));
290
+ });
291
+ });
292
+
293
+ window.addEventListener('hashchange', function () { show(fromHash()); });
294
+ window.addEventListener('resize', fitDiagrams);
295
+ window.addEventListener('beforeprint', fitDiagrams);
296
+
297
+ fitDiagrams();
298
+ show(fromHash());
299
+ })();
300
+ """
301
+
302
+
303
+ def load_deck(path: Path, model) -> dict:
304
+ """Parse and validate a deck TOML; errors name the file (in Dutch)."""
305
+ path = Path(path)
306
+ try:
307
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
308
+ except tomllib.TOMLDecodeError as exc:
309
+ raise ModelError(
310
+ f"Deck '{path.as_posix()}' is geen geldige TOML: {exc}") from exc
311
+ return validate_deck(data, model, source=path.as_posix(),
312
+ default_slug=slugify(path.stem))
313
+
314
+
315
+ def _check_str(value, source: str, where: str, key: str) -> str:
316
+ if not isinstance(value, str) or not value.strip():
317
+ raise ModelError(f"Deck '{source}'{where}: '{key}' moet een "
318
+ f"niet-lege tekst zijn")
319
+ return value
320
+
321
+
322
+ def _resolve_view(model, ref, source: str, n: int):
323
+ diagrams = model.diagrams()
324
+ for diagram in diagrams:
325
+ if diagram.get("id") == ref:
326
+ return diagram
327
+ matches = [d for d in diagrams if (d.get("name") or "") == ref]
328
+ if len(matches) == 1:
329
+ return matches[0]
330
+ if len(matches) > 1:
331
+ raise ModelError(f"Deck '{source}', slide {n}: viewnaam '{ref}' is "
332
+ f"niet uniek in het model; gebruik het view-id")
333
+ names = ", ".join(sorted(d.get("name") or d.get("id")
334
+ for d in diagrams)) or "(geen)"
335
+ raise ModelError(f"Deck '{source}', slide {n}: view '{ref}' bestaat "
336
+ f"niet in het model. Beschikbare views: {names}")
337
+
338
+
339
+ def validate_deck(deck: dict, model, source: str, default_slug: str) -> dict:
340
+ """Normalize a raw deck dict; raise ModelError (Dutch) on any problem."""
341
+ unknown = sorted(set(deck) - DECK_KEYS)
342
+ if unknown:
343
+ raise ModelError(f"Deck '{source}': onbekende sleutel "
344
+ f"'{unknown[0]}'. Toegestaan: "
345
+ + ", ".join(sorted(DECK_KEYS)))
346
+ if "title" not in deck:
347
+ raise ModelError(f"Deck '{source}': verplicht veld 'title' ontbreekt")
348
+ title = _check_str(deck["title"], source, "", "title")
349
+ for key in ("slug", "speaker", "affiliation", "date", "lead"):
350
+ if key in deck:
351
+ _check_str(deck[key], source, "", key)
352
+
353
+ slug = slugify(deck.get("slug") or default_slug)
354
+
355
+ raw_slides = deck.get("slides")
356
+ if not isinstance(raw_slides, list) or not raw_slides:
357
+ raise ModelError(f"Deck '{source}': 'slides' moet een niet-lege "
358
+ f"lijst van [[slides]]-tabellen zijn")
359
+
360
+ slides = []
361
+ for n, raw in enumerate(raw_slides, start=1):
362
+ where = f", slide {n}"
363
+ if not isinstance(raw, dict):
364
+ raise ModelError(f"Deck '{source}'{where}: elke slide moet een "
365
+ f"[[slides]]-tabel zijn")
366
+ kind = raw.get("type")
367
+ if kind not in SLIDE_KEYS:
368
+ raise ModelError(f"Deck '{source}'{where}: onbekend type "
369
+ f"'{kind}'. Toegestaan: "
370
+ + ", ".join(sorted(SLIDE_KEYS)))
371
+ unknown = sorted(set(raw) - SLIDE_KEYS[kind])
372
+ if unknown:
373
+ raise ModelError(f"Deck '{source}'{where}: onbekende sleutel "
374
+ f"'{unknown[0]}'. Toegestaan voor type "
375
+ f"'{kind}': "
376
+ + ", ".join(sorted(SLIDE_KEYS[kind])))
377
+ for key in REQUIRED_SLIDE_KEYS.get(kind, ()):
378
+ if key not in raw:
379
+ raise ModelError(f"Deck '{source}'{where}: verplicht veld "
380
+ f"'{key}' ontbreekt voor type '{kind}'")
381
+ slide = dict(raw)
382
+ for key in ("title", "lead", "intro", "notes", "gov", "body",
383
+ "focus"):
384
+ if key in slide:
385
+ _check_str(slide[key], source, where, key)
386
+ if kind == "view":
387
+ ref = _check_str(slide["view"], source, where, "view")
388
+ slide["diagram"] = _resolve_view(model, ref, source, n)
389
+ if kind == "bullets":
390
+ bullets = slide["bullets"]
391
+ if (not isinstance(bullets, list) or not bullets
392
+ or not all(isinstance(b, str) and b.strip()
393
+ for b in bullets)):
394
+ raise ModelError(f"Deck '{source}'{where}: 'bullets' moet "
395
+ f"een niet-lege lijst van teksten zijn")
396
+ if kind == "closing" and "link" in slide:
397
+ link = slide["link"]
398
+ if isinstance(link, str):
399
+ slide["link"] = {"href": link, "label": link}
400
+ elif isinstance(link, dict) and isinstance(link.get("href"), str):
401
+ slide["link"] = {"href": link["href"],
402
+ "label": link.get("label") or link["href"]}
403
+ else:
404
+ raise ModelError(f"Deck '{source}'{where}: 'link' moet een "
405
+ f"tekst of een tabel met 'href' (en "
406
+ f"optioneel 'label') zijn")
407
+ slides.append(slide)
408
+
409
+ return {"title": title, "slug": slug, "source": source,
410
+ "speaker": deck.get("speaker"),
411
+ "affiliation": deck.get("affiliation"),
412
+ "date": deck.get("date"),
413
+ "lead": deck.get("lead"), "slides": slides}
414
+
415
+
416
+ def _notes_html(slide: dict) -> str:
417
+ notes = slide.get("notes")
418
+ if not notes:
419
+ return ""
420
+ return f'<aside class="notes" hidden>{html.escape(notes)}</aside>'
421
+
422
+
423
+ def _slide_title(deck: dict, slide: dict) -> str:
424
+ title = slide.get("title") or deck["title"]
425
+ lead = slide.get("lead") or deck.get("lead")
426
+ parts = ['<div class="accent"></div>', f"<h1>{html.escape(title)}</h1>"]
427
+ if lead:
428
+ parts.append(f'<p class="lead">{html.escape(lead)}</p>')
429
+ byline = []
430
+ if deck.get("speaker"):
431
+ byline.append(f'<span class="speaker">'
432
+ f'{html.escape(deck["speaker"])}</span>')
433
+ if deck.get("affiliation"):
434
+ byline.append(f"<span>{html.escape(deck['affiliation'])}</span>")
435
+ if deck.get("date"):
436
+ byline.append(f"<span>{html.escape(deck['date'])}</span>")
437
+ if byline:
438
+ parts.append('<footer class="byline">' + "".join(byline)
439
+ + "</footer>")
440
+ return "".join(parts)
441
+
442
+
443
+ def _slide_section(slide: dict) -> str:
444
+ parts = ['<div class="accent"></div>',
445
+ f"<h2>{html.escape(slide['title'])}</h2>"]
446
+ if slide.get("lead"):
447
+ parts.append(f'<p class="lead">{html.escape(slide["lead"])}</p>')
448
+ return "".join(parts)
449
+
450
+
451
+ def _resolve_focus(model, diagram, ref: str, source: str, n: int):
452
+ """The focused element's box subtree in this view: (element, rect,
453
+ ids of everything outside the subtree)."""
454
+ boxes = absolute_boxes(diagram, model.id_index())
455
+ matches = [b for b in boxes if b["kind"] == "element"
456
+ and (b["element"].get("id") == ref
457
+ or (b["element"].get("name") or "") == ref)]
458
+ if len(matches) > 1:
459
+ raise ModelError(f"Deck '{source}', slide {n}: focus '{ref}' staat "
460
+ f"meer dan één keer in de view; gebruik het id")
461
+ if not matches:
462
+ containers = sorted((b["element"].get("name") or "?")
463
+ for b in boxes
464
+ if b["kind"] == "element" and b["container"])
465
+ hint = (f" Containers in deze view: {', '.join(containers)}"
466
+ if containers else "")
467
+ raise ModelError(f"Deck '{source}', slide {n}: focus '{ref}' niet "
468
+ f"gevonden in de view.{hint}")
469
+ focus = matches[0]
470
+ subtree = {b["id"] for b in boxes
471
+ if b is focus or is_descendant(b["node"], focus["node"])}
472
+ members = [b for b in boxes if b["id"] in subtree]
473
+ x = max(0, min(b["x"] for b in members) - FOCUS_MARGIN)
474
+ y = max(0, min(b["y"] for b in members) - FOCUS_MARGIN)
475
+ w = max(b["x"] + b["w"] for b in members) + FOCUS_MARGIN - x
476
+ h = max(b["y"] + b["h"] for b in members) + FOCUS_MARGIN - y
477
+ dim_ids = {b["id"] for b in boxes} - subtree
478
+ return focus["element"], (x, y, w, h), dim_ids
479
+
480
+
481
+ def _slide_view(model, slide: dict, n: int, source: str) -> str:
482
+ diagram = slide["diagram"]
483
+ name = diagram.get("name") or "(naamloze view)"
484
+ focus_el, rect, dim_ids = None, None, None
485
+ if slide.get("focus"):
486
+ focus_el, rect, dim_ids = _resolve_focus(
487
+ model, diagram, slide["focus"], source, n)
488
+ if focus_el is not None:
489
+ title = slide.get("title") or focus_el.get("name") or name
490
+ intro = (slide.get("intro")
491
+ or model.properties(focus_el).get("Omschrijving", "")
492
+ or model.documentation(focus_el))
493
+ else:
494
+ title = slide.get("title") or name
495
+ intro = slide.get("intro") or model.documentation(diagram)
496
+ canvas = diagram_canvas(model, diagram, marker_prefix=f"s{n}-",
497
+ ref_base="../", dim_ids=dim_ids)
498
+ intro_html = (f'<p class="intro">{html.escape(intro)}</p>'
499
+ if intro else "")
500
+ focus_attrs = (f' data-fx="{rect[0]}" data-fy="{rect[1]}"'
501
+ f' data-fw="{rect[2]}" data-fh="{rect[3]}"'
502
+ if rect else "")
503
+ return (
504
+ f'<header class="view-head"><h2>{html.escape(title)}</h2>'
505
+ f"{intro_html}</header>"
506
+ f'<div class="view-card">'
507
+ f'<div class="view-fit" data-w="{canvas["width"]}" '
508
+ f'data-h="{canvas["height"]}"{focus_attrs}>'
509
+ f'<div class="diagram" style="width:{canvas["width"]}px;'
510
+ f'height:{canvas["height"]}px">{canvas["svg"]}{canvas["divs"]}'
511
+ f"</div></div>"
512
+ f'{legend_html(canvas["boxes"], canvas["edges"])}'
513
+ f"</div>"
514
+ f'<footer class="view-foot">'
515
+ f'<a class="view-open" href="../{slugify(name)}.html">'
516
+ f"open als losse pagina</a></footer>")
517
+
518
+
519
+ def _slide_bullets(slide: dict) -> str:
520
+ parts = ['<div class="accent"></div>',
521
+ f"<h2>{html.escape(slide['title'])}</h2>"]
522
+ if slide.get("lead"):
523
+ parts.append(f'<p class="lead">{html.escape(slide["lead"])}</p>')
524
+ items = "".join(f"<li>{html.escape(b)}</li>" for b in slide["bullets"])
525
+ parts.append(f'<ul class="bullets">{items}</ul>')
526
+ if slide.get("gov"):
527
+ parts.append('<span class="gov-pill">Specifiek voor de '
528
+ "overheid</span>")
529
+ parts.append(f'<p class="gov-note">{html.escape(slide["gov"])}</p>')
530
+ return "".join(parts)
531
+
532
+
533
+ def _slide_text(slide: dict) -> str:
534
+ parts = ['<div class="accent"></div>']
535
+ if slide.get("title"):
536
+ parts.append(f"<h2>{html.escape(slide['title'])}</h2>")
537
+ if slide.get("lead"):
538
+ parts.append(f'<p class="lead">{html.escape(slide["lead"])}</p>')
539
+ paragraphs = "".join(
540
+ f"<p>{html.escape(p.strip())}</p>"
541
+ for p in slide["body"].split("\n\n") if p.strip())
542
+ parts.append(f'<div class="prose">{paragraphs}</div>')
543
+ return "".join(parts)
544
+
545
+
546
+ def _slide_closing(slide: dict) -> str:
547
+ parts = ['<div class="accent"></div>',
548
+ f"<h2>{html.escape(slide['title'])}</h2>"]
549
+ if slide.get("lead"):
550
+ parts.append(f'<p class="lead">{html.escape(slide["lead"])}</p>')
551
+ link = slide.get("link")
552
+ if link:
553
+ parts.append(f'<p class="closing-link">'
554
+ f'<a href="{html.escape(link["href"])}">'
555
+ f'{html.escape(link["label"])}</a></p>')
556
+ return "".join(parts)
557
+
558
+
559
+ def render_slide_html(model, deck: dict, slide: dict, n: int) -> str:
560
+ kind = slide["type"]
561
+ if kind == "title":
562
+ inner = _slide_title(deck, slide)
563
+ elif kind == "section":
564
+ inner = _slide_section(slide)
565
+ elif kind == "view":
566
+ inner = _slide_view(model, slide, n, deck.get("source", ""))
567
+ elif kind == "text":
568
+ inner = _slide_text(slide)
569
+ elif kind == "bullets":
570
+ inner = _slide_bullets(slide)
571
+ else:
572
+ inner = _slide_closing(slide)
573
+ return (f'<section class="slide slide-{kind}" id="s{n}">'
574
+ f"{inner}{_notes_html(slide)}</section>")
575
+
576
+
577
+ def render_deck_html(model, deck: dict) -> str:
578
+ """One self-contained HTML document for a validated deck."""
579
+ total = len(deck["slides"])
580
+ slides_html = "\n".join(
581
+ render_slide_html(model, deck, slide, n)
582
+ for n, slide in enumerate(deck["slides"], start=1))
583
+ return f"""{MARKER}
584
+ <!doctype html>
585
+ <html lang="nl">
586
+ <head>
587
+ <meta charset="utf-8">
588
+ <meta name="viewport" content="width=device-width, initial-scale=1">
589
+ <title>{html.escape(deck["title"])}</title>
590
+ <link rel="icon" href="{FAVICON}">
591
+ <link rel="stylesheet" href="{NLDD_CSS}">
592
+ <style>
593
+ {layer_css()}
594
+ {DIAGRAM_CSS}{DECK_CSS}</style>
595
+ </head>
596
+ <body>
597
+ <main class="deck">
598
+ {slides_html}
599
+ </main>
600
+ <div class="hint">&larr; &rarr; navigeren &middot; f volledig scherm &middot;
601
+ n notities &middot; a autoplay</div>
602
+ <div class="chrome">
603
+ <button class="nav-btn" data-nav="-1" aria-label="Vorige slide">&lsaquo;</button>
604
+ <button class="nav-btn" data-nav="1" aria-label="Volgende slide">&rsaquo;</button>
605
+ <div class="counter">01/{total:02d}</div>
606
+ </div>
607
+ <aside class="notes-panel" hidden><h3>Notities</h3>
608
+ <div class="notes-body"></div></aside>
609
+ <div class="progress"><div class="progress-fill"></div></div>
610
+ <script>
611
+ {DECK_JS}</script>
612
+ </body>
613
+ </html>
614
+ """
615
+
616
+
617
+ def render_all_slides(model, decks_dir, out_dir) -> tuple[list, list]:
618
+ """Render every decks/*.toml; returns (written, removed) path lists.
619
+ A missing or empty decks dir is not an error: nothing is rendered and
620
+ stale generated decks are cleaned up."""
621
+ out = Path(out_dir)
622
+ out.mkdir(parents=True, exist_ok=True)
623
+
624
+ decks = [load_deck(path, model)
625
+ for path in sorted(Path(decks_dir).glob("*.toml"))]
626
+
627
+ slugs = set()
628
+ for deck in decks:
629
+ if deck["slug"] in slugs:
630
+ raise ModelError(f"Meerdere decks renderen naar "
631
+ f"'{deck['slug']}.html'; geef een deck een "
632
+ f"uniek 'slug'-veld")
633
+ slugs.add(deck["slug"])
634
+
635
+ written, produced = [], set()
636
+ for deck in decks:
637
+ path = out / f"{deck['slug']}.html"
638
+ if write_if_changed(path, render_deck_html(model, deck)):
639
+ written.append(path)
640
+ produced.add(path.name)
641
+
642
+ removed = []
643
+ for stale in out.glob("*.html"):
644
+ if stale.name in produced:
645
+ continue
646
+ first_line = stale.read_text(encoding="utf-8").split("\n", 1)[0]
647
+ if first_line.strip() == MARKER:
648
+ stale.unlink()
649
+ removed.append(stale)
650
+ return written, removed