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.cjs 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
@@ -125,9 +125,6 @@ class PerformanceLogger {
125
125
  this.metrics.length = 0;
126
126
  this.activeTimers.clear();
127
127
  }
128
- reset() {
129
- this.clear();
130
- }
131
128
  time(name, fn, metadata) {
132
129
  if (!isLogEnabled)
133
130
  return fn();
@@ -189,11 +186,11 @@ var FitnessClass;
189
186
  FitnessClass[FitnessClass["VERY_LOOSE"] = 3] = "VERY_LOOSE";
190
187
  })(FitnessClass || (FitnessClass = {}));
191
188
  // ActiveNodeList maintains all currently viable breakpoints as we scan through the text.
192
- // Each node represents a potential break with accumulated demerits (total "cost" from start).
189
+ // Each node represents a potential break with accumulated demerits (total "cost" from start)
193
190
  //
194
191
  // Demerits = cumulative penalty score from text start to this break, calculated as:
195
- // (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web §859)
196
- // Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph.
192
+ // (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web line 16634)
193
+ // Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
197
194
  //
198
195
  // Implementation differs from TeX:
199
196
  // - Hash map for O(1) lookups by position+fitness
@@ -260,15 +257,15 @@ const DEFAULT_RIGHT_HYPHEN_MIN = 4;
260
257
  const INF_BAD = 10000;
261
258
  // Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
262
259
  const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
