three-text 0.2.7 → 0.2.8

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.8
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();
@@ -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
261
  // Calculate badness according to TeX's formula (tex.web §108, 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,39 +789,32 @@ 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: compute hyphenation only if needed
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: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
640
817
  if (breaks.length === 0) {
641
- currentItems = allItems;
642
818
  breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
643
819
  }
644
820
  // Force with infinite tolerance if still no breaks found
@@ -649,13 +825,13 @@ class LineBreak {
649
825
  if (breaks.length > 0) {
650
826
  const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
651
827
  resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
652
- // Check for single-word lines if detection is enabled
653
- if (singleWordDetectionEnabled &&
828
+ // Check for short lines if detection is enabled
829
+ if (shortLineDetectionEnabled &&
654
830
  breaks.length > 1 &&
655
- LineBreak.hasSingleWordLines(currentItems, breaks, width)) {
831
+ LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
656
832
  // Increase emergency stretch and try again
657
833
  currentEmergencyStretch +=
658
- width * SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT;
834
+ width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
659
835
  iteration++;
660
836
  continue;
661
837
  }
@@ -989,6 +1165,8 @@ class LineBreak {
989
1165
  const item = items[i];
990
1166
  widths[i + 1] = widths[i] + item.width;
991
1167
  if (item.type === ItemType.PENALTY) {
1168
+ stretches[i + 1] = stretches[i];
1169
+ shrinks[i + 1] = shrinks[i];
992
1170
  minWidths[i + 1] = minWidths[i];
993
1171
  }
994
1172
  else if (item.type === ItemType.GLUE) {
@@ -1239,7 +1417,7 @@ class TextLayout {
1239
1417
  this.loadedFont = loadedFont;
1240
1418
  }
1241
1419
  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;
1420
+ 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
1421
  let lines;
1244
1422
  if (width) {
1245
1423
  // Line breaking uses a measureText function that already includes letterSpacing,
@@ -1265,7 +1443,8 @@ class TextLayout {
1265
1443
  exhyphenpenalty,
1266
1444
  doublehyphendemerits,
1267
1445
  looseness,
1268
- disableSingleWordDetection,
1446
+ disableShortLineDetection,
1447
+ shortLineThreshold,
1269
1448
  unitsPerEm: this.loadedFont.upem,
1270
1449
  measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1271
1450
  )
@@ -3847,6 +4026,7 @@ class TextShaper {
3847
4026
  // Apply letter spacing between glyphs (must match what was used in width measurements)
3848
4027
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3849
4028
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4029
+ const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
3850
4030
  for (let i = 0; i < glyphInfos.length; i++) {
3851
4031
  const glyph = glyphInfos[i];
3852
4032
  const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
@@ -3892,6 +4072,29 @@ class TextShaper {
3892
4072
  if (isWhitespace) {
3893
4073
  cursor.x += spaceAdjustment;
3894
4074
  }
4075
+ // CJK glue adjustment (must match exactly where LineBreak adds glue)
4076
+ if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
4077
+ const currentChar = lineInfo.text[glyph.cl];
4078
+ const nextGlyph = glyphInfos[i + 1];
4079
+ const nextChar = lineInfo.text[nextGlyph.cl];
4080
+ const isCJKChar = LineBreak.isCJK(currentChar);
4081
+ const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
4082
+ if (isCJKChar && nextIsCJKChar) {
4083
+ let shouldApply = true;
4084
+ if (LineBreak.isCJClosingPunctuation(nextChar)) {
4085
+ shouldApply = false;
4086
+ }
4087
+ if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4088
+ shouldApply = false;
4089
+ }
4090
+ if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
4091
+ shouldApply = false;
4092
+ }
4093
+ if (shouldApply) {
4094
+ cursor.x += cjkAdjustment;
4095
+ }
4096
+ }
4097
+ }
3895
4098
  }
3896
4099
  if (currentClusterGlyphs.length > 0) {
3897
4100
  clusters.push({
@@ -3924,6 +4127,23 @@ class TextShaper {
3924
4127
  }
3925
4128
  return spaceAdjustment;
3926
4129
  }
4130
+ calculateCJKAdjustment(lineInfo, align) {
4131
+ if (lineInfo.adjustmentRatio === undefined ||
4132
+ align !== 'justify' ||
4133
+ lineInfo.isLastLine) {
4134
+ return 0;
4135
+ }
4136
+ const baseCharWidth = this.loadedFont.upem;
4137
+ const glueStretch = baseCharWidth * 0.04;
4138
+ const glueShrink = baseCharWidth * 0.04;
4139
+ if (lineInfo.adjustmentRatio > 0) {
4140
+ return lineInfo.adjustmentRatio * glueStretch;
4141
+ }
4142
+ else if (lineInfo.adjustmentRatio < 0) {
4143
+ return lineInfo.adjustmentRatio * glueShrink;
4144
+ }
4145
+ return 0;
4146
+ }
3927
4147
  clearCache() {
3928
4148
  this.geometryBuilder.clearCache();
3929
4149
  }
@@ -5041,7 +5261,7 @@ class Text {
5041
5261
  throw new Error('Font not loaded. Use Text.create() with a font option');
5042
5262
  }
5043
5263
  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;
5264
+ 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
5265
  let widthInFontUnits;
5046
5266
  if (width !== undefined) {
5047
5267
  widthInFontUnits = width * (this.loadedFont.upem / size);
@@ -5071,7 +5291,8 @@ class Text {
5071
5291
  exhyphenpenalty,
5072
5292
  doublehyphendemerits,
5073
5293
  looseness,
5074
- disableSingleWordDetection,
5294
+ disableShortLineDetection,
5295
+ shortLineThreshold,
5075
5296
  letterSpacing
5076
5297
  });
5077
5298
  const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);