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.umd.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
@@ -27,7 +27,7 @@
27
27
  }
28
28
  return false;
29
29
  })();
30
- class DebugLogger {
30
+ class Logger {
31
31
  warn(message, ...args) {
32
32
  console.warn(message, ...args);
33
33
  }
@@ -38,7 +38,7 @@
38
38
  isLogEnabled && console.log(message, ...args);
39
39
  }
40
40
  }
41
- const debugLogger = new DebugLogger();
41
+ const logger = new Logger();
42
42
 
43
43
  class PerformanceLogger {
44
44
  constructor() {
@@ -64,7 +64,7 @@
64
64
  const endTime = performance.now();
65
65
  const startTime = this.activeTimers.get(name);
66
66
  if (startTime === undefined) {
67
- debugLogger.warn(`Performance timer "${name}" was not started`);
67
+ logger.warn(`Performance timer "${name}" was not started`);
68
68
  return null;
69
69
  }
70
70
  const duration = endTime - startTime;
@@ -127,6 +127,9 @@
127
127
  this.metrics.length = 0;
128
128
  this.activeTimers.clear();
129
129
  }
130
+ reset() {
131
+ this.clear();
132
+ }
130
133
  time(name, fn, metadata) {
131
134
  if (!isLogEnabled)
132
135
  return fn();
@@ -326,6 +329,8 @@
326
329
  const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
327
330
  return filteredPoints;
328
331
  }
332
+ // Converts text into items (boxes, glues, penalties) for line breaking.
333
+ // The measureText function should return widths that include any letter spacing.
329
334
  static itemizeText(text, measureText, // function to measure text width
330
335
  hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
331
336
  const items = [];
@@ -567,7 +572,7 @@
567
572
  let useHyphenation = hyphenate;
568
573
  if (useHyphenation &&
569
574
  (!hyphenationPatterns || !hyphenationPatterns[language])) {
570
- debugLogger.warn(`Hyphenation patterns for ${language} not available`);
575
+ logger.warn(`Hyphenation patterns for ${language} not available`);
571
576
  useHyphenation = false;
572
577
  }
573
578
  // Calculate initial emergency stretch (TeX default: 0)
@@ -1182,12 +1187,43 @@
1182
1187
  }
1183
1188
  }
1184
1189
 
1190
+ // Convert feature objects to HarfBuzz comma-separated format
1191
+ function convertFontFeaturesToString(features) {
1192
+ if (!features || Object.keys(features).length === 0) {
1193
+ return undefined;
1194
+ }
1195
+ const featureStrings = [];
1196
+ for (const [tag, value] of Object.entries(features)) {
1197
+ if (!/^[a-zA-Z0-9]{4}$/.test(tag)) {
1198
+ logger.warn(`Invalid OpenType feature tag: "${tag}". Tags must be exactly 4 alphanumeric characters.`);
1199
+ continue;
1200
+ }
1201
+ if (value === false || value === 0) {
1202
+ featureStrings.push(`${tag}=0`);
1203
+ }
1204
+ else if (value === true || value === 1) {
1205
+ featureStrings.push(tag);
1206
+ }
1207
+ else if (typeof value === 'number' && value > 1) {
1208
+ featureStrings.push(`${tag}=${Math.floor(value)}`);
1209
+ }
1210
+ else {
1211
+ logger.warn(`Invalid value for feature "${tag}": ${value}. Expected boolean or positive number.`);
1212
+ }
1213
+ }
1214
+ return featureStrings.length > 0 ? featureStrings.join(',') : undefined;
1215
+ }
1216
+
1185
1217
  class TextMeasurer {
1218
+ // Measures text width including letter spacing
1219
+ // Letter spacing is added uniformly after each glyph during measurement,
1220
+ // so the widths given to the line-breaking algorithm already account for tracking
1186
1221
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1187
1222
  const buffer = loadedFont.hb.createBuffer();
1188
1223
  buffer.addText(text);
1189
1224
  buffer.guessSegmentProperties();
1190
- loadedFont.hb.shape(loadedFont.font, buffer);
1225
+ const featuresString = convertFontFeaturesToString(loadedFont.fontFeatures);
1226
+ loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
1191
1227
  const glyphInfos = buffer.json(loadedFont.font);
1192
1228
  const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
1193
1229
  // Calculate total advance width with letter spacing
@@ -1211,6 +1247,8 @@
1211
1247
  const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection, letterSpacing } = options;
1212
1248
  let lines;
