three-text 0.2.4 → 0.2.6

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.d.ts CHANGED
@@ -62,10 +62,17 @@ interface LoadedFont {
62
62
  fontVariations?: {
63
63
  [key: string]: number;
64
64
  };
65
+ fontFeatures?: {
66
+ [tag: string]: boolean | number;
67
+ };
65
68
  isVariable?: boolean;
66
69
  variationAxes?: {
67
70
  [key: string]: VariationAxis;
68
71
  };
72
+ availableFeatures?: string[];
73
+ featureNames?: {
74
+ [tag: string]: string;
75
+ };
69
76
  _buffer?: ArrayBuffer;
70
77
  }
71
78
  interface HarfBuzzModule {
@@ -84,7 +91,7 @@ interface HarfBuzzAPI {
84
91
  createFace: (blob: HarfBuzzBlob, index: number) => HarfBuzzFace;
85
92
  createFont: (face: HarfBuzzFace) => HarfBuzzFont;
86
93
  createBuffer: () => HarfBuzzBuffer;
87
- shape: (font: HarfBuzzFont, buffer: HarfBuzzBuffer) => void;
94
+ shape: (font: HarfBuzzFont, buffer: HarfBuzzBuffer, features?: string) => void;
88
95
  }
89
96
  interface HarfBuzzBlob {
90
97
  destroy: () => void;
@@ -263,6 +270,9 @@ interface TextOptions {
263
270
  fontVariations?: {
264
271
  [key: string]: number;
265
272
  };
273
+ fontFeatures?: {
274
+ [tag: string]: boolean | number;
275
+ };
266
276
  maxTextLength?: number;
267
277
  removeOverlaps?: boolean;
268
278
  curveFidelity?: CurveFidelityConfig;
@@ -436,6 +446,13 @@ declare const DEFAULT_CURVE_FIDELITY: CurveFidelityConfig;
436
446
 
437
447
  declare class FontMetadataExtractor {
438
448
  static extractMetadata(fontBuffer: ArrayBuffer): ExtractedMetrics;
449
+ static extractFeatureTags(fontBuffer: ArrayBuffer): {
450
+ tags: string[];
451
+ names: {
452
+ [tag: string]: string;
453
+ };
454
+ } | undefined;
455
+ private static extractFeatureDataFromTable;
439
456
  private static extractAxisNames;
440
457
  private static getNameFromNameTable;
441
458
  static getVerticalMetrics(metrics: ExtractedMetrics): VerticalMetrics;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.4
2
+ * three-text v0.2.6
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;
@@ -562,7 +562,7 @@ class LineBreak {
562
562
  let useHyphenation = hyphenate;
563
563
  if (useHyphenation &&
564
564
  (!hyphenationPatterns || !hyphenationPatterns[language])) {
565
- debugLogger.warn(`Hyphenation patterns for ${language} not available`);
565
+ logger.warn(`Hyphenation patterns for ${language} not available`);
566
566
  useHyphenation = false;
567
567
  }
568
568
  // Calculate initial emergency stretch (TeX default: 0)
@@ -1177,22 +1177,47 @@ class LineBreak {
1177
1177
  }
1178
1178
  }
1179
1179
 
1180
+ // Convert feature objects to HarfBuzz comma-separated format
1181
+ function convertFontFeaturesToString(features) {
1182
+ if (!features || Object.keys(features).length === 0) {
1183
+ return undefined;
1184
+ }
1185
+ const featureStrings = [];
1186
+ for (const [tag, value] of Object.entries(features)) {
1187
+ if (!/^[a-zA-Z0-9]{4}$/.test(tag)) {
1188
+ logger.warn(`Invalid OpenType feature tag: "${tag}". Tags must be exactly 4 alphanumeric characters.`);
1189
+ continue;
1190
+ }
1191
+ if (value === false || value === 0) {
1192
+ featureStrings.push(`${tag}=0`);
1193
+ }
1194
+ else if (value === true || value === 1) {
1195
+ featureStrings.push(tag);
1196
+ }
1197
+ else if (typeof value === 'number' && value > 1) {
1198
+ featureStrings.push(`${tag}=${Math.floor(value)}`);
1199
+ }
1200
+ else {
1201
+ logger.warn(`Invalid value for feature "${tag}": ${value}. Expected boolean or positive number.`);
1202
+ }
1203
+ }
1204
+ return featureStrings.length > 0 ? featureStrings.join(',') : undefined;
1205
+ }
1206
+
1180
1207
  class TextMeasurer {
1181
1208
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1182
1209
  const buffer = loadedFont.hb.createBuffer();
1183
1210
  buffer.addText(text);
1184
1211
  buffer.guessSegmentProperties();
1185
- loadedFont.hb.shape(loadedFont.font, buffer);
1212
+ const featuresString = convertFontFeaturesToString(loadedFont.fontFeatures);
1213
+ loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
1186
1214
  const glyphInfos = buffer.json(loadedFont.font);
1187
1215
  const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
1188
1216
  // Calculate total advance width with letter spacing
1189
1217
  let totalWidth = 0;
1190
- glyphInfos.forEach((glyph, index) => {
1218
+ glyphInfos.forEach((glyph) => {
1191
1219
  totalWidth += glyph.ax;
1192
- // Spaces measured alone need letter spacing to match final rendering
1193
- const isLastChar = index === glyphInfos.length - 1;
1194
- const isSingleSpace = text === ' ' || text === ' ' || /^\s+$/.test(text);
1195
- if (letterSpacingInFontUnits !== 0 && (!isLastChar || isSingleSpace)) {
1220
+ if (letterSpacingInFontUnits !== 0) {
1196
1221
  totalWidth += letterSpacingInFontUnits;
1197
1222
  }
1198
1223
  });
@@ -1247,7 +1272,7 @@ class TextLayout {
1247
1272
  originalEnd: currentIndex + line.length - 1,
1248
1273
  xOffset: 0
1249
1274
  });
1250
- currentIndex += line.length + 1; // +1 for the newline character
1275
+ currentIndex += line.length + 1;
1251
1276
  }
1252
1277
  }
1253
1278
  return { lines };
@@ -1268,7 +1293,7 @@ class TextLayout {
1268
1293
  offset = width - planeBounds.max.x;
1269
1294
  }
1270
1295
  if (offset !== 0) {
1271
- // Translate vertices directly
1296
+ // Translate vertices
1272
1297
  for (let i = 0; i < vertices.length; i += 3) {
1273
1298
  vertices[i] += offset;
1274
1299
  }
@@ -1286,6 +1311,17 @@ const FONT_SIGNATURE_OPEN_TYPE_CFF = 0x4f54544f; // 'OTTO'
1286
1311
  const FONT_SIGNATURE_TRUE_TYPE_COLLECTION = 0x74746366; // 'ttcf'
1287
1312
  const FONT_SIGNATURE_WOFF = 0x774f4646; // 'wOFF'
1288
1313
  const FONT_SIGNATURE_WOFF2 = 0x774f4632; // 'wOF2'
1314
+ // Table Tags
1315
+ const TABLE_TAG_HEAD = 0x68656164; // 'head'
1316
+ const TABLE_TAG_HHEA = 0x68686561; // 'hhea'
1317
+ const TABLE_TAG_OS2 = 0x4f532f32; // 'OS/2'
1318
+ const TABLE_TAG_FVAR = 0x66766172; // 'fvar'
1319
+ const TABLE_TAG_STAT = 0x53544154; // 'STAT'
1320
+ const TABLE_TAG_NAME = 0x6e616d65; // 'name'
1321
+ const TABLE_TAG_CFF = 0x43464620; // 'CFF '
1322
+ const TABLE_TAG_CFF2 = 0x43464632; // 'CFF2'
1323
+ const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
1324
+ const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
1289
1325
 
1290
1326
  class FontMetadataExtractor {
1291
1327
  static extractMetadata(fontBuffer) {
@@ -1302,7 +1338,6 @@ class FontMetadataExtractor {
1302
1338
  if (!validSignatures.includes(sfntVersion)) {
1303
1339
  throw new Error(`Invalid font format. Expected TrueType or OpenType, got signature: 0x${sfntVersion.toString(16)}`);
1304
1340
  }
1305
- const buffer = new Uint8Array(fontBuffer);
1306
1341
  const numTables = view.getUint16(4); // OpenType header - number of tables is at offset 4
1307
1342
  let isCFF = false;
1308
1343
  let headTableOffset = 0;
@@ -1312,30 +1347,28 @@ class FontMetadataExtractor {
1312
1347
  let nameTableOffset = 0;
1313
1348
  let fvarTableOffset = 0;
1314
1349
  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 ') {
1350
+ const offset = 12 + i * 16;
1351
+ const tag = view.getUint32(offset);
1352
+ if (tag === TABLE_TAG_CFF || tag === TABLE_TAG_CFF2) {
1317
1353
  isCFF = true;
1318
1354
  }
1319
- else if (tag === 'CFF2') {
1320
- isCFF = true;
1355
+ else if (tag === TABLE_TAG_HEAD) {
1356
+ headTableOffset = view.getUint32(offset + 8);
1321
1357
  }
1322
- if (tag === 'head') {
1323
- headTableOffset = view.getUint32(12 + i * 16 + 8);
1358
+ else if (tag === TABLE_TAG_HHEA) {
1359
+ hheaTableOffset = view.getUint32(offset + 8);
1324
1360
  }
1325
- if (tag === 'hhea') {
1326
- hheaTableOffset = view.getUint32(12 + i * 16 + 8);
1361
+ else if (tag === TABLE_TAG_OS2) {
1362
+ os2TableOffset = view.getUint32(offset + 8);
1327
1363
  }
1328
- if (tag === 'OS/2') {
1329
- os2TableOffset = view.getUint32(12 + i * 16 + 8);
1364
+ else if (tag === TABLE_TAG_FVAR) {
1365
+ fvarTableOffset = view.getUint32(offset + 8);
1330
1366
  }
1331
- if (tag === 'fvar') {
1332
- fvarTableOffset = view.getUint32(12 + i * 16 + 8);
1367
+ else if (tag === TABLE_TAG_STAT) {
1368
+ statTableOffset = view.getUint32(offset + 8);
1333
1369
  }
1334
- if (tag === 'STAT') {
1335
- statTableOffset = view.getUint32(12 + i * 16 + 8);
1336
- }
1337
- if (tag === 'name') {
1338
- nameTableOffset = view.getUint32(12 + i * 16 + 8);
1370
+ else if (tag === TABLE_TAG_NAME) {
1371
+ nameTableOffset = view.getUint32(offset + 8);
1339
1372
  }
1340
1373
  }
1341
1374
  const unitsPerEm = headTableOffset
@@ -1378,6 +1411,88 @@ class FontMetadataExtractor {
1378
1411
  axisNames
1379
1412
  };
1380
1413
  }
1414
+ static extractFeatureTags(fontBuffer) {
1415
+ const view = new DataView(fontBuffer);
1416
+ const numTables = view.getUint16(4);
1417
+ let gsubTableOffset = 0;
1418
+ let gposTableOffset = 0;
1419
+ let nameTableOffset = 0;
1420
+ for (let i = 0; i < numTables; i++) {
1421
+ const offset = 12 + i * 16;
1422
+ const tag = view.getUint32(offset);
1423
+ if (tag === TABLE_TAG_GSUB) {
1424
+ gsubTableOffset = view.getUint32(offset + 8);
1425
+ }
1426
+ else if (tag === TABLE_TAG_GPOS) {
1427
+ gposTableOffset = view.getUint32(offset + 8);
1428
+ }
1429
+ else if (tag === TABLE_TAG_NAME) {
1430
+ nameTableOffset = view.getUint32(offset + 8);
1431
+ }
1432
+ }
1433
+ const features = new Set();
1434
+ const featureNames = {};
1435
+ try {
1436
+ if (gsubTableOffset) {
1437
+ const gsubData = this.extractFeatureDataFromTable(view, gsubTableOffset, nameTableOffset);
1438
+ gsubData.features.forEach(f => features.add(f));
1439
+ Object.assign(featureNames, gsubData.names);
1440
+ }
1441
+ if (gposTableOffset) {
1442
+ const gposData = this.extractFeatureDataFromTable(view, gposTableOffset, nameTableOffset);
1443
+ gposData.features.forEach(f => features.add(f));
1444
+ Object.assign(featureNames, gposData.names);
1445
+ }
1446
+ }
1447
+ catch (e) {
1448
+ return undefined;
1449
+ }
1450
+ const featureArray = Array.from(features).sort();
1451
+ if (featureArray.length === 0)
1452
+ return undefined;
1453
+ return {
1454
+ tags: featureArray,
1455
+ names: Object.keys(featureNames).length > 0 ? featureNames : {}
1456
+ };
1457
+ }
1458
+ static extractFeatureDataFromTable(view, tableOffset, nameTableOffset) {
1459
+ const featureListOffset = view.getUint16(tableOffset + 6);
1460
+ const featureListStart = tableOffset + featureListOffset;
1461
+ const featureCount = view.getUint16(featureListStart);
1462
+ const features = [];
1463
+ const names = {};
1464
+ for (let i = 0; i < featureCount; i++) {
1465
+ const recordOffset = featureListStart + 2 + i * 6;
1466
+ // Decode feature tag
1467
+ const tag = String.fromCharCode(view.getUint8(recordOffset), view.getUint8(recordOffset + 1), view.getUint8(recordOffset + 2), view.getUint8(recordOffset + 3));
1468
+ features.push(tag);
1469
+ // Extract feature name for stylistic sets and character variants
1470
+ if (/^(ss\d{2}|cv\d{2})$/.test(tag) && nameTableOffset) {
1471
+ const featureOffset = view.getUint16(recordOffset + 4);
1472
+ const featureTableStart = featureListStart + featureOffset;
1473
+ // Feature table structure:
1474
+ // uint16 FeatureParams offset
1475
+ // uint16 LookupCount
1476
+ // uint16[LookupCount] LookupListIndex
1477
+ const featureParamsOffset = view.getUint16(featureTableStart);
1478
+ // FeatureParams for ss features:
1479
+ // uint16 Version (should be 0)
1480
+ // uint16 UINameID
1481
+ if (featureParamsOffset !== 0) {
1482
+ const paramsStart = featureTableStart + featureParamsOffset;
1483
+ const version = view.getUint16(paramsStart);
1484
+ if (version === 0) {
1485
+ const nameID = view.getUint16(paramsStart + 2);
1486
+ const name = this.getNameFromNameTable(view, nameTableOffset, nameID);
1487
+ if (name) {
1488
+ names[tag] = name;
1489
+ }
1490
+ }
1491
+ }
1492
+ }
1493
+ }
1494
+ return { features, names };
1495
+ }
1381
1496
  static extractAxisNames(view, statOffset, nameOffset) {
1382
1497
  try {
1383
1498
  // STAT table structure
@@ -1588,7 +1703,7 @@ class WoffConverter {
1588
1703
  const padding = (4 - (table.origLength % 4)) % 4;
1589
1704
  sfntOffset += padding;
1590
1705
  }
1591
- debugLogger.log('WOFF font decompressed successfully');
1706
+ logger.log('WOFF font decompressed successfully');
1592
1707
  return sfntData.buffer.slice(0, sfntOffset);
1593
1708
  }
1594
1709
  static async decompressZlib(compressedData) {
@@ -1617,7 +1732,7 @@ class FontLoader {
1617
1732
  // Check if this is a WOFF font and decompress if needed
1618
1733
  const format = WoffConverter.detectFormat(fontBuffer);
1619
1734
  if (format === 'woff') {
1620
- debugLogger.log('WOFF font detected, decompressing...');
1735
+ logger.log('WOFF font detected, decompressing...');
1621
1736
  fontBuffer = await WoffConverter.decompressWoff(fontBuffer);
1622
1737
  }
1623
1738
  else if (format === 'woff2') {
@@ -1655,6 +1770,7 @@ class FontLoader {
1655
1770
  };
1656
1771
  }
1657
1772
  }
1773
+ const featureData = FontMetadataExtractor.extractFeatureTags(fontBuffer);
1658
1774
  return {
1659
1775
  hb,
1660
1776
  fontBlob,
@@ -1665,11 +1781,13 @@ class FontLoader {
1665
1781
  metrics,
1666
1782
  fontVariations,
1667
1783
  isVariable,
1668
- variationAxes
1784
+ variationAxes,
1785
+ availableFeatures: featureData?.tags,
1786
+ featureNames: featureData?.names
1669
1787
  };
1670
1788
  }
1671
1789
  catch (error) {
1672
- debugLogger.error('Failed to load font:', error);
1790
+ logger.error('Failed to load font:', error);
1673
1791
  throw error;
1674
1792
  }
1675
1793
  finally {
@@ -1690,7 +1808,7 @@ class FontLoader {
1690
1808
  }
1691
1809
  }
1692
1810
  catch (error) {
1693
- debugLogger.error('Error destroying font resources:', error);
1811
+ logger.error('Error destroying font resources:', error);
1694
1812
  }
1695
1813
  }
1696
1814
  }
@@ -2092,7 +2210,7 @@ class Tessellator {
2092
2210
  if (valid.length === 0) {
2093
2211
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2094
2212
  }
2095
- debugLogger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2213
+ logger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2096
2214
  return this.tessellate(valid, removeOverlaps, isCFF);
2097
2215
  }
2098
2216
  tessellate(paths, removeOverlaps, isCFF) {
@@ -2102,19 +2220,19 @@ class Tessellator {
2102
2220
  : paths;
2103
2221
  let contours = this.pathsToContours(normalizedPaths);
2104
2222
  if (removeOverlaps) {
2105
- debugLogger.log('Two-pass: boundary extraction then triangulation');
2223
+ logger.log('Two-pass: boundary extraction then triangulation');
2106
2224
  // Extract boundaries to remove overlaps
2107
2225
  const boundaryResult = this.performTessellation(contours, 'boundary');
2108
2226
  if (!boundaryResult) {
2109
- debugLogger.warn('libtess returned empty result from boundary pass');
2227
+ logger.warn('libtess returned empty result from boundary pass');
2110
2228
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2111
2229
  }
2112
2230
  // Convert boundary elements back to contours
2113
2231
  contours = this.boundaryToContours(boundaryResult);
2114
- debugLogger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2232
+ logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2115
2233
  }
2116
2234
  else {
2117
- debugLogger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2235
+ logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2118
2236
  }
2119
2237
  // Triangulate the contours
2120
2238
  const triangleResult = this.performTessellation(contours, 'triangles');
@@ -2122,7 +2240,7 @@ class Tessellator {
2122
2240
  const warning = removeOverlaps
2123
2241
  ? 'libtess returned empty result from triangulation pass'
2124
2242
  : 'libtess returned empty result from single-pass triangulation';
2125
- debugLogger.warn(warning);
2243
+ logger.warn(warning);
2126
2244
  return { triangles: { vertices: [], indices: [] }, contours };
2127
2245
  }
2128
2246
  return {
@@ -2177,7 +2295,7 @@ class Tessellator {
2177
2295
  return idx;
2178
2296
  });
2179
2297
  tess.gluTessCallback(libtess_minExports.gluEnum.GLU_TESS_ERROR, (errno) => {
2180
- debugLogger.warn(`libtess error: ${errno}`);
2298
+ logger.warn(`libtess error: ${errno}`);
2181
2299
  });
2182
2300
  tess.gluTessNormal(0, 0, 1);
2183
2301
  tess.gluTessBeginPolygon(null);
@@ -3199,7 +3317,7 @@ class DrawCallbackHandler {
3199
3317
  }
3200
3318
  }
3201
3319
  catch (error) {
3202
- debugLogger.warn('Error destroying draw callbacks:', error);
3320
+ logger.warn('Error destroying draw callbacks:', error);
3203
3321
  }
3204
3322
  this.collector = undefined;
3205
3323
  }
@@ -3489,7 +3607,8 @@ class TextShaper {
3489
3607
  }
3490
3608
  buffer.addText(lineInfo.text);
3491
3609
  buffer.guessSegmentProperties();
3492
- this.loadedFont.hb.shape(this.loadedFont.font, buffer);
3610
+ const featuresString = convertFontFeaturesToString(this.loadedFont.fontFeatures);
3611
+ this.loadedFont.hb.shape(this.loadedFont.font, buffer, featuresString);
3493
3612
  const glyphInfos = buffer.json(this.loadedFont.font);
3494
3613
  buffer.destroy();
3495
3614
  const clusters = [];
@@ -3564,15 +3683,14 @@ class TextShaper {
3564
3683
  naturalSpaceWidth = TextMeasurer.measureTextWidth(this.loadedFont, ' ', letterSpacing);
3565
3684
  this.cachedSpaceWidth.set(letterSpacing, naturalSpaceWidth);
3566
3685
  }
3686
+ const width = naturalSpaceWidth;
3567
3687
  const stretchFactor = SPACE_STRETCH_RATIO;
3568
3688
  const shrinkFactor = SPACE_SHRINK_RATIO;
3569
3689
  if (lineInfo.adjustmentRatio > 0) {
3570
- spaceAdjustment =
3571
- lineInfo.adjustmentRatio * naturalSpaceWidth * stretchFactor;
3690
+ spaceAdjustment = lineInfo.adjustmentRatio * width * stretchFactor;
3572
3691
  }
3573
3692
  else if (lineInfo.adjustmentRatio < 0) {
3574
- spaceAdjustment =
3575
- lineInfo.adjustmentRatio * naturalSpaceWidth * shrinkFactor;
3693
+ spaceAdjustment = lineInfo.adjustmentRatio * width * shrinkFactor;
3576
3694
  }
3577
3695
  }
3578
3696
  return spaceAdjustment;
@@ -4497,12 +4615,16 @@ class Text {
4497
4615
  const baseFontKey = typeof options.font === 'string'
4498
4616
  ? options.font
4499
4617
  : `buffer-${Text.generateFontContentHash(options.font)}`;
4500
- const fontKey = options.fontVariations
4501
- ? `${baseFontKey}_${JSON.stringify(options.fontVariations)}`
4502
- : baseFontKey;
4618
+ let fontKey = baseFontKey;
4619
+ if (options.fontVariations) {
4620
+ fontKey += `_var_${JSON.stringify(options.fontVariations)}`;
4621
+ }
4622
+ if (options.fontFeatures) {
4623
+ fontKey += `_feat_${JSON.stringify(options.fontFeatures)}`;
4624
+ }
4503
4625
  let loadedFont = Text.fontCache.get(fontKey);
4504
4626
  if (!loadedFont) {
4505
- loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations);
4627
+ loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
4506
4628
  }
4507
4629
  const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
4508
4630
  text.setLoadedFont(loadedFont);
@@ -4516,12 +4638,11 @@ class Text {
4516
4638
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
4517
4639
  };
4518
4640
  }
4519
- static async loadAndCacheFont(fontKey, font, fontVariations) {
4641
+ static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
4520
4642
  const tempText = new Text();
4521
- await tempText.loadFont(font, fontVariations);
4643
+ await tempText.loadFont(font, fontVariations, fontFeatures);
4522
4644
  const loadedFont = tempText.getLoadedFont();
4523
4645
  Text.fontCache.set(fontKey, loadedFont);
4524
- // Don't destroy tempText - the cached font references its HarfBuzz objects
4525
4646
  return loadedFont;
4526
4647
  }
4527
4648
  static generateFontContentHash(buffer) {
@@ -4540,10 +4661,13 @@ class Text {
4540
4661
  const contentHash = Text.generateFontContentHash(loadedFont._buffer);
4541
4662
  this.currentFontId = `font_${contentHash}`;
4542
4663
  if (loadedFont.fontVariations) {
4543
- this.currentFontId += `_${JSON.stringify(loadedFont.fontVariations)}`;
4664
+ this.currentFontId += `_var_${JSON.stringify(loadedFont.fontVariations)}`;
4665
+ }
4666
+ if (loadedFont.fontFeatures) {
4667
+ this.currentFontId += `_feat_${JSON.stringify(loadedFont.fontFeatures)}`;
4544
4668
  }
4545
4669
  }
4546
- async loadFont(fontSrc, fontVariations) {
4670
+ async loadFont(fontSrc, fontVariations, fontFeatures) {
4547
4671
  perfLogger.start('Text.loadFont', {
4548
4672
  fontSrc: typeof fontSrc === 'string' ? fontSrc : `buffer(${fontSrc.byteLength})`
4549
4673
  });
@@ -4564,14 +4688,20 @@ class Text {
4564
4688
  this.destroy();
4565
4689
  }
4566
4690
  this.loadedFont = await this.fontLoader.loadFont(fontBuffer, fontVariations);
4691
+ if (fontFeatures) {
4692
+ this.loadedFont.fontFeatures = fontFeatures;
4693
+ }
4567
4694
  const contentHash = Text.generateFontContentHash(fontBuffer);
4568
4695
  this.currentFontId = `font_${contentHash}`;
4569
4696
  if (fontVariations) {
4570
- this.currentFontId += `_${JSON.stringify(fontVariations)}`;
4697
+ this.currentFontId += `_var_${JSON.stringify(fontVariations)}`;
4698
+ }
4699
+ if (fontFeatures) {
4700
+ this.currentFontId += `_feat_${JSON.stringify(fontFeatures)}`;
4571
4701
  }
4572
4702
  }
4573
4703
  catch (error) {
4574
- debugLogger.error('Failed to load font:', error);
4704
+ logger.error('Failed to load font:', error);
4575
4705
  throw error;
4576
4706
  }
4577
4707
  finally {
@@ -4646,7 +4776,7 @@ class Text {
4646
4776
  };
4647
4777
  }
4648
4778
  catch (error) {
4649
- debugLogger.warn(`Failed to load patterns for ${language}: ${error}`);
4779
+ logger.warn(`Failed to load patterns for ${language}: ${error}`);
4650
4780
  return {
4651
4781
  ...options,
4652
4782
  layout: {
@@ -4910,7 +5040,7 @@ class Text {
4910
5040
  Text.patternCache.set(language, pattern);
4911
5041
  }
4912
5042
  catch (error) {
4913
- debugLogger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
5043
+ logger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
4914
5044
  }
4915
5045
  }
4916
5046
  }));
@@ -4972,7 +5102,7 @@ class Text {
4972
5102
  FontLoader.destroyFont(currentFont);
4973
5103
  }
4974
5104
  catch (error) {
4975
- debugLogger.warn('Error destroying HarfBuzz objects:', error);
5105
+ logger.warn('Error destroying HarfBuzz objects:', error);
4976
5106
  }
4977
5107
  finally {
4978
5108
  this.loadedFont = undefined;