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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.6
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
- return this.clusterSweepLine(glyphContoursList, positions);
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
- // Return empty contours for glyphs with no paths (e.g., space, zero-width characters)
3488
- if (!collected) {
3489
- return {
3490
- glyphId,
3491
- paths: [],
3492
- bounds: {
3493
- min: { x: 0, y: 0 },
3494
- max: { x: 0, y: 0 }
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++) {