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/README.md CHANGED
@@ -395,7 +395,8 @@ The Knuth-Plass algorithm provides extensive control over line breaking quality:
395
395
  - **tolerance** (800): Maximum badness for the second pass with hyphenation
396
396
  - **emergencyStretch** (0): Additional stretchability for difficult paragraphs
397
397
  - **autoEmergencyStretch** (0.1): Emergency stretch as percentage of line width (e.g., 0.1 = 10%). Defaults to 10% for non-hyphenated text
398
- - **disableSingleWordDetection** (false): Disable automatic prevention of short single-word lines
398
+ - **disableShortLineDetection** (false): Disable automatic prevention of short lines
399
+ - **shortLineThreshold** (0.5): Width ratio threshold for short line detection (0.0 to 1.0)
399
400
 
400
401
  #### Advanced parameters
401
402
 
@@ -416,9 +417,9 @@ The Knuth-Plass algorithm provides extensive control over line breaking quality:
416
417
 
417
418
  Lower penalty/tolerance values produce tighter spacing but may fail to find acceptable breaks for challenging text
418
419
 
419
- #### Single-word line detection
420
+ #### Short line detection
420
421
 
421
- By default, the library detects and prevents short single-word lines (words occupying less than 50% of the line width on non-final lines) by iteratively applying emergency stretch. This can be disabled if needed:
422
+ By default, the library detects and prevents short lines (lines occupying less than 50% of the target width on non-final lines) by iteratively applying emergency stretch. This can be customized or disabled:
422
423
 
423
424
  ```javascript
424
425
  const text = await Text.create({
@@ -426,7 +427,9 @@ const text = await Text.create({
426
427
  font: '/fonts/Font.ttf',
427
428
  layout: {
428
429
  width: 1000,
429
- disableSingleWordDetection: true,
430
+ shortLineThreshold: 0.6, // Only flag lines < 60% width (more lenient)
431
+ // Or disable entirely:
432
+ // disableShortLineDetection: true,
430
433
  },
431
434
  });
432
435
  ```
