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.umd.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
|
|
@@ -127,6 +127,9 @@
|
|
|
127
127
|
this.metrics.length = 0;
|
|
128
128
|
this.activeTimers.clear();
|
|
129
129
|
}
|
|
130
|
+
reset() {
|
|
131
|
+
this.clear();
|
|
132
|
+
}
|
|
130
133
|
time(name, fn, metadata) {
|
|
131
134
|
if (!isLogEnabled)
|
|
132
135
|
return fn();
|
|
@@ -326,6 +329,8 @@
|
|
|
326
329
|
const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
|
|
327
330
|
return filteredPoints;
|
|
328
331
|
}
|
|
332
|
+
// Converts text into items (boxes, glues, penalties) for line breaking.
|
|
333
|
+
// The measureText function should return widths that include any letter spacing.
|
|
329
334
|
static itemizeText(text, measureText, // function to measure text width
|
|
330
335
|
hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
|
|
331
336
|
const items = [];
|
|
@@ -1210,6 +1215,9 @@
|
|
|
1210
1215
|
}
|
|
1211
1216
|
|
|
1212
1217
|
class TextMeasurer {
|
|
1218
|
+
// Measures text width including letter spacing
|
|
1219
|
+
// Letter spacing is added uniformly after each glyph during measurement,
|
|
1220
|
+
// so the widths given to the line-breaking algorithm already account for tracking
|
|
1213
1221
|
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1214
1222
|
const buffer = loadedFont.hb.createBuffer();
|
|
1215
1223
|
buffer.addText(text);
|
|
@@ -1239,6 +1247,8 @@
|
|
|
1239
1247
|
const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection, letterSpacing } = options;
|
|
1240
1248
|
let lines;
|
|
1241
1249
|
if (width) {
|
|
1250
|
+
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
1251
|
+
// so widths passed into LineBreak.breakText account for tracking
|
|
1242
1252
|
lines = LineBreak.breakText({
|
|
1243
1253
|
text,
|
|
1244
1254
|
width,
|
|
@@ -1262,7 +1272,8 @@
|
|
|
1262
1272
|
looseness,
|
|
1263
1273
|
disableSingleWordDetection,
|
|
1264
1274
|
unitsPerEm: this.loadedFont.upem,
|
|
1265
|
-
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing
|
|
1275
|
+
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1276
|
+
)
|
|
1266
1277
|
});
|
|
1267
1278
|
}
|
|
1268
1279
|
else {
|
|
@@ -2229,7 +2240,11 @@
|
|
|
2229
2240
|
if (removeOverlaps) {
|
|
2230
2241
|
logger.log('Two-pass: boundary extraction then triangulation');
|
|
2231
2242
|
// Extract boundaries to remove overlaps
|
|
2243
|
+
perfLogger.start('Tessellator.boundaryPass', {
|
|
2244
|
+
contourCount: contours.length
|
|
2245
|
+
});
|
|
2232
2246
|
const boundaryResult = this.performTessellation(contours, 'boundary');
|
|
2247
|
+
perfLogger.end('Tessellator.boundaryPass');
|
|
2233
2248
|
if (!boundaryResult) {
|
|
2234
2249
|
logger.warn('libtess returned empty result from boundary pass');
|
|
2235
2250
|
return { triangles: { vertices: [], indices: [] }, contours: [] };
|
|
@@ -2242,7 +2257,11 @@
|
|
|
2242
2257
|
logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
|
|
2243
2258
|
}
|
|
2244
2259
|
// Triangulate the contours
|
|
2260
|
+
perfLogger.start('Tessellator.triangulationPass', {
|
|
2261
|
+
contourCount: contours.length
|
|
2262
|
+
});
|
|
2245
2263
|
const triangleResult = this.performTessellation(contours, 'triangles');
|
|
2264
|
+
perfLogger.end('Tessellator.triangulationPass');
|
|
2246
2265
|
if (!triangleResult) {
|
|
2247
2266
|
const warning = removeOverlaps
|
|
2248
2267
|
? 'libtess returned empty result from triangulation pass'
|
|
@@ -2429,10 +2448,16 @@
|
|
|
2429
2448
|
class BoundaryClusterer {
|
|
2430
2449
|
constructor() { }
|
|
2431
2450
|
cluster(glyphContoursList, positions) {
|
|
2451
|
+
perfLogger.start('BoundaryClusterer.cluster', {
|
|
2452
|
+
glyphCount: glyphContoursList.length
|
|
2453
|
+
});
|
|
2432
2454
|
if (glyphContoursList.length === 0) {
|
|
2455
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2433
2456
|
return [];
|
|
2434
2457
|
}
|
|
2435
|
-
|
|
2458
|
+
const result = this.clusterSweepLine(glyphContoursList, positions);
|
|
2459
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2460
|
+
return result;
|
|
2436
2461
|
}
|
|
2437
2462
|
clusterSweepLine(glyphContoursList, positions) {
|
|
2438
2463
|
const n = glyphContoursList.length;
|
|
@@ -3073,6 +3098,11 @@
|
|
|
3073
3098
|
this.currentGlyphBounds.max.set(-Infinity, -Infinity);
|
|
3074
3099
|
// Record position for this glyph
|
|
3075
3100
|
this.glyphPositions.push(this.currentPosition.clone());
|
|
3101
|
+
// Time polygonization + path optimization per glyph
|
|
3102
|
+
perfLogger.start('Glyph.polygonizeAndOptimize', {
|
|
3103
|
+
glyphId,
|
|
3104
|
+
textIndex
|
|
3105
|
+
});
|
|
3076
3106
|
}
|
|
3077
3107
|
finishGlyph() {
|
|
3078
3108
|
if (this.currentPath) {
|
|
@@ -3096,6 +3126,8 @@
|
|
|
3096
3126
|
// Track textIndex separately
|
|
3097
3127
|
this.glyphTextIndices.push(this.currentTextIndex);
|
|
3098
3128
|
}
|
|
3129
|
+
// Stop timing for this glyph (even if it ended up empty)
|
|
3130
|
+
perfLogger.end('Glyph.polygonizeAndOptimize');
|
|
3099
3131
|
this.currentGlyphPaths = [];
|
|
3100
3132
|
}
|
|
3101
3133
|
onMoveTo(x, y) {
|
|
@@ -3330,6 +3362,184 @@
|
|
|
3330
3362
|
}
|
|
3331
3363
|
}
|
|
3332
3364
|
|
|
3365
|
+
// Generic LRU (Least Recently Used) cache with optional memory-based eviction
|
|
3366
|
+
class LRUCache {
|
|
3367
|
+
constructor(options = {}) {
|
|
3368
|
+
this.cache = new Map();
|
|
3369
|
+
this.head = null;
|
|
3370
|
+
this.tail = null;
|
|
3371
|
+
this.stats = {
|
|
3372
|
+
hits: 0,
|
|
3373
|
+
misses: 0,
|
|
3374
|
+
evictions: 0,
|
|
3375
|
+
size: 0,
|
|
3376
|
+
memoryUsage: 0
|
|
3377
|
+
};
|
|
3378
|
+
this.options = {
|
|
3379
|
+
maxEntries: options.maxEntries ?? Infinity,
|
|
3380
|
+
maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
|
|
3381
|
+
calculateSize: options.calculateSize ?? (() => 0),
|
|
3382
|
+
onEvict: options.onEvict
|
|
3383
|
+
};
|
|
3384
|
+
}
|
|
3385
|
+
get(key) {
|
|
3386
|
+
const node = this.cache.get(key);
|
|
3387
|
+
if (node) {
|
|
3388
|
+
this.stats.hits++;
|
|
3389
|
+
this.moveToHead(node);
|
|
3390
|
+
return node.value;
|
|
3391
|
+
}
|
|
3392
|
+
else {
|
|
3393
|
+
this.stats.misses++;
|
|
3394
|
+
return undefined;
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
has(key) {
|
|
3398
|
+
return this.cache.has(key);
|
|
3399
|
+
}
|
|
3400
|
+
set(key, value) {
|
|
3401
|
+
// If key already exists, update it
|
|
3402
|
+
const existingNode = this.cache.get(key);
|
|
3403
|
+
if (existingNode) {
|
|
3404
|
+
const oldSize = this.options.calculateSize(existingNode.value);
|
|
3405
|
+
const newSize = this.options.calculateSize(value);
|
|
3406
|
+
this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
|
|
3407
|
+
existingNode.value = value;
|
|
3408
|
+
this.moveToHead(existingNode);
|
|
3409
|
+
return;
|
|
3410
|
+
}
|
|
3411
|
+
const size = this.options.calculateSize(value);
|
|
3412
|
+
// Evict entries if we exceed limits
|
|
3413
|
+
this.evictIfNeeded(size);
|
|
3414
|
+
// Create new node
|
|
3415
|
+
const node = {
|
|
3416
|
+
key,
|
|
3417
|
+
value,
|
|
3418
|
+
prev: null,
|
|
3419
|
+
next: null
|
|
3420
|
+
};
|
|
3421
|
+
this.cache.set(key, node);
|
|
3422
|
+
this.addToHead(node);
|
|
3423
|
+
this.stats.size = this.cache.size;
|
|
3424
|
+
this.stats.memoryUsage += size;
|
|
3425
|
+
}
|
|
3426
|
+
delete(key) {
|
|
3427
|
+
const node = this.cache.get(key);
|
|
3428
|
+
if (!node)
|
|
3429
|
+
return false;
|
|
3430
|
+
const size = this.options.calculateSize(node.value);
|
|
3431
|
+
this.removeNode(node);
|
|
3432
|
+
this.cache.delete(key);
|
|
3433
|
+
this.stats.size = this.cache.size;
|
|
3434
|
+
this.stats.memoryUsage -= size;
|
|
3435
|
+
if (this.options.onEvict) {
|
|
3436
|
+
this.options.onEvict(key, node.value);
|
|
3437
|
+
}
|
|
3438
|
+
return true;
|
|
3439
|
+
}
|
|
3440
|
+
clear() {
|
|
3441
|
+
if (this.options.onEvict) {
|
|
3442
|
+
for (const [key, node] of this.cache) {
|
|
3443
|
+
this.options.onEvict(key, node.value);
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
this.cache.clear();
|
|
3447
|
+
this.head = null;
|
|
3448
|
+
this.tail = null;
|
|
3449
|
+
this.stats = {
|
|
3450
|
+
hits: 0,
|
|
3451
|
+
misses: 0,
|
|
3452
|
+
evictions: 0,
|
|
3453
|
+
size: 0,
|
|
3454
|
+
memoryUsage: 0
|
|
3455
|
+
};
|
|
3456
|
+
}
|
|
3457
|
+
getStats() {
|
|
3458
|
+
const total = this.stats.hits + this.stats.misses;
|
|
3459
|
+
const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
|
|
3460
|
+
const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
|
|
3461
|
+
return {
|
|
3462
|
+
...this.stats,
|
|
3463
|
+
hitRate,
|
|
3464
|
+
memoryUsageMB
|
|
3465
|
+
};
|
|
3466
|
+
}
|
|
3467
|
+
keys() {
|
|
3468
|
+
const keys = [];
|
|
3469
|
+
let current = this.head;
|
|
3470
|
+
while (current) {
|
|
3471
|
+
keys.push(current.key);
|
|
3472
|
+
current = current.next;
|
|
3473
|
+
}
|
|
3474
|
+
return keys;
|
|
3475
|
+
}
|
|
3476
|
+
get size() {
|
|
3477
|
+
return this.cache.size;
|
|
3478
|
+
}
|
|
3479
|
+
evictIfNeeded(requiredSize) {
|
|
3480
|
+
// Evict by entry count
|
|
3481
|
+
while (this.cache.size >= this.options.maxEntries && this.tail) {
|
|
3482
|
+
this.evictTail();
|
|
3483
|
+
}
|
|
3484
|
+
// Evict by memory usage
|
|
3485
|
+
if (this.options.maxMemoryBytes < Infinity) {
|
|
3486
|
+
while (this.tail &&
|
|
3487
|
+
this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
|
|
3488
|
+
this.evictTail();
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
evictTail() {
|
|
3493
|
+
if (!this.tail)
|
|
3494
|
+
return;
|
|
3495
|
+
const nodeToRemove = this.tail;
|
|
3496
|
+
const size = this.options.calculateSize(nodeToRemove.value);
|
|
3497
|
+
this.removeTail();
|
|
3498
|
+
this.cache.delete(nodeToRemove.key);
|
|
3499
|
+
this.stats.size = this.cache.size;
|
|
3500
|
+
this.stats.memoryUsage -= size;
|
|
3501
|
+
this.stats.evictions++;
|
|
3502
|
+
if (this.options.onEvict) {
|
|
3503
|
+
this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
addToHead(node) {
|
|
3507
|
+
if (!this.head) {
|
|
3508
|
+
this.head = this.tail = node;
|
|
3509
|
+
}
|
|
3510
|
+
else {
|
|
3511
|
+
node.next = this.head;
|
|
3512
|
+
this.head.prev = node;
|
|
3513
|
+
this.head = node;
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
removeNode(node) {
|
|
3517
|
+
if (node.prev) {
|
|
3518
|
+
node.prev.next = node.next;
|
|
3519
|
+
}
|
|
3520
|
+
else {
|
|
3521
|
+
this.head = node.next;
|
|
3522
|
+
}
|
|
3523
|
+
if (node.next) {
|
|
3524
|
+
node.next.prev = node.prev;
|
|
3525
|
+
}
|
|
3526
|
+
else {
|
|
3527
|
+
this.tail = node.prev;
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
removeTail() {
|
|
3531
|
+
if (this.tail) {
|
|
3532
|
+
this.removeNode(this.tail);
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
moveToHead(node) {
|
|
3536
|
+
if (node === this.head)
|
|
3537
|
+
return;
|
|
3538
|
+
this.removeNode(node);
|
|
3539
|
+
this.addToHead(node);
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3333
3543
|
class GlyphGeometryBuilder {
|
|
3334
3544
|
constructor(cache, loadedFont) {
|
|
3335
3545
|
this.fontId = 'default';
|
|
@@ -3342,6 +3552,16 @@
|
|
|
3342
3552
|
this.collector = new GlyphContourCollector();
|
|
3343
3553
|
this.drawCallbacks = new DrawCallbackHandler();
|
|
3344
3554
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
3555
|
+
this.contourCache = new LRUCache({
|
|
3556
|
+
maxEntries: 1000,
|
|
3557
|
+
calculateSize: (contours) => {
|
|
3558
|
+
let size = 0;
|
|
3559
|
+
for (const path of contours.paths) {
|
|
3560
|
+
size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
|
|
3561
|
+
}
|
|
3562
|
+
return size + 64; // bounds overhead
|
|
3563
|
+
}
|
|
3564
|
+
});
|
|
3345
3565
|
}
|
|
3346
3566
|
getOptimizationStats() {
|
|
3347
3567
|
return this.collector.getOptimizationStats();
|
|
@@ -3486,30 +3706,37 @@
|
|
|
3486
3706
|
};
|
|
3487
3707
|
}
|
|
3488
3708
|
getContoursForGlyph(glyphId) {
|
|
3709
|
+
const cached = this.contourCache.get(glyphId);
|
|
3710
|
+
if (cached) {
|
|
3711
|
+
return cached;
|
|
3712
|
+
}
|
|
3489
3713
|
this.collector.reset();
|
|
3490
3714
|
this.collector.beginGlyph(glyphId, 0);
|
|
3491
3715
|
this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
|
|
3492
3716
|
this.collector.finishGlyph();
|
|
3493
3717
|
const collected = this.collector.getCollectedGlyphs()[0];
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
}
|
|
3505
|
-
return collected;
|
|
3718
|
+
const contours = collected || {
|
|
3719
|
+
glyphId,
|
|
3720
|
+
paths: [],
|
|
3721
|
+
bounds: {
|
|
3722
|
+
min: { x: 0, y: 0 },
|
|
3723
|
+
max: { x: 0, y: 0 }
|
|
3724
|
+
}
|
|
3725
|
+
};
|
|
3726
|
+
this.contourCache.set(glyphId, contours);
|
|
3727
|
+
return contours;
|
|
3506
3728
|
}
|
|
3507
3729
|
tessellateGlyphCluster(paths, depth, isCFF) {
|
|
3508
3730
|
const processedGeometry = this.tessellator.process(paths, true, isCFF);
|
|
3509
3731
|
return this.extrudeAndPackage(processedGeometry, depth);
|
|
3510
3732
|
}
|
|
3511
3733
|
extrudeAndPackage(processedGeometry, depth) {
|
|
3734
|
+
perfLogger.start('Extruder.extrude', {
|
|
3735
|
+
depth,
|
|
3736
|
+
upem: this.loadedFont.upem
|
|
3737
|
+
});
|
|
3512
3738
|
const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
|
|
3739
|
+
perfLogger.end('Extruder.extrude');
|
|
3513
3740
|
// Compute bounding box from vertices
|
|
3514
3741
|
const vertices = extrudedResult.vertices;
|
|
3515
3742
|
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
@@ -3551,6 +3778,7 @@
|
|
|
3551
3778
|
pathCount: glyphContours.paths.length
|
|
3552
3779
|
});
|
|
3553
3780
|
const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
|
|
3781
|
+
perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
|
|
3554
3782
|
return this.extrudeAndPackage(processedGeometry, depth);
|
|
3555
3783
|
}
|
|
3556
3784
|
updatePlaneBounds(glyphBounds, planeBounds) {
|
|
@@ -3623,6 +3851,7 @@
|
|
|
3623
3851
|
let currentClusterText = '';
|
|
3624
3852
|
let clusterStartPosition = new Vec3();
|
|
3625
3853
|
let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
|
|
3854
|
+
// Apply letter spacing between glyphs (must match what was used in width measurements)
|
|
3626
3855
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
3627
3856
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
3628
3857
|
for (let i = 0; i < glyphInfos.length; i++) {
|