1213
1249
  if (width) {
1250
+ // Line breaking uses a measureText function that already includes letterSpacing,
1251
+ // so widths passed into LineBreak.breakText account for tracking
1214
1252
  lines = LineBreak.breakText({
1215
1253
  text,
1216
1254
  width,
@@ -1234,7 +1272,8 @@
1234
1272
  looseness,
1235
1273
  disableSingleWordDetection,
1236
1274
  unitsPerEm: this.loadedFont.upem,
1237
- measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing)
1275
+ measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1276
+ )
1238
1277
  });
1239
1278
  }
1240
1279
  else {
@@ -1288,6 +1327,17 @@
1288
1327
  const FONT_SIGNATURE_TRUE_TYPE_COLLECTION = 0x74746366; // 'ttcf'
1289
1328
  const FONT_SIGNATURE_WOFF = 0x774f4646; // 'wOFF'
1290
1329
  const FONT_SIGNATURE_WOFF2 = 0x774f4632; // 'wOF2'
1330
+ // Table Tags
1331
+ const TABLE_TAG_HEAD = 0x68656164; // 'head'
1332
+ const TABLE_TAG_HHEA = 0x68686561; // 'hhea'
1333
+ const TABLE_TAG_OS2 = 0x4f532f32; // 'OS/2'
1334
+ const TABLE_TAG_FVAR = 0x66766172; // 'fvar'
1335
+ const TABLE_TAG_STAT = 0x53544154; // 'STAT'
1336
+ const TABLE_TAG_NAME = 0x6e616d65; // 'name'
1337
+ const TABLE_TAG_CFF = 0x43464620; // 'CFF '
1338
+ const TABLE_TAG_CFF2 = 0x43464632; // 'CFF2'
1339
+ const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
1340
+ const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
1291
1341
 
1292
1342
  class FontMetadataExtractor {
1293
1343
  static extractMetadata(fontBuffer) {
@@ -1304,7 +1354,6 @@
1304
1354
  if (!validSignatures.includes(sfntVersion)) {
1305
1355
  throw new Error(`Invalid font format. Expected TrueType or OpenType, got signature: 0x${sfntVersion.toString(16)}`);
1306
1356
  }
1307
- const buffer = new Uint8Array(fontBuffer);
1308
1357
  const numTables = view.getUint16(4); // OpenType header - number of tables is at offset 4
1309
1358
  let isCFF = false;
1310
1359
  let headTableOffset = 0;
@@ -1314,30 +1363,28 @@
1314
1363
  let nameTableOffset = 0;
1315
1364
  let fvarTableOffset = 0;
1316
1365
  for (let i = 0; i < numTables; i++) {
1317
- const tag = new TextDecoder().decode(buffer.slice(12 + i * 16, 12 + i * 16 + 4));
1318
- if (tag === 'CFF ') {
1319
- isCFF = true;
1320
- }
1321
- else if (tag === 'CFF2') {
1366
+ const offset = 12 + i * 16;
1367
+ const tag = view.getUint32(offset);
1368
+ if (tag === TABLE_TAG_CFF || tag === TABLE_TAG_CFF2) {
1322
1369
  isCFF = true;
1323
1370
  }
1324
- if (tag === 'head') {
1325
- headTableOffset = view.getUint32(12 + i * 16 + 8);
1371
+ else if (tag === TABLE_TAG_HEAD) {
1372
+ headTableOffset = view.getUint32(offset + 8);
1326
1373
  }
1327
- if (tag === 'hhea') {
1328
- hheaTableOffset = view.getUint32(12 + i * 16 + 8);
1374
+ else if (tag === TABLE_TAG_HHEA) {
1375
+ hheaTableOffset = view.getUint32(offset + 8);
1329
1376
  }
1330
- if (tag === 'OS/2') {
1331
- os2TableOffset = view.getUint32(12 + i * 16 + 8);
1377
+ else if (tag === TABLE_TAG_OS2) {
1378
+ os2TableOffset = view.getUint32(offset + 8);
1332
1379
  }
1333
- if (tag === 'fvar') {
1334
- fvarTableOffset = view.getUint32(12 + i * 16 + 8);
1380
+ else if (tag === TABLE_TAG_FVAR) {
1381
+ fvarTableOffset = view.getUint32(offset + 8);
1335
1382
  }
1336
- if (tag === 'STAT') {
1337
- statTableOffset = view.getUint32(12 + i * 16 + 8);
1383
+ else if (tag === TABLE_TAG_STAT) {
1384
+ statTableOffset = view.getUint32(offset + 8);
1338
1385
  }
1339
- if (tag === 'name') {
1340
- nameTableOffset = view.getUint32(12 + i * 16 + 8);
1386
+ else if (tag === TABLE_TAG_NAME) {
1387
+ nameTableOffset = view.getUint32(offset + 8);
1341
1388
  }
1342
1389
  }
1343
1390
  const unitsPerEm = headTableOffset
@@ -1380,6 +1427,88 @@
1380
1427
  axisNames
1381
1428
  };
1382
1429
  }
1430
+ static extractFeatureTags(fontBuffer) {
1431
+ const view = new DataView(fontBuffer);
1432
+ const numTables = view.getUint16(4);
1433
+ let gsubTableOffset = 0;
1434
+ let gposTableOffset = 0;
1435
+ let nameTableOffset = 0;
1436
+ for (let i = 0; i < numTables; i++) {
1437
+ const offset = 12 + i * 16;
1438
+ const tag = view.getUint32(offset);
1439
+ if (tag === TABLE_TAG_GSUB) {
1440
+ gsubTableOffset = view.getUint32(offset + 8);
1441
+ }
1442
+ else if (tag === TABLE_TAG_GPOS) {
1443
+ gposTableOffset = view.getUint32(offset + 8);
1444
+ }
1445
+ else if (tag === TABLE_TAG_NAME) {
1446
+ nameTableOffset = view.getUint32(offset + 8);
1447
+ }
1448
+ }
1449
+ const features = new Set();
1450
+ const featureNames = {};
1451
+ try {
1452
+ if (gsubTableOffset) {
1453
+ const gsubData = this.extractFeatureDataFromTable(view, gsubTableOffset, nameTableOffset);
1454
+ gsubData.features.forEach(f => features.add(f));
1455
+ Object.assign(featureNames, gsubData.names);
1456
+ }
1457
+ if (gposTableOffset) {
1458
+ const gposData = this.extractFeatureDataFromTable(view, gposTableOffset, nameTableOffset);
1459
+ gposData.features.forEach(f => features.add(f));
1460
+ Object.assign(featureNames, gposData.names);
1461
+ }
1462
+ }
1463
+ catch (e) {
1464
+ return undefined;
1465
+ }
1466
+ const featureArray = Array.from(features).sort();
1467
+ if (featureArray.length === 0)
1468
+ return undefined;
1469
+ return {
1470
+ tags: featureArray,
1471
+ names: Object.keys(featureNames).length > 0 ? featureNames : {}
1472
+ };
1473
+ }
1474
+ static extractFeatureDataFromTable(view, tableOffset, nameTableOffset) {
1475
+ const featureListOffset = view.getUint16(tableOffset + 6);
1476
+ const featureListStart = tableOffset + featureListOffset;
1477
+ const featureCount = view.getUint16(featureListStart);
1478
+ const features = [];
1479
+ const names = {};
1480
+ for (let i = 0; i < featureCount; i++) {
1481
+ const recordOffset = featureListStart + 2 + i * 6;
1482
+ // Decode feature tag
1483
+ const tag = String.fromCharCode(view.getUint8(recordOffset), view.getUint8(recordOffset + 1), view.getUint8(recordOffset + 2), view.getUint8(recordOffset + 3));
1484
+ features.push(tag);
1485
+ // Extract feature name for stylistic sets and character variants
1486
+ if (/^(ss\d{2}|cv\d{2})$/.test(tag) && nameTableOffset) {
1487
+ const featureOffset = view.getUint16(recordOffset + 4);
1488
+ const featureTableStart = featureListStart + featureOffset;
1489
+ // Feature table structure:
1490
+ // uint16 FeatureParams offset
1491
+ // uint16 LookupCount
1492
+ // uint16[LookupCount] LookupListIndex
1493
+ const featureParamsOffset = view.getUint16(featureTableStart);
1494
+ // FeatureParams for ss features:
1495
+ // uint16 Version (should be 0)
1496
+ // uint16 UINameID
1497
+ if (featureParamsOffset !== 0) {
1498
+ const paramsStart = featureTableStart + featureParamsOffset;
1499
+ const version = view.getUint16(paramsStart);
1500
+ if (version === 0) {
1501
+ const nameID = view.getUint16(paramsStart + 2);
1502
+ const name = this.getNameFromNameTable(view, nameTableOffset, nameID);
1503
+ if (name) {
1504
+ names[tag] = name;
1505
+ }
1506
+ }
1507
+ }
1508
+ }
1509
+ }
1510
+ return { features, names };
1511
+ }
1383
1512
  static extractAxisNames(view, statOffset, nameOffset) {
1384
1513
  try {
1385
1514
  // STAT table structure
@@ -1590,7 +1719,7 @@
1590
1719
  const padding = (4 - (table.origLength % 4)) % 4;
1591
1720
  sfntOffset += padding;
1592
1721
  }
1593
- debugLogger.log('WOFF font decompressed successfully');
1722
+ logger.log('WOFF font decompressed successfully');
1594
1723
  return sfntData.buffer.slice(0, sfntOffset);
1595
1724
  }
1596
1725
  static async decompressZlib(compressedData) {
@@ -1619,7 +1748,7 @@
1619
1748
  // Check if this is a WOFF font and decompress if needed
1620
1749
  const format = WoffConverter.detectFormat(fontBuffer);
1621
1750
  if (format === 'woff') {
1622
- debugLogger.log('WOFF font detected, decompressing...');
1751
+ logger.log('WOFF font detected, decompressing...');
1623
1752
  fontBuffer = await WoffConverter.decompressWoff(fontBuffer);
1624
1753
  }
1625
1754
  else if (format === 'woff2') {
@@ -1657,6 +1786,7 @@
1657
1786
  };
1658
1787
  }
1659
1788
  }
1789
+ const featureData = FontMetadataExtractor.extractFeatureTags(fontBuffer);
1660
1790
  return {
1661
1791
  hb,
1662
1792
  fontBlob,
@@ -1667,11 +1797,13 @@
1667
1797
  metrics,
1668
1798
  fontVariations,
1669
1799
  isVariable,
1670
- variationAxes
1800
+ variationAxes,
1801
+ availableFeatures: featureData?.tags,
1802
+ featureNames: featureData?.names
1671
1803
  };
1672
1804
  }
1673
1805
  catch (error) {
1674
- debugLogger.error('Failed to load font:', error);
1806
+ logger.error('Failed to load font:', error);
1675
1807
  throw error;
1676
1808
  }
1677
1809
  finally {
@@ -1692,7 +1824,7 @@
1692
1824
  }
1693
1825
  }
1694
1826
  catch (error) {
1695
- debugLogger.error('Error destroying font resources:', error);
1827
+ logger.error('Error destroying font resources:', error);
1696
1828
  }
1697
1829
  }
1698
1830
  }
@@ -2096,7 +2228,7 @@
2096
2228
  if (valid.length === 0) {
2097
2229
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2098
2230
  }
2099
- debugLogger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2231
+ logger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2100
2232
  return this.tessellate(valid, removeOverlaps, isCFF);
2101
2233
  }
2102
2234
  tessellate(paths, removeOverlaps, isCFF) {
@@ -2106,27 +2238,35 @@
2106
2238
  : paths;
2107
2239
  let contours = this.pathsToContours(normalizedPaths);
2108
2240
  if (removeOverlaps) {
2109
- debugLogger.log('Two-pass: boundary extraction then triangulation');
2241
+ logger.log('Two-pass: boundary extraction then triangulation');
2110
2242
  // Extract boundaries to remove overlaps
2243
+ perfLogger.start('Tessellator.boundaryPass', {
2244
+ contourCount: contours.length
2245
+ });
2111
2246
  const boundaryResult = this.performTessellation(contours, 'boundary');
2247
+ perfLogger.end('Tessellator.boundaryPass');
2112
2248
  if (!boundaryResult) {
2113
- debugLogger.warn('libtess returned empty result from boundary pass');
2249
+ logger.warn('libtess returned empty result from boundary pass');
2114
2250
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2115
2251
  }
2116
2252
  // Convert boundary elements back to contours
2117
2253
  contours = this.boundaryToContours(boundaryResult);
2118
- debugLogger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2254
+ logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2119
2255
  }
2120
2256
  else {
2121
- debugLogger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2257
+ logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2122
2258
  }
2123
2259
  // Triangulate the contours
2260
+ perfLogger.start('Tessellator.triangulationPass', {
2261
+ contourCount: contours.length
2262
+ });
2124
2263
  const triangleResult = this.performTessellation(contours, 'triangles');
2264
+ perfLogger.end('Tessellator.triangulationPass');
2125
2265
  if (!triangleResult) {
2126
2266
  const warning = removeOverlaps
2127
2267
  ? 'libtess returned empty result from triangulation pass'
2128
2268
  : 'libtess returned empty result from single-pass triangulation';
2129
- debugLogger.warn(warning);
2269
+ logger.warn(warning);
2130
2270
  return { triangles: { vertices: [], indices: [] }, contours };
2131
2271
  }
2132
2272
  return {
@@ -2181,7 +2321,7 @@
2181
2321
  return idx;
2182
2322
  });
2183
2323
  tess.gluTessCallback(libtess_minExports.gluEnum.GLU_TESS_ERROR, (errno) => {
2184
- debugLogger.warn(`libtess error: ${errno}`);
2324
+ logger.warn(`libtess error: ${errno}`);
2185
2325
  });
2186
2326
  tess.gluTessNormal(0, 0, 1);
2187
2327
  tess.gluTessBeginPolygon(null);
@@ -2308,10 +2448,16 @@
2308
2448
  class BoundaryClusterer {
2309
2449
  constructor() { }
2310
2450
  cluster(glyphContoursList, positions) {
2451
+ perfLogger.start('BoundaryClusterer.cluster', {
2452
+ glyphCount: glyphContoursList.length
2453
+ });
2311
2454
  if (glyphContoursList.length === 0) {
2455
+ perfLogger.end('BoundaryClusterer.cluster');
2312
2456
  return [];
2313
2457
  }
2314
- return this.clusterSweepLine(glyphContoursList, positions);
2458
+ const result = this.clusterSweepLine(glyphContoursList, positions);
2459
+ perfLogger.end('BoundaryClusterer.cluster');
2460
+ return result;
2315
2461
  }
2316
2462
  clusterSweepLine(glyphContoursList, positions) {
2317
2463
  const n = glyphContoursList.length;
@@ -2952,6 +3098,11 @@
2952
3098
  this.currentGlyphBounds.max.set(-Infinity, -Infinity);
2953
3099
  // Record position for this glyph
2954
3100
  this.glyphPositions.push(this.currentPosition.clone());
3101
+ // Time polygonization + path optimization per glyph
3102
+ perfLogger.start('Glyph.polygonizeAndOptimize', {
3103
+ glyphId,
3104
+ textIndex
3105
+ });
2955
3106
  }
2956
3107
  finishGlyph() {
2957
3108
  if (this.currentPath) {
@@ -2975,6 +3126,8 @@
2975
3126
  // Track textIndex separately
2976
3127
  this.glyphTextIndices.push(this.currentTextIndex);
2977
3128
  }
3129
+ // Stop timing for this glyph (even if it ended up empty)
3130
+ perfLogger.end('Glyph.polygonizeAndOptimize');
2978
3131
  this.currentGlyphPaths = [];
2979
3132
  }
2980
3133
  onMoveTo(x, y) {
@@ -3203,12 +3356,190 @@
3203
3356
  }
3204
3357
  }
3205
3358
  catch (error) {
3206
- debugLogger.warn('Error destroying draw callbacks:', error);
3359
+ logger.warn('Error destroying draw callbacks:', error);
3207
3360
  }
3208
3361
  this.collector = undefined;
3209
3362
  }
3210
3363
  }
