@lutrin/core 1.0.0

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,1586 @@
1
+ /**
2
+ * HTML renderer: scenes → standalone HTML document.
3
+ *
4
+ * Same contract as the PPTX renderer: all the geometry comes from the layout
5
+ * engine (scenes in px on the 1280 × 720 grid), the renderer takes no layout
6
+ * decision of its own. Every slide is an absolutely positioned 1280 × 720 px
7
+ * surface, scaled to its container by a small inline script — the HTML
8
+ * rendering is therefore geometrically identical to the .pptx.
9
+ *
10
+ * The document is 100 % standalone (designed for a VS Code webview — live
11
+ * preview — where every external request is blocked by the CSP):
12
+ * - theme fonts inlined as base64 (woff2) when the theme provides them;
13
+ * - local and remote images inlined as data URIs;
14
+ * - charts, Lucide icons, MathJax equations and Mermaid diagrams inlined as
15
+ * SVG (vector: sharp at any zoom level) — every SVG we did not author
16
+ * goes through sanitizeSvg first.
17
+ *
18
+ * Three optional inline scripts equip the complete document (never the
19
+ * fragment mode): scaling (FIT_SCRIPT), steps on click (ANIM_SCRIPT) and the
20
+ * standalone presenter mode (PRESENT_SCRIPT — key P: full screen; key N:
21
+ * notes/timer view in a second window).
22
+ *
23
+ * API for a programmatic host (VS Code plugin):
24
+ * - renderDeckHtml(scenes, meta, baseDir) → { html, stats }
25
+ * - compileHtml(markdown, { baseDir }) → { html, stats, scenes, meta }
26
+ * The DOM is stable and addressable: every slide carries `id="slide-N"`,
27
+ * `data-slide` and `data-layout` (scroll restoration, editor → preview
28
+ * synchronization).
29
+ */
30
+
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+ import {
35
+ CHROME,
36
+ COLORS,
37
+ FONTS,
38
+ FONT_FILES,
39
+ LOGOS,
40
+ TYPE,
41
+ SPACE,
42
+ PAGE,
43
+ SEMANTIC,
44
+ TREND_INK,
45
+ panelStyle,
46
+ } from '../deck/tokens.mjs';
47
+ import { ALERT_BLOCK_TYPES, parseDeck } from '../deck/parse.mjs';
48
+ import { buildScenes } from '../deck/layout.mjs';
49
+ import { prepareDeckContext } from '../deck/context.mjs';
50
+ import { chartSvg } from '../deck/chart.mjs';
51
+ import { highlightLine } from '../deck/highlight.mjs';
52
+ import {
53
+ fetchRemoteImage,
54
+ iconSvg,
55
+ mathSvg,
56
+ renderMermaidCached,
57
+ resolveLocalImage,
58
+ vendorRemoteAssets,
59
+ } from '../deck/assets.mjs';
60
+
61
+ /** @font-face variants of the FONTS.body family — the .woff2 paths are
62
+ * derived from the .ttf of FONT_FILES (same names, .woff2 extension), so a
63
+ * theme that ships its .ttf ships its .woff2 alongside them. */
64
+ const FONT_FACE_VARIANTS = [
65
+ { key: 'regular', weight: 400, style: 'normal' },
66
+ { key: 'bold', weight: 700, style: 'normal' },
67
+ { key: 'italic', weight: 400, style: 'italic' },
68
+ ];
69
+
70
+ const esc = (s) =>
71
+ String(s)
72
+ .replace(/&/g, '&')
73
+ .replace(/</g, '&lt;')
74
+ .replace(/>/g, '&gt;')
75
+ .replace(/"/g, '&quot;');
76
+
77
+ /** Absolute positioning style of an element inside the slide. */
78
+ const at = (r, withH = false) =>
79
+ `left:${Math.round(r.x)}px;top:${Math.round(r.y)}px;width:${Math.round(r.w)}px;${withH ? `height:${Math.round(r.h)}px;` : ''}`;
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Text: IR runs → inline HTML
83
+ // ---------------------------------------------------------------------------
84
+
85
+ function runsHtml(runs) {
86
+ return runs
87
+ .map((r) => {
88
+ let s = esc(r.text);
89
+ if (r.code) s = `<code>${s}</code>`;
90
+ if (r.bold) s = `<strong>${s}</strong>`;
91
+ if (r.italic) s = `<em>${s}</em>`;
92
+ if (r.link) s = `<a href="${esc(r.link)}">${s}</a>`;
93
+ return s;
94
+ })
95
+ .join('');
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Inlined resources: images (data URI), SVG with unique identifiers
100
+ // ---------------------------------------------------------------------------
101
+
102
+ const MIME_BY_EXT = {
103
+ '.png': 'image/png',
104
+ '.jpg': 'image/jpeg',
105
+ '.jpeg': 'image/jpeg',
106
+ '.gif': 'image/gif',
107
+ '.webp': 'image/webp',
108
+ '.svg': 'image/svg+xml',
109
+ };
110
+
111
+ /** Cache key carrying the file's digest (path + mtime + size). Without it, in
112
+ * a warm process — `lutrin preview`, VS Code worker, Obsidian plugin — an
113
+ * image replaced on disk would serve its old content forever: the watcher
114
+ * recompiles, but the cache returned the stale base64. Same recipe as the
115
+ * font memo (fontFacesCss). */
116
+ function fileCacheKey(file) {
117
+ try {
118
+ const st = fs.statSync(file);
119
+ return `${file}|${st.mtimeMs}|${st.size}`;
120
+ } catch {
121
+ return file; // does not exist: the read will fail anyway
122
+ }
123
+ }
124
+
125
+ const dataUriCache = new Map();
126
+ function fileToDataUri(file) {
127
+ const key = fileCacheKey(file);
128
+ if (dataUriCache.has(key)) return dataUriCache.get(key);
129
+ let uri = null;
130
+ try {
131
+ const mime = MIME_BY_EXT[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
132
+ uri = `data:${mime};base64,${fs.readFileSync(file).toString('base64')}`;
133
+ } catch {
134
+ uri = null;
135
+ }
136
+ dataUriCache.set(key, uri);
137
+ return uri;
138
+ }
139
+
140
+ /** Makes an SVG's internal identifiers unique (Mermaid's styles and url(#…)
141
+ * collide as soon as two diagrams are inlined). */
142
+ function uniquifySvgIds(svg, prefix) {
143
+ const id = svg.match(/<svg[^>]*\sid="([^"]+)"/)?.[1];
144
+ if (!id) return svg;
145
+ return svg.replaceAll(`"${id}"`, `"${prefix}"`).replaceAll(`#${id}`, `#${prefix}`);
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Sanitizing SVG that came from outside
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Three sources of SVG enter the document without our having written them:
154
+ * the logos of a KIT, the Lucide icons (user cache or CDN) and the diagrams
155
+ * rendered by mmdc. All three go through sanitizeSvg, and that is the only
156
+ * inlining path.
157
+ *
158
+ * `kit/archive.mjs` promises in so many words that a kit is DATA — "nothing
159
+ * that is installed will ever be executed" — while `.svg` sits in its
160
+ * extension allow-list. Without what follows, a logo carrying `<script>` or
161
+ * `onload=` runs: the Obsidian plugin drops this HTML through innerHTML into
162
+ * an Electron renderer, with no CSP to catch it.
163
+ *
164
+ * The parsing is done by hand rather than by successive replacements: a
165
+ * `replace(/<script[^>]*>/gi, '')` is easy to bypass (mixed case, an
166
+ * unquoted attribute containing a `>`, entities in the value). We retokenize,
167
+ * we re-emit what we understood, and the rest does not come back out — the
168
+ * default is refusal, not a free pass.
169
+ *
170
+ * THE `<style>` CASE. An SVG inlined into HTML has no style scope of its own:
171
+ * its `<style>` is a GLOBAL stylesheet, reaching the whole deck. We keep it
172
+ * anyway, with its content filtered, for two reasons. First, mmdc puts ALL of
173
+ * a Mermaid diagram's formatting in there — dropping it would render every
174
+ * diagram in black on transparent, with nothing to signal it (`uniquifySvgIds`
175
+ * above exists precisely because these stylesheets tread on each other).
176
+ * Second, `archive.mjs`'s promise is about EXECUTION, not about appearance: a
177
+ * kit is made exactly to change the look of the deck, and its `theme.json` can
178
+ * already repaint it white through perfectly legitimate settings. The line to
179
+ * hold is therefore not "the kit must not be able to style anything", it is
180
+ * "nothing the kit brings executes and nothing goes out to the network". Hence
181
+ * the filter: no `@import`, no `@namespace`, and no `url()` that is not a
182
+ * `#local` fragment — neither in the stylesheet, nor in a `style` attribute,
183
+ * nor in a presentation attribute (`fill`, `filter`, `mask`…). That is what
184
+ * closes both the outgoing request and exfiltration by attribute selector +
185
+ * `url()`.
186
+ *
187
+ * AND ITS BODY IS NOT RAW TEXT. That is true of an HTML `<style>`, not of
188
+ * ours: ours is always inside `<svg>`, hence in FOREIGN CONTENT, where the
189
+ * parser merely "inserts a foreign element" without ever switching the
190
+ * tokenizer to RAWTEXT. The body is therefore read as MARKUP, and `img` is on
191
+ * the list of elements that break out of foreign content:
192
+ * `<style>…<img src=x onerror=…>` produces a real HTML image, and the handler
193
+ * fires. This is the only place where we re-emitted input without having
194
+ * retokenized it — exactly the gap that bypasses live on.
195
+ * (Observed in a browser, not deduced from the specification: the body
196
+ * `a{color:red}<img src=x onerror=…>` comes out of the tree as an HTML `<img>`
197
+ * and the handler runs, without any `<script>` appearing at all.)
198
+ * A stylesheet containing a `<` is therefore refused wholesale: no legitimate
199
+ * CSS from a logo or an mmdc diagram needs one.
200
+ */
201
+
202
+ /** Elements never re-emitted, ALONG WITH their content. */
203
+ const SVG_DROPPED_ELEMENTS = new Set([
204
+ 'script',
205
+ 'foreignobject',
206
+ 'iframe',
207
+ 'object',
208
+ 'embed',
209
+ 'link',
210
+ 'meta',
211
+ 'handler',
212
+ 'listener',
213
+ ]);
214
+ /** Animations: harmless, unless they target an attribute that is not
215
+ * (`<set attributeName="onload" to="…">`). */
216
+ const SVG_ANIMATION_ELEMENTS = new Set(['animate', 'animatetransform', 'animatemotion', 'set']);
217
+ /** Attributes whose value is a URL — hence a vector for navigation or, more
218
+ * seriously, for automatic fetching (see `svgUrlAllowed`). */
219
+ const SVG_URL_ATTRS = new Set([
220
+ 'href',
221
+ 'xlink:href',
222
+ 'src',
223
+ 'action',
224
+ 'formaction',
225
+ 'data',
226
+ 'ping',
227
+ ]);
228
+ /** Elements whose URL may only designate a fragment of the current document:
229
+ * a `<use href="https://…">` is a network request AND a DOM graft. `<image>`
230
+ * is not on the list — a `data:image/png` is legitimate there, and it is
231
+ * `svgUrlAllowed` that cuts off the remote case for it. */
232
+ const SVG_LOCAL_ONLY_ELEMENTS = new Set(['use', 'textpath', 'mpath']);
233
+ /** Attribute name re-emitted as is: nothing else can be a real name. */
234
+ const SVG_ATTR_NAME_RE = /^[A-Za-z_:][\w:.-]*$/;
235
+ /** Characters browsers ignore at the head of a URL: a "javascript:" broken up
236
+ * by tabs or exotic spaces is still a javascript:. Control characters and the
237
+ * zero-width joiner are precisely the TARGET of this class — flagging them as
238
+ * suspicious inverts the intent. */
239
+ const URL_NOISE_RE =
240
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: they are the target
241
+ // biome-ignore lint/suspicious/noMisleadingCharacterClass: same, ZWJ included
242
+ /[\u0000-\u0020\u00a0\u1680\u2000-\u200d\u2028\u2029\u202f\u205f\u3000\ufeff]/g;
243
+
244
+ /** CSS comments: removed BEFORE inspection, and it is the comment-free version
245
+ * that is re-emitted — what we read is exactly what we write. */
246
+ const CSS_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
247
+
248
+ /** Decodes a value's entities in order to INSPECT it (never to re-emit it):
249
+ * `&#106;avascript:` is a `javascript:` from the browser's point of view. */
250
+ function decodeEntities(s) {
251
+ return String(s)
252
+ .replace(/&#x([0-9a-f]+);?/gi, (_, h) => safeCodePoint(Number.parseInt(h, 16)))
253
+ .replace(/&#(\d+);?/g, (_, d) => safeCodePoint(Number.parseInt(d, 10)))
254
+ .replace(
255
+ /&(quot|apos|amp|lt|gt|Tab|NewLine|colon|sol|lpar|rpar);/gi,
256
+ (_, n) =>
257
+ ({
258
+ quot: '"',
259
+ apos: "'",
260
+ amp: '&',
261
+ lt: '<',
262
+ gt: '>',
263
+ tab: '\t',
264
+ newline: '\n',
265
+ colon: ':',
266
+ sol: '/',
267
+ lpar: '(',
268
+ rpar: ')',
269
+ })[n.toLowerCase()],
270
+ );
271
+ }
272
+
273
+ const safeCodePoint = (n) =>
274
+ Number.isInteger(n) && n >= 0 && n <= 0x10ffff ? String.fromCodePoint(n) : '';
275
+
276
+ /** Decodes CSS escapes in order to INSPECT a text: in CSS, `@\69 mport` is an
277
+ * `@import` and `\75 rl(…)` a `url(…)` — the browser's tokenizer resolves
278
+ * them before we do. */
279
+ function decodeCssEscapes(s) {
280
+ return String(s)
281
+ .replace(/\\([0-9a-f]{1,6})[ \t\n\f\r]?/gi, (_, h) => safeCodePoint(Number.parseInt(h, 16)))
282
+ .replace(/\\([^\r\n\f0-9a-f])/gi, '$1');
283
+ }
284
+
285
+ /**
286
+ * Normalized form of a URL as the browser will read it. The `\` is folded onto
287
+ * `/` — the URL parser conflates them for special schemes, so that `\\host/x`
288
+ * is the `//host/x` of the next paragraph. Above all, we do NOT decode CSS
289
+ * escapes here: inside a URL the `\` is a character, not an escape, and
290
+ * decoding it would make `\\host` read as `/host` — a local URL instead of a
291
+ * remote machine.
292
+ */
293
+ function urlProbe(raw) {
294
+ return decodeEntities(raw).replace(URL_NOISE_RE, '').replace(/\\/g, '/').toLowerCase();
295
+ }
296
+
297
+ /** Normalized form of a CSS text: there, the `\` IS an escape, and the
298
+ * browser's tokenizer resolves it before reading `@import` or `url(`.
299
+ * Comments are dropped HERE and not only in the body of a `<style>`: a
300
+ * presentation attribute is CSS too, and `u/*z*\/rl(…)` is a `url()` there
301
+ * for the browser while the raw text shows none. */
302
+ function cssProbe(raw) {
303
+ return decodeCssEscapes(decodeEntities(raw))
304
+ .replace(CSS_COMMENT_RE, '')
305
+ .replace(URL_NOISE_RE, '')
306
+ .replace(/\\/g, '/')
307
+ .toLowerCase();
308
+ }
309
+
310
+ /**
311
+ * Is a URL allowed into the document? A local fragment, a bitmap image as a
312
+ * data:, and — on an `<a>` only — http(s) and mailto. `javascript:` and
313
+ * `data:text/html` are code; so is `data:image/svg+xml` (a referenced SVG
314
+ * carries scripts of its own).
315
+ *
316
+ * FETCHING VERSUS NAVIGATION, and that is the WHOLE rule — but it is read on
317
+ * the element + attribute PAIR, not on the element alone. An `href` (or
318
+ * `xlink:href`) carried by an `<a>` is only followed if the reader clicks: it
319
+ * betrays no one at render time, and refusing it would rule out the legitimate
320
+ * case of the clickable logo. The OTHER URL attributes of an `<a>` do not
321
+ * navigate: `ping` is a fetching beacon — the browser sends a background POST
322
+ * on click, to a host the reader sees nowhere — and `src`, `data`, `action`,
323
+ * `formaction` carry no navigation meaning on an `<a>`. They therefore stay
324
+ * under the common regime. Everywhere
325
+ * else — `<image>`, `<feImage>`, a legacy `href` on `<filter>`, `<pattern>`,
326
+ * `<marker>` or a gradient — the URL is LOADED, on its own, at render time, on
327
+ * every recipient of the `.html`: it is a tracking beacon (IP, User-Agent,
328
+ * timestamp, identifier planted by the kit's author), and it makes the promise
329
+ * of `SECURITY.md` §2 false — "once opened, the presentation has no network
330
+ * dependency left". Hence the default: outside `<a>`, no remote scheme,
331
+ * whatever the element. The refusal bears on the CLASS of the URL, not on a
332
+ * list of elements that would have to be kept up to date — that is the only
333
+ * shape that covers the loading element we will have forgotten — and, through
334
+ * `SVG_NAVIGATION_ATTRS`, the fetching attribute we will have forgotten on `<a>`.
335
+ */
336
+ /** The only attributes through which an `<a>` NAVIGATES. Closed list: any
337
+ * other URL attribute of an `<a>` loads without a click, or means nothing. */
338
+ const SVG_NAVIGATION_ATTRS = new Set(['href', 'xlink:href']);
339
+
340
+ function svgUrlAllowed(raw, tagName, attrName) {
341
+ const v = urlProbe(raw);
342
+ if (SVG_LOCAL_ONLY_ELEMENTS.has(tagName)) return v.startsWith('#');
343
+ if (!v) return true;
344
+ // `//host/x` has no scheme but does designate a remote machine: the browser
345
+ // lends it the document's own. It is an absolute URL in disguise.
346
+ if (v.startsWith('//')) return false;
347
+ const scheme = /^([a-z][a-z0-9+.-]*):/.exec(v);
348
+ if (!scheme) return true; // relative or fragment: no scheme to refuse
349
+ if (
350
+ tagName === 'a' &&
351
+ SVG_NAVIGATION_ATTRS.has(attrName) &&
352
+ ['http', 'https', 'mailto'].includes(scheme[1])
353
+ )
354
+ return true;
355
+ return /^data:image\/(png|jpeg|gif|webp);/.test(v);
356
+ }
357
+
358
+ /**
359
+ * Any `url()` cited in CSS may only designate a fragment of the current
360
+ * document. This is the rule that cuts off the network: a remote `url()` is an
361
+ * outgoing request at render time and — paired with an attribute selector — an
362
+ * exfiltration channel that reads the DOM character by character.
363
+ */
364
+ function cssUrlsAreLocal(css) {
365
+ const probe = cssProbe(css);
366
+ let i = 0;
367
+ for (;;) {
368
+ const at = probe.indexOf('url(', i);
369
+ if (at < 0) return true;
370
+ const close = probe.indexOf(')', at);
371
+ if (close < 0) return false; // url() never closed: we could not read it
372
+ const target = probe.slice(at + 4, close).replace(/^["']|["']$/g, '');
373
+ if (target && !target.startsWith('#')) return false;
374
+ i = close + 1;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Is a stylesheet allowed in? No `@import` and no `@namespace` (they go and
380
+ * fetch a document elsewhere), and no remote `url()`. The verdict bears on the
381
+ * WHOLE stylesheet: excising the offending rule would mean rewriting CSS, and
382
+ * a miscounted brace reactivates everything that follows — we refuse
383
+ * wholesale, which is what refusal by default means.
384
+ */
385
+ function cssStylesheetAllowed(css) {
386
+ const probe = cssProbe(css);
387
+ if (probe.includes('@import') || probe.includes('@namespace')) return false;
388
+ return cssUrlsAreLocal(css);
389
+ }
390
+
391
+ /**
392
+ * The body of a `<style>` is re-emitted as is — it is the only input we do not
393
+ * retokenize. But inside `<svg>` the parser reads it as MARKUP (see the
394
+ * header), so that a `<` there opens a real HTML tag. We therefore refuse the
395
+ * stylesheet as soon as it carries a literal `<`: that is THE vector, verified
396
+ * in a browser.
397
+ *
398
+ * The entity form is refused IN ADDITION, out of caution and not out of
399
+ * necessity: having checked, `&lt;img …>` stays text in the body of an SVG
400
+ * `<style>` (the tokenizer recognizes tags BEFORE resolving entities, so a
401
+ * character reference cannot reopen the "tag open" state). We refuse it all
402
+ * the same because no logo CSS needs it and because the cost of a refusal is
403
+ * nil, where the cost of an oversight is not.
404
+ */
405
+ function cssHasMarkup(css) {
406
+ return String(css).includes('<') || decodeEntities(css).includes('<');
407
+ }
408
+
409
+ /**
410
+ * Reads the tag beginning at `start` (`svg[start] === '<'`).
411
+ * @returns {{name, closing, selfClose, attrs, end}|null} null if this `<` does
412
+ * not open a tag (it is then part of the text).
413
+ */
414
+ function readSvgTag(s, start) {
415
+ let i = start + 1;
416
+ const closing = s[i] === '/';
417
+ if (closing) i++;
418
+ const nm = /^[A-Za-z_][\w:.-]*/.exec(s.slice(i));
419
+ if (!nm) return null;
420
+ const name = nm[0];
421
+ i += name.length;
422
+ const attrs = [];
423
+ let selfClose = false;
424
+ for (;;) {
425
+ while (i < s.length && /\s/.test(s[i])) i++;
426
+ if (i >= s.length) break;
427
+ if (s[i] === '>') {
428
+ i++;
429
+ break;
430
+ }
431
+ if (s[i] === '/') {
432
+ selfClose = true;
433
+ i++;
434
+ continue;
435
+ }
436
+ const am = /^[^\s=/>]+/.exec(s.slice(i));
437
+ if (!am) {
438
+ i++;
439
+ continue;
440
+ }
441
+ const aname = am[0];
442
+ i += aname.length;
443
+ let j = i;
444
+ while (j < s.length && /\s/.test(s[j])) j++;
445
+ let value = null;
446
+ if (s[j] === '=') {
447
+ j++;
448
+ while (j < s.length && /\s/.test(s[j])) j++;
449
+ if (s[j] === '"' || s[j] === "'") {
450
+ const quote = s[j];
451
+ const close = s.indexOf(quote, ++j);
452
+ value = close < 0 ? s.slice(j) : s.slice(j, close);
453
+ i = close < 0 ? s.length : close + 1;
454
+ } else {
455
+ // unquoted value: it stops at the first whitespace or `>`
456
+ const vm = /^[^\s>]*/.exec(s.slice(j));
457
+ value = vm[0];
458
+ i = j + value.length;
459
+ }
460
+ }
461
+ // no `=`: boolean attribute; `i` stays after the name, the whitespace
462
+ // swallowed by `j` will be read again on the next pass
463
+ attrs.push({ name: aname, value });
464
+ }
465
+ return { name, closing, selfClose, attrs, end: i };
466
+ }
467
+
468
+ /** Re-emits an opening tag, attribute by attribute — whatever is not
469
+ * explicitly admitted is left aside. */
470
+ function svgTagHtml(tag) {
471
+ const tagName = tag.name.toLowerCase();
472
+ let out = `<${tag.name}`;
473
+ for (const a of tag.attrs) {
474
+ if (!SVG_ATTR_NAME_RE.test(a.name)) continue;
475
+ const an = a.name.toLowerCase();
476
+ if (/^on/.test(decodeEntities(an))) continue; // any event handler
477
+ if (SVG_URL_ATTRS.has(an) && a.value != null && !svgUrlAllowed(a.value, tagName, an)) continue;
478
+ // `fill`, `filter`, `mask`, `clip-path`, `marker-*` and `style` carry
479
+ // `url()`: they are presentation attributes, not URLs, so they escape
480
+ // SVG_URL_ATTRS — and a remote `url()` there sends a request out over the
481
+ // network just as surely.
482
+ if (a.value != null && !cssUrlsAreLocal(a.value)) continue;
483
+ if (an === 'style' && a.value != null && !cssStylesheetAllowed(a.value)) continue;
484
+ out +=
485
+ a.value == null
486
+ ? ` ${a.name}`
487
+ : ` ${a.name}="${a.value.replace(/"/g, '&quot;').replace(/</g, '&lt;')}"`;
488
+ }
489
+ return out + (tag.selfClose ? '/>' : '>');
490
+ }
491
+
492
+ /** An animation targeting a handler or a URL amounts to writing the forbidden
493
+ * attribute — same verdict. */
494
+ function svgAnimationForbidden(tagName, tag) {
495
+ if (!SVG_ANIMATION_ELEMENTS.has(tagName)) return false;
496
+ const target = decodeEntities(
497
+ tag.attrs.find((a) => a.name.toLowerCase() === 'attributename')?.value ?? '',
498
+ )
499
+ .replace(URL_NOISE_RE, '')
500
+ .toLowerCase();
501
+ return /^on/.test(target) || SVG_URL_ATTRS.has(target);
502
+ }
503
+
504
+ /**
505
+ * Sanitizes an SVG that came from outside and prepares it for inlining (the
506
+ * `<?xml … ?>` prologue, the comments and the declarations are dropped along
507
+ * the way: none of that means anything in an HTML document).
508
+ */
509
+ export function sanitizeSvg(svg) {
510
+ if (typeof svg !== 'string' || !svg) return '';
511
+ let out = '';
512
+ let i = 0;
513
+ let dropping = null; // { name, depth } — dropped element, content included
514
+ while (i < svg.length) {
515
+ const lt = svg.indexOf('<', i);
516
+ if (lt < 0) {
517
+ if (!dropping) out += svg.slice(i);
518
+ break;
519
+ }
520
+ if (!dropping) out += svg.slice(i, lt);
521
+ if (svg.startsWith('<!--', lt)) {
522
+ const e = svg.indexOf('-->', lt + 4);
523
+ i = e < 0 ? svg.length : e + 3;
524
+ continue;
525
+ }
526
+ if (svg.startsWith('<?', lt) || svg.startsWith('<!', lt)) {
527
+ const e = svg.indexOf('>', lt);
528
+ i = e < 0 ? svg.length : e + 1;
529
+ continue;
530
+ }
531
+ const tag = readSvgTag(svg, lt);
532
+ if (!tag) {
533
+ if (!dropping) out += '&lt;'; // literal `<` from the text
534
+ i = lt + 1;
535
+ continue;
536
+ }
537
+ i = tag.end;
538
+ const name = tag.name.toLowerCase();
539
+ if (dropping) {
540
+ // the content of a dropped element is not re-emitted; only its closing
541
+ // (nesting included) is of interest. A tag that is never closed
542
+ // therefore carries away the end of the document — in the right
543
+ // direction.
544
+ if (name === dropping.name) {
545
+ if (tag.closing) {
546
+ if (--dropping.depth <= 0) dropping = null;
547
+ } else if (!tag.selfClose) dropping.depth++;
548
+ }
549
+ continue;
550
+ }
551
+ if (SVG_DROPPED_ELEMENTS.has(name) || svgAnimationForbidden(name, tag)) {
552
+ if (!tag.closing && !tag.selfClose) dropping = { name, depth: 1 };
553
+ continue;
554
+ }
555
+ if (name === 'style' && !tag.closing && !tag.selfClose) {
556
+ // The body runs to the first `</style`, whatever it contains: that is
557
+ // where the element closes, whether the parser sees text or markup in
558
+ // it. We therefore take it in one block — but we re-emit it only if it
559
+ // is CSS and NOTHING BUT CSS (cssHasMarkup), failing which it would be
560
+ // the only input to get in without having been retokenized.
561
+ const rest = svg.slice(i).search(/<\/style/i);
562
+ const raw = (rest < 0 ? svg.slice(i) : svg.slice(i, i + rest)).replace(CSS_COMMENT_RE, '');
563
+ if (rest < 0) i = svg.length;
564
+ else {
565
+ const closeTag = readSvgTag(svg, i + rest);
566
+ i = closeTag ? closeTag.end : svg.length;
567
+ }
568
+ if (!cssHasMarkup(raw) && cssStylesheetAllowed(raw))
569
+ out += `${svgTagHtml(tag)}${raw}</style>`;
570
+ continue;
571
+ }
572
+ out += tag.closing ? `</${tag.name}>` : svgTagHtml(tag);
573
+ }
574
+ return out;
575
+ }
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // Block rendering (same regions as the PPTX renderer)
579
+ // ---------------------------------------------------------------------------
580
+
581
+ /** Ink imposed by the layout (dark layers, quadrant titles…). */
582
+ const ink = (block) => (block.color ? `color:#${block.color};` : '');
583
+
584
+ function htmlPara(block, r) {
585
+ return `<p class="para el" style="${at(r)}${ink(block)}">${runsHtml(block.runs)}</p>`;
586
+ }
587
+
588
+ function htmlHeading(block, r) {
589
+ // `size` (pt) and `align`: key message of the focus layout — otherwise a slot title
590
+ const extra = `${block.size ? `font-size:${block.size}pt;` : ''}${block.align === 'center' ? 'text-align:center;' : ''}`;
591
+ return `<h3 class="slot-heading el" style="${at(r)}${ink(block)}${extra}">${runsHtml(block.runs)}</h3>`;
592
+ }
593
+
594
+ /** Rebuilds the nesting from the flattened items `{ runs, level }`. */
595
+ function htmlBullets(block, r) {
596
+ const tag = block.ordered ? 'ol' : 'ul';
597
+ // `startAt`: chunk of a numbered list split by pagination. Only the root
598
+ // list resumes the count; sub-lists start again from 1.
599
+ const start = block.ordered && block.startAt > 1 ? ` start="${block.startAt}"` : '';
600
+ let out = '';
601
+ let level = -1;
602
+ for (const it of block.items) {
603
+ while (level < it.level) {
604
+ out += `<${tag}${level < 0 ? start : ''}>`;
605
+ level++;
606
+ }
607
+ while (level > it.level) {
608
+ out += `</${tag}>`;
609
+ level--;
610
+ }
611
+ out += `<li>${runsHtml(it.runs)}</li>`;
612
+ }
613
+ while (level >= 0) {
614
+ out += `</${tag}>`;
615
+ level--;
616
+ }
617
+ return `<div class="bullets el" style="${at(r)}${ink(block)}">${out}</div>`;
618
+ }
619
+
620
+ function htmlCode(block, r) {
621
+ const lines = block.source.split('\n').map((line) =>
622
+ highlightLine(line, block.lang)
623
+ .map((seg) => {
624
+ const t = esc(seg.text);
625
+ // the contract is `kind`, never the color: a theme can change the
626
+ // design tokens without silently breaking the hl-* classes
627
+ if (seg.kind === 'string') return `<span class="hl-str">${t}</span>`;
628
+ if (seg.kind === 'keyword') return `<span class="hl-kw">${t}</span>`;
629
+ if (seg.kind === 'comment') return `<span class="hl-com">${t}</span>`;
630
+ return t;
631
+ })
632
+ .join(''),
633
+ );
634
+ return `<pre class="code el" style="${at(r, true)}">${lines.join('\n')}</pre>`;
635
+ }
636
+
637
+ function htmlTable(block, r) {
638
+ const row = (cells, tag) =>
639
+ `<tr>${cells.map((c) => `<${tag}>${runsHtml(c)}</${tag}>`).join('')}</tr>`;
640
+ const head = block.header.length ? `<thead>${row(block.header, 'th')}</thead>` : '';
641
+ const body = block.rows.map((cells) => row(cells, 'td')).join('');
642
+ return `<table class="table el" style="${at(r)}">${head}<tbody>${body}</tbody></table>`;
643
+ }
644
+
645
+ function htmlAlert(block, r) {
646
+ const kind = SEMANTIC[block.kind] ? block.kind : 'info';
647
+ const sem = SEMANTIC[kind];
648
+ // outside ALERT_BLOCK_TYPES: ignored (height not reserved by blockHeight,
649
+ // reported to the author by the ALERT_CONTENT_DROPPED diagnostic)
650
+ const inner = block.blocks
651
+ .filter((b) => ALERT_BLOCK_TYPES.has(b.type))
652
+ .map((b) => {
653
+ if (b.type === 'para') return `<p>${runsHtml(b.runs)}</p>`;
654
+ return `<ul>${b.items.map((it) => `<li>${runsHtml(it.runs)}</li>`).join('')}</ul>`;
655
+ })
656
+ .join('');
657
+ return (
658
+ `<div class="alert alert-${kind} el" style="${at(r, true)}">` +
659
+ `<div class="alert-label">${esc(sem.label)}</div>${inner}</div>`
660
+ );
661
+ }
662
+
663
+ /** Canonical trend arrow (the glyph typed in is not preserved). */
664
+ const TREND_GLYPH = { up: '↑', down: '↓', flat: '→' };
665
+
666
+ function htmlMetric(block, r) {
667
+ const t = block.trend;
668
+ const trend = t
669
+ ? `<div class="metric-trend" style="color:#${TREND_INK[t.sentiment]}">${TREND_GLYPH[t.dir]} ${esc(t.text)}</div>`
670
+ : '';
671
+ return (
672
+ `<div class="metric el" style="${at(r, true)}">` +
673
+ `<div class="metric-value">${esc(block.value)}</div>` +
674
+ `<div class="metric-label">${esc(block.label)}</div>${trend}</div>`
675
+ );
676
+ }
677
+
678
+ // ---------------------------------------------------------------------------
679
+ // Blocks synthesized by the structured layouts (comparison, pillars,
680
+ // timeline, layers, swot) — never coming straight from the DSL
681
+ // ---------------------------------------------------------------------------
682
+
683
+ function htmlPanel(block, r) {
684
+ const style = panelStyle(block);
685
+ const border = style.line ? `border:${style.line.width}px solid #${style.line.color};` : '';
686
+ const radius =
687
+ block.variant === 'accent'
688
+ ? 2
689
+ : block.variant === 'layer' || block.variant === 'semantic'
690
+ ? 4
691
+ : 8;
692
+ const accent =
693
+ block.variant === 'pillar' && block.accent !== false ? '<div class="panel-accent"></div>' : '';
694
+ return (
695
+ `<div class="panel el" style="${at(r, true)}background:#${style.fill};${border}border-radius:${radius}px">` +
696
+ `${accent}</div>`
697
+ );
698
+ }
699
+
700
+ function htmlTimelineAxis(block, r) {
701
+ const arrow = block.arrow !== false;
702
+ const cls = `${block.vertical ? 'tl-axis-v' : 'tl-axis'}${arrow ? '' : ' tl-no-arrow'}`;
703
+ const head = arrow ? `<div class="${block.vertical ? 'tl-arrow-v' : 'tl-arrow'}"></div>` : '';
704
+ return `<div class="${cls} el" style="${at(r, true)}">${head}</div>`;
705
+ }
706
+
707
+ function htmlTimelineDot(block, r) {
708
+ return `<div class="tl-dot el" style="${at(r, true)}">${block.numbered === false ? '' : block.index}</div>`;
709
+ }
710
+
711
+ function htmlQuote(block, r) {
712
+ const cite = block.cite ? `<figcaption>— ${esc(block.cite)}</figcaption>` : '';
713
+ return (
714
+ `<figure class="quote el" style="${at(r, true)}">` +
715
+ `<div class="quote-mark">"</div><blockquote>${runsHtml(block.runs)}</blockquote>${cite}</figure>`
716
+ );
717
+ }
718
+
719
+ function htmlImage(block, r, ctx, { fullBleed = false } = {}) {
720
+ const file = /^https?:/.test(block.src)
721
+ ? (ctx.remote.get(block.src) ?? null)
722
+ : resolveLocalImage(ctx.imageRoots, block.src);
723
+ const uri = file && fs.existsSync(file) ? fileToDataUri(file) : null;
724
+ if (uri) {
725
+ const cover = fullBleed || block.role === 'background' || block.role === 'cover';
726
+ return `<img class="el ${cover ? 'img-cover' : 'img-contain'}" style="${at(r, true)}" src="${uri}" alt="${esc(block.alt ?? '')}">`;
727
+ }
728
+ return (
729
+ `<div class="placeholder el" style="${at(r, true)}">` +
730
+ `<span>[image: ${esc(block.alt || block.src)}]</span></div>`
731
+ );
732
+ }
733
+
734
+ function htmlMermaid(block, r, ctx) {
735
+ const svg = ctx.mermaid.get(block);
736
+ if (svg) return `<div class="figure mermaid el" style="${at(r, true)}">${svg}</div>`;
737
+ // faithful fallback: source shown as a code block + a caption
738
+ return `${htmlCode({ lang: 'mermaid', source: block.source }, { ...r, h: r.h - 24 })}<div class="fallback-caption el" style="left:${Math.round(r.x)}px;top:${Math.round(r.y + r.h - 22)}px;width:${Math.round(r.w)}px;">Mermaid diagram — install @mermaid-js/mermaid-cli for graphical rendering</div>`;
739
+ }
740
+
741
+ function htmlIcon(block, r, ctx) {
742
+ const svg = ctx.icons.get(block);
743
+ if (!svg) return ''; // icon not found: nothing rather than a broken box
744
+ const size = Math.round(Math.min(r.w, r.h, 160));
745
+ // flush left, like the text (the brand is left-aligned)
746
+ return (
747
+ `<div class="icon el" style="${at(r, true)}">` +
748
+ `<div class="icon-box" style="width:${size}px;height:${size}px">${sanitizeSvg(svg)}</div></div>`
749
+ );
750
+ }
751
+
752
+ function htmlMath(block, r, ctx) {
753
+ const m = ctx.math.get(block);
754
+ if (m) {
755
+ // natural size of the equation, centered; shrunk only if it overflows
756
+ const scale = Math.min(1, r.w / m.displayW, r.h / m.displayH);
757
+ const w = m.displayW * scale;
758
+ const h = m.displayH * scale;
759
+ const svg = m.svg.replace(/^<svg[^>]*>/, (tag) =>
760
+ tag
761
+ .replace(/width="[^"]+"/, `width="${w.toFixed(1)}px"`)
762
+ .replace(/height="[^"]+"/, `height="${h.toFixed(1)}px"`),
763
+ );
764
+ return `<div class="figure el" style="${at(r, true)}">${svg}</div>`;
765
+ }
766
+ return `${htmlCode({ lang: 'latex', source: block.source }, { ...r, h: r.h - 24 })}<div class="fallback-caption el" style="left:${Math.round(r.x)}px;top:${Math.round(r.y + r.h - 22)}px;width:${Math.round(r.w)}px;">LaTeX equation — install mathjax-full for graphical rendering</div>`;
767
+ }
768
+
769
+ /** Charts: in-house SVG at the slot's exact dimensions, inlined as is. */
770
+ function htmlChart(block, r) {
771
+ return `<div class="figure el" style="${at(r, true)}">${chartSvg(block, r.w, r.h)}</div>`;
772
+ }
773
+
774
+ /** Exported for the parity test with the PPTX renderer: the two tables must
775
+ * cover exactly the same block types. */
776
+ export const BLOCK_RENDERERS = {
777
+ para: htmlPara,
778
+ heading: htmlHeading,
779
+ bullets: htmlBullets,
780
+ code: htmlCode,
781
+ table: htmlTable,
782
+ alert: htmlAlert,
783
+ metric: htmlMetric,
784
+ quote: htmlQuote,
785
+ image: htmlImage,
786
+ mermaid: htmlMermaid,
787
+ icon: htmlIcon,
788
+ math: htmlMath,
789
+ chart: htmlChart,
790
+ panel: htmlPanel,
791
+ 'timeline-axis': htmlTimelineAxis,
792
+ 'timeline-dot': htmlTimelineDot,
793
+ };
794
+
795
+ // ---------------------------------------------------------------------------
796
+ // Slide chrome (same geometries as the PPTX masters)
797
+ // ---------------------------------------------------------------------------
798
+
799
+ const logoSvgCache = new Map(); // key: file digest — safe across themes AND after a hot edit
800
+ function logoHtml(file, heightPx, cls = '') {
801
+ if (!file) return ''; // theme without a signature (generic default)
802
+ const key = fileCacheKey(file);
803
+ let inner = logoSvgCache.get(key);
804
+ if (inner === undefined) {
805
+ if (!fs.existsSync(file)) inner = '';
806
+ else if (path.extname(file).toLowerCase() === '.svg')
807
+ inner = sanitizeSvg(fs.readFileSync(file, 'utf8'));
808
+ else {
809
+ // bitmap logo (theme): inlined as a data URI, resized by the style
810
+ const uri = fileToDataUri(file);
811
+ inner = uri ? `<img src="${uri}" alt="">` : '';
812
+ }
813
+ logoSvgCache.set(key, inner);
814
+ }
815
+ // decorative: the signature repeats on every slide, no point having screen
816
+ // readers announce it
817
+ return inner
818
+ ? `<div class="logo ${cls}" aria-hidden="true" style="height:${heightPx}px">${inner}</div>`
819
+ : '';
820
+ }
821
+
822
+ function coverHtml(scene) {
823
+ const parts = [logoHtml(LOGOS.coverSvg, CHROME.cover.logoH, 'logo-cover')];
824
+ parts.push('<div class="cover-bar"></div>');
825
+ parts.push(`<h1 class="cover-title">${esc(scene.title ?? '')}</h1>`);
826
+ if (scene.subtitle) parts.push(`<p class="cover-subtitle">${esc(scene.subtitle)}</p>`);
827
+ if (scene.byline) parts.push(`<p class="cover-byline">${esc(scene.byline)}</p>`);
828
+ return parts.join('\n');
829
+ }
830
+
831
+ function sectionHtml(scene) {
832
+ return `<h2 class="section-title">${esc(scene.title ?? '')}</h2>\n${logoHtml(LOGOS.sectionSvg, CHROME.section.logoH, 'logo-section')}`;
833
+ }
834
+
835
+ function contentHtml(scene, num, footerText, ctx) {
836
+ const parts = [];
837
+ const hero = scene.master === 'hero' && Boolean(scene.image);
838
+ if (hero) {
839
+ parts.push(
840
+ htmlImage(scene.image, { x: 0, y: 0, w: PAGE.width, h: PAGE.height }, ctx, {
841
+ fullBleed: true,
842
+ }),
843
+ );
844
+ }
845
+ if (scene.title) {
846
+ const title = scene.titleRuns ? runsHtml(scene.titleRuns) : esc(scene.title);
847
+ parts.push(`<div class="slide-title">${title}</div>`);
848
+ }
849
+ // hero: in PPTX, the master's rule and footer are COVERED by the full-frame
850
+ // image — do not paint them on top in HTML (parity); the page number, for
851
+ // its part, is written after the image and stays visible
852
+ if (!hero) parts.push('<div class="title-accent"></div><div class="title-rule"></div>');
853
+ for (const el of scene.elements) {
854
+ const fn = BLOCK_RENDERERS[el.block.type];
855
+ if (!fn) continue;
856
+ let frag = fn(el.block, el.region, ctx);
857
+ if (el.step != null) {
858
+ if (el.block.type === 'bullets' && el.stepCount > 1) {
859
+ // list bullet by bullet: one step per <li> (the container stays visible)
860
+ let k = el.step;
861
+ frag = frag.replace(/<li>/g, () => `<li data-step="${k++}">`);
862
+ } else {
863
+ frag = frag.replace(/^<(\w+)/, `<$1 data-step="${el.step}"`);
864
+ }
865
+ }
866
+ parts.push(frag);
867
+ }
868
+ if (!hero) parts.push(`<div class="footer-text">${esc(footerText)}</div>`);
869
+ parts.push(`<div class="footer-num">${num}</div>`);
870
+ return parts.join('\n');
871
+ }
872
+
873
+ // ---------------------------------------------------------------------------
874
+ // Stylesheet (design tokens of the active theme — see tokens.mjs)
875
+ // ---------------------------------------------------------------------------
876
+
877
+ // ~300 kB of base64 woff2: encoded once per process — memo KEYED BY THEME
878
+ // (family + files): a theme that changes the fonts within the same process
879
+ // (preview, warm worker) must not serve the previous ones again
880
+ let _fontFaces = null;
881
+ let _fontFacesKey = null;
882
+ function fontFacesCss() {
883
+ const key = [FONTS.body, FONT_FILES.regular, FONT_FILES.bold, FONT_FILES.italic].join('|');
884
+ if (_fontFaces && _fontFacesKey === key) return _fontFaces;
885
+ const faces = [];
886
+ for (const f of FONT_FACE_VARIANTS) {
887
+ const ttf = FONT_FILES[f.key];
888
+ if (typeof ttf !== 'string') continue;
889
+ const file = ttf.replace(/\.ttf$/i, '.woff2');
890
+ if (!fs.existsSync(file)) continue;
891
+ const b64 = fs.readFileSync(file).toString('base64');
892
+ faces.push(
893
+ `@font-face{font-family:"${FONTS.body}";font-weight:${f.weight};font-style:${f.style};` +
894
+ `src:url(data:font/woff2;base64,${b64}) format('woff2');font-display:swap}`,
895
+ );
896
+ }
897
+ _fontFacesKey = key;
898
+ return (_fontFaces = { css: faces.join('\n'), count: faces.length });
899
+ }
900
+
901
+ function baseCss() {
902
+ const C = COLORS;
903
+ const CH = CHROME;
904
+ return `
905
+ *{box-sizing:border-box}
906
+ body{margin:0;background:#${C.underground2};font-family:"${FONTS.body}",-apple-system,'Segoe UI',Arial,sans-serif;color:#${C.neutralPrimary}}
907
+ .deck{max-width:1328px;margin:0 auto;padding:24px;display:flex;flex-direction:column;gap:24px}
908
+ .slide-frame{position:relative;width:100%;height:720px;overflow:hidden;background:#${C.ground};border:1px solid #${C.neutralStroke};border-radius:4px}
909
+ .slide{position:absolute;left:0;top:0;width:${PAGE.width}px;height:${PAGE.height}px;overflow:hidden;transform-origin:0 0;background:#${C.ground}}
910
+ .el{position:absolute;margin:0}
911
+ a{color:#${C.primary};text-decoration:none}
912
+ a:hover{text-decoration:underline}
913
+ code{font-family:"${FONTS.mono}",monospace;color:#${C.primaryDarker}}
914
+
915
+ /* chrome of content slides */
916
+ .slide-title{position:absolute;left:${PAGE.margin}px;top:${SPACE.lg}px;width:${PAGE.width - 2 * PAGE.margin}px;height:${PAGE.titleHeight - SPACE.lg - 8}px;display:flex;align-items:center;font-size:${TYPE.slideTitle}pt;font-weight:700;line-height:1.15}
917
+ .title-accent{position:absolute;left:${PAGE.margin}px;top:${PAGE.titleHeight}px;width:${CH.title.accentW}px;height:${CH.title.accentH}px;background:#${C.primary}}
918
+ .title-rule{position:absolute;left:${PAGE.margin + CH.title.accentW}px;top:${PAGE.titleHeight + 1}px;width:${PAGE.width - 2 * PAGE.margin - CH.title.accentW}px;height:1px;background:#${C.neutralStroke}}
919
+ .footer-text{position:absolute;left:${PAGE.margin}px;top:${PAGE.height - PAGE.footerHeight}px;width:${CH.footer.textW}px;height:${CH.footer.h}px;display:flex;align-items:center;font-size:${TYPE.caption}pt;color:#${C.neutralSecondary}}
920
+ .footer-num{position:absolute;left:${PAGE.width - PAGE.margin - CH.footer.numW}px;top:${PAGE.height - PAGE.footerHeight}px;width:${CH.footer.numW}px;height:${CH.footer.h}px;display:flex;align-items:center;justify-content:flex-end;font-size:${TYPE.caption}pt;color:#${C.neutralSecondary}}
921
+
922
+ /* cover */
923
+ .logo{position:absolute;left:${PAGE.margin}px;top:${PAGE.margin}px}
924
+ .logo svg,.logo img{height:100%;width:auto;display:block}
925
+ .logo-section{top:auto;bottom:${PAGE.margin}px}
926
+ .cover-bar{position:absolute;left:${PAGE.margin}px;top:${CH.cover.barY}px;width:${CH.cover.barW}px;height:${CH.cover.barH}px;background:#${C.primary}}
927
+ .cover-title{position:absolute;left:${PAGE.margin}px;top:${CH.cover.titleY}px;width:${PAGE.width - 2 * PAGE.margin}px;margin:0;font-size:${TYPE.coverTitle}pt;font-weight:700;line-height:1.15}
928
+ .cover-subtitle{position:absolute;left:${PAGE.margin}px;top:${CH.cover.subtitleY}px;width:${PAGE.width - 2 * PAGE.margin}px;margin:0;font-size:${TYPE.coverSubtitle}pt;color:#${C.neutralSecondary};line-height:1.3}
929
+ .cover-byline{position:absolute;left:${PAGE.margin}px;top:${PAGE.height - CH.cover.bylineBottom}px;width:${PAGE.width - 2 * PAGE.margin}px;height:${CH.cover.bylineH}px;display:flex;align-items:center;margin:0;font-size:${TYPE.small}pt;color:#${C.neutralSecondary}}
930
+
931
+ /* section (green background) */
932
+ .slide.master-section{background:#${C.primary}}
933
+ .section-title{position:absolute;left:${PAGE.margin}px;top:${CH.section.titleY}px;width:${PAGE.width - 2 * PAGE.margin}px;height:${CH.section.titleH}px;display:flex;align-items:center;margin:0;font-size:${TYPE.sectionTitle}pt;font-weight:700;color:#${C.ground};line-height:1.2}
934
+
935
+ /* blocks */
936
+ .para{font-size:${TYPE.body}pt;line-height:1.4}
937
+ .slot-heading{font-size:${TYPE.sectionHeading}pt;font-weight:700;line-height:1.3}
938
+ .bullets ul,.bullets ol{margin:0;padding-left:28px;font-size:${TYPE.bullet}pt;line-height:1.3}
939
+ .bullets ul ul,.bullets ol ol,.bullets ul ol,.bullets ol ul{font-size:${TYPE.bulletNested}pt;margin-top:6px}
940
+ .bullets li{margin-bottom:6px}
941
+ .bullets li::marker{color:#${C.neutralSecondary}}
942
+ .code{background:#${C.underground1};border:1px solid #${C.neutralStroke};border-radius:8px;padding:${SPACE.xs}px ${SPACE.sm}px;font-family:"${FONTS.mono}",monospace;font-size:${TYPE.code}pt;line-height:1.3;color:#${C.neutralPrimary};overflow:hidden;white-space:pre}
943
+ .hl-kw{color:#${C.primaryDarker};font-weight:700}
944
+ .hl-str{color:#${C.positiveDark}}
945
+ .hl-com{color:#${C.neutralSecondary};font-style:italic}
946
+ .table{border-collapse:collapse;font-size:${TYPE.tableBody}pt}
947
+ .table th{background:#${C.underground1};font-weight:700;text-align:left}
948
+ .table th,.table td{border-bottom:1px solid #${C.neutralStroke};padding:7px 8px;vertical-align:middle}
949
+ .alert{border-radius:4px;padding:${SPACE.xs}px ${SPACE.sm}px;font-size:${TYPE.body}pt;line-height:1.3;overflow:hidden}
950
+ .alert-label{font-size:${TYPE.small}pt;font-weight:700;margin-bottom:2px}
951
+ .alert p{margin:0}
952
+ .alert ul{margin:0;padding-left:24px}
953
+ .alert-info{background:#${SEMANTIC.info.fill};color:#${SEMANTIC.info.text}}
954
+ .alert-success{background:#${SEMANTIC.success.fill};color:#${SEMANTIC.success.text}}
955
+ .alert-warning{background:#${SEMANTIC.warning.fill};color:#${SEMANTIC.warning.text}}
956
+ .alert-danger{background:#${SEMANTIC.danger.fill};color:#${SEMANTIC.danger.text}}
957
+ .metric{background:#${C.ground};border:1px solid #${C.neutralStroke};border-radius:8px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:${SPACE.xs}px}
958
+ .metric-value{font-size:${TYPE.metricValue}pt;font-weight:700;color:#${C.primary};line-height:1.05}
959
+ .metric-label{font-size:${TYPE.metricLabel}pt;color:#${C.neutralSecondary};margin-top:6px}
960
+ .metric-trend{font-size:${TYPE.small}pt;font-weight:700;margin-top:8px}
961
+
962
+ /* structured layouts: panels, timeline */
963
+ .panel{overflow:hidden}
964
+ .panel-accent{position:absolute;left:${SPACE.xs}px;right:${SPACE.xs}px;top:0;height:4px;background:#${C.primary}}
965
+ .tl-axis{background:linear-gradient(#${C.neutralStroke},#${C.neutralStroke}) no-repeat 0 50%/calc(100% - 14px) 2px}
966
+ .tl-axis.tl-no-arrow{background-size:100% 2px}
967
+ .tl-arrow{position:absolute;right:0;top:50%;transform:translateY(-50%);width:0;height:0;border-left:14px solid #${C.neutralStroke};border-top:7px solid transparent;border-bottom:7px solid transparent}
968
+ .tl-axis-v{background:linear-gradient(#${C.neutralStroke},#${C.neutralStroke}) no-repeat 50% 0/2px calc(100% - 14px)}
969
+ .tl-axis-v.tl-no-arrow{background-size:2px 100%}
970
+ .tl-arrow-v{position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:0;height:0;border-top:14px solid #${C.neutralStroke};border-left:7px solid transparent;border-right:7px solid transparent}
971
+ .tl-dot{display:flex;align-items:center;justify-content:center;background:#${C.primary};color:#${C.ground};border:2px solid #${C.ground};border-radius:50%;font-size:${TYPE.metricLabel}pt;font-weight:700;box-sizing:border-box}
972
+ .quote blockquote{position:absolute;left:96px;right:32px;top:0;bottom:64px;display:flex;align-items:center;margin:0;font-size:${TYPE.quote}pt;font-style:italic;line-height:1.4}
973
+ .quote-mark{position:absolute;left:0;top:-10px;font-size:72pt;font-weight:700;color:#${C.primary};line-height:1}
974
+ .quote figcaption{position:absolute;right:32px;bottom:12px;font-size:${TYPE.body}pt;color:#${C.neutralSecondary}}
975
+ .img-contain{object-fit:contain}
976
+ .img-cover{object-fit:cover}
977
+ .placeholder{background:#${C.underground1};border:1px dashed #${C.neutralStroke};border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:${TYPE.small}pt;color:#${C.neutralSecondary}}
978
+ .figure{display:flex;align-items:center;justify-content:center;overflow:hidden}
979
+ .figure svg{max-width:100%;max-height:100%}
980
+ .icon{display:flex;align-items:center}
981
+ .icon-box svg{width:100%;height:100%;display:block}
982
+ .fallback-caption{position:absolute;font-size:${TYPE.caption}pt;font-style:italic;color:#${C.neutralSecondary}}
983
+
984
+ /* animations: appear on click (slides carrying data-anim-steps) */
985
+ .slide-frame[data-anim-steps]{cursor:pointer}
986
+ .slide-frame[data-anim-steps] [data-step]{visibility:hidden}
987
+ .slide-frame[data-anim-steps] [data-step].step-shown{visibility:visible}
988
+ .anim-count{position:absolute;right:12px;top:8px;font-size:11px;color:#${C.neutralTertiary};font-variant-numeric:tabular-nums;pointer-events:none;z-index:2}
989
+
990
+ /* presenter notes (below the slide, outside the geometry) */
991
+ .notes{font-size:10pt;color:#${C.neutralSecondary};padding:4px 2px}
992
+ .notes summary{cursor:pointer}
993
+ .notes p{margin:4px 0 0}
994
+
995
+ @media print{
996
+ body{background:#fff}
997
+ .deck{max-width:none;padding:0;gap:0}
998
+ .slide-frame{width:${PAGE.width}px;height:${PAGE.height}px !important;border:none;border-radius:0;break-after:page}
999
+ .slide{transform:none !important}
1000
+ .slide-frame [data-step]{visibility:visible !important}
1001
+ .anim-count{display:none}
1002
+ .notes{display:none}
1003
+ @page{margin:0}
1004
+ }`;
1005
+ }
1006
+
1007
+ /** Rules of the presenter mode (PRESENT_SCRIPT) — a function separate from
1008
+ * baseCss() by design: injected into the complete document only, so the CSS
1009
+ * of the fragment mode (webview) stays identical. Everything is scoped under
1010
+ * `body.presenting` or under the `.present-*` elements created by the script:
1011
+ * the normal rendering and the @media print block of baseCss() do not change. */
1012
+ function presentCss() {
1013
+ const C = COLORS;
1014
+ return `
1015
+ /* presentation mode: a single slide, centered, dark neutral background */
1016
+ body.presenting{background:#0b0b0b;overflow:hidden}
1017
+ body.presenting .deck{max-width:none;padding:0;gap:0}
1018
+ body.presenting .slide-frame,body.presenting .notes{display:none}
1019
+ body.presenting .slide-frame.present-current{display:block;position:fixed;left:0;top:0;right:0;bottom:0;margin:auto;border:none;border-radius:0;z-index:10}
1020
+ body.presenting .anim-count{display:none} /* the step counter is not projected */
1021
+ /* discreet strip: shortcut in normal mode, counter while presenting */
1022
+ .present-hint{position:fixed;right:16px;bottom:12px;z-index:20;padding:4px 10px;font-size:12px;color:#${C.neutralSecondary};background:rgba(255,255,255,.85);border:1px solid #${C.neutralStroke};border-radius:4px;pointer-events:none}
1023
+ body.presenting .present-hint{color:#8a8f98;background:none;border-color:transparent}
1024
+ /* help (? key) */
1025
+ .present-help{display:none;position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:30;min-width:340px;padding:20px 28px;background:rgba(11,11,11,.94);color:#e9ecef;border-radius:8px;font-size:13px;line-height:2;cursor:pointer}
1026
+ .present-help.open{display:block}
1027
+ .present-help b{display:block;margin-bottom:6px;font-size:14px}
1028
+ .present-help kbd{display:inline-block;min-width:20px;margin-right:6px;padding:0 6px;background:#2a2a2a;border-radius:4px;font-family:inherit;font-size:12px;text-align:center}
1029
+ /* the presentation-mode chrome never prints (the print rendering of baseCss()
1030
+ stays identical; PRESENT_SCRIPT exits the mode before printing) */
1031
+ @media print{.present-hint,.present-help{display:none}}`;
1032
+ }
1033
+
1034
+ /** Scaling of the slides — the only piece of JS, optional (without it, the
1035
+ * slides stay at 1280 px and the container crops them).
1036
+ * A function (not a module constant): PAGE must be read AFTER applyTheme. */
1037
+ const fitScript = () => `
1038
+ (function(){
1039
+ var frames = Array.prototype.slice.call(document.querySelectorAll('.slide-frame'));
1040
+ function fit(){
1041
+ for (var i = 0; i < frames.length; i++){
1042
+ var f = frames[i];
1043
+ var s = f.clientWidth / ${PAGE.width};
1044
+ f.style.height = (${PAGE.height} * s) + 'px';
1045
+ f.firstElementChild.style.transform = 'scale(' + s + ')';
1046
+ }
1047
+ }
1048
+ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(fit).observe(document.body);
1049
+ window.addEventListener('resize', fit);
1050
+ fit();
1051
+ })();`;
1052
+
1053
+ /** Click-to-reveal on animated slides (data-anim-steps): each click shows the
1054
+ * next step; a click after the last one resets. Without JS (or when
1055
+ * printing), all the content is visible.
1056
+ * Every animated slide exposes its state on the element —
1057
+ * `frame.__anim = { total, shown, set(n) }` — consumed by presentScript() to
1058
+ * drive the steps from the keyboard; the click behaviour is unchanged.
1059
+ * A function by symmetry with fitScript()/presentScript() (nothing to
1060
+ * interpolate). */
1061
+ const animScript = () => `
1062
+ (function(){
1063
+ var frames = document.querySelectorAll('.slide-frame[data-anim-steps]');
1064
+ for (var i = 0; i < frames.length; i++)(function(f){
1065
+ var total = Number(f.getAttribute('data-anim-steps'));
1066
+ var els = Array.prototype.slice.call(f.querySelectorAll('[data-step]'));
1067
+ var badge = document.createElement('div');
1068
+ badge.className = 'anim-count';
1069
+ f.appendChild(badge);
1070
+ var st = f.__anim = {
1071
+ total: total,
1072
+ shown: 0,
1073
+ set: function(n){
1074
+ st.shown = n < 0 ? 0 : n > total ? total : n;
1075
+ badge.textContent = st.shown + ' / ' + total;
1076
+ for (var k = 0; k < els.length; k++)
1077
+ els[k].classList.toggle('step-shown', Number(els[k].getAttribute('data-step')) < st.shown);
1078
+ }
1079
+ };
1080
+ f.addEventListener('click', function(e){
1081
+ if (e.target.closest && e.target.closest('a')) return; // clickable links
1082
+ // in presentation mode, no reset in front of an audience: after the last
1083
+ // step, the click does nothing (the arrow keys change slide)
1084
+ if (st.shown >= total && document.body.classList.contains('presenting')) return;
1085
+ st.set(st.shown < total ? st.shown + 1 : 0);
1086
+ });
1087
+ st.set(0);
1088
+ })(frames[i]);
1089
+ })();`;
1090
+
1091
+ /** Standalone presenter mode — complete document only, never in fragment
1092
+ * mode. Zero dependencies, designed for a .html opened over file:// by a
1093
+ * double click (no request, no server). Shortcuts:
1094
+ * P enter / exit (full screen if allowed — the mode also
1095
+ * works windowed if the browser refuses);
1096
+ * → Space PgDn next animation step, then next slide;
1097
+ * ← PgUp previous step, then previous slide;
1098
+ * Home / End first / last slide;
1099
+ * N presenter view (2nd window);
1100
+ * Esc exit; ? help.
1101
+ * The presenter view is an about:blank filled in by document.write and driven
1102
+ * by a direct window reference: over file:// the origin is opaque and
1103
+ * BroadcastChannel is not reliable — the direct reference is the only robust
1104
+ * channel locally. All the logic (the timer included) lives in the main
1105
+ * window; the second one contains no script at all.
1106
+ * A function (not a module constant): PAGE and COLORS must be read AFTER
1107
+ * applyTheme. */
1108
+ const presentScript = () => `
1109
+ (function(){
1110
+ var W = ${PAGE.width}, H = ${PAGE.height};
1111
+ var frames = Array.prototype.slice.call(document.querySelectorAll('.slide-frame'));
1112
+ if (!frames.length) return;
1113
+ var current = 0;
1114
+ var presenting = false;
1115
+ var notesWin = null; // presenter view
1116
+ var timer = { acc: 0, from: 0, running: false }; // timer (state on the main side)
1117
+ var tick = null;
1118
+
1119
+ // Notes: innerHTML of the <p> of the <details class="notes"> that follows
1120
+ // each slide (content already escaped by the renderer, reinjectable as is).
1121
+ var notes = [];
1122
+ for (var i = 0; i < frames.length; i++){
1123
+ var sib = frames[i].nextElementSibling;
1124
+ var ps = sib && sib.classList && sib.classList.contains('notes') ? sib.querySelectorAll('p') : [];
1125
+ var list = [];
1126
+ for (var k = 0; k < ps.length; k++) list.push(ps[k].innerHTML);
1127
+ notes.push(list);
1128
+ }
1129
+
1130
+ // Animation state set by ANIM_SCRIPT (absent if the slide is not animated)
1131
+ function anim(n){ return frames[n].__anim || null; }
1132
+
1133
+ // ------ discreet strip + help (?) -----------------------------------------
1134
+ var hint = document.createElement('div');
1135
+ hint.className = 'present-hint';
1136
+ document.body.appendChild(hint);
1137
+ var help = document.createElement('div');
1138
+ help.className = 'present-help';
1139
+ help.innerHTML = '<b>Shortcuts</b>' +
1140
+ '<div><kbd>P</kbd>enter / exit presentation mode</div>' +
1141
+ '<div><kbd>→</kbd><kbd>Space</kbd><kbd>PgDn</kbd>next step or slide</div>' +
1142
+ '<div><kbd>←</kbd><kbd>PgUp</kbd>previous step or slide</div>' +
1143
+ '<div><kbd>Home</kbd><kbd>End</kbd>first / last slide</div>' +
1144
+ '<div><kbd>N</kbd>presenter view (notes, timer)</div>' +
1145
+ '<div><kbd>Esc</kbd>exit</div>';
1146
+ document.body.appendChild(help);
1147
+ help.addEventListener('click', function(){ help.classList.remove('open'); });
1148
+ function updateHint(){
1149
+ hint.textContent = presenting
1150
+ ? (current + 1) + ' / ' + frames.length + ' — N: notes · Esc: exit · ?: help'
1151
+ : 'P: presentation mode · ?: help';
1152
+ }
1153
+ updateHint();
1154
+
1155
+ // ------ scaling of the current slide --------------------------------------
1156
+ function fitCurrent(){
1157
+ if (!presenting) return;
1158
+ var f = frames[current];
1159
+ var s = Math.min(window.innerWidth / W, window.innerHeight / H);
1160
+ f.style.width = (W * s) + 'px';
1161
+ f.style.height = (H * s) + 'px';
1162
+ f.firstElementChild.style.transform = 'scale(' + s + ')';
1163
+ }
1164
+ window.addEventListener('resize', fitCurrent);
1165
+
1166
+ /** Most visible slide in the window (starting point of presentation mode). */
1167
+ function mostVisible(){
1168
+ var best = 0, max = -Infinity;
1169
+ for (var n = 0; n < frames.length; n++){
1170
+ var r = frames[n].getBoundingClientRect();
1171
+ var vis = Math.min(r.bottom, window.innerHeight) - Math.max(r.top, 0);
1172
+ if (vis > max){ max = vis; best = n; }
1173
+ }
1174
+ return best;
1175
+ }
1176
+
1177
+ // ------ navigation: animation steps first, slides afterwards --------------
1178
+ function goTo(n, atEnd){
1179
+ if (n < 0 || n >= frames.length) return;
1180
+ frames[current].classList.remove('present-current');
1181
+ frames[current].style.width = '';
1182
+ current = n;
1183
+ var a = anim(n);
1184
+ if (a) a.set(atEnd ? a.total : 0); // going backwards: every step already revealed
1185
+ frames[n].classList.add('present-current');
1186
+ fitCurrent(); updateHint(); sync();
1187
+ }
1188
+ function next(){
1189
+ var a = anim(current);
1190
+ if (a && a.shown < a.total){ a.set(a.shown + 1); sync(); return; }
1191
+ goTo(current + 1, false);
1192
+ }
1193
+ function prev(){
1194
+ var a = anim(current);
1195
+ if (a && a.shown > 0){ a.set(a.shown - 1); sync(); return; }
1196
+ goTo(current - 1, true);
1197
+ }
1198
+
1199
+ // ------ entering / leaving the mode ---------------------------------------
1200
+ function enter(){
1201
+ if (presenting) return;
1202
+ presenting = true;
1203
+ document.body.classList.add('presenting');
1204
+ current = mostVisible();
1205
+ var a = anim(current);
1206
+ if (a) a.set(0);
1207
+ frames[current].classList.add('present-current');
1208
+ fitCurrent(); updateHint();
1209
+ // full screen if allowed — if refused, the mode stays windowed
1210
+ var p = document.documentElement.requestFullscreen && document.documentElement.requestFullscreen();
1211
+ if (p && p.catch) p.catch(function(){});
1212
+ }
1213
+ function exit(){
1214
+ if (!presenting) return;
1215
+ presenting = false;
1216
+ help.classList.remove('open');
1217
+ var f = frames[current];
1218
+ f.classList.remove('present-current');
1219
+ f.style.width = '';
1220
+ document.body.classList.remove('presenting');
1221
+ closePresenter();
1222
+ if (document.fullscreenElement && document.exitFullscreen){
1223
+ var p = document.exitFullscreen();
1224
+ if (p && p.catch) p.catch(function(){});
1225
+ }
1226
+ window.dispatchEvent(new Event('resize')); // FIT_SCRIPT rescales the slides
1227
+ f.scrollIntoView({ block: 'center' });
1228
+ updateHint();
1229
+ }
1230
+ // Esc in full screen is absorbed by the browser: we follow the real state.
1231
+ // Exception: opening the presenter view (window.open) makes the browser
1232
+ // leave full screen — within the second that follows, we stay in windowed
1233
+ // presentation mode instead of tearing everything down.
1234
+ document.addEventListener('fullscreenchange', function(){
1235
+ if (presenting && !document.fullscreenElement && Date.now() - popupAt > 1000) exit();
1236
+ });
1237
+ window.addEventListener('beforeprint', function(){ exit(); }); // printing unchanged
1238
+
1239
+ // ------ presenter view (2nd window, no embedded script) -------------------
1240
+ var PRES_CSS = 'html,body{height:100%;overflow:hidden}' +
1241
+ 'body{margin:0;display:flex;flex-direction:column;background:#0b0b0b;color:#e9ecef}' +
1242
+ '.p-top{flex:none;display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid #2a2a2a}' +
1243
+ '#t-timer{font-size:26px;font-weight:700;font-variant-numeric:tabular-nums;color:#8a8f98;cursor:pointer}' +
1244
+ '#t-timer.run{color:#e9ecef}' +
1245
+ '#t-reset{font:inherit;font-size:14px;color:#8a8f98;background:none;border:1px solid #2a2a2a;border-radius:4px;padding:2px 10px;cursor:pointer}' +
1246
+ '#t-count{margin-left:auto;font-size:14px;color:#8a8f98;font-variant-numeric:tabular-nums}' +
1247
+ '.p-cols{flex:1;min-height:0;display:flex;gap:16px;padding:16px}' +
1248
+ '.p-main{flex:3;min-width:0}' +
1249
+ '.p-side{flex:2;min-width:0;min-height:0;display:flex;flex-direction:column;gap:8px}' +
1250
+ // the cloned slides inherit their ink from body (baseCss): restore it —
1251
+ // background and ink from the design tokens, no hard-coded white (dark theme)
1252
+ '.p-frame{position:relative;overflow:hidden;background:#${COLORS.ground};border-radius:4px;color:#${COLORS.neutralPrimary}}' +
1253
+ '.p-label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#8a8f98}' +
1254
+ '.p-notes{flex:1;min-height:0;overflow:auto;font-size:22px;line-height:1.5}' +
1255
+ '.p-notes p{margin:0 0 12px}' +
1256
+ '.p-notes .p-empty{color:#8a8f98;font-style:italic}' +
1257
+ '#p-cur [data-step]{visibility:hidden}' + // the current slide follows
1258
+ '#p-cur [data-step].step-shown{visibility:visible}'; // the step state; the next one shows everything
1259
+ var PRES_BODY = '<div class="p-top">' +
1260
+ '<span id="t-timer" title="Start / pause">00:00</span>' +
1261
+ '<button id="t-reset" type="button" title="Reset">reset</button>' +
1262
+ '<span id="t-count"></span></div>' +
1263
+ '<div class="p-cols"><div class="p-main"><div class="p-frame" id="p-cur"></div></div>' +
1264
+ '<div class="p-side"><div class="p-label">Next slide</div><div class="p-frame" id="p-next"></div>' +
1265
+ '<div class="p-label">Notes</div><div class="p-notes" id="p-notes"></div></div></div>';
1266
+
1267
+ var popupAt = 0; // timestamp of the last window.open (guards fullscreenchange)
1268
+ function presOpen(){ return notesWin && !notesWin.closed; }
1269
+ function openPresenter(){
1270
+ if (presOpen()){ notesWin.focus(); sync(); return; }
1271
+ popupAt = Date.now();
1272
+ var w = window.open('', 'lutrinPresenter', 'width=1100,height=680');
1273
+ if (!w){ // window blocked: presentation mode stays usable, but say so
1274
+ hint.textContent = 'Window blocked — allow pop-ups to get the presenter view';
1275
+ return;
1276
+ }
1277
+ notesWin = w;
1278
+ var doc = w.document;
1279
+ doc.open();
1280
+ // the deck's stylesheet (fonts included) is reused as is
1281
+ // NB: the body tags are split so that their closing literal stays unique
1282
+ // in the generated document — lutrin preview injects its SSE client
1283
+ // before the LAST occurrence (a contract tested by html.test)
1284
+ doc.write('<!doctype html><html lang="en"><head><meta charset="utf-8">' +
1285
+ '<title>Presenter view</title><style>' +
1286
+ document.querySelector('style').textContent + PRES_CSS +
1287
+ '</style></head><bo' + 'dy>' + PRES_BODY + '</bo' + 'dy></html>');
1288
+ doc.close();
1289
+ doc.getElementById('t-timer').onclick = toggleTimer;
1290
+ doc.getElementById('t-reset').onclick = resetTimer;
1291
+ doc.onkeydown = presenterKeys;
1292
+ w.addEventListener('resize', fitPanes);
1293
+ if (!tick) tick = setInterval(tickTimer, 500);
1294
+ sync();
1295
+ }
1296
+ function closePresenter(){
1297
+ if (presOpen()) notesWin.close();
1298
+ notesWin = null;
1299
+ if (tick){ clearInterval(tick); tick = null; }
1300
+ }
1301
+ window.addEventListener('beforeunload', closePresenter);
1302
+
1303
+ function fitPane(pane, maxH){
1304
+ var slide = pane.firstElementChild;
1305
+ if (!slide){ pane.style.width = ''; pane.style.height = '0'; return; }
1306
+ var s = Math.min(pane.parentNode.clientWidth / W, (maxH || H) / H);
1307
+ pane.style.width = (W * s) + 'px';
1308
+ pane.style.height = (H * s) + 'px';
1309
+ slide.style.transform = 'scale(' + s + ')';
1310
+ }
1311
+ function fitPanes(){
1312
+ if (!presOpen()) return;
1313
+ var doc = notesWin.document;
1314
+ var main = doc.querySelector('.p-main');
1315
+ fitPane(doc.getElementById('p-cur'), main ? main.clientHeight : H);
1316
+ fitPane(doc.getElementById('p-next'), notesWin.innerHeight * 0.3);
1317
+ }
1318
+ /** Pushes the current state to the presenter view (direct reference). */
1319
+ function sync(){
1320
+ if (!presOpen()) return;
1321
+ var doc = notesWin.document;
1322
+ var a = anim(current);
1323
+ doc.getElementById('t-count').textContent = (current + 1) + ' / ' + frames.length +
1324
+ (a ? ' — step ' + a.shown + '/' + a.total : '');
1325
+ doc.getElementById('p-cur').innerHTML = frames[current].firstElementChild.outerHTML;
1326
+ doc.getElementById('p-next').innerHTML =
1327
+ current + 1 < frames.length ? frames[current + 1].firstElementChild.outerHTML : '';
1328
+ var list = notes[current];
1329
+ doc.getElementById('p-notes').innerHTML = list.length
1330
+ ? '<p>' + list.join('</p><p>') + '</p>'
1331
+ : '<p class="p-empty">No notes for this slide.</p>';
1332
+ fitPanes();
1333
+ }
1334
+
1335
+ // ------ timer (click: start / pause; button: reset) -----------------------
1336
+ function timerText(){
1337
+ var s = Math.floor((timer.acc + (timer.running ? Date.now() - timer.from : 0)) / 1000);
1338
+ var two = function(x){ return (x < 10 ? '0' : '') + x; };
1339
+ var h = Math.floor(s / 3600);
1340
+ return (h ? h + ':' : '') + two(Math.floor(s / 60) % 60) + ':' + two(s % 60);
1341
+ }
1342
+ function tickTimer(){
1343
+ if (!presOpen()){ // window closed by hand: the interval cleans itself up
1344
+ if (tick){ clearInterval(tick); tick = null; }
1345
+ return;
1346
+ }
1347
+ var el = notesWin.document.getElementById('t-timer');
1348
+ if (el){ el.textContent = timerText(); el.className = timer.running ? 'run' : ''; }
1349
+ }
1350
+ function toggleTimer(){
1351
+ if (timer.running){ timer.acc += Date.now() - timer.from; timer.running = false; }
1352
+ else { timer.from = Date.now(); timer.running = true; }
1353
+ tickTimer();
1354
+ }
1355
+ function resetTimer(){ timer.acc = 0; timer.from = Date.now(); tickTimer(); }
1356
+
1357
+ // ------ keyboard (same keys in both windows) ------------------------------
1358
+ function navKey(e){
1359
+ var k = e.key;
1360
+ if (k === 'ArrowRight' || k === ' ' || k === 'PageDown'){ next(); }
1361
+ else if (k === 'ArrowLeft' || k === 'PageUp'){ prev(); }
1362
+ else if (k === 'Home'){ goTo(0, false); }
1363
+ else if (k === 'End'){ goTo(frames.length - 1, false); }
1364
+ else return false;
1365
+ e.preventDefault();
1366
+ return true;
1367
+ }
1368
+ function presenterKeys(e){
1369
+ if (navKey(e)) return;
1370
+ if (e.key === 'Escape') notesWin.close();
1371
+ }
1372
+ document.addEventListener('keydown', function(e){
1373
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
1374
+ var k = e.key;
1375
+ if (k === '?'){ help.classList.toggle('open'); e.preventDefault(); return; }
1376
+ if (k === 'p' || k === 'P'){ presenting ? exit() : enter(); e.preventDefault(); return; }
1377
+ if (!presenting) return; // in scrolling mode, the browser keeps its keys
1378
+ if (k === 'Escape'){ help.classList.contains('open') ? help.classList.remove('open') : exit(); return; }
1379
+ if (k === 'n' || k === 'N'){ openPresenter(); e.preventDefault(); return; }
1380
+ navKey(e);
1381
+ });
1382
+ // a click on an animated slide (ANIM_SCRIPT) changes the step: resynchronize
1383
+ document.addEventListener('click', function(){
1384
+ if (presenting) setTimeout(sync, 0);
1385
+ });
1386
+ })();`;
1387
+
1388
+ // ---------------------------------------------------------------------------
1389
+ // Entry point
1390
+ // ---------------------------------------------------------------------------
1391
+
1392
+ /**
1393
+ * Shared core: renders each scene as an HTML fragment (`<div
1394
+ * class="slide-frame">…` followed by the notes). Consumed by the complete
1395
+ * document (renderDeckHtml) and by the fragment mode of compileHtml (VS Code
1396
+ * webview, slide-by-slide update).
1397
+ *
1398
+ * @returns {Promise<{slides: string[], stats: object}>}
1399
+ */
1400
+ async function renderSlideFragments(scenes, meta, baseDir, opts = {}) {
1401
+ const vendor = vendorRemoteAssets(meta, opts.vendor);
1402
+ // ------ pre-pass: everything that requires asynchronous work --------------
1403
+ const allBlocks = scenes.flatMap((sc) => [
1404
+ ...sc.elements.map((e) => e.block),
1405
+ ...(sc.image ? [sc.image] : []), // image of the hero layout, outside the elements
1406
+ ]);
1407
+ const ofType = (t) => allBlocks.filter((b) => b.type === t);
1408
+
1409
+ // Mermaid diagrams → inline SVG (persistent cache, unique identifiers)
1410
+ const mermaidBlocks = ofType('mermaid');
1411
+ const mermaid = new Map();
1412
+ mermaidBlocks.forEach((b, k) => {
1413
+ const file = renderMermaidCached(b.source, { format: 'svg', baseDir });
1414
+ if (file)
1415
+ mermaid.set(b, uniquifySvgIds(sanitizeSvg(fs.readFileSync(file, 'utf8')), `mmd-${k}`));
1416
+ });
1417
+
1418
+ // Remote images → user cache (or assets/remote/ if the deck vendors them),
1419
+ // then data URI: the HTML document stays standalone in both cases
1420
+ const remote = new Map();
1421
+ const remoteUrls = [
1422
+ ...new Set(
1423
+ ofType('image')
1424
+ .map((b) => b.src)
1425
+ .filter((s) => /^https?:/.test(s)),
1426
+ ),
1427
+ ];
1428
+ await Promise.all(
1429
+ remoteUrls.map(async (url) => {
1430
+ const local = await fetchRemoteImage(url, baseDir, { vendor });
1431
+ if (local) remote.set(url, local);
1432
+ }),
1433
+ );
1434
+
1435
+ // Lucide icons → recolored inline SVG
1436
+ const icons = new Map();
1437
+ await Promise.all(
1438
+ ofType('icon').map(async (b) => {
1439
+ const svg = await iconSvg(b.name, { color: b.color });
1440
+ if (svg) icons.set(b, svg);
1441
+ }),
1442
+ );
1443
+
1444
+ // LaTeX equations → inline MathJax SVG
1445
+ const math = new Map();
1446
+ await Promise.all(
1447
+ ofType('math').map(async (b) => {
1448
+ const m = await mathSvg(b.source);
1449
+ if (m) math.set(b, m);
1450
+ }),
1451
+ );
1452
+
1453
+ // trust roots for local images: the deck's directory, plus the project/vault
1454
+ // roots declared by the host (containment — assets.mjs)
1455
+ const imageRoots = [baseDir, ...(opts.imageRoots ?? [])];
1456
+ const ctx = { baseDir, imageRoots, mermaid, remote, icons, math };
1457
+ const footerText = meta.footer ?? meta.title ?? '';
1458
+
1459
+ const slides = scenes.map((scene, k) => {
1460
+ let body;
1461
+ let masterCls;
1462
+ if (scene.master === 'cover') {
1463
+ masterCls = 'master-cover';
1464
+ body = coverHtml(scene);
1465
+ } else if (scene.master === 'section') {
1466
+ masterCls = 'master-section';
1467
+ body = sectionHtml(scene);
1468
+ } else {
1469
+ masterCls = scene.master === 'hero' ? 'master-hero' : 'master-content';
1470
+ body = contentHtml(scene, k + 1, footerText, ctx);
1471
+ }
1472
+ const notes = scene.notes?.length
1473
+ ? `<details class="notes"><summary>Notes</summary><p>${scene.notes.map(esc).join('</p><p>')}</p></details>`
1474
+ : '';
1475
+ const anim = scene.animSteps ? ` data-anim-steps="${scene.animSteps}"` : '';
1476
+ // role="group" + aria-roledescription (APG carousel pattern): role="img"
1477
+ // would hide all the real content — links, tables — from screen readers
1478
+ const label = `Slide ${k + 1} of ${scenes.length}${scene.title ? ` — ${scene.title}` : ''}`;
1479
+ return (
1480
+ `<div class="slide-frame" id="slide-${k + 1}" data-slide="${k + 1}" data-layout="${esc(scene.layout)}"${anim}>` +
1481
+ `<div class="slide ${masterCls}" role="group" aria-roledescription="slide" aria-label="${esc(label)}">\n${body}\n</div></div>${notes}`
1482
+ );
1483
+ });
1484
+
1485
+ return {
1486
+ slides,
1487
+ stats: {
1488
+ slideCount: scenes.length,
1489
+ warnings: [], // filled in by the caller (theme fallbacks, etc.)
1490
+ fontsEmbedded: fontFacesCss().count,
1491
+ animatedSlides: scenes.filter((s) => s.animSteps).length,
1492
+ mermaidRendered: mermaid.size,
1493
+ mermaidTotal: mermaidBlocks.length,
1494
+ remoteFetched: remote.size,
1495
+ remoteTotal: remoteUrls.length,
1496
+ remoteVendored: vendor,
1497
+ iconsRendered: icons.size,
1498
+ iconsTotal: ofType('icon').length,
1499
+ mathRendered: math.size,
1500
+ mathTotal: ofType('math').length,
1501
+ },
1502
+ };
1503
+ }
1504
+
1505
+ /**
1506
+ * @param {Array} scenes scenes produced by buildScenes()
1507
+ * @param {object} meta frontmatter of the deck
1508
+ * @param {string} baseDir directory of the source file (image resolution)
1509
+ * @param {object} [opts] `vendor` forces remote images to be copied into the
1510
+ * project (CLI flag; otherwise frontmatter `assets:`)
1511
+ * @returns {Promise<{html: string, stats: object}>}
1512
+ */
1513
+ export async function renderDeckHtml(scenes, meta, baseDir, opts = {}) {
1514
+ const { slides, stats } = await renderSlideFragments(scenes, meta, baseDir, opts);
1515
+ const html = `<!doctype html>
1516
+ <html lang="en">
1517
+ <head>
1518
+ <meta charset="utf-8">
1519
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1520
+ <title>${esc(meta.title ?? scenes[0]?.title ?? 'Presentation')}</title>
1521
+ <style>
1522
+ ${fontFacesCss().css}
1523
+ ${baseCss()}
1524
+ ${presentCss()}
1525
+ </style>
1526
+ </head>
1527
+ <body>
1528
+ <main class="deck">
1529
+ ${slides.join('\n')}
1530
+ </main>
1531
+ <script>${fitScript()}</script>
1532
+ ${scenes.some((s) => s.animSteps) ? `<script>${animScript()}</script>` : ''}
1533
+ <script>${presentScript()}</script>
1534
+ </body>
1535
+ </html>
1536
+ `;
1537
+
1538
+ return { html, stats };
1539
+ }
1540
+
1541
+ /**
1542
+ * Convenience for a programmatic host (VS Code plugin, tests): Markdown (DSL)
1543
+ * → standalone HTML document, in a single call.
1544
+ *
1545
+ * `fragment: true` (webview): instead of the complete document, returns
1546
+ * `{ slides, css, fontsCss, … }` — one standalone fragment per slide, the
1547
+ * stylesheet returned separately, and NO script (the host provides fit/animations; HTML
1548
+ * injected through innerHTML would not run its <script> anyway).
1549
+ */
1550
+ export async function compileHtml(
1551
+ source,
1552
+ {
1553
+ baseDir = process.cwd(),
1554
+ fragment = false,
1555
+ themePath = null,
1556
+ defaultTheme = null,
1557
+ vendor = undefined,
1558
+ imageRoots = [],
1559
+ } = {},
1560
+ ) {
1561
+ const deck = parseDeck(source);
1562
+ // theme + user layouts of the deck — BEFORE buildScenes (the geometry of the
1563
+ // scenes depends on the design tokens)
1564
+ const prep = prepareDeckContext(deck.meta, { baseDir, themePath, defaultTheme });
1565
+ const scenes = buildScenes(deck);
1566
+ if (fragment) {
1567
+ const { slides, stats } = await renderSlideFragments(scenes, deck.meta, baseDir, {
1568
+ vendor,
1569
+ imageRoots,
1570
+ });
1571
+ stats.warnings.push(...prep.diagnostics.map((d) => d.message));
1572
+ return {
1573
+ slides,
1574
+ css: baseCss(),
1575
+ fontsCss: fontFacesCss().css,
1576
+ stats,
1577
+ scenes,
1578
+ deck,
1579
+ meta: deck.meta,
1580
+ themeFile: prep.themeFile,
1581
+ };
1582
+ }
1583
+ const { html, stats } = await renderDeckHtml(scenes, deck.meta, baseDir, { vendor, imageRoots });
1584
+ stats.warnings.push(...prep.diagnostics.map((d) => d.message));
1585
+ return { html, stats, scenes, deck, meta: deck.meta, themeFile: prep.themeFile };
1586
+ }