three-text 0.2.8 → 0.2.10
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 +25 -0
- package/dist/index.cjs +110 -46
- package/dist/index.d.ts +7 -1
- package/dist/index.js +110 -46
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +110 -46
- package/dist/index.umd.min.js +2 -2
- package/dist/three/index.cjs +36 -29
- package/dist/three/index.d.ts +1 -0
- package/dist/three/index.js +36 -29
- package/dist/three/react.d.ts +7 -1
- package/dist/types/core/Text.d.ts +7 -1
- package/dist/types/core/layout/constants.d.ts +1 -1
- package/dist/types/three/index.d.ts +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* three-text v0.2.
|
|
2
|
+
* three-text v0.2.10
|
|
3
3
|
* Copyright (C) 2025 Countertype LLC
|
|
4
4
|
*
|
|
5
5
|
* This program is free software: you can redistribute it and/or modify
|
|
@@ -152,7 +152,7 @@ const perfLogger = new PerformanceLogger();
|
|
|
152
152
|
// TeX defaults
|
|
153
153
|
const FITNESS_TIGHT_THRESHOLD = 12; // if badness > 12 when shrinking -> tight_fit
|
|
154
154
|
const FITNESS_NORMAL_THRESHOLD = 99; // if badness > 99 when stretching -> loose_fit
|
|
155
|
-
const DEFAULT_TOLERANCE =
|
|
155
|
+
const DEFAULT_TOLERANCE = 800;
|
|
156
156
|
const DEFAULT_PRETOLERANCE = 100;
|
|
157
157
|
const DEFAULT_EMERGENCY_STRETCH = 0;
|
|
158
158
|
// In TeX, interword spacing is defined by font parameters (fontdimen):
|
|
@@ -183,11 +183,11 @@ var FitnessClass;
|
|
|
183
183
|
FitnessClass[FitnessClass["VERY_LOOSE"] = 3] = "VERY_LOOSE";
|
|
184
184
|
})(FitnessClass || (FitnessClass = {}));
|
|
185
185
|
// ActiveNodeList maintains all currently viable breakpoints as we scan through the text.
|
|
186
|
-
// Each node represents a potential break with accumulated demerits (total "cost" from start)
|
|
186
|
+
// Each node represents a potential break with accumulated demerits (total "cost" from start)
|
|
187
187
|
//
|
|
188
188
|
// Demerits = cumulative penalty score from text start to this break, calculated as:
|
|
189
|
-
// (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web
|
|
190
|
-
// Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
|
|
189
|
+
// (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web line 16634)
|
|
190
|
+
// Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
|
|
191
191
|
//
|
|
192
192
|
// Implementation differs from TeX:
|
|
193
193
|
// - Hash map for O(1) lookups by position+fitness
|
|
@@ -258,7 +258,7 @@ const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
|
|
|
258
258
|
const SHORT_LINE_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
|
|
259
259
|
const SHORT_LINE_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
|
|
260
260
|
class LineBreak {
|
|
261
|
-
// Calculate badness according to TeX's formula (tex.web
|
|
261
|
+
// Calculate badness according to TeX's formula (tex.web line 2337)
|
|
262
262
|
// Given t (desired adjustment) and s (available stretch/shrink)
|
|
263
263
|
// Returns approximation to 100(t/s)³, representing how "bad" a line is
|
|
264
264
|
// Constants are derived from TeX's fixed-point arithmetic:
|
|
@@ -807,19 +807,22 @@ class LineBreak {
|
|
|
807
807
|
// First pass: no hyphenation
|
|
808
808
|
let currentItems = allItems;
|
|
809
809
|
let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
|
|
810
|
-
// Second pass:
|
|
810
|
+
// Second pass: with hyphenation if first pass failed
|
|
811
811
|
if (breaks.length === 0 && useHyphenation) {
|
|
812
812
|
const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
813
813
|
currentItems = itemsWithHyphenation;
|
|
814
814
|
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
|
|
815
815
|
}
|
|
816
|
-
// Emergency pass:
|
|
816
|
+
// Emergency pass: add emergency stretch to background stretchability
|
|
817
817
|
if (breaks.length === 0) {
|
|
818
|
-
|
|
818
|
+
// For first emergency attempt, use initialEmergencyStretch
|
|
819
|
+
// For subsequent iterations (short line detection), progressively increase
|
|
820
|
+
currentEmergencyStretch = initialEmergencyStretch + (iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT);
|
|
821
|
+
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, true, currentEmergencyStretch, context);
|
|
819
822
|
}
|
|
820
|
-
//
|
|
823
|
+
// Last resort: allow higher badness (but not infinite)
|
|
821
824
|
if (breaks.length === 0) {
|
|
822
|
-
breaks = LineBreak.findBreakpoints(currentItems, width,
|
|
825
|
+
breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD, looseness, true, currentEmergencyStretch, context);
|
|
823
826
|
}
|
|
824
827
|
// Create lines from breaks
|
|
825
828
|
if (breaks.length > 0) {
|
|
@@ -829,9 +832,7 @@ class LineBreak {
|
|
|
829
832
|
if (shortLineDetectionEnabled &&
|
|
830
833
|
breaks.length > 1 &&
|
|
831
834
|
LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
|
|
832
|
-
//
|
|
833
|
-
currentEmergencyStretch +=
|
|
834
|
-
width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
|
|
835
|
+
// Retry with more emergency stretch to push words to next line
|
|
835
836
|
iteration++;
|
|
836
837
|
continue;
|
|
837
838
|
}
|
|
@@ -863,11 +864,12 @@ class LineBreak {
|
|
|
863
864
|
threshold = Infinity, // maximum badness allowed for a break
|
|
864
865
|
looseness = 0, // desired line count adjustment
|
|
865
866
|
isFinalPass = false, // whether this is the final pass
|
|
866
|
-
|
|
867
|
+
backgroundStretch = 0, // additional stretchability for all glue (emergency stretch)
|
|
867
868
|
context) {
|
|
868
869
|
// Pre-compute cumulative widths for fast range queries
|
|
869
870
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(items);
|
|
870
871
|
const activeNodes = new ActiveNodeList();
|
|
872
|
+
const minimumDemerits = { value: Infinity };
|
|
871
873
|
activeNodes.insert({
|
|
872
874
|
position: 0,
|
|
873
875
|
line: 0,
|
|
@@ -881,16 +883,16 @@ class LineBreak {
|
|
|
881
883
|
const item = items[i];
|
|
882
884
|
if (item.type === ItemType.PENALTY &&
|
|
883
885
|
item.penalty < Infinity) {
|
|
884
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
886
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
885
887
|
}
|
|
886
888
|
if (item.type === ItemType.DISCRETIONARY &&
|
|
887
889
|
item.penalty < Infinity) {
|
|
888
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
890
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
889
891
|
}
|
|
890
892
|
if (item.type === ItemType.GLUE &&
|
|
891
893
|
i > 0 &&
|
|
892
894
|
items[i - 1].type === ItemType.BOX) {
|
|
893
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
895
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
894
896
|
}
|
|
895
897
|
LineBreak.deactivateNodes(activeNodes, i, lineWidth, cumulativeWidths.minWidths);
|
|
896
898
|
}
|
|
@@ -957,7 +959,7 @@ class LineBreak {
|
|
|
957
959
|
}
|
|
958
960
|
return breakpoints;
|
|
959
961
|
}
|
|
960
|
-
static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity,
|
|
962
|
+
static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity, backgroundStretch = 0, cumulativeWidths, context, isFinalPass = false, minimumDemerits = { value: Infinity }) {
|
|
961
963
|
const penalty = items[breakpoint].type === ItemType.PENALTY
|
|
962
964
|
? items[breakpoint].penalty
|
|
963
965
|
: 0;
|
|
@@ -969,11 +971,11 @@ class LineBreak {
|
|
|
969
971
|
continue;
|
|
970
972
|
const adjustmentData = LineBreak.computeAdjustmentRatio(items, node.position, breakpoint, node.line, lineWidth, cumulativeWidths, context);
|
|
971
973
|
const { ratio: r, adjustment, stretch, shrink, totalWidth } = adjustmentData;
|
|
972
|
-
// Calculate badness
|
|
974
|
+
// Calculate badness
|
|
973
975
|
let badness;
|
|
974
976
|
if (adjustment > 0) {
|
|
975
|
-
//
|
|
976
|
-
const effectiveStretch = stretch +
|
|
977
|
+
// backgroundStretch includes emergency stretch if in emergency pass
|
|
978
|
+
const effectiveStretch = stretch + backgroundStretch;
|
|
977
979
|
if (effectiveStretch <= 0) {
|
|
978
980
|
// Overfull box - badness is infinite + 1
|
|
979
981
|
badness = INF_BAD + 1;
|
|
@@ -998,26 +1000,33 @@ class LineBreak {
|
|
|
998
1000
|
else {
|
|
999
1001
|
badness = 0;
|
|
1000
1002
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1003
|
+
// Artificial demerits: in final pass with no feasible solution yet
|
|
1004
|
+
// and only one active node left, force this break as a last resort
|
|
1005
|
+
const isLastResort = isFinalPass &&
|
|
1006
|
+
isForcedBreak &&
|
|
1007
|
+
minimumDemerits.value === Infinity &&
|
|
1008
|
+
allActiveNodes.length === 1 &&
|
|
1009
|
+
node.active;
|
|
1010
|
+
if (!isForcedBreak && !isLastResort && r < -1) {
|
|
1011
|
+
continue; // too tight
|
|
1004
1012
|
}
|
|
1005
1013
|
const fitnessClass = LineBreak.computeFitnessClass(badness, adjustment > 0);
|
|
1006
|
-
if (!isForcedBreak && badness > threshold) {
|
|
1014
|
+
if (!isForcedBreak && !isLastResort && badness > threshold) {
|
|
1007
1015
|
continue;
|
|
1008
1016
|
}
|
|
1009
|
-
// Initialize demerits
|
|
1017
|
+
// Initialize demerits with saturation check
|
|
1010
1018
|
let flaggedDemerits = 0;
|
|
1011
1019
|
let fitnessDemerits = 0;
|
|
1012
1020
|
const configuredLinePenalty = context?.linePenalty ?? 0;
|
|
1013
1021
|
let d = configuredLinePenalty + badness;
|
|
1014
1022
|
let demerits = Math.abs(d) >= 10000 ? 100000000 : d * d;
|
|
1023
|
+
const artificialDemerits = isLastResort;
|
|
1015
1024
|
const breakpointPenalty = items[breakpoint].type === ItemType.PENALTY
|
|
1016
1025
|
? items[breakpoint].penalty
|
|
1017
1026
|
: items[breakpoint].type === ItemType.DISCRETIONARY
|
|
1018
1027
|
? items[breakpoint].penalty
|
|
1019
1028
|
: 0;
|
|
1020
|
-
//
|
|
1029
|
+
// Penalty contribution to demerits
|
|
1021
1030
|
if (breakpointPenalty !== 0) {
|
|
1022
1031
|
if (breakpointPenalty > 0) {
|
|
1023
1032
|
demerits += breakpointPenalty * breakpointPenalty;
|
|
@@ -1043,10 +1052,13 @@ class LineBreak {
|
|
|
1043
1052
|
fitnessDemerits = context?.adjDemerits ?? 0;
|
|
1044
1053
|
demerits += fitnessDemerits;
|
|
1045
1054
|
}
|
|
1046
|
-
if (isForcedBreak) {
|
|
1055
|
+
if (isForcedBreak || artificialDemerits) {
|
|
1047
1056
|
demerits = 0;
|
|
1048
1057
|
}
|
|
1049
1058
|
const totalDemerits = node.totalDemerits + demerits;
|
|
1059
|
+
if (totalDemerits < minimumDemerits.value) {
|
|
1060
|
+
minimumDemerits.value = totalDemerits;
|
|
1061
|
+
}
|
|
1050
1062
|
let existingNode = activeNodes.findExisting(breakpoint, fitnessClass);
|
|
1051
1063
|
if (existingNode) {
|
|
1052
1064
|
if (totalDemerits < existingNode.totalDemerits) {
|
|
@@ -1184,7 +1196,6 @@ class LineBreak {
|
|
|
1184
1196
|
return { widths, stretches, shrinks, minWidths };
|
|
1185
1197
|
}
|
|
1186
1198
|
// Deactivate nodes that can't lead to good line breaks
|
|
1187
|
-
// TeX recalculates minWidth each time, we use cumulative arrays for lookup
|
|
1188
1199
|
static deactivateNodes(activeNodeList, currentPosition, lineWidth, minWidths) {
|
|
1189
1200
|
const activeNodes = activeNodeList.getAllActive();
|
|
1190
1201
|
for (let i = activeNodes.length - 1; i >= 0; i--) {
|
|
@@ -1389,8 +1400,8 @@ function convertFontFeaturesToString(features) {
|
|
|
1389
1400
|
|
|
1390
1401
|
class TextMeasurer {
|
|
1391
1402
|
// Measures text width including letter spacing
|
|
1392
|
-
//
|
|
1393
|
-
// so the widths given to the line-breaking algorithm already account for tracking
|
|
1403
|
+
// (letter spacing is added uniformly after each glyph during measurement,
|
|
1404
|
+
// so the widths given to the line-breaking algorithm already account for tracking)
|
|
1394
1405
|
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1395
1406
|
const buffer = loadedFont.hb.createBuffer();
|
|
1396
1407
|
buffer.addText(text);
|
|
@@ -3712,10 +3723,11 @@ class LRUCache {
|
|
|
3712
3723
|
}
|
|
3713
3724
|
}
|
|
3714
3725
|
|
|
3726
|
+
const CONTOUR_CACHE_MAX_ENTRIES = 1000;
|
|
3727
|
+
const WORD_CACHE_MAX_ENTRIES = 1000;
|
|
3715
3728
|
class GlyphGeometryBuilder {
|
|
3716
3729
|
constructor(cache, loadedFont) {
|
|
3717
3730
|
this.fontId = 'default';
|
|
3718
|
-
this.wordCache = new Map();
|
|
3719
3731
|
this.cache = cache;
|
|
3720
3732
|
this.loadedFont = loadedFont;
|
|
3721
3733
|
this.tessellator = new Tessellator();
|
|
@@ -3725,7 +3737,7 @@ class GlyphGeometryBuilder {
|
|
|
3725
3737
|
this.drawCallbacks = new DrawCallbackHandler();
|
|
3726
3738
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
3727
3739
|
this.contourCache = new LRUCache({
|
|
3728
|
-
maxEntries:
|
|
3740
|
+
maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
|
|
3729
3741
|
calculateSize: (contours) => {
|
|
3730
3742
|
let size = 0;
|
|
3731
3743
|
for (const path of contours.paths) {
|
|
@@ -3734,6 +3746,15 @@ class GlyphGeometryBuilder {
|
|
|
3734
3746
|
return size + 64; // bounds overhead
|
|
3735
3747
|
}
|
|
3736
3748
|
});
|
|
3749
|
+
this.wordCache = new LRUCache({
|
|
3750
|
+
maxEntries: WORD_CACHE_MAX_ENTRIES,
|
|
3751
|
+
calculateSize: (data) => {
|
|
3752
|
+
let size = data.vertices.length * 4;
|
|
3753
|
+
size += data.normals.length * 4;
|
|
3754
|
+
size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
|
|
3755
|
+
return size;
|
|
3756
|
+
}
|
|
3757
|
+
});
|
|
3737
3758
|
}
|
|
3738
3759
|
getOptimizationStats() {
|
|
3739
3760
|
return this.collector.getOptimizationStats();
|
|
@@ -4023,7 +4044,7 @@ class TextShaper {
|
|
|
4023
4044
|
let currentClusterText = '';
|
|
4024
4045
|
let clusterStartPosition = new Vec3();
|
|
4025
4046
|
let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
|
|
4026
|
-
// Apply letter spacing
|
|
4047
|
+
// Apply letter spacing after each glyph to match width measurements used during line breaking
|
|
4027
4048
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
4028
4049
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
4029
4050
|
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
@@ -5061,6 +5082,54 @@ class Text {
|
|
|
5061
5082
|
if (!Text.hbInitPromise) {
|
|
5062
5083
|
Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
|
|
5063
5084
|
}
|
|
5085
|
+
const loadedFont = await Text.resolveFont(options);
|
|
5086
|
+
const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
|
|
5087
|
+
text.setLoadedFont(loadedFont);
|
|
5088
|
+
// Initial creation
|
|
5089
|
+
const { font, maxCacheSizeMB, ...geometryOptions } = options;
|
|
5090
|
+
const result = await text.createGeometry(geometryOptions);
|
|
5091
|
+
// Recursive update function
|
|
5092
|
+
const update = async (newOptions) => {
|
|
5093
|
+
// Merge options - preserve font from original options if not provided
|
|
5094
|
+
const mergedOptions = { ...options };
|
|
5095
|
+
for (const key in newOptions) {
|
|
5096
|
+
const value = newOptions[key];
|
|
5097
|
+
if (value !== undefined) {
|
|
5098
|
+
mergedOptions[key] = value;
|
|
5099
|
+
}
|
|
5100
|
+
}
|
|
5101
|
+
// If font definition or configuration changed, reload font and reset helpers
|
|
5102
|
+
if (newOptions.font !== undefined ||
|
|
5103
|
+
newOptions.fontVariations !== undefined ||
|
|
5104
|
+
newOptions.fontFeatures !== undefined) {
|
|
5105
|
+
const newLoadedFont = await Text.resolveFont(mergedOptions);
|
|
5106
|
+
text.setLoadedFont(newLoadedFont);
|
|
5107
|
+
// Reset geometry builder and shaper to use new font
|
|
5108
|
+
text.resetHelpers();
|
|
5109
|
+
}
|
|
5110
|
+
// Update closure options for next time
|
|
5111
|
+
options = mergedOptions;
|
|
5112
|
+
const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
|
|
5113
|
+
const newResult = await text.createGeometry(currentGeometryOptions);
|
|
5114
|
+
return {
|
|
5115
|
+
...newResult,
|
|
5116
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
5117
|
+
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5118
|
+
clearCache: () => text.clearCache(),
|
|
5119
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
5120
|
+
update
|
|
5121
|
+
};
|
|
5122
|
+
};
|
|
5123
|
+
return {
|
|
5124
|
+
...result,
|
|
5125
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
5126
|
+
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5127
|
+
clearCache: () => text.clearCache(),
|
|
5128
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
5129
|
+
update
|
|
5130
|
+
};
|
|
5131
|
+
}
|
|
5132
|
+
static async resolveFont(options) {
|
|
5064
5133
|
const baseFontKey = typeof options.font === 'string'
|
|
5065
5134
|
? options.font
|
|
5066
5135
|
: `buffer-${Text.generateFontContentHash(options.font)}`;
|
|
@@ -5075,17 +5144,7 @@ class Text {
|
|
|
5075
5144
|
if (!loadedFont) {
|
|
5076
5145
|
loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
|
|
5077
5146
|
}
|
|
5078
|
-
|
|
5079
|
-
text.setLoadedFont(loadedFont);
|
|
5080
|
-
const { font, maxCacheSizeMB, ...geometryOptions } = options;
|
|
5081
|
-
const result = await text.createGeometry(geometryOptions);
|
|
5082
|
-
return {
|
|
5083
|
-
...result,
|
|
5084
|
-
getLoadedFont: () => text.getLoadedFont(),
|
|
5085
|
-
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5086
|
-
clearCache: () => text.clearCache(),
|
|
5087
|
-
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
|
|
5088
|
-
};
|
|
5147
|
+
return loadedFont;
|
|
5089
5148
|
}
|
|
5090
5149
|
static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
|
|
5091
5150
|
const tempText = new Text();
|
|
@@ -5543,6 +5602,11 @@ class Text {
|
|
|
5543
5602
|
glyphLineIndex: glyphLineIndices
|
|
5544
5603
|
};
|
|
5545
5604
|
}
|
|
5605
|
+
resetHelpers() {
|
|
5606
|
+
this.geometryBuilder = undefined;
|
|
5607
|
+
this.textShaper = undefined;
|
|
5608
|
+
this.textLayout = undefined;
|
|
5609
|
+
}
|
|
5546
5610
|
destroy() {
|
|
5547
5611
|
if (!this.loadedFont) {
|
|
5548
5612
|
return;
|