three-text 0.2.6 → 0.2.7
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 +21 -4
- package/dist/index.cjs +244 -15
- package/dist/index.js +244 -15
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +244 -15
- package/dist/index.umd.min.js +2 -2
- package/dist/types/core/cache/GlyphGeometryBuilder.d.ts +1 -0
- package/dist/types/utils/LRUCache.d.ts +38 -0
- package/dist/types/utils/PerformanceLogger.d.ts +1 -0
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* three-text v0.2.
|
|
2
|
+
* three-text v0.2.7
|
|
3
3
|
* Copyright (C) 2025 Countertype LLC
|
|
4
4
|
*
|
|
5
5
|
* This program is free software: you can redistribute it and/or modify
|
|
@@ -122,6 +122,9 @@ class PerformanceLogger {
|
|
|
122
122
|
this.metrics.length = 0;
|
|
123
123
|
this.activeTimers.clear();
|
|
124
124
|
}
|
|
125
|
+
reset() {
|
|
126
|
+
this.clear();
|
|
127
|
+
}
|
|
125
128
|
time(name, fn, metadata) {
|
|
126
129
|
if (!isLogEnabled)
|
|
127
130
|
return fn();
|
|
@@ -321,6 +324,8 @@ class LineBreak {
|
|
|
321
324
|
const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
|
|
322
325
|
return filteredPoints;
|
|
323
326
|
}
|
|
327
|
+
// Converts text into items (boxes, glues, penalties) for line breaking.
|
|
328
|
+
// The measureText function should return widths that include any letter spacing.
|
|
324
329
|
static itemizeText(text, measureText, // function to measure text width
|
|
325
330
|
hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
|
|
326
331
|
const items = [];
|
|
@@ -1205,6 +1210,9 @@ function convertFontFeaturesToString(features) {
|
|
|
1205
1210
|
}
|
|
1206
1211
|
|
|
1207
1212
|
class TextMeasurer {
|
|
1213
|
+
// Measures text width including letter spacing
|
|
1214
|
+
// Letter spacing is added uniformly after each glyph during measurement,
|
|
1215
|
+
// so the widths given to the line-breaking algorithm already account for tracking
|
|
1208
1216
|
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1209
1217
|
const buffer = loadedFont.hb.createBuffer();
|
|
1210
1218
|
buffer.addText(text);
|
|
@@ -1234,6 +1242,8 @@ class TextLayout {
|
|
|
1234
1242
|
const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection, letterSpacing } = options;
|
|
1235
1243
|
let lines;
|
|
1236
1244
|
if (width) {
|
|
1245
|
+
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
1246
|
+
// so widths passed into LineBreak.breakText account for tracking
|
|
1237
1247
|
lines = LineBreak.breakText({
|
|
1238
1248
|
text,
|
|
1239
1249
|
width,
|
|
@@ -1257,7 +1267,8 @@ class TextLayout {
|
|
|
1257
1267
|
looseness,
|
|
1258
1268
|
disableSingleWordDetection,
|
|
1259
1269
|
unitsPerEm: this.loadedFont.upem,
|
|
1260
|
-
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing
|
|
1270
|
+
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1271
|
+
)
|
|
1261
1272
|
});
|
|
1262
1273
|
}
|
|
1263
1274
|
else {
|
|
@@ -2222,7 +2233,11 @@ class Tessellator {
|
|
|
2222
2233
|
if (removeOverlaps) {
|
|
2223
2234
|
logger.log('Two-pass: boundary extraction then triangulation');
|
|
2224
2235
|
// Extract boundaries to remove overlaps
|
|
2236
|
+
perfLogger.start('Tessellator.boundaryPass', {
|
|
2237
|
+
contourCount: contours.length
|
|
2238
|
+
});
|
|
2225
2239
|
const boundaryResult = this.performTessellation(contours, 'boundary');
|
|
2240
|
+
perfLogger.end('Tessellator.boundaryPass');
|
|
2226
2241
|
if (!boundaryResult) {
|
|
2227
2242
|
logger.warn('libtess returned empty result from boundary pass');
|
|
2228
2243
|
return { triangles: { vertices: [], indices: [] }, contours: [] };
|
|
@@ -2235,7 +2250,11 @@ class Tessellator {
|
|
|
2235
2250
|
logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
|
|
2236
2251
|
}
|
|
2237
2252
|
// Triangulate the contours
|
|
2253
|
+
perfLogger.start('Tessellator.triangulationPass', {
|
|
2254
|
+
contourCount: contours.length
|
|
2255
|
+
});
|
|
2238
2256
|
const triangleResult = this.performTessellation(contours, 'triangles');
|
|
2257
|
+
perfLogger.end('Tessellator.triangulationPass');
|
|
2239
2258
|
if (!triangleResult) {
|
|
2240
2259
|
const warning = removeOverlaps
|
|
2241
2260
|
? 'libtess returned empty result from triangulation pass'
|
|
@@ -2422,10 +2441,16 @@ const OVERLAP_EPSILON = 1e-3;
|
|
|
2422
2441
|
class BoundaryClusterer {
|
|
2423
2442
|
constructor() { }
|
|
2424
2443
|
cluster(glyphContoursList, positions) {
|
|
2444
|
+
perfLogger.start('BoundaryClusterer.cluster', {
|
|
2445
|
+
glyphCount: glyphContoursList.length
|
|
2446
|
+
});
|
|
2425
2447
|
if (glyphContoursList.length === 0) {
|
|
2448
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2426
2449
|
return [];
|
|
2427
2450
|
}
|
|
2428
|
-
|
|
2451
|
+
const result = this.clusterSweepLine(glyphContoursList, positions);
|
|
2452
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2453
|
+
return result;
|
|
2429
2454
|
}
|
|
2430
2455
|
clusterSweepLine(glyphContoursList, positions) {
|
|
2431
2456
|
const n = glyphContoursList.length;
|
|
@@ -3066,6 +3091,11 @@ class GlyphContourCollector {
|
|
|
3066
3091
|
this.currentGlyphBounds.max.set(-Infinity, -Infinity);
|
|
3067
3092
|
// Record position for this glyph
|
|
3068
3093
|
this.glyphPositions.push(this.currentPosition.clone());
|
|
3094
|
+
// Time polygonization + path optimization per glyph
|
|
3095
|
+
perfLogger.start('Glyph.polygonizeAndOptimize', {
|
|
3096
|
+
glyphId,
|
|
3097
|
+
textIndex
|
|
3098
|
+
});
|
|
3069
3099
|
}
|
|
3070
3100
|
finishGlyph() {
|
|
3071
3101
|
if (this.currentPath) {
|
|
@@ -3089,6 +3119,8 @@ class GlyphContourCollector {
|
|
|
3089
3119
|
// Track textIndex separately
|
|
3090
3120
|
this.glyphTextIndices.push(this.currentTextIndex);
|
|
3091
3121
|
}
|
|
3122
|
+
// Stop timing for this glyph (even if it ended up empty)
|
|
3123
|
+
perfLogger.end('Glyph.polygonizeAndOptimize');
|
|
3092
3124
|
this.currentGlyphPaths = [];
|
|
3093
3125
|
}
|
|
3094
3126
|
onMoveTo(x, y) {
|
|
@@ -3323,6 +3355,184 @@ class DrawCallbackHandler {
|
|
|
3323
3355
|
}
|
|
3324
3356
|
}
|
|
3325
3357
|
|
|
3358
|
+
// Generic LRU (Least Recently Used) cache with optional memory-based eviction
|
|
3359
|
+
class LRUCache {
|
|
3360
|
+
constructor(options = {}) {
|
|
3361
|
+
this.cache = new Map();
|
|
3362
|
+
this.head = null;
|
|
3363
|
+
this.tail = null;
|
|
3364
|
+
this.stats = {
|
|
3365
|
+
hits: 0,
|
|
3366
|
+
misses: 0,
|
|
3367
|
+
evictions: 0,
|
|
3368
|
+
size: 0,
|
|
3369
|
+
memoryUsage: 0
|
|
3370
|
+
};
|
|
3371
|
+
this.options = {
|
|
3372
|
+
maxEntries: options.maxEntries ?? Infinity,
|
|
3373
|
+
maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
|
|
3374
|
+
calculateSize: options.calculateSize ?? (() => 0),
|
|
3375
|
+
onEvict: options.onEvict
|
|
3376
|
+
};
|
|
3377
|
+
}
|
|
3378
|
+
get(key) {
|
|
3379
|
+
const node = this.cache.get(key);
|
|
3380
|
+
if (node) {
|
|
3381
|
+
this.stats.hits++;
|
|
3382
|
+
this.moveToHead(node);
|
|
3383
|
+
return node.value;
|
|
3384
|
+
}
|
|
3385
|
+
else {
|
|
3386
|
+
this.stats.misses++;
|
|
3387
|
+
return undefined;
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
has(key) {
|
|
3391
|
+
return this.cache.has(key);
|
|
3392
|
+
}
|
|
3393
|
+
set(key, value) {
|
|
3394
|
+
// If key already exists, update it
|
|
3395
|
+
const existingNode = this.cache.get(key);
|
|
3396
|
+
if (existingNode) {
|
|
3397
|
+
const oldSize = this.options.calculateSize(existingNode.value);
|
|
3398
|
+
const newSize = this.options.calculateSize(value);
|
|
3399
|
+
this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
|
|
3400
|
+
existingNode.value = value;
|
|
3401
|
+
this.moveToHead(existingNode);
|
|
3402
|
+
return;
|
|
3403
|
+
}
|
|
3404
|
+
const size = this.options.calculateSize(value);
|
|
3405
|
+
// Evict entries if we exceed limits
|
|
3406
|
+
this.evictIfNeeded(size);
|
|
3407
|
+
// Create new node
|
|
3408
|
+
const node = {
|
|
3409
|
+
key,
|
|
3410
|
+
value,
|
|
3411
|
+
prev: null,
|
|
3412
|
+
next: null
|
|
3413
|
+
};
|
|
3414
|
+
this.cache.set(key, node);
|
|
3415
|
+
this.addToHead(node);
|
|
3416
|
+
this.stats.size = this.cache.size;
|
|
3417
|
+
this.stats.memoryUsage += size;
|
|
3418
|
+
}
|
|
3419
|
+
delete(key) {
|
|
3420
|
+
const node = this.cache.get(key);
|
|
3421
|
+
if (!node)
|
|
3422
|
+
return false;
|
|
3423
|
+
const size = this.options.calculateSize(node.value);
|
|
3424
|
+
this.removeNode(node);
|
|
3425
|
+
this.cache.delete(key);
|
|
3426
|
+
this.stats.size = this.cache.size;
|
|
3427
|
+
this.stats.memoryUsage -= size;
|
|
3428
|
+
if (this.options.onEvict) {
|
|
3429
|
+
this.options.onEvict(key, node.value);
|
|
3430
|
+
}
|
|
3431
|
+
return true;
|
|
3432
|
+
}
|
|
3433
|
+
clear() {
|
|
3434
|
+
if (this.options.onEvict) {
|
|
3435
|
+
for (const [key, node] of this.cache) {
|
|
3436
|
+
this.options.onEvict(key, node.value);
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
this.cache.clear();
|
|
3440
|
+
this.head = null;
|
|
3441
|
+
this.tail = null;
|
|
3442
|
+
this.stats = {
|
|
3443
|
+
hits: 0,
|
|
3444
|
+
misses: 0,
|
|
3445
|
+
evictions: 0,
|
|
3446
|
+
size: 0,
|
|
3447
|
+
memoryUsage: 0
|
|
3448
|
+
};
|
|
3449
|
+
}
|
|
3450
|
+
getStats() {
|
|
3451
|
+
const total = this.stats.hits + this.stats.misses;
|
|
3452
|
+
const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
|
|
3453
|
+
const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
|
|
3454
|
+
return {
|
|
3455
|
+
...this.stats,
|
|
3456
|
+
hitRate,
|
|
3457
|
+
memoryUsageMB
|
|
3458
|
+
};
|
|
3459
|
+
}
|
|
3460
|
+
keys() {
|
|
3461
|
+
const keys = [];
|
|
3462
|
+
let current = this.head;
|
|
3463
|
+
while (current) {
|
|
3464
|
+
keys.push(current.key);
|
|
3465
|
+
current = current.next;
|
|
3466
|
+
}
|
|
3467
|
+
return keys;
|
|
3468
|
+
}
|
|
3469
|
+
get size() {
|
|
3470
|
+
return this.cache.size;
|
|
3471
|
+
}
|
|
3472
|
+
evictIfNeeded(requiredSize) {
|
|
3473
|
+
// Evict by entry count
|
|
3474
|
+
while (this.cache.size >= this.options.maxEntries && this.tail) {
|
|
3475
|
+
this.evictTail();
|
|
3476
|
+
}
|
|
3477
|
+
// Evict by memory usage
|
|
3478
|
+
if (this.options.maxMemoryBytes < Infinity) {
|
|
3479
|
+
while (this.tail &&
|
|
3480
|
+
this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
|
|
3481
|
+
this.evictTail();
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
evictTail() {
|
|
3486
|
+
if (!this.tail)
|
|
3487
|
+
return;
|
|
3488
|
+
const nodeToRemove = this.tail;
|
|
3489
|
+
const size = this.options.calculateSize(nodeToRemove.value);
|
|
3490
|
+
this.removeTail();
|
|
3491
|
+
this.cache.delete(nodeToRemove.key);
|
|
3492
|
+
this.stats.size = this.cache.size;
|
|
3493
|
+
this.stats.memoryUsage -= size;
|
|
3494
|
+
this.stats.evictions++;
|
|
3495
|
+
if (this.options.onEvict) {
|
|
3496
|
+
this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
addToHead(node) {
|
|
3500
|
+
if (!this.head) {
|
|
3501
|
+
this.head = this.tail = node;
|
|
3502
|
+
}
|
|
3503
|
+
else {
|
|
3504
|
+
node.next = this.head;
|
|
3505
|
+
this.head.prev = node;
|
|
3506
|
+
this.head = node;
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
removeNode(node) {
|
|
3510
|
+
if (node.prev) {
|
|
3511
|
+
node.prev.next = node.next;
|
|
3512
|
+
}
|
|
3513
|
+
else {
|
|
3514
|
+
this.head = node.next;
|
|
3515
|
+
}
|
|
3516
|
+
if (node.next) {
|
|
3517
|
+
node.next.prev = node.prev;
|
|
3518
|
+
}
|
|
3519
|
+
else {
|
|
3520
|
+
this.tail = node.prev;
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
removeTail() {
|
|
3524
|
+
if (this.tail) {
|
|
3525
|
+
this.removeNode(this.tail);
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
moveToHead(node) {
|
|
3529
|
+
if (node === this.head)
|
|
3530
|
+
return;
|
|
3531
|
+
this.removeNode(node);
|
|
3532
|
+
this.addToHead(node);
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3326
3536
|
class GlyphGeometryBuilder {
|
|
3327
3537
|
constructor(cache, loadedFont) {
|
|
3328
3538
|
this.fontId = 'default';
|
|
@@ -3335,6 +3545,16 @@ class GlyphGeometryBuilder {
|
|
|
3335
3545
|
this.collector = new GlyphContourCollector();
|
|
3336
3546
|
this.drawCallbacks = new DrawCallbackHandler();
|
|
3337
3547
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
3548
|
+
this.contourCache = new LRUCache({
|
|
3549
|
+
maxEntries: 1000,
|
|
3550
|
+
calculateSize: (contours) => {
|
|
3551
|
+
let size = 0;
|
|
3552
|
+
for (const path of contours.paths) {
|
|
3553
|
+
size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
|
|
3554
|
+
}
|
|
3555
|
+
return size + 64; // bounds overhead
|
|
3556
|
+
}
|
|
3557
|
+
});
|
|
3338
3558
|
}
|
|
3339
3559
|
getOptimizationStats() {
|
|
3340
3560
|
return this.collector.getOptimizationStats();
|
|
@@ -3479,30 +3699,37 @@ class GlyphGeometryBuilder {
|
|
|
3479
3699
|
};
|
|
3480
3700
|
}
|
|
3481
3701
|
getContoursForGlyph(glyphId) {
|
|
3702
|
+
const cached = this.contourCache.get(glyphId);
|
|
3703
|
+
if (cached) {
|
|
3704
|
+
return cached;
|
|
3705
|
+
}
|
|
3482
3706
|
this.collector.reset();
|
|
3483
3707
|
this.collector.beginGlyph(glyphId, 0);
|
|
3484
3708
|
this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
|
|
3485
3709
|
this.collector.finishGlyph();
|
|
3486
3710
|
const collected = this.collector.getCollectedGlyphs()[0];
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
}
|
|
3498
|
-
return collected;
|
|
3711
|
+
const contours = collected || {
|
|
3712
|
+
glyphId,
|
|
3713
|
+
paths: [],
|
|
3714
|
+
bounds: {
|
|
3715
|
+
min: { x: 0, y: 0 },
|
|
3716
|
+
max: { x: 0, y: 0 }
|
|
3717
|
+
}
|
|
3718
|
+
};
|
|
3719
|
+
this.contourCache.set(glyphId, contours);
|
|
3720
|
+
return contours;
|
|
3499
3721
|
}
|
|
3500
3722
|
tessellateGlyphCluster(paths, depth, isCFF) {
|
|
3501
3723
|
const processedGeometry = this.tessellator.process(paths, true, isCFF);
|
|
3502
3724
|
return this.extrudeAndPackage(processedGeometry, depth);
|
|
3503
3725
|
}
|
|
3504
3726
|
extrudeAndPackage(processedGeometry, depth) {
|
|
3727
|
+
perfLogger.start('Extruder.extrude', {
|
|
3728
|
+
depth,
|
|
3729
|
+
upem: this.loadedFont.upem
|
|
3730
|
+
});
|
|
3505
3731
|
const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
|
|
3732
|
+
perfLogger.end('Extruder.extrude');
|
|
3506
3733
|
// Compute bounding box from vertices
|
|
3507
3734
|
const vertices = extrudedResult.vertices;
|
|
3508
3735
|
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
@@ -3544,6 +3771,7 @@ class GlyphGeometryBuilder {
|
|
|
3544
3771
|
pathCount: glyphContours.paths.length
|
|
3545
3772
|
});
|
|
3546
3773
|
const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
|
|
3774
|
+
perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
|
|
3547
3775
|
return this.extrudeAndPackage(processedGeometry, depth);
|
|
3548
3776
|
}
|
|
3549
3777
|
updatePlaneBounds(glyphBounds, planeBounds) {
|
|
@@ -3616,6 +3844,7 @@ class TextShaper {
|
|
|
3616
3844
|
let currentClusterText = '';
|
|
3617
3845
|
let clusterStartPosition = new Vec3();
|
|
3618
3846
|
let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
|
|
3847
|
+
// Apply letter spacing between glyphs (must match what was used in width measurements)
|
|
3619
3848
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
3620
3849
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
3621
3850
|
for (let i = 0; i < glyphInfos.length; i++) {
|