three-text 0.2.8 → 0.2.9

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,34 @@ 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
+
683
706
  ##### `Text.setHarfBuzzPath(path: string): void`
684
707
 
685
708
  **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.9
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -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,32 @@ 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
+ minimumDemerits.value === Infinity &&
1010
+ allActiveNodes.length === 1 &&
1011
+ node.active;
1012
+ if (!isForcedBreak && !isLastResort && r < -1) {
1013
+ continue; // too tight
1007
1014
  }
1008
1015
  const fitnessClass = LineBreak.computeFitnessClass(badness, adjustment > 0);
1009
- if (!isForcedBreak && badness > threshold) {
1016
+ if (!isForcedBreak && !isLastResort && badness > threshold) {
1010
1017
  continue;
1011
1018
  }
1012
- // Initialize demerits based on TeX formula with saturation check
1019
+ // Initialize demerits with saturation check
1013
1020
  let flaggedDemerits = 0;
1014
1021
  let fitnessDemerits = 0;
1015
1022
  const configuredLinePenalty = context?.linePenalty ?? 0;
1016
1023
  let d = configuredLinePenalty + badness;
1017
1024
  let demerits = Math.abs(d) >= 10000 ? 100000000 : d * d;
1025
+ const artificialDemerits = isLastResort;
1018
1026
  const breakpointPenalty = items[breakpoint].type === ItemType.PENALTY
1019
1027
  ? items[breakpoint].penalty
1020
1028
  : items[breakpoint].type === ItemType.DISCRETIONARY
1021
1029
  ? items[breakpoint].penalty
1022
1030
  : 0;
1023
- // TeX penalty handling: pi != 0 check, then positive/negative logic
1031
+ // Penalty contribution to demerits
1024
1032
  if (breakpointPenalty !== 0) {
1025
1033
  if (breakpointPenalty > 0) {
1026
1034
  demerits += breakpointPenalty * breakpointPenalty;
@@ -1046,10 +1054,13 @@ class LineBreak {
1046
1054
  fitnessDemerits = context?.adjDemerits ?? 0;
1047
1055
  demerits += fitnessDemerits;
1048
1056
  }
1049
- if (isForcedBreak) {
1057
+ if (isForcedBreak || artificialDemerits) {
1050
1058
  demerits = 0;
1051
1059
  }
1052
1060
  const totalDemerits = node.totalDemerits + demerits;
1061
+ if (totalDemerits < minimumDemerits.value) {
1062
+ minimumDemerits.value = totalDemerits;
1063
+ }
1053
1064
  let existingNode = activeNodes.findExisting(breakpoint, fitnessClass);
1054
1065
  if (existingNode) {
1055
1066
  if (totalDemerits < existingNode.totalDemerits) {
@@ -1187,7 +1198,6 @@ class LineBreak {
1187
1198
  return { widths, stretches, shrinks, minWidths };
1188
1199
  }
1189
1200
  // Deactivate nodes that can't lead to good line breaks
1190
- // TeX recalculates minWidth each time, we use cumulative arrays for lookup
1191
1201
  static deactivateNodes(activeNodeList, currentPosition, lineWidth, minWidths) {
1192
1202
  const activeNodes = activeNodeList.getAllActive();
1193
1203
  for (let i = activeNodes.length - 1; i >= 0; i--) {
@@ -1392,8 +1402,8 @@ function convertFontFeaturesToString(features) {
1392
1402
 
1393
1403
  class TextMeasurer {
1394
1404
  // 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
1405
+ // (letter spacing is added uniformly after each glyph during measurement,
1406
+ // so the widths given to the line-breaking algorithm already account for tracking)
1397
1407
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1398
1408
  const buffer = loadedFont.hb.createBuffer();
1399
1409
  buffer.addText(text);
@@ -3715,10 +3725,11 @@ class LRUCache {
3715
3725
  }
3716
3726
  }
3717
3727
 
3728
+ const CONTOUR_CACHE_MAX_ENTRIES = 1000;
3729
+ const WORD_CACHE_MAX_ENTRIES = 1000;
3718
3730
  class GlyphGeometryBuilder {
3719
3731
  constructor(cache, loadedFont) {
3720
3732
  this.fontId = 'default';
3721
- this.wordCache = new Map();
3722
3733
  this.cache = cache;
3723
3734
  this.loadedFont = loadedFont;
3724
3735
  this.tessellator = new Tessellator();
@@ -3728,7 +3739,7 @@ class GlyphGeometryBuilder {
3728
3739
  this.drawCallbacks = new DrawCallbackHandler();
3729
3740
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3730
3741
  this.contourCache = new LRUCache({
3731
- maxEntries: 1000,
3742
+ maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
3732
3743
  calculateSize: (contours) => {
3733
3744
  let size = 0;
3734
3745
  for (const path of contours.paths) {
@@ -3737,6 +3748,15 @@ class GlyphGeometryBuilder {
3737
3748
  return size + 64; // bounds overhead
3738
3749
  }
3739
3750
  });
3751
+ this.wordCache = new LRUCache({
3752
+ maxEntries: WORD_CACHE_MAX_ENTRIES,
3753
+ calculateSize: (data) => {
3754
+ let size = data.vertices.length * 4;
3755
+ size += data.normals.length * 4;
3756
+ size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
3757
+ return size;
3758
+ }
3759
+ });
3740
3760
  }
3741
3761
  getOptimizationStats() {
3742
3762
  return this.collector.getOptimizationStats();
@@ -4026,7 +4046,7 @@ class TextShaper {
4026
4046
  let currentClusterText = '';
4027
4047
  let clusterStartPosition = new Vec3();
4028
4048
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
4029
- // Apply letter spacing between glyphs (must match what was used in width measurements)
4049
+ // Apply letter spacing after each glyph to match width measurements used during line breaking
4030
4050
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
4031
4051
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4032
4052
  const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
@@ -5064,6 +5084,54 @@ class Text {
5064
5084
  if (!Text.hbInitPromise) {
5065
5085
  Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
5066
5086
  }
5087
+ const loadedFont = await Text.resolveFont(options);
5088
+ const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
5089
+ text.setLoadedFont(loadedFont);
5090
+ // Initial creation
5091
+ const { font, maxCacheSizeMB, ...geometryOptions } = options;
5092
+ const result = await text.createGeometry(geometryOptions);
5093
+ // Recursive update function
5094
+ const update = async (newOptions) => {
5095
+ // Merge options - preserve font from original options if not provided
5096
+ const mergedOptions = { ...options };
5097
+ for (const key in newOptions) {
5098
+ const value = newOptions[key];
5099
+ if (value !== undefined) {
5100
+ mergedOptions[key] = value;
5101
+ }
5102
+ }
5103
+ // If font definition or configuration changed, reload font and reset helpers
5104
+ if (newOptions.font !== undefined ||
5105
+ newOptions.fontVariations !== undefined ||
5106
+ newOptions.fontFeatures !== undefined) {
5107
+ const newLoadedFont = await Text.resolveFont(mergedOptions);
5108
+ text.setLoadedFont(newLoadedFont);
5109
+ // Reset geometry builder and shaper to use new font
5110
+ text.resetHelpers();
5111
+ }
5112
+ // Update closure options for next time
5113
+ options = mergedOptions;
5114
+ const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
5115
+ const newResult = await text.createGeometry(currentGeometryOptions);
5116
+ return {
5117
+ ...newResult,
5118
+ getLoadedFont: () => text.getLoadedFont(),
5119
+ getCacheStatistics: () => text.getCacheStatistics(),
5120
+ clearCache: () => text.clearCache(),
5121
+ measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5122
+ update
5123
+ };
5124
+ };
5125
+ return {
5126
+ ...result,
5127
+ getLoadedFont: () => text.getLoadedFont(),
5128
+ getCacheStatistics: () => text.getCacheStatistics(),
5129
+ clearCache: () => text.clearCache(),
5130
+ measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5131
+ update
5132
+ };
5133
+ }
5134
+ static async resolveFont(options) {
5067
5135
  const baseFontKey = typeof options.font === 'string'
5068
5136
  ? options.font
5069
5137
  : `buffer-${Text.generateFontContentHash(options.font)}`;
@@ -5078,17 +5146,7 @@ class Text {
5078
5146
  if (!loadedFont) {
5079
5147
  loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
5080
5148
  }
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
- };
5149
+ return loadedFont;
5092
5150
  }
5093
5151
  static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
5094
5152
  const tempText = new Text();
@@ -5546,6 +5604,11 @@ class Text {
5546
5604
  glyphLineIndex: glyphLineIndices
5547
5605
  };
5548
5606
  }
5607
+ resetHelpers() {
5608
+ this.geometryBuilder = undefined;
5609
+ this.textShaper = undefined;
5610
+ this.textLayout = undefined;
5611
+ }
5549
5612
  destroy() {
5550
5613
  if (!this.loadedFont) {
5551
5614
  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