3211
3364
 
3365
+ // Generic LRU (Least Recently Used) cache with optional memory-based eviction
3366
+ class LRUCache {
3367
+ constructor(options = {}) {
3368
+ this.cache = new Map();
3369
+ this.head = null;
3370
+ this.tail = null;
3371
+ this.stats = {
3372
+ hits: 0,
3373
+ misses: 0,
3374
+ evictions: 0,
3375
+ size: 0,
3376
+ memoryUsage: 0
3377
+ };
3378
+ this.options = {
3379
+ maxEntries: options.maxEntries ?? Infinity,
3380
+ maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
3381
+ calculateSize: options.calculateSize ?? (() => 0),
3382
+ onEvict: options.onEvict
3383
+ };
3384
+ }
3385
+ get(key) {
3386
+ const node = this.cache.get(key);
3387
+ if (node) {
3388
+ this.stats.hits++;
3389
+ this.moveToHead(node);
3390
+ return node.value;
3391
+ }
3392
+ else {
3393
+ this.stats.misses++;
3394
+ return undefined;
3395
+ }
3396
+ }
3397
+ has(key) {
3398
+ return this.cache.has(key);
3399
+ }
3400
+ set(key, value) {
3401
+ // If key already exists, update it
3402
+ const existingNode = this.cache.get(key);
3403
+ if (existingNode) {
3404
+ const oldSize = this.options.calculateSize(existingNode.value);
3405
+ const newSize = this.options.calculateSize(value);
3406
+ this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
3407
+ existingNode.value = value;
3408
+ this.moveToHead(existingNode);
3409
+ return;
3410
+ }
3411
+ const size = this.options.calculateSize(value);
3412
+ // Evict entries if we exceed limits
3413
+ this.evictIfNeeded(size);
3414
+ // Create new node
3415
+ const node = {
3416
+ key,
3417
+ value,
3418
+ prev: null,
3419
+ next: null
3420
+ };
3421
+ this.cache.set(key, node);
3422
+ this.addToHead(node);
3423
+ this.stats.size = this.cache.size;
3424
+ this.stats.memoryUsage += size;
3425
+ }
3426
+ delete(key) {
3427
+ const node = this.cache.get(key);
3428
+ if (!node)
3429
+ return false;
3430
+ const size = this.options.calculateSize(node.value);
3431
+ this.removeNode(node);
3432
+ this.cache.delete(key);
3433
+ this.stats.size = this.cache.size;
3434
+ this.stats.memoryUsage -= size;
3435
+ if (this.options.onEvict) {
3436
+ this.options.onEvict(key, node.value);
3437
+ }
3438
+ return true;
3439
+ }
3440
+ clear() {
3441
+ if (this.options.onEvict) {
3442
+ for (const [key, node] of this.cache) {
3443
+ this.options.onEvict(key, node.value);
3444
+ }
3445
+ }
3446
+ this.cache.clear();
3447
+ this.head = null;
3448
+ this.tail = null;
3449
+ this.stats = {
3450
+ hits: 0,
3451
+ misses: 0,
3452
+ evictions: 0,
3453
+ size: 0,
3454
+ memoryUsage: 0
3455
+ };
3456
+ }
3457
+ getStats() {
3458
+ const total = this.stats.hits + this.stats.misses;
3459
+ const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
3460
+ const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
3461
+ return {
3462
+ ...this.stats,
3463
+ hitRate,
3464
+ memoryUsageMB
3465
+ };
3466
+ }
3467
+ keys() {
3468
+ const keys = [];
3469
+ let current = this.head;
3470
+ while (current) {
3471
+ keys.push(current.key);
3472
+ current = current.next;
3473
+ }
3474
+ return keys;
3475
+ }
3476
+ get size() {
3477
+ return this.cache.size;
3478
+ }
3479
+ evictIfNeeded(requiredSize) {
3480
+ // Evict by entry count
3481
+ while (this.cache.size >= this.options.maxEntries && this.tail) {
3482
+ this.evictTail();
3483
+ }
3484
+ // Evict by memory usage
3485
+ if (this.options.maxMemoryBytes < Infinity) {
3486
+ while (this.tail &&
3487
+ this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
3488
+ this.evictTail();
3489
+ }
3490
+ }
3491
+ }
3492
+ evictTail() {
3493
+ if (!this.tail)
3494
+ return;
3495
+ const nodeToRemove = this.tail;
3496
+ const size = this.options.calculateSize(nodeToRemove.value);
3497
+ this.removeTail();
3498
+ this.cache.delete(nodeToRemove.key);
3499
+ this.stats.size = this.cache.size;
3500
+ this.stats.memoryUsage -= size;
3501
+ this.stats.evictions++;
3502
+ if (this.options.onEvict) {
3503
+ this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
3504
+ }
3505
+ }
3506
+ addToHead(node) {
3507
+ if (!this.head) {
3508
+ this.head = this.tail = node;
3509
+ }
3510
+ else {
3511
+ node.next = this.head;
3512
+ this.head.prev = node;
3513
+ this.head = node;
3514
+ }
3515
+ }
3516
+ removeNode(node) {
3517
+ if (node.prev) {
3518
+ node.prev.next = node.next;
3519
+ }
3520
+ else {
3521
+ this.head = node.next;
3522
+ }
3523
+ if (node.next) {
3524
+ node.next.prev = node.prev;
3525
+ }
3526
+ else {
3527
+ this.tail = node.prev;
3528
+ }
3529
+ }
3530
+ removeTail() {
3531
+ if (this.tail) {
3532
+ this.removeNode(this.tail);
3533
+ }
3534
+ }
3535
+ moveToHead(node) {
3536
+ if (node === this.head)
3537
+ return;
3538
+ this.removeNode(node);
3539
+ this.addToHead(node);
3540
+ }
3541
+ }
3542
+
3212
3543
  class GlyphGeometryBuilder {
3213
3544
  constructor(cache, loadedFont) {
3214
3545
  this.fontId = 'default';
@@ -3221,6 +3552,16 @@
3221
3552
  this.collector = new GlyphContourCollector();
3222
3553
  this.drawCallbacks = new DrawCallbackHandler();
3223
3554
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3555
+ this.contourCache = new LRUCache({
3556
+ maxEntries: 1000,
3557
+ calculateSize: (contours) => {
3558
+ let size = 0;
3559
+ for (const path of contours.paths) {
3560
+ size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
3561
+ }
3562
+ return size + 64; // bounds overhead
3563
+ }
3564
+ });
3224
3565
  }
3225
3566
  getOptimizationStats() {
3226
3567
  return this.collector.getOptimizationStats();
@@ -3365,30 +3706,37 @@
3365
3706
  };
3366
3707
  }
