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.js 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
@@ -254,15 +254,15 @@ const DEFAULT_RIGHT_HYPHEN_MIN = 4;
254
254
  const INF_BAD = 10000;
255
255
  // Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
256
256
  const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
257
- // Another non TeX default: Single-word line detection thresholds
258
- const SINGLE_WORD_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
259
- const SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
257
+ // Another non TeX default: Short line detection thresholds
258
+ const SHORT_LINE_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
259
+ const SHORT_LINE_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
260
260
  class LineBreak {
261
261
  // Calculate badness according to TeX's formula (tex.web §108, line 2337)
262
262
  // Given t (desired adjustment) and s (available stretch/shrink)
263
263
  // Returns approximation to 100(t/s)³, representing how "bad" a line is
264
264
  // Constants are derived from TeX's fixed-point arithmetic:
265
- // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
265
+ // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
266
266
  static badness(t, s) {
267
267
  if (t === 0)
268
268
  return 0;
@@ -321,6 +321,8 @@ class LineBreak {
321
321
  const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
322
322
  return filteredPoints;
323
323
  }
324
+ // Converts text into items (boxes, glues, penalties) for line breaking
325
+ // The measureText function should return widths that include any letter spacing
324
326
  static itemizeText(text, measureText, // function to measure text width
325
327
  hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
326
328
  const items = [];
@@ -344,16 +346,214 @@ class LineBreak {
344
346
  });
345
347
  return items;
346
348
  }
349
+ // Chinese, Japanese, and Korean character ranges
350
+ static isCJK(char) {
351
+ const code = char.codePointAt(0);
352
+ if (code === undefined)
353
+ return false;
354
+ return (
355
+ // CJK Unified Ideographs
356
+ (code >= 0x4e00 && code <= 0x9fff) ||
357
+ // CJK Extension A
358
+ (code >= 0x3400 && code <= 0x4dbf) ||
359
+ // CJK Extension B
360
+ (code >= 0x20000 && code <= 0x2a6df) ||
361
+ // CJK Extension C
362
+ (code >= 0x2a700 && code <= 0x2b73f) ||
363
+ // CJK Extension D
364
+ (code >= 0x2b740 && code <= 0x2b81f) ||
365
+ // CJK Extension E
366
+ (code >= 0x2b820 && code <= 0x2ceaf) ||
367
+ // CJK Compatibility Ideographs
368
+ (code >= 0xf900 && code <= 0xfaff) ||
369
+ // Hiragana
370
+ (code >= 0x3040 && code <= 0x309f) ||
371
+ // Katakana
372
+ (code >= 0x30a0 && code <= 0x30ff) ||
373
+ // Hangul Syllables
374
+ (code >= 0xac00 && code <= 0xd7af) ||
375
+ // Hangul Jamo
376
+ (code >= 0x1100 && code <= 0x11ff) ||
377
+ // Hangul Compatibility Jamo
378
+ (code >= 0x3130 && code <= 0x318f) ||
379
+ // Hangul Jamo Extended-A
380
+ (code >= 0xa960 && code <= 0xa97f) ||
381
+ // Hangul Jamo Extended-B
382
+ (code >= 0xd7b0 && code <= 0xd7ff) ||
383
+ // Halfwidth and Fullwidth Forms (Korean)
384
+ (code >= 0xffa0 && code <= 0xffdc));
385
+ }
386
+ // Closing punctuation where line breaks are prohibited (UAX #14 LB30, JIS X 4051)
387
+ static isCJClosingPunctuation(char) {
388
+ const code = char.charCodeAt(0);
389
+ return (code === 0x3001 || // 、
390
+ code === 0x3002 || // 。
391
+ code === 0xff0c || // ,
392
+ code === 0xff0e || // .
393
+ code === 0xff1a || // :
394
+ code === 0xff1b || // ;
395
+ code === 0xff01 || // !
396
+ code === 0xff1f || // ?
397
+ code === 0xff09 || // )
398
+ code === 0x3011 || // 】
399
+ code === 0xff5d || // }
400
+ code === 0x300d || // 」
401
+ code === 0x300f || // 』
402
+ code === 0x3009 || // 〉
403
+ code === 0x300b || // 》
404
+ code === 0x3015 || // 〕
405
+ code === 0x3017 || // 〗
406
+ code === 0x3019 || // 〙
407
+ code === 0x301b || // 〛
408
+ code === 0x30fc || // ー
409
+ code === 0x2014 || // —
410
+ code === 0x2026 || // …
411
+ code === 0x2025 // ‥
412
+ );
413
+ }
414
+ // Opening punctuation where line breaks are prohibited (UAX #14 LB30a, JIS X 4051)
415
+ static isCJOpeningPunctuation(char) {
416
+ const code = char.charCodeAt(0);
417
+ return (code === 0xff08 || // (
418
+ code === 0x3010 || // 【
419
+ code === 0xff5b || // {
420
+ code === 0x300c || // 「
421
+ code === 0x300e || // 『
422
+ code === 0x3008 || // 〈
423
+ code === 0x300a || // 《
424
+ code === 0x3014 || // 〔
425
+ code === 0x3016 || // 〖
426
+ code === 0x3018 || // 〘
427
+ code === 0x301a // 〚
428
+ );
429
+ }
430
+ static isCJPunctuation(char) {
431
+ return (this.isCJClosingPunctuation(char) || this.isCJOpeningPunctuation(char));
432
+ }
433
+ // CJK (Chinese/Japanese/Korean) character-level itemization with inter-character glue
434
+ static itemizeCJKText(text, measureText, context, startOffset = 0, glueParams) {
435
+ const items = [];
436
+ const chars = Array.from(text);
437
+ let textPosition = startOffset;
438
+ // Inter-character glue parameters
439
+ let glueWidth;
440
+ let glueStretch;
441
+ let glueShrink;
442
+ if (glueParams) {
443
+ glueWidth = glueParams.width;
444
+ glueStretch = glueParams.stretch;
445
+ glueShrink = glueParams.shrink;
446
+ }
447
+ else {
448
+ const baseCharWidth = measureText('字');
449
+ glueWidth = 0;
450
+ glueStretch = baseCharWidth * 0.04;
451
+ glueShrink = baseCharWidth * 0.04;
452
+ }
453
+ for (let i = 0; i < chars.length; i++) {
454
+ const char = chars[i];
455
+ const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
456
+ if (/\s/.test(char)) {
457
+ const width = measureText(char);
458
+ items.push({
459
+ type: ItemType.GLUE,
460
+ width,
461
+ stretch: width * SPACE_STRETCH_RATIO,
462
+ shrink: width * SPACE_SHRINK_RATIO,
463
+ text: char,
464
+ originIndex: textPosition
465
+ });
466
+ textPosition += char.length;
467
+ continue;
468
+ }
469
+ items.push({
470
+ type: ItemType.BOX,
471
+ width: measureText(char),
472
+ text: char,
473
+ originIndex: textPosition
474
+ });
475
+ textPosition += char.length;
476
+ // Glue after a box creates a break opportunity
477
+ // Must not add glue where breaks are prohibited by Chinese/Japanese line breaking rules
478
+ if (nextChar && !/\s/.test(nextChar)) {
479
+ let canBreak = true;
480
+ if (this.isCJClosingPunctuation(nextChar)) {
481
+ canBreak = false;
482
+ }
483
+ if (this.isCJOpeningPunctuation(char)) {
484
+ canBreak = false;
485
+ }
486
+ // Avoid stretch between consecutive punctuation (?" or 。」)
487
+ const isPunctPair = this.isCJPunctuation(char) && this.isCJPunctuation(nextChar);
488
+ if (canBreak && !isPunctPair) {
489
+ items.push({
490
+ type: ItemType.GLUE,
491
+ width: glueWidth,
492
+ stretch: glueStretch,
493
+ shrink: glueShrink,
494
+ text: '',
495
+ originIndex: textPosition
496
+ });
497
+ }
498
+ }
499
+ }
500
+ return items;
501
+ }
347
502
  static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
348
503
  const items = [];
349
- // First split into words and spaces
504
+ const chars = Array.from(text);
505
+ // Calculate CJK glue parameters once for consistency across all segments
506
+ const baseCharWidth = measureText('字');
507
+ const cjkGlueParams = {
508
+ width: 0,
509
+ stretch: baseCharWidth * 0.04,
510
+ shrink: baseCharWidth * 0.04
511
+ };
512
+ let buffer = '';
513
+ let bufferStart = 0;
514
+ let bufferScript = null;
515
+ let textPosition = 0;
516
+ const flushBuffer = () => {
517
+ if (buffer.length === 0)
518
+ return;
519
+ if (bufferScript === 'cjk') {
520
+ const cjkItems = this.itemizeCJKText(buffer, measureText, context, bufferStart, cjkGlueParams);
521
+ items.push(...cjkItems);
522
+ }
523
+ else {
524
+ const wordItems = this.itemizeWordBased(buffer, bufferStart, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context);
525
+ items.push(...wordItems);
526
+ }
527
+ buffer = '';
528
+ bufferScript = null;
529
+ };
530
+ for (let i = 0; i < chars.length; i++) {
531
+ const char = chars[i];
532
+ const isCJKChar = this.isCJK(char);
533
+ const currentScript = isCJKChar ? 'cjk' : 'word';
534
+ if (bufferScript !== null && bufferScript !== currentScript) {
535
+ flushBuffer();
536
+ bufferStart = textPosition;
537
+ }
538
+ if (bufferScript === null) {
539
+ bufferScript = currentScript;
540
+ bufferStart = textPosition;
541
+ }
542
+ buffer += char;
543
+ textPosition += char.length;
544
+ }
545
+ flushBuffer();
546
+ return items;
547
+ }
548
+ // Word-based itemization for alphabetic scripts (Latin, Cyrillic, Greek, etc.)
549
+ static itemizeWordBased(text, startOffset, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
550
+ const items = [];
350
551
  const tokens = text.match(/\S+|\s+/g) || [];
351
552
  let currentIndex = 0;
352
553
  for (let i = 0; i < tokens.length; i++) {
353
554
  const token = tokens[i];
354
- const tokenStartIndex = currentIndex;
555
+ const tokenStartIndex = startOffset + currentIndex;
355
556
  if (/\s+/.test(token)) {
356
- // Handle spaces
357
557
  const width = measureText(token);
358
558
  items.push({
359
559
  type: ItemType.GLUE,
@@ -366,22 +566,19 @@ class LineBreak {
366
566
  currentIndex += token.length;
367
567
  }
368
568
  else {
369
- // Process word, splitting on explicit hyphens
370
- // Split on hyphens while keeping them in the result
371
569
  const segments = token.split(/(-)/);
372
570
  let segmentIndex = tokenStartIndex;
373
571
  for (let j = 0; j < segments.length; j++) {
374
572
  const segment = segments[j];
375
573
  if (!segment)
376
- continue; // Skip empty segments
574
+ continue;
377
575
  if (segment === '-') {
378
- // Handle explicit hyphen as discretionary break
379
576
  items.push({
380
577
  type: ItemType.DISCRETIONARY,
381
- width: measureText('-'), // Width of hyphen in normal flow
382
- preBreak: '-', // Hyphen appears before break
383
- postBreak: '', // Nothing after break
384
- noBreak: '-', // Hyphen if no break
578
+ width: measureText('-'),
579
+ preBreak: '-',
580
+ postBreak: '',
581
+ noBreak: '-',
385
582
  preBreakWidth: measureText('-'),
386
583
  penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
387
584
  flagged: true,
@@ -391,8 +588,6 @@ class LineBreak {
391
588
  segmentIndex += 1;
392
589
  }
393
590
  else {
394
- // Process non-hyphen segment
395
- // First handle soft hyphens (U+00AD)
396
591
  if (segment.includes('\u00AD')) {
397
592
  const partsWithMarkers = segment.split('\u00AD');
398
593
  let runningIndex = 0;
@@ -410,23 +605,23 @@ class LineBreak {
410
605
  if (k < partsWithMarkers.length - 1) {
411
606
  items.push({
412
607
  type: ItemType.DISCRETIONARY,
413
- width: 0, // No width in normal flow
414
- preBreak: '-', // Hyphen appears before break
415
- postBreak: '', // Nothing after break
416
- noBreak: '', // Nothing if no break (word continues)
608
+ width: 0,
609
+ preBreak: '-',
610
+ postBreak: '',
611
+ noBreak: '',
417
612
  preBreakWidth: measureText('-'),
418
613
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
419
614
  flagged: true,
420
615
  text: '',
421
616
  originIndex: segmentIndex + runningIndex
422
617
  });
423
- runningIndex += 1; // Account for the soft hyphen character
618
+ runningIndex += 1;
424
619
  }
425
620
  }
426
621
  }
427
622
  else if (hyphenate &&
428
- segment.length >= lefthyphenmin + righthyphenmin) {
429
- // Apply hyphenation patterns only to segments between explicit hyphens
623
+ segment.length >= lefthyphenmin + righthyphenmin &&
624
+ /^\p{L}+$/u.test(segment)) {
430
625
  const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
431
626
  if (hyphenPoints.length > 0) {
432
627
  let lastPoint = 0;
@@ -440,10 +635,10 @@ class LineBreak {
440
635
  });
441
636
  items.push({
442
637
  type: ItemType.DISCRETIONARY,
443
- width: 0, // No width in normal flow
444
- preBreak: '-', // Hyphen appears before break
445
- postBreak: '', // Nothing after break
446
- noBreak: '', // Nothing if no break (word continues)
638
+ width: 0,
639
+ preBreak: '-',
640
+ postBreak: '',
641
+ noBreak: '',
447
642
  preBreakWidth: measureText('-'),
448
643
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
449
644
  flagged: true,
@@ -461,7 +656,6 @@ class LineBreak {
461
656
  });
462
657
  }
463
658
  else {
464
- // No hyphenation points, add as single box
465
659
  items.push({
466
660
  type: ItemType.BOX,
467
661
  width: measureText(segment),
@@ -471,7 +665,6 @@ class LineBreak {
471
665
  }
472
666
  }
473
667
  else {
474
- // No hyphenation, add as single box
475
668
  items.push({
476
669
  type: ItemType.BOX,
477
670
  width: measureText(segment),
@@ -487,27 +680,22 @@ class LineBreak {
487
680
  }
488
681
  return items;
489
682
  }
490
- // Detect if breakpoints create problematic single-word lines
491
- static hasSingleWordLines(items, breakpoints, lineWidth) {
683
+ // Detect if breakpoints create problematic short lines
684
+ static hasShortLines(items, breakpoints, lineWidth, threshold) {
492
685
  // Check each line segment (except the last, which can naturally be short)
493
686
  let lineStart = 0;
494
687
  for (let i = 0; i < breakpoints.length - 1; i++) {
495
688
  const breakpoint = breakpoints[i];
496
- // Count glue items (spaces) between line start and breakpoint
497
- let glueCount = 0;
498
689
  let totalWidth = 0;
499
690
  for (let j = lineStart; j < breakpoint; j++) {
500
- if (items[j].type === ItemType.GLUE) {
501
- glueCount++;
502
- }
503
691
  if (items[j].type !== ItemType.PENALTY) {
504
692
  totalWidth += items[j].width;
505
693
  }
506
694
  }
507
- // Single word line = no glue items
508
- if (glueCount === 0 && totalWidth > 0) {
695
+ // Check if line is narrow relative to target width
696
+ if (totalWidth > 0) {
509
697
  const widthRatio = totalWidth / lineWidth;
510
- if (widthRatio < SINGLE_WORD_WIDTH_THRESHOLD) {
698
+ if (widthRatio < threshold) {
511
699
  return true;
512
700
  }
513
701
  }
@@ -522,7 +710,7 @@ class LineBreak {
522
710
  align: options.align || 'left',
523
711
  hyphenate: options.hyphenate || false
524
712
  });
525
- const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0, disableSingleWordDetection = false } = options;
713
+ const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0, disableShortLineDetection = false, shortLineThreshold = SHORT_LINE_WIDTH_THRESHOLD } = options;
526
714
  // Handle multiple paragraphs by processing each independently
527
715
  if (respectExistingBreaks && text.includes('\n')) {
528
716
  const paragraphs = text.split('\n');
@@ -601,39 +789,32 @@ class LineBreak {
601
789
  }
602
790
  ];
603
791
  }
604
- // Itemize text once, including all potential hyphenation points
605
- const allItems = LineBreak.itemizeText(text, measureText, useHyphenation, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
792
+ // Itemize without hyphenation first (TeX approach: only compute if needed)
793
+ const allItems = LineBreak.itemizeText(text, measureText, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
606
794
  if (allItems.length === 0) {
607
795
  return [];
608
796
  }
609
- // Iteratively increase emergency stretch to eliminate short single-word lines.
610
- // Post-processing approach preserves TeX algorithm integrity while itemization
611
- // (the expensive part) happens once
612
797
  const MAX_ITERATIONS = 5;
613
798
  let iteration = 0;
614
799
  let currentEmergencyStretch = initialEmergencyStretch;
615
800
  let resultLines = null;
616
- const singleWordDetectionEnabled = !disableSingleWordDetection;
801
+ const shortLineDetectionEnabled = !disableShortLineDetection;
617
802
  while (iteration < MAX_ITERATIONS) {
618
- // Three-pass approach for optimal line breaking:
619
- // First pass: Try without hyphenation using pretolerance (fast)
620
- // Second pass: Enable hyphenation if available, use tolerance (quality)
621
- // Final pass: Emergency stretch for difficult paragraphs (last resort)
803
+ // Three-pass approach matching TeX:
804
+ // First pass: without hyphenation (pretolerance)
805
+ // Second pass: with hyphenation, only if first pass fails (tolerance)
806
+ // Emergency pass: additional stretch as last resort
622
807
  // First pass: no hyphenation
623
- let currentItems = useHyphenation
624
- ? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
625
- item.penalty !==
626
- (context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
627
- : allItems;
808
+ let currentItems = allItems;
628
809
  let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
629
- // Second pass: with hyphenation
810
+ // Second pass: compute hyphenation only if needed
630
811
  if (breaks.length === 0 && useHyphenation) {
631
- currentItems = allItems;
812
+ const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
813
+ currentItems = itemsWithHyphenation;
632
814
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
633
815
  }
634
- // Final pass: emergency stretch
816
+ // Emergency pass: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
635
817
  if (breaks.length === 0) {
636
- currentItems = allItems;
637
818
  breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
638
819
  }
639
820
  // Force with infinite tolerance if still no breaks found
@@ -644,13 +825,13 @@ class LineBreak {
644
825
  if (breaks.length > 0) {
645
826
  const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
646
827
  resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
647
- // Check for single-word lines if detection is enabled
648
- if (singleWordDetectionEnabled &&
828
+ // Check for short lines if detection is enabled
829
+ if (shortLineDetectionEnabled &&
649
830
  breaks.length > 1 &&
650
- LineBreak.hasSingleWordLines(currentItems, breaks, width)) {
831
+ LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
651
832
  // Increase emergency stretch and try again
652
833
  currentEmergencyStretch +=
653
- width * SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT;
834
+ width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
654
835
  iteration++;
655
836
  continue;
656
837
  }
@@ -984,6 +1165,8 @@ class LineBreak {
984
1165
  const item = items[i];
985
1166
  widths[i + 1] = widths[i] + item.width;
986
1167
  if (item.type === ItemType.PENALTY) {
1168
+ stretches[i + 1] = stretches[i];
1169
+ shrinks[i + 1] = shrinks[i];
987
1170
  minWidths[i + 1] = minWidths[i];
988
1171
  }
989
1172
  else if (item.type === ItemType.GLUE) {
@@ -1205,6 +1388,9 @@ function convertFontFeaturesToString(features) {
1205
1388
  }
1206
1389
 
1207
1390
  class TextMeasurer {
1391
+ // Measures text width including letter spacing
1392
+ // Letter spacing is added uniformly after each glyph during measurement,
1393
+ // so the widths given to the line-breaking algorithm already account for tracking
1208
1394
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1209
1395
  const buffer = loadedFont.hb.createBuffer();
1210
1396
  buffer.addText(text);
@@ -1231,9 +1417,11 @@ class TextLayout {
1231
1417
  this.loadedFont = loadedFont;
1232
1418
  }
1233
1419
  computeLines(options) {
1234
- const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection, letterSpacing } = options;
1420
+ const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableShortLineDetection, shortLineThreshold, letterSpacing } = options;
1235
1421
  let lines;
1236
1422
  if (width) {
1423
+ // Line breaking uses a measureText function that already includes letterSpacing,
1424
+ // so widths passed into LineBreak.breakText account for tracking
1237
1425
  lines = LineBreak.breakText({
1238
1426
  text,
1239
1427
  width,
@@ -1255,9 +1443,11 @@ class TextLayout {
1255
1443
  exhyphenpenalty,
1256
1444
  doublehyphendemerits,
1257
1445
  looseness,
1258
- disableSingleWordDetection,
1446
+ disableShortLineDetection,
1447
+ shortLineThreshold,
1259
1448
  unitsPerEm: this.loadedFont.upem,
1260
- measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing)
1449
+ measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1450
+ )
1261
1451
  });
1262
1452
  }
1263
1453
  else {
@@ -2222,7 +2412,11 @@ class Tessellator {
2222
2412
  if (removeOverlaps) {
2223
2413
  logger.log('Two-pass: boundary extraction then triangulation');
2224
2414
  // Extract boundaries to remove overlaps
2415
+ perfLogger.start('Tessellator.boundaryPass', {
2416
+ contourCount: contours.length
2417
+ });
2225
2418
  const boundaryResult = this.performTessellation(contours, 'boundary');
2419
+ perfLogger.end('Tessellator.boundaryPass');
2226
2420
  if (!boundaryResult) {
2227
2421
  logger.warn('libtess returned empty result from boundary pass');
2228
2422
  return { triangles: { vertices: [], indices: [] }, contours: [] };
@@ -2235,7 +2429,11 @@ class Tessellator {
2235
2429
  logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2236
2430
  }
2237
2431
  // Triangulate the contours
2432
+ perfLogger.start('Tessellator.triangulationPass', {
2433
+ contourCount: contours.length
2434
+ });
2238
2435
  const triangleResult = this.performTessellation(contours, 'triangles');
2436
+ perfLogger.end('Tessellator.triangulationPass');
2239
2437
  if (!triangleResult) {
2240
2438
  const warning = removeOverlaps
2241
2439
  ? 'libtess returned empty result from triangulation pass'
@@ -2422,10 +2620,16 @@ const OVERLAP_EPSILON = 1e-3;
2422
2620
  class BoundaryClusterer {
2423
2621
  constructor() { }
2424
2622
  cluster(glyphContoursList, positions) {
2623
+ perfLogger.start('BoundaryClusterer.cluster', {
2624
+ glyphCount: glyphContoursList.length
2625
+ });
2425
2626
  if (glyphContoursList.length === 0) {
2627
+ perfLogger.end('BoundaryClusterer.cluster');
2426
2628
  return [];
2427
2629
  }
2428
- return this.clusterSweepLine(glyphContoursList, positions);
2630
+ const result = this.clusterSweepLine(glyphContoursList, positions);
2631
+ perfLogger.end('BoundaryClusterer.cluster');
2632
+ return result;
2429
2633
  }
2430
2634
  clusterSweepLine(glyphContoursList, positions) {
2431
2635
  const n = glyphContoursList.length;
@@ -3066,6 +3270,11 @@ class GlyphContourCollector {
3066
3270
  this.currentGlyphBounds.max.set(-Infinity, -Infinity);
3067
3271
  // Record position for this glyph
3068
3272
  this.glyphPositions.push(this.currentPosition.clone());
3273
+ // Time polygonization + path optimization per glyph
3274
+ perfLogger.start('Glyph.polygonizeAndOptimize', {
3275
+ glyphId,
3276
+ textIndex
3277
+ });
3069
3278
  }
3070
3279
  finishGlyph() {
3071
3280
  if (this.currentPath) {
@@ -3089,6 +3298,8 @@ class GlyphContourCollector {
3089
3298
  // Track textIndex separately
3090
3299
  this.glyphTextIndices.push(this.currentTextIndex);
3091
3300
  }
3301
+ // Stop timing for this glyph (even if it ended up empty)
3302
+ perfLogger.end('Glyph.polygonizeAndOptimize');
3092
3303
  this.currentGlyphPaths = [];
3093
3304
  }
3094
3305
  onMoveTo(x, y) {
@@ -3323,6 +3534,184 @@ class DrawCallbackHandler {
3323
3534
  }
3324
3535
  }
3325
3536
 
3537
+ // Generic LRU (Least Recently Used) cache with optional memory-based eviction
3538
+ class LRUCache {
3539
+ constructor(options = {}) {
3540
+ this.cache = new Map();
3541
+ this.head = null;
3542
+ this.tail = null;
3543
+ this.stats = {
3544
+ hits: 0,
3545
+ misses: 0,
3546
+ evictions: 0,
3547
+ size: 0,
3548
+ memoryUsage: 0
3549
+ };
3550
+ this.options = {
3551
+ maxEntries: options.maxEntries ?? Infinity,
3552
+ maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
3553
+ calculateSize: options.calculateSize ?? (() => 0),
3554
+ onEvict: options.onEvict
3555
+ };
3556
+ }
3557
+ get(key) {
3558
+ const node = this.cache.get(key);
3559
+ if (node) {
3560
+ this.stats.hits++;
3561
+ this.moveToHead(node);
3562
+ return node.value;
3563
+ }
3564
+ else {
3565
+ this.stats.misses++;
3566
+ return undefined;
3567
+ }
3568
+ }
3569
+ has(key) {
3570
+ return this.cache.has(key);
3571
+ }
3572
+ set(key, value) {
3573
+ // If key already exists, update it
3574
+ const existingNode = this.cache.get(key);
3575
+ if (existingNode) {
3576
+ const oldSize = this.options.calculateSize(existingNode.value);
3577
+ const newSize = this.options.calculateSize(value);
3578
+ this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
3579
+ existingNode.value = value;
3580
+ this.moveToHead(existingNode);
3581
+ return;
3582
+ }
3583
+ const size = this.options.calculateSize(value);
3584
+ // Evict entries if we exceed limits
3585
+ this.evictIfNeeded(size);
3586
+ // Create new node
3587
+ const node = {
3588
+ key,
3589
+ value,
3590
+ prev: null,
3591
+ next: null
3592
+ };
3593
+ this.cache.set(key, node);
3594
+ this.addToHead(node);
3595
+ this.stats.size = this.cache.size;
3596
+ this.stats.memoryUsage += size;
3597
+ }
3598
+ delete(key) {
3599
+ const node = this.cache.get(key);
3600
+ if (!node)
3601
+ return false;
3602
+ const size = this.options.calculateSize(node.value);
3603
+ this.removeNode(node);
3604
+ this.cache.delete(key);
3605
+ this.stats.size = this.cache.size;
3606
+ this.stats.memoryUsage -= size;
3607
+ if (this.options.onEvict) {
3608
+ this.options.onEvict(key, node.value);
3609
+ }
3610
+ return true;
3611
+ }
3612
+ clear() {
3613
+ if (this.options.onEvict) {
3614
+ for (const [key, node] of this.cache) {
3615
+ this.options.onEvict(key, node.value);
3616
+ }
3617
+ }
3618
+ this.cache.clear();
3619
+ this.head = null;
3620
+ this.tail = null;
3621
+ this.stats = {
3622
+ hits: 0,
3623
+ misses: 0,
3624
+ evictions: 0,
3625
+ size: 0,
3626
+ memoryUsage: 0
3627
+ };
3628
+ }
3629
+ getStats() {
3630
+ const total = this.stats.hits + this.stats.misses;
3631
+ const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
3632
+ const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
3633
+ return {
3634
+ ...this.stats,
3635
+ hitRate,
3636
+ memoryUsageMB
3637
+ };
3638
+ }
3639
+ keys() {
3640
+ const keys = [];
3641
+ let current = this.head;
3642
+ while (current) {
3643
+ keys.push(current.key);
3644
+ current = current.next;
3645
+ }
3646
+ return keys;
3647
+ }
3648
+ get size() {
3649
+ return this.cache.size;
3650
+ }
3651
+ evictIfNeeded(requiredSize) {
3652
+ // Evict by entry count
3653
+ while (this.cache.size >= this.options.maxEntries && this.tail) {
3654
+ this.evictTail();
3655
+ }
3656
+ // Evict by memory usage
3657
+ if (this.options.maxMemoryBytes < Infinity) {
3658
+ while (this.tail &&
3659
+ this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
3660
+ this.evictTail();
3661
+ }
3662
+ }
3663
+ }
3664
+ evictTail() {
3665
+ if (!this.tail)
3666
+ return;
3667
+ const nodeToRemove = this.tail;
3668
+ const size = this.options.calculateSize(nodeToRemove.value);
3669
+ this.removeTail();
3670
+ this.cache.delete(nodeToRemove.key);
3671
+ this.stats.size = this.cache.size;
3672
+ this.stats.memoryUsage -= size;
3673
+ this.stats.evictions++;
3674
+ if (this.options.onEvict) {
3675
+ this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
3676
+ }
3677
+ }
3678
+ addToHead(node) {
3679
+ if (!this.head) {
3680
+ this.head = this.tail = node;
3681
+ }
3682
+ else {
3683
+ node.next = this.head;
3684
+ this.head.prev = node;
3685
+ this.head = node;
3686
+ }
3687
+ }
3688
+ removeNode(node) {
3689
+ if (node.prev) {
3690
+ node.prev.next = node.next;
3691
+ }
3692
+ else {
3693
+ this.head = node.next;
3694
+ }
3695
+ if (node.next) {
3696
+ node.next.prev = node.prev;
3697
+ }
3698
+ else {
3699
+ this.tail = node.prev;
3700
+ }
3701
+ }
3702
+ removeTail() {
3703
+ if (this.tail) {
3704
+ this.removeNode(this.tail);
3705
+ }
3706
+ }
3707
+ moveToHead(node) {
3708
+ if (node === this.head)
3709
+ return;
3710
+ this.removeNode(node);
3711
+ this.addToHead(node);
3712
+ }
3713
+ }
3714
+
3326
3715
  class GlyphGeometryBuilder {
3327
3716
  constructor(cache, loadedFont) {
3328
3717
  this.fontId = 'default';
@@ -3335,6 +3724,16 @@ class GlyphGeometryBuilder {
3335
3724
  this.collector = new GlyphContourCollector();
3336
3725
  this.drawCallbacks = new DrawCallbackHandler();
3337
3726
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3727
+ this.contourCache = new LRUCache({
3728
+ maxEntries: 1000,
3729
+ calculateSize: (contours) => {
3730
+ let size = 0;
3731
+ for (const path of contours.paths) {
3732
+ size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
3733
+ }
3734
+ return size + 64; // bounds overhead
3735
+ }
3736
+ });
3338
3737
  }
3339
3738
  getOptimizationStats() {
3340
3739
  return this.collector.getOptimizationStats();
@@ -3479,30 +3878,37 @@ class GlyphGeometryBuilder {
3479
3878
  };
3480
3879
  }
3481
3880
  getContoursForGlyph(glyphId) {
3881
+ const cached = this.contourCache.get(glyphId);
3882
+ if (cached) {
3883
+ return cached;
3884
+ }
3482
3885
  this.collector.reset();
3483
3886
  this.collector.beginGlyph(glyphId, 0);
3484
3887
  this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
3485
3888
  this.collector.finishGlyph();
3486
3889
  const collected = this.collector.getCollectedGlyphs()[0];
3487
- // Return empty contours for glyphs with no paths (e.g., space, zero-width characters)
3488
- if (!collected) {
3489
- return {
3490
- glyphId,
3491
- paths: [],
3492
- bounds: {
3493
- min: { x: 0, y: 0 },
3494
- max: { x: 0, y: 0 }
3495
- }
3496
- };
3497
- }
3498
- return collected;
3890
+ const contours = collected || {
3891
+ glyphId,
3892
+ paths: [],
3893
+ bounds: {
3894
+ min: { x: 0, y: 0 },
3895
+ max: { x: 0, y: 0 }
3896
+ }
3897
+ };
3898
+ this.contourCache.set(glyphId, contours);
3899
+ return contours;
3499
3900
  }
3500
3901
  tessellateGlyphCluster(paths, depth, isCFF) {
3501
3902
  const processedGeometry = this.tessellator.process(paths, true, isCFF);
3502
3903
  return this.extrudeAndPackage(processedGeometry, depth);
3503
3904
  }
3504
3905
  extrudeAndPackage(processedGeometry, depth) {
3906
+ perfLogger.start('Extruder.extrude', {
3907
+ depth,
3908
+ upem: this.loadedFont.upem
3909
+ });
3505
3910
  const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
3911
+ perfLogger.end('Extruder.extrude');
3506
3912
  // Compute bounding box from vertices
3507
3913
  const vertices = extrudedResult.vertices;
3508
3914
  let minX = Infinity, minY = Infinity, minZ = Infinity;
@@ -3544,6 +3950,7 @@ class GlyphGeometryBuilder {
3544
3950
  pathCount: glyphContours.paths.length
3545
3951
  });
3546
3952
  const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
3953
+ perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
3547
3954
  return this.extrudeAndPackage(processedGeometry, depth);
3548
3955
  }
3549
3956
  updatePlaneBounds(glyphBounds, planeBounds) {
@@ -3616,8 +4023,10 @@ class TextShaper {
3616
4023
  let currentClusterText = '';
3617
4024
  let clusterStartPosition = new Vec3();
3618
4025
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
4026
+ // Apply letter spacing between glyphs (must match what was used in width measurements)
3619
4027
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3620
4028
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4029
+ const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
3621
4030
  for (let i = 0; i < glyphInfos.length; i++) {
3622
4031
  const glyph = glyphInfos[i];
3623
4032
  const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
@@ -3663,6 +4072,29 @@ class TextShaper {
3663
4072
  if (isWhitespace) {
3664
4073
  cursor.x += spaceAdjustment;
3665
4074
  }
4075
+ // CJK glue adjustment (must match exactly where LineBreak adds glue)
4076
+ if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
4077
+ const currentChar = lineInfo.text[glyph.cl];
4078
+ const nextGlyph = glyphInfos[i + 1];
4079
+ const nextChar = lineInfo.text[nextGlyph.cl];
4080
+ const isCJKChar = LineBreak.isCJK(currentChar);
4081
+ const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
4082
+ if (isCJKChar && nextIsCJKChar) {
4083
+ let shouldApply = true;
4084
+ if (LineBreak.isCJClosingPunctuation(nextChar)) {
4085
+ shouldApply = false;
4086
+ }
4087
+ if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4088
+ shouldApply = false;
4089
+ }
4090
+ if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
4091
+ shouldApply = false;
4092
+ }
4093
+ if (shouldApply) {
4094
+ cursor.x += cjkAdjustment;
4095
+ }
4096
+ }
4097
+ }
3666
4098
  }
3667
4099
  if (currentClusterGlyphs.length > 0) {
3668
4100
  clusters.push({
@@ -3695,6 +4127,23 @@ class TextShaper {
3695
4127
  }
3696
4128
  return spaceAdjustment;
3697
4129
  }
4130
+ calculateCJKAdjustment(lineInfo, align) {
4131
+ if (lineInfo.adjustmentRatio === undefined ||
4132
+ align !== 'justify' ||
4133
+ lineInfo.isLastLine) {
4134
+ return 0;
4135
+ }
4136
+ const baseCharWidth = this.loadedFont.upem;
4137
+ const glueStretch = baseCharWidth * 0.04;
4138
+ const glueShrink = baseCharWidth * 0.04;
4139
+ if (lineInfo.adjustmentRatio > 0) {
4140
+ return lineInfo.adjustmentRatio * glueStretch;
4141
+ }
4142
+ else if (lineInfo.adjustmentRatio < 0) {
4143
+ return lineInfo.adjustmentRatio * glueShrink;
4144
+ }
4145
+ return 0;
4146
+ }
3698
4147
  clearCache() {
3699
4148
  this.geometryBuilder.clearCache();
3700
4149
  }
@@ -4812,7 +5261,7 @@ class Text {
4812
5261
  throw new Error('Font not loaded. Use Text.create() with a font option');
4813
5262
  }
4814
5263
  const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
4815
- const { width, direction = 'ltr', align = direction === 'rtl' ? 'right' : 'left', respectExistingBreaks = true, hyphenate = true, language = 'en-us', tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection } = layout;
5264
+ const { width, direction = 'ltr', align = direction === 'rtl' ? 'right' : 'left', respectExistingBreaks = true, hyphenate = true, language = 'en-us', tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableShortLineDetection, shortLineThreshold } = layout;
4816
5265
  let widthInFontUnits;
4817
5266
  if (width !== undefined) {
4818
5267
  widthInFontUnits = width * (this.loadedFont.upem / size);
@@ -4842,7 +5291,8 @@ class Text {
4842
5291
  exhyphenpenalty,
4843
5292
  doublehyphendemerits,
4844
5293
  looseness,
4845
- disableSingleWordDetection,
5294
+ disableShortLineDetection,
5295
+ shortLineThreshold,
4846
5296
  letterSpacing
4847
5297
  });
4848
5298
  const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);