@@ -754,9 +757,9 @@ interface LayoutOptions {
754
757
  pretolerance?: number; // Maximum badness for first pass (default: 100)
755
758
  emergencyStretch?: number; // Additional stretchability for difficult paragraphs
756
759
  autoEmergencyStretch?: number; // Emergency stretch as percentage of line width (defaults to 10% for non-hyphenated)
757
- disableSingleWordDetection?: boolean; // Disable automatic single-word line prevention (default: false)
758
- lefthyphenmin?: number; // Minimum character
759
- // s before hyphen (default: 2)
760
+ disableShortLineDetection?: boolean; // Disable automatic short line prevention (default: false)
761
+ shortLineThreshold?: number; // Width ratio threshold for short line detection (default: 0.5)
762
+ lefthyphenmin?: number; // Minimum characters before hyphen (default: 2)
760
763
  righthyphenmin?: number; // Minimum characters after hyphen (default: 4)
761
764
  linepenalty?: number; // Base penalty per line (default: 10)
762
765
  adjdemerits?: number; // Penalty for incompatible fitness classes (default: 10000)
@@ -940,9 +943,9 @@ While `three-text` runs on all modern browsers, performance varies significantly
940
943
 
941
944
  **Chrome** provides the best experience
942
945
 
943
- **Firefox** also delivers great performance but may exhibit less responsive mouse interactions in WebGL contexts due to the way it handles events
946
+ **Firefox** also delivers great performance but may exhibit less responsive mouse interactions
944
947
 
945
- **Safari** for macOS shows reduced performance, which is likely due to the platform's conservative resource management, particularly around battery life; 120FPS is not acheivable
948
+ **Safari** for macOS shows reduced performance, which is likely due to the platform's conservative resource management; 120FPS is not acheivable
946
949
 
947
950
  The library was also tested on a Brightsign 223HD, which took a long time to generate the initial geometry but seemed fine after that
948
951
 
package/dist/index.cjs 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
@@ -125,9 +125,6 @@ class PerformanceLogger {
125
125
  this.metrics.length = 0;
126
126
  this.activeTimers.clear();
127
127
  }
128
- reset() {
129
- this.clear();
130
- }
131
128
  time(name, fn, metadata) {
132
129
  if (!isLogEnabled)
133
130
  return fn();
@@ -260,15 +257,15 @@ const DEFAULT_RIGHT_HYPHEN_MIN = 4;
260
257
  const INF_BAD = 10000;
261
258
  // Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
262
259
  const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
263
- // Another non TeX default: Single-word line detection thresholds
264
- const SINGLE_WORD_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
265
- const SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
260
+ // Another non TeX default: Short line detection thresholds
261
+ const SHORT_LINE_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
262
+ const SHORT_LINE_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
266
263
  class LineBreak {
267
264
  // Calculate badness according to TeX's formula (tex.web §108, line 2337)
268
265
  // Given t (desired adjustment) and s (available stretch/shrink)
269
266
  // Returns approximation to 100(t/s)³, representing how "bad" a line is
270
267
  // Constants are derived from TeX's fixed-point arithmetic:
271
- // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
268
+ // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
272
269
  static badness(t, s) {
273
270
  if (t === 0)
274
271
  return 0;
@@ -327,8 +324,8 @@ class LineBreak {
327
324
  const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
328
325
  return filteredPoints;
329
326
  }
330
- // Converts text into items (boxes, glues, penalties) for line breaking.
331
- // The measureText function should return widths that include any letter spacing.
327
+ // Converts text into items (boxes, glues, penalties) for line breaking
328
+ // The measureText function should return widths that include any letter spacing
332
329
  static itemizeText(text, measureText, // function to measure text width
333
330
  hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
334
331
  const items = [];
@@ -352,16 +349,214 @@ class LineBreak {
352
349
  });
353
350
  return items;
354
351
  }
352
+ // Chinese, Japanese, and Korean character ranges
353
+ static isCJK(char) {
354
+ const code = char.codePointAt(0);
355
+ if (code === undefined)
356
+ return false;
357
+ return (
358
+ // CJK Unified Ideographs
359
+ (code >= 0x4e00 && code <= 0x9fff) ||
360
+ // CJK Extension A
361
+ (code >= 0x3400 && code <= 0x4dbf) ||
362
+ // CJK Extension B
363
+ (code >= 0x20000 && code <= 0x2a6df) ||
364
+ // CJK Extension C
365
+ (code >= 0x2a700 && code <= 0x2b73f) ||
366
+ // CJK Extension D
367
+ (code >= 0x2b740 && code <= 0x2b81f) ||
368
+ // CJK Extension E
369
+ (code >= 0x2b820 && code <= 0x2ceaf) ||
370
+ // CJK Compatibility Ideographs
371
+ (code >= 0xf900 && code <= 0xfaff) ||
372
+ // Hiragana
373
+ (code >= 0x3040 && code <= 0x309f) ||
374
+ // Katakana
375
+ (code >= 0x30a0 && code <= 0x30ff) ||
376
+ // Hangul Syllables
377
+ (code >= 0xac00 && code <= 0xd7af) ||
378
+ // Hangul Jamo
379
+ (code >= 0x1100 && code <= 0x11ff) ||
380
+ // Hangul Compatibility Jamo
381
+ (code >= 0x3130 && code <= 0x318f) ||
382
+ // Hangul Jamo Extended-A
383
+ (code >= 0xa960 && code <= 0xa97f) ||
384
+ // Hangul Jamo Extended-B
385
+ (code >= 0xd7b0 && code <= 0xd7ff) ||
386
+ // Halfwidth and Fullwidth Forms (Korean)
387
+ (code >= 0xffa0 && code <= 0xffdc));
388
+ }
389
+ // Closing punctuation where line breaks are prohibited (UAX #14 LB30, JIS X 4051)
390
+ static isCJClosingPunctuation(char) {
391
+ const code = char.charCodeAt(0);
392
+ return (code === 0x3001 || // 、
393
+ code === 0x3002 || // 。
394
+ code === 0xff0c || // ,
395
+ code === 0xff0e || // .
396
+ code === 0xff1a || // :
397
+ code === 0xff1b || // ;
398
+ code === 0xff01 || // !
399
+ code === 0xff1f || // ?
400
+ code === 0xff09 || // )
401
+ code === 0x3011 || // 】
402
+ code === 0xff5d || // }
403
+ code === 0x300d || // 」
404
+ code === 0x300f || // 』
405
+ code === 0x3009 || // 〉
406
+ code === 0x300b || // 》
407
+ code === 0x3015 || // 〕
408
+ code === 0x3017 || // 〗
409
+ code === 0x3019 || // 〙
410
+ code === 0x301b || // 〛
411
+ code === 0x30fc || // ー
412
+ code === 0x2014 || // —
413
+ code === 0x2026 || // …
414
+ code === 0x2025 // ‥
415
+ );
416
+ }
417
+ // Opening punctuation where line breaks are prohibited (UAX #14 LB30a, JIS X 4051)
418
+ static isCJOpeningPunctuation(char) {
419
+ const code = char.charCodeAt(0);
420
+ return (code === 0xff08 || // (
421
+ code === 0x3010 || // 【
422
+ code === 0xff5b || // {
423
+ code === 0x300c || // 「
424
+ code === 0x300e || // 『
425
+ code === 0x3008 || // 〈
426
+ code === 0x300a || // 《
427
+ code === 0x3014 || // 〔
428
+ code === 0x3016 || // 〖
429
+ code === 0x3018 || // 〘
430
+ code === 0x301a // 〚
431
+ );
432
+ }
433
+ static isCJPunctuation(char) {
434
+ return (this.isCJClosingPunctuation(char) || this.isCJOpeningPunctuation(char));
435
+ }
436
+ // CJK (Chinese/Japanese/Korean) character-level itemization with inter-character glue
437
+ static itemizeCJKText(text, measureText, context, startOffset = 0, glueParams) {
438
+ const items = [];
439
+ const chars = Array.from(text);
440
+ let textPosition = startOffset;
441
+ // Inter-character glue parameters
442
+ let glueWidth;
443
+ let glueStretch;
444
+ let glueShrink;
445
+ if (glueParams) {
446
+ glueWidth = glueParams.width;
447
+ glueStretch = glueParams.stretch;
448
+ glueShrink = glueParams.shrink;
449
+ }
450
+ else {
451
+ const baseCharWidth = measureText('字');
452
+ glueWidth = 0;
453
+ glueStretch = baseCharWidth * 0.04;
454
+ glueShrink = baseCharWidth * 0.04;
455
+ }
456
+ for (let i = 0; i < chars.length; i++) {
457
+ const char = chars[i];
458
+ const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
459
+ if (/\s/.test(char)) {
460
+ const width = measureText(char);
461
+ items.push({
462
+ type: ItemType.GLUE,
463
+ width,
464
+ stretch: width * SPACE_STRETCH_RATIO,
465
+ shrink: width * SPACE_SHRINK_RATIO,
466
+ text: char,
467
+ originIndex: textPosition
468
+ });
469
+ textPosition += char.length;
470
+ continue;
471
+ }
472
+ items.push({
473
+ type: ItemType.BOX,
474
+ width: measureText(char),
475
+ text: char,
476
+ originIndex: textPosition
477
+ });
478
+ textPosition += char.length;
479
+ // Glue after a box creates a break opportunity
480
+ // Must not add glue where breaks are prohibited by Chinese/Japanese line breaking rules
481
+ if (nextChar && !/\s/.test(nextChar)) {
482
+ let canBreak = true;
483
+ if (this.isCJClosingPunctuation(nextChar)) {
484
+ canBreak = false;
485
+ }
486
+ if (this.isCJOpeningPunctuation(char)) {
487
+ canBreak = false;
488
+ }
489
+ // Avoid stretch between consecutive punctuation (?" or 。」)
490
+ const isPunctPair = this.isCJPunctuation(char) && this.isCJPunctuation(nextChar);
491
+ if (canBreak && !isPunctPair) {
492
+ items.push({
493
+ type: ItemType.GLUE,
494
+ width: glueWidth,
495
+ stretch: glueStretch,
496
+ shrink: glueShrink,
497
+ text: '',
498
+ originIndex: textPosition
499
+ });
500
+ }
501
+ }
502
+ }
503
+ return items;
504
+ }
355
505
  static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
356
506
  const items = [];
357
- // First split into words and spaces
507
+ const chars = Array.from(text);
508
+ // Calculate CJK glue parameters once for consistency across all segments
509
+ const baseCharWidth = measureText('字');
510
+ const cjkGlueParams = {
511
+ width: 0,
512
+ stretch: baseCharWidth * 0.04,
513
+ shrink: baseCharWidth * 0.04
514
+ };
515
+ let buffer = '';
516
+ let bufferStart = 0;
517
+ let bufferScript = null;
518
+ let textPosition = 0;
519
+ const flushBuffer = () => {
520
+ if (buffer.length === 0)
521
+ return;
522
+ if (bufferScript === 'cjk') {
523
+ const cjkItems = this.itemizeCJKText(buffer, measureText, context, bufferStart, cjkGlueParams);
524
+ items.push(...cjkItems);
525
+ }
526
+ else {
527
+ const wordItems = this.itemizeWordBased(buffer, bufferStart, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context);
528
+ items.push(...wordItems);
529
+ }
530
+ buffer = '';
531
+ bufferScript = null;
532
+ };
533
+ for (let i = 0; i < chars.length; i++) {
534
+ const char = chars[i];
535
+ const isCJKChar = this.isCJK(char);
536
+ const currentScript = isCJKChar ? 'cjk' : 'word';
537
+ if (bufferScript !== null && bufferScript !== currentScript) {
538
+ flushBuffer();
539
+ bufferStart = textPosition;
540
+ }
541
+ if (bufferScript === null) {
542
+ bufferScript = currentScript;
543
+ bufferStart = textPosition;
544
+ }
545
+ buffer += char;
546
+ textPosition += char.length;
547
+ }
548
+ flushBuffer();
549
+ return items;
550
+ }
551
+ // Word-based itemization for alphabetic scripts (Latin, Cyrillic, Greek, etc.)
552
+ static itemizeWordBased(text, startOffset, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
553
+ const items = [];
358
554
  const tokens = text.match(/\S+|\s+/g) || [];
359
555
  let currentIndex = 0;
360
556
  for (let i = 0; i < tokens.length; i++) {
361
557
  const token = tokens[i];
362
- const tokenStartIndex = currentIndex;
558
+ const tokenStartIndex = startOffset + currentIndex;
363
559
  if (/\s+/.test(token)) {
364
- // Handle spaces
365
560
  const width = measureText(token);
366
561
  items.push({
367
562
  type: ItemType.GLUE,
@@ -374,22 +569,19 @@ class LineBreak {
374
569
  currentIndex += token.length;
375
570
  }
376
571
  else {
377
- // Process word, splitting on explicit hyphens
378
- // Split on hyphens while keeping them in the result
379
572
  const segments = token.split(/(-)/);
380
573
  let segmentIndex = tokenStartIndex;
381
574
  for (let j = 0; j < segments.length; j++) {
382
575
  const segment = segments[j];
383
576
  if (!segment)
384
- continue; // Skip empty segments
577
+ continue;
385
578
  if (segment === '-') {
386
- // Handle explicit hyphen as discretionary break
387
579
  items.push({
388
580
  type: ItemType.DISCRETIONARY,
389
- width: measureText('-'), // Width of hyphen in normal flow
390
- preBreak: '-', // Hyphen appears before break
391
- postBreak: '', // Nothing after break
392
- noBreak: '-', // Hyphen if no break
581
+ width: measureText('-'),
582
+ preBreak: '-',
583
+ postBreak: '',
584
+ noBreak: '-',
393
585
  preBreakWidth: measureText('-'),
394
586
  penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
395
587
  flagged: true,
@@ -399,8 +591,6 @@ class LineBreak {
399
591
  segmentIndex += 1;
400
592
  }
401
593
  else {
402
- // Process non-hyphen segment
403
- // First handle soft hyphens (U+00AD)
404
594
  if (segment.includes('\u00AD')) {
405
595
  const partsWithMarkers = segment.split('\u00AD');
406
596
  let runningIndex = 0;
@@ -418,23 +608,23 @@ class LineBreak {
418
608
  if (k < partsWithMarkers.length - 1) {
419
609
  items.push({
420
610
  type: ItemType.DISCRETIONARY,
421
- width: 0, // No width in normal flow
422
- preBreak: '-', // Hyphen appears before break
423
- postBreak: '', // Nothing after break
424
- noBreak: '', // Nothing if no break (word continues)
611
+ width: 0,
612
+ preBreak: '-',
613
+ postBreak: '',
614
+ noBreak: '',
425
615
  preBreakWidth: measureText('-'),
426
616
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
427
617
  flagged: true,
428
618
  text: '',
429
619
  originIndex: segmentIndex + runningIndex
430
620
  });
431
- runningIndex += 1; // Account for the soft hyphen character
621
+ runningIndex += 1;
432
622
  }
433
623
  }
434
624
  }
435
625
  else if (hyphenate &&
436
- segment.length >= lefthyphenmin + righthyphenmin) {
437
- // Apply hyphenation patterns only to segments between explicit hyphens
626
+ segment.length >= lefthyphenmin + righthyphenmin &&
627
+ /^\p{L}+$/u.test(segment)) {
438
628
  const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
439
629
  if (hyphenPoints.length > 0) {
440
630
  let lastPoint = 0;
@@ -448,10 +638,10 @@ class LineBreak {
448
638
  });
449
639
  items.push({
450
640
  type: ItemType.DISCRETIONARY,
451
- width: 0, // No width in normal flow
452
- preBreak: '-', // Hyphen appears before break
453
- postBreak: '', // Nothing after break
454
- noBreak: '', // Nothing if no break (word continues)
641
+ width: 0,
642
+ preBreak: '-',
643
+ postBreak: '',
644
+ noBreak: '',
455
645
  preBreakWidth: measureText('-'),
456
646
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
457
647
  flagged: true,
@@ -469,7 +659,6 @@ class LineBreak {
469
659
  });
470
660
  }
471
661
  else {
472
- // No hyphenation points, add as single box
473
662
  items.push({
474
663
  type: ItemType.BOX,
475
664
  width: measureText(segment),
@@ -479,7 +668,6 @@ class LineBreak {
479
668
  }
480
669
  }
481
670
  else {
482
- // No hyphenation, add as single box
483
671
  items.push({
484
672
  type: ItemType.BOX,
485
673
  width: measureText(segment),
@@ -495,27 +683,22 @@ class LineBreak {
495
683
  }
496
684
  return items;
497
685
  }
498
- // Detect if breakpoints create problematic single-word lines
499
- static hasSingleWordLines(items, breakpoints, lineWidth) {
686
+ // Detect if breakpoints create problematic short lines
687
+ static hasShortLines(items, breakpoints, lineWidth, threshold) {
500
688
  // Check each line segment (except the last, which can naturally be short)
501
689
  let lineStart = 0;
502
690
  for (let i = 0; i < breakpoints.length - 1; i++) {
503
691
  const breakpoint = breakpoints[i];
504
- // Count glue items (spaces) between line start and breakpoint
505
- let glueCount = 0;
506
692
  let totalWidth = 0;
507
693
  for (let j = lineStart; j < breakpoint; j++) {
508
- if (items[j].type === ItemType.GLUE) {
509
- glueCount++;
510
- }
511
694
  if (items[j].type !== ItemType.PENALTY) {
512
695
  totalWidth += items[j].width;
513
696
  }
514
697
  }
515
- // Single word line = no glue items
516
- if (glueCount === 0 && totalWidth > 0) {
698
+ // Check if line is narrow relative to target width
699
+ if (totalWidth > 0) {
517
700
  const widthRatio = totalWidth / lineWidth;
518
- if (widthRatio < SINGLE_WORD_WIDTH_THRESHOLD) {
701
+ if (widthRatio < threshold) {
519
702
  return true;
520
703
  }
521
704
  }
@@ -530,7 +713,7 @@ class LineBreak {
530
713
  align: options.align || 'left',
531
714
  hyphenate: options.hyphenate || false
532
715
  });
533
- const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0, disableSingleWordDetection = false } = options;
716
+ const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0, disableShortLineDetection = false, shortLineThreshold = SHORT_LINE_WIDTH_THRESHOLD } = options;
534
717
  // Handle multiple paragraphs by processing each independently
535
718
  if (respectExistingBreaks && text.includes('\n')) {
536
719
  const paragraphs = text.split('\n');
@@ -609,39 +792,32 @@ class LineBreak {
609
792
  }
610
793
  ];
611
794
  }
612
- // Itemize text once, including all potential hyphenation points
613
- const allItems = LineBreak.itemizeText(text, measureText, useHyphenation, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
795
+ // Itemize without hyphenation first (TeX approach: only compute if needed)
796
+ const allItems = LineBreak.itemizeText(text, measureText, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
614
797
  if (allItems.length === 0) {
615
798
  return [];
616
799
  }
617
- // Iteratively increase emergency stretch to eliminate short single-word lines.
618
- // Post-processing approach preserves TeX algorithm integrity while itemization
619
- // (the expensive part) happens once
620
800
  const MAX_ITERATIONS = 5;
621
801
  let iteration = 0;
622
802
  let currentEmergencyStretch = initialEmergencyStretch;
623
803
  let resultLines = null;
624
- const singleWordDetectionEnabled = !disableSingleWordDetection;
804
+ const shortLineDetectionEnabled = !disableShortLineDetection;
625
805
  while (iteration < MAX_ITERATIONS) {
626
- // Three-pass approach for optimal line breaking:
627
- // First pass: Try without hyphenation using pretolerance (fast)
628
- // Second pass: Enable hyphenation if available, use tolerance (quality)
629
- // Final pass: Emergency stretch for difficult paragraphs (last resort)
806
+ // Three-pass approach matching TeX:
807
+ // First pass: without hyphenation (pretolerance)
808
+ // Second pass: with hyphenation, only if first pass fails (tolerance)
809
+ // Emergency pass: additional stretch as last resort
630
810
  // First pass: no hyphenation
631
- let currentItems = useHyphenation
632
- ? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
633
- item.penalty !==
634
- (context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
635
- : allItems;
811
+ let currentItems = allItems;
636
812
  let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
637
- // Second pass: with hyphenation
813
+ // Second pass: compute hyphenation only if needed
638
814
  if (breaks.length === 0 && useHyphenation) {
639
- currentItems = allItems;
815
+ const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
816
+ currentItems = itemsWithHyphenation;
640
817
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
641
818
  }
642
- // Final pass: emergency stretch
819
+ // Emergency pass: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
643
820
  if (breaks.length === 0) {
644
- currentItems = allItems;
645
821
  breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
646
822
  }
647
823
  // Force with infinite tolerance if still no breaks found
@@ -652,13 +828,13 @@ class LineBreak {
652
828
  if (breaks.length > 0) {
653
829
  const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
654
830
  resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
655
- // Check for single-word lines if detection is enabled
656
- if (singleWordDetectionEnabled &&
831
+ // Check for short lines if detection is enabled
832
+ if (shortLineDetectionEnabled &&
657
833
  breaks.length > 1 &&
658
- LineBreak.hasSingleWordLines(currentItems, breaks, width)) {
834
+ LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
659
835
  // Increase emergency stretch and try again
660
836
  currentEmergencyStretch +=
661
- width * SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT;
837
+ width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
662
838
  iteration++;
663
839
  continue;
664
840
  }
@@ -992,6 +1168,8 @@ class LineBreak {
992
1168
  const item = items[i];
993
1169
  widths[i + 1] = widths[i] + item.width;
994
1170
  if (item.type === ItemType.PENALTY) {
1171
+ stretches[i + 1] = stretches[i];
1172
+ shrinks[i + 1] = shrinks[i];
995
1173
  minWidths[i + 1] = minWidths[i];
996
1174
  }
997
1175
  else if (item.type === ItemType.GLUE) {
@@ -1242,7 +1420,7 @@ class TextLayout {
1242
1420
  this.loadedFont = loadedFont;
1243
1421
  }
1244
1422
  computeLines(options) {
1245
- const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection, letterSpacing } = options;
1423
+ const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableShortLineDetection, shortLineThreshold, letterSpacing } = options;
1246
1424
  let lines;
1247
1425
  if (width) {
1248
1426
  // Line breaking uses a measureText function that already includes letterSpacing,
@@ -1268,7 +1446,8 @@ class TextLayout {
1268
1446
  exhyphenpenalty,
1269
1447
  doublehyphendemerits,
1270
1448
  looseness,
1271
- disableSingleWordDetection,
1449
+ disableShortLineDetection,
1450
+ shortLineThreshold,
1272
1451
  unitsPerEm: this.loadedFont.upem,
1273
1452
  measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1274
1453
  )
@@ -3850,6 +4029,7 @@ class TextShaper {
3850
4029
  // Apply letter spacing between glyphs (must match what was used in width measurements)
3851
4030
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3852
4031
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4032
+ const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
3853
4033
  for (let i = 0; i < glyphInfos.length; i++) {
3854
4034
  const glyph = glyphInfos[i];
3855
4035
  const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
@@ -3895,6 +4075,29 @@ class TextShaper {
3895
4075
  if (isWhitespace) {
3896
4076
  cursor.x += spaceAdjustment;
3897
4077
  }
4078
+ // CJK glue adjustment (must match exactly where LineBreak adds glue)
4079
+ if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
4080
+ const currentChar = lineInfo.text[glyph.cl];
4081
+ const nextGlyph = glyphInfos[i + 1];
4082
+ const nextChar = lineInfo.text[nextGlyph.cl];
4083
+ const isCJKChar = LineBreak.isCJK(currentChar);
4084
+ const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
4085
+ if (isCJKChar && nextIsCJKChar) {
4086
+ let shouldApply = true;
4087
+ if (LineBreak.isCJClosingPunctuation(nextChar)) {
4088
+ shouldApply = false;
4089
+ }
4090
+ if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4091
+ shouldApply = false;
4092
+ }
4093
+ if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
4094
+ shouldApply = false;
4095
+ }
4096
+ if (shouldApply) {
4097
+ cursor.x += cjkAdjustment;
4098
+ }
4099
+ }
4100
+ }
3898
4101
  }
3899
4102
  if (currentClusterGlyphs.length > 0) {
3900
4103
  clusters.push({
@@ -3927,6 +4130,23 @@ class TextShaper {
3927
4130
  }
3928
4131
  return spaceAdjustment;
3929
4132
  }
4133
+ calculateCJKAdjustment(lineInfo, align) {
4134
+ if (lineInfo.adjustmentRatio === undefined ||
4135
+ align !== 'justify' ||
4136
+ lineInfo.isLastLine) {
4137
+ return 0;
4138
+ }
4139
+ const baseCharWidth = this.loadedFont.upem;
4140
+ const glueStretch = baseCharWidth * 0.04;
4141
+ const glueShrink = baseCharWidth * 0.04;
4142
+ if (lineInfo.adjustmentRatio > 0) {
4143
+ return lineInfo.adjustmentRatio * glueStretch;
4144
+ }
4145
+ else if (lineInfo.adjustmentRatio < 0) {
4146
+ return lineInfo.adjustmentRatio * glueShrink;
4147
+ }
4148
+ return 0;
4149
+ }
3930
4150
  clearCache() {
3931
4151
  this.geometryBuilder.clearCache();
3932
4152
  }
@@ -5044,7 +5264,7 @@ class Text {
5044
5264
  throw new Error('Font not loaded. Use Text.create() with a font option');
5045
5265
  }
5046
5266
  const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
5047
- const { width, direction = 'ltr', align = direction === 'rtl' ? 'right' : 'left', respectExistingBreaks = true, hyphenate = true, language = 'en-us', tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection } = layout;
5267
+ const { width, direction = 'ltr', align = direction === 'rtl' ? 'right' : 'left', respectExistingBreaks = true, hyphenate = true, language = 'en-us', tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableShortLineDetection, shortLineThreshold } = layout;
5048
5268
  let widthInFontUnits;
5049
5269
  if (width !== undefined) {
5050
5270
  widthInFontUnits = width * (this.loadedFont.upem / size);
@@ -5074,7 +5294,8 @@ class Text {
5074
5294
  exhyphenpenalty,
5075
5295
  doublehyphendemerits,
5076
5296
  looseness,
5077
- disableSingleWordDetection,
5297
+ disableShortLineDetection,
5298
+ shortLineThreshold,
5078
5299
  letterSpacing
5079
5300
  });
5080
5301
  const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
package/dist/index.d.ts CHANGED
@@ -315,7 +315,8 @@ interface LayoutOptions {
315
315
  exhyphenpenalty?: number;
316
316
  doublehyphendemerits?: number;
317
317
  looseness?: number;
318
- disableSingleWordDetection?: boolean;
318
+ disableShortLineDetection?: boolean;
319
+ shortLineThreshold?: number;
319
320
  }
320
321
 
321
322
  interface GlyphData {