three-text 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.5
2
+ * three-text v0.2.7
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -25,7 +25,7 @@ const isLogEnabled = (() => {
25
25
  }
26
26
  return false;
27
27
  })();
28
- class DebugLogger {
28
+ class Logger {
29
29
  warn(message, ...args) {
30
30
  console.warn(message, ...args);
31
31
  }
@@ -36,7 +36,7 @@ class DebugLogger {
36
36
  isLogEnabled && console.log(message, ...args);
37
37
  }
38
38
  }
39
- const debugLogger = new DebugLogger();
39
+ const logger = new Logger();
40
40
 
41
41
  class PerformanceLogger {
42
42
  constructor() {
@@ -62,7 +62,7 @@ class PerformanceLogger {
62
62
  const endTime = performance.now();
63
63
  const startTime = this.activeTimers.get(name);
64
64
  if (startTime === undefined) {
65
- debugLogger.warn(`Performance timer "${name}" was not started`);
65
+ logger.warn(`Performance timer "${name}" was not started`);
66
66
  return null;
67
67
  }
68
68
  const duration = endTime - startTime;
@@ -125,6 +125,9 @@ class PerformanceLogger {
125
125
  this.metrics.length = 0;
126
126
  this.activeTimers.clear();
127
127
  }
128
+ reset() {
129
+ this.clear();
130
+ }
128
131
  time(name, fn, metadata) {
129
132
  if (!isLogEnabled)
130
133
  return fn();
@@ -324,6 +327,8 @@ class LineBreak {
324
327
  const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
325
328
  return filteredPoints;
326
329
  }
330
+ // Converts text into items (boxes, glues, penalties) for line breaking.
331
+ // The measureText function should return widths that include any letter spacing.
327
332
  static itemizeText(text, measureText, // function to measure text width
328
333
  hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
329
334
  const items = [];
@@ -565,7 +570,7 @@ class LineBreak {
565
570
  let useHyphenation = hyphenate;
566
571
  if (useHyphenation &&
567
572
  (!hyphenationPatterns || !hyphenationPatterns[language])) {
568
- debugLogger.warn(`Hyphenation patterns for ${language} not available`);
573
+ logger.warn(`Hyphenation patterns for ${language} not available`);
569
574
  useHyphenation = false;
570
575
  }
571
576
  // Calculate initial emergency stretch (TeX default: 0)
@@ -1180,12 +1185,43 @@ class LineBreak {
1180
1185
  }
1181
1186
  }
1182
1187
 
1188
+ // Convert feature objects to HarfBuzz comma-separated format
1189
+ function convertFontFeaturesToString(features) {
1190
+ if (!features || Object.keys(features).length === 0) {
1191
+ return undefined;
1192
+ }
1193
+ const featureStrings = [];
1194
+ for (const [tag, value] of Object.entries(features)) {
1195
+ if (!/^[a-zA-Z0-9]{4}$/.test(tag)) {
1196
+ logger.warn(`Invalid OpenType feature tag: "${tag}". Tags must be exactly 4 alphanumeric characters.`);
1197
+ continue;
1198
+ }
1199
+ if (value === false || value === 0) {
1200
+ featureStrings.push(`${tag}=0`);
1201
+ }
1202
+ else if (value === true || value === 1) {
1203
+ featureStrings.push(tag);
1204
+ }
1205
+ else if (typeof value === 'number' && value > 1) {
1206
+ featureStrings.push(`${tag}=${Math.floor(value)}`);
1207
+ }
1208
+ else {
1209
+ logger.warn(`Invalid value for feature "${tag}": ${value}. Expected boolean or positive number.`);
1210
+ }
1211
+ }
1212
+ return featureStrings.length > 0 ? featureStrings.join(',') : undefined;
1213
+ }
1214
+
1183
1215
  class TextMeasurer {
1216
+ // Measures text width including letter spacing
1217
+ // Letter spacing is added uniformly after each glyph during measurement,
1218
+ // so the widths given to the line-breaking algorithm already account for tracking
1184
1219
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1185
1220
  const buffer = loadedFont.hb.createBuffer();
1186
1221
  buffer.addText(text);
1187
1222
  buffer.guessSegmentProperties();
1188
- loadedFont.hb.shape(loadedFont.font, buffer);
1223
+ const featuresString = convertFontFeaturesToString(loadedFont.fontFeatures);
1224
+ loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
1189
1225
  const glyphInfos = buffer.json(loadedFont.font);
1190
1226
  const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
1191
1227
  // Calculate total advance width with letter spacing
@@ -1209,6 +1245,8 @@ class TextLayout {
1209
1245
  const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection, letterSpacing } = options;
1210
1246
  let lines;
1211
1247
  if (width) {
1248
+ // Line breaking uses a measureText function that already includes letterSpacing,
1249
+ // so widths passed into LineBreak.breakText account for tracking
1212
1250
  lines = LineBreak.breakText({
1213
1251
  text,
1214
1252
  width,
@@ -1232,7 +1270,8 @@ class TextLayout {
1232
1270
  looseness,
1233
1271
  disableSingleWordDetection,
1234
1272
  unitsPerEm: this.loadedFont.upem,
1235
- measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing)
1273
+ measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1274
+ )
1236
1275
  });
1237
1276
  }
1238
1277
  else {
@@ -1286,6 +1325,17 @@ const FONT_SIGNATURE_OPEN_TYPE_CFF = 0x4f54544f; // 'OTTO'
1286
1325
  const FONT_SIGNATURE_TRUE_TYPE_COLLECTION = 0x74746366; // 'ttcf'
1287
1326
  const FONT_SIGNATURE_WOFF = 0x774f4646; // 'wOFF'
1288
1327
  const FONT_SIGNATURE_WOFF2 = 0x774f4632; // 'wOF2'
1328
+ // Table Tags
1329
+ const TABLE_TAG_HEAD = 0x68656164; // 'head'
1330
+ const TABLE_TAG_HHEA = 0x68686561; // 'hhea'
1331
+ const TABLE_TAG_OS2 = 0x4f532f32; // 'OS/2'
1332
+ const TABLE_TAG_FVAR = 0x66766172; // 'fvar'
1333
+ const TABLE_TAG_STAT = 0x53544154; // 'STAT'
1334
+ const TABLE_TAG_NAME = 0x6e616d65; // 'name'
1335
+ const TABLE_TAG_CFF = 0x43464620; // 'CFF '
1336
+ const TABLE_TAG_CFF2 = 0x43464632; // 'CFF2'
1337
+ const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
1338
+ const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
1289
1339
 
1290
1340
  class FontMetadataExtractor {
1291
1341
  static extractMetadata(fontBuffer) {
@@ -1302,7 +1352,6 @@ class FontMetadataExtractor {
1302
1352
  if (!validSignatures.includes(sfntVersion)) {
1303
1353
  throw new Error(`Invalid font format. Expected TrueType or OpenType, got signature: 0x${sfntVersion.toString(16)}`);
1304
1354
  }
1305
- const buffer = new Uint8Array(fontBuffer);
1306
1355
  const numTables = view.getUint16(4); // OpenType header - number of tables is at offset 4
1307
1356
  let isCFF = false;
1308
1357
  let headTableOffset = 0;
@@ -1312,30 +1361,28 @@ class FontMetadataExtractor {
1312
1361
  let nameTableOffset = 0;
1313
1362
  let fvarTableOffset = 0;
1314
1363
  for (let i = 0; i < numTables; i++) {
1315
- const tag = new TextDecoder().decode(buffer.slice(12 + i * 16, 12 + i * 16 + 4));
1316
- if (tag === 'CFF ') {
1317
- isCFF = true;
1318
- }
1319
- else if (tag === 'CFF2') {
1364
+ const offset = 12 + i * 16;
1365
+ const tag = view.getUint32(offset);
1366
+ if (tag === TABLE_TAG_CFF || tag === TABLE_TAG_CFF2) {
1320
1367
  isCFF = true;
1321
1368
  }
1322
- if (tag === 'head') {
1323
- headTableOffset = view.getUint32(12 + i * 16 + 8);
1369
+ else if (tag === TABLE_TAG_HEAD) {
1370
+ headTableOffset = view.getUint32(offset + 8);
1324
1371
  }
1325
- if (tag === 'hhea') {
1326
- hheaTableOffset = view.getUint32(12 + i * 16 + 8);
1372
+ else if (tag === TABLE_TAG_HHEA) {
1373
+ hheaTableOffset = view.getUint32(offset + 8);
1327
1374
  }
1328
- if (tag === 'OS/2') {
1329
- os2TableOffset = view.getUint32(12 + i * 16 + 8);
1375
+ else if (tag === TABLE_TAG_OS2) {
1376
+ os2TableOffset = view.getUint32(offset + 8);
1330
1377
  }
1331
- if (tag === 'fvar') {
1332
- fvarTableOffset = view.getUint32(12 + i * 16 + 8);
1378
+ else if (tag === TABLE_TAG_FVAR) {
1379
+ fvarTableOffset = view.getUint32(offset + 8);
1333
1380
  }
1334
- if (tag === 'STAT') {
1335
- statTableOffset = view.getUint32(12 + i * 16 + 8);
1381
+ else if (tag === TABLE_TAG_STAT) {
1382
+ statTableOffset = view.getUint32(offset + 8);
1336
1383
  }
1337
- if (tag === 'name') {
1338
- nameTableOffset = view.getUint32(12 + i * 16 + 8);
1384
+ else if (tag === TABLE_TAG_NAME) {
1385
+ nameTableOffset = view.getUint32(offset + 8);
1339
1386
  }
1340
1387
  }
1341
1388
  const unitsPerEm = headTableOffset
@@ -1378,6 +1425,88 @@ class FontMetadataExtractor {
1378
1425
  axisNames
1379
1426
  };
1380
1427
  }
1428
+ static extractFeatureTags(fontBuffer) {
1429
+ const view = new DataView(fontBuffer);
1430
+ const numTables = view.getUint16(4);
1431
+ let gsubTableOffset = 0;
1432
+ let gposTableOffset = 0;
1433
+ let nameTableOffset = 0;
1434
+ for (let i = 0; i < numTables; i++) {
1435
+ const offset = 12 + i * 16;
1436
+ const tag = view.getUint32(offset);
1437
+ if (tag === TABLE_TAG_GSUB) {
1438
+ gsubTableOffset = view.getUint32(offset + 8);
1439
+ }
1440
+ else if (tag === TABLE_TAG_GPOS) {
1441
+ gposTableOffset = view.getUint32(offset + 8);
1442
+ }
1443
+ else if (tag === TABLE_TAG_NAME) {
1444
+ nameTableOffset = view.getUint32(offset + 8);
1445
+ }
1446
+ }
1447
+ const features = new Set();
1448
+ const featureNames = {};
1449
+ try {
1450
+ if (gsubTableOffset) {
1451
+ const gsubData = this.extractFeatureDataFromTable(view, gsubTableOffset, nameTableOffset);
1452
+ gsubData.features.forEach(f => features.add(f));
1453
+ Object.assign(featureNames, gsubData.names);
1454
+ }
1455
+ if (gposTableOffset) {
1456
+ const gposData = this.extractFeatureDataFromTable(view, gposTableOffset, nameTableOffset);
1457
+ gposData.features.forEach(f => features.add(f));
1458
+ Object.assign(featureNames, gposData.names);
1459
+ }
1460
+ }
1461
+ catch (e) {
1462
+ return undefined;
1463
+ }
1464
+ const featureArray = Array.from(features).sort();
1465
+ if (featureArray.length === 0)
1466
+ return undefined;
1467
+ return {
1468
+ tags: featureArray,
1469
+ names: Object.keys(featureNames).length > 0 ? featureNames : {}
1470
+ };
1471
+ }
1472
+ static extractFeatureDataFromTable(view, tableOffset, nameTableOffset) {
1473
+ const featureListOffset = view.getUint16(tableOffset + 6);
1474
+ const featureListStart = tableOffset + featureListOffset;
1475
+ const featureCount = view.getUint16(featureListStart);
1476
+ const features = [];
1477
+ const names = {};
1478
+ for (let i = 0; i < featureCount; i++) {
1479
+ const recordOffset = featureListStart + 2 + i * 6;
1480
+ // Decode feature tag
1481
+ const tag = String.fromCharCode(view.getUint8(recordOffset), view.getUint8(recordOffset + 1), view.getUint8(recordOffset + 2), view.getUint8(recordOffset + 3));
1482
+ features.push(tag);
1483
+ // Extract feature name for stylistic sets and character variants
1484
+ if (/^(ss\d{2}|cv\d{2})$/.test(tag) && nameTableOffset) {
1485
+ const featureOffset = view.getUint16(recordOffset + 4);
1486
+ const featureTableStart = featureListStart + featureOffset;
1487
+ // Feature table structure:
1488
+ // uint16 FeatureParams offset
1489
+ // uint16 LookupCount
1490
+ // uint16[LookupCount] LookupListIndex
1491
+ const featureParamsOffset = view.getUint16(featureTableStart);
1492
+ // FeatureParams for ss features:
1493
+ // uint16 Version (should be 0)
1494
+ // uint16 UINameID
1495
+ if (featureParamsOffset !== 0) {
1496
+ const paramsStart = featureTableStart + featureParamsOffset;
1497
+ const version = view.getUint16(paramsStart);
1498
+ if (version === 0) {
1499
+ const nameID = view.getUint16(paramsStart + 2);
1500
+ const name = this.getNameFromNameTable(view, nameTableOffset, nameID);
1501
+ if (name) {
1502
+ names[tag] = name;
1503
+ }
1504
+ }
1505
+ }
1506
+ }
1507
+ }
1508
+ return { features, names };
1509
+ }
1381
1510
  static extractAxisNames(view, statOffset, nameOffset) {
1382
1511
  try {
1383
1512
  // STAT table structure
@@ -1588,7 +1717,7 @@ class WoffConverter {
1588
1717
  const padding = (4 - (table.origLength % 4)) % 4;
1589
1718
  sfntOffset += padding;
1590
1719
  }
1591
- debugLogger.log('WOFF font decompressed successfully');
1720
+ logger.log('WOFF font decompressed successfully');
1592
1721
  return sfntData.buffer.slice(0, sfntOffset);
1593
1722
  }
1594
1723
  static async decompressZlib(compressedData) {
@@ -1617,7 +1746,7 @@ class FontLoader {
1617
1746
  // Check if this is a WOFF font and decompress if needed
1618
1747
  const format = WoffConverter.detectFormat(fontBuffer);
1619
1748
  if (format === 'woff') {
1620
- debugLogger.log('WOFF font detected, decompressing...');
1749
+ logger.log('WOFF font detected, decompressing...');
1621
1750
  fontBuffer = await WoffConverter.decompressWoff(fontBuffer);
1622
1751
  }
1623
1752
  else if (format === 'woff2') {
@@ -1655,6 +1784,7 @@ class FontLoader {
1655
1784
  };
1656
1785
  }
1657
1786
  }
1787
+ const featureData = FontMetadataExtractor.extractFeatureTags(fontBuffer);
1658
1788
  return {
1659
1789
  hb,
1660
1790
  fontBlob,
@@ -1665,11 +1795,13 @@ class FontLoader {
1665
1795
  metrics,
1666
1796
  fontVariations,
1667
1797
  isVariable,
1668
- variationAxes
1798
+ variationAxes,
1799
+ availableFeatures: featureData?.tags,
1800
+ featureNames: featureData?.names
1669
1801
  };
1670
1802
  }
1671
1803
  catch (error) {
1672
- debugLogger.error('Failed to load font:', error);
1804
+ logger.error('Failed to load font:', error);
1673
1805
  throw error;
1674
1806
  }
1675
1807
  finally {
@@ -1690,7 +1822,7 @@ class FontLoader {
1690
1822
  }
1691
1823
  }
1692
1824
  catch (error) {
1693
- debugLogger.error('Error destroying font resources:', error);
1825
+ logger.error('Error destroying font resources:', error);
1694
1826
  }
1695
1827
  }
1696
1828
  }
@@ -2092,7 +2224,7 @@ class Tessellator {
2092
2224
  if (valid.length === 0) {
2093
2225
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2094
2226
  }
2095
- debugLogger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2227
+ logger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2096
2228
  return this.tessellate(valid, removeOverlaps, isCFF);
2097
2229
  }
2098
2230
  tessellate(paths, removeOverlaps, isCFF) {
@@ -2102,27 +2234,35 @@ class Tessellator {
2102
2234
  : paths;
2103
2235
  let contours = this.pathsToContours(normalizedPaths);
2104
2236
  if (removeOverlaps) {
2105
- debugLogger.log('Two-pass: boundary extraction then triangulation');
2237
+ logger.log('Two-pass: boundary extraction then triangulation');
2106
2238
  // Extract boundaries to remove overlaps
2239
+ perfLogger.start('Tessellator.boundaryPass', {
2240
+ contourCount: contours.length
2241
+ });
2107
2242
  const boundaryResult = this.performTessellation(contours, 'boundary');
2243
+ perfLogger.end('Tessellator.boundaryPass');
2108
2244
  if (!boundaryResult) {
2109
- debugLogger.warn('libtess returned empty result from boundary pass');
2245
+ logger.warn('libtess returned empty result from boundary pass');
2110
2246
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2111
2247
  }
2112
2248
  // Convert boundary elements back to contours
2113
2249
  contours = this.boundaryToContours(boundaryResult);
2114
- debugLogger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2250
+ logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2115
2251
  }
2116
2252
  else {
2117
- debugLogger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2253
+ logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2118
2254
  }
2119
2255
  // Triangulate the contours
2256
+ perfLogger.start('Tessellator.triangulationPass', {
2257
+ contourCount: contours.length
2258
+ });
2120
2259
  const triangleResult = this.performTessellation(contours, 'triangles');
2260
+ perfLogger.end('Tessellator.triangulationPass');
2121
2261
  if (!triangleResult) {
2122
2262
  const warning = removeOverlaps
2123
2263
  ? 'libtess returned empty result from triangulation pass'
2124
2264
  : 'libtess returned empty result from single-pass triangulation';
2125
- debugLogger.warn(warning);
2265
+ logger.warn(warning);
2126
2266
  return { triangles: { vertices: [], indices: [] }, contours };
2127
2267
  }
2128
2268
  return {
@@ -2177,7 +2317,7 @@ class Tessellator {
2177
2317
  return idx;
2178
2318
  });
2179
2319
  tess.gluTessCallback(libtess_minExports.gluEnum.GLU_TESS_ERROR, (errno) => {
2180
- debugLogger.warn(`libtess error: ${errno}`);
2320
+ logger.warn(`libtess error: ${errno}`);
2181
2321
  });
2182
2322
  tess.gluTessNormal(0, 0, 1);
2183
2323
  tess.gluTessBeginPolygon(null);
@@ -2304,10 +2444,16 @@ const OVERLAP_EPSILON = 1e-3;
2304
2444
  class BoundaryClusterer {
2305
2445
  constructor() { }
2306
2446
  cluster(glyphContoursList, positions) {
2447
+ perfLogger.start('BoundaryClusterer.cluster', {
2448
+ glyphCount: glyphContoursList.length
2449
+ });
2307
2450
  if (glyphContoursList.length === 0) {
2451
+ perfLogger.end('BoundaryClusterer.cluster');
2308
2452
  return [];
2309
2453
  }
2310
- return this.clusterSweepLine(glyphContoursList, positions);
2454
+ const result = this.clusterSweepLine(glyphContoursList, positions);
2455
+ perfLogger.end('BoundaryClusterer.cluster');
2456
+ return result;
2311
2457
  }
2312
2458
  clusterSweepLine(glyphContoursList, positions) {
2313
2459
  const n = glyphContoursList.length;
@@ -2948,6 +3094,11 @@ class GlyphContourCollector {
2948
3094
  this.currentGlyphBounds.max.set(-Infinity, -Infinity);
2949
3095
  // Record position for this glyph
2950
3096
  this.glyphPositions.push(this.currentPosition.clone());
3097
+ // Time polygonization + path optimization per glyph
3098
+ perfLogger.start('Glyph.polygonizeAndOptimize', {
3099
+ glyphId,
3100
+ textIndex
3101
+ });
2951
3102
  }
2952
3103
  finishGlyph() {
2953
3104
  if (this.currentPath) {
@@ -2971,6 +3122,8 @@ class GlyphContourCollector {
2971
3122
  // Track textIndex separately
2972
3123
  this.glyphTextIndices.push(this.currentTextIndex);
2973
3124
  }
3125
+ // Stop timing for this glyph (even if it ended up empty)
3126
+ perfLogger.end('Glyph.polygonizeAndOptimize');
2974
3127
  this.currentGlyphPaths = [];
2975
3128
  }
2976
3129
  onMoveTo(x, y) {
@@ -3199,12 +3352,190 @@ class DrawCallbackHandler {
3199
3352
  }
3200
3353
  }
3201
3354
  catch (error) {
3202
- debugLogger.warn('Error destroying draw callbacks:', error);
3355
+ logger.warn('Error destroying draw callbacks:', error);
3203
3356
  }
3204
3357
  this.collector = undefined;
3205
3358
  }
3206
3359
  }
3207
3360
 
3361
+ // Generic LRU (Least Recently Used) cache with optional memory-based eviction
3362
+ class LRUCache {
3363
+ constructor(options = {}) {
3364
+ this.cache = new Map();
3365
+ this.head = null;
3366
+ this.tail = null;
3367
+ this.stats = {
3368
+ hits: 0,
3369
+ misses: 0,
3370
+ evictions: 0,
3371
+ size: 0,
3372
+ memoryUsage: 0
3373
+ };
3374
+ this.options = {
3375
+ maxEntries: options.maxEntries ?? Infinity,
3376
+ maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
3377
+ calculateSize: options.calculateSize ?? (() => 0),
3378
+ onEvict: options.onEvict
3379
+ };
3380
+ }
3381
+ get(key) {
3382
+ const node = this.cache.get(key);
3383
+ if (node) {
3384
+ this.stats.hits++;
3385
+ this.moveToHead(node);
3386
+ return node.value;
3387
+ }
3388
+ else {
3389
+ this.stats.misses++;
3390
+ return undefined;
3391
+ }
3392
+ }
3393
+ has(key) {
3394
+ return this.cache.has(key);
3395
+ }
3396
+ set(key, value) {
3397
+ // If key already exists, update it
3398
+ const existingNode = this.cache.get(key);
3399
+ if (existingNode) {
3400
+ const oldSize = this.options.calculateSize(existingNode.value);
3401
+ const newSize = this.options.calculateSize(value);
3402
+ this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
3403
+ existingNode.value = value;
3404
+ this.moveToHead(existingNode);
3405
+ return;
3406
+ }
3407
+ const size = this.options.calculateSize(value);
3408
+ // Evict entries if we exceed limits
3409
+ this.evictIfNeeded(size);
3410
+ // Create new node
3411
+ const node = {
3412
+ key,
3413
+ value,
3414
+ prev: null,
3415
+ next: null
3416
+ };
3417
+ this.cache.set(key, node);
3418
+ this.addToHead(node);
3419
+ this.stats.size = this.cache.size;
3420
+ this.stats.memoryUsage += size;
3421
+ }
3422
+ delete(key) {
3423
+ const node = this.cache.get(key);
3424
+ if (!node)
3425
+ return false;
3426
+ const size = this.options.calculateSize(node.value);
3427
+ this.removeNode(node);
3428
+ this.cache.delete(key);
3429
+ this.stats.size = this.cache.size;
3430
+ this.stats.memoryUsage -= size;
3431
+ if (this.options.onEvict) {
3432
+ this.options.onEvict(key, node.value);
3433
+ }
3434
+ return true;
3435
+ }
3436
+ clear() {
3437
+ if (this.options.onEvict) {
3438
+ for (const [key, node] of this.cache) {
3439
+ this.options.onEvict(key, node.value);
3440
+ }
3441
+ }
3442
+ this.cache.clear();
3443
+ this.head = null;
3444
+ this.tail = null;
3445
+ this.stats = {
3446
+ hits: 0,
3447
+ misses: 0,
3448
+ evictions: 0,
3449
+ size: 0,
3450
+ memoryUsage: 0
3451
+ };
3452
+ }
3453
+ getStats() {
3454
+ const total = this.stats.hits + this.stats.misses;
3455
+ const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
3456
+ const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
3457
+ return {
3458
+ ...this.stats,
3459
+ hitRate,
3460
+ memoryUsageMB
3461
+ };
3462
+ }
3463
+ keys() {
3464
+ const keys = [];
3465
+ let current = this.head;
3466
+ while (current) {
3467
+ keys.push(current.key);
3468
+ current = current.next;
3469
+ }
3470
+ return keys;
3471
+ }
3472
+ get size() {
3473
+ return this.cache.size;
3474
+ }
3475
+ evictIfNeeded(requiredSize) {
3476
+ // Evict by entry count
3477
+ while (this.cache.size >= this.options.maxEntries && this.tail) {
3478
+ this.evictTail();
3479
+ }
3480
+ // Evict by memory usage
3481
+ if (this.options.maxMemoryBytes < Infinity) {
3482
+ while (this.tail &&
3483
+ this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
3484
+ this.evictTail();
3485
+ }
3486
+ }
3487
+ }
3488
+ evictTail() {
3489
+ if (!this.tail)
3490
+ return;
3491
+ const nodeToRemove = this.tail;
3492
+ const size = this.options.calculateSize(nodeToRemove.value);
3493
+ this.removeTail();
3494
+ this.cache.delete(nodeToRemove.key);
3495
+ this.stats.size = this.cache.size;
3496
+ this.stats.memoryUsage -= size;
3497
+ this.stats.evictions++;
3498
+ if (this.options.onEvict) {
3499
+ this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
3500
+ }
3501
+ }
3502
+ addToHead(node) {
3503
+ if (!this.head) {
3504
+ this.head = this.tail = node;
3505
+ }
3506
+ else {
3507
+ node.next = this.head;
3508
+ this.head.prev = node;
3509
+ this.head = node;
3510
+ }
3511
+ }
3512
+ removeNode(node) {
3513
+ if (node.prev) {
3514
+ node.prev.next = node.next;
3515
+ }
3516
+ else {
3517
+ this.head = node.next;
3518
+ }
3519
+ if (node.next) {
3520
+ node.next.prev = node.prev;
3521
+ }
3522
+ else {
3523
+ this.tail = node.prev;
3524
+ }
3525
+ }
3526
+ removeTail() {
3527
+ if (this.tail) {
3528
+ this.removeNode(this.tail);
3529
+ }
3530
+ }
3531
+ moveToHead(node) {
3532
+ if (node === this.head)
3533
+ return;
3534
+ this.removeNode(node);
3535
+ this.addToHead(node);
3536
+ }
3537
+ }
3538
+
3208
3539
  class GlyphGeometryBuilder {
3209
3540
  constructor(cache, loadedFont) {
3210
3541
  this.fontId = 'default';
@@ -3217,6 +3548,16 @@ class GlyphGeometryBuilder {
3217
3548
  this.collector = new GlyphContourCollector();
3218
3549
  this.drawCallbacks = new DrawCallbackHandler();
3219
3550
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3551
+ this.contourCache = new LRUCache({
3552
+ maxEntries: 1000,
3553
+ calculateSize: (contours) => {
3554
+ let size = 0;
3555
+ for (const path of contours.paths) {
3556
+ size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
3557
+ }
3558
+ return size + 64; // bounds overhead
3559
+ }
3560
+ });
3220
3561
  }
3221
3562
  getOptimizationStats() {
3222
3563
  return this.collector.getOptimizationStats();
@@ -3361,30 +3702,37 @@ class GlyphGeometryBuilder {
3361
3702
  };
3362
3703
  }
3363
3704
  getContoursForGlyph(glyphId) {
3705
+ const cached = this.contourCache.get(glyphId);
3706
+ if (cached) {
3707
+ return cached;
3708
+ }
3364
3709
  this.collector.reset();
3365
3710
  this.collector.beginGlyph(glyphId, 0);
3366
3711
  this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
3367
3712
  this.collector.finishGlyph();
3368
3713
  const collected = this.collector.getCollectedGlyphs()[0];
3369
- // Return empty contours for glyphs with no paths (e.g., space, zero-width characters)
3370
- if (!collected) {
3371
- return {
3372
- glyphId,
3373
- paths: [],
3374
- bounds: {
3375
- min: { x: 0, y: 0 },
3376
- max: { x: 0, y: 0 }
3377
- }
3378
- };
3379
- }
3380
- return collected;
3714
+ const contours = collected || {
3715
+ glyphId,
3716
+ paths: [],
3717
+ bounds: {
3718
+ min: { x: 0, y: 0 },
3719
+ max: { x: 0, y: 0 }
3720
+ }
3721
+ };
3722
+ this.contourCache.set(glyphId, contours);
3723
+ return contours;
3381
3724
  }
3382
3725
  tessellateGlyphCluster(paths, depth, isCFF) {
3383
3726
  const processedGeometry = this.tessellator.process(paths, true, isCFF);
3384
3727
  return this.extrudeAndPackage(processedGeometry, depth);
3385
3728
  }
3386
3729
  extrudeAndPackage(processedGeometry, depth) {
3730
+ perfLogger.start('Extruder.extrude', {
3731
+ depth,
3732
+ upem: this.loadedFont.upem
3733
+ });
3387
3734
  const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
3735
+ perfLogger.end('Extruder.extrude');
3388
3736
  // Compute bounding box from vertices
3389
3737
  const vertices = extrudedResult.vertices;
3390
3738
  let minX = Infinity, minY = Infinity, minZ = Infinity;
@@ -3426,6 +3774,7 @@ class GlyphGeometryBuilder {
3426
3774
  pathCount: glyphContours.paths.length
3427
3775
  });
3428
3776
  const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
3777
+ perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
3429
3778
  return this.extrudeAndPackage(processedGeometry, depth);
3430
3779
  }
3431
3780
  updatePlaneBounds(glyphBounds, planeBounds) {
@@ -3489,7 +3838,8 @@ class TextShaper {
3489
3838
  }
3490
3839
  buffer.addText(lineInfo.text);
3491
3840
  buffer.guessSegmentProperties();
3492
- this.loadedFont.hb.shape(this.loadedFont.font, buffer);
3841
+ const featuresString = convertFontFeaturesToString(this.loadedFont.fontFeatures);
3842
+ this.loadedFont.hb.shape(this.loadedFont.font, buffer, featuresString);
3493
3843
  const glyphInfos = buffer.json(this.loadedFont.font);
3494
3844
  buffer.destroy();
3495
3845
  const clusters = [];
@@ -3497,6 +3847,7 @@ class TextShaper {
3497
3847
  let currentClusterText = '';
3498
3848
  let clusterStartPosition = new Vec3();
3499
3849
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
3850
+ // Apply letter spacing between glyphs (must match what was used in width measurements)
3500
3851
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3501
3852
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
3502
3853
  for (let i = 0; i < glyphInfos.length; i++) {
@@ -3568,12 +3919,10 @@ class TextShaper {
3568
3919
  const stretchFactor = SPACE_STRETCH_RATIO;
3569
3920
  const shrinkFactor = SPACE_SHRINK_RATIO;
3570
3921
  if (lineInfo.adjustmentRatio > 0) {
3571
- spaceAdjustment =
3572
- lineInfo.adjustmentRatio * width * stretchFactor;
3922
+ spaceAdjustment = lineInfo.adjustmentRatio * width * stretchFactor;
3573
3923
  }
3574
3924
  else if (lineInfo.adjustmentRatio < 0) {
3575
- spaceAdjustment =
3576
- lineInfo.adjustmentRatio * width * shrinkFactor;
3925
+ spaceAdjustment = lineInfo.adjustmentRatio * width * shrinkFactor;
3577
3926
  }
3578
3927
  }
3579
3928
  return spaceAdjustment;
@@ -4498,12 +4847,16 @@ class Text {
4498
4847
  const baseFontKey = typeof options.font === 'string'
4499
4848
  ? options.font
4500
4849
  : `buffer-${Text.generateFontContentHash(options.font)}`;
4501
- const fontKey = options.fontVariations
4502
- ? `${baseFontKey}_${JSON.stringify(options.fontVariations)}`
4503
- : baseFontKey;
4850
+ let fontKey = baseFontKey;
4851
+ if (options.fontVariations) {
4852
+ fontKey += `_var_${JSON.stringify(options.fontVariations)}`;
4853
+ }
4854
+ if (options.fontFeatures) {
4855
+ fontKey += `_feat_${JSON.stringify(options.fontFeatures)}`;
4856
+ }
4504
4857
  let loadedFont = Text.fontCache.get(fontKey);
4505
4858
  if (!loadedFont) {
4506
- loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations);
4859
+ loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
4507
4860
  }
4508
4861
  const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
4509
4862
  text.setLoadedFont(loadedFont);
@@ -4517,12 +4870,11 @@ class Text {
4517
4870
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
4518
4871
  };
4519
4872
  }
4520
- static async loadAndCacheFont(fontKey, font, fontVariations) {
4873
+ static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
4521
4874
  const tempText = new Text();
4522
- await tempText.loadFont(font, fontVariations);
4875
+ await tempText.loadFont(font, fontVariations, fontFeatures);
4523
4876
  const loadedFont = tempText.getLoadedFont();
4524
4877
  Text.fontCache.set(fontKey, loadedFont);
4525
- // Don't destroy tempText - the cached font references its HarfBuzz objects
4526
4878
  return loadedFont;
4527
4879
  }
4528
4880
  static generateFontContentHash(buffer) {
@@ -4541,10 +4893,13 @@ class Text {
4541
4893
  const contentHash = Text.generateFontContentHash(loadedFont._buffer);
4542
4894
  this.currentFontId = `font_${contentHash}`;
4543
4895
  if (loadedFont.fontVariations) {
4544
- this.currentFontId += `_${JSON.stringify(loadedFont.fontVariations)}`;
4896
+ this.currentFontId += `_var_${JSON.stringify(loadedFont.fontVariations)}`;
4897
+ }
4898
+ if (loadedFont.fontFeatures) {
4899
+ this.currentFontId += `_feat_${JSON.stringify(loadedFont.fontFeatures)}`;
4545
4900
  }
4546
4901
  }
4547
- async loadFont(fontSrc, fontVariations) {
4902
+ async loadFont(fontSrc, fontVariations, fontFeatures) {
4548
4903
  perfLogger.start('Text.loadFont', {
4549
4904
  fontSrc: typeof fontSrc === 'string' ? fontSrc : `buffer(${fontSrc.byteLength})`
4550
4905
  });
@@ -4565,14 +4920,20 @@ class Text {
4565
4920
  this.destroy();
4566
4921
  }
4567
4922
  this.loadedFont = await this.fontLoader.loadFont(fontBuffer, fontVariations);
4923
+ if (fontFeatures) {
4924
+ this.loadedFont.fontFeatures = fontFeatures;
4925
+ }
4568
4926
  const contentHash = Text.generateFontContentHash(fontBuffer);
4569
4927
  this.currentFontId = `font_${contentHash}`;
4570
4928
  if (fontVariations) {
4571
- this.currentFontId += `_${JSON.stringify(fontVariations)}`;
4929
+ this.currentFontId += `_var_${JSON.stringify(fontVariations)}`;
4930
+ }
4931
+ if (fontFeatures) {
4932
+ this.currentFontId += `_feat_${JSON.stringify(fontFeatures)}`;
4572
4933
  }
4573
4934
  }
4574
4935
  catch (error) {
4575
- debugLogger.error('Failed to load font:', error);
4936
+ logger.error('Failed to load font:', error);
4576
4937
  throw error;
4577
4938
  }
4578
4939
  finally {
@@ -4647,7 +5008,7 @@ class Text {
4647
5008
  };
4648
5009
  }
4649
5010
  catch (error) {
4650
- debugLogger.warn(`Failed to load patterns for ${language}: ${error}`);
5011
+ logger.warn(`Failed to load patterns for ${language}: ${error}`);
4651
5012
  return {
4652
5013
  ...options,
4653
5014
  layout: {
@@ -4911,7 +5272,7 @@ class Text {
4911
5272
  Text.patternCache.set(language, pattern);
4912
5273
  }
4913
5274
  catch (error) {
4914
- debugLogger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
5275
+ logger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
4915
5276
  }
4916
5277
  }
4917
5278
  }));
@@ -4973,7 +5334,7 @@ class Text {
4973
5334
  FontLoader.destroyFont(currentFont);
4974
5335
  }
4975
5336
  catch (error) {
4976
- debugLogger.warn('Error destroying HarfBuzz objects:', error);
5337
+ logger.warn('Error destroying HarfBuzz objects:', error);
4977
5338
  }
4978
5339
  finally {
4979
5340
  this.loadedFont = undefined;