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.js 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
@@ -22,7 +22,7 @@ const isLogEnabled = (() => {
22
22
  }
23
23
  return false;
24
24
  })();
25
- class DebugLogger {
25
+ class Logger {
26
26
  warn(message, ...args) {
27
27
  console.warn(message, ...args);
28
28
  }
@@ -33,7 +33,7 @@ class DebugLogger {
33
33
  isLogEnabled && console.log(message, ...args);
34
34
  }
35
35
  }
36
- const debugLogger = new DebugLogger();
36
+ const logger = new Logger();
37
37
 
38
38
  class PerformanceLogger {
39
39
  constructor() {
@@ -59,7 +59,7 @@ class PerformanceLogger {
59
59
  const endTime = performance.now();
60
60
  const startTime = this.activeTimers.get(name);
61
61
  if (startTime === undefined) {
62
- debugLogger.warn(`Performance timer "${name}" was not started`);
62
+ logger.warn(`Performance timer "${name}" was not started`);
63
63
  return null;
64
64
  }
65
65
  const duration = endTime - startTime;
@@ -122,6 +122,9 @@ class PerformanceLogger {
122
122
  this.metrics.length = 0;
123
123
  this.activeTimers.clear();
124
124
  }
125
+ reset() {
126
+ this.clear();
127
+ }
125
128
  time(name, fn, metadata) {
126
129
  if (!isLogEnabled)
127
130
  return fn();
@@ -321,6 +324,8 @@ class LineBreak {
321
324
  const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
322
325
  return filteredPoints;
323
326
  }
327
+ // Converts text into items (boxes, glues, penalties) for line breaking.
328
+ // The measureText function should return widths that include any letter spacing.
324
329
  static itemizeText(text, measureText, // function to measure text width
325
330
  hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
326
331
  const items = [];
@@ -562,7 +567,7 @@ class LineBreak {
562
567
  let useHyphenation = hyphenate;
563
568
  if (useHyphenation &&
564
569
  (!hyphenationPatterns || !hyphenationPatterns[language])) {
565
- debugLogger.warn(`Hyphenation patterns for ${language} not available`);
570
+ logger.warn(`Hyphenation patterns for ${language} not available`);
566
571
  useHyphenation = false;
567
572
  }
568
573
  // Calculate initial emergency stretch (TeX default: 0)
@@ -1177,12 +1182,43 @@ class LineBreak {
1177
1182
  }
1178
1183
  }
1179
1184
 
1185
+ // Convert feature objects to HarfBuzz comma-separated format
1186
+ function convertFontFeaturesToString(features) {
1187
+ if (!features || Object.keys(features).length === 0) {
1188
+ return undefined;
1189
+ }
1190
+ const featureStrings = [];
1191
+ for (const [tag, value] of Object.entries(features)) {
1192
+ if (!/^[a-zA-Z0-9]{4}$/.test(tag)) {
1193
+ logger.warn(`Invalid OpenType feature tag: "${tag}". Tags must be exactly 4 alphanumeric characters.`);
1194
+ continue;
1195
+ }
1196
+ if (value === false || value === 0) {
1197
+ featureStrings.push(`${tag}=0`);
1198
+ }
1199
+ else if (value === true || value === 1) {
1200
+ featureStrings.push(tag);
1201
+ }
1202
+ else if (typeof value === 'number' && value > 1) {
1203
+ featureStrings.push(`${tag}=${Math.floor(value)}`);
1204
+ }
1205
+ else {
1206
+ logger.warn(`Invalid value for feature "${tag}": ${value}. Expected boolean or positive number.`);
1207
+ }
1208
+ }
1209
+ return featureStrings.length > 0 ? featureStrings.join(',') : undefined;
1210
+ }
1211
+
1180
1212
  class TextMeasurer {
1213
+ // Measures text width including letter spacing
1214
+ // Letter spacing is added uniformly after each glyph during measurement,
1215
+ // so the widths given to the line-breaking algorithm already account for tracking
1181
1216
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1182
1217
  const buffer = loadedFont.hb.createBuffer();
1183
1218
  buffer.addText(text);
1184
1219
  buffer.guessSegmentProperties();
1185
- loadedFont.hb.shape(loadedFont.font, buffer);
1220
+ const featuresString = convertFontFeaturesToString(loadedFont.fontFeatures);
1221
+ loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
1186
1222
  const glyphInfos = buffer.json(loadedFont.font);
1187
1223
  const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
1188
1224
  // Calculate total advance width with letter spacing
@@ -1206,6 +1242,8 @@ class TextLayout {
1206
1242
  const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection, letterSpacing } = options;
1207
1243
  let lines;
1208
1244
  if (width) {
1245
+ // Line breaking uses a measureText function that already includes letterSpacing,
1246
+ // so widths passed into LineBreak.breakText account for tracking
1209
1247
  lines = LineBreak.breakText({
1210
1248
  text,
1211
1249
  width,
@@ -1229,7 +1267,8 @@ class TextLayout {
1229
1267
  looseness,
1230
1268
  disableSingleWordDetection,
1231
1269
  unitsPerEm: this.loadedFont.upem,
1232
- measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing)
1270
+ measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1271
+ )
1233
1272
  });
1234
1273
  }
1235
1274
  else {
@@ -1283,6 +1322,17 @@ const FONT_SIGNATURE_OPEN_TYPE_CFF = 0x4f54544f; // 'OTTO'
1283
1322
  const FONT_SIGNATURE_TRUE_TYPE_COLLECTION = 0x74746366; // 'ttcf'
1284
1323
  const FONT_SIGNATURE_WOFF = 0x774f4646; // 'wOFF'
1285
1324
  const FONT_SIGNATURE_WOFF2 = 0x774f4632; // 'wOF2'
1325
+ // Table Tags
1326
+ const TABLE_TAG_HEAD = 0x68656164; // 'head'
1327
+ const TABLE_TAG_HHEA = 0x68686561; // 'hhea'
1328
+ const TABLE_TAG_OS2 = 0x4f532f32; // 'OS/2'
1329
+ const TABLE_TAG_FVAR = 0x66766172; // 'fvar'
1330
+ const TABLE_TAG_STAT = 0x53544154; // 'STAT'
1331
+ const TABLE_TAG_NAME = 0x6e616d65; // 'name'
1332
+ const TABLE_TAG_CFF = 0x43464620; // 'CFF '
1333
+ const TABLE_TAG_CFF2 = 0x43464632; // 'CFF2'
1334
+ const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
1335
+ const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
1286
1336
 
1287
1337
  class FontMetadataExtractor {
1288
1338
  static extractMetadata(fontBuffer) {
@@ -1299,7 +1349,6 @@ class FontMetadataExtractor {
1299
1349
  if (!validSignatures.includes(sfntVersion)) {
1300
1350
  throw new Error(`Invalid font format. Expected TrueType or OpenType, got signature: 0x${sfntVersion.toString(16)}`);
1301
1351
  }
1302
- const buffer = new Uint8Array(fontBuffer);
1303
1352
  const numTables = view.getUint16(4); // OpenType header - number of tables is at offset 4
1304
1353
  let isCFF = false;
1305
1354
  let headTableOffset = 0;
@@ -1309,30 +1358,28 @@ class FontMetadataExtractor {
1309
1358
  let nameTableOffset = 0;
1310
1359
  let fvarTableOffset = 0;
1311
1360
  for (let i = 0; i < numTables; i++) {
1312
- const tag = new TextDecoder().decode(buffer.slice(12 + i * 16, 12 + i * 16 + 4));
1313
- if (tag === 'CFF ') {
1314
- isCFF = true;
1315
- }
1316
- else if (tag === 'CFF2') {
1361
+ const offset = 12 + i * 16;
1362
+ const tag = view.getUint32(offset);
1363
+ if (tag === TABLE_TAG_CFF || tag === TABLE_TAG_CFF2) {
1317
1364
  isCFF = true;
1318
1365
  }
1319
- if (tag === 'head') {
1320
- headTableOffset = view.getUint32(12 + i * 16 + 8);
1366
+ else if (tag === TABLE_TAG_HEAD) {
1367
+ headTableOffset = view.getUint32(offset + 8);
1321
1368
  }
1322
- if (tag === 'hhea') {
1323
- hheaTableOffset = view.getUint32(12 + i * 16 + 8);
1369
+ else if (tag === TABLE_TAG_HHEA) {
1370
+ hheaTableOffset = view.getUint32(offset + 8);
1324
1371
  }
1325
- if (tag === 'OS/2') {
1326
- os2TableOffset = view.getUint32(12 + i * 16 + 8);
1372
+ else if (tag === TABLE_TAG_OS2) {
1373
+ os2TableOffset = view.getUint32(offset + 8);
1327
1374
  }
1328
- if (tag === 'fvar') {
1329
- fvarTableOffset = view.getUint32(12 + i * 16 + 8);
1375
+ else if (tag === TABLE_TAG_FVAR) {
1376
+ fvarTableOffset = view.getUint32(offset + 8);
1330
1377
  }
1331
- if (tag === 'STAT') {
1332
- statTableOffset = view.getUint32(12 + i * 16 + 8);
1378
+ else if (tag === TABLE_TAG_STAT) {
1379
+ statTableOffset = view.getUint32(offset + 8);
1333
1380
  }
1334
- if (tag === 'name') {
1335
- nameTableOffset = view.getUint32(12 + i * 16 + 8);
1381
+ else if (tag === TABLE_TAG_NAME) {
1382
+ nameTableOffset = view.getUint32(offset + 8);
1336
1383
  }
1337
1384
  }
1338
1385
  const unitsPerEm = headTableOffset
@@ -1375,6 +1422,88 @@ class FontMetadataExtractor {
1375
1422
  axisNames
1376
1423
  };
1377
1424
  }
1425
+ static extractFeatureTags(fontBuffer) {
1426
+ const view = new DataView(fontBuffer);
1427
+ const numTables = view.getUint16(4);
1428
+ let gsubTableOffset = 0;
1429
+ let gposTableOffset = 0;
1430
+ let nameTableOffset = 0;
1431
+ for (let i = 0; i < numTables; i++) {
1432
+ const offset = 12 + i * 16;
1433
+ const tag = view.getUint32(offset);
1434
+ if (tag === TABLE_TAG_GSUB) {
1435
+ gsubTableOffset = view.getUint32(offset + 8);
1436
+ }
1437
+ else if (tag === TABLE_TAG_GPOS) {
1438
+ gposTableOffset = view.getUint32(offset + 8);
1439
+ }
1440
+ else if (tag === TABLE_TAG_NAME) {
1441
+ nameTableOffset = view.getUint32(offset + 8);
1442
+ }
1443
+ }
1444
+ const features = new Set();
1445
+ const featureNames = {};
1446
+ try {
1447
+ if (gsubTableOffset) {
1448
+ const gsubData = this.extractFeatureDataFromTable(view, gsubTableOffset, nameTableOffset);
1449
+ gsubData.features.forEach(f => features.add(f));
1450
+ Object.assign(featureNames, gsubData.names);
1451
+ }
1452
+ if (gposTableOffset) {
1453
+ const gposData = this.extractFeatureDataFromTable(view, gposTableOffset, nameTableOffset);
1454
+ gposData.features.forEach(f => features.add(f));
1455
+ Object.assign(featureNames, gposData.names);
1456
+ }
1457
+ }
1458
+ catch (e) {
1459
+ return undefined;
1460
+ }
1461
+ const featureArray = Array.from(features).sort();
1462
+ if (featureArray.length === 0)
1463
+ return undefined;
1464
+ return {
1465
+ tags: featureArray,
1466
+ names: Object.keys(featureNames).length > 0 ? featureNames : {}
1467
+ };
1468
+ }
1469
+ static extractFeatureDataFromTable(view, tableOffset, nameTableOffset) {
1470
+ const featureListOffset = view.getUint16(tableOffset + 6);
1471
+ const featureListStart = tableOffset + featureListOffset;
1472
+ const featureCount = view.getUint16(featureListStart);
1473
+ const features = [];
1474
+ const names = {};
1475
+ for (let i = 0; i < featureCount; i++) {
1476
+ const recordOffset = featureListStart + 2 + i * 6;
1477
+ // Decode feature tag
1478
+ const tag = String.fromCharCode(view.getUint8(recordOffset), view.getUint8(recordOffset + 1), view.getUint8(recordOffset + 2), view.getUint8(recordOffset + 3));
1479
+ features.push(tag);
1480
+ // Extract feature name for stylistic sets and character variants
1481
+ if (/^(ss\d{2}|cv\d{2})$/.test(tag) && nameTableOffset) {
1482
+ const featureOffset = view.getUint16(recordOffset + 4);
1483
+ const featureTableStart = featureListStart + featureOffset;
1484
+ // Feature table structure:
1485
+ // uint16 FeatureParams offset
1486
+ // uint16 LookupCount
1487
+ // uint16[LookupCount] LookupListIndex
1488
+ const featureParamsOffset = view.getUint16(featureTableStart);
1489
+ // FeatureParams for ss features:
1490
+ // uint16 Version (should be 0)
1491
+ // uint16 UINameID
1492
+ if (featureParamsOffset !== 0) {
1493
+ const paramsStart = featureTableStart + featureParamsOffset;
1494
+ const version = view.getUint16(paramsStart);
1495
+ if (version === 0) {
1496
+ const nameID = view.getUint16(paramsStart + 2);
1497
+ const name = this.getNameFromNameTable(view, nameTableOffset, nameID);
1498
+ if (name) {
1499
+ names[tag] = name;
1500
+ }
1501
+ }
1502
+ }
1503
+ }
1504
+ }
1505
+ return { features, names };
1506
+ }
1378
1507
  static extractAxisNames(view, statOffset, nameOffset) {
1379
1508
  try {
1380
1509
  // STAT table structure
@@ -1585,7 +1714,7 @@ class WoffConverter {
1585
1714
  const padding = (4 - (table.origLength % 4)) % 4;
1586
1715
  sfntOffset += padding;
1587
1716
  }
1588
- debugLogger.log('WOFF font decompressed successfully');
1717
+ logger.log('WOFF font decompressed successfully');
1589
1718
  return sfntData.buffer.slice(0, sfntOffset);
1590
1719
  }
1591
1720
  static async decompressZlib(compressedData) {
@@ -1614,7 +1743,7 @@ class FontLoader {
1614
1743
  // Check if this is a WOFF font and decompress if needed
1615
1744
  const format = WoffConverter.detectFormat(fontBuffer);
1616
1745
  if (format === 'woff') {
1617
- debugLogger.log('WOFF font detected, decompressing...');
1746
+ logger.log('WOFF font detected, decompressing...');
1618
1747
  fontBuffer = await WoffConverter.decompressWoff(fontBuffer);
1619
1748
  }
1620
1749
  else if (format === 'woff2') {
@@ -1652,6 +1781,7 @@ class FontLoader {
1652
1781
  };
1653
1782
  }
1654
1783
  }
1784
+ const featureData = FontMetadataExtractor.extractFeatureTags(fontBuffer);
1655
1785
  return {
1656
1786
  hb,
1657
1787
  fontBlob,
@@ -1662,11 +1792,13 @@ class FontLoader {
1662
1792
  metrics,
1663
1793
  fontVariations,
1664
1794
  isVariable,
1665
- variationAxes
1795
+ variationAxes,
1796
+ availableFeatures: featureData?.tags,
1797
+ featureNames: featureData?.names
1666
1798
  };
1667
1799
  }
1668
1800
  catch (error) {
1669
- debugLogger.error('Failed to load font:', error);
1801
+ logger.error('Failed to load font:', error);
1670
1802
  throw error;
1671
1803
  }
1672
1804
  finally {
@@ -1687,7 +1819,7 @@ class FontLoader {
1687
1819
  }
1688
1820
  }
1689
1821
  catch (error) {
1690
- debugLogger.error('Error destroying font resources:', error);
1822
+ logger.error('Error destroying font resources:', error);
1691
1823
  }
1692
1824
  }
1693
1825
  }
@@ -2089,7 +2221,7 @@ class Tessellator {
2089
2221
  if (valid.length === 0) {
2090
2222
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2091
2223
  }
2092
- debugLogger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2224
+ logger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2093
2225
  return this.tessellate(valid, removeOverlaps, isCFF);
2094
2226
  }
2095
2227
  tessellate(paths, removeOverlaps, isCFF) {
@@ -2099,27 +2231,35 @@ class Tessellator {
2099
2231
  : paths;
2100
2232
  let contours = this.pathsToContours(normalizedPaths);
2101
2233
  if (removeOverlaps) {
2102
- debugLogger.log('Two-pass: boundary extraction then triangulation');
2234
+ logger.log('Two-pass: boundary extraction then triangulation');
2103
2235
  // Extract boundaries to remove overlaps
2236
+ perfLogger.start('Tessellator.boundaryPass', {
2237
+ contourCount: contours.length
2238
+ });
2104
2239
  const boundaryResult = this.performTessellation(contours, 'boundary');
2240
+ perfLogger.end('Tessellator.boundaryPass');
2105
2241
  if (!boundaryResult) {
2106
- debugLogger.warn('libtess returned empty result from boundary pass');
2242
+ logger.warn('libtess returned empty result from boundary pass');
2107
2243
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2108
2244
  }
2109
2245
  // Convert boundary elements back to contours
2110
2246
  contours = this.boundaryToContours(boundaryResult);
2111
- debugLogger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2247
+ logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2112
2248
  }
2113
2249
  else {
2114
- debugLogger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2250
+ logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2115
2251
  }
2116
2252
  // Triangulate the contours
2253
+ perfLogger.start('Tessellator.triangulationPass', {
2254
+ contourCount: contours.length
2255
+ });
2117
2256
  const triangleResult = this.performTessellation(contours, 'triangles');
2257
+ perfLogger.end('Tessellator.triangulationPass');
2118
2258
  if (!triangleResult) {
2119
2259
  const warning = removeOverlaps
2120
2260
  ? 'libtess returned empty result from triangulation pass'
2121
2261
  : 'libtess returned empty result from single-pass triangulation';
2122
- debugLogger.warn(warning);
2262
+ logger.warn(warning);
2123
2263
  return { triangles: { vertices: [], indices: [] }, contours };
2124
2264
  }
2125
2265
  return {
@@ -2174,7 +2314,7 @@ class Tessellator {
2174
2314
  return idx;
2175
2315
  });
2176
2316
  tess.gluTessCallback(libtess_minExports.gluEnum.GLU_TESS_ERROR, (errno) => {
2177
- debugLogger.warn(`libtess error: ${errno}`);
2317
+ logger.warn(`libtess error: ${errno}`);
2178
2318
  });
2179
2319
  tess.gluTessNormal(0, 0, 1);
2180
2320
  tess.gluTessBeginPolygon(null);
@@ -2301,10 +2441,16 @@ const OVERLAP_EPSILON = 1e-3;
2301
2441
  class BoundaryClusterer {
2302
2442
  constructor() { }
2303
2443
  cluster(glyphContoursList, positions) {
2444
+ perfLogger.start('BoundaryClusterer.cluster', {
2445
+ glyphCount: glyphContoursList.length
2446
+ });
2304
2447
  if (glyphContoursList.length === 0) {
2448
+ perfLogger.end('BoundaryClusterer.cluster');
2305
2449
  return [];
2306
2450
  }
2307
- return this.clusterSweepLine(glyphContoursList, positions);
2451
+ const result = this.clusterSweepLine(glyphContoursList, positions);
2452
+ perfLogger.end('BoundaryClusterer.cluster');
2453
+ return result;
2308
2454
  }
2309
2455
  clusterSweepLine(glyphContoursList, positions) {
2310
2456
  const n = glyphContoursList.length;
@@ -2945,6 +3091,11 @@ class GlyphContourCollector {
2945
3091
  this.currentGlyphBounds.max.set(-Infinity, -Infinity);
2946
3092
  // Record position for this glyph
2947
3093
  this.glyphPositions.push(this.currentPosition.clone());
3094
+ // Time polygonization + path optimization per glyph
3095
+ perfLogger.start('Glyph.polygonizeAndOptimize', {
3096
+ glyphId,
3097
+ textIndex
3098
+ });
2948
3099
  }
2949
3100
  finishGlyph() {
2950
3101
  if (this.currentPath) {
@@ -2968,6 +3119,8 @@ class GlyphContourCollector {
2968
3119
  // Track textIndex separately
2969
3120
  this.glyphTextIndices.push(this.currentTextIndex);
2970
3121
  }
3122
+ // Stop timing for this glyph (even if it ended up empty)
3123
+ perfLogger.end('Glyph.polygonizeAndOptimize');
2971
3124
  this.currentGlyphPaths = [];
2972
3125
  }
2973
3126
  onMoveTo(x, y) {
@@ -3196,12 +3349,190 @@ class DrawCallbackHandler {
3196
3349
  }
3197
3350
  }
3198
3351
  catch (error) {
3199
- debugLogger.warn('Error destroying draw callbacks:', error);
3352
+ logger.warn('Error destroying draw callbacks:', error);
3200
3353
  }
3201
3354
  this.collector = undefined;
3202
3355
  }
3203
3356
  }
3204
3357
 
3358
+ // Generic LRU (Least Recently Used) cache with optional memory-based eviction
3359
+ class LRUCache {
3360
+ constructor(options = {}) {
3361
+ this.cache = new Map();
3362
+ this.head = null;
3363
+ this.tail = null;
3364
+ this.stats = {
3365
+ hits: 0,
3366
+ misses: 0,
3367
+ evictions: 0,
3368
+ size: 0,
3369
+ memoryUsage: 0
3370
+ };
3371
+ this.options = {
3372
+ maxEntries: options.maxEntries ?? Infinity,
3373
+ maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
3374
+ calculateSize: options.calculateSize ?? (() => 0),
3375
+ onEvict: options.onEvict
3376
+ };
3377
+ }
3378
+ get(key) {
3379
+ const node = this.cache.get(key);
3380
+ if (node) {
3381
+ this.stats.hits++;
3382
+ this.moveToHead(node);
3383
+ return node.value;
3384
+ }
3385
+ else {
3386
+ this.stats.misses++;
3387
+ return undefined;
3388
+ }
3389
+ }
3390
+ has(key) {
3391
+ return this.cache.has(key);
3392
+ }
3393
+ set(key, value) {
3394
+ // If key already exists, update it
3395
+ const existingNode = this.cache.get(key);
3396
+ if (existingNode) {
3397
+ const oldSize = this.options.calculateSize(existingNode.value);
3398
+ const newSize = this.options.calculateSize(value);
3399
+ this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
3400
+ existingNode.value = value;
3401
+ this.moveToHead(existingNode);
3402
+ return;
3403
+ }
3404
+ const size = this.options.calculateSize(value);
3405
+ // Evict entries if we exceed limits
3406
+ this.evictIfNeeded(size);
3407
+ // Create new node
3408
+ const node = {
3409
+ key,
3410
+ value,
3411
+ prev: null,
3412
+ next: null
3413
+ };
3414
+ this.cache.set(key, node);
3415
+ this.addToHead(node);
3416
+ this.stats.size = this.cache.size;
3417
+ this.stats.memoryUsage += size;
3418
+ }
3419
+ delete(key) {
3420
+ const node = this.cache.get(key);
3421
+ if (!node)
3422
+ return false;
3423
+ const size = this.options.calculateSize(node.value);
3424
+ this.removeNode(node);
3425
+ this.cache.delete(key);
3426
+ this.stats.size = this.cache.size;
3427
+ this.stats.memoryUsage -= size;
3428
+ if (this.options.onEvict) {
3429
+ this.options.onEvict(key, node.value);
3430
+ }
3431
+ return true;
3432
+ }
3433
+ clear() {
3434
+ if (this.options.onEvict) {
3435
+ for (const [key, node] of this.cache) {
3436
+ this.options.onEvict(key, node.value);
3437
+ }
3438
+ }
3439
+ this.cache.clear();
3440
+ this.head = null;
3441
+ this.tail = null;
3442
+ this.stats = {
3443
+ hits: 0,
3444
+ misses: 0,
3445
+ evictions: 0,
3446
+ size: 0,
3447
+ memoryUsage: 0
3448
+ };
3449
+ }
3450
+ getStats() {
3451
+ const total = this.stats.hits + this.stats.misses;
3452
+ const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
3453
+ const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
3454
+ return {
3455
+ ...this.stats,
3456
+ hitRate,
3457
+ memoryUsageMB
3458
+ };
3459
+ }
3460
+ keys() {
3461
+ const keys = [];
3462
+ let current = this.head;
3463
+ while (current) {
3464
+ keys.push(current.key);
3465
+ current = current.next;
3466
+ }
3467
+ return keys;
3468
+ }
3469
+ get size() {
3470
+ return this.cache.size;
3471
+ }
3472
+ evictIfNeeded(requiredSize) {
3473
+ // Evict by entry count
3474
+ while (this.cache.size >= this.options.maxEntries && this.tail) {
3475
+ this.evictTail();
3476
+ }
3477
+ // Evict by memory usage
3478
+ if (this.options.maxMemoryBytes < Infinity) {
3479
+ while (this.tail &&
3480
+ this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
3481
+ this.evictTail();
3482
+ }
3483
+ }
3484
+ }
3485
+ evictTail() {
3486
+ if (!this.tail)
3487
+ return;
3488
+ const nodeToRemove = this.tail;
3489
+ const size = this.options.calculateSize(nodeToRemove.value);
3490
+ this.removeTail();
3491
+ this.cache.delete(nodeToRemove.key);
3492
+ this.stats.size = this.cache.size;
3493
+ this.stats.memoryUsage -= size;
3494
+ this.stats.evictions++;
3495
+ if (this.options.onEvict) {
3496
+ this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
3497
+ }
3498
+ }
3499
+ addToHead(node) {
3500
+ if (!this.head) {
3501
+ this.head = this.tail = node;
3502
+ }
3503
+ else {
3504
+ node.next = this.head;
3505
+ this.head.prev = node;
3506
+ this.head = node;
3507
+ }
3508
+ }
3509
+ removeNode(node) {
3510
+ if (node.prev) {
3511
+ node.prev.next = node.next;
3512
+ }
3513
+ else {
3514
+ this.head = node.next;
3515
+ }
3516
+ if (node.next) {
3517
+ node.next.prev = node.prev;
3518
+ }
3519
+ else {
3520
+ this.tail = node.prev;
3521
+ }
3522
+ }
3523
+ removeTail() {
3524
+ if (this.tail) {
3525
+ this.removeNode(this.tail);
3526
+ }
3527
+ }
3528
+ moveToHead(node) {
3529
+ if (node === this.head)
3530
+ return;
3531
+ this.removeNode(node);
3532
+ this.addToHead(node);
3533
+ }
3534
+ }
3535
+
3205
3536
  class GlyphGeometryBuilder {
3206
3537
  constructor(cache, loadedFont) {
3207
3538
  this.fontId = 'default';
@@ -3214,6 +3545,16 @@ class GlyphGeometryBuilder {
3214
3545
  this.collector = new GlyphContourCollector();
3215
3546
  this.drawCallbacks = new DrawCallbackHandler();
3216
3547
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3548
+ this.contourCache = new LRUCache({
3549
+ maxEntries: 1000,
3550
+ calculateSize: (contours) => {
3551
+ let size = 0;
3552
+ for (const path of contours.paths) {
3553
+ size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
3554
+ }
3555
+ return size + 64; // bounds overhead
3556
+ }
3557
+ });
3217
3558
  }
3218
3559
  getOptimizationStats() {
3219
3560
  return this.collector.getOptimizationStats();
@@ -3358,30 +3699,37 @@ class GlyphGeometryBuilder {
3358
3699
  };
3359
3700
  }
3360
3701
  getContoursForGlyph(glyphId) {
3702
+ const cached = this.contourCache.get(glyphId);
3703
+ if (cached) {
3704
+ return cached;
3705
+ }
3361
3706
  this.collector.reset();
3362
3707
  this.collector.beginGlyph(glyphId, 0);
3363
3708
  this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
3364
3709
  this.collector.finishGlyph();
3365
3710
  const collected = this.collector.getCollectedGlyphs()[0];
3366
- // Return empty contours for glyphs with no paths (e.g., space, zero-width characters)
3367
- if (!collected) {
3368
- return {
3369
- glyphId,
3370
- paths: [],
3371
- bounds: {
3372
- min: { x: 0, y: 0 },
3373
- max: { x: 0, y: 0 }
3374
- }
3375
- };
3376
- }
3377
- return collected;
3711
+ const contours = collected || {
3712
+ glyphId,
3713
+ paths: [],
3714
+ bounds: {
3715
+ min: { x: 0, y: 0 },
3716
+ max: { x: 0, y: 0 }
3717
+ }
3718
+ };
3719
+ this.contourCache.set(glyphId, contours);
3720
+ return contours;
3378
3721
  }
3379
3722
  tessellateGlyphCluster(paths, depth, isCFF) {
3380
3723
  const processedGeometry = this.tessellator.process(paths, true, isCFF);
3381
3724
  return this.extrudeAndPackage(processedGeometry, depth);
3382
3725
  }
3383
3726
  extrudeAndPackage(processedGeometry, depth) {
3727
+ perfLogger.start('Extruder.extrude', {
3728
+ depth,
3729
+ upem: this.loadedFont.upem
3730
+ });
3384
3731
  const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
3732
+ perfLogger.end('Extruder.extrude');
3385
3733
  // Compute bounding box from vertices
3386
3734
  const vertices = extrudedResult.vertices;
3387
3735
  let minX = Infinity, minY = Infinity, minZ = Infinity;
@@ -3423,6 +3771,7 @@ class GlyphGeometryBuilder {
3423
3771
  pathCount: glyphContours.paths.length
3424
3772
  });
3425
3773
  const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
3774
+ perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
3426
3775
  return this.extrudeAndPackage(processedGeometry, depth);
3427
3776
  }
3428
3777
  updatePlaneBounds(glyphBounds, planeBounds) {
@@ -3486,7 +3835,8 @@ class TextShaper {
3486
3835
  }
3487
3836
  buffer.addText(lineInfo.text);
3488
3837
  buffer.guessSegmentProperties();
3489
- this.loadedFont.hb.shape(this.loadedFont.font, buffer);
3838
+ const featuresString = convertFontFeaturesToString(this.loadedFont.fontFeatures);
3839
+ this.loadedFont.hb.shape(this.loadedFont.font, buffer, featuresString);
3490
3840
  const glyphInfos = buffer.json(this.loadedFont.font);
3491
3841
  buffer.destroy();
3492
3842
  const clusters = [];
@@ -3494,6 +3844,7 @@ class TextShaper {
3494
3844
  let currentClusterText = '';
3495
3845
  let clusterStartPosition = new Vec3();
3496
3846
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
3847
+ // Apply letter spacing between glyphs (must match what was used in width measurements)
3497
3848
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3498
3849
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
3499
3850
  for (let i = 0; i < glyphInfos.length; i++) {
@@ -3565,12 +3916,10 @@ class TextShaper {
3565
3916
  const stretchFactor = SPACE_STRETCH_RATIO;
3566
3917
  const shrinkFactor = SPACE_SHRINK_RATIO;
3567
3918
  if (lineInfo.adjustmentRatio > 0) {
3568
- spaceAdjustment =
3569
- lineInfo.adjustmentRatio * width * stretchFactor;
3919
+ spaceAdjustment = lineInfo.adjustmentRatio * width * stretchFactor;
3570
3920
  }
3571
3921
  else if (lineInfo.adjustmentRatio < 0) {
3572
- spaceAdjustment =
3573
- lineInfo.adjustmentRatio * width * shrinkFactor;
3922
+ spaceAdjustment = lineInfo.adjustmentRatio * width * shrinkFactor;
3574
3923
  }
3575
3924
  }
3576
3925
  return spaceAdjustment;
@@ -4495,12 +4844,16 @@ class Text {
4495
4844
  const baseFontKey = typeof options.font === 'string'
4496
4845
  ? options.font
4497
4846
  : `buffer-${Text.generateFontContentHash(options.font)}`;
4498
- const fontKey = options.fontVariations
4499
- ? `${baseFontKey}_${JSON.stringify(options.fontVariations)}`
4500
- : baseFontKey;
4847
+ let fontKey = baseFontKey;
4848
+ if (options.fontVariations) {
4849
+ fontKey += `_var_${JSON.stringify(options.fontVariations)}`;
4850
+ }
4851
+ if (options.fontFeatures) {
4852
+ fontKey += `_feat_${JSON.stringify(options.fontFeatures)}`;
4853
+ }
4501
4854
  let loadedFont = Text.fontCache.get(fontKey);
4502
4855
  if (!loadedFont) {
4503
- loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations);
4856
+ loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
4504
4857
  }
4505
4858
  const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
4506
4859
  text.setLoadedFont(loadedFont);
@@ -4514,12 +4867,11 @@ class Text {
4514
4867
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
4515
4868
  };
4516
4869
  }
4517
- static async loadAndCacheFont(fontKey, font, fontVariations) {
4870
+ static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
4518
4871
  const tempText = new Text();
4519
- await tempText.loadFont(font, fontVariations);
4872
+ await tempText.loadFont(font, fontVariations, fontFeatures);
4520
4873
  const loadedFont = tempText.getLoadedFont();
4521
4874
  Text.fontCache.set(fontKey, loadedFont);
4522
- // Don't destroy tempText - the cached font references its HarfBuzz objects
4523
4875
  return loadedFont;
4524
4876
  }
4525
4877
  static generateFontContentHash(buffer) {
@@ -4538,10 +4890,13 @@ class Text {
4538
4890
  const contentHash = Text.generateFontContentHash(loadedFont._buffer);
4539
4891
  this.currentFontId = `font_${contentHash}`;
4540
4892
  if (loadedFont.fontVariations) {
4541
- this.currentFontId += `_${JSON.stringify(loadedFont.fontVariations)}`;
4893
+ this.currentFontId += `_var_${JSON.stringify(loadedFont.fontVariations)}`;
4894
+ }
4895
+ if (loadedFont.fontFeatures) {
4896
+ this.currentFontId += `_feat_${JSON.stringify(loadedFont.fontFeatures)}`;
4542
4897
  }
4543
4898
  }
4544
- async loadFont(fontSrc, fontVariations) {
4899
+ async loadFont(fontSrc, fontVariations, fontFeatures) {
4545
4900
  perfLogger.start('Text.loadFont', {
4546
4901
  fontSrc: typeof fontSrc === 'string' ? fontSrc : `buffer(${fontSrc.byteLength})`
4547
4902
  });
@@ -4562,14 +4917,20 @@ class Text {
4562
4917
  this.destroy();
4563
4918
  }
4564
4919
  this.loadedFont = await this.fontLoader.loadFont(fontBuffer, fontVariations);
4920
+ if (fontFeatures) {
4921
+ this.loadedFont.fontFeatures = fontFeatures;
4922
+ }
4565
4923
  const contentHash = Text.generateFontContentHash(fontBuffer);
4566
4924
  this.currentFontId = `font_${contentHash}`;
4567
4925
  if (fontVariations) {
4568
- this.currentFontId += `_${JSON.stringify(fontVariations)}`;
4926
+ this.currentFontId += `_var_${JSON.stringify(fontVariations)}`;
4927
+ }
4928
+ if (fontFeatures) {
4929
+ this.currentFontId += `_feat_${JSON.stringify(fontFeatures)}`;
4569
4930
  }
4570
4931
  }
4571
4932
  catch (error) {
4572
- debugLogger.error('Failed to load font:', error);
4933
+ logger.error('Failed to load font:', error);
4573
4934
  throw error;
4574
4935
  }
4575
4936
  finally {
@@ -4644,7 +5005,7 @@ class Text {
4644
5005
  };
4645
5006
  }
4646
5007
  catch (error) {
4647
- debugLogger.warn(`Failed to load patterns for ${language}: ${error}`);
5008
+ logger.warn(`Failed to load patterns for ${language}: ${error}`);
4648
5009
  return {
4649
5010
  ...options,
4650
5011
  layout: {
@@ -4908,7 +5269,7 @@ class Text {
4908
5269
  Text.patternCache.set(language, pattern);
4909
5270
  }
4910
5271
  catch (error) {
4911
- debugLogger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
5272
+ logger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
4912
5273
  }
4913
5274
  }
4914
5275
  }));
@@ -4970,7 +5331,7 @@ class Text {
4970
5331
  FontLoader.destroyFont(currentFont);
4971
5332
  }
4972
5333
  catch (error) {
4973
- debugLogger.warn('Error destroying HarfBuzz objects:', error);
5334
+ logger.warn('Error destroying HarfBuzz objects:', error);
4974
5335
  }
4975
5336
  finally {
4976
5337
  this.loadedFont = undefined;