3367
3708
  getContoursForGlyph(glyphId) {
3709
+ const cached = this.contourCache.get(glyphId);
3710
+ if (cached) {
3711
+ return cached;
3712
+ }
3368
3713
  this.collector.reset();
3369
3714
  this.collector.beginGlyph(glyphId, 0);
3370
3715
  this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
3371
3716
  this.collector.finishGlyph();
3372
3717
  const collected = this.collector.getCollectedGlyphs()[0];
3373
- // Return empty contours for glyphs with no paths (e.g., space, zero-width characters)
3374
- if (!collected) {
3375
- return {
3376
- glyphId,
3377
- paths: [],
3378
- bounds: {
3379
- min: { x: 0, y: 0 },
3380
- max: { x: 0, y: 0 }
3381
- }
3382
- };
3383
- }
3384
- return collected;
3718
+ const contours = collected || {
3719
+ glyphId,
3720
+ paths: [],
3721
+ bounds: {
3722
+ min: { x: 0, y: 0 },
3723
+ max: { x: 0, y: 0 }
3724
+ }
3725
+ };
3726
+ this.contourCache.set(glyphId, contours);
3727
+ return contours;
3385
3728
  }
3386
3729
  tessellateGlyphCluster(paths, depth, isCFF) {
3387
3730
  const processedGeometry = this.tessellator.process(paths, true, isCFF);
3388
3731
  return this.extrudeAndPackage(processedGeometry, depth);
3389
3732
  }
