three-text 0.2.7 → 0.2.9
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 +35 -9
- package/dist/index.cjs +399 -115
- package/dist/index.d.ts +9 -2
- package/dist/index.js +399 -115
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +399 -115
- package/dist/index.umd.min.js +2 -2
- package/dist/three/index.cjs +36 -29
- package/dist/three/index.d.ts +1 -0
- package/dist/three/index.js +36 -29
- package/dist/three/react.d.ts +9 -2
- package/dist/types/core/Text.d.ts +7 -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/three/index.d.ts +1 -0
- package/dist/types/utils/PerformanceLogger.d.ts +0 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* three-text v0.2.
|
|
2
|
+
* three-text v0.2.9
|
|
3
3
|
* Copyright (C) 2025 Countertype LLC
|
|
4
4
|
*
|
|
5
5
|
* This program is free software: you can redistribute it and/or modify
|
|
@@ -125,9 +125,6 @@ class PerformanceLogger {
|
|
|
125
125
|
this.metrics.length = 0;
|
|
126
126
|
this.activeTimers.clear();
|
|
127
127
|
}
|
|
128
|
-
reset() {
|
|
129
|
-
this.clear();
|
|
130
|
-
}
|
|
131
128
|
time(name, fn, metadata) {
|
|
132
129
|
if (!isLogEnabled)
|
|
133
130
|
return fn();
|
|
@@ -189,11 +186,11 @@ var FitnessClass;
|
|
|
189
186
|
FitnessClass[FitnessClass["VERY_LOOSE"] = 3] = "VERY_LOOSE";
|
|
190
187
|
})(FitnessClass || (FitnessClass = {}));
|
|
191
188
|
// ActiveNodeList maintains all currently viable breakpoints as we scan through the text.
|
|
192
|
-
// Each node represents a potential break with accumulated demerits (total "cost" from start)
|
|
189
|
+
// Each node represents a potential break with accumulated demerits (total "cost" from start)
|
|
193
190
|
//
|
|
194
191
|
// Demerits = cumulative penalty score from text start to this break, calculated as:
|
|
195
|
-
// (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web
|
|
196
|
-
// Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
|
|
192
|
+
// (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web line 16634)
|
|
193
|
+
// Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
|
|
197
194
|
//
|
|
198
195
|
// Implementation differs from TeX:
|
|
199
196
|
// - Hash map for O(1) lookups by position+fitness
|
|
@@ -260,15 +257,15 @@ const DEFAULT_RIGHT_HYPHEN_MIN = 4;
|
|
|
260
257
|
const INF_BAD = 10000;
|
|
261
258
|
// Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
|
|
262
259
|
const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
|
|
263
|
-
// Another non TeX default:
|
|
264
|
-
const
|
|
265
|
-
const
|
|
260
|
+
// Another non TeX default: Short line detection thresholds
|
|
261
|
+
const SHORT_LINE_WIDTH_THRESHOLD = 0.5; // Lines < 50% of width are problematic
|
|
262
|
+
const SHORT_LINE_EMERGENCY_STRETCH_INCREMENT = 0.1; // Add 10% per iteration
|
|
266
263
|
class LineBreak {
|
|
267
|
-
// Calculate badness according to TeX's formula (tex.web
|
|
264
|
+
// Calculate badness according to TeX's formula (tex.web line 2337)
|
|
268
265
|
// Given t (desired adjustment) and s (available stretch/shrink)
|
|
269
266
|
// Returns approximation to 100(t/s)³, representing how "bad" a line is
|
|
270
267
|
// Constants are derived from TeX's fixed-point arithmetic:
|
|
271
|
-
//
|
|
268
|
+
// 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
|
|
272
269
|
static badness(t, s) {
|
|
273
270
|
if (t === 0)
|
|
274
271
|
return 0;
|
|
@@ -327,8 +324,8 @@ class LineBreak {
|
|
|
327
324
|
const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
|
|
328
325
|
return filteredPoints;
|
|
329
326
|
}
|
|
330
|
-
// Converts text into items (boxes, glues, penalties) for line breaking
|
|
331
|
-
// The measureText function should return widths that include any letter spacing
|
|
327
|
+
// Converts text into items (boxes, glues, penalties) for line breaking
|
|
328
|
+
// The measureText function should return widths that include any letter spacing
|
|
332
329
|
static itemizeText(text, measureText, // function to measure text width
|
|
333
330
|
hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
|
|
334
331
|
const items = [];
|
|
@@ -352,16 +349,214 @@ class LineBreak {
|
|
|
352
349
|
});
|
|
353
350
|
return items;
|
|
354
351
|
}
|
|
352
|
+
// Chinese, Japanese, and Korean character ranges
|
|
353
|
+
static isCJK(char) {
|
|
354
|
+
const code = char.codePointAt(0);
|
|
355
|
+
if (code === undefined)
|
|
356
|
+
return false;
|
|
357
|
+
return (
|
|
358
|
+
// CJK Unified Ideographs
|
|
359
|
+
(code >= 0x4e00 && code <= 0x9fff) ||
|
|
360
|
+
// CJK Extension A
|
|
361
|
+
(code >= 0x3400 && code <= 0x4dbf) ||
|
|
362
|
+
// CJK Extension B
|
|
363
|
+
(code >= 0x20000 && code <= 0x2a6df) ||
|
|
364
|
+
// CJK Extension C
|
|
365
|
+
(code >= 0x2a700 && code <= 0x2b73f) ||
|
|
366
|
+
// CJK Extension D
|
|
367
|
+
(code >= 0x2b740 && code <= 0x2b81f) ||
|
|
368
|
+
// CJK Extension E
|
|
369
|
+
(code >= 0x2b820 && code <= 0x2ceaf) ||
|
|
370
|
+
// CJK Compatibility Ideographs
|
|
371
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
372
|
+
// Hiragana
|
|
373
|
+
(code >= 0x3040 && code <= 0x309f) ||
|
|
374
|
+
// Katakana
|
|
375
|
+
(code >= 0x30a0 && code <= 0x30ff) ||
|
|
376
|
+
// Hangul Syllables
|
|
377
|
+
(code >= 0xac00 && code <= 0xd7af) ||
|
|
378
|
+
// Hangul Jamo
|
|
379
|
+
(code >= 0x1100 && code <= 0x11ff) ||
|
|
380
|
+
// Hangul Compatibility Jamo
|
|
381
|
+
(code >= 0x3130 && code <= 0x318f) ||
|
|
382
|
+
// Hangul Jamo Extended-A
|
|
383
|
+
(code >= 0xa960 && code <= 0xa97f) ||
|
|
384
|
+
// Hangul Jamo Extended-B
|
|
385
|
+
(code >= 0xd7b0 && code <= 0xd7ff) ||
|
|
386
|
+
// Halfwidth and Fullwidth Forms (Korean)
|
|
387
|
+
(code >= 0xffa0 && code <= 0xffdc));
|
|
388
|
+
}
|
|
389
|
+
// Closing punctuation where line breaks are prohibited (UAX #14 LB30, JIS X 4051)
|
|
390
|
+
static isCJClosingPunctuation(char) {
|
|
391
|
+
const code = char.charCodeAt(0);
|
|
392
|
+
return (code === 0x3001 || // 、
|
|
393
|
+
code === 0x3002 || // 。
|
|
394
|
+
code === 0xff0c || // ,
|
|
395
|
+
code === 0xff0e || // .
|
|
396
|
+
code === 0xff1a || // :
|
|
397
|
+
code === 0xff1b || // ;
|
|
398
|
+
code === 0xff01 || // !
|
|
399
|
+
code === 0xff1f || // ?
|
|
400
|
+
code === 0xff09 || // )
|
|
401
|
+
code === 0x3011 || // 】
|
|
402
|
+
code === 0xff5d || // }
|
|
403
|
+
code === 0x300d || // 」
|
|
404
|
+
code === 0x300f || // 』
|
|
405
|
+
code === 0x3009 || // 〉
|
|
406
|
+
code === 0x300b || // 》
|
|
407
|
+
code === 0x3015 || // 〕
|
|
408
|
+
code === 0x3017 || // 〗
|
|
409
|
+
code === 0x3019 || // 〙
|
|
410
|
+
code === 0x301b || // 〛
|
|
411
|
+
code === 0x30fc || // ー
|
|
412
|
+
code === 0x2014 || // —
|
|
413
|
+
code === 0x2026 || // …
|
|
414
|
+
code === 0x2025 // ‥
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
// Opening punctuation where line breaks are prohibited (UAX #14 LB30a, JIS X 4051)
|
|
418
|
+
static isCJOpeningPunctuation(char) {
|
|
419
|
+
const code = char.charCodeAt(0);
|
|
420
|
+
return (code === 0xff08 || // (
|
|
421
|
+
code === 0x3010 || // 【
|
|
422
|
+
code === 0xff5b || // {
|
|
423
|
+
code === 0x300c || // 「
|
|
424
|
+
code === 0x300e || // 『
|
|
425
|
+
code === 0x3008 || // 〈
|
|
426
|
+
code === 0x300a || // 《
|
|
427
|
+
code === 0x3014 || // 〔
|
|
428
|
+
code === 0x3016 || // 〖
|
|
429
|
+
code === 0x3018 || // 〘
|
|
430
|
+
code === 0x301a // 〚
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
static isCJPunctuation(char) {
|
|
434
|
+
return (this.isCJClosingPunctuation(char) || this.isCJOpeningPunctuation(char));
|
|
435
|
+
}
|
|
436
|
+
// CJK (Chinese/Japanese/Korean) character-level itemization with inter-character glue
|
|
437
|
+
static itemizeCJKText(text, measureText, context, startOffset = 0, glueParams) {
|
|
438
|
+
const items = [];
|
|
439
|
+
const chars = Array.from(text);
|
|
440
|
+
let textPosition = startOffset;
|
|
441
|
+
// Inter-character glue parameters
|
|
442
|
+
let glueWidth;
|
|
443
|
+
let glueStretch;
|
|
444
|
+
let glueShrink;
|
|
445
|
+
if (glueParams) {
|
|
446
|
+
glueWidth = glueParams.width;
|
|
447
|
+
glueStretch = glueParams.stretch;
|
|
448
|
+
glueShrink = glueParams.shrink;
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
const baseCharWidth = measureText('字');
|
|
452
|
+
glueWidth = 0;
|
|
453
|
+
glueStretch = baseCharWidth * 0.04;
|
|
454
|
+
glueShrink = baseCharWidth * 0.04;
|
|
455
|
+
}
|
|
456
|
+
for (let i = 0; i < chars.length; i++) {
|
|
457
|
+
const char = chars[i];
|
|
458
|
+
const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
|
|
459
|
+
if (/\s/.test(char)) {
|
|
460
|
+
const width = measureText(char);
|
|
461
|
+
items.push({
|
|
462
|
+
type: ItemType.GLUE,
|
|
463
|
+
width,
|
|
464
|
+
stretch: width * SPACE_STRETCH_RATIO,
|
|
465
|
+
shrink: width * SPACE_SHRINK_RATIO,
|
|
466
|
+
text: char,
|
|
467
|
+
originIndex: textPosition
|
|
468
|
+
});
|
|
469
|
+
textPosition += char.length;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
items.push({
|
|
473
|
+
type: ItemType.BOX,
|
|
474
|
+
width: measureText(char),
|
|
475
|
+
text: char,
|
|
476
|
+
originIndex: textPosition
|
|
477
|
+
});
|
|
478
|
+
textPosition += char.length;
|
|
479
|
+
// Glue after a box creates a break opportunity
|
|
480
|
+
// Must not add glue where breaks are prohibited by Chinese/Japanese line breaking rules
|
|
481
|
+
if (nextChar && !/\s/.test(nextChar)) {
|
|
482
|
+
let canBreak = true;
|
|
483
|
+
if (this.isCJClosingPunctuation(nextChar)) {
|
|
484
|
+
canBreak = false;
|
|
485
|
+
}
|
|
486
|
+
if (this.isCJOpeningPunctuation(char)) {
|
|
487
|
+
canBreak = false;
|
|
488
|
+
}
|
|
489
|
+
// Avoid stretch between consecutive punctuation (?" or 。」)
|
|
490
|
+
const isPunctPair = this.isCJPunctuation(char) && this.isCJPunctuation(nextChar);
|
|
491
|
+
if (canBreak && !isPunctPair) {
|
|
492
|
+
items.push({
|
|
493
|
+
type: ItemType.GLUE,
|
|
494
|
+
width: glueWidth,
|
|
495
|
+
stretch: glueStretch,
|
|
496
|
+
shrink: glueShrink,
|
|
497
|
+
text: '',
|
|
498
|
+
originIndex: textPosition
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return items;
|
|
504
|
+
}
|
|
355
505
|
static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
|
|
356
506
|
const items = [];
|
|
357
|
-
|
|
507
|
+
const chars = Array.from(text);
|
|
508
|
+
// Calculate CJK glue parameters once for consistency across all segments
|
|
509
|
+
const baseCharWidth = measureText('字');
|
|
510
|
+
const cjkGlueParams = {
|
|
511
|
+
width: 0,
|
|
512
|
+
stretch: baseCharWidth * 0.04,
|
|
513
|
+
shrink: baseCharWidth * 0.04
|
|
514
|
+
};
|
|
515
|
+
let buffer = '';
|
|
516
|
+
let bufferStart = 0;
|
|
517
|
+
let bufferScript = null;
|
|
518
|
+
let textPosition = 0;
|
|
519
|
+
const flushBuffer = () => {
|
|
520
|
+
if (buffer.length === 0)
|
|
521
|
+
return;
|
|
522
|
+
if (bufferScript === 'cjk') {
|
|
523
|
+
const cjkItems = this.itemizeCJKText(buffer, measureText, context, bufferStart, cjkGlueParams);
|
|
524
|
+
items.push(...cjkItems);
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
const wordItems = this.itemizeWordBased(buffer, bufferStart, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context);
|
|
528
|
+
items.push(...wordItems);
|
|
529
|
+
}
|
|
530
|
+
buffer = '';
|
|
531
|
+
bufferScript = null;
|
|
532
|
+
};
|
|
533
|
+
for (let i = 0; i < chars.length; i++) {
|
|
534
|
+
const char = chars[i];
|
|
535
|
+
const isCJKChar = this.isCJK(char);
|
|
536
|
+
const currentScript = isCJKChar ? 'cjk' : 'word';
|
|
537
|
+
if (bufferScript !== null && bufferScript !== currentScript) {
|
|
538
|
+
flushBuffer();
|
|
539
|
+
bufferStart = textPosition;
|
|
540
|
+
}
|
|
541
|
+
if (bufferScript === null) {
|
|
542
|
+
bufferScript = currentScript;
|
|
543
|
+
bufferStart = textPosition;
|
|
544
|
+
}
|
|
545
|
+
buffer += char;
|
|
546
|
+
textPosition += char.length;
|
|
547
|
+
}
|
|
548
|
+
flushBuffer();
|
|
549
|
+
return items;
|
|
550
|
+
}
|
|
551
|
+
// Word-based itemization for alphabetic scripts (Latin, Cyrillic, Greek, etc.)
|
|
552
|
+
static itemizeWordBased(text, startOffset, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
|
|
553
|
+
const items = [];
|
|
358
554
|
const tokens = text.match(/\S+|\s+/g) || [];
|
|
359
555
|
let currentIndex = 0;
|
|
360
556
|
for (let i = 0; i < tokens.length; i++) {
|
|
361
557
|
const token = tokens[i];
|
|
362
|
-
const tokenStartIndex = currentIndex;
|
|
558
|
+
const tokenStartIndex = startOffset + currentIndex;
|
|
363
559
|
if (/\s+/.test(token)) {
|
|
364
|
-
// Handle spaces
|
|
365
560
|
const width = measureText(token);
|
|
366
561
|
items.push({
|
|
367
562
|
type: ItemType.GLUE,
|
|
@@ -374,22 +569,19 @@ class LineBreak {
|
|
|
374
569
|
currentIndex += token.length;
|
|
375
570
|
}
|
|
376
571
|
else {
|
|
377
|
-
// Process word, splitting on explicit hyphens
|
|
378
|
-
// Split on hyphens while keeping them in the result
|
|
379
572
|
const segments = token.split(/(-)/);
|
|
380
573
|
let segmentIndex = tokenStartIndex;
|
|
381
574
|
for (let j = 0; j < segments.length; j++) {
|
|
382
575
|
const segment = segments[j];
|
|
383
576
|
if (!segment)
|
|
384
|
-
continue;
|
|
577
|
+
continue;
|
|
385
578
|
if (segment === '-') {
|
|
386
|
-
// Handle explicit hyphen as discretionary break
|
|
387
579
|
items.push({
|
|
388
580
|
type: ItemType.DISCRETIONARY,
|
|
389
|
-
width: measureText('-'),
|
|
390
|
-
preBreak: '-',
|
|
391
|
-
postBreak: '',
|
|
392
|
-
noBreak: '-',
|
|
581
|
+
width: measureText('-'),
|
|
582
|
+
preBreak: '-',
|
|
583
|
+
postBreak: '',
|
|
584
|
+
noBreak: '-',
|
|
393
585
|
preBreakWidth: measureText('-'),
|
|
394
586
|
penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
|
|
395
587
|
flagged: true,
|
|
@@ -399,8 +591,6 @@ class LineBreak {
|
|
|
399
591
|
segmentIndex += 1;
|
|
400
592
|
}
|
|
401
593
|
else {
|
|
402
|
-
// Process non-hyphen segment
|
|
403
|
-
// First handle soft hyphens (U+00AD)
|
|
404
594
|
if (segment.includes('\u00AD')) {
|
|
405
595
|
const partsWithMarkers = segment.split('\u00AD');
|
|
406
596
|
let runningIndex = 0;
|
|
@@ -418,23 +608,23 @@ class LineBreak {
|
|
|
418
608
|
if (k < partsWithMarkers.length - 1) {
|
|
419
609
|
items.push({
|
|
420
610
|
type: ItemType.DISCRETIONARY,
|
|
421
|
-
width: 0,
|
|
422
|
-
preBreak: '-',
|
|
423
|
-
postBreak: '',
|
|
424
|
-
noBreak: '',
|
|
611
|
+
width: 0,
|
|
612
|
+
preBreak: '-',
|
|
613
|
+
postBreak: '',
|
|
614
|
+
noBreak: '',
|
|
425
615
|
preBreakWidth: measureText('-'),
|
|
426
616
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
427
617
|
flagged: true,
|
|
428
618
|
text: '',
|
|
429
619
|
originIndex: segmentIndex + runningIndex
|
|
430
620
|
});
|
|
431
|
-
runningIndex += 1;
|
|
621
|
+
runningIndex += 1;
|
|
432
622
|
}
|
|
433
623
|
}
|
|
434
624
|
}
|
|
435
625
|
else if (hyphenate &&
|
|
436
|
-
segment.length >= lefthyphenmin + righthyphenmin
|
|
437
|
-
|
|
626
|
+
segment.length >= lefthyphenmin + righthyphenmin &&
|
|
627
|
+
/^\p{L}+$/u.test(segment)) {
|
|
438
628
|
const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
|
|
439
629
|
if (hyphenPoints.length > 0) {
|
|
440
630
|
let lastPoint = 0;
|
|
@@ -448,10 +638,10 @@ class LineBreak {
|
|
|
448
638
|
});
|
|
449
639
|
items.push({
|
|
450
640
|
type: ItemType.DISCRETIONARY,
|
|
451
|
-
width: 0,
|
|
452
|
-
preBreak: '-',
|
|
453
|
-
postBreak: '',
|
|
454
|
-
noBreak: '',
|
|
641
|
+
width: 0,
|
|
642
|
+
preBreak: '-',
|
|
643
|
+
postBreak: '',
|
|
644
|
+
noBreak: '',
|
|
455
645
|
preBreakWidth: measureText('-'),
|
|
456
646
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
457
647
|
flagged: true,
|
|
@@ -469,7 +659,6 @@ class LineBreak {
|
|
|
469
659
|
});
|
|
470
660
|
}
|
|
471
661
|
else {
|
|
472
|
-
// No hyphenation points, add as single box
|
|
473
662
|
items.push({
|
|
474
663
|
type: ItemType.BOX,
|
|
475
664
|
width: measureText(segment),
|
|
@@ -479,7 +668,6 @@ class LineBreak {
|
|
|
479
668
|
}
|
|
480
669
|
}
|
|
481
670
|
else {
|
|
482
|
-
// No hyphenation, add as single box
|
|
483
671
|
items.push({
|
|
484
672
|
type: ItemType.BOX,
|
|
485
673
|
width: measureText(segment),
|
|
@@ -495,27 +683,22 @@ class LineBreak {
|
|
|
495
683
|
}
|
|
496
684
|
return items;
|
|
497
685
|
}
|
|
498
|
-
// Detect if breakpoints create problematic
|
|
499
|
-
static
|
|
686
|
+
// Detect if breakpoints create problematic short lines
|
|
687
|
+
static hasShortLines(items, breakpoints, lineWidth, threshold) {
|
|
500
688
|
// Check each line segment (except the last, which can naturally be short)
|
|
501
689
|
let lineStart = 0;
|
|
502
690
|
for (let i = 0; i < breakpoints.length - 1; i++) {
|
|
503
691
|
const breakpoint = breakpoints[i];
|
|
504
|
-
// Count glue items (spaces) between line start and breakpoint
|
|
505
|
-
let glueCount = 0;
|
|
506
692
|
let totalWidth = 0;
|
|
507
693
|
for (let j = lineStart; j < breakpoint; j++) {
|
|
508
|
-
if (items[j].type === ItemType.GLUE) {
|
|
509
|
-
glueCount++;
|
|
510
|
-
}
|
|
511
694
|
if (items[j].type !== ItemType.PENALTY) {
|
|
512
695
|
totalWidth += items[j].width;
|
|
513
696
|
}
|
|
514
697
|
}
|
|
515
|
-
//
|
|
516
|
-
if (
|
|
698
|
+
// Check if line is narrow relative to target width
|
|
699
|
+
if (totalWidth > 0) {
|
|
517
700
|
const widthRatio = totalWidth / lineWidth;
|
|
518
|
-
if (widthRatio <
|
|
701
|
+
if (widthRatio < threshold) {
|
|
519
702
|
return true;
|
|
520
703
|
}
|
|
521
704
|
}
|
|
@@ -530,7 +713,7 @@ class LineBreak {
|
|
|
530
713
|
align: options.align || 'left',
|
|
531
714
|
hyphenate: options.hyphenate || false
|
|
532
715
|
});
|
|
533
|
-
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,
|
|
716
|
+
const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0, disableShortLineDetection = false, shortLineThreshold = SHORT_LINE_WIDTH_THRESHOLD } = options;
|
|
534
717
|
// Handle multiple paragraphs by processing each independently
|
|
535
718
|
if (respectExistingBreaks && text.includes('\n')) {
|
|
536
719
|
const paragraphs = text.split('\n');
|
|
@@ -609,56 +792,50 @@ class LineBreak {
|
|
|
609
792
|
}
|
|
610
793
|
];
|
|
611
794
|
}
|
|
612
|
-
// Itemize
|
|
613
|
-
const allItems = LineBreak.itemizeText(text, measureText,
|
|
795
|
+
// Itemize without hyphenation first (TeX approach: only compute if needed)
|
|
796
|
+
const allItems = LineBreak.itemizeText(text, measureText, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
614
797
|
if (allItems.length === 0) {
|
|
615
798
|
return [];
|
|
616
799
|
}
|
|
617
|
-
// Iteratively increase emergency stretch to eliminate short single-word lines.
|
|
618
|
-
// Post-processing approach preserves TeX algorithm integrity while itemization
|
|
619
|
-
// (the expensive part) happens once
|
|
620
800
|
const MAX_ITERATIONS = 5;
|
|
621
801
|
let iteration = 0;
|
|
622
802
|
let currentEmergencyStretch = initialEmergencyStretch;
|
|
623
803
|
let resultLines = null;
|
|
624
|
-
const
|
|
804
|
+
const shortLineDetectionEnabled = !disableShortLineDetection;
|
|
625
805
|
while (iteration < MAX_ITERATIONS) {
|
|
626
|
-
// Three-pass approach
|
|
627
|
-
// First pass:
|
|
628
|
-
// Second pass:
|
|
629
|
-
//
|
|
806
|
+
// Three-pass approach matching TeX:
|
|
807
|
+
// First pass: without hyphenation (pretolerance)
|
|
808
|
+
// Second pass: with hyphenation, only if first pass fails (tolerance)
|
|
809
|
+
// Emergency pass: additional stretch as last resort
|
|
630
810
|
// First pass: no hyphenation
|
|
631
|
-
let currentItems =
|
|
632
|
-
? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
|
|
633
|
-
item.penalty !==
|
|
634
|
-
(context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
|
|
635
|
-
: allItems;
|
|
811
|
+
let currentItems = allItems;
|
|
636
812
|
let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
|
|
637
|
-
// Second pass: with hyphenation
|
|
813
|
+
// Second pass: with hyphenation if first pass failed
|
|
638
814
|
if (breaks.length === 0 && useHyphenation) {
|
|
639
|
-
|
|
815
|
+
const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
816
|
+
currentItems = itemsWithHyphenation;
|
|
640
817
|
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
|
|
641
818
|
}
|
|
642
|
-
//
|
|
819
|
+
// Emergency pass: add emergency stretch to background stretchability
|
|
643
820
|
if (breaks.length === 0) {
|
|
644
|
-
|
|
645
|
-
|
|
821
|
+
// For first emergency attempt, use initialEmergencyStretch
|
|
822
|
+
// For subsequent iterations (short line detection), progressively increase
|
|
823
|
+
currentEmergencyStretch = initialEmergencyStretch + (iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT);
|
|
824
|
+
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, true, currentEmergencyStretch, context);
|
|
646
825
|
}
|
|
647
|
-
//
|
|
826
|
+
// Last resort: allow higher badness (but not infinite)
|
|
648
827
|
if (breaks.length === 0) {
|
|
649
|
-
breaks = LineBreak.findBreakpoints(currentItems, width,
|
|
828
|
+
breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD, looseness, true, currentEmergencyStretch, context);
|
|
650
829
|
}
|
|
651
830
|
// Create lines from breaks
|
|
652
831
|
if (breaks.length > 0) {
|
|
653
832
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
|
|
654
833
|
resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
|
|
655
|
-
// Check for
|
|
656
|
-
if (
|
|
834
|
+
// Check for short lines if detection is enabled
|
|
835
|
+
if (shortLineDetectionEnabled &&
|
|
657
836
|
breaks.length > 1 &&
|
|
658
|
-
LineBreak.
|
|
659
|
-
//
|
|
660
|
-
currentEmergencyStretch +=
|
|
661
|
-
width * SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT;
|
|
837
|
+
LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
|
|
838
|
+
// Retry with more emergency stretch to push words to next line
|
|
662
839
|
iteration++;
|
|
663
840
|
continue;
|
|
664
841
|
}
|
|
@@ -690,11 +867,12 @@ class LineBreak {
|
|
|
690
867
|
threshold = Infinity, // maximum badness allowed for a break
|
|
691
868
|
looseness = 0, // desired line count adjustment
|
|
692
869
|
isFinalPass = false, // whether this is the final pass
|
|
693
|
-
|
|
870
|
+
backgroundStretch = 0, // additional stretchability for all glue (emergency stretch)
|
|
694
871
|
context) {
|
|
695
872
|
// Pre-compute cumulative widths for fast range queries
|
|
696
873
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(items);
|
|
697
874
|
const activeNodes = new ActiveNodeList();
|
|
875
|
+
const minimumDemerits = { value: Infinity };
|
|
698
876
|
activeNodes.insert({
|
|
699
877
|
position: 0,
|
|
700
878
|
line: 0,
|
|
@@ -708,16 +886,16 @@ class LineBreak {
|
|
|
708
886
|
const item = items[i];
|
|
709
887
|
if (item.type === ItemType.PENALTY &&
|
|
710
888
|
item.penalty < Infinity) {
|
|
711
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
889
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
712
890
|
}
|
|
713
891
|
if (item.type === ItemType.DISCRETIONARY &&
|
|
714
892
|
item.penalty < Infinity) {
|
|
715
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
893
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
716
894
|
}
|
|
717
895
|
if (item.type === ItemType.GLUE &&
|
|
718
896
|
i > 0 &&
|
|
719
897
|
items[i - 1].type === ItemType.BOX) {
|
|
720
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
898
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
721
899
|
}
|
|
722
900
|
LineBreak.deactivateNodes(activeNodes, i, lineWidth, cumulativeWidths.minWidths);
|
|
723
901
|
}
|
|
@@ -784,7 +962,7 @@ class LineBreak {
|
|
|
784
962
|
}
|
|
785
963
|
return breakpoints;
|
|
786
964
|
}
|
|
787
|
-
static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity,
|
|
965
|
+
static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity, backgroundStretch = 0, cumulativeWidths, context, isFinalPass = false, minimumDemerits = { value: Infinity }) {
|
|
788
966
|
const penalty = items[breakpoint].type === ItemType.PENALTY
|
|
789
967
|
? items[breakpoint].penalty
|
|
790
968
|
: 0;
|
|
@@ -796,11 +974,11 @@ class LineBreak {
|
|
|
796
974
|
continue;
|
|
797
975
|
const adjustmentData = LineBreak.computeAdjustmentRatio(items, node.position, breakpoint, node.line, lineWidth, cumulativeWidths, context);
|
|
798
976
|
const { ratio: r, adjustment, stretch, shrink, totalWidth } = adjustmentData;
|
|
799
|
-
// Calculate badness
|
|
977
|
+
// Calculate badness
|
|
800
978
|
let badness;
|
|
801
979
|
if (adjustment > 0) {
|
|
802
|
-
//
|
|
803
|
-
const effectiveStretch = stretch +
|
|
980
|
+
// backgroundStretch includes emergency stretch if in emergency pass
|
|
981
|
+
const effectiveStretch = stretch + backgroundStretch;
|
|
804
982
|
if (effectiveStretch <= 0) {
|
|
805
983
|
// Overfull box - badness is infinite + 1
|
|
806
984
|
badness = INF_BAD + 1;
|
|
@@ -825,26 +1003,32 @@ class LineBreak {
|
|
|
825
1003
|
else {
|
|
826
1004
|
badness = 0;
|
|
827
1005
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1006
|
+
// Artificial demerits: in final pass with no feasible solution yet
|
|
1007
|
+
// and only one active node left, force this break as a last resort
|
|
1008
|
+
const isLastResort = isFinalPass &&
|
|
1009
|
+
minimumDemerits.value === Infinity &&
|
|
1010
|
+
allActiveNodes.length === 1 &&
|
|
1011
|
+
node.active;
|
|
1012
|
+
if (!isForcedBreak && !isLastResort && r < -1) {
|
|
1013
|
+
continue; // too tight
|
|
831
1014
|
}
|
|
832
1015
|
const fitnessClass = LineBreak.computeFitnessClass(badness, adjustment > 0);
|
|
833
|
-
if (!isForcedBreak && badness > threshold) {
|
|
1016
|
+
if (!isForcedBreak && !isLastResort && badness > threshold) {
|
|
834
1017
|
continue;
|
|
835
1018
|
}
|
|
836
|
-
// Initialize demerits
|
|
1019
|
+
// Initialize demerits with saturation check
|
|
837
1020
|
let flaggedDemerits = 0;
|
|
838
1021
|
let fitnessDemerits = 0;
|
|
839
1022
|
const configuredLinePenalty = context?.linePenalty ?? 0;
|
|
840
1023
|
let d = configuredLinePenalty + badness;
|
|
841
1024
|
let demerits = Math.abs(d) >= 10000 ? 100000000 : d * d;
|
|
1025
|
+
const artificialDemerits = isLastResort;
|
|
842
1026
|
const breakpointPenalty = items[breakpoint].type === ItemType.PENALTY
|
|
843
1027
|
? items[breakpoint].penalty
|
|
844
1028
|
: items[breakpoint].type === ItemType.DISCRETIONARY
|
|
845
1029
|
? items[breakpoint].penalty
|
|
846
1030
|
: 0;
|
|
847
|
-
//
|
|
1031
|
+
// Penalty contribution to demerits
|
|
848
1032
|
if (breakpointPenalty !== 0) {
|
|
849
1033
|
if (breakpointPenalty > 0) {
|
|
850
1034
|
demerits += breakpointPenalty * breakpointPenalty;
|
|
@@ -870,10 +1054,13 @@ class LineBreak {
|
|
|
870
1054
|
fitnessDemerits = context?.adjDemerits ?? 0;
|
|
871
1055
|
demerits += fitnessDemerits;
|
|
872
1056
|
}
|
|
873
|
-
if (isForcedBreak) {
|
|
1057
|
+
if (isForcedBreak || artificialDemerits) {
|
|
874
1058
|
demerits = 0;
|
|
875
1059
|
}
|
|
876
1060
|
const totalDemerits = node.totalDemerits + demerits;
|
|
1061
|
+
if (totalDemerits < minimumDemerits.value) {
|
|
1062
|
+
minimumDemerits.value = totalDemerits;
|
|
1063
|
+
}
|
|
877
1064
|
let existingNode = activeNodes.findExisting(breakpoint, fitnessClass);
|
|
878
1065
|
if (existingNode) {
|
|
879
1066
|
if (totalDemerits < existingNode.totalDemerits) {
|
|
@@ -992,6 +1179,8 @@ class LineBreak {
|
|
|
992
1179
|
const item = items[i];
|
|
993
1180
|
widths[i + 1] = widths[i] + item.width;
|
|
994
1181
|
if (item.type === ItemType.PENALTY) {
|
|
1182
|
+
stretches[i + 1] = stretches[i];
|
|
1183
|
+
shrinks[i + 1] = shrinks[i];
|
|
995
1184
|
minWidths[i + 1] = minWidths[i];
|
|
996
1185
|
}
|
|
997
1186
|
else if (item.type === ItemType.GLUE) {
|
|
@@ -1009,7 +1198,6 @@ class LineBreak {
|
|
|
1009
1198
|
return { widths, stretches, shrinks, minWidths };
|
|
1010
1199
|
}
|
|
1011
1200
|
// Deactivate nodes that can't lead to good line breaks
|
|
1012
|
-
// TeX recalculates minWidth each time, we use cumulative arrays for lookup
|
|
1013
1201
|
static deactivateNodes(activeNodeList, currentPosition, lineWidth, minWidths) {
|
|
1014
1202
|
const activeNodes = activeNodeList.getAllActive();
|
|
1015
1203
|
for (let i = activeNodes.length - 1; i >= 0; i--) {
|
|
@@ -1214,8 +1402,8 @@ function convertFontFeaturesToString(features) {
|
|
|
1214
1402
|
|
|
1215
1403
|
class TextMeasurer {
|
|
1216
1404
|
// Measures text width including letter spacing
|
|
1217
|
-
//
|
|
1218
|
-
// so the widths given to the line-breaking algorithm already account for tracking
|
|
1405
|
+
// (letter spacing is added uniformly after each glyph during measurement,
|
|
1406
|
+
// so the widths given to the line-breaking algorithm already account for tracking)
|
|
1219
1407
|
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1220
1408
|
const buffer = loadedFont.hb.createBuffer();
|
|
1221
1409
|
buffer.addText(text);
|
|
@@ -1242,7 +1430,7 @@ class TextLayout {
|
|
|
1242
1430
|
this.loadedFont = loadedFont;
|
|
1243
1431
|
}
|
|
1244
1432
|
computeLines(options) {
|
|
1245
|
-
const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness,
|
|
1433
|
+
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;
|
|
1246
1434
|
let lines;
|
|
1247
1435
|
if (width) {
|
|
1248
1436
|
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
@@ -1268,7 +1456,8 @@ class TextLayout {
|
|
|
1268
1456
|
exhyphenpenalty,
|
|
1269
1457
|
doublehyphendemerits,
|
|
1270
1458
|
looseness,
|
|
1271
|
-
|
|
1459
|
+
disableShortLineDetection,
|
|
1460
|
+
shortLineThreshold,
|
|
1272
1461
|
unitsPerEm: this.loadedFont.upem,
|
|
1273
1462
|
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1274
1463
|
)
|
|
@@ -3536,10 +3725,11 @@ class LRUCache {
|
|
|
3536
3725
|
}
|
|
3537
3726
|
}
|
|
3538
3727
|
|
|
3728
|
+
const CONTOUR_CACHE_MAX_ENTRIES = 1000;
|
|
3729
|
+
const WORD_CACHE_MAX_ENTRIES = 1000;
|
|
3539
3730
|
class GlyphGeometryBuilder {
|
|
3540
3731
|
constructor(cache, loadedFont) {
|
|
3541
3732
|
this.fontId = 'default';
|
|
3542
|
-
this.wordCache = new Map();
|
|
3543
3733
|
this.cache = cache;
|
|
3544
3734
|
this.loadedFont = loadedFont;
|
|
3545
3735
|
this.tessellator = new Tessellator();
|
|
@@ -3549,7 +3739,7 @@ class GlyphGeometryBuilder {
|
|
|
3549
3739
|
this.drawCallbacks = new DrawCallbackHandler();
|
|
3550
3740
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
3551
3741
|
this.contourCache = new LRUCache({
|
|
3552
|
-
maxEntries:
|
|
3742
|
+
maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
|
|
3553
3743
|
calculateSize: (contours) => {
|
|
3554
3744
|
let size = 0;
|
|
3555
3745
|
for (const path of contours.paths) {
|
|
@@ -3558,6 +3748,15 @@ class GlyphGeometryBuilder {
|
|
|
3558
3748
|
return size + 64; // bounds overhead
|
|
3559
3749
|
}
|
|
3560
3750
|
});
|
|
3751
|
+
this.wordCache = new LRUCache({
|
|
3752
|
+
maxEntries: WORD_CACHE_MAX_ENTRIES,
|
|
3753
|
+
calculateSize: (data) => {
|
|
3754
|
+
let size = data.vertices.length * 4;
|
|
3755
|
+
size += data.normals.length * 4;
|
|
3756
|
+
size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
|
|
3757
|
+
return size;
|
|
3758
|
+
}
|
|
3759
|
+
});
|
|
3561
3760
|
}
|
|
3562
3761
|
getOptimizationStats() {
|
|
3563
3762
|
return this.collector.getOptimizationStats();
|
|
@@ -3847,9 +4046,10 @@ class TextShaper {
|
|
|
3847
4046
|
let currentClusterText = '';
|
|
3848
4047
|
let clusterStartPosition = new Vec3();
|
|
3849
4048
|
let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
|
|
3850
|
-
// Apply letter spacing
|
|
4049
|
+
// Apply letter spacing after each glyph to match width measurements used during line breaking
|
|
3851
4050
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
3852
4051
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
4052
|
+
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
3853
4053
|
for (let i = 0; i < glyphInfos.length; i++) {
|
|
3854
4054
|
const glyph = glyphInfos[i];
|
|
3855
4055
|
const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
|
|
@@ -3895,6 +4095,29 @@ class TextShaper {
|
|
|
3895
4095
|
if (isWhitespace) {
|
|
3896
4096
|
cursor.x += spaceAdjustment;
|
|
3897
4097
|
}
|
|
4098
|
+
// CJK glue adjustment (must match exactly where LineBreak adds glue)
|
|
4099
|
+
if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
|
|
4100
|
+
const currentChar = lineInfo.text[glyph.cl];
|
|
4101
|
+
const nextGlyph = glyphInfos[i + 1];
|
|
4102
|
+
const nextChar = lineInfo.text[nextGlyph.cl];
|
|
4103
|
+
const isCJKChar = LineBreak.isCJK(currentChar);
|
|
4104
|
+
const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
|
|
4105
|
+
if (isCJKChar && nextIsCJKChar) {
|
|
4106
|
+
let shouldApply = true;
|
|
4107
|
+
if (LineBreak.isCJClosingPunctuation(nextChar)) {
|
|
4108
|
+
shouldApply = false;
|
|
4109
|
+
}
|
|
4110
|
+
if (LineBreak.isCJOpeningPunctuation(currentChar)) {
|
|
4111
|
+
shouldApply = false;
|
|
4112
|
+
}
|
|
4113
|
+
if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
|
|
4114
|
+
shouldApply = false;
|
|
4115
|
+
}
|
|
4116
|
+
if (shouldApply) {
|
|
4117
|
+
cursor.x += cjkAdjustment;
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
3898
4121
|
}
|
|
3899
4122
|
if (currentClusterGlyphs.length > 0) {
|
|
3900
4123
|
clusters.push({
|
|
@@ -3927,6 +4150,23 @@ class TextShaper {
|
|
|
3927
4150
|
}
|
|
3928
4151
|
return spaceAdjustment;
|
|
3929
4152
|
}
|
|
4153
|
+
calculateCJKAdjustment(lineInfo, align) {
|
|
4154
|
+
if (lineInfo.adjustmentRatio === undefined ||
|
|
4155
|
+
align !== 'justify' ||
|
|
4156
|
+
lineInfo.isLastLine) {
|
|
4157
|
+
return 0;
|
|
4158
|
+
}
|
|
4159
|
+
const baseCharWidth = this.loadedFont.upem;
|
|
4160
|
+
const glueStretch = baseCharWidth * 0.04;
|
|
4161
|
+
const glueShrink = baseCharWidth * 0.04;
|
|
4162
|
+
if (lineInfo.adjustmentRatio > 0) {
|
|
4163
|
+
return lineInfo.adjustmentRatio * glueStretch;
|
|
4164
|
+
}
|
|
4165
|
+
else if (lineInfo.adjustmentRatio < 0) {
|
|
4166
|
+
return lineInfo.adjustmentRatio * glueShrink;
|
|
4167
|
+
}
|
|
4168
|
+
return 0;
|
|
4169
|
+
}
|
|
3930
4170
|
clearCache() {
|
|
3931
4171
|
this.geometryBuilder.clearCache();
|
|
3932
4172
|
}
|
|
@@ -4844,6 +5084,54 @@ class Text {
|
|
|
4844
5084
|
if (!Text.hbInitPromise) {
|
|
4845
5085
|
Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
|
|
4846
5086
|
}
|
|
5087
|
+
const loadedFont = await Text.resolveFont(options);
|
|
5088
|
+
const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
|
|
5089
|
+
text.setLoadedFont(loadedFont);
|
|
5090
|
+
// Initial creation
|
|
5091
|
+
const { font, maxCacheSizeMB, ...geometryOptions } = options;
|
|
5092
|
+
const result = await text.createGeometry(geometryOptions);
|
|
5093
|
+
// Recursive update function
|
|
5094
|
+
const update = async (newOptions) => {
|
|
5095
|
+
// Merge options - preserve font from original options if not provided
|
|
5096
|
+
const mergedOptions = { ...options };
|
|
5097
|
+
for (const key in newOptions) {
|
|
5098
|
+
const value = newOptions[key];
|
|
5099
|
+
if (value !== undefined) {
|
|
5100
|
+
mergedOptions[key] = value;
|
|
5101
|
+
}
|
|
5102
|
+
}
|
|
5103
|
+
// If font definition or configuration changed, reload font and reset helpers
|
|
5104
|
+
if (newOptions.font !== undefined ||
|
|
5105
|
+
newOptions.fontVariations !== undefined ||
|
|
5106
|
+
newOptions.fontFeatures !== undefined) {
|
|
5107
|
+
const newLoadedFont = await Text.resolveFont(mergedOptions);
|
|
5108
|
+
text.setLoadedFont(newLoadedFont);
|
|
5109
|
+
// Reset geometry builder and shaper to use new font
|
|
5110
|
+
text.resetHelpers();
|
|
5111
|
+
}
|
|
5112
|
+
// Update closure options for next time
|
|
5113
|
+
options = mergedOptions;
|
|
5114
|
+
const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
|
|
5115
|
+
const newResult = await text.createGeometry(currentGeometryOptions);
|
|
5116
|
+
return {
|
|
5117
|
+
...newResult,
|
|
5118
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
5119
|
+
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5120
|
+
clearCache: () => text.clearCache(),
|
|
5121
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
5122
|
+
update
|
|
5123
|
+
};
|
|
5124
|
+
};
|
|
5125
|
+
return {
|
|
5126
|
+
...result,
|
|
5127
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
5128
|
+
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5129
|
+
clearCache: () => text.clearCache(),
|
|
5130
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
5131
|
+
update
|
|
5132
|
+
};
|
|
5133
|
+
}
|
|
5134
|
+
static async resolveFont(options) {
|
|
4847
5135
|
const baseFontKey = typeof options.font === 'string'
|
|
4848
5136
|
? options.font
|
|
4849
5137
|
: `buffer-${Text.generateFontContentHash(options.font)}`;
|
|
@@ -4858,17 +5146,7 @@ class Text {
|
|
|
4858
5146
|
if (!loadedFont) {
|
|
4859
5147
|
loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
|
|
4860
5148
|
}
|
|
4861
|
-
|
|
4862
|
-
text.setLoadedFont(loadedFont);
|
|
4863
|
-
const { font, maxCacheSizeMB, ...geometryOptions } = options;
|
|
4864
|
-
const result = await text.createGeometry(geometryOptions);
|
|
4865
|
-
return {
|
|
4866
|
-
...result,
|
|
4867
|
-
getLoadedFont: () => text.getLoadedFont(),
|
|
4868
|
-
getCacheStatistics: () => text.getCacheStatistics(),
|
|
4869
|
-
clearCache: () => text.clearCache(),
|
|
4870
|
-
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
|
|
4871
|
-
};
|
|
5149
|
+
return loadedFont;
|
|
4872
5150
|
}
|
|
4873
5151
|
static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
|
|
4874
5152
|
const tempText = new Text();
|
|
@@ -5044,7 +5322,7 @@ class Text {
|
|
|
5044
5322
|
throw new Error('Font not loaded. Use Text.create() with a font option');
|
|
5045
5323
|
}
|
|
5046
5324
|
const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
|
|
5047
|
-
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,
|
|
5325
|
+
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;
|
|
5048
5326
|
let widthInFontUnits;
|
|
5049
5327
|
if (width !== undefined) {
|
|
5050
5328
|
widthInFontUnits = width * (this.loadedFont.upem / size);
|
|
@@ -5074,7 +5352,8 @@ class Text {
|
|
|
5074
5352
|
exhyphenpenalty,
|
|
5075
5353
|
doublehyphendemerits,
|
|
5076
5354
|
looseness,
|
|
5077
|
-
|
|
5355
|
+
disableShortLineDetection,
|
|
5356
|
+
shortLineThreshold,
|
|
5078
5357
|
letterSpacing
|
|
5079
5358
|
});
|
|
5080
5359
|
const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
|
|
@@ -5325,6 +5604,11 @@ class Text {
|
|
|
5325
5604
|
glyphLineIndex: glyphLineIndices
|
|
5326
5605
|
};
|
|
5327
5606
|
}
|
|
5607
|
+
resetHelpers() {
|
|
5608
|
+
this.geometryBuilder = undefined;
|
|
5609
|
+
this.textShaper = undefined;
|
|
5610
|
+
this.textLayout = undefined;
|
|
5611
|
+
}
|
|
5328
5612
|
destroy() {
|
|
5329
5613
|
if (!this.loadedFont) {
|
|
5330
5614
|
return;
|