three-text 0.2.13 → 0.2.14

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