three-text 0.5.2 → 0.6.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/LICENSE_THIRD_PARTY +15 -0
- package/README.md +73 -42
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/index.min.cjs +1 -1
- package/dist/index.min.js +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/index.umd.min.js +1 -1
- package/dist/three/react.d.ts +2 -0
- package/dist/types/core/types.d.ts +2 -33
- package/dist/types/vector/{core.d.ts → core/index.d.ts} +8 -7
- package/dist/types/vector/index.d.ts +22 -25
- package/dist/types/vector/react.d.ts +4 -3
- package/dist/types/vector/slug/SlugPacker.d.ts +2 -0
- package/dist/types/vector/slug/curveUtils.d.ts +6 -0
- package/dist/types/vector/slug/index.d.ts +8 -0
- package/dist/types/vector/slug/shaderStrings.d.ts +4 -0
- package/dist/types/vector/slug/slugGLSL.d.ts +21 -0
- package/dist/types/vector/slug/slugTSL.d.ts +13 -0
- package/dist/types/vector/slug/types.d.ts +30 -0
- package/dist/types/vector/slug/unpackVertices.d.ts +11 -0
- package/dist/types/vector/webgl/index.d.ts +7 -3
- package/dist/types/vector/webgpu/index.d.ts +4 -4
- package/dist/vector/core/index.cjs +856 -0
- package/dist/vector/core/index.d.ts +63 -0
- package/dist/vector/core/index.js +854 -0
- package/dist/vector/core.cjs +4419 -240
- package/dist/vector/core.d.ts +361 -71
- package/dist/vector/core.js +4406 -226
- package/dist/vector/index.cjs +5 -229
- package/dist/vector/index.d.ts +45 -396
- package/dist/vector/index.js +3 -223
- package/dist/vector/index2.cjs +287 -0
- package/dist/vector/index2.js +264 -0
- package/dist/vector/react.cjs +37 -8
- package/dist/vector/react.d.ts +6 -3
- package/dist/vector/react.js +18 -8
- package/dist/vector/slugTSL.cjs +252 -0
- package/dist/vector/slugTSL.js +231 -0
- package/dist/vector/webgl/index.cjs +131 -201
- package/dist/vector/webgl/index.d.ts +19 -44
- package/dist/vector/webgl/index.js +131 -201
- package/dist/vector/webgpu/index.cjs +100 -283
- package/dist/vector/webgpu/index.d.ts +16 -45
- package/dist/vector/webgpu/index.js +100 -283
- package/package.json +6 -1
- package/dist/types/vector/GlyphVectorGeometryBuilder.d.ts +0 -26
- package/dist/types/vector/LoopBlinnGeometry.d.ts +0 -68
- package/dist/types/vector/loopBlinnTSL.d.ts +0 -22
- package/dist/vector/loopBlinnTSL.cjs +0 -229
- package/dist/vector/loopBlinnTSL.js +0 -207
package/dist/vector/core.cjs
CHANGED
|
@@ -1,9 +1,2250 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
4
|
-
|
|
5
|
-
var
|
|
6
|
-
|
|
3
|
+
var require$$0 = require('fs');
|
|
4
|
+
|
|
5
|
+
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
6
|
+
// Cached flag check at module load time for zero-cost logging
|
|
7
|
+
const isLogEnabled = (() => {
|
|
8
|
+
if (typeof window !== 'undefined' && window.THREE_TEXT_LOG) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if (typeof globalThis !== 'undefined' &&
|
|
12
|
+
globalThis.process?.env?.THREE_TEXT_LOG === 'true') {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
})();
|
|
17
|
+
class Logger {
|
|
18
|
+
warn(message, ...args) {
|
|
19
|
+
console.warn(message, ...args);
|
|
20
|
+
}
|
|
21
|
+
error(message, ...args) {
|
|
22
|
+
console.error(message, ...args);
|
|
23
|
+
}
|
|
24
|
+
log(message, ...args) {
|
|
25
|
+
isLogEnabled && console.log(message, ...args);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const logger = new Logger();
|
|
29
|
+
|
|
30
|
+
class PerformanceLogger {
|
|
31
|
+
constructor() {
|
|
32
|
+
this.metrics = [];
|
|
33
|
+
this.activeTimers = new Map();
|
|
34
|
+
}
|
|
35
|
+
start(name, metadata) {
|
|
36
|
+
// Early exit if disabled - no metric collection
|
|
37
|
+
if (!isLogEnabled)
|
|
38
|
+
return;
|
|
39
|
+
const startTime = performance.now();
|
|
40
|
+
// Generate unique key for nested timing support
|
|
41
|
+
const timerKey = `${name}_${startTime}`;
|
|
42
|
+
this.activeTimers.set(timerKey, startTime);
|
|
43
|
+
this.metrics.push({
|
|
44
|
+
name,
|
|
45
|
+
startTime,
|
|
46
|
+
metadata
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
end(name) {
|
|
50
|
+
// Early exit if disabled
|
|
51
|
+
if (!isLogEnabled)
|
|
52
|
+
return null;
|
|
53
|
+
const endTime = performance.now();
|
|
54
|
+
// Find the most recent matching timer by scanning backwards
|
|
55
|
+
let timerKey;
|
|
56
|
+
let startTime;
|
|
57
|
+
for (const [key, time] of Array.from(this.activeTimers.entries()).reverse()) {
|
|
58
|
+
if (key.startsWith(`${name}_`)) {
|
|
59
|
+
timerKey = key;
|
|
60
|
+
startTime = time;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (startTime === undefined || !timerKey) {
|
|
65
|
+
logger.warn(`Performance timer "${name}" was not started`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const duration = endTime - startTime;
|
|
69
|
+
this.activeTimers.delete(timerKey);
|
|
70
|
+
// Find the metric in reverse order (most recent first)
|
|
71
|
+
for (let i = this.metrics.length - 1; i >= 0; i--) {
|
|
72
|
+
const metric = this.metrics[i];
|
|
73
|
+
if (metric.name === name &&
|
|
74
|
+
metric.startTime === startTime &&
|
|
75
|
+
!metric.endTime) {
|
|
76
|
+
metric.endTime = endTime;
|
|
77
|
+
metric.duration = duration;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
console.log(`${name}: ${duration.toFixed(2)}ms`);
|
|
82
|
+
return duration;
|
|
83
|
+
}
|
|
84
|
+
getSummary() {
|
|
85
|
+
if (!isLogEnabled)
|
|
86
|
+
return {};
|
|
87
|
+
const summary = {};
|
|
88
|
+
for (const metric of this.metrics) {
|
|
89
|
+
if (!metric.duration)
|
|
90
|
+
continue;
|
|
91
|
+
const existing = summary[metric.name];
|
|
92
|
+
if (existing) {
|
|
93
|
+
existing.count++;
|
|
94
|
+
existing.totalDuration += metric.duration;
|
|
95
|
+
existing.avgDuration = existing.totalDuration / existing.count;
|
|
96
|
+
existing.lastDuration = metric.duration;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
summary[metric.name] = {
|
|
100
|
+
count: 1,
|
|
101
|
+
avgDuration: metric.duration,
|
|
102
|
+
totalDuration: metric.duration,
|
|
103
|
+
lastDuration: metric.duration
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return summary;
|
|
108
|
+
}
|
|
109
|
+
printSummary() {
|
|
110
|
+
if (!isLogEnabled)
|
|
111
|
+
return;
|
|
112
|
+
const summary = this.getSummary();
|
|
113
|
+
console.table(summary);
|
|
114
|
+
console.log('Operations:', Object.keys(summary).sort().join(', '));
|
|
115
|
+
}
|
|
116
|
+
printBaseline() {
|
|
117
|
+
if (!isLogEnabled)
|
|
118
|
+
return;
|
|
119
|
+
const summary = this.getSummary();
|
|
120
|
+
Object.entries(summary).forEach(([name, stats]) => {
|
|
121
|
+
console.log(`BASELINE ${name}: ${stats.avgDuration.toFixed(2)}ms avg (${stats.count} calls)`);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
clear() {
|
|
125
|
+
if (!isLogEnabled)
|
|
126
|
+
return;
|
|
127
|
+
this.metrics.length = 0;
|
|
128
|
+
this.activeTimers.clear();
|
|
129
|
+
}
|
|
130
|
+
time(name, fn, metadata) {
|
|
131
|
+
if (!isLogEnabled)
|
|
132
|
+
return fn();
|
|
133
|
+
this.start(name, metadata);
|
|
134
|
+
try {
|
|
135
|
+
return fn();
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
this.end(name);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async timeAsync(name, fn, metadata) {
|
|
142
|
+
if (!isLogEnabled)
|
|
143
|
+
return fn();
|
|
144
|
+
this.start(name, metadata);
|
|
145
|
+
try {
|
|
146
|
+
return await fn();
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
this.end(name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Create a single instance
|
|
154
|
+
// When debug is disabled, all methods return immediately with minimal overhead
|
|
155
|
+
const perfLogger = new PerformanceLogger();
|
|
156
|
+
|
|
157
|
+
// TeX defaults
|
|
158
|
+
const DEFAULT_TOLERANCE = 800;
|
|
159
|
+
const DEFAULT_PRETOLERANCE = 100;
|
|
160
|
+
const DEFAULT_EMERGENCY_STRETCH = 0;
|
|
161
|
+
// In TeX, interword spacing is defined by font parameters (fontdimen):
|
|
162
|
+
// - fontdimen 2: interword space
|
|
163
|
+
// - fontdimen 3: interword stretch
|
|
164
|
+
// - fontdimen 4: interword shrink
|
|
165
|
+
// - fontdimen 7: extra space (for sentence endings)
|
|
166
|
+
//
|
|
167
|
+
// For Computer Modern, stretch = 1/2 space, shrink = 1/3 space,
|
|
168
|
+
// which has become the default for OpenType fonts in modern
|
|
169
|
+
// TeX implementations
|
|
170
|
+
const SPACE_STRETCH_RATIO = 0.5; // stretch = 50% of space width (fontdimen 3 / fontdimen 2)
|
|
171
|
+
const SPACE_SHRINK_RATIO = 1 / 3; // shrink = 33% of space width (fontdimen 4 / fontdimen 2)
|
|
172
|
+
|
|
173
|
+
// Knuth-Plass line breaking with Liang hyphenation
|
|
174
|
+
// References: break.lua (SILE), tex.web (TeX), linebreak.c (LuaTeX), pTeX, xeCJK
|
|
175
|
+
var ItemType;
|
|
176
|
+
(function (ItemType) {
|
|
177
|
+
ItemType[ItemType["BOX"] = 0] = "BOX";
|
|
178
|
+
ItemType[ItemType["GLUE"] = 1] = "GLUE";
|
|
179
|
+
ItemType[ItemType["PENALTY"] = 2] = "PENALTY";
|
|
180
|
+
ItemType[ItemType["DISCRETIONARY"] = 3] = "DISCRETIONARY"; // hyphenation point with pre/post/no-break forms
|
|
181
|
+
})(ItemType || (ItemType = {}));
|
|
182
|
+
// TeX fitness classes (tex.web lines 16099-16105)
|
|
183
|
+
var FitnessClass;
|
|
184
|
+
(function (FitnessClass) {
|
|
185
|
+
FitnessClass[FitnessClass["VERY_LOOSE"] = 0] = "VERY_LOOSE";
|
|
186
|
+
FitnessClass[FitnessClass["LOOSE"] = 1] = "LOOSE";
|
|
187
|
+
FitnessClass[FitnessClass["DECENT"] = 2] = "DECENT";
|
|
188
|
+
FitnessClass[FitnessClass["TIGHT"] = 3] = "TIGHT"; // lines shrinking 0.5 to 1.0 of their shrinkability
|
|
189
|
+
})(FitnessClass || (FitnessClass = {}));
|
|
190
|
+
// Active node management with Map for lookup by (position, fitness)
|
|
191
|
+
class ActiveNodeList {
|
|
192
|
+
constructor() {
|
|
193
|
+
this.nodesByKey = new Map();
|
|
194
|
+
this.activeList = [];
|
|
195
|
+
}
|
|
196
|
+
getKey(position, fitness) {
|
|
197
|
+
return (position << 2) | fitness;
|
|
198
|
+
}
|
|
199
|
+
// Insert or update node - returns true if node was added/updated
|
|
200
|
+
insert(node) {
|
|
201
|
+
const key = this.getKey(node.position, node.fitness);
|
|
202
|
+
const existing = this.nodesByKey.get(key);
|
|
203
|
+
if (existing) {
|
|
204
|
+
// Update existing if new one is better
|
|
205
|
+
if (node.totalDemerits < existing.totalDemerits) {
|
|
206
|
+
existing.totalDemerits = node.totalDemerits;
|
|
207
|
+
existing.previous = node.previous;
|
|
208
|
+
existing.hyphenated = node.hyphenated;
|
|
209
|
+
existing.line = node.line;
|
|
210
|
+
existing.cumWidth = node.cumWidth;
|
|
211
|
+
existing.cumStretch = node.cumStretch;
|
|
212
|
+
existing.cumShrink = node.cumShrink;
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
// Add new node
|
|
218
|
+
node.active = true;
|
|
219
|
+
node.activeIndex = this.activeList.length;
|
|
220
|
+
this.activeList.push(node);
|
|
221
|
+
this.nodesByKey.set(key, node);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
deactivate(node) {
|
|
225
|
+
if (!node.active)
|
|
226
|
+
return;
|
|
227
|
+
node.active = false;
|
|
228
|
+
const idx = node.activeIndex;
|
|
229
|
+
const lastIdx = this.activeList.length - 1;
|
|
230
|
+
if (idx !== lastIdx) {
|
|
231
|
+
const lastNode = this.activeList[lastIdx];
|
|
232
|
+
this.activeList[idx] = lastNode;
|
|
233
|
+
lastNode.activeIndex = idx;
|
|
234
|
+
}
|
|
235
|
+
this.activeList.pop();
|
|
236
|
+
}
|
|
237
|
+
getActive() {
|
|
238
|
+
return this.activeList;
|
|
239
|
+
}
|
|
240
|
+
size() {
|
|
241
|
+
return this.activeList.length;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// TeX parameters (tex.web lines 4934-4936, 4997-4999)
|
|
245
|
+
const DEFAULT_HYPHEN_PENALTY = 50; // \hyphenpenalty
|
|
246
|
+
const DEFAULT_EX_HYPHEN_PENALTY = 50; // \exhyphenpenalty
|
|
247
|
+
const DEFAULT_DOUBLE_HYPHEN_DEMERITS = 10000; // \doublehyphendemerits
|
|
248
|
+
const DEFAULT_FINAL_HYPHEN_DEMERITS = 5000; // \finalhyphendemerits
|
|
249
|
+
const DEFAULT_LINE_PENALTY = 10; // \linepenalty
|
|
250
|
+
const DEFAULT_FITNESS_DIFF_DEMERITS = 10000; // \adjdemerits
|
|
251
|
+
const DEFAULT_LEFT_HYPHEN_MIN = 2; // \lefthyphenmin
|
|
252
|
+
const DEFAULT_RIGHT_HYPHEN_MIN = 3; // \righthyphenmin
|
|
253
|
+
// TeX special values (tex.web lines 2335, 3258, 3259)
|
|
254
|
+
const INF_BAD = 10000; // inf_bad - infinitely bad badness
|
|
255
|
+
const INFINITY_PENALTY = 10000; // inf_penalty - never break here
|
|
256
|
+
const EJECT_PENALTY = -10000; // eject_penalty - force break here
|
|
257
|
+
// Retry increment when no breakpoints found
|
|
258
|
+
const EMERGENCY_STRETCH_INCREMENT = 0.1;
|
|
259
|
+
class LineBreak {
|
|
260
|
+
// TeX: badness function (tex.web lines 2337-2348)
|
|
261
|
+
// Computes badness = 100 * (t/s)³ where t=adjustment, s=stretchability
|
|
262
|
+
// Simplified from TeX's fixed-point integer arithmetic to floating-point
|
|
263
|
+
//
|
|
264
|
+
// Returns INF_BAD+1 for overfull boxes so they're rejected even when
|
|
265
|
+
// threshold=INF_BAD in emergency pass
|
|
266
|
+
static badness(t, s) {
|
|
267
|
+
if (t === 0)
|
|
268
|
+
return 0;
|
|
269
|
+
if (s <= 0)
|
|
270
|
+
return INF_BAD + 1;
|
|
271
|
+
const r = Math.abs(t / s);
|
|
272
|
+
if (r > 10)
|
|
273
|
+
return INF_BAD + 1;
|
|
274
|
+
return Math.min(Math.round(100 * r ** 3), INF_BAD);
|
|
275
|
+
}
|
|
276
|
+
// TeX fitness classification (tex.web lines 16099-16105, 16799-16812)
|
|
277
|
+
// TeX uses badness thresholds 12 and 99, which correspond to ratios ~0.5 and ~1.0
|
|
278
|
+
// We use ratio directly since we compute it anyway. Well, and because SILE does
|
|
279
|
+
// it this way. Thanks Simon :)
|
|
280
|
+
static fitnessClass(ratio) {
|
|
281
|
+
if (ratio < -0.5)
|
|
282
|
+
return FitnessClass.TIGHT; // shrinking significantly
|
|
283
|
+
if (ratio < 0.5)
|
|
284
|
+
return FitnessClass.DECENT; // normal
|
|
285
|
+
if (ratio < 1)
|
|
286
|
+
return FitnessClass.LOOSE; // stretching 0.5-1.0
|
|
287
|
+
return FitnessClass.VERY_LOOSE; // stretching > 1.0
|
|
288
|
+
}
|
|
289
|
+
static findHyphenationPoints(word, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN) {
|
|
290
|
+
let patternTrie;
|
|
291
|
+
if (availablePatterns && availablePatterns[language]) {
|
|
292
|
+
patternTrie = availablePatterns[language];
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
if (!patternTrie)
|
|
298
|
+
return [];
|
|
299
|
+
const lowerWord = word.toLowerCase();
|
|
300
|
+
const paddedWord = `.${lowerWord}.`;
|
|
301
|
+
const points = new Array(paddedWord.length).fill(0);
|
|
302
|
+
for (let i = 0; i < paddedWord.length; i++) {
|
|
303
|
+
let node = patternTrie;
|
|
304
|
+
for (let j = i; j < paddedWord.length; j++) {
|
|
305
|
+
const char = paddedWord[j];
|
|
306
|
+
if (!node.children || !node.children[char])
|
|
307
|
+
break;
|
|
308
|
+
node = node.children[char];
|
|
309
|
+
if (node.patterns) {
|
|
310
|
+
for (let k = 0; k < node.patterns.length; k++) {
|
|
311
|
+
const pos = i + k;
|
|
312
|
+
if (pos < points.length) {
|
|
313
|
+
points[pos] = Math.max(points[pos], node.patterns[k]);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const hyphenPoints = [];
|
|
320
|
+
for (let i = 2; i < paddedWord.length - 2; i++) {
|
|
321
|
+
if (points[i] % 2 === 1) {
|
|
322
|
+
hyphenPoints.push(i - 1);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
|
|
326
|
+
}
|
|
327
|
+
static itemizeText(text, measureText, measureTextWidths, hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context, lineWidth) {
|
|
328
|
+
const items = [];
|
|
329
|
+
items.push(...this.itemizeParagraph(text, measureText, measureTextWidths, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context, lineWidth));
|
|
330
|
+
// Final glue and penalty
|
|
331
|
+
items.push({
|
|
332
|
+
type: ItemType.GLUE,
|
|
333
|
+
width: 0,
|
|
334
|
+
stretch: 10000000,
|
|
335
|
+
shrink: 0,
|
|
336
|
+
text: '',
|
|
337
|
+
originIndex: text.length
|
|
338
|
+
});
|
|
339
|
+
items.push({
|
|
340
|
+
type: ItemType.PENALTY,
|
|
341
|
+
width: 0,
|
|
342
|
+
penalty: EJECT_PENALTY,
|
|
343
|
+
text: '',
|
|
344
|
+
originIndex: text.length
|
|
345
|
+
});
|
|
346
|
+
return items;
|
|
347
|
+
}
|
|
348
|
+
static isCJK(char) {
|
|
349
|
+
const code = char.codePointAt(0);
|
|
350
|
+
if (code === undefined)
|
|
351
|
+
return false;
|
|
352
|
+
return ((code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
|
|
353
|
+
(code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
|
|
354
|
+
(code >= 0x20000 && code <= 0x2a6df) || // CJK Extension B
|
|
355
|
+
(code >= 0x2a700 && code <= 0x2b73f) || // CJK Extension C
|
|
356
|
+
(code >= 0x2b740 && code <= 0x2b81f) || // CJK Extension D
|
|
357
|
+
(code >= 0x2b820 && code <= 0x2ceaf) || // CJK Extension E
|
|
358
|
+
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
|
|
359
|
+
(code >= 0x3040 && code <= 0x309f) || // Hiragana
|
|
360
|
+
(code >= 0x30a0 && code <= 0x30ff) || // Katakana
|
|
361
|
+
(code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables
|
|
362
|
+
(code >= 0x1100 && code <= 0x11ff) || // Hangul Jamo
|
|
363
|
+
(code >= 0x3130 && code <= 0x318f) || // Hangul Compatibility Jamo
|
|
364
|
+
(code >= 0xa960 && code <= 0xa97f) || // Hangul Jamo Extended-A
|
|
365
|
+
(code >= 0xd7b0 && code <= 0xd7ff) || // Hangul Jamo Extended-B
|
|
366
|
+
(code >= 0xffa0 && code <= 0xffdc) // Halfwidth Hangul
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
// Closing punctuation - no line break before (UAX #14 CL, JIS X 4051)
|
|
370
|
+
static isCJClosingPunctuation(char) {
|
|
371
|
+
const code = char.charCodeAt(0);
|
|
372
|
+
return (code === 0x3001 || // 、
|
|
373
|
+
code === 0x3002 || // 。
|
|
374
|
+
code === 0xff0c || // ,
|
|
375
|
+
code === 0xff0e || // .
|
|
376
|
+
code === 0xff1a || // :
|
|
377
|
+
code === 0xff1b || // ;
|
|
378
|
+
code === 0xff01 || // !
|
|
379
|
+
code === 0xff1f || // ?
|
|
380
|
+
code === 0xff09 || // )
|
|
381
|
+
code === 0x3011 || // 】
|
|
382
|
+
code === 0xff5d || // }
|
|
383
|
+
code === 0x300d || // 」
|
|
384
|
+
code === 0x300f || // 』
|
|
385
|
+
code === 0x3009 || // 〉
|
|
386
|
+
code === 0x300b || // 》
|
|
387
|
+
code === 0x3015 || // 〕
|
|
388
|
+
code === 0x3017 || // 〗
|
|
389
|
+
code === 0x3019 || // 〙
|
|
390
|
+
code === 0x301b || // 〛
|
|
391
|
+
code === 0x30fc || // ー
|
|
392
|
+
code === 0x2014 || // —
|
|
393
|
+
code === 0x2026 || // …
|
|
394
|
+
code === 0x2025 // ‥
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
// Opening punctuation - no line break after (UAX #14 OP, JIS X 4051)
|
|
398
|
+
static isCJOpeningPunctuation(char) {
|
|
399
|
+
const code = char.charCodeAt(0);
|
|
400
|
+
return (code === 0xff08 || // (
|
|
401
|
+
code === 0x3010 || // 【
|
|
402
|
+
code === 0xff5b || // {
|
|
403
|
+
code === 0x300c || // 「
|
|
404
|
+
code === 0x300e || // 『
|
|
405
|
+
code === 0x3008 || // 〈
|
|
406
|
+
code === 0x300a || // 《
|
|
407
|
+
code === 0x3014 || // 〔
|
|
408
|
+
code === 0x3016 || // 〖
|
|
409
|
+
code === 0x3018 || // 〘
|
|
410
|
+
code === 0x301a // 〚
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
static isCJPunctuation(char) {
|
|
414
|
+
return (this.isCJClosingPunctuation(char) || this.isCJOpeningPunctuation(char));
|
|
415
|
+
}
|
|
416
|
+
static itemizeCJKText(text, measureText, measureTextWidths, context, startOffset = 0, glueParams) {
|
|
417
|
+
const items = [];
|
|
418
|
+
const chars = Array.from(text);
|
|
419
|
+
const widths = measureTextWidths ? measureTextWidths(text) : null;
|
|
420
|
+
let textPosition = startOffset;
|
|
421
|
+
let glueWidth, glueStretch, glueShrink;
|
|
422
|
+
if (glueParams) {
|
|
423
|
+
glueWidth = glueParams.width;
|
|
424
|
+
glueStretch = glueParams.stretch;
|
|
425
|
+
glueShrink = glueParams.shrink;
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
const baseCharWidth = measureText('字');
|
|
429
|
+
glueWidth = 0;
|
|
430
|
+
glueStretch = baseCharWidth * 0.04;
|
|
431
|
+
glueShrink = baseCharWidth * 0.04;
|
|
432
|
+
}
|
|
433
|
+
for (let i = 0; i < chars.length; i++) {
|
|
434
|
+
const char = chars[i];
|
|
435
|
+
const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
|
|
436
|
+
if (/\s/.test(char)) {
|
|
437
|
+
const width = widths
|
|
438
|
+
? (widths[i] ?? measureText(char))
|
|
439
|
+
: measureText(char);
|
|
440
|
+
items.push({
|
|
441
|
+
type: ItemType.GLUE,
|
|
442
|
+
width,
|
|
443
|
+
stretch: width * SPACE_STRETCH_RATIO,
|
|
444
|
+
shrink: width * SPACE_SHRINK_RATIO,
|
|
445
|
+
text: char,
|
|
446
|
+
originIndex: textPosition
|
|
447
|
+
});
|
|
448
|
+
textPosition += char.length;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
items.push({
|
|
452
|
+
type: ItemType.BOX,
|
|
453
|
+
width: widths ? (widths[i] ?? measureText(char)) : measureText(char),
|
|
454
|
+
text: char,
|
|
455
|
+
originIndex: textPosition
|
|
456
|
+
});
|
|
457
|
+
textPosition += char.length;
|
|
458
|
+
if (nextChar && !/\s/.test(nextChar)) {
|
|
459
|
+
let canBreak = true;
|
|
460
|
+
if (this.isCJClosingPunctuation(nextChar))
|
|
461
|
+
canBreak = false;
|
|
462
|
+
if (this.isCJOpeningPunctuation(char))
|
|
463
|
+
canBreak = false;
|
|
464
|
+
const isPunctPair = this.isCJPunctuation(char) && this.isCJPunctuation(nextChar);
|
|
465
|
+
if (canBreak && !isPunctPair) {
|
|
466
|
+
items.push({
|
|
467
|
+
type: ItemType.GLUE,
|
|
468
|
+
width: glueWidth,
|
|
469
|
+
stretch: glueStretch,
|
|
470
|
+
shrink: glueShrink,
|
|
471
|
+
text: '',
|
|
472
|
+
originIndex: textPosition
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return items;
|
|
478
|
+
}
|
|
479
|
+
static itemizeParagraph(text, measureText, measureTextWidths, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context, lineWidth) {
|
|
480
|
+
const items = [];
|
|
481
|
+
const chars = Array.from(text);
|
|
482
|
+
// Inter-character glue for CJK justification
|
|
483
|
+
// Matches pTeX's default \kanjiskip behavior
|
|
484
|
+
let cjkGlueParams;
|
|
485
|
+
const getCjkGlueParams = () => {
|
|
486
|
+
if (!cjkGlueParams) {
|
|
487
|
+
const baseCharWidth = measureText('字');
|
|
488
|
+
cjkGlueParams = {
|
|
489
|
+
width: 0,
|
|
490
|
+
stretch: baseCharWidth * 0.04,
|
|
491
|
+
shrink: baseCharWidth * 0.04
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
return cjkGlueParams;
|
|
495
|
+
};
|
|
496
|
+
let buffer = '';
|
|
497
|
+
let bufferStart = 0;
|
|
498
|
+
let bufferScript = null;
|
|
499
|
+
let textPosition = 0;
|
|
500
|
+
const flushBuffer = () => {
|
|
501
|
+
if (buffer.length === 0)
|
|
502
|
+
return;
|
|
503
|
+
if (bufferScript === 'cjk') {
|
|
504
|
+
items.push(...this.itemizeCJKText(buffer, measureText, measureTextWidths, context, bufferStart, getCjkGlueParams()));
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
items.push(...this.itemizeWordBased(buffer, bufferStart, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context, lineWidth));
|
|
508
|
+
}
|
|
509
|
+
buffer = '';
|
|
510
|
+
bufferScript = null;
|
|
511
|
+
};
|
|
512
|
+
for (let i = 0; i < chars.length; i++) {
|
|
513
|
+
const char = chars[i];
|
|
514
|
+
const isCJKChar = this.isCJK(char);
|
|
515
|
+
const currentScript = isCJKChar ? 'cjk' : 'word';
|
|
516
|
+
if (bufferScript !== null && bufferScript !== currentScript) {
|
|
517
|
+
flushBuffer();
|
|
518
|
+
bufferStart = textPosition;
|
|
519
|
+
}
|
|
520
|
+
if (bufferScript === null) {
|
|
521
|
+
bufferScript = currentScript;
|
|
522
|
+
bufferStart = textPosition;
|
|
523
|
+
}
|
|
524
|
+
buffer += char;
|
|
525
|
+
textPosition += char.length;
|
|
526
|
+
}
|
|
527
|
+
flushBuffer();
|
|
528
|
+
return items;
|
|
529
|
+
}
|
|
530
|
+
static itemizeWordBased(text, startOffset, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context, lineWidth) {
|
|
531
|
+
const items = [];
|
|
532
|
+
const tokens = text.match(/\S+|\s+/g) || [];
|
|
533
|
+
let currentIndex = 0;
|
|
534
|
+
for (const token of tokens) {
|
|
535
|
+
const tokenStartIndex = startOffset + currentIndex;
|
|
536
|
+
if (/\s+/.test(token)) {
|
|
537
|
+
const width = measureText(token);
|
|
538
|
+
items.push({
|
|
539
|
+
type: ItemType.GLUE,
|
|
540
|
+
width,
|
|
541
|
+
stretch: width * SPACE_STRETCH_RATIO,
|
|
542
|
+
shrink: width * SPACE_SHRINK_RATIO,
|
|
543
|
+
text: token,
|
|
544
|
+
originIndex: tokenStartIndex
|
|
545
|
+
});
|
|
546
|
+
currentIndex += token.length;
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
if (lineWidth && token.includes('-') && !token.includes('\u00AD')) {
|
|
550
|
+
const tokenWidth = measureText(token);
|
|
551
|
+
if (tokenWidth > lineWidth) {
|
|
552
|
+
// Break long hyphenated tokens into characters (break-all behavior)
|
|
553
|
+
const chars = Array.from(token);
|
|
554
|
+
for (let i = 0; i < chars.length; i++) {
|
|
555
|
+
items.push({
|
|
556
|
+
type: ItemType.BOX,
|
|
557
|
+
width: measureText(chars[i]),
|
|
558
|
+
text: chars[i],
|
|
559
|
+
originIndex: tokenStartIndex + i
|
|
560
|
+
});
|
|
561
|
+
if (i < chars.length - 1) {
|
|
562
|
+
items.push({
|
|
563
|
+
type: ItemType.PENALTY,
|
|
564
|
+
width: 0,
|
|
565
|
+
penalty: 5000,
|
|
566
|
+
originIndex: tokenStartIndex + i + 1
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
currentIndex += token.length;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const segments = token.split(/(-)/);
|
|
575
|
+
let segmentIndex = tokenStartIndex;
|
|
576
|
+
for (const segment of segments) {
|
|
577
|
+
if (!segment)
|
|
578
|
+
continue;
|
|
579
|
+
if (segment === '-') {
|
|
580
|
+
items.push({
|
|
581
|
+
type: ItemType.DISCRETIONARY,
|
|
582
|
+
width: measureText('-'),
|
|
583
|
+
preBreak: '-',
|
|
584
|
+
postBreak: '',
|
|
585
|
+
noBreak: '-',
|
|
586
|
+
preBreakWidth: measureText('-'),
|
|
587
|
+
penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
|
|
588
|
+
flagged: true,
|
|
589
|
+
text: '-',
|
|
590
|
+
originIndex: segmentIndex
|
|
591
|
+
});
|
|
592
|
+
segmentIndex += 1;
|
|
593
|
+
}
|
|
594
|
+
else if (segment.includes('\u00AD')) {
|
|
595
|
+
const parts = segment.split('\u00AD');
|
|
596
|
+
let runningIndex = 0;
|
|
597
|
+
for (let k = 0; k < parts.length; k++) {
|
|
598
|
+
const partText = parts[k];
|
|
599
|
+
if (partText.length > 0) {
|
|
600
|
+
items.push({
|
|
601
|
+
type: ItemType.BOX,
|
|
602
|
+
width: measureText(partText),
|
|
603
|
+
text: partText,
|
|
604
|
+
originIndex: segmentIndex + runningIndex
|
|
605
|
+
});
|
|
606
|
+
runningIndex += partText.length;
|
|
607
|
+
}
|
|
608
|
+
if (k < parts.length - 1) {
|
|
609
|
+
items.push({
|
|
610
|
+
type: ItemType.DISCRETIONARY,
|
|
611
|
+
width: 0,
|
|
612
|
+
preBreak: '-',
|
|
613
|
+
postBreak: '',
|
|
614
|
+
noBreak: '',
|
|
615
|
+
preBreakWidth: measureText('-'),
|
|
616
|
+
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
617
|
+
flagged: true,
|
|
618
|
+
text: '',
|
|
619
|
+
originIndex: segmentIndex + runningIndex
|
|
620
|
+
});
|
|
621
|
+
runningIndex += 1;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
else if (hyphenate &&
|
|
626
|
+
segment.length >= lefthyphenmin + righthyphenmin &&
|
|
627
|
+
/^\p{L}+$/u.test(segment)) {
|
|
628
|
+
const hyphenPoints = this.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
|
|
629
|
+
if (hyphenPoints.length > 0) {
|
|
630
|
+
let lastPoint = 0;
|
|
631
|
+
for (const point of hyphenPoints) {
|
|
632
|
+
const part = segment.substring(lastPoint, point);
|
|
633
|
+
items.push({
|
|
634
|
+
type: ItemType.BOX,
|
|
635
|
+
width: measureText(part),
|
|
636
|
+
text: part,
|
|
637
|
+
originIndex: segmentIndex + lastPoint
|
|
638
|
+
});
|
|
639
|
+
items.push({
|
|
640
|
+
type: ItemType.DISCRETIONARY,
|
|
641
|
+
width: 0,
|
|
642
|
+
preBreak: '-',
|
|
643
|
+
postBreak: '',
|
|
644
|
+
noBreak: '',
|
|
645
|
+
preBreakWidth: measureText('-'),
|
|
646
|
+
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
647
|
+
flagged: true,
|
|
648
|
+
text: '',
|
|
649
|
+
originIndex: segmentIndex + point
|
|
650
|
+
});
|
|
651
|
+
lastPoint = point;
|
|
652
|
+
}
|
|
653
|
+
items.push({
|
|
654
|
+
type: ItemType.BOX,
|
|
655
|
+
width: measureText(segment.substring(lastPoint)),
|
|
656
|
+
text: segment.substring(lastPoint),
|
|
657
|
+
originIndex: segmentIndex + lastPoint
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
const wordWidth = measureText(segment);
|
|
662
|
+
if (lineWidth && wordWidth > lineWidth) {
|
|
663
|
+
// Word longer than line width - break into characters
|
|
664
|
+
const chars = Array.from(segment);
|
|
665
|
+
for (let i = 0; i < chars.length; i++) {
|
|
666
|
+
items.push({
|
|
667
|
+
type: ItemType.BOX,
|
|
668
|
+
width: measureText(chars[i]),
|
|
669
|
+
text: chars[i],
|
|
670
|
+
originIndex: segmentIndex + i
|
|
671
|
+
});
|
|
672
|
+
if (i < chars.length - 1) {
|
|
673
|
+
items.push({
|
|
674
|
+
type: ItemType.PENALTY,
|
|
675
|
+
width: 0,
|
|
676
|
+
penalty: 5000,
|
|
677
|
+
originIndex: segmentIndex + i + 1
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
items.push({
|
|
684
|
+
type: ItemType.BOX,
|
|
685
|
+
width: wordWidth,
|
|
686
|
+
text: segment,
|
|
687
|
+
originIndex: segmentIndex
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
const wordWidth = measureText(segment);
|
|
694
|
+
if (lineWidth && wordWidth > lineWidth) {
|
|
695
|
+
// Word longer than line width - break into characters
|
|
696
|
+
const chars = Array.from(segment);
|
|
697
|
+
for (let i = 0; i < chars.length; i++) {
|
|
698
|
+
items.push({
|
|
699
|
+
type: ItemType.BOX,
|
|
700
|
+
width: measureText(chars[i]),
|
|
701
|
+
text: chars[i],
|
|
702
|
+
originIndex: segmentIndex + i
|
|
703
|
+
});
|
|
704
|
+
if (i < chars.length - 1) {
|
|
705
|
+
items.push({
|
|
706
|
+
type: ItemType.PENALTY,
|
|
707
|
+
width: 0,
|
|
708
|
+
penalty: 5000,
|
|
709
|
+
originIndex: segmentIndex + i + 1
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
items.push({
|
|
716
|
+
type: ItemType.BOX,
|
|
717
|
+
width: wordWidth,
|
|
718
|
+
text: segment,
|
|
719
|
+
originIndex: segmentIndex
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
segmentIndex += segment.length;
|
|
724
|
+
}
|
|
725
|
+
currentIndex += token.length;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return items;
|
|
729
|
+
}
|
|
730
|
+
// TeX: line_break inner loop (tex.web lines 16169-17256)
|
|
731
|
+
// Finds optimal breakpoints using Knuth-Plass algorithm
|
|
732
|
+
static lineBreak(items, lineWidth, threshold, emergencyStretch, context) {
|
|
733
|
+
const activeNodes = new ActiveNodeList();
|
|
734
|
+
// Start node with zero cumulative width
|
|
735
|
+
activeNodes.insert({
|
|
736
|
+
position: 0,
|
|
737
|
+
line: 0,
|
|
738
|
+
fitness: FitnessClass.DECENT,
|
|
739
|
+
totalDemerits: 0,
|
|
740
|
+
previous: null,
|
|
741
|
+
hyphenated: false,
|
|
742
|
+
active: true,
|
|
743
|
+
activeIndex: 0,
|
|
744
|
+
cumWidth: 0,
|
|
745
|
+
cumStretch: 0,
|
|
746
|
+
cumShrink: 0
|
|
747
|
+
});
|
|
748
|
+
// Cumulative width from paragraph start, representing items[0..i-1]
|
|
749
|
+
let cumWidth = 0;
|
|
750
|
+
let cumStretch = 0;
|
|
751
|
+
let cumShrink = 0;
|
|
752
|
+
// Process each item
|
|
753
|
+
for (let i = 0; i < items.length; i++) {
|
|
754
|
+
const item = items[i];
|
|
755
|
+
// Check if this is a legal breakpoint
|
|
756
|
+
const isBreakpoint = (item.type === ItemType.PENALTY &&
|
|
757
|
+
item.penalty < INFINITY_PENALTY) ||
|
|
758
|
+
item.type === ItemType.DISCRETIONARY ||
|
|
759
|
+
(item.type === ItemType.GLUE &&
|
|
760
|
+
i > 0 &&
|
|
761
|
+
items[i - 1].type === ItemType.BOX);
|
|
762
|
+
if (!isBreakpoint) {
|
|
763
|
+
// Accumulate width for non-breakpoint items
|
|
764
|
+
if (item.type === ItemType.BOX) {
|
|
765
|
+
cumWidth += item.width;
|
|
766
|
+
}
|
|
767
|
+
else if (item.type === ItemType.GLUE) {
|
|
768
|
+
const glue = item;
|
|
769
|
+
cumWidth += glue.width;
|
|
770
|
+
cumStretch += glue.stretch;
|
|
771
|
+
cumShrink += glue.shrink;
|
|
772
|
+
}
|
|
773
|
+
else if (item.type === ItemType.DISCRETIONARY) {
|
|
774
|
+
cumWidth += item.width;
|
|
775
|
+
}
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
// Get penalty and flagged status
|
|
779
|
+
let pi = 0;
|
|
780
|
+
let flagged = false;
|
|
781
|
+
if (item.type === ItemType.PENALTY) {
|
|
782
|
+
pi = item.penalty;
|
|
783
|
+
flagged = item.flagged || false;
|
|
784
|
+
}
|
|
785
|
+
else if (item.type === ItemType.DISCRETIONARY) {
|
|
786
|
+
pi = item.penalty;
|
|
787
|
+
flagged = item.flagged || false;
|
|
788
|
+
}
|
|
789
|
+
// Width added at break
|
|
790
|
+
let breakWidth = 0;
|
|
791
|
+
if (item.type === ItemType.DISCRETIONARY) {
|
|
792
|
+
breakWidth = item.preBreakWidth;
|
|
793
|
+
}
|
|
794
|
+
// Best for each fitness class
|
|
795
|
+
const bestNode = [null, null, null, null];
|
|
796
|
+
const bestDemerits = [Infinity, Infinity, Infinity, Infinity];
|
|
797
|
+
// Nodes to deactivate
|
|
798
|
+
const toDeactivate = [];
|
|
799
|
+
// Try each active node as predecessor
|
|
800
|
+
const active = activeNodes.getActive();
|
|
801
|
+
for (let j = 0; j < active.length; j++) {
|
|
802
|
+
const a = active[j];
|
|
803
|
+
// Line width from a to i
|
|
804
|
+
const lineW = cumWidth - a.cumWidth + breakWidth;
|
|
805
|
+
const lineStretch = cumStretch - a.cumStretch;
|
|
806
|
+
const lineShrink = cumShrink - a.cumShrink;
|
|
807
|
+
const shortfall = lineWidth - lineW;
|
|
808
|
+
let ratio;
|
|
809
|
+
if (shortfall > 0) {
|
|
810
|
+
const effectiveStretch = lineStretch + emergencyStretch;
|
|
811
|
+
ratio =
|
|
812
|
+
effectiveStretch > 0 ? shortfall / effectiveStretch : Infinity;
|
|
813
|
+
}
|
|
814
|
+
else if (shortfall < 0) {
|
|
815
|
+
ratio = lineShrink > 0 ? shortfall / lineShrink : -Infinity;
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
ratio = 0;
|
|
819
|
+
}
|
|
820
|
+
// Calculate badness
|
|
821
|
+
const bad = this.badness(shortfall, shortfall > 0 ? lineStretch + emergencyStretch : lineShrink);
|
|
822
|
+
// Check feasibility
|
|
823
|
+
if (ratio < -1) {
|
|
824
|
+
toDeactivate.push(a);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
if (pi !== EJECT_PENALTY && bad > threshold) {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
// Calculate demerits
|
|
831
|
+
let demerits = context.linePenalty + bad;
|
|
832
|
+
if (Math.abs(demerits) >= 10000) {
|
|
833
|
+
demerits = 100000000;
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
demerits = demerits * demerits;
|
|
837
|
+
}
|
|
838
|
+
if (pi > 0) {
|
|
839
|
+
demerits += pi * pi;
|
|
840
|
+
}
|
|
841
|
+
else if (pi > EJECT_PENALTY) {
|
|
842
|
+
demerits -= pi * pi;
|
|
843
|
+
}
|
|
844
|
+
if (flagged && a.hyphenated) {
|
|
845
|
+
demerits += context.doubleHyphenDemerits;
|
|
846
|
+
}
|
|
847
|
+
const fitness = this.fitnessClass(ratio);
|
|
848
|
+
if (Math.abs(fitness - a.fitness) > 1) {
|
|
849
|
+
demerits += context.adjDemerits;
|
|
850
|
+
}
|
|
851
|
+
const totalDemerits = a.totalDemerits + demerits;
|
|
852
|
+
if (totalDemerits < bestDemerits[fitness]) {
|
|
853
|
+
bestDemerits[fitness] = totalDemerits;
|
|
854
|
+
bestNode[fitness] = {
|
|
855
|
+
position: i,
|
|
856
|
+
line: a.line + 1,
|
|
857
|
+
fitness,
|
|
858
|
+
totalDemerits,
|
|
859
|
+
previous: a,
|
|
860
|
+
hyphenated: flagged,
|
|
861
|
+
active: true,
|
|
862
|
+
activeIndex: -1,
|
|
863
|
+
cumWidth: cumWidth,
|
|
864
|
+
cumStretch: cumStretch,
|
|
865
|
+
cumShrink: cumShrink
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// Deactivate nodes
|
|
870
|
+
for (const node of toDeactivate) {
|
|
871
|
+
activeNodes.deactivate(node);
|
|
872
|
+
}
|
|
873
|
+
// Insert best nodes
|
|
874
|
+
for (let f = 0; f < 4; f++) {
|
|
875
|
+
if (bestNode[f]) {
|
|
876
|
+
activeNodes.insert(bestNode[f]);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (activeNodes.size() === 0 && pi !== EJECT_PENALTY) {
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
// Accumulate width after evaluating this breakpoint
|
|
883
|
+
if (item.type === ItemType.BOX) {
|
|
884
|
+
cumWidth += item.width;
|
|
885
|
+
}
|
|
886
|
+
else if (item.type === ItemType.GLUE) {
|
|
887
|
+
const glue = item;
|
|
888
|
+
cumWidth += glue.width;
|
|
889
|
+
cumStretch += glue.stretch;
|
|
890
|
+
cumShrink += glue.shrink;
|
|
891
|
+
}
|
|
892
|
+
else if (item.type === ItemType.DISCRETIONARY) {
|
|
893
|
+
cumWidth += item.width;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Find best solution
|
|
897
|
+
let best = null;
|
|
898
|
+
let bestTotal = Infinity;
|
|
899
|
+
for (const node of activeNodes.getActive()) {
|
|
900
|
+
if (node.totalDemerits < bestTotal) {
|
|
901
|
+
bestTotal = node.totalDemerits;
|
|
902
|
+
best = node;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return best;
|
|
906
|
+
}
|
|
907
|
+
// Main entry point for line breaking
|
|
908
|
+
// Implements the multi-pass approach similar to TeX's line_break (tex.web lines 16054-17067)
|
|
909
|
+
// 1. First pass without hyphenation (pretolerance)
|
|
910
|
+
// 2. Second pass with hyphenation (tolerance)
|
|
911
|
+
// 3. Emergency stretch passes with increasing stretchability
|
|
912
|
+
static breakText(options) {
|
|
913
|
+
if (!options.text || options.text.length === 0) {
|
|
914
|
+
return [];
|
|
915
|
+
}
|
|
916
|
+
perfLogger.start('LineBreak.breakText', {
|
|
917
|
+
textLength: options.text.length,
|
|
918
|
+
width: options.width,
|
|
919
|
+
align: options.align || 'left',
|
|
920
|
+
hyphenate: options.hyphenate || false
|
|
921
|
+
});
|
|
922
|
+
const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, measureTextWidths, hyphenationPatterns, unitsPerEm, letterSpacing = 0, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, finalhyphendemerits = DEFAULT_FINAL_HYPHEN_DEMERITS } = options;
|
|
923
|
+
// Handle multiple paragraphs
|
|
924
|
+
if (respectExistingBreaks && text.includes('\n')) {
|
|
925
|
+
const paragraphs = text.split('\n');
|
|
926
|
+
const allLines = [];
|
|
927
|
+
let currentOriginOffset = 0;
|
|
928
|
+
for (const paragraph of paragraphs) {
|
|
929
|
+
if (paragraph.length === 0) {
|
|
930
|
+
allLines.push({
|
|
931
|
+
text: '',
|
|
932
|
+
originalStart: currentOriginOffset,
|
|
933
|
+
originalEnd: currentOriginOffset,
|
|
934
|
+
xOffset: 0,
|
|
935
|
+
isLastLine: true,
|
|
936
|
+
naturalWidth: 0,
|
|
937
|
+
endedWithHyphen: false
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
const paragraphLines = this.breakText({
|
|
942
|
+
...options,
|
|
943
|
+
text: paragraph,
|
|
944
|
+
respectExistingBreaks: false
|
|
945
|
+
});
|
|
946
|
+
paragraphLines.forEach((line) => {
|
|
947
|
+
line.originalStart += currentOriginOffset;
|
|
948
|
+
line.originalEnd += currentOriginOffset;
|
|
949
|
+
});
|
|
950
|
+
allLines.push(...paragraphLines);
|
|
951
|
+
}
|
|
952
|
+
currentOriginOffset += paragraph.length + 1;
|
|
953
|
+
}
|
|
954
|
+
perfLogger.end('LineBreak.breakText');
|
|
955
|
+
return allLines;
|
|
956
|
+
}
|
|
957
|
+
let useHyphenation = hyphenate;
|
|
958
|
+
if (useHyphenation &&
|
|
959
|
+
(!hyphenationPatterns || !hyphenationPatterns[language])) {
|
|
960
|
+
logger.warn(`Hyphenation patterns for ${language} not available`);
|
|
961
|
+
useHyphenation = false;
|
|
962
|
+
}
|
|
963
|
+
let initialEmergencyStretch = emergencyStretch;
|
|
964
|
+
if (autoEmergencyStretch !== undefined && width) {
|
|
965
|
+
initialEmergencyStretch = width * autoEmergencyStretch;
|
|
966
|
+
}
|
|
967
|
+
const context = {
|
|
968
|
+
linePenalty: linepenalty,
|
|
969
|
+
adjDemerits: adjdemerits,
|
|
970
|
+
doubleHyphenDemerits: doublehyphendemerits,
|
|
971
|
+
finalHyphenDemerits: finalhyphendemerits,
|
|
972
|
+
hyphenPenalty: hyphenpenalty,
|
|
973
|
+
exHyphenPenalty: exhyphenpenalty,
|
|
974
|
+
currentAlign: align,
|
|
975
|
+
unitsPerEm,
|
|
976
|
+
letterSpacingFU: unitsPerEm ? letterSpacing * unitsPerEm : 0
|
|
977
|
+
};
|
|
978
|
+
if (!width || width === Infinity) {
|
|
979
|
+
const measuredWidth = measureText(text);
|
|
980
|
+
perfLogger.end('LineBreak.breakText');
|
|
981
|
+
return [
|
|
982
|
+
{
|
|
983
|
+
text,
|
|
984
|
+
originalStart: 0,
|
|
985
|
+
originalEnd: text.length - 1,
|
|
986
|
+
xOffset: 0,
|
|
987
|
+
isLastLine: true,
|
|
988
|
+
naturalWidth: measuredWidth,
|
|
989
|
+
endedWithHyphen: false
|
|
990
|
+
}
|
|
991
|
+
];
|
|
992
|
+
}
|
|
993
|
+
// First pass: without hyphenation
|
|
994
|
+
let items = this.itemizeText(text, measureText, measureTextWidths, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context, width);
|
|
995
|
+
let best = this.lineBreak(items, width, pretolerance, 0, context);
|
|
996
|
+
// Second pass: with hyphenation
|
|
997
|
+
if (!best && useHyphenation) {
|
|
998
|
+
items = this.itemizeText(text, measureText, measureTextWidths, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context, width);
|
|
999
|
+
best = this.lineBreak(items, width, tolerance, 0, context);
|
|
1000
|
+
}
|
|
1001
|
+
// Third pass: with emergency stretch, retry with increasing amounts
|
|
1002
|
+
if (!best) {
|
|
1003
|
+
const MAX_EMERGENCY_ITERATIONS = 5;
|
|
1004
|
+
for (let i = 0; i < MAX_EMERGENCY_ITERATIONS && !best; i++) {
|
|
1005
|
+
const currentStretch = initialEmergencyStretch + i * width * EMERGENCY_STRETCH_INCREMENT;
|
|
1006
|
+
best = this.lineBreak(items, width, tolerance, currentStretch, context);
|
|
1007
|
+
// Fourth pass: high threshold with current stretch
|
|
1008
|
+
if (!best) {
|
|
1009
|
+
best = this.lineBreak(items, width, INF_BAD, currentStretch, context);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (best) {
|
|
1014
|
+
const breakpoints = [];
|
|
1015
|
+
let node = best;
|
|
1016
|
+
while (node && node.position > 0) {
|
|
1017
|
+
breakpoints.unshift(node.position);
|
|
1018
|
+
node = node.previous;
|
|
1019
|
+
}
|
|
1020
|
+
perfLogger.end('LineBreak.breakText');
|
|
1021
|
+
return this.postLineBreak(text, items, breakpoints, width, align, direction, context);
|
|
1022
|
+
}
|
|
1023
|
+
perfLogger.end('LineBreak.breakText');
|
|
1024
|
+
return [
|
|
1025
|
+
{
|
|
1026
|
+
text,
|
|
1027
|
+
originalStart: 0,
|
|
1028
|
+
originalEnd: text.length - 1,
|
|
1029
|
+
xOffset: 0,
|
|
1030
|
+
adjustmentRatio: 0,
|
|
1031
|
+
isLastLine: true,
|
|
1032
|
+
naturalWidth: measureText(text),
|
|
1033
|
+
endedWithHyphen: false
|
|
1034
|
+
}
|
|
1035
|
+
];
|
|
1036
|
+
}
|
|
1037
|
+
// TeX: post_line_break (tex.web lines 17260-17448)
|
|
1038
|
+
// Creates the actual lines from the computed breakpoints
|
|
1039
|
+
static postLineBreak(text, items, breakpoints, lineWidth, align, direction, context) {
|
|
1040
|
+
if (breakpoints.length === 0) {
|
|
1041
|
+
return [
|
|
1042
|
+
{
|
|
1043
|
+
text,
|
|
1044
|
+
originalStart: 0,
|
|
1045
|
+
originalEnd: text.length - 1,
|
|
1046
|
+
xOffset: 0
|
|
1047
|
+
}
|
|
1048
|
+
];
|
|
1049
|
+
}
|
|
1050
|
+
const lines = [];
|
|
1051
|
+
let lineStart = 0;
|
|
1052
|
+
for (let i = 0; i < breakpoints.length; i++) {
|
|
1053
|
+
const breakpoint = breakpoints[i];
|
|
1054
|
+
const willHaveFinalLine = breakpoints[breakpoints.length - 1] + 1 < items.length - 1;
|
|
1055
|
+
const isLastLine = willHaveFinalLine
|
|
1056
|
+
? false
|
|
1057
|
+
: i === breakpoints.length - 1;
|
|
1058
|
+
const lineTextParts = [];
|
|
1059
|
+
let originalStart = -1;
|
|
1060
|
+
let originalEnd = -1;
|
|
1061
|
+
let naturalWidth = 0;
|
|
1062
|
+
let totalStretch = 0;
|
|
1063
|
+
let totalShrink = 0;
|
|
1064
|
+
for (let j = lineStart; j < breakpoint; j++) {
|
|
1065
|
+
const item = items[j];
|
|
1066
|
+
if ((item.type === ItemType.PENALTY && !item.text) ||
|
|
1067
|
+
(item.type === ItemType.DISCRETIONARY &&
|
|
1068
|
+
!item.noBreak)) {
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
if (item.originIndex !== undefined) {
|
|
1072
|
+
if (originalStart === -1 || item.originIndex < originalStart)
|
|
1073
|
+
originalStart = item.originIndex;
|
|
1074
|
+
const textLength = item.text ? item.text.length : 0;
|
|
1075
|
+
const itemEnd = item.originIndex + textLength - 1;
|
|
1076
|
+
if (itemEnd > originalEnd)
|
|
1077
|
+
originalEnd = itemEnd;
|
|
1078
|
+
}
|
|
1079
|
+
if (item.text) {
|
|
1080
|
+
lineTextParts.push(item.text);
|
|
1081
|
+
}
|
|
1082
|
+
else if (item.type === ItemType.DISCRETIONARY) {
|
|
1083
|
+
const disc = item;
|
|
1084
|
+
if (disc.noBreak)
|
|
1085
|
+
lineTextParts.push(disc.noBreak);
|
|
1086
|
+
}
|
|
1087
|
+
naturalWidth += item.width;
|
|
1088
|
+
if (item.type === ItemType.GLUE) {
|
|
1089
|
+
totalStretch += item.stretch;
|
|
1090
|
+
totalShrink += item.shrink;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const breakItem = items[breakpoint];
|
|
1094
|
+
let endedWithHyphen = false;
|
|
1095
|
+
if (breakpoint < items.length) {
|
|
1096
|
+
if (breakItem.type === ItemType.PENALTY &&
|
|
1097
|
+
breakItem.flagged) {
|
|
1098
|
+
lineTextParts.push('-');
|
|
1099
|
+
naturalWidth += breakItem.width;
|
|
1100
|
+
endedWithHyphen = true;
|
|
1101
|
+
if (breakItem.originIndex !== undefined)
|
|
1102
|
+
originalEnd = breakItem.originIndex - 1;
|
|
1103
|
+
}
|
|
1104
|
+
else if (breakItem.type === ItemType.DISCRETIONARY) {
|
|
1105
|
+
const disc = breakItem;
|
|
1106
|
+
if (disc.preBreak) {
|
|
1107
|
+
lineTextParts.push(disc.preBreak);
|
|
1108
|
+
naturalWidth += disc.preBreakWidth;
|
|
1109
|
+
endedWithHyphen = disc.flagged || false;
|
|
1110
|
+
if (breakItem.originIndex !== undefined)
|
|
1111
|
+
originalEnd = breakItem.originIndex - 1;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
const lineText = lineTextParts.join('');
|
|
1116
|
+
if (context?.letterSpacingFU && naturalWidth !== 0) {
|
|
1117
|
+
naturalWidth -= context.letterSpacingFU;
|
|
1118
|
+
}
|
|
1119
|
+
let xOffset = 0;
|
|
1120
|
+
let adjustmentRatio = 0;
|
|
1121
|
+
let effectiveAlign = align;
|
|
1122
|
+
if (align === 'justify' && isLastLine) {
|
|
1123
|
+
effectiveAlign = direction === 'rtl' ? 'right' : 'left';
|
|
1124
|
+
}
|
|
1125
|
+
if (effectiveAlign === 'center') {
|
|
1126
|
+
xOffset = (lineWidth - naturalWidth) / 2;
|
|
1127
|
+
}
|
|
1128
|
+
else if (effectiveAlign === 'right') {
|
|
1129
|
+
xOffset = lineWidth - naturalWidth;
|
|
1130
|
+
}
|
|
1131
|
+
else if (effectiveAlign === 'justify' && !isLastLine) {
|
|
1132
|
+
const shortfall = lineWidth - naturalWidth;
|
|
1133
|
+
if (shortfall > 0 && totalStretch > 0) {
|
|
1134
|
+
adjustmentRatio = shortfall / totalStretch;
|
|
1135
|
+
}
|
|
1136
|
+
else if (shortfall < 0 && totalShrink > 0) {
|
|
1137
|
+
adjustmentRatio = shortfall / totalShrink;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
lines.push({
|
|
1141
|
+
text: lineText,
|
|
1142
|
+
originalStart,
|
|
1143
|
+
originalEnd,
|
|
1144
|
+
xOffset,
|
|
1145
|
+
adjustmentRatio,
|
|
1146
|
+
isLastLine: false,
|
|
1147
|
+
naturalWidth,
|
|
1148
|
+
endedWithHyphen
|
|
1149
|
+
});
|
|
1150
|
+
lineStart = breakpoint + 1;
|
|
1151
|
+
}
|
|
1152
|
+
// Handle remaining content
|
|
1153
|
+
if (lineStart < items.length - 1) {
|
|
1154
|
+
const finalLineTextParts = [];
|
|
1155
|
+
let finalOriginalStart = -1;
|
|
1156
|
+
let finalOriginalEnd = -1;
|
|
1157
|
+
let finalNaturalWidth = 0;
|
|
1158
|
+
for (let j = lineStart; j < items.length - 1; j++) {
|
|
1159
|
+
const item = items[j];
|
|
1160
|
+
if (item.type === ItemType.PENALTY)
|
|
1161
|
+
continue;
|
|
1162
|
+
if (item.originIndex !== undefined) {
|
|
1163
|
+
if (finalOriginalStart === -1 ||
|
|
1164
|
+
item.originIndex < finalOriginalStart) {
|
|
1165
|
+
finalOriginalStart = item.originIndex;
|
|
1166
|
+
}
|
|
1167
|
+
if (item.originIndex > finalOriginalEnd) {
|
|
1168
|
+
finalOriginalEnd = item.originIndex;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (item.text)
|
|
1172
|
+
finalLineTextParts.push(item.text);
|
|
1173
|
+
finalNaturalWidth += item.width;
|
|
1174
|
+
}
|
|
1175
|
+
if (context?.letterSpacingFU && finalNaturalWidth !== 0) {
|
|
1176
|
+
finalNaturalWidth -= context.letterSpacingFU;
|
|
1177
|
+
}
|
|
1178
|
+
let finalXOffset = 0;
|
|
1179
|
+
let finalEffectiveAlign = align;
|
|
1180
|
+
if (align === 'justify') {
|
|
1181
|
+
finalEffectiveAlign = direction === 'rtl' ? 'right' : 'left';
|
|
1182
|
+
}
|
|
1183
|
+
if (finalEffectiveAlign === 'center') {
|
|
1184
|
+
finalXOffset = (lineWidth - finalNaturalWidth) / 2;
|
|
1185
|
+
}
|
|
1186
|
+
else if (finalEffectiveAlign === 'right') {
|
|
1187
|
+
finalXOffset = lineWidth - finalNaturalWidth;
|
|
1188
|
+
}
|
|
1189
|
+
lines.push({
|
|
1190
|
+
text: finalLineTextParts.join(''),
|
|
1191
|
+
originalStart: finalOriginalStart,
|
|
1192
|
+
originalEnd: finalOriginalEnd,
|
|
1193
|
+
xOffset: finalXOffset,
|
|
1194
|
+
adjustmentRatio: 0,
|
|
1195
|
+
isLastLine: true,
|
|
1196
|
+
naturalWidth: finalNaturalWidth,
|
|
1197
|
+
endedWithHyphen: false
|
|
1198
|
+
});
|
|
1199
|
+
if (lines.length > 1)
|
|
1200
|
+
lines[lines.length - 2].isLastLine = false;
|
|
1201
|
+
lines[lines.length - 1].isLastLine = true;
|
|
1202
|
+
}
|
|
1203
|
+
else if (lines.length > 0) {
|
|
1204
|
+
lines[lines.length - 1].isLastLine = true;
|
|
1205
|
+
}
|
|
1206
|
+
return lines;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Memoize conversion per feature-object identity to avoid rebuilding the same
|
|
1211
|
+
// comma-separated string on every HarfBuzz shape call
|
|
1212
|
+
const featureStringCache = new WeakMap();
|
|
1213
|
+
// Convert feature objects to HarfBuzz comma-separated format
|
|
1214
|
+
function convertFontFeaturesToString(features) {
|
|
1215
|
+
if (!features || Object.keys(features).length === 0) {
|
|
1216
|
+
return undefined;
|
|
1217
|
+
}
|
|
1218
|
+
const cached = featureStringCache.get(features);
|
|
1219
|
+
if (cached !== undefined) {
|
|
1220
|
+
return cached ?? undefined;
|
|
1221
|
+
}
|
|
1222
|
+
const featureStrings = [];
|
|
1223
|
+
// Preserve insertion order of the input object
|
|
1224
|
+
// (The public API/tests expect this to be stable and predictable)
|
|
1225
|
+
for (const [tag, value] of Object.entries(features)) {
|
|
1226
|
+
if (!/^[a-zA-Z0-9]{4}$/.test(tag)) {
|
|
1227
|
+
logger.warn(`Invalid OpenType feature tag: "${tag}". Tags must be exactly 4 alphanumeric characters.`);
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
if (value === false || value === 0) {
|
|
1231
|
+
featureStrings.push(`${tag}=0`);
|
|
1232
|
+
}
|
|
1233
|
+
else if (value === true || value === 1) {
|
|
1234
|
+
featureStrings.push(tag);
|
|
1235
|
+
}
|
|
1236
|
+
else if (typeof value === 'number' && value > 1) {
|
|
1237
|
+
featureStrings.push(`${tag}=${Math.floor(value)}`);
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
logger.warn(`Invalid value for feature "${tag}": ${value}. Expected boolean or positive number.`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
const result = featureStrings.length > 0 ? featureStrings.join(',') : undefined;
|
|
1244
|
+
featureStringCache.set(features, result ?? null);
|
|
1245
|
+
return result;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
class TextMeasurer {
|
|
1249
|
+
// Shape once and return per-codepoint widths aligned with Array.from(text)
|
|
1250
|
+
// Groups glyph advances by HarfBuzz cluster (cl)
|
|
1251
|
+
// Includes trailing per-glyph letter spacing like measureTextWidth
|
|
1252
|
+
static measureTextWidths(loadedFont, text, letterSpacing = 0) {
|
|
1253
|
+
const chars = Array.from(text);
|
|
1254
|
+
if (chars.length === 0)
|
|
1255
|
+
return [];
|
|
1256
|
+
// HarfBuzz clusters are UTF-16 code unit indices
|
|
1257
|
+
const startToCharIndex = new Map();
|
|
1258
|
+
let codeUnitIndex = 0;
|
|
1259
|
+
for (let i = 0; i < chars.length; i++) {
|
|
1260
|
+
startToCharIndex.set(codeUnitIndex, i);
|
|
1261
|
+
codeUnitIndex += chars[i].length;
|
|
1262
|
+
}
|
|
1263
|
+
const widths = new Array(chars.length).fill(0);
|
|
1264
|
+
const buffer = loadedFont.hb.createBuffer();
|
|
1265
|
+
try {
|
|
1266
|
+
buffer.addText(text);
|
|
1267
|
+
buffer.guessSegmentProperties();
|
|
1268
|
+
const featuresString = convertFontFeaturesToString(loadedFont.fontFeatures);
|
|
1269
|
+
loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
|
|
1270
|
+
const glyphInfos = buffer.json(loadedFont.font);
|
|
1271
|
+
const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
|
|
1272
|
+
for (let i = 0; i < glyphInfos.length; i++) {
|
|
1273
|
+
const glyph = glyphInfos[i];
|
|
1274
|
+
const cl = glyph.cl ?? 0;
|
|
1275
|
+
let charIndex = startToCharIndex.get(cl);
|
|
1276
|
+
// Fallback if cl lands mid-codepoint
|
|
1277
|
+
if (charIndex === undefined) {
|
|
1278
|
+
// Find the closest start <= cl
|
|
1279
|
+
for (let back = cl; back >= 0; back--) {
|
|
1280
|
+
const candidate = startToCharIndex.get(back);
|
|
1281
|
+
if (candidate !== undefined) {
|
|
1282
|
+
charIndex = candidate;
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
if (charIndex === undefined)
|
|
1288
|
+
continue;
|
|
1289
|
+
widths[charIndex] += glyph.ax;
|
|
1290
|
+
if (letterSpacingInFontUnits !== 0) {
|
|
1291
|
+
widths[charIndex] += letterSpacingInFontUnits;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
return widths;
|
|
1295
|
+
}
|
|
1296
|
+
finally {
|
|
1297
|
+
buffer.destroy();
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1301
|
+
const buffer = loadedFont.hb.createBuffer();
|
|
1302
|
+
buffer.addText(text);
|
|
1303
|
+
buffer.guessSegmentProperties();
|
|
1304
|
+
const featuresString = convertFontFeaturesToString(loadedFont.fontFeatures);
|
|
1305
|
+
loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
|
|
1306
|
+
const glyphInfos = buffer.json(loadedFont.font);
|
|
1307
|
+
const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
|
|
1308
|
+
let totalWidth = 0;
|
|
1309
|
+
glyphInfos.forEach((glyph) => {
|
|
1310
|
+
totalWidth += glyph.ax;
|
|
1311
|
+
if (letterSpacingInFontUnits !== 0) {
|
|
1312
|
+
totalWidth += letterSpacingInFontUnits;
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
buffer.destroy();
|
|
1316
|
+
return totalWidth;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
class TextLayout {
|
|
1321
|
+
constructor(loadedFont) {
|
|
1322
|
+
this.loadedFont = loadedFont;
|
|
1323
|
+
}
|
|
1324
|
+
computeLines(options) {
|
|
1325
|
+
const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, letterSpacing } = options;
|
|
1326
|
+
let lines;
|
|
1327
|
+
if (width) {
|
|
1328
|
+
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
1329
|
+
// so widths passed into LineBreak.breakText account for tracking
|
|
1330
|
+
lines = LineBreak.breakText({
|
|
1331
|
+
text,
|
|
1332
|
+
width,
|
|
1333
|
+
align,
|
|
1334
|
+
direction,
|
|
1335
|
+
hyphenate,
|
|
1336
|
+
language,
|
|
1337
|
+
respectExistingBreaks,
|
|
1338
|
+
tolerance,
|
|
1339
|
+
pretolerance,
|
|
1340
|
+
emergencyStretch,
|
|
1341
|
+
autoEmergencyStretch,
|
|
1342
|
+
hyphenationPatterns,
|
|
1343
|
+
lefthyphenmin,
|
|
1344
|
+
righthyphenmin,
|
|
1345
|
+
linepenalty,
|
|
1346
|
+
adjdemerits,
|
|
1347
|
+
hyphenpenalty,
|
|
1348
|
+
exhyphenpenalty,
|
|
1349
|
+
doublehyphendemerits,
|
|
1350
|
+
unitsPerEm: this.loadedFont.upem,
|
|
1351
|
+
letterSpacing,
|
|
1352
|
+
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1353
|
+
),
|
|
1354
|
+
measureTextWidths: (textToMeasure) => TextMeasurer.measureTextWidths(this.loadedFont, textToMeasure, letterSpacing)
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
else {
|
|
1358
|
+
// No width specified, just split on newlines
|
|
1359
|
+
const linesArray = text.split('\n');
|
|
1360
|
+
lines = [];
|
|
1361
|
+
let currentIndex = 0;
|
|
1362
|
+
for (const line of linesArray) {
|
|
1363
|
+
const originalEnd = line.length === 0 ? currentIndex : currentIndex + line.length - 1;
|
|
1364
|
+
lines.push({
|
|
1365
|
+
text: line,
|
|
1366
|
+
originalStart: currentIndex,
|
|
1367
|
+
originalEnd,
|
|
1368
|
+
xOffset: 0
|
|
1369
|
+
});
|
|
1370
|
+
currentIndex += line.length + 1;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
return { lines };
|
|
1374
|
+
}
|
|
1375
|
+
applyAlignment(vertices, options) {
|
|
1376
|
+
const { offset, adjustedBounds } = this.computeAlignmentOffset(options);
|
|
1377
|
+
if (offset !== 0) {
|
|
1378
|
+
for (let i = 0; i < vertices.length; i += 3) {
|
|
1379
|
+
vertices[i] += offset;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return { offset, adjustedBounds };
|
|
1383
|
+
}
|
|
1384
|
+
computeAlignmentOffset(options) {
|
|
1385
|
+
const { width, align, planeBounds } = options;
|
|
1386
|
+
let offset = 0;
|
|
1387
|
+
const adjustedBounds = {
|
|
1388
|
+
min: { ...planeBounds.min },
|
|
1389
|
+
max: { ...planeBounds.max }
|
|
1390
|
+
};
|
|
1391
|
+
if (width && (align === 'center' || align === 'right')) {
|
|
1392
|
+
const lineWidth = planeBounds.max.x - planeBounds.min.x;
|
|
1393
|
+
if (align === 'center') {
|
|
1394
|
+
offset = (width - lineWidth) / 2 - planeBounds.min.x;
|
|
1395
|
+
}
|
|
1396
|
+
else {
|
|
1397
|
+
offset = width - planeBounds.max.x;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
if (offset !== 0) {
|
|
1401
|
+
adjustedBounds.min.x += offset;
|
|
1402
|
+
adjustedBounds.max.x += offset;
|
|
1403
|
+
}
|
|
1404
|
+
return { offset, adjustedBounds };
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Font file signature constants
|
|
1409
|
+
const FONT_SIGNATURE_TRUE_TYPE = 0x00010000;
|
|
1410
|
+
const FONT_SIGNATURE_OPEN_TYPE_CFF = 0x4f54544f; // 'OTTO'
|
|
1411
|
+
const FONT_SIGNATURE_WOFF = 0x774f4646; // 'wOFF'
|
|
1412
|
+
const FONT_SIGNATURE_WOFF2 = 0x774f4632; // 'wOF2'
|
|
1413
|
+
// Table Tags
|
|
1414
|
+
const TABLE_TAG_HEAD = 0x68656164; // 'head'
|
|
1415
|
+
const TABLE_TAG_HHEA = 0x68686561; // 'hhea'
|
|
1416
|
+
const TABLE_TAG_OS2 = 0x4f532f32; // 'OS/2'
|
|
1417
|
+
const TABLE_TAG_FVAR = 0x66766172; // 'fvar'
|
|
1418
|
+
const TABLE_TAG_STAT = 0x53544154; // 'STAT'
|
|
1419
|
+
const TABLE_TAG_NAME = 0x6e616d65; // 'name'
|
|
1420
|
+
const TABLE_TAG_CFF = 0x43464620; // 'CFF '
|
|
1421
|
+
const TABLE_TAG_CFF2 = 0x43464632; // 'CFF2'
|
|
1422
|
+
const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
|
|
1423
|
+
const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
|
|
1424
|
+
|
|
1425
|
+
// SFNT table directory
|
|
1426
|
+
function parseTableDirectory(view) {
|
|
1427
|
+
const numTables = view.getUint16(4);
|
|
1428
|
+
const tableRecordsStart = 12;
|
|
1429
|
+
const tables = new Map();
|
|
1430
|
+
for (let i = 0; i < numTables; i++) {
|
|
1431
|
+
const recordOffset = tableRecordsStart + i * 16;
|
|
1432
|
+
// Guard against corrupt buffers that report more tables than exist
|
|
1433
|
+
if (recordOffset + 16 > view.byteLength) {
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
const tag = view.getUint32(recordOffset);
|
|
1437
|
+
const checksum = view.getUint32(recordOffset + 4);
|
|
1438
|
+
const offset = view.getUint32(recordOffset + 8);
|
|
1439
|
+
const length = view.getUint32(recordOffset + 12);
|
|
1440
|
+
tables.set(tag, { tag, checksum, offset, length });
|
|
1441
|
+
}
|
|
1442
|
+
return tables;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// OpenType tag prefixes as uint16 for bitwise comparison
|
|
1446
|
+
const TAG_SS_PREFIX = 0x7373; // 'ss'
|
|
1447
|
+
const TAG_CV_PREFIX = 0x6376; // 'cv'
|
|
1448
|
+
const utf16beDecoder = new TextDecoder('utf-16be');
|
|
1449
|
+
class FontMetadataExtractor {
|
|
1450
|
+
static extractMetadata(fontBuffer) {
|
|
1451
|
+
if (!fontBuffer || fontBuffer.byteLength < 12) {
|
|
1452
|
+
throw new Error('Invalid font buffer: too small to be a valid font file');
|
|
1453
|
+
}
|
|
1454
|
+
const view = new DataView(fontBuffer);
|
|
1455
|
+
const sfntVersion = view.getUint32(0);
|
|
1456
|
+
const validSignatures = [
|
|
1457
|
+
FONT_SIGNATURE_TRUE_TYPE,
|
|
1458
|
+
FONT_SIGNATURE_OPEN_TYPE_CFF
|
|
1459
|
+
];
|
|
1460
|
+
if (!validSignatures.includes(sfntVersion)) {
|
|
1461
|
+
throw new Error(`Invalid font format. Expected TTF/OTF/WOFF/WOFF2, got signature: 0x${sfntVersion.toString(16)}`);
|
|
1462
|
+
}
|
|
1463
|
+
const tableDirectory = parseTableDirectory(view);
|
|
1464
|
+
const isCFF = tableDirectory.has(TABLE_TAG_CFF) || tableDirectory.has(TABLE_TAG_CFF2);
|
|
1465
|
+
const headTableOffset = tableDirectory.get(TABLE_TAG_HEAD)?.offset ?? 0;
|
|
1466
|
+
const hheaTableOffset = tableDirectory.get(TABLE_TAG_HHEA)?.offset ?? 0;
|
|
1467
|
+
const os2TableOffset = tableDirectory.get(TABLE_TAG_OS2)?.offset ?? 0;
|
|
1468
|
+
const fvarTableOffset = tableDirectory.get(TABLE_TAG_FVAR)?.offset ?? 0;
|
|
1469
|
+
const statTableOffset = tableDirectory.get(TABLE_TAG_STAT)?.offset ?? 0;
|
|
1470
|
+
const nameTableOffset = tableDirectory.get(TABLE_TAG_NAME)?.offset ?? 0;
|
|
1471
|
+
const unitsPerEm = headTableOffset
|
|
1472
|
+
? view.getUint16(headTableOffset + 18)
|
|
1473
|
+
: 1000;
|
|
1474
|
+
let hheaMetrics = null;
|
|
1475
|
+
if (hheaTableOffset) {
|
|
1476
|
+
hheaMetrics = {
|
|
1477
|
+
ascender: view.getInt16(hheaTableOffset + 4),
|
|
1478
|
+
descender: view.getInt16(hheaTableOffset + 6),
|
|
1479
|
+
lineGap: view.getInt16(hheaTableOffset + 8)
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
let os2Metrics = null;
|
|
1483
|
+
if (os2TableOffset) {
|
|
1484
|
+
os2Metrics = {
|
|
1485
|
+
typoAscender: view.getInt16(os2TableOffset + 68),
|
|
1486
|
+
typoDescender: view.getInt16(os2TableOffset + 70),
|
|
1487
|
+
typoLineGap: view.getInt16(os2TableOffset + 72),
|
|
1488
|
+
winAscent: view.getUint16(os2TableOffset + 74),
|
|
1489
|
+
winDescent: view.getUint16(os2TableOffset + 76)
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
// Extract axis names only for variable fonts (fvar table present) with STAT table
|
|
1493
|
+
let axisNames = null;
|
|
1494
|
+
if (fvarTableOffset && statTableOffset && nameTableOffset) {
|
|
1495
|
+
axisNames = this.extractAxisNames(view, statTableOffset, nameTableOffset);
|
|
1496
|
+
}
|
|
1497
|
+
return {
|
|
1498
|
+
isCFF,
|
|
1499
|
+
unitsPerEm,
|
|
1500
|
+
hheaAscender: hheaMetrics?.ascender || null,
|
|
1501
|
+
hheaDescender: hheaMetrics?.descender || null,
|
|
1502
|
+
hheaLineGap: hheaMetrics?.lineGap || null,
|
|
1503
|
+
typoAscender: os2Metrics?.typoAscender || null,
|
|
1504
|
+
typoDescender: os2Metrics?.typoDescender || null,
|
|
1505
|
+
typoLineGap: os2Metrics?.typoLineGap || null,
|
|
1506
|
+
winAscent: os2Metrics?.winAscent || null,
|
|
1507
|
+
winDescent: os2Metrics?.winDescent || null,
|
|
1508
|
+
axisNames
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
static extractFeatureTags(fontBuffer) {
|
|
1512
|
+
const view = new DataView(fontBuffer);
|
|
1513
|
+
const tableDirectory = parseTableDirectory(view);
|
|
1514
|
+
const gsubTableOffset = tableDirectory.get(TABLE_TAG_GSUB)?.offset ?? 0;
|
|
1515
|
+
const gposTableOffset = tableDirectory.get(TABLE_TAG_GPOS)?.offset ?? 0;
|
|
1516
|
+
const nameTableOffset = tableDirectory.get(TABLE_TAG_NAME)?.offset ?? 0;
|
|
1517
|
+
const features = new Set();
|
|
1518
|
+
const featureNames = {};
|
|
1519
|
+
try {
|
|
1520
|
+
if (gsubTableOffset) {
|
|
1521
|
+
const gsubData = this.extractFeatureDataFromTable(view, gsubTableOffset, nameTableOffset);
|
|
1522
|
+
gsubData.features.forEach((f) => features.add(f));
|
|
1523
|
+
Object.assign(featureNames, gsubData.names);
|
|
1524
|
+
}
|
|
1525
|
+
if (gposTableOffset) {
|
|
1526
|
+
const gposData = this.extractFeatureDataFromTable(view, gposTableOffset, nameTableOffset);
|
|
1527
|
+
gposData.features.forEach((f) => features.add(f));
|
|
1528
|
+
Object.assign(featureNames, gposData.names);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
catch (e) {
|
|
1532
|
+
return undefined;
|
|
1533
|
+
}
|
|
1534
|
+
const featureArray = Array.from(features).sort();
|
|
1535
|
+
if (featureArray.length === 0)
|
|
1536
|
+
return undefined;
|
|
1537
|
+
return {
|
|
1538
|
+
tags: featureArray,
|
|
1539
|
+
names: Object.keys(featureNames).length > 0 ? featureNames : {}
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
static extractFeatureDataFromTable(view, tableOffset, nameTableOffset) {
|
|
1543
|
+
const featureListOffset = view.getUint16(tableOffset + 6);
|
|
1544
|
+
const featureListStart = tableOffset + featureListOffset;
|
|
1545
|
+
const featureCount = view.getUint16(featureListStart);
|
|
1546
|
+
const features = [];
|
|
1547
|
+
const names = {};
|
|
1548
|
+
for (let i = 0; i < featureCount; i++) {
|
|
1549
|
+
const recordOffset = featureListStart + 2 + i * 6;
|
|
1550
|
+
const tag = String.fromCharCode(view.getUint8(recordOffset), view.getUint8(recordOffset + 1), view.getUint8(recordOffset + 2), view.getUint8(recordOffset + 3));
|
|
1551
|
+
features.push(tag);
|
|
1552
|
+
// Extract feature name for stylistic sets and character variants
|
|
1553
|
+
if (/^(ss\d{2}|cv\d{2})$/.test(tag) && nameTableOffset) {
|
|
1554
|
+
const featureOffset = view.getUint16(recordOffset + 4);
|
|
1555
|
+
const featureTableStart = featureListStart + featureOffset;
|
|
1556
|
+
// Feature table structure:
|
|
1557
|
+
// uint16 FeatureParams offset
|
|
1558
|
+
// uint16 LookupCount
|
|
1559
|
+
// uint16[LookupCount] LookupListIndex
|
|
1560
|
+
const featureParamsOffset = view.getUint16(featureTableStart);
|
|
1561
|
+
// FeatureParams for ss features:
|
|
1562
|
+
// uint16 Version (should be 0)
|
|
1563
|
+
// uint16 UINameID
|
|
1564
|
+
if (featureParamsOffset !== 0) {
|
|
1565
|
+
const paramsStart = featureTableStart + featureParamsOffset;
|
|
1566
|
+
const version = view.getUint16(paramsStart);
|
|
1567
|
+
if (version === 0) {
|
|
1568
|
+
const nameID = view.getUint16(paramsStart + 2);
|
|
1569
|
+
const name = this.getNameFromNameTable(view, nameTableOffset, nameID);
|
|
1570
|
+
if (name) {
|
|
1571
|
+
names[tag] = name;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
return { features, names };
|
|
1578
|
+
}
|
|
1579
|
+
static extractAxisNames(view, statOffset, nameOffset) {
|
|
1580
|
+
try {
|
|
1581
|
+
const majorVersion = view.getUint16(statOffset);
|
|
1582
|
+
if (majorVersion < 1)
|
|
1583
|
+
return null;
|
|
1584
|
+
const designAxisSize = view.getUint16(statOffset + 4);
|
|
1585
|
+
const designAxisCount = view.getUint16(statOffset + 6);
|
|
1586
|
+
const designAxisOffset = view.getUint32(statOffset + 8);
|
|
1587
|
+
const axisNames = {};
|
|
1588
|
+
// Read each design axis record (size specified by designAxisSize)
|
|
1589
|
+
for (let i = 0; i < designAxisCount; i++) {
|
|
1590
|
+
const axisRecordOffset = statOffset + designAxisOffset + i * designAxisSize;
|
|
1591
|
+
const axisTag = String.fromCharCode(view.getUint8(axisRecordOffset), view.getUint8(axisRecordOffset + 1), view.getUint8(axisRecordOffset + 2), view.getUint8(axisRecordOffset + 3));
|
|
1592
|
+
const axisNameID = view.getUint16(axisRecordOffset + 4);
|
|
1593
|
+
const name = this.getNameFromNameTable(view, nameOffset, axisNameID);
|
|
1594
|
+
if (name) {
|
|
1595
|
+
axisNames[axisTag] = name;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
return Object.keys(axisNames).length > 0 ? axisNames : null;
|
|
1599
|
+
}
|
|
1600
|
+
catch (e) {
|
|
1601
|
+
return null;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
static getNameFromNameTable(view, nameOffset, nameID) {
|
|
1605
|
+
try {
|
|
1606
|
+
const count = view.getUint16(nameOffset + 2);
|
|
1607
|
+
const stringOffset = view.getUint16(nameOffset + 4);
|
|
1608
|
+
for (let i = 0; i < count; i++) {
|
|
1609
|
+
const recordOffset = nameOffset + 6 + i * 12;
|
|
1610
|
+
const platformID = view.getUint16(recordOffset);
|
|
1611
|
+
const encodingID = view.getUint16(recordOffset + 2);
|
|
1612
|
+
const languageID = view.getUint16(recordOffset + 4);
|
|
1613
|
+
const recordNameID = view.getUint16(recordOffset + 6);
|
|
1614
|
+
const length = view.getUint16(recordOffset + 8);
|
|
1615
|
+
const offset = view.getUint16(recordOffset + 10);
|
|
1616
|
+
if (recordNameID !== nameID)
|
|
1617
|
+
continue;
|
|
1618
|
+
// Accept Unicode platform or Windows US English
|
|
1619
|
+
if (platformID !== 0 && !(platformID === 3 && languageID === 0x0409)) {
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
const stringStart = nameOffset + stringOffset + offset;
|
|
1623
|
+
const bytes = new Uint8Array(view.buffer, stringStart, length);
|
|
1624
|
+
if (platformID === 0 || (platformID === 3 && encodingID === 1)) {
|
|
1625
|
+
let str = '';
|
|
1626
|
+
for (let j = 0; j < bytes.length; j += 2) {
|
|
1627
|
+
str += String.fromCharCode((bytes[j] << 8) | bytes[j + 1]);
|
|
1628
|
+
}
|
|
1629
|
+
return str;
|
|
1630
|
+
}
|
|
1631
|
+
return new TextDecoder('ascii').decode(bytes);
|
|
1632
|
+
}
|
|
1633
|
+
return null;
|
|
1634
|
+
}
|
|
1635
|
+
catch (e) {
|
|
1636
|
+
return null;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
static extractAll(fontBuffer) {
|
|
1640
|
+
if (!fontBuffer || fontBuffer.byteLength < 12) {
|
|
1641
|
+
throw new Error('Invalid font buffer: too small to be a valid font file');
|
|
1642
|
+
}
|
|
1643
|
+
const view = new DataView(fontBuffer);
|
|
1644
|
+
const sfntVersion = view.getUint32(0);
|
|
1645
|
+
const validSignatures = [
|
|
1646
|
+
FONT_SIGNATURE_TRUE_TYPE,
|
|
1647
|
+
FONT_SIGNATURE_OPEN_TYPE_CFF
|
|
1648
|
+
];
|
|
1649
|
+
if (!validSignatures.includes(sfntVersion)) {
|
|
1650
|
+
throw new Error(`Invalid font format. Expected TTF/OTF/WOFF/WOFF2, got signature: 0x${sfntVersion.toString(16)}`);
|
|
1651
|
+
}
|
|
1652
|
+
const tableDirectory = parseTableDirectory(view);
|
|
1653
|
+
const nameTableOffset = tableDirectory.get(TABLE_TAG_NAME)?.offset ?? 0;
|
|
1654
|
+
const nameIndex = nameTableOffset
|
|
1655
|
+
? this.buildNameIndex(view, nameTableOffset)
|
|
1656
|
+
: null;
|
|
1657
|
+
const metrics = this.extractMetricsWithIndex(view, tableDirectory, nameIndex);
|
|
1658
|
+
const features = this.extractFeaturesWithIndex(view, tableDirectory, nameIndex);
|
|
1659
|
+
return { metrics, features };
|
|
1660
|
+
}
|
|
1661
|
+
// Index name records by nameID; prefers Unicode (platform 0) or Windows US English
|
|
1662
|
+
static buildNameIndex(view, nameOffset) {
|
|
1663
|
+
const index = new Map();
|
|
1664
|
+
const count = view.getUint16(nameOffset + 2);
|
|
1665
|
+
const stringOffset = view.getUint16(nameOffset + 4);
|
|
1666
|
+
for (let i = 0; i < count; i++) {
|
|
1667
|
+
const recordOffset = nameOffset + 6 + i * 12;
|
|
1668
|
+
const platformID = view.getUint16(recordOffset);
|
|
1669
|
+
const encodingID = view.getUint16(recordOffset + 2);
|
|
1670
|
+
const languageID = view.getUint16(recordOffset + 4);
|
|
1671
|
+
const nameID = view.getUint16(recordOffset + 6);
|
|
1672
|
+
const length = view.getUint16(recordOffset + 8);
|
|
1673
|
+
const offset = view.getUint16(recordOffset + 10);
|
|
1674
|
+
if (platformID === 0 || (platformID === 3 && languageID === 0x0409)) {
|
|
1675
|
+
if (!index.has(nameID)) {
|
|
1676
|
+
index.set(nameID, {
|
|
1677
|
+
offset: nameOffset + stringOffset + offset,
|
|
1678
|
+
length,
|
|
1679
|
+
platformID,
|
|
1680
|
+
encodingID
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
return index;
|
|
1686
|
+
}
|
|
1687
|
+
static getNameFromIndex(view, nameIndex, nameID) {
|
|
1688
|
+
if (!nameIndex)
|
|
1689
|
+
return null;
|
|
1690
|
+
const record = nameIndex.get(nameID);
|
|
1691
|
+
if (!record)
|
|
1692
|
+
return null;
|
|
1693
|
+
try {
|
|
1694
|
+
const bytes = new Uint8Array(view.buffer, record.offset, record.length);
|
|
1695
|
+
// UTF-16BE for Unicode/Windows platform with encoding 1
|
|
1696
|
+
if (record.platformID === 0 ||
|
|
1697
|
+
(record.platformID === 3 && record.encodingID === 1)) {
|
|
1698
|
+
return utf16beDecoder.decode(bytes);
|
|
1699
|
+
}
|
|
1700
|
+
// ASCII fallback
|
|
1701
|
+
return new TextDecoder('ascii').decode(bytes);
|
|
1702
|
+
}
|
|
1703
|
+
catch {
|
|
1704
|
+
return null;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
static extractMetricsWithIndex(view, tableDirectory, nameIndex) {
|
|
1708
|
+
const isCFF = tableDirectory.has(TABLE_TAG_CFF) || tableDirectory.has(TABLE_TAG_CFF2);
|
|
1709
|
+
const headTableOffset = tableDirectory.get(TABLE_TAG_HEAD)?.offset ?? 0;
|
|
1710
|
+
const hheaTableOffset = tableDirectory.get(TABLE_TAG_HHEA)?.offset ?? 0;
|
|
1711
|
+
const os2TableOffset = tableDirectory.get(TABLE_TAG_OS2)?.offset ?? 0;
|
|
1712
|
+
const fvarTableOffset = tableDirectory.get(TABLE_TAG_FVAR)?.offset ?? 0;
|
|
1713
|
+
const statTableOffset = tableDirectory.get(TABLE_TAG_STAT)?.offset ?? 0;
|
|
1714
|
+
const unitsPerEm = headTableOffset
|
|
1715
|
+
? view.getUint16(headTableOffset + 18)
|
|
1716
|
+
: 1000;
|
|
1717
|
+
let hheaMetrics = null;
|
|
1718
|
+
if (hheaTableOffset) {
|
|
1719
|
+
hheaMetrics = {
|
|
1720
|
+
ascender: view.getInt16(hheaTableOffset + 4),
|
|
1721
|
+
descender: view.getInt16(hheaTableOffset + 6),
|
|
1722
|
+
lineGap: view.getInt16(hheaTableOffset + 8)
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
let os2Metrics = null;
|
|
1726
|
+
if (os2TableOffset) {
|
|
1727
|
+
os2Metrics = {
|
|
1728
|
+
typoAscender: view.getInt16(os2TableOffset + 68),
|
|
1729
|
+
typoDescender: view.getInt16(os2TableOffset + 70),
|
|
1730
|
+
typoLineGap: view.getInt16(os2TableOffset + 72),
|
|
1731
|
+
winAscent: view.getUint16(os2TableOffset + 74),
|
|
1732
|
+
winDescent: view.getUint16(os2TableOffset + 76)
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
let axisNames = null;
|
|
1736
|
+
if (fvarTableOffset && statTableOffset && nameIndex) {
|
|
1737
|
+
axisNames = this.extractAxisNamesWithIndex(view, statTableOffset, nameIndex);
|
|
1738
|
+
}
|
|
1739
|
+
return {
|
|
1740
|
+
isCFF,
|
|
1741
|
+
unitsPerEm,
|
|
1742
|
+
hheaAscender: hheaMetrics?.ascender || null,
|
|
1743
|
+
hheaDescender: hheaMetrics?.descender || null,
|
|
1744
|
+
hheaLineGap: hheaMetrics?.lineGap || null,
|
|
1745
|
+
typoAscender: os2Metrics?.typoAscender || null,
|
|
1746
|
+
typoDescender: os2Metrics?.typoDescender || null,
|
|
1747
|
+
typoLineGap: os2Metrics?.typoLineGap || null,
|
|
1748
|
+
winAscent: os2Metrics?.winAscent || null,
|
|
1749
|
+
winDescent: os2Metrics?.winDescent || null,
|
|
1750
|
+
axisNames
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
static extractAxisNamesWithIndex(view, statOffset, nameIndex) {
|
|
1754
|
+
try {
|
|
1755
|
+
const majorVersion = view.getUint16(statOffset);
|
|
1756
|
+
if (majorVersion < 1)
|
|
1757
|
+
return null;
|
|
1758
|
+
const designAxisSize = view.getUint16(statOffset + 4);
|
|
1759
|
+
const designAxisCount = view.getUint16(statOffset + 6);
|
|
1760
|
+
const designAxisOffset = view.getUint32(statOffset + 8);
|
|
1761
|
+
const axisNames = {};
|
|
1762
|
+
for (let i = 0; i < designAxisCount; i++) {
|
|
1763
|
+
const axisRecordOffset = statOffset + designAxisOffset + i * designAxisSize;
|
|
1764
|
+
const axisTag = String.fromCharCode(view.getUint8(axisRecordOffset), view.getUint8(axisRecordOffset + 1), view.getUint8(axisRecordOffset + 2), view.getUint8(axisRecordOffset + 3));
|
|
1765
|
+
const axisNameID = view.getUint16(axisRecordOffset + 4);
|
|
1766
|
+
const name = this.getNameFromIndex(view, nameIndex, axisNameID);
|
|
1767
|
+
if (name) {
|
|
1768
|
+
axisNames[axisTag] = name;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
return Object.keys(axisNames).length > 0 ? axisNames : null;
|
|
1772
|
+
}
|
|
1773
|
+
catch {
|
|
1774
|
+
return null;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
static extractFeaturesWithIndex(view, tableDirectory, nameIndex) {
|
|
1778
|
+
const gsubTableOffset = tableDirectory.get(TABLE_TAG_GSUB)?.offset ?? 0;
|
|
1779
|
+
const gposTableOffset = tableDirectory.get(TABLE_TAG_GPOS)?.offset ?? 0;
|
|
1780
|
+
// Tags stored as uint32 during iteration, converted to strings at end
|
|
1781
|
+
const featureTags = new Set();
|
|
1782
|
+
const featureNames = {};
|
|
1783
|
+
try {
|
|
1784
|
+
if (gsubTableOffset) {
|
|
1785
|
+
this.extractFeatureData(view, gsubTableOffset, nameIndex, featureTags, featureNames);
|
|
1786
|
+
}
|
|
1787
|
+
if (gposTableOffset) {
|
|
1788
|
+
this.extractFeatureData(view, gposTableOffset, nameIndex, featureTags, featureNames);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
catch {
|
|
1792
|
+
return undefined;
|
|
1793
|
+
}
|
|
1794
|
+
if (featureTags.size === 0)
|
|
1795
|
+
return undefined;
|
|
1796
|
+
// Numeric sort preserves alphabetical order for big-endian tags
|
|
1797
|
+
const sortedTags = Array.from(featureTags).sort((a, b) => a - b);
|
|
1798
|
+
const featureArray = sortedTags.map(this.tagToString);
|
|
1799
|
+
return {
|
|
1800
|
+
tags: featureArray,
|
|
1801
|
+
names: Object.keys(featureNames).length > 0 ? featureNames : {}
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
static extractFeatureData(view, tableOffset, nameIndex, featureTags, featureNames) {
|
|
1805
|
+
const featureListOffset = view.getUint16(tableOffset + 6);
|
|
1806
|
+
const featureListStart = tableOffset + featureListOffset;
|
|
1807
|
+
const featureCount = view.getUint16(featureListStart);
|
|
1808
|
+
for (let i = 0; i < featureCount; i++) {
|
|
1809
|
+
const recordOffset = featureListStart + 2 + i * 6;
|
|
1810
|
+
const tagBytes = view.getUint32(recordOffset);
|
|
1811
|
+
featureTags.add(tagBytes);
|
|
1812
|
+
// Stylistic sets (ss01-ss20) and character variants (cv01-cv99) may have UI names
|
|
1813
|
+
const prefix = (tagBytes >> 16) & 0xffff;
|
|
1814
|
+
if (!nameIndex)
|
|
1815
|
+
continue;
|
|
1816
|
+
if (prefix !== TAG_SS_PREFIX && prefix !== TAG_CV_PREFIX)
|
|
1817
|
+
continue;
|
|
1818
|
+
const d1 = (tagBytes >> 8) & 0xff;
|
|
1819
|
+
const d2 = tagBytes & 0xff;
|
|
1820
|
+
if (d1 < 0x30 || d1 > 0x39 || d2 < 0x30 || d2 > 0x39)
|
|
1821
|
+
continue;
|
|
1822
|
+
const featureOffset = view.getUint16(recordOffset + 4);
|
|
1823
|
+
const featureTableStart = featureListStart + featureOffset;
|
|
1824
|
+
const featureParamsOffset = view.getUint16(featureTableStart);
|
|
1825
|
+
if (featureParamsOffset === 0)
|
|
1826
|
+
continue;
|
|
1827
|
+
const paramsStart = featureTableStart + featureParamsOffset;
|
|
1828
|
+
const version = view.getUint16(paramsStart);
|
|
1829
|
+
if (version !== 0)
|
|
1830
|
+
continue;
|
|
1831
|
+
const nameID = view.getUint16(paramsStart + 2);
|
|
1832
|
+
const name = this.getNameFromIndex(view, nameIndex, nameID);
|
|
1833
|
+
if (name) {
|
|
1834
|
+
const tag = String.fromCharCode((tagBytes >> 24) & 0xff, (tagBytes >> 16) & 0xff, (tagBytes >> 8) & 0xff, tagBytes & 0xff);
|
|
1835
|
+
featureNames[tag] = name;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
static tagToString(tag) {
|
|
1840
|
+
return String.fromCharCode((tag >> 24) & 0xff, (tag >> 16) & 0xff, (tag >> 8) & 0xff, tag & 0xff);
|
|
1841
|
+
}
|
|
1842
|
+
// Metric priority: typo > hhea > win > fallback (ignoring line gap per Google's approach)
|
|
1843
|
+
static getVerticalMetrics(metrics) {
|
|
1844
|
+
if (metrics.typoAscender !== null && metrics.typoDescender !== null) {
|
|
1845
|
+
return {
|
|
1846
|
+
ascender: metrics.typoAscender,
|
|
1847
|
+
descender: metrics.typoDescender,
|
|
1848
|
+
lineGap: 0
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
if (metrics.hheaAscender !== null && metrics.hheaDescender !== null) {
|
|
1852
|
+
return {
|
|
1853
|
+
ascender: metrics.hheaAscender,
|
|
1854
|
+
descender: metrics.hheaDescender,
|
|
1855
|
+
lineGap: 0
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
if (metrics.winAscent !== null && metrics.winDescent !== null) {
|
|
1859
|
+
return {
|
|
1860
|
+
ascender: metrics.winAscent,
|
|
1861
|
+
descender: -metrics.winDescent, // winDescent is typically positive
|
|
1862
|
+
lineGap: 0
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
// Last resort - default based on UPM
|
|
1866
|
+
return {
|
|
1867
|
+
ascender: Math.round(metrics.unitsPerEm * 0.8),
|
|
1868
|
+
descender: -Math.round(metrics.unitsPerEm * 0.2),
|
|
1869
|
+
lineGap: 0
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
static getFontMetrics(metrics) {
|
|
1873
|
+
const verticalMetrics = FontMetadataExtractor.getVerticalMetrics(metrics);
|
|
1874
|
+
return {
|
|
1875
|
+
ascender: verticalMetrics.ascender,
|
|
1876
|
+
descender: verticalMetrics.descender,
|
|
1877
|
+
lineGap: verticalMetrics.lineGap,
|
|
1878
|
+
unitsPerEm: metrics.unitsPerEm,
|
|
1879
|
+
naturalLineHeight: verticalMetrics.ascender - verticalMetrics.descender
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
let woff2Decoder = null;
|
|
1885
|
+
function setWoff2Decoder(decoder) {
|
|
1886
|
+
woff2Decoder = decoder;
|
|
1887
|
+
}
|
|
1888
|
+
// Uses DecompressionStream to decompress WOFF (WOFF is just zlib compressed TTF/OTF so we can use deflate)
|
|
1889
|
+
class WoffConverter {
|
|
1890
|
+
static detectFormat(buffer) {
|
|
1891
|
+
if (buffer.byteLength < 4) {
|
|
1892
|
+
return 'ttf/otf';
|
|
1893
|
+
}
|
|
1894
|
+
const view = new DataView(buffer);
|
|
1895
|
+
const signature = view.getUint32(0);
|
|
1896
|
+
if (signature === FONT_SIGNATURE_WOFF) {
|
|
1897
|
+
return 'woff';
|
|
1898
|
+
}
|
|
1899
|
+
if (signature === FONT_SIGNATURE_WOFF2) {
|
|
1900
|
+
return 'woff2';
|
|
1901
|
+
}
|
|
1902
|
+
return 'ttf/otf';
|
|
1903
|
+
}
|
|
1904
|
+
static async decompressWoff(woffBuffer) {
|
|
1905
|
+
const view = new DataView(woffBuffer);
|
|
1906
|
+
const data = new Uint8Array(woffBuffer);
|
|
1907
|
+
// WOFF Header structure:
|
|
1908
|
+
// Offset Size Description
|
|
1909
|
+
// 0 4 signature (0x774F4646 'wOFF')
|
|
1910
|
+
// 4 4 flavor (TTF = 0x00010000, CFF = 0x4F54544F)
|
|
1911
|
+
// 8 4 length (total size)
|
|
1912
|
+
// 12 2 numTables
|
|
1913
|
+
// 14 2 reserved
|
|
1914
|
+
// 16 4 totalSfntSize (size of uncompressed font)
|
|
1915
|
+
// 20 2 majorVersion
|
|
1916
|
+
// 22 2 minorVersion
|
|
1917
|
+
// 24 4 metaOffset
|
|
1918
|
+
// 28 4 metaLength
|
|
1919
|
+
// 32 4 metaOrigLength
|
|
1920
|
+
// 36 4 privOffset
|
|
1921
|
+
// 40 4 privLength
|
|
1922
|
+
const signature = view.getUint32(0);
|
|
1923
|
+
if (signature !== FONT_SIGNATURE_WOFF) {
|
|
1924
|
+
throw new Error('Not a valid WOFF font');
|
|
1925
|
+
}
|
|
1926
|
+
const flavor = view.getUint32(4);
|
|
1927
|
+
const numTables = view.getUint16(12);
|
|
1928
|
+
const totalSfntSize = view.getUint32(16);
|
|
1929
|
+
// Check for DecompressionStream support
|
|
1930
|
+
if (typeof DecompressionStream === 'undefined') {
|
|
1931
|
+
throw new Error('WOFF fonts require DecompressionStream API (Chrome 80+, Firefox 113+, Safari 16.4+). ' +
|
|
1932
|
+
'Please use TTF/OTF fonts or upgrade your browser.');
|
|
1933
|
+
}
|
|
1934
|
+
// Create the output buffer for the TTF/OTF font
|
|
1935
|
+
const sfntData = new Uint8Array(totalSfntSize);
|
|
1936
|
+
const sfntView = new DataView(sfntData.buffer);
|
|
1937
|
+
// Write SFNT header. The flavor (0x00010000 for TrueType, 0x4F54544F for CFF)
|
|
1938
|
+
// determines glyph winding order and will be detected by FontMetadataExtractor.
|
|
1939
|
+
sfntView.setUint32(0, flavor);
|
|
1940
|
+
sfntView.setUint16(4, numTables); // numTables
|
|
1941
|
+
const searchRange = 2 ** Math.floor(Math.log2(numTables)) * 16;
|
|
1942
|
+
sfntView.setUint16(6, searchRange);
|
|
1943
|
+
sfntView.setUint16(8, Math.floor(Math.log2(numTables)));
|
|
1944
|
+
sfntView.setUint16(10, numTables * 16 - searchRange);
|
|
1945
|
+
let sfntOffset = 12 + numTables * 16; // Start of table data
|
|
1946
|
+
const tableDirectory = [];
|
|
1947
|
+
// Read WOFF table directory
|
|
1948
|
+
for (let i = 0; i < numTables; i++) {
|
|
1949
|
+
const tableOffset = 44 + i * 20;
|
|
1950
|
+
tableDirectory.push({
|
|
1951
|
+
tag: view.getUint32(tableOffset),
|
|
1952
|
+
offset: view.getUint32(tableOffset + 4),
|
|
1953
|
+
length: view.getUint32(tableOffset + 8), // compressed length
|
|
1954
|
+
origLength: view.getUint32(tableOffset + 12),
|
|
1955
|
+
checksum: view.getUint32(tableOffset + 16)
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
// Sort tables by tag (required for SFNT)
|
|
1959
|
+
tableDirectory.sort((a, b) => a.tag - b.tag);
|
|
1960
|
+
// Tables are independent, decompress in parallel
|
|
1961
|
+
const decompressedTables = await Promise.all(tableDirectory.map(async (table) => {
|
|
1962
|
+
if (table.length === table.origLength) {
|
|
1963
|
+
return data.subarray(table.offset, table.offset + table.length);
|
|
1964
|
+
}
|
|
1965
|
+
const compressedData = data.subarray(table.offset, table.offset + table.length);
|
|
1966
|
+
const decompressed = await WoffConverter.decompressZlib(compressedData);
|
|
1967
|
+
if (decompressed.byteLength !== table.origLength) {
|
|
1968
|
+
throw new Error(`Decompression failed: expected ${table.origLength} bytes, got ${decompressed.byteLength}`);
|
|
1969
|
+
}
|
|
1970
|
+
return new Uint8Array(decompressed);
|
|
1971
|
+
}));
|
|
1972
|
+
// Write SFNT table directory and table data
|
|
1973
|
+
for (let i = 0; i < numTables; i++) {
|
|
1974
|
+
const table = tableDirectory[i];
|
|
1975
|
+
const dirOffset = 12 + i * 16;
|
|
1976
|
+
sfntView.setUint32(dirOffset, table.tag);
|
|
1977
|
+
sfntView.setUint32(dirOffset + 4, table.checksum);
|
|
1978
|
+
sfntView.setUint32(dirOffset + 8, sfntOffset);
|
|
1979
|
+
sfntView.setUint32(dirOffset + 12, table.origLength);
|
|
1980
|
+
sfntData.set(decompressedTables[i], sfntOffset);
|
|
1981
|
+
// Add padding to 4-byte boundary
|
|
1982
|
+
sfntOffset += table.origLength;
|
|
1983
|
+
const padding = (4 - (table.origLength % 4)) % 4;
|
|
1984
|
+
sfntOffset += padding;
|
|
1985
|
+
}
|
|
1986
|
+
logger.log('WOFF font decompressed successfully');
|
|
1987
|
+
return sfntData.buffer.slice(0, sfntOffset);
|
|
1988
|
+
}
|
|
1989
|
+
static async decompressWoff2(woff2Buffer) {
|
|
1990
|
+
const view = new DataView(woff2Buffer);
|
|
1991
|
+
const signature = view.getUint32(0);
|
|
1992
|
+
if (signature !== FONT_SIGNATURE_WOFF2) {
|
|
1993
|
+
throw new Error('Not a valid WOFF2 font');
|
|
1994
|
+
}
|
|
1995
|
+
if (!woff2Decoder) {
|
|
1996
|
+
throw new Error('WOFF2 fonts require enabling the decoder. Add to your code:\n' +
|
|
1997
|
+
" import { woff2Decode } from 'woff-lib/woff2/decode';\n" +
|
|
1998
|
+
' Text.enableWoff2(woff2Decode);');
|
|
1999
|
+
}
|
|
2000
|
+
const decoded = await woff2Decoder(woff2Buffer);
|
|
2001
|
+
logger.log('WOFF2 font decompressed successfully');
|
|
2002
|
+
return decoded.buffer;
|
|
2003
|
+
}
|
|
2004
|
+
static async decompressZlib(compressedData) {
|
|
2005
|
+
const stream = new ReadableStream({
|
|
2006
|
+
start(controller) {
|
|
2007
|
+
controller.enqueue(compressedData);
|
|
2008
|
+
controller.close();
|
|
2009
|
+
}
|
|
2010
|
+
}).pipeThrough(new DecompressionStream('deflate'));
|
|
2011
|
+
const response = new Response(stream);
|
|
2012
|
+
return response.arrayBuffer();
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
class FontLoader {
|
|
2017
|
+
constructor(getHarfBuzzInstance) {
|
|
2018
|
+
this.getHarfBuzzInstance = getHarfBuzzInstance;
|
|
2019
|
+
}
|
|
2020
|
+
async loadFont(fontBuffer, fontVariations) {
|
|
2021
|
+
perfLogger.start('FontLoader.loadFont', {
|
|
2022
|
+
bufferSize: fontBuffer.byteLength
|
|
2023
|
+
});
|
|
2024
|
+
if (!fontBuffer || fontBuffer.byteLength < 12) {
|
|
2025
|
+
throw new Error('Invalid font buffer: too small to be a valid font file');
|
|
2026
|
+
}
|
|
2027
|
+
// Check if this is a WOFF font and decompress if needed
|
|
2028
|
+
const format = WoffConverter.detectFormat(fontBuffer);
|
|
2029
|
+
if (format === 'woff') {
|
|
2030
|
+
logger.log('WOFF font detected, decompressing...');
|
|
2031
|
+
fontBuffer = await WoffConverter.decompressWoff(fontBuffer);
|
|
2032
|
+
}
|
|
2033
|
+
else if (format === 'woff2') {
|
|
2034
|
+
logger.log('WOFF2 font detected, decompressing...');
|
|
2035
|
+
fontBuffer = await WoffConverter.decompressWoff2(fontBuffer);
|
|
2036
|
+
}
|
|
2037
|
+
const view = new DataView(fontBuffer);
|
|
2038
|
+
const sfntVersion = view.getUint32(0);
|
|
2039
|
+
const validSignatures = [
|
|
2040
|
+
FONT_SIGNATURE_TRUE_TYPE,
|
|
2041
|
+
FONT_SIGNATURE_OPEN_TYPE_CFF
|
|
2042
|
+
];
|
|
2043
|
+
if (!validSignatures.includes(sfntVersion)) {
|
|
2044
|
+
throw new Error(`Invalid font format. Expected TTF/OTF/WOFF/WOFF2, got signature: 0x${sfntVersion.toString(16)}`);
|
|
2045
|
+
}
|
|
2046
|
+
const { hb, module } = await this.getHarfBuzzInstance();
|
|
2047
|
+
try {
|
|
2048
|
+
const fontBlob = hb.createBlob(new Uint8Array(fontBuffer));
|
|
2049
|
+
const face = hb.createFace(fontBlob, 0);
|
|
2050
|
+
const font = hb.createFont(face);
|
|
2051
|
+
if (fontVariations) {
|
|
2052
|
+
font.setVariations(fontVariations);
|
|
2053
|
+
}
|
|
2054
|
+
const axisInfos = face.getAxisInfos();
|
|
2055
|
+
const isVariable = Object.keys(axisInfos).length > 0;
|
|
2056
|
+
const { metrics, features: featureData } = FontMetadataExtractor.extractAll(fontBuffer);
|
|
2057
|
+
// Merge axis names from STAT table with HarfBuzz axis info
|
|
2058
|
+
let variationAxes = undefined;
|
|
2059
|
+
if (isVariable && axisInfos) {
|
|
2060
|
+
variationAxes = {};
|
|
2061
|
+
for (const [tag, info] of Object.entries(axisInfos)) {
|
|
2062
|
+
variationAxes[tag] = {
|
|
2063
|
+
...info,
|
|
2064
|
+
name: metrics.axisNames?.[tag] || null
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
return {
|
|
2069
|
+
hb,
|
|
2070
|
+
fontBlob,
|
|
2071
|
+
face,
|
|
2072
|
+
font,
|
|
2073
|
+
module,
|
|
2074
|
+
upem: metrics.unitsPerEm,
|
|
2075
|
+
metrics,
|
|
2076
|
+
fontVariations,
|
|
2077
|
+
isVariable,
|
|
2078
|
+
variationAxes,
|
|
2079
|
+
availableFeatures: featureData?.tags,
|
|
2080
|
+
featureNames: featureData?.names,
|
|
2081
|
+
_buffer: fontBuffer // For stable font ID generation
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
catch (error) {
|
|
2085
|
+
logger.error('Failed to load font:', error);
|
|
2086
|
+
throw error;
|
|
2087
|
+
}
|
|
2088
|
+
finally {
|
|
2089
|
+
perfLogger.end('FontLoader.loadFont');
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
static destroyFont(loadedFont) {
|
|
2093
|
+
try {
|
|
2094
|
+
if (loadedFont.font && typeof loadedFont.font.destroy === 'function') {
|
|
2095
|
+
loadedFont.font.destroy();
|
|
2096
|
+
}
|
|
2097
|
+
if (loadedFont.face && typeof loadedFont.face.destroy === 'function') {
|
|
2098
|
+
loadedFont.face.destroy();
|
|
2099
|
+
}
|
|
2100
|
+
if (loadedFont.fontBlob &&
|
|
2101
|
+
typeof loadedFont.fontBlob.destroy === 'function') {
|
|
2102
|
+
loadedFont.fontBlob.destroy();
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
catch (error) {
|
|
2106
|
+
logger.error('Error destroying font resources:', error);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
const SAFE_LANGUAGE_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,16})*$/i;
|
|
2112
|
+
// Built-in patterns shipped with three-text (matches files in src/hyphenation/*)
|
|
2113
|
+
// Note: GPL-licensed patterns (cs, id, mk, sr-cyrl) are excluded for MIT compatibility
|
|
2114
|
+
const BUILTIN_PATTERN_LANGUAGES = new Set([
|
|
2115
|
+
'af',
|
|
2116
|
+
'as',
|
|
2117
|
+
'be',
|
|
2118
|
+
'bg',
|
|
2119
|
+
'bn',
|
|
2120
|
+
'ca',
|
|
2121
|
+
'cy',
|
|
2122
|
+
'da',
|
|
2123
|
+
'de-1996',
|
|
2124
|
+
'el-monoton',
|
|
2125
|
+
'el-polyton',
|
|
2126
|
+
'en-gb',
|
|
2127
|
+
'en-us',
|
|
2128
|
+
'eo',
|
|
2129
|
+
'es',
|
|
2130
|
+
'et',
|
|
2131
|
+
'eu',
|
|
2132
|
+
'fi',
|
|
2133
|
+
'fr',
|
|
2134
|
+
'fur',
|
|
2135
|
+
'ga',
|
|
2136
|
+
'gl',
|
|
2137
|
+
'gu',
|
|
2138
|
+
'hi',
|
|
2139
|
+
'hr',
|
|
2140
|
+
'hsb',
|
|
2141
|
+
'hu',
|
|
2142
|
+
'hy',
|
|
2143
|
+
'ia',
|
|
2144
|
+
'is',
|
|
2145
|
+
'it',
|
|
2146
|
+
'ka',
|
|
2147
|
+
'kmr',
|
|
2148
|
+
'kn',
|
|
2149
|
+
'la',
|
|
2150
|
+
'lt',
|
|
2151
|
+
'lv',
|
|
2152
|
+
'ml',
|
|
2153
|
+
'mn-cyrl',
|
|
2154
|
+
'mr',
|
|
2155
|
+
'mul-ethi',
|
|
2156
|
+
'nb',
|
|
2157
|
+
'nl',
|
|
2158
|
+
'nn',
|
|
2159
|
+
'oc',
|
|
2160
|
+
'or',
|
|
2161
|
+
'pa',
|
|
2162
|
+
'pl',
|
|
2163
|
+
'pms',
|
|
2164
|
+
'pt',
|
|
2165
|
+
'rm',
|
|
2166
|
+
'ro',
|
|
2167
|
+
'ru',
|
|
2168
|
+
'sa',
|
|
2169
|
+
'sh-cyrl',
|
|
2170
|
+
'sh-latn',
|
|
2171
|
+
'sk',
|
|
2172
|
+
'sl',
|
|
2173
|
+
'sq',
|
|
2174
|
+
'sv',
|
|
2175
|
+
'ta',
|
|
2176
|
+
'te',
|
|
2177
|
+
'th',
|
|
2178
|
+
'tk',
|
|
2179
|
+
'tr',
|
|
2180
|
+
'uk',
|
|
2181
|
+
'zh-latn-pinyin'
|
|
2182
|
+
]);
|
|
2183
|
+
async function loadPattern(language, patternsPath) {
|
|
2184
|
+
if (!SAFE_LANGUAGE_RE.test(language)) {
|
|
2185
|
+
throw new Error(`Invalid hyphenation language code "${language}". Expected e.g. "en-us".`);
|
|
2186
|
+
}
|
|
2187
|
+
// When no patternsPath is provided, we only allow the built-in set shipped with
|
|
2188
|
+
// three-text to avoid accidental arbitrary imports / path traversal
|
|
2189
|
+
if (!patternsPath && !BUILTIN_PATTERN_LANGUAGES.has(language)) {
|
|
2190
|
+
throw new Error(`Unsupported hyphenation language "${language}". ` +
|
|
2191
|
+
`Use a built-in language (e.g. "en-us") or register patterns via Text.registerPattern("${language}", pattern).`);
|
|
2192
|
+
}
|
|
2193
|
+
if (__UMD__) {
|
|
2194
|
+
const safeLangName = language.replace(/-/g, '_');
|
|
2195
|
+
const globalName = `ThreeTextPatterns_${safeLangName}`;
|
|
2196
|
+
// Check if pattern is already loaded as a global
|
|
2197
|
+
if (window[globalName]) {
|
|
2198
|
+
return window[globalName];
|
|
2199
|
+
}
|
|
2200
|
+
// Use provided path or default
|
|
2201
|
+
const patternBasePath = patternsPath || '/patterns/';
|
|
2202
|
+
// Dynamically load pattern via script tag
|
|
2203
|
+
return new Promise((resolve, reject) => {
|
|
2204
|
+
const script = document.createElement('script');
|
|
2205
|
+
script.src = `${patternBasePath}${language}.umd.js`;
|
|
2206
|
+
script.async = true;
|
|
2207
|
+
script.onload = () => {
|
|
2208
|
+
if (window[globalName]) {
|
|
2209
|
+
resolve(window[globalName]);
|
|
2210
|
+
}
|
|
2211
|
+
else {
|
|
2212
|
+
reject(new Error(`Pattern script loaded, but global ${globalName} not found.`));
|
|
2213
|
+
}
|
|
2214
|
+
};
|
|
2215
|
+
script.onerror = () => {
|
|
2216
|
+
reject(new Error(`Failed to load hyphenation pattern from ${script.src}. Did you copy the pattern files to your public directory?`));
|
|
2217
|
+
};
|
|
2218
|
+
document.head.appendChild(script);
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
else {
|
|
2222
|
+
// In ESM build, use dynamic imports
|
|
2223
|
+
try {
|
|
2224
|
+
if (patternsPath) {
|
|
2225
|
+
const module = await import(
|
|
2226
|
+
/* @vite-ignore */ `${patternsPath}${language}.js`);
|
|
2227
|
+
return module.default;
|
|
2228
|
+
}
|
|
2229
|
+
else if (typeof (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('core.cjs', document.baseURI).href)) === 'string') {
|
|
2230
|
+
// Use import.meta.url to resolve relative to this module's location
|
|
2231
|
+
const baseUrl = new URL('.', (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('core.cjs', document.baseURI).href))).href;
|
|
2232
|
+
const patternUrl = new URL(`./patterns/${language}.js`, baseUrl).href;
|
|
2233
|
+
const module = await import(/* @vite-ignore */ patternUrl);
|
|
2234
|
+
return module.default;
|
|
2235
|
+
}
|
|
2236
|
+
else {
|
|
2237
|
+
// Fallback for environments without import.meta.url
|
|
2238
|
+
const module = await import(
|
|
2239
|
+
/* @vite-ignore */ `./patterns/${language}.js`);
|
|
2240
|
+
return module.default;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
catch (error) {
|
|
2244
|
+
throw new Error(`Failed to load hyphenation patterns for ${language}. Consider using static imports: import pattern from 'three-text/patterns/${language}'; Text.registerPattern('${language}', pattern);`);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
7
2248
|
|
|
8
2249
|
// 2D Vector
|
|
9
2250
|
class Vec2 {
|
|
@@ -11,73 +2252,1476 @@ class Vec2 {
|
|
|
11
2252
|
this.x = x;
|
|
12
2253
|
this.y = y;
|
|
13
2254
|
}
|
|
14
|
-
set(x, y) {
|
|
15
|
-
this.x = x;
|
|
16
|
-
this.y = y;
|
|
17
|
-
return this;
|
|
2255
|
+
set(x, y) {
|
|
2256
|
+
this.x = x;
|
|
2257
|
+
this.y = y;
|
|
2258
|
+
return this;
|
|
2259
|
+
}
|
|
2260
|
+
clone() {
|
|
2261
|
+
return new Vec2(this.x, this.y);
|
|
2262
|
+
}
|
|
2263
|
+
copy(v) {
|
|
2264
|
+
this.x = v.x;
|
|
2265
|
+
this.y = v.y;
|
|
2266
|
+
return this;
|
|
2267
|
+
}
|
|
2268
|
+
add(v) {
|
|
2269
|
+
this.x += v.x;
|
|
2270
|
+
this.y += v.y;
|
|
2271
|
+
return this;
|
|
2272
|
+
}
|
|
2273
|
+
sub(v) {
|
|
2274
|
+
this.x -= v.x;
|
|
2275
|
+
this.y -= v.y;
|
|
2276
|
+
return this;
|
|
2277
|
+
}
|
|
2278
|
+
multiply(scalar) {
|
|
2279
|
+
this.x *= scalar;
|
|
2280
|
+
this.y *= scalar;
|
|
2281
|
+
return this;
|
|
2282
|
+
}
|
|
2283
|
+
divide(scalar) {
|
|
2284
|
+
this.x /= scalar;
|
|
2285
|
+
this.y /= scalar;
|
|
2286
|
+
return this;
|
|
2287
|
+
}
|
|
2288
|
+
length() {
|
|
2289
|
+
return Math.sqrt(this.x * this.x + this.y * this.y);
|
|
2290
|
+
}
|
|
2291
|
+
lengthSq() {
|
|
2292
|
+
return this.x * this.x + this.y * this.y;
|
|
2293
|
+
}
|
|
2294
|
+
normalize() {
|
|
2295
|
+
const len = this.length();
|
|
2296
|
+
if (len > 0) {
|
|
2297
|
+
this.divide(len);
|
|
2298
|
+
}
|
|
2299
|
+
return this;
|
|
2300
|
+
}
|
|
2301
|
+
dot(v) {
|
|
2302
|
+
return this.x * v.x + this.y * v.y;
|
|
2303
|
+
}
|
|
2304
|
+
distanceTo(v) {
|
|
2305
|
+
const dx = this.x - v.x;
|
|
2306
|
+
const dy = this.y - v.y;
|
|
2307
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
2308
|
+
}
|
|
2309
|
+
distanceToSquared(v) {
|
|
2310
|
+
const dx = this.x - v.x;
|
|
2311
|
+
const dy = this.y - v.y;
|
|
2312
|
+
return dx * dx + dy * dy;
|
|
2313
|
+
}
|
|
2314
|
+
equals(v) {
|
|
2315
|
+
return this.x === v.x && this.y === v.y;
|
|
2316
|
+
}
|
|
2317
|
+
angle() {
|
|
2318
|
+
return Math.atan2(this.y, this.x);
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
// 3D Vector
|
|
2322
|
+
class Vec3 {
|
|
2323
|
+
constructor(x = 0, y = 0, z = 0) {
|
|
2324
|
+
this.x = x;
|
|
2325
|
+
this.y = y;
|
|
2326
|
+
this.z = z;
|
|
2327
|
+
}
|
|
2328
|
+
set(x, y, z) {
|
|
2329
|
+
this.x = x;
|
|
2330
|
+
this.y = y;
|
|
2331
|
+
this.z = z;
|
|
2332
|
+
return this;
|
|
2333
|
+
}
|
|
2334
|
+
clone() {
|
|
2335
|
+
return new Vec3(this.x, this.y, this.z);
|
|
2336
|
+
}
|
|
2337
|
+
copy(v) {
|
|
2338
|
+
this.x = v.x;
|
|
2339
|
+
this.y = v.y;
|
|
2340
|
+
this.z = v.z;
|
|
2341
|
+
return this;
|
|
2342
|
+
}
|
|
2343
|
+
add(v) {
|
|
2344
|
+
this.x += v.x;
|
|
2345
|
+
this.y += v.y;
|
|
2346
|
+
this.z += v.z;
|
|
2347
|
+
return this;
|
|
2348
|
+
}
|
|
2349
|
+
sub(v) {
|
|
2350
|
+
this.x -= v.x;
|
|
2351
|
+
this.y -= v.y;
|
|
2352
|
+
this.z -= v.z;
|
|
2353
|
+
return this;
|
|
2354
|
+
}
|
|
2355
|
+
multiply(scalar) {
|
|
2356
|
+
this.x *= scalar;
|
|
2357
|
+
this.y *= scalar;
|
|
2358
|
+
this.z *= scalar;
|
|
2359
|
+
return this;
|
|
2360
|
+
}
|
|
2361
|
+
divide(scalar) {
|
|
2362
|
+
this.x /= scalar;
|
|
2363
|
+
this.y /= scalar;
|
|
2364
|
+
this.z /= scalar;
|
|
2365
|
+
return this;
|
|
2366
|
+
}
|
|
2367
|
+
length() {
|
|
2368
|
+
return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
|
|
2369
|
+
}
|
|
2370
|
+
lengthSq() {
|
|
2371
|
+
return this.x * this.x + this.y * this.y + this.z * this.z;
|
|
2372
|
+
}
|
|
2373
|
+
normalize() {
|
|
2374
|
+
const len = this.length();
|
|
2375
|
+
if (len > 0) {
|
|
2376
|
+
this.divide(len);
|
|
2377
|
+
}
|
|
2378
|
+
return this;
|
|
2379
|
+
}
|
|
2380
|
+
dot(v) {
|
|
2381
|
+
return this.x * v.x + this.y * v.y + this.z * v.z;
|
|
2382
|
+
}
|
|
2383
|
+
cross(v) {
|
|
2384
|
+
const x = this.y * v.z - this.z * v.y;
|
|
2385
|
+
const y = this.z * v.x - this.x * v.z;
|
|
2386
|
+
const z = this.x * v.y - this.y * v.x;
|
|
2387
|
+
this.x = x;
|
|
2388
|
+
this.y = y;
|
|
2389
|
+
this.z = z;
|
|
2390
|
+
return this;
|
|
2391
|
+
}
|
|
2392
|
+
distanceTo(v) {
|
|
2393
|
+
const dx = this.x - v.x;
|
|
2394
|
+
const dy = this.y - v.y;
|
|
2395
|
+
const dz = this.z - v.z;
|
|
2396
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
2397
|
+
}
|
|
2398
|
+
distanceToSquared(v) {
|
|
2399
|
+
const dx = this.x - v.x;
|
|
2400
|
+
const dy = this.y - v.y;
|
|
2401
|
+
const dz = this.z - v.z;
|
|
2402
|
+
return dx * dx + dy * dy + dz * dz;
|
|
2403
|
+
}
|
|
2404
|
+
equals(v) {
|
|
2405
|
+
return this.x === v.x && this.y === v.y && this.z === v.z;
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
class TextShaper {
|
|
2410
|
+
constructor(loadedFont) {
|
|
2411
|
+
this.cachedSpaceWidth = new Map();
|
|
2412
|
+
this.loadedFont = loadedFont;
|
|
2413
|
+
}
|
|
2414
|
+
shapeLines(lineInfos, scaledLineHeight, letterSpacing, align, direction, color, originalText) {
|
|
2415
|
+
perfLogger.start('TextShaper.shapeLines', {
|
|
2416
|
+
lineCount: lineInfos.length
|
|
2417
|
+
});
|
|
2418
|
+
try {
|
|
2419
|
+
const clustersByLine = [];
|
|
2420
|
+
lineInfos.forEach((lineInfo, lineIndex) => {
|
|
2421
|
+
const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction);
|
|
2422
|
+
clustersByLine.push(clusters);
|
|
2423
|
+
});
|
|
2424
|
+
return clustersByLine;
|
|
2425
|
+
}
|
|
2426
|
+
finally {
|
|
2427
|
+
perfLogger.end('TextShaper.shapeLines');
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction) {
|
|
2431
|
+
const buffer = this.loadedFont.hb.createBuffer();
|
|
2432
|
+
if (direction === 'rtl') {
|
|
2433
|
+
buffer.setDirection('rtl');
|
|
2434
|
+
}
|
|
2435
|
+
buffer.addText(lineInfo.text);
|
|
2436
|
+
buffer.guessSegmentProperties();
|
|
2437
|
+
const featuresString = convertFontFeaturesToString(this.loadedFont.fontFeatures);
|
|
2438
|
+
this.loadedFont.hb.shape(this.loadedFont.font, buffer, featuresString);
|
|
2439
|
+
const glyphInfos = buffer.json(this.loadedFont.font);
|
|
2440
|
+
buffer.destroy();
|
|
2441
|
+
const clusters = [];
|
|
2442
|
+
let currentClusterGlyphs = [];
|
|
2443
|
+
let clusterTextChars = [];
|
|
2444
|
+
let clusterStartX = 0;
|
|
2445
|
+
let clusterStartY = 0;
|
|
2446
|
+
let cursorX = lineInfo.xOffset;
|
|
2447
|
+
let cursorY = -lineIndex * scaledLineHeight;
|
|
2448
|
+
const cursorZ = 0;
|
|
2449
|
+
// Apply letter spacing after each glyph to match width measurements used during line breaking
|
|
2450
|
+
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
2451
|
+
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
2452
|
+
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
2453
|
+
const lineText = lineInfo.text;
|
|
2454
|
+
const lineTextLength = lineText.length;
|
|
2455
|
+
const glyphCount = glyphInfos.length;
|
|
2456
|
+
let nextCharIsCJK;
|
|
2457
|
+
for (let i = 0; i < glyphCount; i++) {
|
|
2458
|
+
const glyph = glyphInfos[i];
|
|
2459
|
+
const charIndex = glyph.cl;
|
|
2460
|
+
const char = lineText[charIndex];
|
|
2461
|
+
const charCode = char.charCodeAt(0);
|
|
2462
|
+
const isWhitespace = charCode === 32 || charCode === 9 || charCode === 10 || charCode === 13;
|
|
2463
|
+
// Inserted hyphens inherit the color of the last character in the word
|
|
2464
|
+
if (lineInfo.endedWithHyphen &&
|
|
2465
|
+
charIndex === lineTextLength - 1 &&
|
|
2466
|
+
char === '-') {
|
|
2467
|
+
glyph.absoluteTextIndex = lineInfo.originalEnd;
|
|
2468
|
+
}
|
|
2469
|
+
else {
|
|
2470
|
+
glyph.absoluteTextIndex = lineInfo.originalStart + charIndex;
|
|
2471
|
+
}
|
|
2472
|
+
glyph.lineIndex = lineIndex;
|
|
2473
|
+
// Cluster boundaries are based on whitespace only.
|
|
2474
|
+
// Coloring is applied later via vertex colors and must never affect shaping/kerning.
|
|
2475
|
+
if (isWhitespace) {
|
|
2476
|
+
if (currentClusterGlyphs.length > 0) {
|
|
2477
|
+
clusters.push({
|
|
2478
|
+
text: clusterTextChars.join(''),
|
|
2479
|
+
glyphs: currentClusterGlyphs,
|
|
2480
|
+
position: new Vec3(clusterStartX, clusterStartY, cursorZ)
|
|
2481
|
+
});
|
|
2482
|
+
currentClusterGlyphs = [];
|
|
2483
|
+
clusterTextChars = [];
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
const absoluteGlyphX = cursorX + glyph.dx;
|
|
2487
|
+
const absoluteGlyphY = cursorY + glyph.dy;
|
|
2488
|
+
if (!isWhitespace) {
|
|
2489
|
+
if (currentClusterGlyphs.length === 0) {
|
|
2490
|
+
clusterStartX = absoluteGlyphX;
|
|
2491
|
+
clusterStartY = absoluteGlyphY;
|
|
2492
|
+
}
|
|
2493
|
+
glyph.x = absoluteGlyphX - clusterStartX;
|
|
2494
|
+
glyph.y = absoluteGlyphY - clusterStartY;
|
|
2495
|
+
currentClusterGlyphs.push(glyph);
|
|
2496
|
+
clusterTextChars.push(char);
|
|
2497
|
+
}
|
|
2498
|
+
cursorX += glyph.ax;
|
|
2499
|
+
cursorY += glyph.ay;
|
|
2500
|
+
if (letterSpacingFU !== 0 && i < glyphCount - 1) {
|
|
2501
|
+
cursorX += letterSpacingFU;
|
|
2502
|
+
}
|
|
2503
|
+
if (isWhitespace) {
|
|
2504
|
+
cursorX += spaceAdjustment;
|
|
2505
|
+
}
|
|
2506
|
+
// CJK glue adjustment (must match exactly where LineBreak adds glue)
|
|
2507
|
+
if (cjkAdjustment !== 0 && i < glyphCount - 1 && !isWhitespace) {
|
|
2508
|
+
const nextGlyph = glyphInfos[i + 1];
|
|
2509
|
+
const nextChar = lineText[nextGlyph.cl];
|
|
2510
|
+
const isCJK = nextCharIsCJK !== undefined ? nextCharIsCJK : LineBreak.isCJK(char);
|
|
2511
|
+
nextCharIsCJK = nextChar ? LineBreak.isCJK(nextChar) : false;
|
|
2512
|
+
if (isCJK && nextCharIsCJK) {
|
|
2513
|
+
let shouldApply = true;
|
|
2514
|
+
if (LineBreak.isCJClosingPunctuation(nextChar)) {
|
|
2515
|
+
shouldApply = false;
|
|
2516
|
+
}
|
|
2517
|
+
if (LineBreak.isCJOpeningPunctuation(char)) {
|
|
2518
|
+
shouldApply = false;
|
|
2519
|
+
}
|
|
2520
|
+
if (LineBreak.isCJPunctuation(char) &&
|
|
2521
|
+
LineBreak.isCJPunctuation(nextChar)) {
|
|
2522
|
+
shouldApply = false;
|
|
2523
|
+
}
|
|
2524
|
+
if (shouldApply) {
|
|
2525
|
+
cursorX += cjkAdjustment;
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
else {
|
|
2530
|
+
nextCharIsCJK = undefined;
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
if (currentClusterGlyphs.length > 0) {
|
|
2534
|
+
clusters.push({
|
|
2535
|
+
text: clusterTextChars.join(''),
|
|
2536
|
+
glyphs: currentClusterGlyphs,
|
|
2537
|
+
position: new Vec3(clusterStartX, clusterStartY, cursorZ)
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
return clusters;
|
|
2541
|
+
}
|
|
2542
|
+
calculateSpaceAdjustment(lineInfo, align, letterSpacing) {
|
|
2543
|
+
let spaceAdjustment = 0;
|
|
2544
|
+
if (lineInfo.adjustmentRatio !== undefined &&
|
|
2545
|
+
align === 'justify' &&
|
|
2546
|
+
!lineInfo.isLastLine) {
|
|
2547
|
+
let naturalSpaceWidth = this.cachedSpaceWidth.get(letterSpacing);
|
|
2548
|
+
if (naturalSpaceWidth === undefined) {
|
|
2549
|
+
naturalSpaceWidth = TextMeasurer.measureTextWidth(this.loadedFont, ' ', letterSpacing);
|
|
2550
|
+
this.cachedSpaceWidth.set(letterSpacing, naturalSpaceWidth);
|
|
2551
|
+
}
|
|
2552
|
+
const width = naturalSpaceWidth;
|
|
2553
|
+
const stretchFactor = SPACE_STRETCH_RATIO;
|
|
2554
|
+
const shrinkFactor = SPACE_SHRINK_RATIO;
|
|
2555
|
+
if (lineInfo.adjustmentRatio > 0) {
|
|
2556
|
+
spaceAdjustment = lineInfo.adjustmentRatio * width * stretchFactor;
|
|
2557
|
+
}
|
|
2558
|
+
else if (lineInfo.adjustmentRatio < 0) {
|
|
2559
|
+
spaceAdjustment = lineInfo.adjustmentRatio * width * shrinkFactor;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
return spaceAdjustment;
|
|
2563
|
+
}
|
|
2564
|
+
calculateCJKAdjustment(lineInfo, align) {
|
|
2565
|
+
if (lineInfo.adjustmentRatio === undefined ||
|
|
2566
|
+
align !== 'justify' ||
|
|
2567
|
+
lineInfo.isLastLine) {
|
|
2568
|
+
return 0;
|
|
2569
|
+
}
|
|
2570
|
+
const baseCharWidth = this.loadedFont.upem;
|
|
2571
|
+
const glueStretch = baseCharWidth * 0.04;
|
|
2572
|
+
const glueShrink = baseCharWidth * 0.04;
|
|
2573
|
+
if (lineInfo.adjustmentRatio > 0) {
|
|
2574
|
+
return lineInfo.adjustmentRatio * glueStretch;
|
|
2575
|
+
}
|
|
2576
|
+
else if (lineInfo.adjustmentRatio < 0) {
|
|
2577
|
+
return lineInfo.adjustmentRatio * glueShrink;
|
|
2578
|
+
}
|
|
2579
|
+
return 0;
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// Fetch with fs fallback for Electron file:// and Node.js environments
|
|
2584
|
+
async function loadBinary(filePath) {
|
|
2585
|
+
try {
|
|
2586
|
+
const res = await fetch(filePath);
|
|
2587
|
+
if (!res.ok) {
|
|
2588
|
+
throw new Error(`HTTP ${res.status}`);
|
|
2589
|
+
}
|
|
2590
|
+
return await res.arrayBuffer();
|
|
2591
|
+
}
|
|
2592
|
+
catch (fetchError) {
|
|
2593
|
+
const req = globalThis.require;
|
|
2594
|
+
if (typeof req !== 'function') {
|
|
2595
|
+
throw new Error(`Failed to fetch ${filePath}: ${fetchError}`);
|
|
2596
|
+
}
|
|
2597
|
+
try {
|
|
2598
|
+
const fs = req('fs');
|
|
2599
|
+
const nodePath = req('path');
|
|
2600
|
+
// file:// URLs need path resolution relative to the HTML document
|
|
2601
|
+
let resolvedPath = filePath;
|
|
2602
|
+
if (typeof window !== 'undefined' &&
|
|
2603
|
+
window.location?.protocol === 'file:') {
|
|
2604
|
+
const dir = nodePath.dirname(window.location.pathname);
|
|
2605
|
+
resolvedPath = nodePath.join(dir, filePath);
|
|
2606
|
+
}
|
|
2607
|
+
const buffer = fs.readFileSync(resolvedPath);
|
|
2608
|
+
if (buffer instanceof ArrayBuffer)
|
|
2609
|
+
return buffer;
|
|
2610
|
+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
2611
|
+
}
|
|
2612
|
+
catch (fsError) {
|
|
2613
|
+
throw new Error(`Failed to load ${filePath}: fetch failed (${fetchError}), fs.readFileSync failed (${fsError})`);
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
function getDefaultExportFromCjs (x) {
|
|
2619
|
+
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
var hb = {exports: {}};
|
|
2623
|
+
|
|
2624
|
+
(function (module, exports) {
|
|
2625
|
+
var createHarfBuzz=(()=>{var _scriptName=typeof document!="undefined"?document.currentScript?.src:undefined;return async function(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof WorkerGlobalScope!="undefined";var ENVIRONMENT_IS_NODE=typeof process=="object"&&process.versions?.node&&process.type!="renderer";var quit_=(status,toThrow)=>{throw toThrow};if(typeof __filename!="undefined"){_scriptName=__filename;}else if(ENVIRONMENT_IS_WORKER){_scriptName=self.location.href;}var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require$$0;scriptDirectory=__dirname+"/";readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){process.argv[1].replace(/\\/g,"/");}process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow};}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href;}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)};}readAsync=async url=>{if(isFileURI(url)){return new Promise((resolve,reject)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){resolve(xhr.response);return}reject(xhr.status);};xhr.onerror=reject;xhr.send(null);})}var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)};}}else;console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var EXITSTATUS;var isFileURI=filename=>filename.startsWith("file://");var readyPromiseResolve,readyPromiseReject;var wasmMemory;var HEAPU8;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=new Int8Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAP32"]=new Int32Array(b);Module["HEAPU32"]=new Uint32Array(b);Module["HEAPF32"]=new Float32Array(b);new BigInt64Array(b);new BigUint64Array(b);}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift());}}callRuntimeCallbacks(onPreRuns);}function initRuntime(){runtimeInitialized=true;wasmExports["__wasm_call_ctors"]();}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift());}}callRuntimeCallbacks(onPostRuns);}var runDependencies=0;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies);}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback();}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){return locateFile("hb.wasm")}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw "both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason);}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!isFileURI(binaryFile)&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){return {env:wasmImports,wasi_snapshot_preview1:wasmImports}}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;Module["wasmExports"]=wasmExports;wasmMemory=wasmExports["memory"];Module["wasmMemory"]=wasmMemory;updateMemoryViews();wasmTable=wasmExports["__indirect_function_table"];assignWasmExports(wasmExports);removeRunDependency();return wasmExports}addRunDependency();function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(mod,inst)=>{resolve(receiveInstance(mod));});})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status;}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module);}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var noExitRuntime=true;var __abort_js=()=>abort("");var runtimeKeepaliveCounter=0;var __emscripten_runtime_keepalive_clear=()=>{noExitRuntime=false;runtimeKeepaliveCounter=0;};var timers={};var handleException=e=>{if(e instanceof ExitStatus||e=="unwind"){return EXITSTATUS}quit_(1,e);};var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true;}quit_(code,new ExitStatus(code));};var exitJS=(status,implicit)=>{EXITSTATUS=status;_proc_exit(status);};var _exit=exitJS;var maybeExit=()=>{if(!keepRuntimeAlive()){try{_exit(EXITSTATUS);}catch(e){handleException(e);}}};var callUserCallback=func=>{if(ABORT){return}try{func();maybeExit();}catch(e){handleException(e);}};var _emscripten_get_now=()=>performance.now();var __setitimer_js=(which,timeout_ms)=>{if(timers[which]){clearTimeout(timers[which].id);delete timers[which];}if(!timeout_ms)return 0;var id=setTimeout(()=>{delete timers[which];callUserCallback(()=>__emscripten_timeout(which,_emscripten_get_now()));},timeout_ms);timers[which]={id,timeout_ms};return 0};var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var uleb128EncodeWithLen=arr=>{const n=arr.length;return [n%128|128,n>>7,...arr]};var wasmTypeCodes={i:127,p:127,j:126,f:125,d:124,e:111};var generateTypePack=types=>uleb128EncodeWithLen(Array.from(types,type=>{var code=wasmTypeCodes[type];return code}));var convertJsFunctionToWasm=(func,sig)=>{var bytes=Uint8Array.of(0,97,115,109,1,0,0,0,1,...uleb128EncodeWithLen([1,96,...generateTypePack(sig.slice(1)),...generateTypePack(sig[0]==="v"?"":sig[0])]),2,7,1,1,101,1,102,0,0,7,5,1,1,102,0,0);var module=new WebAssembly.Module(bytes);var instance=new WebAssembly.Instance(module,{e:{f:func}});var wrappedFunc=instance.exports["f"];return wrappedFunc};var wasmTable;var getWasmTableEntry=funcPtr=>wasmTable.get(funcPtr);var updateTableMap=(offset,count)=>{if(functionsInTableMap){for(var i=offset;i<offset+count;i++){var item=getWasmTableEntry(i);if(item){functionsInTableMap.set(item,i);}}}};var functionsInTableMap;var getFunctionAddress=func=>{if(!functionsInTableMap){functionsInTableMap=new WeakMap;updateTableMap(0,wasmTable.length);}return functionsInTableMap.get(func)||0};var freeTableIndexes=[];var getEmptyTableSlot=()=>{if(freeTableIndexes.length){return freeTableIndexes.pop()}return wasmTable["grow"](1)};var setWasmTableEntry=(idx,func)=>wasmTable.set(idx,func);var addFunction=(func,sig)=>{var rtn=getFunctionAddress(func);if(rtn){return rtn}var ret=getEmptyTableSlot();try{setWasmTableEntry(ret,func);}catch(err){if(!(err instanceof TypeError)){throw err}var wrapped=convertJsFunctionToWasm(func,sig);setWasmTableEntry(ret,wrapped);}functionsInTableMap.set(func,ret);return ret};var removeFunction=index=>{functionsInTableMap.delete(getWasmTableEntry(index));setWasmTableEntry(index,null);freeTableIndexes.push(index);};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])Module["arguments"];if(Module["thisProgram"])Module["thisProgram"];}Module["wasmMemory"]=wasmMemory;Module["wasmExports"]=wasmExports;Module["addFunction"]=addFunction;Module["removeFunction"]=removeFunction;var __emscripten_timeout;function assignWasmExports(wasmExports){Module["_hb_blob_create"]=wasmExports["hb_blob_create"];Module["_hb_blob_destroy"]=wasmExports["hb_blob_destroy"];Module["_hb_blob_get_length"]=wasmExports["hb_blob_get_length"];Module["_hb_blob_get_data"]=wasmExports["hb_blob_get_data"];Module["_hb_buffer_serialize_glyphs"]=wasmExports["hb_buffer_serialize_glyphs"];Module["_hb_buffer_create"]=wasmExports["hb_buffer_create"];Module["_hb_buffer_destroy"]=wasmExports["hb_buffer_destroy"];Module["_hb_buffer_get_content_type"]=wasmExports["hb_buffer_get_content_type"];Module["_hb_buffer_set_direction"]=wasmExports["hb_buffer_set_direction"];Module["_hb_buffer_set_script"]=wasmExports["hb_buffer_set_script"];Module["_hb_buffer_set_language"]=wasmExports["hb_buffer_set_language"];Module["_hb_buffer_set_flags"]=wasmExports["hb_buffer_set_flags"];Module["_hb_buffer_set_cluster_level"]=wasmExports["hb_buffer_set_cluster_level"];Module["_hb_buffer_get_length"]=wasmExports["hb_buffer_get_length"];Module["_hb_buffer_get_glyph_infos"]=wasmExports["hb_buffer_get_glyph_infos"];Module["_hb_buffer_get_glyph_positions"]=wasmExports["hb_buffer_get_glyph_positions"];Module["_hb_glyph_info_get_glyph_flags"]=wasmExports["hb_glyph_info_get_glyph_flags"];Module["_hb_buffer_guess_segment_properties"]=wasmExports["hb_buffer_guess_segment_properties"];Module["_hb_buffer_add_utf8"]=wasmExports["hb_buffer_add_utf8"];Module["_hb_buffer_add_utf16"]=wasmExports["hb_buffer_add_utf16"];Module["_hb_buffer_set_message_func"]=wasmExports["hb_buffer_set_message_func"];Module["_hb_language_from_string"]=wasmExports["hb_language_from_string"];Module["_hb_script_from_string"]=wasmExports["hb_script_from_string"];Module["_hb_version"]=wasmExports["hb_version"];Module["_hb_version_string"]=wasmExports["hb_version_string"];Module["_hb_feature_from_string"]=wasmExports["hb_feature_from_string"];Module["_malloc"]=wasmExports["malloc"];Module["_free"]=wasmExports["free"];Module["_hb_draw_funcs_set_move_to_func"]=wasmExports["hb_draw_funcs_set_move_to_func"];Module["_hb_draw_funcs_set_line_to_func"]=wasmExports["hb_draw_funcs_set_line_to_func"];Module["_hb_draw_funcs_set_quadratic_to_func"]=wasmExports["hb_draw_funcs_set_quadratic_to_func"];Module["_hb_draw_funcs_set_cubic_to_func"]=wasmExports["hb_draw_funcs_set_cubic_to_func"];Module["_hb_draw_funcs_set_close_path_func"]=wasmExports["hb_draw_funcs_set_close_path_func"];Module["_hb_draw_funcs_create"]=wasmExports["hb_draw_funcs_create"];Module["_hb_draw_funcs_destroy"]=wasmExports["hb_draw_funcs_destroy"];Module["_hb_face_create"]=wasmExports["hb_face_create"];Module["_hb_face_destroy"]=wasmExports["hb_face_destroy"];Module["_hb_face_reference_table"]=wasmExports["hb_face_reference_table"];Module["_hb_face_get_upem"]=wasmExports["hb_face_get_upem"];Module["_hb_face_collect_unicodes"]=wasmExports["hb_face_collect_unicodes"];Module["_hb_font_draw_glyph"]=wasmExports["hb_font_draw_glyph"];Module["_hb_font_glyph_to_string"]=wasmExports["hb_font_glyph_to_string"];Module["_hb_font_create"]=wasmExports["hb_font_create"];Module["_hb_font_set_variations"]=wasmExports["hb_font_set_variations"];Module["_hb_font_destroy"]=wasmExports["hb_font_destroy"];Module["_hb_font_set_scale"]=wasmExports["hb_font_set_scale"];Module["_hb_set_create"]=wasmExports["hb_set_create"];Module["_hb_set_destroy"]=wasmExports["hb_set_destroy"];Module["_hb_ot_var_get_axis_infos"]=wasmExports["hb_ot_var_get_axis_infos"];Module["_hb_set_get_population"]=wasmExports["hb_set_get_population"];Module["_hb_set_next_many"]=wasmExports["hb_set_next_many"];Module["_hb_shape"]=wasmExports["hb_shape"];__emscripten_timeout=wasmExports["_emscripten_timeout"];}var wasmImports={_abort_js:__abort_js,_emscripten_runtime_keepalive_clear:__emscripten_runtime_keepalive_clear,_setitimer_js:__setitimer_js,emscripten_resize_heap:_emscripten_resize_heap,proc_exit:_proc_exit};var wasmExports=await createWasm();function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun();}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun();},1);}else {doRun();}}function preInit(){if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()();}}}preInit();run();if(runtimeInitialized){moduleRtn=Module;}else {moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject;});}
|
|
2626
|
+
return moduleRtn}})();{module.exports=createHarfBuzz;module.exports.default=createHarfBuzz;}
|
|
2627
|
+
} (hb));
|
|
2628
|
+
|
|
2629
|
+
var hbExports = hb.exports;
|
|
2630
|
+
var createHarfBuzz = /*@__PURE__*/getDefaultExportFromCjs(hbExports);
|
|
2631
|
+
|
|
2632
|
+
var hbjs$2 = {exports: {}};
|
|
2633
|
+
|
|
2634
|
+
function hbjs(Module) {
|
|
2635
|
+
|
|
2636
|
+
var exports = Module.wasmExports;
|
|
2637
|
+
var utf8Decoder = new TextDecoder("utf8");
|
|
2638
|
+
let addFunction = Module.addFunction;
|
|
2639
|
+
let removeFunction = Module.removeFunction;
|
|
2640
|
+
|
|
2641
|
+
var freeFuncPtr = addFunction(function (ptr) { exports.free(ptr); }, 'vi');
|
|
2642
|
+
|
|
2643
|
+
var HB_MEMORY_MODE_WRITABLE = 2;
|
|
2644
|
+
var HB_SET_VALUE_INVALID = -1;
|
|
2645
|
+
var HB_BUFFER_CONTENT_TYPE_GLYPHS = 2;
|
|
2646
|
+
var DONT_STOP = 0;
|
|
2647
|
+
var GSUB_PHASE = 1;
|
|
2648
|
+
var GPOS_PHASE = 2;
|
|
2649
|
+
|
|
2650
|
+
function hb_tag(s) {
|
|
2651
|
+
return (
|
|
2652
|
+
(s.charCodeAt(0) & 0xFF) << 24 |
|
|
2653
|
+
(s.charCodeAt(1) & 0xFF) << 16 |
|
|
2654
|
+
(s.charCodeAt(2) & 0xFF) << 8 |
|
|
2655
|
+
(s.charCodeAt(3) & 0xFF) << 0
|
|
2656
|
+
);
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
var HB_BUFFER_SERIALIZE_FORMAT_JSON = hb_tag('JSON');
|
|
2660
|
+
var HB_BUFFER_SERIALIZE_FLAG_NO_GLYPH_NAMES = 4;
|
|
2661
|
+
|
|
2662
|
+
function _hb_untag(tag) {
|
|
2663
|
+
return [
|
|
2664
|
+
String.fromCharCode((tag >> 24) & 0xFF),
|
|
2665
|
+
String.fromCharCode((tag >> 16) & 0xFF),
|
|
2666
|
+
String.fromCharCode((tag >> 8) & 0xFF),
|
|
2667
|
+
String.fromCharCode((tag >> 0) & 0xFF)
|
|
2668
|
+
].join('');
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
function _buffer_flag(s) {
|
|
2672
|
+
if (s == "BOT") { return 0x1; }
|
|
2673
|
+
if (s == "EOT") { return 0x2; }
|
|
2674
|
+
if (s == "PRESERVE_DEFAULT_IGNORABLES") { return 0x4; }
|
|
2675
|
+
if (s == "REMOVE_DEFAULT_IGNORABLES") { return 0x8; }
|
|
2676
|
+
if (s == "DO_NOT_INSERT_DOTTED_CIRCLE") { return 0x10; }
|
|
2677
|
+
if (s == "PRODUCE_UNSAFE_TO_CONCAT") { return 0x40; }
|
|
2678
|
+
return 0x0;
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/**
|
|
2682
|
+
* Create an object representing a Harfbuzz blob.
|
|
2683
|
+
* @param {string} blob A blob of binary data (usually the contents of a font file).
|
|
2684
|
+
**/
|
|
2685
|
+
function createBlob(blob) {
|
|
2686
|
+
var blobPtr = exports.malloc(blob.byteLength);
|
|
2687
|
+
Module.HEAPU8.set(new Uint8Array(blob), blobPtr);
|
|
2688
|
+
var ptr = exports.hb_blob_create(blobPtr, blob.byteLength, HB_MEMORY_MODE_WRITABLE, blobPtr, freeFuncPtr);
|
|
2689
|
+
return {
|
|
2690
|
+
ptr: ptr,
|
|
2691
|
+
/**
|
|
2692
|
+
* Free the object.
|
|
2693
|
+
*/
|
|
2694
|
+
destroy: function () { exports.hb_blob_destroy(ptr); }
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
/**
|
|
2699
|
+
* Return the typed array of HarfBuzz set contents.
|
|
2700
|
+
* @param {number} setPtr Pointer of set
|
|
2701
|
+
* @returns {Uint32Array} Typed array instance
|
|
2702
|
+
*/
|
|
2703
|
+
function typedArrayFromSet(setPtr) {
|
|
2704
|
+
const setCount = exports.hb_set_get_population(setPtr);
|
|
2705
|
+
const arrayPtr = exports.malloc(setCount << 2);
|
|
2706
|
+
const arrayOffset = arrayPtr >> 2;
|
|
2707
|
+
const array = Module.HEAPU32.subarray(arrayOffset, arrayOffset + setCount);
|
|
2708
|
+
Module.HEAPU32.set(array, arrayOffset);
|
|
2709
|
+
exports.hb_set_next_many(setPtr, HB_SET_VALUE_INVALID, arrayPtr, setCount);
|
|
2710
|
+
return array;
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
/**
|
|
2714
|
+
* Create an object representing a Harfbuzz face.
|
|
2715
|
+
* @param {object} blob An object returned from `createBlob`.
|
|
2716
|
+
* @param {number} index The index of the font in the blob. (0 for most files,
|
|
2717
|
+
* or a 0-indexed font number if the `blob` came form a TTC/OTC file.)
|
|
2718
|
+
**/
|
|
2719
|
+
function createFace(blob, index) {
|
|
2720
|
+
var ptr = exports.hb_face_create(blob.ptr, index);
|
|
2721
|
+
const upem = exports.hb_face_get_upem(ptr);
|
|
2722
|
+
return {
|
|
2723
|
+
ptr: ptr,
|
|
2724
|
+
upem,
|
|
2725
|
+
/**
|
|
2726
|
+
* Return the binary contents of an OpenType table.
|
|
2727
|
+
* @param {string} table Table name
|
|
2728
|
+
*/
|
|
2729
|
+
reference_table: function(table) {
|
|
2730
|
+
var blob = exports.hb_face_reference_table(ptr, hb_tag(table));
|
|
2731
|
+
var length = exports.hb_blob_get_length(blob);
|
|
2732
|
+
if (!length) { return; }
|
|
2733
|
+
var blobptr = exports.hb_blob_get_data(blob, null);
|
|
2734
|
+
var table_string = Module.HEAPU8.subarray(blobptr, blobptr+length);
|
|
2735
|
+
return table_string;
|
|
2736
|
+
},
|
|
2737
|
+
/**
|
|
2738
|
+
* Return variation axis infos
|
|
2739
|
+
*/
|
|
2740
|
+
getAxisInfos: function() {
|
|
2741
|
+
var axis = exports.malloc(64 * 32);
|
|
2742
|
+
var c = exports.malloc(4);
|
|
2743
|
+
Module.HEAPU32[c / 4] = 64;
|
|
2744
|
+
exports.hb_ot_var_get_axis_infos(ptr, 0, c, axis);
|
|
2745
|
+
var result = {};
|
|
2746
|
+
Array.from({ length: Module.HEAPU32[c / 4] }).forEach(function (_, i) {
|
|
2747
|
+
result[_hb_untag(Module.HEAPU32[axis / 4 + i * 8 + 1])] = {
|
|
2748
|
+
min: Module.HEAPF32[axis / 4 + i * 8 + 4],
|
|
2749
|
+
default: Module.HEAPF32[axis / 4 + i * 8 + 5],
|
|
2750
|
+
max: Module.HEAPF32[axis / 4 + i * 8 + 6]
|
|
2751
|
+
};
|
|
2752
|
+
});
|
|
2753
|
+
exports.free(c);
|
|
2754
|
+
exports.free(axis);
|
|
2755
|
+
return result;
|
|
2756
|
+
},
|
|
2757
|
+
/**
|
|
2758
|
+
* Return unicodes the face supports
|
|
2759
|
+
*/
|
|
2760
|
+
collectUnicodes: function() {
|
|
2761
|
+
var unicodeSetPtr = exports.hb_set_create();
|
|
2762
|
+
exports.hb_face_collect_unicodes(ptr, unicodeSetPtr);
|
|
2763
|
+
var result = typedArrayFromSet(unicodeSetPtr);
|
|
2764
|
+
exports.hb_set_destroy(unicodeSetPtr);
|
|
2765
|
+
return result;
|
|
2766
|
+
},
|
|
2767
|
+
/**
|
|
2768
|
+
* Free the object.
|
|
2769
|
+
*/
|
|
2770
|
+
destroy: function () {
|
|
2771
|
+
exports.hb_face_destroy(ptr);
|
|
2772
|
+
},
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
var pathBuffer = "";
|
|
2777
|
+
|
|
2778
|
+
var nameBufferSize = 256; // should be enough for most glyphs
|
|
2779
|
+
var nameBuffer = exports.malloc(nameBufferSize); // permanently allocated
|
|
2780
|
+
|
|
2781
|
+
/**
|
|
2782
|
+
* Create an object representing a Harfbuzz font.
|
|
2783
|
+
* @param {object} blob An object returned from `createFace`.
|
|
2784
|
+
**/
|
|
2785
|
+
function createFont(face) {
|
|
2786
|
+
var ptr = exports.hb_font_create(face.ptr);
|
|
2787
|
+
var drawFuncsPtr = null;
|
|
2788
|
+
var moveToPtr = null;
|
|
2789
|
+
var lineToPtr = null;
|
|
2790
|
+
var cubicToPtr = null;
|
|
2791
|
+
var quadToPtr = null;
|
|
2792
|
+
var closePathPtr = null;
|
|
2793
|
+
|
|
2794
|
+
/**
|
|
2795
|
+
* Return a glyph as an SVG path string.
|
|
2796
|
+
* @param {number} glyphId ID of the requested glyph in the font.
|
|
2797
|
+
**/
|
|
2798
|
+
function glyphToPath(glyphId) {
|
|
2799
|
+
if (!drawFuncsPtr) {
|
|
2800
|
+
var moveTo = function (dfuncs, draw_data, draw_state, to_x, to_y, user_data) {
|
|
2801
|
+
pathBuffer += `M${to_x},${to_y}`;
|
|
2802
|
+
};
|
|
2803
|
+
var lineTo = function (dfuncs, draw_data, draw_state, to_x, to_y, user_data) {
|
|
2804
|
+
pathBuffer += `L${to_x},${to_y}`;
|
|
2805
|
+
};
|
|
2806
|
+
var cubicTo = function (dfuncs, draw_data, draw_state, c1_x, c1_y, c2_x, c2_y, to_x, to_y, user_data) {
|
|
2807
|
+
pathBuffer += `C${c1_x},${c1_y} ${c2_x},${c2_y} ${to_x},${to_y}`;
|
|
2808
|
+
};
|
|
2809
|
+
var quadTo = function (dfuncs, draw_data, draw_state, c_x, c_y, to_x, to_y, user_data) {
|
|
2810
|
+
pathBuffer += `Q${c_x},${c_y} ${to_x},${to_y}`;
|
|
2811
|
+
};
|
|
2812
|
+
var closePath = function (dfuncs, draw_data, draw_state, user_data) {
|
|
2813
|
+
pathBuffer += 'Z';
|
|
2814
|
+
};
|
|
2815
|
+
|
|
2816
|
+
moveToPtr = addFunction(moveTo, 'viiiffi');
|
|
2817
|
+
lineToPtr = addFunction(lineTo, 'viiiffi');
|
|
2818
|
+
cubicToPtr = addFunction(cubicTo, 'viiiffffffi');
|
|
2819
|
+
quadToPtr = addFunction(quadTo, 'viiiffffi');
|
|
2820
|
+
closePathPtr = addFunction(closePath, 'viiii');
|
|
2821
|
+
drawFuncsPtr = exports.hb_draw_funcs_create();
|
|
2822
|
+
exports.hb_draw_funcs_set_move_to_func(drawFuncsPtr, moveToPtr, 0, 0);
|
|
2823
|
+
exports.hb_draw_funcs_set_line_to_func(drawFuncsPtr, lineToPtr, 0, 0);
|
|
2824
|
+
exports.hb_draw_funcs_set_cubic_to_func(drawFuncsPtr, cubicToPtr, 0, 0);
|
|
2825
|
+
exports.hb_draw_funcs_set_quadratic_to_func(drawFuncsPtr, quadToPtr, 0, 0);
|
|
2826
|
+
exports.hb_draw_funcs_set_close_path_func(drawFuncsPtr, closePathPtr, 0, 0);
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
pathBuffer = "";
|
|
2830
|
+
exports.hb_font_draw_glyph(ptr, glyphId, drawFuncsPtr, 0);
|
|
2831
|
+
return pathBuffer;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
/**
|
|
2835
|
+
* Return glyph name.
|
|
2836
|
+
* @param {number} glyphId ID of the requested glyph in the font.
|
|
2837
|
+
**/
|
|
2838
|
+
function glyphName(glyphId) {
|
|
2839
|
+
exports.hb_font_glyph_to_string(
|
|
2840
|
+
ptr,
|
|
2841
|
+
glyphId,
|
|
2842
|
+
nameBuffer,
|
|
2843
|
+
nameBufferSize
|
|
2844
|
+
);
|
|
2845
|
+
var array = Module.HEAPU8.subarray(nameBuffer, nameBuffer + nameBufferSize);
|
|
2846
|
+
return utf8Decoder.decode(array.slice(0, array.indexOf(0)));
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
return {
|
|
2850
|
+
ptr: ptr,
|
|
2851
|
+
glyphName: glyphName,
|
|
2852
|
+
glyphToPath: glyphToPath,
|
|
2853
|
+
/**
|
|
2854
|
+
* Return a glyph as a JSON path string
|
|
2855
|
+
* based on format described on https://svgwg.org/specs/paths/#InterfaceSVGPathSegment
|
|
2856
|
+
* @param {number} glyphId ID of the requested glyph in the font.
|
|
2857
|
+
**/
|
|
2858
|
+
glyphToJson: function (glyphId) {
|
|
2859
|
+
var path = glyphToPath(glyphId);
|
|
2860
|
+
return path.replace(/([MLQCZ])/g, '|$1 ').split('|').filter(function (x) { return x.length; }).map(function (x) {
|
|
2861
|
+
var row = x.split(/[ ,]/g);
|
|
2862
|
+
return { type: row[0], values: row.slice(1).filter(function (x) { return x.length; }).map(function (x) { return +x; }) };
|
|
2863
|
+
});
|
|
2864
|
+
},
|
|
2865
|
+
/**
|
|
2866
|
+
* Set the font's scale factor, affecting the position values returned from
|
|
2867
|
+
* shaping.
|
|
2868
|
+
* @param {number} xScale Units to scale in the X dimension.
|
|
2869
|
+
* @param {number} yScale Units to scale in the Y dimension.
|
|
2870
|
+
**/
|
|
2871
|
+
setScale: function (xScale, yScale) {
|
|
2872
|
+
exports.hb_font_set_scale(ptr, xScale, yScale);
|
|
2873
|
+
},
|
|
2874
|
+
/**
|
|
2875
|
+
* Set the font's variations.
|
|
2876
|
+
* @param {object} variations Dictionary of variations to set
|
|
2877
|
+
**/
|
|
2878
|
+
setVariations: function (variations) {
|
|
2879
|
+
var entries = Object.entries(variations);
|
|
2880
|
+
var vars = exports.malloc(8 * entries.length);
|
|
2881
|
+
entries.forEach(function (entry, i) {
|
|
2882
|
+
Module.HEAPU32[vars / 4 + i * 2 + 0] = hb_tag(entry[0]);
|
|
2883
|
+
Module.HEAPF32[vars / 4 + i * 2 + 1] = entry[1];
|
|
2884
|
+
});
|
|
2885
|
+
exports.hb_font_set_variations(ptr, vars, entries.length);
|
|
2886
|
+
exports.free(vars);
|
|
2887
|
+
},
|
|
2888
|
+
/**
|
|
2889
|
+
* Free the object.
|
|
2890
|
+
*/
|
|
2891
|
+
destroy: function () {
|
|
2892
|
+
exports.hb_font_destroy(ptr);
|
|
2893
|
+
if (drawFuncsPtr) {
|
|
2894
|
+
exports.hb_draw_funcs_destroy(drawFuncsPtr);
|
|
2895
|
+
drawFuncsPtr = null;
|
|
2896
|
+
removeFunction(moveToPtr);
|
|
2897
|
+
removeFunction(lineToPtr);
|
|
2898
|
+
removeFunction(cubicToPtr);
|
|
2899
|
+
removeFunction(quadToPtr);
|
|
2900
|
+
removeFunction(closePathPtr);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
/**
|
|
2907
|
+
* Use when you know the input range should be ASCII.
|
|
2908
|
+
* Faster than encoding to UTF-8
|
|
2909
|
+
**/
|
|
2910
|
+
function createAsciiString(text) {
|
|
2911
|
+
var ptr = exports.malloc(text.length + 1);
|
|
2912
|
+
for (let i = 0; i < text.length; ++i) {
|
|
2913
|
+
const char = text.charCodeAt(i);
|
|
2914
|
+
if (char > 127) throw new Error('Expected ASCII text');
|
|
2915
|
+
Module.HEAPU8[ptr + i] = char;
|
|
2916
|
+
}
|
|
2917
|
+
Module.HEAPU8[ptr + text.length] = 0;
|
|
2918
|
+
return {
|
|
2919
|
+
ptr: ptr,
|
|
2920
|
+
length: text.length,
|
|
2921
|
+
free: function () { exports.free(ptr); }
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
function createJsString(text) {
|
|
2926
|
+
const ptr = exports.malloc(text.length * 2);
|
|
2927
|
+
const words = new Uint16Array(Module.wasmMemory.buffer, ptr, text.length);
|
|
2928
|
+
for (let i = 0; i < words.length; ++i) words[i] = text.charCodeAt(i);
|
|
2929
|
+
return {
|
|
2930
|
+
ptr: ptr,
|
|
2931
|
+
length: words.length,
|
|
2932
|
+
free: function () { exports.free(ptr); }
|
|
2933
|
+
};
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
/**
|
|
2937
|
+
* Create an object representing a Harfbuzz buffer.
|
|
2938
|
+
**/
|
|
2939
|
+
function createBuffer() {
|
|
2940
|
+
var ptr = exports.hb_buffer_create();
|
|
2941
|
+
return {
|
|
2942
|
+
ptr: ptr,
|
|
2943
|
+
/**
|
|
2944
|
+
* Add text to the buffer.
|
|
2945
|
+
* @param {string} text Text to be added to the buffer.
|
|
2946
|
+
**/
|
|
2947
|
+
addText: function (text) {
|
|
2948
|
+
const str = createJsString(text);
|
|
2949
|
+
exports.hb_buffer_add_utf16(ptr, str.ptr, str.length, 0, str.length);
|
|
2950
|
+
str.free();
|
|
2951
|
+
},
|
|
2952
|
+
/**
|
|
2953
|
+
* Set buffer script, language and direction.
|
|
2954
|
+
*
|
|
2955
|
+
* This needs to be done before shaping.
|
|
2956
|
+
**/
|
|
2957
|
+
guessSegmentProperties: function () {
|
|
2958
|
+
return exports.hb_buffer_guess_segment_properties(ptr);
|
|
2959
|
+
},
|
|
2960
|
+
/**
|
|
2961
|
+
* Set buffer direction explicitly.
|
|
2962
|
+
* @param {string} direction: One of "ltr", "rtl", "ttb" or "btt"
|
|
2963
|
+
*/
|
|
2964
|
+
setDirection: function (dir) {
|
|
2965
|
+
exports.hb_buffer_set_direction(ptr, {
|
|
2966
|
+
ltr: 4,
|
|
2967
|
+
rtl: 5,
|
|
2968
|
+
ttb: 6,
|
|
2969
|
+
btt: 7
|
|
2970
|
+
}[dir] || 0);
|
|
2971
|
+
},
|
|
2972
|
+
/**
|
|
2973
|
+
* Set buffer flags explicitly.
|
|
2974
|
+
* @param {string[]} flags: A list of strings which may be either:
|
|
2975
|
+
* "BOT"
|
|
2976
|
+
* "EOT"
|
|
2977
|
+
* "PRESERVE_DEFAULT_IGNORABLES"
|
|
2978
|
+
* "REMOVE_DEFAULT_IGNORABLES"
|
|
2979
|
+
* "DO_NOT_INSERT_DOTTED_CIRCLE"
|
|
2980
|
+
* "PRODUCE_UNSAFE_TO_CONCAT"
|
|
2981
|
+
*/
|
|
2982
|
+
setFlags: function (flags) {
|
|
2983
|
+
var flagValue = 0;
|
|
2984
|
+
flags.forEach(function (s) {
|
|
2985
|
+
flagValue |= _buffer_flag(s);
|
|
2986
|
+
});
|
|
2987
|
+
|
|
2988
|
+
exports.hb_buffer_set_flags(ptr,flagValue);
|
|
2989
|
+
},
|
|
2990
|
+
/**
|
|
2991
|
+
* Set buffer language explicitly.
|
|
2992
|
+
* @param {string} language: The buffer language
|
|
2993
|
+
*/
|
|
2994
|
+
setLanguage: function (language) {
|
|
2995
|
+
var str = createAsciiString(language);
|
|
2996
|
+
exports.hb_buffer_set_language(ptr, exports.hb_language_from_string(str.ptr,-1));
|
|
2997
|
+
str.free();
|
|
2998
|
+
},
|
|
2999
|
+
/**
|
|
3000
|
+
* Set buffer script explicitly.
|
|
3001
|
+
* @param {string} script: The buffer script
|
|
3002
|
+
*/
|
|
3003
|
+
setScript: function (script) {
|
|
3004
|
+
var str = createAsciiString(script);
|
|
3005
|
+
exports.hb_buffer_set_script(ptr, exports.hb_script_from_string(str.ptr,-1));
|
|
3006
|
+
str.free();
|
|
3007
|
+
},
|
|
3008
|
+
|
|
3009
|
+
/**
|
|
3010
|
+
* Set the Harfbuzz clustering level.
|
|
3011
|
+
*
|
|
3012
|
+
* Affects the cluster values returned from shaping.
|
|
3013
|
+
* @param {number} level: Clustering level. See the Harfbuzz manual chapter
|
|
3014
|
+
* on Clusters.
|
|
3015
|
+
**/
|
|
3016
|
+
setClusterLevel: function (level) {
|
|
3017
|
+
exports.hb_buffer_set_cluster_level(ptr, level);
|
|
3018
|
+
},
|
|
3019
|
+
/**
|
|
3020
|
+
* Return the buffer contents as a JSON object.
|
|
3021
|
+
*
|
|
3022
|
+
* After shaping, this function will return an array of glyph information
|
|
3023
|
+
* objects. Each object will have the following attributes:
|
|
3024
|
+
*
|
|
3025
|
+
* - g: The glyph ID
|
|
3026
|
+
* - cl: The cluster ID
|
|
3027
|
+
* - ax: Advance width (width to advance after this glyph is painted)
|
|
3028
|
+
* - ay: Advance height (height to advance after this glyph is painted)
|
|
3029
|
+
* - dx: X displacement (adjustment in X dimension when painting this glyph)
|
|
3030
|
+
* - dy: Y displacement (adjustment in Y dimension when painting this glyph)
|
|
3031
|
+
* - flags: Glyph flags like `HB_GLYPH_FLAG_UNSAFE_TO_BREAK` (0x1)
|
|
3032
|
+
**/
|
|
3033
|
+
json: function () {
|
|
3034
|
+
var length = exports.hb_buffer_get_length(ptr);
|
|
3035
|
+
var result = [];
|
|
3036
|
+
var infosPtr = exports.hb_buffer_get_glyph_infos(ptr, 0);
|
|
3037
|
+
var infosPtr32 = infosPtr / 4;
|
|
3038
|
+
var positionsPtr32 = exports.hb_buffer_get_glyph_positions(ptr, 0) / 4;
|
|
3039
|
+
var infos = Module.HEAPU32.subarray(infosPtr32, infosPtr32 + 5 * length);
|
|
3040
|
+
var positions = Module.HEAP32.subarray(positionsPtr32, positionsPtr32 + 5 * length);
|
|
3041
|
+
for (var i = 0; i < length; ++i) {
|
|
3042
|
+
result.push({
|
|
3043
|
+
g: infos[i * 5 + 0],
|
|
3044
|
+
cl: infos[i * 5 + 2],
|
|
3045
|
+
ax: positions[i * 5 + 0],
|
|
3046
|
+
ay: positions[i * 5 + 1],
|
|
3047
|
+
dx: positions[i * 5 + 2],
|
|
3048
|
+
dy: positions[i * 5 + 3],
|
|
3049
|
+
flags: exports.hb_glyph_info_get_glyph_flags(infosPtr + i * 20)
|
|
3050
|
+
});
|
|
3051
|
+
}
|
|
3052
|
+
return result;
|
|
3053
|
+
},
|
|
3054
|
+
/**
|
|
3055
|
+
* Free the object.
|
|
3056
|
+
*/
|
|
3057
|
+
destroy: function () { exports.hb_buffer_destroy(ptr); }
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
/**
|
|
3062
|
+
* Shape a buffer with a given font.
|
|
3063
|
+
*
|
|
3064
|
+
* This returns nothing, but modifies the buffer.
|
|
3065
|
+
*
|
|
3066
|
+
* @param {object} font: A font returned from `createFont`
|
|
3067
|
+
* @param {object} buffer: A buffer returned from `createBuffer` and suitably
|
|
3068
|
+
* prepared.
|
|
3069
|
+
* @param {object} features: A string of comma-separated OpenType features to apply.
|
|
3070
|
+
*/
|
|
3071
|
+
function shape(font, buffer, features) {
|
|
3072
|
+
var featuresPtr = 0;
|
|
3073
|
+
var featuresLen = 0;
|
|
3074
|
+
if (features) {
|
|
3075
|
+
features = features.split(",");
|
|
3076
|
+
featuresPtr = exports.malloc(16 * features.length);
|
|
3077
|
+
features.forEach(function (feature, i) {
|
|
3078
|
+
var str = createAsciiString(feature);
|
|
3079
|
+
if (exports.hb_feature_from_string(str.ptr, -1, featuresPtr + featuresLen * 16))
|
|
3080
|
+
featuresLen++;
|
|
3081
|
+
str.free();
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
exports.hb_shape(font.ptr, buffer.ptr, featuresPtr, featuresLen);
|
|
3086
|
+
if (featuresPtr)
|
|
3087
|
+
exports.free(featuresPtr);
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
/**
|
|
3091
|
+
* Shape a buffer with a given font, returning a JSON trace of the shaping process.
|
|
3092
|
+
*
|
|
3093
|
+
* This function supports "partial shaping", where the shaping process is
|
|
3094
|
+
* terminated after a given lookup ID is reached. If the user requests the function
|
|
3095
|
+
* to terminate shaping after an ID in the GSUB phase, GPOS table lookups will be
|
|
3096
|
+
* processed as normal.
|
|
3097
|
+
*
|
|
3098
|
+
* @param {object} font: A font returned from `createFont`
|
|
3099
|
+
* @param {object} buffer: A buffer returned from `createBuffer` and suitably
|
|
3100
|
+
* prepared.
|
|
3101
|
+
* @param {object} features: A string of comma-separated OpenType features to apply.
|
|
3102
|
+
* @param {number} stop_at: A lookup ID at which to terminate shaping.
|
|
3103
|
+
* @param {number} stop_phase: Either 0 (don't terminate shaping), 1 (`stop_at`
|
|
3104
|
+
refers to a lookup ID in the GSUB table), 2 (`stop_at` refers to a lookup
|
|
3105
|
+
ID in the GPOS table).
|
|
3106
|
+
*/
|
|
3107
|
+
function shapeWithTrace(font, buffer, features, stop_at, stop_phase) {
|
|
3108
|
+
var trace = [];
|
|
3109
|
+
var currentPhase = DONT_STOP;
|
|
3110
|
+
var stopping = false;
|
|
3111
|
+
|
|
3112
|
+
var traceBufLen = 1024 * 1024;
|
|
3113
|
+
var traceBufPtr = exports.malloc(traceBufLen);
|
|
3114
|
+
|
|
3115
|
+
var traceFunc = function (bufferPtr, fontPtr, messagePtr, user_data) {
|
|
3116
|
+
var message = utf8Decoder.decode(Module.HEAPU8.subarray(messagePtr, Module.HEAPU8.indexOf(0, messagePtr)));
|
|
3117
|
+
if (message.startsWith("start table GSUB"))
|
|
3118
|
+
currentPhase = GSUB_PHASE;
|
|
3119
|
+
else if (message.startsWith("start table GPOS"))
|
|
3120
|
+
currentPhase = GPOS_PHASE;
|
|
3121
|
+
|
|
3122
|
+
if (currentPhase != stop_phase)
|
|
3123
|
+
stopping = false;
|
|
3124
|
+
|
|
3125
|
+
if (stop_phase != DONT_STOP && currentPhase == stop_phase && message.startsWith("end lookup " + stop_at))
|
|
3126
|
+
stopping = true;
|
|
3127
|
+
|
|
3128
|
+
if (stopping)
|
|
3129
|
+
return 0;
|
|
3130
|
+
|
|
3131
|
+
exports.hb_buffer_serialize_glyphs(
|
|
3132
|
+
bufferPtr,
|
|
3133
|
+
0, exports.hb_buffer_get_length(bufferPtr),
|
|
3134
|
+
traceBufPtr, traceBufLen, 0,
|
|
3135
|
+
fontPtr,
|
|
3136
|
+
HB_BUFFER_SERIALIZE_FORMAT_JSON,
|
|
3137
|
+
HB_BUFFER_SERIALIZE_FLAG_NO_GLYPH_NAMES);
|
|
3138
|
+
|
|
3139
|
+
trace.push({
|
|
3140
|
+
m: message,
|
|
3141
|
+
t: JSON.parse(utf8Decoder.decode(Module.HEAPU8.subarray(traceBufPtr, Module.HEAPU8.indexOf(0, traceBufPtr)))),
|
|
3142
|
+
glyphs: exports.hb_buffer_get_content_type(bufferPtr) == HB_BUFFER_CONTENT_TYPE_GLYPHS,
|
|
3143
|
+
});
|
|
3144
|
+
|
|
3145
|
+
return 1;
|
|
3146
|
+
};
|
|
3147
|
+
|
|
3148
|
+
var traceFuncPtr = addFunction(traceFunc, 'iiiii');
|
|
3149
|
+
exports.hb_buffer_set_message_func(buffer.ptr, traceFuncPtr, 0, 0);
|
|
3150
|
+
shape(font, buffer, features);
|
|
3151
|
+
exports.free(traceBufPtr);
|
|
3152
|
+
removeFunction(traceFuncPtr);
|
|
3153
|
+
|
|
3154
|
+
return trace;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
function version() {
|
|
3158
|
+
var versionPtr = exports.malloc(12);
|
|
3159
|
+
exports.hb_version(versionPtr, versionPtr + 4, versionPtr + 8);
|
|
3160
|
+
var version = {
|
|
3161
|
+
major: Module.HEAPU32[versionPtr / 4],
|
|
3162
|
+
minor: Module.HEAPU32[(versionPtr + 4) / 4],
|
|
3163
|
+
micro: Module.HEAPU32[(versionPtr + 8) / 4],
|
|
3164
|
+
};
|
|
3165
|
+
exports.free(versionPtr);
|
|
3166
|
+
return version;
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
function version_string() {
|
|
3170
|
+
var versionPtr = exports.hb_version_string();
|
|
3171
|
+
var version = utf8Decoder.decode(Module.HEAPU8.subarray(versionPtr, Module.HEAPU8.indexOf(0, versionPtr)));
|
|
3172
|
+
return version;
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
return {
|
|
3176
|
+
createBlob: createBlob,
|
|
3177
|
+
createFace: createFace,
|
|
3178
|
+
createFont: createFont,
|
|
3179
|
+
createBuffer: createBuffer,
|
|
3180
|
+
shape: shape,
|
|
3181
|
+
shapeWithTrace: shapeWithTrace,
|
|
3182
|
+
version: version,
|
|
3183
|
+
version_string: version_string,
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
// Should be replaced with something more reliable
|
|
3188
|
+
try {
|
|
3189
|
+
hbjs$2.exports = hbjs;
|
|
3190
|
+
} catch (e) {}
|
|
3191
|
+
|
|
3192
|
+
var hbjsExports = hbjs$2.exports;
|
|
3193
|
+
var hbjs$1 = /*@__PURE__*/getDefaultExportFromCjs(hbjsExports);
|
|
3194
|
+
|
|
3195
|
+
let harfbuzzPromise = null;
|
|
3196
|
+
let wasmPath = null;
|
|
3197
|
+
let wasmBuffer = null;
|
|
3198
|
+
const HarfBuzzLoader = {
|
|
3199
|
+
setWasmPath(path) {
|
|
3200
|
+
wasmPath = path;
|
|
3201
|
+
wasmBuffer = null; // Clear buffer if path is set
|
|
3202
|
+
harfbuzzPromise = null;
|
|
3203
|
+
},
|
|
3204
|
+
setWasmBuffer(buffer) {
|
|
3205
|
+
wasmBuffer = buffer;
|
|
3206
|
+
wasmPath = null; // Clear path if buffer is set
|
|
3207
|
+
harfbuzzPromise = null;
|
|
3208
|
+
},
|
|
3209
|
+
async getHarfBuzz() {
|
|
3210
|
+
if (harfbuzzPromise) {
|
|
3211
|
+
return harfbuzzPromise;
|
|
3212
|
+
}
|
|
3213
|
+
harfbuzzPromise = new Promise(async (resolve, reject) => {
|
|
3214
|
+
try {
|
|
3215
|
+
const moduleConfig = {};
|
|
3216
|
+
if (wasmBuffer) {
|
|
3217
|
+
moduleConfig.wasmBinary = wasmBuffer;
|
|
3218
|
+
}
|
|
3219
|
+
else if (wasmPath) {
|
|
3220
|
+
moduleConfig.wasmBinary = await loadBinary(wasmPath);
|
|
3221
|
+
}
|
|
3222
|
+
else {
|
|
3223
|
+
throw new Error('HarfBuzz WASM path or buffer must be set before initialization.');
|
|
3224
|
+
}
|
|
3225
|
+
const hbModule = await createHarfBuzz(moduleConfig);
|
|
3226
|
+
const hb = hbjs$1(hbModule);
|
|
3227
|
+
const module = {
|
|
3228
|
+
addFunction: hbModule.addFunction,
|
|
3229
|
+
exports: hbModule.wasmExports,
|
|
3230
|
+
removeFunction: hbModule.removeFunction
|
|
3231
|
+
};
|
|
3232
|
+
resolve({ hb, module });
|
|
3233
|
+
}
|
|
3234
|
+
catch (error) {
|
|
3235
|
+
reject(new Error(`Failed to initialize HarfBuzz: ${error}`));
|
|
3236
|
+
}
|
|
3237
|
+
});
|
|
3238
|
+
return harfbuzzPromise;
|
|
3239
|
+
}
|
|
3240
|
+
};
|
|
3241
|
+
|
|
3242
|
+
const DEFAULT_MAX_TEXT_LENGTH = 100000;
|
|
3243
|
+
const DEFAULT_FONT_SIZE = 72;
|
|
3244
|
+
let Text$1 = class Text {
|
|
3245
|
+
static { this.patternCache = new Map(); }
|
|
3246
|
+
static { this.hbInitPromise = null; }
|
|
3247
|
+
static { this.fontCache = new Map(); }
|
|
3248
|
+
static { this.fontLoadPromises = new Map(); }
|
|
3249
|
+
static { this.fontRefCounts = new Map(); }
|
|
3250
|
+
static { this.fontCacheMemoryBytes = 0; }
|
|
3251
|
+
static { this.maxFontCacheMemoryBytes = Infinity; }
|
|
3252
|
+
static { this.fontIdCounter = 0; }
|
|
3253
|
+
static enableWoff2(decoder) {
|
|
3254
|
+
setWoff2Decoder(decoder);
|
|
3255
|
+
}
|
|
3256
|
+
static stableStringify(obj) {
|
|
3257
|
+
const keys = Object.keys(obj).sort();
|
|
3258
|
+
let result = '';
|
|
3259
|
+
for (let i = 0; i < keys.length; i++) {
|
|
3260
|
+
if (i > 0)
|
|
3261
|
+
result += ',';
|
|
3262
|
+
result += keys[i] + ':' + obj[keys[i]];
|
|
3263
|
+
}
|
|
3264
|
+
return result;
|
|
3265
|
+
}
|
|
3266
|
+
constructor() {
|
|
3267
|
+
this.currentFontId = '';
|
|
3268
|
+
if (!Text.hbInitPromise) {
|
|
3269
|
+
Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
|
|
3270
|
+
}
|
|
3271
|
+
this.fontLoader = new FontLoader(() => Text.hbInitPromise);
|
|
3272
|
+
}
|
|
3273
|
+
static setHarfBuzzPath(path) {
|
|
3274
|
+
HarfBuzzLoader.setWasmPath(path);
|
|
3275
|
+
Text.hbInitPromise = null;
|
|
3276
|
+
}
|
|
3277
|
+
static setHarfBuzzBuffer(wasmBuffer) {
|
|
3278
|
+
HarfBuzzLoader.setWasmBuffer(wasmBuffer);
|
|
3279
|
+
Text.hbInitPromise = null;
|
|
3280
|
+
}
|
|
3281
|
+
static init() {
|
|
3282
|
+
if (!Text.hbInitPromise) {
|
|
3283
|
+
Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
|
|
3284
|
+
}
|
|
3285
|
+
return Text.hbInitPromise;
|
|
3286
|
+
}
|
|
3287
|
+
static async create(options) {
|
|
3288
|
+
if (!options.font) {
|
|
3289
|
+
throw new Error('Font is required. Specify options.font as a URL string or ArrayBuffer.');
|
|
3290
|
+
}
|
|
3291
|
+
if (!Text.hbInitPromise) {
|
|
3292
|
+
Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
|
|
3293
|
+
}
|
|
3294
|
+
const { loadedFont, fontKey } = await Text.resolveFont(options);
|
|
3295
|
+
const text = new Text();
|
|
3296
|
+
text.setLoadedFont(loadedFont, fontKey);
|
|
3297
|
+
const result = await text.createLayout(options);
|
|
3298
|
+
const update = async (newOptions) => {
|
|
3299
|
+
const mergedOptions = { ...options };
|
|
3300
|
+
for (const key in newOptions) {
|
|
3301
|
+
const value = newOptions[key];
|
|
3302
|
+
if (value !== undefined) {
|
|
3303
|
+
mergedOptions[key] = value;
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
if (newOptions.font !== undefined ||
|
|
3307
|
+
newOptions.fontVariations !== undefined ||
|
|
3308
|
+
newOptions.fontFeatures !== undefined) {
|
|
3309
|
+
const { loadedFont: newLoadedFont, fontKey: newFontKey } = await Text.resolveFont(mergedOptions);
|
|
3310
|
+
text.setLoadedFont(newLoadedFont, newFontKey);
|
|
3311
|
+
text.resetHelpers();
|
|
3312
|
+
}
|
|
3313
|
+
options = mergedOptions;
|
|
3314
|
+
const newResult = await text.createLayout(options);
|
|
3315
|
+
return {
|
|
3316
|
+
...newResult,
|
|
3317
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
3318
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
3319
|
+
update,
|
|
3320
|
+
dispose: () => text.destroy()
|
|
3321
|
+
};
|
|
3322
|
+
};
|
|
3323
|
+
return {
|
|
3324
|
+
...result,
|
|
3325
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
3326
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
3327
|
+
update,
|
|
3328
|
+
dispose: () => text.destroy()
|
|
3329
|
+
};
|
|
3330
|
+
}
|
|
3331
|
+
static retainFont(fontKey) {
|
|
3332
|
+
Text.fontRefCounts.set(fontKey, (Text.fontRefCounts.get(fontKey) ?? 0) + 1);
|
|
3333
|
+
}
|
|
3334
|
+
static releaseFont(fontKey, loadedFont) {
|
|
3335
|
+
const nextCount = (Text.fontRefCounts.get(fontKey) ?? 0) - 1;
|
|
3336
|
+
if (nextCount > 0) {
|
|
3337
|
+
Text.fontRefCounts.set(fontKey, nextCount);
|
|
3338
|
+
return;
|
|
3339
|
+
}
|
|
3340
|
+
Text.fontRefCounts.delete(fontKey);
|
|
3341
|
+
// Cached fonts stay alive while present in the cache. If a font has been
|
|
3342
|
+
// evicted, destroy it once the last live handle releases it.
|
|
3343
|
+
if (!Text.fontCache.has(fontKey)) {
|
|
3344
|
+
FontLoader.destroyFont(loadedFont);
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
static async resolveFont(options) {
|
|
3348
|
+
const baseFontKey = typeof options.font === 'string'
|
|
3349
|
+
? options.font
|
|
3350
|
+
: `buffer-${Text.generateFontContentHash(options.font)}`;
|
|
3351
|
+
let fontKey = baseFontKey;
|
|
3352
|
+
if (options.fontVariations) {
|
|
3353
|
+
fontKey += `_var_${Text.stableStringify(options.fontVariations)}`;
|
|
3354
|
+
}
|
|
3355
|
+
if (options.fontFeatures) {
|
|
3356
|
+
fontKey += `_feat_${Text.stableStringify(options.fontFeatures)}`;
|
|
3357
|
+
}
|
|
3358
|
+
let loadedFont = Text.fontCache.get(fontKey);
|
|
3359
|
+
if (!loadedFont) {
|
|
3360
|
+
let loadPromise = Text.fontLoadPromises.get(fontKey);
|
|
3361
|
+
if (!loadPromise) {
|
|
3362
|
+
loadPromise = Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures).finally(() => {
|
|
3363
|
+
Text.fontLoadPromises.delete(fontKey);
|
|
3364
|
+
});
|
|
3365
|
+
Text.fontLoadPromises.set(fontKey, loadPromise);
|
|
3366
|
+
}
|
|
3367
|
+
loadedFont = await loadPromise;
|
|
3368
|
+
}
|
|
3369
|
+
Text.retainFont(fontKey);
|
|
3370
|
+
return { loadedFont, fontKey };
|
|
3371
|
+
}
|
|
3372
|
+
static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
|
|
3373
|
+
const tempText = new Text();
|
|
3374
|
+
await tempText.loadFont(font, fontVariations, fontFeatures);
|
|
3375
|
+
const loadedFont = tempText.getLoadedFont();
|
|
3376
|
+
Text.fontCache.set(fontKey, loadedFont);
|
|
3377
|
+
Text.trackFontCacheAdd(loadedFont);
|
|
3378
|
+
Text.enforceFontCacheMemoryLimit();
|
|
3379
|
+
return loadedFont;
|
|
3380
|
+
}
|
|
3381
|
+
static trackFontCacheAdd(loadedFont) {
|
|
3382
|
+
const size = loadedFont._buffer?.byteLength ?? 0;
|
|
3383
|
+
Text.fontCacheMemoryBytes += size;
|
|
3384
|
+
}
|
|
3385
|
+
static trackFontCacheRemove(fontKey) {
|
|
3386
|
+
const font = Text.fontCache.get(fontKey);
|
|
3387
|
+
if (!font)
|
|
3388
|
+
return;
|
|
3389
|
+
const size = font._buffer?.byteLength ?? 0;
|
|
3390
|
+
Text.fontCacheMemoryBytes -= size;
|
|
3391
|
+
if (Text.fontCacheMemoryBytes < 0)
|
|
3392
|
+
Text.fontCacheMemoryBytes = 0;
|
|
3393
|
+
}
|
|
3394
|
+
static enforceFontCacheMemoryLimit() {
|
|
3395
|
+
if (Text.maxFontCacheMemoryBytes === Infinity)
|
|
3396
|
+
return;
|
|
3397
|
+
while (Text.fontCacheMemoryBytes > Text.maxFontCacheMemoryBytes &&
|
|
3398
|
+
Text.fontCache.size > 0) {
|
|
3399
|
+
const firstKey = Text.fontCache.keys().next().value;
|
|
3400
|
+
if (firstKey === undefined)
|
|
3401
|
+
break;
|
|
3402
|
+
const font = Text.fontCache.get(firstKey);
|
|
3403
|
+
Text.trackFontCacheRemove(firstKey);
|
|
3404
|
+
Text.fontCache.delete(firstKey);
|
|
3405
|
+
if ((Text.fontRefCounts.get(firstKey) ?? 0) <= 0 && font) {
|
|
3406
|
+
FontLoader.destroyFont(font);
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
18
3409
|
}
|
|
19
|
-
|
|
20
|
-
|
|
3410
|
+
static generateFontContentHash(buffer) {
|
|
3411
|
+
if (buffer) {
|
|
3412
|
+
const view = new Uint8Array(buffer);
|
|
3413
|
+
let hash = 2166136261;
|
|
3414
|
+
const samplePoints = Math.min(32, view.length);
|
|
3415
|
+
const step = Math.floor(view.length / samplePoints);
|
|
3416
|
+
for (let i = 0; i < samplePoints; i++) {
|
|
3417
|
+
const index = i * step;
|
|
3418
|
+
hash ^= view[index];
|
|
3419
|
+
hash = Math.imul(hash, 16777619);
|
|
3420
|
+
}
|
|
3421
|
+
hash ^= view.length;
|
|
3422
|
+
hash = Math.imul(hash, 16777619);
|
|
3423
|
+
return (hash >>> 0).toString(36);
|
|
3424
|
+
}
|
|
3425
|
+
else {
|
|
3426
|
+
return `c${++Text.fontIdCounter}`;
|
|
3427
|
+
}
|
|
21
3428
|
}
|
|
22
|
-
|
|
23
|
-
this.
|
|
24
|
-
|
|
25
|
-
|
|
3429
|
+
setLoadedFont(loadedFont, fontKey) {
|
|
3430
|
+
if (this.loadedFont && this.loadedFont !== loadedFont) {
|
|
3431
|
+
this.releaseCurrentFont();
|
|
3432
|
+
}
|
|
3433
|
+
this.loadedFont = loadedFont;
|
|
3434
|
+
this.currentFontCacheKey = fontKey;
|
|
3435
|
+
const contentHash = Text.generateFontContentHash(loadedFont._buffer);
|
|
3436
|
+
this.currentFontId = `font_${contentHash}`;
|
|
3437
|
+
if (loadedFont.fontVariations) {
|
|
3438
|
+
this.currentFontId += `_var_${Text.stableStringify(loadedFont.fontVariations)}`;
|
|
3439
|
+
}
|
|
3440
|
+
if (loadedFont.fontFeatures) {
|
|
3441
|
+
this.currentFontId += `_feat_${Text.stableStringify(loadedFont.fontFeatures)}`;
|
|
3442
|
+
}
|
|
26
3443
|
}
|
|
27
|
-
|
|
28
|
-
this.
|
|
29
|
-
|
|
30
|
-
|
|
3444
|
+
releaseCurrentFont() {
|
|
3445
|
+
if (!this.loadedFont)
|
|
3446
|
+
return;
|
|
3447
|
+
const currentFont = this.loadedFont;
|
|
3448
|
+
const currentFontKey = this.currentFontCacheKey;
|
|
3449
|
+
try {
|
|
3450
|
+
if (currentFontKey) {
|
|
3451
|
+
Text.releaseFont(currentFontKey, currentFont);
|
|
3452
|
+
}
|
|
3453
|
+
else {
|
|
3454
|
+
FontLoader.destroyFont(currentFont);
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
catch (error) {
|
|
3458
|
+
logger.warn('Error destroying HarfBuzz objects:', error);
|
|
3459
|
+
}
|
|
3460
|
+
finally {
|
|
3461
|
+
this.loadedFont = undefined;
|
|
3462
|
+
this.currentFontCacheKey = undefined;
|
|
3463
|
+
this.textLayout = undefined;
|
|
3464
|
+
this.textShaper = undefined;
|
|
3465
|
+
}
|
|
31
3466
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
3467
|
+
async loadFont(fontSrc, fontVariations, fontFeatures) {
|
|
3468
|
+
perfLogger.start('Text.loadFont', {
|
|
3469
|
+
fontSrc: typeof fontSrc === 'string' ? fontSrc : `buffer(${fontSrc.byteLength})`
|
|
3470
|
+
});
|
|
3471
|
+
if (!Text.hbInitPromise) {
|
|
3472
|
+
Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
|
|
3473
|
+
}
|
|
3474
|
+
await Text.hbInitPromise;
|
|
3475
|
+
const fontBuffer = typeof fontSrc === 'string' ? await loadBinary(fontSrc) : fontSrc;
|
|
3476
|
+
try {
|
|
3477
|
+
if (this.loadedFont) {
|
|
3478
|
+
this.destroy();
|
|
3479
|
+
}
|
|
3480
|
+
this.loadedFont = await this.fontLoader.loadFont(fontBuffer, fontVariations);
|
|
3481
|
+
if (fontFeatures) {
|
|
3482
|
+
this.loadedFont.fontFeatures = fontFeatures;
|
|
3483
|
+
}
|
|
3484
|
+
const contentHash = Text.generateFontContentHash(fontBuffer);
|
|
3485
|
+
this.currentFontId = `font_${contentHash}`;
|
|
3486
|
+
if (fontVariations) {
|
|
3487
|
+
this.currentFontId += `_var_${Text.stableStringify(fontVariations)}`;
|
|
3488
|
+
}
|
|
3489
|
+
if (fontFeatures) {
|
|
3490
|
+
this.currentFontId += `_feat_${Text.stableStringify(fontFeatures)}`;
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
catch (error) {
|
|
3494
|
+
logger.error('Failed to load font:', error);
|
|
3495
|
+
throw error;
|
|
3496
|
+
}
|
|
3497
|
+
finally {
|
|
3498
|
+
perfLogger.end('Text.loadFont');
|
|
3499
|
+
}
|
|
36
3500
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
3501
|
+
async createLayout(options) {
|
|
3502
|
+
perfLogger.start('Text.createLayout', {
|
|
3503
|
+
textLength: options.text.length,
|
|
3504
|
+
size: options.size || DEFAULT_FONT_SIZE,
|
|
3505
|
+
hasLayout: !!options.layout
|
|
3506
|
+
});
|
|
3507
|
+
try {
|
|
3508
|
+
if (!this.loadedFont) {
|
|
3509
|
+
throw new Error('Font not loaded. Use Text.create() with a font option.');
|
|
3510
|
+
}
|
|
3511
|
+
const updatedOptions = await this.prepareHyphenation(options);
|
|
3512
|
+
this.validateOptions(updatedOptions);
|
|
3513
|
+
options = updatedOptions;
|
|
3514
|
+
this.updateFontVariations(options);
|
|
3515
|
+
this.loadedFont.font.setScale(this.loadedFont.upem, this.loadedFont.upem);
|
|
3516
|
+
if (!this.textShaper) {
|
|
3517
|
+
this.textShaper = new TextShaper(this.loadedFont);
|
|
3518
|
+
}
|
|
3519
|
+
const layoutData = this.prepareLayout(options);
|
|
3520
|
+
const clustersByLine = this.textShaper.shapeLines(layoutData.lines, layoutData.scaledLineHeight, layoutData.letterSpacing, layoutData.align, layoutData.direction, options.color, options.text);
|
|
3521
|
+
return {
|
|
3522
|
+
clustersByLine,
|
|
3523
|
+
layoutData,
|
|
3524
|
+
options,
|
|
3525
|
+
loadedFont: this.loadedFont,
|
|
3526
|
+
fontId: this.currentFontId
|
|
3527
|
+
};
|
|
3528
|
+
}
|
|
3529
|
+
finally {
|
|
3530
|
+
perfLogger.end('Text.createLayout');
|
|
3531
|
+
}
|
|
41
3532
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
3533
|
+
async prepareHyphenation(options) {
|
|
3534
|
+
if (options.layout?.hyphenate !== false && options.layout?.width) {
|
|
3535
|
+
const language = options.layout?.language || 'en-us';
|
|
3536
|
+
if (!options.layout?.hyphenationPatterns?.[language]) {
|
|
3537
|
+
try {
|
|
3538
|
+
if (!Text.patternCache.has(language)) {
|
|
3539
|
+
const pattern = await loadPattern(language, options.layout?.patternsPath);
|
|
3540
|
+
Text.patternCache.set(language, pattern);
|
|
3541
|
+
}
|
|
3542
|
+
return {
|
|
3543
|
+
...options,
|
|
3544
|
+
layout: {
|
|
3545
|
+
...options.layout,
|
|
3546
|
+
hyphenationPatterns: {
|
|
3547
|
+
...options.layout?.hyphenationPatterns,
|
|
3548
|
+
[language]: Text.patternCache.get(language)
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
};
|
|
3552
|
+
}
|
|
3553
|
+
catch (error) {
|
|
3554
|
+
logger.warn(`Failed to load patterns for ${language}: ${error}`);
|
|
3555
|
+
return {
|
|
3556
|
+
...options,
|
|
3557
|
+
layout: {
|
|
3558
|
+
...options.layout,
|
|
3559
|
+
hyphenate: false
|
|
3560
|
+
}
|
|
3561
|
+
};
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
return options;
|
|
46
3566
|
}
|
|
47
|
-
|
|
48
|
-
|
|
3567
|
+
validateOptions(options) {
|
|
3568
|
+
if (!options.text) {
|
|
3569
|
+
throw new Error('Text content is required');
|
|
3570
|
+
}
|
|
3571
|
+
const maxLength = options.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH;
|
|
3572
|
+
if (options.text.length > maxLength) {
|
|
3573
|
+
throw new Error(`Text exceeds ${maxLength} character limit`);
|
|
3574
|
+
}
|
|
49
3575
|
}
|
|
50
|
-
|
|
51
|
-
|
|
3576
|
+
updateFontVariations(options) {
|
|
3577
|
+
if (options.fontVariations && this.loadedFont) {
|
|
3578
|
+
if (Text.stableStringify(options.fontVariations) !==
|
|
3579
|
+
Text.stableStringify(this.loadedFont.fontVariations || {})) {
|
|
3580
|
+
this.loadedFont.font.setVariations(options.fontVariations);
|
|
3581
|
+
this.loadedFont.fontVariations = options.fontVariations;
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
52
3584
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.divide(len);
|
|
3585
|
+
prepareLayout(options) {
|
|
3586
|
+
if (!this.loadedFont) {
|
|
3587
|
+
throw new Error('Font not loaded. Use Text.create() with a font option');
|
|
57
3588
|
}
|
|
58
|
-
|
|
3589
|
+
const { text, size = DEFAULT_FONT_SIZE, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
|
|
3590
|
+
const { width, direction = 'ltr', align = direction === 'rtl' ? 'right' : 'left', respectExistingBreaks = true, hyphenate = true, language = 'en-us', tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits } = layout;
|
|
3591
|
+
const fontUnitsPerPixel = this.loadedFont.upem / size;
|
|
3592
|
+
let widthInFontUnits;
|
|
3593
|
+
if (width !== undefined) {
|
|
3594
|
+
widthInFontUnits = width * fontUnitsPerPixel;
|
|
3595
|
+
}
|
|
3596
|
+
const rawDepthInFontUnits = depth * fontUnitsPerPixel;
|
|
3597
|
+
const minExtrudeDepth = this.loadedFont.upem * 0.000025;
|
|
3598
|
+
const depthInFontUnits = rawDepthInFontUnits <= 0
|
|
3599
|
+
? 0
|
|
3600
|
+
: Math.max(rawDepthInFontUnits, minExtrudeDepth);
|
|
3601
|
+
if (!this.textLayout) {
|
|
3602
|
+
this.textLayout = new TextLayout(this.loadedFont);
|
|
3603
|
+
}
|
|
3604
|
+
const layoutResult = this.textLayout.computeLines({
|
|
3605
|
+
text,
|
|
3606
|
+
width: widthInFontUnits,
|
|
3607
|
+
align,
|
|
3608
|
+
direction,
|
|
3609
|
+
hyphenate,
|
|
3610
|
+
language,
|
|
3611
|
+
respectExistingBreaks,
|
|
3612
|
+
tolerance,
|
|
3613
|
+
pretolerance,
|
|
3614
|
+
emergencyStretch,
|
|
3615
|
+
autoEmergencyStretch,
|
|
3616
|
+
hyphenationPatterns,
|
|
3617
|
+
lefthyphenmin,
|
|
3618
|
+
righthyphenmin,
|
|
3619
|
+
linepenalty,
|
|
3620
|
+
adjdemerits,
|
|
3621
|
+
hyphenpenalty,
|
|
3622
|
+
exhyphenpenalty,
|
|
3623
|
+
doublehyphendemerits,
|
|
3624
|
+
letterSpacing
|
|
3625
|
+
});
|
|
3626
|
+
const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
|
|
3627
|
+
const fontLineHeight = metrics.ascender - metrics.descender;
|
|
3628
|
+
const scaledLineHeight = fontLineHeight * lineHeight;
|
|
3629
|
+
return {
|
|
3630
|
+
lines: layoutResult.lines,
|
|
3631
|
+
scaledLineHeight,
|
|
3632
|
+
letterSpacing,
|
|
3633
|
+
align,
|
|
3634
|
+
direction,
|
|
3635
|
+
depth: depthInFontUnits,
|
|
3636
|
+
size,
|
|
3637
|
+
pixelsPerFontUnit: 1 / fontUnitsPerPixel
|
|
3638
|
+
};
|
|
59
3639
|
}
|
|
60
|
-
|
|
61
|
-
|
|
3640
|
+
getFontMetrics() {
|
|
3641
|
+
if (!this.loadedFont) {
|
|
3642
|
+
throw new Error('Font not loaded. Call loadFont() first');
|
|
3643
|
+
}
|
|
3644
|
+
return FontMetadataExtractor.getFontMetrics(this.loadedFont.metrics);
|
|
62
3645
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
3646
|
+
static async preloadPatterns(languages, patternsPath) {
|
|
3647
|
+
await Promise.all(languages.map(async (language) => {
|
|
3648
|
+
if (!Text.patternCache.has(language)) {
|
|
3649
|
+
try {
|
|
3650
|
+
const pattern = await loadPattern(language, patternsPath);
|
|
3651
|
+
Text.patternCache.set(language, pattern);
|
|
3652
|
+
}
|
|
3653
|
+
catch (error) {
|
|
3654
|
+
logger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
}));
|
|
67
3658
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const dy = this.y - v.y;
|
|
71
|
-
return dx * dx + dy * dy;
|
|
3659
|
+
static registerPattern(language, pattern) {
|
|
3660
|
+
Text.patternCache.set(language, pattern);
|
|
72
3661
|
}
|
|
73
|
-
|
|
74
|
-
|
|
3662
|
+
static setMaxFontCacheMemoryMB(limitMB) {
|
|
3663
|
+
Text.maxFontCacheMemoryBytes =
|
|
3664
|
+
limitMB === Infinity
|
|
3665
|
+
? Infinity
|
|
3666
|
+
: Math.max(1, Math.floor(limitMB)) * 1024 * 1024;
|
|
3667
|
+
Text.enforceFontCacheMemoryLimit();
|
|
75
3668
|
}
|
|
76
|
-
|
|
77
|
-
return
|
|
3669
|
+
getLoadedFont() {
|
|
3670
|
+
return this.loadedFont;
|
|
3671
|
+
}
|
|
3672
|
+
measureTextWidth(text, letterSpacing = 0) {
|
|
3673
|
+
if (!this.loadedFont) {
|
|
3674
|
+
throw new Error('Font not loaded. Call loadFont() first');
|
|
3675
|
+
}
|
|
3676
|
+
return TextMeasurer.measureTextWidth(this.loadedFont, text, letterSpacing);
|
|
3677
|
+
}
|
|
3678
|
+
resetHelpers() {
|
|
3679
|
+
this.textShaper = undefined;
|
|
3680
|
+
this.textLayout = undefined;
|
|
3681
|
+
}
|
|
3682
|
+
destroy() {
|
|
3683
|
+
if (!this.loadedFont) {
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
this.releaseCurrentFont();
|
|
3687
|
+
}
|
|
3688
|
+
};
|
|
3689
|
+
|
|
3690
|
+
// Map-based cache with no eviction policy
|
|
3691
|
+
class Cache {
|
|
3692
|
+
constructor() {
|
|
3693
|
+
this.cache = new Map();
|
|
3694
|
+
}
|
|
3695
|
+
get(key) {
|
|
3696
|
+
return this.cache.get(key);
|
|
3697
|
+
}
|
|
3698
|
+
has(key) {
|
|
3699
|
+
return this.cache.has(key);
|
|
3700
|
+
}
|
|
3701
|
+
set(key, value) {
|
|
3702
|
+
this.cache.set(key, value);
|
|
3703
|
+
}
|
|
3704
|
+
delete(key) {
|
|
3705
|
+
return this.cache.delete(key);
|
|
3706
|
+
}
|
|
3707
|
+
clear() {
|
|
3708
|
+
this.cache.clear();
|
|
3709
|
+
}
|
|
3710
|
+
get size() {
|
|
3711
|
+
return this.cache.size;
|
|
3712
|
+
}
|
|
3713
|
+
keys() {
|
|
3714
|
+
return Array.from(this.cache.keys());
|
|
3715
|
+
}
|
|
3716
|
+
getStats() {
|
|
3717
|
+
return {
|
|
3718
|
+
size: this.cache.size
|
|
3719
|
+
};
|
|
78
3720
|
}
|
|
79
3721
|
}
|
|
80
3722
|
|
|
3723
|
+
const globalOutlineCache = new Cache();
|
|
3724
|
+
|
|
81
3725
|
class GlyphOutlineCollector {
|
|
82
3726
|
constructor() {
|
|
83
3727
|
this.currentGlyphId = 0;
|
|
@@ -251,6 +3895,122 @@ class GlyphOutlineCollector {
|
|
|
251
3895
|
}
|
|
252
3896
|
}
|
|
253
3897
|
|
|
3898
|
+
class DrawCallbackHandler {
|
|
3899
|
+
constructor() {
|
|
3900
|
+
this.moveTo_func = null;
|
|
3901
|
+
this.lineTo_func = null;
|
|
3902
|
+
this.quadTo_func = null;
|
|
3903
|
+
this.cubicTo_func = null;
|
|
3904
|
+
this.closePath_func = null;
|
|
3905
|
+
this.drawFuncsPtr = 0;
|
|
3906
|
+
this.position = { x: 0, y: 0 };
|
|
3907
|
+
}
|
|
3908
|
+
setPosition(x, y) {
|
|
3909
|
+
this.position.x = x;
|
|
3910
|
+
this.position.y = y;
|
|
3911
|
+
if (this.collector) {
|
|
3912
|
+
this.collector.setPosition(x, y);
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
updatePosition(dx, dy) {
|
|
3916
|
+
this.position.x += dx;
|
|
3917
|
+
this.position.y += dy;
|
|
3918
|
+
if (this.collector) {
|
|
3919
|
+
this.collector.updatePosition(dx, dy);
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
setCollector(collector) {
|
|
3923
|
+
this.collector = collector;
|
|
3924
|
+
}
|
|
3925
|
+
createDrawFuncs(font, collector) {
|
|
3926
|
+
if (!font || !font.module || !font.hb) {
|
|
3927
|
+
throw new Error('Invalid font object');
|
|
3928
|
+
}
|
|
3929
|
+
this.collector = collector;
|
|
3930
|
+
if (this.drawFuncsPtr) {
|
|
3931
|
+
return;
|
|
3932
|
+
}
|
|
3933
|
+
const module = font.module;
|
|
3934
|
+
// Collect contours at origin - position applied during instancing
|
|
3935
|
+
this.moveTo_func = module.addFunction((_dfuncs, _draw_data, _draw_state, to_x, to_y) => {
|
|
3936
|
+
this.collector?.onMoveTo(to_x, to_y);
|
|
3937
|
+
}, 'viiiffi');
|
|
3938
|
+
this.lineTo_func = module.addFunction((_dfuncs, _draw_data, _draw_state, to_x, to_y) => {
|
|
3939
|
+
this.collector?.onLineTo(to_x, to_y);
|
|
3940
|
+
}, 'viiiffi');
|
|
3941
|
+
this.quadTo_func = module.addFunction((_dfuncs, _draw_data, _draw_state, c_x, c_y, to_x, to_y) => {
|
|
3942
|
+
this.collector?.onQuadTo(c_x, c_y, to_x, to_y);
|
|
3943
|
+
}, 'viiiffffi');
|
|
3944
|
+
this.cubicTo_func = module.addFunction((_dfuncs, _draw_data, _draw_state, c1_x, c1_y, c2_x, c2_y, to_x, to_y) => {
|
|
3945
|
+
this.collector?.onCubicTo(c1_x, c1_y, c2_x, c2_y, to_x, to_y);
|
|
3946
|
+
}, 'viiiffffffi');
|
|
3947
|
+
this.closePath_func = module.addFunction((_dfuncs, _draw_data, _draw_state) => {
|
|
3948
|
+
this.collector?.onClosePath();
|
|
3949
|
+
}, 'viiii');
|
|
3950
|
+
// Create HarfBuzz draw functions object using the module exports
|
|
3951
|
+
this.drawFuncsPtr = module.exports.hb_draw_funcs_create();
|
|
3952
|
+
module.exports.hb_draw_funcs_set_move_to_func(this.drawFuncsPtr, this.moveTo_func, 0, 0);
|
|
3953
|
+
module.exports.hb_draw_funcs_set_line_to_func(this.drawFuncsPtr, this.lineTo_func, 0, 0);
|
|
3954
|
+
module.exports.hb_draw_funcs_set_quadratic_to_func(this.drawFuncsPtr, this.quadTo_func, 0, 0);
|
|
3955
|
+
module.exports.hb_draw_funcs_set_cubic_to_func(this.drawFuncsPtr, this.cubicTo_func, 0, 0);
|
|
3956
|
+
module.exports.hb_draw_funcs_set_close_path_func(this.drawFuncsPtr, this.closePath_func, 0, 0);
|
|
3957
|
+
}
|
|
3958
|
+
getDrawFuncsPtr() {
|
|
3959
|
+
if (!this.drawFuncsPtr) {
|
|
3960
|
+
throw new Error('Draw functions not initialized');
|
|
3961
|
+
}
|
|
3962
|
+
return this.drawFuncsPtr;
|
|
3963
|
+
}
|
|
3964
|
+
destroy(font) {
|
|
3965
|
+
if (!font || !font.module || !font.hb) {
|
|
3966
|
+
return;
|
|
3967
|
+
}
|
|
3968
|
+
const module = font.module;
|
|
3969
|
+
try {
|
|
3970
|
+
if (this.drawFuncsPtr) {
|
|
3971
|
+
module.exports.hb_draw_funcs_destroy(this.drawFuncsPtr);
|
|
3972
|
+
this.drawFuncsPtr = 0;
|
|
3973
|
+
}
|
|
3974
|
+
if (this.moveTo_func !== null) {
|
|
3975
|
+
module.removeFunction(this.moveTo_func);
|
|
3976
|
+
this.moveTo_func = null;
|
|
3977
|
+
}
|
|
3978
|
+
if (this.lineTo_func !== null) {
|
|
3979
|
+
module.removeFunction(this.lineTo_func);
|
|
3980
|
+
this.lineTo_func = null;
|
|
3981
|
+
}
|
|
3982
|
+
if (this.quadTo_func !== null) {
|
|
3983
|
+
module.removeFunction(this.quadTo_func);
|
|
3984
|
+
this.quadTo_func = null;
|
|
3985
|
+
}
|
|
3986
|
+
if (this.cubicTo_func !== null) {
|
|
3987
|
+
module.removeFunction(this.cubicTo_func);
|
|
3988
|
+
this.cubicTo_func = null;
|
|
3989
|
+
}
|
|
3990
|
+
if (this.closePath_func !== null) {
|
|
3991
|
+
module.removeFunction(this.closePath_func);
|
|
3992
|
+
this.closePath_func = null;
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
catch (error) {
|
|
3996
|
+
logger.warn('Error destroying draw callbacks:', error);
|
|
3997
|
+
}
|
|
3998
|
+
this.collector = undefined;
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
// Share a single DrawCallbackHandler per HarfBuzz module to avoid leaking
|
|
4002
|
+
// wasm function pointers when users create many Text instances
|
|
4003
|
+
const sharedDrawCallbackHandlers = new WeakMap();
|
|
4004
|
+
function getSharedDrawCallbackHandler(font) {
|
|
4005
|
+
const key = font.module;
|
|
4006
|
+
const existing = sharedDrawCallbackHandlers.get(key);
|
|
4007
|
+
if (existing)
|
|
4008
|
+
return existing;
|
|
4009
|
+
const handler = new DrawCallbackHandler();
|
|
4010
|
+
sharedDrawCallbackHandlers.set(key, handler);
|
|
4011
|
+
return handler;
|
|
4012
|
+
}
|
|
4013
|
+
|
|
254
4014
|
function ceilDiv(a, b) {
|
|
255
4015
|
return Math.floor((a + b - 1) / b);
|
|
256
4016
|
}
|
|
@@ -259,7 +4019,7 @@ function createPackedTexture(texelCount, width) {
|
|
|
259
4019
|
return { width, height, data: new Float32Array(width * height * 4) };
|
|
260
4020
|
}
|
|
261
4021
|
// All cubic-to-quadratic work uses scalar x/y to avoid per-recursion
|
|
262
|
-
// Vec2 allocations. Only the final emitted QuadSegs create Vec2s
|
|
4022
|
+
// Vec2 allocations. Only the final emitted QuadSegs create Vec2s.
|
|
263
4023
|
function cubicToQuadraticsAdaptive(contourId, p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y, tol2, depth, out) {
|
|
264
4024
|
// Approximate quad control points
|
|
265
4025
|
const c0x = p0x + 0.75 * (p1x - p0x);
|
|
@@ -354,14 +4114,14 @@ function toQuadratics(segments) {
|
|
|
354
4114
|
// Packs glyph outlines into GPU-friendly textures and instance
|
|
355
4115
|
// attributes for vector rendering without tessellation
|
|
356
4116
|
class GlyphVectorGeometryBuilder {
|
|
357
|
-
constructor(loadedFont, cache =
|
|
4117
|
+
constructor(loadedFont, cache = globalOutlineCache) {
|
|
358
4118
|
this.fontId = 'default';
|
|
359
4119
|
this.cacheKeyPrefix = 'default';
|
|
360
4120
|
this.emptyGlyphs = new Set();
|
|
361
4121
|
this.loadedFont = loadedFont;
|
|
362
4122
|
this.outlineCache = cache;
|
|
363
4123
|
this.collector = new GlyphOutlineCollector();
|
|
364
|
-
this.drawCallbacks =
|
|
4124
|
+
this.drawCallbacks = getSharedDrawCallbackHandler(this.loadedFont);
|
|
365
4125
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
366
4126
|
}
|
|
367
4127
|
setFontId(fontId) {
|
|
@@ -463,14 +4223,7 @@ class GlyphVectorGeometryBuilder {
|
|
|
463
4223
|
const bounds = glyphBoundsByGlyph.get(g.g);
|
|
464
4224
|
const px = (cluster.position.x + (g.x ?? 0)) * scale;
|
|
465
4225
|
const py = (cluster.position.y + (g.y ?? 0)) * scale;
|
|
466
|
-
loopBlinnGlyphs.push({
|
|
467
|
-
offsetX: px,
|
|
468
|
-
offsetY: py,
|
|
469
|
-
segments,
|
|
470
|
-
bounds,
|
|
471
|
-
lineIndex: g.lineIndex,
|
|
472
|
-
baselineY: (cluster.position.y + (g.y ?? 0)) * scale
|
|
473
|
-
});
|
|
4226
|
+
loopBlinnGlyphs.push({ offsetX: px, offsetY: py, segments, bounds });
|
|
474
4227
|
glyphInfos.push({
|
|
475
4228
|
textIndex: g.absoluteTextIndex,
|
|
476
4229
|
lineIndex: g.lineIndex,
|
|
@@ -492,6 +4245,47 @@ class GlyphVectorGeometryBuilder {
|
|
|
492
4245
|
glyphs: glyphInfos
|
|
493
4246
|
};
|
|
494
4247
|
}
|
|
4248
|
+
collectForSlug(clustersByLine, scale) {
|
|
4249
|
+
const uniqueGlyphIds = this.collectUniqueGlyphIds(clustersByLine);
|
|
4250
|
+
const outlinesByGlyph = new Map();
|
|
4251
|
+
for (const glyphId of uniqueGlyphIds) {
|
|
4252
|
+
outlinesByGlyph.set(glyphId, this.getOutlineForGlyph(glyphId));
|
|
4253
|
+
}
|
|
4254
|
+
const outlines = [];
|
|
4255
|
+
const positions = [];
|
|
4256
|
+
const glyphInfos = [];
|
|
4257
|
+
for (const line of clustersByLine) {
|
|
4258
|
+
for (const cluster of line) {
|
|
4259
|
+
for (const g of cluster.glyphs) {
|
|
4260
|
+
const outline = outlinesByGlyph.get(g.g);
|
|
4261
|
+
if (!outline || outline.segments.length === 0)
|
|
4262
|
+
continue;
|
|
4263
|
+
const px = (cluster.position.x + (g.x ?? 0)) * scale;
|
|
4264
|
+
const py = (cluster.position.y + (g.y ?? 0)) * scale;
|
|
4265
|
+
outlines.push(outline);
|
|
4266
|
+
positions.push({ x: px, y: py });
|
|
4267
|
+
const bMinX = outline.bounds.min.x * scale;
|
|
4268
|
+
const bMinY = outline.bounds.min.y * scale;
|
|
4269
|
+
const bMaxX = outline.bounds.max.x * scale;
|
|
4270
|
+
const bMaxY = outline.bounds.max.y * scale;
|
|
4271
|
+
glyphInfos.push({
|
|
4272
|
+
textIndex: g.absoluteTextIndex,
|
|
4273
|
+
lineIndex: g.lineIndex,
|
|
4274
|
+
vertexStart: 0,
|
|
4275
|
+
vertexCount: 0,
|
|
4276
|
+
segmentStart: 0,
|
|
4277
|
+
segmentCount: outline.segments.length,
|
|
4278
|
+
bounds: {
|
|
4279
|
+
min: { x: px + bMinX, y: py + bMinY, z: 0 },
|
|
4280
|
+
max: { x: px + bMaxX, y: py + bMaxY, z: 0 }
|
|
4281
|
+
}
|
|
4282
|
+
});
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
const planeBounds = this.computePlaneBounds(glyphInfos);
|
|
4287
|
+
return { outlines, positions, glyphs: glyphInfos, planeBounds };
|
|
4288
|
+
}
|
|
495
4289
|
getOutlineForGlyph(glyphId) {
|
|
496
4290
|
if (this.emptyGlyphs.has(glyphId)) {
|
|
497
4291
|
return {
|
|
@@ -577,6 +4371,7 @@ class GlyphVectorGeometryBuilder {
|
|
|
577
4371
|
1, -1, 1, 0
|
|
578
4372
|
]);
|
|
579
4373
|
const quadIndices = new Uint16Array([0, 1, 2, 0, 2, 3]);
|
|
4374
|
+
// Collect unique glyph IDs so we pack segment data once per glyph
|
|
580
4375
|
const uniqueGlyphIds = [];
|
|
581
4376
|
const seen = new Set();
|
|
582
4377
|
for (const line of clustersByLine) {
|
|
@@ -952,6 +4747,7 @@ class GlyphVectorGeometryBuilder {
|
|
|
952
4747
|
}
|
|
953
4748
|
}
|
|
954
4749
|
}
|
|
4750
|
+
// Count instances (glyph occurrences) with non-empty outlines
|
|
955
4751
|
let glyphInstanceCount = 0;
|
|
956
4752
|
for (const line of clustersByLine) {
|
|
957
4753
|
for (const cluster of line) {
|
|
@@ -1023,6 +4819,7 @@ class GlyphVectorGeometryBuilder {
|
|
|
1023
4819
|
}
|
|
1024
4820
|
};
|
|
1025
4821
|
glyphInfos.push(glyphInfo);
|
|
4822
|
+
// Update plane bounds
|
|
1026
4823
|
if (glyphInfo.bounds.min.x < planeBounds.min.x)
|
|
1027
4824
|
planeBounds.min.x = glyphInfo.bounds.min.x;
|
|
1028
4825
|
if (glyphInfo.bounds.min.y < planeBounds.min.y)
|
|
@@ -1070,186 +4867,565 @@ class GlyphVectorGeometryBuilder {
|
|
|
1070
4867
|
}
|
|
1071
4868
|
}
|
|
1072
4869
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
4870
|
+
/**
|
|
4871
|
+
* CPU-side data packer for the Slug algorithm.
|
|
4872
|
+
* Faithful to Eric Lengyel's reference layout (MIT License, 2017).
|
|
4873
|
+
*
|
|
4874
|
+
* Takes generic quadratic Bezier shapes (not text-specific) and produces
|
|
4875
|
+
* GPU-ready packed textures + vertex attribute buffers.
|
|
4876
|
+
*/
|
|
4877
|
+
const TEX_WIDTH = 4096;
|
|
4878
|
+
const LOG_TEX_WIDTH = 12;
|
|
4879
|
+
// Float ↔ Uint32 reinterpretation helpers
|
|
4880
|
+
const _f32 = new Float32Array(1);
|
|
4881
|
+
const _u32 = new Uint32Array(_f32.buffer);
|
|
4882
|
+
function uintAsFloat(u) {
|
|
4883
|
+
_u32[0] = u;
|
|
4884
|
+
return _f32[0];
|
|
1077
4885
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
4886
|
+
/**
|
|
4887
|
+
* Pack an array of shapes into Slug's GPU data layout.
|
|
4888
|
+
*
|
|
4889
|
+
* Each shape is a closed region defined by quadratic Bezier curves.
|
|
4890
|
+
* Returns textures + vertex buffers ready for GPU upload.
|
|
4891
|
+
*/
|
|
4892
|
+
function packSlugData(shapes, options) {
|
|
4893
|
+
const bandCount = options?.bandCount ?? 16;
|
|
4894
|
+
const evenOdd = options?.evenOdd ?? false;
|
|
4895
|
+
// Phase 1: Pack all curves into curveTexture
|
|
4896
|
+
const allCurves = [];
|
|
4897
|
+
// Estimate max texels needed
|
|
4898
|
+
let totalCurves = 0;
|
|
4899
|
+
for (const shape of shapes) {
|
|
4900
|
+
totalCurves += shape.curves.length;
|
|
4901
|
+
}
|
|
4902
|
+
const curveTexHeight = Math.ceil((totalCurves * 2) / TEX_WIDTH) + 1;
|
|
4903
|
+
const curveData = new Float32Array(TEX_WIDTH * curveTexHeight * 4);
|
|
4904
|
+
let curveX = 0;
|
|
4905
|
+
let curveY = 0;
|
|
4906
|
+
for (const shape of shapes) {
|
|
4907
|
+
const entries = [];
|
|
4908
|
+
for (const curve of shape.curves) {
|
|
4909
|
+
// Don't let a curve span across row boundary (needs 2 consecutive texels)
|
|
4910
|
+
if (curveX >= TEX_WIDTH - 1) {
|
|
4911
|
+
curveX = 0;
|
|
4912
|
+
curveY++;
|
|
4913
|
+
}
|
|
4914
|
+
const base = (curveY * TEX_WIDTH + curveX) * 4;
|
|
4915
|
+
curveData[base + 0] = curve.p1[0];
|
|
4916
|
+
curveData[base + 1] = curve.p1[1];
|
|
4917
|
+
curveData[base + 2] = curve.p2[0];
|
|
4918
|
+
curveData[base + 3] = curve.p2[1];
|
|
4919
|
+
const base2 = base + 4;
|
|
4920
|
+
curveData[base2 + 0] = curve.p3[0];
|
|
4921
|
+
curveData[base2 + 1] = curve.p3[1];
|
|
4922
|
+
const minX = Math.min(curve.p1[0], curve.p2[0], curve.p3[0]);
|
|
4923
|
+
const minY = Math.min(curve.p1[1], curve.p2[1], curve.p3[1]);
|
|
4924
|
+
const maxX = Math.max(curve.p1[0], curve.p2[0], curve.p3[0]);
|
|
4925
|
+
const maxY = Math.max(curve.p1[1], curve.p2[1], curve.p3[1]);
|
|
4926
|
+
entries.push({
|
|
4927
|
+
p1x: curve.p1[0], p1y: curve.p1[1],
|
|
4928
|
+
p2x: curve.p2[0], p2y: curve.p2[1],
|
|
4929
|
+
p3x: curve.p3[0], p3y: curve.p3[1],
|
|
4930
|
+
minX, minY, maxX, maxY,
|
|
4931
|
+
curveTexX: curveX,
|
|
4932
|
+
curveTexY: curveY
|
|
1098
4933
|
});
|
|
4934
|
+
curveX += 2;
|
|
1099
4935
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
for
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
4936
|
+
allCurves.push(entries);
|
|
4937
|
+
}
|
|
4938
|
+
const actualCurveTexHeight = curveY + 1;
|
|
4939
|
+
// Phase 2: Build band data for each shape and pack into bandTexture
|
|
4940
|
+
// Layout per shape in bandTexture (relative to glyphLoc):
|
|
4941
|
+
// [0 .. hBandMax] : h-band headers
|
|
4942
|
+
// [hBandMax+1 .. hBandMax+1+vBandMax] : v-band headers
|
|
4943
|
+
// [hBandMax+vBandMax+2 .. ] : curve index lists
|
|
4944
|
+
// First pass: compute total band texels needed
|
|
4945
|
+
const shapeBandData = [];
|
|
4946
|
+
let totalBandTexels = 0;
|
|
4947
|
+
for (let si = 0; si < shapes.length; si++) {
|
|
4948
|
+
const shape = shapes[si];
|
|
4949
|
+
const curves = allCurves[si];
|
|
4950
|
+
if (curves.length === 0) {
|
|
4951
|
+
shapeBandData.push({
|
|
4952
|
+
hBands: [], vBands: [], hLists: [], vLists: [],
|
|
4953
|
+
totalTexels: 0, bandMaxX: 0, bandMaxY: 0
|
|
4954
|
+
});
|
|
1107
4955
|
continue;
|
|
1108
4956
|
}
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
4957
|
+
const [bMinX, bMinY, bMaxX, bMaxY] = shape.bounds;
|
|
4958
|
+
const w = bMaxX - bMinX;
|
|
4959
|
+
const h = bMaxY - bMinY;
|
|
4960
|
+
const hBandCount = Math.min(bandCount, 255); // max 255 (fits in 8 bits)
|
|
4961
|
+
const vBandCount = Math.min(bandCount, 255);
|
|
4962
|
+
const bandMaxY = hBandCount - 1;
|
|
4963
|
+
const bandMaxX = vBandCount - 1;
|
|
4964
|
+
// Build horizontal bands (partition y-axis)
|
|
4965
|
+
const hBands = [];
|
|
4966
|
+
const hLists = [];
|
|
4967
|
+
const bandH = h / hBandCount;
|
|
4968
|
+
for (let bi = 0; bi < hBandCount; bi++) {
|
|
4969
|
+
const bandMinY = bMinY + bi * bandH;
|
|
4970
|
+
const bandMaxYCoord = bandMinY + bandH;
|
|
4971
|
+
// Collect curves whose y-range overlaps this band
|
|
4972
|
+
const list = [];
|
|
4973
|
+
for (const c of curves) {
|
|
4974
|
+
if (c.maxY >= bandMinY && c.minY <= bandMaxYCoord) {
|
|
4975
|
+
list.push({ curve: c, sortKey: c.maxX });
|
|
4976
|
+
}
|
|
4977
|
+
}
|
|
4978
|
+
// Sort by descending max-x for early exit
|
|
4979
|
+
list.sort((a, b) => b.sortKey - a.sortKey);
|
|
4980
|
+
const flatList = [];
|
|
4981
|
+
for (const item of list) {
|
|
4982
|
+
flatList.push(item.curve.curveTexX, item.curve.curveTexY);
|
|
4983
|
+
}
|
|
4984
|
+
hBands.push({ curveCount: list.length, listOffset: 0 });
|
|
4985
|
+
hLists.push(flatList);
|
|
4986
|
+
}
|
|
4987
|
+
// Build vertical bands (partition x-axis)
|
|
4988
|
+
const vBands = [];
|
|
4989
|
+
const vLists = [];
|
|
4990
|
+
const bandW = w / vBandCount;
|
|
4991
|
+
for (let bi = 0; bi < vBandCount; bi++) {
|
|
4992
|
+
const bandMinX = bMinX + bi * bandW;
|
|
4993
|
+
const bandMaxXCoord = bandMinX + bandW;
|
|
4994
|
+
const list = [];
|
|
4995
|
+
for (const c of curves) {
|
|
4996
|
+
if (c.maxX >= bandMinX && c.minX <= bandMaxXCoord) {
|
|
4997
|
+
list.push({ curve: c, sortKey: c.maxY });
|
|
4998
|
+
}
|
|
4999
|
+
}
|
|
5000
|
+
// Sort by descending max-y for early exit
|
|
5001
|
+
list.sort((a, b) => b.sortKey - a.sortKey);
|
|
5002
|
+
const flatList = [];
|
|
5003
|
+
for (const item of list) {
|
|
5004
|
+
flatList.push(item.curve.curveTexX, item.curve.curveTexY);
|
|
5005
|
+
}
|
|
5006
|
+
vBands.push({ curveCount: list.length, listOffset: 0 });
|
|
5007
|
+
vLists.push(flatList);
|
|
1115
5008
|
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
5009
|
+
// Total texels for this shape: band headers + curve lists
|
|
5010
|
+
const headerTexels = hBandCount + vBandCount;
|
|
5011
|
+
let listTexels = 0;
|
|
5012
|
+
for (const l of hLists)
|
|
5013
|
+
listTexels += l.length / 2;
|
|
5014
|
+
for (const l of vLists)
|
|
5015
|
+
listTexels += l.length / 2;
|
|
5016
|
+
const total = headerTexels + listTexels;
|
|
5017
|
+
shapeBandData.push({
|
|
5018
|
+
hBands, vBands, hLists, vLists,
|
|
5019
|
+
totalTexels: total, bandMaxX, bandMaxY
|
|
5020
|
+
});
|
|
5021
|
+
totalBandTexels += total;
|
|
1119
5022
|
}
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
5023
|
+
// Allocate bandTexture (extra rows for row-alignment padding of curve lists)
|
|
5024
|
+
const bandTexHeight = Math.max(1, Math.ceil(totalBandTexels / TEX_WIDTH) + shapes.length * 2);
|
|
5025
|
+
const bandData = new Uint32Array(TEX_WIDTH * bandTexHeight * 4);
|
|
5026
|
+
// Pack band data per shape
|
|
5027
|
+
let bandX = 0;
|
|
5028
|
+
let bandY = 0;
|
|
5029
|
+
const glyphLocs = [];
|
|
5030
|
+
for (let si = 0; si < shapes.length; si++) {
|
|
5031
|
+
const sd = shapeBandData[si];
|
|
5032
|
+
if (sd.totalTexels === 0) {
|
|
5033
|
+
glyphLocs.push({ x: 0, y: 0 });
|
|
5034
|
+
continue;
|
|
5035
|
+
}
|
|
5036
|
+
// Ensure glyph data doesn't start too close to row end
|
|
5037
|
+
// (need at least headerTexels contiguous... actually wrapping is handled by CalcBandLoc)
|
|
5038
|
+
// But the initial band header reads don't use CalcBandLoc, so glyphLoc.x + bandMax.y + 1 + bandMaxX
|
|
5039
|
+
// must be reachable. CalcBandLoc handles wrapping for curve lists.
|
|
5040
|
+
// To be safe, start each glyph at the beginning of a row if remaining space is tight.
|
|
5041
|
+
const minContiguous = sd.hBands.length + sd.vBands.length;
|
|
5042
|
+
if (bandX + minContiguous > TEX_WIDTH) {
|
|
5043
|
+
bandX = 0;
|
|
5044
|
+
bandY++;
|
|
5045
|
+
}
|
|
5046
|
+
const glyphLocX = bandX;
|
|
5047
|
+
const glyphLocY = bandY;
|
|
5048
|
+
glyphLocs.push({ x: glyphLocX, y: glyphLocY });
|
|
5049
|
+
// Curve lists start after all headers
|
|
5050
|
+
let listStartOffset = sd.hBands.length + sd.vBands.length;
|
|
5051
|
+
// The shader reads curve list entries at (hbandLoc.x + curveIndex, hbandLoc.y)
|
|
5052
|
+
// with NO row wrapping. Each list must fit entirely within a single texture row.
|
|
5053
|
+
// Pad the offset to the next row start when a list would cross a row boundary.
|
|
5054
|
+
const ensureListFits = (listLen) => {
|
|
5055
|
+
if (listLen === 0)
|
|
5056
|
+
return;
|
|
5057
|
+
const startX = (glyphLocX + listStartOffset) & ((1 << LOG_TEX_WIDTH) - 1);
|
|
5058
|
+
if (startX + listLen > TEX_WIDTH) {
|
|
5059
|
+
listStartOffset += (TEX_WIDTH - startX);
|
|
5060
|
+
}
|
|
5061
|
+
};
|
|
5062
|
+
// Assign list offsets for h-bands
|
|
5063
|
+
for (let bi = 0; bi < sd.hBands.length; bi++) {
|
|
5064
|
+
const listLen = sd.hLists[bi].length / 2;
|
|
5065
|
+
ensureListFits(listLen);
|
|
5066
|
+
sd.hBands[bi].listOffset = listStartOffset;
|
|
5067
|
+
listStartOffset += listLen;
|
|
5068
|
+
}
|
|
5069
|
+
// Assign list offsets for v-bands
|
|
5070
|
+
for (let bi = 0; bi < sd.vBands.length; bi++) {
|
|
5071
|
+
const listLen = sd.vLists[bi].length / 2;
|
|
5072
|
+
ensureListFits(listLen);
|
|
5073
|
+
sd.vBands[bi].listOffset = listStartOffset;
|
|
5074
|
+
listStartOffset += listLen;
|
|
5075
|
+
}
|
|
5076
|
+
// Write h-band headers
|
|
5077
|
+
for (let bi = 0; bi < sd.hBands.length; bi++) {
|
|
5078
|
+
const tx = glyphLocX + bi;
|
|
5079
|
+
const ty = glyphLocY;
|
|
5080
|
+
const idx = (ty * TEX_WIDTH + tx) * 4;
|
|
5081
|
+
bandData[idx + 0] = sd.hBands[bi].curveCount;
|
|
5082
|
+
bandData[idx + 1] = sd.hBands[bi].listOffset;
|
|
5083
|
+
bandData[idx + 2] = 0;
|
|
5084
|
+
bandData[idx + 3] = 0;
|
|
5085
|
+
}
|
|
5086
|
+
// Write v-band headers (after h-bands)
|
|
5087
|
+
const vBandStart = glyphLocX + sd.hBands.length;
|
|
5088
|
+
for (let bi = 0; bi < sd.vBands.length; bi++) {
|
|
5089
|
+
const tx = vBandStart + bi;
|
|
5090
|
+
const ty = glyphLocY;
|
|
5091
|
+
const idx = (ty * TEX_WIDTH + tx) * 4;
|
|
5092
|
+
bandData[idx + 0] = sd.vBands[bi].curveCount;
|
|
5093
|
+
bandData[idx + 1] = sd.vBands[bi].listOffset;
|
|
5094
|
+
bandData[idx + 2] = 0;
|
|
5095
|
+
bandData[idx + 3] = 0;
|
|
5096
|
+
}
|
|
5097
|
+
// Write curve lists using CalcBandLoc-style wrapping
|
|
5098
|
+
const writeBandLoc = (offset) => {
|
|
5099
|
+
let bx = glyphLocX + offset;
|
|
5100
|
+
let by = glyphLocY;
|
|
5101
|
+
by += bx >> LOG_TEX_WIDTH;
|
|
5102
|
+
bx &= (1 << LOG_TEX_WIDTH) - 1;
|
|
5103
|
+
return { x: bx, y: by };
|
|
5104
|
+
};
|
|
5105
|
+
// Write h-band curve lists
|
|
5106
|
+
for (let bi = 0; bi < sd.hBands.length; bi++) {
|
|
5107
|
+
const list = sd.hLists[bi];
|
|
5108
|
+
const baseOffset = sd.hBands[bi].listOffset;
|
|
5109
|
+
for (let ci = 0; ci < list.length; ci += 2) {
|
|
5110
|
+
const loc = writeBandLoc(baseOffset + ci / 2);
|
|
5111
|
+
const idx = (loc.y * TEX_WIDTH + loc.x) * 4;
|
|
5112
|
+
bandData[idx + 0] = list[ci]; // curveTexX
|
|
5113
|
+
bandData[idx + 1] = list[ci + 1]; // curveTexY
|
|
5114
|
+
bandData[idx + 2] = 0;
|
|
5115
|
+
bandData[idx + 3] = 0;
|
|
5116
|
+
}
|
|
5117
|
+
}
|
|
5118
|
+
// Write v-band curve lists
|
|
5119
|
+
for (let bi = 0; bi < sd.vBands.length; bi++) {
|
|
5120
|
+
const list = sd.vLists[bi];
|
|
5121
|
+
const baseOffset = sd.vBands[bi].listOffset;
|
|
5122
|
+
for (let ci = 0; ci < list.length; ci += 2) {
|
|
5123
|
+
const loc = writeBandLoc(baseOffset + ci / 2);
|
|
5124
|
+
const idx = (loc.y * TEX_WIDTH + loc.x) * 4;
|
|
5125
|
+
bandData[idx + 0] = list[ci];
|
|
5126
|
+
bandData[idx + 1] = list[ci + 1];
|
|
5127
|
+
bandData[idx + 2] = 0;
|
|
5128
|
+
bandData[idx + 3] = 0;
|
|
5129
|
+
}
|
|
5130
|
+
}
|
|
5131
|
+
// Advance band cursor past this shape's data
|
|
5132
|
+
const totalForShape = listStartOffset;
|
|
5133
|
+
const endLoc = writeBandLoc(totalForShape);
|
|
5134
|
+
bandX = endLoc.x;
|
|
5135
|
+
bandY = endLoc.y;
|
|
1127
5136
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
5137
|
+
const actualBandTexHeight = bandY + 1;
|
|
5138
|
+
// Phase 3: Build vertex attributes
|
|
5139
|
+
// 5 attribs x 4 floats x 4 vertices per shape = 80 floats per shape
|
|
5140
|
+
const FLOATS_PER_VERTEX = 20; // 5 attribs * 4 components
|
|
5141
|
+
const VERTS_PER_SHAPE = 4;
|
|
5142
|
+
const vertices = new Float32Array(shapes.length * VERTS_PER_SHAPE * FLOATS_PER_VERTEX);
|
|
5143
|
+
const indices = new Uint16Array(shapes.length * 6);
|
|
5144
|
+
// Corner normals (outward-pointing, un-normalized — SlugDilate normalizes)
|
|
5145
|
+
const cornerNormals = [
|
|
5146
|
+
[-1, -1], // bottom-left
|
|
5147
|
+
[1, -1], // bottom-right
|
|
5148
|
+
[1, 1], // top-right
|
|
5149
|
+
[-1, 1], // top-left
|
|
5150
|
+
];
|
|
5151
|
+
for (let si = 0; si < shapes.length; si++) {
|
|
5152
|
+
const shape = shapes[si];
|
|
5153
|
+
const sd = shapeBandData[si];
|
|
5154
|
+
const glyph = glyphLocs[si];
|
|
5155
|
+
const [bMinX, bMinY, bMaxX, bMaxY] = shape.bounds;
|
|
5156
|
+
const w = bMaxX - bMinX;
|
|
5157
|
+
const h = bMaxY - bMinY;
|
|
5158
|
+
// Corner positions in object-space
|
|
5159
|
+
const corners = [
|
|
5160
|
+
[bMinX, bMinY],
|
|
5161
|
+
[bMaxX, bMinY],
|
|
5162
|
+
[bMaxX, bMaxY],
|
|
5163
|
+
[bMinX, bMaxY],
|
|
5164
|
+
];
|
|
5165
|
+
// Em-space sample coords at corners (same as object-space for 1:1 mapping)
|
|
5166
|
+
const emCorners = [
|
|
5167
|
+
[bMinX, bMinY],
|
|
5168
|
+
[bMaxX, bMinY],
|
|
5169
|
+
[bMaxX, bMaxY],
|
|
5170
|
+
[bMinX, bMaxY],
|
|
5171
|
+
];
|
|
5172
|
+
// Pack tex.z: glyph location in band texture
|
|
5173
|
+
const texZ = uintAsFloat((glyph.x & 0xFFFF) | ((glyph.y & 0xFFFF) << 16));
|
|
5174
|
+
// Pack tex.w: band max + flags
|
|
5175
|
+
let texWBits = (sd.bandMaxX & 0xFF) | ((sd.bandMaxY & 0xFF) << 16);
|
|
5176
|
+
if (evenOdd)
|
|
5177
|
+
texWBits |= 0x10000000; // E flag at bit 28
|
|
5178
|
+
const texW = uintAsFloat(texWBits);
|
|
5179
|
+
// Band transform: scale and offset to map em-coords to band indices
|
|
5180
|
+
const bandScaleX = w > 0 ? sd.vBands.length / w : 0;
|
|
5181
|
+
const bandScaleY = h > 0 ? sd.hBands.length / h : 0;
|
|
5182
|
+
const bandOffsetX = -bMinX * bandScaleX;
|
|
5183
|
+
const bandOffsetY = -bMinY * bandScaleY;
|
|
5184
|
+
for (let vi = 0; vi < 4; vi++) {
|
|
5185
|
+
const base = (si * 4 + vi) * FLOATS_PER_VERTEX;
|
|
5186
|
+
// pos: .xy = position, .zw = normal
|
|
5187
|
+
vertices[base + 0] = corners[vi][0];
|
|
5188
|
+
vertices[base + 1] = corners[vi][1];
|
|
5189
|
+
vertices[base + 2] = cornerNormals[vi][0];
|
|
5190
|
+
vertices[base + 3] = cornerNormals[vi][1];
|
|
5191
|
+
// tex: .xy = em-space coords, .z = packed glyph loc, .w = packed band max
|
|
5192
|
+
vertices[base + 4] = emCorners[vi][0];
|
|
5193
|
+
vertices[base + 5] = emCorners[vi][1];
|
|
5194
|
+
vertices[base + 6] = texZ;
|
|
5195
|
+
vertices[base + 7] = texW;
|
|
5196
|
+
// jac: identity Jacobian (em-space = object-space)
|
|
5197
|
+
vertices[base + 8] = 1.0;
|
|
5198
|
+
vertices[base + 9] = 0.0;
|
|
5199
|
+
vertices[base + 10] = 0.0;
|
|
5200
|
+
vertices[base + 11] = 1.0;
|
|
5201
|
+
// bnd: band scale and offset
|
|
5202
|
+
vertices[base + 12] = bandScaleX;
|
|
5203
|
+
vertices[base + 13] = bandScaleY;
|
|
5204
|
+
vertices[base + 14] = bandOffsetX;
|
|
5205
|
+
vertices[base + 15] = bandOffsetY;
|
|
5206
|
+
// col: white with full alpha (caller overrides via uniform or attribute)
|
|
5207
|
+
vertices[base + 16] = 1.0;
|
|
5208
|
+
vertices[base + 17] = 1.0;
|
|
5209
|
+
vertices[base + 18] = 1.0;
|
|
5210
|
+
vertices[base + 19] = 1.0;
|
|
5211
|
+
}
|
|
5212
|
+
// Indices: two triangles per quad
|
|
5213
|
+
const vBase = si * 4;
|
|
5214
|
+
const iBase = si * 6;
|
|
5215
|
+
indices[iBase + 0] = vBase + 0;
|
|
5216
|
+
indices[iBase + 1] = vBase + 1;
|
|
5217
|
+
indices[iBase + 2] = vBase + 2;
|
|
5218
|
+
indices[iBase + 3] = vBase + 0;
|
|
5219
|
+
indices[iBase + 4] = vBase + 2;
|
|
5220
|
+
indices[iBase + 5] = vBase + 3;
|
|
1132
5221
|
}
|
|
1133
|
-
return
|
|
5222
|
+
return {
|
|
5223
|
+
curveTexture: {
|
|
5224
|
+
data: curveData.subarray(0, TEX_WIDTH * actualCurveTexHeight * 4),
|
|
5225
|
+
width: TEX_WIDTH,
|
|
5226
|
+
height: actualCurveTexHeight
|
|
5227
|
+
},
|
|
5228
|
+
bandTexture: {
|
|
5229
|
+
data: bandData.subarray(0, TEX_WIDTH * actualBandTexHeight * 4),
|
|
5230
|
+
width: TEX_WIDTH,
|
|
5231
|
+
height: actualBandTexHeight
|
|
5232
|
+
},
|
|
5233
|
+
vertices,
|
|
5234
|
+
indices,
|
|
5235
|
+
shapeCount: shapes.length
|
|
5236
|
+
};
|
|
1134
5237
|
}
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
return true;
|
|
1141
|
-
const cross = (segment.p1x - segment.p0x) * dy - (segment.p1y - segment.p0y) * dx;
|
|
1142
|
-
return Math.abs(cross) < CURVE_LINEARITY_EPSILON * lenSq;
|
|
5238
|
+
|
|
5239
|
+
const DEFAULT_MAX_ERROR = 0.01;
|
|
5240
|
+
const DEFAULT_MAX_DEPTH = 8;
|
|
5241
|
+
function cloneVec2(p) {
|
|
5242
|
+
return [p[0], p[1]];
|
|
1143
5243
|
}
|
|
1144
|
-
function
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
gp.push(progress);
|
|
1149
|
-
gl.push(lineIdx);
|
|
1150
|
-
gb.push(baselineY);
|
|
1151
|
-
}
|
|
5244
|
+
function lineToQuadratic(p0, p1) {
|
|
5245
|
+
const mx = (p0[0] + p1[0]) * 0.5;
|
|
5246
|
+
const my = (p0[1] + p1[1]) * 0.5;
|
|
5247
|
+
return { p1: cloneVec2(p0), p2: [mx, my], p3: cloneVec2(p1) };
|
|
1152
5248
|
}
|
|
1153
|
-
function
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
5249
|
+
function cubicToQuadratics(p0, p1, p2, p3, options) {
|
|
5250
|
+
const maxError = options?.maxError ?? DEFAULT_MAX_ERROR;
|
|
5251
|
+
const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
5252
|
+
const recurse = (rp0, rp1, rp2, rp3, depth) => {
|
|
5253
|
+
// Error bound for best-fit quadratic approximation of this cubic:
|
|
5254
|
+
// |P3 - 3*P2 + 3*P1 - P0| / 6
|
|
5255
|
+
const dx = rp3[0] - 3 * rp2[0] + 3 * rp1[0] - rp0[0];
|
|
5256
|
+
const dy = rp3[1] - 3 * rp2[1] + 3 * rp1[1] - rp0[1];
|
|
5257
|
+
const err = dx * dx + dy * dy;
|
|
5258
|
+
const threshold = maxError * maxError * 36;
|
|
5259
|
+
if (err <= threshold || depth >= maxDepth) {
|
|
5260
|
+
const qx = (3 * (rp1[0] + rp2[0]) - rp0[0] - rp3[0]) * 0.25;
|
|
5261
|
+
const qy = (3 * (rp1[1] + rp2[1]) - rp0[1] - rp3[1]) * 0.25;
|
|
5262
|
+
return [{ p1: cloneVec2(rp0), p2: [qx, qy], p3: cloneVec2(rp3) }];
|
|
5263
|
+
}
|
|
5264
|
+
const m01x = (rp0[0] + rp1[0]) * 0.5, m01y = (rp0[1] + rp1[1]) * 0.5;
|
|
5265
|
+
const m12x = (rp1[0] + rp2[0]) * 0.5, m12y = (rp1[1] + rp2[1]) * 0.5;
|
|
5266
|
+
const m23x = (rp2[0] + rp3[0]) * 0.5, m23y = (rp2[1] + rp3[1]) * 0.5;
|
|
5267
|
+
const m012x = (m01x + m12x) * 0.5, m012y = (m01y + m12y) * 0.5;
|
|
5268
|
+
const m123x = (m12x + m23x) * 0.5, m123y = (m12y + m23y) * 0.5;
|
|
5269
|
+
const midx = (m012x + m123x) * 0.5, midy = (m012y + m123y) * 0.5;
|
|
5270
|
+
const left = recurse(rp0, [m01x, m01y], [m012x, m012y], [midx, midy], depth + 1);
|
|
5271
|
+
const right = recurse([midx, midy], [m123x, m123y], [m23x, m23y], rp3, depth + 1);
|
|
5272
|
+
return left.concat(right);
|
|
1160
5273
|
};
|
|
5274
|
+
return recurse(p0, p1, p2, p3, 0);
|
|
1161
5275
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
5276
|
+
|
|
5277
|
+
/**
|
|
5278
|
+
* Bridges three-text's glyph outline pipeline to the Slug packer.
|
|
5279
|
+
*
|
|
5280
|
+
* Takes GlyphOutline[] (raw bezier segments from HarfBuzz) and produces
|
|
5281
|
+
* SlugGPUData ready for WebGL2/WebGPU upload.
|
|
5282
|
+
*/
|
|
5283
|
+
function segmentToQuadCurves(seg) {
|
|
5284
|
+
switch (seg.type) {
|
|
5285
|
+
case 0:
|
|
5286
|
+
return [lineToQuadratic([seg.p0.x, seg.p0.y], [seg.p1.x, seg.p1.y])];
|
|
5287
|
+
case 1:
|
|
5288
|
+
return [{
|
|
5289
|
+
p1: [seg.p0.x, seg.p0.y],
|
|
5290
|
+
p2: [seg.p1.x, seg.p1.y],
|
|
5291
|
+
p3: [seg.p2.x, seg.p2.y]
|
|
5292
|
+
}];
|
|
5293
|
+
case 2:
|
|
5294
|
+
return cubicToQuadratics([seg.p0.x, seg.p0.y], [seg.p1.x, seg.p1.y], [seg.p2.x, seg.p2.y], [seg.p3.x, seg.p3.y]);
|
|
5295
|
+
default:
|
|
5296
|
+
return [];
|
|
5297
|
+
}
|
|
5298
|
+
}
|
|
5299
|
+
function buildSlugData(outlines, positions, scale, options) {
|
|
5300
|
+
const shapes = [];
|
|
5301
|
+
const glyphMeta = [];
|
|
5302
|
+
for (let i = 0; i < outlines.length; i++) {
|
|
5303
|
+
const outline = outlines[i];
|
|
5304
|
+
if (outline.segments.length === 0)
|
|
5305
|
+
continue;
|
|
5306
|
+
const pos = positions[i] || { x: 0, y: 0 };
|
|
5307
|
+
const curves = [];
|
|
5308
|
+
for (const seg of outline.segments) {
|
|
5309
|
+
const quads = segmentToQuadCurves(seg);
|
|
5310
|
+
for (const q of quads) {
|
|
5311
|
+
curves.push({
|
|
5312
|
+
p1: [q.p1[0] * scale + pos.x, q.p1[1] * scale + pos.y],
|
|
5313
|
+
p2: [q.p2[0] * scale + pos.x, q.p2[1] * scale + pos.y],
|
|
5314
|
+
p3: [q.p3[0] * scale + pos.x, q.p3[1] * scale + pos.y]
|
|
5315
|
+
});
|
|
1198
5316
|
}
|
|
1199
5317
|
}
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
5318
|
+
const bounds = [
|
|
5319
|
+
outline.bounds.min.x * scale + pos.x,
|
|
5320
|
+
outline.bounds.min.y * scale + pos.y,
|
|
5321
|
+
outline.bounds.max.x * scale + pos.x,
|
|
5322
|
+
outline.bounds.max.y * scale + pos.y
|
|
5323
|
+
];
|
|
5324
|
+
glyphMeta.push({
|
|
5325
|
+
glyphId: outline.glyphId,
|
|
5326
|
+
textIndex: outline.textIndex,
|
|
5327
|
+
shapeIndex: shapes.length,
|
|
5328
|
+
bounds
|
|
1205
5329
|
});
|
|
1206
|
-
|
|
1207
|
-
fillPositions[fp] = glyph.offsetX + minX;
|
|
1208
|
-
fillPositions[fp + 1] = glyph.offsetY + minY;
|
|
1209
|
-
fillPositions[fp + 2] = 0;
|
|
1210
|
-
fillPositions[fp + 3] = glyph.offsetX + maxX;
|
|
1211
|
-
fillPositions[fp + 4] = glyph.offsetY + minY;
|
|
1212
|
-
fillPositions[fp + 5] = 0;
|
|
1213
|
-
fillPositions[fp + 6] = glyph.offsetX + maxX;
|
|
1214
|
-
fillPositions[fp + 7] = glyph.offsetY + maxY;
|
|
1215
|
-
fillPositions[fp + 8] = 0;
|
|
1216
|
-
fillPositions[fp + 9] = glyph.offsetX + minX;
|
|
1217
|
-
fillPositions[fp + 10] = glyph.offsetY + maxY;
|
|
1218
|
-
fillPositions[fp + 11] = 0;
|
|
1219
|
-
fillGlyphAttrs(center, i, progress, glyph.lineIndex, glyph.baselineY, 4, fGC, fGI, fGP, fGL, fGB);
|
|
1220
|
-
const fi = i * 6;
|
|
1221
|
-
const fv = i * 4;
|
|
1222
|
-
fillIndices[fi] = fv;
|
|
1223
|
-
fillIndices[fi + 1] = fv + 1;
|
|
1224
|
-
fillIndices[fi + 2] = fv + 2;
|
|
1225
|
-
fillIndices[fi + 3] = fv;
|
|
1226
|
-
fillIndices[fi + 4] = fv + 2;
|
|
1227
|
-
fillIndices[fi + 5] = fv + 3;
|
|
5330
|
+
shapes.push({ curves, bounds });
|
|
1228
5331
|
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
5332
|
+
const gpuData = packSlugData(shapes, options);
|
|
5333
|
+
return { gpuData, glyphMeta };
|
|
5334
|
+
}
|
|
5335
|
+
|
|
5336
|
+
class TextRangeQuery {
|
|
5337
|
+
constructor(text, glyphs) {
|
|
5338
|
+
this.text = text;
|
|
5339
|
+
this.glyphsByTextIndex = new Map();
|
|
5340
|
+
glyphs.forEach((g) => {
|
|
5341
|
+
const existing = this.glyphsByTextIndex.get(g.textIndex) || [];
|
|
5342
|
+
existing.push(g);
|
|
5343
|
+
this.glyphsByTextIndex.set(g.textIndex, existing);
|
|
5344
|
+
});
|
|
5345
|
+
}
|
|
5346
|
+
execute(options) {
|
|
5347
|
+
const ranges = [];
|
|
5348
|
+
if (options.byText) {
|
|
5349
|
+
ranges.push(...this.findByText(options.byText));
|
|
1245
5350
|
}
|
|
1246
|
-
|
|
5351
|
+
if (options.byCharRange) {
|
|
5352
|
+
ranges.push(...this.findByCharRange(options.byCharRange));
|
|
5353
|
+
}
|
|
5354
|
+
return ranges;
|
|
5355
|
+
}
|
|
5356
|
+
findByText(patterns) {
|
|
5357
|
+
const ranges = [];
|
|
5358
|
+
for (const pattern of patterns) {
|
|
5359
|
+
let index = 0;
|
|
5360
|
+
while ((index = this.text.indexOf(pattern, index)) !== -1) {
|
|
5361
|
+
ranges.push(this.createTextRange(index, index + pattern.length, pattern));
|
|
5362
|
+
index += pattern.length;
|
|
5363
|
+
}
|
|
5364
|
+
}
|
|
5365
|
+
return ranges;
|
|
5366
|
+
}
|
|
5367
|
+
findByCharRange(ranges) {
|
|
5368
|
+
return ranges.map((range) => {
|
|
5369
|
+
const text = this.text.slice(range.start, range.end);
|
|
5370
|
+
return this.createTextRange(range.start, range.end, text);
|
|
5371
|
+
});
|
|
5372
|
+
}
|
|
5373
|
+
createTextRange(start, end, originalText) {
|
|
5374
|
+
const relevantGlyphs = [];
|
|
5375
|
+
const lineGroups = new Map();
|
|
5376
|
+
for (let i = start; i < end; i++) {
|
|
5377
|
+
const glyphs = this.glyphsByTextIndex.get(i);
|
|
5378
|
+
if (glyphs) {
|
|
5379
|
+
for (const glyph of glyphs) {
|
|
5380
|
+
relevantGlyphs.push(glyph);
|
|
5381
|
+
const lineGlyphs = lineGroups.get(glyph.lineIndex) || [];
|
|
5382
|
+
lineGlyphs.push(glyph);
|
|
5383
|
+
lineGroups.set(glyph.lineIndex, lineGlyphs);
|
|
5384
|
+
}
|
|
5385
|
+
}
|
|
5386
|
+
}
|
|
5387
|
+
const bounds = Array.from(lineGroups.values()).map((lineGlyphs) => this.calculateBounds(lineGlyphs));
|
|
5388
|
+
return {
|
|
5389
|
+
start,
|
|
5390
|
+
end,
|
|
5391
|
+
originalText,
|
|
5392
|
+
bounds,
|
|
5393
|
+
glyphs: relevantGlyphs,
|
|
5394
|
+
lineIndices: Array.from(lineGroups.keys()).sort((a, b) => a - b)
|
|
5395
|
+
};
|
|
5396
|
+
}
|
|
5397
|
+
calculateBounds(glyphs) {
|
|
5398
|
+
if (glyphs.length === 0) {
|
|
5399
|
+
return {
|
|
5400
|
+
min: { x: 0, y: 0, z: 0 },
|
|
5401
|
+
max: { x: 0, y: 0, z: 0 }
|
|
5402
|
+
};
|
|
5403
|
+
}
|
|
5404
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
5405
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
5406
|
+
for (const glyph of glyphs) {
|
|
5407
|
+
if (glyph.bounds.min.x < minX)
|
|
5408
|
+
minX = glyph.bounds.min.x;
|
|
5409
|
+
if (glyph.bounds.min.y < minY)
|
|
5410
|
+
minY = glyph.bounds.min.y;
|
|
5411
|
+
if (glyph.bounds.min.z < minZ)
|
|
5412
|
+
minZ = glyph.bounds.min.z;
|
|
5413
|
+
if (glyph.bounds.max.x > maxX)
|
|
5414
|
+
maxX = glyph.bounds.max.x;
|
|
5415
|
+
if (glyph.bounds.max.y > maxY)
|
|
5416
|
+
maxY = glyph.bounds.max.y;
|
|
5417
|
+
if (glyph.bounds.max.z > maxZ)
|
|
5418
|
+
maxZ = glyph.bounds.max.z;
|
|
5419
|
+
}
|
|
5420
|
+
return {
|
|
5421
|
+
min: { x: minX, y: minY, z: minZ },
|
|
5422
|
+
max: { x: maxX, y: maxY, z: maxZ }
|
|
5423
|
+
};
|
|
5424
|
+
}
|
|
1247
5425
|
}
|
|
1248
5426
|
|
|
1249
|
-
function
|
|
5427
|
+
function buildCoreResult(layoutHandle, vectorBuilder, options) {
|
|
1250
5428
|
const scale = layoutHandle.layoutData.pixelsPerFontUnit;
|
|
1251
|
-
const { loopBlinnInput, glyphs } = vectorBuilder.buildForLoopBlinn(layoutHandle.clustersByLine, scale);
|
|
1252
|
-
const geometryData = buildVectorGeometry(loopBlinnInput);
|
|
1253
5429
|
let cachedQuery = null;
|
|
1254
5430
|
const update = async (newOptions) => {
|
|
1255
5431
|
const mergedOptions = { ...options };
|
|
@@ -1263,48 +5439,51 @@ function buildVectorResult(layoutHandle, vectorBuilder, options) {
|
|
|
1263
5439
|
newOptions.fontVariations !== undefined ||
|
|
1264
5440
|
newOptions.fontFeatures !== undefined) {
|
|
1265
5441
|
const newLayout = await layoutHandle.update(mergedOptions);
|
|
1266
|
-
const newBuilder = new GlyphVectorGeometryBuilder(newLayout.loadedFont,
|
|
5442
|
+
const newBuilder = new GlyphVectorGeometryBuilder(newLayout.loadedFont, globalOutlineCache);
|
|
1267
5443
|
newBuilder.setFontId(newLayout.fontId);
|
|
1268
5444
|
layoutHandle = newLayout;
|
|
1269
5445
|
options = mergedOptions;
|
|
1270
|
-
return
|
|
5446
|
+
return buildCoreResult(layoutHandle, newBuilder, options);
|
|
1271
5447
|
}
|
|
1272
5448
|
const newLayout = await layoutHandle.update(mergedOptions);
|
|
1273
5449
|
layoutHandle = newLayout;
|
|
1274
5450
|
options = mergedOptions;
|
|
1275
|
-
return
|
|
5451
|
+
return buildCoreResult(layoutHandle, vectorBuilder, options);
|
|
1276
5452
|
};
|
|
5453
|
+
const { outlines, positions, glyphs, planeBounds } = vectorBuilder.collectForSlug(layoutHandle.clustersByLine, scale);
|
|
5454
|
+
const { gpuData } = buildSlugData(outlines, positions, scale);
|
|
1277
5455
|
return {
|
|
5456
|
+
gpuData,
|
|
1278
5457
|
glyphs,
|
|
1279
|
-
|
|
5458
|
+
planeBounds,
|
|
1280
5459
|
query: (queryOptions) => {
|
|
1281
5460
|
if (!cachedQuery) {
|
|
1282
|
-
cachedQuery = new TextRangeQuery
|
|
5461
|
+
cachedQuery = new TextRangeQuery(options.text, glyphs);
|
|
1283
5462
|
}
|
|
1284
5463
|
return cachedQuery.execute(queryOptions);
|
|
1285
5464
|
},
|
|
1286
5465
|
getLoadedFont: () => layoutHandle.getLoadedFont(),
|
|
1287
5466
|
measureTextWidth: (text, letterSpacing) => layoutHandle.measureTextWidth(text, letterSpacing),
|
|
1288
5467
|
update,
|
|
1289
|
-
dispose: () =>
|
|
5468
|
+
dispose: () => {
|
|
5469
|
+
layoutHandle.dispose();
|
|
5470
|
+
}
|
|
1290
5471
|
};
|
|
1291
5472
|
}
|
|
1292
5473
|
class Text {
|
|
1293
|
-
static { this.setHarfBuzzPath = Text$1.
|
|
1294
|
-
static { this.setHarfBuzzBuffer = Text$1.
|
|
1295
|
-
static { this.init = Text$1.
|
|
1296
|
-
static { this.registerPattern = Text$1.
|
|
1297
|
-
static { this.preloadPatterns = Text$1.
|
|
1298
|
-
static { this.setMaxFontCacheMemoryMB = Text$1.
|
|
1299
|
-
static { this.enableWoff2 = Text$1.
|
|
5474
|
+
static { this.setHarfBuzzPath = Text$1.setHarfBuzzPath; }
|
|
5475
|
+
static { this.setHarfBuzzBuffer = Text$1.setHarfBuzzBuffer; }
|
|
5476
|
+
static { this.init = Text$1.init; }
|
|
5477
|
+
static { this.registerPattern = Text$1.registerPattern; }
|
|
5478
|
+
static { this.preloadPatterns = Text$1.preloadPatterns; }
|
|
5479
|
+
static { this.setMaxFontCacheMemoryMB = Text$1.setMaxFontCacheMemoryMB; }
|
|
5480
|
+
static { this.enableWoff2 = Text$1.enableWoff2; }
|
|
1300
5481
|
static async create(options) {
|
|
1301
|
-
const layoutHandle = await Text$1.
|
|
1302
|
-
const vectorBuilder = new GlyphVectorGeometryBuilder(layoutHandle.loadedFont,
|
|
5482
|
+
const layoutHandle = await Text$1.create(options);
|
|
5483
|
+
const vectorBuilder = new GlyphVectorGeometryBuilder(layoutHandle.loadedFont, globalOutlineCache);
|
|
1303
5484
|
vectorBuilder.setFontId(layoutHandle.fontId);
|
|
1304
|
-
return
|
|
5485
|
+
return buildCoreResult(layoutHandle, vectorBuilder, options);
|
|
1305
5486
|
}
|
|
1306
5487
|
}
|
|
1307
5488
|
|
|
1308
5489
|
exports.Text = Text;
|
|
1309
|
-
exports.buildVectorGeometry = buildVectorGeometry;
|
|
1310
|
-
exports.extractContours = extractContours;
|