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/dist/index.umd.js 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
@@ -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;
@@ -567,7 +567,7 @@
567
567
  let useHyphenation = hyphenate;
568
568
  if (useHyphenation &&
569
569
  (!hyphenationPatterns || !hyphenationPatterns[language])) {
570
- debugLogger.warn(`Hyphenation patterns for ${language} not available`);
570
+ logger.warn(`Hyphenation patterns for ${language} not available`);
571
571
  useHyphenation = false;
572
572
  }
573
573
  // Calculate initial emergency stretch (TeX default: 0)
@@ -1182,12 +1182,40 @@
1182
1182
  }
1183
1183
  }
1184
1184
 
1185
+ // Convert feature objects to HarfBuzz comma-separated format
1186
+ function convertFontFeaturesToString(features) {
1187
+ if (!features || Object.keys(features).length === 0) {
1188
+ return undefined;
1189
+ }
1190
+ const featureStrings = [];
1191
+ for (const [tag, value] of Object.entries(features)) {
1192
+ if (!/^[a-zA-Z0-9]{4}$/.test(tag)) {
1193
+ logger.warn(`Invalid OpenType feature tag: "${tag}". Tags must be exactly 4 alphanumeric characters.`);
1194
+ continue;
1195
+ }
1196
+ if (value === false || value === 0) {
1197
+ featureStrings.push(`${tag}=0`);
1198
+ }
1199
+ else if (value === true || value === 1) {
1200
+ featureStrings.push(tag);
1201
+ }
1202
+ else if (typeof value === 'number' && value > 1) {
1203
+ featureStrings.push(`${tag}=${Math.floor(value)}`);
1204
+ }
1205
+ else {
1206
+ logger.warn(`Invalid value for feature "${tag}": ${value}. Expected boolean or positive number.`);
1207
+ }
1208
+ }
1209
+ return featureStrings.length > 0 ? featureStrings.join(',') : undefined;
1210
+ }
1211
+
1185
1212
  class TextMeasurer {
1186
1213
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1187
1214
  const buffer = loadedFont.hb.createBuffer();
1188
1215
  buffer.addText(text);
1189
1216
  buffer.guessSegmentProperties();
1190
- loadedFont.hb.shape(loadedFont.font, buffer);
1217
+ const featuresString = convertFontFeaturesToString(loadedFont.fontFeatures);
1218
+ loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
1191
1219
  const glyphInfos = buffer.json(loadedFont.font);
1192
1220
  const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
1193
1221
  // Calculate total advance width with letter spacing
@@ -1288,6 +1316,17 @@
1288
1316
  const FONT_SIGNATURE_TRUE_TYPE_COLLECTION = 0x74746366; // 'ttcf'
1289
1317
  const FONT_SIGNATURE_WOFF = 0x774f4646; // 'wOFF'
1290
1318
  const FONT_SIGNATURE_WOFF2 = 0x774f4632; // 'wOF2'
1319
+ // Table Tags
1320
+ const TABLE_TAG_HEAD = 0x68656164; // 'head'
1321
+ const TABLE_TAG_HHEA = 0x68686561; // 'hhea'
1322
+ const TABLE_TAG_OS2 = 0x4f532f32; // 'OS/2'
1323
+ const TABLE_TAG_FVAR = 0x66766172; // 'fvar'
1324
+ const TABLE_TAG_STAT = 0x53544154; // 'STAT'
1325
+ const TABLE_TAG_NAME = 0x6e616d65; // 'name'
1326
+ const TABLE_TAG_CFF = 0x43464620; // 'CFF '
1327
+ const TABLE_TAG_CFF2 = 0x43464632; // 'CFF2'
1328
+ const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
1329
+ const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
1291
1330
 
1292
1331
  class FontMetadataExtractor {
1293
1332
  static extractMetadata(fontBuffer) {
@@ -1304,7 +1343,6 @@
1304
1343
  if (!validSignatures.includes(sfntVersion)) {
1305
1344
  throw new Error(`Invalid font format. Expected TrueType or OpenType, got signature: 0x${sfntVersion.toString(16)}`);
1306
1345
  }
1307
- const buffer = new Uint8Array(fontBuffer);
1308
1346
  const numTables = view.getUint16(4); // OpenType header - number of tables is at offset 4
1309
1347
  let isCFF = false;
1310
1348
  let headTableOffset = 0;
@@ -1314,30 +1352,28 @@
1314
1352
  let nameTableOffset = 0;
1315
1353
  let fvarTableOffset = 0;
1316
1354
  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 ') {
1355
+ const offset = 12 + i * 16;
1356
+ const tag = view.getUint32(offset);
1357
+ if (tag === TABLE_TAG_CFF || tag === TABLE_TAG_CFF2) {
1319
1358
  isCFF = true;
1320
1359
  }
1321
- else if (tag === 'CFF2') {
1322
- isCFF = true;
1360
+ else if (tag === TABLE_TAG_HEAD) {
1361
+ headTableOffset = view.getUint32(offset + 8);
1323
1362
  }
1324
- if (tag === 'head') {
1325
- headTableOffset = view.getUint32(12 + i * 16 + 8);
1363
+ else if (tag === TABLE_TAG_HHEA) {
1364
+ hheaTableOffset = view.getUint32(offset + 8);
1326
1365
  }
1327
- if (tag === 'hhea') {
1328
- hheaTableOffset = view.getUint32(12 + i * 16 + 8);
1366
+ else if (tag === TABLE_TAG_OS2) {
1367
+ os2TableOffset = view.getUint32(offset + 8);
1329
1368
  }
1330
- if (tag === 'OS/2') {
1331
- os2TableOffset = view.getUint32(12 + i * 16 + 8);
1369
+ else if (tag === TABLE_TAG_FVAR) {
1370
+ fvarTableOffset = view.getUint32(offset + 8);
1332
1371
  }
1333
- if (tag === 'fvar') {
1334
- fvarTableOffset = view.getUint32(12 + i * 16 + 8);
1372
+ else if (tag === TABLE_TAG_STAT) {
1373
+ statTableOffset = view.getUint32(offset + 8);
1335
1374
  }
1336
- if (tag === 'STAT') {
1337
- statTableOffset = view.getUint32(12 + i * 16 + 8);
1338
- }
1339
- if (tag === 'name') {
1340
- nameTableOffset = view.getUint32(12 + i * 16 + 8);
1375
+ else if (tag === TABLE_TAG_NAME) {
1376
+ nameTableOffset = view.getUint32(offset + 8);
1341
1377
  }
1342
1378
  }
1343
1379
  const unitsPerEm = headTableOffset
@@ -1380,6 +1416,88 @@
1380
1416
  axisNames
1381
1417
  };
1382
1418
  }
1419
+ static extractFeatureTags(fontBuffer) {
1420
+ const view = new DataView(fontBuffer);
1421
+ const numTables = view.getUint16(4);
1422
+ let gsubTableOffset = 0;
1423
+ let gposTableOffset = 0;
1424
+ let nameTableOffset = 0;
1425
+ for (let i = 0; i < numTables; i++) {
1426
+ const offset = 12 + i * 16;
1427
+ const tag = view.getUint32(offset);
1428
+ if (tag === TABLE_TAG_GSUB) {
1429
+ gsubTableOffset = view.getUint32(offset + 8);
1430
+ }
1431
+ else if (tag === TABLE_TAG_GPOS) {
1432
+ gposTableOffset = view.getUint32(offset + 8);
1433
+ }
1434
+ else if (tag === TABLE_TAG_NAME) {
1435
+ nameTableOffset = view.getUint32(offset + 8);
1436
+ }
1437
+ }
1438
+ const features = new Set();
1439
+ const featureNames = {};
1440
+ try {
1441
+ if (gsubTableOffset) {
1442
+ const gsubData = this.extractFeatureDataFromTable(view, gsubTableOffset, nameTableOffset);
1443
+ gsubData.features.forEach(f => features.add(f));
1444
+ Object.assign(featureNames, gsubData.names);
1445
+ }
1446
+ if (gposTableOffset) {
1447
+ const gposData = this.extractFeatureDataFromTable(view, gposTableOffset, nameTableOffset);
1448
+ gposData.features.forEach(f => features.add(f));
1449
+ Object.assign(featureNames, gposData.names);
1450
+ }
1451
+ }
1452
+ catch (e) {
1453
+ return undefined;
1454
+ }
1455
+ const featureArray = Array.from(features).sort();
1456
+ if (featureArray.length === 0)
1457
+ return undefined;
1458
+ return {
1459
+ tags: featureArray,
1460
+ names: Object.keys(featureNames).length > 0 ? featureNames : {}
1461
+ };
1462
+ }
1463
+ static extractFeatureDataFromTable(view, tableOffset, nameTableOffset) {
1464
+ const featureListOffset = view.getUint16(tableOffset + 6);
1465
+ const featureListStart = tableOffset + featureListOffset;
1466
+ const featureCount = view.getUint16(featureListStart);
1467
+ const features = [];
1468
+ const names = {};
1469
+ for (let i = 0; i < featureCount; i++) {
1470
+ const recordOffset = featureListStart + 2 + i * 6;
1471
+ // Decode feature tag
1472
+ const tag = String.fromCharCode(view.getUint8(recordOffset), view.getUint8(recordOffset + 1), view.getUint8(recordOffset + 2), view.getUint8(recordOffset + 3));
1473
+ features.push(tag);
1474
+ // Extract feature name for stylistic sets and character variants
1475
+ if (/^(ss\d{2}|cv\d{2})$/.test(tag) && nameTableOffset) {
1476
+ const featureOffset = view.getUint16(recordOffset + 4);
1477
+ const featureTableStart = featureListStart + featureOffset;
1478
+ // Feature table structure:
1479
+ // uint16 FeatureParams offset
1480
+ // uint16 LookupCount
1481
+ // uint16[LookupCount] LookupListIndex
1482
+ const featureParamsOffset = view.getUint16(featureTableStart);
1483
+ // FeatureParams for ss features:
1484
+ // uint16 Version (should be 0)
1485
+ // uint16 UINameID
1486
+ if (featureParamsOffset !== 0) {
1487
+ const paramsStart = featureTableStart + featureParamsOffset;
1488
+ const version = view.getUint16(paramsStart);
1489
+ if (version === 0) {
1490
+ const nameID = view.getUint16(paramsStart + 2);
1491
+ const name = this.getNameFromNameTable(view, nameTableOffset, nameID);
1492
+ if (name) {
1493
+ names[tag] = name;
1494
+ }
1495
+ }
1496
+ }
1497
+ }
1498
+ }
1499
+ return { features, names };
1500
+ }
1383
1501
  static extractAxisNames(view, statOffset, nameOffset) {
1384
1502
  try {
1385
1503
  // STAT table structure
@@ -1590,7 +1708,7 @@
1590
1708
  const padding = (4 - (table.origLength % 4)) % 4;
1591
1709
  sfntOffset += padding;
1592
1710
  }
1593
- debugLogger.log('WOFF font decompressed successfully');
1711
+ logger.log('WOFF font decompressed successfully');
1594
1712
  return sfntData.buffer.slice(0, sfntOffset);
1595
1713
  }
1596
1714
  static async decompressZlib(compressedData) {
@@ -1619,7 +1737,7 @@
1619
1737
  // Check if this is a WOFF font and decompress if needed
1620
1738
  const format = WoffConverter.detectFormat(fontBuffer);
1621
1739
  if (format === 'woff') {
1622
- debugLogger.log('WOFF font detected, decompressing...');
1740
+ logger.log('WOFF font detected, decompressing...');
1623
1741
  fontBuffer = await WoffConverter.decompressWoff(fontBuffer);
1624
1742
  }
1625
1743
  else if (format === 'woff2') {
@@ -1657,6 +1775,7 @@
1657
1775
  };
1658
1776
  }
1659
1777
  }
1778
+ const featureData = FontMetadataExtractor.extractFeatureTags(fontBuffer);
1660
1779
  return {
1661
1780
  hb,
1662
1781
  fontBlob,
@@ -1667,11 +1786,13 @@
1667
1786
  metrics,
1668
1787
  fontVariations,
1669
1788
  isVariable,
1670
- variationAxes
1789
+ variationAxes,
1790
+ availableFeatures: featureData?.tags,
1791
+ featureNames: featureData?.names
1671
1792
  };
1672
1793
  }
1673
1794
  catch (error) {
1674
- debugLogger.error('Failed to load font:', error);
1795
+ logger.error('Failed to load font:', error);
1675
1796
  throw error;
1676
1797
  }
1677
1798
  finally {
@@ -1692,7 +1813,7 @@
1692
1813
  }
1693
1814
  }
1694
1815
  catch (error) {
1695
- debugLogger.error('Error destroying font resources:', error);
1816
+ logger.error('Error destroying font resources:', error);
1696
1817
  }
1697
1818
  }
1698
1819
  }
@@ -2096,7 +2217,7 @@
2096
2217
  if (valid.length === 0) {
2097
2218
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2098
2219
  }
2099
- debugLogger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2220
+ logger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2100
2221
  return this.tessellate(valid, removeOverlaps, isCFF);
2101
2222
  }
