@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.
package/dist/index.d.ts CHANGED
@@ -41,7 +41,7 @@ export type RenderDiagnostic = LayoutDiagnostic | {
41
41
  scripts: string[];
42
42
  characters: string[];
43
43
  } | {
44
- code: "unsupported-pattern" | "date-needs-value" | "language-preview-unavailable" | "language-preview-unresolved" | "paragraph-direction-unavailable";
44
+ code: "unsupported-pattern" | "variable-example-used" | "date-needs-value" | "language-preview-unavailable" | "language-preview-unresolved" | "paragraph-direction-unavailable";
45
45
  path: string;
46
46
  message: string;
47
47
  } | {
@@ -73,6 +73,14 @@ export interface RenderSvgOptions {
73
73
  imageResolver?: (src: string | undefined, context: { asset: unknown; path: string }) => string | null | undefined;
74
74
  onDiagnostic?: (diagnostic: RenderDiagnostic) => void;
75
75
  validate?: boolean;
76
+ /**
77
+ * Values for the deck's template variables, keyed by variable id (core `resolveVariables`). A deck that uses
78
+ * content variables, or is marked `template: true`, is resolved to a concrete deck first. A template previews with
79
+ * each unfilled variable's example (reported as `variable-example-used`); a normal deck with an unfilled required
80
+ * variable throws `unfilled-variables`. `false` draws the document as authored, with `{{id}}` tokens and
81
+ * `var:id` references visible and nothing resolved: the view an editor uses while the template itself is edited.
82
+ */
83
+ variables?: Record<string, unknown> | false;
76
84
  slideIndex?: number;
77
85
  /**
78
86
  * Today's calendar date (ISO YYYY-MM-DD) for `date: true` header/footer furniture. The renderer
@@ -81,6 +89,11 @@ export interface RenderSvgOptions {
81
89
  date?: string;
82
90
  trace?: boolean;
83
91
  catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
92
+ /**
93
+ * Records for catalog sources the host has already fetched, keyed by the source string a document's
94
+ * `catalogs.<kind>.source` names (a single source or an ordered array; first match wins, the bundled
95
+ * default catalog is appended). The renderer never fetches a source itself.
96
+ */
84
97
  catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
85
98
  }
86
99
 
@@ -97,7 +110,97 @@ export interface SvgToPngOptions {
97
110
  monospaceFamily?: string;
98
111
  }
99
112
 
100
- export interface SvgToPdfOptions extends SvgToPngOptions {}
113
+ /** A note, warning or (under `strict`) error from the vector PDF export. Every code starts with `pdf-`. */
114
+ export type PdfDiagnostic = {
115
+ /** One per embedded font face: what was embedded, how, and under which embedding permissions. */
116
+ code: "pdf-font-embedded";
117
+ family: string; postscriptName: string; weight: number; italic: boolean;
118
+ /** Distinct glyphs kept, and the size of the embedded font program in bytes. */
119
+ glyphs: number; bytes: number;
120
+ /** `subset` unless the font forbids subsetting (OS/2 fsType bit 8), then `full`. */
121
+ embedding: "subset" | "full";
122
+ /** OS/2 fsType bits of the face. */
123
+ fsType: number;
124
+ /** `preview-and-print` when fsType allows only that. Fonts whose fsType forbids embedding are never embedded. */
125
+ embeddingRestriction?: "preview-and-print";
126
+ /** The face's own license description (name table), when it has one. */
127
+ license?: string;
128
+ /** The file name or `svg @font-face` the face came from. */
129
+ origin?: string;
130
+ } | {
131
+ /** The first requested family has no embeddable face; the named face is used instead (like the PNG preview does). */
132
+ code: "pdf-font-substituted";
133
+ message: string; requestedFamily: string; resolvedFamily: string; weight: number; italic: boolean;
134
+ } | {
135
+ /** A character the selected face lacks is drawn with another supplied face. */
136
+ code: "pdf-font-fallback";
137
+ message: string; fontFamily: string; fallbackFamily: string; codePoint: number;
138
+ } | {
139
+ code: "pdf-font-embedding-restricted" | "pdf-font-unsupported-format" | "pdf-font-unreadable";
140
+ message: string; family?: string; origin?: string;
141
+ } | {
142
+ /** An element with an effect that has no PDF equivalent (filter, mask, nested SVG picture); that element alone is drawn as an image. */
143
+ code: "pdf-raster-fallback";
144
+ message: string; path?: string; element: string; reason: string;
145
+ } | {
146
+ /** No supplied font has the character: the font's missing-glyph box is drawn. */
147
+ code: "pdf-glyph-missing";
148
+ message: string; fontFamily: string; codePoint: number;
149
+ } | {
150
+ /** An attribute with no PDF form here (`rotate`, `dominant-baseline`, `text-transform`, `paint-order`, `mix-blend-mode`, markers, ...). */
151
+ code: "pdf-unsupported-feature";
152
+ message: string; path?: string; element: string; attribute: string;
153
+ } | {
154
+ /** Too much content (more than 50,000 elements on a page, `<use>` expansions included, or groups nested over 256 deep): the rest of the page is not drawn. */
155
+ code: "pdf-expansion-limit";
156
+ message: string; path?: string;
157
+ } | {
158
+ code: "pdf-unsupported-element" | "pdf-unsupported-clip" | "pdf-unsupported-paint" | "pdf-unsupported-css" | "pdf-image-skipped" | "pdf-link-skipped";
159
+ message: string; path?: string; element?: string; kind?: string;
160
+ };
161
+
162
+ export interface PdfMetadata {
163
+ title?: string;
164
+ author?: string;
165
+ subject?: string;
166
+ keywords?: string | string[];
167
+ /** BCP 47 document language; default: the first SVG's `lang`. */
168
+ language?: string;
169
+ /** Default: the package name. */
170
+ creator?: string;
171
+ /** Omitted from the PDF unless supplied: the export never reads a clock. A date-time without a zone designator is read as UTC. */
172
+ creationDate?: Date | string;
173
+ modificationDate?: Date | string;
174
+ }
175
+
176
+ export interface SvgToPdfOptions extends SvgToPngOptions {
177
+ /**
178
+ * `"vector"` (default): shapes, gradients and patterns as PDF vector graphics, pictures as images, text as real text
179
+ * with embedded font subsets (selectable, searchable, correct copy and paste); the fonts are the bundled files and
180
+ * `fontFiles` / `fontDirs` you supply, never system fonts. `"raster"`: each slide an image, as before.
181
+ */
182
+ mode?: "vector" | "raster";
183
+ /** Vector mode paints the page this colour first (default white, as raster mode composites on white); `"none"` leaves it unpainted. */
184
+ background?: string;
185
+ /** Vector only. Family drawn for the generic `serif`; default `defaultFontFamily`. */
186
+ serifFamily?: string;
187
+ /** Vector only. Document title, author, language and dates. */
188
+ metadata?: PdfMetadata;
189
+ /** Vector only. Write the structure tree (reading order, headings, links, figures) and mark decoration as artifacts; default true. */
190
+ tagged?: boolean;
191
+ /** Vector only. `false` leaves streams uncompressed, for inspection; default true. */
192
+ compress?: boolean;
193
+ /** Vector only. Throw (`OPFRenderError`, the diagnostic's code) instead of rasterizing an element or skipping an unsupported feature. */
194
+ strict?: boolean;
195
+ /** Vector only. Pixel density of the rare element rasterized because it has no vector form; default 2. */
196
+ rasterFallbackScale?: number;
197
+ /** Vector only. Receives font embedding reports, substitutions, fallbacks and unsupported features. */
198
+ onDiagnostic?: (diagnostic: PdfDiagnostic) => void;
199
+ /** Vector only. Checked between pages; an abort rejects with the signal's reason. */
200
+ signal?: AbortSignal;
201
+ /** Vector only. Called after each page is written. */
202
+ onProgress?: (progress: { page: number; pages: number }) => void;
203
+ }
101
204
 
102
205
  export interface ResolvedPresentation {
103
206
  presentation: unknown;
@@ -0,0 +1,333 @@
1
+ import { create } from "fontkit";
2
+ import { skipUndecodableLookups } from "./fonts.js";
3
+ import { isTrueTypeOutlines, subsetTrueType } from "./pdf-subset.js";
4
+ import { hex, name, num, sha256, textString, utf16Hex } from "./pdf-writer.js";
5
+
6
+ // Font selection, shaping and embedding for the vector PDF export (RR-12). Fonts come only from the faces the
7
+ // caller supplies (the same bundled font files, `fontFiles` and `fontDirs` the PNG preview draws with, and the
8
+ // `@font-face` data of the SVG itself); nothing is read from the system and nothing is fetched. A face whose
9
+ // OS/2 embedding bits forbid embedding is never embedded.
10
+
11
+ const GENERIC = new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-sans-serif", "ui-serif", "ui-monospace"]);
12
+
13
+ // Parsed faces are kept between calls (keyed by file path, size and modification time) so a deck exported twice does
14
+ // not read, hash and parse the same font files again; the bytes of a face never change what it embeds.
15
+ const FILE_CACHE = new Map();
16
+ let cachedBytes = 0;
17
+ const CACHE_LIMIT_BYTES = 192 * 1024 * 1024;
18
+
19
+ function remember(key, prototype) {
20
+ if (FILE_CACHE.has(key)) return;
21
+ FILE_CACHE.set(key, prototype);
22
+ cachedBytes += prototype.data.length;
23
+ for (const [oldest, value] of FILE_CACHE) {
24
+ if (cachedBytes <= CACHE_LIMIT_BYTES || oldest === key) break;
25
+ FILE_CACHE.delete(oldest);
26
+ cachedBytes -= value.data.length;
27
+ }
28
+ }
29
+
30
+ export class FontLibrary {
31
+ constructor(options = {}) {
32
+ this.options = options;
33
+ this.faces = [];
34
+ this.byHash = new Map();
35
+ this.onDiagnostic = options.onDiagnostic ?? (() => {});
36
+ }
37
+
38
+ /** Register font bytes once (by content hash). Returns the face, or null when the file cannot be used for embedding. */
39
+ addData(data, origin, declaredFamily) {
40
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
41
+ const face = this.adopt(parseFace(bytes, hex(sha256(bytes)), origin), origin);
42
+ // The family a style sheet declares for the data names the face too (the name table may say something else).
43
+ if (face && declaredFamily) face.names.add(declaredFamily.toLowerCase());
44
+ return face;
45
+ }
46
+
47
+ /** Register a font file through the cross-call cache; `stat` is {size, mtimeMs} and `read` returns the bytes. */
48
+ async addFile(file, stat, read) {
49
+ const key = `${file}|${stat.size}|${stat.mtimeMs}`;
50
+ let prototype = FILE_CACHE.get(key);
51
+ if (!prototype) {
52
+ const bytes = new Uint8Array(await read());
53
+ prototype = parseFace(bytes, hex(sha256(bytes)), file.split(/[\\/]/).pop());
54
+ if (!prototype.error) remember(key, prototype);
55
+ }
56
+ return this.adopt(prototype, prototype.origin);
57
+ }
58
+
59
+ adopt(prototype, origin) {
60
+ if (prototype.error) {
61
+ this.onDiagnostic({ code: "pdf-font-unreadable", message: `A font could not be read and was not used: ${prototype.error}`, origin });
62
+ return null;
63
+ }
64
+ if (this.byHash.has(prototype.hash)) return this.byHash.get(prototype.hash);
65
+ const face = { ...prototype, names: new Set(prototype.names), id: this.faces.length };
66
+ this.byHash.set(prototype.hash, face);
67
+ this.faces.push(face);
68
+ if (face.unusable) {
69
+ this.onDiagnostic({
70
+ code: face.unusable,
71
+ message: face.unusable === "pdf-font-embedding-restricted"
72
+ ? `Font '${[...face.names][0]}' forbids embedding (OS/2 fsType), so it is not embedded in the PDF and is never used for vector text.`
73
+ : `Font '${[...face.names][0]}' has no TrueType (glyf) outlines, so it cannot be subset for the PDF and is not used for vector text.`,
74
+ family: [...face.names][0], origin,
75
+ });
76
+ }
77
+ return face;
78
+ }
79
+
80
+ usable() {
81
+ return this.faces.filter((face) => face.embeddable);
82
+ }
83
+
84
+ /**
85
+ * The best face for a CSS font-family list in a weight and style, by the CSS font matching rules: the first named
86
+ * family that has a face wins; generic names map through `genericFamilies`.
87
+ */
88
+ match(families, weight, italic) {
89
+ const usable = this.usable();
90
+ for (const raw of families) {
91
+ const family = raw.toLowerCase();
92
+ const wanted = GENERIC.has(family) ? this.options.genericFamilies?.(family) ?? [] : [family];
93
+ for (const candidate of wanted) {
94
+ const group = usable.filter((face) => face.names.has(candidate.toLowerCase()));
95
+ if (group.length) return { face: bestStyle(group, weight, italic), requested: raw };
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /** A face (nearest to `style`) that has a glyph for the code point, in library order; used for per-character fallback. */
102
+ fallbackFor(codePoint, weight, italic, exclude) {
103
+ const candidates = this.usable().filter((face) => face !== exclude && hasGlyph(face, codePoint));
104
+ if (!candidates.length) return null;
105
+ const exact = candidates.filter((face) => face.italic === italic);
106
+ const pool = exact.length ? exact : candidates;
107
+ return [...pool].sort((a, b) => Math.abs(a.weight - weight) - Math.abs(b.weight - weight) || a.id - b.id)[0];
108
+ }
109
+ }
110
+
111
+ function parseFace(data, hash, origin) {
112
+ try {
113
+ const font = create(data);
114
+ if (!font?.layout || !font.unitsPerEm) throw new Error("Font collections must name one face.");
115
+ // fontkit caches one Glyph per glyph id and keeps the code points of the first use, so a glyph that several
116
+ // letters share (the dotless base of Arabic beh and noon) would report the wrong character everywhere after the
117
+ // first. Hand out a fresh view per call that carries this call's code points.
118
+ const original = font.getGlyph.bind(font);
119
+ font.getGlyph = (id, codePoints = []) => {
120
+ const view = Object.create(original(id));
121
+ view.codePoints = codePoints;
122
+ return view;
123
+ };
124
+ const os2 = font["OS/2"];
125
+ const names = new Set();
126
+ for (const key of ["fontFamily", "preferredFamily"]) {
127
+ const value = font.getName?.(key, "en");
128
+ if (value) names.add(String(value).toLowerCase());
129
+ }
130
+ if (font.familyName) names.add(String(font.familyName).toLowerCase());
131
+ let weight = os2?.usWeightClass ?? (font.head?.macStyle?.bold ? 700 : 400);
132
+ if (weight > 0 && weight < 10) weight *= 100;
133
+ const italic = Boolean(os2?.fsSelection?.italic || font.head?.macStyle?.italic || font.italicAngle);
134
+ const fsType = os2?.fsType ?? {};
135
+ let unusable;
136
+ if (!isTrueTypeOutlines(data)) unusable = "pdf-font-unsupported-format";
137
+ else if (fsType.noEmbedding || fsType.bitmapOnly) unusable = "pdf-font-embedding-restricted";
138
+ // A corrupt loca/glyf/hmtx fails here, as an unreadable font, not later as a RangeError.
139
+ if (!unusable) subsetTrueType(data, [0]);
140
+ return {
141
+ hash, data, font, origin, names, weight, italic, unusable,
142
+ upem: font.unitsPerEm, postscriptName: font.postscriptName || [...names][0] || "Font",
143
+ embeddable: !unusable, subsettable: !fsType.noSubsetting, fsType: fsTypeBits(fsType),
144
+ license: String(font.getName?.("licenseDescription", "en") ?? "").replace(/\s+/g, " ").trim().slice(0, 160) || undefined,
145
+ restriction: fsType.viewOnly && !fsType.editable ? "preview-and-print" : undefined,
146
+ cmapCache: new Map(), nominalCache: new Map(),
147
+ };
148
+ } catch (error) {
149
+ return { hash, origin, error: error instanceof Error ? error.message : String(error) };
150
+ }
151
+ }
152
+
153
+ function fsTypeBits(fsType) {
154
+ return (fsType.noEmbedding ? 0x2 : 0) | (fsType.viewOnly ? 0x4 : 0) | (fsType.editable ? 0x8 : 0) | (fsType.noSubsetting ? 0x100 : 0) | (fsType.bitmapOnly ? 0x200 : 0);
155
+ }
156
+
157
+ export function hasGlyph(face, codePoint) {
158
+ let value = face.cmapCache.get(codePoint);
159
+ if (value === undefined) {
160
+ value = face.font.hasGlyphForCodePoint(codePoint);
161
+ face.cmapCache.set(codePoint, value);
162
+ }
163
+ return value;
164
+ }
165
+
166
+ function bestStyle(group, weight, italic) {
167
+ const sameStyle = group.filter((face) => face.italic === italic);
168
+ const pool = sameStyle.length ? sameStyle : group;
169
+ const weights = [...new Set(pool.map((face) => face.weight))].sort((a, b) => a - b);
170
+ let order;
171
+ const below = weights.filter((value) => value < weight).reverse(), above = weights.filter((value) => value > weight);
172
+ if (weight >= 400 && weight <= 500) {
173
+ const between = weights.filter((value) => value > weight && value <= 500);
174
+ order = [weight, ...between, ...below, ...above.filter((value) => value > 500)];
175
+ } else if (weight < 400) order = [weight, ...below, ...above];
176
+ else order = [weight, ...above, ...below];
177
+ const target = order.find((value) => weights.includes(value));
178
+ return pool.find((face) => face.weight === target) ?? pool[0];
179
+ }
180
+
181
+ /**
182
+ * Shape `text` with `face` and return glyphs left to right: {gid, codePoints, advance, xOffset, yOffset} in font units.
183
+ * `direction` is "ltr" or "rtl" (the caller has already applied the bidirectional algorithm), `features` an object of
184
+ * OpenType feature booleans, `language` an OpenType language tag. Shaping that fontkit cannot complete degrades the
185
+ * same way the registry's measurement does (marks off, then plain character mapping).
186
+ */
187
+ export function shape(face, text, { features, language, direction } = {}) {
188
+ const font = face.font;
189
+ const attempt = (extra) => {
190
+ const merged = features || extra ? { ...features, ...extra } : undefined;
191
+ return font.layout(text, merged, undefined, language, direction);
192
+ };
193
+ let run;
194
+ try { run = attempt(); } catch {
195
+ if (skipUndecodableLookups(font)) { try { run = attempt(); } catch { /* fall through */ } }
196
+ if (!run) { try { run = attempt({ abvm: false, blwm: false, mark: false, mkmk: false }); } catch { /* fall through */ } }
197
+ }
198
+ if (run) {
199
+ return run.glyphs.map((glyph, index) => ({
200
+ gid: glyph.id,
201
+ codePoints: glyph.codePoints ?? [],
202
+ advance: run.positions[index].xAdvance,
203
+ xOffset: run.positions[index].xOffset ?? 0,
204
+ yOffset: run.positions[index].yOffset ?? 0,
205
+ }));
206
+ }
207
+ const glyphs = [];
208
+ for (const character of text) {
209
+ const glyph = font.glyphForCodePoint(character.codePointAt(0));
210
+ glyphs.push({ gid: glyph.id, codePoints: [character.codePointAt(0)], advance: glyph.advanceWidth, xOffset: 0, yOffset: 0 });
211
+ }
212
+ return direction === "rtl" ? glyphs.reverse() : glyphs;
213
+ }
214
+
215
+ /** Tracks which glyphs of which faces a document uses, then writes the Type0/CIDFontType2 font objects. */
216
+ export class FontEmbedder {
217
+ constructor(file) {
218
+ this.file = file;
219
+ this.uses = new Map();
220
+ }
221
+
222
+ use(face) {
223
+ let entry = this.uses.get(face);
224
+ if (!entry) {
225
+ entry = { face, resource: `F${this.uses.size + 1}`, object: this.file.reserve(), cids: new Map() };
226
+ this.uses.set(face, entry);
227
+ }
228
+ return entry;
229
+ }
230
+
231
+ /**
232
+ * Record a drawn glyph and return its CID. A CID stands for one glyph drawing one piece of text: a font that builds
233
+ * several letters from one shared glyph (Arabic letters differing only by their dots, which are separate glyphs)
234
+ * gets one CID per letter, each with its own Unicode value, so the glyph-to-Unicode map is exact. All CIDs of a glyph
235
+ * share its outline through the CIDToGIDMap.
236
+ */
237
+ record(entry, glyph) {
238
+ // Every space separator (no-break, thin, ideographic...) is copied as a plain space; text that uses one of the
239
+ // others is marked by the caller so its exact characters survive as /ActualText.
240
+ const spaceLike = glyph.codePoints.length === 1 && /^\p{Zs}$/u.test(String.fromCodePoint(glyph.codePoints[0]));
241
+ const codePoints = spaceLike ? [0x20] : glyph.codePoints;
242
+ const key = glyph.gid + "|" + codePoints.join(",");
243
+ let known = entry.cids.get(key);
244
+ if (known === undefined) {
245
+ known = { cid: entry.cids.size, gid: glyph.gid, codePoints };
246
+ entry.cids.set(key, known);
247
+ }
248
+ return known.cid;
249
+ }
250
+
251
+ /** Write every font object; returns the diagnostics (one `pdf-font-embedded` per face). */
252
+ finish() {
253
+ const reports = [];
254
+ for (const entry of this.uses.values()) reports.push(this.#write(entry));
255
+ return reports;
256
+ }
257
+
258
+ #write(entry) {
259
+ const { face } = entry;
260
+ const scale = 1000 / face.upem;
261
+ const cids = [...entry.cids.values()];
262
+ const used = [...new Set(cids.map((item) => item.gid))].sort((a, b) => a - b);
263
+ let fontBytes = face.data, mapping = null, embedding = "full";
264
+ if (face.subsettable) {
265
+ const subset = subsetTrueType(face.data, used);
266
+ fontBytes = subset.data;
267
+ mapping = subset.mapping;
268
+ embedding = "subset";
269
+ }
270
+ const digest = hex(sha256(new TextEncoder().encode(face.hash + ":" + used.join(","))));
271
+ const tag = [...Array(6).keys()].map((index) => String.fromCharCode(65 + (parseInt(digest.slice(index * 2, index * 2 + 2), 16) % 26))).join("");
272
+ const base = tag + "+" + face.postscriptName.replace(/[^A-Za-z0-9-]/g, "");
273
+ const file = this.file;
274
+ const fontFile = file.addStream("/Length1 " + fontBytes.length, fontBytes);
275
+
276
+ const ascent = face.font.ascent, descent = face.font.descent, bbox = face.font.bbox;
277
+ const descriptor = file.add(
278
+ "/Type/FontDescriptor/FontName" + name(base) + "/Flags " + (face.italic ? 96 : 32) +
279
+ "/FontBBox[" + [bbox.minX, bbox.minY, bbox.maxX, bbox.maxY].map((value) => num(value * scale, 0)).join(" ") + "]" +
280
+ "/ItalicAngle " + num(face.font.italicAngle || 0, 2) + "/Ascent " + num(ascent * scale, 0) + "/Descent " + num(descent * scale, 0) +
281
+ "/CapHeight " + num((face.font.capHeight || ascent) * scale, 0) + "/StemV " + num(50 + Math.max(0, face.weight - 100) / 10, 0) + "/FontFile2 " + fontFile + " 0 R",
282
+ );
283
+
284
+ // CIDs are numbered in order of first use; the CIDToGIDMap sends each to its glyph in the (renumbered) font program.
285
+ const cache = new Map();
286
+ const widths = "0[" + cids.map((item) => num(nominalAdvance(face, item.gid, cache), 2)).join(" ") + "]";
287
+ const map = new Uint8Array(cids.length * 2);
288
+ for (const item of cids) { const to = mapping ? mapping.get(item.gid) : item.gid; map[item.cid * 2] = to >> 8; map[item.cid * 2 + 1] = to & 0xff; }
289
+ const cidToGid = " " + file.addStream("", map) + " 0 R";
290
+ const cidFont = file.add(
291
+ "/Type/Font/Subtype/CIDFontType2/BaseFont" + name(base) + "/CIDSystemInfo<</Registry(Adobe)/Ordering(Identity)/Supplement 0>>" +
292
+ "/FontDescriptor " + descriptor + " 0 R/CIDToGIDMap" + cidToGid + "/DW 0/W[" + widths + "]",
293
+ );
294
+ const toUnicode = file.addStream("", toUnicodeCMap(cids));
295
+ file.set(entry.object, "/Type/Font/Subtype/Type0/BaseFont" + name(base) + "/Encoding/Identity-H/DescendantFonts[" + cidFont + " 0 R]/ToUnicode " + toUnicode + " 0 R");
296
+ return {
297
+ code: "pdf-font-embedded", family: [...face.names][0], postscriptName: face.postscriptName, weight: face.weight, italic: face.italic,
298
+ glyphs: used.length, bytes: fontBytes.length, embedding, fsType: face.fsType, ...(face.restriction ? { embeddingRestriction: face.restriction } : {}),
299
+ ...(face.license ? { license: face.license } : {}), origin: face.origin,
300
+ };
301
+ }
302
+ }
303
+
304
+ function nominalWidth(face, gid) {
305
+ return face.font.getGlyph(gid).advanceWidth * 1000 / face.upem;
306
+ }
307
+
308
+ /** The width the PDF assumes for a glyph (what the font dictionary's /W says); shaped advances are corrected against it. */
309
+ export function nominalAdvance(face, gid, cache) {
310
+ let value = cache.get(gid);
311
+ if (value === undefined) {
312
+ value = Math.round(nominalWidth(face, gid) * 100) / 100;
313
+ cache.set(gid, value);
314
+ }
315
+ return value;
316
+ }
317
+
318
+ // A glyph that stands for no character of its own (a diacritic or dot the font split off a letter) maps to a zero width
319
+ // space: extractors drop it, and viewers that honour /ActualText never see it.
320
+ function toUnicodeCMap(cids) {
321
+ const pairs = cids.map((item) => [item.cid, item.codePoints]).sort((a, b) => a[0] - b[0]);
322
+ let body = "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo<</Registry(Adobe)/Ordering(UCS)/Supplement 0>> def\n/CMapName/Adobe-Identity-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n";
323
+ for (let start = 0; start < pairs.length; start += 100) {
324
+ const chunk = pairs.slice(start, start + 100);
325
+ body += `${chunk.length} beginbfchar\n`;
326
+ for (const [id, codePoints] of chunk) body += `<${id.toString(16).toUpperCase().padStart(4, "0")}> <${utf16Hex(String.fromCodePoint(...(codePoints.length ? codePoints : [0x200b])))}>\n`;
327
+ body += "endbfchar\n";
328
+ }
329
+ body += "endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n";
330
+ return body;
331
+ }
332
+
333
+ export { textString };