@openpresentation/opf-render 0.11.9 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -12
- package/dist/annotations.js +45 -0
- package/dist/chart-options.js +177 -0
- package/dist/charts.js +194 -36
- package/dist/color-fonts.js +32 -0
- package/dist/deck-runtime.js +343 -0
- package/dist/element-define.d.ts +1 -0
- package/dist/element-define.js +5 -0
- package/dist/element.d.ts +86 -0
- package/dist/element.js +521 -0
- package/dist/export-browser.d.ts +26 -0
- package/dist/export-browser.js +228 -0
- package/dist/font-manifest.js +158 -0
- package/dist/font-policy.js +232 -16
- package/dist/fonts.d.ts +30 -0
- package/dist/fonts.js +48 -22
- package/dist/index.d.ts +100 -2
- package/dist/pdf-fonts.js +333 -0
- package/dist/pdf-style.js +429 -0
- package/dist/pdf-subset.js +176 -0
- package/dist/pdf-text.js +338 -0
- package/dist/pdf-vector.js +1249 -0
- package/dist/pdf-writer.js +154 -0
- package/dist/pdf-xml.js +78 -0
- package/dist/player.d.ts +79 -0
- package/dist/player.js +706 -0
- package/dist/preview-fonts-cli.js +15 -0
- package/dist/preview-fonts-node.d.ts +14 -0
- package/dist/preview-fonts-node.js +86 -0
- package/dist/preview-fonts.d.ts +23 -0
- package/dist/preview-fonts.js +78 -0
- package/dist/raster-images.js +19 -6
- package/dist/raster-text.js +468 -0
- package/dist/raster.js +81 -12
- package/dist/script-font-pack.js +19 -1
- package/dist/script-fonts.js +214 -23
- package/dist/sha256.js +47 -0
- package/dist/svg.d.ts +9 -1
- package/dist/svg.js +360 -53
- package/dist/symbol-encodings.js +7 -0
- package/dist/symbol-fonts.js +96 -0
- package/package.json +90 -8
package/dist/pdf-text.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import bidiFactory from "bidi-js";
|
|
2
|
+
import { hasGlyph, shape } from "./pdf-fonts.js";
|
|
3
|
+
import { openTypeLanguage } from "./script-fonts.js";
|
|
4
|
+
import { isElement } from "./pdf-xml.js";
|
|
5
|
+
import { attributesOf, inheritStyle, parseFontFamilies, parseFontWeight, parseLength } from "./pdf-style.js";
|
|
6
|
+
|
|
7
|
+
// SVG text layout for the vector PDF export (RR-12): the renderer has already decided every line, position and
|
|
8
|
+
// width (x, y, text-anchor, textLength), so this module only turns that into positioned glyph runs: text chunks,
|
|
9
|
+
// the Unicode bidirectional algorithm (the SVG carries directional isolates around right-to-left paragraphs),
|
|
10
|
+
// per-character font fallback, OpenType shaping with fontkit and textLength adjustment. It never wraps, shrinks
|
|
11
|
+
// or truncates text.
|
|
12
|
+
|
|
13
|
+
const bidi = bidiFactory();
|
|
14
|
+
const IGNORED = /[--]/u;
|
|
15
|
+
|
|
16
|
+
/** Parse `font-feature-settings` / `font-variant-ligatures` of a style into an object of OpenType feature booleans. */
|
|
17
|
+
export function featuresOf(style) {
|
|
18
|
+
const features = {};
|
|
19
|
+
if (/^none$/i.test(style["font-variant-ligatures"] ?? "")) { features.liga = false; features.clig = false; }
|
|
20
|
+
const settings = style["font-feature-settings"];
|
|
21
|
+
if (settings && !/^normal$/i.test(settings)) {
|
|
22
|
+
for (const match of settings.matchAll(/["']([A-Za-z0-9 ]{4})["']\s*(on|off|\d+)?/g)) {
|
|
23
|
+
const value = match[2];
|
|
24
|
+
features[match[1]] = value === undefined || value === "on" ? true : value === "off" ? false : Number(value) !== 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return Object.keys(features).length ? features : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Lay out one <text> element. `env` provides `fonts` (FontLibrary), `defaults` (the family list used when a requested
|
|
32
|
+
* family has no face) and `diagnostic(d)`; `style` is the inherited style at the element; `link` the enclosing <a>.
|
|
33
|
+
* Returns {runs, chunks}: runs are in drawing (visual) order, each with absolute user-space positions.
|
|
34
|
+
*/
|
|
35
|
+
export function layoutText(textNode, env, parentStyle, link) {
|
|
36
|
+
const chars = [];
|
|
37
|
+
const scopes = [];
|
|
38
|
+
const pending = [];
|
|
39
|
+
const rootAttrs = attributesOf(textNode);
|
|
40
|
+
const rootStyle = inheritStyle(parentStyle, rootAttrs);
|
|
41
|
+
|
|
42
|
+
const walk = (node, style, scope, decoration, activeLink) => {
|
|
43
|
+
const attrs = node === textNode ? rootAttrs : attributesOf(node);
|
|
44
|
+
const own = node === textNode ? rootStyle : inheritStyle(style, attrs);
|
|
45
|
+
const fontSize = parseFloat(own["font-size"]) || 16;
|
|
46
|
+
const startIndex = chars.length;
|
|
47
|
+
for (const key of ["x", "y", "dx", "dy"]) {
|
|
48
|
+
if (attrs[key] === undefined) continue;
|
|
49
|
+
const values = String(attrs[key]).trim().split(/[\s,]+/).filter(Boolean).map((item) => parseLength(item, { fontSize }));
|
|
50
|
+
values.forEach((value, offset) => { if (value !== undefined) pending.push({ index: startIndex + offset, key, value }); });
|
|
51
|
+
}
|
|
52
|
+
let nextScope = scope;
|
|
53
|
+
if (attrs.textLength !== undefined && parseLength(attrs.textLength, { fontSize }) > 0) {
|
|
54
|
+
nextScope = { id: scopes.length + 1, length: parseLength(attrs.textLength, { fontSize }), adjust: attrs.lengthAdjust === "spacingAndGlyphs" ? "glyphs" : "spacing" };
|
|
55
|
+
scopes.push(nextScope);
|
|
56
|
+
}
|
|
57
|
+
let nextDecoration = decoration;
|
|
58
|
+
if (attrs["text-decoration"] && attrs["text-decoration"] !== "none") nextDecoration = new Set([...decoration, ...String(attrs["text-decoration"]).split(/\s+/).filter((item) => /^(underline|line-through|overline)$/.test(item))]);
|
|
59
|
+
const preserve = own["xml:space"] === "preserve" || /^pre/.test(own["white-space"] ?? "");
|
|
60
|
+
for (const child of node.children) {
|
|
61
|
+
if (child.text !== undefined) {
|
|
62
|
+
for (const character of child.text.replace(/[\r\n\t]/g, " ")) chars.push({ ch: character, style: own, scope: nextScope, decoration: nextDecoration, link: activeLink, preserve });
|
|
63
|
+
} else if (isElement(child)) {
|
|
64
|
+
if (child.name === "tspan" || child.name === "textPath" || child.name === "tref") walk(child, own, nextScope, nextDecoration, activeLink);
|
|
65
|
+
else if (child.name === "a") {
|
|
66
|
+
const href = attributesOf(child).href ?? attributesOf(child)["xlink:href"];
|
|
67
|
+
walk(child, own, nextScope, nextDecoration, href === undefined ? activeLink : { href, node: child });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
walk(textNode, parentStyle, null, new Set(), link);
|
|
73
|
+
for (const item of pending) if (chars[item.index]) chars[item.index][item.key] = item.value;
|
|
74
|
+
collapseWhitespace(chars);
|
|
75
|
+
if (!chars.length) return { runs: [] };
|
|
76
|
+
|
|
77
|
+
// Text chunks start at every character with an absolute position.
|
|
78
|
+
const chunks = [];
|
|
79
|
+
let currentX = 0, currentY = 0;
|
|
80
|
+
for (let index = 0; index < chars.length; index++) {
|
|
81
|
+
const char = chars[index];
|
|
82
|
+
if (index === 0 || char.x !== undefined || char.y !== undefined) {
|
|
83
|
+
chunks.push({ chars: [], x: char.x ?? currentX, y: char.y ?? currentY });
|
|
84
|
+
currentX = chunks.at(-1).x; currentY = chunks.at(-1).y;
|
|
85
|
+
}
|
|
86
|
+
chunks.at(-1).chars.push(char);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const styleIds = new Map();
|
|
90
|
+
const faceCache = new Map();
|
|
91
|
+
const linkIds = new Map();
|
|
92
|
+
const runsByChunk = chunks.map((chunk) => buildRuns(chunk, env, styleIds, faceCache, linkIds));
|
|
93
|
+
|
|
94
|
+
// textLength: the renderer gives lines and tabs an exact width; reproduce it per scope over all its runs.
|
|
95
|
+
const scopeWidth = new Map();
|
|
96
|
+
for (const runs of runsByChunk) for (const run of runs) if (run.scope) scopeWidth.set(run.scope, (scopeWidth.get(run.scope) ?? 0) + run.natural);
|
|
97
|
+
for (const runs of runsByChunk) {
|
|
98
|
+
for (const run of runs) {
|
|
99
|
+
run.width = run.natural;
|
|
100
|
+
run.scaleX = 1;
|
|
101
|
+
if (!run.scope) continue;
|
|
102
|
+
const total = scopeWidth.get(run.scope);
|
|
103
|
+
if (!(total > 0)) continue;
|
|
104
|
+
if (run.scope.adjust === "glyphs") {
|
|
105
|
+
run.scaleX = run.scope.length / total;
|
|
106
|
+
run.width = run.natural * run.scaleX;
|
|
107
|
+
} else {
|
|
108
|
+
const glyphs = chunkGlyphCount(runsByChunk, run.scope);
|
|
109
|
+
if (glyphs > 1) {
|
|
110
|
+
const extra = (run.scope.length - total) / (glyphs - 1);
|
|
111
|
+
run.extraSpacing = extra;
|
|
112
|
+
run.width = run.natural + extra * run.glyphs.length;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Anchor and place each chunk.
|
|
119
|
+
const placed = [];
|
|
120
|
+
chunks.forEach((chunk, chunkIndex) => {
|
|
121
|
+
const runs = runsByChunk[chunkIndex];
|
|
122
|
+
const total = runs.reduce((sum, run) => sum + run.width, 0);
|
|
123
|
+
const anchor = chunk.chars[0].style["text-anchor"];
|
|
124
|
+
let x = chunk.x + (anchor === "middle" ? -total / 2 : anchor === "end" ? -total : 0);
|
|
125
|
+
for (const run of reorder(runs)) {
|
|
126
|
+
run.x = x + (run.dx ?? 0);
|
|
127
|
+
run.y = chunk.y + (run.dy ?? 0);
|
|
128
|
+
x += run.width;
|
|
129
|
+
placed.push(run);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return { runs: placed };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function chunkGlyphCount(runsByChunk, scope) {
|
|
136
|
+
let count = 0;
|
|
137
|
+
for (const runs of runsByChunk) for (const run of runs) if (run.scope === scope) count += run.glyphs.length;
|
|
138
|
+
return count;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function collapseWhitespace(chars) {
|
|
142
|
+
let previousSpace = true;
|
|
143
|
+
const kept = [];
|
|
144
|
+
for (const char of chars) {
|
|
145
|
+
if (char.preserve) { kept.push(char); previousSpace = false; continue; }
|
|
146
|
+
if (char.ch === " ") {
|
|
147
|
+
if (previousSpace) continue;
|
|
148
|
+
previousSpace = true;
|
|
149
|
+
} else previousSpace = false;
|
|
150
|
+
kept.push(char);
|
|
151
|
+
}
|
|
152
|
+
while (kept.length && !kept.at(-1).preserve && kept.at(-1).ch === " ") kept.pop();
|
|
153
|
+
// Not chars.push(...kept): a spread of a very long text overflows the call stack.
|
|
154
|
+
chars.length = 0;
|
|
155
|
+
for (const char of kept) chars.push(char);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildRuns(chunk, env, styleIds, faceCache, linkIds) {
|
|
159
|
+
const text = chunk.chars.map((char) => char.ch).join("");
|
|
160
|
+
const baseDirection = chunk.chars[0].style.direction === "rtl" ? "rtl" : "ltr";
|
|
161
|
+
// Levels per UTF-16 unit; characters take the level of their first unit.
|
|
162
|
+
const embedding = bidi.getEmbeddingLevels(text, baseDirection);
|
|
163
|
+
const levels = [];
|
|
164
|
+
let unit = 0;
|
|
165
|
+
for (const char of chunk.chars) { levels.push(embedding.levels[unit]); unit += char.ch.length; }
|
|
166
|
+
const mirrored = bidi.getMirroredCharactersMap(text, embedding.levels);
|
|
167
|
+
const runs = [];
|
|
168
|
+
const chunkInfo = { logical: chunk.chars.filter((char) => !IGNORED.test(char.ch)).map((char) => char.ch).join("") };
|
|
169
|
+
let current = null;
|
|
170
|
+
unit = 0;
|
|
171
|
+
chunk.chars.forEach((char, index) => {
|
|
172
|
+
const start = unit;
|
|
173
|
+
unit += char.ch.length;
|
|
174
|
+
if (IGNORED.test(char.ch)) return;
|
|
175
|
+
const level = levels[index];
|
|
176
|
+
const resolved = pickFace(char, env, styleIds, faceCache);
|
|
177
|
+
const key = `${resolved.styleId}|${resolved.face.id}|${level}|${char.scope?.id ?? 0}|${linkId(linkIds, char.link)}|${[...char.decoration].sort().join(",")}`;
|
|
178
|
+
if (!current || current.key !== key) {
|
|
179
|
+
current = { chunk: chunkInfo, key, face: resolved.face, style: char.style, level, scope: char.scope, link: char.link, decoration: char.decoration, chars: [], text: "", logical: "", weight: resolved.weight, italic: resolved.italic };
|
|
180
|
+
runs.push(current);
|
|
181
|
+
}
|
|
182
|
+
current.logical += char.ch;
|
|
183
|
+
current.text += level % 2 === 1 && mirrored.has(start) ? mirrored.get(start) : char.ch;
|
|
184
|
+
current.chars.push(char);
|
|
185
|
+
});
|
|
186
|
+
for (const run of runs) shapeRun(run, env);
|
|
187
|
+
return runs;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A number per link within one layout call, so runs of different links never merge; nothing is written on the nodes.
|
|
191
|
+
function linkId(linkIds, link) {
|
|
192
|
+
if (!link) return 0;
|
|
193
|
+
let id = linkIds.get(link.node);
|
|
194
|
+
if (id === undefined) { id = linkIds.size + 1; linkIds.set(link.node, id); }
|
|
195
|
+
return id;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function pickFace(char, env, styleIds, faceCache) {
|
|
199
|
+
const style = char.style;
|
|
200
|
+
let styleId = styleIds.get(style);
|
|
201
|
+
if (styleId === undefined) { styleId = styleIds.size + 1; styleIds.set(style, styleId); }
|
|
202
|
+
let base = faceCache.get(style);
|
|
203
|
+
if (!base) {
|
|
204
|
+
const weight = parseFontWeight(style["font-weight"], 400);
|
|
205
|
+
const italic = /italic|oblique/i.test(style["font-style"] ?? "");
|
|
206
|
+
const families = parseFontFamilies(style["font-family"]);
|
|
207
|
+
let match = env.fonts.match(families, weight, italic);
|
|
208
|
+
if (!match) match = env.fonts.match(env.defaults, weight, italic);
|
|
209
|
+
if (!match) match = { face: env.fonts.usable()[0] && bestAny(env.fonts, weight, italic), requested: families[0] };
|
|
210
|
+
if (!match.face) throw env.unavailable(families);
|
|
211
|
+
const requestedFirst = families[0];
|
|
212
|
+
if (requestedFirst && !match.face.names.has(requestedFirst.toLowerCase()) && !/^(serif|sans-serif|monospace|cursive|fantasy|system-ui)$/i.test(requestedFirst)) {
|
|
213
|
+
env.diagnostic({ code: "pdf-font-substituted", message: `Font '${requestedFirst}' has no face in the PDF font set; '${[...match.face.names][0]}' is embedded instead.`, requestedFamily: requestedFirst, resolvedFamily: [...match.face.names][0], weight, italic });
|
|
214
|
+
}
|
|
215
|
+
base = { face: match.face, weight, italic };
|
|
216
|
+
faceCache.set(style, base);
|
|
217
|
+
}
|
|
218
|
+
const codePoint = char.ch.codePointAt(0);
|
|
219
|
+
let face = base.face;
|
|
220
|
+
if (!hasGlyph(face, codePoint) && !/\p{Default_Ignorable_Code_Point}|\s/u.test(char.ch)) {
|
|
221
|
+
const fallback = env.fonts.fallbackFor(codePoint, base.weight, base.italic, face);
|
|
222
|
+
if (fallback) {
|
|
223
|
+
face = fallback;
|
|
224
|
+
env.diagnostic({ code: "pdf-font-fallback", message: `'${[...base.face.names][0]}' has no glyph for U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}; '${[...face.names][0]}' draws it.`, fontFamily: [...base.face.names][0], fallbackFamily: [...face.names][0], codePoint });
|
|
225
|
+
} else if (!/[\p{Cc}\p{Cf}\p{Zs}]/u.test(char.ch)) {
|
|
226
|
+
// No supplied face has the character: the font's .notdef box is drawn, which is never what was written.
|
|
227
|
+
env.missing({ code: "pdf-glyph-missing", message: `No supplied font has a glyph for U+${codePoint.toString(16).toUpperCase().padStart(4, "0")} (${JSON.stringify(char.ch)}); the font's missing-glyph box is drawn instead.`, fontFamily: [...base.face.names][0], codePoint });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { face, styleId, weight: base.weight, italic: base.italic };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function bestAny(fonts, weight, italic) {
|
|
234
|
+
const [first] = fonts.usable();
|
|
235
|
+
const same = fonts.usable().filter((face) => face.italic === italic);
|
|
236
|
+
return (same.length ? same : [first]).sort((a, b) => Math.abs(a.weight - weight) - Math.abs(b.weight - weight) || a.id - b.id)[0];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function shapeRun(run, env) {
|
|
240
|
+
const style = run.style;
|
|
241
|
+
const size = parseFloat(style["font-size"]) || 16;
|
|
242
|
+
const direction = run.level % 2 === 1 ? "rtl" : "ltr";
|
|
243
|
+
const language = openTypeLanguage(style.lang ?? style["xml:lang"]);
|
|
244
|
+
const glyphs = shape(run.face, run.text, { features: featuresOf(style), language, direction });
|
|
245
|
+
const scale = size / run.face.upem;
|
|
246
|
+
const letterSpacing = parseLength(style["letter-spacing"], { fontSize: size }) ?? 0;
|
|
247
|
+
const wordSpacing = parseLength(style["word-spacing"], { fontSize: size }) ?? 0;
|
|
248
|
+
run.size = size;
|
|
249
|
+
run.glyphs = glyphs;
|
|
250
|
+
run.natural = glyphs.reduce((sum, glyph, index) => {
|
|
251
|
+
glyph.spacing = letterSpacing + (glyph.codePoints.length === 1 && glyph.codePoints[0] === 0x20 ? wordSpacing : 0);
|
|
252
|
+
glyph.nominalIndex = index;
|
|
253
|
+
return sum + glyph.advance * scale + glyph.spacing;
|
|
254
|
+
}, 0);
|
|
255
|
+
run.scale = scale;
|
|
256
|
+
run.dx = 0;
|
|
257
|
+
run.dy = 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Reorder a chunk's runs into visual (left to right) order: rule L2 applied at run granularity. */
|
|
261
|
+
function reorder(runs) {
|
|
262
|
+
const order = [...runs];
|
|
263
|
+
if (!order.some((run) => run.level > 0)) return order;
|
|
264
|
+
const max = Math.max(...order.map((run) => run.level));
|
|
265
|
+
const minOdd = Math.min(...order.filter((run) => run.level % 2 === 1).map((run) => run.level));
|
|
266
|
+
for (let level = max; level >= minOdd; level--) {
|
|
267
|
+
for (let index = 0; index < order.length;) {
|
|
268
|
+
if (order[index].level < level) { index++; continue; }
|
|
269
|
+
let end = index;
|
|
270
|
+
while (end + 1 < order.length && order[end + 1].level >= level) end++;
|
|
271
|
+
for (let low = index, high = end; low < high; low++, high--) [order[low], order[high]] = [order[high], order[low]];
|
|
272
|
+
index = end + 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return order;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The logical text of a glyph when the glyph-to-Unicode map cannot give it in a right-to-left run (read back to front by
|
|
282
|
+
* extractors): a ligature of several characters, or a mirrored bracket whose glyph is the other bracket. Otherwise null.
|
|
283
|
+
*/
|
|
284
|
+
export function actualTextFor(run, glyph) {
|
|
285
|
+
if (run.level % 2 !== 1) return null;
|
|
286
|
+
const text = String.fromCodePoint(...glyph.codePoints);
|
|
287
|
+
if (glyph.codePoints.length > 1) return text;
|
|
288
|
+
return bidi.getMirroredCharacter(text) ?? null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const NON_ASCII_SPACE = new RegExp("[" + [" ", " ", " - ", " ", " ", " "].join("") + "]");
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Split a left-to-right run into clusters: the shortest glyph groups whose characters are exactly the next characters of
|
|
295
|
+
* the logical text (a reordered Indic syllable is one cluster). Each cluster gets `actual`, its logical text, when its
|
|
296
|
+
* glyphs do not give that text in order or it holds a space the glyph map copies as a plain space. Null when the glyphs
|
|
297
|
+
* and the text do not line up.
|
|
298
|
+
*/
|
|
299
|
+
export function textClusters(run) {
|
|
300
|
+
// Fonts decompose some characters (a Bengali two-part vowel becomes its parts) and compose others, so try the text as
|
|
301
|
+
// written first and its canonical decomposition second.
|
|
302
|
+
return alignClusters(run, run.logical) ?? alignClusters(run, run.logical.normalize("NFD"));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function alignClusters(run, text) {
|
|
306
|
+
const logical = [...text].map((character) => character.codePointAt(0));
|
|
307
|
+
const balance = new Map();
|
|
308
|
+
let open = 0;
|
|
309
|
+
const add = (point, delta) => {
|
|
310
|
+
const before = balance.get(point) ?? 0, after = before + delta;
|
|
311
|
+
balance.set(point, after);
|
|
312
|
+
if (before === 0 && after !== 0) open++;
|
|
313
|
+
else if (before !== 0 && after === 0) open--;
|
|
314
|
+
};
|
|
315
|
+
const clusters = [];
|
|
316
|
+
let group = [], taken = [], consumed = 0, start = 0;
|
|
317
|
+
for (const glyph of run.glyphs) {
|
|
318
|
+
group.push(glyph);
|
|
319
|
+
for (const point of glyph.codePoints) {
|
|
320
|
+
if (consumed >= logical.length) return null;
|
|
321
|
+
add(point, 1);
|
|
322
|
+
add(logical[consumed++], -1);
|
|
323
|
+
taken.push(point);
|
|
324
|
+
}
|
|
325
|
+
if (open === 0 && taken.length) {
|
|
326
|
+
const slice = logical.slice(start, consumed);
|
|
327
|
+
const exact = slice.every((point, index) => point === taken[index]) && !slice.some((point) => NON_ASCII_SPACE.test(String.fromCodePoint(point)));
|
|
328
|
+
clusters.push({ glyphs: group, actual: exact ? null : String.fromCodePoint(...slice).normalize("NFC") });
|
|
329
|
+
group = []; taken = []; start = consumed;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (group.length || consumed !== logical.length) {
|
|
333
|
+
// Trailing glyphs without characters join the last cluster; anything else means no alignment.
|
|
334
|
+
if (!group.length || taken.length || !clusters.length) return null;
|
|
335
|
+
clusters.at(-1).glyphs.push(...group);
|
|
336
|
+
}
|
|
337
|
+
return clusters;
|
|
338
|
+
}
|