3390
3733
  extrudeAndPackage(processedGeometry, depth) {
3734
+ perfLogger.start('Extruder.extrude', {
3735
+ depth,
3736
+ upem: this.loadedFont.upem
3737
+ });
3391
3738
  const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
3739
+ perfLogger.end('Extruder.extrude');
3392
3740
  // Compute bounding box from vertices
3393
3741
  const vertices = extrudedResult.vertices;
3394
3742
  let minX = Infinity, minY = Infinity, minZ = Infinity;
@@ -3430,6 +3778,7 @@
3430
3778
  pathCount: glyphContours.paths.length
3431
3779
  });
3432
3780
  const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
3781
+ perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
3433
3782
  return this.extrudeAndPackage(processedGeometry, depth);
3434
3783
  }
3435
3784
  updatePlaneBounds(glyphBounds, planeBounds) {
@@ -3493,7 +3842,8 @@
3493
3842
  }
3494
3843
  buffer.addText(lineInfo.text);
3495
3844
  buffer.guessSegmentProperties();
3496
- this.loadedFont.hb.shape(this.loadedFont.font, buffer);
3845
+ const featuresString = convertFontFeaturesToString(this.loadedFont.fontFeatures);
3846
+ this.loadedFont.hb.shape(this.loadedFont.font, buffer, featuresString);
3497
3847
  const glyphInfos = buffer.json(this.loadedFont.font);
