three-text 0.3.4 → 0.4.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.d.ts CHANGED
@@ -236,7 +236,6 @@ interface TextGeometryInfo {
236
236
  trianglesGenerated: number;
237
237
  verticesGenerated: number;
238
238
  pointsRemovedByVisvalingam: number;
239
- pointsRemovedByColinear: number;
240
239
  originalPointCount: number;
241
240
  } & Partial<CacheStats & {
242
241
  hitRate: number;
@@ -300,6 +299,7 @@ interface TextOptions {
300
299
  };
301
300
  maxTextLength?: number;
302
301
  removeOverlaps?: boolean;
302
+ curveSteps?: number;
303
303
  curveFidelity?: CurveFidelityConfig;
304
304
  geometryOptimization?: GeometryOptimizationOptions;
305
305
  layout?: LayoutOptions;
@@ -315,8 +315,6 @@ interface CurveFidelityConfig {
315
315
  interface GeometryOptimizationOptions {
316
316
  enabled?: boolean;
317
317
  areaThreshold?: number;
318
- colinearThreshold?: number;
319
- minSegmentLength?: number;
320
318
  }
321
319
  interface LayoutOptions {
322
320
  width?: number;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.3.4
2
+ * three-text v0.4.0
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -195,7 +195,7 @@ var FitnessClass;
195
195
  FitnessClass[FitnessClass["DECENT"] = 2] = "DECENT";
196
196
  FitnessClass[FitnessClass["TIGHT"] = 3] = "TIGHT"; // lines shrinking 0.5 to 1.0 of their shrinkability
197
197
  })(FitnessClass || (FitnessClass = {}));
198
- // Fast active node management with O(1) operations
198
+ // Active node management with Map for O(1) lookup by (position, fitness)
199
199
  class ActiveNodeList {
200
200
  constructor() {
201
201
  this.nodesByKey = new Map();
@@ -226,7 +226,6 @@ class ActiveNodeList {
226
226
  this.nodesByKey.set(key, node);
227
227
  return true;
228
228
  }
229
- // Swap-and-pop removal
230
229
  deactivate(node) {
231
230
  if (!node.active)
232
231
  return;
@@ -369,36 +368,66 @@ class LineBreak {
369
368
  const code = char.codePointAt(0);
370
369
  if (code === undefined)
371
370
  return false;
372
- return ((code >= 0x4e00 && code <= 0x9fff) ||
373
- (code >= 0x3400 && code <= 0x4dbf) ||
374
- (code >= 0x20000 && code <= 0x2a6df) ||
375
- (code >= 0x2a700 && code <= 0x2b73f) ||
376
- (code >= 0x2b740 && code <= 0x2b81f) ||
377
- (code >= 0x2b820 && code <= 0x2ceaf) ||
378
- (code >= 0xf900 && code <= 0xfaff) ||
379
- (code >= 0x3040 && code <= 0x309f) ||
380
- (code >= 0x30a0 && code <= 0x30ff) ||
381
- (code >= 0xac00 && code <= 0xd7af) ||
382
- (code >= 0x1100 && code <= 0x11ff) ||
383
- (code >= 0x3130 && code <= 0x318f) ||
384
- (code >= 0xa960 && code <= 0xa97f) ||
385
- (code >= 0xd7b0 && code <= 0xd7ff) ||
386
- (code >= 0xffa0 && code <= 0xffdc));
387
- }
371
+ return ((code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
372
+ (code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
373
+ (code >= 0x20000 && code <= 0x2a6df) || // CJK Extension B
374
+ (code >= 0x2a700 && code <= 0x2b73f) || // CJK Extension C
375
+ (code >= 0x2b740 && code <= 0x2b81f) || // CJK Extension D
376
+ (code >= 0x2b820 && code <= 0x2ceaf) || // CJK Extension E
377
+ (code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
378
+ (code >= 0x3040 && code <= 0x309f) || // Hiragana
379
+ (code >= 0x30a0 && code <= 0x30ff) || // Katakana
380
+ (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables
381
+ (code >= 0x1100 && code <= 0x11ff) || // Hangul Jamo
382
+ (code >= 0x3130 && code <= 0x318f) || // Hangul Compatibility Jamo
383
+ (code >= 0xa960 && code <= 0xa97f) || // Hangul Jamo Extended-A
384
+ (code >= 0xd7b0 && code <= 0xd7ff) || // Hangul Jamo Extended-B
385
+ (code >= 0xffa0 && code <= 0xffdc) // Halfwidth Hangul
386
+ );
387
+ }
388
+ // Closing punctuation - no line break before (UAX #14 CL, JIS X 4051)
388
389
  static isCJClosingPunctuation(char) {
389
390
  const code = char.charCodeAt(0);
390
- return (code === 0x3001 || code === 0x3002 || code === 0xff0c || code === 0xff0e ||
391
- code === 0xff1a || code === 0xff1b || code === 0xff01 || code === 0xff1f ||
392
- code === 0xff09 || code === 0x3011 || code === 0xff5d || code === 0x300d ||
393
- code === 0x300f || code === 0x3009 || code === 0x300b || code === 0x3015 ||
394
- code === 0x3017 || code === 0x3019 || code === 0x301b || code === 0x30fc ||
395
- code === 0x2014 || code === 0x2026 || code === 0x2025);
396
- }
391
+ return (code === 0x3001 || //
392
+ code === 0x3002 || //
393
+ code === 0xff0c || //
394
+ code === 0xff0e || //
395
+ code === 0xff1a || //
396
+ code === 0xff1b || //
397
+ code === 0xff01 || // !
398
+ code === 0xff1f || // ?
399
+ code === 0xff09 || // )
400
+ code === 0x3011 || // 】
401
+ code === 0xff5d || // }
402
+ code === 0x300d || // 」
403
+ code === 0x300f || // 』
404
+ code === 0x3009 || // 〉
405
+ code === 0x300b || // 》
406
+ code === 0x3015 || // 〕
407
+ code === 0x3017 || // 〗
408
+ code === 0x3019 || // 〙
409
+ code === 0x301b || // 〛
410
+ code === 0x30fc || // ー
411
+ code === 0x2014 || // —
412
+ code === 0x2026 || // …
413
+ code === 0x2025 // ‥
414
+ );
415
+ }
416
+ // Opening punctuation - no line break after (UAX #14 OP, JIS X 4051)
397
417
  static isCJOpeningPunctuation(char) {
398
418
  const code = char.charCodeAt(0);
399
- return (code === 0xff08 || code === 0x3010 || code === 0xff5b || code === 0x300c ||
400
- code === 0x300e || code === 0x3008 || code === 0x300a || code === 0x3014 ||
401
- code === 0x3016 || code === 0x3018 || code === 0x301a);
419
+ return (code === 0xff08 || //
420
+ code === 0x3010 || //
421
+ code === 0xff5b || //
422
+ code === 0x300c || // 「
423
+ code === 0x300e || // 『
424
+ code === 0x3008 || // 〈
425
+ code === 0x300a || // 《
426
+ code === 0x3014 || // 〔
427
+ code === 0x3016 || // 〖
428
+ code === 0x3018 || // 〘
429
+ code === 0x301a // 〚
430
+ );
402
431
  }
403
432
  static isCJPunctuation(char) {
404
433
  return this.isCJClosingPunctuation(char) || this.isCJOpeningPunctuation(char);
@@ -3039,15 +3068,12 @@ class MinHeap {
3039
3068
 
3040
3069
  const DEFAULT_OPTIMIZATION_CONFIG = {
3041
3070
  enabled: true,
3042
- areaThreshold: 1.0, // Remove triangles smaller than 1 square font unit
3043
- colinearThreshold: 0.0087, // ~0.5 degrees in radians
3044
- minSegmentLength: 10
3071
+ areaThreshold: 1.0 // Remove triangles smaller than 1 square font unit
3045
3072
  };
3046
3073
  class PathOptimizer {
3047
3074
  constructor(config) {
3048
3075
  this.stats = {
3049
3076
  pointsRemovedByVisvalingam: 0,
3050
- pointsRemovedByColinear: 0,
3051
3077
  originalPointCount: 0
3052
3078
  };
3053
3079
  this.config = config;
@@ -3056,7 +3082,10 @@ class PathOptimizer {
3056
3082
  this.config = config;
3057
3083
  }
3058
3084
  optimizePath(path) {
3059
- if (!this.config.enabled || path.points.length <= 2) {
3085
+ if (path.points.length <= 2) {
3086
+ return path;
3087
+ }
3088
+ if (!this.config.enabled) {
3060
3089
  return path;
3061
3090
  }
3062
3091
  this.stats.originalPointCount += path.points.length;
@@ -3066,11 +3095,9 @@ class PathOptimizer {
3066
3095
  if (points.length < 5) {
3067
3096
  return path;
3068
3097
  }
3069
- let optimized = this.simplifyPathVW(points, this.config.areaThreshold);
3070
- if (optimized.length < 3) {
3071
- return path;
3072
- }
3073
- optimized = this.removeColinearPoints(optimized, this.config.colinearThreshold);
3098
+ let optimized = points;
3099
+ // Visvalingam-Whyatt simplification
3100
+ optimized = this.simplifyPathVW(optimized, this.config.areaThreshold);
3074
3101
  if (optimized.length < 3) {
3075
3102
  return path;
3076
3103
  }
@@ -3107,17 +3134,6 @@ class PathOptimizer {
3107
3134
  if (!p || p.area > areaThreshold) {
3108
3135
  break;
3109
3136
  }
3110
- if (this.config.minSegmentLength > 0 && p.prev && p.next) {
3111
- const prevPoint = points[p.prev.index];
3112
- const currentPoint = points[p.index];
3113
- const nextPoint = points[p.next.index];
3114
- const len1 = prevPoint.distanceTo(currentPoint);
3115
- const len2 = currentPoint.distanceTo(nextPoint);
3116
- if (len1 < this.config.minSegmentLength ||
3117
- len2 < this.config.minSegmentLength) {
3118
- continue;
3119
- }
3120
- }
3121
3137
  if (p.prev)
3122
3138
  p.prev.next = p.next;
3123
3139
  if (p.next)
@@ -3142,32 +3158,6 @@ class PathOptimizer {
3142
3158
  this.stats.pointsRemovedByVisvalingam += pointsRemoved;
3143
3159
  return simplifiedPoints;
3144
3160
  }
3145
- removeColinearPoints(points, threshold) {
3146
- if (points.length <= 2)
3147
- return points;
3148
- const result = [points[0]];
3149
- for (let i = 1; i < points.length - 1; i++) {
3150
- const prev = points[i - 1];
3151
- const current = points[i];
3152
- const next = points[i + 1];
3153
- const v1x = current.x - prev.x;
3154
- const v1y = current.y - prev.y;
3155
- const v2x = next.x - current.x;
3156
- const v2y = next.y - current.y;
3157
- const angle = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
3158
- const v1LenSq = v1x * v1x + v1y * v1y;
3159
- const v2LenSq = v2x * v2x + v2y * v2y;
3160
- const minLenSq = this.config.minSegmentLength * this.config.minSegmentLength;
3161
- if (angle > threshold || v1LenSq < minLenSq || v2LenSq < minLenSq) {
3162
- result.push(current);
3163
- }
3164
- else {
3165
- this.stats.pointsRemovedByColinear++;
3166
- }
3167
- }
3168
- result.push(points[points.length - 1]);
3169
- return result;
3170
- }
3171
3161
  // Shoelace formula
3172
3162
  calculateTriangleArea(p1, p2, p3) {
3173
3163
  return Math.abs((p1.x * (p2.y - p3.y) + p2.x * (p3.y - p1.y) + p3.x * (p1.y - p2.y)) / 2);
@@ -3178,7 +3168,6 @@ class PathOptimizer {
3178
3168
  resetStats() {
3179
3169
  this.stats = {
3180
3170
  pointsRemovedByVisvalingam: 0,
3181
- pointsRemovedByColinear: 0,
3182
3171
  originalPointCount: 0
3183
3172
  };
3184
3173
  }
@@ -3229,6 +3218,7 @@ const COLLINEARITY_EPSILON = 1e-6;
3229
3218
  const RECURSION_LIMIT = 16;
3230
3219
  class Polygonizer {
3231
3220
  constructor(curveFidelityConfig) {
3221
+ this.curveSteps = null;
3232
3222
  this.curveFidelityConfig = {
3233
3223
  ...DEFAULT_CURVE_FIDELITY,
3234
3224
  ...curveFidelityConfig
@@ -3240,18 +3230,77 @@ class Polygonizer {
3240
3230
  ...curveFidelityConfig
3241
3231
  };
3242
3232
  }
3233
+ // Fixed-step subdivision; overrides adaptive curveFidelity when set
3234
+ setCurveSteps(curveSteps) {
3235
+ if (curveSteps === undefined || curveSteps === null) {
3236
+ this.curveSteps = null;
3237
+ return;
3238
+ }
3239
+ if (!Number.isFinite(curveSteps)) {
3240
+ this.curveSteps = null;
3241
+ return;
3242
+ }
3243
+ const stepsInt = Math.round(curveSteps);
3244
+ this.curveSteps = stepsInt >= 1 ? stepsInt : null;
3245
+ }
3243
3246
  polygonizeQuadratic(start, control, end) {
3247
+ if (this.curveSteps !== null) {
3248
+ return this.polygonizeQuadraticFixedSteps(start, control, end, this.curveSteps);
3249
+ }
3244
3250
  const points = [];
3245
3251
  this.recursiveQuadratic(start.x, start.y, control.x, control.y, end.x, end.y, points);
3246
3252
  this.addPoint(end.x, end.y, points);
3247
3253
  return points;
3248
3254
  }
3249
3255
  polygonizeCubic(start, control1, control2, end) {
3256
+ if (this.curveSteps !== null) {
3257
+ return this.polygonizeCubicFixedSteps(start, control1, control2, end, this.curveSteps);
3258
+ }
3250
3259
  const points = [];
3251
3260
  this.recursiveCubic(start.x, start.y, control1.x, control1.y, control2.x, control2.y, end.x, end.y, points);
3252
3261
  this.addPoint(end.x, end.y, points);
3253
3262
  return points;
3254
3263
  }
3264
+ lerp(a, b, t) {
3265
+ return a + (b - a) * t;
3266
+ }
3267
+ polygonizeQuadraticFixedSteps(start, control, end, steps) {
3268
+ const points = [];
3269
+ // Emit intermediate points; caller already has start
3270
+ for (let i = 1; i <= steps; i++) {
3271
+ const t = i / steps;
3272
+ const x12 = this.lerp(start.x, control.x, t);
3273
+ const y12 = this.lerp(start.y, control.y, t);
3274
+ const x23 = this.lerp(control.x, end.x, t);
3275
+ const y23 = this.lerp(control.y, end.y, t);
3276
+ const x = this.lerp(x12, x23, t);
3277
+ const y = this.lerp(y12, y23, t);
3278
+ this.addPoint(x, y, points);
3279
+ }
3280
+ return points;
3281
+ }
3282
+ polygonizeCubicFixedSteps(start, control1, control2, end, steps) {
3283
+ const points = [];
3284
+ // Emit intermediate points; caller already has start
3285
+ for (let i = 1; i <= steps; i++) {
3286
+ const t = i / steps;
3287
+ // De Casteljau
3288
+ const x12 = this.lerp(start.x, control1.x, t);
3289
+ const y12 = this.lerp(start.y, control1.y, t);
3290
+ const x23 = this.lerp(control1.x, control2.x, t);
3291
+ const y23 = this.lerp(control1.y, control2.y, t);
3292
+ const x34 = this.lerp(control2.x, end.x, t);
3293
+ const y34 = this.lerp(control2.y, end.y, t);
3294
+ const x123 = this.lerp(x12, x23, t);
3295
+ const y123 = this.lerp(y12, y23, t);
3296
+ const x234 = this.lerp(x23, x34, t);
3297
+ const y234 = this.lerp(y23, y34, t);
3298
+ const x = this.lerp(x123, x234, t);
3299
+ const y = this.lerp(y123, y234, t);
3300
+ this.addPoint(x, y, points);
3301
+ }
3302
+ return points;
3303
+ }
3255
3304
  recursiveQuadratic(x1, y1, x2, y2, x3, y3, points, level = 0) {
3256
3305
  if (level > RECURSION_LIMIT)
3257
3306
  return;
@@ -3278,8 +3327,8 @@ class Polygonizer {
3278
3327
  const angleTolerance = this.curveFidelityConfig.angleTolerance ??
3279
3328
  DEFAULT_CURVE_FIDELITY.angleTolerance;
3280
3329
  if (angleTolerance > 0) {
3281
- // Angle between segments (p1->p2) and (p2->p3).
3282
- // Using atan2(cross, dot) avoids computing 2 separate atan2() values + wrap logic.
3330
+ // Angle between segments (p1->p2) and (p2->p3)
3331
+ // atan2(cross, dot) avoids computing 2 separate atan2() values
3283
3332
  const v1x = x2 - x1;
3284
3333
  const v1y = y2 - y1;
3285
3334
  const v2x = x3 - x2;
@@ -3664,6 +3713,9 @@ class GlyphContourCollector {
3664
3713
  setCurveFidelityConfig(config) {
3665
3714
  this.polygonizer.setCurveFidelityConfig(config);
3666
3715
  }
3716
+ setCurveSteps(curveSteps) {
3717
+ this.polygonizer.setCurveSteps(curveSteps);
3718
+ }
3667
3719
  setGeometryOptimization(options) {
3668
3720
  this.pathOptimizer.setConfig({
3669
3721
  ...DEFAULT_OPTIMIZATION_CONFIG,
@@ -3817,6 +3869,21 @@ class GlyphGeometryBuilder {
3817
3869
  this.collector.setCurveFidelityConfig(config);
3818
3870
  this.updateCacheKeyPrefix();
3819
3871
  }
3872
+ setCurveSteps(curveSteps) {
3873
+ // Normalize: unset for undefined/null/non-finite/<=0
3874
+ if (curveSteps === undefined || curveSteps === null) {
3875
+ this.curveSteps = undefined;
3876
+ }
3877
+ else if (!Number.isFinite(curveSteps)) {
3878
+ this.curveSteps = undefined;
3879
+ }
3880
+ else {
3881
+ const stepsInt = Math.round(curveSteps);
3882
+ this.curveSteps = stepsInt >= 1 ? stepsInt : undefined;
3883
+ }
3884
+ this.collector.setCurveSteps(this.curveSteps);
3885
+ this.updateCacheKeyPrefix();
3886
+ }
3820
3887
  setGeometryOptimization(options) {
3821
3888
  this.geometryOptimizationOptions = options;
3822
3889
  this.collector.setGeometryOptimization(options);
@@ -3830,22 +3897,24 @@ class GlyphGeometryBuilder {
3830
3897
  this.cacheKeyPrefix = `${this.fontId}__${this.getGeometryConfigSignature()}`;
3831
3898
  }
3832
3899
  getGeometryConfigSignature() {
3833
- const distanceTolerance = this.curveFidelityConfig?.distanceTolerance ??
3834
- DEFAULT_CURVE_FIDELITY.distanceTolerance;
3835
- const angleTolerance = this.curveFidelityConfig?.angleTolerance ??
3836
- DEFAULT_CURVE_FIDELITY.angleTolerance;
3900
+ const curveSignature = (() => {
3901
+ if (this.curveSteps !== undefined) {
3902
+ return `cf:steps:${this.curveSteps}`;
3903
+ }
3904
+ const distanceTolerance = this.curveFidelityConfig?.distanceTolerance ??
3905
+ DEFAULT_CURVE_FIDELITY.distanceTolerance;
3906
+ const angleTolerance = this.curveFidelityConfig?.angleTolerance ??
3907
+ DEFAULT_CURVE_FIDELITY.angleTolerance;
3908
+ return `cf:${distanceTolerance.toFixed(4)},${angleTolerance.toFixed(4)}`;
3909
+ })();
3837
3910
  const enabled = this.geometryOptimizationOptions?.enabled ??
3838
3911
  DEFAULT_OPTIMIZATION_CONFIG.enabled;
3839
3912
  const areaThreshold = this.geometryOptimizationOptions?.areaThreshold ??
3840
3913
  DEFAULT_OPTIMIZATION_CONFIG.areaThreshold;
3841
- const colinearThreshold = this.geometryOptimizationOptions?.colinearThreshold ??
3842
- DEFAULT_OPTIMIZATION_CONFIG.colinearThreshold;
3843
- const minSegmentLength = this.geometryOptimizationOptions?.minSegmentLength ??
3844
- DEFAULT_OPTIMIZATION_CONFIG.minSegmentLength;
3845
3914
  // Use fixed precision to keep cache keys stable and avoid float noise
3846
3915
  return [
3847
- `cf:${distanceTolerance.toFixed(4)},${angleTolerance.toFixed(4)}`,
3848
- `opt:${enabled ? 1 : 0},${areaThreshold.toFixed(4)},${colinearThreshold.toFixed(6)},${minSegmentLength.toFixed(4)}`
3916
+ curveSignature,
3917
+ `opt:${enabled ? 1 : 0},${areaThreshold.toFixed(4)}`
3849
3918
  ].join('|');
3850
3919
  }
3851
3920
  // Build instanced geometry from glyph contours
@@ -3899,15 +3968,20 @@ class GlyphGeometryBuilder {
3899
3968
  boundaryGroups = [[0]];
3900
3969
  }
3901
3970
  else {
3902
- // Check clustering cache (same text + glyph IDs = same overlap groups)
3971
+ // Check clustering cache (same text + glyph IDs + positions = same overlap groups)
3903
3972
  // Key must be font-specific; glyph ids/bounds differ between fonts
3973
+ // Positions must match since overlap detection depends on relative glyph placement
3904
3974
  const cacheKey = `${this.cacheKeyPrefix}_${cluster.text}`;
3905
3975
  const cached = this.clusteringCache.get(cacheKey);
3906
3976
  let isValid = false;
3907
3977
  if (cached && cached.glyphIds.length === cluster.glyphs.length) {
3908
3978
  isValid = true;
3909
3979
  for (let i = 0; i < cluster.glyphs.length; i++) {
3910
- if (cached.glyphIds[i] !== cluster.glyphs[i].g) {
3980
+ const glyph = cluster.glyphs[i];
3981
+ const cachedPos = cached.positions[i];
3982
+ if (cached.glyphIds[i] !== glyph.g ||
3983
+ cachedPos.x !== (glyph.x ?? 0) ||
3984
+ cachedPos.y !== (glyph.y ?? 0)) {
3911
3985
  isValid = false;
3912
3986
  break;
3913
3987
  }
@@ -3921,17 +3995,52 @@ class GlyphGeometryBuilder {
3921
3995
  boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
3922
3996
  this.clusteringCache.set(cacheKey, {
3923
3997
  glyphIds: cluster.glyphs.map((g) => g.g),
3998
+ positions: cluster.glyphs.map((g) => ({
3999
+ x: g.x ?? 0,
4000
+ y: g.y ?? 0
4001
+ })),
3924
4002
  groups: boundaryGroups
3925
4003
  });
3926
4004
  }
3927
4005
  }
3928
- const clusterHasColoredGlyphs = coloredTextIndices &&
3929
- cluster.glyphs.some((g) => coloredTextIndices.has(g.absoluteTextIndex));
3930
- // Use glyph-level caching when separateGlyphs is set or when cluster contains colored text
3931
- const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
4006
+ // Only force separate tessellation when explicitly requested via separateGlyphs
4007
+ const forceSeparate = separateGlyphs;
4008
+ // Split boundary groups so colored and non-colored glyphs don't merge together
4009
+ // This preserves overlap removal within each color class while keeping
4010
+ // geometry separate for accurate vertex coloring
4011
+ let finalGroups = boundaryGroups;
4012
+ if (coloredTextIndices && coloredTextIndices.size > 0) {
4013
+ finalGroups = [];
4014
+ for (const group of boundaryGroups) {
4015
+ if (group.length <= 1) {
4016
+ finalGroups.push(group);
4017
+ }
4018
+ else {
4019
+ // Split group into colored and non-colored sub-groups
4020
+ const coloredIndices = [];
4021
+ const nonColoredIndices = [];
4022
+ for (const idx of group) {
4023
+ const glyph = cluster.glyphs[idx];
4024
+ if (coloredTextIndices.has(glyph.absoluteTextIndex)) {
4025
+ coloredIndices.push(idx);
4026
+ }
4027
+ else {
4028
+ nonColoredIndices.push(idx);
4029
+ }
4030
+ }
4031
+ // Add non-empty sub-groups
4032
+ if (coloredIndices.length > 0) {
4033
+ finalGroups.push(coloredIndices);
4034
+ }
4035
+ if (nonColoredIndices.length > 0) {
4036
+ finalGroups.push(nonColoredIndices);
4037
+ }
4038
+ }
4039
+ }
4040
+ }
3932
4041
  // Iterate over the geometric groups identified by BoundaryClusterer
3933
4042
  // logical groups (words) split into geometric sub-groups
3934
- for (const groupIndices of boundaryGroups) {
4043
+ for (const groupIndices of finalGroups) {
3935
4044
  const isOverlappingGroup = groupIndices.length > 1;
3936
4045
  const shouldCluster = isOverlappingGroup && !forceSeparate;
3937
4046
  if (shouldCluster) {
@@ -5362,7 +5471,13 @@ class Text {
5362
5471
  this.geometryBuilder = new GlyphGeometryBuilder(globalGlyphCache, this.loadedFont);
5363
5472
  this.geometryBuilder.setFontId(this.currentFontId);
5364
5473
  }
5365
- this.geometryBuilder.setCurveFidelityConfig(options.curveFidelity);
5474
+ // Curve flattening: either use fixed-step De Casteljau (`curveSteps`) OR
5475
+ // adaptive AGG-style tolerances (`curveFidelity`)
5476
+ const useCurveSteps = options.curveSteps !== undefined &&
5477
+ options.curveSteps !== null &&
5478
+ options.curveSteps > 0;
5479
+ this.geometryBuilder.setCurveSteps(options.curveSteps);
5480
+ this.geometryBuilder.setCurveFidelityConfig(useCurveSteps ? undefined : options.curveFidelity);
5366
5481
  this.geometryBuilder.setGeometryOptimization(options.geometryOptimization);
5367
5482
  this.loadedFont.font.setScale(this.loadedFont.upem, this.loadedFont.upem);
5368
5483
  if (!this.textShaper) {
@@ -5592,6 +5707,7 @@ class Text {
5592
5707
  else {
5593
5708
  lineGroups.set(glyph.lineIndex, [glyph]);
5594
5709
  }
5710
+ // Color vertices owned by this glyph
5595
5711
  for (let v = 0; v < glyph.vertexCount; v++) {
5596
5712
  const vertexIndex = (glyph.vertexStart + v) * 3;
5597
5713
  if (vertexIndex >= 0 && vertexIndex < colors.length) {
@@ -5633,6 +5749,7 @@ class Text {
5633
5749
  else {
5634
5750
  lineGroups.set(glyph.lineIndex, [glyph]);
5635
5751
  }
5752
+ // Color vertices owned by this glyph
5636
5753
  for (let v = 0; v < glyph.vertexCount; v++) {
5637
5754
  const vertexIndex = (glyph.vertexStart + v) * 3;
5638
5755
  if (vertexIndex >= 0 && vertexIndex < colors.length) {
@@ -5733,7 +5850,6 @@ class Text {
5733
5850
  trianglesGenerated,
5734
5851
  verticesGenerated,
5735
5852
  pointsRemovedByVisvalingam: optimizationStats.pointsRemovedByVisvalingam,
5736
- pointsRemovedByColinear: optimizationStats.pointsRemovedByColinear,
5737
5853
  originalPointCount: optimizationStats.originalPointCount
5738
5854
  },
5739
5855
  query: (() => {