three-text 0.2.10 → 0.2.12

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/README.md CHANGED
@@ -396,7 +396,7 @@ The Knuth-Plass algorithm provides extensive control over line breaking quality:
396
396
  - **emergencyStretch** (0): Additional stretchability for difficult paragraphs
397
397
  - **autoEmergencyStretch** (0.1): Emergency stretch as percentage of line width (e.g., 0.1 = 10%). Defaults to 10% for non-hyphenated text
398
398
  - **disableShortLineDetection** (false): Disable automatic prevention of short lines
399
- - **shortLineThreshold** (0.5): Width ratio threshold for short line detection (0.0 to 1.0)
399
+ - **shortLineThreshold** (0.7): Width ratio threshold for short line detection (0.0 to 1.0)
400
400
 
401
401
  #### Advanced parameters
402
402
 
@@ -419,7 +419,7 @@ Lower penalty/tolerance values produce tighter spacing but may fail to find acce
419
419
 
420
420
  #### Short line detection
421
421
 
422
- By default, the library detects and prevents short lines (lines occupying less than 50% of the target width on non-final lines) by iteratively applying emergency stretch. This can be customized or disabled:
422
+ By default, the library detects and prevents short lines (lines occupying less than 70% of the target width on non-final lines) by iteratively applying emergency stretch. This can be customized or disabled:
423
423
 