3498
3848
  buffer.destroy();
3499
3849
  const clusters = [];
@@ -3501,6 +3851,7 @@
3501
3851
  let currentClusterText = '';
3502
3852
  let clusterStartPosition = new Vec3();
3503
3853
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
3854
+ // Apply letter spacing between glyphs (must match what was used in width measurements)
3504
3855
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3505
3856
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
3506
3857
  for (let i = 0; i < glyphInfos.length; i++) {
@@ -3572,12 +3923,10 @@
3572
3923
  const stretchFactor = SPACE_STRETCH_RATIO;
3573
3924
  const shrinkFactor = SPACE_SHRINK_RATIO;
3574
3925
  if (lineInfo.adjustmentRatio > 0) {
3575
- spaceAdjustment =
3576
- lineInfo.adjustmentRatio * width * stretchFactor;
3926
+ spaceAdjustment = lineInfo.adjustmentRatio * width * stretchFactor;
3577
3927
  }
3578
3928
  else if (lineInfo.adjustmentRatio < 0) {
3579
- spaceAdjustment =
3580
- lineInfo.adjustmentRatio * width * shrinkFactor;
3929
+ spaceAdjustment = lineInfo.adjustmentRatio * width * shrinkFactor;
3581
3930
  }
3582
3931
  }
