@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,468 @@
1
+ // RR-17 (FF-44): complex-script text for the raster path (resvg-js 2.6.2). Two resvg limits, found by test/script-corpora-raster.mjs:
2
+ // - usvg draws every HarfBuzz cluster with the advance of its widest glyph instead of the sum of its glyphs (`outline_cluster`), so a
3
+ // spacing vowel sign, a split vowel or a decomposed sara am that shares a cluster with its consonant loses its advance: भाषा draws as
4
+ // भषा, words run together, and a textLength then stretches the glyphs by the lost amount;
5
+ // - rustybuzz breaks a ligature conjunct under a pre-base vowel sign (द्धि, દ્ધિ, ট্টি draw with an explicit halant) and ignores the
6
+ // SVG lang (Korean text is shaped without the KOR language system).
7
+ // Browsers and the vector PDF path (fontkit) are right; only PNG and raster PDF were wrong.
8
+ //
9
+ // For the SVG about to be rasterized (never the emitted SVG), every <text> whose content has characters of an affected script (the
10
+ // Indic scripts, Thai, Lao, Khmer, Myanmar), or Hangul in a KOR-language text, becomes a <g> that carries the text's attributes and
11
+ // draws its content cluster by cluster at absolute positions:
12
+ // - the x of each cluster is the fontkit advance of the prefix of its run, measured with the face resvg will pick for the run
13
+ // (fontdb's family, style and weight matching over the same font files, with the same glyph fallback order), the OpenType language
14
+ // of the SVG's lang and the policy features, scaled to the run's textLength when it has one; text-anchor middle and end are
15
+ // resolved into absolute starts, baseline-shift into the y;
16
+ // - a cluster of a script whose fontkit glyph runs equal HarfBuzz's in the corpus (Devanagari, Gujarati, Oriya, Tamil, Kannada,
17
+ // Sinhala) is drawn as fontkit's glyph outlines (<path> per glyph), so a conjunct under a pre-base vowel is the browser's;
18
+ // - any other cluster (Bengali, Gurmukhi, Telugu, Malayalam, Thai, Lao, Khmer and Myanmar, where fontkit orders marks differently
19
+ // or needs the no-mark-positioning retry; every grapheme of a KOR text, where fontkit's outlines of the KOR punctuation composites
20
+ // lack their shift) is its own <text x y> that resvg shapes correctly in isolation (its glyphs are placed by the sum of the
21
+ // advances; only the cluster's own advance is wrong, and that is not needed when the next cluster is pinned);
22
+ // - runs of other scripts in such a text stay one <text> each, so they keep their own shaping and kerning.
23
+ // Clusters are extended grapheme clusters (Intl.Segmenter) joined across a virama, halant, coeng or Myanmar virama (plus a ZWJ) to the
24
+ // following letter, and across the Myanmar signs UAX #29 leaves out of SpacingMark (vowel sign aa, tall aa, visarga): the syllables
25
+ // HarfBuzz's Indic, Khmer and Myanmar shapers cluster. Text with no affected character is left byte-identical; a <text> the rewrite
26
+ // cannot lay out (right-to-left content, letter-spacing, per-character x lists, dx/dy/rotate, relative units) is left to resvg as it was.
27
+ import { readFile } from "node:fs/promises";
28
+ import { create } from "fontkit";
29
+ import { disabledFeaturesFor } from "./font-compatibility.js";
30
+ import { openTypeLanguage } from "./script-fonts.js";
31
+
32
+ const AFFECTED = /[\p{scx=Devanagari}\p{scx=Bengali}\p{scx=Gurmukhi}\p{scx=Gujarati}\p{scx=Oriya}\p{scx=Tamil}\p{scx=Telugu}\p{scx=Kannada}\p{scx=Malayalam}\p{scx=Sinhala}\p{scx=Thai}\p{scx=Lao}\p{scx=Khmer}\p{scx=Myanmar}]/u;
33
+ // Scripts whose fontkit glyph runs equal HarfBuzz's for every corpus sample (glyph ids, order and advances, no retry): drawn as outlines.
34
+ const OUTLINED = /[\p{scx=Devanagari}\p{scx=Gujarati}\p{scx=Oriya}\p{scx=Tamil}\p{scx=Kannada}\p{scx=Sinhala}]/u;
35
+ const HANGUL = /\p{Script=Hangul}/u;
36
+ const LETTER = /^(?=\p{L})[\p{scx=Devanagari}\p{scx=Bengali}\p{scx=Gurmukhi}\p{scx=Gujarati}\p{scx=Oriya}\p{scx=Tamil}\p{scx=Telugu}\p{scx=Kannada}\p{scx=Malayalam}\p{scx=Sinhala}\p{scx=Khmer}\p{scx=Myanmar}]/u;
37
+ // Virama, halant, al-lakuna, coeng and the Myanmar virama: a cluster ending in one (optionally followed by ZWJ) joins the next letter.
38
+ const JOINER = /[\u094D\u09CD\u0A4D\u0ACD\u0B4D\u0BCD\u0C4D\u0CCD\u0D4D\u0DCA\u17D2\u1039]\u200D?$/u;
39
+ // Myanmar signs that UAX #29 excludes from SpacingMark (they start a grapheme cluster) but HarfBuzz keeps in the syllable.
40
+ const MYANMAR_SIGN = /^[\u102B\u102C\u1038\u1062-\u1064\u1067-\u106D\u1083\u1087-\u108C\u108F\u109A-\u109C]/u;
41
+ // Kinzi (nga, asat, virama) is the zero-advance mark HarfBuzz forms above the next consonant; fontkit's universal shaper keeps the full nga.
42
+ const KINZI = "\u1004\u103A\u1039";
43
+ // Strong right-to-left characters and directional controls: the text would be reordered by bidi, which per-cluster pinning cannot follow.
44
+ const RTL = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Adlam}\u061C\u200F\u202B\u202E\u2067]/u;
45
+ const IGNORABLE = /\p{Default_Ignorable_Code_Point}/u;
46
+ const TOKENS = /<(\/?)([A-Za-z_][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<[?!][^>]*>|[^<]+/g;
47
+ const ATTRIBUTE = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
48
+ const STRETCH = { "ultra-condensed": 1, "extra-condensed": 2, condensed: 3, "semi-condensed": 4, normal: 5, "semi-expanded": 6, expanded: 7, "extra-expanded": 8, "ultra-expanded": 9 };
49
+ // Attributes that position or size a text element or a span: never copied to a piece.
50
+ const POSITIONAL = new Set(["x", "y", "dx", "dy", "rotate", "textLength", "lengthAdjust", "text-anchor", "baseline-shift", "id"]);
51
+ // Attributes of a span that paint its glyphs: the only ones a glyph outline takes from its spans.
52
+ const PAINT = new Set(["fill", "fill-opacity", "fill-rule", "opacity", "stroke", "stroke-width", "stroke-opacity", "visibility", "display"]);
53
+ const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
54
+ const faceCache = new Map();
55
+
56
+ /** Rewrites complex-script text of `svg` into per-cluster positioned pieces for resvg (see the file comment). */
57
+ export async function pinScriptClusters(svg, { fontFiles = [], defaultFontFamily = "Roboto", sansSerifFamily, serifFamily, monospaceFamily, cursiveFamily, fantasyFamily } = {}) {
58
+ if (!AFFECTED.test(svg) && !HANGUL.test(svg)) return svg;
59
+ // resvg-js names for the generic families (its own defaults where rasterizeSvg sets none) and for text without a font-family.
60
+ const generics = {
61
+ default: defaultFontFamily, "sans-serif": sansSerifFamily ?? defaultFontFamily, serif: serifFamily ?? "Times New Roman",
62
+ monospace: monospaceFamily ?? "Roboto Mono", cursive: cursiveFamily ?? "Comic Sans MS", fantasy: fantasyFamily ?? "Impact"
63
+ };
64
+ let db = null;
65
+ const database = async () => db ?? (db = await loadFaces(fontFiles));
66
+ const stack = [{ props: { fontFamily: undefined, fontSize: 12, fontWeight: 400, fontStyle: "normal", fontStretch: 5, lang: undefined, preserve: false, anchor: "start", direction: "ltr", spacing: false } }];
67
+ const tokens = new RegExp(TOKENS.source, "g");
68
+ let output = "", end = 0;
69
+ for (let match = tokens.exec(svg); match; match = tokens.exec(svg)) {
70
+ const [, close, name, attributes, selfClose] = match;
71
+ if (name === undefined) continue;
72
+ if (close) { if (stack.length > 1) stack.pop(); continue; }
73
+ const attrs = parseAttributes(attributes);
74
+ const props = inherit(stack.at(-1).props, attrs);
75
+ if (name !== "text" || selfClose) { if (!selfClose) stack.push({ props }); continue; }
76
+ const closeIndex = svg.indexOf("</text>", tokens.lastIndex);
77
+ if (closeIndex < 0) break;
78
+ const inner = svg.slice(tokens.lastIndex, closeIndex);
79
+ tokens.lastIndex = closeIndex + "</text>".length;
80
+ if (!needsRewrite(inner, props)) continue;
81
+ const rewritten = rewriteText({ attrs, attributes, inner, props }, await database(), generics);
82
+ if (rewritten === null) continue;
83
+ output += svg.slice(end, match.index) + rewritten;
84
+ end = tokens.lastIndex;
85
+ }
86
+ return end ? output + svg.slice(end) : svg;
87
+ }
88
+
89
+ function needsRewrite(inner, props) {
90
+ const content = decodeEntities(inner.replace(/<!--[\s\S]*?-->|<[^>]*>/g, ""));
91
+ if (!/\S/.test(content)) return false;
92
+ if (AFFECTED.test(content)) return true;
93
+ return HANGUL.test(content) && openTypeLanguage(props.lang) === "KOR";
94
+ }
95
+
96
+ function parseAttributes(attributes) {
97
+ const attrs = new Map();
98
+ for (const found of attributes.matchAll(ATTRIBUTE)) attrs.set(found[1], decodeEntities(found[2] ?? found[3] ?? ""));
99
+ return attrs;
100
+ }
101
+
102
+ function styleProperty(attrs, name) {
103
+ const style = attrs.get("style");
104
+ if (!style) return undefined;
105
+ const found = new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`).exec(style);
106
+ return found ? found[1].trim() : undefined;
107
+ }
108
+
109
+ const property = (attrs, name) => styleProperty(attrs, name) ?? attrs.get(name);
110
+
111
+ /** The inherited text properties of an element, from its attributes and style over its parent's. `invalid` marks what the rewrite cannot lay out. */
112
+ function inherit(parent, attrs) {
113
+ const props = { ...parent };
114
+ const family = property(attrs, "font-family");
115
+ if (family !== undefined) props.fontFamily = family;
116
+ const size = property(attrs, "font-size");
117
+ if (size !== undefined) {
118
+ const number = /^\s*(\d+(?:\.\d+)?|\.\d+)(px)?\s*$/.exec(size);
119
+ if (number) props.fontSize = Number(number[1]); else props.invalid = true;
120
+ }
121
+ const weight = property(attrs, "font-weight");
122
+ if (weight !== undefined) {
123
+ const value = weight.trim();
124
+ if (value === "normal") props.fontWeight = 400;
125
+ else if (value === "bold") props.fontWeight = 700;
126
+ else if (value === "bolder") props.fontWeight = parent.fontWeight < 350 ? 400 : parent.fontWeight < 550 ? 700 : 900;
127
+ else if (value === "lighter") props.fontWeight = parent.fontWeight < 550 ? 100 : parent.fontWeight < 750 ? 400 : 700;
128
+ else if (/^\d+(\.\d+)?$/.test(value)) props.fontWeight = Number(value);
129
+ else props.invalid = true;
130
+ }
131
+ const style = property(attrs, "font-style");
132
+ if (style !== undefined) props.fontStyle = /italic/.test(style) ? "italic" : /oblique/.test(style) ? "oblique" : "normal";
133
+ const stretch = property(attrs, "font-stretch");
134
+ if (stretch !== undefined) {
135
+ const percent = /^\s*(\d+(?:\.\d+)?)%\s*$/.exec(stretch);
136
+ props.fontStretch = percent ? Math.min(9, Math.max(1, Math.round(Number(percent[1]) / 12.5))) : STRETCH[stretch.trim()] ?? 5;
137
+ }
138
+ const lang = attrs.get("xml:lang") ?? attrs.get("lang");
139
+ if (lang !== undefined) props.lang = lang;
140
+ const space = attrs.get("xml:space");
141
+ if (space !== undefined) props.preserve = space === "preserve";
142
+ const anchor = property(attrs, "text-anchor");
143
+ if (anchor !== undefined) props.anchor = anchor.trim();
144
+ const direction = property(attrs, "direction");
145
+ if (direction !== undefined) props.direction = direction.trim();
146
+ const shift = property(attrs, "baseline-shift");
147
+ if (shift !== undefined) {
148
+ const number = singleNumber(shift);
149
+ if (number === null) props.invalid = true; else props.shift = (parent.shift ?? 0) + number;
150
+ }
151
+ for (const name of ["letter-spacing", "word-spacing"]) {
152
+ const value = property(attrs, name);
153
+ if (value !== undefined && !/^\s*(normal|0+(\.0+)?(px)?)\s*$/.test(value)) props.spacing = true;
154
+ }
155
+ for (const name of ["dx", "dy", "rotate", "writing-mode", "unicode-bidi"]) if (attrs.has(name)) props.invalid = true;
156
+ if (property(attrs, "writing-mode") !== undefined) props.invalid = true;
157
+ return props;
158
+ }
159
+
160
+ /** Parses the content of one <text> into a tree of elements and text nodes. */
161
+ function parseInner(inner) {
162
+ const root = { type: "element", children: [] }, open = [root];
163
+ for (const match of inner.matchAll(TOKENS)) {
164
+ const [token, close, name, attributes, selfClose] = match;
165
+ const parent = open.at(-1);
166
+ if (name === undefined) {
167
+ if (!token.startsWith("<")) parent.children.push({ type: "text", value: decodeEntities(token) });
168
+ continue;
169
+ }
170
+ if (close) { if (open.length > 1) open.pop(); continue; }
171
+ if (name === "textPath") return null;
172
+ const element = { type: "element", name, attrs: parseAttributes(attributes), children: [] };
173
+ parent.children.push(element);
174
+ if (!selfClose) open.push(element);
175
+ }
176
+ return root;
177
+ }
178
+
179
+ function decodeEntities(value) {
180
+ if (!value.includes("&")) return value;
181
+ return value.replace(/&(#x[0-9a-f]+|#\d+|amp|quot|apos|lt|gt);/gi, (entity, code) => {
182
+ if (code[0] === "#") {
183
+ const point = code[1].toLowerCase() === "x" ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
184
+ return point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : entity;
185
+ }
186
+ return { amp: "&", quot: "\"", apos: "'", lt: "<", gt: ">" }[code.toLowerCase()] ?? entity;
187
+ });
188
+ }
189
+
190
+ const escapeText = value => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
191
+ const escapeAttr = value => escapeText(value).replaceAll("\"", "&quot;");
192
+ const stable = value => Number.isInteger(value) ? String(value) : Number(value).toFixed(3).replace(/\.?0+$/, "");
193
+
194
+ function singleNumber(value) {
195
+ if (value === undefined) return undefined;
196
+ const number = /^\s*(-?(?:\d+(?:\.\d+)?|\.\d+)(?:e-?\d+)?)(px)?\s*$/i.exec(value);
197
+ return number ? Number(number[1]) : null;
198
+ }
199
+
200
+ /** The affected-script syllables and the runs of other text of `text`, as [start, end) offsets with `affected`. */
201
+ export function clusterPieces(text, pinAll = false) {
202
+ const segments = [];
203
+ for (const { segment, index } of segmenter.segment(text)) {
204
+ const previous = segments.at(-1);
205
+ if (previous && ((JOINER.test(previous.text) && LETTER.test(segment)) || MYANMAR_SIGN.test(segment))) { previous.text += segment; previous.end = index + segment.length; continue; }
206
+ segments.push({ text: segment, start: index, end: index + segment.length });
207
+ }
208
+ const pieces = [];
209
+ for (const segment of segments) {
210
+ const affected = pinAll || AFFECTED.test(segment.text);
211
+ const previous = pieces.at(-1);
212
+ if (!affected && previous && !previous.affected) { previous.end = segment.end; continue; }
213
+ pieces.push({ start: segment.start, end: segment.end, affected });
214
+ }
215
+ return pieces;
216
+ }
217
+
218
+ function rewriteText(text, db, generics) {
219
+ const root = parseInner(text.inner);
220
+ if (!root) return null;
221
+ const rootProps = text.props;
222
+ if (rootProps.invalid || rootProps.spacing || rootProps.direction === "rtl" || RTL.test(text.inner)) return null;
223
+ const rootX = singleNumber(text.attrs.get("x")), rootY = singleNumber(text.attrs.get("y")), rootLength = singleNumber(text.attrs.get("textLength"));
224
+ if (rootX === null || rootY === null || rootLength === null) return null; // a list of x values, a unit the rewrite does not resolve
225
+ const pinAll = openTypeLanguage(rootProps.lang) === "KOR";
226
+ // Leaves in document order with their resolved properties, span chain, textLength scope and the x that positions their first character.
227
+ const leaves = [];
228
+ let pending = { x: rootX ?? 0 }, invalid = false;
229
+ const walk = (element, props, scope, chain) => {
230
+ for (const child of element.children) {
231
+ if (child.type === "text") { leaves.push({ value: child.value, props, scope, chain, pending }); pending = null; continue; }
232
+ const childProps = inherit(props, child.attrs);
233
+ if (childProps.invalid || childProps.spacing || childProps.direction === "rtl" || child.attrs.has("y")) { invalid = true; return; }
234
+ if (child.attrs.has("x")) {
235
+ const x = singleNumber(child.attrs.get("x"));
236
+ if (x === null) { invalid = true; return; }
237
+ pending = { x };
238
+ }
239
+ const textLength = singleNumber(child.attrs.get("textLength"));
240
+ if (textLength === null) { invalid = true; return; }
241
+ walk(child, childProps, textLength !== undefined && textLength > 0 ? { textLength, natural: 0 } : scope, [...chain, child]);
242
+ if (invalid) return;
243
+ }
244
+ };
245
+ walk(root, rootProps, rootLength !== undefined && rootLength > 0 ? { textLength: rootLength, natural: 0 } : undefined, []);
246
+ if (invalid || !leaves.length) return null;
247
+ // Whitespace: without xml:space="preserve" resvg removes newlines, turns tabs into spaces, collapses runs of spaces and trims the ends.
248
+ if (!rootProps.preserve) {
249
+ let previousSpace = true;
250
+ for (const leaf of leaves) {
251
+ let out = "";
252
+ for (const character of leaf.value.replace(/[\r\n]+/g, "").replace(/\t/g, " ")) { if (character === " ") { if (previousSpace) continue; previousSpace = true; } else previousSpace = false; out += character; }
253
+ leaf.value = out;
254
+ }
255
+ for (let index = leaves.length - 1; index >= 0; index--) { leaves[index].value = leaves[index].value.replace(/ +$/, ""); if (leaves[index].value) break; }
256
+ }
257
+ // Pieces, their faces and their natural advances from the fontkit prefix widths of each leaf.
258
+ for (const leaf of leaves) {
259
+ leaf.pieces = clusterPieces(leaf.value, pinAll);
260
+ const base = selectFace(db, leaf.props, generics);
261
+ if (!base) return null;
262
+ leaf.runs = faceRuns(db, base, leaf.value);
263
+ const widths = measurePrefixes(leaf.runs, leaf.pieces.map(piece => piece.end), leaf.props);
264
+ if (!widths) return null;
265
+ let previous = 0;
266
+ for (const [index, piece] of leaf.pieces.entries()) { piece.width = widths[index] - previous; previous = widths[index]; }
267
+ if (leaf.scope) leaf.scope.natural += previous;
268
+ }
269
+ // Positions: chunks start at each absolutely positioned character (an element's x applies to the next character drawn, as in
270
+ // resvg, so an empty element passes it on); a chunk's anchor shifts all of its pieces. A textLength scope ends at its own
271
+ // length: its pieces are scaled to it, and a scope with no natural advance (a tab) still advances by it.
272
+ let chunk = null, x = 0, carried = null;
273
+ const chunks = [];
274
+ const finish = () => { if (chunk) chunks.push(chunk); chunk = null; };
275
+ for (const leaf of leaves) {
276
+ const start = leaf.pending ?? carried;
277
+ carried = leaf.pieces.length ? null : start;
278
+ if (leaf.scope) leaf.scope.remaining = (leaf.scope.remaining ?? leaves.filter(item => item.scope === leaf.scope).length) - 1;
279
+ for (const [index, piece] of leaf.pieces.entries()) {
280
+ if (index === 0 && start) { finish(); x = start.x; chunk = { anchor: leaf.props.anchor, pieces: [], width: 0 }; }
281
+ const factor = leaf.scope && leaf.scope.natural > 0 ? leaf.scope.textLength / leaf.scope.natural : 1;
282
+ const advance = piece.width * factor;
283
+ piece.x = x; piece.advance = advance; piece.leaf = leaf;
284
+ chunk.pieces.push(piece);
285
+ chunk.width += advance;
286
+ x += advance;
287
+ }
288
+ if (leaf.scope && leaf.scope.remaining === 0 && leaf.scope.natural === 0 && chunk) { chunk.width += leaf.scope.textLength; x += leaf.scope.textLength; }
289
+ }
290
+ finish();
291
+ for (const item of chunks) {
292
+ const shift = item.anchor === "middle" ? -item.width / 2 : item.anchor === "end" ? -item.width : 0;
293
+ for (const piece of item.pieces) piece.x += shift;
294
+ }
295
+ // Emit: a <g> with the text's attributes, one <path> per glyph of an outlined cluster or one <text> per other piece.
296
+ const group = startTag("g", text.attributes, { x: null, y: null, textLength: null, lengthAdjust: null, "text-anchor": null });
297
+ const parts = [];
298
+ for (const leaf of leaves) {
299
+ const spanAttrs = new Map();
300
+ for (const element of leaf.chain) for (const [key, value] of element.attrs) if (!POSITIONAL.has(key)) spanAttrs.set(key, key === "style" && spanAttrs.has("style") ? `${spanAttrs.get("style")};${value}` : value);
301
+ const y = (rootY ?? 0) - (leaf.props.shift ?? 0); // a positive baseline-shift raises the baseline
302
+ const decoration = (property(spanAttrs, "text-decoration") ?? property(text.attrs, "text-decoration") ?? "").split(/\s+/);
303
+ for (const piece of leaf.pieces) {
304
+ const value = leaf.value.slice(piece.start, piece.end);
305
+ const run = leaf.runs.find(item => item.start <= piece.start && item.start + item.text.length >= piece.end);
306
+ const outlined = piece.affected && run && OUTLINED.test(value) ? shapeOutlines(run.face, value, leaf.props) : null;
307
+ if (outlined) {
308
+ const paint = [...spanAttrs].filter(([key]) => PAINT.has(key)).map(([key, value]) => ` ${key}="${escapeAttr(value)}"`).join("");
309
+ const scale = leaf.props.fontSize / run.face.font.unitsPerEm;
310
+ // A textLength scales the advances inside the cluster too (resvg and browsers stretch the glyphs; the outlines keep their shape).
311
+ const factor = piece.width > 0 ? piece.advance / piece.width : 1;
312
+ let glyphX = piece.x;
313
+ for (const glyph of outlined) {
314
+ if (glyph.d) parts.push(`<path${paint} transform="translate(${stable(glyphX + glyph.xOffset * scale)} ${stable(y - glyph.yOffset * scale)}) scale(${stable(scale)} ${stable(-scale)})" d="${glyph.d}"/>`);
315
+ glyphX += glyph.xAdvance * scale * factor;
316
+ }
317
+ for (const line of decorationLines(run.face, decoration, scale)) parts.push(`<rect${paint} x="${stable(piece.x)}" y="${stable(y + line.offset)}" width="${stable(piece.advance)}" height="${stable(line.thickness)}"/>`);
318
+ continue;
319
+ }
320
+ const attrs = [...spanAttrs].map(([key, value]) => ` ${key}="${escapeAttr(value)}"`).join("");
321
+ parts.push(`<text x="${stable(piece.x)}" y="${stable(y)}"${attrs} text-anchor="start" xml:space="preserve">${escapeText(value)}</text>`);
322
+ }
323
+ }
324
+ return `${group}${parts.join("")}</g>`;
325
+ }
326
+
327
+ /** A start tag from its original attribute source with `changes` applied (null removes, undefined keeps, a string sets or adds). */
328
+ function startTag(name, attributes, changes) {
329
+ let source = attributes;
330
+ const added = [];
331
+ for (const [key, value] of Object.entries(changes)) {
332
+ if (value === undefined) continue;
333
+ const pattern = new RegExp(`\\s${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=\\s*(?:"[^"]*"|'[^']*')`);
334
+ if (pattern.test(source)) source = source.replace(pattern, value === null ? "" : ` ${key}="${escapeAttr(value)}"`);
335
+ else if (value !== null) added.push(` ${key}="${escapeAttr(value)}"`);
336
+ }
337
+ return `<${name}${source}${added.join("")}>`;
338
+ }
339
+
340
+ // --- faces: the files resvg loads, matched as fontdb matches them ---
341
+
342
+ async function loadFaces(files) {
343
+ const faces = [];
344
+ for (const file of files) {
345
+ if (!faceCache.has(file)) faceCache.set(file, readFace(file).catch(() => null));
346
+ const face = await faceCache.get(file);
347
+ if (face) faces.push(face);
348
+ }
349
+ return faces;
350
+ }
351
+
352
+ async function readFace(file) {
353
+ const data = new Uint8Array(await readFile(file));
354
+ const font = create(data);
355
+ if (!font?.layout || !font.unitsPerEm) return null;
356
+ const records = font.name?.records ?? {};
357
+ const names = Object.values(records.preferredFamily ?? {}).length ? Object.values(records.preferredFamily) : Object.values(records.fontFamily ?? {});
358
+ const os2 = font["OS/2"], selection = os2?.fsSelection;
359
+ return {
360
+ file, font, families: new Set(names.filter(name => typeof name === "string")), outlines: new Map(),
361
+ weight: os2?.usWeightClass ?? 400, stretch: os2?.usWidthClass ?? 5,
362
+ style: selection?.italic || (!os2 && font.head?.macStyle?.italic) ? "italic" : selection?.oblique ? "oblique" : "normal"
363
+ };
364
+ }
365
+
366
+ /** resvg's face for the properties: the first family of the list that fontdb has, matched like CSS Fonts (stretch, style, weight). */
367
+ function selectFace(db, props, generics) {
368
+ const list = props.fontFamily === undefined ? [generics.default] : props.fontFamily.split(",").map(name => name.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
369
+ list.push("serif");
370
+ for (const name of list) {
371
+ const family = generics[name.toLowerCase()] ?? name;
372
+ const candidates = db.filter(face => face.families.has(family));
373
+ if (candidates.length) return bestMatch(candidates, props);
374
+ }
375
+ return null;
376
+ }
377
+
378
+ function bestMatch(candidates, props) {
379
+ let set = candidates;
380
+ const stretches = set.map(face => face.stretch);
381
+ if (!stretches.includes(props.fontStretch)) {
382
+ const below = stretches.filter(value => value < props.fontStretch), above = stretches.filter(value => value > props.fontStretch);
383
+ const chosen = props.fontStretch <= 5 ? (below.length ? Math.max(...below) : Math.min(...above)) : (above.length ? Math.min(...above) : Math.max(...below));
384
+ set = set.filter(face => face.stretch === chosen);
385
+ } else set = set.filter(face => face.stretch === props.fontStretch);
386
+ const order = props.fontStyle === "italic" ? ["italic", "oblique", "normal"] : props.fontStyle === "oblique" ? ["oblique", "italic", "normal"] : ["normal", "oblique", "italic"];
387
+ for (const style of order) { const styled = set.filter(face => face.style === style); if (styled.length) { set = styled; break; } }
388
+ const weights = set.map(face => face.weight), wanted = props.fontWeight;
389
+ const pick = weight => set.find(face => face.weight === weight);
390
+ if (weights.includes(wanted)) return pick(wanted);
391
+ const below = weights.filter(value => value < wanted), above = weights.filter(value => value > wanted);
392
+ const nearestBelow = below.length ? Math.max(...below) : undefined, nearestAbove = above.length ? Math.min(...above) : undefined;
393
+ if (wanted === 400 && weights.includes(500)) return pick(500);
394
+ if (wanted === 500 && weights.includes(400)) return pick(400);
395
+ if (wanted <= 500) return pick(nearestBelow ?? nearestAbove);
396
+ return pick(nearestAbove ?? nearestBelow);
397
+ }
398
+
399
+ const hasGlyph = (face, character) => face.font.hasGlyphForCodePoint(character.codePointAt(0));
400
+
401
+ /** The face resvg falls back to for a character the base face lacks: the first loaded face that has it and shares the base's style, weight or stretch. */
402
+ function fallbackFace(db, base, character) {
403
+ return db.find(face => face !== base && (face.style === base.style || face.weight === base.weight || face.stretch === base.stretch) && hasGlyph(face, character)) ?? null;
404
+ }
405
+
406
+ /** Maximal runs of `text` drawn with one face: the base face, or resvg's fallback for a character it lacks. */
407
+ function faceRuns(db, base, text) {
408
+ const runs = [];
409
+ for (const character of text) {
410
+ const face = IGNORABLE.test(character) || hasGlyph(base, character) ? base : fallbackFace(db, base, character) ?? base;
411
+ const previous = runs.at(-1);
412
+ if (previous && previous.face === face) previous.text += character; else runs.push({ face, text: character, start: previous ? previous.start + previous.text.length : 0 });
413
+ }
414
+ return runs;
415
+ }
416
+
417
+ /** fontkit's glyph run of `value` in `face`, shaped like the renderer measures (language, policy features), with the renderer's mark retry. */
418
+ function shapeRun(face, value, props) {
419
+ const language = openTypeLanguage(props.lang);
420
+ const off = disabledFeaturesFor([...face.families][0]), policyFeatures = off && Object.fromEntries(off.map(tag => [tag, false]));
421
+ const layout = extra => face.font.layout(value, policyFeatures || extra ? { ...policyFeatures, ...extra } : undefined, undefined, language);
422
+ try { return { run: layout(), retry: false }; }
423
+ catch { return { run: layout({ abvm: false, blwm: false, mark: false, mkmk: false }), retry: true }; }
424
+ }
425
+
426
+ /** Advance widths (px) of the prefixes of the runs' text ending at each offset of `ends`. */
427
+ function measurePrefixes(runs, ends, props) {
428
+ const width = (face, value) => {
429
+ if (!value) return 0;
430
+ // The Myanmar kinzi is a zero-advance mark in HarfBuzz; fontkit's universal shaper keeps the nga's advance, so it is not measured.
431
+ const measured = value.includes(KINZI) ? value.replaceAll(KINZI, "") : value;
432
+ return shapeRun(face, measured, props).run.positions.reduce((total, position) => total + position.xAdvance, 0) / face.font.unitsPerEm * props.fontSize;
433
+ };
434
+ const widths = [];
435
+ try {
436
+ let runIndex = 0, before = 0;
437
+ for (const end of ends) {
438
+ while (runIndex < runs.length && runs[runIndex].start + runs[runIndex].text.length <= end) { before += width(runs[runIndex].face, runs[runIndex].text); runIndex++; }
439
+ const run = runs[runIndex];
440
+ widths.push(run && end > run.start ? before + width(run.face, run.text.slice(0, end - run.start)) : before);
441
+ }
442
+ } catch { return null; }
443
+ return widths;
444
+ }
445
+
446
+ /** The glyph outlines of one cluster, or null when fontkit needed the mark retry (its glyph placement is then not the browser's). */
447
+ function shapeOutlines(face, value, props) {
448
+ let shaped;
449
+ try { shaped = shapeRun(face, value, props); } catch { return null; }
450
+ if (shaped.retry) return null;
451
+ const glyphs = [];
452
+ for (const [index, glyph] of shaped.run.glyphs.entries()) {
453
+ const position = shaped.run.positions[index];
454
+ let d = face.outlines.get(glyph.id);
455
+ if (d === undefined) { d = glyph.path.toSVG(); face.outlines.set(glyph.id, d); }
456
+ glyphs.push({ d, xOffset: position.xOffset, yOffset: position.yOffset, xAdvance: position.xAdvance });
457
+ }
458
+ return glyphs;
459
+ }
460
+
461
+ /** Underline and line-through geometry (px, from the baseline) for the face, as resvg draws them from the post and OS/2 tables. */
462
+ function decorationLines(face, decoration, scale) {
463
+ const lines = [];
464
+ const font = face.font, os2 = font["OS/2"];
465
+ if (decoration.includes("underline")) lines.push({ offset: -(font.underlinePosition ?? -100) * scale, thickness: Math.max(1, (font.underlineThickness ?? 50) * scale) });
466
+ if (decoration.includes("line-through") && os2) lines.push({ offset: -(os2.yStrikeoutPosition ?? 250) * scale, thickness: Math.max(1, (os2.yStrikeoutSize ?? 50) * scale) });
467
+ return lines;
468
+ }
package/dist/raster.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import {prepareRasterImages} from './raster-images.js';
2
2
  import {OPFRenderError,packageName} from './svg.js';
3
3
  import {separateLigatures} from './font-compatibility.js';
4
+ import {monochromeColorFonts,rasterFontFiles} from './color-fonts.js';
5
+ import {pinScriptClusters} from './raster-text.js';
4
6
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
5
7
  const DEFAULT_RASTER_SCALE = 1;
6
8
  const DEFAULT_RASTER_BACKGROUND = "#FFFFFF";
9
+ // RR-12: PDF output is vector (selectable text, vector drawing) unless a caller asks for the raster-backed compatibility mode.
10
+ const DEFAULT_PDF_MODE = "vector";
7
11
  let bundledFontFilesCache=null;
8
12
 
9
13
  export async function svgToPng(svg, options = {}) {
@@ -16,6 +20,11 @@ export async function svgToPdf(svgs, options = {}) {
16
20
  if (!pdfInputs.length) {
17
21
  throw new OPFRenderError("empty-pdf", "svgToPdf requires at least one SVG slide.");
18
22
  }
23
+ const mode = options.mode ?? DEFAULT_PDF_MODE;
24
+ if (mode !== "vector" && mode !== "raster") {
25
+ throw new OPFRenderError("invalid-conversion-option", 'mode must be "vector" or "raster".', { option: "mode", value: mode });
26
+ }
27
+ if (mode === "vector") return vectorPdf(pdfInputs, options);
19
28
 
20
29
  const { PDFDocument } = await loadPdfLib();
21
30
  const pdf = await PDFDocument.create({ updateMetadata: false });
@@ -39,31 +48,91 @@ export async function svgToPdf(svgs, options = {}) {
39
48
  return pdf.save({ addDefaultPage: false, useObjectStreams: false });
40
49
  }
41
50
 
51
+ async function vectorPdf(inputs, options) {
52
+ if (options.loadSystemFonts === true) {
53
+ throw new OPFRenderError("pdf-system-fonts-unsupported", 'Vector PDF output embeds only the font files you supply: loadSystemFonts is not supported with mode "vector". Pass fontFiles/fontDirs, or use mode "raster".', { option: "loadSystemFonts" });
54
+ }
55
+ const scale = positiveNumber(options.rasterFallbackScale, "rasterFallbackScale", 2);
56
+ const fontFiles = [
57
+ ...(options.useBundledFonts === false ? [] : await bundledFontFiles()),
58
+ ...stringArray(options.fontFiles)
59
+ ];
60
+ const svgs = [];
61
+ for (const input of inputs) svgs.push(await prepareRasterImages(normalizeSvgInput(input)));
62
+ const {svgsToVectorPdf} = await import("./pdf-vector.js");
63
+ return svgsToVectorPdf(svgs, {
64
+ fontFiles,
65
+ fontDirs: stringArray(options.fontDirs),
66
+ defaultFontFamily: options.defaultFontFamily ?? "Roboto",
67
+ sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
68
+ monospaceFamily: options.monospaceFamily ?? "Roboto Mono",
69
+ serifFamily: options.serifFamily,
70
+ background: options.background,
71
+ metadata: options.metadata,
72
+ tagged: options.tagged,
73
+ compress: options.compress,
74
+ strict: options.strict === true,
75
+ rasterFallbackScale: scale,
76
+ onDiagnostic: typeof options.onDiagnostic === "function" ? options.onDiagnostic : undefined,
77
+ signal: options.signal,
78
+ imageCodec: sharpImageCodec(),
79
+ onProgress: typeof options.onProgress === "function" ? options.onProgress : undefined,
80
+ producer: packageName,
81
+ ErrorClass: OPFRenderError,
82
+ rasterize: (svg, factor) => rasterizeSvg(svg, { ...options, scale: factor, background: "rgba(0, 0, 0, 0)" })
83
+ });
84
+ }
85
+
86
+ // The picture decoder of the Node vector export (the browser entry passes a canvas one). sharp loads on first use.
87
+ function sharpImageCodec() {
88
+ let loaded;
89
+ const sharp = () => (loaded ??= import("sharp").then((module) => module.default));
90
+ return {
91
+ metadata: async (bytes) => (await sharp())(bytes, { limitInputPixels: 40_000_000, animated: false }).metadata(),
92
+ rgba: async (bytes, { orient }) => {
93
+ const decoder = (await sharp())(bytes, orient ? { limitInputPixels: 40_000_000, animated: false } : undefined);
94
+ return (orient ? decoder.autoOrient().toColourspace("srgb") : decoder).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
95
+ },
96
+ };
97
+ }
98
+
42
99
  async function rasterizeSvg(svgInput, options) {
43
100
  const { Resvg } = await loadResvg();
44
- // FF-31: resvg ignores the SVG's ligature properties, so separate the letters a Gelasio ligature would join.
45
- const svg = separateLigatures(await prepareRasterImages(normalizeSvgInput(svgInput)));
46
101
  const scale = positiveNumber(options.scale, "scale", DEFAULT_RASTER_SCALE);
47
- const fontFiles = [
102
+ // FF-45: resvg draws no COLRv1 or OT-SVG colour glyphs; the colour faces stay out of its font list (see color-fonts.js).
103
+ const fontFiles = rasterFontFiles([
48
104
  ...(options.useBundledFonts === false ? [] : await bundledFontFiles()),
49
105
  ...stringArray(options.fontFiles)
50
- ];
106
+ ]);
107
+ const font = {
108
+ loadSystemFonts: options.loadSystemFonts === true,
109
+ fontFiles,
110
+ fontDirs: stringArray(options.fontDirs),
111
+ defaultFontFamily: options.defaultFontFamily ?? "Roboto",
112
+ sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
113
+ monospaceFamily: options.monospaceFamily ?? "Roboto Mono"
114
+ };
51
115
  const renderOptions = {
52
116
  fitTo: { mode: "zoom", value: scale },
53
117
  background: options.background ?? DEFAULT_RASTER_BACKGROUND,
54
- font: {
55
- loadSystemFonts: options.loadSystemFonts === true,
56
- fontFiles,
57
- fontDirs: stringArray(options.fontDirs),
58
- defaultFontFamily: options.defaultFontFamily ?? "Roboto",
59
- sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
60
- monospaceFamily: options.monospaceFamily ?? "Roboto Mono"
61
- },
118
+ font,
62
119
  logLevel: "off"
63
120
  };
64
121
 
65
122
  if (options.dpi !== undefined) renderOptions.dpi = positiveNumber(options.dpi, "dpi", 96);
66
123
 
124
+ // An SVG used as an image draws its text with these fonts: resvg gives the nested document none.
125
+ const nestedSvg = async text => {
126
+ const pinned = await pinScriptClusters(text, font);
127
+ const probe = new Resvg(pinned, { font, logLevel: "off" });
128
+ const zoom = Math.min(4, Math.max(1, 2048 / Math.max(probe.width, probe.height)));
129
+ return new Resvg(pinned, { ...renderOptions, fitTo: { mode: "zoom", value: zoom }, background: "rgba(0, 0, 0, 0)" }).render().asPng();
130
+ };
131
+ // FF-31: resvg ignores the SVG's ligature properties, so separate the letters a Gelasio ligature would join.
132
+ // FF-45: colour families (Noto Color Emoji) are drawn with their monochrome stand-in, which resvg can draw.
133
+ // FF-44: resvg loses the advance of a vowel sign or space inside a complex-script cluster, so pin each cluster (raster-text.js).
134
+ const svg = await pinScriptClusters(separateLigatures(monochromeColorFonts(await prepareRasterImages(normalizeSvgInput(svgInput), { nestedSvg }))), font);
135
+
67
136
  try {
68
137
  const image = new Resvg(svg, renderOptions).render();
69
138
  return {
@@ -8,6 +8,7 @@ import { scriptOfCharacter, scriptsOfText } from "./script-fonts.js";
8
8
  // Optional core exports are read from the namespace so an older published core still loads.
9
9
  import * as opfCore from "@openpresentation/opf";
10
10
  import { presentationFamilies } from "./lazy-fonts.js";
11
+ import { SYMBOL_SCRIPT, isSymbolEncodedFamily } from "./symbol-fonts.js";
11
12
 
12
13
  const SCRIPT_ALIASES = Object.freeze({Hira: "Jpan", Kana: "Jpan", Hrkt: "Jpan", Hang: "Kore", Hani: "Hans", Zyyy: "Latn"});
13
14
 
@@ -107,10 +108,14 @@ const cjkCharacters = new Set(["Hani", "Hira", "Kana", "Hang", "Bopo"]);
107
108
  /** True when the renderer has per-character glyph fallback (FF-19 glyphFallbackFamilies). */
108
109
  const hasGlyphFallback = () => typeof scriptFontModule.glyphFallbackFamilies === "function";
109
110
 
110
- /** ISO 15924 script whose pinned face a family resolves to: a proprietary script font (Yu Gothic: Jpan) or a pinned face itself (Noto Sans JP: Jpan). */
111
+ /**
112
+ * ISO 15924 script whose pinned face a family resolves to: a proprietary script font (Yu Gothic: Jpan), a pinned face itself
113
+ * (Noto Sans JP: Jpan), or a symbol-encoded family (Wingdings: Zsym, whose codes draw with the pinned symbol faces, FF-45).
114
+ */
111
115
  function scriptOfFamily(family) {
112
116
  const name = String(family ?? "").toLowerCase();
113
117
  if (!name) return undefined;
118
+ if (isSymbolEncodedFamily(name)) return SYMBOL_SCRIPT;
114
119
  const rule = scriptFontModule.SCRIPT_FONT_REPLACEMENTS.find(item => item.requestedFamily.toLowerCase() === name);
115
120
  if (rule) return rule.script;
116
121
  return BUNDLED_FONT_MANIFEST.packages.find(item => item.pack === "scripts" && item.faces.some(face => face.family.toLowerCase() === name))?.scripts[0];
@@ -148,9 +153,22 @@ function designScripts(presentation, profile, renderOptions) {
148
153
  if (renderOptions?.catalogs) {
149
154
  for (const family of presentationFamilies(presentation, renderOptions)) note({ heading: { latin: family }, body: { latin: family } });
150
155
  }
156
+ // FF-45: a run or design that names a symbol-encoded family (a Wingdings bullet run, a Symbol heading) needs the symbol faces.
157
+ for (const family of namedFontFamilies(presentation)) if (isSymbolEncodedFamily(family)) scripts.add(SYMBOL_SCRIPT);
151
158
  return { scripts: [...scripts].sort(), han };
152
159
  }
153
160
 
161
+ /** Every `fontFamily` string and `design.fonts` role family named anywhere in the document (runs, table cells, slide designs). */
162
+ function* namedFontFamilies(value, key) {
163
+ if (typeof value === "string") { if (key === "fontFamily") yield value; return; }
164
+ if (Array.isArray(value)) { for (const child of value) yield* namedFontFamilies(child, key); return; }
165
+ if (!value || typeof value !== "object") return;
166
+ for (const [childKey, child] of Object.entries(value)) {
167
+ if (childKey === "fonts" && child && typeof child === "object" && !Array.isArray(child)) { for (const family of Object.values(child)) if (typeof family === "string") yield family; continue; }
168
+ yield* namedFontFamilies(child, childKey);
169
+ }
170
+ }
171
+
154
172
  /**
155
173
  * What a preview of the presentation draws: its script profile, the scripts of its text (the renderer's own
156
174
  * itemization, so language-dependent punctuation counts), the scripts its font schemes need (`design`, for example