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