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/dist/index.js 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
@@ -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 §859)
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 §108, line 2337)
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: compute hyphenation only if needed
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: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
816
+ // Emergency pass: add emergency stretch to background stretchability
817
817
  if (breaks.length === 0) {
818
- breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
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
- // Force with infinite tolerance if still no breaks found
823
+ // Last resort: allow higher badness (but not infinite)
821
824
  if (breaks.length === 0) {
822
- breaks = LineBreak.findBreakpoints(currentItems, width, Infinity, looseness, true, currentEmergencyStretch, context);
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
- // Increase emergency stretch and try again
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
- emergencyStretch = 0, // emergency stretch added to background stretchability
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, emergencyStretch, cumulativeWidths, context);
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, emergencyStretch, cumulativeWidths, context);
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, emergencyStretch, cumulativeWidths, context);
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, emergencyStretch = 0, cumulativeWidths, context) {
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 according to TeX formula
974
+ // Calculate badness
973
975
  let badness;
974
976
  if (adjustment > 0) {
975
- // Add emergency stretch to the background stretchability
976
- const effectiveStretch = stretch + emergencyStretch;
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,32 @@ class LineBreak {
998
1000
  else {
999
1001
  badness = 0;
1000
1002
  }
1001
- if (!isForcedBreak && r < -1) {
1002
- // Too tight, skip unless forced
1003
- continue;
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
+ minimumDemerits.value === Infinity &&
1007
+ allActiveNodes.length === 1 &&
1008
+ node.active;
1009
+ if (!isForcedBreak && !isLastResort && r < -1) {
1010
+ continue; // too tight
1004
1011
  }
1005
1012
  const fitnessClass = LineBreak.computeFitnessClass(badness, adjustment > 0);
1006
- if (!isForcedBreak && badness > threshold) {
1013
+ if (!isForcedBreak && !isLastResort && badness > threshold) {
1007
1014
  continue;
1008
1015
  }
1009
- // Initialize demerits based on TeX formula with saturation check
1016
+ // Initialize demerits with saturation check
1010
1017
  let flaggedDemerits = 0;
1011
1018
  let fitnessDemerits = 0;
1012
1019
  const configuredLinePenalty = context?.linePenalty ?? 0;
1013
1020
  let d = configuredLinePenalty + badness;
1014
1021
  let demerits = Math.abs(d) >= 10000 ? 100000000 : d * d;
1022
+ const artificialDemerits = isLastResort;
1015
1023
  const breakpointPenalty = items[breakpoint].type === ItemType.PENALTY
1016
1024
  ? items[breakpoint].penalty
1017
1025
  : items[breakpoint].type === ItemType.DISCRETIONARY
1018
1026
  ? items[breakpoint].penalty
1019
1027
  : 0;
1020
- // TeX penalty handling: pi != 0 check, then positive/negative logic
1028
+ // Penalty contribution to demerits
1021
1029
  if (breakpointPenalty !== 0) {
1022
1030
  if (breakpointPenalty > 0) {
1023
1031
  demerits += breakpointPenalty * breakpointPenalty;
@@ -1043,10 +1051,13 @@ class LineBreak {
1043
1051
  fitnessDemerits = context?.adjDemerits ?? 0;
1044
1052
  demerits += fitnessDemerits;
1045
1053
  }
1046
- if (isForcedBreak) {
1054
+ if (isForcedBreak || artificialDemerits) {
1047
1055
  demerits = 0;
1048
1056
  }
1049
1057
  const totalDemerits = node.totalDemerits + demerits;
1058
+ if (totalDemerits < minimumDemerits.value) {
1059
+ minimumDemerits.value = totalDemerits;
1060
+ }
1050
1061
  let existingNode = activeNodes.findExisting(breakpoint, fitnessClass);
1051
1062
  if (existingNode) {
1052
1063
  if (totalDemerits < existingNode.totalDemerits) {
@@ -1184,7 +1195,6 @@ class LineBreak {
1184
1195
  return { widths, stretches, shrinks, minWidths };
1185
1196
  }
1186
1197
  // Deactivate nodes that can't lead to good line breaks
1187
- // TeX recalculates minWidth each time, we use cumulative arrays for lookup
1188
1198
  static deactivateNodes(activeNodeList, currentPosition, lineWidth, minWidths) {
1189
1199
  const activeNodes = activeNodeList.getAllActive();
1190
1200
  for (let i = activeNodes.length - 1; i >= 0; i--) {
@@ -1389,8 +1399,8 @@ function convertFontFeaturesToString(features) {
1389
1399
 
1390
1400
  class TextMeasurer {
1391
1401
  // Measures text width including letter spacing
1392
- // Letter spacing is added uniformly after each glyph during measurement,
1393
- // so the widths given to the line-breaking algorithm already account for tracking
1402
+ // (letter spacing is added uniformly after each glyph during measurement,
1403
+ // so the widths given to the line-breaking algorithm already account for tracking)
1394
1404
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1395
1405
  const buffer = loadedFont.hb.createBuffer();
1396
1406
  buffer.addText(text);
@@ -3712,10 +3722,11 @@ class LRUCache {
3712
3722
  }
3713
3723
  }
3714
3724
 
3725
+ const CONTOUR_CACHE_MAX_ENTRIES = 1000;
3726
+ const WORD_CACHE_MAX_ENTRIES = 1000;
3715
3727
  class GlyphGeometryBuilder {
3716
3728
  constructor(cache, loadedFont) {
3717
3729
  this.fontId = 'default';
3718
- this.wordCache = new Map();
3719
3730
  this.cache = cache;
3720
3731
  this.loadedFont = loadedFont;
3721
3732
  this.tessellator = new Tessellator();
@@ -3725,7 +3736,7 @@ class GlyphGeometryBuilder {
3725
3736
  this.drawCallbacks = new DrawCallbackHandler();
3726
3737
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3727
3738
  this.contourCache = new LRUCache({
3728
- maxEntries: 1000,
3739
+ maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
3729
3740
  calculateSize: (contours) => {
3730
3741
  let size = 0;
3731
3742
  for (const path of contours.paths) {
@@ -3734,6 +3745,15 @@ class GlyphGeometryBuilder {
3734
3745
  return size + 64; // bounds overhead
3735
3746
  }
3736
3747
  });
3748
+ this.wordCache = new LRUCache({
3749
+ maxEntries: WORD_CACHE_MAX_ENTRIES,
3750
+ calculateSize: (data) => {
3751
+ let size = data.vertices.length * 4;
3752
+ size += data.normals.length * 4;
3753
+ size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
3754
+ return size;
3755
+ }
3756
+ });
3737
3757
  }
3738
3758
  getOptimizationStats() {
3739
3759
  return this.collector.getOptimizationStats();
@@ -4023,7 +4043,7 @@ class TextShaper {
4023
4043
  let currentClusterText = '';
4024
4044
  let clusterStartPosition = new Vec3();
4025
4045
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
4026
- // Apply letter spacing between glyphs (must match what was used in width measurements)
4046
+ // Apply letter spacing after each glyph to match width measurements used during line breaking
4027
4047
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
4028
4048
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4029
4049
  const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
@@ -5061,6 +5081,54 @@ class Text {
5061
5081
  if (!Text.hbInitPromise) {
5062
5082
  Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
5063
5083
  }
5084
+ const loadedFont = await Text.resolveFont(options);
5085
+ const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
5086
+ text.setLoadedFont(loadedFont);
5087
+ // Initial creation
5088
+ const { font, maxCacheSizeMB, ...geometryOptions } = options;
5089
+ const result = await text.createGeometry(geometryOptions);
5090
+ // Recursive update function
5091
+ const update = async (newOptions) => {
5092
+ // Merge options - preserve font from original options if not provided
5093
+ const mergedOptions = { ...options };
5094
+ for (const key in newOptions) {
5095
+ const value = newOptions[key];
5096
+ if (value !== undefined) {
5097
+ mergedOptions[key] = value;
5098
+ }
5099
+ }
5100
+ // If font definition or configuration changed, reload font and reset helpers
5101
+ if (newOptions.font !== undefined ||
5102
+ newOptions.fontVariations !== undefined ||
5103
+ newOptions.fontFeatures !== undefined) {
5104
+ const newLoadedFont = await Text.resolveFont(mergedOptions);
5105
+ text.setLoadedFont(newLoadedFont);
5106
+ // Reset geometry builder and shaper to use new font
5107
+ text.resetHelpers();
5108
+ }
5109
+ // Update closure options for next time
5110
+ options = mergedOptions;
5111
+ const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
5112
+ const newResult = await text.createGeometry(currentGeometryOptions);
5113
+ return {
5114
+ ...newResult,
5115
+ getLoadedFont: () => text.getLoadedFont(),
5116
+ getCacheStatistics: () => text.getCacheStatistics(),
5117
+ clearCache: () => text.clearCache(),
5118
+ measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5119
+ update
5120
+ };
5121
+ };
5122
+ return {
5123
+ ...result,
5124
+ getLoadedFont: () => text.getLoadedFont(),
5125
+ getCacheStatistics: () => text.getCacheStatistics(),
5126
+ clearCache: () => text.clearCache(),
5127
+ measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5128
+ update
5129
+ };
5130
+ }
5131
+ static async resolveFont(options) {
5064
5132
  const baseFontKey = typeof options.font === 'string'
5065
5133
  ? options.font
5066
5134
  : `buffer-${Text.generateFontContentHash(options.font)}`;
@@ -5075,17 +5143,7 @@ class Text {
5075
5143
  if (!loadedFont) {
5076
5144
  loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
5077
5145
  }
5078
- const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
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
- };
5146
+ return loadedFont;
5089
5147
  }
5090
5148
  static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
5091
5149
  const tempText = new Text();
@@ -5543,6 +5601,11 @@ class Text {
5543
5601
  glyphLineIndex: glyphLineIndices
5544
5602
  };
5545
5603
  }
5604
+ resetHelpers() {
5605
+ this.geometryBuilder = undefined;
5606
+ this.textShaper = undefined;
5607
+ this.textLayout = undefined;
5608
+ }
5546
5609
  destroy() {
5547
5610
  if (!this.loadedFont) {
5548
5611
  return;