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/dist/index.umd.js 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
@@ -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 = 200;
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 §859)
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 §108, line 2337)
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: compute hyphenation only if needed
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: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
821
+ // Emergency pass: add emergency stretch to background stretchability
822
822
  if (breaks.length === 0) {
823
- breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
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
- // Force with infinite tolerance if still no breaks found
828
+ // Last resort: allow higher badness (but not infinite)
826
829
  if (breaks.length === 0) {
827
- breaks = LineBreak.findBreakpoints(currentItems, width, Infinity, looseness, true, currentEmergencyStretch, context);
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
- // Increase emergency stretch and try again
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
- emergencyStretch = 0, // emergency stretch added to background stretchability
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, emergencyStretch, cumulativeWidths, context);
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, emergencyStretch, cumulativeWidths, context);
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, emergencyStretch, cumulativeWidths, context);
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, emergencyStretch = 0, cumulativeWidths, context) {
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 according to TeX formula
979
+ // Calculate badness
978
980
  let badness;
979
981
  if (adjustment > 0) {
980
- // Add emergency stretch to the background stretchability
981
- const effectiveStretch = stretch + emergencyStretch;
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
- if (!isForcedBreak && r < -1) {
1007
- // Too tight, skip unless forced
1008
- continue;
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 based on TeX formula with saturation check
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
- // TeX penalty handling: pi != 0 check, then positive/negative logic
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
- // Letter spacing is added uniformly after each glyph during measurement,
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: 1000,
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 between glyphs (must match what was used in width measurements)
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
- const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
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;