2102
2223
  tessellate(paths, removeOverlaps, isCFF) {
@@ -2106,19 +2227,19 @@
2106
2227
  : paths;
2107
2228
  let contours = this.pathsToContours(normalizedPaths);
2108
2229
  if (removeOverlaps) {
2109
- debugLogger.log('Two-pass: boundary extraction then triangulation');
2230
+ logger.log('Two-pass: boundary extraction then triangulation');
2110
2231
  // Extract boundaries to remove overlaps
2111
2232
  const boundaryResult = this.performTessellation(contours, 'boundary');
2112
2233
  if (!boundaryResult) {
2113
- debugLogger.warn('libtess returned empty result from boundary pass');
2234
+ logger.warn('libtess returned empty result from boundary pass');
2114
2235
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2115
2236
  }
2116
2237
  // Convert boundary elements back to contours
2117
2238
  contours = this.boundaryToContours(boundaryResult);
2118
- debugLogger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2239
+ logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2119
2240
  }
2120
2241
  else {
2121
- debugLogger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2242
+ logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2122
2243
  }
2123
2244
  // Triangulate the contours
2124
2245
  const triangleResult = this.performTessellation(contours, 'triangles');
@@ -2126,7 +2247,7 @@
2126
2247
  const warning = removeOverlaps
2127
2248
  ? 'libtess returned empty result from triangulation pass'
2128
2249
  : 'libtess returned empty result from single-pass triangulation';
2129
- debugLogger.warn(warning);
2250
+ logger.warn(warning);
2130
2251
  return { triangles: { vertices: [], indices: [] }, contours };
2131
2252
  }
