three-text 0.2.18 → 0.3.0

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.18
2
+ * three-text v0.3.0
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -78,7 +78,9 @@ class PerformanceLogger {
78
78
  // Find the metric in reverse order (most recent first)
79
79
  for (let i = this.metrics.length - 1; i >= 0; i--) {
80
80
  const metric = this.metrics[i];
81
- if (metric.name === name && metric.startTime === startTime && !metric.endTime) {
81
+ if (metric.name === name &&
82
+ metric.startTime === startTime &&
83
+ !metric.endTime) {
82
84
  metric.endTime = endTime;
83
85
  metric.duration = duration;
84
86
  break;
@@ -466,7 +468,9 @@ class LineBreak {
466
468
  const char = chars[i];
467
469
  const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
468
470
  if (/\s/.test(char)) {
469
- const width = widths ? (widths[i] ?? measureText(char)) : measureText(char);
471
+ const width = widths
472
+ ? (widths[i] ?? measureText(char))
473
+ : measureText(char);
470
474
  items.push({
471
475
  type: ItemType.GLUE,
472
476
  width,
@@ -839,7 +843,9 @@ class LineBreak {
839
843
  if (breaks.length === 0) {
840
844
  // For first emergency attempt, use initialEmergencyStretch
841
845
  // For subsequent iterations (short line detection), progressively increase
842
- currentEmergencyStretch = initialEmergencyStretch + (iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT);
846
+ currentEmergencyStretch =
847
+ initialEmergencyStretch +
848
+ iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
843
849
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, true, currentEmergencyStretch, context);
844
850
  }
845
851
  // Last resort: allow higher badness (but not infinite)
@@ -1720,12 +1726,12 @@ class FontMetadataExtractor {
1720
1726
  try {
1721
1727
  if (gsubTableOffset) {
1722
1728
  const gsubData = this.extractFeatureDataFromTable(view, gsubTableOffset, nameTableOffset);
1723
- gsubData.features.forEach(f => features.add(f));
1729
+ gsubData.features.forEach((f) => features.add(f));
1724
1730
  Object.assign(featureNames, gsubData.names);
1725
1731
  }
1726
1732
  if (gposTableOffset) {
1727
1733
  const gposData = this.extractFeatureDataFromTable(view, gposTableOffset, nameTableOffset);
1728
- gposData.features.forEach(f => features.add(f));
1734
+ gposData.features.forEach((f) => features.add(f));
1729
1735
  Object.assign(featureNames, gposData.names);
1730
1736
  }
1731
1737
  }
@@ -2373,231 +2379,50 @@ class Vec3 {
2373
2379
  }
2374
2380
  }
2375
2381
 
2376
- // Generic LRU (Least Recently Used) cache with optional memory-based eviction
2377
- class LRUCache {
2378
- constructor(options = {}) {
2382
+ // Map-based cache with no eviction policy
2383
+ class Cache {
2384
+ constructor() {
2379
2385
  this.cache = new Map();
2380
- this.head = null;
2381
- this.tail = null;
2382
- this.stats = {
2383
- hits: 0,
2384
- misses: 0,
2385
- evictions: 0,
2386
- size: 0,
2387
- memoryUsage: 0
2388
- };
2389
- this.options = {
2390
- maxEntries: options.maxEntries ?? Infinity,
2391
- maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
2392
- calculateSize: options.calculateSize ?? (() => 0),
2393
- onEvict: options.onEvict
2394
- };
2395
2386
  }
2396
2387
  get(key) {
2397
- const node = this.cache.get(key);
2398
- if (node) {
2399
- this.stats.hits++;
2400
- this.moveToHead(node);
2401
- return node.value;
2402
- }
2403
- else {
2404
- this.stats.misses++;
2405
- return undefined;
2406
- }
2388
+ return this.cache.get(key);
2407
2389
  }
2408
2390
  has(key) {
2409
2391
  return this.cache.has(key);
2410
2392
  }
2411
2393
  set(key, value) {
2412
- // If key already exists, update it
2413
- const existingNode = this.cache.get(key);
2414
- if (existingNode) {
2415
- const oldSize = this.options.calculateSize(existingNode.value);
2416
- const newSize = this.options.calculateSize(value);
2417
- this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
2418
- existingNode.value = value;
2419
- this.moveToHead(existingNode);
2420
- return;
2421
- }
2422
- const size = this.options.calculateSize(value);
2423
- // Evict entries if we exceed limits
2424
- this.evictIfNeeded(size);
2425
- // Create new node
2426
- const node = {
2427
- key,
2428
- value,
2429
- prev: null,
2430
- next: null
2431
- };
2432
- this.cache.set(key, node);
2433
- this.addToHead(node);
2434
- this.stats.size = this.cache.size;
2435
- this.stats.memoryUsage += size;
2394
+ this.cache.set(key, value);
2436
2395
  }
2437
2396
  delete(key) {
2438
- const node = this.cache.get(key);
2439
- if (!node)
2440
- return false;
2441
- const size = this.options.calculateSize(node.value);
2442
- this.removeNode(node);
2443
- this.cache.delete(key);
2444
- this.stats.size = this.cache.size;
2445
- this.stats.memoryUsage -= size;
2446
- if (this.options.onEvict) {
2447
- this.options.onEvict(key, node.value);
2448
- }
2449
- return true;
2397
+ return this.cache.delete(key);
2450
2398
  }
2451
2399
  clear() {
2452
- if (this.options.onEvict) {
2453
- for (const [key, node] of this.cache) {
2454
- this.options.onEvict(key, node.value);
2455
- }
2456
- }
2457
2400
  this.cache.clear();
2458
- this.head = null;
2459
- this.tail = null;
2460
- this.stats = {
2461
- hits: 0,
2462
- misses: 0,
2463
- evictions: 0,
2464
- size: 0,
2465
- memoryUsage: 0
2466
- };
2467
- }
2468
- getStats() {
2469
- const total = this.stats.hits + this.stats.misses;
2470
- const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
2471
- const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
2472
- return {
2473
- ...this.stats,
2474
- hitRate,
2475
- memoryUsageMB
2476
- };
2477
- }
2478
- keys() {
2479
- const keys = [];
2480
- let current = this.head;
2481
- while (current) {
2482
- keys.push(current.key);
2483
- current = current.next;
2484
- }
2485
- return keys;
2486
2401
  }
2487
2402
  get size() {
2488
2403
  return this.cache.size;
2489
2404
  }
2490
- evictIfNeeded(requiredSize) {
2491
- // Evict by entry count
2492
- while (this.cache.size >= this.options.maxEntries && this.tail) {
2493
- this.evictTail();
2494
- }
2495
- // Evict by memory usage
2496
- if (this.options.maxMemoryBytes < Infinity) {
2497
- while (this.tail &&
2498
- this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
2499
- this.evictTail();
2500
- }
2501
- }
2502
- }
2503
- evictTail() {
2504
- if (!this.tail)
2505
- return;
2506
- const nodeToRemove = this.tail;
2507
- const size = this.options.calculateSize(nodeToRemove.value);
2508
- this.removeTail();
2509
- this.cache.delete(nodeToRemove.key);
2510
- this.stats.size = this.cache.size;
2511
- this.stats.memoryUsage -= size;
2512
- this.stats.evictions++;
2513
- if (this.options.onEvict) {
2514
- this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
2515
- }
2516
- }
2517
- addToHead(node) {
2518
- node.prev = null;
2519
- node.next = null;
2520
- if (!this.head) {
2521
- this.head = this.tail = node;
2522
- }
2523
- else {
2524
- node.next = this.head;
2525
- this.head.prev = node;
2526
- this.head = node;
2527
- }
2528
- }
2529
- removeNode(node) {
2530
- if (node.prev) {
2531
- node.prev.next = node.next;
2532
- }
2533
- else {
2534
- this.head = node.next;
2535
- }
2536
- if (node.next) {
2537
- node.next.prev = node.prev;
2538
- }
2539
- else {
2540
- this.tail = node.prev;
2541
- }
2542
- }
2543
- removeTail() {
2544
- if (this.tail) {
2545
- this.removeNode(this.tail);
2546
- }
2405
+ keys() {
2406
+ return Array.from(this.cache.keys());
2547
2407
  }
2548
- moveToHead(node) {
2549
- if (node === this.head)
2550
- return;
2551
- this.removeNode(node);
2552
- this.addToHead(node);
2408
+ getStats() {
2409
+ return {
2410
+ size: this.cache.size
2411
+ };
2553
2412
  }
2554
2413
  }
2555
2414
 
2556
- const DEFAULT_CACHE_SIZE_MB = 250;
2557
2415
  function getGlyphCacheKey(fontId, glyphId, depth, removeOverlaps) {
2558
2416
  const roundedDepth = Math.round(depth * 1000) / 1000;
2559
2417
  return `${fontId}_${glyphId}_${roundedDepth}_${removeOverlaps}`;
2560
2418
  }
2561
- function calculateGlyphMemoryUsage(glyph) {
2562
- let size = 0;
2563
- size += glyph.vertices.length * 4;
2564
- size += glyph.normals.length * 4;
2565
- size += glyph.indices.length * glyph.indices.BYTES_PER_ELEMENT;
2566
- size += 24; // 2 Vec3s
2567
- size += 256; // Object overhead
2568
- return size;
2569
- }
2570
- const globalGlyphCache = new LRUCache({
2571
- maxEntries: Infinity,
2572
- maxMemoryBytes: DEFAULT_CACHE_SIZE_MB * 1024 * 1024,
2573
- calculateSize: calculateGlyphMemoryUsage
2574
- });
2575
- function createGlyphCache(maxCacheSizeMB = DEFAULT_CACHE_SIZE_MB) {
2576
- return new LRUCache({
2577
- maxEntries: Infinity,
2578
- maxMemoryBytes: maxCacheSizeMB * 1024 * 1024,
2579
- calculateSize: calculateGlyphMemoryUsage
2580
- });
2419
+ const globalGlyphCache = new Cache();
2420
+ function createGlyphCache() {
2421
+ return new Cache();
2581
2422
  }
2582
- // Shared across builder instances: contour extraction, word clustering, boundary grouping
2583
- const globalContourCache = new LRUCache({
2584
- maxEntries: 1000,
2585
- calculateSize: (contours) => {
2586
- let size = 0;
2587
- for (const path of contours.paths) {
2588
- size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
2589
- }
2590
- return size + 64; // bounds overhead
2591
- }
2592
- });
2593
- const globalWordCache = new LRUCache({
2594
- maxEntries: 1000,
2595
- calculateSize: calculateGlyphMemoryUsage
2596
- });
2597
- const globalClusteringCache = new LRUCache({
2598
- maxEntries: 2000,
2599
- calculateSize: () => 1
2600
- });
2423
+ const globalContourCache = new Cache();
2424
+ const globalWordCache = new Cache();
2425
+ const globalClusteringCache = new Cache();
2601
2426
 
2602
2427
  var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
2603
2428
 
@@ -2726,7 +2551,7 @@ class Tessellator {
2726
2551
  let extrusionContours = needsExtrusionContours
2727
2552
  ? needsWindingReversal
2728
2553
  ? tessContours
2729
- : originalContours ?? this.pathsToContours(paths)
2554
+ : (originalContours ?? this.pathsToContours(paths))
2730
2555
  : [];
2731
2556
  if (removeOverlaps) {
2732
2557
  logger.log('Two-pass: boundary extraction then triangulation');
@@ -2759,7 +2584,10 @@ class Tessellator {
2759
2584
  ? 'libtess returned empty result from triangulation pass'
2760
2585
  : 'libtess returned empty result from single-pass triangulation';
2761
2586
  logger.warn(warning);
2762
- return { triangles: { vertices: [], indices: [] }, contours: extrusionContours };
2587
+ return {
2588
+ triangles: { vertices: [], indices: [] },
2589
+ contours: extrusionContours
2590
+ };
2763
2591
  }
2764
2592
  return {
2765
2593
  triangles: {
@@ -2929,47 +2757,79 @@ class Tessellator {
2929
2757
 
2930
2758
  class Extruder {
2931
2759
  constructor() { }
2932
- packEdge(a, b) {
2933
- const lo = a < b ? a : b;
2934
- const hi = a < b ? b : a;
2935
- return lo * 0x100000000 + hi;
2936
- }
2937
2760
  extrude(geometry, depth = 0, unitsPerEm) {
2938
2761
  const points = geometry.triangles.vertices;
2939
2762
  const triangleIndices = geometry.triangles.indices;
2940
2763
  const numPoints = points.length / 2;
2941
- // Count boundary edges for side walls (4 vertices + 6 indices per edge)
2764
+ // Boundary edges are those that appear in exactly one triangle
2942
2765
  let boundaryEdges = [];
2943
2766
  if (depth !== 0) {
2944
- const counts = new Map();
2945
- const oriented = new Map();
2946
- for (let i = 0; i < triangleIndices.length; i += 3) {
2767
+ // Pack edge pair into integer key: (min << 16) | max
2768
+ // Fits glyph vertex indices comfortably, good hash distribution
2769
+ const edgeMap = new Map();
2770
+ const triLen = triangleIndices.length;
2771
+ for (let i = 0; i < triLen; i += 3) {
2947
2772
  const a = triangleIndices[i];
2948
2773
  const b = triangleIndices[i + 1];
2949
2774
  const c = triangleIndices[i + 2];
2950
- const k0 = this.packEdge(a, b);
2951
- const n0 = (counts.get(k0) ?? 0) + 1;
2952
- counts.set(k0, n0);
2953
- if (n0 === 1)
2954
- oriented.set(k0, [a, b]);
2955
- const k1 = this.packEdge(b, c);
2956
- const n1 = (counts.get(k1) ?? 0) + 1;
2957
- counts.set(k1, n1);
2958
- if (n1 === 1)
2959
- oriented.set(k1, [b, c]);
2960
- const k2 = this.packEdge(c, a);
2961
- const n2 = (counts.get(k2) ?? 0) + 1;
2962
- counts.set(k2, n2);
2963
- if (n2 === 1)
2964
- oriented.set(k2, [c, a]);
2775
+ let key, v0, v1;
2776
+ if (a < b) {
2777
+ key = (a << 16) | b;
2778
+ v0 = a;
2779
+ v1 = b;
2780
+ }
2781
+ else {
2782
+ key = (b << 16) | a;
2783
+ v0 = a;
2784
+ v1 = b;
2785
+ }
2786
+ let data = edgeMap.get(key);
2787
+ if (data) {
2788
+ data[2]++;
2789
+ }
2790
+ else {
2791
+ edgeMap.set(key, [v0, v1, 1]);
2792
+ }
2793
+ if (b < c) {
2794
+ key = (b << 16) | c;
2795
+ v0 = b;
2796
+ v1 = c;
2797
+ }
2798
+ else {
2799
+ key = (c << 16) | b;
2800
+ v0 = b;
2801
+ v1 = c;
2802
+ }
2803
+ data = edgeMap.get(key);
2804
+ if (data) {
2805
+ data[2]++;
2806
+ }
2807
+ else {
2808
+ edgeMap.set(key, [v0, v1, 1]);
2809
+ }
2810
+ if (c < a) {
2811
+ key = (c << 16) | a;
2812
+ v0 = c;
2813
+ v1 = a;
2814
+ }
2815
+ else {
2816
+ key = (a << 16) | c;
2817
+ v0 = c;
2818
+ v1 = a;
2819
+ }
2820
+ data = edgeMap.get(key);
2821
+ if (data) {
2822
+ data[2]++;
2823
+ }
2824
+ else {
2825
+ edgeMap.set(key, [v0, v1, 1]);
2826
+ }
2965
2827
  }
2966
2828
  boundaryEdges = [];
2967
- for (const [key, count] of counts) {
2968
- if (count !== 1)
2969
- continue;
2970
- const edge = oriented.get(key);
2971
- if (edge)
2972
- boundaryEdges.push(edge);
2829
+ for (const [v0, v1, count] of edgeMap.values()) {
2830
+ if (count === 1) {
2831
+ boundaryEdges.push([v0, v1]);
2832
+ }
2973
2833
  }
2974
2834
  }
2975
2835
  const sideEdgeCount = depth === 0 ? 0 : boundaryEdges.length;
@@ -2983,7 +2843,6 @@ class Extruder {
2983
2843
  : triangleIndices.length * 2 + sideEdgeCount * 6;
2984
2844
  const indices = new Uint32Array(indexCount);
2985
2845
  if (depth === 0) {
2986
- // Single-sided flat geometry at z=0
2987
2846
  let vPos = 0;
2988
2847
  for (let i = 0; i < points.length; i += 2) {
2989
2848
  vertices[vPos] = points[i];
@@ -2994,16 +2853,11 @@ class Extruder {
2994
2853
  normals[vPos + 2] = 1;
2995
2854
  vPos += 3;
2996
2855
  }
2997
- // libtess outputs CCW, use as-is for +Z facing geometry
2998
- for (let i = 0; i < triangleIndices.length; i++) {
2999
- indices[i] = triangleIndices[i];
3000
- }
2856
+ indices.set(triangleIndices);
3001
2857
  return { vertices, normals, indices };
3002
2858
  }
3003
- // Extruded geometry: front at z=0, back at z=depth
3004
2859
  const minBackOffset = unitsPerEm * 0.000025;
3005
2860
  const backZ = depth <= minBackOffset ? minBackOffset : depth;
3006
- // Generate both caps in one pass
3007
2861
  for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
3008
2862
  const x = points[p];
3009
2863
  const y = points[p + 1];
@@ -3024,41 +2878,39 @@ class Extruder {
3024
2878
  normals[baseD + 1] = 0;
3025
2879
  normals[baseD + 2] = 1;
3026
2880
  }
3027
- // libtess outputs CCW triangles (viewed from +Z)
3028
- // Z=0 cap faces -Z, reverse winding
3029
- for (let i = 0; i < triangleIndices.length; i++) {
3030
- indices[i] = triangleIndices[triangleIndices.length - 1 - i];
2881
+ // Front cap faces -Z, reverse winding from libtess CCW output
2882
+ const triLen = triangleIndices.length;
2883
+ for (let i = 0; i < triLen; i++) {
2884
+ indices[i] = triangleIndices[triLen - 1 - i];
3031
2885
  }
3032
- // Z=depth cap faces +Z, use original winding
3033
- for (let i = 0; i < triangleIndices.length; i++) {
3034
- indices[triangleIndices.length + i] = triangleIndices[i] + numPoints;
2886
+ // Back cap faces +Z, use original winding
2887
+ for (let i = 0; i < triLen; i++) {
2888
+ indices[triLen + i] = triangleIndices[i] + numPoints;
3035
2889
  }
3036
- // Side walls
3037
2890
  let nextVertex = numPoints * 2;
3038
- let idxPos = triangleIndices.length * 2;
3039
- for (let e = 0; e < boundaryEdges.length; e++) {
3040
- const [u, v] = boundaryEdges[e];
3041
- const u2 = u * 2;
3042
- const v2 = v * 2;
2891
+ let idxPos = triLen * 2;
2892
+ const numEdges = boundaryEdges.length;
2893
+ for (let e = 0; e < numEdges; e++) {
2894
+ const edge = boundaryEdges[e];
2895
+ const u = edge[0];
2896
+ const v = edge[1];
2897
+ const u2 = u << 1;
2898
+ const v2 = v << 1;
3043
2899
  const p0x = points[u2];
3044
2900
  const p0y = points[u2 + 1];
3045
2901
  const p1x = points[v2];
3046
2902
  const p1y = points[v2 + 1];
3047
- // Perpendicular normal for this wall segment
3048
- // Uses the edge direction from the cap triangulation so winding does not depend on contour direction
3049
2903
  const ex = p1x - p0x;
3050
2904
  const ey = p1y - p0y;
3051
2905
  const lenSq = ex * ex + ey * ey;
3052
2906
  let nx = 0;
3053
2907
  let ny = 0;
3054
- if (lenSq > 0) {
2908
+ if (lenSq > 1e-10) {
3055
2909
  const invLen = 1 / Math.sqrt(lenSq);
3056
2910
  nx = ey * invLen;
3057
2911
  ny = -ex * invLen;
3058
2912
  }
3059
- const baseVertex = nextVertex;
3060
- const base = baseVertex * 3;
3061
- // Wall quad: front edge at z=0, back edge at z=depth
2913
+ const base = nextVertex * 3;
3062
2914
  vertices[base] = p0x;
3063
2915
  vertices[base + 1] = p0y;
3064
2916
  vertices[base + 2] = 0;
@@ -3071,7 +2923,6 @@ class Extruder {
3071
2923
  vertices[base + 9] = p1x;
3072
2924
  vertices[base + 10] = p1y;
3073
2925
  vertices[base + 11] = backZ;
3074
- // Wall normals point perpendicular to edge
3075
2926
  normals[base] = nx;
3076
2927
  normals[base + 1] = ny;
3077
2928
  normals[base + 2] = 0;
@@ -3084,13 +2935,14 @@ class Extruder {
3084
2935
  normals[base + 9] = nx;
3085
2936
  normals[base + 10] = ny;
3086
2937
  normals[base + 11] = 0;
3087
- // Two triangles per wall segment
3088
- indices[idxPos++] = baseVertex;
3089
- indices[idxPos++] = baseVertex + 1;
3090
- indices[idxPos++] = baseVertex + 2;
3091
- indices[idxPos++] = baseVertex + 1;
3092
- indices[idxPos++] = baseVertex + 3;
3093
- indices[idxPos++] = baseVertex + 2;
2938
+ const baseVertex = nextVertex;
2939
+ indices[idxPos] = baseVertex;
2940
+ indices[idxPos + 1] = baseVertex + 1;
2941
+ indices[idxPos + 2] = baseVertex + 2;
2942
+ indices[idxPos + 3] = baseVertex + 1;
2943
+ indices[idxPos + 4] = baseVertex + 3;
2944
+ indices[idxPos + 5] = baseVertex + 2;
2945
+ idxPos += 6;
3094
2946
  nextVertex += 4;
3095
2947
  }
3096
2948
  return { vertices, normals, indices };
@@ -3416,9 +3268,7 @@ class PathOptimizer {
3416
3268
  const v1LenSq = v1x * v1x + v1y * v1y;
3417
3269
  const v2LenSq = v2x * v2x + v2y * v2y;
3418
3270
  const minLenSq = this.config.minSegmentLength * this.config.minSegmentLength;
3419
- if (angle > threshold ||
3420
- v1LenSq < minLenSq ||
3421
- v2LenSq < minLenSq) {
3271
+ if (angle > threshold || v1LenSq < minLenSq || v2LenSq < minLenSq) {
3422
3272
  result.push(current);
3423
3273
  }
3424
3274
  else {
@@ -4109,7 +3959,7 @@ class GlyphGeometryBuilder {
4109
3959
  ].join('|');
4110
3960
  }
4111
3961
  // Build instanced geometry from glyph contours
4112
- buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
3962
+ buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, scale, separateGlyphs = false, coloredTextIndices) {
4113
3963
  if (isLogEnabled) {
4114
3964
  let wordCount = 0;
4115
3965
  for (let i = 0; i < clustersByLine.length; i++) {
@@ -4180,7 +4030,7 @@ class GlyphGeometryBuilder {
4180
4030
  const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x ?? 0, g.y ?? 0, 0));
4181
4031
  boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
4182
4032
  this.clusteringCache.set(cacheKey, {
4183
- glyphIds: cluster.glyphs.map(g => g.g),
4033
+ glyphIds: cluster.glyphs.map((g) => g.g),
4184
4034
  groups: boundaryGroups
4185
4035
  });
4186
4036
  }
@@ -4287,9 +4137,9 @@ class GlyphGeometryBuilder {
4287
4137
  const py = task.py;
4288
4138
  const pz = task.pz;
4289
4139
  for (let j = 0; j < v.length; j += 3) {
4290
- vertexArray[vertexPos++] = v[j] + px;
4291
- vertexArray[vertexPos++] = v[j + 1] + py;
4292
- vertexArray[vertexPos++] = v[j + 2] + pz;
4140
+ vertexArray[vertexPos++] = (v[j] + px) * scale;
4141
+ vertexArray[vertexPos++] = (v[j + 1] + py) * scale;
4142
+ vertexArray[vertexPos++] = (v[j + 2] + pz) * scale;
4293
4143
  }
4294
4144
  normalArray.set(n, normalPos);
4295
4145
  normalPos += n.length;
@@ -4299,6 +4149,20 @@ class GlyphGeometryBuilder {
4299
4149
  }
4300
4150
  }
4301
4151
  perfLogger.end('GlyphGeometryBuilder.buildInstancedGeometry');
4152
+ planeBounds.min.x *= scale;
4153
+ planeBounds.min.y *= scale;
4154
+ planeBounds.min.z *= scale;
4155
+ planeBounds.max.x *= scale;
4156
+ planeBounds.max.y *= scale;
4157
+ planeBounds.max.z *= scale;
4158
+ for (let i = 0; i < glyphInfos.length; i++) {
4159
+ glyphInfos[i].bounds.min.x *= scale;
4160
+ glyphInfos[i].bounds.min.y *= scale;
4161
+ glyphInfos[i].bounds.min.z *= scale;
4162
+ glyphInfos[i].bounds.max.x *= scale;
4163
+ glyphInfos[i].bounds.max.y *= scale;
4164
+ glyphInfos[i].bounds.max.z *= scale;
4165
+ }
4302
4166
  return {
4303
4167
  vertices: vertexArray,
4304
4168
  normals: normalArray,
@@ -4310,7 +4174,6 @@ class GlyphGeometryBuilder {
4310
4174
  getClusterKey(glyphs, depth, removeOverlaps) {
4311
4175
  if (glyphs.length === 0)
4312
4176
  return '';
4313
- // Normalize positions relative to the first glyph in the cluster
4314
4177
  const refX = glyphs[0].x ?? 0;
4315
4178
  const refY = glyphs[0].y ?? 0;
4316
4179
  const parts = glyphs.map((g) => {
@@ -4570,7 +4433,8 @@ class TextShaper {
4570
4433
  if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4571
4434
  shouldApply = false;
4572
4435
  }
4573
- if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
4436
+ if (LineBreak.isCJPunctuation(currentChar) &&
4437
+ LineBreak.isCJPunctuation(nextChar)) {
4574
4438
  shouldApply = false;
4575
4439
  }
4576
4440
  if (shouldApply) {
@@ -5364,6 +5228,7 @@ class TextRangeQuery {
5364
5228
  }
5365
5229
 
5366
5230
  const DEFAULT_MAX_TEXT_LENGTH = 100000;
5231
+ const DEFAULT_FONT_SIZE = 72;
5367
5232
  class Text {
5368
5233
  static { this.patternCache = new Map(); }
5369
5234
  static { this.hbInitPromise = null; }
@@ -5374,7 +5239,7 @@ class Text {
5374
5239
  // Stringify with sorted keys for cache stability
5375
5240
  static stableStringify(obj) {
5376
5241
  const keys = Object.keys(obj).sort();
5377
- const pairs = keys.map(k => `${k}:${obj[k]}`);
5242
+ const pairs = keys.map((k) => `${k}:${obj[k]}`);
5378
5243
  return pairs.join(',');
5379
5244
  }
5380
5245
  constructor() {
@@ -5437,7 +5302,7 @@ class Text {
5437
5302
  return {
5438
5303
  ...newResult,
5439
5304
  getLoadedFont: () => text.getLoadedFont(),
5440
- getCacheStatistics: () => text.getCacheStatistics(),
5305
+ getCacheSize: () => text.getCacheSize(),
5441
5306
  clearCache: () => text.clearCache(),
5442
5307
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5443
5308
  update
@@ -5446,7 +5311,7 @@ class Text {
5446
5311
  return {
5447
5312
  ...result,
5448
5313
  getLoadedFont: () => text.getLoadedFont(),
5449
- getCacheStatistics: () => text.getCacheStatistics(),
5314
+ getCacheSize: () => text.getCacheSize(),
5450
5315
  clearCache: () => text.clearCache(),
5451
5316
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5452
5317
  update
@@ -5578,7 +5443,7 @@ class Text {
5578
5443
  async createGeometry(options) {
5579
5444
  perfLogger.start('Text.createGeometry', {
5580
5445
  textLength: options.text.length,
5581
- size: options.size || 72,
5446
+ size: options.size || DEFAULT_FONT_SIZE,
5582
5447
  hasLayout: !!options.layout,
5583
5448
  mode: 'cached'
5584
5449
  });
@@ -5592,7 +5457,7 @@ class Text {
5592
5457
  this.updateFontVariations(options);
5593
5458
  if (!this.geometryBuilder) {
5594
5459
  const cache = options.maxCacheSizeMB
5595
- ? createGlyphCache(options.maxCacheSizeMB)
5460
+ ? createGlyphCache()
5596
5461
  : globalGlyphCache;
5597
5462
  this.geometryBuilder = new GlyphGeometryBuilder(cache, this.loadedFont);
5598
5463
  this.geometryBuilder.setFontId(this.currentFontId);
@@ -5612,7 +5477,9 @@ class Text {
5612
5477
  // to selectively use glyph-level caching (separate vertices) only for clusters containing
5613
5478
  // colored text, while non-colored clusters can still use fast cluster-level merging
5614
5479
  let coloredTextIndices;
5615
- if (options.color && typeof options.color === 'object' && !Array.isArray(options.color)) {
5480
+ if (options.color &&
5481
+ typeof options.color === 'object' &&
5482
+ !Array.isArray(options.color)) {
5616
5483
  if (options.color.byText || options.color.byCharRange) {
5617
5484
  // Build the set manually since glyphs don't exist yet
5618
5485
  coloredTextIndices = new Set();
@@ -5636,10 +5503,9 @@ class Text {
5636
5503
  }
5637
5504
  }
5638
5505
  }
5639
- const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, options.separateGlyphsWithAttributes || false, coloredTextIndices);
5640
- const cacheStats = this.geometryBuilder.getCacheStats();
5641
- const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, cacheStats, options.text);
5642
- if (options.separateGlyphsWithAttributes) {
5506
+ const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, layoutData.pixelsPerFontUnit, options.perGlyphAttributes ?? false, coloredTextIndices);
5507
+ const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, options.text);
5508
+ if (options.perGlyphAttributes) {
5643
5509
  const glyphAttrs = this.createGlyphAttributes(result.vertices.length / 3, result.glyphs);
5644
5510
  result.glyphAttributes = glyphAttrs;
5645
5511
  }
@@ -5706,18 +5572,20 @@ class Text {
5706
5572
  if (!this.loadedFont) {
5707
5573
  throw new Error('Font not loaded. Use Text.create() with a font option');
5708
5574
  }
5709
- const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
5575
+ const { text, size = DEFAULT_FONT_SIZE, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
5710
5576
  const { width, direction = 'ltr', align = direction === 'rtl' ? 'right' : 'left', respectExistingBreaks = true, hyphenate = true, language = 'en-us', tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableShortLineDetection, shortLineThreshold } = layout;
5577
+ const fontUnitsPerPixel = this.loadedFont.upem / size;
5711
5578
  let widthInFontUnits;
5712
5579
  if (width !== undefined) {
5713
- widthInFontUnits = width * (this.loadedFont.upem / size);
5580
+ widthInFontUnits = width * fontUnitsPerPixel;
5714
5581
  }
5715
5582
  // Keep depth behavior consistent with Extruder: extremely small non-zero depths
5716
- // are clamped to a minimum back offset so the back face is not coplanar.
5717
- const depthScale = this.loadedFont.upem / size;
5718
- const rawDepthInFontUnits = depth * depthScale;
5583
+ // are clamped to a minimum back offset to prevent Z fighting
5584
+ const rawDepthInFontUnits = depth * fontUnitsPerPixel;
5719
5585
  const minExtrudeDepth = this.loadedFont.upem * 0.000025;
5720
- const depthInFontUnits = rawDepthInFontUnits <= 0 ? 0 : Math.max(rawDepthInFontUnits, minExtrudeDepth);
5586
+ const depthInFontUnits = rawDepthInFontUnits <= 0
5587
+ ? 0
5588
+ : Math.max(rawDepthInFontUnits, minExtrudeDepth);
5721
5589
  if (!this.textLayout) {
5722
5590
  this.textLayout = new TextLayout(this.loadedFont);
5723
5591
  }
@@ -5756,7 +5624,8 @@ class Text {
5756
5624
  align,
5757
5625
  direction,
5758
5626
  depth: depthInFontUnits,
5759
- size
5627
+ size,
5628
+ pixelsPerFontUnit: 1 / fontUnitsPerPixel
5760
5629
  };
5761
5630
  }
5762
5631
  applyColorSystem(vertices, glyphInfoArray, color, originalText) {
@@ -5852,8 +5721,8 @@ class Text {
5852
5721
  }
5853
5722
  return { colors, coloredRanges };
5854
5723
  }
5855
- finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, cacheStats, originalText) {
5856
- const { layout = {}, size = 72 } = options;
5724
+ finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, originalText) {
5725
+ const { layout = {} } = options;
5857
5726
  const { width, align = layout.direction === 'rtl' ? 'right' : 'left' } = layout;
5858
5727
  if (!this.textLayout) {
5859
5728
  this.textLayout = new TextLayout(this.loadedFont);
@@ -5866,35 +5735,14 @@ class Text {
5866
5735
  const offset = alignmentResult.offset;
5867
5736
  planeBounds.min.x = alignmentResult.adjustedBounds.min.x;
5868
5737
  planeBounds.max.x = alignmentResult.adjustedBounds.max.x;
5869
- const finalScale = size / this.loadedFont.upem;
5870
- const offsetScaled = offset * finalScale;
5871
- // Scale vertices only (normals are unit vectors, don't scale)
5872
- if (offsetScaled === 0) {
5873
- for (let i = 0; i < vertices.length; i++) {
5874
- vertices[i] *= finalScale;
5875
- }
5876
- }
5877
- else {
5738
+ if (offset !== 0) {
5878
5739
  for (let i = 0; i < vertices.length; i += 3) {
5879
- vertices[i] = vertices[i] * finalScale + offsetScaled;
5880
- vertices[i + 1] *= finalScale;
5881
- vertices[i + 2] *= finalScale;
5882
- }
5883
- }
5884
- planeBounds.min.x *= finalScale;
5885
- planeBounds.min.y *= finalScale;
5886
- planeBounds.min.z *= finalScale;
5887
- planeBounds.max.x *= finalScale;
5888
- planeBounds.max.y *= finalScale;
5889
- planeBounds.max.z *= finalScale;
5890
- for (let i = 0; i < glyphInfoArray.length; i++) {
5891
- const glyphInfo = glyphInfoArray[i];
5892
- glyphInfo.bounds.min.x = glyphInfo.bounds.min.x * finalScale + offsetScaled;
5893
- glyphInfo.bounds.min.y *= finalScale;
5894
- glyphInfo.bounds.min.z *= finalScale;
5895
- glyphInfo.bounds.max.x = glyphInfo.bounds.max.x * finalScale + offsetScaled;
5896
- glyphInfo.bounds.max.y *= finalScale;
5897
- glyphInfo.bounds.max.z *= finalScale;
5740
+ vertices[i] += offset;
5741
+ }
5742
+ for (let i = 0; i < glyphInfoArray.length; i++) {
5743
+ glyphInfoArray[i].bounds.min.x += offset;
5744
+ glyphInfoArray[i].bounds.max.x += offset;
5745
+ }
5898
5746
  }
5899
5747
  let colors;
5900
5748
  let coloredRanges;
@@ -5919,8 +5767,7 @@ class Text {
5919
5767
  verticesGenerated,
5920
5768
  pointsRemovedByVisvalingam: optimizationStats.pointsRemovedByVisvalingam,
5921
5769
  pointsRemovedByColinear: optimizationStats.pointsRemovedByColinear,
5922
- originalPointCount: optimizationStats.originalPointCount,
5923
- ...(cacheStats || {})
5770
+ originalPointCount: optimizationStats.originalPointCount
5924
5771
  },
5925
5772
  query: (options) => {
5926
5773
  if (!originalText) {
@@ -5955,13 +5802,11 @@ class Text {
5955
5802
  static registerPattern(language, pattern) {
5956
5803
  Text.patternCache.set(language, pattern);
5957
5804
  }
5958
- static clearFontCache() {
5959
- Text.fontCache.clear();
5960
- Text.fontCacheMemoryBytes = 0;
5961
- }
5962
5805
  static setMaxFontCacheMemoryMB(limitMB) {
5963
5806
  Text.maxFontCacheMemoryBytes =
5964
- limitMB === Infinity ? Infinity : Math.max(1, Math.floor(limitMB)) * 1024 * 1024;
5807
+ limitMB === Infinity
5808
+ ? Infinity
5809
+ : Math.max(1, Math.floor(limitMB)) * 1024 * 1024;
5965
5810
  Text.enforceFontCacheMemoryLimit();
5966
5811
  }
5967
5812
  getLoadedFont() {
@@ -5973,11 +5818,11 @@ class Text {
5973
5818
  }
5974
5819
  return TextMeasurer.measureTextWidth(this.loadedFont, text, letterSpacing);
5975
5820
  }
5976
- getCacheStatistics() {
5821
+ getCacheSize() {
5977
5822
  if (this.geometryBuilder) {
5978
- return this.geometryBuilder.getCacheStats();
5823
+ return this.geometryBuilder.getCacheStats().size;
5979
5824
  }
5980
- return null;
5825
+ return 0;
5981
5826
  }
5982
5827
  clearCache() {
5983
5828
  if (this.geometryBuilder) {
@@ -5988,25 +5833,45 @@ class Text {
5988
5833
  const glyphCenters = new Float32Array(vertexCount * 3);
5989
5834
  const glyphIndices = new Float32Array(vertexCount);
5990
5835
  const glyphLineIndices = new Float32Array(vertexCount);
5991
- glyphs.forEach((glyph, index) => {
5836
+ const glyphProgress = new Float32Array(vertexCount);
5837
+ const glyphBaselineY = new Float32Array(vertexCount);
5838
+ let minX = Infinity;
5839
+ let maxX = -Infinity;
5840
+ for (let i = 0; i < glyphs.length; i++) {
5841
+ const cx = (glyphs[i].bounds.min.x + glyphs[i].bounds.max.x) / 2;
5842
+ if (cx < minX)
5843
+ minX = cx;
5844
+ if (cx > maxX)
5845
+ maxX = cx;
5846
+ }
5847
+ const range = maxX - minX;
5848
+ for (let index = 0; index < glyphs.length; index++) {
5849
+ const glyph = glyphs[index];
5992
5850
  const centerX = (glyph.bounds.min.x + glyph.bounds.max.x) / 2;
5993
5851
  const centerY = (glyph.bounds.min.y + glyph.bounds.max.y) / 2;
5994
5852
  const centerZ = (glyph.bounds.min.z + glyph.bounds.max.z) / 2;
5995
- for (let i = 0; i < glyph.vertexCount; i++) {
5996
- const vertexIndex = glyph.vertexStart + i;
5997
- if (vertexIndex < vertexCount) {
5998
- glyphCenters[vertexIndex * 3] = centerX;
5999
- glyphCenters[vertexIndex * 3 + 1] = centerY;
6000
- glyphCenters[vertexIndex * 3 + 2] = centerZ;
6001
- glyphIndices[vertexIndex] = index;
6002
- glyphLineIndices[vertexIndex] = glyph.lineIndex;
6003
- }
5853
+ const baselineY = glyph.bounds.min.y;
5854
+ const progress = range > 0 ? (centerX - minX) / range : 0;
5855
+ const start = glyph.vertexStart;
5856
+ const end = Math.min(start + glyph.vertexCount, vertexCount);
5857
+ if (end <= start)
5858
+ continue;
5859
+ glyphIndices.fill(index, start, end);
5860
+ glyphLineIndices.fill(glyph.lineIndex, start, end);
5861
+ glyphProgress.fill(progress, start, end);
5862
+ glyphBaselineY.fill(baselineY, start, end);
5863
+ for (let v = start * 3; v < end * 3; v += 3) {
5864
+ glyphCenters[v] = centerX;
5865
+ glyphCenters[v + 1] = centerY;
5866
+ glyphCenters[v + 2] = centerZ;
6004
5867
  }
6005
- });
5868
+ }
6006
5869
  return {
6007
5870
  glyphCenter: glyphCenters,
6008
5871
  glyphIndex: glyphIndices,
6009
- glyphLineIndex: glyphLineIndices
5872
+ glyphLineIndex: glyphLineIndices,
5873
+ glyphProgress,
5874
+ glyphBaselineY
6010
5875
  };
6011
5876
  }
6012
5877
  resetHelpers() {