three-text 0.2.12 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.12
2
+ * three-text v0.2.14
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -48,7 +48,9 @@ class PerformanceLogger {
48
48
  if (!isLogEnabled)
49
49
  return;
50
50
  const startTime = performance.now();
51
- this.activeTimers.set(name, startTime);
51
+ // Generate unique key for nested timing support
52
+ const timerKey = `${name}_${startTime}`;
53
+ this.activeTimers.set(timerKey, startTime);
52
54
  this.metrics.push({
53
55
  name,
54
56
  startTime,
@@ -60,17 +62,26 @@ class PerformanceLogger {
60
62
  if (!isLogEnabled)
61
63
  return null;
62
64
  const endTime = performance.now();
63
- const startTime = this.activeTimers.get(name);
64
- if (startTime === undefined) {
65
+ // Find the most recent matching timer by scanning backwards
66
+ let timerKey;
67
+ let startTime;
68
+ for (const [key, time] of Array.from(this.activeTimers.entries()).reverse()) {
69
+ if (key.startsWith(`${name}_`)) {
70
+ timerKey = key;
71
+ startTime = time;
72
+ break;
73
+ }
74
+ }
75
+ if (startTime === undefined || !timerKey) {
65
76
  logger.warn(`Performance timer "${name}" was not started`);
66
77
  return null;
67
78
  }
68
79
  const duration = endTime - startTime;
69
- this.activeTimers.delete(name);
80
+ this.activeTimers.delete(timerKey);
70
81
  // Find the metric in reverse order (most recent first)
71
82
  for (let i = this.metrics.length - 1; i >= 0; i--) {
72
83
  const metric = this.metrics[i];
73
- if (metric.name === name && !metric.endTime) {
84
+ if (metric.name === name && metric.startTime === startTime && !metric.endTime) {
74
85
  metric.endTime = endTime;
75
86
  metric.duration = duration;
76
87
  break;
@@ -713,7 +724,7 @@ class LineBreak {
713
724
  align: options.align || 'left',
714
725
  hyphenate: options.hyphenate || false
715
726
  });
716
- const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, 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, looseness = 0, disableShortLineDetection = false, shortLineThreshold = SHORT_LINE_WIDTH_THRESHOLD } = options;
727
+ const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, 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, looseness = 0, disableShortLineDetection = false, shortLineThreshold = SHORT_LINE_WIDTH_THRESHOLD } = options;
717
728
  // Handle multiple paragraphs by processing each independently
718
729
  if (respectExistingBreaks && text.includes('\n')) {
719
730
  const paragraphs = text.split('\n');
@@ -775,7 +786,11 @@ class LineBreak {
775
786
  hyphenPenalty: hyphenpenalty,
776
787
  exHyphenPenalty: exhyphenpenalty,
777
788
  currentAlign: align,
778
- unitsPerEm
789
+ unitsPerEm,
790
+ // measureText() includes trailing letter spacing after the final glyph of a token.
791
+ // Shaping applies letter spacing only between glyphs, so we subtract one
792
+ // trailing letterSpacingFU per line segment (see computeAdjustmentRatio/createLines).
793
+ letterSpacingFU: unitsPerEm ? letterSpacing * unitsPerEm : 0
779
794
  };
780
795
  if (!width || width === Infinity) {
781
796
  const measuredWidth = measureText(text);
@@ -1083,7 +1098,7 @@ class LineBreak {
1083
1098
  }
1084
1099
  }
1085
1100
  }
1086
- static computeAdjustmentRatio(items, lineStart, lineEnd, _lineNumber, lineWidth, cumulativeWidths, _context) {
1101
+ static computeAdjustmentRatio(items, lineStart, lineEnd, _lineNumber, lineWidth, cumulativeWidths, context) {
1087
1102
  let totalWidth = 0;
1088
1103
  let totalStretch = 0;
1089
1104
  let totalShrink = 0;
@@ -1126,6 +1141,12 @@ class LineBreak {
1126
1141
  ? items[lineEnd].width
1127
1142
  : items[lineEnd].preBreakWidth;
1128
1143
  }
1144
+ // Correct for trailing letter spacing at the end of the line segment.
1145
+ // Our token measurement includes letter spacing after the final glyph;
1146
+ // shaping does not add letter spacing after the final glyph in a line.
1147
+ if (context?.letterSpacingFU && totalWidth !== 0) {
1148
+ totalWidth -= context.letterSpacingFU;
1149
+ }
1129
1150
  const adjustment = lineWidth - totalWidth;
1130
1151
  let ratio;
1131
1152
  if (adjustment > 0 && totalStretch > 0) {
@@ -1288,6 +1309,10 @@ class LineBreak {
1288
1309
  }
1289
1310
  }
1290
1311
  const lineText = lineTextParts.join('');
1312
+ // Correct for trailing letter spacing at the end of the line.
1313
+ if (context?.letterSpacingFU && naturalWidth !== 0) {
1314
+ naturalWidth -= context.letterSpacingFU;
1315
+ }
1291
1316
  let xOffset = 0;
1292
1317
  let adjustmentRatio = 0;
1293
1318
  let effectiveAlign = align;
@@ -1341,6 +1366,10 @@ class LineBreak {
1341
1366
  finalNaturalWidth += item.width;
1342
1367
  }
1343
1368
  const finalLineText = finalLineTextParts.join('');
1369
+ // Correct for trailing letter spacing at the end of the final line.
1370
+ if (context?.letterSpacingFU && finalNaturalWidth !== 0) {
1371
+ finalNaturalWidth -= context.letterSpacingFU;
1372
+ }
1344
1373
  let finalXOffset = 0;
1345
1374
  let finalEffectiveAlign = align;
1346
1375
  if (align === 'justify') {
@@ -1402,9 +1431,6 @@ function convertFontFeaturesToString(features) {
1402
1431
  }
1403
1432
 
1404
1433
  class TextMeasurer {
1405
- // Measures text width including letter spacing
1406
- // (letter spacing is added uniformly after each glyph during measurement,
1407
- // so the widths given to the line-breaking algorithm already account for tracking)
1408
1434
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1409
1435
  const buffer = loadedFont.hb.createBuffer();
1410
1436
  buffer.addText(text);
@@ -1413,7 +1439,6 @@ class TextMeasurer {
1413
1439
  loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
1414
1440
  const glyphInfos = buffer.json(loadedFont.font);
1415
1441
  const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
1416
- // Calculate total advance width with letter spacing
1417
1442
  let totalWidth = 0;
1418
1443
  glyphInfos.forEach((glyph) => {
1419
1444
  totalWidth += glyph.ax;
@@ -1460,6 +1485,7 @@ class TextLayout {
1460
1485
  disableShortLineDetection,
1461
1486
  shortLineThreshold,
1462
1487
  unitsPerEm: this.loadedFont.upem,
1488
+ letterSpacing,
1463
1489
  measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1464
1490
  )
1465
1491
  });
@@ -1470,10 +1496,11 @@ class TextLayout {
1470
1496
  lines = [];
1471
1497
  let currentIndex = 0;
1472
1498
  for (const line of linesArray) {
1499
+ const originalEnd = line.length === 0 ? currentIndex : currentIndex + line.length - 1;
1473
1500
  lines.push({
1474
1501
  text: line,
1475
1502
  originalStart: currentIndex,
1476
- originalEnd: currentIndex + line.length - 1,
1503
+ originalEnd,
1477
1504
  xOffset: 0
1478
1505
  });
1479
1506
  currentIndex += line.length + 1;
@@ -1512,7 +1539,6 @@ class TextLayout {
1512
1539
  // Font file signature constants
1513
1540
  const FONT_SIGNATURE_TRUE_TYPE = 0x00010000;
1514
1541
  const FONT_SIGNATURE_OPEN_TYPE_CFF = 0x4f54544f; // 'OTTO'
1515
- const FONT_SIGNATURE_TRUE_TYPE_COLLECTION = 0x74746366; // 'ttcf'
1516
1542
  const FONT_SIGNATURE_WOFF = 0x774f4646; // 'wOFF'
1517
1543
  const FONT_SIGNATURE_WOFF2 = 0x774f4632; // 'wOF2'
1518
1544
  // Table Tags
@@ -1527,6 +1553,28 @@ const TABLE_TAG_CFF2 = 0x43464632; // 'CFF2'
1527
1553
  const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
1528
1554
  const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
1529
1555
 
1556
+ // Parses the SFNT table directory for TTF/OTF fonts
1557
+ // Assumes the DataView is positioned at the start of an sfnt font (offset 0)
1558
+ // Table records are 16 bytes each starting at byte offset 12
1559
+ function parseTableDirectory(view) {
1560
+ const numTables = view.getUint16(4);
1561
+ const tableRecordsStart = 12;
1562
+ const tables = new Map();
1563
+ for (let i = 0; i < numTables; i++) {
1564
+ const recordOffset = tableRecordsStart + i * 16;
1565
+ // Guard against corrupt buffers that report more tables than exist
1566
+ if (recordOffset + 16 > view.byteLength) {
1567
+ break;
1568
+ }
1569
+ const tag = view.getUint32(recordOffset);
1570
+ const checksum = view.getUint32(recordOffset + 4);
1571
+ const offset = view.getUint32(recordOffset + 8);
1572
+ const length = view.getUint32(recordOffset + 12);
1573
+ tables.set(tag, { tag, checksum, offset, length });
1574
+ }
1575
+ return tables;
1576
+ }
1577
+
1530
1578
  class FontMetadataExtractor {
1531
1579
  static extractMetadata(fontBuffer) {
1532
1580
  if (!fontBuffer || fontBuffer.byteLength < 12) {
@@ -1536,45 +1584,19 @@ class FontMetadataExtractor {
1536
1584
  const sfntVersion = view.getUint32(0);
1537
1585
  const validSignatures = [
1538
1586
  FONT_SIGNATURE_TRUE_TYPE,
1539
- FONT_SIGNATURE_OPEN_TYPE_CFF,
1540
- FONT_SIGNATURE_TRUE_TYPE_COLLECTION
1587
+ FONT_SIGNATURE_OPEN_TYPE_CFF
1541
1588
  ];
1542
1589
  if (!validSignatures.includes(sfntVersion)) {
1543
- throw new Error(`Invalid font format. Expected TrueType or OpenType, got signature: 0x${sfntVersion.toString(16)}`);
1544
- }
1545
- const numTables = view.getUint16(4); // OpenType header - number of tables is at offset 4
1546
- let isCFF = false;
1547
- let headTableOffset = 0;
1548
- let hheaTableOffset = 0;
1549
- let os2TableOffset = 0;
1550
- let statTableOffset = 0;
1551
- let nameTableOffset = 0;
1552
- let fvarTableOffset = 0;
1553
- for (let i = 0; i < numTables; i++) {
1554
- const offset = 12 + i * 16;
1555
- const tag = view.getUint32(offset);
1556
- if (tag === TABLE_TAG_CFF || tag === TABLE_TAG_CFF2) {
1557
- isCFF = true;
1558
- }
1559
- else if (tag === TABLE_TAG_HEAD) {
1560
- headTableOffset = view.getUint32(offset + 8);
1561
- }
1562
- else if (tag === TABLE_TAG_HHEA) {
1563
- hheaTableOffset = view.getUint32(offset + 8);
1564
- }
1565
- else if (tag === TABLE_TAG_OS2) {
1566
- os2TableOffset = view.getUint32(offset + 8);
1567
- }
1568
- else if (tag === TABLE_TAG_FVAR) {
1569
- fvarTableOffset = view.getUint32(offset + 8);
1570
- }
1571
- else if (tag === TABLE_TAG_STAT) {
1572
- statTableOffset = view.getUint32(offset + 8);
1573
- }
1574
- else if (tag === TABLE_TAG_NAME) {
1575
- nameTableOffset = view.getUint32(offset + 8);
1576
- }
1577
- }
1590
+ throw new Error(`Invalid font format. Expected TTF/OTF (or WOFF), got signature: 0x${sfntVersion.toString(16)}`);
1591
+ }
1592
+ const tableDirectory = parseTableDirectory(view);
1593
+ const isCFF = tableDirectory.has(TABLE_TAG_CFF) || tableDirectory.has(TABLE_TAG_CFF2);
1594
+ const headTableOffset = tableDirectory.get(TABLE_TAG_HEAD)?.offset ?? 0;
1595
+ const hheaTableOffset = tableDirectory.get(TABLE_TAG_HHEA)?.offset ?? 0;
1596
+ const os2TableOffset = tableDirectory.get(TABLE_TAG_OS2)?.offset ?? 0;
1597
+ const fvarTableOffset = tableDirectory.get(TABLE_TAG_FVAR)?.offset ?? 0;
1598
+ const statTableOffset = tableDirectory.get(TABLE_TAG_STAT)?.offset ?? 0;
1599
+ const nameTableOffset = tableDirectory.get(TABLE_TAG_NAME)?.offset ?? 0;
1578
1600
  const unitsPerEm = headTableOffset
1579
1601
  ? view.getUint16(headTableOffset + 18)
1580
1602
  : 1000;
@@ -1617,23 +1639,10 @@ class FontMetadataExtractor {
1617
1639
  }
1618
1640
  static extractFeatureTags(fontBuffer) {
1619
1641
  const view = new DataView(fontBuffer);
1620
- const numTables = view.getUint16(4);
1621
- let gsubTableOffset = 0;
1622
- let gposTableOffset = 0;
1623
- let nameTableOffset = 0;
1624
- for (let i = 0; i < numTables; i++) {
1625
- const offset = 12 + i * 16;
1626
- const tag = view.getUint32(offset);
1627
- if (tag === TABLE_TAG_GSUB) {
1628
- gsubTableOffset = view.getUint32(offset + 8);
1629
- }
1630
- else if (tag === TABLE_TAG_GPOS) {
1631
- gposTableOffset = view.getUint32(offset + 8);
1632
- }
1633
- else if (tag === TABLE_TAG_NAME) {
1634
- nameTableOffset = view.getUint32(offset + 8);
1635
- }
1636
- }
1642
+ const tableDirectory = parseTableDirectory(view);
1643
+ const gsubTableOffset = tableDirectory.get(TABLE_TAG_GSUB)?.offset ?? 0;
1644
+ const gposTableOffset = tableDirectory.get(TABLE_TAG_GPOS)?.offset ?? 0;
1645
+ const nameTableOffset = tableDirectory.get(TABLE_TAG_NAME)?.offset ?? 0;
1637
1646
  const features = new Set();
1638
1647
  const featureNames = {};
1639
1648
  try {
@@ -1864,6 +1873,7 @@ class WoffConverter {
1864
1873
  sfntView.setUint16(6, searchRange);
1865
1874
  sfntView.setUint16(8, Math.floor(Math.log2(numTables)));
1866
1875
  sfntView.setUint16(10, numTables * 16 - searchRange);
1876
+ // Read and decompress table directory
1867
1877
  let sfntOffset = 12 + numTables * 16; // Start of table data
1868
1878
  const tableDirectory = [];
1869
1879
  // Read WOFF table directory
@@ -1946,11 +1956,10 @@ class FontLoader {
1946
1956
  const sfntVersion = view.getUint32(0);
1947
1957
  const validSignatures = [
1948
1958
  FONT_SIGNATURE_TRUE_TYPE,
1949
- FONT_SIGNATURE_OPEN_TYPE_CFF,
1950
- FONT_SIGNATURE_TRUE_TYPE_COLLECTION
1959
+ FONT_SIGNATURE_OPEN_TYPE_CFF
1951
1960
  ];
1952
1961
  if (!validSignatures.includes(sfntVersion)) {
1953
- throw new Error(`Invalid font format. Expected TrueType or OpenType, got signature: 0x${sfntVersion.toString(16)}`);
1962
+ throw new Error(`Invalid font format. Expected TTF/OTF (or WOFF), got signature: 0x${sfntVersion.toString(16)}`);
1954
1963
  }
1955
1964
  const { hb, module } = await this.getHarfBuzzInstance();
1956
1965
  try {
@@ -1987,7 +1996,8 @@ class FontLoader {
1987
1996
  isVariable,
1988
1997
  variationAxes,
1989
1998
  availableFeatures: featureData?.tags,
1990
- featureNames: featureData?.names
1999
+ featureNames: featureData?.names,
2000
+ _buffer: fontBuffer // For stable font ID generation
1991
2001
  };
1992
2002
  }
1993
2003
  catch (error) {
@@ -2017,7 +2027,91 @@ class FontLoader {
2017
2027
  }
2018
2028
  }
2019
2029
 
2030
+ const SAFE_LANGUAGE_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,16})*$/i;
2031
+ // Built-in patterns shipped with three-text (matches files in src/hyphenation/*)
2032
+ const BUILTIN_PATTERN_LANGUAGES = new Set([
2033
+ 'af',
2034
+ 'as',
2035
+ 'be',
2036
+ 'bg',
2037
+ 'bn',
2038
+ 'ca',
2039
+ 'cs',
2040
+ 'cy',
2041
+ 'da',
2042
+ 'de-1996',
2043
+ 'el-monoton',
2044
+ 'el-polyton',
2045
+ 'en-gb',
2046
+ 'en-us',
2047
+ 'eo',
2048
+ 'es',
2049
+ 'et',
2050
+ 'eu',
2051
+ 'fi',
2052
+ 'fr',
2053
+ 'fur',
2054
+ 'ga',
2055
+ 'gl',
2056
+ 'gu',
2057
+ 'hi',
2058
+ 'hr',
2059
+ 'hsb',
2060
+ 'hu',
2061
+ 'hy',
2062
+ 'ia',
2063
+ 'id',
2064
+ 'is',
2065
+ 'it',
2066
+ 'ka',
2067
+ 'kmr',
2068
+ 'kn',
2069
+ 'la',
2070
+ 'lt',
2071
+ 'lv',
2072
+ 'mk',
2073
+ 'ml',
2074
+ 'mn-cyrl',
2075
+ 'mr',
2076
+ 'mul-ethi',
2077
+ 'nb',
2078
+ 'nl',
2079
+ 'nn',
2080
+ 'oc',
2081
+ 'or',
2082
+ 'pa',
2083
+ 'pl',
2084
+ 'pms',
2085
+ 'pt',
2086
+ 'rm',
2087
+ 'ro',
2088
+ 'ru',
2089
+ 'sa',
2090
+ 'sh-cyrl',
2091
+ 'sh-latn',
2092
+ 'sk',
2093
+ 'sl',
2094
+ 'sq',
2095
+ 'sr-cyrl',
2096
+ 'sv',
2097
+ 'ta',
2098
+ 'te',
2099
+ 'th',
2100
+ 'tk',
2101
+ 'tr',
2102
+ 'uk',
2103
+ 'zh-latn-pinyin'
2104
+ ]);
2020
2105
  async function loadPattern(language, patternsPath) {
2106
+ if (!SAFE_LANGUAGE_RE.test(language)) {
2107
+ throw new Error(`Invalid hyphenation language code "${language}". Expected e.g. "en-us".`);
2108
+ }
2109
+ // When no patternsPath is provided, we only allow the built-in set shipped with
2110
+ // three-text to avoid accidental arbitrary imports / path traversal
2111
+ if (!patternsPath && !BUILTIN_PATTERN_LANGUAGES.has(language)) {
2112
+ throw new Error(`Unsupported hyphenation language "${language}". ` +
2113
+ `Use a built-in language (e.g. "en-us") or register patterns via Text.registerPattern("${language}", pattern).`);
2114
+ }
2021
2115
  {
2022
2116
  // In ESM build, use dynamic imports
2023
2117
  try {
@@ -2206,108 +2300,233 @@ class Vec3 {
2206
2300
  return this.x === v.x && this.y === v.y && this.z === v.z;
2207
2301
  }
2208
2302
  }
2209
- // 3D Bounding Box
2210
- class Box3 {
2211
- constructor(min = new Vec3(Infinity, Infinity, Infinity), max = new Vec3(-Infinity, -Infinity, -Infinity)) {
2212
- this.min = min;
2213
- this.max = max;
2214
- }
2215
- set(min, max) {
2216
- this.min.copy(min);
2217
- this.max.copy(max);
2218
- return this;
2303
+
2304
+ // Generic LRU (Least Recently Used) cache with optional memory-based eviction
2305
+ class LRUCache {
2306
+ constructor(options = {}) {
2307
+ this.cache = new Map();
2308
+ this.head = null;
2309
+ this.tail = null;
2310
+ this.stats = {
2311
+ hits: 0,
2312
+ misses: 0,
2313
+ evictions: 0,
2314
+ size: 0,
2315
+ memoryUsage: 0
2316
+ };
2317
+ this.options = {
2318
+ maxEntries: options.maxEntries ?? Infinity,
2319
+ maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
2320
+ calculateSize: options.calculateSize ?? (() => 0),
2321
+ onEvict: options.onEvict
2322
+ };
2219
2323
  }
2220
- setFromPoints(points) {
2221
- this.makeEmpty();
2222
- for (let i = 0; i < points.length; i++) {
2223
- this.expandByPoint(points[i]);
2324
+ get(key) {
2325
+ const node = this.cache.get(key);
2326
+ if (node) {
2327
+ this.stats.hits++;
2328
+ this.moveToHead(node);
2329
+ return node.value;
2330
+ }
2331
+ else {
2332
+ this.stats.misses++;
2333
+ return undefined;
2224
2334
  }
2225
- return this;
2226
2335
  }
2227
- makeEmpty() {
2228
- this.min.x = this.min.y = this.min.z = Infinity;
2229
- this.max.x = this.max.y = this.max.z = -Infinity;
2230
- return this;
2336
+ has(key) {
2337
+ return this.cache.has(key);
2231
2338
  }
2232
- isEmpty() {
2233
- return (this.max.x < this.min.x ||
2234
- this.max.y < this.min.y ||
2235
- this.max.z < this.min.z);
2236
- }
2237
- expandByPoint(point) {
2238
- this.min.x = Math.min(this.min.x, point.x);
2239
- this.min.y = Math.min(this.min.y, point.y);
2240
- this.min.z = Math.min(this.min.z, point.z);
2241
- this.max.x = Math.max(this.max.x, point.x);
2242
- this.max.y = Math.max(this.max.y, point.y);
2243
- this.max.z = Math.max(this.max.z, point.z);
2244
- return this;
2339
+ set(key, value) {
2340
+ // If key already exists, update it
2341
+ const existingNode = this.cache.get(key);
2342
+ if (existingNode) {
2343
+ const oldSize = this.options.calculateSize(existingNode.value);
2344
+ const newSize = this.options.calculateSize(value);
2345
+ this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
2346
+ existingNode.value = value;
2347
+ this.moveToHead(existingNode);
2348
+ return;
2349
+ }
2350
+ const size = this.options.calculateSize(value);
2351
+ // Evict entries if we exceed limits
2352
+ this.evictIfNeeded(size);
2353
+ // Create new node
2354
+ const node = {
2355
+ key,
2356
+ value,
2357
+ prev: null,
2358
+ next: null
2359
+ };
2360
+ this.cache.set(key, node);
2361
+ this.addToHead(node);
2362
+ this.stats.size = this.cache.size;
2363
+ this.stats.memoryUsage += size;
2245
2364
  }
2246
- expandByScalar(scalar) {
2247
- this.min.x -= scalar;
2248
- this.min.y -= scalar;
2249
- this.min.z -= scalar;
2250
- this.max.x += scalar;
2251
- this.max.y += scalar;
2252
- this.max.z += scalar;
2253
- return this;
2365
+ delete(key) {
2366
+ const node = this.cache.get(key);
2367
+ if (!node)
2368
+ return false;
2369
+ const size = this.options.calculateSize(node.value);
2370
+ this.removeNode(node);
2371
+ this.cache.delete(key);
2372
+ this.stats.size = this.cache.size;
2373
+ this.stats.memoryUsage -= size;
2374
+ if (this.options.onEvict) {
2375
+ this.options.onEvict(key, node.value);
2376
+ }
2377
+ return true;
2254
2378
  }
2255
- containsPoint(point) {
2256
- return (point.x >= this.min.x &&
2257
- point.x <= this.max.x &&
2258
- point.y >= this.min.y &&
2259
- point.y <= this.max.y &&
2260
- point.z >= this.min.z &&
2261
- point.z <= this.max.z);
2262
- }
2263
- containsBox(box) {
2264
- return (this.min.x <= box.min.x &&
2265
- box.max.x <= this.max.x &&
2266
- this.min.y <= box.min.y &&
2267
- box.max.y <= this.max.y &&
2268
- this.min.z <= box.min.z &&
2269
- box.max.z <= this.max.z);
2270
- }
2271
- intersectsBox(box) {
2272
- return (box.max.x >= this.min.x &&
2273
- box.min.x <= this.max.x &&
2274
- box.max.y >= this.min.y &&
2275
- box.min.y <= this.max.y &&
2276
- box.max.z >= this.min.z &&
2277
- box.min.z <= this.max.z);
2278
- }
2279
- getCenter(target = new Vec3()) {
2280
- return this.isEmpty()
2281
- ? target.set(0, 0, 0)
2282
- : target.set((this.min.x + this.max.x) * 0.5, (this.min.y + this.max.y) * 0.5, (this.min.z + this.max.z) * 0.5);
2283
- }
2284
- getSize(target = new Vec3()) {
2285
- return this.isEmpty()
2286
- ? target.set(0, 0, 0)
2287
- : target.set(this.max.x - this.min.x, this.max.y - this.min.y, this.max.z - this.min.z);
2379
+ clear() {
2380
+ if (this.options.onEvict) {
2381
+ for (const [key, node] of this.cache) {
2382
+ this.options.onEvict(key, node.value);
2383
+ }
2384
+ }
2385
+ this.cache.clear();
2386
+ this.head = null;
2387
+ this.tail = null;
2388
+ this.stats = {
2389
+ hits: 0,
2390
+ misses: 0,
2391
+ evictions: 0,
2392
+ size: 0,
2393
+ memoryUsage: 0
2394
+ };
2288
2395
  }
2289
- clone() {
2290
- return new Box3(this.min.clone(), this.max.clone());
2396
+ getStats() {
2397
+ const total = this.stats.hits + this.stats.misses;
2398
+ const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
2399
+ const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
2400
+ return {
2401
+ ...this.stats,
2402
+ hitRate,
2403
+ memoryUsageMB
2404
+ };
2291
2405
  }
2292
- copy(box) {
2293
- this.min.copy(box.min);
2294
- this.max.copy(box.max);
2295
- return this;
2406
+ keys() {
2407
+ const keys = [];
2408
+ let current = this.head;
2409
+ while (current) {
2410
+ keys.push(current.key);
2411
+ current = current.next;
2412
+ }
2413
+ return keys;
2296
2414
  }
2297
- union(box) {
2298
- this.min.x = Math.min(this.min.x, box.min.x);
2299
- this.min.y = Math.min(this.min.y, box.min.y);
2300
- this.min.z = Math.min(this.min.z, box.min.z);
2301
- this.max.x = Math.max(this.max.x, box.max.x);
2302
- this.max.y = Math.max(this.max.y, box.max.y);
2303
- this.max.z = Math.max(this.max.z, box.max.z);
2304
- return this;
2415
+ get size() {
2416
+ return this.cache.size;
2417
+ }
2418
+ evictIfNeeded(requiredSize) {
2419
+ // Evict by entry count
2420
+ while (this.cache.size >= this.options.maxEntries && this.tail) {
2421
+ this.evictTail();
2422
+ }
2423
+ // Evict by memory usage
2424
+ if (this.options.maxMemoryBytes < Infinity) {
2425
+ while (this.tail &&
2426
+ this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
2427
+ this.evictTail();
2428
+ }
2429
+ }
2430
+ }
2431
+ evictTail() {
2432
+ if (!this.tail)
2433
+ return;
2434
+ const nodeToRemove = this.tail;
2435
+ const size = this.options.calculateSize(nodeToRemove.value);
2436
+ this.removeTail();
2437
+ this.cache.delete(nodeToRemove.key);
2438
+ this.stats.size = this.cache.size;
2439
+ this.stats.memoryUsage -= size;
2440
+ this.stats.evictions++;
2441
+ if (this.options.onEvict) {
2442
+ this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
2443
+ }
2444
+ }
2445
+ addToHead(node) {
2446
+ node.prev = null;
2447
+ node.next = null;
2448
+ if (!this.head) {
2449
+ this.head = this.tail = node;
2450
+ }
2451
+ else {
2452
+ node.next = this.head;
2453
+ this.head.prev = node;
2454
+ this.head = node;
2455
+ }
2456
+ }
2457
+ removeNode(node) {
2458
+ if (node.prev) {
2459
+ node.prev.next = node.next;
2460
+ }
2461
+ else {
2462
+ this.head = node.next;
2463
+ }
2464
+ if (node.next) {
2465
+ node.next.prev = node.prev;
2466
+ }
2467
+ else {
2468
+ this.tail = node.prev;
2469
+ }
2470
+ }
2471
+ removeTail() {
2472
+ if (this.tail) {
2473
+ this.removeNode(this.tail);
2474
+ }
2305
2475
  }
2306
- equals(box) {
2307
- return box.min.equals(this.min) && box.max.equals(this.max);
2476
+ moveToHead(node) {
2477
+ if (node === this.head)
2478
+ return;
2479
+ this.removeNode(node);
2480
+ this.addToHead(node);
2308
2481
  }
2309
2482
  }
2310
2483
 
2484
+ const DEFAULT_CACHE_SIZE_MB = 250;
2485
+ function getGlyphCacheKey(fontId, glyphId, depth, removeOverlaps) {
2486
+ const roundedDepth = Math.round(depth * 1000) / 1000;
2487
+ return `${fontId}_${glyphId}_${roundedDepth}_${removeOverlaps}`;
2488
+ }
2489
+ function calculateGlyphMemoryUsage(glyph) {
2490
+ let size = 0;
2491
+ size += glyph.vertices.length * 4;
2492
+ size += glyph.normals.length * 4;
2493
+ size += glyph.indices.length * glyph.indices.BYTES_PER_ELEMENT;
2494
+ size += 24; // 2 Vec3s
2495
+ size += 256; // Object overhead
2496
+ return size;
2497
+ }
2498
+ const globalGlyphCache = new LRUCache({
2499
+ maxEntries: Infinity,
2500
+ maxMemoryBytes: DEFAULT_CACHE_SIZE_MB * 1024 * 1024,
2501
+ calculateSize: calculateGlyphMemoryUsage
2502
+ });
2503
+ function createGlyphCache(maxCacheSizeMB = DEFAULT_CACHE_SIZE_MB) {
2504
+ return new LRUCache({
2505
+ maxEntries: Infinity,
2506
+ maxMemoryBytes: maxCacheSizeMB * 1024 * 1024,
2507
+ calculateSize: calculateGlyphMemoryUsage
2508
+ });
2509
+ }
2510
+ // Shared across builder instances: contour extraction, word clustering, boundary grouping
2511
+ const globalContourCache = new LRUCache({
2512
+ maxEntries: 1000,
2513
+ calculateSize: (contours) => {
2514
+ let size = 0;
2515
+ for (const path of contours.paths) {
2516
+ size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
2517
+ }
2518
+ return size + 64; // bounds overhead
2519
+ }
2520
+ });
2521
+ const globalWordCache = new LRUCache({
2522
+ maxEntries: 1000,
2523
+ calculateSize: calculateGlyphMemoryUsage
2524
+ });
2525
+ const globalClusteringCache = new LRUCache({
2526
+ maxEntries: 2000,
2527
+ calculateSize: () => 1
2528
+ });
2529
+
2311
2530
  var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
2312
2531
 
2313
2532
  function getDefaultExportFromCjs (x) {
@@ -2564,69 +2783,137 @@ class Tessellator {
2564
2783
  class Extruder {
2565
2784
  constructor() { }
2566
2785
  extrude(geometry, depth = 0, unitsPerEm) {
2567
- const vertices = [];
2568
- const normals = [];
2569
- const indices = [];
2570
- if (depth === 0) {
2571
- this.addFlatFaces(geometry.triangles, vertices, normals, indices);
2572
- }
2573
- else {
2574
- this.addFrontAndBackFaces(geometry.triangles, vertices, normals, indices, depth, unitsPerEm);
2786
+ const points = geometry.triangles.vertices;
2787
+ const triangleIndices = geometry.triangles.indices;
2788
+ const numPoints = points.length / 2;
2789
+ // Count side-wall segments (each segment emits 4 vertices + 6 indices)
2790
+ let sideSegments = 0;
2791
+ if (depth !== 0) {
2575
2792
  for (const contour of geometry.contours) {
2576
- this.addSideWalls(contour, vertices, normals, indices, depth);
2577
- }
2578
- }
2579
- return { vertices, normals, indices };
2580
- }
2581
- addFlatFaces(triangulatedData, vertices, normals, indices) {
2582
- const baseIndex = vertices.length / 3;
2583
- const points = triangulatedData.vertices;
2584
- const triangleIndices = triangulatedData.indices;
2585
- for (let i = 0; i < points.length; i += 2) {
2586
- vertices.push(points[i], points[i + 1], 0);
2587
- normals.push(0, 0, -1);
2588
- }
2589
- // Add triangle indices
2590
- for (let i = 0; i < triangleIndices.length; i++) {
2591
- indices.push(baseIndex + triangleIndices[i]);
2592
- }
2593
- }
2594
- addFrontAndBackFaces(triangulatedData, vertices, normals, indices, depth, unitsPerEm) {
2595
- const baseIndex = vertices.length / 3;
2596
- const points = triangulatedData.vertices;
2597
- const triangleIndices = triangulatedData.indices;
2598
- for (let i = 0; i < points.length; i += 2) {
2599
- vertices.push(points[i], points[i + 1], 0);
2600
- normals.push(0, 0, -1);
2601
- }
2602
- // Minimum offset to prevent z-fighting between front and back faces
2793
+ // Each contour is a flat [x0,y0,x1,y1,...] array; side walls connect consecutive points
2794
+ // Contours are expected to be closed (last point repeats first), so segments = (nPoints - 1)
2795
+ const contourPoints = contour.length / 2;
2796
+ if (contourPoints >= 2)
2797
+ sideSegments += contourPoints - 1;
2798
+ }
2799
+ }
2800
+ const sideVertexCount = depth === 0 ? 0 : sideSegments * 4;
2801
+ const baseVertexCount = depth === 0 ? numPoints : numPoints * 2;
2802
+ const vertexCount = baseVertexCount + sideVertexCount;
2803
+ const vertices = new Float32Array(vertexCount * 3);
2804
+ const normals = new Float32Array(vertexCount * 3);
2805
+ const indexCount = depth === 0
2806
+ ? triangleIndices.length
2807
+ : triangleIndices.length * 2 + sideSegments * 6;
2808
+ const indices = new Uint32Array(indexCount);
2809
+ if (depth === 0) {
2810
+ // Flat faces only
2811
+ let vPos = 0;
2812
+ for (let i = 0; i < points.length; i += 2) {
2813
+ vertices[vPos] = points[i];
2814
+ vertices[vPos + 1] = points[i + 1];
2815
+ vertices[vPos + 2] = 0;
2816
+ normals[vPos] = 0;
2817
+ normals[vPos + 1] = 0;
2818
+ normals[vPos + 2] = -1;
2819
+ vPos += 3;
2820
+ }
2821
+ for (let i = 0; i < triangleIndices.length; i++) {
2822
+ indices[i] = triangleIndices[i];
2823
+ }
2824
+ return { vertices, normals, indices };
2825
+ }
2826
+ // Front/back faces
2603
2827
  const minBackOffset = unitsPerEm * 0.000025;
2604
2828
  const backZ = depth <= minBackOffset ? minBackOffset : depth;
2605
- for (let i = 0; i < points.length; i += 2) {
2606
- vertices.push(points[i], points[i + 1], backZ);
2607
- normals.push(0, 0, 1);
2608
- }
2609
- const numPoints = points.length / 2;
2829
+ // Fill front vertices/normals (0..numPoints-1)
2830
+ for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
2831
+ const base = vi * 3;
2832
+ vertices[base] = points[p];
2833
+ vertices[base + 1] = points[p + 1];
2834
+ vertices[base + 2] = 0;
2835
+ normals[base] = 0;
2836
+ normals[base + 1] = 0;
2837
+ normals[base + 2] = -1;
2838
+ }
2839
+ // Fill back vertices/normals (numPoints..2*numPoints-1)
2840
+ for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
2841
+ const base = (numPoints + vi) * 3;
2842
+ vertices[base] = points[p];
2843
+ vertices[base + 1] = points[p + 1];
2844
+ vertices[base + 2] = backZ;
2845
+ normals[base] = 0;
2846
+ normals[base + 1] = 0;
2847
+ normals[base + 2] = 1;
2848
+ }
2849
+ // Front indices
2610
2850
  for (let i = 0; i < triangleIndices.length; i++) {
2611
- indices.push(baseIndex + triangleIndices[i]);
2612
- }
2613
- for (let i = triangleIndices.length - 1; i >= 0; i--) {
2614
- indices.push(baseIndex + triangleIndices[i] + numPoints);
2851
+ indices[i] = triangleIndices[i];
2615
2852
  }
2616
- }
2617
- addSideWalls(points, vertices, normals, indices, depth) {
2618
- for (let i = 0; i < points.length - 2; i += 2) {
2619
- const p0x = points[i];
2620
- const p0y = points[i + 1];
2621
- const p1x = points[i + 2];
2622
- const p1y = points[i + 3];
2623
- const edge = new Vec2(p1x - p0x, p1y - p0y);
2624
- const normal = new Vec2(edge.y, -edge.x).normalize();
2625
- const wallBaseIndex = vertices.length / 3;
2626
- vertices.push(p0x, p0y, 0, p1x, p1y, 0, p0x, p0y, depth, p1x, p1y, depth);
2627
- normals.push(normal.x, normal.y, 0, normal.x, normal.y, 0, normal.x, normal.y, 0, normal.x, normal.y, 0);
2628
- indices.push(wallBaseIndex, wallBaseIndex + 1, wallBaseIndex + 2, wallBaseIndex + 1, wallBaseIndex + 3, wallBaseIndex + 2);
2853
+ // Back indices (reverse winding + offset)
2854
+ for (let i = 0; i < triangleIndices.length; i++) {
2855
+ indices[triangleIndices.length + i] =
2856
+ triangleIndices[triangleIndices.length - 1 - i] + numPoints;
2857
+ }
2858
+ // Side walls
2859
+ let nextVertex = numPoints * 2;
2860
+ let idxPos = triangleIndices.length * 2;
2861
+ for (const contour of geometry.contours) {
2862
+ for (let i = 0; i < contour.length - 2; i += 2) {
2863
+ const p0x = contour[i];
2864
+ const p0y = contour[i + 1];
2865
+ const p1x = contour[i + 2];
2866
+ const p1y = contour[i + 3];
2867
+ // Unit normal for the wall quad (per-edge)
2868
+ const ex = p1x - p0x;
2869
+ const ey = p1y - p0y;
2870
+ const lenSq = ex * ex + ey * ey;
2871
+ let nx = 0;
2872
+ let ny = 0;
2873
+ if (lenSq > 0) {
2874
+ const invLen = 1 / Math.sqrt(lenSq);
2875
+ nx = ey * invLen;
2876
+ ny = -ex * invLen;
2877
+ }
2878
+ const baseVertex = nextVertex;
2879
+ const base = baseVertex * 3;
2880
+ // 4 vertices (two at z=0, two at z=depth)
2881
+ vertices[base] = p0x;
2882
+ vertices[base + 1] = p0y;
2883
+ vertices[base + 2] = 0;
2884
+ vertices[base + 3] = p1x;
2885
+ vertices[base + 4] = p1y;
2886
+ vertices[base + 5] = 0;
2887
+ vertices[base + 6] = p0x;
2888
+ vertices[base + 7] = p0y;
2889
+ vertices[base + 8] = backZ;
2890
+ vertices[base + 9] = p1x;
2891
+ vertices[base + 10] = p1y;
2892
+ vertices[base + 11] = backZ;
2893
+ // Normals (same for all 4 wall vertices)
2894
+ normals[base] = nx;
2895
+ normals[base + 1] = ny;
2896
+ normals[base + 2] = 0;
2897
+ normals[base + 3] = nx;
2898
+ normals[base + 4] = ny;
2899
+ normals[base + 5] = 0;
2900
+ normals[base + 6] = nx;
2901
+ normals[base + 7] = ny;
2902
+ normals[base + 8] = 0;
2903
+ normals[base + 9] = nx;
2904
+ normals[base + 10] = ny;
2905
+ normals[base + 11] = 0;
2906
+ // Indices (two triangles)
2907
+ indices[idxPos++] = baseVertex;
2908
+ indices[idxPos++] = baseVertex + 1;
2909
+ indices[idxPos++] = baseVertex + 2;
2910
+ indices[idxPos++] = baseVertex + 1;
2911
+ indices[idxPos++] = baseVertex + 3;
2912
+ indices[idxPos++] = baseVertex + 2;
2913
+ nextVertex += 4;
2914
+ }
2629
2915
  }
2916
+ return { vertices, normals, indices };
2630
2917
  }
2631
2918
  }
2632
2919
 
@@ -2637,18 +2924,21 @@ class BoundaryClusterer {
2637
2924
  perfLogger.start('BoundaryClusterer.cluster', {
2638
2925
  glyphCount: glyphContoursList.length
2639
2926
  });
2640
- if (glyphContoursList.length === 0) {
2927
+ const n = glyphContoursList.length;
2928
+ if (n === 0) {
2641
2929
  perfLogger.end('BoundaryClusterer.cluster');
2642
2930
  return [];
2643
2931
  }
2932
+ if (n === 1) {
2933
+ perfLogger.end('BoundaryClusterer.cluster');
2934
+ return [[0]];
2935
+ }
2644
2936
  const result = this.clusterSweepLine(glyphContoursList, positions);
2645
2937
  perfLogger.end('BoundaryClusterer.cluster');
2646
2938
  return result;
2647
2939
  }
2648
2940
  clusterSweepLine(glyphContoursList, positions) {
2649
2941
  const n = glyphContoursList.length;
2650
- if (n <= 1)
2651
- return n === 0 ? [] : [[0]];
2652
2942
  const bounds = new Array(n);
2653
2943
  const events = new Array(2 * n);
2654
2944
  let eventIndex = 0;
@@ -2668,7 +2958,6 @@ class BoundaryClusterer {
2668
2958
  const py = find(y);
2669
2959
  if (px === py)
2670
2960
  return;
2671
- // Union by rank, attach smaller tree under larger tree
2672
2961
  if (rank[px] < rank[py]) {
2673
2962
  parent[px] = py;
2674
2963
  }
@@ -2684,8 +2973,6 @@ class BoundaryClusterer {
2684
2973
  for (const [, eventType, glyphIndex] of events) {
2685
2974
  if (eventType === 0) {
2686
2975
  const bounds1 = bounds[glyphIndex];
2687
- // Check y-overlap with all currently active glyphs
2688
- // (x-overlap is guaranteed by the sweep line)
2689
2976
  for (const activeIndex of active) {
2690
2977
  const bounds2 = bounds[activeIndex];
2691
2978
  if (bounds1.minY < bounds2.maxY + OVERLAP_EPSILON &&
@@ -2702,10 +2989,12 @@ class BoundaryClusterer {
2702
2989
  const clusters = new Map();
2703
2990
  for (let i = 0; i < n; i++) {
2704
2991
  const root = find(i);
2705
- if (!clusters.has(root)) {
2706
- clusters.set(root, []);
2992
+ let list = clusters.get(root);
2993
+ if (!list) {
2994
+ list = [];
2995
+ clusters.set(root, list);
2707
2996
  }
2708
- clusters.get(root).push(i);
2997
+ list.push(i);
2709
2998
  }
2710
2999
  return Array.from(clusters.values());
2711
3000
  }
@@ -2937,13 +3226,17 @@ class PathOptimizer {
2937
3226
  const prev = points[i - 1];
2938
3227
  const current = points[i];
2939
3228
  const next = points[i + 1];
2940
- const v1 = new Vec2(current.x - prev.x, current.y - prev.y);
2941
- const v2 = new Vec2(next.x - current.x, next.y - current.y);
2942
- const angle = Math.abs(v1.angle() - v2.angle());
2943
- const normalizedAngle = Math.min(angle, 2 * Math.PI - angle);
2944
- if (normalizedAngle > threshold ||
2945
- v1.length() < this.config.minSegmentLength ||
2946
- v2.length() < this.config.minSegmentLength) {
3229
+ const v1x = current.x - prev.x;
3230
+ const v1y = current.y - prev.y;
3231
+ const v2x = next.x - current.x;
3232
+ const v2y = next.y - current.y;
3233
+ const angle = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
3234
+ const v1LenSq = v1x * v1x + v1y * v1y;
3235
+ const v2LenSq = v2x * v2x + v2y * v2y;
3236
+ const minLenSq = this.config.minSegmentLength * this.config.minSegmentLength;
3237
+ if (angle > threshold ||
3238
+ v1LenSq < minLenSq ||
3239
+ v2LenSq < minLenSq) {
2947
3240
  result.push(current);
2948
3241
  }
2949
3242
  else {
@@ -3063,9 +3356,13 @@ class Polygonizer {
3063
3356
  const angleTolerance = this.curveFidelityConfig.angleTolerance ??
3064
3357
  DEFAULT_CURVE_FIDELITY.angleTolerance;
3065
3358
  if (angleTolerance > 0) {
3066
- let da = Math.abs(Math.atan2(y3 - y2, x3 - x2) - Math.atan2(y2 - y1, x2 - x1));
3067
- if (da >= Math.PI)
3068
- da = 2 * Math.PI - da;
3359
+ // Angle between segments (p1->p2) and (p2->p3).
3360
+ // Using atan2(cross, dot) avoids computing 2 separate atan2() values + wrap logic.
3361
+ const v1x = x2 - x1;
3362
+ const v1y = y2 - y1;
3363
+ const v2x = x3 - x2;
3364
+ const v2y = y3 - y2;
3365
+ const da = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
3069
3366
  if (da < angleTolerance) {
3070
3367
  this.addPoint(x2, y2, points);
3071
3368
  return;
@@ -3160,9 +3457,12 @@ class Polygonizer {
3160
3457
  const angleTolerance = this.curveFidelityConfig.angleTolerance ??
3161
3458
  DEFAULT_CURVE_FIDELITY.angleTolerance;
3162
3459
  if (angleTolerance > 0) {
3163
- let da1 = Math.abs(Math.atan2(y4 - y3, x4 - x3) - Math.atan2(y3 - y2, x3 - x2));
3164
- if (da1 >= Math.PI)
3165
- da1 = 2 * Math.PI - da1;
3460
+ // Angle between segments (p2->p3) and (p3->p4)
3461
+ const v1x = x3 - x2;
3462
+ const v1y = y3 - y2;
3463
+ const v2x = x4 - x3;
3464
+ const v2y = y4 - y3;
3465
+ const da1 = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
3166
3466
  if (da1 < angleTolerance) {
3167
3467
  this.addPoint(x2, y2, points);
3168
3468
  this.addPoint(x3, y3, points);
@@ -3182,9 +3482,12 @@ class Polygonizer {
3182
3482
  const angleTolerance = this.curveFidelityConfig.angleTolerance ??
3183
3483
  DEFAULT_CURVE_FIDELITY.angleTolerance;
3184
3484
  if (angleTolerance > 0) {
3185
- let da1 = Math.abs(Math.atan2(y3 - y2, x3 - x2) - Math.atan2(y2 - y1, x2 - x1));
3186
- if (da1 >= Math.PI)
3187
- da1 = 2 * Math.PI - da1;
3485
+ // Angle between segments (p1->p2) and (p2->p3)
3486
+ const v1x = x2 - x1;
3487
+ const v1y = y2 - y1;
3488
+ const v2x = x3 - x2;
3489
+ const v2y = y3 - y2;
3490
+ const da1 = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
3188
3491
  if (da1 < angleTolerance) {
3189
3492
  this.addPoint(x2, y2, points);
3190
3493
  this.addPoint(x3, y3, points);
@@ -3204,12 +3507,18 @@ class Polygonizer {
3204
3507
  const angleTolerance = this.curveFidelityConfig.angleTolerance ??
3205
3508
  DEFAULT_CURVE_FIDELITY.angleTolerance;
3206
3509
  if (angleTolerance > 0) {
3207
- let da1 = Math.abs(Math.atan2(y3 - y2, x3 - x2) - Math.atan2(y2 - y1, x2 - x1));
3208
- let da2 = Math.abs(Math.atan2(y4 - y3, x4 - x3) - Math.atan2(y3 - y2, x3 - x2));
3209
- if (da1 >= Math.PI)
3210
- da1 = 2 * Math.PI - da1;
3211
- if (da2 >= Math.PI)
3212
- da2 = 2 * Math.PI - da2;
3510
+ // da1: angle between (p1->p2) and (p2->p3)
3511
+ const a1x = x2 - x1;
3512
+ const a1y = y2 - y1;
3513
+ const a2x = x3 - x2;
3514
+ const a2y = y3 - y2;
3515
+ const da1 = Math.abs(Math.atan2(a1x * a2y - a1y * a2x, a1x * a2x + a1y * a2y));
3516
+ // da2: angle between (p2->p3) and (p3->p4)
3517
+ const b1x = a2x;
3518
+ const b1y = a2y;
3519
+ const b2x = x4 - x3;
3520
+ const b2y = y4 - y3;
3521
+ const da2 = Math.abs(Math.atan2(b1x * b2y - b1y * b2x, b1x * b2x + b1y * b2y));
3213
3522
  if (da1 + da2 < angleTolerance) {
3214
3523
  this.addPoint(x2, y2, points);
3215
3524
  this.addPoint(x3, y3, points);
@@ -3471,6 +3780,9 @@ class DrawCallbackHandler {
3471
3780
  this.collector.updatePosition(dx, dy);
3472
3781
  }
3473
3782
  }
3783
+ setCollector(collector) {
3784
+ this.collector = collector;
3785
+ }
3474
3786
  createDrawFuncs(font, collector) {
3475
3787
  if (!font || !font.module || !font.hb) {
3476
3788
  throw new Error('Invalid font object');
@@ -3547,229 +3859,73 @@ class DrawCallbackHandler {
3547
3859
  this.collector = undefined;
3548
3860
  }
3549
3861
  }
3862
+ // Share a single DrawCallbackHandler per HarfBuzz module to avoid leaking
3863
+ // wasm function pointers when users create many Text instances
3864
+ const sharedDrawCallbackHandlers = new WeakMap();
3865
+ function getSharedDrawCallbackHandler(font) {
3866
+ const key = font.module;
3867
+ const existing = sharedDrawCallbackHandlers.get(key);
3868
+ if (existing)
3869
+ return existing;
3870
+ const handler = new DrawCallbackHandler();
3871
+ sharedDrawCallbackHandlers.set(key, handler);
3872
+ return handler;
3873
+ }
3550
3874
 
3551
- // Generic LRU (Least Recently Used) cache with optional memory-based eviction
3552
- class LRUCache {
3553
- constructor(options = {}) {
3554
- this.cache = new Map();
3555
- this.head = null;
3556
- this.tail = null;
3557
- this.stats = {
3558
- hits: 0,
3559
- misses: 0,
3560
- evictions: 0,
3561
- size: 0,
3562
- memoryUsage: 0
3563
- };
3564
- this.options = {
3565
- maxEntries: options.maxEntries ?? Infinity,
3566
- maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
3567
- calculateSize: options.calculateSize ?? (() => 0),
3568
- onEvict: options.onEvict
3569
- };
3570
- }
3571
- get(key) {
3572
- const node = this.cache.get(key);
3573
- if (node) {
3574
- this.stats.hits++;
3575
- this.moveToHead(node);
3576
- return node.value;
3577
- }
3578
- else {
3579
- this.stats.misses++;
3580
- return undefined;
3581
- }
3582
- }
3583
- has(key) {
3584
- return this.cache.has(key);
3585
- }
3586
- set(key, value) {
3587
- // If key already exists, update it
3588
- const existingNode = this.cache.get(key);
3589
- if (existingNode) {
3590
- const oldSize = this.options.calculateSize(existingNode.value);
3591
- const newSize = this.options.calculateSize(value);
3592
- this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
3593
- existingNode.value = value;
3594
- this.moveToHead(existingNode);
3595
- return;
3596
- }
3597
- const size = this.options.calculateSize(value);
3598
- // Evict entries if we exceed limits
3599
- this.evictIfNeeded(size);
3600
- // Create new node
3601
- const node = {
3602
- key,
3603
- value,
3604
- prev: null,
3605
- next: null
3606
- };
3607
- this.cache.set(key, node);
3608
- this.addToHead(node);
3609
- this.stats.size = this.cache.size;
3610
- this.stats.memoryUsage += size;
3611
- }
3612
- delete(key) {
3613
- const node = this.cache.get(key);
3614
- if (!node)
3615
- return false;
3616
- const size = this.options.calculateSize(node.value);
3617
- this.removeNode(node);
3618
- this.cache.delete(key);
3619
- this.stats.size = this.cache.size;
3620
- this.stats.memoryUsage -= size;
3621
- if (this.options.onEvict) {
3622
- this.options.onEvict(key, node.value);
3623
- }
3624
- return true;
3625
- }
3626
- clear() {
3627
- if (this.options.onEvict) {
3628
- for (const [key, node] of this.cache) {
3629
- this.options.onEvict(key, node.value);
3630
- }
3631
- }
3632
- this.cache.clear();
3633
- this.head = null;
3634
- this.tail = null;
3635
- this.stats = {
3636
- hits: 0,
3637
- misses: 0,
3638
- evictions: 0,
3639
- size: 0,
3640
- memoryUsage: 0
3641
- };
3642
- }
3643
- getStats() {
3644
- const total = this.stats.hits + this.stats.misses;
3645
- const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
3646
- const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
3647
- return {
3648
- ...this.stats,
3649
- hitRate,
3650
- memoryUsageMB
3651
- };
3652
- }
3653
- keys() {
3654
- const keys = [];
3655
- let current = this.head;
3656
- while (current) {
3657
- keys.push(current.key);
3658
- current = current.next;
3659
- }
3660
- return keys;
3661
- }
3662
- get size() {
3663
- return this.cache.size;
3664
- }
3665
- evictIfNeeded(requiredSize) {
3666
- // Evict by entry count
3667
- while (this.cache.size >= this.options.maxEntries && this.tail) {
3668
- this.evictTail();
3669
- }
3670
- // Evict by memory usage
3671
- if (this.options.maxMemoryBytes < Infinity) {
3672
- while (this.tail &&
3673
- this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
3674
- this.evictTail();
3675
- }
3676
- }
3677
- }
3678
- evictTail() {
3679
- if (!this.tail)
3680
- return;
3681
- const nodeToRemove = this.tail;
3682
- const size = this.options.calculateSize(nodeToRemove.value);
3683
- this.removeTail();
3684
- this.cache.delete(nodeToRemove.key);
3685
- this.stats.size = this.cache.size;
3686
- this.stats.memoryUsage -= size;
3687
- this.stats.evictions++;
3688
- if (this.options.onEvict) {
3689
- this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
3690
- }
3691
- }
3692
- addToHead(node) {
3693
- if (!this.head) {
3694
- this.head = this.tail = node;
3695
- }
3696
- else {
3697
- node.next = this.head;
3698
- this.head.prev = node;
3699
- this.head = node;
3700
- }
3701
- }
3702
- removeNode(node) {
3703
- if (node.prev) {
3704
- node.prev.next = node.next;
3705
- }
3706
- else {
3707
- this.head = node.next;
3708
- }
3709
- if (node.next) {
3710
- node.next.prev = node.prev;
3711
- }
3712
- else {
3713
- this.tail = node.prev;
3714
- }
3715
- }
3716
- removeTail() {
3717
- if (this.tail) {
3718
- this.removeNode(this.tail);
3719
- }
3720
- }
3721
- moveToHead(node) {
3722
- if (node === this.head)
3723
- return;
3724
- this.removeNode(node);
3725
- this.addToHead(node);
3726
- }
3727
- }
3728
-
3729
- const CONTOUR_CACHE_MAX_ENTRIES = 1000;
3730
- const WORD_CACHE_MAX_ENTRIES = 1000;
3731
3875
  class GlyphGeometryBuilder {
3732
3876
  constructor(cache, loadedFont) {
3733
3877
  this.fontId = 'default';
3878
+ this.cacheKeyPrefix = 'default';
3734
3879
  this.cache = cache;
3735
3880
  this.loadedFont = loadedFont;
3736
3881
  this.tessellator = new Tessellator();
3737
3882
  this.extruder = new Extruder();
3738
3883
  this.clusterer = new BoundaryClusterer();
3739
3884
  this.collector = new GlyphContourCollector();
3740
- this.drawCallbacks = new DrawCallbackHandler();
3885
+ this.drawCallbacks = getSharedDrawCallbackHandler(this.loadedFont);
3741
3886
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3742
- this.contourCache = new LRUCache({
3743
- maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
3744
- calculateSize: (contours) => {
3745
- let size = 0;
3746
- for (const path of contours.paths) {
3747
- size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
3748
- }
3749
- return size + 64; // bounds overhead
3750
- }
3751
- });
3752
- this.wordCache = new LRUCache({
3753
- maxEntries: WORD_CACHE_MAX_ENTRIES,
3754
- calculateSize: (data) => {
3755
- let size = data.vertices.length * 4;
3756
- size += data.normals.length * 4;
3757
- size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
3758
- return size;
3759
- }
3760
- });
3887
+ this.contourCache = globalContourCache;
3888
+ this.wordCache = globalWordCache;
3889
+ this.clusteringCache = globalClusteringCache;
3761
3890
  }
3762
3891
  getOptimizationStats() {
3763
3892
  return this.collector.getOptimizationStats();
3764
3893
  }
3765
3894
  setCurveFidelityConfig(config) {
3895
+ this.curveFidelityConfig = config;
3766
3896
  this.collector.setCurveFidelityConfig(config);
3897
+ this.updateCacheKeyPrefix();
3767
3898
  }
3768
3899
  setGeometryOptimization(options) {
3900
+ this.geometryOptimizationOptions = options;
3769
3901
  this.collector.setGeometryOptimization(options);
3902
+ this.updateCacheKeyPrefix();
3770
3903
  }
3771
3904
  setFontId(fontId) {
3772
3905
  this.fontId = fontId;
3906
+ this.updateCacheKeyPrefix();
3907
+ }
3908
+ updateCacheKeyPrefix() {
3909
+ this.cacheKeyPrefix = `${this.fontId}__${this.getGeometryConfigSignature()}`;
3910
+ }
3911
+ getGeometryConfigSignature() {
3912
+ const distanceTolerance = this.curveFidelityConfig?.distanceTolerance ??
3913
+ DEFAULT_CURVE_FIDELITY.distanceTolerance;
3914
+ const angleTolerance = this.curveFidelityConfig?.angleTolerance ??
3915
+ DEFAULT_CURVE_FIDELITY.angleTolerance;
3916
+ const enabled = this.geometryOptimizationOptions?.enabled ??
3917
+ DEFAULT_OPTIMIZATION_CONFIG.enabled;
3918
+ const areaThreshold = this.geometryOptimizationOptions?.areaThreshold ??
3919
+ DEFAULT_OPTIMIZATION_CONFIG.areaThreshold;
3920
+ const colinearThreshold = this.geometryOptimizationOptions?.colinearThreshold ??
3921
+ DEFAULT_OPTIMIZATION_CONFIG.colinearThreshold;
3922
+ const minSegmentLength = this.geometryOptimizationOptions?.minSegmentLength ??
3923
+ DEFAULT_OPTIMIZATION_CONFIG.minSegmentLength;
3924
+ // Use fixed precision to keep cache keys stable and avoid float noise
3925
+ return [
3926
+ `cf:${distanceTolerance.toFixed(4)},${angleTolerance.toFixed(4)}`,
3927
+ `opt:${enabled ? 1 : 0},${areaThreshold.toFixed(4)},${colinearThreshold.toFixed(6)},${minSegmentLength.toFixed(4)}`
3928
+ ].join('|');
3773
3929
  }
3774
3930
  // Build instanced geometry from glyph contours
3775
3931
  buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
@@ -3779,9 +3935,58 @@ class GlyphGeometryBuilder {
3779
3935
  depth,
3780
3936
  removeOverlaps
3781
3937
  });
3782
- const vertices = [];
3783
- const normals = [];
3784
- const indices = [];
3938
+ // Growable typed arrays; slice to final size at end
3939
+ let vertexBuffer = new Float32Array(1024);
3940
+ let normalBuffer = new Float32Array(1024);
3941
+ let indexBuffer = new Uint32Array(1024);
3942
+ let vertexPos = 0; // float index (multiple of 3)
3943
+ let normalPos = 0; // float index (multiple of 3)
3944
+ let indexPos = 0; // index count
3945
+ const ensureFloatCapacity = (buffer, needed) => {
3946
+ if (needed <= buffer.length)
3947
+ return buffer;
3948
+ let nextSize = buffer.length;
3949
+ while (nextSize < needed)
3950
+ nextSize *= 2;
3951
+ const next = new Float32Array(nextSize);
3952
+ next.set(buffer);
3953
+ return next;
3954
+ };
3955
+ const ensureIndexCapacity = (buffer, needed) => {
3956
+ if (needed <= buffer.length)
3957
+ return buffer;
3958
+ let nextSize = buffer.length;
3959
+ while (nextSize < needed)
3960
+ nextSize *= 2;
3961
+ const next = new Uint32Array(nextSize);
3962
+ next.set(buffer);
3963
+ return next;
3964
+ };
3965
+ const appendGeometryToBuffers = (data, position, vertexOffset) => {
3966
+ const v = data.vertices;
3967
+ const n = data.normals;
3968
+ const idx = data.indices;
3969
+ // Grow buffers as needed
3970
+ vertexBuffer = ensureFloatCapacity(vertexBuffer, vertexPos + v.length);
3971
+ normalBuffer = ensureFloatCapacity(normalBuffer, normalPos + n.length);
3972
+ indexBuffer = ensureIndexCapacity(indexBuffer, indexPos + idx.length);
3973
+ // Vertices: translate by position
3974
+ const px = position.x;
3975
+ const py = position.y;
3976
+ const pz = position.z;
3977
+ for (let j = 0; j < v.length; j += 3) {
3978
+ vertexBuffer[vertexPos++] = v[j] + px;
3979
+ vertexBuffer[vertexPos++] = v[j + 1] + py;
3980
+ vertexBuffer[vertexPos++] = v[j + 2] + pz;
3981
+ }
3982
+ // Normals: straight copy
3983
+ normalBuffer.set(n, normalPos);
3984
+ normalPos += n.length;
3985
+ // Indices: copy with vertex offset
3986
+ for (let j = 0; j < idx.length; j++) {
3987
+ indexBuffer[indexPos++] = idx[j] + vertexOffset;
3988
+ }
3989
+ };
3785
3990
  const glyphInfos = [];
3786
3991
  const planeBounds = {
3787
3992
  min: { x: Infinity, y: Infinity, z: 0 },
@@ -3790,83 +3995,126 @@ class GlyphGeometryBuilder {
3790
3995
  for (let lineIndex = 0; lineIndex < clustersByLine.length; lineIndex++) {
3791
3996
  const line = clustersByLine[lineIndex];
3792
3997
  for (const cluster of line) {
3793
- // Step 1: Get contours for all glyphs in the cluster
3794
3998
  const clusterGlyphContours = [];
3795
3999
  for (const glyph of cluster.glyphs) {
3796
4000
  clusterGlyphContours.push(this.getContoursForGlyph(glyph.g));
3797
4001
  }
3798
- // Step 2: Check for overlaps within the cluster
3799
- const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x, g.y, 0));
3800
- const boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
3801
- const clusterHasColoredGlyphs = coloredTextIndices &&
3802
- cluster.glyphs.some((g) => coloredTextIndices.has(g.absoluteTextIndex));
3803
- // Force glyph-level caching if:
3804
- // - separateGlyphs flag is set (for shader attributes), OR
3805
- // - cluster contains selectively colored text (needs separate vertex ranges per glyph)
3806
- const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
3807
- const hasOverlaps = forceSeparate
3808
- ? false
3809
- : boundaryGroups.some((group) => group.length > 1);
3810
- if (hasOverlaps) {
3811
- // Cluster-level caching
3812
- const clusterKey = `${this.fontId}_${cluster.text}_${depth}_${removeOverlaps}`;
3813
- let cachedCluster = this.wordCache.get(clusterKey);
3814
- if (!cachedCluster) {
3815
- const clusterPaths = [];
3816
- for (let i = 0; i < clusterGlyphContours.length; i++) {
3817
- const glyphContours = clusterGlyphContours[i];
3818
- const glyph = cluster.glyphs[i];
3819
- for (const path of glyphContours.paths) {
3820
- clusterPaths.push({
3821
- ...path,
3822
- points: path.points.map((p) => new Vec2(p.x + (glyph.x ?? 0), p.y + (glyph.y ?? 0)))
3823
- });
4002
+ let boundaryGroups;
4003
+ if (cluster.glyphs.length <= 1) {
4004
+ boundaryGroups = [[0]];
4005
+ }
4006
+ else {
4007
+ // Check clustering cache (same text + glyph IDs = same overlap groups)
4008
+ // Key must be font-specific; glyph ids/bounds differ between fonts
4009
+ const cacheKey = `${this.cacheKeyPrefix}_${cluster.text}`;
4010
+ const cached = this.clusteringCache.get(cacheKey);
4011
+ let isValid = false;
4012
+ if (cached && cached.glyphIds.length === cluster.glyphs.length) {
4013
+ isValid = true;
4014
+ for (let i = 0; i < cluster.glyphs.length; i++) {
4015
+ if (cached.glyphIds[i] !== cluster.glyphs[i].g) {
4016
+ isValid = false;
4017
+ break;
3824
4018
  }
3825
4019
  }
3826
- cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
3827
- this.wordCache.set(clusterKey, cachedCluster);
3828
4020
  }
3829
- const vertexOffset = vertices.length / 3;
3830
- this.appendGeometry(vertices, normals, indices, cachedCluster, cluster.position, vertexOffset);
3831
- const clusterVertexCount = cachedCluster.vertices.length / 3;
3832
- for (let i = 0; i < cluster.glyphs.length; i++) {
3833
- const glyph = cluster.glyphs[i];
3834
- const glyphContours = clusterGlyphContours[i];
3835
- const absoluteGlyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
3836
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, clusterVertexCount, absoluteGlyphPosition, glyphContours, depth);
3837
- glyphInfos.push(glyphInfo);
3838
- this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
4021
+ if (isValid && cached) {
4022
+ boundaryGroups = cached.groups;
4023
+ }
4024
+ else {
4025
+ const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x ?? 0, g.y ?? 0, 0));
4026
+ boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
4027
+ this.clusteringCache.set(cacheKey, {
4028
+ glyphIds: cluster.glyphs.map(g => g.g),
4029
+ groups: boundaryGroups
4030
+ });
3839
4031
  }
3840
4032
  }
3841
- else {
3842
- // Glyph-level caching
3843
- for (let i = 0; i < cluster.glyphs.length; i++) {
3844
- const glyph = cluster.glyphs[i];
3845
- const glyphContours = clusterGlyphContours[i];
3846
- const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
3847
- // Skip glyphs with no paths (spaces, zero-width characters, etc.)
3848
- if (glyphContours.paths.length === 0) {
3849
- const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
4033
+ const clusterHasColoredGlyphs = coloredTextIndices &&
4034
+ cluster.glyphs.some((g) => coloredTextIndices.has(g.absoluteTextIndex));
4035
+ // Use glyph-level caching when separateGlyphs is set or when cluster contains colored text
4036
+ const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
4037
+ // Iterate over the geometric groups identified by BoundaryClusterer
4038
+ // logical groups (words) are now split into geometric sub-groups (e.g. "aa", "XX", "bb")
4039
+ for (const groupIndices of boundaryGroups) {
4040
+ const isOverlappingGroup = groupIndices.length > 1;
4041
+ const shouldCluster = isOverlappingGroup && !forceSeparate;
4042
+ if (shouldCluster) {
4043
+ // Cluster-level caching for this specific group of overlapping glyphs
4044
+ const subClusterGlyphs = groupIndices.map((i) => cluster.glyphs[i]);
4045
+ const clusterKey = this.getClusterKey(subClusterGlyphs, depth, removeOverlaps);
4046
+ let cachedCluster = this.wordCache.get(clusterKey);
4047
+ if (!cachedCluster) {
4048
+ const clusterPaths = [];
4049
+ const refX = subClusterGlyphs[0].x ?? 0;
4050
+ const refY = subClusterGlyphs[0].y ?? 0;
4051
+ for (let i = 0; i < groupIndices.length; i++) {
4052
+ const originalIndex = groupIndices[i];
4053
+ const glyphContours = clusterGlyphContours[originalIndex];
4054
+ const glyph = cluster.glyphs[originalIndex];
4055
+ const relX = (glyph.x ?? 0) - refX;
4056
+ const relY = (glyph.y ?? 0) - refY;
4057
+ for (const path of glyphContours.paths) {
4058
+ clusterPaths.push({
4059
+ ...path,
4060
+ points: path.points.map((p) => new Vec2(p.x + relX, p.y + relY))
4061
+ });
4062
+ }
4063
+ }
4064
+ cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
4065
+ this.wordCache.set(clusterKey, cachedCluster);
4066
+ }
4067
+ // Calculate the absolute position of this sub-cluster based on its first glyph
4068
+ // (since the cached geometry is relative to that first glyph)
4069
+ const firstGlyphInGroup = subClusterGlyphs[0];
4070
+ const groupPosition = new Vec3(cluster.position.x + (firstGlyphInGroup.x ?? 0), cluster.position.y + (firstGlyphInGroup.y ?? 0), cluster.position.z);
4071
+ const vertexOffset = vertexPos / 3;
4072
+ appendGeometryToBuffers(cachedCluster, groupPosition, vertexOffset);
4073
+ const clusterVertexCount = cachedCluster.vertices.length / 3;
4074
+ for (let i = 0; i < groupIndices.length; i++) {
4075
+ const originalIndex = groupIndices[i];
4076
+ const glyph = cluster.glyphs[originalIndex];
4077
+ const glyphContours = clusterGlyphContours[originalIndex];
4078
+ const absoluteGlyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
4079
+ const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, clusterVertexCount, absoluteGlyphPosition, glyphContours, depth);
3850
4080
  glyphInfos.push(glyphInfo);
3851
- continue;
4081
+ this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
3852
4082
  }
3853
- let cachedGlyph = this.cache.get(this.fontId, glyph.g, depth, removeOverlaps);
3854
- if (!cachedGlyph) {
3855
- cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
3856
- this.cache.set(this.fontId, glyph.g, depth, removeOverlaps, cachedGlyph);
4083
+ }
4084
+ else {
4085
+ // Glyph-level caching (standard path for isolated glyphs or when forced separate)
4086
+ for (const i of groupIndices) {
4087
+ const glyph = cluster.glyphs[i];
4088
+ const glyphContours = clusterGlyphContours[i];
4089
+ const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
4090
+ // Skip glyphs with no paths (spaces, zero-width characters, etc.)
4091
+ if (glyphContours.paths.length === 0) {
4092
+ const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
4093
+ glyphInfos.push(glyphInfo);
4094
+ continue;
4095
+ }
4096
+ let cachedGlyph = this.cache.get(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps));
4097
+ if (!cachedGlyph) {
4098
+ cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
4099
+ this.cache.set(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps), cachedGlyph);
4100
+ }
4101
+ else {
4102
+ cachedGlyph.useCount++;
4103
+ }
4104
+ const vertexOffset = vertexPos / 3;
4105
+ appendGeometryToBuffers(cachedGlyph, glyphPosition, vertexOffset);
4106
+ const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
4107
+ glyphInfos.push(glyphInfo);
4108
+ this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
3857
4109
  }
3858
- const vertexOffset = vertices.length / 3;
3859
- this.appendGeometry(vertices, normals, indices, cachedGlyph, glyphPosition, vertexOffset);
3860
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
3861
- glyphInfos.push(glyphInfo);
3862
- this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
3863
4110
  }
3864
4111
  }
3865
4112
  }
3866
4113
  }
3867
- const vertexArray = new Float32Array(vertices);
3868
- const normalArray = new Float32Array(normals);
3869
- const indexArray = new Uint32Array(indices);
4114
+ // Slice to used lengths (avoid returning oversized buffers)
4115
+ const vertexArray = vertexBuffer.slice(0, vertexPos);
4116
+ const normalArray = normalBuffer.slice(0, normalPos);
4117
+ const indexArray = indexBuffer.slice(0, indexPos);
3870
4118
  perfLogger.end('GlyphGeometryBuilder.buildInstancedGeometry');
3871
4119
  return {
3872
4120
  vertices: vertexArray,
@@ -3876,16 +4124,20 @@ class GlyphGeometryBuilder {
3876
4124
  planeBounds
3877
4125
  };
3878
4126
  }
3879
- appendGeometry(vertices, normals, indices, data, position, offset) {
3880
- for (let j = 0; j < data.vertices.length; j += 3) {
3881
- vertices.push(data.vertices[j] + position.x, data.vertices[j + 1] + position.y, data.vertices[j + 2] + position.z);
3882
- }
3883
- for (let j = 0; j < data.normals.length; j++) {
3884
- normals.push(data.normals[j]);
3885
- }
3886
- for (let j = 0; j < data.indices.length; j++) {
3887
- indices.push(data.indices[j] + offset);
3888
- }
4127
+ getClusterKey(glyphs, depth, removeOverlaps) {
4128
+ if (glyphs.length === 0)
4129
+ return '';
4130
+ // Normalize positions relative to the first glyph in the cluster
4131
+ const refX = glyphs[0].x ?? 0;
4132
+ const refY = glyphs[0].y ?? 0;
4133
+ const parts = glyphs.map((g) => {
4134
+ const relX = (g.x ?? 0) - refX;
4135
+ const relY = (g.y ?? 0) - refY;
4136
+ return `${g.g}:${relX},${relY}`;
4137
+ });
4138
+ const ids = parts.join('|');
4139
+ const roundedDepth = Math.round(depth * 1000) / 1000;
4140
+ return `${this.cacheKeyPrefix}_${ids}_${roundedDepth}_${removeOverlaps}`;
3889
4141
  }
3890
4142
  createGlyphInfo(glyph, vertexStart, vertexCount, position, contours, depth) {
3891
4143
  return {
@@ -3908,10 +4160,13 @@ class GlyphGeometryBuilder {
3908
4160
  };
3909
4161
  }
3910
4162
  getContoursForGlyph(glyphId) {
3911
- const cached = this.contourCache.get(glyphId);
4163
+ const key = `${this.cacheKeyPrefix}_${glyphId}`;
4164
+ const cached = this.contourCache.get(key);
3912
4165
  if (cached) {
3913
4166
  return cached;
3914
4167
  }
4168
+ // Rebind collector before draw operation
4169
+ this.drawCallbacks.setCollector(this.collector);
3915
4170
  this.collector.reset();
3916
4171
  this.collector.beginGlyph(glyphId, 0);
3917
4172
  this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
@@ -3925,7 +4180,7 @@ class GlyphGeometryBuilder {
3925
4180
  max: { x: 0, y: 0 }
3926
4181
  }
3927
4182
  };
3928
- this.contourCache.set(glyphId, contours);
4183
+ this.contourCache.set(key, contours);
3929
4184
  return contours;
3930
4185
  }
3931
4186
  tessellateGlyphCluster(paths, depth, isCFF) {
@@ -3962,13 +4217,11 @@ class GlyphGeometryBuilder {
3962
4217
  }
3963
4218
  const boundsMin = new Vec3(minX, minY, minZ);
3964
4219
  const boundsMax = new Vec3(maxX, maxY, maxZ);
3965
- const vertexCount = extrudedResult.vertices.length / 3;
3966
- const IndexArray = vertexCount < 65536 ? Uint16Array : Uint32Array;
3967
4220
  return {
3968
4221
  geometry: processedGeometry,
3969
- vertices: new Float32Array(extrudedResult.vertices),
3970
- normals: new Float32Array(extrudedResult.normals),
3971
- indices: new IndexArray(extrudedResult.indices),
4222
+ vertices: extrudedResult.vertices,
4223
+ normals: extrudedResult.normals,
4224
+ indices: extrudedResult.indices,
3972
4225
  bounds: { min: boundsMin, max: boundsMax },
3973
4226
  useCount: 1
3974
4227
  };
@@ -3984,15 +4237,22 @@ class GlyphGeometryBuilder {
3984
4237
  return this.extrudeAndPackage(processedGeometry, depth);
3985
4238
  }
3986
4239
  updatePlaneBounds(glyphBounds, planeBounds) {
3987
- const planeBox = new Box3(new Vec3(planeBounds.min.x, planeBounds.min.y, planeBounds.min.z), new Vec3(planeBounds.max.x, planeBounds.max.y, planeBounds.max.z));
3988
- const glyphBox = new Box3(new Vec3(glyphBounds.min.x, glyphBounds.min.y, glyphBounds.min.z), new Vec3(glyphBounds.max.x, glyphBounds.max.y, glyphBounds.max.z));
3989
- planeBox.union(glyphBox);
3990
- planeBounds.min.x = planeBox.min.x;
3991
- planeBounds.min.y = planeBox.min.y;
3992
- planeBounds.min.z = planeBox.min.z;
3993
- planeBounds.max.x = planeBox.max.x;
3994
- planeBounds.max.y = planeBox.max.y;
3995
- planeBounds.max.z = planeBox.max.z;
4240
+ const pMin = planeBounds.min;
4241
+ const pMax = planeBounds.max;
4242
+ const gMin = glyphBounds.min;
4243
+ const gMax = glyphBounds.max;
4244
+ if (gMin.x < pMin.x)
4245
+ pMin.x = gMin.x;
4246
+ if (gMin.y < pMin.y)
4247
+ pMin.y = gMin.y;
4248
+ if (gMin.z < pMin.z)
4249
+ pMin.z = gMin.z;
4250
+ if (gMax.x > pMax.x)
4251
+ pMax.x = gMax.x;
4252
+ if (gMax.y > pMax.y)
4253
+ pMax.y = gMax.y;
4254
+ if (gMax.z > pMax.z)
4255
+ pMax.z = gMax.z;
3996
4256
  }
3997
4257
  getCacheStats() {
3998
4258
  return this.cache.getStats();
@@ -4000,6 +4260,8 @@ class GlyphGeometryBuilder {
4000
4260
  clearCache() {
4001
4261
  this.cache.clear();
4002
4262
  this.wordCache.clear();
4263
+ this.clusteringCache.clear();
4264
+ this.contourCache.clear();
4003
4265
  }
4004
4266
  }
4005
4267
 
@@ -4014,12 +4276,17 @@ class TextShaper {
4014
4276
  perfLogger.start('TextShaper.shapeLines', {
4015
4277
  lineCount: lineInfos.length
4016
4278
  });
4017
- const clustersByLine = [];
4018
- lineInfos.forEach((lineInfo, lineIndex) => {
4019
- const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction);
4020
- clustersByLine.push(clusters);
4021
- });
4022
- return clustersByLine;
4279
+ try {
4280
+ const clustersByLine = [];
4281
+ lineInfos.forEach((lineInfo, lineIndex) => {
4282
+ const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction);
4283
+ clustersByLine.push(clusters);
4284
+ });
4285
+ return clustersByLine;
4286
+ }
4287
+ finally {
4288
+ perfLogger.end('TextShaper.shapeLines');
4289
+ }
4023
4290
  }
4024
4291
  shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction) {
4025
4292
  const buffer = this.loadedFont.hb.createBuffer();
@@ -4167,162 +4434,6 @@ class TextShaper {
4167
4434
  }
4168
4435
  }
4169
4436
 
4170
- const DEFAULT_CACHE_SIZE_MB = 250;
4171
- class GlyphCache {
4172
- constructor(maxCacheSizeMB) {
4173
- this.cache = new Map();
4174
- this.head = null;
4175
- this.tail = null;
4176
- this.stats = {
4177
- hits: 0,
4178
- misses: 0,
4179
- totalGlyphs: 0,
4180
- uniqueGlyphs: 0,
4181
- cacheSize: 0,
4182
- saved: 0,
4183
- memoryUsage: 0
4184
- };
4185
- if (maxCacheSizeMB) {
4186
- this.maxCacheSize = maxCacheSizeMB * 1024 * 1024;
4187
- }
4188
- }
4189
- getCacheKey(fontId, glyphId, depth, removeOverlaps) {
4190
- // Round depth to avoid floating point precision issues
4191
- const roundedDepth = Math.round(depth * 1000) / 1000;
4192
- return `${fontId}_${glyphId}_${roundedDepth}_${removeOverlaps}`;
4193
- }
4194
- has(fontId, glyphId, depth, removeOverlaps) {
4195
- const key = this.getCacheKey(fontId, glyphId, depth, removeOverlaps);
4196
- return this.cache.has(key);
4197
- }
4198
- get(fontId, glyphId, depth, removeOverlaps) {
4199
- const key = this.getCacheKey(fontId, glyphId, depth, removeOverlaps);
4200
- const node = this.cache.get(key);
4201
- if (node) {
4202
- this.stats.hits++;
4203
- this.stats.saved++;
4204
- node.data.useCount++;
4205
- // Move to head (most recently used)
4206
- this.moveToHead(node);
4207
- this.stats.totalGlyphs++;
4208
- return node.data;
4209
- }
4210
- else {
4211
- this.stats.misses++;
4212
- this.stats.totalGlyphs++;
4213
- return undefined;
4214
- }
4215
- }
4216
- set(fontId, glyphId, depth, removeOverlaps, glyph) {
4217
- const key = this.getCacheKey(fontId, glyphId, depth, removeOverlaps);
4218
- const memoryUsage = this.calculateMemoryUsage(glyph);
4219
- // LRU eviction when memory limit exceeded
4220
- if (this.maxCacheSize &&
4221
- this.stats.memoryUsage + memoryUsage > this.maxCacheSize) {
4222
- this.evictLRU(memoryUsage);
4223
- }
4224
- const node = {
4225
- key,
4226
- data: glyph,
4227
- prev: null,
4228
- next: null
4229
- };
4230
- this.cache.set(key, node);
4231
- this.addToHead(node);
4232
- this.stats.uniqueGlyphs = this.cache.size;
4233
- this.stats.cacheSize++;
4234
- this.stats.memoryUsage += memoryUsage;
4235
- }
4236
- calculateMemoryUsage(glyph) {
4237
- let size = 0;
4238
- // 3 floats per vertex * 4 bytes per float
4239
- size += glyph.vertices.length * 4;
4240
- // 3 floats per normal * 4 bytes per float
4241
- size += glyph.normals.length * 4;
4242
- // Indices (Uint16Array or Uint32Array)
4243
- size += glyph.indices.length * glyph.indices.BYTES_PER_ELEMENT;
4244
- // Bounds (2 Vec3s = 6 floats * 4 bytes)
4245
- size += 24;
4246
- // Object overhead
4247
- size += 256;
4248
- return size;
4249
- }
4250
- // LRU eviction
4251
- evictLRU(requiredSpace) {
4252
- let freedSpace = 0;
4253
- while (this.tail && freedSpace < requiredSpace) {
4254
- const memoryUsage = this.calculateMemoryUsage(this.tail.data);
4255
- const nodeToRemove = this.tail;
4256
- this.removeTail();
4257
- this.cache.delete(nodeToRemove.key);
4258
- this.stats.memoryUsage -= memoryUsage;
4259
- this.stats.cacheSize--;
4260
- freedSpace += memoryUsage;
4261
- }
4262
- }
4263
- addToHead(node) {
4264
- if (!this.head) {
4265
- this.head = this.tail = node;
4266
- }
4267
- else {
4268
- node.next = this.head;
4269
- this.head.prev = node;
4270
- this.head = node;
4271
- }
4272
- }
4273
- removeNode(node) {
4274
- if (node.prev) {
4275
- node.prev.next = node.next;
4276
- }
4277
- else {
4278
- this.head = node.next;
4279
- }
4280
- if (node.next) {
4281
- node.next.prev = node.prev;
4282
- }
4283
- else {
4284
- this.tail = node.prev;
4285
- }
4286
- }
4287
- removeTail() {
4288
- if (this.tail) {
4289
- this.removeNode(this.tail);
4290
- }
4291
- }
4292
- moveToHead(node) {
4293
- if (node === this.head)
4294
- return;
4295
- this.removeNode(node);
4296
- this.addToHead(node);
4297
- }
4298
- clear() {
4299
- this.cache.clear();
4300
- this.head = null;
4301
- this.tail = null;
4302
- this.stats = {
4303
- hits: 0,
4304
- misses: 0,
4305
- totalGlyphs: 0,
4306
- uniqueGlyphs: 0,
4307
- cacheSize: 0,
4308
- saved: 0,
4309
- memoryUsage: 0
4310
- };
4311
- }
4312
- getStats() {
4313
- const hitRate = this.stats.totalGlyphs > 0
4314
- ? (this.stats.hits / this.stats.totalGlyphs) * 100
4315
- : 0;
4316
- this.stats.uniqueGlyphs = this.cache.size;
4317
- return {
4318
- ...this.stats,
4319
- hitRate,
4320
- memoryUsageMB: this.stats.memoryUsage / (1024 * 1024)
4321
- };
4322
- }
4323
- }
4324
- const globalGlyphCache = new GlyphCache(DEFAULT_CACHE_SIZE_MB);
4325
-
4326
4437
  var hb = {exports: {}};
4327
4438
 
4328
4439
  var fs = {}; const readFileSync = () => { throw new Error('fs not available in browser'); };
@@ -5028,14 +5139,25 @@ class TextRangeQuery {
5028
5139
  max: { x: 0, y: 0, z: 0 }
5029
5140
  };
5030
5141
  }
5031
- const box = new Box3();
5142
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
5143
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
5032
5144
  for (const glyph of glyphs) {
5033
- const glyphBox = new Box3(new Vec3(glyph.bounds.min.x, glyph.bounds.min.y, glyph.bounds.min.z), new Vec3(glyph.bounds.max.x, glyph.bounds.max.y, glyph.bounds.max.z));
5034
- box.union(glyphBox);
5145
+ if (glyph.bounds.min.x < minX)
5146
+ minX = glyph.bounds.min.x;
5147
+ if (glyph.bounds.min.y < minY)
5148
+ minY = glyph.bounds.min.y;
5149
+ if (glyph.bounds.min.z < minZ)
5150
+ minZ = glyph.bounds.min.z;
5151
+ if (glyph.bounds.max.x > maxX)
5152
+ maxX = glyph.bounds.max.x;
5153
+ if (glyph.bounds.max.y > maxY)
5154
+ maxY = glyph.bounds.max.y;
5155
+ if (glyph.bounds.max.z > maxZ)
5156
+ maxZ = glyph.bounds.max.z;
5035
5157
  }
5036
5158
  return {
5037
- min: { x: box.min.x, y: box.min.y, z: box.min.z },
5038
- max: { x: box.max.x, y: box.max.y, z: box.max.z }
5159
+ min: { x: minX, y: minY, z: minZ },
5160
+ max: { x: maxX, y: maxY, z: maxZ }
5039
5161
  };
5040
5162
  }
5041
5163
  }
@@ -5045,8 +5167,16 @@ class Text {
5045
5167
  static { this.patternCache = new Map(); }
5046
5168
  static { this.hbInitPromise = null; }
5047
5169
  static { this.fontCache = new Map(); }
5170
+ static { this.fontCacheMemoryBytes = 0; }
5171
+ static { this.maxFontCacheMemoryBytes = Infinity; }
5048
5172
  static { this.fontIdCounter = 0; }
5049
- constructor(config) {
5173
+ // Stringify with sorted keys for cache stability
5174
+ static stableStringify(obj) {
5175
+ const keys = Object.keys(obj).sort();
5176
+ const pairs = keys.map(k => `${k}:${obj[k]}`);
5177
+ return pairs.join(',');
5178
+ }
5179
+ constructor() {
5050
5180
  this.currentFontId = '';
5051
5181
  if (!Text.hbInitPromise) {
5052
5182
  Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
@@ -5077,7 +5207,7 @@ class Text {
5077
5207
  Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
5078
5208
  }
5079
5209
  const loadedFont = await Text.resolveFont(options);
5080
- const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
5210
+ const text = new Text();
5081
5211
  text.setLoadedFont(loadedFont);
5082
5212
  // Initial creation
5083
5213
  const { font, maxCacheSizeMB, ...geometryOptions } = options;
@@ -5129,10 +5259,10 @@ class Text {
5129
5259
  : `buffer-${Text.generateFontContentHash(options.font)}`;
5130
5260
  let fontKey = baseFontKey;
5131
5261
  if (options.fontVariations) {
5132
- fontKey += `_var_${JSON.stringify(options.fontVariations)}`;
5262
+ fontKey += `_var_${Text.stableStringify(options.fontVariations)}`;
5133
5263
  }
5134
5264
  if (options.fontFeatures) {
5135
- fontKey += `_feat_${JSON.stringify(options.fontFeatures)}`;
5265
+ fontKey += `_feat_${Text.stableStringify(options.fontFeatures)}`;
5136
5266
  }
5137
5267
  let loadedFont = Text.fontCache.get(fontKey);
5138
5268
  if (!loadedFont) {
@@ -5145,17 +5275,53 @@ class Text {
5145
5275
  await tempText.loadFont(font, fontVariations, fontFeatures);
5146
5276
  const loadedFont = tempText.getLoadedFont();
5147
5277
  Text.fontCache.set(fontKey, loadedFont);
5278
+ Text.trackFontCacheAdd(loadedFont);
5279
+ Text.enforceFontCacheMemoryLimit();
5148
5280
  return loadedFont;
5149
5281
  }
5282
+ static trackFontCacheAdd(loadedFont) {
5283
+ const size = loadedFont._buffer?.byteLength ?? 0;
5284
+ Text.fontCacheMemoryBytes += size;
5285
+ }
5286
+ static trackFontCacheRemove(fontKey) {
5287
+ const font = Text.fontCache.get(fontKey);
5288
+ if (!font)
5289
+ return;
5290
+ const size = font._buffer?.byteLength ?? 0;
5291
+ Text.fontCacheMemoryBytes -= size;
5292
+ if (Text.fontCacheMemoryBytes < 0)
5293
+ Text.fontCacheMemoryBytes = 0;
5294
+ }
5295
+ static enforceFontCacheMemoryLimit() {
5296
+ if (Text.maxFontCacheMemoryBytes === Infinity)
5297
+ return;
5298
+ while (Text.fontCacheMemoryBytes > Text.maxFontCacheMemoryBytes &&
5299
+ Text.fontCache.size > 0) {
5300
+ const firstKey = Text.fontCache.keys().next().value;
5301
+ if (firstKey === undefined)
5302
+ break;
5303
+ Text.trackFontCacheRemove(firstKey);
5304
+ Text.fontCache.delete(firstKey);
5305
+ }
5306
+ }
5150
5307
  static generateFontContentHash(buffer) {
5151
5308
  if (buffer) {
5152
- // Hash of first and last bytes plus length for uniqueness
5309
+ // FNV-1a hash sampling 32 points
5153
5310
  const view = new Uint8Array(buffer);
5154
- return `${view[0]}_${view[Math.floor(view.length / 2)]}_${view[view.length - 1]}_${view.length}`;
5311
+ let hash = 2166136261;
5312
+ const samplePoints = Math.min(32, view.length);
5313
+ const step = Math.floor(view.length / samplePoints);
5314
+ for (let i = 0; i < samplePoints; i++) {
5315
+ const index = i * step;
5316
+ hash ^= view[index];
5317
+ hash = Math.imul(hash, 16777619);
5318
+ }
5319
+ hash ^= view.length;
5320
+ hash = Math.imul(hash, 16777619);
5321
+ return (hash >>> 0).toString(36);
5155
5322
  }
5156
5323
  else {
5157
- // Fallback to counter if no buffer available
5158
- return `${++Text.fontIdCounter}`;
5324
+ return `c${++Text.fontIdCounter}`;
5159
5325
  }
5160
5326
  }
5161
5327
  setLoadedFont(loadedFont) {
@@ -5163,10 +5329,10 @@ class Text {
5163
5329
  const contentHash = Text.generateFontContentHash(loadedFont._buffer);
5164
5330
  this.currentFontId = `font_${contentHash}`;
5165
5331
  if (loadedFont.fontVariations) {
5166
- this.currentFontId += `_var_${JSON.stringify(loadedFont.fontVariations)}`;
5332
+ this.currentFontId += `_var_${Text.stableStringify(loadedFont.fontVariations)}`;
5167
5333
  }
5168
5334
  if (loadedFont.fontFeatures) {
5169
- this.currentFontId += `_feat_${JSON.stringify(loadedFont.fontFeatures)}`;
5335
+ this.currentFontId += `_feat_${Text.stableStringify(loadedFont.fontFeatures)}`;
5170
5336
  }
5171
5337
  }
5172
5338
  async loadFont(fontSrc, fontVariations, fontFeatures) {
@@ -5196,10 +5362,10 @@ class Text {
5196
5362
  const contentHash = Text.generateFontContentHash(fontBuffer);
5197
5363
  this.currentFontId = `font_${contentHash}`;
5198
5364
  if (fontVariations) {
5199
- this.currentFontId += `_var_${JSON.stringify(fontVariations)}`;
5365
+ this.currentFontId += `_var_${Text.stableStringify(fontVariations)}`;
5200
5366
  }
5201
5367
  if (fontFeatures) {
5202
- this.currentFontId += `_feat_${JSON.stringify(fontFeatures)}`;
5368
+ this.currentFontId += `_feat_${Text.stableStringify(fontFeatures)}`;
5203
5369
  }
5204
5370
  }
5205
5371
  catch (error) {
@@ -5227,7 +5393,7 @@ class Text {
5227
5393
  this.updateFontVariations(options);
5228
5394
  if (!this.geometryBuilder) {
5229
5395
  const cache = options.maxCacheSizeMB
5230
- ? new GlyphCache(options.maxCacheSizeMB)
5396
+ ? createGlyphCache(options.maxCacheSizeMB)
5231
5397
  : globalGlyphCache;
5232
5398
  this.geometryBuilder = new GlyphGeometryBuilder(cache, this.loadedFont);
5233
5399
  this.geometryBuilder.setFontId(this.currentFontId);
@@ -5330,8 +5496,8 @@ class Text {
5330
5496
  }
5331
5497
  updateFontVariations(options) {
5332
5498
  if (options.fontVariations && this.loadedFont) {
5333
- if (JSON.stringify(options.fontVariations) !==
5334
- JSON.stringify(this.loadedFont.fontVariations)) {
5499
+ if (Text.stableStringify(options.fontVariations) !==
5500
+ Text.stableStringify(this.loadedFont.fontVariations || {})) {
5335
5501
  this.loadedFont.font.setVariations(options.fontVariations);
5336
5502
  this.loadedFont.fontVariations = options.fontVariations;
5337
5503
  }
@@ -5347,7 +5513,12 @@ class Text {
5347
5513
  if (width !== undefined) {
5348
5514
  widthInFontUnits = width * (this.loadedFont.upem / size);
5349
5515
  }
5350
- const depthInFontUnits = depth * (this.loadedFont.upem / size);
5516
+ // Keep depth behavior consistent with Extruder: extremely small non-zero depths
5517
+ // are clamped to a minimum back offset so the back face is not coplanar.
5518
+ const depthScale = this.loadedFont.upem / size;
5519
+ const rawDepthInFontUnits = depth * depthScale;
5520
+ const minExtrudeDepth = this.loadedFont.upem * 0.000025;
5521
+ const depthInFontUnits = rawDepthInFontUnits <= 0 ? 0 : Math.max(rawDepthInFontUnits, minExtrudeDepth);
5351
5522
  if (!this.textLayout) {
5352
5523
  this.textLayout = new TextLayout(this.loadedFont);
5353
5524
  }
@@ -5579,6 +5750,15 @@ class Text {
5579
5750
  static registerPattern(language, pattern) {
5580
5751
  Text.patternCache.set(language, pattern);
5581
5752
  }
5753
+ static clearFontCache() {
5754
+ Text.fontCache.clear();
5755
+ Text.fontCacheMemoryBytes = 0;
5756
+ }
5757
+ static setMaxFontCacheMemoryMB(limitMB) {
5758
+ Text.maxFontCacheMemoryBytes =
5759
+ limitMB === Infinity ? Infinity : Math.max(1, Math.floor(limitMB)) * 1024 * 1024;
5760
+ Text.enforceFontCacheMemoryLimit();
5761
+ }
5582
5762
  getLoadedFont() {
5583
5763
  return this.loadedFont;
5584
5764
  }
@@ -5651,4 +5831,5 @@ class Text {
5651
5831
  exports.DEFAULT_CURVE_FIDELITY = DEFAULT_CURVE_FIDELITY;
5652
5832
  exports.FontMetadataExtractor = FontMetadataExtractor;
5653
5833
  exports.Text = Text;
5834
+ exports.createGlyphCache = createGlyphCache;
5654
5835
  exports.globalGlyphCache = globalGlyphCache;