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.
Files changed (52) hide show
  1. package/LICENSE_THIRD_PARTY +15 -0
  2. package/README.md +73 -42
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +1 -1
  6. package/dist/index.min.cjs +1 -1
  7. package/dist/index.min.js +1 -1
  8. package/dist/index.umd.js +1 -1
  9. package/dist/index.umd.min.js +1 -1
  10. package/dist/three/react.d.ts +2 -0
  11. package/dist/types/core/types.d.ts +2 -33
  12. package/dist/types/vector/{core.d.ts → core/index.d.ts} +8 -7
  13. package/dist/types/vector/index.d.ts +22 -25
  14. package/dist/types/vector/react.d.ts +4 -3
  15. package/dist/types/vector/slug/SlugPacker.d.ts +2 -0
  16. package/dist/types/vector/slug/curveUtils.d.ts +6 -0
  17. package/dist/types/vector/slug/index.d.ts +8 -0
  18. package/dist/types/vector/slug/shaderStrings.d.ts +4 -0
  19. package/dist/types/vector/slug/slugGLSL.d.ts +21 -0
  20. package/dist/types/vector/slug/slugTSL.d.ts +13 -0
  21. package/dist/types/vector/slug/types.d.ts +30 -0
  22. package/dist/types/vector/slug/unpackVertices.d.ts +11 -0
  23. package/dist/types/vector/webgl/index.d.ts +7 -3
  24. package/dist/types/vector/webgpu/index.d.ts +4 -4
  25. package/dist/vector/core/index.cjs +856 -0
  26. package/dist/vector/core/index.d.ts +63 -0
  27. package/dist/vector/core/index.js +854 -0
  28. package/dist/vector/core.cjs +4419 -240
  29. package/dist/vector/core.d.ts +361 -71
  30. package/dist/vector/core.js +4406 -226
  31. package/dist/vector/index.cjs +5 -229
  32. package/dist/vector/index.d.ts +45 -396
  33. package/dist/vector/index.js +3 -223
  34. package/dist/vector/index2.cjs +287 -0
  35. package/dist/vector/index2.js +264 -0
  36. package/dist/vector/react.cjs +37 -8
  37. package/dist/vector/react.d.ts +6 -3
  38. package/dist/vector/react.js +18 -8
  39. package/dist/vector/slugTSL.cjs +252 -0
  40. package/dist/vector/slugTSL.js +231 -0
  41. package/dist/vector/webgl/index.cjs +131 -201
  42. package/dist/vector/webgl/index.d.ts +19 -44
  43. package/dist/vector/webgl/index.js +131 -201
  44. package/dist/vector/webgpu/index.cjs +100 -283
  45. package/dist/vector/webgpu/index.d.ts +16 -45
  46. package/dist/vector/webgpu/index.js +100 -283
  47. package/package.json +6 -1
  48. package/dist/types/vector/GlyphVectorGeometryBuilder.d.ts +0 -26
  49. package/dist/types/vector/LoopBlinnGeometry.d.ts +0 -68
  50. package/dist/types/vector/loopBlinnTSL.d.ts +0 -22
  51. package/dist/vector/loopBlinnTSL.cjs +0 -229
  52. package/dist/vector/loopBlinnTSL.js +0 -207
@@ -1,9 +1,2250 @@
1
1
  'use strict';
2
2
 
