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.umd.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
@@ -259,15 +259,15 @@
259
259
  const INF_BAD = 10000;
260
260
  // Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
261
261
  const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
262
- // Another non TeX default: Single-word line detection thresholds
263
- const SINGLE_WORD_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
264
- const SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
262
+ // Another non TeX default: Short line detection thresholds
263
+ const SHORT_LINE_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
264
+ const SHORT_LINE_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
265
265
  class LineBreak {
266
266
  // Calculate badness according to TeX's formula (tex.web §108, line 2337)
267
267
  // Given t (desired adjustment) and s (available stretch/shrink)
268
268
  // Returns approximation to 100(t/s)³, representing how "bad" a line is
269
269
  // Constants are derived from TeX's fixed-point arithmetic:
270
- // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
270
+ // 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
271
271
  static badness(t, s) {
272
272
  if (t === 0)
273
273
  return 0;
@@ -326,6 +326,8 @@
326
326
  const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
327
327
  return filteredPoints;
328
328
  }
329
+ // Converts text into items (boxes, glues, penalties) for line breaking
330
+ // The measureText function should return widths that include any letter spacing
329
331
  static itemizeText(text, measureText, // function to measure text width
330
332
  hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
331
333
  const items = [];
@@ -349,16 +351,214 @@
349
351
  });
350
352
  return items;
351
353
  }
354
+ // Chinese, Japanese, and Korean character ranges
355
+ static isCJK(char) {
356
+ const code = char.codePointAt(0);
357
+ if (code === undefined)
358
+ return false;
359
+ return (
360
+ // CJK Unified Ideographs
361
+ (code >= 0x4e00 && code <= 0x9fff) ||
362
+ // CJK Extension A
363
+ (code >= 0x3400 && code <= 0x4dbf) ||
364
+ // CJK Extension B
365
+ (code >= 0x20000 && code <= 0x2a6df) ||
366
+ // CJK Extension C
367
+ (code >= 0x2a700 && code <= 0x2b73f) ||
368
+ // CJK Extension D
369
+ (code >= 0x2b740 && code <= 0x2b81f) ||
370
+ // CJK Extension E
371
+ (code >= 0x2b820 && code <= 0x2ceaf) ||
372
+ // CJK Compatibility Ideographs
373
+ (code >= 0xf900 && code <= 0xfaff) ||
374
+ // Hiragana
375
+ (code >= 0x3040 && code <= 0x309f) ||
376
+ // Katakana
377
+ (code >= 0x30a0 && code <= 0x30ff) ||
378
+ // Hangul Syllables
379
+ (code >= 0xac00 && code <= 0xd7af) ||
380
+ // Hangul Jamo
381
+ (code >= 0x1100 && code <= 0x11ff) ||
382
+ // Hangul Compatibility Jamo
383
+ (code >= 0x3130 && code <= 0x318f) ||
384
+ // Hangul Jamo Extended-A
385
+ (code >= 0xa960 && code <= 0xa97f) ||
386
+ // Hangul Jamo Extended-B
387
+ (code >= 0xd7b0 && code <= 0xd7ff) ||
388
+ // Halfwidth and Fullwidth Forms (Korean)
389
+ (code >= 0xffa0 && code <= 0xffdc));
390
+ }
391
+ // Closing punctuation where line breaks are prohibited (UAX #14 LB30, JIS X 4051)
392
+ static isCJClosingPunctuation(char) {
393
+ const code = char.charCodeAt(0);
394
+ return (code === 0x3001 || // 、
395
+ code === 0x3002 || // 。
396
+ code === 0xff0c || // ,
397
+ code === 0xff0e || // .
398
+ code === 0xff1a || // :
399
+ code === 0xff1b || // ;
400
+ code === 0xff01 || // !
401
+ code === 0xff1f || // ?
402
+ code === 0xff09 || // )
403
+ code === 0x3011 || // 】
404
+ code === 0xff5d || // }
405
+ code === 0x300d || // 」
406
+ code === 0x300f || // 』
407
+ code === 0x3009 || // 〉
408
+ code === 0x300b || // 》
409
+ code === 0x3015 || // 〕
410
+ code === 0x3017 || // 〗
411
+ code === 0x3019 || // 〙
412
+ code === 0x301b || // 〛
413
+ code === 0x30fc || // ー
414
+ code === 0x2014 || // —
415
+ code === 0x2026 || // …
416
+ code === 0x2025 // ‥
417
+ );
418
+ }
419
+ // Opening punctuation where line breaks are prohibited (UAX #14 LB30a, JIS X 4051)
420
+ static isCJOpeningPunctuation(char) {
421
+ const code = char.charCodeAt(0);
422
+ return (code === 0xff08 || // (
423
+ code === 0x3010 || // 【
424
+ code === 0xff5b || // {
425
+ code === 0x300c || // 「
426
+ code === 0x300e || // 『
427
+ code === 0x3008 || // 〈
428
+ code === 0x300a || // 《
429
+ code === 0x3014 || // 〔
430
+ code === 0x3016 || // 〖
431
+ code === 0x3018 || // 〘
432
+ code === 0x301a // 〚
433
+ );
434
+ }
435
+ static isCJPunctuation(char) {
436
+ return (this.isCJClosingPunctuation(char) || this.isCJOpeningPunctuation(char));
437
+ }
438
+ // CJK (Chinese/Japanese/Korean) character-level itemization with inter-character glue
439
+ static itemizeCJKText(text, measureText, context, startOffset = 0, glueParams) {
440
+ const items = [];
441
+ const chars = Array.from(text);
442
+ let textPosition = startOffset;
443
+ // Inter-character glue parameters
444
+ let glueWidth;
445
+ let glueStretch;
446
+ let glueShrink;
447
+ if (glueParams) {
448
+ glueWidth = glueParams.width;
449
+ glueStretch = glueParams.stretch;
450
+ glueShrink = glueParams.shrink;
451
+ }
452
+ else {
453
+ const baseCharWidth = measureText('字');
454
+ glueWidth = 0;
455
+ glueStretch = baseCharWidth * 0.04;
456
+ glueShrink = baseCharWidth * 0.04;
457
+ }
458
+ for (let i = 0; i < chars.length; i++) {
459
+ const char = chars[i];
460
+ const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
461
+ if (/\s/.test(char)) {
462
+ const width = measureText(char);
463
+ items.push({
464
+ type: ItemType.GLUE,
465
+ width,
466
+ stretch: width * SPACE_STRETCH_RATIO,
467
+ shrink: width * SPACE_SHRINK_RATIO,
468
+ text: char,
469
+ originIndex: textPosition
470
+ });
471
+ textPosition += char.length;
472
+ continue;
473
+ }
474
+ items.push({
475
+ type: ItemType.BOX,
476
+ width: measureText(char),
477
+ text: char,
478
+ originIndex: textPosition
479
+ });
480
+ textPosition += char.length;
481
+ // Glue after a box creates a break opportunity
482
+ // Must not add glue where breaks are prohibited by Chinese/Japanese line breaking rules
483
+ if (nextChar && !/\s/.test(nextChar)) {
484
+ let canBreak = true;
485
+ if (this.isCJClosingPunctuation(nextChar)) {
486
+ canBreak = false;
487
+ }
488
+ if (this.isCJOpeningPunctuation(char)) {
489
+ canBreak = false;
490
+ }
491
+ // Avoid stretch between consecutive punctuation (?" or 。」)
492
+ const isPunctPair = this.isCJPunctuation(char) && this.isCJPunctuation(nextChar);
493
+ if (canBreak && !isPunctPair) {
494
+ items.push({
495
+ type: ItemType.GLUE,
496
+ width: glueWidth,
497
+ stretch: glueStretch,
498
+ shrink: glueShrink,
499
+ text: '',
500
+ originIndex: textPosition
501
+ });
502
+ }
503
+ }
504
+ }
505
+ return items;
506
+ }
352
507
  static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
