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.umd.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
@@ -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
- return this.clusterSweepLine(glyphContoursList, positions);
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
- // Return empty contours for glyphs with no paths (e.g., space, zero-width characters)
3495
- if (!collected) {
3496
- return {
3497
- glyphId,
3498
- paths: [],
3499
- bounds: {
3500
- min: { x: 0, y: 0 },
3501
- max: { x: 0, y: 0 }
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++) {