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 CHANGED
@@ -675,11 +675,36 @@ Creates text geometry with automatic font loading and HarfBuzz initialization
675
675
  - `getCacheStatistics()` - Cache performance data
676
676
  - `clearCache()` - Clear the glyph cache
677
677
  - `measureTextWidth(text, letterSpacing?)` - Measure text width
678
+ - `update(options)` - Re-render with new options while preserving font/cache state
678
679
 
679
680
  **Three.js adapter (`three-text/three`) returns:**
680
681
  - `geometry: BufferGeometry` - Three.js geometry
681
682
  - Plus all the above except vertices/normals/indices/colors/glyphAttributes
682
683
 
684
+ ##### `update(options: Partial<TextOptions>): Promise<TextGeometryInfo>`
685
+
686
+ Returns new geometry with updated options. Font and glyph data are cached globally by default, so performance is similar to calling `Text.create()` again; the method is provided for ergonomics when working with the same font configuration across multiple renders
687
+
688
+ ```javascript
689
+ const text = await Text.create({
690
+ font: '/fonts/Font.ttf',
691
+ text: 'Hello',
692
+ size: 72
693
+ });
694
+
695
+ const mesh = new THREE.Mesh(text.geometry, material);
696
+ scene.add(mesh);
697
+
698
+ // Later, update the text
699
+ const updated = await text.update({ text: 'World' });
700
+ mesh.geometry.dispose();
701
+ mesh.geometry = updated.geometry;
702
+ ```
703
+
704
+ The method preserves custom cache instances if `maxCacheSizeMB` was specified. For most use cases, this is primarily an API convenience
705
+
706
+ Options merge at the top level - to remove a nested property like `layout.width`, pass `{ layout: { width: undefined } }`
707
+
683
708
  ##### `Text.setHarfBuzzPath(path: string): void`
684
709
 
685
710
  **Required.** Sets the path for the HarfBuzz WASM binary. Must be called before `Text.create()`
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.8
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
@@ -155,7 +155,7 @@ const perfLogger = new PerformanceLogger();
155
155
  // TeX defaults
156
156
  const FITNESS_TIGHT_THRESHOLD = 12; // if badness > 12 when shrinking -> tight_fit
157
157
  const FITNESS_NORMAL_THRESHOLD = 99; // if badness > 99 when stretching -> loose_fit
158
- const DEFAULT_TOLERANCE = 200;
158
+ const DEFAULT_TOLERANCE = 800;
159
159
  const DEFAULT_PRETOLERANCE = 100;
160
160
  const DEFAULT_EMERGENCY_STRETCH = 0;
161
161
  // In TeX, interword spacing is defined by font parameters (fontdimen):
@@ -186,11 +186,11 @@ var FitnessClass;
186
186
  FitnessClass[FitnessClass["VERY_LOOSE"] = 3] = "VERY_LOOSE";
187
187
  })(FitnessClass || (FitnessClass = {}));
188
188
  // ActiveNodeList maintains all currently viable breakpoints as we scan through the text.
189
- // Each node represents a potential break with accumulated demerits (total "cost" from start).
189
+ // Each node represents a potential break with accumulated demerits (total "cost" from start)
190
190
  //
191
191
  // Demerits = cumulative penalty score from text start to this break, calculated as:
192
- // (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web §859)
193
- // Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph.
192
+ // (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web line 16634)
193
+ // Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
194
194
  //
195
195
  // Implementation differs from TeX:
196
196
  // - Hash map for O(1) lookups by position+fitness
@@ -261,7 +261,7 @@ const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
261
261
  const SHORT_LINE_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
262
262
  const SHORT_LINE_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
