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.umd.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
|
|
@@ -127,9 +127,6 @@
|
|
|
127
127
|
this.metrics.length = 0;
|
|
128
128
|
this.activeTimers.clear();
|
|
129
129
|
}
|
|
130
|
-
reset() {
|
|
131
|
-
this.clear();
|
|
132
|
-
}
|
|
133
130
|
time(name, fn, metadata) {
|
|
134
131
|
if (!isLogEnabled)
|
|
135
132
|
return fn();
|
|
@@ -262,15 +259,15 @@
|
|
|
262
259
|
const INF_BAD = 10000;
|
|
263
260
|
// Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
|
|
264
261
|
const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
|
|
265
|
-
// Another non TeX default:
|
|
266
|
-
const
|
|
267
|
-
const
|
|
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
|
|
268
265
|
class LineBreak {
|
|
269
266
|
// Calculate badness according to TeX's formula (tex.web §108, line 2337)
|
|
270
267
|
// Given t (desired adjustment) and s (available stretch/shrink)
|
|
271
268
|
// Returns approximation to 100(t/s)³, representing how "bad" a line is
|
|
272
269
|
// Constants are derived from TeX's fixed-point arithmetic:
|
|
273
|
-
//
|
|
270
|
+
// 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
|
|
274
271
|
static badness(t, s) {
|
|
275
272
|
if (t === 0)
|
|
276
273
|
return 0;
|
|
@@ -329,8 +326,8 @@
|
|
|
329
326
|
const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
|
|
330
327
|
return filteredPoints;
|
|
331
328
|
}
|
|
332
|
-
// Converts text into items (boxes, glues, penalties) for line breaking
|
|
333
|
-
// The measureText function should return widths that include any letter spacing
|
|
329
|
+
// Converts text into items (boxes, glues, penalties) for line breaking
|
|
330
|
+
// The measureText function should return widths that include any letter spacing
|
|
334
331
|
static itemizeText(text, measureText, // function to measure text width
|
|
335
332
|
hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
|
|
336
333
|
const items = [];
|
|
@@ -354,16 +351,214 @@
|
|
|
354
351
|
});
|
|
355
352
|
return items;
|
|
356
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
|
+
}
|
|
357
507
|
static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
|
|
358
508
|
const items = [];
|
|
359
|
-
|
|
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 = [];
|
|
360
556
|
const tokens = text.match(/\S+|\s+/g) || [];
|
|
361
557
|
let currentIndex = 0;
|
|
362
558
|
for (let i = 0; i < tokens.length; i++) {
|
|
363
559
|
const token = tokens[i];
|
|
364
|
-
const tokenStartIndex = currentIndex;
|
|
560
|
+
const tokenStartIndex = startOffset + currentIndex;
|
|
365
561
|
if (/\s+/.test(token)) {
|
|
366
|
-
// Handle spaces
|
|
367
562
|
const width = measureText(token);
|
|
368
563
|
items.push({
|
|
369
564
|
type: ItemType.GLUE,
|
|
@@ -376,22 +571,19 @@
|
|
|
376
571
|
currentIndex += token.length;
|
|
377
572
|
}
|
|
378
573
|
else {
|
|
379
|
-
// Process word, splitting on explicit hyphens
|
|
380
|
-
// Split on hyphens while keeping them in the result
|
|
381
574
|
const segments = token.split(/(-)/);
|
|
382
575
|
let segmentIndex = tokenStartIndex;
|
|
383
576
|
for (let j = 0; j < segments.length; j++) {
|
|
384
577
|
const segment = segments[j];
|
|
385
578
|
if (!segment)
|
|
386
|
-
continue;
|
|
579
|
+
continue;
|
|
387
580
|
if (segment === '-') {
|
|
388
|
-
// Handle explicit hyphen as discretionary break
|
|
389
581
|
items.push({
|
|
390
582
|
type: ItemType.DISCRETIONARY,
|
|
391
|
-
width: measureText('-'),
|
|
392
|
-
preBreak: '-',
|
|
393
|
-
postBreak: '',
|
|
394
|
-
noBreak: '-',
|
|
583
|
+
width: measureText('-'),
|
|
584
|
+
preBreak: '-',
|
|
585
|
+
postBreak: '',
|
|
586
|
+
noBreak: '-',
|
|
395
587
|
preBreakWidth: measureText('-'),
|
|
396
588
|
penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
|
|
397
589
|
flagged: true,
|
|
@@ -401,8 +593,6 @@
|
|
|
401
593
|
segmentIndex += 1;
|
|
402
594
|
}
|
|
403
595
|
else {
|
|
404
|
-
// Process non-hyphen segment
|
|
405
|
-
// First handle soft hyphens (U+00AD)
|
|
406
596
|
if (segment.includes('\u00AD')) {
|
|
407
597
|
const partsWithMarkers = segment.split('\u00AD');
|
|
408
598
|
let runningIndex = 0;
|
|
@@ -420,23 +610,23 @@
|
|
|
420
610
|
if (k < partsWithMarkers.length - 1) {
|
|
421
611
|
items.push({
|
|
422
612
|
type: ItemType.DISCRETIONARY,
|
|
423
|
-
width: 0,
|
|
424
|
-
preBreak: '-',
|
|
425
|
-
postBreak: '',
|
|
426
|
-
noBreak: '',
|
|
613
|
+
width: 0,
|
|
614
|
+
preBreak: '-',
|
|
615
|
+
postBreak: '',
|
|
616
|
+
noBreak: '',
|
|
427
617
|
preBreakWidth: measureText('-'),
|
|
428
618
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
429
619
|
flagged: true,
|
|
430
620
|
text: '',
|
|
431
621
|
originIndex: segmentIndex + runningIndex
|
|
432
622
|
});
|
|
433
|
-
runningIndex += 1;
|
|
623
|
+
runningIndex += 1;
|
|
434
624
|
}
|
|
435
625
|
}
|
|
436
626
|
}
|
|
437
627
|
else if (hyphenate &&
|
|
438
|
-
segment.length >= lefthyphenmin + righthyphenmin
|
|
439
|
-
|
|
628
|
+
segment.length >= lefthyphenmin + righthyphenmin &&
|
|
629
|
+
/^\p{L}+$/u.test(segment)) {
|
|
440
630
|
const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
|
|
441
631
|
if (hyphenPoints.length > 0) {
|
|
442
632
|
let lastPoint = 0;
|
|
@@ -450,10 +640,10 @@
|
|
|
450
640
|
});
|
|
451
641
|
items.push({
|
|
452
642
|
type: ItemType.DISCRETIONARY,
|
|
453
|
-
width: 0,
|
|
454
|
-
preBreak: '-',
|
|
455
|
-
postBreak: '',
|
|
456
|
-
noBreak: '',
|
|
643
|
+
width: 0,
|
|
644
|
+
preBreak: '-',
|
|
645
|
+
postBreak: '',
|
|
646
|
+
noBreak: '',
|
|
457
647
|
preBreakWidth: measureText('-'),
|
|
458
648
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
459
649
|
flagged: true,
|
|
@@ -471,7 +661,6 @@
|
|
|
471
661
|
});
|
|
472
662
|
}
|
|
473
663
|
else {
|
|
474
|
-
// No hyphenation points, add as single box
|
|
475
664
|
items.push({
|
|
476
665
|
type: ItemType.BOX,
|
|
477
666
|
width: measureText(segment),
|
|
@@ -481,7 +670,6 @@
|
|
|
481
670
|
}
|
|
482
671
|
}
|
|
483
672
|
else {
|
|
484
|
-
// No hyphenation, add as single box
|
|
485
673
|
items.push({
|
|
486
674
|
type: ItemType.BOX,
|
|
487
675
|
width: measureText(segment),
|
|
@@ -497,27 +685,22 @@
|
|
|
497
685
|
}
|
|
498
686
|
return items;
|
|
499
687
|
}
|
|
500
|
-
// Detect if breakpoints create problematic
|
|
501
|
-
static
|
|
688
|
+
// Detect if breakpoints create problematic short lines
|
|
689
|
+
static hasShortLines(items, breakpoints, lineWidth, threshold) {
|
|
502
690
|
// Check each line segment (except the last, which can naturally be short)
|
|
503
691
|
let lineStart = 0;
|
|
504
692
|
for (let i = 0; i < breakpoints.length - 1; i++) {
|
|
505
693
|
const breakpoint = breakpoints[i];
|
|
506
|
-
// Count glue items (spaces) between line start and breakpoint
|
|
507
|
-
let glueCount = 0;
|
|
508
694
|
let totalWidth = 0;
|
|
509
695
|
for (let j = lineStart; j < breakpoint; j++) {
|
|
510
|
-
if (items[j].type === ItemType.GLUE) {
|
|
511
|
-
glueCount++;
|
|
512
|
-
}
|
|
513
696
|
if (items[j].type !== ItemType.PENALTY) {
|
|
514
697
|
totalWidth += items[j].width;
|
|
515
698
|
}
|
|
516
699
|
}
|
|
517
|
-
//
|
|
518
|
-
if (
|
|
700
|
+
// Check if line is narrow relative to target width
|
|
701
|
+
if (totalWidth > 0) {
|
|
519
702
|
const widthRatio = totalWidth / lineWidth;
|
|
520
|
-
if (widthRatio <
|
|
703
|
+
if (widthRatio < threshold) {
|
|
521
704
|
return true;
|
|
522
705
|
}
|
|
523
706
|
}
|
|
@@ -532,7 +715,7 @@
|
|
|
532
715
|
align: options.align || 'left',
|
|
533
716
|
hyphenate: options.hyphenate || false
|
|
534
717
|
});
|
|
535
|
-
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,
|
|
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;
|
|
536
719
|
// Handle multiple paragraphs by processing each independently
|
|
537
720
|
if (respectExistingBreaks && text.includes('\n')) {
|
|
538
721
|
const paragraphs = text.split('\n');
|
|
@@ -611,39 +794,32 @@
|
|
|
611
794
|
}
|
|
612
795
|
];
|
|
613
796
|
}
|
|
614
|
-
// Itemize
|
|
615
|
-
const allItems = LineBreak.itemizeText(text, measureText,
|
|
797
|
+
// Itemize without hyphenation first (TeX approach: only compute if needed)
|
|
798
|
+
const allItems = LineBreak.itemizeText(text, measureText, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
616
799
|
if (allItems.length === 0) {
|
|
617
800
|
return [];
|
|
618
801
|
}
|
|
619
|
-
// Iteratively increase emergency stretch to eliminate short single-word lines.
|
|
620
|
-
// Post-processing approach preserves TeX algorithm integrity while itemization
|
|
621
|
-
// (the expensive part) happens once
|
|
622
802
|
const MAX_ITERATIONS = 5;
|
|
623
803
|
let iteration = 0;
|
|
624
804
|
let currentEmergencyStretch = initialEmergencyStretch;
|
|
625
805
|
let resultLines = null;
|
|
626
|
-
const
|
|
806
|
+
const shortLineDetectionEnabled = !disableShortLineDetection;
|
|
627
807
|
while (iteration < MAX_ITERATIONS) {
|
|
628
|
-
// Three-pass approach
|
|
629
|
-
// First pass:
|
|
630
|
-
// Second pass:
|
|
631
|
-
//
|
|
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
|
|
632
812
|
// First pass: no hyphenation
|
|
633
|
-
let currentItems =
|
|
634
|
-
? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
|
|
635
|
-
item.penalty !==
|
|
636
|
-
(context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
|
|
637
|
-
: allItems;
|
|
813
|
+
let currentItems = allItems;
|
|
638
814
|
let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
|
|
639
|
-
// Second pass:
|
|
815
|
+
// Second pass: compute hyphenation only if needed
|
|
640
816
|
if (breaks.length === 0 && useHyphenation) {
|
|
641
|
-
|
|
817
|
+
const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
818
|
+
currentItems = itemsWithHyphenation;
|
|
642
819
|
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
|
|
643
820
|
}
|
|
644
|
-
//
|
|
821
|
+
// Emergency pass: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
|
|
645
822
|
if (breaks.length === 0) {
|
|
646
|
-
currentItems = allItems;
|
|
647
823
|
breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
|
|
648
824
|
}
|
|
649
825
|
// Force with infinite tolerance if still no breaks found
|
|
@@ -654,13 +830,13 @@
|
|
|
654
830
|
if (breaks.length > 0) {
|
|
655
831
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
|
|
656
832
|
resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
|
|
657
|
-
// Check for
|
|
658
|
-
if (
|
|
833
|
+
// Check for short lines if detection is enabled
|
|
834
|
+
if (shortLineDetectionEnabled &&
|
|
659
835
|
breaks.length > 1 &&
|
|
660
|
-
LineBreak.
|
|
836
|
+
LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
|
|
661
837
|
// Increase emergency stretch and try again
|
|
662
838
|
currentEmergencyStretch +=
|
|
663
|
-
width *
|
|
839
|
+
width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
|
|
664
840
|
iteration++;
|
|
665
841
|
continue;
|
|
666
842
|
}
|
|
@@ -994,6 +1170,8 @@
|
|
|
994
1170
|
const item = items[i];
|
|
995
1171
|
widths[i + 1] = widths[i] + item.width;
|
|
996
1172
|
if (item.type === ItemType.PENALTY) {
|
|
1173
|
+
stretches[i + 1] = stretches[i];
|
|
1174
|
+
shrinks[i + 1] = shrinks[i];
|
|
997
1175
|
minWidths[i + 1] = minWidths[i];
|
|
998
1176
|
}
|
|
999
1177
|
else if (item.type === ItemType.GLUE) {
|
|
@@ -1244,7 +1422,7 @@
|
|
|
1244
1422
|
this.loadedFont = loadedFont;
|
|
1245
1423
|
}
|
|
1246
1424
|
computeLines(options) {
|
|
1247
|
-
const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness,
|
|
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;
|
|
1248
1426
|
let lines;
|
|
1249
1427
|
if (width) {
|
|
1250
1428
|
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
@@ -1270,7 +1448,8 @@
|
|
|
1270
1448
|
exhyphenpenalty,
|
|
1271
1449
|
doublehyphendemerits,
|
|
1272
1450
|
looseness,
|
|
1273
|
-
|
|
1451
|
+
disableShortLineDetection,
|
|
1452
|
+
shortLineThreshold,
|
|
1274
1453
|
unitsPerEm: this.loadedFont.upem,
|
|
1275
1454
|
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1276
1455
|
)
|
|
@@ -3854,6 +4033,7 @@
|
|
|
3854
4033
|
// Apply letter spacing between glyphs (must match what was used in width measurements)
|
|
3855
4034
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
3856
4035
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
4036
|
+
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
3857
4037
|
for (let i = 0; i < glyphInfos.length; i++) {
|
|
3858
4038
|
const glyph = glyphInfos[i];
|
|
3859
4039
|
const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
|
|
@@ -3899,6 +4079,29 @@
|
|
|
3899
4079
|
if (isWhitespace) {
|
|
3900
4080
|
cursor.x += spaceAdjustment;
|
|
3901
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
|
+
}
|
|
3902
4105
|
}
|
|
3903
4106
|
if (currentClusterGlyphs.length > 0) {
|
|
3904
4107
|
clusters.push({
|
|
@@ -3931,6 +4134,23 @@
|
|
|
3931
4134
|
}
|
|
3932
4135
|
return spaceAdjustment;
|
|
3933
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
|
+
}
|
|
3934
4154
|
clearCache() {
|
|
3935
4155
|
this.geometryBuilder.clearCache();
|
|
3936
4156
|
}
|
|
@@ -5048,7 +5268,7 @@
|
|
|
5048
5268
|
throw new Error('Font not loaded. Use Text.create() with a font option');
|
|
5049
5269
|
}
|
|
5050
5270
|
const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
|
|
5051
|
-
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,
|
|
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;
|
|
5052
5272
|
let widthInFontUnits;
|
|
5053
5273
|
if (width !== undefined) {
|
|
5054
5274
|
widthInFontUnits = width * (this.loadedFont.upem / size);
|
|
@@ -5078,7 +5298,8 @@
|
|
|
5078
5298
|
exhyphenpenalty,
|
|
5079
5299
|
doublehyphendemerits,
|
|
5080
5300
|
looseness,
|
|
5081
|
-
|
|
5301
|
+
disableShortLineDetection,
|
|
5302
|
+
shortLineThreshold,
|
|
5082
5303
|
letterSpacing
|
|
5083
5304
|
});
|
|
5084
5305
|
const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
|