3
- var Text$1 = require('../index.cjs');
4
- var sharedCaches = require('../index.cjs');
5
- var DrawCallbacks = require('../index.cjs');
6
- var TextRangeQuery = require('../index.cjs');
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
- clone() {
20
- return new Vec2(this.x, this.y);
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
- copy(v) {
23
- this.x = v.x;
24
- this.y = v.y;
25
- return this;
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
- add(v) {
28
- this.x += v.x;
29
- this.y += v.y;
30
- return this;
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
- sub(v) {
33
- this.x -= v.x;
34
- this.y -= v.y;
35
- return this;
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
- multiply(scalar) {
38
- this.x *= scalar;
39
- this.y *= scalar;
40
- return this;
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
- divide(scalar) {
43
- this.x /= scalar;
44
- this.y /= scalar;
45
- return this;
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
- length() {
48
- return Math.sqrt(this.x * this.x + this.y * this.y);
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
- lengthSq() {
51
- return this.x * this.x + this.y * this.y;
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
- normalize() {
54
- const len = this.length();
55
- if (len > 0) {
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
- return this;
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
- dot(v) {
61
- return this.x * v.x + this.y * v.y;
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
- distanceTo(v) {
64
- const dx = this.x - v.x;
65
- const dy = this.y - v.y;
66
- return Math.sqrt(dx * dx + dy * dy);
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
- distanceToSquared(v) {
69
- const dx = this.x - v.x;
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
- equals(v) {
74
- return this.x === v.x && this.y === v.y;
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
- angle() {
77
- return Math.atan2(this.y, this.x);
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 = sharedCaches.globalOutlineCache) {
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 = DrawCallbacks.getSharedDrawCallbackHandler(this.loadedFont);
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
- const CONTOUR_EPSILON = 0.001;
1074
- const CURVE_LINEARITY_EPSILON = 1e-5;
1075
- function nearlyEqual(a, b, epsilon) {
1076
- return Math.abs(a - b) < epsilon;
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
- function extractContours(segments) {
1079
- const contours = [];
1080
- let contourVertices = [];
1081
- let contourSegments = [];
1082
- const pushCurrentContour = () => {
1083
- if (contourVertices.length < 3) {
1084
- contourVertices = [];
1085
- contourSegments = [];
1086
- return;
1087
- }
1088
- const first = contourVertices[0];
1089
- const last = contourVertices[contourVertices.length - 1];
1090
- if (nearlyEqual(first.x, last.x, CONTOUR_EPSILON) &&
1091
- nearlyEqual(first.y, last.y, CONTOUR_EPSILON)) {
1092
- contourVertices.pop();
1093
- }
1094
- if (contourVertices.length >= 3) {
1095
- contours.push({
1096
- vertices: contourVertices,
1097
- segments: contourSegments
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
- contourVertices = [];
1101
- contourSegments = [];
1102
- };
1103
- for (const segment of segments) {
1104
- if (contourVertices.length === 0) {
1105
- contourVertices.push({ x: segment.p0x, y: segment.p0y }, { x: segment.p2x, y: segment.p2y });
1106
- contourSegments.push(segment);
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 previousEnd = contourVertices[contourVertices.length - 1];
1110
- if (nearlyEqual(segment.p0x, previousEnd.x, CONTOUR_EPSILON) &&
1111
- nearlyEqual(segment.p0y, previousEnd.y, CONTOUR_EPSILON)) {
1112
- contourVertices.push({ x: segment.p2x, y: segment.p2y });
1113
- contourSegments.push(segment);
1114
- continue;
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
- pushCurrentContour();
1117
- contourVertices.push({ x: segment.p0x, y: segment.p0y }, { x: segment.p2x, y: segment.p2y });
1118
- contourSegments.push(segment);
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
- pushCurrentContour();
1121
- return contours;
1122
- }
1123
- function triangulateContourFan(vertices, offsetX, offsetY, positions, indices) {
1124
- const baseIndex = positions.length / 3;
1125
- for (const vertex of vertices) {
1126
- positions.push(vertex.x + offsetX, vertex.y + offsetY, 0);
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
- if (vertices.length < 3)
1129
- return 0;
1130
- for (let index = 1; index < vertices.length - 1; index++) {
1131
- indices.push(baseIndex, baseIndex + index, baseIndex + index + 1);
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 vertices.length - 2;
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
- function shouldSkipCurveSegment(segment) {
1136
- const dx = segment.p2x - segment.p0x;
1137
- const dy = segment.p2y - segment.p0y;
1138
- const lenSq = dx * dx + dy * dy;
1139
- if (lenSq <= 0)
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 fillGlyphAttrs(center, idx, progress, lineIdx, baselineY, vertCount, gc, gi, gp, gl, gb) {
1145
- for (let v = 0; v < vertCount; v++) {
1146
- gc.push(center[0], center[1], center[2]);
1147
- gi.push(idx);
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 packAttrs(gc, gi, gp, gl, gb) {
1154
- return {
1155
- glyphCenter: new Float32Array(gc),
1156
- glyphIndex: new Float32Array(gi),
1157
- glyphProgress: new Float32Array(gp),
1158
- glyphLineIndex: new Float32Array(gl),
1159
- glyphBaselineY: new Float32Array(gb)
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
- function buildVectorGeometry(input) {
1163
- const interiorPositions = [];
1164
- const interiorIndices = [];
1165
- const curvePositions = [];
1166
- const iGC = [], iGI = [], iGP = [], iGL = [], iGB = [];
1167
- const cGC = [], cGI = [], cGP = [], cGL = [], cGB = [];
1168
- const fGC = [], fGI = [], fGP = [], fGL = [], fGB = [];
1169
- const glyphCount = input.glyphs.length;
1170
- let contourCount = 0;
1171
- let interiorTriangleCount = 0;
1172
- let curveTriangleCount = 0;
1173
- const glyphRanges = [];
1174
- const fillPositions = new Float32Array(glyphCount * 12);
1175
- const fillIndices = new Uint32Array(glyphCount * 6);
1176
- for (let i = 0; i < glyphCount; i++) {
1177
- const glyph = input.glyphs[i];
1178
- const { minX, minY, maxX, maxY } = glyph.bounds;
1179
- const cx = glyph.offsetX + (minX + maxX) * 0.5;
1180
- const cy = glyph.offsetY + (minY + maxY) * 0.5;
1181
- const center = [cx, cy, 0];
1182
- const progress = glyphCount > 1 ? i / (glyphCount - 1) : 0;
1183
- const intIdxStart = interiorIndices.length;
1184
- const curveVertStart = curvePositions.length / 3;
1185
- const contours = extractContours(glyph.segments);
1186
- contourCount += contours.length;
1187
- for (const contour of contours) {
1188
- const intVertsBefore = interiorPositions.length / 3;
1189
- interiorTriangleCount += triangulateContourFan(contour.vertices, glyph.offsetX, glyph.offsetY, interiorPositions, interiorIndices);
1190
- const intVertsAfter = interiorPositions.length / 3;
1191
- fillGlyphAttrs(center, i, progress, glyph.lineIndex, glyph.baselineY, intVertsAfter - intVertsBefore, iGC, iGI, iGP, iGL, iGB);
1192
- for (const segment of contour.segments) {
1193
- if (shouldSkipCurveSegment(segment))
1194
- continue;
1195
- curvePositions.push(glyph.offsetX + segment.p0x, glyph.offsetY + segment.p0y, 0, glyph.offsetX + segment.p1x, glyph.offsetY + segment.p1y, 0, glyph.offsetX + segment.p2x, glyph.offsetY + segment.p2y, 0);
1196
- curveTriangleCount++;
1197
- fillGlyphAttrs(center, i, progress, glyph.lineIndex, glyph.baselineY, 3, cGC, cGI, cGP, cGL, cGB);
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
- glyphRanges.push({
1201
- interiorIndexStart: intIdxStart,
1202
- interiorIndexCount: interiorIndices.length - intIdxStart,
1203
- curveVertexStart: curveVertStart,
1204
- curveVertexCount: (curvePositions.length / 3) - curveVertStart
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
- const fp = i * 12;
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
- return {
1230
- interiorPositions: new Float32Array(interiorPositions),
1231
- interiorIndices: new Uint32Array(interiorIndices),
1232
- curvePositions: new Float32Array(curvePositions),
1233
- fillPositions,
1234
- fillIndices,
1235
- glyphRanges,
1236
- interiorGlyphAttrs: packAttrs(iGC, iGI, iGP, iGL, iGB),
1237
- curveGlyphAttrs: packAttrs(cGC, cGI, cGP, cGL, cGB),
1238
- fillGlyphAttrs: packAttrs(fGC, fGI, fGP, fGL, fGB),
1239
- planeBounds: input.planeBounds,
1240
- stats: {
1241
- glyphCount,
1242
- contourCount,
1243
- interiorTriangleCount,
1244
- curveTriangleCount
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 buildVectorResult(layoutHandle, vectorBuilder, options) {
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, sharedCaches.globalOutlineCache);
5442
+ const newBuilder = new GlyphVectorGeometryBuilder(newLayout.loadedFont, globalOutlineCache);
1267
5443
  newBuilder.setFontId(newLayout.fontId);
1268
5444
  layoutHandle = newLayout;
1269
5445
  options = mergedOptions;
1270
- return buildVectorResult(layoutHandle, newBuilder, options);
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 buildVectorResult(layoutHandle, vectorBuilder, options);
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
- geometryData,
5458
+ planeBounds,
1280
5459
  query: (queryOptions) => {
1281
5460
  if (!cachedQuery) {
1282
- cachedQuery = new TextRangeQuery.TextRangeQuery(options.text, glyphs);
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: () => layoutHandle.dispose()
5468
+ dispose: () => {
5469
+ layoutHandle.dispose();
5470
+ }
1290
5471
  };
1291
5472
  }
1292
5473
  class Text {
1293
- static { this.setHarfBuzzPath = Text$1.Text.setHarfBuzzPath; }
1294
- static { this.setHarfBuzzBuffer = Text$1.Text.setHarfBuzzBuffer; }
1295
- static { this.init = Text$1.Text.init; }
1296
- static { this.registerPattern = Text$1.Text.registerPattern; }
1297
- static { this.preloadPatterns = Text$1.Text.preloadPatterns; }
1298
- static { this.setMaxFontCacheMemoryMB = Text$1.Text.setMaxFontCacheMemoryMB; }
1299
- static { this.enableWoff2 = Text$1.Text.enableWoff2; }
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.Text.create(options);
1302
- const vectorBuilder = new GlyphVectorGeometryBuilder(layoutHandle.loadedFont, sharedCaches.globalOutlineCache);
5482
+ const layoutHandle = await Text$1.create(options);
5483
+ const vectorBuilder = new GlyphVectorGeometryBuilder(layoutHandle.loadedFont, globalOutlineCache);
1303
5484
  vectorBuilder.setFontId(layoutHandle.fontId);
1304
- return buildVectorResult(layoutHandle, vectorBuilder, options);
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;