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.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
@@ -152,7 +152,7 @@ const perfLogger = new PerformanceLogger();
152
152
  // TeX defaults
153
153
  const FITNESS_TIGHT_THRESHOLD = 12; // if badness > 12 when shrinking -> tight_fit
154
154
  const FITNESS_NORMAL_THRESHOLD = 99; // if badness > 99 when stretching -> loose_fit
155
- const DEFAULT_TOLERANCE = 200;
155
+ const DEFAULT_TOLERANCE = 800;
156
156
  const DEFAULT_PRETOLERANCE = 100;
157
157
  const DEFAULT_EMERGENCY_STRETCH = 0;
158
158
  // In TeX, interword spacing is defined by font parameters (fontdimen):
@@ -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,33 @@ 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
+ isForcedBreak &&
1007
+ minimumDemerits.value === Infinity &&
1008
+ allActiveNodes.length === 1 &&
1009
+ node.active;
1010
+ if (!isForcedBreak && !isLastResort && r < -1) {
1011
+ continue; // too tight
1004
1012
  }
1005
1013
  const fitnessClass = LineBreak.computeFitnessClass(badness, adjustment > 0);
1006
- if (!isForcedBreak && badness > threshold) {
1014
+ if (!isForcedBreak && !isLastResort && badness > threshold) {
1007
1015
  continue;
1008
1016
  }
1009
- // Initialize demerits based on TeX formula with saturation check
1017
+ // Initialize demerits with saturation check
1010
1018
  let flaggedDemerits = 0;
1011
1019
  let fitnessDemerits = 0;
1012
1020
  const configuredLinePenalty = context?.linePenalty ?? 0;
1013
1021
  let d = configuredLinePenalty + badness;
1014
1022
  let demerits = Math.abs(d) >= 10000 ? 100000000 : d * d;
1023
+ const artificialDemerits = isLastResort;
1015
1024
  const breakpointPenalty = items[breakpoint].type === ItemType.PENALTY
1016
1025
  ? items[breakpoint].penalty
1017
1026
  : items[breakpoint].type === ItemType.DISCRETIONARY
1018
1027
  ? items[breakpoint].penalty
1019
1028
  : 0;
1020
- // TeX penalty handling: pi != 0 check, then positive/negative logic
1029
+ // Penalty contribution to demerits
1021
1030
  if (breakpointPenalty !== 0) {
1022
1031
  if (breakpointPenalty > 0) {
1023
1032
  demerits += breakpointPenalty * breakpointPenalty;
@@ -1043,10 +1052,13 @@ class LineBreak {
1043
1052
  fitnessDemerits = context?.adjDemerits ?? 0;
1044
1053
  demerits += fitnessDemerits;
1045
1054
  }
1046
- if (isForcedBreak) {
1055
+ if (isForcedBreak || artificialDemerits) {
1047
1056
  demerits = 0;
1048
1057
  }
1049
1058
  const totalDemerits = node.totalDemerits + demerits;
1059
+ if (totalDemerits < minimumDemerits.value) {
1060
+ minimumDemerits.value = totalDemerits;
1061
+ }
1050
1062
  let existingNode = activeNodes.findExisting(breakpoint, fitnessClass);
1051
1063
  if (existingNode) {
1052
1064
  if (totalDemerits < existingNode.totalDemerits) {
@@ -1184,7 +1196,6 @@ class LineBreak {
1184
1196
  return { widths, stretches, shrinks, minWidths };
1185
1197
  }
1186
1198
  // Deactivate nodes that can't lead to good line breaks
1187
- // TeX recalculates minWidth each time, we use cumulative arrays for lookup
1188
1199
  static deactivateNodes(activeNodeList, currentPosition, lineWidth, minWidths) {
1189
1200
  const activeNodes = activeNodeList.getAllActive();
1190
1201
  for (let i = activeNodes.length - 1; i >= 0; i--) {
@@ -1389,8 +1400,8 @@ function convertFontFeaturesToString(features) {
1389
1400
 
1390
1401
  class TextMeasurer {
1391
1402
  // 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
1403
+ // (letter spacing is added uniformly after each glyph during measurement,
1404
+ // so the widths given to the line-breaking algorithm already account for tracking)
1394
1405
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1395
1406
  const buffer = loadedFont.hb.createBuffer();
1396
1407
  buffer.addText(text);
@@ -3712,10 +3723,11 @@ class LRUCache {
3712
3723
  }
3713
3724
  }
3714
3725
 
3726
+ const CONTOUR_CACHE_MAX_ENTRIES = 1000;
3727
+ const WORD_CACHE_MAX_ENTRIES = 1000;
3715
3728
  class GlyphGeometryBuilder {
3716
3729
  constructor(cache, loadedFont) {
3717
3730
  this.fontId = 'default';
3718
- this.wordCache = new Map();
3719
3731
  this.cache = cache;
3720
3732
  this.loadedFont = loadedFont;
3721
3733
  this.tessellator = new Tessellator();
@@ -3725,7 +3737,7 @@ class GlyphGeometryBuilder {
3725
3737
  this.drawCallbacks = new DrawCallbackHandler();
3726
3738
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3727
3739
  this.contourCache = new LRUCache({
3728
- maxEntries: 1000,
3740
+ maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
3729
3741
  calculateSize: (contours) => {
3730
3742
  let size = 0;
3731
3743
  for (const path of contours.paths) {
@@ -3734,6 +3746,15 @@ class GlyphGeometryBuilder {
3734
3746
  return size + 64; // bounds overhead
3735
3747
  }
3736
3748
  });
3749
+ this.wordCache = new LRUCache({
3750
+ maxEntries: WORD_CACHE_MAX_ENTRIES,
3751
+ calculateSize: (data) => {
3752
+ let size = data.vertices.length * 4;
3753
+ size += data.normals.length * 4;
3754
+ size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
3755
+ return size;
3756
+ }
3757
+ });
3737
3758
  }
3738
3759
  getOptimizationStats() {
3739
3760
  return this.collector.getOptimizationStats();
@@ -4023,7 +4044,7 @@ class TextShaper {
4023
4044
  let currentClusterText = '';
4024
4045
  let clusterStartPosition = new Vec3();
4025
4046
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
4026
- // Apply letter spacing between glyphs (must match what was used in width measurements)
4047
+ // Apply letter spacing after each glyph to match width measurements used during line breaking
4027
4048
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
4028
4049
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4029
4050
  const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
@@ -5061,6 +5082,54 @@ class Text {
5061
5082
  if (!Text.hbInitPromise) {
5062
5083
  Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
5063
5084
  }
5085
+ const loadedFont = await Text.resolveFont(options);
5086
+ const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
5087
+ text.setLoadedFont(loadedFont);
5088
+ // Initial creation
5089
+ const { font, maxCacheSizeMB, ...geometryOptions } = options;
5090
+ const result = await text.createGeometry(geometryOptions);
5091
+ // Recursive update function
5092
+ const update = async (newOptions) => {
5093
+ // Merge options - preserve font from original options if not provided
5094
+ const mergedOptions = { ...options };
5095
+ for (const key in newOptions) {
5096
+ const value = newOptions[key];
5097
+ if (value !== undefined) {
5098
+ mergedOptions[key] = value;
5099
+ }
5100
+ }
5101
+ // If font definition or configuration changed, reload font and reset helpers
5102
+ if (newOptions.font !== undefined ||
5103
+ newOptions.fontVariations !== undefined ||
5104
+ newOptions.fontFeatures !== undefined) {
5105
+ const newLoadedFont = await Text.resolveFont(mergedOptions);
5106
+ text.setLoadedFont(newLoadedFont);
5107
+ // Reset geometry builder and shaper to use new font
5108
+ text.resetHelpers();
5109
+ }
5110
+ // Update closure options for next time
5111
+ options = mergedOptions;
5112
+ const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
5113
+ const newResult = await text.createGeometry(currentGeometryOptions);
5114
+ return {
5115
+ ...newResult,
5116
+ getLoadedFont: () => text.getLoadedFont(),
5117
+ getCacheStatistics: () => text.getCacheStatistics(),
5118
+ clearCache: () => text.clearCache(),
5119
+ measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5120
+ update
5121
+ };
5122
+ };
5123
+ return {
5124
+ ...result,
5125
+ getLoadedFont: () => text.getLoadedFont(),
5126
+ getCacheStatistics: () => text.getCacheStatistics(),
5127
+ clearCache: () => text.clearCache(),
5128
+ measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5129
+ update
5130
+ };
5131
+ }
5132
+ static async resolveFont(options) {
5064
5133
  const baseFontKey = typeof options.font === 'string'
5065
5134
  ? options.font
5066
5135
  : `buffer-${Text.generateFontContentHash(options.font)}`;
@@ -5075,17 +5144,7 @@ class Text {
5075
5144
  if (!loadedFont) {
5076
5145
  loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
5077
5146
  }
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
- };
5147
+ return loadedFont;
5089
5148
  }
5090
5149
  static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
5091
5150
  const tempText = new Text();
@@ -5543,6 +5602,11 @@ class Text {
5543
5602
  glyphLineIndex: glyphLineIndices
5544
5603
  };
5545
5604
  }
5605
+ resetHelpers() {
5606
+ this.geometryBuilder = undefined;
5607
+ this.textShaper = undefined;
5608
+ this.textLayout = undefined;
5609
+ }
5546
5610
  destroy() {
5547
5611
  if (!this.loadedFont) {
5548
5612
  return;