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