three-text 0.2.7 → 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.7
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
@@ -122,9 +122,6 @@ class PerformanceLogger {
122
122
  this.metrics.length = 0;
123
123
  this.activeTimers.clear();
124
124
  }
125
- reset() {
126
- this.clear();
127
- }
128
125
  time(name, fn, metadata) {
129
126
  if (!isLogEnabled)
130
127
  return fn();
@@ -186,11 +183,11 @@ var FitnessClass;
186
183
  FitnessClass[FitnessClass["VERY_LOOSE"] = 3] = "VERY_LOOSE";
187
184
  })(FitnessClass || (FitnessClass = {}));
188
185
  // 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).
186
+ // Each node represents a potential break with accumulated demerits (total "cost" from start)
190
187
  //
191
188
  // 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.
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
194
191
  //
195
192
  // Implementation differs from TeX:
196
193
  // - Hash map for O(1) lookups by position+fitness
@@ -257,15 +254,15 @@ const DEFAULT_RIGHT_HYPHEN_MIN = 4;
257
254
  const INF_BAD = 10000;
258
255
  // Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
259
256
  const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
260
- // Another non TeX default: Single-word line detection thresholds
261
- const SINGLE_WORD_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
262
- const SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
257
+ // Another non TeX default: Short line detection thresholds
258
+ const SHORT_LINE_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
259
+ const SHORT_LINE_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
263
260
  class LineBreak {
264
- // Calculate badness according to TeX's formula (tex.web §108, line 2337)
261
+ // Calculate badness according to TeX's formula (tex.web line 2337)
265
262
  // Given t (desired adjustment) and s (available stretch/shrink)
266
263
  // Returns approximation to 100(t/s)³, representing how "bad" a line is
267
264
  // Constants are derived from TeX's fixed-point arithmetic:
268
- // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
265
+ // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
269
266
  static badness(t, s) {
270
267
  if (t === 0)
271
268
  return 0;
@@ -324,8 +321,8 @@ class LineBreak {
324
321
  const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
325
322
  return filteredPoints;
326
323
  }
327
- // Converts text into items (boxes, glues, penalties) for line breaking.
328
- // The measureText function should return widths that include any letter spacing.
324
+ // Converts text into items (boxes, glues, penalties) for line breaking
325
+ // The measureText function should return widths that include any letter spacing
329
326
  static itemizeText(text, measureText, // function to measure text width
330
327
  hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
331
328
  const items = [];
@@ -349,16 +346,214 @@ class LineBreak {
349
346
  });
350
347
  return items;
351
348
  }
349
+ // Chinese, Japanese, and Korean character ranges
350
+ static isCJK(char) {
351
+ const code = char.codePointAt(0);
352
+ if (code === undefined)
353
+ return false;
354
+ return (
355
+ // CJK Unified Ideographs
356
+ (code >= 0x4e00 && code <= 0x9fff) ||
357
+ // CJK Extension A
358
+ (code >= 0x3400 && code <= 0x4dbf) ||
359
+ // CJK Extension B
360
+ (code >= 0x20000 && code <= 0x2a6df) ||
361
+ // CJK Extension C
362
+ (code >= 0x2a700 && code <= 0x2b73f) ||
363
+ // CJK Extension D
364
+ (code >= 0x2b740 && code <= 0x2b81f) ||
365
+ // CJK Extension E
366
+ (code >= 0x2b820 && code <= 0x2ceaf) ||
367
+ // CJK Compatibility Ideographs
368
+ (code >= 0xf900 && code <= 0xfaff) ||
369
+ // Hiragana
370
+ (code >= 0x3040 && code <= 0x309f) ||
371
+ // Katakana
372
+ (code >= 0x30a0 && code <= 0x30ff) ||
373
+ // Hangul Syllables
374
+ (code >= 0xac00 && code <= 0xd7af) ||
375
+ // Hangul Jamo
376
+ (code >= 0x1100 && code <= 0x11ff) ||
377
+ // Hangul Compatibility Jamo
378
+ (code >= 0x3130 && code <= 0x318f) ||
379
+ // Hangul Jamo Extended-A
380
+ (code >= 0xa960 && code <= 0xa97f) ||
381
+ // Hangul Jamo Extended-B
382
+ (code >= 0xd7b0 && code <= 0xd7ff) ||
383
+ // Halfwidth and Fullwidth Forms (Korean)
384
+ (code >= 0xffa0 && code <= 0xffdc));
385
+ }
386
+ // Closing punctuation where line breaks are prohibited (UAX #14 LB30, JIS X 4051)
387
+ static isCJClosingPunctuation(char) {
388
+ const code = char.charCodeAt(0);
389
+ return (code === 0x3001 || // 、
390
+ code === 0x3002 || // 。
391
+ code === 0xff0c || // ,
392
+ code === 0xff0e || // .
393
+ code === 0xff1a || // :
394
+ code === 0xff1b || // ;
395
+ code === 0xff01 || // !
396
+ code === 0xff1f || // ?
397
+ code === 0xff09 || // )
398
+ code === 0x3011 || // 】
399
+ code === 0xff5d || // }
400
+ code === 0x300d || // 」
401
+ code === 0x300f || // 』
402
+ code === 0x3009 || // 〉
403
+ code === 0x300b || // 》
404
+ code === 0x3015 || // 〕
405
+ code === 0x3017 || // 〗
406
+ code === 0x3019 || // 〙
407
+ code === 0x301b || // 〛
408
+ code === 0x30fc || // ー
409
+ code === 0x2014 || // —
410
+ code === 0x2026 || // …
411
+ code === 0x2025 // ‥
412
+ );
413
+ }
414
+ // Opening punctuation where line breaks are prohibited (UAX #14 LB30a, JIS X 4051)
415
+ static isCJOpeningPunctuation(char) {
416
+ const code = char.charCodeAt(0);
417
+ return (code === 0xff08 || // (
418
+ code === 0x3010 || // 【
419
+ code === 0xff5b || // {
420
+ code === 0x300c || // 「
421
+ code === 0x300e || // 『
422
+ code === 0x3008 || // 〈
423
+ code === 0x300a || // 《
424
+ code === 0x3014 || // 〔
425
+ code === 0x3016 || // 〖
426
+ code === 0x3018 || // 〘
427
+ code === 0x301a // 〚
428
+ );
429
+ }
430
+ static isCJPunctuation(char) {
431
+ return (this.isCJClosingPunctuation(char) || this.isCJOpeningPunctuation(char));
432
+ }
433
+ // CJK (Chinese/Japanese/Korean) character-level itemization with inter-character glue
434
+ static itemizeCJKText(text, measureText, context, startOffset = 0, glueParams) {
435
+ const items = [];
436
+ const chars = Array.from(text);
437
+ let textPosition = startOffset;
438
+ // Inter-character glue parameters
439
+ let glueWidth;
440
+ let glueStretch;
441
+ let glueShrink;
442
+ if (glueParams) {
443
+ glueWidth = glueParams.width;
444
+ glueStretch = glueParams.stretch;
445
+ glueShrink = glueParams.shrink;
446
+ }
447
+ else {
448
+ const baseCharWidth = measureText('字');
449
+ glueWidth = 0;
450
+ glueStretch = baseCharWidth * 0.04;
451
+ glueShrink = baseCharWidth * 0.04;
452
+ }
453
+ for (let i = 0; i < chars.length; i++) {
454
+ const char = chars[i];
455
+ const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
456
+ if (/\s/.test(char)) {
457
+ const width = measureText(char);
458
+ items.push({
459
+ type: ItemType.GLUE,
460
+ width,
461
+ stretch: width * SPACE_STRETCH_RATIO,
462
+ shrink: width * SPACE_SHRINK_RATIO,
463
+ text: char,
464
+ originIndex: textPosition
465
+ });
466
+ textPosition += char.length;
467
+ continue;
468
+ }
469
+ items.push({
470
+ type: ItemType.BOX,
471
+ width: measureText(char),
472
+ text: char,
473
+ originIndex: textPosition
474
+ });
475
+ textPosition += char.length;
476
+ // Glue after a box creates a break opportunity
477
+ // Must not add glue where breaks are prohibited by Chinese/Japanese line breaking rules
478
+ if (nextChar && !/\s/.test(nextChar)) {
479
+ let canBreak = true;
480
+ if (this.isCJClosingPunctuation(nextChar)) {
481
+ canBreak = false;
482
+ }
483
+ if (this.isCJOpeningPunctuation(char)) {
484
+ canBreak = false;
485
+ }
486
+ // Avoid stretch between consecutive punctuation (?" or 。」)
487
+ const isPunctPair = this.isCJPunctuation(char) && this.isCJPunctuation(nextChar);
488
+ if (canBreak && !isPunctPair) {
489
+ items.push({
490
+ type: ItemType.GLUE,
491
+ width: glueWidth,
492
+ stretch: glueStretch,
493
+ shrink: glueShrink,
494
+ text: '',
495
+ originIndex: textPosition
496
+ });
497
+ }
498
+ }
499
+ }
500
+ return items;
501
+ }
352
502
  static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
353
503
  const items = [];
354
- // First split into words and spaces
504
+ const chars = Array.from(text);
505
+ // Calculate CJK glue parameters once for consistency across all segments
506
+ const baseCharWidth = measureText('字');
507
+ const cjkGlueParams = {
508
+ width: 0,
509
+ stretch: baseCharWidth * 0.04,
510
+ shrink: baseCharWidth * 0.04
511
+ };
512
+ let buffer = '';
513
+ let bufferStart = 0;
514
+ let bufferScript = null;
515
+ let textPosition = 0;
516
+ const flushBuffer = () => {
517
+ if (buffer.length === 0)
518
+ return;
519
+ if (bufferScript === 'cjk') {
520
+ const cjkItems = this.itemizeCJKText(buffer, measureText, context, bufferStart, cjkGlueParams);
521
+ items.push(...cjkItems);
522
+ }
523
+ else {
524
+ const wordItems = this.itemizeWordBased(buffer, bufferStart, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context);
525
+ items.push(...wordItems);
526
+ }
527
+ buffer = '';
528
+ bufferScript = null;
529
+ };
530
+ for (let i = 0; i < chars.length; i++) {
531
+ const char = chars[i];
532
+ const isCJKChar = this.isCJK(char);
533
+ const currentScript = isCJKChar ? 'cjk' : 'word';
534
+ if (bufferScript !== null && bufferScript !== currentScript) {
535
+ flushBuffer();
536
+ bufferStart = textPosition;
537
+ }
538
+ if (bufferScript === null) {
539
+ bufferScript = currentScript;
540
+ bufferStart = textPosition;
541
+ }
542
+ buffer += char;
543
+ textPosition += char.length;
544
+ }
545
+ flushBuffer();
546
+ return items;
547
+ }
548
+ // Word-based itemization for alphabetic scripts (Latin, Cyrillic, Greek, etc.)
549
+ static itemizeWordBased(text, startOffset, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
550
+ const items = [];
355
551
  const tokens = text.match(/\S+|\s+/g) || [];
356
552
  let currentIndex = 0;
357
553
  for (let i = 0; i < tokens.length; i++) {
358
554
  const token = tokens[i];
359
- const tokenStartIndex = currentIndex;
555
+ const tokenStartIndex = startOffset + currentIndex;
360
556
  if (/\s+/.test(token)) {
361
- // Handle spaces
362
557
  const width = measureText(token);
363
558
  items.push({
364
559
  type: ItemType.GLUE,
@@ -371,22 +566,19 @@ class LineBreak {
371
566
  currentIndex += token.length;
372
567
  }
373
568
  else {
374
- // Process word, splitting on explicit hyphens
375
- // Split on hyphens while keeping them in the result
376
569
  const segments = token.split(/(-)/);
377
570
  let segmentIndex = tokenStartIndex;
378
571
  for (let j = 0; j < segments.length; j++) {
379
572
  const segment = segments[j];
380
573
  if (!segment)
381
- continue; // Skip empty segments
574
+ continue;
382
575
  if (segment === '-') {
383
- // Handle explicit hyphen as discretionary break
384
576
  items.push({
385
577
  type: ItemType.DISCRETIONARY,
386
- width: measureText('-'), // Width of hyphen in normal flow
387
- preBreak: '-', // Hyphen appears before break
388
- postBreak: '', // Nothing after break
389
- noBreak: '-', // Hyphen if no break
578
+ width: measureText('-'),
579
+ preBreak: '-',
580
+ postBreak: '',
581
+ noBreak: '-',
390
582
  preBreakWidth: measureText('-'),
391
583
  penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
392
584
  flagged: true,
@@ -396,8 +588,6 @@ class LineBreak {
396
588
  segmentIndex += 1;
397
589
  }
398
590
  else {
399
- // Process non-hyphen segment
400
- // First handle soft hyphens (U+00AD)
401
591
  if (segment.includes('\u00AD')) {
402
592
  const partsWithMarkers = segment.split('\u00AD');
403
593
  let runningIndex = 0;
@@ -415,23 +605,23 @@ class LineBreak {
415
605
  if (k < partsWithMarkers.length - 1) {
416
606
  items.push({
417
607
  type: ItemType.DISCRETIONARY,
418
- width: 0, // No width in normal flow
419
- preBreak: '-', // Hyphen appears before break
420
- postBreak: '', // Nothing after break
421
- noBreak: '', // Nothing if no break (word continues)
608
+ width: 0,
609
+ preBreak: '-',
610
+ postBreak: '',
611
+ noBreak: '',
422
612
  preBreakWidth: measureText('-'),
423
613
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
424
614
  flagged: true,
425
615
  text: '',
426
616
  originIndex: segmentIndex + runningIndex
427
617
  });
428
- runningIndex += 1; // Account for the soft hyphen character
618
+ runningIndex += 1;
429
619
  }
430
620
  }
431
621
  }
432
622
  else if (hyphenate &&
433
- segment.length >= lefthyphenmin + righthyphenmin) {
434
- // Apply hyphenation patterns only to segments between explicit hyphens
623
+ segment.length >= lefthyphenmin + righthyphenmin &&
624
+ /^\p{L}+$/u.test(segment)) {
435
625
  const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
436
626
  if (hyphenPoints.length > 0) {
437
627
  let lastPoint = 0;
@@ -445,10 +635,10 @@ class LineBreak {
445
635
  });
446
636
  items.push({
447
637
  type: ItemType.DISCRETIONARY,
448
- width: 0, // No width in normal flow
449
- preBreak: '-', // Hyphen appears before break
450
- postBreak: '', // Nothing after break
451
- noBreak: '', // Nothing if no break (word continues)
638
+ width: 0,
639
+ preBreak: '-',
640
+ postBreak: '',
641
+ noBreak: '',
452
642
  preBreakWidth: measureText('-'),
453
643
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
454
644
  flagged: true,
@@ -466,7 +656,6 @@ class LineBreak {
466
656
  });
467
657
  }
468
658
  else {
469
- // No hyphenation points, add as single box
470
659
  items.push({
471
660
  type: ItemType.BOX,
472
661
  width: measureText(segment),
@@ -476,7 +665,6 @@ class LineBreak {
476
665
  }
477
666
  }
478
667
  else {
479
- // No hyphenation, add as single box
480
668
  items.push({
481
669
  type: ItemType.BOX,
482
670
  width: measureText(segment),
@@ -492,27 +680,22 @@ class LineBreak {
492
680
  }
493
681
  return items;
494
682
  }
495
- // Detect if breakpoints create problematic single-word lines
496
- static hasSingleWordLines(items, breakpoints, lineWidth) {
683
+ // Detect if breakpoints create problematic short lines
684
+ static hasShortLines(items, breakpoints, lineWidth, threshold) {
497
685
  // Check each line segment (except the last, which can naturally be short)
498
686
  let lineStart = 0;
499
687
  for (let i = 0; i < breakpoints.length - 1; i++) {
500
688
  const breakpoint = breakpoints[i];
501
- // Count glue items (spaces) between line start and breakpoint
502
- let glueCount = 0;
503
689
  let totalWidth = 0;
504
690
  for (let j = lineStart; j < breakpoint; j++) {
505
- if (items[j].type === ItemType.GLUE) {
506
- glueCount++;
507
- }
508
691
  if (items[j].type !== ItemType.PENALTY) {
509
692
  totalWidth += items[j].width;
510
693
  }
511
694
  }
512
- // Single word line = no glue items
513
- if (glueCount === 0 && totalWidth > 0) {
695
+ // Check if line is narrow relative to target width
696
+ if (totalWidth > 0) {
514
697
  const widthRatio = totalWidth / lineWidth;
515
- if (widthRatio < SINGLE_WORD_WIDTH_THRESHOLD) {
698
+ if (widthRatio < threshold) {
516
699
  return true;
517
700
  }
518
701
  }
@@ -527,7 +710,7 @@ class LineBreak {
527
710
  align: options.align || 'left',
528
711
  hyphenate: options.hyphenate || false
529
712
  });
530
- const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0, disableSingleWordDetection = false } = options;
713
+ const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0, disableShortLineDetection = false, shortLineThreshold = SHORT_LINE_WIDTH_THRESHOLD } = options;
531
714
  // Handle multiple paragraphs by processing each independently
532
715
  if (respectExistingBreaks && text.includes('\n')) {
533
716
  const paragraphs = text.split('\n');
@@ -606,56 +789,50 @@ class LineBreak {
606
789
  }
607
790
  ];
608
791
  }
609
- // Itemize text once, including all potential hyphenation points
610
- const allItems = LineBreak.itemizeText(text, measureText, useHyphenation, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
792
+ // Itemize without hyphenation first (TeX approach: only compute if needed)
793
+ const allItems = LineBreak.itemizeText(text, measureText, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
611
794
  if (allItems.length === 0) {
612
795
  return [];
613
796
  }
614
- // Iteratively increase emergency stretch to eliminate short single-word lines.
615
- // Post-processing approach preserves TeX algorithm integrity while itemization
616
- // (the expensive part) happens once
617
797
  const MAX_ITERATIONS = 5;
618
798
  let iteration = 0;
619
799
  let currentEmergencyStretch = initialEmergencyStretch;
620
800
  let resultLines = null;
621
- const singleWordDetectionEnabled = !disableSingleWordDetection;
801
+ const shortLineDetectionEnabled = !disableShortLineDetection;
622
802
  while (iteration < MAX_ITERATIONS) {
623
- // Three-pass approach for optimal line breaking:
624
- // First pass: Try without hyphenation using pretolerance (fast)
625
- // Second pass: Enable hyphenation if available, use tolerance (quality)
626
- // Final pass: Emergency stretch for difficult paragraphs (last resort)
803
+ // Three-pass approach matching TeX:
804
+ // First pass: without hyphenation (pretolerance)
805
+ // Second pass: with hyphenation, only if first pass fails (tolerance)
806
+ // Emergency pass: additional stretch as last resort
627
807
  // First pass: no hyphenation
628
- let currentItems = useHyphenation
629
- ? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
630
- item.penalty !==
631
- (context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
632
- : allItems;
808
+ let currentItems = allItems;
633
809
  let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
634
- // Second pass: with hyphenation
810
+ // Second pass: with hyphenation if first pass failed
635
811
  if (breaks.length === 0 && useHyphenation) {
636
- currentItems = allItems;
812
+ const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
813
+ currentItems = itemsWithHyphenation;
637
814
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
638
815
  }
639
- // Final pass: emergency stretch
816
+ // Emergency pass: add emergency stretch to background stretchability
640
817
  if (breaks.length === 0) {
641
- currentItems = allItems;
642
- 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);
643
822
  }
644
- // Force with infinite tolerance if still no breaks found
823
+ // Last resort: allow higher badness (but not infinite)
645
824
  if (breaks.length === 0) {
646
- breaks = LineBreak.findBreakpoints(currentItems, width, Infinity, looseness, true, currentEmergencyStretch, context);
825
+ breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD, looseness, true, currentEmergencyStretch, context);
647
826
  }
648
827
  // Create lines from breaks
649
828
  if (breaks.length > 0) {
650
829
  const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
651
830
  resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
652
- // Check for single-word lines if detection is enabled
653
- if (singleWordDetectionEnabled &&
831
+ // Check for short lines if detection is enabled
832
+ if (shortLineDetectionEnabled &&
654
833
  breaks.length > 1 &&
655
- LineBreak.hasSingleWordLines(currentItems, breaks, width)) {
656
- // Increase emergency stretch and try again
657
- currentEmergencyStretch +=
658
- width * SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT;
834
+ LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
835
+ // Retry with more emergency stretch to push words to next line
659
836
  iteration++;
660
837
  continue;
661
838
  }
@@ -687,11 +864,12 @@ class LineBreak {
687
864
  threshold = Infinity, // maximum badness allowed for a break
688
865
  looseness = 0, // desired line count adjustment
689
866
  isFinalPass = false, // whether this is the final pass
690
- emergencyStretch = 0, // emergency stretch added to background stretchability
867
+ backgroundStretch = 0, // additional stretchability for all glue (emergency stretch)
691
868
  context) {
692
869
  // Pre-compute cumulative widths for fast range queries
693
870
  const cumulativeWidths = LineBreak.computeCumulativeWidths(items);
694
871
  const activeNodes = new ActiveNodeList();
872
+ const minimumDemerits = { value: Infinity };
695
873
  activeNodes.insert({
696
874
  position: 0,
697
875
  line: 0,
@@ -705,16 +883,16 @@ class LineBreak {
705
883
  const item = items[i];
706
884
  if (item.type === ItemType.PENALTY &&
707
885
  item.penalty < Infinity) {
708
- LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, emergencyStretch, cumulativeWidths, context);
886
+ LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
709
887
  }
710
888
  if (item.type === ItemType.DISCRETIONARY &&
711
889
  item.penalty < Infinity) {
712
- LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, emergencyStretch, cumulativeWidths, context);
890
+ LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
713
891
  }
714
892
  if (item.type === ItemType.GLUE &&
715
893
  i > 0 &&
716
894
  items[i - 1].type === ItemType.BOX) {
717
- LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, emergencyStretch, cumulativeWidths, context);
895
+ LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
718
896
  }
719
897
  LineBreak.deactivateNodes(activeNodes, i, lineWidth, cumulativeWidths.minWidths);
720
898
  }
@@ -781,7 +959,7 @@ class LineBreak {
781
959
  }
782
960
  return breakpoints;
783
961
  }
784
- 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 }) {
785
963
  const penalty = items[breakpoint].type === ItemType.PENALTY
786
964
  ? items[breakpoint].penalty
787
965
  : 0;
@@ -793,11 +971,11 @@ class LineBreak {
793
971
  continue;
794
972
  const adjustmentData = LineBreak.computeAdjustmentRatio(items, node.position, breakpoint, node.line, lineWidth, cumulativeWidths, context);
795
973
  const { ratio: r, adjustment, stretch, shrink, totalWidth } = adjustmentData;
796
- // Calculate badness according to TeX formula
974
+ // Calculate badness
797
975
  let badness;
798
976
  if (adjustment > 0) {
799
- // Add emergency stretch to the background stretchability
800
- const effectiveStretch = stretch + emergencyStretch;
977
+ // backgroundStretch includes emergency stretch if in emergency pass
978
+ const effectiveStretch = stretch + backgroundStretch;
801
979
  if (effectiveStretch <= 0) {
802
980
  // Overfull box - badness is infinite + 1
803
981
  badness = INF_BAD + 1;
@@ -822,26 +1000,32 @@ class LineBreak {
822
1000
  else {
823
1001
  badness = 0;
824
1002
  }
825
- if (!isForcedBreak && r < -1) {
826
- // Too tight, skip unless forced
827
- 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
828
1011
  }
829
1012
  const fitnessClass = LineBreak.computeFitnessClass(badness, adjustment > 0);
830
- if (!isForcedBreak && badness > threshold) {
1013
+ if (!isForcedBreak && !isLastResort && badness > threshold) {
831
1014
  continue;
832
1015
  }
833
- // Initialize demerits based on TeX formula with saturation check
1016
+ // Initialize demerits with saturation check
834
1017
  let flaggedDemerits = 0;
835
1018
  let fitnessDemerits = 0;
836
1019
  const configuredLinePenalty = context?.linePenalty ?? 0;
837
1020
  let d = configuredLinePenalty + badness;
838
1021
  let demerits = Math.abs(d) >= 10000 ? 100000000 : d * d;
1022
+ const artificialDemerits = isLastResort;
839
1023
  const breakpointPenalty = items[breakpoint].type === ItemType.PENALTY
840
1024
  ? items[breakpoint].penalty
841
1025
  : items[breakpoint].type === ItemType.DISCRETIONARY
842
1026
  ? items[breakpoint].penalty
843
1027
  : 0;
844
- // TeX penalty handling: pi != 0 check, then positive/negative logic
1028
+ // Penalty contribution to demerits
845
1029
  if (breakpointPenalty !== 0) {
846
1030
  if (breakpointPenalty > 0) {
847
1031
  demerits += breakpointPenalty * breakpointPenalty;
@@ -867,10 +1051,13 @@ class LineBreak {
867
1051
  fitnessDemerits = context?.adjDemerits ?? 0;
868
1052
  demerits += fitnessDemerits;
869
1053
  }
870
- if (isForcedBreak) {
1054
+ if (isForcedBreak || artificialDemerits) {
871
1055
  demerits = 0;
872
1056
  }
873
1057
  const totalDemerits = node.totalDemerits + demerits;
1058
+ if (totalDemerits < minimumDemerits.value) {
1059
+ minimumDemerits.value = totalDemerits;
1060
+ }
874
1061
  let existingNode = activeNodes.findExisting(breakpoint, fitnessClass);
875
1062
  if (existingNode) {
876
1063
  if (totalDemerits < existingNode.totalDemerits) {
@@ -989,6 +1176,8 @@ class LineBreak {
989
1176
  const item = items[i];
990
1177
  widths[i + 1] = widths[i] + item.width;
991
1178
  if (item.type === ItemType.PENALTY) {
1179
+ stretches[i + 1] = stretches[i];
1180
+ shrinks[i + 1] = shrinks[i];
992
1181
  minWidths[i + 1] = minWidths[i];
993
1182
  }
994
1183
  else if (item.type === ItemType.GLUE) {
@@ -1006,7 +1195,6 @@ class LineBreak {
1006
1195
  return { widths, stretches, shrinks, minWidths };
1007
1196
  }
1008
1197
  // Deactivate nodes that can't lead to good line breaks
1009
- // TeX recalculates minWidth each time, we use cumulative arrays for lookup
1010
1198
  static deactivateNodes(activeNodeList, currentPosition, lineWidth, minWidths) {
1011
1199
  const activeNodes = activeNodeList.getAllActive();
1012
1200
  for (let i = activeNodes.length - 1; i >= 0; i--) {
@@ -1211,8 +1399,8 @@ function convertFontFeaturesToString(features) {
1211
1399
 
1212
1400
  class TextMeasurer {
1213
1401
  // Measures text width including letter spacing
1214
- // Letter spacing is added uniformly after each glyph during measurement,
1215
- // 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)
1216
1404
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1217
1405
  const buffer = loadedFont.hb.createBuffer();
1218
1406
  buffer.addText(text);
@@ -1239,7 +1427,7 @@ class TextLayout {
1239
1427
  this.loadedFont = loadedFont;
1240
1428
  }
1241
1429
  computeLines(options) {
1242
- const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection, letterSpacing } = options;
1430
+ const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableShortLineDetection, shortLineThreshold, letterSpacing } = options;
1243
1431
  let lines;
1244
1432
  if (width) {
1245
1433
  // Line breaking uses a measureText function that already includes letterSpacing,
@@ -1265,7 +1453,8 @@ class TextLayout {
1265
1453
  exhyphenpenalty,
1266
1454
  doublehyphendemerits,
1267
1455
  looseness,
1268
- disableSingleWordDetection,
1456
+ disableShortLineDetection,
1457
+ shortLineThreshold,
1269
1458
  unitsPerEm: this.loadedFont.upem,
1270
1459
  measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1271
1460
  )
@@ -3533,10 +3722,11 @@ class LRUCache {
3533
3722
  }
3534
3723
  }
3535
3724
 
3725
+ const CONTOUR_CACHE_MAX_ENTRIES = 1000;
3726
+ const WORD_CACHE_MAX_ENTRIES = 1000;
3536
3727
  class GlyphGeometryBuilder {
3537
3728
  constructor(cache, loadedFont) {
3538
3729
  this.fontId = 'default';
3539
- this.wordCache = new Map();
3540
3730
  this.cache = cache;
3541
3731
  this.loadedFont = loadedFont;
3542
3732
  this.tessellator = new Tessellator();
@@ -3546,7 +3736,7 @@ class GlyphGeometryBuilder {
3546
3736
  this.drawCallbacks = new DrawCallbackHandler();
3547
3737
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3548
3738
  this.contourCache = new LRUCache({
3549
- maxEntries: 1000,
3739
+ maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
3550
3740
  calculateSize: (contours) => {
3551
3741
  let size = 0;
3552
3742
  for (const path of contours.paths) {
@@ -3555,6 +3745,15 @@ class GlyphGeometryBuilder {
3555
3745
  return size + 64; // bounds overhead
3556
3746
  }
3557
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
+ });
3558
3757
  }
3559
3758
  getOptimizationStats() {
3560
3759
  return this.collector.getOptimizationStats();
@@ -3844,9 +4043,10 @@ class TextShaper {
3844
4043
  let currentClusterText = '';
3845
4044
  let clusterStartPosition = new Vec3();
3846
4045
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
3847
- // 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
3848
4047
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3849
4048
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4049
+ const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
3850
4050
  for (let i = 0; i < glyphInfos.length; i++) {
3851
4051
  const glyph = glyphInfos[i];
3852
4052
  const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
@@ -3892,6 +4092,29 @@ class TextShaper {
3892
4092
  if (isWhitespace) {
3893
4093
  cursor.x += spaceAdjustment;
3894
4094
  }
4095
+ // CJK glue adjustment (must match exactly where LineBreak adds glue)
4096
+ if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
4097
+ const currentChar = lineInfo.text[glyph.cl];
4098
+ const nextGlyph = glyphInfos[i + 1];
4099
+ const nextChar = lineInfo.text[nextGlyph.cl];
4100
+ const isCJKChar = LineBreak.isCJK(currentChar);
4101
+ const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
4102
+ if (isCJKChar && nextIsCJKChar) {
4103
+ let shouldApply = true;
4104
+ if (LineBreak.isCJClosingPunctuation(nextChar)) {
4105
+ shouldApply = false;
4106
+ }
4107
+ if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4108
+ shouldApply = false;
4109
+ }
4110
+ if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
4111
+ shouldApply = false;
4112
+ }
4113
+ if (shouldApply) {
4114
+ cursor.x += cjkAdjustment;
4115
+ }
4116
+ }
4117
+ }
3895
4118
  }
3896
4119
  if (currentClusterGlyphs.length > 0) {
3897
4120
  clusters.push({
@@ -3924,6 +4147,23 @@ class TextShaper {
3924
4147
  }
3925
4148
  return spaceAdjustment;
3926
4149
  }
4150
+ calculateCJKAdjustment(lineInfo, align) {
4151
+ if (lineInfo.adjustmentRatio === undefined ||
4152
+ align !== 'justify' ||
4153
+ lineInfo.isLastLine) {
4154
+ return 0;
4155
+ }
4156
+ const baseCharWidth = this.loadedFont.upem;
4157
+ const glueStretch = baseCharWidth * 0.04;
4158
+ const glueShrink = baseCharWidth * 0.04;
4159
+ if (lineInfo.adjustmentRatio > 0) {
4160
+ return lineInfo.adjustmentRatio * glueStretch;
4161
+ }
4162
+ else if (lineInfo.adjustmentRatio < 0) {
4163
+ return lineInfo.adjustmentRatio * glueShrink;
4164
+ }
4165
+ return 0;
4166
+ }
3927
4167
  clearCache() {
3928
4168
  this.geometryBuilder.clearCache();
3929
4169
  }
@@ -4841,6 +5081,54 @@ class Text {
4841
5081
  if (!Text.hbInitPromise) {
4842
5082
  Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
4843
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) {
4844
5132
  const baseFontKey = typeof options.font === 'string'
4845
5133
  ? options.font
4846
5134
  : `buffer-${Text.generateFontContentHash(options.font)}`;
@@ -4855,17 +5143,7 @@ class Text {
4855
5143
  if (!loadedFont) {
4856
5144
  loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
4857
5145
  }
4858
- const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
4859
- text.setLoadedFont(loadedFont);
4860
- const { font, maxCacheSizeMB, ...geometryOptions } = options;
4861
- const result = await text.createGeometry(geometryOptions);
4862
- return {
4863
- ...result,
4864
- getLoadedFont: () => text.getLoadedFont(),
4865
- getCacheStatistics: () => text.getCacheStatistics(),
4866
- clearCache: () => text.clearCache(),
4867
- measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
4868
- };
5146
+ return loadedFont;
4869
5147
  }
4870
5148
  static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
4871
5149
  const tempText = new Text();
@@ -5041,7 +5319,7 @@ class Text {
5041
5319
  throw new Error('Font not loaded. Use Text.create() with a font option');
5042
5320
  }
5043
5321
  const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
5044
- const { width, direction = 'ltr', align = direction === 'rtl' ? 'right' : 'left', respectExistingBreaks = true, hyphenate = true, language = 'en-us', tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection } = layout;
5322
+ const { width, direction = 'ltr', align = direction === 'rtl' ? 'right' : 'left', respectExistingBreaks = true, hyphenate = true, language = 'en-us', tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableShortLineDetection, shortLineThreshold } = layout;
5045
5323
  let widthInFontUnits;
5046
5324
  if (width !== undefined) {
5047
5325
  widthInFontUnits = width * (this.loadedFont.upem / size);
@@ -5071,7 +5349,8 @@ class Text {
5071
5349
  exhyphenpenalty,
5072
5350
  doublehyphendemerits,
5073
5351
  looseness,
5074
- disableSingleWordDetection,
5352
+ disableShortLineDetection,
5353
+ shortLineThreshold,
5075
5354
  letterSpacing
5076
5355
  });
5077
5356
  const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
@@ -5322,6 +5601,11 @@ class Text {
5322
5601
  glyphLineIndex: glyphLineIndices
5323
5602
  };
5324
5603
  }
5604
+ resetHelpers() {
5605
+ this.geometryBuilder = undefined;
5606
+ this.textShaper = undefined;
5607
+ this.textLayout = undefined;
5608
+ }
5325
5609
  destroy() {
5326
5610
  if (!this.loadedFont) {
5327
5611
  return;