2132
2253
  return {
@@ -2181,7 +2302,7 @@
2181
2302
  return idx;
2182
2303
  });
2183
2304
  tess.gluTessCallback(libtess_minExports.gluEnum.GLU_TESS_ERROR, (errno) => {
2184
- debugLogger.warn(`libtess error: ${errno}`);
2305
+ logger.warn(`libtess error: ${errno}`);
2185
2306
  });
2186
2307
  tess.gluTessNormal(0, 0, 1);
2187
2308
  tess.gluTessBeginPolygon(null);
@@ -3203,7 +3324,7 @@
3203
3324
  }
3204
3325
  }
3205
3326
  catch (error) {
3206
- debugLogger.warn('Error destroying draw callbacks:', error);
3327
+ logger.warn('Error destroying draw callbacks:', error);
3207
3328
  }
3208
3329
  this.collector = undefined;
3209
3330
  }
@@ -3493,7 +3614,8 @@
3493
3614
  }
3494
3615
  buffer.addText(lineInfo.text);
3495
3616
  buffer.guessSegmentProperties();
3496
- this.loadedFont.hb.shape(this.loadedFont.font, buffer);
3617
+ const featuresString = convertFontFeaturesToString(this.loadedFont.fontFeatures);
3618
+ this.loadedFont.hb.shape(this.loadedFont.font, buffer, featuresString);
3497
3619
  const glyphInfos = buffer.json(this.loadedFont.font);
