@openpresentation/opf-render 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -64
- package/dist/deck-runtime.js +4 -4
- package/dist/drawn-faces.js +38 -0
- package/dist/element.d.ts +12 -9
- package/dist/element.js +33 -18
- package/dist/export-browser.d.ts +21 -11
- package/dist/export-browser.js +33 -20
- package/dist/font-fstype.js +70 -0
- package/dist/font-subset.js +173 -0
- package/dist/fonts-browser.d.ts +33 -22
- package/dist/fonts-browser.js +43 -15
- package/dist/fonts-node.d.ts +9 -9
- package/dist/fonts-node.js +26 -4
- package/dist/fonts.d.ts +10 -6
- package/dist/hb-shape.js +121 -0
- package/dist/index.d.ts +66 -33
- package/dist/index.js +1 -1
- package/dist/lazy-fonts.js +10 -10
- package/dist/pdf-fonts.js +1 -1
- package/dist/pdf-text.js +6 -3
- package/dist/pdf-vector.js +5 -2
- package/dist/pdf.d.ts +2 -2
- package/dist/pdf.js +3 -3
- package/dist/player.d.ts +3 -3
- package/dist/player.js +1 -1
- package/dist/png.d.ts +2 -2
- package/dist/png.js +2 -2
- package/dist/raster.js +33 -28
- package/dist/script-font-pack.js +5 -5
- package/dist/slide-sources.js +47 -0
- package/dist/svg.d.ts +39 -32
- package/dist/svg.js +74 -58
- package/dist/text-paths.js +82 -30
- package/package.json +3 -3
package/dist/element.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// RR-28: <opf-deck>, a framework-free web component that embeds an OPF deck. It draws the renderer's own SVG (
|
|
1
|
+
// RR-28: <opf-deck>, a framework-free web component that embeds an OPF deck. It draws the renderer's own SVG (toSvg), so
|
|
2
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
3
|
// is safe on a server (Next.js); `defineOpfDeck()` registers the tag in a browser.
|
|
4
4
|
import {
|
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
prepareSlideSvg, sanitizeSvgTree, slideInfo, slideLabel, svgDimensions, plainText,
|
|
7
7
|
} from "./deck-runtime.js";
|
|
8
8
|
import { loadPreviewFonts } from "./preview-fonts.js";
|
|
9
|
-
import {
|
|
9
|
+
import { toSvg } from "./svg.js";
|
|
10
|
+
import { drawnFaces } from "./drawn-faces.js";
|
|
11
|
+
import { selectSlides, slideArguments } from "./slide-sources.js";
|
|
10
12
|
|
|
11
13
|
export { DeckError } from "./deck-runtime.js";
|
|
12
14
|
export { loadPreviewFonts, previewBaseFaces, previewFontLayout } from "./preview-fonts.js";
|
|
@@ -139,7 +141,7 @@ function createElementClass() {
|
|
|
139
141
|
if (had && before === this.getAttribute("fonts") && this.isConnected && this.#built) this.#start();
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
/** Extra `
|
|
144
|
+
/** Extra `toSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
|
|
143
145
|
get renderOptions() { return this.#renderOptions; }
|
|
144
146
|
set renderOptions(value) { this.#renderOptions = value && typeof value === "object" ? value : {}; }
|
|
145
147
|
|
|
@@ -512,43 +514,56 @@ const escapeHtml = (value) => String(value).replace(/&/g, "&").replace(/</g,
|
|
|
512
514
|
* `/fonts-browser`, or one with a `textMeasurement` and `embeddedFonts`). A `fonts` string is the font root URL written to the
|
|
513
515
|
* element's `fonts` attribute.
|
|
514
516
|
*
|
|
515
|
-
* `
|
|
516
|
-
* `
|
|
517
|
+
* `toHtml(deck)` draws the first slide plus a list of titles; `toHtml(deck, 3)`, `toHtml(deck, "1-3")` (`"1-"` for every slide) or
|
|
518
|
+
* `toHtml(deck, [1, 3])` draw the selected slides of the presented sequence (counting from 1); the second argument may be the
|
|
519
|
+
* options instead, told apart by type (RR-73). `embed` puts the document in an `application/opf+json` script child, so the element upgrades without a request.
|
|
517
520
|
* `fontMode`: `"standalone"` (default, embeds per slide), `"shared"` (one set of font rules in the returned tag), or
|
|
518
521
|
* `"external"` (no font rules; the host must supply CSS with the exact pinned faces the handle measures).
|
|
519
522
|
*/
|
|
520
|
-
export function
|
|
523
|
+
export function toHtml(input, slides, options) {
|
|
524
|
+
({ slides, options } = slideArguments(slides, options));
|
|
521
525
|
const deck = parseDeckDocument(input);
|
|
522
526
|
const tag = options.tagName ?? OPF_DECK_TAG;
|
|
523
527
|
const sequence = presentableIndexes(deck, { includeHidden: options.includeHidden });
|
|
524
|
-
const
|
|
525
|
-
const shown = wanted === "all" ? sequence.map((_, position) => position) : wanted === "first" ? [0] : wanted.map((number) => number - 1).filter((position) => position >= 0 && position < sequence.length);
|
|
528
|
+
const shown = slides === undefined ? [0] : selectSlides(slides, sequence.length).map((number) => number - 1);
|
|
526
529
|
const handle = options.fonts !== null && typeof options.fonts === "object" ? options.fonts : undefined;
|
|
527
530
|
const fontMode = options.fontMode ?? "standalone";
|
|
528
531
|
if (!["standalone", "shared", "external"].includes(fontMode)) throw new DeckError("invalid-font-mode", "Choose standalone, shared or external SSR fonts.");
|
|
529
|
-
// Every mode measures with the same handle; external writes no @font-face data (
|
|
532
|
+
// Every mode measures with the same handle; external writes no @font-face data (`text: "system"`).
|
|
530
533
|
const renderFonts = handle ?? options.renderOptions?.fonts;
|
|
531
|
-
const sharedRules = new Set();
|
|
534
|
+
const sharedRules = new Set(), sharedCharacters = new Map();
|
|
532
535
|
const attributes = { src: options.src, slide: options.slide, fonts: handle ? undefined : options.fonts, label: options.label, ...options.attributes };
|
|
533
536
|
const attributeText = Object.entries(attributes).filter(([, value]) => value !== undefined && value !== false).map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${escapeHtml(value)}"`)).join("")
|
|
534
537
|
+ (options.thumbnails ? " thumbnails" : "") + (options.present ? " present" : "") + (options.includeHidden ? " include-hidden" : "");
|
|
535
538
|
const date = options.renderOptions?.date ?? options.date;
|
|
536
539
|
const figures = shown.map((position) => {
|
|
537
540
|
const index = sequence[position];
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
541
|
+
// Shared rules are deduplicated across slides, so the slides embed whole faces and the shared set is cut below to what
|
|
542
|
+
// every slide draws (RR-65); per-slide subsets would differ and repeat each face once per slide.
|
|
543
|
+
let svg = toSvg(deck, index + 1, { ...options.renderOptions, fonts: renderFonts, ...(fontMode === "external" ? { text: "system" } : {}), ...(fontMode === "shared" ? { subsetFonts: false } : {}), ...(date ? { date } : {}) });
|
|
544
|
+
if (fontMode === "shared") {
|
|
545
|
+
drawnFaces(svg, sharedCharacters);
|
|
546
|
+
svg = svg.replace(/<style>(@font-face[\s\S]*?)<\/style>/g, (_, css) => {
|
|
547
|
+
// These rules are produced by toSvg after its used-face selection and validation.
|
|
548
|
+
for (const rule of css.split("\n")) sharedRules.add(rule);
|
|
549
|
+
return "";
|
|
550
|
+
});
|
|
551
|
+
}
|
|
544
552
|
const { width, height } = svgDimensions(svg);
|
|
545
553
|
const prepared = prepareSlideSvg(svg).replace(/^<svg\b/, `<svg style="display:block;width:100%;height:auto;aspect-ratio:${width} / ${height}"`);
|
|
546
554
|
const label = slideLabel(slideInfo(deck, index), position + 1, sequence.length);
|
|
547
555
|
return `<figure role="group" aria-roledescription="slide" aria-label="${escapeHtml(label)}" style="margin:0 0 8px">${prepared}</figure>`;
|
|
548
556
|
});
|
|
549
|
-
const titles =
|
|
557
|
+
const titles = slides === undefined && sequence.length > 1
|
|
550
558
|
? `<nav aria-label="Slides"><ol>${sequence.map((index) => `<li>${escapeHtml(slideInfo(deck, index).title)}</li>`).join("")}</ol></nav>` : "";
|
|
551
559
|
const embedded = options.embed ? `<script type="application/opf+json">${JSON.stringify(deck).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")}</script>` : "";
|
|
552
|
-
const
|
|
560
|
+
const subsets = options.renderOptions?.subsetFonts === false ? undefined : renderFonts?.subsets;
|
|
561
|
+
const sharedRule = (rule) => {
|
|
562
|
+
const face = /^@font-face\{font-family:"([^"]+)";font-weight:(\d+);font-style:(\w+);src:url\("([^"]+)"\)\}$/.exec(rule);
|
|
563
|
+
const points = face && sharedCharacters.get(face[1].toLowerCase());
|
|
564
|
+
if (!subsets || !points?.size) return rule;
|
|
565
|
+
return rule.replace(face[4], subsets.subsetDataUrl({ family: face[1], weight: Number(face[2]), italic: face[3] === "italic", dataUrl: face[4] }, points));
|
|
566
|
+
};
|
|
567
|
+
const shared = sharedRules.size ? `<style>${[...sharedRules].map(sharedRule).join("\n")}</style>` : "";
|
|
553
568
|
return `<${tag}${attributeText}>${shared}${figures.join("")}${titles}${embedded}</${tag}>`;
|
|
554
569
|
}
|
package/dist/export-browser.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { PdfDiagnostic, PdfMetadata,
|
|
1
|
+
import type { PdfDiagnostic, PdfMetadata, SlideSelection, SvgSlide, ToPdfOptions, ToSvgOptions } from "./index.js";
|
|
2
2
|
import type { FontsHandle } from "./fonts.js";
|
|
3
3
|
export type { PdfDiagnostic, PdfMetadata } from "./index.js";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** The options of `toPng` in a browser: the canvas's own, and the `toSvg` options that draw a deck (`fonts` is the browser handle). */
|
|
6
|
+
export interface BrowserToPngOptions extends ToSvgOptions {
|
|
6
7
|
/** Multiplies the SVG's own size (default 1). The result is limited to 40 megapixels. */
|
|
7
8
|
scale?: number;
|
|
8
9
|
/** A CSS colour painted first (default white); `"transparent"` keeps the alpha channel. */
|
|
@@ -10,18 +11,19 @@ export interface BrowserSvgToPngOptions {
|
|
|
10
11
|
signal?: AbortSignal;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
/** The options of `toPdf` in a browser: the PDF's own, and the `toSvg` options that draw a deck. */
|
|
15
|
+
export interface BrowserToPdfOptions extends Omit<ToSvgOptions, "fonts" | "onDiagnostic">, Pick<ToPdfOptions, "raster" | "background" | "serifFamily" | "metadata" | "tagged" | "compress" | "strict" | "rasterFallbackScale" | "defaultFontFamily" | "sansSerifFamily" | "monospaceFamily" | "onDiagnostic"> {
|
|
16
|
+
/** `raster: true`: pixel density of each page image (default 2). */
|
|
15
17
|
scale?: number;
|
|
16
18
|
/**
|
|
17
|
-
*
|
|
18
|
-
* bundle of it holds no PDF library. Without it raster
|
|
19
|
-
* when the page cannot. The default vector
|
|
19
|
+
* `raster: true` only: the pdf-lib module (`import * as pdfLib from "pdf-lib"`), an optional peer of the renderer that this entry never imports itself, so a
|
|
20
|
+
* bundle of it holds no PDF library. Without it a raster PDF tries to import "pdf-lib" by name and rejects with `OPFRenderError` `converter-missing`
|
|
21
|
+
* when the page cannot. The default vector PDF needs no option.
|
|
20
22
|
*/
|
|
21
23
|
pdfLib?: { PDFDocument: { create(options?: { updateMetadata?: boolean }): Promise<any> } };
|
|
22
24
|
/**
|
|
23
|
-
* The browser fonts handle (`loadFonts` from `/fonts-browser`): every face it holds can be embedded in the PDF, including
|
|
24
|
-
* a standalone SVG does not carry. Faces an SVG embeds as `@font-face` data (the `embeddedFonts` of the handle `
|
|
25
|
+
* The browser fonts handle (`loadFonts` from `/fonts-browser`): it draws a deck, and every face it holds can be embedded in the PDF, including
|
|
26
|
+
* script faces a standalone SVG does not carry. Faces an SVG embeds as `@font-face` data (the `embeddedFonts` of the handle `toSvg` was given) need no entry.
|
|
25
27
|
*/
|
|
26
28
|
fonts?: FontsHandle;
|
|
27
29
|
/** Extra font faces as bytes (TrueType outlines) when there is no handle. */
|
|
@@ -32,7 +34,15 @@ export interface BrowserSvgToPdfOptions extends Pick<SvgToPdfOptions, "mode" | "
|
|
|
32
34
|
onProgress?: (progress: { page: number; pages: number }) => void;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
export declare function
|
|
37
|
+
/** PNG with a canvas (RR-73): the arguments and results of the Node `toPng`. */
|
|
38
|
+
export declare function toPng(svg: Uint8Array | `<${string}`, options?: BrowserToPngOptions): Promise<Uint8Array>;
|
|
39
|
+
export declare function toPng(svgs: readonly SvgSlide[], options?: BrowserToPngOptions): Promise<Uint8Array[]>;
|
|
40
|
+
export declare function toPng(deck: unknown, slide: number, options?: BrowserToPngOptions): Promise<Uint8Array>;
|
|
41
|
+
export declare function toPng(deck: unknown, slides: Exclude<SlideSelection, number>, options?: BrowserToPngOptions): Promise<Uint8Array[]>;
|
|
42
|
+
export declare function toPng(deck: object, options?: BrowserToPngOptions): Promise<Uint8Array[]>;
|
|
43
|
+
export declare function toPng(source: string, options?: BrowserToPngOptions): Promise<Uint8Array | Uint8Array[]>;
|
|
44
|
+
/** One PDF (RR-73), a page per slide, of a deck drawn with `toSvg` (every slide or a selection) or of SVG slides. */
|
|
45
|
+
export declare function toPdf(source: unknown, options?: BrowserToPdfOptions): Promise<Uint8Array>;
|
|
46
|
+
export declare function toPdf(deck: unknown, slides: SlideSelection, options?: BrowserToPdfOptions): Promise<Uint8Array>;
|
|
37
47
|
/** Format of an embedded picture and, for a JPEG, its size, component count and EXIF orientation. */
|
|
38
48
|
export declare function sniffImage(bytes: Uint8Array): { format: "png" | "webp" | "gif" | "jpeg" | undefined; width?: number; height?: number; channels?: number; orientation?: number; space?: string };
|
package/dist/export-browser.js
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
import { svgsToVectorPdf } from "./pdf-vector.js";
|
|
2
|
-
import { OPFRenderError, packageName } from "./svg.js";
|
|
2
|
+
import { OPFRenderError, packageName, toSvg } from "./svg.js";
|
|
3
|
+
import { slideArguments, slideSources } from "./slide-sources.js";
|
|
3
4
|
|
|
4
|
-
// RR-23: PNG and PDF output in a browser. `
|
|
5
|
+
// RR-23: PNG and PDF output in a browser. `toPng` and `toPdf` of the root entry need Node (resvg, sharp); this entry
|
|
5
6
|
// 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
7
|
// JavaScript) over the SVG the preview draws, so the text stays real text in the same embedded subsets. What differs from
|
|
7
8
|
// 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
9
|
// to hand one over) and nothing is fetched: faces come from the SVG's own @font-face data (the `embeddedFonts` of the fonts handle
|
|
9
|
-
// `
|
|
10
|
+
// `toSvg` was given), from the faces the `fonts` handle holds (`loadFonts` from `/fonts-browser`, so script faces the SVG does not
|
|
10
11
|
// embed reach the PDF too), or from `fontData`.
|
|
11
12
|
//
|
|
12
|
-
// RR-63: this entry imports no converter. The one optional piece is pdf-lib, for `
|
|
13
|
+
// RR-63: this entry imports no converter. The one optional piece is pdf-lib, for `raster: true` only (the default vector PDF
|
|
13
14
|
// needs nothing): the host imports it and passes it as `options.pdfLib`, so a bundler never has to resolve it.
|
|
14
15
|
|
|
15
16
|
const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
|
|
16
17
|
const MAX_PIXELS = 40_000_000;
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* PNG with a canvas (RR-73): a deck drawn with `toSvg` (all slides, a slide number or a selection, as for the Node `toPng`) or SVG
|
|
21
|
+
* the renderer drew; one PNG for a slide number or one SVG, a list otherwise. `scale` multiplies the SVG's own size; `background`
|
|
22
|
+
* is a CSS colour (default white, `"transparent"` for none).
|
|
23
|
+
*/
|
|
24
|
+
export async function toPng(source, slides, options) {
|
|
25
|
+
({ slides, options } = slideArguments(slides, options));
|
|
26
|
+
const { svgs, one } = slideSources(source, slides, options, toSvg);
|
|
27
|
+
const pngs = [];
|
|
28
|
+
for (const svg of svgs) pngs.push(await svgPng(svg, options));
|
|
29
|
+
return one ? pngs[0] : pngs;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function svgPng(svg, options = {}) {
|
|
20
33
|
const text = svgText(svg);
|
|
21
34
|
const size = pageSize(text);
|
|
22
35
|
const scale = positive(options.scale, "scale", 1);
|
|
@@ -32,19 +45,19 @@ export async function svgToPng(svg, options = {}) {
|
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
/**
|
|
35
|
-
*
|
|
36
|
-
* real text, paths, gradients and images; `
|
|
37
|
-
* the Node `
|
|
38
|
-
* names, `rasterFallbackScale`), `pdfLib` (raster
|
|
48
|
+
* One PDF (RR-73) of a deck drawn with `toSvg` (all slides or a selection) or of SVG slides, one slide per page (1 SVG pixel is 1
|
|
49
|
+
* PDF point, as in Node). Vector by default (real text, paths, gradients and images); `raster: true` draws each slide as an image
|
|
50
|
+
* (`scale`, default 2). Takes the options of the Node `toPdf` that make sense here (`metadata`, `tagged`, `strict`, `compress`,
|
|
51
|
+
* `onDiagnostic`, the generic family names, `rasterFallbackScale`), `pdfLib` (`raster: true` only: `import * as pdfLib from "pdf-lib"`), `fonts` (the handle `loadFonts()` returns: every face it holds can be embedded), `fontData`
|
|
39
52
|
* (`[{ data, family? }]`, extra face bytes without a handle), `signal` (an AbortSignal, checked between pages) and
|
|
40
53
|
* `onProgress({ page, pages })`.
|
|
41
54
|
*/
|
|
42
|
-
export async function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
if (
|
|
55
|
+
export async function toPdf(source, slides, options) {
|
|
56
|
+
({ slides, options } = slideArguments(slides, options));
|
|
57
|
+
const inputs = slideSources(source, slides, options, toSvg).svgs.map(svgText);
|
|
58
|
+
if (!inputs.length) throw new OPFRenderError("empty-pdf", "toPdf needs at least one slide.");
|
|
59
|
+
if (options.raster !== undefined && typeof options.raster !== "boolean") throw new OPFRenderError("invalid-conversion-option", "raster must be true or false.", { option: "raster", value: options.raster });
|
|
60
|
+
if (options.raster === true) return rasterPdf(inputs, options);
|
|
48
61
|
const scale = positive(options.rasterFallbackScale, "rasterFallbackScale", 2);
|
|
49
62
|
const fontCss = fontFaceCss(inputs);
|
|
50
63
|
return svgsToVectorPdf(inputs, {
|
|
@@ -68,7 +81,7 @@ export async function svgToPdf(svgs, options = {}) {
|
|
|
68
81
|
producer: packageName,
|
|
69
82
|
ErrorClass: OPFRenderError,
|
|
70
83
|
// The element's own text has no fonts in a standalone SVG image: give it the faces the slides embed.
|
|
71
|
-
rasterize: async (svg, factor) => ({ png: await
|
|
84
|
+
rasterize: async (svg, factor) => ({ png: await svgPng(fontCss && /<text[\s>]/.test(svg) ? svg.replace(/(<svg\b[^>]*>)/, `$1<style>${fontCss}</style>`) : svg, { scale: factor, background: "transparent", signal: options.signal }) }),
|
|
72
85
|
});
|
|
73
86
|
}
|
|
74
87
|
|
|
@@ -81,7 +94,7 @@ async function rasterPdf(inputs, options) {
|
|
|
81
94
|
for (const [index, svg] of inputs.entries()) {
|
|
82
95
|
options.signal?.throwIfAborted?.();
|
|
83
96
|
const size = pageSize(svg);
|
|
84
|
-
const png = await
|
|
97
|
+
const png = await svgPng(svg, { scale, background: options.background ?? "#FFFFFF", signal: options.signal });
|
|
85
98
|
const image = await pdf.embedPng(png);
|
|
86
99
|
pdf.addPage([size.width, size.height]).drawImage(image, { x: 0, y: 0, width: size.width, height: size.height });
|
|
87
100
|
options.onProgress?.({ page: index + 1, pages: inputs.length });
|
|
@@ -90,14 +103,14 @@ async function rasterPdf(inputs, options) {
|
|
|
90
103
|
return pdf.save({ addDefaultPage: false, useObjectStreams: false });
|
|
91
104
|
}
|
|
92
105
|
|
|
93
|
-
// pdf-lib for raster
|
|
106
|
+
// pdf-lib for `raster: true` when the host did not pass it. The specifier is not a literal, so a browser bundler leaves it alone (and
|
|
94
107
|
// never fails for a host that does not install pdf-lib); it resolves where the page can import it by name, for example with an import map.
|
|
95
108
|
async function importPdfLib() {
|
|
96
109
|
const name = "pdf-lib";
|
|
97
110
|
try {
|
|
98
111
|
return await import(/* webpackIgnore: true */ /* @vite-ignore */ name);
|
|
99
112
|
} catch (error) {
|
|
100
|
-
throw new OPFRenderError("converter-missing", '
|
|
113
|
+
throw new OPFRenderError("converter-missing", 'A raster PDF (raster: true) needs pdf-lib, an optional peer dependency of @openpresentation/opf-render: run `npm install pdf-lib@^1.17.1` and pass it as the pdfLib option (import * as pdfLib from "pdf-lib"). The default vector PDF needs nothing.', { package: "pdf-lib", range: "^1.17.1", install: "npm install pdf-lib@^1.17.1", cause: error instanceof Error ? error.message : String(error) });
|
|
101
114
|
}
|
|
102
115
|
}
|
|
103
116
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// RR-76: what a face's license lets a document embed, from its OS/2 fsType (the OpenType embedding permissions). Bit 0x0002
|
|
2
|
+
// (Restricted License) forbids embedding; 0x0200 (bitmap embedding only) allows only bitmaps, so an outline face cannot be
|
|
3
|
+
// embedded either; 0x0100 (no subsetting) allows the whole face only. 0x0004 (preview and print) and 0x0008 (editable) allow it.
|
|
4
|
+
// A face with no OS/2 table carries no restriction. No imports: the SVG writer and the subsetter both read it.
|
|
5
|
+
|
|
6
|
+
const RESTRICTED = 0x0002, NO_SUBSETTING = 0x0100, BITMAP_ONLY = 0x0200;
|
|
7
|
+
|
|
8
|
+
/** The OS/2 fsType of font bytes, or null when the font has no OS/2 table. */
|
|
9
|
+
export function fsTypeOf(data) {
|
|
10
|
+
if (!data || data.length < 12) return null;
|
|
11
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
12
|
+
const count = view.getUint16(4);
|
|
13
|
+
for (let index = 0; index < count; index++) {
|
|
14
|
+
const at = 12 + index * 16;
|
|
15
|
+
if (at + 16 > data.length) return null;
|
|
16
|
+
if (String.fromCharCode(data[at], data[at + 1], data[at + 2], data[at + 3]) !== "OS/2") continue;
|
|
17
|
+
const offset = view.getUint32(at + 8);
|
|
18
|
+
return offset + 10 <= data.length ? view.getUint16(offset + 8) : null;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The OS/2 fsType of a font data URL (base64), reading only the table directory and the two fsType bytes, so a large face is
|
|
25
|
+
* never decoded whole; null when the font has no OS/2 table or the URL cannot be read.
|
|
26
|
+
*/
|
|
27
|
+
export function dataUrlFsType(dataUrl) {
|
|
28
|
+
const comma = String(dataUrl ?? "").indexOf(",");
|
|
29
|
+
if (comma < 0) return null;
|
|
30
|
+
const base64 = dataUrl.slice(comma + 1);
|
|
31
|
+
const head = decodeRange(base64, 0, 12);
|
|
32
|
+
if (head.length < 12) return null;
|
|
33
|
+
const count = (head[4] << 8) | head[5];
|
|
34
|
+
const directory = decodeRange(base64, 12, 12 + count * 16);
|
|
35
|
+
for (let index = 0; index + 16 <= directory.length; index += 16) {
|
|
36
|
+
if (String.fromCharCode(directory[index], directory[index + 1], directory[index + 2], directory[index + 3]) !== "OS/2") continue;
|
|
37
|
+
const offset = ((directory[index + 8] << 24) | (directory[index + 9] << 16) | (directory[index + 10] << 8) | directory[index + 11]) >>> 0;
|
|
38
|
+
const bytes = decodeRange(base64, offset + 8, offset + 10);
|
|
39
|
+
return bytes.length === 2 ? (bytes[0] << 8) | bytes[1] : null;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether the face may be embedded at all (as outline data). */
|
|
45
|
+
export function embeddingAllowed(fsType) {
|
|
46
|
+
return fsType === null || ((fsType & RESTRICTED) === 0 && (fsType & BITMAP_ONLY) === 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether the face may be embedded as a subset. */
|
|
50
|
+
export function subsettingAllowed(fsType) {
|
|
51
|
+
return fsType !== null && embeddingAllowed(fsType) && (fsType & NO_SUBSETTING) === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
55
|
+
const VALUES = new Map([...ALPHABET].map((character, index) => [character, index]));
|
|
56
|
+
|
|
57
|
+
// Bytes [start, end) of base64 text, decoding only the 4-character groups that hold them.
|
|
58
|
+
function decodeRange(base64, start, end) {
|
|
59
|
+
const first = Math.floor(start / 3), last = Math.ceil(end / 3);
|
|
60
|
+
const text = base64.slice(first * 4, last * 4);
|
|
61
|
+
const bytes = [];
|
|
62
|
+
for (let index = 0; index + 4 <= text.length; index += 4) {
|
|
63
|
+
const [a, b, c, d] = [...text.slice(index, index + 4)].map((character) => VALUES.get(character));
|
|
64
|
+
if (a === undefined || b === undefined) break;
|
|
65
|
+
bytes.push((a << 2) | (b >> 4));
|
|
66
|
+
if (c !== undefined) bytes.push(((b & 15) << 4) | (c >> 2));
|
|
67
|
+
if (d !== undefined) bytes.push(((c & 3) << 6) | d);
|
|
68
|
+
}
|
|
69
|
+
return bytes.slice(start - first * 3, end - first * 3);
|
|
70
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// RR-65: glyph subsets for the faces an SVG embeds as @font-face data. HarfBuzz's own subsetter (hb-subset, the
|
|
2
|
+
// `harfbuzz-subset.wasm` of the pinned harfbuzzjs package, MIT) keeps the glyphs a slide's characters reach through the
|
|
3
|
+
// font's layout tables (every GSUB and GPOS feature is kept, so the browser shapes the subset exactly as the whole face),
|
|
4
|
+
// renumbers them and trims cmap, hmtx, GPOS and the rest to them. The module has no imports, so it is instantiated
|
|
5
|
+
// synchronously from its bytes and `toSvg` stays synchronous; the same input always gives the same bytes.
|
|
6
|
+
//
|
|
7
|
+
// Only a face whose license allows it is subset: a bundled face (matched by the sha256 of its bytes against the pinned
|
|
8
|
+
// manifest) under an allowed license, whose family and file names do not contain one of its package's Reserved Font Names
|
|
9
|
+
// (a subset is a modified version, owner decision 2026-09-29), and whose OS/2 fsType allows subsetting. Every other face
|
|
10
|
+
// (a host's own faces, Carlito, Raleway, ...) is embedded whole, as before.
|
|
11
|
+
|
|
12
|
+
import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
13
|
+
import { sha256Bytes } from "./sha256.js";
|
|
14
|
+
import { fsTypeOf, subsettingAllowed } from "./font-fstype.js";
|
|
15
|
+
|
|
16
|
+
const SUBSET_LICENSES = new Set(["OFL-1.1", "Apache-2.0", "MIT", "UFL-1.0"]);
|
|
17
|
+
const HB_MEMORY_MODE_READONLY = 1;
|
|
18
|
+
const HB_SUBSET_SETS_LAYOUT_FEATURE_TAG = 6;
|
|
19
|
+
const CACHE_LIMIT = 256;
|
|
20
|
+
|
|
21
|
+
/** The manifest faces that may be subset, by sha256: permissive license, no Reserved Font Name in the family or file name. */
|
|
22
|
+
const SUBSETTABLE = (() => {
|
|
23
|
+
const hashes = new Set();
|
|
24
|
+
for (const pkg of BUNDLED_FONT_MANIFEST.packages) {
|
|
25
|
+
if (!SUBSET_LICENSES.has(pkg.license)) continue;
|
|
26
|
+
const reserved = (pkg.reservedFontNames ?? []).map((name) => name.toLowerCase());
|
|
27
|
+
for (const face of pkg.faces) {
|
|
28
|
+
const names = `${face.family} ${face.file}`.toLowerCase();
|
|
29
|
+
if (!reserved.some((name) => names.includes(name)) && /^[0-9a-f]{64}$/.test(face.sha256 ?? "")) hashes.add(face.sha256);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return hashes;
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
/** Whether a face may be subset: a permitted bundled face whose OS/2 fsType allows subsetting (and embedding). */
|
|
36
|
+
export function subsetPermitted(data) {
|
|
37
|
+
return SUBSETTABLE.has(hexDigest(data)) && fsTypeAllowsSubset(data);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether the OS/2 fsType allows embedding a subset: not restricted (0x0002), not "no subsetting" (0x0100), not bitmap-only (0x0200). */
|
|
41
|
+
export function fsTypeAllowsSubset(data) {
|
|
42
|
+
return subsettingAllowed(fsTypeOf(data));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hexDigest(data) {
|
|
46
|
+
return [...sha256Bytes(data)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A synchronous subsetter over hb-subset: `subset(data, codePoints)` returns the subset font bytes, or null when HarfBuzz
|
|
51
|
+
* cannot subset the face. `wasm` is the `harfbuzz-subset.wasm` bytes, a compiled WebAssembly.Module or an Instance.
|
|
52
|
+
*/
|
|
53
|
+
export function createSubsetter(wasm) {
|
|
54
|
+
// A browser instantiates asynchronously (large modules may not compile synchronously on the main thread) and passes the instance.
|
|
55
|
+
const instance = wasm instanceof WebAssembly.Instance ? wasm : new WebAssembly.Instance(wasm instanceof WebAssembly.Module ? wasm : new WebAssembly.Module(wasm), {});
|
|
56
|
+
const exports = instance.exports;
|
|
57
|
+
const required = ["memory", "malloc", "free", "hb_blob_create", "hb_blob_destroy", "hb_blob_get_length", "hb_blob_get_data", "hb_face_create", "hb_face_destroy",
|
|
58
|
+
"hb_face_reference_blob", "hb_set_add", "hb_set_clear", "hb_set_invert", "hb_subset_input_create_or_fail", "hb_subset_input_destroy", "hb_subset_input_unicode_set",
|
|
59
|
+
"hb_subset_input_set", "hb_subset_or_fail"];
|
|
60
|
+
for (const name of required) if (!(name in exports)) throw new Error(`harfbuzz-subset.wasm has no export ${name}.`);
|
|
61
|
+
return {
|
|
62
|
+
subset(data, codePoints) {
|
|
63
|
+
const pointer = exports.malloc(data.length);
|
|
64
|
+
if (!pointer) return null;
|
|
65
|
+
new Uint8Array(exports.memory.buffer, pointer, data.length).set(data);
|
|
66
|
+
const blob = exports.hb_blob_create(pointer, data.length, HB_MEMORY_MODE_READONLY, 0, 0);
|
|
67
|
+
const face = exports.hb_face_create(blob, 0);
|
|
68
|
+
const input = exports.hb_subset_input_create_or_fail();
|
|
69
|
+
let result = 0, output = null;
|
|
70
|
+
try {
|
|
71
|
+
if (!input) return null;
|
|
72
|
+
const unicodes = exports.hb_subset_input_unicode_set(input);
|
|
73
|
+
for (const codePoint of codePoints) exports.hb_set_add(unicodes, codePoint);
|
|
74
|
+
// Every layout feature, not only HarfBuzz's default list: the browser shapes the subset like the whole face whatever
|
|
75
|
+
// font-feature-settings the SVG asks for.
|
|
76
|
+
const features = exports.hb_subset_input_set(input, HB_SUBSET_SETS_LAYOUT_FEATURE_TAG);
|
|
77
|
+
exports.hb_set_clear(features);
|
|
78
|
+
exports.hb_set_invert(features);
|
|
79
|
+
result = exports.hb_subset_or_fail(face, input);
|
|
80
|
+
if (!result) return null;
|
|
81
|
+
const subsetBlob = exports.hb_face_reference_blob(result);
|
|
82
|
+
const length = exports.hb_blob_get_length(subsetBlob), at = exports.hb_blob_get_data(subsetBlob, 0);
|
|
83
|
+
output = length && at ? new Uint8Array(exports.memory.buffer, at, length).slice() : null;
|
|
84
|
+
exports.hb_blob_destroy(subsetBlob);
|
|
85
|
+
return output && glyfBeforeLastTable(output);
|
|
86
|
+
} finally {
|
|
87
|
+
if (result) exports.hb_face_destroy(result);
|
|
88
|
+
if (input) exports.hb_subset_input_destroy(input);
|
|
89
|
+
exports.hb_face_destroy(face);
|
|
90
|
+
exports.hb_blob_destroy(blob);
|
|
91
|
+
exports.free(pointer);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// hb-subset writes glyf as the last table. fontkit reads a 10-byte glyph header even for an empty glyph (a space), so when
|
|
98
|
+
// the last glyph is empty it reads past the end of the file, and the vector PDF and the outline engine (which parse SVG
|
|
99
|
+
// @font-face data with fontkit) would fail. Table order in the file is free: glyf moves before the other tables, every table
|
|
100
|
+
// keeps its bytes and checksum, and head.checkSumAdjustment is recomputed for the new file.
|
|
101
|
+
function glyfBeforeLastTable(font) {
|
|
102
|
+
const view = new DataView(font.buffer, font.byteOffset, font.byteLength);
|
|
103
|
+
const count = view.getUint16(4);
|
|
104
|
+
const tables = [];
|
|
105
|
+
for (let index = 0; index < count; index++) {
|
|
106
|
+
const at = 12 + index * 16;
|
|
107
|
+
tables.push({ entry: at, tag: String.fromCharCode(font[at], font[at + 1], font[at + 2], font[at + 3]), offset: view.getUint32(at + 8), length: view.getUint32(at + 12) });
|
|
108
|
+
}
|
|
109
|
+
const byOffset = [...tables].sort((a, b) => a.offset - b.offset);
|
|
110
|
+
if (byOffset.at(-1)?.tag !== "glyf" || byOffset.length < 2) return font;
|
|
111
|
+
const order = [byOffset.at(-1), ...byOffset.slice(0, -1)];
|
|
112
|
+
const padded = (length) => (length + 3) & ~3;
|
|
113
|
+
const start = 12 + count * 16;
|
|
114
|
+
const out = new Uint8Array(start + order.reduce((sum, table) => sum + padded(table.length), 0));
|
|
115
|
+
out.set(font.subarray(0, start));
|
|
116
|
+
const outView = new DataView(out.buffer);
|
|
117
|
+
let offset = start;
|
|
118
|
+
for (const table of order) {
|
|
119
|
+
out.set(font.subarray(table.offset, table.offset + table.length), offset);
|
|
120
|
+
outView.setUint32(table.entry + 8, offset);
|
|
121
|
+
offset += padded(table.length);
|
|
122
|
+
}
|
|
123
|
+
const head = tables.find((table) => table.tag === "head");
|
|
124
|
+
if (head) {
|
|
125
|
+
const at = outView.getUint32(head.entry + 8) + 8;
|
|
126
|
+
outView.setUint32(at, 0);
|
|
127
|
+
let sum = 0;
|
|
128
|
+
for (let index = 0; index < out.length; index += 4) sum = (sum + outView.getUint32(index)) >>> 0;
|
|
129
|
+
outView.setUint32(at, (0xB1B0AFBA - sum) >>> 0);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The subset engine a fonts handle carries as `subsets` (RR-65): `subsetDataUrl(font, codePoints)` returns the data URL of
|
|
136
|
+
* the embedded face `font` cut to the code points, or its own data URL when the face may not be subset (license, Reserved
|
|
137
|
+
* Font Name, fsType), HarfBuzz cannot subset it, or the subset is not smaller. Faces are found in the registry by family,
|
|
138
|
+
* weight and style; results are cached per face and code point set.
|
|
139
|
+
*/
|
|
140
|
+
export function fontSubsets(registry, subsetter) {
|
|
141
|
+
const permitted = new Map();
|
|
142
|
+
const cache = new Map();
|
|
143
|
+
const bytesOf = (font) => {
|
|
144
|
+
const faces = registry.exportFaces(), described = registry.describeFaces();
|
|
145
|
+
const index = described.findIndex((face) => face.family === font.family && face.weight === font.weight && Boolean(face.italic) === Boolean(font.italic));
|
|
146
|
+
return index >= 0 ? faces[index].data : null;
|
|
147
|
+
};
|
|
148
|
+
return Object.freeze({
|
|
149
|
+
subsetDataUrl(font, codePoints) {
|
|
150
|
+
const points = [...new Set(codePoints)].sort((a, b) => a - b);
|
|
151
|
+
const key = `${font.family}|${font.weight}|${font.italic ? 1 : 0}|${font.dataUrl.length}|${points.join(",")}`;
|
|
152
|
+
if (cache.has(key)) return cache.get(key);
|
|
153
|
+
const data = bytesOf(font);
|
|
154
|
+
let url = font.dataUrl;
|
|
155
|
+
if (data) {
|
|
156
|
+
let allowed = permitted.get(data);
|
|
157
|
+
if (allowed === undefined) { allowed = subsetPermitted(data); permitted.set(data, allowed); }
|
|
158
|
+
const subset = allowed ? subsetter.subset(data, points) : null;
|
|
159
|
+
if (subset && subset.length < data.length) url = `${font.dataUrl.slice(0, font.dataUrl.indexOf(",") + 1)}${base64(subset)}`;
|
|
160
|
+
}
|
|
161
|
+
if (cache.size >= CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
|
162
|
+
cache.set(key, url);
|
|
163
|
+
return url;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function base64(bytes) {
|
|
169
|
+
if (typeof Buffer !== "undefined") return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
170
|
+
let binary = "";
|
|
171
|
+
for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
172
|
+
return btoa(binary);
|
|
173
|
+
}
|