@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,154 @@
1
+ import pako from "pako";
2
+ import { sha256Bytes } from "./sha256.js";
3
+
4
+ // A small PDF object writer for the vector export (RR-12). Everything it emits is a pure function of the objects
5
+ // added to it: objects are numbered in the order they are added, written in number order, compressed with pako
6
+ // (pure JavaScript, so the bytes do not depend on the platform's zlib build), and the file identifier is a hash
7
+ // of the content. There is no clock, no random data and no locale-dependent formatting.
8
+
9
+ const encoder = new TextEncoder();
10
+
11
+ /** A coordinate or other real number: at most `digits` decimals, no exponent, no negative zero. */
12
+ export function num(value, digits = 3) {
13
+ if (!Number.isFinite(value)) return "0";
14
+ // PDF readers keep reals in single precision and some reject exponents: clamp absurd coordinates to a sane range.
15
+ if (Math.abs(value) > 1e9) value = Math.sign(value) * 1e9;
16
+ const scale = 10 ** digits;
17
+ let rounded = Math.round(value * scale) / scale;
18
+ if (Object.is(rounded, -0)) rounded = 0;
19
+ let text = String(rounded);
20
+ if (/e/i.test(text)) text = rounded.toFixed(digits).replace(/\.?0+$/, "") || "0";
21
+ return text;
22
+ }
23
+
24
+ /** PDF name object (`/Name`), escaping every byte outside the regular printable range. */
25
+ export function name(value) {
26
+ let out = "/";
27
+ for (const byte of encoder.encode(String(value))) {
28
+ out += byte < 0x21 || byte > 0x7e || "#()<>[]{}/%".includes(String.fromCharCode(byte))
29
+ ? "#" + byte.toString(16).toUpperCase().padStart(2, "0")
30
+ : String.fromCharCode(byte);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** A PDF text string: PDFDocEncoding-safe ASCII as a literal string, everything else as UTF-16BE with a byte order mark. */
36
+ export function textString(value) {
37
+ const text = String(value);
38
+ if (/^[\x20-\x7e]*$/.test(text)) return "(" + text.replace(/[\\()]/g, "\\$&") + ")";
39
+ let hex = "FEFF";
40
+ for (let index = 0; index < text.length; index++) hex += text.charCodeAt(index).toString(16).toUpperCase().padStart(4, "0");
41
+ return "<" + hex + ">";
42
+ }
43
+
44
+ /** A 7-bit ASCII literal string (URIs); non-ASCII must already be percent-encoded. */
45
+ export function asciiString(value) {
46
+ return "(" + String(value).replace(/[\\()]/g, "\\$&").replace(/[\r\n]/g, " ") + ")";
47
+ }
48
+
49
+ export function hex(bytes) {
50
+ let out = "";
51
+ for (const byte of bytes) out += byte.toString(16).toUpperCase().padStart(2, "0");
52
+ return out;
53
+ }
54
+
55
+ export function utf16Hex(text) {
56
+ let out = "";
57
+ for (let index = 0; index < text.length; index++) out += text.charCodeAt(index).toString(16).toUpperCase().padStart(4, "0");
58
+ return out;
59
+ }
60
+
61
+ export function ref(number) {
62
+ return `${number} 0 R`;
63
+ }
64
+
65
+ export function flate(bytes) {
66
+ return pako.deflate(bytes, { level: 9 });
67
+ }
68
+
69
+ export function sha256(bytes) {
70
+ return sha256Bytes(bytes);
71
+ }
72
+
73
+ export class PdfFile {
74
+ constructor({ compress = true } = {}) {
75
+ this.compress = compress;
76
+ this.objects = [];
77
+ }
78
+
79
+ /** Reserve an object number to fill later (forward references). */
80
+ reserve() {
81
+ this.objects.push(null);
82
+ return this.objects.length;
83
+ }
84
+
85
+ /** Fill a reserved object. `dict` is the dictionary body without delimiters; `stream` makes it a stream object. */
86
+ set(number, dict, stream) {
87
+ this.objects[number - 1] = { dict, stream };
88
+ }
89
+
90
+ add(dict, stream) {
91
+ const number = this.reserve();
92
+ this.set(number, dict, stream);
93
+ return number;
94
+ }
95
+
96
+ /** A stream object, Flate-compressed unless the file was created uncompressed or `raw` is set. */
97
+ addStream(dict, bytes, { raw = false } = {}) {
98
+ const data = typeof bytes === "string" ? encoder.encode(bytes) : bytes;
99
+ if (this.compress && !raw && data.length > 32) {
100
+ return this.add(`${dict}/Filter/FlateDecode`, flate(data));
101
+ }
102
+ return this.add(dict, data);
103
+ }
104
+
105
+ /** The complete file. `catalog`, `info` are object numbers. */
106
+ serialize({ catalog, info }) {
107
+ const chunks = [];
108
+ let offset = 0;
109
+ const push = (data) => {
110
+ const bytes = typeof data === "string" ? latin1(data) : data;
111
+ chunks.push(bytes);
112
+ offset += bytes.length;
113
+ };
114
+ push("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
115
+ const offsets = [];
116
+ for (let index = 0; index < this.objects.length; index++) {
117
+ const object = this.objects[index];
118
+ if (!object) throw new Error(`PDF object ${index + 1} was reserved but never filled.`);
119
+ offsets.push(offset);
120
+ if (object.stream) {
121
+ push(`${index + 1} 0 obj\n<<${object.dict}/Length ${object.stream.length}>>\nstream\n`);
122
+ push(object.stream);
123
+ push("\nendstream\nendobj\n");
124
+ } else {
125
+ push(`${index + 1} 0 obj\n<<${object.dict}>>\nendobj\n`);
126
+ }
127
+ }
128
+ const startxref = offset;
129
+ let table = `xref\n0 ${this.objects.length + 1}\n0000000000 65535 f \n`;
130
+ for (const position of offsets) table += String(position).padStart(10, "0") + " 00000 n \n";
131
+ push(table);
132
+ // The identifier is a hash of everything before the trailer: identical input, identical file.
133
+ const digest = hex(sha256(concat(chunks)).subarray(0, 16));
134
+ push(`trailer\n<</Size ${this.objects.length + 1}/Root ${ref(catalog)}${info ? `/Info ${ref(info)}` : ""}/ID[<${digest}><${digest}>]>>\nstartxref\n${startxref}\n%%EOF\n`);
135
+ return concat(chunks);
136
+ }
137
+ }
138
+
139
+ function latin1(text) {
140
+ const bytes = new Uint8Array(text.length);
141
+ for (let index = 0; index < text.length; index++) bytes[index] = text.charCodeAt(index) & 0xff;
142
+ return bytes;
143
+ }
144
+
145
+ export function concat(chunks) {
146
+ let length = 0;
147
+ for (const chunk of chunks) length += chunk.length;
148
+ const out = new Uint8Array(length);
149
+ let position = 0;
150
+ for (const chunk of chunks) { out.set(chunk, position); position += chunk.length; }
151
+ return out;
152
+ }
153
+
154
+ export { encoder as utf8Encoder };
@@ -0,0 +1,78 @@
1
+ // A minimal, dependency-free XML reader for the SVG the renderer (and other SVG producers) emit. It builds
2
+ // {name, attrs, children, parent} elements and {text} nodes, decodes the predefined and numeric entities, keeps
3
+ // CDATA as text, and ignores comments, processing instructions and the DOCTYPE. It does not validate.
4
+
5
+ const TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[([\s\S]*?)\]\]>|<\?[\s\S]*?\?>|<!DOCTYPE(?:[^>[]|\[[\s\S]*?\])*>|<(\/?)([A-Za-z_][\w:.-]*)((?:"[^"]*"|'[^']*'|[^'">])*?)(\/?)>|[^<]+|</g;
6
+ const ATTRIBUTE = /([^\s=/>"']+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
7
+ const MAX_DEPTH = 1000;
8
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
9
+
10
+ export function decodeEntities(text) {
11
+ if (!text.includes("&")) return text;
12
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, code) => {
13
+ if (code[0] === "#") {
14
+ const point = code[1].toLowerCase() === "x" ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
15
+ return Number.isInteger(point) && point >= 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff) ? String.fromCodePoint(point) : match;
16
+ }
17
+ return ENTITIES[code] ?? match;
18
+ });
19
+ }
20
+
21
+ export function parseXml(source) {
22
+ const root = { name: "#document", attrs: {}, children: [], parent: null };
23
+ let current = root;
24
+ let depth = 0;
25
+ for (const match of source.matchAll(TOKEN)) {
26
+ const [token, cdata, closing, tagName, attrText, selfClosing] = match;
27
+ if (tagName !== undefined) {
28
+ const local = tagName.includes(":") ? tagName.slice(tagName.indexOf(":") + 1) : tagName;
29
+ if (closing) {
30
+ // Tolerate stray end tags: close up to the nearest matching open element.
31
+ let node = current;
32
+ while (node && node.name !== local) node = node.parent;
33
+ if (node?.parent) { let up = current; while (up !== node) { up = up.parent; depth--; } depth--; current = node.parent; }
34
+ continue;
35
+ }
36
+ const attrs = {};
37
+ for (const attribute of attrText.matchAll(ATTRIBUTE)) attrs[attribute[1]] = decodeEntities(attribute[2] ?? attribute[3] ?? "");
38
+ const element = { name: local, attrs, children: [], parent: current };
39
+ current.children.push(element);
40
+ if (!selfClosing) {
41
+ current = element;
42
+ // Every walk over the tree is recursive: refuse trees deep enough to overflow the stack.
43
+ if (++depth > MAX_DEPTH) throw new RangeError(`SVG elements are nested more than ${MAX_DEPTH} deep.`);
44
+ }
45
+ } else if (cdata !== undefined) {
46
+ current.children.push({ text: cdata });
47
+ } else if (token[0] !== "<" || token === "<") {
48
+ current.children.push({ text: decodeEntities(token) });
49
+ }
50
+ }
51
+ return root.children.find((child) => child.name === "svg") ?? null;
52
+ }
53
+
54
+ export function isElement(node) {
55
+ return node && node.name !== undefined;
56
+ }
57
+
58
+ /** Serialize an element subtree back to XML (used to rasterize the few effects the PDF cannot draw as vectors). */
59
+ export function serialize(node, keepAttribute = () => true) {
60
+ if (node.text !== undefined) return escapeText(node.text);
61
+ const attrs = Object.entries(node.attrs).filter(([key, value]) => keepAttribute(key, value, node)).map(([key, value]) => ` ${key}="${escapeAttribute(value)}"`).join("");
62
+ if (!node.children.length) return `<${node.name}${attrs}/>`;
63
+ return `<${node.name}${attrs}>${node.children.map((child) => serialize(child, keepAttribute)).join("")}</${node.name}>`;
64
+ }
65
+
66
+ export function escapeText(value) {
67
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
68
+ }
69
+
70
+ export function escapeAttribute(value) {
71
+ return escapeText(value).replace(/"/g, "&quot;");
72
+ }
73
+
74
+ /** Text content of an element, concatenated in document order. */
75
+ export function textContent(node) {
76
+ if (node.text !== undefined) return node.text;
77
+ return node.children.map(textContent).join("");
78
+ }
@@ -0,0 +1,79 @@
1
+ import type { BrowserFontRegistry } from "./fonts-browser.js";
2
+ import type { RenderSvgOptions } from "./svg.js";
3
+
4
+ /**
5
+ * Why a deck could not be loaded, drawn or presented. `code` is stable: `invalid-document`, `fetch-failed`, `fonts-unavailable`,
6
+ * `render-failed`, `no-slides`, `no-document`, `not-ready`, `no-dom`, `present-failed`, `load-failed`.
7
+ */
8
+ export declare class DeckError extends Error {
9
+ readonly code: string;
10
+ constructor(code: string, message: string, options?: { cause?: unknown });
11
+ }
12
+
13
+ /** The slide that is showing, in the sequence that plays (hidden slides are not counted). `notes` is plain text. */
14
+ export interface PlayerSlideChange { slide: number; total: number; index: number; id?: string; title: string; notes: string; section?: string }
15
+
16
+ export type BlankMode = "none" | "black" | "white";
17
+
18
+ export interface PresentOptions {
19
+ /** The self-hosted font root URL (see `copyPreviewFonts`), or a browser font registry the page already has. Without it layout uses estimated widths and system fonts. */
20
+ fonts?: string | BrowserFontRegistry;
21
+ /** Extra `renderSvg` options (catalogs, imageResolver, date, ...). */
22
+ renderOptions?: Partial<RenderSvgOptions>;
23
+ /** 1-based slide to start on, in the sequence that plays. Default 1. */
24
+ startSlide?: number;
25
+ /** Play hidden slides too. Default false. */
26
+ includeHidden?: boolean;
27
+ /** Ask for full screen (needs a user gesture). Default true. */
28
+ fullscreen?: boolean;
29
+ /** Open the speaker view window too (needs a user gesture). */
30
+ presenterView?: boolean;
31
+ /** `"presenter"` makes this window the speaker view (for a second tab or window of your page). Default `"audience"`. */
32
+ role?: "audience" | "presenter";
33
+ /** BroadcastChannel name, or `false` for none. Default: derived from the deck, so windows of one deck (same origin) follow each other. */
34
+ channel?: string | false;
35
+ /** Where the player is mounted. Default `document.body`. */
36
+ container?: HTMLElement;
37
+ /** Where focus returns when the show ends. Default: the element focused when it started. */
38
+ returnFocus?: HTMLElement | null;
39
+ signal?: AbortSignal;
40
+ /** An injectable clock (milliseconds since the epoch) for the speaker view's timer and clock. */
41
+ now?: () => number;
42
+ /** A deck store shared with an `<opf-deck>` (advanced). */
43
+ store?: unknown;
44
+ }
45
+
46
+ /** A running slideshow. Events: `slidechange` (PlayerSlideChange), `blank` ({ mode }), `presenterview` ({ open }), `close` ({ slide, index }). */
47
+ export declare class PlayerSession extends EventTarget {
48
+ /** The 1-based number of the showing slide. */
49
+ readonly slide: number;
50
+ readonly total: number;
51
+ readonly blankMode: BlankMode;
52
+ readonly closed: boolean;
53
+ readonly presenterViewOpen: boolean;
54
+ next(): boolean;
55
+ previous(): boolean;
56
+ first(): boolean;
57
+ last(): boolean;
58
+ goto(number: number): boolean;
59
+ blank(mode?: BlankMode): boolean;
60
+ toggleFullscreen(): void;
61
+ /** Open the speaker view window (from a user gesture). Returns the window, or null when the browser blocked it. */
62
+ openPresenterView(): Window | null;
63
+ closePresenterView(): void;
64
+ /** End the show: leave full screen, close the speaker view, restore focus. Safe to call twice. */
65
+ close(): void;
66
+ addEventListener(type: "slidechange", listener: (event: CustomEvent<PlayerSlideChange>) => void, options?: boolean | AddEventListenerOptions): void;
67
+ addEventListener(type: "blank", listener: (event: CustomEvent<{ mode: BlankMode }>) => void, options?: boolean | AddEventListenerOptions): void;
68
+ addEventListener(type: "presenterview", listener: (event: CustomEvent<{ open: boolean }>) => void, options?: boolean | AddEventListenerOptions): void;
69
+ addEventListener(type: "close", listener: (event: CustomEvent<{ slide: number; index?: number }>) => void, options?: boolean | AddEventListenerOptions): void;
70
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
71
+ }
72
+
73
+ /**
74
+ * Start a slideshow over the page. `source` is an OPF document (object or JSON text), the URL of one, or an `<opf-deck>`
75
+ * element. Keys: arrows, Page Up/Down, Space, Enter, N and P (next, previous), Home/End, a slide number then Enter, B and W
76
+ * (black and white screen), S (speaker view), F (full screen), Escape to leave. Hidden slides are skipped. Call it from a
77
+ * click or key handler: full screen and the speaker view need a user gesture.
78
+ */
79
+ export declare function present(source: unknown, options?: PresentOptions): Promise<PlayerSession>;