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 +3 -3
- package/dist/index.cjs +44 -25
- package/dist/index.js +44 -25
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +44 -25
- package/dist/index.umd.min.js +2 -2
- package/dist/types/core/cache/GlyphGeometryBuilder.d.ts +1 -1
- package/package.json +2 -1
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
4068
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
4065
|
-
|
|
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
|
-
|
|
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) {
|