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 +46 -23
- package/dist/index.cjs +188 -56
- package/dist/index.d.ts +18 -1
- package/dist/index.js +188 -56
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +188 -56
- package/dist/index.umd.min.js +2 -2
- package/dist/p5/index.cjs +4 -8
- package/dist/p5/index.js +4 -8
- package/dist/three/react.d.ts +11 -1
- package/dist/types/core/font/FontMetadata.d.ts +7 -0
- package/dist/types/core/font/constants.d.ts +10 -0
- package/dist/types/core/shaping/fontFeatures.d.ts +3 -0
- package/dist/types/core/types.d.ts +11 -1
- package/dist/types/utils/{DebugLogger.d.ts → Logger.d.ts} +2 -2
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1316
|
-
|
|
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 ===
|
|
1320
|
-
|
|
1358
|
+
else if (tag === TABLE_TAG_HEAD) {
|
|
1359
|
+
headTableOffset = view.getUint32(offset + 8);
|
|
1321
1360
|
}
|
|
1322
|
-
if (tag ===
|
|
1323
|
-
|
|
1361
|
+
else if (tag === TABLE_TAG_HHEA) {
|
|
1362
|
+
hheaTableOffset = view.getUint32(offset + 8);
|
|
1324
1363
|
}
|
|
1325
|
-
if (tag ===
|
|
1326
|
-
|
|
1364
|
+
else if (tag === TABLE_TAG_OS2) {
|
|
1365
|
+
os2TableOffset = view.getUint32(offset + 8);
|
|
1327
1366
|
}
|
|
1328
|
-
if (tag ===
|
|
1329
|
-
|
|
1367
|
+
else if (tag === TABLE_TAG_FVAR) {
|
|
1368
|
+
fvarTableOffset = view.getUint32(offset + 8);
|
|
1330
1369
|
}
|
|
1331
|
-
if (tag ===
|
|
1332
|
-
|
|
1370
|
+
else if (tag === TABLE_TAG_STAT) {
|
|
1371
|
+
statTableOffset = view.getUint32(offset + 8);
|
|
1333
1372
|
}
|
|
1334
|
-
if (tag ===
|
|
1335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2235
|
+
logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
|
|
2115
2236
|
}
|
|
2116
2237
|
else {
|
|
2117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
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 += `
|
|
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 += `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|