@openpresentation/opf-render 0.11.8 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ // RR-28: the part of the slideshow player and the <opf-deck> element that has no DOM of its own. It reads an OPF document,
2
+ // decides which slides play (hidden ones do not), renders each slide once with the renderer's own SVG output (renderSvg:
3
+ // there is no second layout engine) behind a face-level font gate, and synchronizes windows over a BroadcastChannel.
4
+ // Nothing here fetches anything but the font files the host's font root serves.
5
+ import { renderSvg } from "./svg.js";
6
+
7
+ /** Error raised for a deck that cannot be read, rendered or loaded. */
8
+ export class DeckError extends Error {
9
+ constructor(code, message, options) {
10
+ super(message, options);
11
+ this.name = "DeckError";
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
17
+
18
+ /** Plain text of a title, text or notes value (a string, a run or an array of runs). Never markup. */
19
+ export function plainText(value) {
20
+ if (value === undefined || value === null) return "";
21
+ if (typeof value === "string") return value;
22
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
23
+ if (Array.isArray(value)) return value.map(plainText).join("");
24
+ if (isObject(value) && typeof value.text === "string") return value.text;
25
+ return "";
26
+ }
27
+
28
+ /** Read a deck: an OPF object, or a JSON string. Throws a DeckError (`invalid-document`) for anything else. */
29
+ export function parseDeckDocument(input) {
30
+ let value = input;
31
+ if (typeof value === "string") {
32
+ try { value = JSON.parse(value); } catch (cause) { throw new DeckError("invalid-document", "The deck is not valid JSON.", { cause }); }
33
+ }
34
+ if (!isObject(value) || !Array.isArray(value.slides)) throw new DeckError("invalid-document", "The deck is not an OPF document: it needs a slides array.");
35
+ return value;
36
+ }
37
+
38
+ /**
39
+ * The slides that play, as indexes into `document.slides`. A hidden slide is skipped unless `includeHidden` is set, so the
40
+ * counter, number-and-Enter and the `slide` attribute all count the same sequence.
41
+ */
42
+ export function presentableIndexes(document, { includeHidden = false } = {}) {
43
+ const slides = Array.isArray(document?.slides) ? document.slides : [];
44
+ const indexes = [];
45
+ slides.forEach((slide, index) => { if (includeHidden || !(isObject(slide) && slide.hidden === true)) indexes.push(index); });
46
+ return indexes;
47
+ }
48
+
49
+ /** Facts about one slide for a counter, a label, a thumbnail or the speaker view. `notes` is plain text only. */
50
+ export function slideInfo(document, index) {
51
+ const slide = isObject(document?.slides?.[index]) ? document.slides[index] : {};
52
+ const title = plainText(slide.title).trim() || plainText(slide.subtitle).trim() || plainText(slide.tag).trim();
53
+ return {
54
+ index,
55
+ id: typeof slide.id === "string" ? slide.id : undefined,
56
+ title: title || `Slide ${index + 1}`,
57
+ hasTitle: Boolean(title),
58
+ notes: plainText(slide.notes),
59
+ section: typeof slide.section === "string" ? slide.section : undefined,
60
+ hidden: slide.hidden === true,
61
+ };
62
+ }
63
+
64
+ /** "Slide 3 of 8: Revenue grew" (the accessible name of a slide). */
65
+ export function slideLabel(info, position, total) {
66
+ return `Slide ${position} of ${total}${info.hasTitle ? `: ${info.title}` : ""}`;
67
+ }
68
+
69
+ /** Whole numbers only: "3", " 3 " and 3 are slide 3; "", "3.5", "x" and 0 are not slide numbers. */
70
+ export function parseSlideNumber(value) {
71
+ if (typeof value === "number") return Number.isInteger(value) ? value : undefined;
72
+ if (typeof value !== "string" || !/^\s*\d+\s*$/.test(value)) return undefined;
73
+ return Number.parseInt(value, 10);
74
+ }
75
+
76
+ /** A position in a sequence of slides. Positions are 0-based here; the public `slide` number is position + 1. */
77
+ export function createCursor(sequence, position = 0) {
78
+ let list = sequence.slice();
79
+ let at = 0;
80
+ const clamp = (value) => (list.length ? Math.min(Math.max(Math.trunc(value), 0), list.length - 1) : 0);
81
+ at = clamp(position);
82
+ return {
83
+ get total() { return list.length; },
84
+ get position() { return at; },
85
+ get number() { return list.length ? at + 1 : 0; },
86
+ get index() { return list.length ? list[at] : -1; },
87
+ get sequence() { return list.slice(); },
88
+ set(next) { const before = at; at = clamp(next); return at !== before; },
89
+ /** Go to the 1-based slide number (clamped to the sequence). */
90
+ goto(number) { return this.set(number - 1); },
91
+ next() { return this.set(at + 1); },
92
+ previous() { return this.set(at - 1); },
93
+ first() { return this.set(0); },
94
+ last() { return this.set(list.length - 1); },
95
+ /** Go to the slide at a document index, or the nearest one after it that plays. */
96
+ gotoIndex(index) {
97
+ const found = list.findIndex((candidate) => candidate >= index);
98
+ return this.set(found === -1 ? list.length - 1 : found);
99
+ },
100
+ reset(nextSequence, keepIndex) {
101
+ list = nextSequence.slice();
102
+ const found = keepIndex === undefined ? -1 : list.findIndex((candidate) => candidate >= keepIndex);
103
+ at = clamp(found === -1 ? (keepIndex === undefined ? at : list.length - 1) : found);
104
+ },
105
+ };
106
+ }
107
+
108
+ // --- SVG preparation -----------------------------------------------------------------------------------------------
109
+
110
+ /** The slide's own size from its root `viewBox`. */
111
+ export function svgDimensions(svg) {
112
+ const match = /^<svg\b[^>]*\sviewBox="([\d.\s-]+)"/.exec(svg);
113
+ const parts = match ? match[1].trim().split(/\s+/).map(Number) : [];
114
+ const width = parts[2] > 0 ? parts[2] : 1280, height = parts[3] > 0 ? parts[3] : 720;
115
+ return { width, height };
116
+ }
117
+
118
+ /**
119
+ * The renderer's SVG made responsive and readable: the fixed `width` and `height` go (the viewBox scales it), and the root's
120
+ * `role="img"` plus `aria-label` go too, so the slide's own text nodes reach a screen reader in document order, which is the
121
+ * reading order (title, then content, then furniture) the renderer paints. The slide section carries the accessible name.
122
+ * `idPrefix` renames the ids the slide defines (gradients, patterns, clip paths) so a thumbnail of a slide and the slide
123
+ * itself never share an id in one tree.
124
+ */
125
+ export function prepareSlideSvg(svg, { idPrefix } = {}) {
126
+ const match = /^<svg\b[^>]*>/.exec(svg);
127
+ if (!match) return svg;
128
+ let root = match[0].replace(/\s(?:width|height|role|aria-label)="[^"]*"/g, "");
129
+ root = root.replace(/^<svg\b/, '<svg class="opf-slide" preserveAspectRatio="xMidYMid meet" focusable="false"');
130
+ let rest = svg.slice(match[0].length);
131
+ if (idPrefix) {
132
+ rest = rest.replace(/\bid="opf-/g, `id="${idPrefix}opf-`).replace(/url\(#opf-/g, `url(#${idPrefix}opf-`).replace(/href="#opf-/g, `href="#${idPrefix}opf-`);
133
+ }
134
+ return root + rest;
135
+ }
136
+
137
+ const UNSAFE_ELEMENTS = new Set(["script", "foreignobject", "iframe", "object", "embed", "animate", "set", "animatetransform", "animatemotion"]);
138
+
139
+ /**
140
+ * Defence in depth for a parsed slide: the renderer escapes everything it writes, but a player may be pointed at a deck
141
+ * nobody reviewed, so scripts, event handlers, foreign content and SMIL animation never reach the page.
142
+ */
143
+ export function sanitizeSvgTree(root) {
144
+ const doomed = [];
145
+ const walk = (node) => {
146
+ for (const child of Array.from(node.children ?? [])) {
147
+ if (UNSAFE_ELEMENTS.has(child.localName.toLowerCase())) { doomed.push(child); continue; }
148
+ for (const attribute of Array.from(child.attributes)) {
149
+ const name = attribute.name.toLowerCase();
150
+ if (name.startsWith("on")) child.removeAttribute(attribute.name);
151
+ else if ((name === "href" || name === "xlink:href") && /^\s*(?:javascript|vbscript):/i.test(attribute.value)) child.removeAttribute(attribute.name);
152
+ }
153
+ walk(child);
154
+ }
155
+ };
156
+ walk(root);
157
+ for (const node of doomed) node.remove();
158
+ return root;
159
+ }
160
+
161
+ const RTL_LANGUAGES = new Set(["ar", "he", "fa", "ur", "ps", "sd", "ug", "yi", "dv", "ckb", "iw"]);
162
+
163
+ /** Whether a deck's language (a BCP-47 tag such as `ar` or `he-IL`) is written right to left. Anything else, including a catalog id, is not. */
164
+ export function isRtlLanguage(language) {
165
+ return typeof language === "string" && RTL_LANGUAGES.has(language.trim().toLowerCase().split(/[-_]/)[0]);
166
+ }
167
+
168
+ /** Today's local calendar date, ISO `YYYY-MM-DD`, for `date: true` header and footer furniture. The renderer reads no clock. */
169
+ export function localIsoDate(now = new Date()) {
170
+ const pad = (value) => String(value).padStart(2, "0");
171
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
172
+ }
173
+
174
+ // --- font gate -----------------------------------------------------------------------------------------------------
175
+
176
+ const MAX_FONT_ROUNDS = 4;
177
+
178
+ /**
179
+ * The gate every render goes through: nothing draws a document before the faces it needs are loaded. `registry` is a browser
180
+ * font registry (`loadBrowserFontRegistry`); one without the lazy loaders gates nothing. Face level: only the vendored and
181
+ * script faces the document draws are fetched, each hash-verified by the registry.
182
+ */
183
+ export function createFontGate(registry) {
184
+ const sources = (document, options) => {
185
+ let lazy = [], scripts = [];
186
+ try { scripts = registry?.pendingScripts?.(document, options) ?? []; } catch { /* the renderer reports the document itself */ }
187
+ try { lazy = (registry?.pendingLazyFonts?.(document, options) ?? []).map((face) => face?.file ?? String(face)); } catch { /* likewise */ }
188
+ return { lazy, scripts };
189
+ };
190
+ return {
191
+ pending(document, options) { const { lazy, scripts } = sources(document, options); return [...lazy, ...scripts]; },
192
+ async ensure(document, { signal, renderOptions } = {}) {
193
+ const call = { ...renderOptions, signal };
194
+ try {
195
+ for (let round = 0; round < MAX_FONT_ROUNDS; round++) {
196
+ const { lazy, scripts } = sources(document, renderOptions);
197
+ if (!lazy.length && !scripts.length) return;
198
+ signal?.throwIfAborted?.();
199
+ if (lazy.length) await registry.ensureLazyFonts?.(document, call);
200
+ if (scripts.length) await registry.ensureScripts?.(document, call);
201
+ }
202
+ } catch (cause) {
203
+ if (signal?.aborted) throw cause;
204
+ throw new DeckError("fonts-unavailable", `Fonts for this deck could not be loaded: ${cause?.message ?? cause}`, { cause });
205
+ }
206
+ const left = sources(document, renderOptions);
207
+ if (left.lazy.length || left.scripts.length) throw new DeckError("fonts-unavailable", `Fonts for this deck did not finish loading: ${[...left.lazy, ...left.scripts].join(", ")}.`);
208
+ },
209
+ };
210
+ }
211
+
212
+ // --- slide store ---------------------------------------------------------------------------------------------------
213
+
214
+ /**
215
+ * One deck's rendering, shared by the element, the full-screen player and the speaker view: font loading once, each slide
216
+ * rendered once by `renderSvg` and kept, so moving between slides never draws twice and every surface shows the same markup.
217
+ *
218
+ * `fonts` is a browser font registry (or nothing: layout then uses estimated widths and the visitor's system sans-serif).
219
+ */
220
+ export function createDeckStore({ document, fonts, renderOptions = {}, date } = {}) {
221
+ const deck = parseDeckDocument(document);
222
+ const gate = fonts ? createFontGate(fonts) : undefined;
223
+ const cache = new Map();
224
+ const diagnostics = [];
225
+ const options = () => ({
226
+ ...renderOptions,
227
+ ...(fonts?.textMeasurement ? { textMeasurement: fonts.textMeasurement } : {}),
228
+ ...(renderOptions.date === undefined && date !== undefined ? { date } : {}),
229
+ onDiagnostic(diagnostic) { diagnostics.push(diagnostic); renderOptions.onDiagnostic?.(diagnostic); },
230
+ });
231
+ let validated = false, ready;
232
+ const store = {
233
+ document: deck,
234
+ diagnostics,
235
+ /** Load the faces the deck draws. Resolves once; a failed load can be retried by calling it again. */
236
+ ready(signal) {
237
+ if (!gate) return Promise.resolve();
238
+ ready ??= gate.ensure(deck, { signal, renderOptions: renderOptions }).catch((error) => { ready = undefined; throw error; });
239
+ return ready;
240
+ },
241
+ /** The renderer's SVG for the slide at a document index (after `ready()`). Rendered once. */
242
+ svg(index) {
243
+ let svg = cache.get(index);
244
+ if (svg === undefined) {
245
+ try {
246
+ svg = renderSvg(deck, { ...options(), slideIndex: index, validate: !validated });
247
+ } catch (cause) {
248
+ if (cause instanceof DeckError) throw cause;
249
+ // A document the schema rejects says where: the first issue's path and message.
250
+ const issue = cause?.code === "invalid-opf" ? (cause.details?.issues ?? cause.issues)?.[0] : undefined;
251
+ const where = issue ? ` (${issue.path || "/"}: ${issue.message})` : "";
252
+ throw new DeckError(cause?.code === "invalid-opf" ? "invalid-document" : "render-failed", `${cause?.message ?? "The slide could not be drawn."}${where}`, { cause });
253
+ }
254
+ validated = true;
255
+ cache.set(index, svg);
256
+ }
257
+ return svg;
258
+ },
259
+ info(index) { return slideInfo(deck, index); },
260
+ /** Forget every drawn slide (after the fonts or options change). */
261
+ invalidate() { cache.clear(); },
262
+ };
263
+ return store;
264
+ }
265
+
266
+ // --- cross-window sync -----------------------------------------------------------------------------------------------
267
+
268
+ export const SYNC_VERSION = 1;
269
+
270
+ /** FNV-1a over a string: a short stable key (not a security measure). */
271
+ function fnv(text) {
272
+ let hash = 0x811c9dc5;
273
+ for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 0x01000193) >>> 0; }
274
+ return hash.toString(16).padStart(8, "0");
275
+ }
276
+
277
+ /** The default channel name of a deck: windows playing the same deck (same origin) hear each other, wherever each loaded it from. */
278
+ export function deckChannelName(document) {
279
+ const slides = Array.isArray(document?.slides) ? document.slides : [];
280
+ const identity = [document?.name ?? "", slides.length, ...slides.slice(0, 64).map((slide, index) => slide?.id ?? index)].join("|");
281
+ return `opf-deck:${fnv(identity)}`;
282
+ }
283
+
284
+ /**
285
+ * Whether a state from another window beats this window's own. Every state carries a revision (a logical clock: a window
286
+ * that changes the show adds one to the highest revision it has seen), and the window id breaks a tie, so two windows that
287
+ * change the show at the same moment settle on the same state instead of swapping them.
288
+ */
289
+ export function stateWins(remote, local) {
290
+ return remote.rev > local.rev || (remote.rev === local.rev && String(remote.from) > String(local.from));
291
+ }
292
+
293
+ /**
294
+ * A BroadcastChannel carrying the one thing windows share: which slide is showing and whether the screen is blanked.
295
+ * Messages are `{ v, type: "state" | "hello", from, rev, index, blank, reply? }`. A window ignores its own messages and keeps
296
+ * a state only when it wins (`stateWins`); a new window says hello and the others answer with their state (`reply`), which
297
+ * the newcomer takes. Malformed or foreign messages are ignored. Without BroadcastChannel it does nothing (`active` is false).
298
+ */
299
+ export function createSync({ name, id, onState, BroadcastChannelImpl = globalThis.BroadcastChannel, getState }) {
300
+ if (typeof BroadcastChannelImpl !== "function" || !name) return { active: false, post() {}, hello() {}, close() {} };
301
+ const channel = new BroadcastChannelImpl(name);
302
+ const send = (state, reply) => channel.postMessage({ v: SYNC_VERSION, type: "state", from: id, rev: state.rev, index: state.index, blank: state.blank, ...(reply ? { reply: true } : {}) });
303
+ channel.onmessage = (event) => {
304
+ const message = event.data;
305
+ if (!isObject(message) || message.v !== SYNC_VERSION || message.from === id) return;
306
+ if (message.type === "state" && Number.isInteger(message.index) && Number.isInteger(message.rev) && ["none", "black", "white"].includes(message.blank)) onState({ index: message.index, blank: message.blank, rev: message.rev, from: String(message.from), reply: message.reply === true });
307
+ else if (message.type === "hello") { const state = getState?.(); if (state) send(state, true); }
308
+ };
309
+ return {
310
+ active: true,
311
+ post: (state) => send(state, false),
312
+ hello() { channel.postMessage({ v: SYNC_VERSION, type: "hello", from: id }); },
313
+ close() { channel.onmessage = null; channel.close(); },
314
+ };
315
+ }
316
+
317
+ let counter = 0;
318
+ /** A window-local id for sync messages. */
319
+ export function newSurfaceId() { counter += 1; return `${Date.now().toString(36)}-${counter}-${Math.random().toString(36).slice(2, 8)}`; }
320
+
321
+ // --- time ---------------------------------------------------------------------------------------------------------
322
+
323
+ /** `1:05` or `1:02:03` for a number of milliseconds (negative values show a leading minus). */
324
+ export function formatDuration(milliseconds) {
325
+ const sign = milliseconds < 0 ? "-" : "";
326
+ const total = Math.floor(Math.abs(milliseconds) / 1000);
327
+ const hours = Math.floor(total / 3600), minutes = Math.floor((total % 3600) / 60), seconds = total % 60;
328
+ const two = (value) => String(value).padStart(2, "0");
329
+ return hours ? `${sign}${hours}:${two(minutes)}:${two(seconds)}` : `${sign}${minutes}:${two(seconds)}`;
330
+ }
331
+
332
+ /** A stopwatch the speaker view reads. `now` is injectable so tests are exact. */
333
+ export function createTimer(now = () => Date.now()) {
334
+ let started, elapsed = 0;
335
+ return {
336
+ get running() { return started !== undefined; },
337
+ elapsed() { return elapsed + (started === undefined ? 0 : now() - started); },
338
+ start() { if (started === undefined) started = now(); },
339
+ pause() { if (started !== undefined) { elapsed += now() - started; started = undefined; } },
340
+ toggle() { if (started === undefined) this.start(); else this.pause(); },
341
+ reset() { elapsed = 0; started = started === undefined ? undefined : now(); },
342
+ };
343
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ // RR-28: importing this module registers <opf-deck>. It is the only file of the element with a side effect; import it from a
2
+ // browser entry point (or use `defineOpfDeck()` from `@openpresentation/opf-render/element` when you want to choose when).
3
+ import { defineOpfDeck } from "./element.js";
4
+
5
+ defineOpfDeck();
@@ -0,0 +1,86 @@
1
+ import type { BrowserFontRegistry } from "./fonts-browser.js";
2
+ import type { RenderSvgOptions } from "./svg.js";
3
+ import type { PlayerSession, PresentOptions, PlayerSlideChange } from "./player.js";
4
+ export { DeckError } from "./player.js";
5
+ export { loadPreviewFonts, previewBaseFaces, previewFontLayout } from "./preview-fonts.js";
6
+
7
+ export declare const OPF_DECK_TAG: "opf-deck";
8
+
9
+ export type OpfDeckSlideChange = PlayerSlideChange;
10
+ export interface OpfDeckErrorDetail {
11
+ code: string;
12
+ message: string;
13
+ /** False when the deck still shows (for example the fonts did not load and the layout is estimated). */
14
+ fatal: boolean;
15
+ }
16
+ export interface OpfDeckEventMap {
17
+ ready: CustomEvent<{ total: number; slide: number }>;
18
+ slidechange: CustomEvent<OpfDeckSlideChange>;
19
+ error: CustomEvent<OpfDeckErrorDetail>;
20
+ presentstart: CustomEvent<{ slide: number }>;
21
+ presentend: CustomEvent<{ slide: number }>;
22
+ }
23
+
24
+ /**
25
+ * `<opf-deck src="deck.opf.json" fonts="/opf-fonts/">`: an embedded OPF deck. Attributes: `src`, `slide` (1-based), `fonts`,
26
+ * `thumbnails`, `controls="none"`, `present`, `include-hidden`, `label`, `keyboard="off"`. It draws the renderer's own SVG.
27
+ */
28
+ export interface OpfDeckElement extends HTMLElement {
29
+ src: string;
30
+ /** The OPF document. Setting it (an object or JSON text) replaces `src`. */
31
+ document: unknown;
32
+ /** The 1-based number of the showing slide in the sequence that plays; set it to navigate. */
33
+ slide: number;
34
+ readonly total: number;
35
+ fonts: string;
36
+ /** A browser font registry the page already has. Wins over `fonts`. */
37
+ fontRegistry: BrowserFontRegistry | undefined;
38
+ /** Extra `renderSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
39
+ renderOptions: Partial<RenderSvgOptions>;
40
+ includeHidden: boolean;
41
+ thumbnails: boolean;
42
+ readonly ready: Promise<void>;
43
+ readonly currentSlide: PlayerSlideChange | undefined;
44
+ next(): boolean;
45
+ previous(): boolean;
46
+ first(): boolean;
47
+ last(): boolean;
48
+ goto(number: number): boolean;
49
+ reload(): Promise<void>;
50
+ /** Open the full-screen slideshow (from a user gesture). Loads the player on first use. */
51
+ present(options?: PresentOptions): Promise<PlayerSession>;
52
+ addEventListener<K extends keyof OpfDeckEventMap>(type: K, listener: (this: OpfDeckElement, event: OpfDeckEventMap[K]) => void, options?: boolean | AddEventListenerOptions): void;
53
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
54
+ }
55
+
56
+ /** The element class (created on first call, so importing this module is safe on a server). Browser only. */
57
+ export declare function getOpfDeckElement(): { new (): OpfDeckElement; readonly observedAttributes: string[] };
58
+ /** Register `<opf-deck>` (or another tag name). Safe to call twice and on a server, where it does nothing. */
59
+ export declare function defineOpfDeck(tagName?: string): CustomElementConstructor | undefined;
60
+
61
+ export interface RenderDeckHtmlOptions {
62
+ tagName?: string;
63
+ /** `"first"` (default: the first slide and a list of titles), `"all"`, or 1-based slide numbers. */
64
+ slides?: "first" | "all" | readonly number[];
65
+ includeHidden?: boolean;
66
+ src?: string;
67
+ slide?: number;
68
+ fonts?: string;
69
+ label?: string;
70
+ thumbnails?: boolean;
71
+ present?: boolean;
72
+ /** Extra attributes on the tag. */
73
+ attributes?: Record<string, string | boolean | undefined>;
74
+ /** Put the document in an `application/opf+json` script child so the element upgrades without a request. */
75
+ embed?: boolean;
76
+ /** Today's date (ISO) for `date: true` furniture. */
77
+ date?: string;
78
+ renderOptions?: Partial<RenderSvgOptions>;
79
+ }
80
+
81
+ /** Server-side markup: the deck's slides as inline SVG inside the tag, shown as is without JavaScript and replaced when the element upgrades. */
82
+ export declare function renderDeckHtml(document: unknown, options?: RenderDeckHtmlOptions): string;
83
+
84
+ declare global {
85
+ interface HTMLElementTagNameMap { "opf-deck": OpfDeckElement }
86
+ }