353
508
  const items = [];
354
- // First split into words and spaces
509
+ const chars = Array.from(text);
510
+ // Calculate CJK glue parameters once for consistency across all segments
511
+ const baseCharWidth = measureText('字');
512
+ const cjkGlueParams = {
513
+ width: 0,
514
+ stretch: baseCharWidth * 0.04,
515
+ shrink: baseCharWidth * 0.04
516
+ };
517
+ let buffer = '';
518
+ let bufferStart = 0;
519
+ let bufferScript = null;
520
+ let textPosition = 0;
521
+ const flushBuffer = () => {
522
+ if (buffer.length === 0)
523
+ return;
524
+ if (bufferScript === 'cjk') {
525
+ const cjkItems = this.itemizeCJKText(buffer, measureText, context, bufferStart, cjkGlueParams);
526
+ items.push(...cjkItems);
527
+ }
528
+ else {
529
+ const wordItems = this.itemizeWordBased(buffer, bufferStart, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context);
530
+ items.push(...wordItems);
531
+ }
532
+ buffer = '';
533
+ bufferScript = null;
534
+ };
535
+ for (let i = 0; i < chars.length; i++) {
536
+ const char = chars[i];
537
+ const isCJKChar = this.isCJK(char);
538
+ const currentScript = isCJKChar ? 'cjk' : 'word';
539
+ if (bufferScript !== null && bufferScript !== currentScript) {
540
+ flushBuffer();
541
+ bufferStart = textPosition;
542
+ }
543
+ if (bufferScript === null) {
544
+ bufferScript = currentScript;
545
+ bufferStart = textPosition;
546
+ }
547
+ buffer += char;
548
+ textPosition += char.length;
549
+ }
550
+ flushBuffer();
551
+ return items;
552
+ }
553
+ // Word-based itemization for alphabetic scripts (Latin, Cyrillic, Greek, etc.)
554
+ static itemizeWordBased(text, startOffset, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
555
+ const items = [];
355
556
  const tokens = text.match(/\S+|\s+/g) || [];
356
557
  let currentIndex = 0;
357
558
  for (let i = 0; i < tokens.length; i++) {
358
559
  const token = tokens[i];
359
- const tokenStartIndex = currentIndex;
560
+ const tokenStartIndex = startOffset + currentIndex;
360
561
  if (/\s+/.test(token)) {
361
- // Handle spaces
362
562
  const width = measureText(token);
363
563
  items.push({
364
564
  type: ItemType.GLUE,
@@ -371,22 +571,19 @@
371
571
  currentIndex += token.length;
372
572
  }
373
573
  else {
374
- // Process word, splitting on explicit hyphens
375
- // Split on hyphens while keeping them in the result
376
574
  const segments = token.split(/(-)/);
377
575
  let segmentIndex = tokenStartIndex;
378
576
  for (let j = 0; j < segments.length; j++) {
379
577
  const segment = segments[j];
380
578
  if (!segment)
381
- continue; // Skip empty segments
579
+ continue;
382
580
  if (segment === '-') {
383
- // Handle explicit hyphen as discretionary break
384
581
  items.push({
385
582
  type: ItemType.DISCRETIONARY,
386
- width: measureText('-'), // Width of hyphen in normal flow
387
- preBreak: '-', // Hyphen appears before break
388
- postBreak: '', // Nothing after break
389
- noBreak: '-', // Hyphen if no break
583
+ width: measureText('-'),
584
+ preBreak: '-',
585
+ postBreak: '',
586
+ noBreak: '-',
390
587
  preBreakWidth: measureText('-'),
391
588
  penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
392
589
  flagged: true,
@@ -396,8 +593,6 @@
396
593
  segmentIndex += 1;
397
594
  }
398
595
  else {
399
- // Process non-hyphen segment
400
- // First handle soft hyphens (U+00AD)
401
596
  if (segment.includes('\u00AD')) {
402
597
  const partsWithMarkers = segment.split('\u00AD');
403
598
  let runningIndex = 0;
@@ -415,23 +610,23 @@
415
610
  if (k < partsWithMarkers.length - 1) {
416
611
  items.push({
417
612
  type: ItemType.DISCRETIONARY,
418
- width: 0, // No width in normal flow
419
- preBreak: '-', // Hyphen appears before break
420
- postBreak: '', // Nothing after break
421
- noBreak: '', // Nothing if no break (word continues)
613
+ width: 0,
614
+ preBreak: '-',
615
+ postBreak: '',
616
+ noBreak: '',
422
617
  preBreakWidth: measureText('-'),
423
618
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
424
619
  flagged: true,
425
620
  text: '',
426
621
  originIndex: segmentIndex + runningIndex
427
622
  });
428
- runningIndex += 1; // Account for the soft hyphen character
623
+ runningIndex += 1;
429
624
  }
430
625
  }
431
626
  }
432
627
  else if (hyphenate &&
433
- segment.length >= lefthyphenmin + righthyphenmin) {
434
- // Apply hyphenation patterns only to segments between explicit hyphens
628
+ segment.length >= lefthyphenmin + righthyphenmin &&
629
+ /^\p{L}+$/u.test(segment)) {
435
630
  const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
436
631
  if (hyphenPoints.length > 0) {
437
632
  let lastPoint = 0;
@@ -445,10 +640,10 @@
445
640
  });
446
641
  items.push({
447
642
  type: ItemType.DISCRETIONARY,
448
- width: 0, // No width in normal flow
449
- preBreak: '-', // Hyphen appears before break
450
- postBreak: '', // Nothing after break
451
- noBreak: '', // Nothing if no break (word continues)
643
+ width: 0,
644
+ preBreak: '-',
645
+ postBreak: '',
646
+ noBreak: '',
452
647
  preBreakWidth: measureText('-'),
453
648
  penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
454
649
  flagged: true,
@@ -466,7 +661,6 @@
466
661
  });
467
662
  }
468
663
  else {
469
- // No hyphenation points, add as single box
470
664
  items.push({
471
665
  type: ItemType.BOX,
472
666
  width: measureText(segment),
@@ -476,7 +670,6 @@
476
670
  }
477
671
  }
478
672
  else {
479
- // No hyphenation, add as single box
480
673
  items.push({
481
674
  type: ItemType.BOX,
482
675
  width: measureText(segment),
@@ -492,27 +685,22 @@
492
685
  }
493
686
  return items;
494
687
  }
495
- // Detect if breakpoints create problematic single-word lines
496
- static hasSingleWordLines(items, breakpoints, lineWidth) {
688
+ // Detect if breakpoints create problematic short lines
689
+ static hasShortLines(items, breakpoints, lineWidth, threshold) {
497
690
  // Check each line segment (except the last, which can naturally be short)
498
691
  let lineStart = 0;
499
692
  for (let i = 0; i < breakpoints.length - 1; i++) {
500
693
  const breakpoint = breakpoints[i];
501
- // Count glue items (spaces) between line start and breakpoint
502
- let glueCount = 0;
503
694
  let totalWidth = 0;
504
695
  for (let j = lineStart; j < breakpoint; j++) {
505
- if (items[j].type === ItemType.GLUE) {
506
- glueCount++;
507
- }
508
696
  if (items[j].type !== ItemType.PENALTY) {
509
697
  totalWidth += items[j].width;
510
698
  }
511
699
  }
512
- // Single word line = no glue items
513
- if (glueCount === 0 && totalWidth > 0) {
700
+ // Check if line is narrow relative to target width
701
+ if (totalWidth > 0) {
514
702
  const widthRatio = totalWidth / lineWidth;
515
- if (widthRatio < SINGLE_WORD_WIDTH_THRESHOLD) {
703
+ if (widthRatio < threshold) {
516
704
  return true;
517
705
  }
518
706
  }
@@ -527,7 +715,7 @@
527
715
  align: options.align || 'left',
528
716
  hyphenate: options.hyphenate || false
529
717
  });
530
- const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0, disableSingleWordDetection = false } = options;
718
+ const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0, disableShortLineDetection = false, shortLineThreshold = SHORT_LINE_WIDTH_THRESHOLD } = options;
531
719
  // Handle multiple paragraphs by processing each independently
532
720
  if (respectExistingBreaks && text.includes('\n')) {
533
721
  const paragraphs = text.split('\n');
@@ -606,39 +794,32 @@
606
794
  }
607
795
  ];
608
796
  }
609
- // Itemize text once, including all potential hyphenation points
610
- const allItems = LineBreak.itemizeText(text, measureText, useHyphenation, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
797
+ // Itemize without hyphenation first (TeX approach: only compute if needed)
798
+ const allItems = LineBreak.itemizeText(text, measureText, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
611
799
  if (allItems.length === 0) {
612
800
  return [];
613
801
  }
614
- // Iteratively increase emergency stretch to eliminate short single-word lines.
615
- // Post-processing approach preserves TeX algorithm integrity while itemization
616
- // (the expensive part) happens once
617
802
  const MAX_ITERATIONS = 5;
618
803
  let iteration = 0;
619
804
  let currentEmergencyStretch = initialEmergencyStretch;
620
805
  let resultLines = null;
621
- const singleWordDetectionEnabled = !disableSingleWordDetection;
806
+ const shortLineDetectionEnabled = !disableShortLineDetection;
622
807
  while (iteration < MAX_ITERATIONS) {
623
- // Three-pass approach for optimal line breaking:
624
- // First pass: Try without hyphenation using pretolerance (fast)
625
- // Second pass: Enable hyphenation if available, use tolerance (quality)
626
- // Final pass: Emergency stretch for difficult paragraphs (last resort)
808
+ // Three-pass approach matching TeX:
809
+ // First pass: without hyphenation (pretolerance)
810
+ // Second pass: with hyphenation, only if first pass fails (tolerance)
811
+ // Emergency pass: additional stretch as last resort
627
812
  // First pass: no hyphenation
628
- let currentItems = useHyphenation
629
- ? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
630
- item.penalty !==
631
- (context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
632
- : allItems;
813
+ let currentItems = allItems;
633
814
  let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
634
- // Second pass: with hyphenation
815
+ // Second pass: compute hyphenation only if needed
635
816
  if (breaks.length === 0 && useHyphenation) {
636
- currentItems = allItems;
817
+ const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
818
+ currentItems = itemsWithHyphenation;
637
819
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
638
820
  }
639
- // Final pass: emergency stretch
821
+ // Emergency pass: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
640
822
  if (breaks.length === 0) {
641
- currentItems = allItems;
642
823
  breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
643
824
  }
644
825
  // Force with infinite tolerance if still no breaks found
@@ -649,13 +830,13 @@
649
830
  if (breaks.length > 0) {
650
831
  const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
651
832
  resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
652
- // Check for single-word lines if detection is enabled
653
- if (singleWordDetectionEnabled &&
833
+ // Check for short lines if detection is enabled
834
+ if (shortLineDetectionEnabled &&
654
835
  breaks.length > 1 &&
655
- LineBreak.hasSingleWordLines(currentItems, breaks, width)) {
836
+ LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
656
837
  // Increase emergency stretch and try again
657
838
  currentEmergencyStretch +=
658
- width * SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT;
839
+ width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
659
840
  iteration++;
660
841
  continue;
661
842
  }
@@ -989,6 +1170,8 @@
989
1170
  const item = items[i];
990
1171
  widths[i + 1] = widths[i] + item.width;
991
1172
  if (item.type === ItemType.PENALTY) {
1173
+ stretches[i + 1] = stretches[i];
1174
+ shrinks[i + 1] = shrinks[i];
992
1175
  minWidths[i + 1] = minWidths[i];
993
1176
  }
994
1177
  else if (item.type === ItemType.GLUE) {
@@ -1210,6 +1393,9 @@
1210
1393
  }
1211
1394
 
1212
1395
  class TextMeasurer {
1396
+ // Measures text width including letter spacing
1397
+ // Letter spacing is added uniformly after each glyph during measurement,
1398
+ // so the widths given to the line-breaking algorithm already account for tracking
1213
1399
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1214
1400
  const buffer = loadedFont.hb.createBuffer();
1215
1401
  buffer.addText(text);
@@ -1236,9 +1422,11 @@
1236
1422
  this.loadedFont = loadedFont;
1237
1423
  }
1238
1424
  computeLines(options) {
1239
- const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection, letterSpacing } = options;
1425
+ const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableShortLineDetection, shortLineThreshold, letterSpacing } = options;
1240
1426
  let lines;
1241
1427
  if (width) {
1428
+ // Line breaking uses a measureText function that already includes letterSpacing,
1429
+ // so widths passed into LineBreak.breakText account for tracking
1242
1430
  lines = LineBreak.breakText({
1243
1431
  text,
1244
1432
  width,
@@ -1260,9 +1448,11 @@
1260
1448
  exhyphenpenalty,
1261
1449
  doublehyphendemerits,
1262
1450
  looseness,
1263
- disableSingleWordDetection,
1451
+ disableShortLineDetection,
1452
+ shortLineThreshold,
1264
1453
  unitsPerEm: this.loadedFont.upem,
1265
- measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing)
1454
+ measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1455
+ )
1266
1456
  });
1267
1457
  }
1268
1458
  else {
@@ -2229,7 +2419,11 @@
2229
2419
  if (removeOverlaps) {
2230
2420
  logger.log('Two-pass: boundary extraction then triangulation');
2231
2421
  // Extract boundaries to remove overlaps
2422
+ perfLogger.start('Tessellator.boundaryPass', {
2423
+ contourCount: contours.length
2424
+ });
2232
2425
  const boundaryResult = this.performTessellation(contours, 'boundary');
2426
+ perfLogger.end('Tessellator.boundaryPass');
2233
2427
  if (!boundaryResult) {
2234
2428
  logger.warn('libtess returned empty result from boundary pass');
2235
2429
  return { triangles: { vertices: [], indices: [] }, contours: [] };
@@ -2242,7 +2436,11 @@
2242
2436
  logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2243
2437
  }
2244
2438
  // Triangulate the contours
2439
+ perfLogger.start('Tessellator.triangulationPass', {
2440
+ contourCount: contours.length
2441
+ });
2245
2442
  const triangleResult = this.performTessellation(contours, 'triangles');
2443
+ perfLogger.end('Tessellator.triangulationPass');
2246
2444
  if (!triangleResult) {
2247
2445
  const warning = removeOverlaps
2248
2446
  ? 'libtess returned empty result from triangulation pass'
@@ -2429,10 +2627,16 @@
2429
2627
  class BoundaryClusterer {
2430
2628
  constructor() { }
2431
2629
  cluster(glyphContoursList, positions) {
2630
+ perfLogger.start('BoundaryClusterer.cluster', {
2631
+ glyphCount: glyphContoursList.length
2632
+ });
2432
2633
  if (glyphContoursList.length === 0) {
2634
+ perfLogger.end('BoundaryClusterer.cluster');
2433
2635
  return [];
2434
2636
  }
2435
- return this.clusterSweepLine(glyphContoursList, positions);
2637
+ const result = this.clusterSweepLine(glyphContoursList, positions);
2638
+ perfLogger.end('BoundaryClusterer.cluster');
2639
+ return result;
2436
2640
  }
2437
2641
  clusterSweepLine(glyphContoursList, positions) {
2438
2642
  const n = glyphContoursList.length;
@@ -3073,6 +3277,11 @@
3073
3277
  this.currentGlyphBounds.max.set(-Infinity, -Infinity);
3074
3278
  // Record position for this glyph
3075
3279
  this.glyphPositions.push(this.currentPosition.clone());
3280
+ // Time polygonization + path optimization per glyph
3281
+ perfLogger.start('Glyph.polygonizeAndOptimize', {
3282
+ glyphId,
3283
+ textIndex
3284
+ });
3076
3285
  }
3077
3286
  finishGlyph() {
3078
3287
  if (this.currentPath) {
@@ -3096,6 +3305,8 @@
3096
3305
  // Track textIndex separately
3097
3306
  this.glyphTextIndices.push(this.currentTextIndex);
3098
3307
  }
3308
+ // Stop timing for this glyph (even if it ended up empty)
3309
+ perfLogger.end('Glyph.polygonizeAndOptimize');
3099
3310
  this.currentGlyphPaths = [];
3100
3311
  }
3101
3312
  onMoveTo(x, y) {
@@ -3330,6 +3541,184 @@
3330
3541
  }
3331
3542
  }
3332
3543
 
3544
+ // Generic LRU (Least Recently Used) cache with optional memory-based eviction
3545
+ class LRUCache {
3546
+ constructor(options = {}) {
3547
+ this.cache = new Map();
3548
+ this.head = null;
3549
+ this.tail = null;
3550
+ this.stats = {
3551
+ hits: 0,
3552
+ misses: 0,
3553
+ evictions: 0,
3554
+ size: 0,
3555
+ memoryUsage: 0
3556
+ };
3557
+ this.options = {
3558
+ maxEntries: options.maxEntries ?? Infinity,
3559
+ maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
3560
+ calculateSize: options.calculateSize ?? (() => 0),
3561
+ onEvict: options.onEvict
3562
+ };
3563
+ }
3564
+ get(key) {
3565
+ const node = this.cache.get(key);
3566
+ if (node) {
3567
+ this.stats.hits++;
3568
+ this.moveToHead(node);
3569
+ return node.value;
3570
+ }
3571
+ else {
3572
+ this.stats.misses++;
3573
+ return undefined;
3574
+ }
3575
+ }
3576
+ has(key) {
3577
+ return this.cache.has(key);
3578
+ }
3579
+ set(key, value) {
3580
+ // If key already exists, update it
3581
+ const existingNode = this.cache.get(key);
3582
+ if (existingNode) {
3583
+ const oldSize = this.options.calculateSize(existingNode.value);
3584
+ const newSize = this.options.calculateSize(value);
3585
+ this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
3586
+ existingNode.value = value;
3587
+ this.moveToHead(existingNode);
3588
+ return;
3589
+ }
3590
+ const size = this.options.calculateSize(value);
3591
+ // Evict entries if we exceed limits
3592
+ this.evictIfNeeded(size);
3593
+ // Create new node
3594
+ const node = {
3595
+ key,
3596
+ value,
3597
+ prev: null,
3598
+ next: null
3599
+ };
3600
+ this.cache.set(key, node);
3601
+ this.addToHead(node);
3602
+ this.stats.size = this.cache.size;
3603
+ this.stats.memoryUsage += size;
3604
+ }
3605
+ delete(key) {
3606
+ const node = this.cache.get(key);
3607
+ if (!node)
3608
+ return false;
3609
+ const size = this.options.calculateSize(node.value);
3610
+ this.removeNode(node);
3611
+ this.cache.delete(key);
3612
+ this.stats.size = this.cache.size;
3613
+ this.stats.memoryUsage -= size;
3614
+ if (this.options.onEvict) {
3615
+ this.options.onEvict(key, node.value);
3616
+ }
3617
+ return true;
3618
+ }
3619
+ clear() {
3620
+ if (this.options.onEvict) {
3621
+ for (const [key, node] of this.cache) {
3622
+ this.options.onEvict(key, node.value);
3623
+ }
3624
+ }
3625
+ this.cache.clear();
3626
+ this.head = null;
3627
+ this.tail = null;
3628
+ this.stats = {
3629
+ hits: 0,
3630
+ misses: 0,
3631
+ evictions: 0,
3632
+ size: 0,
3633
+ memoryUsage: 0
3634
+ };
3635
+ }
3636
+ getStats() {
3637
+ const total = this.stats.hits + this.stats.misses;
3638
+ const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
3639
+ const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
3640
+ return {
3641
+ ...this.stats,
3642
+ hitRate,
3643
+ memoryUsageMB
3644
+ };
3645
+ }
3646
+ keys() {
3647
+ const keys = [];
3648
+ let current = this.head;
3649
+ while (current) {
3650
+ keys.push(current.key);
3651
+ current = current.next;
3652
+ }
3653
+ return keys;
3654
+ }
3655
+ get size() {
3656
+ return this.cache.size;
3657
+ }
3658
+ evictIfNeeded(requiredSize) {
3659
+ // Evict by entry count
3660
+ while (this.cache.size >= this.options.maxEntries && this.tail) {
3661
+ this.evictTail();
3662
+ }
3663
+ // Evict by memory usage
3664
+ if (this.options.maxMemoryBytes < Infinity) {
3665
+ while (this.tail &&
3666
+ this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
3667
+ this.evictTail();
3668
+ }
3669
+ }
3670
+ }
3671
+ evictTail() {
3672
+ if (!this.tail)
3673
+ return;
3674
+ const nodeToRemove = this.tail;
3675
+ const size = this.options.calculateSize(nodeToRemove.value);
3676
+ this.removeTail();
3677
+ this.cache.delete(nodeToRemove.key);
3678
+ this.stats.size = this.cache.size;
3679
+ this.stats.memoryUsage -= size;
3680
+ this.stats.evictions++;
3681
+ if (this.options.onEvict) {
3682
+ this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
3683
+ }
3684
+ }
3685
+ addToHead(node) {
3686
+ if (!this.head) {
3687
+ this.head = this.tail = node;
3688
+ }
3689
+ else {
3690
+ node.next = this.head;
3691
+ this.head.prev = node;
3692
+ this.head = node;
3693
+ }
3694
+ }
3695
+ removeNode(node) {
3696
+ if (node.prev) {
3697
+ node.prev.next = node.next;
3698
+ }
3699
+ else {
3700
+ this.head = node.next;
3701
+ }
3702
+ if (node.next) {
3703
+ node.next.prev = node.prev;
3704
+ }
3705
+ else {
3706
+ this.tail = node.prev;
3707
+ }
3708
+ }
3709
+ removeTail() {
3710
+ if (this.tail) {
3711
+ this.removeNode(this.tail);
3712
+ }
3713
+ }
3714
+ moveToHead(node) {
3715
+ if (node === this.head)
3716
+ return;
3717
+ this.removeNode(node);
3718
+ this.addToHead(node);
3719
+ }
3720
+ }
3721
+
3333
3722
  class GlyphGeometryBuilder {
3334
3723
  constructor(cache, loadedFont) {
3335
3724
  this.fontId = 'default';
@@ -3342,6 +3731,16 @@
3342
3731
  this.collector = new GlyphContourCollector();
3343
3732
  this.drawCallbacks = new DrawCallbackHandler();
3344
3733
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3734
+ this.contourCache = new LRUCache({
3735
+ maxEntries: 1000,
3736
+ calculateSize: (contours) => {
3737
+ let size = 0;
3738
+ for (const path of contours.paths) {
3739
+ size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
3740
+ }
3741
+ return size + 64; // bounds overhead
3742
+ }
3743
+ });
3345
3744
  }
3346
3745
  getOptimizationStats() {
3347
3746
  return this.collector.getOptimizationStats();
@@ -3486,30 +3885,37 @@
3486
3885
  };
3487
3886
  }
3488
3887
  getContoursForGlyph(glyphId) {
3888
+ const cached = this.contourCache.get(glyphId);
3889
+ if (cached) {
3890
+ return cached;
3891
+ }
3489
3892
  this.collector.reset();
3490
3893
  this.collector.beginGlyph(glyphId, 0);
3491
3894
  this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
3492
3895
  this.collector.finishGlyph();
3493
3896
  const collected = this.collector.getCollectedGlyphs()[0];
3494
- // Return empty contours for glyphs with no paths (e.g., space, zero-width characters)
3495
- if (!collected) {
3496
- return {
3497
- glyphId,
3498
- paths: [],
3499
- bounds: {
3500
- min: { x: 0, y: 0 },
3501
- max: { x: 0, y: 0 }
3502
- }
3503
- };
3504
- }
3505
- return collected;
3897
+ const contours = collected || {
3898
+ glyphId,
3899
+ paths: [],
3900
+ bounds: {
3901
+ min: { x: 0, y: 0 },
3902
+ max: { x: 0, y: 0 }
3903
+ }
3904
+ };
3905
+ this.contourCache.set(glyphId, contours);
3906
+ return contours;
3506
3907
  }
3507
3908
  tessellateGlyphCluster(paths, depth, isCFF) {
3508
3909
  const processedGeometry = this.tessellator.process(paths, true, isCFF);
3509
3910
  return this.extrudeAndPackage(processedGeometry, depth);
3510
3911
  }
3511
3912
  extrudeAndPackage(processedGeometry, depth) {
3913
+ perfLogger.start('Extruder.extrude', {
3914
+ depth,
3915
+ upem: this.loadedFont.upem
3916
+ });
3512
3917
  const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
3918
+ perfLogger.end('Extruder.extrude');
3513
3919
  // Compute bounding box from vertices
3514
3920
  const vertices = extrudedResult.vertices;
3515
3921
  let minX = Infinity, minY = Infinity, minZ = Infinity;
@@ -3551,6 +3957,7 @@
3551
3957
  pathCount: glyphContours.paths.length
3552
3958
  });
3553
3959
  const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
3960
+ perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
3554
3961
  return this.extrudeAndPackage(processedGeometry, depth);
3555
3962
  }
3556
3963
  updatePlaneBounds(glyphBounds, planeBounds) {
@@ -3623,8 +4030,10 @@
3623
4030
  let currentClusterText = '';
3624
4031
  let clusterStartPosition = new Vec3();
3625
4032
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
4033
+ // Apply letter spacing between glyphs (must match what was used in width measurements)
3626
4034
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3627
4035
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4036
+ const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
3628
4037
  for (let i = 0; i < glyphInfos.length; i++) {
3629
4038
  const glyph = glyphInfos[i];
3630
4039
  const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
@@ -3670,6 +4079,29 @@
3670
4079
  if (isWhitespace) {
3671
4080
  cursor.x += spaceAdjustment;
3672
4081
  }
4082
+ // CJK glue adjustment (must match exactly where LineBreak adds glue)
4083
+ if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
4084
+ const currentChar = lineInfo.text[glyph.cl];
4085
+ const nextGlyph = glyphInfos[i + 1];
4086
+ const nextChar = lineInfo.text[nextGlyph.cl];
4087
+ const isCJKChar = LineBreak.isCJK(currentChar);
4088
+ const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
4089
+ if (isCJKChar && nextIsCJKChar) {
4090
+ let shouldApply = true;
4091
+ if (LineBreak.isCJClosingPunctuation(nextChar)) {
4092
+ shouldApply = false;
4093
+ }
4094
+ if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4095
+ shouldApply = false;
4096
+ }
4097
+ if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
4098
+ shouldApply = false;
4099
+ }
4100
+ if (shouldApply) {
4101
+ cursor.x += cjkAdjustment;
4102
+ }
4103
+ }
4104
+ }
3673
4105
  }
3674
4106
  if (currentClusterGlyphs.length > 0) {
3675
4107
  clusters.push({
@@ -3702,6 +4134,23 @@
3702
4134
  }
3703
4135
  return spaceAdjustment;
3704
4136
  }
4137
+ calculateCJKAdjustment(lineInfo, align) {
4138
+ if (lineInfo.adjustmentRatio === undefined ||
4139
+ align !== 'justify' ||
4140
+ lineInfo.isLastLine) {
4141
+ return 0;
4142
+ }
4143
+ const baseCharWidth = this.loadedFont.upem;
4144
+ const glueStretch = baseCharWidth * 0.04;
4145
+ const glueShrink = baseCharWidth * 0.04;
4146
+ if (lineInfo.adjustmentRatio > 0) {
4147
+ return lineInfo.adjustmentRatio * glueStretch;
4148
+ }
4149
+ else if (lineInfo.adjustmentRatio < 0) {
4150
+ return lineInfo.adjustmentRatio * glueShrink;
4151
+ }
4152
+ return 0;
4153
+ }
3705
4154
  clearCache() {
3706
4155
  this.geometryBuilder.clearCache();
3707
4156
  }
@@ -4819,7 +5268,7 @@
4819
5268
  throw new Error('Font not loaded. Use Text.create() with a font option');
4820
5269
  }
4821
5270
  const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
4822
- const { width, direction = 'ltr', align = direction === 'rtl' ? 'right' : 'left', respectExistingBreaks = true, hyphenate = true, language = 'en-us', tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection } = layout;
5271
+ const { width, direction = 'ltr', align = direction === 'rtl' ? 'right' : 'left', respectExistingBreaks = true, hyphenate = true, language = 'en-us', tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableShortLineDetection, shortLineThreshold } = layout;
4823
5272
  let widthInFontUnits;
4824
5273
  if (width !== undefined) {
4825
5274
  widthInFontUnits = width * (this.loadedFont.upem / size);
@@ -4849,7 +5298,8 @@
4849
5298
  exhyphenpenalty,
4850
5299
  doublehyphendemerits,
4851
5300
  looseness,
4852
- disableSingleWordDetection,
5301
+ disableShortLineDetection,
5302
+ shortLineThreshold,
4853
5303
  letterSpacing
4854
5304
  });
4855
5305
  const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);