3498
3620
  buffer.destroy();
3499
3621
  const clusters = [];
@@ -3572,12 +3694,10 @@
3572
3694
  const stretchFactor = SPACE_STRETCH_RATIO;
3573
3695
  const shrinkFactor = SPACE_SHRINK_RATIO;
3574
3696
  if (lineInfo.adjustmentRatio > 0) {
3575
- spaceAdjustment =
3576
- lineInfo.adjustmentRatio * width * stretchFactor;
3697
+ spaceAdjustment = lineInfo.adjustmentRatio * width * stretchFactor;
3577
3698
  }
3578
3699
  else if (lineInfo.adjustmentRatio < 0) {
3579
- spaceAdjustment =
3580
- lineInfo.adjustmentRatio * width * shrinkFactor;
3700
+ spaceAdjustment = lineInfo.adjustmentRatio * width * shrinkFactor;
3581
3701
  }
3582
3702
  }
3583
3703
  return spaceAdjustment;
@@ -4502,12 +4622,16 @@
4502
4622
  const baseFontKey = typeof options.font === 'string'
4503
4623
  ? options.font
4504
4624
  : `buffer-${Text.generateFontContentHash(options.font)}`;
4505
- const fontKey = options.fontVariations
4506
- ? `${baseFontKey}_${JSON.stringify(options.fontVariations)}`
4507
- : baseFontKey;
4625
+ let fontKey = baseFontKey;
4626
+ if (options.fontVariations) {
4627
+ fontKey += `_var_${JSON.stringify(options.fontVariations)}`;
4628
+ }
4629
+ if (options.fontFeatures) {
4630
+ fontKey += `_feat_${JSON.stringify(options.fontFeatures)}`;
4631
+ }
4508
4632
  let loadedFont = Text.fontCache.get(fontKey);
