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