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.umd.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
|
|
@@ -157,7 +157,7 @@
|
|
|
157
157
|
// TeX defaults
|
|
158
158
|
const FITNESS_TIGHT_THRESHOLD = 12; // if badness > 12 when shrinking -> tight_fit
|
|
159
159
|
const FITNESS_NORMAL_THRESHOLD = 99; // if badness > 99 when stretching -> loose_fit
|
|
160
|
-
const DEFAULT_TOLERANCE =
|
|
160
|
+
const DEFAULT_TOLERANCE = 800;
|
|
161
161
|
const DEFAULT_PRETOLERANCE = 100;
|
|
162
162
|
const DEFAULT_EMERGENCY_STRETCH = 0;
|
|
163
163
|
// In TeX, interword spacing is defined by font parameters (fontdimen):
|
|
@@ -188,11 +188,11 @@
|
|
|
188
188
|
FitnessClass[FitnessClass["VERY_LOOSE"] = 3] = "VERY_LOOSE";
|
|
189
189
|
})(FitnessClass || (FitnessClass = {}));
|
|
190
190
|
// ActiveNodeList maintains all currently viable breakpoints as we scan through the text.
|
|
191
|
-
// Each node represents a potential break with accumulated demerits (total "cost" from start)
|
|
191
|
+
// Each node represents a potential break with accumulated demerits (total "cost" from start)
|
|
192
192
|
//
|
|
193
193
|
// Demerits = cumulative penalty score from text start to this break, calculated as:
|
|
194
|
-
// (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web
|
|
195
|
-
// Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
|
|
194
|
+
// (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web line 16634)
|
|
195
|
+
// Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
|
|
196
196
|
//
|
|
197
197
|
// Implementation differs from TeX:
|
|
198
198
|
// - Hash map for O(1) lookups by position+fitness
|
|
@@ -263,7 +263,7 @@
|
|
|
263
263
|
const SHORT_LINE_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
|
|
264
264
|
const SHORT_LINE_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
|
|
265
265
|
class LineBreak {
|
|
266
|
-
// Calculate badness according to TeX's formula (tex.web
|
|
266
|
+
// Calculate badness according to TeX's formula (tex.web line 2337)
|
|
267
267
|
// Given t (desired adjustment) and s (available stretch/shrink)
|
|
268
268
|
// Returns approximation to 100(t/s)³, representing how "bad" a line is
|
|
269
269
|
// Constants are derived from TeX's fixed-point arithmetic:
|
|
@@ -812,19 +812,22 @@
|
|
|
812
812
|
// First pass: no hyphenation
|
|
813
813
|
let currentItems = allItems;
|
|
814
814
|
let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
|
|
815
|
-
// Second pass:
|
|
815
|
+
// Second pass: with hyphenation if first pass failed
|
|
816
816
|
if (breaks.length === 0 && useHyphenation) {
|
|
817
817
|
const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
818
818
|
currentItems = itemsWithHyphenation;
|
|
819
819
|
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
|
|
820
820
|
}
|
|
821
|
-
// Emergency pass:
|
|
821
|
+
// Emergency pass: add emergency stretch to background stretchability
|
|
822
822
|
if (breaks.length === 0) {
|
|
823
|
-
|
|
823
|
+
// For first emergency attempt, use initialEmergencyStretch
|
|
824
|
+
// For subsequent iterations (short line detection), progressively increase
|
|
825
|
+
currentEmergencyStretch = initialEmergencyStretch + (iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT);
|
|
826
|
+
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, true, currentEmergencyStretch, context);
|
|
824
827
|
}
|
|
825
|
-
//
|
|
828
|
+
// Last resort: allow higher badness (but not infinite)
|
|
826
829
|
if (breaks.length === 0) {
|
|
827
|
-
breaks = LineBreak.findBreakpoints(currentItems, width,
|
|
830
|
+
breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD, looseness, true, currentEmergencyStretch, context);
|
|
828
831
|
}
|
|
829
832
|
// Create lines from breaks
|
|
830
833
|
if (breaks.length > 0) {
|
|
@@ -834,9 +837,7 @@
|
|
|
834
837
|
if (shortLineDetectionEnabled &&
|
|
835
838
|
breaks.length > 1 &&
|
|
836
839
|
LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
|
|
837
|
-
//
|
|
838
|
-
currentEmergencyStretch +=
|
|
839
|
-
width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
|
|
840
|
+
// Retry with more emergency stretch to push words to next line
|
|
840
841
|
iteration++;
|
|
841
842
|
continue;
|
|
842
843
|
}
|
|
@@ -868,11 +869,12 @@
|
|
|
868
869
|
threshold = Infinity, // maximum badness allowed for a break
|
|
869
870
|
looseness = 0, // desired line count adjustment
|
|
870
871
|
isFinalPass = false, // whether this is the final pass
|
|
871
|
-
|
|
872
|
+
backgroundStretch = 0, // additional stretchability for all glue (emergency stretch)
|
|
872
873
|
context) {
|
|
873
874
|
// Pre-compute cumulative widths for fast range queries
|
|
874
875
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(items);
|
|
875
876
|
const activeNodes = new ActiveNodeList();
|
|
877
|
+
const minimumDemerits = { value: Infinity };
|
|
876
878
|
activeNodes.insert({
|
|
877
879
|
position: 0,
|
|
878
880
|
line: 0,
|
|
@@ -886,16 +888,16 @@
|
|
|
886
888
|
const item = items[i];
|
|
887
889
|
if (item.type === ItemType.PENALTY &&
|
|
888
890
|
item.penalty < Infinity) {
|
|
889
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
891
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
890
892
|
}
|
|
891
893
|
if (item.type === ItemType.DISCRETIONARY &&
|
|
892
894
|
item.penalty < Infinity) {
|
|
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
|
if (item.type === ItemType.GLUE &&
|
|
896
898
|
i > 0 &&
|
|
897
899
|
items[i - 1].type === ItemType.BOX) {
|
|
898
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
900
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
899
901
|
}
|
|
900
902
|
LineBreak.deactivateNodes(activeNodes, i, lineWidth, cumulativeWidths.minWidths);
|
|
901
903
|
}
|
|
@@ -962,7 +964,7 @@
|
|
|
962
964
|
}
|
|
963
965
|
return breakpoints;
|
|
964
966
|
}
|
|
965
|
-
static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity,
|
|
967
|
+
static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity, backgroundStretch = 0, cumulativeWidths, context, isFinalPass = false, minimumDemerits = { value: Infinity }) {
|
|
966
968
|
const penalty = items[breakpoint].type === ItemType.PENALTY
|
|
967
969
|
? items[breakpoint].penalty
|
|
968
970
|
: 0;
|
|
@@ -974,11 +976,11 @@
|
|
|
974
976
|
continue;
|
|
975
977
|
const adjustmentData = LineBreak.computeAdjustmentRatio(items, node.position, breakpoint, node.line, lineWidth, cumulativeWidths, context);
|
|
976
978
|
const { ratio: r, adjustment, stretch, shrink, totalWidth } = adjustmentData;
|
|
977
|
-
// Calculate badness
|
|
979
|
+
// Calculate badness
|
|
978
980
|
let badness;
|
|
979
981
|
if (adjustment > 0) {
|
|
980
|
-
//
|
|
981
|
-
const effectiveStretch = stretch +
|
|
982
|
+
// backgroundStretch includes emergency stretch if in emergency pass
|
|
983
|
+
const effectiveStretch = stretch + backgroundStretch;
|
|
982
984
|
if (effectiveStretch <= 0) {
|
|
983
985
|
// Overfull box - badness is infinite + 1
|
|
984
986
|
badness = INF_BAD + 1;
|
|
@@ -1003,26 +1005,33 @@
|
|
|
1003
1005
|
else {
|
|
1004
1006
|
badness = 0;
|
|
1005
1007
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1008
|
+
// Artificial demerits: in final pass with no feasible solution yet
|
|
1009
|
+
// and only one active node left, force this break as a last resort
|
|
1010
|
+
const isLastResort = isFinalPass &&
|
|
1011
|
+
isForcedBreak &&
|
|
1012
|
+
minimumDemerits.value === Infinity &&
|
|
1013
|
+
allActiveNodes.length === 1 &&
|
|
1014
|
+
node.active;
|
|
1015
|
+
if (!isForcedBreak && !isLastResort && r < -1) {
|
|
1016
|
+
continue; // too tight
|
|
1009
1017
|
}
|
|
1010
1018
|
const fitnessClass = LineBreak.computeFitnessClass(badness, adjustment > 0);
|
|
1011
|
-
if (!isForcedBreak && badness > threshold) {
|
|
1019
|
+
if (!isForcedBreak && !isLastResort && badness > threshold) {
|
|
1012
1020
|
continue;
|
|
1013
1021
|
}
|
|
1014
|
-
// Initialize demerits
|
|
1022
|
+
// Initialize demerits with saturation check
|
|
1015
1023
|
let flaggedDemerits = 0;
|
|
1016
1024
|
let fitnessDemerits = 0;
|
|
1017
1025
|
const configuredLinePenalty = context?.linePenalty ?? 0;
|
|
1018
1026
|
let d = configuredLinePenalty + badness;
|
|
1019
1027
|
let demerits = Math.abs(d) >= 10000 ? 100000000 : d * d;
|
|
1028
|
+
const artificialDemerits = isLastResort;
|
|
1020
1029
|
const breakpointPenalty = items[breakpoint].type === ItemType.PENALTY
|
|
1021
1030
|
? items[breakpoint].penalty
|
|
1022
1031
|
: items[breakpoint].type === ItemType.DISCRETIONARY
|
|
1023
1032
|
? items[breakpoint].penalty
|
|
1024
1033
|
: 0;
|
|
1025
|
-
//
|
|
1034
|
+
// Penalty contribution to demerits
|
|
1026
1035
|
if (breakpointPenalty !== 0) {
|
|
1027
1036
|
if (breakpointPenalty > 0) {
|
|
1028
1037
|
demerits += breakpointPenalty * breakpointPenalty;
|
|
@@ -1048,10 +1057,13 @@
|
|
|
1048
1057
|
fitnessDemerits = context?.adjDemerits ?? 0;
|
|
1049
1058
|
demerits += fitnessDemerits;
|
|
1050
1059
|
}
|
|
1051
|
-
if (isForcedBreak) {
|
|
1060
|
+
if (isForcedBreak || artificialDemerits) {
|
|
1052
1061
|
demerits = 0;
|
|
1053
1062
|
}
|
|
1054
1063
|
const totalDemerits = node.totalDemerits + demerits;
|
|
1064
|
+
if (totalDemerits < minimumDemerits.value) {
|
|
1065
|
+
minimumDemerits.value = totalDemerits;
|
|
1066
|
+
}
|
|
1055
1067
|
let existingNode = activeNodes.findExisting(breakpoint, fitnessClass);
|
|
1056
1068
|
if (existingNode) {
|
|
1057
1069
|
if (totalDemerits < existingNode.totalDemerits) {
|
|
@@ -1189,7 +1201,6 @@
|
|
|
1189
1201
|
return { widths, stretches, shrinks, minWidths };
|
|
1190
1202
|
}
|
|
1191
1203
|
// Deactivate nodes that can't lead to good line breaks
|
|
1192
|
-
// TeX recalculates minWidth each time, we use cumulative arrays for lookup
|
|
1193
1204
|
static deactivateNodes(activeNodeList, currentPosition, lineWidth, minWidths) {
|
|
1194
1205
|
const activeNodes = activeNodeList.getAllActive();
|
|
1195
1206
|
for (let i = activeNodes.length - 1; i >= 0; i--) {
|
|
@@ -1394,8 +1405,8 @@
|
|
|
1394
1405
|
|
|
1395
1406
|
class TextMeasurer {
|
|
1396
1407
|
// Measures text width including letter spacing
|
|
1397
|
-
//
|
|
1398
|
-
// so the widths given to the line-breaking algorithm already account for tracking
|
|
1408
|
+
// (letter spacing is added uniformly after each glyph during measurement,
|
|
1409
|
+
// so the widths given to the line-breaking algorithm already account for tracking)
|
|
1399
1410
|
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1400
1411
|
const buffer = loadedFont.hb.createBuffer();
|
|
1401
1412
|
buffer.addText(text);
|
|
@@ -3719,10 +3730,11 @@
|
|
|
3719
3730
|
}
|
|
3720
3731
|
}
|
|
3721
3732
|
|
|
3733
|
+
const CONTOUR_CACHE_MAX_ENTRIES = 1000;
|
|
3734
|
+
const WORD_CACHE_MAX_ENTRIES = 1000;
|
|
3722
3735
|
class GlyphGeometryBuilder {
|
|
3723
3736
|
constructor(cache, loadedFont) {
|
|
3724
3737
|
this.fontId = 'default';
|
|
3725
|
-
this.wordCache = new Map();
|
|
3726
3738
|
this.cache = cache;
|
|
3727
3739
|
this.loadedFont = loadedFont;
|
|
3728
3740
|
this.tessellator = new Tessellator();
|
|
@@ -3732,7 +3744,7 @@
|
|
|
3732
3744
|
this.drawCallbacks = new DrawCallbackHandler();
|
|
3733
3745
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
3734
3746
|
this.contourCache = new LRUCache({
|
|
3735
|
-
maxEntries:
|
|
3747
|
+
maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
|
|
3736
3748
|
calculateSize: (contours) => {
|
|
3737
3749
|
let size = 0;
|
|
3738
3750
|
for (const path of contours.paths) {
|
|
@@ -3741,6 +3753,15 @@
|
|
|
3741
3753
|
return size + 64; // bounds overhead
|
|
3742
3754
|
}
|
|
3743
3755
|
});
|
|
3756
|
+
this.wordCache = new LRUCache({
|
|
3757
|
+
maxEntries: WORD_CACHE_MAX_ENTRIES,
|
|
3758
|
+
calculateSize: (data) => {
|
|
3759
|
+
let size = data.vertices.length * 4;
|
|
3760
|
+
size += data.normals.length * 4;
|
|
3761
|
+
size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
|
|
3762
|
+
return size;
|
|
3763
|
+
}
|
|
3764
|
+
});
|
|
3744
3765
|
}
|
|
3745
3766
|
getOptimizationStats() {
|
|
3746
3767
|
return this.collector.getOptimizationStats();
|
|
@@ -4030,7 +4051,7 @@
|
|
|
4030
4051
|
let currentClusterText = '';
|
|
4031
4052
|
let clusterStartPosition = new Vec3();
|
|
4032
4053
|
let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
|
|
4033
|
-
// Apply letter spacing
|
|
4054
|
+
// Apply letter spacing after each glyph to match width measurements used during line breaking
|
|
4034
4055
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
4035
4056
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
4036
4057
|
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
@@ -5068,6 +5089,54 @@
|
|
|
5068
5089
|
if (!Text.hbInitPromise) {
|
|
5069
5090
|
Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
|
|
5070
5091
|
}
|
|
5092
|
+
const loadedFont = await Text.resolveFont(options);
|
|
5093
|
+
const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
|
|
5094
|
+
text.setLoadedFont(loadedFont);
|
|
5095
|
+
// Initial creation
|
|
5096
|
+
const { font, maxCacheSizeMB, ...geometryOptions } = options;
|
|
5097
|
+
const result = await text.createGeometry(geometryOptions);
|
|
5098
|
+
// Recursive update function
|
|
5099
|
+
const update = async (newOptions) => {
|
|
5100
|
+
// Merge options - preserve font from original options if not provided
|
|
5101
|
+
const mergedOptions = { ...options };
|
|
5102
|
+
for (const key in newOptions) {
|
|
5103
|
+
const value = newOptions[key];
|
|
5104
|
+
if (value !== undefined) {
|
|
5105
|
+
mergedOptions[key] = value;
|
|
5106
|
+
}
|
|
5107
|
+
}
|
|
5108
|
+
// If font definition or configuration changed, reload font and reset helpers
|
|
5109
|
+
if (newOptions.font !== undefined ||
|
|
5110
|
+
newOptions.fontVariations !== undefined ||
|
|
5111
|
+
newOptions.fontFeatures !== undefined) {
|
|
5112
|
+
const newLoadedFont = await Text.resolveFont(mergedOptions);
|
|
5113
|
+
text.setLoadedFont(newLoadedFont);
|
|
5114
|
+
// Reset geometry builder and shaper to use new font
|
|
5115
|
+
text.resetHelpers();
|
|
5116
|
+
}
|
|
5117
|
+
// Update closure options for next time
|
|
5118
|
+
options = mergedOptions;
|
|
5119
|
+
const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
|
|
5120
|
+
const newResult = await text.createGeometry(currentGeometryOptions);
|
|
5121
|
+
return {
|
|
5122
|
+
...newResult,
|
|
5123
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
5124
|
+
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5125
|
+
clearCache: () => text.clearCache(),
|
|
5126
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
5127
|
+
update
|
|
5128
|
+
};
|
|
5129
|
+
};
|
|
5130
|
+
return {
|
|
5131
|
+
...result,
|
|
5132
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
5133
|
+
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5134
|
+
clearCache: () => text.clearCache(),
|
|
5135
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
5136
|
+
update
|
|
5137
|
+
};
|
|
5138
|
+
}
|
|
5139
|
+
static async resolveFont(options) {
|
|
5071
5140
|
const baseFontKey = typeof options.font === 'string'
|
|
5072
5141
|
? options.font
|
|
5073
5142
|
: `buffer-${Text.generateFontContentHash(options.font)}`;
|
|
@@ -5082,17 +5151,7 @@
|
|
|
5082
5151
|
if (!loadedFont) {
|
|
5083
5152
|
loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
|
|
5084
5153
|
}
|
|
5085
|
-
|
|
5086
|
-
text.setLoadedFont(loadedFont);
|
|
5087
|
-
const { font, maxCacheSizeMB, ...geometryOptions } = options;
|
|
5088
|
-
const result = await text.createGeometry(geometryOptions);
|
|
5089
|
-
return {
|
|
5090
|
-
...result,
|
|
5091
|
-
getLoadedFont: () => text.getLoadedFont(),
|
|
5092
|
-
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5093
|
-
clearCache: () => text.clearCache(),
|
|
5094
|
-
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
|
|
5095
|
-
};
|
|
5154
|
+
return loadedFont;
|
|
5096
5155
|
}
|
|
5097
5156
|
static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
|
|
5098
5157
|
const tempText = new Text();
|
|
@@ -5550,6 +5609,11 @@
|
|
|
5550
5609
|
glyphLineIndex: glyphLineIndices
|
|
5551
5610
|
};
|
|
5552
5611
|
}
|
|
5612
|
+
resetHelpers() {
|
|
5613
|
+
this.geometryBuilder = undefined;
|
|
5614
|
+
this.textShaper = undefined;
|
|
5615
|
+
this.textLayout = undefined;
|
|
5616
|
+
}
|
|
5553
5617
|
destroy() {
|
|
5554
5618
|
if (!this.loadedFont) {
|
|
5555
5619
|
return;
|