three-text 0.2.5 → 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/README.md CHANGED
@@ -235,7 +235,7 @@ Then navigate to `http://localhost:3000`
235
235
 
236
236
  ## Why three-text?
237
237
 
238
- three-text generates high-fidelity 3D mesh geometry from font files. Unlike texture-based approaches, it produces true geometry that can be lit, shaded, and manipulated like any 3D model.
238
+ three-text generates high-fidelity 3D mesh geometry from font files. Unlike texture-based approaches, it produces true geometry that can be lit, shaded, and manipulated like any 3D model
239
239
 
240
240
  Existing solutions take different approaches:
241
241
 
@@ -305,7 +305,7 @@ To optimize performance, three-text generates the geometry for each unique glyph
305
305
  3. **Geometry optimization**:
306
306
  - **Visvalingam-Whyatt simplification**: removes vertices that contribute the least to the overall shape, preserving sharp corners and subtle curves
307
307
  - **Colinear point removal**: eliminates redundant points that lie on straight lines within angle tolerances
308
- 4. **Overlap removal**: removes self-intersections and resolves overlapping paths between glyphs, preserving correct winding rules for triangulation.
308
+ 4. **Overlap removal**: removes self-intersections and resolves overlapping paths between glyphs, preserving correct winding rules for triangulation
309
309
  5. **Triangulation**: converts cleaned 2D shapes into triangles using libtess2 with non-zero winding rule
310
310
  6. **Mesh construction**: generates 2D or 3D geometry with front faces and optional depth/extrusion (back faces and side walls)
311
311
 
@@ -334,7 +334,7 @@ The library converts bezier curves into line segments by recursively subdividing
334
334
  - `distanceTolerance`: The maximum allowed deviation of the curve from a straight line segment, measured in font units. Lower values produce higher fidelity and more vertices. Default is `0.5`, which is nearly imperceptable without extrusion
335
335
  - `angleTolerance`: The maximum angle in radians between segments at a join. This helps preserve sharp corners. Default is `0.2`
336
336
 
337
- In general, this step helps more with time to first render than ongoing interactions in the scene.
337
+ In general, this step helps more with time to first render than ongoing interactions in the scene
338
338
 
339
339
  ```javascript
340
340
  // Using the default configuration
@@ -466,25 +466,6 @@ const text = await Text.create({
466
466
  });
467
467
  ```
468
468
 
469
- ### Per-glyph animation attributes
470
-
471
- For shader-based animations and interactive effects, the library can generate per-vertex attributes that identify which glyph each vertex belongs to:
472
-
473
- ```javascript
474
- const text = await Text.create({
475
- text: 'Sample text',
476
- font: '/fonts/Font.ttf',
477
- separateGlyphsWithAttributes: true,
478
- });
479
-
480
- // Geometry includes these vertex attributes:
481
- // - glyphCenter (vec3): center point of each glyph
482
- // - glyphIndex (float): sequential glyph index
483
- // - glyphLineIndex (float): line number
484
- ```
485
-
486
- This option bypasses overlap-based clustering and adds vertex attributes suitable for per-character manipulation in vertex shaders. Each unique glyph is still tessellated only once and cached for reuse. The tradeoff is potential visual artifacts where glyphs actually overlap (tight kerning, cursive scripts)
487
-
488
469
  ### Variable fonts
489
470
 
490
471
  Variable fonts allow dynamic adjustment of typographic characteristics through variation axes:
@@ -537,6 +518,47 @@ const text = await Text.create({
537
518
  });
