three-text 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/dist/index.cjs +295 -74
- package/dist/index.d.ts +2 -1
- package/dist/index.js +295 -74
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +295 -74
- package/dist/index.umd.min.js +2 -2
- package/dist/three/react.d.ts +2 -1
- package/dist/types/core/layout/LineBreak.d.ts +9 -2
- package/dist/types/core/shaping/TextShaper.d.ts +1 -0
- package/dist/types/core/types.d.ts +2 -1
- package/dist/types/utils/PerformanceLogger.d.ts +0 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* three-text v0.2.
|
|
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
|
|
@@ -122,9 +122,6 @@ class PerformanceLogger {
|
|
|
122
122
|
this.metrics.length = 0;
|
|
123
123
|
this.activeTimers.clear();
|
|
124
124
|
}
|
|
125
|
-
reset() {
|
|
126
|
-
this.clear();
|
|
127
|
-
}
|
|
128
125
|
time(name, fn, metadata) {
|
|
129
126
|
if (!isLogEnabled)
|
|
130
127
|
return fn();
|
|
@@ -257,15 +254,15 @@ const DEFAULT_RIGHT_HYPHEN_MIN = 4;
|
|
|
257
254
|
const INF_BAD = 10000;
|
|
258
255
|
// Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
|
|
259
256
|
const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
|
|
260
|
-
// Another non TeX default:
|
|
261
|
-
const
|
|
262
|
-
const
|
|
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
|
|
263
260
|
class LineBreak {
|
|
264
261
|
// Calculate badness according to TeX's formula (tex.web §108, line 2337)
|
|
265
262
|
// Given t (desired adjustment) and s (available stretch/shrink)
|
|
266
263
|
// Returns approximation to 100(t/s)³, representing how "bad" a line is
|
|
267
264
|
// Constants are derived from TeX's fixed-point arithmetic:
|
|
268
|
-
//
|
|
265
|
+
// 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
|
|
269
266
|
static badness(t, s) {
|
|
270
267
|
if (t === 0)
|
|
271
268
|
return 0;
|
|
@@ -324,8 +321,8 @@ class LineBreak {
|
|
|
324
321
|
const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
|
|
325
322
|
return filteredPoints;
|
|
326
323
|
}
|
|
327
|
-
// Converts text into items (boxes, glues, penalties) for line breaking
|
|
328
|
-
// The measureText function should return widths that include any letter spacing
|
|
324
|
+
// Converts text into items (boxes, glues, penalties) for line breaking
|
|
325
|
+
// The measureText function should return widths that include any letter spacing
|
|
329
326
|
static itemizeText(text, measureText, // function to measure text width
|
|
330
327
|
hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
|
|
331
328
|
const items = [];
|
|
@@ -349,16 +346,214 @@ class LineBreak {
|
|
|
349
346
|
});
|
|
350
347
|
return items;
|
|
351
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
|
+
}
|
|
352
502
|
static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
|
|
353
503
|
const items = [];
|
|
354
|
-
|
|
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 = [];
|
|
355
551
|
const tokens = text.match(/\S+|\s+/g) || [];
|
|
356
552
|
let currentIndex = 0;
|
|
357
553
|
for (let i = 0; i < tokens.length; i++) {
|
|
358
554
|
const token = tokens[i];
|
|
359
|
-
const tokenStartIndex = currentIndex;
|
|
555
|
+
const tokenStartIndex = startOffset + currentIndex;
|
|
360
556
|
if (/\s+/.test(token)) {
|
|
361
|
-
// Handle spaces
|
|
362
557
|
const width = measureText(token);
|
|
363
558
|
items.push({
|
|
364
559
|
type: ItemType.GLUE,
|
|
@@ -371,22 +566,19 @@ class LineBreak {
|
|
|
371
566
|
currentIndex += token.length;
|
|
372
567
|
}
|
|
373
568
|
else {
|
|
374
|
-
// Process word, splitting on explicit hyphens
|
|
375
|
-
// Split on hyphens while keeping them in the result
|
|
376
569
|
const segments = token.split(/(-)/);
|
|
377
570
|
let segmentIndex = tokenStartIndex;
|
|
378
571
|
for (let j = 0; j < segments.length; j++) {
|
|
379
572
|
const segment = segments[j];
|
|
380
573
|
if (!segment)
|
|
381
|
-
continue;
|
|
574
|
+
continue;
|
|
382
575
|
if (segment === '-') {
|
|
383
|
-
// Handle explicit hyphen as discretionary break
|
|
384
576
|
items.push({
|
|
385
577
|
type: ItemType.DISCRETIONARY,
|
|
386
|
-
width: measureText('-'),
|
|
387
|
-
preBreak: '-',
|
|
388
|
-
postBreak: '',
|
|
389
|
-
noBreak: '-',
|
|
578
|
+
width: measureText('-'),
|
|
579
|
+
preBreak: '-',
|
|
580
|
+
postBreak: '',
|
|
581
|
+
noBreak: '-',
|
|
390
582
|
preBreakWidth: measureText('-'),
|
|
391
583
|
penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
|
|
392
584
|
flagged: true,
|
|
@@ -396,8 +588,6 @@ class LineBreak {
|
|
|
396
588
|
segmentIndex += 1;
|
|
397
589
|
}
|
|
398
590
|
else {
|
|
399
|
-
// Process non-hyphen segment
|
|
400
|
-
// First handle soft hyphens (U+00AD)
|
|
401
591
|
if (segment.includes('\u00AD')) {
|
|
402
592
|
const partsWithMarkers = segment.split('\u00AD');
|
|
403
593
|
let runningIndex = 0;
|
|
@@ -415,23 +605,23 @@ class LineBreak {
|
|
|
415
605
|
if (k < partsWithMarkers.length - 1) {
|
|
416
606
|
items.push({
|
|
417
607
|
type: ItemType.DISCRETIONARY,
|
|
418
|
-
width: 0,
|
|
419
|
-
preBreak: '-',
|
|
420
|
-
postBreak: '',
|
|
421
|
-
noBreak: '',
|
|
608
|
+
width: 0,
|
|
609
|
+
preBreak: '-',
|
|
610
|
+
postBreak: '',
|
|
611
|
+
noBreak: '',
|
|
422
612
|
preBreakWidth: measureText('-'),
|
|
423
613
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
424
614
|
flagged: true,
|
|
425
615
|
text: '',
|
|
426
616
|
originIndex: segmentIndex + runningIndex
|
|
427
617
|
});
|
|
428
|
-
runningIndex += 1;
|
|
618
|
+
runningIndex += 1;
|
|
429
619
|
}
|
|
430
620
|
}
|
|
431
621
|
}
|
|
432
622
|
else if (hyphenate &&
|
|
433
|
-
segment.length >= lefthyphenmin + righthyphenmin
|
|
434
|
-
|
|
623
|
+
segment.length >= lefthyphenmin + righthyphenmin &&
|
|
624
|
+
/^\p{L}+$/u.test(segment)) {
|
|
435
625
|
const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
|
|
436
626
|
if (hyphenPoints.length > 0) {
|
|
437
627
|
let lastPoint = 0;
|
|
@@ -445,10 +635,10 @@ class LineBreak {
|
|
|
445
635
|
});
|
|
446
636
|
items.push({
|
|
447
637
|
type: ItemType.DISCRETIONARY,
|
|
448
|
-
width: 0,
|
|
449
|
-
preBreak: '-',
|
|
450
|
-
postBreak: '',
|
|
451
|
-
noBreak: '',
|
|
638
|
+
width: 0,
|
|
639
|
+
preBreak: '-',
|
|
640
|
+
postBreak: '',
|
|
641
|
+
noBreak: '',
|
|
452
642
|
preBreakWidth: measureText('-'),
|
|
453
643
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
454
644
|
flagged: true,
|
|
@@ -466,7 +656,6 @@ class LineBreak {
|
|
|
466
656
|
});
|
|
467
657
|
}
|
|
468
658
|
else {
|
|
469
|
-
// No hyphenation points, add as single box
|
|
470
659
|
items.push({
|
|
471
660
|
type: ItemType.BOX,
|
|
472
661
|
width: measureText(segment),
|
|
@@ -476,7 +665,6 @@ class LineBreak {
|
|
|
476
665
|
}
|
|
477
666
|
}
|
|
478
667
|
else {
|
|
479
|
-
// No hyphenation, add as single box
|
|
480
668
|
items.push({
|
|
481
669
|
type: ItemType.BOX,
|
|
482
670
|
width: measureText(segment),
|
|
@@ -492,27 +680,22 @@ class LineBreak {
|
|
|
492
680
|
}
|
|
493
681
|
return items;
|
|
494
682
|
}
|
|
495
|
-
// Detect if breakpoints create problematic
|
|
496
|
-
static
|
|
683
|
+
// Detect if breakpoints create problematic short lines
|
|
684
|
+
static hasShortLines(items, breakpoints, lineWidth, threshold) {
|
|
497
685
|
// Check each line segment (except the last, which can naturally be short)
|
|
498
686
|
let lineStart = 0;
|
|
499
687
|
for (let i = 0; i < breakpoints.length - 1; i++) {
|
|
500
688
|
const breakpoint = breakpoints[i];
|
|
501
|
-
// Count glue items (spaces) between line start and breakpoint
|
|
502
|
-
let glueCount = 0;
|
|
503
689
|
let totalWidth = 0;
|
|
504
690
|
for (let j = lineStart; j < breakpoint; j++) {
|
|
505
|
-
if (items[j].type === ItemType.GLUE) {
|
|
506
|
-
glueCount++;
|
|
507
|
-
}
|
|
508
691
|
if (items[j].type !== ItemType.PENALTY) {
|
|
509
692
|
totalWidth += items[j].width;
|
|
510
693
|
}
|
|
511
694
|
}
|
|
512
|
-
//
|
|
513
|
-
if (
|
|
695
|
+
// Check if line is narrow relative to target width
|
|
696
|
+
if (totalWidth > 0) {
|
|
514
697
|
const widthRatio = totalWidth / lineWidth;
|
|
515
|
-
if (widthRatio <
|
|
698
|
+
if (widthRatio < threshold) {
|
|
516
699
|
return true;
|
|
517
700
|
}
|
|
518
701
|
}
|
|
@@ -527,7 +710,7 @@ class LineBreak {
|
|
|
527
710
|
align: options.align || 'left',
|
|
528
711
|
hyphenate: options.hyphenate || false
|
|
529
712
|
});
|
|
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,
|
|
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;
|
|
531
714
|
// Handle multiple paragraphs by processing each independently
|
|
532
715
|
if (respectExistingBreaks && text.includes('\n')) {
|
|
533
716
|
const paragraphs = text.split('\n');
|
|
@@ -606,39 +789,32 @@ class LineBreak {
|
|
|
606
789
|
}
|
|
607
790
|
];
|
|
608
791
|
}
|
|
609
|
-
// Itemize
|
|
610
|
-
const allItems = LineBreak.itemizeText(text, measureText,
|
|
792
|
+
// Itemize without hyphenation first (TeX approach: only compute if needed)
|
|
793
|
+
const allItems = LineBreak.itemizeText(text, measureText, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
611
794
|
if (allItems.length === 0) {
|
|
612
795
|
return [];
|
|
613
796
|
}
|
|
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
797
|
const MAX_ITERATIONS = 5;
|
|
618
798
|
let iteration = 0;
|
|
619
799
|
let currentEmergencyStretch = initialEmergencyStretch;
|
|
620
800
|
let resultLines = null;
|
|
621
|
-
const
|
|
801
|
+
const shortLineDetectionEnabled = !disableShortLineDetection;
|
|
622
802
|
while (iteration < MAX_ITERATIONS) {
|
|
623
|
-
// Three-pass approach
|
|
624
|
-
// First pass:
|
|
625
|
-
// Second pass:
|
|
626
|
-
//
|
|
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
|
|
627
807
|
// First pass: no hyphenation
|
|
628
|
-
let currentItems =
|
|
629
|
-
? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
|
|
630
|
-
item.penalty !==
|
|
631
|
-
(context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
|
|
632
|
-
: allItems;
|
|
808
|
+
let currentItems = allItems;
|
|
633
809
|
let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
|
|
634
|
-
// Second pass:
|
|
810
|
+
// Second pass: compute hyphenation only if needed
|
|
635
811
|
if (breaks.length === 0 && useHyphenation) {
|
|
636
|
-
|
|
812
|
+
const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
813
|
+
currentItems = itemsWithHyphenation;
|
|
637
814
|
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
|
|
638
815
|
}
|
|
639
|
-
//
|
|
816
|
+
// Emergency pass: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
|
|
640
817
|
if (breaks.length === 0) {
|
|
641
|
-
currentItems = allItems;
|
|
642
818
|
breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
|
|
643
819
|
}
|
|
644
820
|
// Force with infinite tolerance if still no breaks found
|
|
@@ -649,13 +825,13 @@ class LineBreak {
|
|
|
649
825
|
if (breaks.length > 0) {
|
|
650
826
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
|
|
651
827
|
resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
|
|
652
|
-
// Check for
|
|
653
|
-
if (
|
|
828
|
+
// Check for short lines if detection is enabled
|
|
829
|
+
if (shortLineDetectionEnabled &&
|
|
654
830
|
breaks.length > 1 &&
|
|
655
|
-
LineBreak.
|
|
831
|
+
LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
|
|
656
832
|
// Increase emergency stretch and try again
|
|
657
833
|
currentEmergencyStretch +=
|
|
658
|
-
width *
|
|
834
|
+
width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
|
|
659
835
|
iteration++;
|
|
660
836
|
continue;
|
|
661
837
|
}
|
|
@@ -989,6 +1165,8 @@ class LineBreak {
|
|
|
989
1165
|
const item = items[i];
|
|
990
1166
|
widths[i + 1] = widths[i] + item.width;
|
|
991
1167
|
if (item.type === ItemType.PENALTY) {
|
|
1168
|
+
stretches[i + 1] = stretches[i];
|
|
1169
|
+
shrinks[i + 1] = shrinks[i];
|
|
992
1170
|
minWidths[i + 1] = minWidths[i];
|
|
993
1171
|
}
|
|
994
1172
|
else if (item.type === ItemType.GLUE) {
|
|
@@ -1239,7 +1417,7 @@ class TextLayout {
|
|
|
1239
1417
|
this.loadedFont = loadedFont;
|
|
1240
1418
|
}
|
|
1241
1419
|
computeLines(options) {
|
|
1242
|
-
const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness,
|
|
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;
|
|
1243
1421
|
let lines;
|
|
1244
1422
|
if (width) {
|
|
1245
1423
|
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
@@ -1265,7 +1443,8 @@ class TextLayout {
|
|
|
1265
1443
|
exhyphenpenalty,
|
|
1266
1444
|
doublehyphendemerits,
|
|
1267
1445
|
looseness,
|
|
1268
|
-
|
|
1446
|
+
disableShortLineDetection,
|
|
1447
|
+
shortLineThreshold,
|
|
1269
1448
|
unitsPerEm: this.loadedFont.upem,
|
|
1270
1449
|
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1271
1450
|
)
|
|
@@ -3847,6 +4026,7 @@ class TextShaper {
|
|
|
3847
4026
|
// Apply letter spacing between glyphs (must match what was used in width measurements)
|
|
3848
4027
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
3849
4028
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
4029
|
+
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
3850
4030
|
for (let i = 0; i < glyphInfos.length; i++) {
|
|
3851
4031
|
const glyph = glyphInfos[i];
|
|
3852
4032
|
const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
|
|
@@ -3892,6 +4072,29 @@ class TextShaper {
|
|
|
3892
4072
|
if (isWhitespace) {
|
|
3893
4073
|
cursor.x += spaceAdjustment;
|
|
3894
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
|
+
}
|
|
3895
4098
|
}
|
|
3896
4099
|
if (currentClusterGlyphs.length > 0) {
|
|
3897
4100
|
clusters.push({
|
|
@@ -3924,6 +4127,23 @@ class TextShaper {
|
|
|
3924
4127
|
}
|
|
3925
4128
|
return spaceAdjustment;
|
|
3926
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
|
+
}
|
|
3927
4147
|
clearCache() {
|
|
3928
4148
|
this.geometryBuilder.clearCache();
|
|
3929
4149
|
}
|
|
@@ -5041,7 +5261,7 @@ class Text {
|
|
|
5041
5261
|
throw new Error('Font not loaded. Use Text.create() with a font option');
|
|
5042
5262
|
}
|
|
5043
5263
|
const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
|
|
5044
|
-
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,
|
|
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;
|
|
5045
5265
|
let widthInFontUnits;
|
|
5046
5266
|
if (width !== undefined) {
|
|
5047
5267
|
widthInFontUnits = width * (this.loadedFont.upem / size);
|
|
@@ -5071,7 +5291,8 @@ class Text {
|
|
|
5071
5291
|
exhyphenpenalty,
|
|
5072
5292
|
doublehyphendemerits,
|
|
5073
5293
|
looseness,
|
|
5074
|
-
|
|
5294
|
+
disableShortLineDetection,
|
|
5295
|
+
shortLineThreshold,
|
|
5075
5296
|
letterSpacing
|
|
5076
5297
|
});
|
|
5077
5298
|
const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
|