4509
4633
  if (!loadedFont) {
4510
- loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations);
4634
+ loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
4511
4635
  }
4512
4636
  const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
4513
4637
  text.setLoadedFont(loadedFont);
@@ -4521,12 +4645,11 @@
4521
4645
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
4522
4646
  };
4523
4647
  }
4524
- static async loadAndCacheFont(fontKey, font, fontVariations) {
4648
+ static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
4525
4649
  const tempText = new Text();
4526
- await tempText.loadFont(font, fontVariations);
4650
+ await tempText.loadFont(font, fontVariations, fontFeatures);
4527
4651
  const loadedFont = tempText.getLoadedFont();
4528
4652
  Text.fontCache.set(fontKey, loadedFont);
4529
- // Don't destroy tempText - the cached font references its HarfBuzz objects
4530
4653
  return loadedFont;
4531
4654
  }
4532
4655
  static generateFontContentHash(buffer) {
@@ -4545,10 +4668,13 @@
4545
4668
  const contentHash = Text.generateFontContentHash(loadedFont._buffer);
4546
4669
  this.currentFontId = `font_${contentHash}`;
4547
4670
  if (loadedFont.fontVariations) {
4548
- this.currentFontId += `_${JSON.stringify(loadedFont.fontVariations)}`;
4671
+ this.currentFontId += `_var_${JSON.stringify(loadedFont.fontVariations)}`;
4672
+ }
4673
+ if (loadedFont.fontFeatures) {
4674
+ this.currentFontId += `_feat_${JSON.stringify(loadedFont.fontFeatures)}`;
4549
4675
  }
4550
4676
  }
4551
- async loadFont(fontSrc, fontVariations) {
4677
+ async loadFont(fontSrc, fontVariations, fontFeatures) {
4552
4678
  perfLogger.start('Text.loadFont', {
4553
4679
  fontSrc: typeof fontSrc === 'string' ? fontSrc : `buffer(${fontSrc.byteLength})`
4554
4680
  });
@@ -4569,14 +4695,20 @@
4569
4695
  this.destroy();
4570
4696
  }
4571
4697
  this.loadedFont = await this.fontLoader.loadFont(fontBuffer, fontVariations);
4698
+ if (fontFeatures) {
4699
+ this.loadedFont.fontFeatures = fontFeatures;
4700
+ }
4572
4701
  const contentHash = Text.generateFontContentHash(fontBuffer);
4573
4702
  this.currentFontId = `font_${contentHash}`;
4574
4703
  if (fontVariations) {
4575
- this.currentFontId += `_${JSON.stringify(fontVariations)}`;
4704
+ this.currentFontId += `_var_${JSON.stringify(fontVariations)}`;
4705
+ }
4706
+ if (fontFeatures) {
4707
+ this.currentFontId += `_feat_${JSON.stringify(fontFeatures)}`;
4576
4708
  }
4577
4709
  }
4578
4710
  catch (error) {
4579
- debugLogger.error('Failed to load font:', error);
4711
+ logger.error('Failed to load font:', error);
4580
4712
  throw error;
4581
4713
  }
4582
4714
  finally {
@@ -4651,7 +4783,7 @@
4651
4783
  };
4652
4784
  }
4653
4785
  catch (error) {
4654
- debugLogger.warn(`Failed to load patterns for ${language}: ${error}`);
4786
+ logger.warn(`Failed to load patterns for ${language}: ${error}`);
4655
4787
  return {
4656
4788
  ...options,
4657
4789
  layout: {
@@ -4915,7 +5047,7 @@
4915
5047
  Text.patternCache.set(language, pattern);
4916
5048
  }
4917
5049
  catch (error) {
4918
- debugLogger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
5050
+ logger.warn(`Failed to pre-load patterns for ${language}: ${error}`);
4919
5051
  }
4920
5052
  }
4921
5053
  }));
@@ -4977,7 +5109,7 @@
4977
5109
  FontLoader.destroyFont(currentFont);
4978
5110
  }
4979
5111
  catch (error) {
4980
- debugLogger.warn('Error destroying HarfBuzz objects:', error);
5112
+ logger.warn('Error destroying HarfBuzz objects:', error);
4981
5113
  }
4982
5114
  finally {
4983
5115
  this.loadedFont = undefined;