@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,228 @@
1
+ import { svgsToVectorPdf } from "./pdf-vector.js";
2
+ import { OPFRenderError, packageName } from "./svg.js";
3
+
4
+ // RR-23: PNG and PDF output in a browser. `svgToPng` and `svgToPdf` of the root entry need Node (resvg, sharp); this entry
5
+ // has the same two names for a page. It uses the same vector PDF converter as Node (fontkit, bidi-js and pako, all pure
6
+ // JavaScript) over the SVG the preview draws, so the text stays real text in the same embedded subsets. What differs from
7
+ // Node is only how pixels are made: a canvas decodes pictures and draws PNGs. No system font is read (a browser has no way
8
+ // to hand one over) and nothing is fetched: faces come from the SVG's own @font-face data (renderSvg's `embeddedFonts`) or
9
+ // from `fontData`.
10
+
11
+ const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
12
+ const MAX_PIXELS = 40_000_000;
13
+
14
+ /** Convert one SVG to PNG bytes with a canvas. `scale` multiplies the SVG's own size; `background` is a CSS colour (default white, `"transparent"` for none). */
15
+ export async function svgToPng(svg, options = {}) {
16
+ const text = svgText(svg);
17
+ const size = pageSize(text);
18
+ const scale = positive(options.scale, "scale", 1);
19
+ const width = Math.max(1, Math.round(size.width * scale)), height = Math.max(1, Math.round(size.height * scale));
20
+ if (width * height > MAX_PIXELS) throw new OPFRenderError("png-render-failed", `The PNG would be ${width} by ${height} pixels; the limit is ${MAX_PIXELS / 1e6} megapixels.`, { width, height });
21
+ options.signal?.throwIfAborted?.();
22
+ const image = await loadSvgImage(text, options.signal);
23
+ const { canvas, context } = createCanvas(width, height);
24
+ const background = options.background ?? "#FFFFFF";
25
+ if (background && background !== "transparent" && background !== "none") { context.fillStyle = background; context.fillRect(0, 0, width, height); }
26
+ context.drawImage(image, 0, 0, width, height);
27
+ return canvasPng(canvas);
28
+ }
29
+
30
+ /**
31
+ * Convert SVG slides to a PDF, one slide per page (1 SVG pixel is 1 PDF point, as in Node). `mode: "vector"` (default) writes
32
+ * real text, paths, gradients and images; `"raster"` draws each slide as an image (`scale`, default 2). Takes the options of
33
+ * the Node `svgToPdf` that make sense here (`metadata`, `tagged`, `strict`, `compress`, `onDiagnostic`, the generic family
34
+ * names, `rasterFallbackScale`) and `fontData` (`[{ data, family? }]`, extra face bytes), `signal` (an AbortSignal, checked between
35
+ * pages) and `onProgress({ page, pages })`.
36
+ */
37
+ export async function svgToPdf(svgs, options = {}) {
38
+ const inputs = (Array.isArray(svgs) ? svgs : [svgs]).map(svgText);
39
+ if (!inputs.length) throw new OPFRenderError("empty-pdf", "svgToPdf requires at least one SVG slide.");
40
+ const mode = options.mode ?? "vector";
41
+ if (mode !== "vector" && mode !== "raster") throw new OPFRenderError("invalid-conversion-option", 'mode must be "vector" or "raster".', { option: "mode", value: mode });
42
+ if (options.loadSystemFonts === true) throw new OPFRenderError("pdf-system-fonts-unsupported", "A browser cannot hand system fonts to the PDF export: supply faces as @font-face data or `fontData`.", { option: "loadSystemFonts" });
43
+ if (mode === "raster") return rasterPdf(inputs, options);
44
+ const scale = positive(options.rasterFallbackScale, "rasterFallbackScale", 2);
45
+ const fontCss = fontFaceCss(inputs);
46
+ return svgsToVectorPdf(inputs, {
47
+ fontFiles: [], fontDirs: [],
48
+ fontData: Array.isArray(options.fontData) ? options.fontData : [],
49
+ imageCodec: canvasImageCodec(),
50
+ defaultFontFamily: options.defaultFontFamily ?? "Roboto",
51
+ sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
52
+ monospaceFamily: options.monospaceFamily ?? "Roboto Mono",
53
+ serifFamily: options.serifFamily,
54
+ background: options.background,
55
+ metadata: options.metadata,
56
+ tagged: options.tagged,
57
+ compress: options.compress,
58
+ strict: options.strict === true,
59
+ rasterFallbackScale: scale,
60
+ onDiagnostic: typeof options.onDiagnostic === "function" ? options.onDiagnostic : undefined,
61
+ signal: options.signal,
62
+ onProgress: typeof options.onProgress === "function" ? options.onProgress : undefined,
63
+ yieldToHost: () => new Promise((resolve) => setTimeout(resolve, 0)),
64
+ producer: packageName,
65
+ ErrorClass: OPFRenderError,
66
+ // The element's own text has no fonts in a standalone SVG image: give it the faces the slides embed.
67
+ rasterize: async (svg, factor) => ({ png: await svgToPng(fontCss && /<text[\s>]/.test(svg) ? svg.replace(/(<svg\b[^>]*>)/, `$1<style>${fontCss}</style>`) : svg, { scale: factor, background: "transparent", signal: options.signal }) }),
68
+ });
69
+ }
70
+
71
+ async function rasterPdf(inputs, options) {
72
+ const { PDFDocument } = await import("pdf-lib");
73
+ const pdf = await PDFDocument.create({ updateMetadata: false });
74
+ pdf.setCreator(packageName);
75
+ pdf.setProducer(packageName);
76
+ const scale = positive(options.scale, "scale", 2);
77
+ for (const [index, svg] of inputs.entries()) {
78
+ options.signal?.throwIfAborted?.();
79
+ const size = pageSize(svg);
80
+ const png = await svgToPng(svg, { scale, background: options.background ?? "#FFFFFF", signal: options.signal });
81
+ const image = await pdf.embedPng(png);
82
+ pdf.addPage([size.width, size.height]).drawImage(image, { x: 0, y: 0, width: size.width, height: size.height });
83
+ options.onProgress?.({ page: index + 1, pages: inputs.length });
84
+ await new Promise((resolve) => setTimeout(resolve, 0));
85
+ }
86
+ return pdf.save({ addDefaultPage: false, useObjectStreams: false });
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------------------------------------------------------
90
+ // Canvas
91
+
92
+ function createCanvas(width, height) {
93
+ const canvas = typeof OffscreenCanvas === "function" ? new OffscreenCanvas(width, height) : Object.assign(document.createElement("canvas"), { width, height });
94
+ const context = canvas.getContext("2d", { willReadFrequently: true });
95
+ if (!context) throw new OPFRenderError("png-renderer-unavailable", "The browser could not create a 2D canvas for this size.", { width, height });
96
+ return { canvas, context };
97
+ }
98
+
99
+ async function canvasPng(canvas) {
100
+ const blob = canvas.convertToBlob ? await canvas.convertToBlob({ type: "image/png" }) : await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
101
+ if (!blob) throw new OPFRenderError("png-render-failed", "The browser could not encode the canvas as PNG.");
102
+ return new Uint8Array(await blob.arrayBuffer());
103
+ }
104
+
105
+ async function loadSvgImage(text, signal) {
106
+ const url = URL.createObjectURL(new Blob([text], { type: "image/svg+xml;charset=utf-8" }));
107
+ try {
108
+ const image = new Image();
109
+ image.decoding = "sync";
110
+ const loaded = new Promise((resolve, reject) => {
111
+ image.onload = resolve;
112
+ image.onerror = () => reject(new OPFRenderError("png-render-failed", "The browser could not draw this SVG as an image."));
113
+ });
114
+ image.src = url;
115
+ await loaded;
116
+ signal?.throwIfAborted?.();
117
+ // Fonts the SVG embeds as data: faces load with the image; decode() settles the picture before the first draw.
118
+ try { await image.decode(); } catch { /* drawn below as the browser has it */ }
119
+ return image;
120
+ } finally {
121
+ // The decoded image keeps its own copy; the URL is no longer needed once loaded.
122
+ setTimeout(() => URL.revokeObjectURL(url), 0);
123
+ }
124
+ }
125
+
126
+ /** The picture decoder the vector converter uses in a browser (the Node default is sharp): see `imageCodec` in pdf-vector.js. */
127
+ function canvasImageCodec() {
128
+ return {
129
+ async metadata(bytes) { return sniffImage(bytes); },
130
+ async rgba(bytes, { orient }) {
131
+ const sniffed = sniffImage(bytes);
132
+ const blob = new Blob([bytes], { type: sniffed.format ? `image/${sniffed.format}` : undefined });
133
+ let bitmap;
134
+ try { bitmap = await createImageBitmap(blob, { imageOrientation: orient ? "from-image" : "none", premultiplyAlpha: "none", colorSpaceConversion: "default" }); }
135
+ catch (error) { throw new Error(`The browser could not decode this picture: ${error?.message ?? error}`); }
136
+ try {
137
+ if (bitmap.width * bitmap.height > MAX_PIXELS) throw new Error(`The picture is larger than ${MAX_PIXELS / 1e6} megapixels.`);
138
+ const { context } = createCanvas(bitmap.width, bitmap.height);
139
+ context.drawImage(bitmap, 0, 0);
140
+ const { data, width, height } = context.getImageData(0, 0, bitmap.width, bitmap.height);
141
+ return { data, info: { width, height } };
142
+ } finally { bitmap.close?.(); }
143
+ },
144
+ };
145
+ }
146
+
147
+ /** Format, and for a JPEG its size, component count and EXIF orientation: the facts the PDF writer needs to pass a JPEG through. */
148
+ export function sniffImage(bytes) {
149
+ const b = bytes;
150
+ if (b.length > 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return { format: "png" };
151
+ if (b.length > 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP") return { format: "webp" };
152
+ if (b.length > 6 && ascii(b, 0, 3) === "GIF") return { format: "gif" };
153
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return { format: undefined };
154
+ let orientation, width, height, channels;
155
+ let adobe = false;
156
+ for (let position = 2; position + 4 <= b.length;) {
157
+ if (b[position] !== 0xff) { position++; continue; }
158
+ const marker = b[position + 1];
159
+ if (marker === 0xff) { position++; continue; }
160
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { position += 2; continue; }
161
+ if (marker === 0xd9 || marker === 0xda) break;
162
+ const length = (b[position + 2] << 8) | b[position + 3];
163
+ const start = position + 4;
164
+ if (marker === 0xe1 && ascii(b, start, start + 4) === "Exif") orientation = exifOrientation(b, start + 6, position + 2 + length) ?? orientation;
165
+ if (marker === 0xee && ascii(b, start, start + 5) === "Adobe") adobe = true;
166
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
167
+ height = (b[start + 1] << 8) | b[start + 2]; width = (b[start + 3] << 8) | b[start + 4]; channels = b[start + 5];
168
+ }
169
+ position += 2 + length;
170
+ }
171
+ return { format: "jpeg", width, height, channels, orientation, space: channels === 4 || adobe ? "cmyk" : "srgb" };
172
+ }
173
+
174
+ function exifOrientation(b, tiff, end) {
175
+ if (tiff + 8 > end) return undefined;
176
+ const little = ascii(b, tiff, tiff + 2) === "II";
177
+ const u16 = (at) => (little ? b[at] | (b[at + 1] << 8) : (b[at] << 8) | b[at + 1]);
178
+ const u32 = (at) => (little ? (b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24)) >>> 0 : ((b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]) >>> 0);
179
+ const directory = tiff + u32(tiff + 4);
180
+ if (directory + 2 > end) return undefined;
181
+ const entries = u16(directory);
182
+ for (let index = 0; index < entries; index++) {
183
+ const entry = directory + 2 + index * 12;
184
+ if (entry + 12 > end) break;
185
+ if (u16(entry) === 0x0112) return u16(entry + 8);
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ const ascii = (bytes, from, to) => String.fromCharCode(...bytes.subarray(from, to));
191
+
192
+ // ---------------------------------------------------------------------------------------------------------------------------
193
+ // SVG text helpers
194
+
195
+ function svgText(value) {
196
+ if (typeof value === "string") return value;
197
+ if (value instanceof Uint8Array) return new TextDecoder().decode(value);
198
+ throw new OPFRenderError("invalid-svg-input", "SVG input must be a string or Uint8Array.");
199
+ }
200
+
201
+ function pageSize(svg) {
202
+ const root = /<svg\s+([^>]*?)>/i.exec(svg);
203
+ if (!root) throw new OPFRenderError("invalid-svg", "SVG input must contain a root <svg> element.");
204
+ const attributes = {};
205
+ for (const attribute of root[1].matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attributes[attribute[1]] = attribute[2] ?? attribute[3] ?? "";
206
+ const box = String(attributes.viewBox ?? "").trim().split(/[\s,]+/).map(Number);
207
+ const hasBox = box.length === 4 && box.every(Number.isFinite);
208
+ const length = (value) => { const match = /^-?\d+(?:\.\d+)?/.exec(String(value ?? "").trim()); return match ? Number(match[0]) : null; };
209
+ const dimension = (value, fallback) => (Number.isFinite(value) && value > 0 ? value : fallback);
210
+ return {
211
+ width: dimension(length(attributes.width) ?? (hasBox ? box[2] : null), DEFAULT_DIMENSIONS.width),
212
+ height: dimension(length(attributes.height) ?? (hasBox ? box[3] : null), DEFAULT_DIMENSIONS.height),
213
+ };
214
+ }
215
+
216
+ /** The distinct @font-face rules of the pages' own style sheets, as one CSS string. */
217
+ function fontFaceCss(svgs) {
218
+ const rules = new Set();
219
+ for (const svg of svgs) for (const rule of svg.matchAll(/@font-face\s*\{[^}]*\}/g)) rules.add(rule[0]);
220
+ return [...rules].join("\n");
221
+ }
222
+
223
+ function positive(value, name, fallback) {
224
+ if (value === undefined || value === null) return fallback;
225
+ const number = Number(value);
226
+ if (Number.isFinite(number) && number > 0) return number;
227
+ throw new OPFRenderError("invalid-conversion-option", `${name} must be a positive finite number.`, { option: name, value });
228
+ }
@@ -2549,6 +2549,109 @@ export const BUNDLED_FONT_MANIFEST=freeze({
2549
2549
  }
2550
2550
  ]
2551
2551
  },
2552
+ {
2553
+ "name": "@expo-google-fonts/noto-color-emoji",
2554
+ "version": "0.4.6",
2555
+ "pack": "scripts",
2556
+ "scripts": [
2557
+ "Zsye"
2558
+ ],
2559
+ "color": {
2560
+ "format": "COLRv1, SVG",
2561
+ "rasterFamily": "Noto Emoji"
2562
+ },
2563
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-color-emoji/v/0.4.6",
2564
+ "license": "OFL-1.1",
2565
+ "licenseFile": "LICENSE_FONT",
2566
+ "licenseSha256": "ac564676d10054a8445923dfc2dfb13c042d97888bd27c1b6ec6dfe89a9d8d62",
2567
+ "reservedFontNames": [],
2568
+ "upstream": "http://scripts.sil.org/OFL",
2569
+ "copyright": "Copyright 2021 Google Inc. All Rights Reserved.",
2570
+ "faces": [
2571
+ {
2572
+ "file": "400Regular/NotoColorEmoji_400Regular.ttf",
2573
+ "family": "Noto Color Emoji",
2574
+ "weight": 400,
2575
+ "italic": false,
2576
+ "sha256": "7fb39738ab18f10612d6f4595e2e8e47a0afdf34738460442d99cd0c344a4d90"
2577
+ }
2578
+ ]
2579
+ },
2580
+ {
2581
+ "name": "@expo-google-fonts/noto-emoji",
2582
+ "version": "0.4.7",
2583
+ "pack": "scripts",
2584
+ "scripts": [
2585
+ "Zsye"
2586
+ ],
2587
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-emoji/v/0.4.7",
2588
+ "license": "OFL-1.1",
2589
+ "licenseFile": "LICENSE_FONT",
2590
+ "licenseSha256": "500bb1ccf43df7bbb522112f9133a52b16e1c35e809632f5d8609b179152de5b",
2591
+ "reservedFontNames": [],
2592
+ "upstream": "https://scripts.sil.org/OFL",
2593
+ "copyright": "Copyright 2013 Google LLC",
2594
+ "faces": [
2595
+ {
2596
+ "file": "400Regular/NotoEmoji_400Regular.ttf",
2597
+ "family": "Noto Emoji",
2598
+ "weight": 400,
2599
+ "italic": false,
2600
+ "sha256": "24aaa2a20c9c06ffe9bf04284bfd1f14376a329d10122f902abe2770817eb504"
2601
+ }
2602
+ ]
2603
+ },
2604
+ {
2605
+ "name": "@expo-google-fonts/stix-two-math",
2606
+ "version": "0.4.0",
2607
+ "pack": "scripts",
2608
+ "scripts": [
2609
+ "Zmth"
2610
+ ],
2611
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/stix-two-math/v/0.4.0",
2612
+ "license": "OFL-1.1",
2613
+ "licenseFile": "LICENSE_FONT",
2614
+ "licenseSha256": "0c8825913b60d858aacdb33c4ca6660a7d64b0d6464702efbb19313f5765861a",
2615
+ "reservedFontNames": [
2616
+ "TM Math"
2617
+ ],
2618
+ "upstream": "https://github.com/stipub/stixfonts",
2619
+ "copyright": "Copyright 2001-2021 The STIX Fonts Project Authors (https://github.com/stipub/stixfonts), with Reserved Font Name \"TM Math\". STIX Fonts™ is a trademark of The Institute of Electrical and Electronics Engineers, Inc.",
2620
+ "faces": [
2621
+ {
2622
+ "file": "400Regular/STIXTwoMath_400Regular.ttf",
2623
+ "family": "STIX Two Math",
2624
+ "weight": 400,
2625
+ "italic": false,
2626
+ "sha256": "c3377c20641ecee349677e3f6cc71a6405c30cda7171a4cbc4a1c3970cfff0b4"
2627
+ }
2628
+ ]
2629
+ },
2630
+ {
2631
+ "name": "@expo-google-fonts/noto-sans-math",
2632
+ "version": "0.4.2",
2633
+ "pack": "scripts",
2634
+ "scripts": [
2635
+ "Zmth",
2636
+ "Zsym"
2637
+ ],
2638
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-math/v/0.4.2",
2639
+ "license": "OFL-1.1",
2640
+ "licenseFile": "LICENSE_FONT",
2641
+ "licenseSha256": "403a95275b469061b7d4371c328e0ada3bc7d63328abe2e88aad5cd243b2fe21",
2642
+ "reservedFontNames": [],
2643
+ "upstream": "https://github.com/notofonts/math",
2644
+ "copyright": "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/math)",
2645
+ "faces": [
2646
+ {
2647
+ "file": "400Regular/NotoSansMath_400Regular.ttf",
2648
+ "family": "Noto Sans Math",
2649
+ "weight": 400,
2650
+ "italic": false,
2651
+ "sha256": "b4a1dfdbfa13b4755e5eac20cb25c1d17ed5a745ceb89639595e8cf45a2b1e07"
2652
+ }
2653
+ ]
2654
+ },
2552
2655
  {
2553
2656
  "name": "@expo-google-fonts/noto-sans",
2554
2657
  "version": "0.4.2",
@@ -2595,6 +2698,61 @@ export const BUNDLED_FONT_MANIFEST=freeze({
2595
2698
  "sha256": "7e34616e092593c340449baf9a259075a054c6419511ccf21ff0d83a1c9dd0d0"
2596
2699
  }
2597
2700
  ]
2701
+ },
2702
+ {
2703
+ "name": "@expo-google-fonts/noto-sans-symbols-2",
2704
+ "version": "0.4.1",
2705
+ "pack": "scripts",
2706
+ "scripts": [
2707
+ "Zsym"
2708
+ ],
2709
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-symbols-2/v/0.4.1",
2710
+ "license": "OFL-1.1",
2711
+ "licenseFile": "LICENSE_FONT",
2712
+ "licenseSha256": "b118dd41337806a5d4797052c77caf3bd096aed783e5eb21b4d11154351e1ac0",
2713
+ "reservedFontNames": [],
2714
+ "upstream": "https://github.com/notofonts/symbols",
2715
+ "copyright": "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)",
2716
+ "faces": [
2717
+ {
2718
+ "file": "400Regular/NotoSansSymbols2_400Regular.ttf",
2719
+ "family": "Noto Sans Symbols 2",
2720
+ "weight": 400,
2721
+ "italic": false,
2722
+ "sha256": "f67f40c65f929f029827ab37cfdc4897dfd9ae3c28d9e98ba46e0d52e39eb97e"
2723
+ }
2724
+ ]
2725
+ },
2726
+ {
2727
+ "name": "@expo-google-fonts/noto-sans-symbols",
2728
+ "version": "0.4.1",
2729
+ "pack": "scripts",
2730
+ "scripts": [
2731
+ "Zsym"
2732
+ ],
2733
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-symbols/v/0.4.1",
2734
+ "license": "OFL-1.1",
2735
+ "licenseFile": "LICENSE_FONT",
2736
+ "licenseSha256": "b118dd41337806a5d4797052c77caf3bd096aed783e5eb21b4d11154351e1ac0",
2737
+ "reservedFontNames": [],
2738
+ "upstream": "https://github.com/notofonts/symbols",
2739
+ "copyright": "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)",
2740
+ "faces": [
2741
+ {
2742
+ "file": "400Regular/NotoSansSymbols_400Regular.ttf",
2743
+ "family": "Noto Sans Symbols",
2744
+ "weight": 400,
2745
+ "italic": false,
2746
+ "sha256": "aedeec1cd0514930aeeafc4a88a6deff83cda1e6b58086f0b9bb9c7dd0157578"
2747
+ },
2748
+ {
2749
+ "file": "700Bold/NotoSansSymbols_700Bold.ttf",
2750
+ "family": "Noto Sans Symbols",
2751
+ "weight": 700,
2752
+ "italic": false,
2753
+ "sha256": "5682f6c88d6199623edf026f67a8722697e8c5f409e5249477594e409d657eb0"
2754
+ }
2755
+ ]
2598
2756
  }
2599
2757
  ]
2600
2758
  });