@openpresentation/opf-render 0.11.9 → 0.12.2

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,521 @@
1
+ // RR-28: <opf-deck>, a framework-free web component that embeds an OPF deck. It draws the renderer's own SVG (renderSvg), so
2
+ // the slide a page shows is the slide the preview, the editor and the PDF show. Importing this module touches no DOM, so it
3
+ // is safe on a server (Next.js); `defineOpfDeck()` registers the tag in a browser.
4
+ import {
5
+ DeckError, createCursor, createDeckStore, isRtlLanguage, localIsoDate, parseDeckDocument, parseSlideNumber, presentableIndexes,
6
+ prepareSlideSvg, sanitizeSvgTree, slideInfo, slideLabel, svgDimensions, plainText,
7
+ } from "./deck-runtime.js";
8
+ import { loadPreviewFonts } from "./preview-fonts.js";
9
+ import { renderSvg } from "./svg.js";
10
+
11
+ export { DeckError } from "./deck-runtime.js";
12
+ export { loadPreviewFonts, previewBaseFaces, previewFontLayout } from "./preview-fonts.js";
13
+
14
+ export const OPF_DECK_TAG = "opf-deck";
15
+
16
+ const SHEET = `
17
+ :host { display: block; min-width: 0; max-width: 100%; color: var(--opf-deck-fg, CanvasText); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
18
+ :host([hidden]) { display: none; }
19
+ * { box-sizing: border-box; }
20
+ [hidden] { display: none !important; }
21
+ .deck { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
22
+ .viewport { position: relative; width: 100%; aspect-ratio: var(--ratio, 16 / 9); overflow: hidden; border-radius: var(--opf-deck-radius, 6px); background: var(--opf-deck-stage, #f1f1f1); border: 1px solid var(--opf-deck-border, rgb(128 128 128 / 0.35)); touch-action: pan-y; outline-offset: 2px; }
23
+ .viewport:focus-visible { outline: 3px solid var(--opf-deck-focus, Highlight); }
24
+ .slide { position: absolute; inset: 0; }
25
+ .slide svg.opf-slide { display: block; width: 100%; height: 100%; }
26
+ .status { position: absolute; inset: 0; display: grid; place-items: center; padding: 16px; text-align: center; color: var(--opf-deck-muted, GrayText); }
27
+ .error { color: var(--opf-deck-error, #b00020); }
28
+ .bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
29
+ .bar[hidden], .thumbs-wrap[hidden] { display: none; }
30
+ .spacer { flex: 1 1 auto; }
31
+ button { font: inherit; color: inherit; background: var(--opf-deck-button, transparent); border: 1px solid var(--opf-deck-border, rgb(128 128 128 / 0.5)); border-radius: var(--opf-deck-radius, 6px); min-width: 36px; min-height: 36px; padding: 4px 12px; cursor: pointer; }
32
+ button:hover:not([aria-disabled="true"]) { background: var(--opf-deck-hover, rgb(128 128 128 / 0.15)); }
33
+ button:focus-visible { outline: 3px solid var(--opf-deck-focus, Highlight); outline-offset: 1px; }
34
+ button[aria-disabled="true"] { opacity: 0.45; cursor: default; }
35
+ .counter { min-width: 4.5em; text-align: center; font-variant-numeric: tabular-nums; }
36
+ .thumbs-wrap { min-width: 0; }
37
+ .thumbs { position: relative; display: flex; gap: 8px; margin: 0; padding: 4px 2px; list-style: none; overflow-x: auto; scroll-snap-type: x proximity; }
38
+ .thumbs li { flex: 0 0 auto; width: 132px; scroll-snap-align: center; }
39
+ .thumb { display: block; width: 100%; padding: 0; min-height: 0; overflow: hidden; background: var(--opf-deck-stage, #f1f1f1); }
40
+ .thumb[aria-current="true"] { outline: 3px solid var(--opf-deck-accent, Highlight); outline-offset: 0; }
41
+ .thumb .frame { display: block; width: 100%; aspect-ratio: var(--ratio, 16 / 9); }
42
+ .thumb svg.opf-slide { display: block; width: 100%; height: 100%; pointer-events: none; }
43
+ .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
44
+ .flip { display: inline-block; }
45
+ .rtl .flip { transform: scaleX(-1); }
46
+ .rtl .bar, .rtl .thumbs { direction: rtl; }
47
+ @media (prefers-reduced-motion: no-preference) { button { transition: background-color 120ms; } }
48
+ @media (forced-colors: active) { .viewport, button, .thumb[aria-current="true"] { border-color: CanvasText; } .thumb[aria-current="true"] { outline-color: Highlight; } }
49
+ `;
50
+
51
+ const supportsCustomElements = () => typeof customElements !== "undefined" && typeof HTMLElement !== "undefined";
52
+ const reducedMotion = () => globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
53
+ const truthy = (value) => value !== null && value !== undefined && !/^(?:false|0|off|no|none)$/i.test(String(value).trim());
54
+
55
+ let ElementClass;
56
+
57
+ /** The element class, created on first use so that importing this module never needs a DOM. Browser only. */
58
+ export function getOpfDeckElement() {
59
+ if (!supportsCustomElements()) throw new DeckError("no-dom", "<opf-deck> needs a browser with custom elements.");
60
+ ElementClass ??= createElementClass();
61
+ return ElementClass;
62
+ }
63
+
64
+ /** Register `<opf-deck>` (or another tag name). Safe to call more than once and on a server, where it does nothing. */
65
+ export function defineOpfDeck(tagName = OPF_DECK_TAG) {
66
+ if (!supportsCustomElements()) return undefined;
67
+ const constructor = getOpfDeckElement();
68
+ if (!customElements.get(tagName)) customElements.define(tagName, tagName === OPF_DECK_TAG ? constructor : class extends constructor {});
69
+ return customElements.get(tagName);
70
+ }
71
+
72
+ function createElementClass() {
73
+ const Base = HTMLElement;
74
+ return class OpfDeckElement extends Base {
75
+ static get observedAttributes() { return ["src", "slide", "fonts", "thumbnails", "controls", "present", "include-hidden", "label", "keyboard"]; }
76
+
77
+ #root; #parts = {}; #built = false; #store; #cursor; #deck; #generation = 0; #abort; #documentValue; #fontRegistry; #renderOptions = {};
78
+ #readyPromise = Promise.resolve(); #state = "idle"; #error; #observer; #thumbsBuilt = false; #pointer; #session; #announce = false;
79
+ #slideAttribute; #sheet;
80
+
81
+ constructor() {
82
+ super();
83
+ this.#root = this.attachShadow({ mode: "open" });
84
+ }
85
+
86
+ connectedCallback() {
87
+ this.#build();
88
+ if (!this.#deck && this.#state === "idle") this.#start();
89
+ else this.#sync();
90
+ }
91
+
92
+ disconnectedCallback() {
93
+ this.#abort?.abort();
94
+ this.#observer?.disconnect();
95
+ this.#observer = undefined;
96
+ this.#thumbsBuilt = false;
97
+ this.#state = this.#state === "ready" ? "ready" : "idle";
98
+ }
99
+
100
+ attributeChangedCallback(name, before, after) {
101
+ if (before === after || !this.#built) return;
102
+ if (name === "src" || name === "fonts" || name === "include-hidden") { if (this.isConnected) this.#start(); return; }
103
+ if (name === "slide") { this.#applySlideAttribute(); return; }
104
+ this.#sync();
105
+ }
106
+
107
+ // --- public API -----------------------------------------------------------------------------------------------
108
+
109
+ /** URL of an OPF JSON document. The one request the element makes besides the font files of `fonts`. */
110
+ get src() { return this.getAttribute("src") ?? ""; }
111
+ set src(value) { if (value === null || value === undefined || value === "") this.removeAttribute("src"); else this.setAttribute("src", String(value)); }
112
+
113
+ /** The OPF document (an object, or a JSON string to set). Setting it replaces `src`. */
114
+ get document() { return this.#deck; }
115
+ set document(value) {
116
+ this.#documentValue = value === null || value === undefined ? undefined : value;
117
+ if (this.isConnected) this.#start();
118
+ }
119
+
120
+ /** The 1-based number of the showing slide, in the sequence that plays (hidden slides are not counted). */
121
+ get slide() { return this.#cursor?.number ?? (parseSlideNumber(this.getAttribute("slide")) ?? 1); }
122
+ set slide(value) { this.setAttribute("slide", String(value)); }
123
+
124
+ /** Number of slides in the sequence that plays. */
125
+ get total() { return this.#cursor?.total ?? 0; }
126
+
127
+ /** URL of the self-hosted font root (see the package README). Without it, layout uses estimated widths and system fonts. */
128
+ get fonts() { return this.getAttribute("fonts") ?? ""; }
129
+ set fonts(value) { if (value) this.setAttribute("fonts", String(value)); else this.removeAttribute("fonts"); }
130
+
131
+ /** A browser font registry the page already has (`loadBrowserFontRegistry`). Wins over `fonts`. */
132
+ get fontRegistry() { return this.#fontRegistry; }
133
+ set fontRegistry(value) { this.#fontRegistry = value || undefined; if (this.isConnected && this.#built) this.#start(); }
134
+
135
+ /** Extra `renderSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
136
+ get renderOptions() { return this.#renderOptions; }
137
+ set renderOptions(value) { this.#renderOptions = value && typeof value === "object" ? value : {}; }
138
+
139
+ get includeHidden() { return this.hasAttribute("include-hidden"); }
140
+ set includeHidden(value) { this.toggleAttribute("include-hidden", Boolean(value)); }
141
+
142
+ get thumbnails() { return this.hasAttribute("thumbnails"); }
143
+ set thumbnails(value) { this.toggleAttribute("thumbnails", Boolean(value)); }
144
+
145
+ /** Resolves when the deck is drawn (or rejects with the error the `error` event carries). */
146
+ get ready() { return this.#readyPromise; }
147
+
148
+ /** What the showing slide is: id, title, plain-text notes, section. */
149
+ get currentSlide() { return this.#cursor && this.#deck ? { ...slideInfo(this.#deck, this.#cursor.index), slide: this.#cursor.number, total: this.#cursor.total } : undefined; }
150
+
151
+ next() { return this.#go(() => this.#cursor?.next()); }
152
+ previous() { return this.#go(() => this.#cursor?.previous()); }
153
+ first() { return this.#go(() => this.#cursor?.first()); }
154
+ last() { return this.#go(() => this.#cursor?.last()); }
155
+ /** Show the slide with this 1-based number (clamped). */
156
+ goto(number) { return this.#go(() => this.#cursor?.goto(Number(number))); }
157
+
158
+ /** Re-read `src` and draw again. */
159
+ reload() { this.#start(); return this.#readyPromise; }
160
+
161
+ /** Open the full-screen slideshow (needs a user gesture to enter full screen). Loads the player on first use. */
162
+ async present(options = {}) {
163
+ if (this.#session && !this.#session.closed) return this.#session;
164
+ await this.#readyPromise;
165
+ if (!this.#store || !this.#cursor) throw new DeckError("not-ready", "The deck is not loaded yet.");
166
+ const { present } = await import("./player.js");
167
+ const session = await present(this.#deck, {
168
+ store: this.#store, fonts: this.#fontRegistry ?? this.#resolvedRegistry, renderOptions: this.#renderOptions,
169
+ includeHidden: this.includeHidden, startSlide: this.#cursor.number, returnFocus: this.#parts.viewport, ...options,
170
+ });
171
+ this.#session = session;
172
+ session.addEventListener("slidechange", (event) => { if (this.#cursor && event.detail?.index !== undefined && this.#cursor.gotoIndex(event.detail.index)) { this.#show({ announce: false }); this.#emitChange(); } });
173
+ session.addEventListener("close", () => {
174
+ if (this.#session === session) this.#session = undefined;
175
+ this.#show({ announce: false });
176
+ this.#parts.viewport?.focus({ preventScroll: true });
177
+ this.dispatchEvent(new CustomEvent("presentend", { detail: { slide: this.#cursor?.number } }));
178
+ });
179
+ this.dispatchEvent(new CustomEvent("presentstart", { detail: { slide: this.#cursor?.number } }));
180
+ return session;
181
+ }
182
+
183
+ // --- loading ----------------------------------------------------------------------------------------------------
184
+
185
+ #resolvedRegistry;
186
+
187
+ #start() {
188
+ this.#abort?.abort();
189
+ const abort = (this.#abort = new AbortController());
190
+ const generation = ++this.#generation;
191
+ this.#state = "loading";
192
+ this.#error = undefined;
193
+ this.#renderState();
194
+ const run = this.#load(abort.signal, generation);
195
+ this.#readyPromise = run;
196
+ run.catch(() => {});
197
+ }
198
+
199
+ async #load(signal, generation) {
200
+ const stale = () => generation !== this.#generation || signal.aborted;
201
+ try {
202
+ const deck = await this.#readDocument(signal);
203
+ if (stale()) return;
204
+ parseDeckDocument(deck);
205
+ let registry = this.#fontRegistry;
206
+ const root = this.fonts;
207
+ if (!registry && root) {
208
+ try { registry = await loadPreviewFonts(root, { signal }); } catch (cause) {
209
+ if (stale()) return;
210
+ this.#report(new DeckError("fonts-unavailable", `The fonts at ${root} could not be loaded; the deck draws with estimated layout and system fonts.`, { cause }), false);
211
+ }
212
+ }
213
+ this.#resolvedRegistry = registry;
214
+ const date = this.#renderOptions.date ?? localIsoDate();
215
+ let store = createDeckStore({ document: deck, fonts: registry, renderOptions: this.#renderOptions, date });
216
+ try { await store.ready(signal); } catch (cause) {
217
+ if (stale()) return;
218
+ if (!registry) throw cause;
219
+ this.#report(cause, false);
220
+ this.#resolvedRegistry = undefined;
221
+ store = createDeckStore({ document: deck, renderOptions: this.#renderOptions, date });
222
+ }
223
+ if (stale()) return;
224
+ // Draw the first slide before accepting the deck, so an invalid document fails here and not on the first click.
225
+ const sequence = presentableIndexes(deck, { includeHidden: this.includeHidden });
226
+ if (sequence.length) store.svg(sequence[Math.min(Math.max((parseSlideNumber(this.getAttribute("slide")) ?? 1) - 1, 0), sequence.length - 1)]);
227
+ this.#deck = deck;
228
+ this.#store = store;
229
+ this.#cursor = createCursor(sequence, (parseSlideNumber(this.getAttribute("slide")) ?? 1) - 1);
230
+ this.#thumbsBuilt = false;
231
+ this.#state = "ready";
232
+ this.#show({ announce: false });
233
+ this.dispatchEvent(new CustomEvent("ready", { detail: { total: this.#cursor.total, slide: this.#cursor.number } }));
234
+ this.#prefetch();
235
+ } catch (error) {
236
+ if (stale() || error?.name === "AbortError") return;
237
+ this.#state = "error";
238
+ this.#error = error instanceof DeckError ? error : new DeckError(error?.code ?? "load-failed", error?.message ?? "The deck could not be loaded.", { cause: error });
239
+ this.#renderState();
240
+ this.#report(this.#error, true);
241
+ throw this.#error;
242
+ }
243
+ }
244
+
245
+ async #readDocument(signal) {
246
+ if (this.#documentValue !== undefined) return parseDeckDocument(this.#documentValue);
247
+ const src = this.getAttribute("src");
248
+ if (src) {
249
+ const response = await fetch(new URL(src, this.ownerDocument.baseURI), { signal, credentials: "same-origin" });
250
+ if (!response.ok) throw new DeckError("fetch-failed", `The deck could not be loaded (${response.status}).`);
251
+ return parseDeckDocument(await response.text());
252
+ }
253
+ const inline = this.querySelector(':scope > script[type="application/opf+json"], :scope > script[type="application/json"]');
254
+ if (inline) return parseDeckDocument(inline.textContent ?? "");
255
+ throw new DeckError("no-document", "Give <opf-deck> a src, a document property or an inline application/opf+json script.");
256
+ }
257
+
258
+ #report(error, fatal) {
259
+ this.dispatchEvent(new CustomEvent("error", { detail: { code: error.code, message: error.message, fatal } }));
260
+ }
261
+
262
+ #prefetch() {
263
+ const store = this.#store, cursor = this.#cursor;
264
+ if (!store || !cursor) return;
265
+ const next = cursor.sequence[cursor.position + 1];
266
+ if (next === undefined) return;
267
+ const run = () => { try { if (this.#store === store) store.svg(next); } catch { /* reported when the slide is shown */ } };
268
+ if (typeof requestIdleCallback === "function") requestIdleCallback(run, { timeout: 500 }); else setTimeout(run, 0);
269
+ }
270
+
271
+ // --- DOM ----------------------------------------------------------------------------------------------------------
272
+
273
+ #build() {
274
+ if (this.#built) return;
275
+ this.#built = true;
276
+ try { this.#sheet = new CSSStyleSheet(); this.#sheet.replaceSync(SHEET); this.#root.adoptedStyleSheets = [this.#sheet]; } catch {
277
+ const style = this.ownerDocument.createElement("style"); style.textContent = SHEET; this.#root.append(style);
278
+ }
279
+ const doc = this.ownerDocument;
280
+ const el = (tag, attributes = {}, ...children) => {
281
+ const node = doc.createElement(tag);
282
+ for (const [name, value] of Object.entries(attributes)) if (value !== undefined) node.setAttribute(name, value);
283
+ node.append(...children);
284
+ return node;
285
+ };
286
+ const parts = this.#parts;
287
+ parts.deck = el("div", { class: "deck", part: "deck", role: "region", "aria-roledescription": "slide deck" });
288
+ parts.viewport = el("div", { class: "viewport", part: "viewport", tabindex: "0", role: "group", "aria-roledescription": "slide" });
289
+ parts.slide = el("div", { class: "slide", part: "slide" });
290
+ parts.status = el("div", { class: "status", part: "status" });
291
+ parts.viewport.append(parts.slide, parts.status);
292
+ parts.prev = el("button", { type: "button", class: "previous", part: "button previous", "aria-label": "Previous slide" }, el("span", { class: "flip", "aria-hidden": "true" }, "\u25C0"));
293
+ parts.counterText = el("span", { "aria-hidden": "true" });
294
+ parts.counterLabel = el("span", { class: "sr-only" });
295
+ parts.counter = el("span", { class: "counter", part: "counter" }, parts.counterText, parts.counterLabel);
296
+ parts.next = el("button", { type: "button", class: "next", part: "button next", "aria-label": "Next slide" }, el("span", { class: "flip", "aria-hidden": "true" }, "\u25B6"));
297
+ parts.present = el("button", { type: "button", class: "present", part: "button present" }, "Present");
298
+ parts.bar = el("div", { class: "bar", part: "bar", role: "group", "aria-label": "Slide navigation" }, parts.prev, parts.counter, parts.next, el("span", { class: "spacer" }), parts.present);
299
+ parts.thumbsWrap = el("div", { class: "thumbs-wrap", part: "thumbnails" });
300
+ parts.thumbs = el("ul", { class: "thumbs", role: "list", "aria-label": "Slides" });
301
+ parts.thumbsWrap.append(parts.thumbs);
302
+ parts.live = el("div", { class: "sr-only", role: "status", "aria-live": "polite", "aria-atomic": "true" });
303
+ parts.deck.append(parts.viewport, parts.bar, parts.thumbsWrap, parts.live);
304
+ this.#root.append(parts.deck);
305
+
306
+ parts.prev.addEventListener("click", () => this.previous());
307
+ parts.next.addEventListener("click", () => this.next());
308
+ parts.present.addEventListener("click", () => { this.present().catch((error) => this.#report(error instanceof DeckError ? error : new DeckError("present-failed", error?.message ?? "Could not start the slideshow.", { cause: error }), false)); });
309
+ parts.viewport.addEventListener("keydown", (event) => this.#onKey(event));
310
+ parts.viewport.addEventListener("pointerdown", (event) => { if (event.pointerType !== "mouse") this.#pointer = { x: event.clientX, y: event.clientY, id: event.pointerId }; });
311
+ parts.viewport.addEventListener("pointerup", (event) => this.#onPointerUp(event));
312
+ parts.viewport.addEventListener("pointercancel", () => { this.#pointer = undefined; });
313
+ parts.thumbs.addEventListener("keydown", (event) => this.#onThumbKey(event));
314
+ this.#renderState();
315
+ }
316
+
317
+ #onKey(event) {
318
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || !truthy(this.getAttribute("keyboard") ?? "on")) return;
319
+ const rtl = this.#parts.deck.classList.contains("rtl");
320
+ const map = { ArrowRight: rtl ? "previous" : "next", ArrowLeft: rtl ? "next" : "previous", PageDown: "next", PageUp: "previous", Home: "first", End: "last" };
321
+ const action = map[event.key];
322
+ if (!action) return;
323
+ event.preventDefault();
324
+ this.#announce = true;
325
+ this[action]();
326
+ }
327
+
328
+ #onThumbKey(event) {
329
+ const buttons = [...this.#parts.thumbs.querySelectorAll("button")];
330
+ const at = buttons.indexOf(this.#root.activeElement);
331
+ if (at === -1) return;
332
+ const rtl = this.#parts.deck.classList.contains("rtl");
333
+ const step = { ArrowRight: rtl ? -1 : 1, ArrowLeft: rtl ? 1 : -1 }[event.key];
334
+ let target;
335
+ if (step) target = buttons[Math.min(Math.max(at + step, 0), buttons.length - 1)];
336
+ else if (event.key === "Home") target = buttons[0];
337
+ else if (event.key === "End") target = buttons[buttons.length - 1];
338
+ if (!target) return;
339
+ event.preventDefault();
340
+ for (const button of buttons) button.tabIndex = button === target ? 0 : -1;
341
+ target.focus();
342
+ }
343
+
344
+ #onPointerUp(event) {
345
+ const start = this.#pointer;
346
+ this.#pointer = undefined;
347
+ if (!start || start.id !== event.pointerId) return;
348
+ const dx = event.clientX - start.x, dy = event.clientY - start.y;
349
+ if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
350
+ const rtl = this.#parts.deck.classList.contains("rtl");
351
+ this.#announce = true;
352
+ if ((dx < 0) !== rtl) this.next(); else this.previous();
353
+ }
354
+
355
+ #go(move) {
356
+ if (!this.#cursor) return false;
357
+ const changed = move();
358
+ if (changed) { this.#show({ announce: this.#announce }); this.#emitChange(); }
359
+ this.#announce = false;
360
+ return changed;
361
+ }
362
+
363
+ #applySlideAttribute() {
364
+ const wanted = parseSlideNumber(this.getAttribute("slide"));
365
+ if (!this.#cursor || wanted === undefined || wanted === this.#cursor.number) return;
366
+ if (this.#cursor.goto(wanted)) { this.#show({ announce: false }); this.#emitChange(); }
367
+ }
368
+
369
+ #emitChange() {
370
+ if (!this.#cursor || !this.#deck) return;
371
+ const info = slideInfo(this.#deck, this.#cursor.index);
372
+ this.dispatchEvent(new CustomEvent("slidechange", { detail: { slide: this.#cursor.number, total: this.#cursor.total, index: info.index, id: info.id, title: info.title, notes: info.notes, section: info.section }, bubbles: true, composed: true }));
373
+ }
374
+
375
+ #renderState() {
376
+ const parts = this.#parts;
377
+ if (!parts.status) return;
378
+ const loading = this.#state === "loading" || this.#state === "idle";
379
+ parts.deck.setAttribute("aria-busy", loading ? "true" : "false");
380
+ parts.status.hidden = this.#state === "ready";
381
+ parts.status.className = `status${this.#state === "error" ? " error" : ""}`;
382
+ parts.status.setAttribute("role", this.#state === "error" ? "alert" : "status");
383
+ parts.status.textContent = this.#state === "error" ? this.#error?.message ?? "The deck could not be loaded." : loading ? "Loading slides\u2026" : "";
384
+ parts.deck.setAttribute("aria-label", this.getAttribute("label") || plainText(this.#deck?.name) || "Presentation");
385
+ }
386
+
387
+ #sync() { if (this.#built) { this.#renderState(); if (this.#state === "ready") this.#show({ announce: false }); } }
388
+
389
+ /** Draw the showing slide, the counter and the strip. */
390
+ #show({ announce }) {
391
+ const parts = this.#parts, cursor = this.#cursor, store = this.#store;
392
+ if (!cursor || !store) return;
393
+ this.#renderState();
394
+ // The page decides (a dir attribute, CSS direction); a deck in a right-to-left language decides when the page did not.
395
+ const rtl = this.ownerDocument.defaultView?.getComputedStyle(this)?.direction === "rtl" || (!this.closest("[dir]") && isRtlLanguage(this.#deck?.language));
396
+ parts.deck.classList.toggle("rtl", Boolean(rtl));
397
+ const info = store.info(cursor.index);
398
+ let svg;
399
+ try { svg = store.svg(cursor.index); } catch (error) {
400
+ this.#state = "error"; this.#error = error; this.#renderState(); this.#report(error, true); return;
401
+ }
402
+ const label = slideLabel(info, cursor.number, cursor.total);
403
+ const template = this.ownerDocument.createElement("template");
404
+ template.innerHTML = prepareSlideSvg(svg);
405
+ const node = template.content.firstElementChild;
406
+ if (node) { sanitizeSvgTree(node); parts.slide.replaceChildren(node); }
407
+ const { width, height } = svgDimensions(svg);
408
+ parts.deck.style.setProperty("--ratio", `${width} / ${height}`);
409
+ parts.viewport.setAttribute("aria-label", label);
410
+ parts.counterText.textContent = `${cursor.number} / ${cursor.total}`;
411
+ parts.counterLabel.textContent = label;
412
+ parts.prev.setAttribute("aria-disabled", String(cursor.number <= 1));
413
+ parts.next.setAttribute("aria-disabled", String(cursor.number >= cursor.total));
414
+ parts.bar.hidden = this.getAttribute("controls") === "none" || !cursor.total;
415
+ parts.present.hidden = !this.hasAttribute("present");
416
+ parts.thumbsWrap.hidden = !this.hasAttribute("thumbnails");
417
+ if (announce) parts.live.textContent = label;
418
+ if (this.hasAttribute("thumbnails")) this.#renderThumbnails();
419
+ this.#markThumbnail();
420
+ this.#prefetch();
421
+ }
422
+
423
+ #renderThumbnails() {
424
+ const parts = this.#parts, store = this.#store, cursor = this.#cursor;
425
+ if (!store || !cursor || this.#thumbsBuilt) return;
426
+ this.#thumbsBuilt = true;
427
+ this.#observer?.disconnect();
428
+ const doc = this.ownerDocument;
429
+ const sequence = cursor.sequence;
430
+ const first = sequence.length ? svgDimensions(store.svg(sequence[0])) : { width: 16, height: 9 };
431
+ const items = sequence.map((index, position) => {
432
+ const info = store.info(index);
433
+ const li = doc.createElement("li");
434
+ const button = doc.createElement("button");
435
+ button.type = "button";
436
+ button.className = "thumb";
437
+ button.setAttribute("part", "thumbnail");
438
+ button.setAttribute("aria-label", `Go to ${slideLabel(info, position + 1, sequence.length)}`);
439
+ button.tabIndex = position === cursor.position ? 0 : -1;
440
+ const frame = doc.createElement("span");
441
+ frame.className = "frame";
442
+ frame.style.setProperty("--ratio", `${first.width} / ${first.height}`);
443
+ frame.setAttribute("aria-hidden", "true");
444
+ button.append(frame);
445
+ button.addEventListener("click", () => { this.#announce = true; this.#go(() => this.#cursor?.goto(position + 1)); });
446
+ li.append(button);
447
+ li._frame = frame; li._index = index;
448
+ return li;
449
+ });
450
+ parts.thumbs.replaceChildren(...items);
451
+ const fill = (li) => {
452
+ if (li._filled) return; li._filled = true;
453
+ try {
454
+ const template = doc.createElement("template");
455
+ template.innerHTML = prepareSlideSvg(store.svg(li._index), { idPrefix: "t-" });
456
+ const node = template.content.firstElementChild;
457
+ if (node) { sanitizeSvgTree(node); li._frame.replaceChildren(node); }
458
+ } catch { /* the slide itself reports its error when shown */ }
459
+ };
460
+ if (typeof IntersectionObserver === "function") {
461
+ this.#observer = new IntersectionObserver((entries) => { for (const entry of entries) if (entry.isIntersecting) { fill(entry.target); this.#observer?.unobserve(entry.target); } }, { root: parts.thumbs, rootMargin: "200px" });
462
+ for (const li of items) this.#observer.observe(li);
463
+ } else for (const li of items) fill(li);
464
+ }
465
+
466
+ #markThumbnail() {
467
+ const parts = this.#parts, cursor = this.#cursor;
468
+ if (!cursor || !parts.thumbs) return;
469
+ const items = [...parts.thumbs.children];
470
+ items.forEach((li, position) => {
471
+ const button = li.firstElementChild;
472
+ const current = position === cursor.position;
473
+ if (current) button.setAttribute("aria-current", "true"); else button.removeAttribute("aria-current");
474
+ if (this.#root.activeElement !== button || current) button.tabIndex = current ? 0 : -1;
475
+ });
476
+ const li = items[cursor.position];
477
+ if (li && parts.thumbs.scrollWidth > parts.thumbs.clientWidth) {
478
+ // Measured against the strip itself (not offsetLeft, which depends on where the page positions the element).
479
+ const strip = parts.thumbs.getBoundingClientRect(), item = li.getBoundingClientRect();
480
+ const left = parts.thumbs.scrollLeft + (item.left - strip.left) - (strip.width - item.width) / 2;
481
+ parts.thumbs.scrollTo({ left: Math.max(0, left), behavior: reducedMotion() ? "auto" : "smooth" });
482
+ }
483
+ }
484
+ };
485
+ }
486
+
487
+ // --- static fallback --------------------------------------------------------------------------------------------------
488
+
489
+ const escapeHtml = (value) => String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
490
+
491
+ /**
492
+ * Server-side markup for a deck: the slides as inline SVG inside the tag, which a page that has not loaded JavaScript (or a
493
+ * crawler, or a reader) shows as it is, and which the element replaces with its own shadow DOM when it upgrades. Pure and
494
+ * synchronous; layout uses estimated widths unless you pass `renderOptions.textMeasurement` (and `embeddedFonts`).
495
+ *
496
+ * `slides`: `"first"` (default: the first slide plus a list of titles), `"all"` or a list of 1-based slide numbers.
497
+ * `embed` puts the document in an `application/opf+json` script child, so the element upgrades without a request.
498
+ */
499
+ export function renderDeckHtml(input, options = {}) {
500
+ const deck = parseDeckDocument(input);
501
+ const tag = options.tagName ?? OPF_DECK_TAG;
502
+ const sequence = presentableIndexes(deck, { includeHidden: options.includeHidden });
503
+ const wanted = options.slides ?? "first";
504
+ const shown = wanted === "all" ? sequence.map((_, position) => position) : wanted === "first" ? [0] : wanted.map((number) => number - 1).filter((position) => position >= 0 && position < sequence.length);
505
+ const attributes = { src: options.src, slide: options.slide, fonts: options.fonts, label: options.label, ...options.attributes };
506
+ const attributeText = Object.entries(attributes).filter(([, value]) => value !== undefined && value !== false).map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${escapeHtml(value)}"`)).join("")
507
+ + (options.thumbnails ? " thumbnails" : "") + (options.present ? " present" : "") + (options.includeHidden ? " include-hidden" : "");
508
+ const date = options.renderOptions?.date ?? options.date;
509
+ const figures = shown.map((position) => {
510
+ const index = sequence[position];
511
+ const svg = renderSvg(deck, { ...options.renderOptions, ...(date ? { date } : {}), slideIndex: index });
512
+ const { width, height } = svgDimensions(svg);
513
+ const prepared = prepareSlideSvg(svg).replace(/^<svg\b/, `<svg style="display:block;width:100%;height:auto;aspect-ratio:${width} / ${height}"`);
514
+ const label = slideLabel(slideInfo(deck, index), position + 1, sequence.length);
515
+ return `<figure role="group" aria-roledescription="slide" aria-label="${escapeHtml(label)}" style="margin:0 0 8px">${prepared}</figure>`;
516
+ });
517
+ const titles = wanted === "first" && sequence.length > 1
518
+ ? `<nav aria-label="Slides"><ol>${sequence.map((index) => `<li>${escapeHtml(slideInfo(deck, index).title)}</li>`).join("")}</ol></nav>` : "";
519
+ const embedded = options.embed ? `<script type="application/opf+json">${JSON.stringify(deck).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")}</script>` : "";
520
+ return `<${tag}${attributeText}>${figures.join("")}${titles}${embedded}</${tag}>`;
521
+ }
@@ -0,0 +1,26 @@
1
+ import type { PdfDiagnostic, PdfMetadata, SvgToPdfOptions } from "./index.js";
2
+ export type { PdfDiagnostic, PdfMetadata } from "./index.js";
3
+
4
+ export interface BrowserSvgToPngOptions {
5
+ /** Multiplies the SVG's own size (default 1). The result is limited to 40 megapixels. */
6
+ scale?: number;
7
+ /** A CSS colour painted first (default white); `"transparent"` keeps the alpha channel. */
8
+ background?: string;
9
+ signal?: AbortSignal;
10
+ }
11
+
12
+ export interface BrowserSvgToPdfOptions extends Pick<SvgToPdfOptions, "mode" | "background" | "serifFamily" | "metadata" | "tagged" | "compress" | "strict" | "rasterFallbackScale" | "defaultFontFamily" | "sansSerifFamily" | "monospaceFamily" | "onDiagnostic"> {
13
+ /** Raster mode: pixel density of each page image (default 2). */
14
+ scale?: number;
15
+ /** Extra font faces as bytes (TrueType outlines). Faces an SVG embeds as `@font-face` data (renderSvg's `embeddedFonts`) need no entry here. */
16
+ fontData?: Array<{ data: Uint8Array; family?: string }>;
17
+ /** Checked between pages; an abort rejects with the signal's reason (an `AbortError`). */
18
+ signal?: AbortSignal;
19
+ /** Called after each page is written. */
20
+ onProgress?: (progress: { page: number; pages: number }) => void;
21
+ }
22
+
23
+ export declare function svgToPng(svg: string | Uint8Array, options?: BrowserSvgToPngOptions): Promise<Uint8Array>;
24
+ export declare function svgToPdf(svgs: string | Uint8Array | Array<string | Uint8Array>, options?: BrowserSvgToPdfOptions): Promise<Uint8Array>;
25
+ /** Format of an embedded picture and, for a JPEG, its size, component count and EXIF orientation. */
26
+ export declare function sniffImage(bytes: Uint8Array): { format: "png" | "webp" | "gif" | "jpeg" | undefined; width?: number; height?: number; channels?: number; orientation?: number; space?: string };