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/README.md CHANGED
@@ -19,7 +19,7 @@ A high fidelity 3D font renderer and text layout engine for the web
19
19
 
20
20
  The library has a framework-agnostic core that returns raw vertex data, with lightweight adapters for [Three.js](https://threejs.org), [React Three Fiber](https://docs.pmnd.rs/react-three-fiber), [p5.js](https://p5js.org), [WebGL](https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API), and [WebGPU](https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API)
21
21
 
22
- Under the hood, three-text relies on [HarfBuzz](https://github.com/harfbuzz/harfbuzzjs) for text shaping, [Knuth-Plass](http://www.eprg.org/G53DOC/pdfs/knuth-plass-breaking.pdf) line breaking, [Liang](https://tug.org/docs/liang/liang-thesis.pdf) hyphenation, [libtess](https://github.com/brendankenny/libtess.js), based on the [GLU tessellator](https://www.songho.ca/opengl/gl_tessellation.html) by Eric Veach) for removing overlaps and triangulation, bezier curve polygonization from Maxim Shemanarev's [Anti-Grain Geometry](https://web.archive.org/web/20060128212843/http://www.antigrain.com/research/adaptive_bezier/index.html), and [Visvalingam-Whyatt](https://hull-repository.worktribe.com/preview/376364/000870493786962263.pdf) [line simplification](https://bost.ocks.org/mike/simplify/).
22
+ Under the hood, three-text relies on [HarfBuzz](https://github.com/harfbuzz/harfbuzzjs) for text shaping, [Knuth-Plass](http://www.eprg.org/G53DOC/pdfs/knuth-plass-breaking.pdf) line breaking, [Liang](https://tug.org/docs/liang/liang-thesis.pdf) hyphenation, [libtess](https://github.com/brendankenny/libtess.js) (based on the [GLU tessellator](https://www.songho.ca/opengl/gl_tessellation.html) by Eric Veach) for removing overlaps and triangulation, curve polygonization from Maxim Shemanarev's [Anti-Grain Geometry](https://web.archive.org/web/20060128212843/http://www.antigrain.com/research/adaptive_bezier/index.html), and [Visvalingam-Whyatt](https://hull-repository.worktribe.com/preview/376364/000870493786962263.pdf) [line simplification](https://bost.ocks.org/mike/simplify/)
23
23
 
24
24
  ## Table of contents
25
25
 
@@ -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.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
@@ -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,22 +1180,47 @@ 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
1192
1220
  let totalWidth = 0;
1193
- glyphInfos.forEach((glyph, index) => {
1221
+ glyphInfos.forEach((glyph) => {
1194
1222
  totalWidth += glyph.ax;
1195
- // Spaces measured alone need letter spacing to match final rendering
1196
- const isLastChar = index === glyphInfos.length - 1;
1197
- const isSingleSpace = text === ' ' || text === ' ' || /^\s+$/.test(text);
1198
- if (letterSpacingInFontUnits !== 0 && (!isLastChar || isSingleSpace)) {
1223
+ if (letterSpacingInFontUnits !== 0) {
1199
1224
  totalWidth += letterSpacingInFontUnits;
1200
1225
  }
1201
1226
  });
@@ -1250,7 +1275,7 @@ class TextLayout {
1250
1275
  originalEnd: currentIndex + line.length - 1,
1251
1276
  xOffset: 0
1252
1277
  });
1253
- currentIndex += line.length + 1; // +1 for the newline character
1278
+ currentIndex += line.length + 1;
1254
1279
  }
1255
1280
  }
1256
1281
  return { lines };
@@ -1271,7 +1296,7 @@ class TextLayout {
1271
1296
  offset = width - planeBounds.max.x;
1272
1297
  }
1273
1298
  if (offset !== 0) {
1274
- // Translate vertices directly
1299
+ // Translate vertices
1275
1300
  for (let i = 0; i < vertices.length; i += 3) {
1276
1301
  vertices[i] += offset;
1277
1302
  }
@@ -1289,6 +1314,17 @@ const FONT_SIGNATURE_OPEN_TYPE_CFF = 0x4f54544f; // 'OTTO'
1289
1314
  const FONT_SIGNATURE_TRUE_TYPE_COLLECTION = 0x74746366; // 'ttcf'
1290
1315
  const FONT_SIGNATURE_WOFF = 0x774f4646; // 'wOFF'
1291
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'
1292
1328
 
1293
1329
  class FontMetadataExtractor {
1294
1330
  static extractMetadata(fontBuffer) {
@@ -1305,7 +1341,6 @@ class FontMetadataExtractor {
1305
1341
  if (!validSignatures.includes(sfntVersion)) {
1306
1342
  throw new Error(`Invalid font format. Expected TrueType or OpenType, got signature: 0x${sfntVersion.toString(16)}`);
1307
1343
  }
1308
- const buffer = new Uint8Array(fontBuffer);
1309
1344
  const numTables = view.getUint16(4); // OpenType header - number of tables is at offset 4
1310
1345
  let isCFF = false;
1311
1346
  let headTableOffset = 0;
@@ -1315,30 +1350,28 @@ class FontMetadataExtractor {
1315
1350
  let nameTableOffset = 0;
1316
1351
  let fvarTableOffset = 0;
1317
1352
  for (let i = 0; i < numTables; i++) {
1318
- const tag = new TextDecoder().decode(buffer.slice(12 + i * 16, 12 + i * 16 + 4));
1319
- 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) {
1320
1356
  isCFF = true;
1321
1357
  }
1322
- else if (tag === 'CFF2') {
1323
- isCFF = true;
1358
+ else if (tag === TABLE_TAG_HEAD) {
1359
+ headTableOffset = view.getUint32(offset + 8);
1324
1360
  }
1325
- if (tag === 'head') {
1326
- headTableOffset = view.getUint32(12 + i * 16 + 8);
1361
+ else if (tag === TABLE_TAG_HHEA) {
1362
+ hheaTableOffset = view.getUint32(offset + 8);
1327
1363
  }
1328
- if (tag === 'hhea') {
1329
- hheaTableOffset = view.getUint32(12 + i * 16 + 8);
1364
+ else if (tag === TABLE_TAG_OS2) {
1365
+ os2TableOffset = view.getUint32(offset + 8);
1330
1366
  }
1331
- if (tag === 'OS/2') {
1332
- os2TableOffset = view.getUint32(12 + i * 16 + 8);
1367
+ else if (tag === TABLE_TAG_FVAR) {
1368
+ fvarTableOffset = view.getUint32(offset + 8);
1333
1369
  }
1334
- if (tag === 'fvar') {
1335
- fvarTableOffset = view.getUint32(12 + i * 16 + 8);
1370
+ else if (tag === TABLE_TAG_STAT) {
1371
+ statTableOffset = view.getUint32(offset + 8);
1336
1372
  }
1337
- if (tag === 'STAT') {
1338
- statTableOffset = view.getUint32(12 + i * 16 + 8);
1339
- }
1340
- if (tag === 'name') {
1341
- nameTableOffset = view.getUint32(12 + i * 16 + 8);
1373
+ else if (tag === TABLE_TAG_NAME) {
1374
+ nameTableOffset = view.getUint32(offset + 8);
1342
1375
  }
1343
1376
  }
1344
1377
  const unitsPerEm = headTableOffset
@@ -1381,6 +1414,88 @@ class FontMetadataExtractor {
1381
1414
  axisNames
1382
1415
  };
1383
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
+ }
1384
1499
  static extractAxisNames(view, statOffset, nameOffset) {
1385
1500
  try {
1386
1501
  // STAT table structure
@@ -1591,7 +1706,7 @@ class WoffConverter {
1591
1706
  const padding = (4 - (table.origLength % 4)) % 4;
1592
1707
  sfntOffset += padding;
1593
1708
  }
1594
- debugLogger.log('WOFF font decompressed successfully');
1709
+ logger.log('WOFF font decompressed successfully');
1595
1710
  return sfntData.buffer.slice(0, sfntOffset);
1596
1711
  }
1597
1712
  static async decompressZlib(compressedData) {
@@ -1620,7 +1735,7 @@ class FontLoader {
1620
1735
  // Check if this is a WOFF font and decompress if needed
1621
1736
  const format = WoffConverter.detectFormat(fontBuffer);
1622
1737
  if (format === 'woff') {
1623
- debugLogger.log('WOFF font detected, decompressing...');
1738
+ logger.log('WOFF font detected, decompressing...');
1624
1739
  fontBuffer = await WoffConverter.decompressWoff(fontBuffer);
1625
1740
  }
1626
1741
  else if (format === 'woff2') {
@@ -1658,6 +1773,7 @@ class FontLoader {
1658
1773
  };
1659
1774
  }
1660
1775
  }
1776
+ const featureData = FontMetadataExtractor.extractFeatureTags(fontBuffer);
1661
1777
  return {
1662
1778
  hb,
1663
1779
  fontBlob,
@@ -1668,11 +1784,13 @@ class FontLoader {
1668
1784
  metrics,
1669
1785
  fontVariations,
1670
1786
  isVariable,
1671
- variationAxes
1787
+ variationAxes,
1788
+ availableFeatures: featureData?.tags,
1789
+ featureNames: featureData?.names
1672
1790
  };
1673
1791
  }
1674
1792
  catch (error) {
1675
- debugLogger.error('Failed to load font:', error);
1793
+ logger.error('Failed to load font:', error);
1676
1794
  throw error;
1677
1795
  }
1678
1796
  finally {
@@ -1693,7 +1811,7 @@ class FontLoader {
1693
1811
  }
1694
1812
  }
1695
1813
  catch (error) {
1696
- debugLogger.error('Error destroying font resources:', error);
1814
+ logger.error('Error destroying font resources:', error);
1697
1815
  }
1698
1816
  }
1699
1817
  }
@@ -2095,7 +2213,7 @@ class Tessellator {
2095
2213
  if (valid.length === 0) {
2096
2214
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2097
2215
  }
2098
- debugLogger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2216
+ logger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2099
2217
  return this.tessellate(valid, removeOverlaps, isCFF);
2100
2218
  }
2101
2219
  tessellate(paths, removeOverlaps, isCFF) {
@@ -2105,19 +2223,19 @@ class Tessellator {
2105
2223
  : paths;
2106
2224
  let contours = this.pathsToContours(normalizedPaths);
2107
2225
  if (removeOverlaps) {
2108
- debugLogger.log('Two-pass: boundary extraction then triangulation');
2226
+ logger.log('Two-pass: boundary extraction then triangulation');
2109
2227
  // Extract boundaries to remove overlaps
2110
2228
  const boundaryResult = this.performTessellation(contours, 'boundary');
2111
2229
  if (!boundaryResult) {
2112
- debugLogger.warn('libtess returned empty result from boundary pass');
2230
+ logger.warn('libtess returned empty result from boundary pass');
2113
2231
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2114
2232
  }
2115
2233
  // Convert boundary elements back to contours
2116
2234
  contours = this.boundaryToContours(boundaryResult);
2117
- debugLogger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2235
+ logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2118
2236
  }
2119
2237
  else {
2120
- debugLogger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2238
+ logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2121
2239
  }
2122
2240
  // Triangulate the contours
2123
2241
  const triangleResult = this.performTessellation(contours, 'triangles');
@@ -2125,7 +2243,7 @@ class Tessellator {
2125
2243
  const warning = removeOverlaps
2126
2244
  ? 'libtess returned empty result from triangulation pass'
2127
2245
  : 'libtess returned empty result from single-pass triangulation';
2128
- debugLogger.warn(warning);
2246
+ logger.warn(warning);
2129
2247
  return { triangles: { vertices: [], indices: [] }, contours };
2130
2248
  }
2131
2249
  return {
@@ -2180,7 +2298,7 @@ class Tessellator {
2180
2298
  return idx;
2181
2299
  });
2182
2300
  tess.gluTessCallback(libtess_minExports.gluEnum.GLU_TESS_ERROR, (errno) => {
2183
- debugLogger.warn(`libtess error: ${errno}`);
2301
+ logger.warn(`libtess error: ${errno}`);
2184
2302
  });
2185
2303
  tess.gluTessNormal(0, 0, 1);
2186
2304
  tess.gluTessBeginPolygon(null);
@@ -3202,7 +3320,7 @@ class DrawCallbackHandler {
3202
3320
  }
3203
3321
  }
3204
3322
  catch (error) {
3205
- debugLogger.warn('Error destroying draw callbacks:', error);
3323
+ logger.warn('Error destroying draw callbacks:', error);
3206
3324
  }
3207
3325
  this.collector = undefined;
3208
3326
  }
@@ -3492,7 +3610,8 @@ class TextShaper {
3492
3610
  }
3493
3611
  buffer.addText(lineInfo.text);
3494
3612
  buffer.guessSegmentProperties();
3495
- 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);
3496
3615
  const glyphInfos = buffer.json(this.loadedFont.font);
3497
3616
  buffer.destroy();
3498
3617
  const clusters = [];
@@ -3567,15 +3686,14 @@ class TextShaper {
3567
3686
  naturalSpaceWidth = TextMeasurer.measureTextWidth(this.loadedFont, ' ', letterSpacing);
3568
3687
  this.cachedSpaceWidth.set(letterSpacing, naturalSpaceWidth);
3569
3688
  }
3689
+ const width = naturalSpaceWidth;
3570
3690
  const stretchFactor = SPACE_STRETCH_RATIO;
3571
3691
  const shrinkFactor = SPACE_SHRINK_RATIO;
3572
3692
  if (lineInfo.adjustmentRatio > 0) {
3573
- spaceAdjustment =
3574
- lineInfo.adjustmentRatio * naturalSpaceWidth * stretchFactor;
3693
+ spaceAdjustment = lineInfo.adjustmentRatio * width * stretchFactor;
3575
3694
  }
3576
3695
  else if (lineInfo.adjustmentRatio < 0) {
3577
- spaceAdjustment =
3578
- lineInfo.adjustmentRatio * naturalSpaceWidth * shrinkFactor;
3696
+ spaceAdjustment = lineInfo.adjustmentRatio * width * shrinkFactor;
3579
3697
  }
3580
3698
  }
3581
3699
  return spaceAdjustment;
@@ -4500,12 +4618,16 @@ class Text {
4500
4618
  const baseFontKey = typeof options.font === 'string'
4501
4619
  ? options.font
4502
4620
  : `buffer-${Text.generateFontContentHash(options.font)}`;
4503
- const fontKey = options.fontVariations
4504
- ? `${baseFontKey}_${JSON.stringify(options.fontVariations)}`
4505
- : 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
+ }
4506
4628
  let loadedFont = Text.fontCache.get(fontKey);
4507
4629
  if (!loadedFont) {
4508
- loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations);
4630
+ loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
4509
4631
  }
4510
4632
  const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
4511
4633
  text.setLoadedFont(loadedFont);
@@ -4519,12 +4641,11 @@ class Text {
4519
4641
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
4520
4642
  };
4521
4643
  }
4522
- static async loadAndCacheFont(fontKey, font, fontVariations) {
4644
+ static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
4523
4645
  const tempText = new Text();
4524
- await tempText.loadFont(font, fontVariations);
4646
+ await tempText.loadFont(font, fontVariations, fontFeatures);
4525
4647
  const loadedFont = tempText.getLoadedFont();
4526
4648
  Text.fontCache.set(fontKey, loadedFont);
4527
- // Don't destroy tempText - the cached font references its HarfBuzz objects
4528
4649
  return loadedFont;
4529
4650
  }
4530
4651
  static generateFontContentHash(buffer) {
@@ -4543,10 +4664,13 @@ class Text {
4543
4664
  const contentHash = Text.generateFontContentHash(loadedFont._buffer);
4544
4665
  this.currentFontId = `font_${contentHash}`;
4545
4666
  if (loadedFont.fontVariations) {
4546
- 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)}`;
4547
4671
  }
4548
4672
  }
4549
- async loadFont(fontSrc, fontVariations) {
4673
+ async loadFont(fontSrc, fontVariations, fontFeatures) {
4550
4674
  perfLogger.start('Text.loadFont', {
4551
4675
  fontSrc: typeof fontSrc === 'string' ? fontSrc : `buffer(${fontSrc.byteLength})`
4552
4676
  });
@@ -4567,14 +4691,20 @@ class Text {
4567
4691
  this.destroy();
4568
4692
  }
4569
4693
  this.loadedFont = await this.fontLoader.loadFont(fontBuffer, fontVariations);
4694
+ if (fontFeatures) {
4695
+ this.loadedFont.fontFeatures = fontFeatures;
4696
+ }
4570
4697
  const contentHash = Text.generateFontContentHash(fontBuffer);
4571
4698
  this.currentFontId = `font_${contentHash}`;
4572
4699
  if (fontVariations) {
4573
- this.currentFontId += `_${JSON.stringify(fontVariations)}`;
4700
+ this.currentFontId += `_var_${JSON.stringify(fontVariations)}`;
4701
+ }
4702
+ if (fontFeatures) {
4703
+ this.currentFontId += `_feat_${JSON.stringify(fontFeatures)}`;
4574
4704
  }
4575
4705
  }
4576
4706
  catch (error) {
4577
- debugLogger.error('Failed to load font:', error);
4707
+ logger.error('Failed to load font:', error);
4578
4708
  throw error;
4579
4709
  }
4580
4710
  finally {
@@ -4649,7 +4779,7 @@ class Text {
4649
4779
  };
4650
4780
  }
4651
4781
  catch (error) {
4652
- debugLogger.warn(`Failed to load patterns for ${language}: ${error}`);
4782
+ logger.warn(`Failed to load patterns for ${language}: ${error}`);
4653
4783
  return {
4654
4784
  ...options,
4655
4785
  layout: {
@@ -4913,7 +5043,7 @@ class Text {
4913
5043
  Text.patternCache.set(language, pattern);
4914
5044
  }
4915
5045
  catch (error) {
4916
- debugLogger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
5046
+ logger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
4917
5047
  }
4918
5048
  }
4919
5049
  }));
@@ -4975,7 +5105,7 @@ class Text {
4975
5105
  FontLoader.destroyFont(currentFont);
4976
5106
  }
4977
5107
  catch (error) {
4978
- debugLogger.warn('Error destroying HarfBuzz objects:', error);
5108
+ logger.warn('Error destroying HarfBuzz objects:', error);
4979
5109
  }
4980
5110
  finally {
4981
5111
  this.loadedFont = undefined;