263
- // Another non TeX default: Single-word line detection thresholds
264
- const SINGLE_WORD_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
265
- const SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
260
+ // Another non TeX default: Short line detection thresholds
261
+ const SHORT_LINE_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
262
+ const SHORT_LINE_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
266
263
  class LineBreak {
267
- // Calculate badness according to TeX's formula (tex.web §108, line 2337)
264
+ // Calculate badness according to TeX's formula (tex.web line 2337)
268
265
  // Given t (desired adjustment) and s (available stretch/shrink)
269
266
  // Returns approximation to 100(t/s)³, representing how "bad" a line is
270
267
  // Constants are derived from TeX's fixed-point arithmetic:
271
- // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
268
+ // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
272
269
  static badness(t, s) {
273
270
  if (t === 0)
274
271
  return 0;
@@ -327,8 +324,8 @@ class LineBreak {
327
324
  const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
328
325
  return filteredPoints;
329
326
  }
330
- // Converts text into items (boxes, glues, penalties) for line breaking.
331
- // The measureText function should return widths that include any letter spacing.
327
+ // Converts text into items (boxes, glues, penalties) for line breaking
328
+ // The measureText function should return widths that include any letter spacing
332
329
  static itemizeText(text, measureText, // function to measure text width
333
330
  hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
334
331
  const items = [];
@@ -352,16 +349,214 @@ class LineBreak {
352
349
  });
353
350
  return items;
354
351
  }
352
+ // Chinese, Japanese, and Korean character ranges
353
+ static isCJK(char) {
354
+ const code = char.codePointAt(0);
355
+ if (code === undefined)
356
+ return false;
357
+ return (
358
+ // CJK Unified Ideographs
359
+ (code >= 0x4e00 && code <= 0x9fff) ||
360
+ // CJK Extension A
361
+ (code >= 0x3400 && code <= 0x4dbf) ||
362
+ // CJK Extension B
363
+ (code >= 0x20000 && code <= 0x2a6df) ||
364
+ // CJK Extension C
365
+ (code >= 0x2a700 && code <= 0x2b73f) ||
366
+ // CJK Extension D
367
+ (code >= 0x2b740 && code <= 0x2b81f) ||
368
+ // CJK Extension E
369
+ (code >= 0x2b820 && code <= 0x2ceaf) ||
370
+ // CJK Compatibility Ideographs
371
+ (code >= 0xf900 && code <= 0xfaff) ||
372
+ // Hiragana
373
+ (code >= 0x3040 && code <= 0x309f) ||
374
+ // Katakana
375
+ (code >= 0x30a0 && code <= 0x30ff) ||
376
+ // Hangul Syllables
377
+ (code >= 0xac00 && code <= 0xd7af) ||
378
+ // Hangul Jamo
379
+ (code >= 0x1100 && code <= 0x11ff) ||
380
+ // Hangul Compatibility Jamo
381
+ (code >= 0x3130 && code <= 0x318f) ||
382
+ // Hangul Jamo Extended-A
383
+ (code >= 0xa960 && code <= 0xa97f) ||
384
+ // Hangul Jamo Extended-B
385
+ (code >= 0xd7b0 && code <= 0xd7ff) ||
386
+ // Halfwidth and Fullwidth Forms (Korean)
387
+ (code >= 0xffa0 && code <= 0xffdc));
388
+ }
389
+ // Closing punctuation where line breaks are prohibited (UAX #14 LB30, JIS X 4051)
390
+ static isCJClosingPunctuation(char) {
391
+ const code = char.charCodeAt(0);
392
+ return (code === 0x3001 || // 、
393
+ code === 0x3002 || // 。
394
+ code === 0xff0c || // ,
395
+ code === 0xff0e || // .
396
+ code === 0xff1a || // :
397
+ code === 0xff1b || // ;
398
+ code === 0xff01 || // !
399
+ code === 0xff1f || // ?
400
+ code === 0xff09 || // )
401
+ code === 0x3011 || // 】
402
+ code === 0xff5d || // }
403
+ code === 0x300d || // 」
404
+ code === 0x300f || // 』
405
+ code === 0x3009 || // 〉
406
+ code === 0x300b || // 》
407
+ code === 0x3015 || // 〕
408
+ code === 0x3017 || // 〗
409
+ code === 0x3019 || // 〙
410
+ code === 0x301b || // 〛
411
+ code === 0x30fc || // ー
412
+ code === 0x2014 || // —
413
+ code === 0x2026 || // …
414
+ code === 0x2025 // ‥
415
+ );
416
+ }
417
+ // Opening punctuation where line breaks are prohibited (UAX #14 LB30a, JIS X 4051)
418
+ static isCJOpeningPunctuation(char) {
419
+ const code = char.charCodeAt(0);
420
+ return (code === 0xff08 || // (
421
+ code === 0x3010 || // 【
422
+ code === 0xff5b || // {
423
+ code === 0x300c || // 「
424
+ code === 0x300e || // 『
425
+ code === 0x3008 || // 〈
426
+ code === 0x300a || // 《
427
+ code === 0x3014 || // 〔
428
+ code === 0x3016 || // 〖
429
+ code === 0x3018 || // 〘
430
+ code === 0x301a // 〚
431
+ );
432
+ }
433
+ static isCJPunctuation(char) {
434
+ return (this.isCJClosingPunctuation(char) || this.isCJOpeningPunctuation(char));
435
+ }
436
+ // CJK (Chinese/Japanese/Korean) character-level itemization with inter-character glue
437
+ static itemizeCJKText(text, measureText, context, startOffset = 0, glueParams) {
438
+ const items = [];
439
+ const chars = Array.from(text);
440
+ let textPosition = startOffset;
441
+ // Inter-character glue parameters
442
+ let glueWidth;
443
+ let glueStretch;
444
+ let glueShrink;
445
+ if (glueParams) {
446
+ glueWidth = glueParams.width;
447
+ glueStretch = glueParams.stretch;
448
+ glueShrink = glueParams.shrink;
449
+ }
450
+ else {
451
+ const baseCharWidth = measureText('字');
452
+ glueWidth = 0;
453
+ glueStretch = baseCharWidth * 0.04;
454
+ glueShrink = baseCharWidth * 0.04;
455
+ }
456
+ for (let i = 0; i < chars.length; i++) {
457
+ const char = chars[i];
458
+ const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
459
+ if (/\s/.test(char)) {
460
+ const width = measureText(char);
461
+ items.push({
462
+ type: ItemType.GLUE,
463
+ width,
464
+ stretch: width * SPACE_STRETCH_RATIO,
465
+ shrink: width * SPACE_SHRINK_RATIO,
466
+ text: char,
467
+ originIndex: textPosition
468
+ });
469
+ textPosition += char.length;
470
+ continue;
471
+ }
472
+ items.push({
473
+ type: ItemType.BOX,
474
+ width: measureText(char),
475
+ text: char,
476
+ originIndex: textPosition
477
+ });
478
+ textPosition += char.length;
479
+ // Glue after a box creates a break opportunity
480
+ // Must not add glue where breaks are prohibited by Chinese/Japanese line breaking rules
481
+ if (nextChar && !/\s/.test(nextChar)) {
482
+ let canBreak = true;
483
+ if (this.isCJClosingPunctuation(nextChar)) {
484
+ canBreak = false;
485
+ }
486
+ if (this.isCJOpeningPunctuation(char)) {
487
+ canBreak = false;
488
+ }
489
+ // Avoid stretch between consecutive punctuation (?" or 。」)
490
+ const isPunctPair = this.isCJPunctuation(char) && this.isCJPunctuation(nextChar);
491
+ if (canBreak && !isPunctPair) {
492
+ items.push({
493
+ type: ItemType.GLUE,
494
+ width: glueWidth,
495
+ stretch: glueStretch,
496
+ shrink: glueShrink,
497
+ text: '',
498
+ originIndex: textPosition
499
+ });
500
+ }
501
+ }
502
+ }
503
+ return items;
504
+ }
355
505
  static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
356
506
  const items = [];
357
- // First split into words and spaces
507
+ const chars = Array.from(text);
508
+ // Calculate CJK glue parameters once for consistency across all segments
509
+ const baseCharWidth = measureText('字');
510
+ const cjkGlueParams = {
511
+ width: 0,
512
+ stretch: baseCharWidth * 0.04,
513
+ shrink: baseCharWidth * 0.04
514
+ };
515
+ let buffer = '';
516
+ let bufferStart = 0;
517
+ let bufferScript = null;
518
+ let textPosition = 0;
519
+ const flushBuffer = () => {
520
+ if (buffer.length === 0)
521
+ return;
522
+ if (bufferScript === 'cjk') {
523
+ const cjkItems = this.itemizeCJKText(buffer, measureText, context, bufferStart, cjkGlueParams);
524
+ items.push(...cjkItems);
525
+ }
526
+ else {
527
+ const wordItems = this.itemizeWordBased(buffer, bufferStart, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context);
528
+ items.push(...wordItems);
529
+ }
530
+ buffer = '';
531
+ bufferScript = null;
532
+ };
533
+ for (let i = 0; i < chars.length; i++) {
534
+ const char = chars[i];
535
+ const isCJKChar = this.isCJK(char);
536
+ const currentScript = isCJKChar ? 'cjk' : 'word';
537
+ if (bufferScript !== null && bufferScript !== currentScript) {
538
+ flushBuffer();
539
+ bufferStart = textPosition;
540
+ }
541
+ if (bufferScript === null) {
542
+ bufferScript = currentScript;
543
+ bufferStart = textPosition;
544
+ }
545
+ buffer += char;
546
+ textPosition += char.length;
547
+ }
548
+ flushBuffer();
549
+ return items;
550
+ }
551
+ // Word-based itemization for alphabetic scripts (Latin, Cyrillic, Greek, etc.)
552
+ static itemizeWordBased(text, startOffset, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
553
+ const items = [];
358
554
  const tokens = text.match(/\S+|\s+/g) || [];
359
555
  let currentIndex = 0;
360
556
  for (let i = 0; i < tokens.length; i++) {
361
557
  const token = tokens[i];
362
- const tokenStartIndex = currentIndex;
558
+ const tokenStartIndex = startOffset + currentIndex;
363
559
  if (/\s+/.test(token)) {
364
- // Handle spaces
365
560
  const width = measureText(token);
366
561
  items.push({
367
562
  type: ItemType.GLUE,
@@ -374,22 +569,19 @@ class LineBreak {
374
569
  currentIndex += token.length;
375
570
  }
376
571
  else {
377
- // Process word, splitting on explicit hyphens
378
- // Split on hyphens while keeping them in the result
379
572
  const segments = token.split(/(-)/);
380
573
  let segmentIndex = tokenStartIndex;
381
574
  for (let j = 0; j < segments.length; j++) {
382
575
  const segment = segments[j];
383
576
  if (!segment)
384
- continue; // Skip empty segments
577
+ continue;
385
578
  if (segment === '-') {
386
- // Handle explicit hyphen as discretionary break
387
579
  items.push({
388
580
  type: ItemType.DISCRETIONARY,
389
- width: measureText('-'), // Width of hyphen in normal flow
390
- preBreak: '-', // Hyphen appears before break
391
- postBreak: '', // Nothing after break
392
- noBreak: '-', // Hyphen if no break
581
+ width: measureText('-'),
582
+ preBreak: '-',
583
+ postBreak: '',
584
+ noBreak: '-',
393
585
  preBreakWidth: measureText('-'),
394
586
  penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
395
587
  flagged: true,
@@ -399,8 +591,6 @@ class LineBreak {
399
591
  segmentIndex += 1;
400
592
  }
401
593
  else {
402
- // Process non-hyphen segment
403
- // First handle soft hyphens (U+00AD)
404
594
  if (segment.includes('\u00AD')) {
405
595
  const partsWithMarkers = segment.split('\u00AD');
406
596
  let runningIndex = 0;
@@ -418,23 +608,23 @@ class LineBreak {
418
608
  if (k < partsWithMarkers.length - 1) {
419
609
  items.push({
420
610
  type: ItemType.DISCRETIONARY,
421
- width: 0, // No width in normal flow
422
- preBreak: '-', // Hyphen appears before break
423
- postBreak: '', // Nothing after break
424
- noBreak: '', // Nothing if no break (word continues)
611
+ width: 0,
612
+ preBreak: '-',
613
+ postBreak: '',
614
+ noBreak: '',
425
615
  preBreakWidth: measureText('-'),
426
616
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
427
617
  flagged: true,
428
618
  text: '',
429
619
  originIndex: segmentIndex + runningIndex
430
620
  });
431
- runningIndex += 1; // Account for the soft hyphen character
621
+ runningIndex += 1;
432
622
  }
433
623
  }
434
624
  }
435
625
  else if (hyphenate &&
436
- segment.length >= lefthyphenmin + righthyphenmin) {
437
- // Apply hyphenation patterns only to segments between explicit hyphens
626
+ segment.length >= lefthyphenmin + righthyphenmin &&
627
+ /^\p{L}+$/u.test(segment)) {
438
628
  const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
439
629
  if (hyphenPoints.length > 0) {
440
630
  let lastPoint = 0;
@@ -448,10 +638,10 @@ class LineBreak {
448
638
  });
449
639
  items.push({
450
640
  type: ItemType.DISCRETIONARY,
451
- width: 0, // No width in normal flow
452
- preBreak: '-', // Hyphen appears before break
453
- postBreak: '', // Nothing after break
454
- noBreak: '', // Nothing if no break (word continues)
641
+ width: 0,
642
+ preBreak: '-',
643
+ postBreak: '',
644
+ noBreak: '',
455
645
  preBreakWidth: measureText('-'),
456
646
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
457
647
  flagged: true,
@@ -469,7 +659,6 @@ class LineBreak {
469
659
  });
470
660
  }
471
661
  else {
472
- // No hyphenation points, add as single box
473
662
  items.push({
474
663
  type: ItemType.BOX,
475
664
  width: measureText(segment),
@@ -479,7 +668,6 @@ class LineBreak {
479
668
  }
480
669
  }
481
670
  else {
482
- // No hyphenation, add as single box
483
671
  items.push({
484
672
  type: ItemType.BOX,
485
673
  width: measureText(segment),
@@ -495,27 +683,22 @@ class LineBreak {
495
683
  }
496
684
  return items;
497
685
  }
498
- // Detect if breakpoints create problematic single-word lines
499
- static hasSingleWordLines(items, breakpoints, lineWidth) {
686
+ // Detect if breakpoints create problematic short lines
687
+ static hasShortLines(items, breakpoints, lineWidth, threshold) {
500
688
  // Check each line segment (except the last, which can naturally be short)
501
689
  let lineStart = 0;
502
690
  for (let i = 0; i < breakpoints.length - 1; i++) {
503
691
  const breakpoint = breakpoints[i];
504
- // Count glue items (spaces) between line start and breakpoint
505
- let glueCount = 0;
506
692
  let totalWidth = 0;
507
693
  for (let j = lineStart; j < breakpoint; j++) {
508
- if (items[j].type === ItemType.GLUE) {
509
- glueCount++;
510
- }
511
694
  if (items[j].type !== ItemType.PENALTY) {
512
695
  totalWidth += items[j].width;
513
696
  }
514
697
  }
515
- // Single word line = no glue items
516
- if (glueCount === 0 && totalWidth > 0) {
698
+ // Check if line is narrow relative to target width
699
+ if (totalWidth > 0) {
517
700
  const widthRatio = totalWidth / lineWidth;
518
- if (widthRatio < SINGLE_WORD_WIDTH_THRESHOLD) {
701
+ if (widthRatio < threshold) {
519
702
  return true;
520
703
  }
521
704
  }
@@ -530,7 +713,7 @@ class LineBreak {
530
713
  align: options.align || 'left',
531
714
  hyphenate: options.hyphenate || false
532
715
  });
533
- 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;
716
+ 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;
534
717
  // Handle multiple paragraphs by processing each independently
535
718
  if (respectExistingBreaks && text.includes('\n')) {
536
719
  const paragraphs = text.split('\n');
@@ -609,56 +792,50 @@ class LineBreak {
609
792
  }
610
793
  ];
611
794
  }
612
- // Itemize text once, including all potential hyphenation points
613
- const allItems = LineBreak.itemizeText(text, measureText, useHyphenation, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
795
+ // Itemize without hyphenation first (TeX approach: only compute if needed)
796
+ const allItems = LineBreak.itemizeText(text, measureText, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
614
797
  if (allItems.length === 0) {
615
798
  return [];
616
799
  }
617
- // Iteratively increase emergency stretch to eliminate short single-word lines.
618
- // Post-processing approach preserves TeX algorithm integrity while itemization
619
- // (the expensive part) happens once
620
800
  const MAX_ITERATIONS = 5;
621
801
  let iteration = 0;
622
802
  let currentEmergencyStretch = initialEmergencyStretch;
623
803
  let resultLines = null;
624
- const singleWordDetectionEnabled = !disableSingleWordDetection;
804
+ const shortLineDetectionEnabled = !disableShortLineDetection;
625
805
  while (iteration < MAX_ITERATIONS) {
626
- // Three-pass approach for optimal line breaking:
627
- // First pass: Try without hyphenation using pretolerance (fast)
628
- // Second pass: Enable hyphenation if available, use tolerance (quality)
629
- // Final pass: Emergency stretch for difficult paragraphs (last resort)
806
+ // Three-pass approach matching TeX:
807
+ // First pass: without hyphenation (pretolerance)
808
+ // Second pass: with hyphenation, only if first pass fails (tolerance)
809
+ // Emergency pass: additional stretch as last resort
630
810
  // First pass: no hyphenation
631
- let currentItems = useHyphenation
632
- ? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
633
- item.penalty !==
634
- (context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
635
- : allItems;
811
+ let currentItems = allItems;
636
812
  let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
637
- // Second pass: with hyphenation
813
+ // Second pass: with hyphenation if first pass failed
638
814
  if (breaks.length === 0 && useHyphenation) {
639
- currentItems = allItems;
815
+ const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
816
+ currentItems = itemsWithHyphenation;
640
817
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
641
818
  }
642
- // Final pass: emergency stretch
819
+ // Emergency pass: add emergency stretch to background stretchability
643
820
  if (breaks.length === 0) {
644
- currentItems = allItems;
645
- breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
821
+ // For first emergency attempt, use initialEmergencyStretch
822
+ // For subsequent iterations (short line detection), progressively increase
823
+ currentEmergencyStretch = initialEmergencyStretch + (iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT);
824
+ breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, true, currentEmergencyStretch, context);
646
825
  }
647
- // Force with infinite tolerance if still no breaks found
826
+ // Last resort: allow higher badness (but not infinite)
648
827
  if (breaks.length === 0) {
649
- breaks = LineBreak.findBreakpoints(currentItems, width, Infinity, looseness, true, currentEmergencyStretch, context);
828
+ breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD, looseness, true, currentEmergencyStretch, context);
650
829
  }
651
830
  // Create lines from breaks
652
831
  if (breaks.length > 0) {
653
832
  const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
654
833
  resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
655
- // Check for single-word lines if detection is enabled
656
- if (singleWordDetectionEnabled &&
834
+ // Check for short lines if detection is enabled
835
+ if (shortLineDetectionEnabled &&
657
836
  breaks.length > 1 &&
658
- LineBreak.hasSingleWordLines(currentItems, breaks, width)) {
659
- // Increase emergency stretch and try again
660
- currentEmergencyStretch +=
661
- width * SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT;
837
+ LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
838
+ // Retry with more emergency stretch to push words to next line
662
839
  iteration++;
663
840
  continue;
664
841
  }
@@ -690,11 +867,12 @@ class LineBreak {
690
867
  threshold = Infinity, // maximum badness allowed for a break
691
868
  looseness = 0, // desired line count adjustment
692
869
  isFinalPass = false, // whether this is the final pass
693
- emergencyStretch = 0, // emergency stretch added to background stretchability
870
+ backgroundStretch = 0, // additional stretchability for all glue (emergency stretch)
694
871
  context) {
695
872
  // Pre-compute cumulative widths for fast range queries
696
873
  const cumulativeWidths = LineBreak.computeCumulativeWidths(items);
697
874
  const activeNodes = new ActiveNodeList();
875
+ const minimumDemerits = { value: Infinity };
698
876
  activeNodes.insert({
699
877
  position: 0,
700
878
  line: 0,
@@ -708,16 +886,16 @@ class LineBreak {
708
886
  const item = items[i];
709
887
  if (item.type === ItemType.PENALTY &&
710
888
  item.penalty < Infinity) {
711
- LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, emergencyStretch, cumulativeWidths, context);
889
+ LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
712
890
  }
713
891
  if (item.type === ItemType.DISCRETIONARY &&
714
892
  item.penalty < Infinity) {
715
- LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, emergencyStretch, cumulativeWidths, context);
893
+ LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
716
894
  }
717
895
  if (item.type === ItemType.GLUE &&
718
896
  i > 0 &&
719
897
  items[i - 1].type === ItemType.BOX) {
720
- LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, emergencyStretch, cumulativeWidths, context);
898
+ LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
721
899
  }
722
900
  LineBreak.deactivateNodes(activeNodes, i, lineWidth, cumulativeWidths.minWidths);
723
901
  }
@@ -784,7 +962,7 @@ class LineBreak {
784
962
  }
785
963
  return breakpoints;
786
964
  }
787
- static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity, emergencyStretch = 0, cumulativeWidths, context) {
965
+ static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity, backgroundStretch = 0, cumulativeWidths, context, isFinalPass = false, minimumDemerits = { value: Infinity }) {
788
966
  const penalty = items[breakpoint].type === ItemType.PENALTY
789
967
  ? items[breakpoint].penalty
790
968
  : 0;
@@ -796,11 +974,11 @@ class LineBreak {
796
974
  continue;
797
975
  const adjustmentData = LineBreak.computeAdjustmentRatio(items, node.position, breakpoint, node.line, lineWidth, cumulativeWidths, context);
798
976
  const { ratio: r, adjustment, stretch, shrink, totalWidth } = adjustmentData;
799
- // Calculate badness according to TeX formula
977
+ // Calculate badness
800
978
  let badness;
801
979
  if (adjustment > 0) {
802
- // Add emergency stretch to the background stretchability
803
- const effectiveStretch = stretch + emergencyStretch;
980
+ // backgroundStretch includes emergency stretch if in emergency pass
981
+ const effectiveStretch = stretch + backgroundStretch;
804
982
  if (effectiveStretch <= 0) {
805
983
  // Overfull box - badness is infinite + 1
806
984
  badness = INF_BAD + 1;
@@ -825,26 +1003,32 @@ class LineBreak {
825
1003
  else {
826
1004
  badness = 0;
827
1005
  }
828
- if (!isForcedBreak && r < -1) {
829
- // Too tight, skip unless forced
830
- continue;
1006
+ // Artificial demerits: in final pass with no feasible solution yet
1007
+ // and only one active node left, force this break as a last resort
1008
+ const isLastResort = isFinalPass &&
1009
+ minimumDemerits.value === Infinity &&
1010
+ allActiveNodes.length === 1 &&
1011
+ node.active;
1012
+ if (!isForcedBreak && !isLastResort && r < -1) {
1013
+ continue; // too tight
831
1014
  }
832
1015
  const fitnessClass = LineBreak.computeFitnessClass(badness, adjustment > 0);
833
- if (!isForcedBreak && badness > threshold) {
1016
+ if (!isForcedBreak && !isLastResort && badness > threshold) {
834
1017
  continue;
835
1018
  }
836
- // Initialize demerits based on TeX formula with saturation check
1019
+ // Initialize demerits with saturation check
837
1020
  let flaggedDemerits = 0;
838
1021
  let fitnessDemerits = 0;
839
1022
  const configuredLinePenalty = context?.linePenalty ?? 0;
840
1023
  let d = configuredLinePenalty + badness;
841
1024
  let demerits = Math.abs(d) >= 10000 ? 100000000 : d * d;
1025
+ const artificialDemerits = isLastResort;
842
1026
  const breakpointPenalty = items[breakpoint].type === ItemType.PENALTY
843
1027
  ? items[breakpoint].penalty
844
1028
  : items[breakpoint].type === ItemType.DISCRETIONARY
845
1029
  ? items[breakpoint].penalty
846
1030
  : 0;
847
- // TeX penalty handling: pi != 0 check, then positive/negative logic
1031
+ // Penalty contribution to demerits
848
1032
  if (breakpointPenalty !== 0) {
849
1033
  if (breakpointPenalty > 0) {
850
1034
  demerits += breakpointPenalty * breakpointPenalty;
@@ -870,10 +1054,13 @@ class LineBreak {
870
1054
  fitnessDemerits = context?.adjDemerits ?? 0;
871
1055
  demerits += fitnessDemerits;
872
1056
  }
873
- if (isForcedBreak) {
1057
+ if (isForcedBreak || artificialDemerits) {
874
1058
  demerits = 0;
875
1059
  }
876
1060
  const totalDemerits = node.totalDemerits + demerits;
1061
+ if (totalDemerits < minimumDemerits.value) {
1062
+ minimumDemerits.value = totalDemerits;
1063
+ }
877
1064
  let existingNode = activeNodes.findExisting(breakpoint, fitnessClass);
878
1065
  if (existingNode) {
879
1066
  if (totalDemerits < existingNode.totalDemerits) {
@@ -992,6 +1179,8 @@ class LineBreak {
992
1179
  const item = items[i];
993
1180
  widths[i + 1] = widths[i] + item.width;
994
1181
  if (item.type === ItemType.PENALTY) {
1182
+ stretches[i + 1] = stretches[i];
1183
+ shrinks[i + 1] = shrinks[i];
995
1184
  minWidths[i + 1] = minWidths[i];
996
1185
  }
997
1186
  else if (item.type === ItemType.GLUE) {
@@ -1009,7 +1198,6 @@ class LineBreak {
1009
1198
  return { widths, stretches, shrinks, minWidths };
1010
1199
  }
1011
1200
  // Deactivate nodes that can't lead to good line breaks
1012
- // TeX recalculates minWidth each time, we use cumulative arrays for lookup
1013
1201
  static deactivateNodes(activeNodeList, currentPosition, lineWidth, minWidths) {
1014
1202
  const activeNodes = activeNodeList.getAllActive();
1015
1203
  for (let i = activeNodes.length - 1; i >= 0; i--) {
@@ -1214,8 +1402,8 @@ function convertFontFeaturesToString(features) {
1214
1402
 
1215
1403
  class TextMeasurer {
1216
1404
  // Measures text width including letter spacing
1217
- // Letter spacing is added uniformly after each glyph during measurement,
1218
- // so the widths given to the line-breaking algorithm already account for tracking
1405
+ // (letter spacing is added uniformly after each glyph during measurement,
1406
+ // so the widths given to the line-breaking algorithm already account for tracking)
1219
1407
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1220
1408
  const buffer = loadedFont.hb.createBuffer();
1221
1409
  buffer.addText(text);
@@ -1242,7 +1430,7 @@ class TextLayout {
1242
1430
  this.loadedFont = loadedFont;
1243
1431
  }
1244
1432
  computeLines(options) {
1245
- const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection, letterSpacing } = options;
1433
+ 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;
1246
1434
  let lines;
1247
1435
  if (width) {
1248
1436
  // Line breaking uses a measureText function that already includes letterSpacing,
@@ -1268,7 +1456,8 @@ class TextLayout {
1268
1456
  exhyphenpenalty,
1269
1457
  doublehyphendemerits,
1270
1458
  looseness,
1271
- disableSingleWordDetection,
1459
+ disableShortLineDetection,
1460
+ shortLineThreshold,
1272
1461
  unitsPerEm: this.loadedFont.upem,
1273
1462
  measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1274
1463
  )
@@ -3536,10 +3725,11 @@ class LRUCache {
3536
3725
  }
3537
3726
  }
3538
3727
 
3728
+ const CONTOUR_CACHE_MAX_ENTRIES = 1000;
3729
+ const WORD_CACHE_MAX_ENTRIES = 1000;
3539
3730
  class GlyphGeometryBuilder {
3540
3731
  constructor(cache, loadedFont) {
3541
3732
  this.fontId = 'default';
3542
- this.wordCache = new Map();
3543
3733
  this.cache = cache;
3544
3734
  this.loadedFont = loadedFont;
3545
3735
  this.tessellator = new Tessellator();
@@ -3549,7 +3739,7 @@ class GlyphGeometryBuilder {
3549
3739
  this.drawCallbacks = new DrawCallbackHandler();
3550
3740
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3551
3741
  this.contourCache = new LRUCache({
3552
- maxEntries: 1000,
3742
+ maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
3553
3743
  calculateSize: (contours) => {
3554
3744
  let size = 0;
3555
3745
  for (const path of contours.paths) {
@@ -3558,6 +3748,15 @@ class GlyphGeometryBuilder {
3558
3748
  return size + 64; // bounds overhead
3559
3749
  }
3560
3750
  });
3751
+ this.wordCache = new LRUCache({
3752
+ maxEntries: WORD_CACHE_MAX_ENTRIES,
3753
+ calculateSize: (data) => {
3754
+ let size = data.vertices.length * 4;
3755
+ size += data.normals.length * 4;
3756
+ size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
3757
+ return size;
3758
+ }
3759
+ });
3561
3760
  }
3562
3761
  getOptimizationStats() {
3563
3762
  return this.collector.getOptimizationStats();
@@ -3847,9 +4046,10 @@ class TextShaper {
3847
4046
  let currentClusterText = '';
3848
4047
  let clusterStartPosition = new Vec3();
3849
4048
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
3850
- // Apply letter spacing between glyphs (must match what was used in width measurements)
4049
+ // Apply letter spacing after each glyph to match width measurements used during line breaking
3851
4050
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3852
4051
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4052
+ const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
3853
4053
  for (let i = 0; i < glyphInfos.length; i++) {
3854
4054
  const glyph = glyphInfos[i];
3855
4055
  const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
@@ -3895,6 +4095,29 @@ class TextShaper {
3895
4095
  if (isWhitespace) {
3896
4096
  cursor.x += spaceAdjustment;
3897
4097
  }
4098
+ // CJK glue adjustment (must match exactly where LineBreak adds glue)
4099
+ if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
4100
+ const currentChar = lineInfo.text[glyph.cl];
4101
+ const nextGlyph = glyphInfos[i + 1];
4102
+ const nextChar = lineInfo.text[nextGlyph.cl];
4103
+ const isCJKChar = LineBreak.isCJK(currentChar);
4104
+ const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
4105
+ if (isCJKChar && nextIsCJKChar) {
4106
+ let shouldApply = true;
4107
+ if (LineBreak.isCJClosingPunctuation(nextChar)) {
4108
+ shouldApply = false;
4109
+ }
4110
+ if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4111
+ shouldApply = false;
4112
+ }
4113
+ if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
4114
+ shouldApply = false;
4115
+ }
4116
+ if (shouldApply) {
4117
+ cursor.x += cjkAdjustment;
4118
+ }
4119
+ }
4120
+ }
3898
4121
  }
3899
4122
  if (currentClusterGlyphs.length > 0) {
3900
4123
  clusters.push({
@@ -3927,6 +4150,23 @@ class TextShaper {
3927
4150
  }
3928
4151
  return spaceAdjustment;
3929
4152
  }
4153
+ calculateCJKAdjustment(lineInfo, align) {
4154
+ if (lineInfo.adjustmentRatio === undefined ||
4155
+ align !== 'justify' ||
4156
+ lineInfo.isLastLine) {
4157
+ return 0;
4158
+ }
4159
+ const baseCharWidth = this.loadedFont.upem;
4160
+ const glueStretch = baseCharWidth * 0.04;
4161
+ const glueShrink = baseCharWidth * 0.04;
4162
+ if (lineInfo.adjustmentRatio > 0) {
4163
+ return lineInfo.adjustmentRatio * glueStretch;
4164
+ }
4165
+ else if (lineInfo.adjustmentRatio < 0) {
4166
+ return lineInfo.adjustmentRatio * glueShrink;
4167
+ }
4168
+ return 0;
4169
+ }
3930
4170
  clearCache() {
3931
4171
  this.geometryBuilder.clearCache();
3932
4172
  }
@@ -4844,6 +5084,54 @@ class Text {
4844
5084
  if (!Text.hbInitPromise) {
4845
5085
  Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
4846
5086
  }
5087
+ const loadedFont = await Text.resolveFont(options);
5088
+ const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
5089
+ text.setLoadedFont(loadedFont);
5090
+ // Initial creation
5091
+ const { font, maxCacheSizeMB, ...geometryOptions } = options;
5092
+ const result = await text.createGeometry(geometryOptions);
5093
+ // Recursive update function
5094
+ const update = async (newOptions) => {
5095
+ // Merge options - preserve font from original options if not provided
5096
+ const mergedOptions = { ...options };
5097
+ for (const key in newOptions) {
5098
+ const value = newOptions[key];
5099
+ if (value !== undefined) {
5100
+ mergedOptions[key] = value;
5101
+ }
5102
+ }
5103
+ // If font definition or configuration changed, reload font and reset helpers
5104
+ if (newOptions.font !== undefined ||
5105
+ newOptions.fontVariations !== undefined ||
5106
+ newOptions.fontFeatures !== undefined) {
5107
+ const newLoadedFont = await Text.resolveFont(mergedOptions);
5108
+ text.setLoadedFont(newLoadedFont);
5109
+ // Reset geometry builder and shaper to use new font
5110
+ text.resetHelpers();
5111
+ }
5112
+ // Update closure options for next time
5113
+ options = mergedOptions;
5114
+ const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
5115
+ const newResult = await text.createGeometry(currentGeometryOptions);
5116
+ return {
5117
+ ...newResult,
5118
+ getLoadedFont: () => text.getLoadedFont(),
5119
+ getCacheStatistics: () => text.getCacheStatistics(),
5120
+ clearCache: () => text.clearCache(),
5121
+ measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5122
+ update
5123
+ };
5124
+ };
5125
+ return {
5126
+ ...result,
5127
+ getLoadedFont: () => text.getLoadedFont(),
5128
+ getCacheStatistics: () => text.getCacheStatistics(),
5129
+ clearCache: () => text.clearCache(),
5130
+ measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5131
+ update
5132
+ };
5133
+ }
5134
+ static async resolveFont(options) {
4847
5135
  const baseFontKey = typeof options.font === 'string'
4848
5136
  ? options.font
4849
5137
  : `buffer-${Text.generateFontContentHash(options.font)}`;
@@ -4858,17 +5146,7 @@ class Text {
4858
5146
  if (!loadedFont) {
4859
5147
  loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
4860
5148
  }
4861
- const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
4862
- text.setLoadedFont(loadedFont);
4863
- const { font, maxCacheSizeMB, ...geometryOptions } = options;
4864
- const result = await text.createGeometry(geometryOptions);
4865
- return {
4866
- ...result,
4867
- getLoadedFont: () => text.getLoadedFont(),
4868
- getCacheStatistics: () => text.getCacheStatistics(),
4869
- clearCache: () => text.clearCache(),
4870
- measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
4871
- };
5149
+ return loadedFont;
4872
5150
  }
4873
5151
  static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
4874
5152
  const tempText = new Text();
@@ -5044,7 +5322,7 @@ class Text {
5044
5322
  throw new Error('Font not loaded. Use Text.create() with a font option');
5045
5323
  }
5046
5324
  const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
5047
- 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;
5325
+ 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;
5048
5326
  let widthInFontUnits;
5049
5327
  if (width !== undefined) {
5050
5328
  widthInFontUnits = width * (this.loadedFont.upem / size);
@@ -5074,7 +5352,8 @@ class Text {
5074
5352
  exhyphenpenalty,
5075
5353
  doublehyphendemerits,
5076
5354
  looseness,
5077
- disableSingleWordDetection,
5355
+ disableShortLineDetection,
5356
+ shortLineThreshold,
5078
5357
  letterSpacing
5079
5358
  });
5080
5359
  const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
@@ -5325,6 +5604,11 @@ class Text {
5325
5604
  glyphLineIndex: glyphLineIndices
5326
5605
  };
5327
5606
  }
5607
+ resetHelpers() {
5608
+ this.geometryBuilder = undefined;
5609
+ this.textShaper = undefined;
5610
+ this.textLayout = undefined;
5611
+ }
5328
5612
  destroy() {
5329
5613
  if (!this.loadedFont) {
5330
5614
  return;