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.umd.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
|
|
@@ -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();
|
|
@@ -191,11 +188,11 @@
|
|
|
191
188
|
FitnessClass[FitnessClass["VERY_LOOSE"] = 3] = "VERY_LOOSE";
|
|
192
189
|
})(FitnessClass || (FitnessClass = {}));
|
|
193
190
|
// ActiveNodeList maintains all currently viable breakpoints as we scan through the text.
|
|
194
|
-
// Each node represents a potential break with accumulated demerits (total "cost" from start)
|
|
191
|
+
// Each node represents a potential break with accumulated demerits (total "cost" from start)
|
|
195
192
|
//
|
|
196
193
|
// Demerits = cumulative penalty score from text start to this break, calculated as:
|
|
197
|
-
// (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web
|
|
198
|
-
// Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
|
|
194
|
+
// (line_penalty + badness)² + penalty² + flagged/fitness adjustments (tex.web line 16634)
|
|
195
|
+
// Lower demerits = better line breaks. TeX minimizes total demerits across the paragraph
|
|
199
196
|
//
|
|
200
197
|
// Implementation differs from TeX:
|
|
201
198
|
// - Hash map for O(1) lookups by position+fitness
|
|
@@ -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
|
-
// Calculate badness according to TeX's formula (tex.web
|
|
266
|
+
// Calculate badness according to TeX's formula (tex.web 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,56 +794,50 @@
|
|
|
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: with hyphenation
|
|
815
|
+
// Second pass: with hyphenation if first pass failed
|
|
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: add emergency stretch to background stretchability
|
|
645
822
|
if (breaks.length === 0) {
|
|
646
|
-
|
|
647
|
-
|
|
823
|
+
// For first emergency attempt, use initialEmergencyStretch
|
|
824
|
+
// For subsequent iterations (short line detection), progressively increase
|
|
825
|
+
currentEmergencyStretch = initialEmergencyStretch + (iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT);
|
|
826
|
+
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, true, currentEmergencyStretch, context);
|
|
648
827
|
}
|
|
649
|
-
//
|
|
828
|
+
// Last resort: allow higher badness (but not infinite)
|
|
650
829
|
if (breaks.length === 0) {
|
|
651
|
-
breaks = LineBreak.findBreakpoints(currentItems, width,
|
|
830
|
+
breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD, looseness, true, currentEmergencyStretch, context);
|
|
652
831
|
}
|
|
653
832
|
// Create lines from breaks
|
|
654
833
|
if (breaks.length > 0) {
|
|
655
834
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
|
|
656
835
|
resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
|
|
657
|
-
// Check for
|
|
658
|
-
if (
|
|
836
|
+
// Check for short lines if detection is enabled
|
|
837
|
+
if (shortLineDetectionEnabled &&
|
|
659
838
|
breaks.length > 1 &&
|
|
660
|
-
LineBreak.
|
|
661
|
-
//
|
|
662
|
-
currentEmergencyStretch +=
|
|
663
|
-
width * SINGLE_WORD_EMERGENCY_STRETCH_INCREMENT;
|
|
839
|
+
LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
|
|
840
|
+
// Retry with more emergency stretch to push words to next line
|
|
664
841
|
iteration++;
|
|
665
842
|
continue;
|
|
666
843
|
}
|
|
@@ -692,11 +869,12 @@
|
|
|
692
869
|
threshold = Infinity, // maximum badness allowed for a break
|
|
693
870
|
looseness = 0, // desired line count adjustment
|
|
694
871
|
isFinalPass = false, // whether this is the final pass
|
|
695
|
-
|
|
872
|
+
backgroundStretch = 0, // additional stretchability for all glue (emergency stretch)
|
|
696
873
|
context) {
|
|
697
874
|
// Pre-compute cumulative widths for fast range queries
|
|
698
875
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(items);
|
|
699
876
|
const activeNodes = new ActiveNodeList();
|
|
877
|
+
const minimumDemerits = { value: Infinity };
|
|
700
878
|
activeNodes.insert({
|
|
701
879
|
position: 0,
|
|
702
880
|
line: 0,
|
|
@@ -710,16 +888,16 @@
|
|
|
710
888
|
const item = items[i];
|
|
711
889
|
if (item.type === ItemType.PENALTY &&
|
|
712
890
|
item.penalty < Infinity) {
|
|
713
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
891
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
714
892
|
}
|
|
715
893
|
if (item.type === ItemType.DISCRETIONARY &&
|
|
716
894
|
item.penalty < Infinity) {
|
|
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
|
if (item.type === ItemType.GLUE &&
|
|
720
898
|
i > 0 &&
|
|
721
899
|
items[i - 1].type === ItemType.BOX) {
|
|
722
|
-
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold,
|
|
900
|
+
LineBreak.considerBreak(items, activeNodes, i, lineWidth, threshold, backgroundStretch, cumulativeWidths, context, isFinalPass, minimumDemerits);
|
|
723
901
|
}
|
|
724
902
|
LineBreak.deactivateNodes(activeNodes, i, lineWidth, cumulativeWidths.minWidths);
|
|
725
903
|
}
|
|
@@ -786,7 +964,7 @@
|
|
|
786
964
|
}
|
|
787
965
|
return breakpoints;
|
|
788
966
|
}
|
|
789
|
-
static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity,
|
|
967
|
+
static considerBreak(items, activeNodes, breakpoint, lineWidth, threshold = Infinity, backgroundStretch = 0, cumulativeWidths, context, isFinalPass = false, minimumDemerits = { value: Infinity }) {
|
|
790
968
|
const penalty = items[breakpoint].type === ItemType.PENALTY
|
|
791
969
|
? items[breakpoint].penalty
|
|
792
970
|
: 0;
|
|
@@ -798,11 +976,11 @@
|
|
|
798
976
|
continue;
|
|
799
977
|
const adjustmentData = LineBreak.computeAdjustmentRatio(items, node.position, breakpoint, node.line, lineWidth, cumulativeWidths, context);
|
|
800
978
|
const { ratio: r, adjustment, stretch, shrink, totalWidth } = adjustmentData;
|
|
801
|
-
// Calculate badness
|
|
979
|
+
// Calculate badness
|
|
802
980
|
let badness;
|
|
803
981
|
if (adjustment > 0) {
|
|
804
|
-
//
|
|
805
|
-
const effectiveStretch = stretch +
|
|
982
|
+
// backgroundStretch includes emergency stretch if in emergency pass
|
|
983
|
+
const effectiveStretch = stretch + backgroundStretch;
|
|
806
984
|
if (effectiveStretch <= 0) {
|
|
807
985
|
// Overfull box - badness is infinite + 1
|
|
808
986
|
badness = INF_BAD + 1;
|
|
@@ -827,26 +1005,32 @@
|
|
|
827
1005
|
else {
|
|
828
1006
|
badness = 0;
|
|
829
1007
|
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1008
|
+
// Artificial demerits: in final pass with no feasible solution yet
|
|
1009
|
+
// and only one active node left, force this break as a last resort
|
|
1010
|
+
const isLastResort = isFinalPass &&
|
|
1011
|
+
minimumDemerits.value === Infinity &&
|
|
1012
|
+
allActiveNodes.length === 1 &&
|
|
1013
|
+
node.active;
|
|
1014
|
+
if (!isForcedBreak && !isLastResort && r < -1) {
|
|
1015
|
+
continue; // too tight
|
|
833
1016
|
}
|
|
834
1017
|
const fitnessClass = LineBreak.computeFitnessClass(badness, adjustment > 0);
|
|
835
|
-
if (!isForcedBreak && badness > threshold) {
|
|
1018
|
+
if (!isForcedBreak && !isLastResort && badness > threshold) {
|
|
836
1019
|
continue;
|
|
837
1020
|
}
|
|
838
|
-
// Initialize demerits
|
|
1021
|
+
// Initialize demerits with saturation check
|
|
839
1022
|
let flaggedDemerits = 0;
|
|
840
1023
|
let fitnessDemerits = 0;
|
|
841
1024
|
const configuredLinePenalty = context?.linePenalty ?? 0;
|
|
842
1025
|
let d = configuredLinePenalty + badness;
|
|
843
1026
|
let demerits = Math.abs(d) >= 10000 ? 100000000 : d * d;
|
|
1027
|
+
const artificialDemerits = isLastResort;
|
|
844
1028
|
const breakpointPenalty = items[breakpoint].type === ItemType.PENALTY
|
|
845
1029
|
? items[breakpoint].penalty
|
|
846
1030
|
: items[breakpoint].type === ItemType.DISCRETIONARY
|
|
847
1031
|
? items[breakpoint].penalty
|
|
848
1032
|
: 0;
|
|
849
|
-
//
|
|
1033
|
+
// Penalty contribution to demerits
|
|
850
1034
|
if (breakpointPenalty !== 0) {
|
|
851
1035
|
if (breakpointPenalty > 0) {
|
|
852
1036
|
demerits += breakpointPenalty * breakpointPenalty;
|
|
@@ -872,10 +1056,13 @@
|
|
|
872
1056
|
fitnessDemerits = context?.adjDemerits ?? 0;
|
|
873
1057
|
demerits += fitnessDemerits;
|
|
874
1058
|
}
|
|
875
|
-
if (isForcedBreak) {
|
|
1059
|
+
if (isForcedBreak || artificialDemerits) {
|
|
876
1060
|
demerits = 0;
|
|
877
1061
|
}
|
|
878
1062
|
const totalDemerits = node.totalDemerits + demerits;
|
|
1063
|
+
if (totalDemerits < minimumDemerits.value) {
|
|
1064
|
+
minimumDemerits.value = totalDemerits;
|
|
1065
|
+
}
|
|
879
1066
|
let existingNode = activeNodes.findExisting(breakpoint, fitnessClass);
|
|
880
1067
|
if (existingNode) {
|
|
881
1068
|
if (totalDemerits < existingNode.totalDemerits) {
|
|
@@ -994,6 +1181,8 @@
|
|
|
994
1181
|
const item = items[i];
|
|
995
1182
|
widths[i + 1] = widths[i] + item.width;
|
|
996
1183
|
if (item.type === ItemType.PENALTY) {
|
|
1184
|
+
stretches[i + 1] = stretches[i];
|
|
1185
|
+
shrinks[i + 1] = shrinks[i];
|
|
997
1186
|
minWidths[i + 1] = minWidths[i];
|
|
998
1187
|
}
|
|
999
1188
|
else if (item.type === ItemType.GLUE) {
|
|
@@ -1011,7 +1200,6 @@
|
|
|
1011
1200
|
return { widths, stretches, shrinks, minWidths };
|
|
1012
1201
|
}
|
|
1013
1202
|
// Deactivate nodes that can't lead to good line breaks
|
|
1014
|
-
// TeX recalculates minWidth each time, we use cumulative arrays for lookup
|
|
1015
1203
|
static deactivateNodes(activeNodeList, currentPosition, lineWidth, minWidths) {
|
|
1016
1204
|
const activeNodes = activeNodeList.getAllActive();
|
|
1017
1205
|
for (let i = activeNodes.length - 1; i >= 0; i--) {
|
|
@@ -1216,8 +1404,8 @@
|
|
|
1216
1404
|
|
|
1217
1405
|
class TextMeasurer {
|
|
1218
1406
|
// Measures text width including letter spacing
|
|
1219
|
-
//
|
|
1220
|
-
// so the widths given to the line-breaking algorithm already account for tracking
|
|
1407
|
+
// (letter spacing is added uniformly after each glyph during measurement,
|
|
1408
|
+
// so the widths given to the line-breaking algorithm already account for tracking)
|
|
1221
1409
|
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1222
1410
|
const buffer = loadedFont.hb.createBuffer();
|
|
1223
1411
|
buffer.addText(text);
|
|
@@ -1244,7 +1432,7 @@
|
|
|
1244
1432
|
this.loadedFont = loadedFont;
|
|
1245
1433
|
}
|
|
1246
1434
|
computeLines(options) {
|
|
1247
|
-
const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness,
|
|
1435
|
+
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
1436
|
let lines;
|
|
1249
1437
|
if (width) {
|
|
1250
1438
|
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
@@ -1270,7 +1458,8 @@
|
|
|
1270
1458
|
exhyphenpenalty,
|
|
1271
1459
|
doublehyphendemerits,
|
|
1272
1460
|
looseness,
|
|
1273
|
-
|
|
1461
|
+
disableShortLineDetection,
|
|
1462
|
+
shortLineThreshold,
|
|
1274
1463
|
unitsPerEm: this.loadedFont.upem,
|
|
1275
1464
|
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1276
1465
|
)
|
|
@@ -3540,10 +3729,11 @@
|
|
|
3540
3729
|
}
|
|
3541
3730
|
}
|
|
3542
3731
|
|
|
3732
|
+
const CONTOUR_CACHE_MAX_ENTRIES = 1000;
|
|
3733
|
+
const WORD_CACHE_MAX_ENTRIES = 1000;
|
|
3543
3734
|
class GlyphGeometryBuilder {
|
|
3544
3735
|
constructor(cache, loadedFont) {
|
|
3545
3736
|
this.fontId = 'default';
|
|
3546
|
-
this.wordCache = new Map();
|
|
3547
3737
|
this.cache = cache;
|
|
3548
3738
|
this.loadedFont = loadedFont;
|
|
3549
3739
|
this.tessellator = new Tessellator();
|
|
@@ -3553,7 +3743,7 @@
|
|
|
3553
3743
|
this.drawCallbacks = new DrawCallbackHandler();
|
|
3554
3744
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
3555
3745
|
this.contourCache = new LRUCache({
|
|
3556
|
-
maxEntries:
|
|
3746
|
+
maxEntries: CONTOUR_CACHE_MAX_ENTRIES,
|
|
3557
3747
|
calculateSize: (contours) => {
|
|
3558
3748
|
let size = 0;
|
|
3559
3749
|
for (const path of contours.paths) {
|
|
@@ -3562,6 +3752,15 @@
|
|
|
3562
3752
|
return size + 64; // bounds overhead
|
|
3563
3753
|
}
|
|
3564
3754
|
});
|
|
3755
|
+
this.wordCache = new LRUCache({
|
|
3756
|
+
maxEntries: WORD_CACHE_MAX_ENTRIES,
|
|
3757
|
+
calculateSize: (data) => {
|
|
3758
|
+
let size = data.vertices.length * 4;
|
|
3759
|
+
size += data.normals.length * 4;
|
|
3760
|
+
size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
|
|
3761
|
+
return size;
|
|
3762
|
+
}
|
|
3763
|
+
});
|
|
3565
3764
|
}
|
|
3566
3765
|
getOptimizationStats() {
|
|
3567
3766
|
return this.collector.getOptimizationStats();
|
|
@@ -3851,9 +4050,10 @@
|
|
|
3851
4050
|
let currentClusterText = '';
|
|
3852
4051
|
let clusterStartPosition = new Vec3();
|
|
3853
4052
|
let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
|
|
3854
|
-
// Apply letter spacing
|
|
4053
|
+
// Apply letter spacing after each glyph to match width measurements used during line breaking
|
|
3855
4054
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
3856
4055
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
4056
|
+
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
3857
4057
|
for (let i = 0; i < glyphInfos.length; i++) {
|
|
3858
4058
|
const glyph = glyphInfos[i];
|
|
3859
4059
|
const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
|
|
@@ -3899,6 +4099,29 @@
|
|
|
3899
4099
|
if (isWhitespace) {
|
|
3900
4100
|
cursor.x += spaceAdjustment;
|
|
3901
4101
|
}
|
|
4102
|
+
// CJK glue adjustment (must match exactly where LineBreak adds glue)
|
|
4103
|
+
if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
|
|
4104
|
+
const currentChar = lineInfo.text[glyph.cl];
|
|
4105
|
+
const nextGlyph = glyphInfos[i + 1];
|
|
4106
|
+
const nextChar = lineInfo.text[nextGlyph.cl];
|
|
4107
|
+
const isCJKChar = LineBreak.isCJK(currentChar);
|
|
4108
|
+
const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
|
|
4109
|
+
if (isCJKChar && nextIsCJKChar) {
|
|
4110
|
+
let shouldApply = true;
|
|
4111
|
+
if (LineBreak.isCJClosingPunctuation(nextChar)) {
|
|
4112
|
+
shouldApply = false;
|
|
4113
|
+
}
|
|
4114
|
+
if (LineBreak.isCJOpeningPunctuation(currentChar)) {
|
|
4115
|
+
shouldApply = false;
|
|
4116
|
+
}
|
|
4117
|
+
if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
|
|
4118
|
+
shouldApply = false;
|
|
4119
|
+
}
|
|
4120
|
+
if (shouldApply) {
|
|
4121
|
+
cursor.x += cjkAdjustment;
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
3902
4125
|
}
|
|
3903
4126
|
if (currentClusterGlyphs.length > 0) {
|
|
3904
4127
|
clusters.push({
|
|
@@ -3931,6 +4154,23 @@
|
|
|
3931
4154
|
}
|
|
3932
4155
|
return spaceAdjustment;
|
|
3933
4156
|
}
|
|
4157
|
+
calculateCJKAdjustment(lineInfo, align) {
|
|
4158
|
+
if (lineInfo.adjustmentRatio === undefined ||
|
|
4159
|
+
align !== 'justify' ||
|
|
4160
|
+
lineInfo.isLastLine) {
|
|
4161
|
+
return 0;
|
|
4162
|
+
}
|
|
4163
|
+
const baseCharWidth = this.loadedFont.upem;
|
|
4164
|
+
const glueStretch = baseCharWidth * 0.04;
|
|
4165
|
+
const glueShrink = baseCharWidth * 0.04;
|
|
4166
|
+
if (lineInfo.adjustmentRatio > 0) {
|
|
4167
|
+
return lineInfo.adjustmentRatio * glueStretch;
|
|
4168
|
+
}
|
|
4169
|
+
else if (lineInfo.adjustmentRatio < 0) {
|
|
4170
|
+
return lineInfo.adjustmentRatio * glueShrink;
|
|
4171
|
+
}
|
|
4172
|
+
return 0;
|
|
4173
|
+
}
|
|
3934
4174
|
clearCache() {
|
|
3935
4175
|
this.geometryBuilder.clearCache();
|
|
3936
4176
|
}
|
|
@@ -4848,6 +5088,54 @@
|
|
|
4848
5088
|
if (!Text.hbInitPromise) {
|
|
4849
5089
|
Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
|
|
4850
5090
|
}
|
|
5091
|
+
const loadedFont = await Text.resolveFont(options);
|
|
5092
|
+
const text = new Text({ maxCacheSizeMB: options.maxCacheSizeMB });
|
|
5093
|
+
text.setLoadedFont(loadedFont);
|
|
5094
|
+
// Initial creation
|
|
5095
|
+
const { font, maxCacheSizeMB, ...geometryOptions } = options;
|
|
5096
|
+
const result = await text.createGeometry(geometryOptions);
|
|
5097
|
+
// Recursive update function
|
|
5098
|
+
const update = async (newOptions) => {
|
|
5099
|
+
// Merge options - preserve font from original options if not provided
|
|
5100
|
+
const mergedOptions = { ...options };
|
|
5101
|
+
for (const key in newOptions) {
|
|
5102
|
+
const value = newOptions[key];
|
|
5103
|
+
if (value !== undefined) {
|
|
5104
|
+
mergedOptions[key] = value;
|
|
5105
|
+
}
|
|
5106
|
+
}
|
|
5107
|
+
// If font definition or configuration changed, reload font and reset helpers
|
|
5108
|
+
if (newOptions.font !== undefined ||
|
|
5109
|
+
newOptions.fontVariations !== undefined ||
|
|
5110
|
+
newOptions.fontFeatures !== undefined) {
|
|
5111
|
+
const newLoadedFont = await Text.resolveFont(mergedOptions);
|
|
5112
|
+
text.setLoadedFont(newLoadedFont);
|
|
5113
|
+
// Reset geometry builder and shaper to use new font
|
|
5114
|
+
text.resetHelpers();
|
|
5115
|
+
}
|
|
5116
|
+
// Update closure options for next time
|
|
5117
|
+
options = mergedOptions;
|
|
5118
|
+
const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
|
|
5119
|
+
const newResult = await text.createGeometry(currentGeometryOptions);
|
|
5120
|
+
return {
|
|
5121
|
+
...newResult,
|
|
5122
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
5123
|
+
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5124
|
+
clearCache: () => text.clearCache(),
|
|
5125
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
5126
|
+
update
|
|
5127
|
+
};
|
|
5128
|
+
};
|
|
5129
|
+
return {
|
|
5130
|
+
...result,
|
|
5131
|
+
getLoadedFont: () => text.getLoadedFont(),
|
|
5132
|
+
getCacheStatistics: () => text.getCacheStatistics(),
|
|
5133
|
+
clearCache: () => text.clearCache(),
|
|
5134
|
+
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
|
|
5135
|
+
update
|
|
5136
|
+
};
|
|
5137
|
+
}
|
|
5138
|
+
static async resolveFont(options) {
|
|
4851
5139
|
const baseFontKey = typeof options.font === 'string'
|
|
4852
5140
|
? options.font
|
|
4853
5141
|
: `buffer-${Text.generateFontContentHash(options.font)}`;
|
|
@@ -4862,17 +5150,7 @@
|
|
|
4862
5150
|
if (!loadedFont) {
|
|
4863
5151
|
loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
|
|
4864
5152
|
}
|
|
4865
|
-
|
|
4866
|
-
text.setLoadedFont(loadedFont);
|
|
4867
|
-
const { font, maxCacheSizeMB, ...geometryOptions } = options;
|
|
4868
|
-
const result = await text.createGeometry(geometryOptions);
|
|
4869
|
-
return {
|
|
4870
|
-
...result,
|
|
4871
|
-
getLoadedFont: () => text.getLoadedFont(),
|
|
4872
|
-
getCacheStatistics: () => text.getCacheStatistics(),
|
|
4873
|
-
clearCache: () => text.clearCache(),
|
|
4874
|
-
measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing)
|
|
4875
|
-
};
|
|
5153
|
+
return loadedFont;
|
|
4876
5154
|
}
|
|
4877
5155
|
static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
|
|
4878
5156
|
const tempText = new Text();
|
|
@@ -5048,7 +5326,7 @@
|
|
|
5048
5326
|
throw new Error('Font not loaded. Use Text.create() with a font option');
|
|
5049
5327
|
}
|
|
5050
5328
|
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,
|
|
5329
|
+
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
5330
|
let widthInFontUnits;
|
|
5053
5331
|
if (width !== undefined) {
|
|
5054
5332
|
widthInFontUnits = width * (this.loadedFont.upem / size);
|
|
@@ -5078,7 +5356,8 @@
|
|
|
5078
5356
|
exhyphenpenalty,
|
|
5079
5357
|
doublehyphendemerits,
|
|
5080
5358
|
looseness,
|
|
5081
|
-
|
|
5359
|
+
disableShortLineDetection,
|
|
5360
|
+
shortLineThreshold,
|
|
5082
5361
|
letterSpacing
|
|
5083
5362
|
});
|
|
5084
5363
|
const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
|
|
@@ -5329,6 +5608,11 @@
|
|
|
5329
5608
|
glyphLineIndex: glyphLineIndices
|
|
5330
5609
|
};
|
|
5331
5610
|
}
|
|
5611
|
+
resetHelpers() {
|
|
5612
|
+
this.geometryBuilder = undefined;
|
|
5613
|
+
this.textShaper = undefined;
|
|
5614
|
+
this.textLayout = undefined;
|
|
5615
|
+
}
|
|
5332
5616
|
destroy() {
|
|
5333
5617
|
if (!this.loadedFont) {
|
|
5334
5618
|
return;
|