538
519
  ```
539
520
 
521
+ ### OpenType features
522
+
523
+ The `fontFeatures` option controls OpenType layout features using 4-character tags from the [feature registry](https://learn.microsoft.com/en-us/typography/opentype/spec/featuretags):
524
+
525
+ ```javascript
526
+ const text = await Text.create({
527
+ text: 'Difficult ffi ffl',
528
+ font: '/fonts/Font.ttf',
529
+ fontFeatures: {
530
+ liga: true,
531
+ dlig: true,
532
+ kern: false,
533
+ ss01: 1,
534
+ cv01: 3,
535
+ },
536
+ });
537
+ ```
538
+
539
+ Values can be boolean (`true`/`false`) to enable or disable, or numeric for features accepting variant indices. Explicitly disabling a feature overrides the font's defaults
540
+
541
+ Common tags include [`liga`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_ko#liga) (ligatures), [`kern`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_ko#kern) (kerning), [`calt`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_ae#calt) (contextual alternates), and [`smcp`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt#smcp) (small capitals). Number styling uses [`lnum`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_ko#lnum)/[`onum`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_ko#onum)/[`tnum`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt#tnum). Stylistic alternates are [`ss01`-`ss20`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt#ss01--ss20) and [`cv01`-`cv99`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_ae#cv01--cv99). Feature availability depends on the font
542
+
543
+ ### Per-glyph attributes
544
+
545
+ For shader-based animations and interactive effects, the library can generate per-vertex attributes that identify which glyph each vertex belongs to:
546
+
547
+ ```javascript
548
+ const text = await Text.create({
549
+ text: 'Sample text',
550
+ font: '/fonts/Font.ttf',
551
+ separateGlyphsWithAttributes: true,
552
+ });
553
+
554
+ // Geometry includes these vertex attributes:
555
+ // - glyphCenter (vec3): center point of each glyph
556
+ // - glyphIndex (float): sequential glyph index
557
+ // - glyphLineIndex (float): line number
558
+ ```
559
+
560
+ This option bypasses overlap-based clustering and adds vertex attributes suitable for per-character manipulation in vertex shaders. Each unique glyph is still tessellated only once and cached for reuse. The tradeoff is potential visual artifacts where glyphs actually overlap (tight kerning, cursive scripts)
561
+
540
562
  ## Querying text content
541
563
 
542
564
  After creating text geometry, use the `query()` method to find text ranges:
@@ -634,7 +656,7 @@ The library's full TypeScript definitions are the most complete source of truth
634
656
 
635
657
  #### `Text.create(options: TextOptions): Promise<TextGeometryInfo>`
636
658
 
637
- Creates text geometry with automatic font loading and HarfBuzz initialization.
659
+ Creates text geometry with automatic font loading and HarfBuzz initialization
638
660
 
639
661
  **Core (`three-text`) returns:**
640
662
  - `vertices: Float32Array` - Vertex positions
@@ -694,6 +716,7 @@ interface TextOptions {
694
716
  lineHeight?: number; // Line height multiplier (default: 1.0)
695
717
  letterSpacing?: number; // Letter spacing as a fraction of em (e.g., 0.05)
696
718
  fontVariations?: { [key: string]: number }; // Variable font axis settings
719
+ fontFeatures?: { [tag: string]: boolean | number }; // OpenType feature settings
697
720
  removeOverlaps?: boolean; // Override default overlap removal (auto-enabled for VF only)
698
721
  separateGlyphsWithAttributes?: boolean; // Force individual glyph tessellation and add shader attributes
699
722
  color?: [number, number, number] | ColorOptions; // Text coloring (simple or complex)
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.5
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
@@ -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;
@@ -565,7 +565,7 @@ class LineBreak {
565
565
  let useHyphenation = hyphenate;
566
566
  if (useHyphenation &&
567
567
  (!hyphenationPatterns || !hyphenationPatterns[language])) {
568
- debugLogger.warn(`Hyphenation patterns for ${language} not available`);
568
+ logger.warn(`Hyphenation patterns for ${language} not available`);
569
569
  useHyphenation = false;
570
570
  }
571
571
  // Calculate initial emergency stretch (TeX default: 0)
@@ -1180,12 +1180,40 @@ class LineBreak {
1180
1180
  }
1181
1181
  }
1182
1182
 
1183
+ // Convert feature objects to HarfBuzz comma-separated format
1184
+ function convertFontFeaturesToString(features) {
1185
+ if (!features || Object.keys(features).length === 0) {
1186
+ return undefined;
1187
+ }
1188
+ const featureStrings = [];
1189
+ for (const [tag, value] of Object.entries(features)) {
1190
+ if (!/^[a-zA-Z0-9]{4}$/.test(tag)) {
1191
+ logger.warn(`Invalid OpenType feature tag: "${tag}". Tags must be exactly 4 alphanumeric characters.`);
1192
+ continue;
1193
+ }
1194
+ if (value === false || value === 0) {
1195
+ featureStrings.push(`${tag}=0`);
1196
+ }
1197
+ else if (value === true || value === 1) {
1198
+ featureStrings.push(tag);
1199
+ }
1200
+ else if (typeof value === 'number' && value > 1) {
1201
+ featureStrings.push(`${tag}=${Math.floor(value)}`);
1202
+ }
1203
+ else {
1204
+ logger.warn(`Invalid value for feature "${tag}": ${value}. Expected boolean or positive number.`);
1205
+ }
1206
+ }
1207
+ return featureStrings.length > 0 ? featureStrings.join(',') : undefined;
1208
+ }
1209
+
1183
1210
  class TextMeasurer {
1184
1211
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1185
1212
  const buffer = loadedFont.hb.createBuffer();
1186
1213
  buffer.addText(text);
1187
1214
  buffer.guessSegmentProperties();
1188
- loadedFont.hb.shape(loadedFont.font, buffer);
1215
+ const featuresString = convertFontFeaturesToString(loadedFont.fontFeatures);
1216
+ loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
1189
1217
  const glyphInfos = buffer.json(loadedFont.font);
1190
1218
  const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
1191
1219
  // Calculate total advance width with letter spacing
@@ -1286,6 +1314,17 @@ const FONT_SIGNATURE_OPEN_TYPE_CFF = 0x4f54544f; // 'OTTO'
1286
1314
  const FONT_SIGNATURE_TRUE_TYPE_COLLECTION = 0x74746366; // 'ttcf'
1287
1315
  const FONT_SIGNATURE_WOFF = 0x774f4646; // 'wOFF'
1288
1316
  const FONT_SIGNATURE_WOFF2 = 0x774f4632; // 'wOF2'
1317
+ // Table Tags
1318
+ const TABLE_TAG_HEAD = 0x68656164; // 'head'
1319
+ const TABLE_TAG_HHEA = 0x68686561; // 'hhea'
1320
+ const TABLE_TAG_OS2 = 0x4f532f32; // 'OS/2'
1321
+ const TABLE_TAG_FVAR = 0x66766172; // 'fvar'
1322
+ const TABLE_TAG_STAT = 0x53544154; // 'STAT'
1323
+ const TABLE_TAG_NAME = 0x6e616d65; // 'name'
1324
+ const TABLE_TAG_CFF = 0x43464620; // 'CFF '
1325
+ const TABLE_TAG_CFF2 = 0x43464632; // 'CFF2'
1326
+ const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
1327
+ const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
1289
1328
 
1290
1329
  class FontMetadataExtractor {
1291
1330
  static extractMetadata(fontBuffer) {
@@ -1302,7 +1341,6 @@ class FontMetadataExtractor {
1302
1341
  if (!validSignatures.includes(sfntVersion)) {
1303
1342
  throw new Error(`Invalid font format. Expected TrueType or OpenType, got signature: 0x${sfntVersion.toString(16)}`);
1304
1343
  }
1305
- const buffer = new Uint8Array(fontBuffer);
1306
1344
  const numTables = view.getUint16(4); // OpenType header - number of tables is at offset 4
1307
1345
  let isCFF = false;
1308
1346
  let headTableOffset = 0;
@@ -1312,30 +1350,28 @@ class FontMetadataExtractor {
1312
1350
  let nameTableOffset = 0;
1313
1351
  let fvarTableOffset = 0;
1314
1352
  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 ') {
1353
+ const offset = 12 + i * 16;
1354
+ const tag = view.getUint32(offset);
1355
+ if (tag === TABLE_TAG_CFF || tag === TABLE_TAG_CFF2) {
1317
1356
  isCFF = true;
1318
1357
  }
1319
- else if (tag === 'CFF2') {
1320
- isCFF = true;
1358
+ else if (tag === TABLE_TAG_HEAD) {
1359
+ headTableOffset = view.getUint32(offset + 8);
1321
1360
  }
1322
- if (tag === 'head') {
1323
- headTableOffset = view.getUint32(12 + i * 16 + 8);
1361
+ else if (tag === TABLE_TAG_HHEA) {
1362
+ hheaTableOffset = view.getUint32(offset + 8);
1324
1363
  }
1325
- if (tag === 'hhea') {
1326
- hheaTableOffset = view.getUint32(12 + i * 16 + 8);
1364
+ else if (tag === TABLE_TAG_OS2) {
1365
+ os2TableOffset = view.getUint32(offset + 8);
1327
1366
  }
1328
- if (tag === 'OS/2') {
1329
- os2TableOffset = view.getUint32(12 + i * 16 + 8);
1367
+ else if (tag === TABLE_TAG_FVAR) {
1368
+ fvarTableOffset = view.getUint32(offset + 8);
1330
1369
  }
1331
- if (tag === 'fvar') {
1332
- fvarTableOffset = view.getUint32(12 + i * 16 + 8);
1370
+ else if (tag === TABLE_TAG_STAT) {
1371
+ statTableOffset = view.getUint32(offset + 8);
1333
1372
  }
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);
1373
+ else if (tag === TABLE_TAG_NAME) {
1374
+ nameTableOffset = view.getUint32(offset + 8);
1339
1375
  }
1340
1376
  }
1341
1377
  const unitsPerEm = headTableOffset
@@ -1378,6 +1414,88 @@ class FontMetadataExtractor {
1378
1414
  axisNames
1379
1415
  };
1380
1416
  }
1417
+ static extractFeatureTags(fontBuffer) {
1418
+ const view = new DataView(fontBuffer);
1419
+ const numTables = view.getUint16(4);
1420
+ let gsubTableOffset = 0;
1421
+ let gposTableOffset = 0;
1422
+ let nameTableOffset = 0;
1423
+ for (let i = 0; i < numTables; i++) {
1424
+ const offset = 12 + i * 16;
1425
+ const tag = view.getUint32(offset);
1426
+ if (tag === TABLE_TAG_GSUB) {
1427
+ gsubTableOffset = view.getUint32(offset + 8);
1428
+ }
1429
+ else if (tag === TABLE_TAG_GPOS) {
1430
+ gposTableOffset = view.getUint32(offset + 8);
1431
+ }
1432
+ else if (tag === TABLE_TAG_NAME) {
1433
+ nameTableOffset = view.getUint32(offset + 8);
1434
+ }
1435
+ }
1436
+ const features = new Set();
1437
+ const featureNames = {};
1438
+ try {
1439
+ if (gsubTableOffset) {
1440
+ const gsubData = this.extractFeatureDataFromTable(view, gsubTableOffset, nameTableOffset);
1441
+ gsubData.features.forEach(f => features.add(f));
1442
+ Object.assign(featureNames, gsubData.names);
1443
+ }
1444
+ if (gposTableOffset) {
1445
+ const gposData = this.extractFeatureDataFromTable(view, gposTableOffset, nameTableOffset);
1446
+ gposData.features.forEach(f => features.add(f));
1447
+ Object.assign(featureNames, gposData.names);
1448
+ }
1449
+ }
1450
+ catch (e) {
1451
+ return undefined;
1452
+ }
1453
+ const featureArray = Array.from(features).sort();
1454
+ if (featureArray.length === 0)
1455
+ return undefined;
1456
+ return {
1457
+ tags: featureArray,
1458
+ names: Object.keys(featureNames).length > 0 ? featureNames : {}
1459
+ };
1460
+ }
1461
+ static extractFeatureDataFromTable(view, tableOffset, nameTableOffset) {
1462
+ const featureListOffset = view.getUint16(tableOffset + 6);
1463
+ const featureListStart = tableOffset + featureListOffset;
1464
+ const featureCount = view.getUint16(featureListStart);
1465
+ const features = [];
1466
+ const names = {};
1467
+ for (let i = 0; i < featureCount; i++) {
1468
+ const recordOffset = featureListStart + 2 + i * 6;
1469
+ // Decode feature tag
1470
+ const tag = String.fromCharCode(view.getUint8(recordOffset), view.getUint8(recordOffset + 1), view.getUint8(recordOffset + 2), view.getUint8(recordOffset + 3));
1471
+ features.push(tag);
1472
+ // Extract feature name for stylistic sets and character variants
1473
+ if (/^(ss\d{2}|cv\d{2})$/.test(tag) && nameTableOffset) {
1474
+ const featureOffset = view.getUint16(recordOffset + 4);
1475
+ const featureTableStart = featureListStart + featureOffset;
1476
+ // Feature table structure:
1477
+ // uint16 FeatureParams offset
1478
+ // uint16 LookupCount
1479
+ // uint16[LookupCount] LookupListIndex
1480
+ const featureParamsOffset = view.getUint16(featureTableStart);
1481
+ // FeatureParams for ss features:
1482
+ // uint16 Version (should be 0)
1483
+ // uint16 UINameID
1484
+ if (featureParamsOffset !== 0) {
1485
+ const paramsStart = featureTableStart + featureParamsOffset;
1486
+ const version = view.getUint16(paramsStart);
1487
+ if (version === 0) {
1488
+ const nameID = view.getUint16(paramsStart + 2);
1489
+ const name = this.getNameFromNameTable(view, nameTableOffset, nameID);
1490
+ if (name) {
1491
+ names[tag] = name;
1492
+ }
1493
+ }
1494
+ }
1495
+ }
1496
+ }
1497
+ return { features, names };
1498
+ }
1381
1499
  static extractAxisNames(view, statOffset, nameOffset) {
1382
1500
  try {
1383
1501
  // STAT table structure
@@ -1588,7 +1706,7 @@ class WoffConverter {
1588
1706
  const padding = (4 - (table.origLength % 4)) % 4;
1589
1707
  sfntOffset += padding;
1590
1708
  }
1591
- debugLogger.log('WOFF font decompressed successfully');
1709
+ logger.log('WOFF font decompressed successfully');
1592
1710
  return sfntData.buffer.slice(0, sfntOffset);
1593
1711
  }
1594
1712
  static async decompressZlib(compressedData) {
@@ -1617,7 +1735,7 @@ class FontLoader {
1617
1735
  // Check if this is a WOFF font and decompress if needed
1618
1736
  const format = WoffConverter.detectFormat(fontBuffer);
1619
1737
  if (format === 'woff') {
1620
- debugLogger.log('WOFF font detected, decompressing...');
1738
+ logger.log('WOFF font detected, decompressing...');
1621
1739
  fontBuffer = await WoffConverter.decompressWoff(fontBuffer);
1622
1740
  }
1623
1741
  else if (format === 'woff2') {
@@ -1655,6 +1773,7 @@ class FontLoader {
1655
1773
  };
1656
1774
  }
1657
1775
  }
1776
+ const featureData = FontMetadataExtractor.extractFeatureTags(fontBuffer);
1658
1777
  return {
1659
1778
  hb,
1660
1779
  fontBlob,
@@ -1665,11 +1784,13 @@ class FontLoader {
1665
1784
  metrics,
1666
1785
  fontVariations,
1667
1786
  isVariable,
1668
- variationAxes
1787
+ variationAxes,
1788
+ availableFeatures: featureData?.tags,
1789
+ featureNames: featureData?.names
1669
1790
  };
1670
1791
  }
1671
1792
  catch (error) {
1672
- debugLogger.error('Failed to load font:', error);
1793
+ logger.error('Failed to load font:', error);
1673
1794
  throw error;
1674
1795
  }
1675
1796
  finally {
@@ -1690,7 +1811,7 @@ class FontLoader {
1690
1811
  }
1691
1812
  }
1692
1813
  catch (error) {
1693
- debugLogger.error('Error destroying font resources:', error);
1814
+ logger.error('Error destroying font resources:', error);
1694
1815
  }
1695
1816
  }
1696
1817
  }
@@ -2092,7 +2213,7 @@ class Tessellator {
2092
2213
  if (valid.length === 0) {
2093
2214
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2094
2215
  }
2095
- debugLogger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2216
+ logger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2096
2217
  return this.tessellate(valid, removeOverlaps, isCFF);
2097
2218
  }
2098
2219
  tessellate(paths, removeOverlaps, isCFF) {
@@ -2102,19 +2223,19 @@ class Tessellator {
2102
2223
  : paths;
2103
2224
  let contours = this.pathsToContours(normalizedPaths);
2104
2225
  if (removeOverlaps) {
2105
- debugLogger.log('Two-pass: boundary extraction then triangulation');
2226
+ logger.log('Two-pass: boundary extraction then triangulation');
2106
2227
  // Extract boundaries to remove overlaps
2107
2228
  const boundaryResult = this.performTessellation(contours, 'boundary');
2108
2229
  if (!boundaryResult) {
2109
- debugLogger.warn('libtess returned empty result from boundary pass');
2230
+ logger.warn('libtess returned empty result from boundary pass');
2110
2231
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2111
2232
  }
2112
2233
  // Convert boundary elements back to contours
2113
2234
  contours = this.boundaryToContours(boundaryResult);
2114
- debugLogger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2235
+ logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2115
2236
  }
2116
2237
  else {
2117
- debugLogger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2238
+ logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2118
2239
  }
2119
2240
  // Triangulate the contours
2120
2241
  const triangleResult = this.performTessellation(contours, 'triangles');
@@ -2122,7 +2243,7 @@ class Tessellator {
2122
2243
  const warning = removeOverlaps
2123
2244
  ? 'libtess returned empty result from triangulation pass'
2124
2245
  : 'libtess returned empty result from single-pass triangulation';
2125
- debugLogger.warn(warning);
2246
+ logger.warn(warning);
2126
2247
  return { triangles: { vertices: [], indices: [] }, contours };
2127
2248
  }
2128
2249
  return {
@@ -2177,7 +2298,7 @@ class Tessellator {
2177
2298
  return idx;
2178
2299
  });
2179
2300
  tess.gluTessCallback(libtess_minExports.gluEnum.GLU_TESS_ERROR, (errno) => {
2180
- debugLogger.warn(`libtess error: ${errno}`);
2301
+ logger.warn(`libtess error: ${errno}`);
2181
2302
  });
2182
2303
  tess.gluTessNormal(0, 0, 1);
2183
2304
  tess.gluTessBeginPolygon(null);
@@ -3199,7 +3320,7 @@ class DrawCallbackHandler {
3199
3320
  }
3200
3321
  }
3201
3322
  catch (error) {
3202
- debugLogger.warn('Error destroying draw callbacks:', error);
3323
+ logger.warn('Error destroying draw callbacks:', error);
3203
3324
  }
3204
3325
  this.collector = undefined;
3205
3326
  }
@@ -3489,7 +3610,8 @@ class TextShaper {
3489
3610
  }
3490
3611
  buffer.addText(lineInfo.text);
3491
3612
  buffer.guessSegmentProperties();
3492
- this.loadedFont.hb.shape(this.loadedFont.font, buffer);
3613
+ const featuresString = convertFontFeaturesToString(this.loadedFont.fontFeatures);
3614
+ this.loadedFont.hb.shape(this.loadedFont.font, buffer, featuresString);
3493
3615
  const glyphInfos = buffer.json(this.loadedFont.font);
3494
3616
  buffer.destroy();
3495
3617
  const clusters = [];
@@ -3568,12 +3690,10 @@ class TextShaper {
3568
3690
  const stretchFactor = SPACE_STRETCH_RATIO;
3569
3691
  const shrinkFactor = SPACE_SHRINK_RATIO;
3570
3692
  if (lineInfo.adjustmentRatio > 0) {
3571
- spaceAdjustment =
3572
- lineInfo.adjustmentRatio * width * stretchFactor;
3693
+ spaceAdjustment = lineInfo.adjustmentRatio * width * stretchFactor;
3573
3694
  }
3574
3695
  else if (lineInfo.adjustmentRatio < 0) {
3575
- spaceAdjustment =
3576
- lineInfo.adjustmentRatio * width * shrinkFactor;
3696
+ spaceAdjustment = lineInfo.adjustmentRatio * width * shrinkFactor;
3577
3697
  }
3578
3698
  }
3579
3699
  return spaceAdjustment;
@@ -4498,12 +4618,16 @@ class Text {
4498
4618
  const baseFontKey = typeof options.font === 'string'
4499
4619
  ? options.font
4500
4620
  : `buffer-${Text.generateFontContentHash(options.font)}`;
4501
- const fontKey = options.fontVariations
4502
- ? `${baseFontKey}_${JSON.stringify(options.fontVariations)}`
4503
- : baseFontKey;
4621
+ let fontKey = baseFontKey;
4622
+ if (options.fontVariations) {
4623
+ fontKey += `_var_${JSON.stringify(options.fontVariations)}`;
4624
+ }
4625
+ if (options.fontFeatures) {
4626
+ fontKey += `_feat_${JSON.stringify(options.fontFeatures)}`;
4627
+ }
4504
4628
  let loadedFont = Text.fontCache.get(fontKey);
4505
4629
  if (!loadedFont) {
4506
- loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations);
4630
+ loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
4507
4631
  }
4508
4632
  const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
4509
4633
  text.setLoadedFont(loadedFont);
@@ -4517,12 +4641,11 @@ class Text {
4517
4641
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
4518
4642
  };
4519
4643
  }
4520
- static async loadAndCacheFont(fontKey, font, fontVariations) {
4644
+ static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
4521
4645
  const tempText = new Text();
4522
- await tempText.loadFont(font, fontVariations);
4646
+ await tempText.loadFont(font, fontVariations, fontFeatures);
4523
4647
  const loadedFont = tempText.getLoadedFont();
4524
4648
  Text.fontCache.set(fontKey, loadedFont);
4525
- // Don't destroy tempText - the cached font references its HarfBuzz objects
4526
4649
  return loadedFont;
4527
4650
  }
4528
4651
  static generateFontContentHash(buffer) {
@@ -4541,10 +4664,13 @@ class Text {
4541
4664
  const contentHash = Text.generateFontContentHash(loadedFont._buffer);
4542
4665
  this.currentFontId = `font_${contentHash}`;
4543
4666
  if (loadedFont.fontVariations) {
4544
- this.currentFontId += `_${JSON.stringify(loadedFont.fontVariations)}`;
4667
+ this.currentFontId += `_var_${JSON.stringify(loadedFont.fontVariations)}`;
4668
+ }
4669
+ if (loadedFont.fontFeatures) {
4670
+ this.currentFontId += `_feat_${JSON.stringify(loadedFont.fontFeatures)}`;
4545
4671
  }
4546
4672
  }
4547
- async loadFont(fontSrc, fontVariations) {
4673
+ async loadFont(fontSrc, fontVariations, fontFeatures) {
4548
4674
  perfLogger.start('Text.loadFont', {
4549
4675
  fontSrc: typeof fontSrc === 'string' ? fontSrc : `buffer(${fontSrc.byteLength})`
4550
4676
  });
@@ -4565,14 +4691,20 @@ class Text {
4565
4691
  this.destroy();
4566
4692
  }
4567
4693
  this.loadedFont = await this.fontLoader.loadFont(fontBuffer, fontVariations);
4694
+ if (fontFeatures) {
4695
+ this.loadedFont.fontFeatures = fontFeatures;
4696
+ }
4568
4697
  const contentHash = Text.generateFontContentHash(fontBuffer);
4569
4698
  this.currentFontId = `font_${contentHash}`;
4570
4699
  if (fontVariations) {
4571
- this.currentFontId += `_${JSON.stringify(fontVariations)}`;
4700
+ this.currentFontId += `_var_${JSON.stringify(fontVariations)}`;
4701
+ }
4702
+ if (fontFeatures) {
4703
+ this.currentFontId += `_feat_${JSON.stringify(fontFeatures)}`;
4572
4704
  }
4573
4705
  }
4574
4706
  catch (error) {
4575
- debugLogger.error('Failed to load font:', error);
4707
+ logger.error('Failed to load font:', error);
4576
4708
  throw error;
4577
4709
  }
4578
4710
  finally {
@@ -4647,7 +4779,7 @@ class Text {
4647
4779
  };
4648
4780
  }
4649
4781
  catch (error) {
4650
- debugLogger.warn(`Failed to load patterns for ${language}: ${error}`);
4782
+ logger.warn(`Failed to load patterns for ${language}: ${error}`);
4651
4783
  return {
4652
4784
  ...options,
4653
4785
  layout: {
@@ -4911,7 +5043,7 @@ class Text {
4911
5043
  Text.patternCache.set(language, pattern);
4912
5044
  }
4913
5045
  catch (error) {
4914
- debugLogger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
5046
+ logger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
4915
5047
  }
4916
5048
  }
4917
5049
  }));
@@ -4973,7 +5105,7 @@ class Text {
4973
5105
  FontLoader.destroyFont(currentFont);
4974
5106
  }
4975
5107
  catch (error) {
4976
- debugLogger.warn('Error destroying HarfBuzz objects:', error);
5108
+ logger.warn('Error destroying HarfBuzz objects:', error);
4977
5109
  }
4978
5110
  finally {
4979
5111
  this.loadedFont = undefined;
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;