three-text 0.2.6 → 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.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.6
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
@@ -257,15 +257,15 @@ const DEFAULT_RIGHT_HYPHEN_MIN = 4;
257
257
  const INF_BAD = 10000;
258
258
  // Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
259
259
  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
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
263
263
  class LineBreak {
264
264
  // Calculate badness according to TeX's formula (tex.web §108, line 2337)
265
265
  // Given t (desired adjustment) and s (available stretch/shrink)
266
266
  // Returns approximation to 100(t/s)³, representing how "bad" a line is
267
267
  // Constants are derived from TeX's fixed-point arithmetic:
268
- // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
268
+ // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
269
269
  static badness(t, s) {
270
270
  if (t === 0)
271
271
  return 0;
@@ -324,6 +324,8 @@ class LineBreak {
324
324
  const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
325
325
  return filteredPoints;
326
326
  }
327
+ // Converts text into items (boxes, glues, penalties) for line breaking
328
+ // The measureText function should return widths that include any letter spacing
327
329
  static itemizeText(text, measureText, // function to measure text width
328
330
  hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
329
331
  const items = [];
@@ -347,16 +349,214 @@ class LineBreak {
347
349
  });
348
350
  return items;
349
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
+ }
350
505
  static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
351
506
  const items = [];
352
- // 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 = [];
353
554
  const tokens = text.match(/\S+|\s+/g) || [];
354
555
  let currentIndex = 0;
355
556
  for (let i = 0; i < tokens.length; i++) {
356
557
  const token = tokens[i];
357
- const tokenStartIndex = currentIndex;
558
+ const tokenStartIndex = startOffset + currentIndex;
358
559
  if (/\s+/.test(token)) {
359
- // Handle spaces
360
560
  const width = measureText(token);
361
561
  items.push({
362
562
  type: ItemType.GLUE,
@@ -369,22 +569,19 @@ class LineBreak {
369
569
  currentIndex += token.length;
370
570
  }
371
571
  else {
372
- // Process word, splitting on explicit hyphens
373
- // Split on hyphens while keeping them in the result
374
572
  const segments = token.split(/(-)/);
375
573
  let segmentIndex = tokenStartIndex;
376
574
  for (let j = 0; j < segments.length; j++) {
377
575
  const segment = segments[j];
378
576
  if (!segment)
379
- continue; // Skip empty segments
577
+ continue;
380
578
  if (segment === '-') {
381
- // Handle explicit hyphen as discretionary break
382
579
  items.push({
383
580
  type: ItemType.DISCRETIONARY,
384
- width: measureText('-'), // Width of hyphen in normal flow
385
- preBreak: '-', // Hyphen appears before break
386
- postBreak: '', // Nothing after break
387
- noBreak: '-', // Hyphen if no break
581
+ width: measureText('-'),
582
+ preBreak: '-',
583
+ postBreak: '',
584
+ noBreak: '-',
388
585
  preBreakWidth: measureText('-'),
389
586
  penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
390
587
  flagged: true,
@@ -394,8 +591,6 @@ class LineBreak {
394
591
  segmentIndex += 1;
395
592
  }
396
593
  else {
397
- // Process non-hyphen segment
398
- // First handle soft hyphens (U+00AD)
399
594
  if (segment.includes('\u00AD')) {
400
595
  const partsWithMarkers = segment.split('\u00AD');
401
596
  let runningIndex = 0;
@@ -413,23 +608,23 @@ class LineBreak {
413
608
  if (k < partsWithMarkers.length - 1) {
414
609
  items.push({
415
610
  type: ItemType.DISCRETIONARY,
416
- width: 0, // No width in normal flow
417
- preBreak: '-', // Hyphen appears before break
418
- postBreak: '', // Nothing after break
419
- noBreak: '', // Nothing if no break (word continues)
611
+ width: 0,
612
+ preBreak: '-',
613
+ postBreak: '',
614
+ noBreak: '',
420
615
  preBreakWidth: measureText('-'),
421
616
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
422
617
  flagged: true,
423
618
  text: '',
424
619
  originIndex: segmentIndex + runningIndex
425
620
  });
426
- runningIndex += 1; // Account for the soft hyphen character
621
+ runningIndex += 1;
427
622
  }
428
623
  }
429
624
  }
430
625
  else if (hyphenate &&
431
- segment.length >= lefthyphenmin + righthyphenmin) {
432
- // Apply hyphenation patterns only to segments between explicit hyphens
626
+ segment.length >= lefthyphenmin + righthyphenmin &&
627
+ /^\p{L}+$/u.test(segment)) {
433
628
  const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
434
629
  if (hyphenPoints.length > 0) {
435
630
  let lastPoint = 0;
@@ -443,10 +638,10 @@ class LineBreak {
443
638
  });
444
639
  items.push({
445
640
  type: ItemType.DISCRETIONARY,
446
- width: 0, // No width in normal flow
447
- preBreak: '-', // Hyphen appears before break
448
- postBreak: '', // Nothing after break
449
- noBreak: '', // Nothing if no break (word continues)
641
+ width: 0,
642
+ preBreak: '-',
643
+ postBreak: '',
644
+ noBreak: '',
450
645
  preBreakWidth: measureText('-'),
451
646
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
452
647
  flagged: true,
@@ -464,7 +659,6 @@ class LineBreak {
464
659
  });
465
660
  }
466
661
  else {
467
- // No hyphenation points, add as single box
468
662
  items.push({
469
663
  type: ItemType.BOX,
470
664
  width: measureText(segment),
@@ -474,7 +668,6 @@ class LineBreak {
474
668
  }
475
669
  }
476
670
  else {
477
- // No hyphenation, add as single box
478
671
  items.push({
479
672
  type: ItemType.BOX,
480
673
  width: measureText(segment),
@@ -490,27 +683,22 @@ class LineBreak {
490
683
  }
491
684
  return items;
492
685
  }
493
- // Detect if breakpoints create problematic single-word lines
494
- static hasSingleWordLines(items, breakpoints, lineWidth) {
686
+ // Detect if breakpoints create problematic short lines
687
+ static hasShortLines(items, breakpoints, lineWidth, threshold) {
495
688
  // Check each line segment (except the last, which can naturally be short)
496
689
  let lineStart = 0;
497
690
  for (let i = 0; i < breakpoints.length - 1; i++) {
498
691
  const breakpoint = breakpoints[i];
499
- // Count glue items (spaces) between line start and breakpoint
500
- let glueCount = 0;
501
692
  let totalWidth = 0;
502
693
  for (let j = lineStart; j < breakpoint; j++) {
503
- if (items[j].type === ItemType.GLUE) {
504
- glueCount++;
505
- }
506
694
  if (items[j].type !== ItemType.PENALTY) {
507
695
  totalWidth += items[j].width;
508
696
  }
509
697
  }
510
- // Single word line = no glue items
511
- if (glueCount === 0 && totalWidth > 0) {
698
+ // Check if line is narrow relative to target width
699
+ if (totalWidth > 0) {
512
700
  const widthRatio = totalWidth / lineWidth;
513
- if (widthRatio < SINGLE_WORD_WIDTH_THRESHOLD) {
701
+ if (widthRatio < threshold) {
514
702
  return true;
515
703
  }
516
704
  }
@@ -525,7 +713,7 @@ class LineBreak {
525
713
  align: options.align || 'left',
526
714
  hyphenate: options.hyphenate || false
527
715
  });
528
- 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;
529
717
  // Handle multiple paragraphs by processing each independently
530
718
  if (respectExistingBreaks && text.includes('\n')) {
531
719
  const paragraphs = text.split('\n');
@@ -604,39 +792,32 @@ class LineBreak {
604
792
  }
605
793
  ];
606
794
  }
607
- // Itemize text once, including all potential hyphenation points
608
- 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);
609
797
  if (allItems.length === 0) {
610
798
  return [];
611
799
  }
612
- // Iteratively increase emergency stretch to eliminate short single-word lines.
613
- // Post-processing approach preserves TeX algorithm integrity while itemization
614
- // (the expensive part) happens once
615
800
  const MAX_ITERATIONS = 5;
616
801
  let iteration = 0;
617
802
  let currentEmergencyStretch = initialEmergencyStretch;
618
803
  let resultLines = null;
619
- const singleWordDetectionEnabled = !disableSingleWordDetection;
804
+ const shortLineDetectionEnabled = !disableShortLineDetection;
620
805
  while (iteration < MAX_ITERATIONS) {
621
- // Three-pass approach for optimal line breaking:
622
- // First pass: Try without hyphenation using pretolerance (fast)
623
- // Second pass: Enable hyphenation if available, use tolerance (quality)
624
- // 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
625
810
  // First pass: no hyphenation
626
- let currentItems = useHyphenation
627
- ? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
628
- item.penalty !==
629
- (context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
630
- : allItems;
811
+ let currentItems = allItems;
631
812
  let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
632
- // Second pass: with hyphenation
813
+ // Second pass: compute hyphenation only if needed
633
814
  if (breaks.length === 0 && useHyphenation) {
634
- currentItems = allItems;
815
+ const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
816
+ currentItems = itemsWithHyphenation;
635
817
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
636
818
  }
637
- // Final pass: emergency stretch
819
+ // Emergency pass: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
638
820
  if (breaks.length === 0) {
639
- currentItems = allItems;
640
821
  breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
641
822
  }
642
823
  // Force with infinite tolerance if still no breaks found
@@ -647,13 +828,13 @@ class LineBreak {
647
828
  if (breaks.length > 0) {
648
829
  const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
649
830
  resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
650
- // Check for single-word lines if detection is enabled
651
- if (singleWordDetectionEnabled &&
831
+ // Check for short lines if detection is enabled
832
+ if (shortLineDetectionEnabled &&
652
833
  breaks.length > 1 &&
653
- LineBreak.hasSingleWordLines(currentItems, breaks, width)) {
834
+ LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
654
835
  // Increase emergency stretch and try again
655
836
  currentEmergencyStretch +=
656
- width * SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT;
837
+ width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
657
838
  iteration++;
658
839
  continue;
659
840
  }
@@ -987,6 +1168,8 @@ class LineBreak {
987
1168
  const item = items[i];
988
1169
  widths[i + 1] = widths[i] + item.width;
989
1170
  if (item.type === ItemType.PENALTY) {
1171
+ stretches[i + 1] = stretches[i];
1172
+ shrinks[i + 1] = shrinks[i];
990
1173
  minWidths[i + 1] = minWidths[i];
991
1174
  }
992
1175
  else if (item.type === ItemType.GLUE) {
@@ -1208,6 +1391,9 @@ function convertFontFeaturesToString(features) {
1208
1391
  }
1209
1392
 
1210
1393
  class TextMeasurer {
1394
+ // Measures text width including letter spacing
1395
+ // Letter spacing is added uniformly after each glyph during measurement,
1396
+ // so the widths given to the line-breaking algorithm already account for tracking
1211
1397
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1212
1398
  const buffer = loadedFont.hb.createBuffer();
1213
1399
  buffer.addText(text);
@@ -1234,9 +1420,11 @@ class TextLayout {
1234
1420
  this.loadedFont = loadedFont;
1235
1421
  }
1236
1422
  computeLines(options) {
1237
- 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;
1238
1424
  let lines;
1239
1425
  if (width) {
1426
+ // Line breaking uses a measureText function that already includes letterSpacing,
1427
+ // so widths passed into LineBreak.breakText account for tracking
1240
1428
  lines = LineBreak.breakText({
1241
1429
  text,
1242
1430
  width,
@@ -1258,9 +1446,11 @@ class TextLayout {
1258
1446
  exhyphenpenalty,
1259
1447
  doublehyphendemerits,
1260
1448
  looseness,
1261
- disableSingleWordDetection,
1449
+ disableShortLineDetection,
1450
+ shortLineThreshold,
1262
1451
  unitsPerEm: this.loadedFont.upem,
1263
- measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing)
1452
+ measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1453
+ )
1264
1454
  });
1265
1455
  }
1266
1456
  else {
@@ -2225,7 +2415,11 @@ class Tessellator {
2225
2415
  if (removeOverlaps) {
2226
2416
  logger.log('Two-pass: boundary extraction then triangulation');
2227
2417
  // Extract boundaries to remove overlaps
2418
+ perfLogger.start('Tessellator.boundaryPass', {
2419
+ contourCount: contours.length
2420
+ });
2228
2421
  const boundaryResult = this.performTessellation(contours, 'boundary');
2422
+ perfLogger.end('Tessellator.boundaryPass');
2229
2423
  if (!boundaryResult) {
2230
2424
  logger.warn('libtess returned empty result from boundary pass');
2231
2425
  return { triangles: { vertices: [], indices: [] }, contours: [] };
@@ -2238,7 +2432,11 @@ class Tessellator {
2238
2432
  logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2239
2433
  }
2240
2434
  // Triangulate the contours
2435
+ perfLogger.start('Tessellator.triangulationPass', {
2436
+ contourCount: contours.length
2437
+ });
2241
2438
  const triangleResult = this.performTessellation(contours, 'triangles');
2439
+ perfLogger.end('Tessellator.triangulationPass');
2242
2440
  if (!triangleResult) {
2243
2441
  const warning = removeOverlaps
2244
2442
  ? 'libtess returned empty result from triangulation pass'
@@ -2425,10 +2623,16 @@ const OVERLAP_EPSILON = 1e-3;
2425
2623
  class BoundaryClusterer {
2426
2624
  constructor() { }
2427
2625
  cluster(glyphContoursList, positions) {
2626
+ perfLogger.start('BoundaryClusterer.cluster', {
2627
+ glyphCount: glyphContoursList.length
2628
+ });
2428
2629
  if (glyphContoursList.length === 0) {
2630
+ perfLogger.end('BoundaryClusterer.cluster');
2429
2631
  return [];
2430
2632
  }
2431
- return this.clusterSweepLine(glyphContoursList, positions);
2633
+ const result = this.clusterSweepLine(glyphContoursList, positions);
2634
+ perfLogger.end('BoundaryClusterer.cluster');
2635
+ return result;
2432
2636
  }
2433
2637
  clusterSweepLine(glyphContoursList, positions) {
2434
2638
  const n = glyphContoursList.length;
@@ -3069,6 +3273,11 @@ class GlyphContourCollector {
3069
3273
  this.currentGlyphBounds.max.set(-Infinity, -Infinity);
3070
3274
  // Record position for this glyph
3071
3275
  this.glyphPositions.push(this.currentPosition.clone());
3276
+ // Time polygonization + path optimization per glyph
3277
+ perfLogger.start('Glyph.polygonizeAndOptimize', {
3278
+ glyphId,
3279
+ textIndex
3280
+ });
3072
3281
  }
3073
3282
  finishGlyph() {
3074
3283
  if (this.currentPath) {
@@ -3092,6 +3301,8 @@ class GlyphContourCollector {
3092
3301
  // Track textIndex separately
3093
3302
  this.glyphTextIndices.push(this.currentTextIndex);
3094
3303
  }
3304
+ // Stop timing for this glyph (even if it ended up empty)
3305
+ perfLogger.end('Glyph.polygonizeAndOptimize');
3095
3306
  this.currentGlyphPaths = [];
3096
3307
  }
3097
3308
  onMoveTo(x, y) {
@@ -3326,6 +3537,184 @@ class DrawCallbackHandler {
3326
3537
  }
3327
3538
  }
3328
3539
 
3540
+ // Generic LRU (Least Recently Used) cache with optional memory-based eviction
3541
+ class LRUCache {
3542
+ constructor(options = {}) {
3543
+ this.cache = new Map();
3544
+ this.head = null;
3545
+ this.tail = null;
3546
+ this.stats = {
3547
+ hits: 0,
3548
+ misses: 0,
3549
+ evictions: 0,
3550
+ size: 0,
3551
+ memoryUsage: 0
3552
+ };
3553
+ this.options = {
3554
+ maxEntries: options.maxEntries ?? Infinity,
3555
+ maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
3556
+ calculateSize: options.calculateSize ?? (() => 0),
3557
+ onEvict: options.onEvict
3558
+ };
3559
+ }
3560
+ get(key) {
3561
+ const node = this.cache.get(key);
3562
+ if (node) {
3563
+ this.stats.hits++;
3564
+ this.moveToHead(node);
3565
+ return node.value;
3566
+ }
3567
+ else {
3568
+ this.stats.misses++;
3569
+ return undefined;
3570
+ }
3571
+ }
3572
+ has(key) {
3573
+ return this.cache.has(key);
3574
+ }
3575
+ set(key, value) {
3576
+ // If key already exists, update it
3577
+ const existingNode = this.cache.get(key);
3578
+ if (existingNode) {
3579
+ const oldSize = this.options.calculateSize(existingNode.value);
3580
+ const newSize = this.options.calculateSize(value);
3581
+ this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
3582
+ existingNode.value = value;
3583
+ this.moveToHead(existingNode);
3584
+ return;
3585
+ }
3586
+ const size = this.options.calculateSize(value);
3587
+ // Evict entries if we exceed limits
3588
+ this.evictIfNeeded(size);
3589
+ // Create new node
3590
+ const node = {
3591
+ key,
3592
+ value,
3593
+ prev: null,
3594
+ next: null
3595
+ };
3596
+ this.cache.set(key, node);
3597
+ this.addToHead(node);
3598
+ this.stats.size = this.cache.size;
3599
+ this.stats.memoryUsage += size;
3600
+ }
3601
+ delete(key) {
3602
+ const node = this.cache.get(key);
3603
+ if (!node)
3604
+ return false;
3605
+ const size = this.options.calculateSize(node.value);
3606
+ this.removeNode(node);
3607
+ this.cache.delete(key);
3608
+ this.stats.size = this.cache.size;
3609
+ this.stats.memoryUsage -= size;
3610
+ if (this.options.onEvict) {
3611
+ this.options.onEvict(key, node.value);
3612
+ }
3613
+ return true;
3614
+ }
3615
+ clear() {
3616
+ if (this.options.onEvict) {
3617
+ for (const [key, node] of this.cache) {
3618
+ this.options.onEvict(key, node.value);
3619
+ }
3620
+ }
3621
+ this.cache.clear();
3622
+ this.head = null;
3623
+ this.tail = null;
3624
+ this.stats = {
3625
+ hits: 0,
3626
+ misses: 0,
3627
+ evictions: 0,
3628
+ size: 0,
3629
+ memoryUsage: 0
3630
+ };
3631
+ }
3632
+ getStats() {
3633
+ const total = this.stats.hits + this.stats.misses;
3634
+ const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
3635
+ const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
3636
+ return {
3637
+ ...this.stats,
3638
+ hitRate,
3639
+ memoryUsageMB
3640
+ };
3641
+ }
3642
+ keys() {
3643
+ const keys = [];
3644
+ let current = this.head;
3645
+ while (current) {
3646
+ keys.push(current.key);
3647
+ current = current.next;
3648
+ }
3649
+ return keys;
3650
+ }
3651
+ get size() {
3652
+ return this.cache.size;
3653
+ }
3654
+ evictIfNeeded(requiredSize) {
3655
+ // Evict by entry count
3656
+ while (this.cache.size >= this.options.maxEntries && this.tail) {
3657
+ this.evictTail();
3658
+ }
3659
+ // Evict by memory usage
3660
+ if (this.options.maxMemoryBytes < Infinity) {
3661
+ while (this.tail &&
3662
+ this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
3663
+ this.evictTail();
3664
+ }
3665
+ }
3666
+ }
3667
+ evictTail() {
3668
+ if (!this.tail)
3669
+ return;
3670
+ const nodeToRemove = this.tail;
3671
+ const size = this.options.calculateSize(nodeToRemove.value);
3672
+ this.removeTail();
3673
+ this.cache.delete(nodeToRemove.key);
3674
+ this.stats.size = this.cache.size;
3675
+ this.stats.memoryUsage -= size;
3676
+ this.stats.evictions++;
3677
+ if (this.options.onEvict) {
3678
+ this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
3679
+ }
3680
+ }
3681
+ addToHead(node) {
3682
+ if (!this.head) {
3683
+ this.head = this.tail = node;
3684
+ }
3685
+ else {
3686
+ node.next = this.head;
3687
+ this.head.prev = node;
3688
+ this.head = node;
3689
+ }
3690
+ }
3691
+ removeNode(node) {
3692
+ if (node.prev) {
3693
+ node.prev.next = node.next;
3694
+ }
3695
+ else {
3696
+ this.head = node.next;
3697
+ }
3698
+ if (node.next) {
3699
+ node.next.prev = node.prev;
3700
+ }
3701
+ else {
3702
+ this.tail = node.prev;
3703
+ }
3704
+ }
3705
+ removeTail() {
3706
+ if (this.tail) {
3707
+ this.removeNode(this.tail);
3708
+ }
3709
+ }
3710
+ moveToHead(node) {
3711
+ if (node === this.head)
3712
+ return;
3713
+ this.removeNode(node);
3714
+ this.addToHead(node);
3715
+ }
3716
+ }
3717
+
3329
3718
  class GlyphGeometryBuilder {
3330
3719
  constructor(cache, loadedFont) {
3331
3720
  this.fontId = 'default';
@@ -3338,6 +3727,16 @@ class GlyphGeometryBuilder {
3338
3727
  this.collector = new GlyphContourCollector();
3339
3728
  this.drawCallbacks = new DrawCallbackHandler();
3340
3729
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3730
+ this.contourCache = new LRUCache({
3731
+ maxEntries: 1000,
3732
+ calculateSize: (contours) => {
3733
+ let size = 0;
3734
+ for (const path of contours.paths) {
3735
+ size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
3736
+ }
3737
+ return size + 64; // bounds overhead
3738
+ }
3739
+ });
3341
3740
  }
3342
3741
  getOptimizationStats() {
3343
3742
  return this.collector.getOptimizationStats();
@@ -3482,30 +3881,37 @@ class GlyphGeometryBuilder {
3482
3881
  };
3483
3882
  }
3484
3883
  getContoursForGlyph(glyphId) {
3884
+ const cached = this.contourCache.get(glyphId);
3885
+ if (cached) {
3886
+ return cached;
3887
+ }
3485
3888
  this.collector.reset();
3486
3889
  this.collector.beginGlyph(glyphId, 0);
3487
3890
  this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
3488
3891
  this.collector.finishGlyph();
3489
3892
  const collected = this.collector.getCollectedGlyphs()[0];
3490
- // Return empty contours for glyphs with no paths (e.g., space, zero-width characters)
3491
- if (!collected) {
3492
- return {
3493
- glyphId,
3494
- paths: [],
3495
- bounds: {
3496
- min: { x: 0, y: 0 },
3497
- max: { x: 0, y: 0 }
3498
- }
3499
- };
3500
- }
3501
- return collected;
3893
+ const contours = collected || {
3894
+ glyphId,
3895
+ paths: [],
3896
+ bounds: {
3897
+ min: { x: 0, y: 0 },
3898
+ max: { x: 0, y: 0 }
3899
+ }
3900
+ };
3901
+ this.contourCache.set(glyphId, contours);
3902
+ return contours;
3502
3903
  }
3503
3904
  tessellateGlyphCluster(paths, depth, isCFF) {
3504
3905
  const processedGeometry = this.tessellator.process(paths, true, isCFF);
3505
3906
  return this.extrudeAndPackage(processedGeometry, depth);
3506
3907
  }
3507
3908
  extrudeAndPackage(processedGeometry, depth) {
3909
+ perfLogger.start('Extruder.extrude', {
3910
+ depth,
3911
+ upem: this.loadedFont.upem
3912
+ });
3508
3913
  const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
3914
+ perfLogger.end('Extruder.extrude');
3509
3915
  // Compute bounding box from vertices
3510
3916
  const vertices = extrudedResult.vertices;
3511
3917
  let minX = Infinity, minY = Infinity, minZ = Infinity;
@@ -3547,6 +3953,7 @@ class GlyphGeometryBuilder {
3547
3953
  pathCount: glyphContours.paths.length
3548
3954
  });
3549
3955
  const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
3956
+ perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
3550
3957
  return this.extrudeAndPackage(processedGeometry, depth);
3551
3958
  }
3552
3959
  updatePlaneBounds(glyphBounds, planeBounds) {
@@ -3619,8 +4026,10 @@ class TextShaper {
3619
4026
  let currentClusterText = '';
3620
4027
  let clusterStartPosition = new Vec3();
3621
4028
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
4029
+ // Apply letter spacing between glyphs (must match what was used in width measurements)
3622
4030
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3623
4031
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4032
+ const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
3624
4033
  for (let i = 0; i < glyphInfos.length; i++) {
3625
4034
  const glyph = glyphInfos[i];
3626
4035
  const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
@@ -3666,6 +4075,29 @@ class TextShaper {
3666
4075
  if (isWhitespace) {
3667
4076
  cursor.x += spaceAdjustment;
3668
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
+ }
3669
4101
  }
3670
4102
  if (currentClusterGlyphs.length > 0) {
3671
4103
  clusters.push({
@@ -3698,6 +4130,23 @@ class TextShaper {
3698
4130
  }
3699
4131
  return spaceAdjustment;
3700
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
+ }
3701
4150
  clearCache() {
3702
4151
  this.geometryBuilder.clearCache();
3703
4152
  }
@@ -4815,7 +5264,7 @@ class Text {
4815
5264
  throw new Error('Font not loaded. Use Text.create() with a font option');
4816
5265
  }
4817
5266
  const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
4818
- 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;
4819
5268
  let widthInFontUnits;
4820
5269
  if (width !== undefined) {
4821
5270
  widthInFontUnits = width * (this.loadedFont.upem / size);
@@ -4845,7 +5294,8 @@ class Text {
4845
5294
  exhyphenpenalty,
4846
5295
  doublehyphendemerits,
4847
5296
  looseness,
4848
- disableSingleWordDetection,
5297
+ disableShortLineDetection,
5298
+ shortLineThreshold,
4849
5299
  letterSpacing
4850
5300
  });
4851
5301
  const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);