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