424
424
  ```javascript
425
425
  const text = await Text.create({
@@ -783,7 +783,7 @@ interface LayoutOptions {
783
783
  emergencyStretch?: number; // Additional stretchability for difficult paragraphs
784
784
  autoEmergencyStretch?: number; // Emergency stretch as percentage of line width (defaults to 10% for non-hyphenated)
785
785
  disableShortLineDetection?: boolean; // Disable automatic short line prevention (default: false)
786
- shortLineThreshold?: number; // Width ratio threshold for short line detection (default: 0.5)
786
+ shortLineThreshold?: number; // Width ratio threshold for short line detection (default: 0.7)
787
787
  lefthyphenmin?: number; // Minimum characters before hyphen (default: 2)
788
788
  righthyphenmin?: number; // Minimum characters after hyphen (default: 4)
789
789
  linepenalty?: number; // Base penalty per line (default: 10)
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.10
2
+ * three-text v0.2.12
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -258,7 +258,7 @@ const INF_BAD = 10000;
258
258
  // Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
259
259
  const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
260
260
  // Another non TeX default: Short line detection thresholds
261
- const SHORT_LINE_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
261
+ const SHORT_LINE_WIDTH_THRESHOLD = 0.7; // Lines < 70% of width are problematic
262
262
  const SHORT_LINE_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
263
263
  class LineBreak {
264
264
  // Calculate badness according to TeX's formula (tex.web line 2337)
@@ -3772,7 +3772,7 @@ class GlyphGeometryBuilder {
3772
3772
  this.fontId = fontId;
3773
3773
  }
3774
3774
  // Build instanced geometry from glyph contours
3775
- buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false) {
3775
+ buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
3776
3776
  perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry', {
3777
3777
  lineCount: clustersByLine.length,
3778
3778
  wordCount: clustersByLine.flat().length,
@@ -3798,7 +3798,13 @@ class GlyphGeometryBuilder {
3798
3798
  // Step 2: Check for overlaps within the cluster
3799
3799
  const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x, g.y, 0));
3800
3800
  const boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
3801
- const hasOverlaps = separateGlyphs
3801
+ const clusterHasColoredGlyphs = coloredTextIndices &&
3802
+ cluster.glyphs.some((g) => coloredTextIndices.has(g.absoluteTextIndex));
3803
+ // Force glyph-level caching if:
3804
+ // - separateGlyphs flag is set (for shader attributes), OR
3805
+ // - cluster contains selectively colored text (needs separate vertex ranges per glyph)
3806
+ const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
3807
+ const hasOverlaps = forceSeparate
3802
3808
  ? false
3803
3809
  : boundaryGroups.some((group) => group.length > 1);
3804
3810
  if (hasOverlaps) {
@@ -4008,30 +4014,14 @@ class TextShaper {
4008
4014
  perfLogger.start('TextShaper.shapeLines', {
4009
4015
  lineCount: lineInfos.length
4010
4016
  });
4011
- // Calculate color boundaries once for the entire text before line processing
4012
- const colorBoundaries = new Set();
4013
- if (color &&
4014
- typeof color === 'object' &&
4015
- 'byText' in color &&
4016
- color.byText &&
4017
- originalText) {
4018
- for (const textToColor of Object.keys(color.byText)) {
4019
- let index = 0;
4020
- while ((index = originalText.indexOf(textToColor, index)) !== -1) {
4021
- colorBoundaries.add(index);
4022
- colorBoundaries.add(index + textToColor.length);
4023
- index += textToColor.length;
4024
- }
4025
- }
4026
- }
4027
4017
  const clustersByLine = [];
4028
4018
  lineInfos.forEach((lineInfo, lineIndex) => {
4029
- const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction, colorBoundaries);
4019
+ const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction);
4030
4020
  clustersByLine.push(clusters);
4031
4021
  });
4032
4022
  return clustersByLine;
4033
4023
  }
4034
- shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction, colorBoundaries) {
4024
+ shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction) {
4035
4025
  const buffer = this.loadedFont.hb.createBuffer();
4036
4026
  if (direction === 'rtl') {
4037
4027
  buffer.setDirection('rtl');
@@ -4064,8 +4054,9 @@ class TextShaper {
4064
4054
  glyph.absoluteTextIndex = lineInfo.originalStart + glyph.cl;
4065
4055
  }
4066
4056
  glyph.lineIndex = lineIndex;
4067
- const isBoundary = colorBoundaries.has(glyph.absoluteTextIndex);
4068
- if (isWhitespace || isBoundary) {
4057
+ // Cluster boundaries are based on whitespace only.
4058
+ // Coloring is applied later via vertex colors and must never affect shaping/kerning.
4059
+ if (isWhitespace) {
4069
4060
  if (currentClusterGlyphs.length > 0) {
4070
4061
  clusters.push({
4071
4062
  text: currentClusterText,
@@ -5252,7 +5243,35 @@ class Text {
5252
5243
  // Allow manual override via options.removeOverlaps
5253
5244
  const shouldRemoveOverlaps = options.removeOverlaps ?? this.loadedFont.isVariable ?? false;
5254
5245
  const clustersByLine = this.textShaper.shapeLines(layoutData.lines, layoutData.scaledLineHeight, layoutData.letterSpacing, layoutData.align, layoutData.direction, options.color, options.text);
5255
- const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, options.separateGlyphsWithAttributes || false);
5246
+ // Pre-compute which character indices will be colored. This allows geometry building
5247
+ // to selectively use glyph-level caching (separate vertices) only for clusters containing
5248
+ // colored text, while non-colored clusters can still use fast cluster-level merging
5249
+ let coloredTextIndices;
5250
+ if (options.color && typeof options.color === 'object' && !Array.isArray(options.color)) {
5251
+ if (options.color.byText || options.color.byCharRange) {
5252
+ // Build the set manually since glyphs don't exist yet
5253
+ coloredTextIndices = new Set();
5254
+ if (options.color.byText) {
5255
+ for (const pattern of Object.keys(options.color.byText)) {
5256
+ let index = 0;
5257
+ while ((index = options.text.indexOf(pattern, index)) !== -1) {
5258
+ for (let i = index; i < index + pattern.length; i++) {
5259
+ coloredTextIndices.add(i);
5260
+ }
5261
+ index += pattern.length;
5262
+ }
5263
+ }
5264
+ }
5265
+ if (options.color.byCharRange) {
5266
+ for (const range of options.color.byCharRange) {
5267
+ for (let i = range.start; i < range.end; i++) {
5268
+ coloredTextIndices.add(i);
5269
+ }
5270
+ }
5271
+ }
5272
+ }
5273
+ }
5274
+ const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, options.separateGlyphsWithAttributes || false, coloredTextIndices);
5256
5275
  const cacheStats = this.geometryBuilder.getCacheStats();
5257
5276
  const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, cacheStats, options.text);
5258
5277
  if (options.separateGlyphsWithAttributes) {
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.10
2
+ * three-text v0.2.12
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -255,7 +255,7 @@ const INF_BAD = 10000;
255
255
  // Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
256
256
  const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
257
257
  // Another non TeX default: Short line detection thresholds
258
- const SHORT_LINE_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
258
+ const SHORT_LINE_WIDTH_THRESHOLD = 0.7; // Lines < 70% of width are problematic
259
259
  const SHORT_LINE_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
260
260
  class LineBreak {
261
261
  // Calculate badness according to TeX's formula (tex.web line 2337)
@@ -3769,7 +3769,7 @@ class GlyphGeometryBuilder {
3769
3769
  this.fontId = fontId;
3770
3770
  }
3771
3771
  // Build instanced geometry from glyph contours
3772
- buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false) {
3772
+ buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
3773
3773
  perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry', {
3774
3774
  lineCount: clustersByLine.length,
3775
3775
  wordCount: clustersByLine.flat().length,
@@ -3795,7 +3795,13 @@ class GlyphGeometryBuilder {
3795
3795
  // Step 2: Check for overlaps within the cluster
3796
3796
  const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x, g.y, 0));
3797
3797
  const boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
3798
- const hasOverlaps = separateGlyphs
3798
+ const clusterHasColoredGlyphs = coloredTextIndices &&
3799
+ cluster.glyphs.some((g) => coloredTextIndices.has(g.absoluteTextIndex));
3800
+ // Force glyph-level caching if:
3801
+ // - separateGlyphs flag is set (for shader attributes), OR
3802
+ // - cluster contains selectively colored text (needs separate vertex ranges per glyph)
3803
+ const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
3804
+ const hasOverlaps = forceSeparate
3799
3805
  ? false
3800
3806
  : boundaryGroups.some((group) => group.length > 1);
3801
3807
  if (hasOverlaps) {
@@ -4005,30 +4011,14 @@ class TextShaper {
4005
4011
  perfLogger.start('TextShaper.shapeLines', {
4006
4012
  lineCount: lineInfos.length
4007
4013
  });
4008
- // Calculate color boundaries once for the entire text before line processing
4009
- const colorBoundaries = new Set();
4010
- if (color &&
4011
- typeof color === 'object' &&
4012
- 'byText' in color &&
4013
- color.byText &&
4014
- originalText) {
4015
- for (const textToColor of Object.keys(color.byText)) {
4016
- let index = 0;
4017
- while ((index = originalText.indexOf(textToColor, index)) !== -1) {
4018
- colorBoundaries.add(index);
4019
- colorBoundaries.add(index + textToColor.length);
4020
- index += textToColor.length;
4021
- }
4022
- }
4023
- }
4024
4014
  const clustersByLine = [];
4025
4015
  lineInfos.forEach((lineInfo, lineIndex) => {
4026
- const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction, colorBoundaries);
4016
+ const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction);
4027
4017
  clustersByLine.push(clusters);
4028
4018
  });
4029
4019
  return clustersByLine;
4030
4020
  }
4031
- shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction, colorBoundaries) {
4021
+ shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction) {
4032
4022
  const buffer = this.loadedFont.hb.createBuffer();
4033
4023
  if (direction === 'rtl') {
4034
4024
  buffer.setDirection('rtl');
@@ -4061,8 +4051,9 @@ class TextShaper {
4061
4051
  glyph.absoluteTextIndex = lineInfo.originalStart + glyph.cl;
4062
4052
  }
4063
4053
  glyph.lineIndex = lineIndex;
4064
- const isBoundary = colorBoundaries.has(glyph.absoluteTextIndex);
4065
- if (isWhitespace || isBoundary) {
4054
+ // Cluster boundaries are based on whitespace only.
4055
+ // Coloring is applied later via vertex colors and must never affect shaping/kerning.
4056
+ if (isWhitespace) {
4066
4057
  if (currentClusterGlyphs.length > 0) {
4067
4058
  clusters.push({
4068
4059
  text: currentClusterText,
@@ -5249,7 +5240,35 @@ class Text {
5249
5240
  // Allow manual override via options.removeOverlaps
5250
5241
  const shouldRemoveOverlaps = options.removeOverlaps ?? this.loadedFont.isVariable ?? false;
5251
5242
  const clustersByLine = this.textShaper.shapeLines(layoutData.lines, layoutData.scaledLineHeight, layoutData.letterSpacing, layoutData.align, layoutData.direction, options.color, options.text);
5252
- const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, options.separateGlyphsWithAttributes || false);
5243
+ // Pre-compute which character indices will be colored. This allows geometry building
5244
+ // to selectively use glyph-level caching (separate vertices) only for clusters containing
5245
+ // colored text, while non-colored clusters can still use fast cluster-level merging
5246
+ let coloredTextIndices;
5247
+ if (options.color && typeof options.color === 'object' && !Array.isArray(options.color)) {
5248
+ if (options.color.byText || options.color.byCharRange) {
5249
+ // Build the set manually since glyphs don't exist yet
5250
+ coloredTextIndices = new Set();
5251
+ if (options.color.byText) {
5252
+ for (const pattern of Object.keys(options.color.byText)) {
5253
+ let index = 0;
5254
+ while ((index = options.text.indexOf(pattern, index)) !== -1) {
5255
+ for (let i = index; i < index + pattern.length; i++) {
5256
+ coloredTextIndices.add(i);
5257
+ }
5258
+ index += pattern.length;
5259
+ }
5260
+ }
5261
+ }
5262
+ if (options.color.byCharRange) {
5263
+ for (const range of options.color.byCharRange) {
5264
+ for (let i = range.start; i < range.end; i++) {
5265
+ coloredTextIndices.add(i);
5266
+ }
5267
+ }
5268
+ }
5269
+ }
5270
+ }
5271
+ const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, options.separateGlyphsWithAttributes || false, coloredTextIndices);
5253
5272
  const cacheStats = this.geometryBuilder.getCacheStats();
5254
5273
  const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, cacheStats, options.text);
5255
5274
  if (options.separateGlyphsWithAttributes) {