3583
3932
  return spaceAdjustment;
@@ -4502,12 +4851,16 @@
4502
4851
  const baseFontKey = typeof options.font === 'string'
4503
4852
  ? options.font
4504
4853
  : `buffer-${Text.generateFontContentHash(options.font)}`;
4505
- const fontKey = options.fontVariations
4506
- ? `${baseFontKey}_${JSON.stringify(options.fontVariations)}`
4507
- : baseFontKey;
4854
+ let fontKey = baseFontKey;
4855
+ if (options.fontVariations) {
4856
+ fontKey += `_var_${JSON.stringify(options.fontVariations)}`;
4857
+ }
4858
+ if (options.fontFeatures) {
4859
+ fontKey += `_feat_${JSON.stringify(options.fontFeatures)}`;
4860
+ }
4508
4861
  let loadedFont = Text.fontCache.get(fontKey);
4509
4862
  if (!loadedFont) {
4510
- loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations);
4863
+ loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
4511
4864
  }
4512
4865
  const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
4513
4866
  text.setLoadedFont(loadedFont);
@@ -4521,12 +4874,11 @@
4521
4874
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
4522
4875
  };
4523
4876
  }
4524
- static async loadAndCacheFont(fontKey, font, fontVariations) {
4877
+ static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
4525
4878
  const tempText = new Text();
4526
- await tempText.loadFont(font, fontVariations);
4879
+ await tempText.loadFont(font, fontVariations, fontFeatures);
4527
4880
  const loadedFont = tempText.getLoadedFont();
4528
4881
  Text.fontCache.set(fontKey, loadedFont);
4529
- // Don't destroy tempText - the cached font references its HarfBuzz objects
4530
4882
  return loadedFont;
4531
4883
  }
