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.umd.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
@@ -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();
@@ -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
266
  // Calculate badness according to TeX's formula (tex.web §108, 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,39 +794,32 @@
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: compute hyphenation only if needed
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: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
645
822
  if (breaks.length === 0) {
646
- currentItems = allItems;
647
823
  breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
648
824
  }
649
825
  // Force with infinite tolerance if still no breaks found
@@ -654,13 +830,13 @@
654
830
  if (breaks.length > 0) {
655
831
  const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
656
832
  resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
657
- // Check for single-word lines if detection is enabled
658
- if (singleWordDetectionEnabled &&
833
+ // Check for short lines if detection is enabled
834
+ if (shortLineDetectionEnabled &&
659
835
  breaks.length > 1 &&
660
- LineBreak.hasSingleWordLines(currentItems, breaks, width)) {
836
+ LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
661
837
  // Increase emergency stretch and try again
662
838
  currentEmergencyStretch +=
663
- width * SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT;
839
+ width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
664
840
  iteration++;
665
841
  continue;
666
842
  }
@@ -994,6 +1170,8 @@
994
1170
  const item = items[i];
995
1171
  widths[i + 1] = widths[i] + item.width;
996
1172
  if (item.type === ItemType.PENALTY) {
1173
+ stretches[i + 1] = stretches[i];
1174
+ shrinks[i + 1] = shrinks[i];
997
1175
  minWidths[i + 1] = minWidths[i];
998
1176
  }
999
1177
  else if (item.type === ItemType.GLUE) {
@@ -1244,7 +1422,7 @@
1244
1422
  this.loadedFont = loadedFont;
1245
1423
  }
1246
1424
  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;
1425
+ 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
1426
  let lines;
1249
1427
  if (width) {
1250
1428
  // Line breaking uses a measureText function that already includes letterSpacing,
@@ -1270,7 +1448,8 @@
1270
1448
  exhyphenpenalty,
1271
1449
  doublehyphendemerits,
1272
1450
  looseness,
1273
- disableSingleWordDetection,
1451
+ disableShortLineDetection,
1452
+ shortLineThreshold,
1274
1453
  unitsPerEm: this.loadedFont.upem,
1275
1454
  measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1276
1455
  )
@@ -3854,6 +4033,7 @@
3854
4033
  // Apply letter spacing between glyphs (must match what was used in width measurements)
3855
4034
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3856
4035
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4036
+ const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
3857
4037
  for (let i = 0; i < glyphInfos.length; i++) {
3858
4038
  const glyph = glyphInfos[i];
3859
4039
  const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
@@ -3899,6 +4079,29 @@
3899
4079
  if (isWhitespace) {
3900
4080
  cursor.x += spaceAdjustment;
3901
4081
  }
4082
+ // CJK glue adjustment (must match exactly where LineBreak adds glue)
4083
+ if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
4084
+ const currentChar = lineInfo.text[glyph.cl];
4085
+ const nextGlyph = glyphInfos[i + 1];
4086
+ const nextChar = lineInfo.text[nextGlyph.cl];
4087
+ const isCJKChar = LineBreak.isCJK(currentChar);
4088
+ const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
4089
+ if (isCJKChar && nextIsCJKChar) {
4090
+ let shouldApply = true;
4091
+ if (LineBreak.isCJClosingPunctuation(nextChar)) {
4092
+ shouldApply = false;
4093
+ }
4094
+ if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4095
+ shouldApply = false;
4096
+ }
4097
+ if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
4098
+ shouldApply = false;
4099
+ }
4100
+ if (shouldApply) {
4101
+ cursor.x += cjkAdjustment;
4102
+ }
4103
+ }
4104
+ }
3902
4105
  }
3903
4106
  if (currentClusterGlyphs.length > 0) {
3904
4107
  clusters.push({
@@ -3931,6 +4134,23 @@
3931
4134
  }
3932
4135
  return spaceAdjustment;
3933
4136
  }
4137
+ calculateCJKAdjustment(lineInfo, align) {
4138
+ if (lineInfo.adjustmentRatio === undefined ||
4139
+ align !== 'justify' ||
4140
+ lineInfo.isLastLine) {
4141
+ return 0;
4142
+ }
4143
+ const baseCharWidth = this.loadedFont.upem;
4144
+ const glueStretch = baseCharWidth * 0.04;
4145
+ const glueShrink = baseCharWidth * 0.04;
4146
+ if (lineInfo.adjustmentRatio > 0) {
4147
+ return lineInfo.adjustmentRatio * glueStretch;
4148
+ }
4149
+ else if (lineInfo.adjustmentRatio < 0) {
4150
+ return lineInfo.adjustmentRatio * glueShrink;
4151
+ }
4152
+ return 0;
4153
+ }
3934
4154
  clearCache() {
3935
4155
  this.geometryBuilder.clearCache();
3936
4156
  }
@@ -5048,7 +5268,7 @@
5048
5268
  throw new Error('Font not loaded. Use Text.create() with a font option');
5049
5269
  }
5050
5270
  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;
5271
+ 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
5272
  let widthInFontUnits;
5053
5273
  if (width !== undefined) {
5054
5274
  widthInFontUnits = width * (this.loadedFont.upem / size);
@@ -5078,7 +5298,8 @@
5078
5298
  exhyphenpenalty,
5079
5299
  doublehyphendemerits,
5080
5300
  looseness,
5081
- disableSingleWordDetection,
5301
+ disableShortLineDetection,
5302
+ shortLineThreshold,
5082
5303
  letterSpacing
5083
5304
  });
5084
5305
  const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);