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