@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.
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/design/layouts/before-after.json +6 -0
- package/design/layouts/funnel.json +6 -0
- package/design/layouts/journey.json +5 -0
- package/design/layouts/key-message.json +5 -0
- package/design/layouts/portfolio.json +8 -0
- package/design/layouts/priority-matrix.json +7 -0
- package/design/layouts/pros-cons.json +6 -0
- package/design/layouts/pyramid.json +6 -0
- package/design/layouts/risk-map.json +8 -0
- package/design/layouts/roadmap.json +6 -0
- package/design/themes/default.json +159 -0
- package/package.json +57 -0
- package/src/cli.mjs +1114 -0
- package/src/deck/assets.mjs +910 -0
- package/src/deck/chart.mjs +424 -0
- package/src/deck/context.mjs +62 -0
- package/src/deck/highlight.mjs +206 -0
- package/src/deck/kit.mjs +342 -0
- package/src/deck/layout.mjs +1599 -0
- package/src/deck/parse.mjs +776 -0
- package/src/deck/suggest.mjs +33 -0
- package/src/deck/theme.mjs +1135 -0
- package/src/deck/tokens.mjs +268 -0
- package/src/deck/validate.mjs +737 -0
- package/src/html/render.mjs +1586 -0
- package/src/kit/archive.mjs +448 -0
- package/src/pptx/anim.mjs +196 -0
- package/src/pptx/fonts.mjs +204 -0
- package/src/pptx/morph.mjs +103 -0
- package/src/pptx/render.mjs +1379 -0
- package/src/vendor.mjs +281 -0
- package/src/worker/protocol.d.ts +82 -0
- package/src/worker/worker.mjs +145 -0
|
@@ -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, '<')
|
|
74
|
+
.replace(/>/g, '>')
|
|
75
|
+
.replace(/"/g, '"');
|
|
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
|
+
* `javascript:` 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, `<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, '"').replace(/</g, '<')}"`;
|
|
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 += '<'; // 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
|
+
}
|