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.js
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
|
|
@@ -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();
|
|
@@ -186,11 +183,11 @@ var FitnessClass;
|
|
|
186
183
|
FitnessClass[FitnessClass["VERY_LOOSE"] = 3] = "VERY_LOOSE";
|
|
187
184
|
})(FitnessClass || (FitnessClass = {}));
|
|
188
185
|
// ActiveNodeList maintains all currently viable breakpoints as we scan through the text.
|
|
189
|
-
// Each node represents a potential break with accumulated demerits (total "cost" from start)
|
|
186
|
+
// Each node represents a potential break with accumulated demerits (total "cost" from start)
|
|
190
187
|
//
|
|
191
188
|
// Demerits = cumulative penalty score from text start to this break, calculated as:
|
|
192
|
-
// (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web
|
|
193
|
-
// Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
|
|
189
|
+
// (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web line 16634)
|
|
190
|
+
// Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
|
|
194
191
|
//
|
|
195
192
|
// Implementation differs from TeX:
|
|
196
193
|
// - Hash map for O(1) lookups by position+fitness
|
|
@@ -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
|
-
// Calculate badness according to TeX's formula (tex.web
|
|
261
|
+
// Calculate badness according to TeX's formula (tex.web 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,56 +789,50 @@ 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: with hyphenation
|
|
810
|
+
// Second pass: with hyphenation if first pass failed
|
|
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: add emergency stretch to background stretchability
|
|
640
817
|
if (breaks.length === 0) {
|
|
641
|
-
|
|
642
|
-
|
|
818
|
+
// For first emergency attempt, use initialEmergencyStretch
|
|
819
|
+
// For subsequent iterations (short line detection), progressively increase
|
|
820
|
+
currentEmergencyStretch = initialEmergencyStretch + (iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT);
|
|
821
|
+
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, true, currentEmergencyStretch, context);
|
|
643
822
|
}
|
|
644
|
-
//
|
|
823
|
+
// Last resort: allow higher badness (but not infinite)
|
|
645
824
|
if (breaks.length === 0) {
|
|
646
|
-
breaks = LineBreak.findBreakpoints(currentItems, width,
|
|
825
|
+
breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD, looseness, true, currentEmergencyStretch, context);
|
|
647
826
|
}
|
|
648
827
|
// Create lines from breaks
|
|
649
828
|
if (breaks.length > 0) {
|
|
650
829
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
|
|
651
830
|
resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
|
|
652
|
-
// Check for
|
|
653
|
-
if (
|
|
831
|
+
// Check for short lines if detection is enabled
|
|
832
|
+
if (shortLineDetectionEnabled &&
|
|
654
833
|
breaks.length > 1 &&
|
|
655
|
-
LineBreak.
|
|
656
|
-
//
|
|
657
|
-
currentEmergencyStretch +=
|
|
658
|
-
width * SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT;
|
|
834
|
+
LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
|
|
835
|
+
// Retry with more emergency stretch to push words to next line
|
|
659
836
|
iteration++;
|
|
660
837
|
continue;
|
|
661
838
|
}
|
|
@@ -687,11 +864,12 @@ class LineBreak {
|
|
|
687
864
|
threshold = Infinity, // maximum badness allowed for a break
|
|
688
865
|
looseness = 0, // desired line count adjustment
|
|
689
866
|
isFinalPass = false, // whether this is the final pass
|
|
690
|
-
|
|
867
|
+
backgroundStretch = 0, // additional stretchability for all glue (emergency stretch)
|
|
691
868
|
context) {
|
|
692
869
|
// Pre-compute cumulative widths for fast range queries
|
|
693
870
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(items);
|
|
694
871
|
const activeNodes = new ActiveNodeList();
|
|
872
|
+
const minimumDemerits = { value: Infinity };
|
|
695
873
|
activeNodes.insert({
|
|
696
874
|
position: 0,
|
|
697
875
|
line: 0,
|
|
@@ -705,16 +883,16 @@ class LineBreak {
|
|
|
705
883
|
const item = items[i];
|
|
706
884
|
if (item.type === ItemType.PENALTY &&
|
|
707
885
|
item.penalty < Infinity) {
|
|
708
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
886
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
709
887
|
}
|
|
710
888
|
if (item.type === ItemType.DISCRETIONARY &&
|
|
711
889
|
item.penalty < Infinity) {
|
|
712
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
890
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
713
891
|
}
|
|
714
892
|
if (item.type === ItemType.GLUE &&
|
|
715
893
|
i > 0 &&
|
|
716
894
|
items[i - 1].type === ItemType.BOX) {
|
|
717
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
895
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
718
896
|
}
|
|
719
897
|
LineBreak.deactivateNodes(activeNodes, i, lineWidth, cumulativeWidths.minWidths);
|
|
720
898
|
}
|
|
@@ -781,7 +959,7 @@ class LineBreak {
|
|
|
781
959
|
}
|
|
782
960
|
return breakpoints;
|
|
783
961
|
}
|
|
784
|
-
static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity,
|
|
962
|
+
static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity, backgroundStretch = 0, cumulativeWidths, context, isFinalPass = false, minimumDemerits = { value: Infinity }) {
|
|
785
963
|
const penalty = items[breakpoint].type === ItemType.PENALTY
|
|
786
964
|
? items[breakpoint].penalty
|
|
787
965
|
: 0;
|
|
@@ -793,11 +971,11 @@ class LineBreak {
|
|
|
793
971
|
continue;
|
|
794
972
|
const adjustmentData = LineBreak.computeAdjustmentRatio(items, node.position, breakpoint, node.line, lineWidth, cumulativeWidths, context);
|
|
795
973
|
const { ratio: r, adjustment, stretch, shrink, totalWidth } = adjustmentData;
|
|
796
|
-
// Calculate badness
|
|
974
|
+
// Calculate badness
|
|
797
975
|
let badness;
|
|
798
976
|
if (adjustment > 0) {
|
|
799
|
-
//
|
|
800
|
-
const effectiveStretch = stretch +
|
|
977
|
+
// backgroundStretch includes emergency stretch if in emergency pass
|
|
978
|
+
const effectiveStretch = stretch + backgroundStretch;
|
|
801
979
|
if (effectiveStretch <= 0) {
|
|
802
980
|
// Overfull box - badness is infinite + 1
|
|
803
981
|
badness = INF_BAD + 1;
|
|
@@ -822,26 +1000,32 @@ class LineBreak {
|
|
|
822
1000
|
else {
|
|
823
1001
|
badness = 0;
|
|
824
1002
|
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1003
|
+
// Artificial demerits: in final pass with no feasible solution yet
|
|
1004
|
+
// and only one active node left, force this break as a last resort
|
|
1005
|
+
const isLastResort = isFinalPass &&
|
|
1006
|
+
minimumDemerits.value === Infinity &&
|
|
1007
|
+
allActiveNodes.length === 1 &&
|
|
1008
|
+
node.active;
|
|
1009
|
+
if (!isForcedBreak && !isLastResort && r < -1) {
|
|
1010
|
+
continue; // too tight
|
|
828
1011
|
}
|
|
829
1012
|
const fitnessClass = LineBreak.computeFitnessClass(badness, adjustment > 0);
|
|
830
|
-
if (!isForcedBreak && badness > threshold) {
|
|
1013
|
+
if (!isForcedBreak && !isLastResort && badness > threshold) {
|
|
831
1014
|
continue;
|
|
832
1015
|
}
|
|
833
|
-
// Initialize demerits
|
|
1016
|
+
// Initialize demerits with saturation check
|
|
834
1017
|
let flaggedDemerits = 0;
|
|
835
1018
|
let fitnessDemerits = 0;
|
|
836
1019
|
const configuredLinePenalty = context?.linePenalty ?? 0;
|
|
837
1020
|
let d = configuredLinePenalty + badness;
|
|
838
1021
|
let demerits = Math.abs(d) >= 10000 ? 100000000 : d * d;
|
|
1022
|
+
const artificialDemerits = isLastResort;
|
|
839
1023
|
const breakpointPenalty = items[breakpoint].type === ItemType.PENALTY
|
|
840
1024
|
? items[breakpoint].penalty
|
|
841
1025
|
: items[breakpoint].type === ItemType.DISCRETIONARY
|
|
842
1026
|
? items[breakpoint].penalty
|
|
843
1027
|
: 0;
|
|
844
|
-
//
|
|
1028
|
+
// Penalty contribution to demerits
|
|
845
1029
|
if (breakpointPenalty !== 0) {
|
|
846
1030
|
if (breakpointPenalty > 0) {
|
|
847
1031
|
demerits += breakpointPenalty * breakpointPenalty;
|
|
@@ -867,10 +1051,13 @@ class LineBreak {
|
|
|
867
1051
|
fitnessDemerits = context?.adjDemerits ?? 0;
|
|
868
1052
|
demerits += fitnessDemerits;
|
|
869
1053
|
}
|
|
870
|
-
if (isForcedBreak) {
|
|
1054
|
+
if (isForcedBreak || artificialDemerits) {
|
|
871
1055
|
demerits = 0;
|
|
872
1056
|
}
|
|
873
1057
|
const totalDemerits = node.totalDemerits + demerits;
|
|
1058
|
+
if (totalDemerits < minimumDemerits.value) {
|
|
1059
|
+
minimumDemerits.value = totalDemerits;
|
|
1060
|
+
}
|
|
874
1061
|
let existingNode = activeNodes.findExisting(breakpoint, fitnessClass);
|
|
875
1062
|
if (existingNode) {
|
|
876
1063
|
if (totalDemerits < existingNode.totalDemerits) {
|
|
@@ -989,6 +1176,8 @@ class LineBreak {
|
|
|
989
1176
|
const item = items[i];
|
|
990
1177
|
widths[i + 1] = widths[i] + item.width;
|
|
991
1178
|
if (item.type === ItemType.PENALTY) {
|
|
1179
|
+
stretches[i + 1] = stretches[i];
|
|
1180
|
+
shrinks[i + 1] = shrinks[i];
|
|
992
1181
|
minWidths[i + 1] = minWidths[i];
|
|
993
1182
|
}
|
|
994
1183
|
else if (item.type === ItemType.GLUE) {
|
|
@@ -1006,7 +1195,6 @@ class LineBreak {
|
|
|
1006
1195
|
return { widths, stretches, shrinks, minWidths };
|
|
1007
1196
|
}
|
|
1008
1197
|
// Deactivate nodes that can't lead to good line breaks
|
|
1009
|
-
// TeX recalculates minWidth each time, we use cumulative arrays for lookup
|
|
1010
1198
|
static deactivateNodes(activeNodeList, currentPosition, lineWidth, minWidths) {
|
|
1011
1199
|
const activeNodes = activeNodeList.getAllActive();
|
|
1012
1200
|
for (let i = activeNodes.length - 1; i >= 0; i--) {
|
|
@@ -1211,8 +1399,8 @@ function convertFontFeaturesToString(features) {
|
|
|
1211
1399
|
|
|
1212
1400
|
class TextMeasurer {
|
|
1213
1401
|
// Measures text width including letter spacing
|
|
1214
|
-
//
|
|
1215
|
-
// so the widths given to the line-breaking algorithm already account for tracking
|
|
1402
|
+
// (letter spacing is added uniformly after each glyph during measurement,
|
|
1403
|
+
// so the widths given to the line-breaking algorithm already account for tracking)
|
|
1216
1404
|
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1217
1405
|
const buffer = loadedFont.hb.createBuffer();
|
|
1218
1406
|
buffer.addText(text);
|
|
@@ -1239,7 +1427,7 @@ class TextLayout {
|
|
|
1239
1427
|
this.loadedFont = loadedFont;
|
|
1240
1428
|
}
|
|
1241
1429
|
computeLines(options) {
|
|
1242
|
-
const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness,
|
|
1430
|
+
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
1431
|
let lines;
|
|
1244
1432
|
if (width) {
|
|
1245
1433
|
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
@@ -1265,7 +1453,8 @@ class TextLayout {
|
|
|
1265
1453
|
exhyphenpenalty,
|
|
1266
1454
|
doublehyphendemerits,
|
|
1267
1455
|
looseness,
|
|
1268
|
-
|
|
1456
|
+
disableShortLineDetection,
|
|
1457
|
+
shortLineThreshold,
|
|
1269
1458
|
unitsPerEm: this.loadedFont.upem,
|
|
1270
1459
|
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1271
1460
|
)
|
|
@@ -3533,10 +3722,11 @@ class LRUCache {
|
|
|
3533
3722
|
}
|
|
3534
3723
|
}
|
|
3535
3724
|
|
|
3725
|
+
const CONTOUR_CACHE_MAX_ENTRIES = 1000;
|
|
3726
|
+
const WORD_CACHE_MAX_ENTRIES = 1000;
|
|
3536
3727
|
class GlyphGeometryBuilder {
|
|
3537
3728
|
constructor(cache, loadedFont) {
|
|
3538
3729
|
this.fontId = 'default';
|
|
3539
|
-
this.wordCache = new Map();
|
|
3540
3730
|
this.cache = cache;
|
|
3541
3731
|
this.loadedFont = loadedFont;
|
|
3542
3732
|
this.tessellator = new Tessellator();
|
|
@@ -3546,7 +3736,7 @@ class GlyphGeometryBuilder {
|
|
|
3546
3736
|
this.drawCallbacks = new DrawCallbackHandler();
|
|
3547
3737
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
3548
3738
|
this.contourCache = new LRUCache({
|
|
3549
|
-
maxEntries:
|
|
3739
|
+
maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
|
|
3550
3740
|
calculateSize: (contours) => {
|
|
3551
3741
|
let size = 0;
|
|
3552
3742
|
for (const path of contours.paths) {
|
|
@@ -3555,6 +3745,15 @@ class GlyphGeometryBuilder {
|
|
|
3555
3745
|
return size + 64; // bounds overhead
|
|
3556
3746
|
}
|
|
3557
3747
|
});
|
|
3748
|
+
this.wordCache = new LRUCache({
|
|
3749
|
+
maxEntries: WORD_CACHE_MAX_ENTRIES,
|
|
3750
|
+
calculateSize: (data) => {
|
|
3751
|
+
let size = data.vertices.length * 4;
|
|
3752
|
+
size += data.normals.length * 4;
|
|
3753
|
+
size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
|
|
3754
|
+
return size;
|
|
3755
|
+
}
|
|
3756
|
+
});
|
|
3558
3757
|
}
|
|
3559
3758
|
getOptimizationStats() {
|
|
3560
3759
|
return this.collector.getOptimizationStats();
|
|
@@ -3844,9 +4043,10 @@ class TextShaper {
|
|
|
3844
4043
|
let currentClusterText = '';
|
|
3845
4044
|
let clusterStartPosition = new Vec3();
|
|
3846
4045
|
let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
|
|
3847
|
-
// Apply letter spacing
|
|
4046
|
+
// Apply letter spacing after each glyph to match width measurements used during line breaking
|
|
3848
4047
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
3849
4048
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
4049
|
+
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
3850
4050
|
for (let i = 0; i < glyphInfos.length; i++) {
|
|
3851
4051
|
const glyph = glyphInfos[i];
|
|
3852
4052
|
const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
|
|
@@ -3892,6 +4092,29 @@ class TextShaper {
|
|
|
3892
4092
|
if (isWhitespace) {
|
|
3893
4093
|
cursor.x += spaceAdjustment;
|
|
3894
4094
|
}
|
|
4095
|
+
// CJK glue adjustment (must match exactly where LineBreak adds glue)
|
|
4096
|
+
if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
|
|
4097
|
+
const currentChar = lineInfo.text[glyph.cl];
|
|
4098
|
+
const nextGlyph = glyphInfos[i + 1];
|
|
4099
|
+
const nextChar = lineInfo.text[nextGlyph.cl];
|
|
4100
|
+
const isCJKChar = LineBreak.isCJK(currentChar);
|
|
4101
|
+
const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
|
|
4102
|
+
if (isCJKChar && nextIsCJKChar) {
|
|
4103
|
+
let shouldApply = true;
|
|
4104
|
+
if (LineBreak.isCJClosingPunctuation(nextChar)) {
|
|
4105
|
+
shouldApply = false;
|
|
4106
|
+
}
|
|
4107
|
+
if (LineBreak.isCJOpeningPunctuation(currentChar)) {
|
|
4108
|
+
shouldApply = false;
|
|
4109
|
+
}
|
|
4110
|
+
if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
|
|
4111
|
+
shouldApply = false;
|
|
4112
|
+
}
|
|
4113
|
+
if (shouldApply) {
|
|
4114
|
+
cursor.x += cjkAdjustment;
|
|
4115
|
+
}
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
3895
4118
|
}
|
|
3896
4119
|
if (currentClusterGlyphs.length > 0) {
|
|
3897
4120
|
clusters.push({
|
|
@@ -3924,6 +4147,23 @@ class TextShaper {
|
|
|
3924
4147
|
}
|
|
3925
4148
|
return spaceAdjustment;
|
|
3926
4149
|
}
|
|
4150
|
+
calculateCJKAdjustment(lineInfo, align) {
|
|
4151
|
+
if (lineInfo.adjustmentRatio === undefined ||
|
|
4152
|
+
align !== 'justify' ||
|
|
4153
|
+
lineInfo.isLastLine) {
|
|
4154
|
+
return 0;
|
|
4155
|
+
}
|
|
4156
|
+
const baseCharWidth = this.loadedFont.upem;
|
|
4157
|
+
const glueStretch = baseCharWidth * 0.04;
|
|
4158
|
+
const glueShrink = baseCharWidth * 0.04;
|
|
4159
|
+
if (lineInfo.adjustmentRatio > 0) {
|
|
4160
|
+
return lineInfo.adjustmentRatio * glueStretch;
|
|
4161
|
+
}
|
|
4162
|
+
else if (lineInfo.adjustmentRatio < 0) {
|
|
4163
|
+
return lineInfo.adjustmentRatio * glueShrink;
|
|
4164
|
+
}
|
|
4165
|
+
return 0;
|
|
4166
|
+
}
|
|
3927
4167
|
clearCache() {
|
|
3928
4168
|
this.geometryBuilder.clearCache();
|
|
3929
4169
|
}
|
|
@@ -4841,6 +5081,54 @@ class Text {
|
|
|
4841
5081
|
if (!Text.hbInitPromise) {
|
|
4842
5082
|
Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
|
|
4843
5083
|
}
|
|
5084
|
+
const loadedFont = await Text.resolveFont(options);
|
|
5085
|
+
const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
|
|
5086
|
+
text.setLoadedFont(loadedFont);
|
|
5087
|
+
// Initial creation
|
|
5088
|
+
const { font, maxCacheSizeMB, ...geometryOptions } = options;
|
|
5089
|
+
const result = await text.createGeometry(geometryOptions);
|
|
5090
|
+
// Recursive update function
|
|
5091
|
+
const update = async (newOptions) => {
|
|
5092
|
+
// Merge options - preserve font from original options if not provided
|
|
5093
|
+
const mergedOptions = { ...options };
|
|
5094
|
+
for (const key in newOptions) {
|
|
5095
|
+
const value = newOptions[key];
|
|
5096
|
+
if (value !== undefined) {
|
|
5097
|
+
mergedOptions[key] = value;
|
|
5098
|
+
}
|
|
5099
|
+
}
|
|
5100
|
+
// If font definition or configuration changed, reload font and reset helpers
|
|
5101
|
+
if (newOptions.font !== undefined ||
|
|
5102
|
+
newOptions.fontVariations !== undefined ||
|
|
5103
|
+
newOptions.fontFeatures !== undefined) {
|
|
5104
|
+
const newLoadedFont = await Text.resolveFont(mergedOptions);
|
|
5105
|
+
text.setLoadedFont(newLoadedFont);
|
|
5106
|
+
// Reset geometry builder and shaper to use new font
|
|
5107
|
+
text.resetHelpers();
|
|
5108
|
+
}
|
|
5109
|
+
// Update closure options for next time
|
|
5110
|
+
options = mergedOptions;
|
|
5111
|
+
const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
|
|
5112
|
+
const newResult = await text.createGeometry(currentGeometryOptions);
|
|
5113
|
+
return {
|
|
5114
|
+
...newResult,
|
|
5115
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
5116
|
+
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5117
|
+
clearCache: () => text.clearCache(),
|
|
5118
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
5119
|
+
update
|
|
5120
|
+
};
|
|
5121
|
+
};
|
|
5122
|
+
return {
|
|
5123
|
+
...result,
|
|
5124
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
5125
|
+
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5126
|
+
clearCache: () => text.clearCache(),
|
|
5127
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
5128
|
+
update
|
|
5129
|
+
};
|
|
5130
|
+
}
|
|
5131
|
+
static async resolveFont(options) {
|
|
4844
5132
|
const baseFontKey = typeof options.font === 'string'
|
|
4845
5133
|
? options.font
|
|
4846
5134
|
: `buffer-${Text.generateFontContentHash(options.font)}`;
|
|
@@ -4855,17 +5143,7 @@ class Text {
|
|
|
4855
5143
|
if (!loadedFont) {
|
|
4856
5144
|
loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
|
|
4857
5145
|
}
|
|
4858
|
-
|
|
4859
|
-
text.setLoadedFont(loadedFont);
|
|
4860
|
-
const { font, maxCacheSizeMB, ...geometryOptions } = options;
|
|
4861
|
-
const result = await text.createGeometry(geometryOptions);
|
|
4862
|
-
return {
|
|
4863
|
-
...result,
|
|
4864
|
-
getLoadedFont: () => text.getLoadedFont(),
|
|
4865
|
-
getCacheStatistics: () => text.getCacheStatistics(),
|
|
4866
|
-
clearCache: () => text.clearCache(),
|
|
4867
|
-
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
|
|
4868
|
-
};
|
|
5146
|
+
return loadedFont;
|
|
4869
5147
|
}
|
|
4870
5148
|
static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
|
|
4871
5149
|
const tempText = new Text();
|
|
@@ -5041,7 +5319,7 @@ class Text {
|
|
|
5041
5319
|
throw new Error('Font not loaded. Use Text.create() with a font option');
|
|
5042
5320
|
}
|
|
5043
5321
|
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,
|
|
5322
|
+
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
5323
|
let widthInFontUnits;
|
|
5046
5324
|
if (width !== undefined) {
|
|
5047
5325
|
widthInFontUnits = width * (this.loadedFont.upem / size);
|
|
@@ -5071,7 +5349,8 @@ class Text {
|
|
|
5071
5349
|
exhyphenpenalty,
|
|
5072
5350
|
doublehyphendemerits,
|
|
5073
5351
|
looseness,
|
|
5074
|
-
|
|
5352
|
+
disableShortLineDetection,
|
|
5353
|
+
shortLineThreshold,
|
|
5075
5354
|
letterSpacing
|
|
5076
5355
|
});
|
|
5077
5356
|
const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
|
|
@@ -5322,6 +5601,11 @@ class Text {
|
|
|
5322
5601
|
glyphLineIndex: glyphLineIndices
|
|
5323
5602
|
};
|
|
5324
5603
|
}
|
|
5604
|
+
resetHelpers() {
|
|
5605
|
+
this.geometryBuilder = undefined;
|
|
5606
|
+
this.textShaper = undefined;
|
|
5607
|
+
this.textLayout = undefined;
|
|
5608
|
+
}
|
|
5325
5609
|
destroy() {
|
|
5326
5610
|
if (!this.loadedFont) {
|
|
5327
5611
|
return;
|