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