263
263
  class LineBreak {
264
- // Calculate badness according to TeX's formula (tex.web §108, line 2337)
264
+ // Calculate badness according to TeX's formula (tex.web line 2337)
265
265
  // Given t (desired adjustment) and s (available stretch/shrink)
266
266
  // Returns approximation to 100(t/s)³, representing how "bad" a line is
267
267
  // Constants are derived from TeX's fixed-point arithmetic:
@@ -810,19 +810,22 @@ class LineBreak {
810
810
  // First pass: no hyphenation
811
811
  let currentItems = allItems;
812
812
  let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
813
- // Second pass: compute hyphenation only if needed
813
+ // Second pass: with hyphenation if first pass failed
814
814
  if (breaks.length === 0 && useHyphenation) {
815
815
  const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
816
816
  currentItems = itemsWithHyphenation;
817
817
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
818
818
  }
819
- // Emergency pass: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
819
+ // Emergency pass: add emergency stretch to background stretchability
820
820
  if (breaks.length === 0) {
821
- breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
821
+ // For first emergency attempt, use initialEmergencyStretch
822
+ // For subsequent iterations (short line detection), progressively increase
823
+ currentEmergencyStretch = initialEmergencyStretch + (iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT);
824
+ breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, true, currentEmergencyStretch, context);
822
825
  }
823
- // Force with infinite tolerance if still no breaks found
826
+ // Last resort: allow higher badness (but not infinite)
824
827
  if (breaks.length === 0) {
825
- breaks = LineBreak.findBreakpoints(currentItems, width, Infinity, looseness, true, currentEmergencyStretch, context);
828
+ breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD, looseness, true, currentEmergencyStretch, context);
826
829
  }
827
830
  // Create lines from breaks
