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 +47 -24
- package/dist/index.cjs +193 -63
- package/dist/index.d.ts +18 -1
- package/dist/index.js +193 -63
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +193 -63
- 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
|
@@ -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)
|
|
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.
|
|
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,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
|
-
|
|
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
|
|
1221
|
+
glyphInfos.forEach((glyph) => {
|
|
1194
1222
|
totalWidth += glyph.ax;
|
|
1195
|
-
|
|
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;
|
|
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
|
|
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
|
|
1319
|
-
|
|
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 ===
|
|
1323
|
-
|
|
1358
|
+
else if (tag === TABLE_TAG_HEAD) {
|
|
1359
|
+
headTableOffset = view.getUint32(offset + 8);
|
|
1324
1360
|
}
|
|
1325
|
-
if (tag ===
|
|
1326
|
-
|
|
1361
|
+
else if (tag === TABLE_TAG_HHEA) {
|
|
1362
|
+
hheaTableOffset = view.getUint32(offset + 8);
|
|
1327
1363
|
}
|
|
1328
|
-
if (tag ===
|
|
1329
|
-
|
|
1364
|
+
else if (tag === TABLE_TAG_OS2) {
|
|
1365
|
+
os2TableOffset = view.getUint32(offset + 8);
|
|
1330
1366
|
}
|
|
1331
|
-
if (tag ===
|
|
1332
|
-
|
|
1367
|
+
else if (tag === TABLE_TAG_FVAR) {
|
|
1368
|
+
fvarTableOffset = view.getUint32(offset + 8);
|
|
1333
1369
|
}
|
|
1334
|
-
if (tag ===
|
|
1335
|
-
|
|
1370
|
+
else if (tag === TABLE_TAG_STAT) {
|
|
1371
|
+
statTableOffset = view.getUint32(offset + 8);
|
|
1336
1372
|
}
|
|
1337
|
-
if (tag ===
|
|
1338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2235
|
+
logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
|
|
2118
2236
|
}
|
|
2119
2237
|
else {
|
|
2120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
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 += `
|
|
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 += `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5108
|
+
logger.warn('Error destroying HarfBuzz objects:', error);
|
|
4979
5109
|
}
|
|
4980
5110
|
finally {
|
|
4981
5111
|
this.loadedFont = undefined;
|