4532
4884
  static generateFontContentHash(buffer) {
@@ -4545,10 +4897,13 @@
4545
4897
  const contentHash = Text.generateFontContentHash(loadedFont._buffer);
4546
4898
  this.currentFontId = `font_${contentHash}`;
4547
4899
  if (loadedFont.fontVariations) {
4548
- this.currentFontId += `_${JSON.stringify(loadedFont.fontVariations)}`;
4900
+ this.currentFontId += `_var_${JSON.stringify(loadedFont.fontVariations)}`;
4901
+ }
4902
+ if (loadedFont.fontFeatures) {
4903
+ this.currentFontId += `_feat_${JSON.stringify(loadedFont.fontFeatures)}`;
4549
4904
  }
4550
4905
  }
4551
- async loadFont(fontSrc, fontVariations) {
4906
+ async loadFont(fontSrc, fontVariations, fontFeatures) {
4552
4907
  perfLogger.start('Text.loadFont', {
4553
4908
  fontSrc: typeof fontSrc === 'string' ? fontSrc : `buffer(${fontSrc.byteLength})`
4554
4909
  });
@@ -4569,14 +4924,20 @@
4569
4924
  this.destroy();
4570
4925
  }
4571
4926
  this.loadedFont = await this.fontLoader.loadFont(fontBuffer, fontVariations);
4927
+ if (fontFeatures) {
4928
+ this.loadedFont.fontFeatures = fontFeatures;
4929
+ }
4572
4930
  const contentHash = Text.generateFontContentHash(fontBuffer);
4573
4931
  this.currentFontId = `font_${contentHash}`;
4574
4932
  if (fontVariations) {
4575
- this.currentFontId += `_${JSON.stringify(fontVariations)}`;
4933
+ this.currentFontId += `_var_${JSON.stringify(fontVariations)}`;
4934
+ }
4935
+ if (fontFeatures) {
4936
+ this.currentFontId += `_feat_${JSON.stringify(fontFeatures)}`;
4576
4937
  }
4577
4938
  }
4578
4939
  catch (error) {
4579
- debugLogger.error('Failed to load font:', error);
4940
+ logger.error('Failed to load font:', error);
4580
4941
  throw error;
4581
4942
  }
4582
4943
  finally {
@@ -4651,7 +5012,7 @@
4651
5012
  };
4652
5013
  }
4653
5014
  catch (error) {
4654
- debugLogger.warn(`Failed to load patterns for ${language}: ${error}`);
5015
+ logger.warn(`Failed to load patterns for ${language}: ${error}`);
4655
5016
  return {
4656
5017
  ...options,
4657
5018
  layout: {
@@ -4915,7 +5276,7 @@
4915
5276
  Text.patternCache.set(language, pattern);
4916
5277
  }
4917
5278
  catch (error) {
4918
- debugLogger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
5279
+ logger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
4919
5280
  }
4920
5281
  }
4921
5282
  }));
@@ -4977,7 +5338,7 @@
4977
5338
  FontLoader.destroyFont(currentFont);
4978
5339
  }
4979
5340
  catch (error) {
4980
- debugLogger.warn('Error destroying HarfBuzz objects:', error);
5341
+ logger.warn('Error destroying HarfBuzz objects:', error);
4981
5342
  }
4982
5343
  finally {
4983
5344
  this.loadedFont = undefined;