828
831
  if (breaks.length > 0) {
@@ -832,9 +835,7 @@ class LineBreak {
832
835
  if (shortLineDetectionEnabled &&
833
836
  breaks.length > 1 &&
834
837
  LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
835
- // Increase emergency stretch and try again
836
- currentEmergencyStretch +=
837
- width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
838
+ // Retry with more emergency stretch to push words to next line
838
839
  iteration++;
839
840
  continue;
840
841
  }
@@ -866,11 +867,12 @@ class LineBreak {
866
867
  threshold = Infinity, // maximum badness allowed for a break
867
868
  looseness = 0, // desired line count adjustment
868
869
  isFinalPass = false, // whether this is the final pass
869
- emergencyStretch = 0, // emergency stretch added to background stretchability
870
+ backgroundStretch = 0, // additional stretchability for all glue (emergency stretch)
870
871
  context) {
871
872
  // Pre-compute cumulative widths for fast range queries
872
873
  const cumulativeWidths = LineBreak.computeCumulativeWidths(items);
873
874
  const activeNodes = new ActiveNodeList();
875
+ const minimumDemerits = { value: Infinity };
874
876
  activeNodes.insert({
875
877
  position: 0,
876
878
  line: 0,
@@ -884,16 +886,16 @@ class LineBreak {
884
886
  const item = items[i];
885
887
  if (item.type === ItemType.PENALTY &&
886
888
  item.penalty < Infinity) {
887
- LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, emergencyStretch, cumulativeWidths, context);
889
+ LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
888
890
  }
889
891
  if (item.type === ItemType.DISCRETIONARY &&
890
892
  item.penalty < Infinity) {
891
- LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, emergencyStretch, cumulativeWidths, context);
893
+ LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
892
894
  }
893
895
  if (item.type === ItemType.GLUE &&
894
896
  i > 0 &&
895
897
  items[i - 1].type === ItemType.BOX) {
896
- LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, emergencyStretch, cumulativeWidths, context);
898
+ LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
897
899
  }
898
900
  LineBreak.deactivateNodes(activeNodes, i, lineWidth, cumulativeWidths.minWidths);
899
901
  }
@@ -960,7 +962,7 @@ class LineBreak {
960
962
  }
961
963
  return breakpoints;
962
964
  }
963
- static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity, emergencyStretch = 0, cumulativeWidths, context) {
965
+ static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity, backgroundStretch = 0, cumulativeWidths, context, isFinalPass = false, minimumDemerits = { value: Infinity }) {
964
966
  const penalty = items[breakpoint].type === ItemType.PENALTY
965
967
  ? items[breakpoint].penalty
966
968
  : 0;
@@ -972,11 +974,11 @@ class LineBreak {
972
974
  continue;
973
975
  const adjustmentData = LineBreak.computeAdjustmentRatio(items, node.position, breakpoint, node.line, lineWidth, cumulativeWidths, context);
974
976
  const { ratio: r, adjustment, stretch, shrink, totalWidth } = adjustmentData;
975
- // Calculate badness according to TeX formula
977
+ // Calculate badness
976
978
  let badness;
977
979
  if (adjustment > 0) {
978
- // Add emergency stretch to the background stretchability
979
- const effectiveStretch = stretch + emergencyStretch;
980
+ // backgroundStretch includes emergency stretch if in emergency pass
981
+ const effectiveStretch = stretch + backgroundStretch;
980
982
  if (effectiveStretch <= 0) {
981
983
  // Overfull box - badness is infinite + 1
982
984
  badness = INF_BAD + 1;
@@ -1001,26 +1003,33 @@ class LineBreak {
1001
1003
  else {
1002
1004
  badness = 0;
1003
1005
  }
1004
- if (!isForcedBreak && r < -1) {
1005
- // Too tight, skip unless forced
1006
- continue;
1006
+ // Artificial demerits: in final pass with no feasible solution yet
1007
+ // and only one active node left, force this break as a last resort
1008
+ const isLastResort = isFinalPass &&
1009
+ isForcedBreak &&
1010
+ minimumDemerits.value === Infinity &&
1011
+ allActiveNodes.length === 1 &&
1012
+ node.active;
1013
+ if (!isForcedBreak && !isLastResort && r < -1) {
1014
+ continue; // too tight
1007
1015
  }
1008
1016
  const fitnessClass = LineBreak.computeFitnessClass(badness, adjustment > 0);
1009
- if (!isForcedBreak && badness > threshold) {
1017
+ if (!isForcedBreak && !isLastResort && badness > threshold) {
1010
1018
  continue;
1011
1019
  }
1012
- // Initialize demerits based on TeX formula with saturation check
1020
+ // Initialize demerits with saturation check
1013
1021
  let flaggedDemerits = 0;
1014
1022
  let fitnessDemerits = 0;
1015
1023
  const configuredLinePenalty = context?.linePenalty ?? 0;
1016
1024
  let d = configuredLinePenalty + badness;
1017
1025
  let demerits = Math.abs(d) >= 10000 ? 100000000 : d * d;
1026
+ const artificialDemerits = isLastResort;
1018
1027
  const breakpointPenalty = items[breakpoint].type === ItemType.PENALTY
1019
1028
  ? items[breakpoint].penalty
1020
1029
  : items[breakpoint].type === ItemType.DISCRETIONARY
1021
1030
  ? items[breakpoint].penalty
1022
1031
  : 0;
1023
- // TeX penalty handling: pi != 0 check, then positive/negative logic
1032
+ // Penalty contribution to demerits
1024
1033
  if (breakpointPenalty !== 0) {
1025
1034
  if (breakpointPenalty > 0) {
1026
1035
  demerits += breakpointPenalty * breakpointPenalty;
@@ -1046,10 +1055,13 @@ class LineBreak {
1046
1055
  fitnessDemerits = context?.adjDemerits ?? 0;
1047
1056
  demerits += fitnessDemerits;
1048
1057
  }
1049
- if (isForcedBreak) {
1058
+ if (isForcedBreak || artificialDemerits) {
1050
1059
  demerits = 0;
1051
1060
  }
1052
1061
  const totalDemerits = node.totalDemerits + demerits;
1062
+ if (totalDemerits < minimumDemerits.value) {
1063
+ minimumDemerits.value = totalDemerits;
1064
+ }
1053
1065
  let existingNode = activeNodes.findExisting(breakpoint, fitnessClass);
1054
1066
  if (existingNode) {
1055
1067
  if (totalDemerits < existingNode.totalDemerits) {
@@ -1187,7 +1199,6 @@ class LineBreak {
1187
1199
  return { widths, stretches, shrinks, minWidths };
1188
1200
  }
1189
1201
  // Deactivate nodes that can't lead to good line breaks
1190
- // TeX recalculates minWidth each time, we use cumulative arrays for lookup
1191
1202
  static deactivateNodes(activeNodeList, currentPosition, lineWidth, minWidths) {
1192
1203
  const activeNodes = activeNodeList.getAllActive();
1193
1204
  for (let i = activeNodes.length - 1; i >= 0; i--) {
@@ -1392,8 +1403,8 @@ function convertFontFeaturesToString(features) {
1392
1403
 
1393
1404
  class TextMeasurer {
1394
1405
  // Measures text width including letter spacing
1395
- // Letter spacing is added uniformly after each glyph during measurement,
1396
- // so the widths given to the line-breaking algorithm already account for tracking
1406
+ // (letter spacing is added uniformly after each glyph during measurement,
1407
+ // so the widths given to the line-breaking algorithm already account for tracking)
1397
1408
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1398
1409
  const buffer = loadedFont.hb.createBuffer();
1399
1410
  buffer.addText(text);
@@ -3715,10 +3726,11 @@ class LRUCache {
3715
3726
  }
3716
3727
  }
3717
3728
 
3729
+ const CONTOUR_CACHE_MAX_ENTRIES = 1000;
3730
+ const WORD_CACHE_MAX_ENTRIES = 1000;
3718
3731
  class GlyphGeometryBuilder {
3719
3732
  constructor(cache, loadedFont) {
3720
3733
  this.fontId = 'default';
3721
- this.wordCache = new Map();
3722
3734
  this.cache = cache;
3723
3735
  this.loadedFont = loadedFont;
3724
3736
  this.tessellator = new Tessellator();
@@ -3728,7 +3740,7 @@ class GlyphGeometryBuilder {
3728
3740
  this.drawCallbacks = new DrawCallbackHandler();
3729
3741
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3730
3742
  this.contourCache = new LRUCache({
3731
- maxEntries: 1000,
3743
+ maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
3732
3744
  calculateSize: (contours) => {
3733
3745
  let size = 0;
3734
3746
  for (const path of contours.paths) {
@@ -3737,6 +3749,15 @@ class GlyphGeometryBuilder {
3737
3749
  return size + 64; // bounds overhead
3738
3750
  }
3739
3751
  });
3752
+ this.wordCache = new LRUCache({
3753
+ maxEntries: WORD_CACHE_MAX_ENTRIES,
3754
+ calculateSize: (data) => {
3755
+ let size = data.vertices.length * 4;
3756
+ size += data.normals.length * 4;
3757
+ size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
3758
+ return size;
3759
+ }
3760
+ });
3740
3761
  }
3741
3762
  getOptimizationStats() {
3742
3763
  return this.collector.getOptimizationStats();
@@ -4026,7 +4047,7 @@ class TextShaper {
4026
4047
  let currentClusterText = '';
4027
4048
  let clusterStartPosition = new Vec3();
4028
4049
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
4029
- // Apply letter spacing between glyphs (must match what was used in width measurements)
4050
+ // Apply letter spacing after each glyph to match width measurements used during line breaking
4030
4051
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
4031
4052
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4032
4053
  const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
@@ -5064,6 +5085,54 @@ class Text {
5064
5085
  if (!Text.hbInitPromise) {
5065
5086
  Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
5066
5087
  }
5088
+ const loadedFont = await Text.resolveFont(options);
5089
+ const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
5090
+ text.setLoadedFont(loadedFont);
5091
+ // Initial creation
5092
+ const { font, maxCacheSizeMB, ...geometryOptions } = options;
5093
+ const result = await text.createGeometry(geometryOptions);
5094
+ // Recursive update function
5095
+ const update = async (newOptions) => {
5096
+ // Merge options - preserve font from original options if not provided
5097
+ const mergedOptions = { ...options };
5098
+ for (const key in newOptions) {
5099
+ const value = newOptions[key];
5100
+ if (value !== undefined) {
5101
+ mergedOptions[key] = value;
5102
+ }
5103
+ }
5104
+ // If font definition or configuration changed, reload font and reset helpers
5105
+ if (newOptions.font !== undefined ||
5106
+ newOptions.fontVariations !== undefined ||
5107
+ newOptions.fontFeatures !== undefined) {
5108
+ const newLoadedFont = await Text.resolveFont(mergedOptions);
5109
+ text.setLoadedFont(newLoadedFont);
5110
+ // Reset geometry builder and shaper to use new font
5111
+ text.resetHelpers();
5112
+ }
5113
+ // Update closure options for next time
5114
+ options = mergedOptions;
5115
+ const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
5116
+ const newResult = await text.createGeometry(currentGeometryOptions);
5117
+ return {
5118
+ ...newResult,
5119
+ getLoadedFont: () => text.getLoadedFont(),
5120
+ getCacheStatistics: () => text.getCacheStatistics(),
5121
+ clearCache: () => text.clearCache(),
5122
+ measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5123
+ update
5124
+ };
5125
+ };
5126
+ return {
5127
+ ...result,
5128
+ getLoadedFont: () => text.getLoadedFont(),
5129
+ getCacheStatistics: () => text.getCacheStatistics(),
5130
+ clearCache: () => text.clearCache(),
5131
+ measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5132
+ update
5133
+ };
5134
+ }
5135
+ static async resolveFont(options) {
5067
5136
  const baseFontKey = typeof options.font === 'string'
5068
5137
  ? options.font
5069
5138
  : `buffer-${Text.generateFontContentHash(options.font)}`;
@@ -5078,17 +5147,7 @@ class Text {
5078
5147
  if (!loadedFont) {
5079
5148
  loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
5080
5149
  }
5081
- const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
5082
- text.setLoadedFont(loadedFont);
5083
- const { font, maxCacheSizeMB, ...geometryOptions } = options;
5084
- const result = await text.createGeometry(geometryOptions);
5085
- return {
5086
- ...result,
5087
- getLoadedFont: () => text.getLoadedFont(),
5088
- getCacheStatistics: () => text.getCacheStatistics(),
5089
- clearCache: () => text.clearCache(),
5090
- measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
5091
- };
5150
+ return loadedFont;
5092
5151
  }
5093
5152
  static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
5094
5153
  const tempText = new Text();
@@ -5546,6 +5605,11 @@ class Text {
5546
5605
  glyphLineIndex: glyphLineIndices
5547
5606
  };
5548
5607
  }
5608
+ resetHelpers() {
5609
+ this.geometryBuilder = undefined;
5610
+ this.textShaper = undefined;
5611
+ this.textLayout = undefined;
5612
+ }
5549
5613
  destroy() {
5550
5614
  if (!this.loadedFont) {
5551
5615
  return;
package/dist/index.d.ts CHANGED
@@ -382,7 +382,12 @@ declare class Text {
382
382
  static setHarfBuzzPath(path: string): void;
383
383
  static setHarfBuzzBuffer(wasmBuffer: ArrayBuffer): void;
384
384
  static init(): Promise<HarfBuzzInstance>;
385
- static create(options: TextOptions): Promise<TextGeometryInfo & Pick<Text, 'getLoadedFont' | 'getCacheStatistics' | 'clearCache' | 'measureTextWidth'>>;
385
+ static create(options: TextOptions): Promise<TextGeometryInfo & Pick<Text, 'getLoadedFont' | 'getCacheStatistics' | 'clearCache' | 'measureTextWidth'> & {
386
+ update: (options: Partial<TextOptions>) => Promise<TextGeometryInfo & Pick<Text, 'getLoadedFont' | 'getCacheStatistics' | 'clearCache' | 'measureTextWidth'> & {
387
+ update: (options: Partial<TextOptions>) => Promise<any>;
388
+ }>;
389
+ }>;
390
+ private static resolveFont;
386
391
  private static loadAndCacheFont;
387
392
  private static generateFontContentHash;
388
393
  private setLoadedFont;
@@ -402,6 +407,7 @@ declare class Text {
402
407
  getCacheStatistics(): GlyphCacheStats | null;
403
408
  clearCache(): void;
404
409
  private createGlyphAttributes;
410
+ private resetHelpers;
405
411
  destroy(): void;
406
412
  }
407
413