three-text 0.2.13 → 0.2.15

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