three-text 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -13
- package/dist/index.cjs +533 -83
- package/dist/index.d.ts +2 -1
- package/dist/index.js +533 -83
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +533 -83
- package/dist/index.umd.min.js +2 -2
- package/dist/three/react.d.ts +2 -1
- package/dist/types/core/cache/GlyphGeometryBuilder.d.ts +1 -0
- package/dist/types/core/layout/LineBreak.d.ts +9 -2
- package/dist/types/core/shaping/TextShaper.d.ts +1 -0
- package/dist/types/core/types.d.ts +2 -1
- package/dist/types/utils/LRUCache.d.ts +38 -0
- package/package.json +2 -1
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* three-text v0.2.
|
|
2
|
+
* three-text v0.2.8
|
|
3
3
|
* Copyright (C) 2025 Countertype LLC
|
|
4
4
|
*
|
|
5
5
|
* This program is free software: you can redistribute it and/or modify
|
|
@@ -257,15 +257,15 @@ const DEFAULT_RIGHT_HYPHEN_MIN = 4;
|
|
|
257
257
|
const INF_BAD = 10000;
|
|
258
258
|
// Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
|
|
259
259
|
const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
|
|
260
|
-
// Another non TeX default:
|
|
261
|
-
const
|
|
262
|
-
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
|
|
263
263
|
class LineBreak {
|
|
264
264
|
// Calculate badness according to TeX's formula (tex.web §108, line 2337)
|
|
265
265
|
// Given t (desired adjustment) and s (available stretch/shrink)
|
|
266
266
|
// Returns approximation to 100(t/s)³, representing how "bad" a line is
|
|
267
267
|
// Constants are derived from TeX's fixed-point arithmetic:
|
|
268
|
-
//
|
|
268
|
+
// 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
|
|
269
269
|
static badness(t, s) {
|
|
270
270
|
if (t === 0)
|
|
271
271
|
return 0;
|
|
@@ -324,6 +324,8 @@ class LineBreak {
|
|
|
324
324
|
const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
|
|
325
325
|
return filteredPoints;
|
|
326
326
|
}
|
|
327
|
+
// Converts text into items (boxes, glues, penalties) for line breaking
|
|
328
|
+
// The measureText function should return widths that include any letter spacing
|
|
327
329
|
static itemizeText(text, measureText, // function to measure text width
|
|
328
330
|
hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
|
|
329
331
|
const items = [];
|
|
@@ -347,16 +349,214 @@ class LineBreak {
|
|
|
347
349
|
});
|
|
348
350
|
return items;
|
|
349
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
|
+
}
|
|
350
505
|
static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
|
|
351
506
|
const items = [];
|
|
352
|
-
|
|
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 = [];
|
|
353
554
|
const tokens = text.match(/\S+|\s+/g) || [];
|
|
354
555
|
let currentIndex = 0;
|
|
355
556
|
for (let i = 0; i < tokens.length; i++) {
|
|
356
557
|
const token = tokens[i];
|
|
357
|
-
const tokenStartIndex = currentIndex;
|
|
558
|
+
const tokenStartIndex = startOffset + currentIndex;
|
|
358
559
|
if (/\s+/.test(token)) {
|
|
359
|
-
// Handle spaces
|
|
360
560
|
const width = measureText(token);
|
|
361
561
|
items.push({
|
|
362
562
|
type: ItemType.GLUE,
|
|
@@ -369,22 +569,19 @@ class LineBreak {
|
|
|
369
569
|
currentIndex += token.length;
|
|
370
570
|
}
|
|
371
571
|
else {
|
|
372
|
-
// Process word, splitting on explicit hyphens
|
|
373
|
-
// Split on hyphens while keeping them in the result
|
|
374
572
|
const segments = token.split(/(-)/);
|
|
375
573
|
let segmentIndex = tokenStartIndex;
|
|
376
574
|
for (let j = 0; j < segments.length; j++) {
|
|
377
575
|
const segment = segments[j];
|
|
378
576
|
if (!segment)
|
|
379
|
-
continue;
|
|
577
|
+
continue;
|
|
380
578
|
if (segment === '-') {
|
|
381
|
-
// Handle explicit hyphen as discretionary break
|
|
382
579
|
items.push({
|
|
383
580
|
type: ItemType.DISCRETIONARY,
|
|
384
|
-
width: measureText('-'),
|
|
385
|
-
preBreak: '-',
|
|
386
|
-
postBreak: '',
|
|
387
|
-
noBreak: '-',
|
|
581
|
+
width: measureText('-'),
|
|
582
|
+
preBreak: '-',
|
|
583
|
+
postBreak: '',
|
|
584
|
+
noBreak: '-',
|
|
388
585
|
preBreakWidth: measureText('-'),
|
|
389
586
|
penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
|
|
390
587
|
flagged: true,
|
|
@@ -394,8 +591,6 @@ class LineBreak {
|
|
|
394
591
|
segmentIndex += 1;
|
|
395
592
|
}
|
|
396
593
|
else {
|
|
397
|
-
// Process non-hyphen segment
|
|
398
|
-
// First handle soft hyphens (U+00AD)
|
|
399
594
|
if (segment.includes('\u00AD')) {
|
|
400
595
|
const partsWithMarkers = segment.split('\u00AD');
|
|
401
596
|
let runningIndex = 0;
|
|
@@ -413,23 +608,23 @@ class LineBreak {
|
|
|
413
608
|
if (k < partsWithMarkers.length - 1) {
|
|
414
609
|
items.push({
|
|
415
610
|
type: ItemType.DISCRETIONARY,
|
|
416
|
-
width: 0,
|
|
417
|
-
preBreak: '-',
|
|
418
|
-
postBreak: '',
|
|
419
|
-
noBreak: '',
|
|
611
|
+
width: 0,
|
|
612
|
+
preBreak: '-',
|
|
613
|
+
postBreak: '',
|
|
614
|
+
noBreak: '',
|
|
420
615
|
preBreakWidth: measureText('-'),
|
|
421
616
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
422
617
|
flagged: true,
|
|
423
618
|
text: '',
|
|
424
619
|
originIndex: segmentIndex + runningIndex
|
|
425
620
|
});
|
|
426
|
-
runningIndex += 1;
|
|
621
|
+
runningIndex += 1;
|
|
427
622
|
}
|
|
428
623
|
}
|
|
429
624
|
}
|
|
430
625
|
else if (hyphenate &&
|
|
431
|
-
segment.length >= lefthyphenmin + righthyphenmin
|
|
432
|
-
|
|
626
|
+
segment.length >= lefthyphenmin + righthyphenmin &&
|
|
627
|
+
/^\p{L}+$/u.test(segment)) {
|
|
433
628
|
const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
|
|
434
629
|
if (hyphenPoints.length > 0) {
|
|
435
630
|
let lastPoint = 0;
|
|
@@ -443,10 +638,10 @@ class LineBreak {
|
|
|
443
638
|
});
|
|
444
639
|
items.push({
|
|
445
640
|
type: ItemType.DISCRETIONARY,
|
|
446
|
-
width: 0,
|
|
447
|
-
preBreak: '-',
|
|
448
|
-
postBreak: '',
|
|
449
|
-
noBreak: '',
|
|
641
|
+
width: 0,
|
|
642
|
+
preBreak: '-',
|
|
643
|
+
postBreak: '',
|
|
644
|
+
noBreak: '',
|
|
450
645
|
preBreakWidth: measureText('-'),
|
|
451
646
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
452
647
|
flagged: true,
|
|
@@ -464,7 +659,6 @@ class LineBreak {
|
|
|
464
659
|
});
|
|
465
660
|
}
|
|
466
661
|
else {
|
|
467
|
-
// No hyphenation points, add as single box
|
|
468
662
|
items.push({
|
|
469
663
|
type: ItemType.BOX,
|
|
470
664
|
width: measureText(segment),
|
|
@@ -474,7 +668,6 @@ class LineBreak {
|
|
|
474
668
|
}
|
|
475
669
|
}
|
|
476
670
|
else {
|
|
477
|
-
// No hyphenation, add as single box
|
|
478
671
|
items.push({
|
|
479
672
|
type: ItemType.BOX,
|
|
480
673
|
width: measureText(segment),
|
|
@@ -490,27 +683,22 @@ class LineBreak {
|
|
|
490
683
|
}
|
|
491
684
|
return items;
|
|
492
685
|
}
|
|
493
|
-
// Detect if breakpoints create problematic
|
|
494
|
-
static
|
|
686
|
+
// Detect if breakpoints create problematic short lines
|
|
687
|
+
static hasShortLines(items, breakpoints, lineWidth, threshold) {
|
|
495
688
|
// Check each line segment (except the last, which can naturally be short)
|
|
496
689
|
let lineStart = 0;
|
|
497
690
|
for (let i = 0; i < breakpoints.length - 1; i++) {
|
|
498
691
|
const breakpoint = breakpoints[i];
|
|
499
|
-
// Count glue items (spaces) between line start and breakpoint
|
|
500
|
-
let glueCount = 0;
|
|
501
692
|
let totalWidth = 0;
|
|
502
693
|
for (let j = lineStart; j < breakpoint; j++) {
|
|
503
|
-
if (items[j].type === ItemType.GLUE) {
|
|
504
|
-
glueCount++;
|
|
505
|
-
}
|
|
506
694
|
if (items[j].type !== ItemType.PENALTY) {
|
|
507
695
|
totalWidth += items[j].width;
|
|
508
696
|
}
|
|
509
697
|
}
|
|
510
|
-
//
|
|
511
|
-
if (
|
|
698
|
+
// Check if line is narrow relative to target width
|
|
699
|
+
if (totalWidth > 0) {
|
|
512
700
|
const widthRatio = totalWidth / lineWidth;
|
|
513
|
-
if (widthRatio <
|
|
701
|
+
if (widthRatio < threshold) {
|
|
514
702
|
return true;
|
|
515
703
|
}
|
|
516
704
|
}
|
|
@@ -525,7 +713,7 @@ class LineBreak {
|
|
|
525
713
|
align: options.align || 'left',
|
|
526
714
|
hyphenate: options.hyphenate || false
|
|
527
715
|
});
|
|
528
|
-
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;
|
|
529
717
|
// Handle multiple paragraphs by processing each independently
|
|
530
718
|
if (respectExistingBreaks && text.includes('\n')) {
|
|
531
719
|
const paragraphs = text.split('\n');
|
|
@@ -604,39 +792,32 @@ class LineBreak {
|
|
|
604
792
|
}
|
|
605
793
|
];
|
|
606
794
|
}
|
|
607
|
-
// Itemize
|
|
608
|
-
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);
|
|
609
797
|
if (allItems.length === 0) {
|
|
610
798
|
return [];
|
|
611
799
|
}
|
|
612
|
-
// Iteratively increase emergency stretch to eliminate short single-word lines.
|
|
613
|
-
// Post-processing approach preserves TeX algorithm integrity while itemization
|
|
614
|
-
// (the expensive part) happens once
|
|
615
800
|
const MAX_ITERATIONS = 5;
|
|
616
801
|
let iteration = 0;
|
|
617
802
|
let currentEmergencyStretch = initialEmergencyStretch;
|
|
618
803
|
let resultLines = null;
|
|
619
|
-
const
|
|
804
|
+
const shortLineDetectionEnabled = !disableShortLineDetection;
|
|
620
805
|
while (iteration < MAX_ITERATIONS) {
|
|
621
|
-
// Three-pass approach
|
|
622
|
-
// First pass:
|
|
623
|
-
// Second pass:
|
|
624
|
-
//
|
|
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
|
|
625
810
|
// First pass: no hyphenation
|
|
626
|
-
let currentItems =
|
|
627
|
-
? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
|
|
628
|
-
item.penalty !==
|
|
629
|
-
(context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
|
|
630
|
-
: allItems;
|
|
811
|
+
let currentItems = allItems;
|
|
631
812
|
let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
|
|
632
|
-
// Second pass:
|
|
813
|
+
// Second pass: compute hyphenation only if needed
|
|
633
814
|
if (breaks.length === 0 && useHyphenation) {
|
|
634
|
-
|
|
815
|
+
const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
816
|
+
currentItems = itemsWithHyphenation;
|
|
635
817
|
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
|
|
636
818
|
}
|
|
637
|
-
//
|
|
819
|
+
// Emergency pass: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
|
|
638
820
|
if (breaks.length === 0) {
|
|
639
|
-
currentItems = allItems;
|
|
640
821
|
breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
|
|
641
822
|
}
|
|
642
823
|
// Force with infinite tolerance if still no breaks found
|
|
@@ -647,13 +828,13 @@ class LineBreak {
|
|
|
647
828
|
if (breaks.length > 0) {
|
|
648
829
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
|
|
649
830
|
resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
|
|
650
|
-
// Check for
|
|
651
|
-
if (
|
|
831
|
+
// Check for short lines if detection is enabled
|
|
832
|
+
if (shortLineDetectionEnabled &&
|
|
652
833
|
breaks.length > 1 &&
|
|
653
|
-
LineBreak.
|
|
834
|
+
LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
|
|
654
835
|
// Increase emergency stretch and try again
|
|
655
836
|
currentEmergencyStretch +=
|
|
656
|
-
width *
|
|
837
|
+
width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
|
|
657
838
|
iteration++;
|
|
658
839
|
continue;
|
|
659
840
|
}
|
|
@@ -987,6 +1168,8 @@ class LineBreak {
|
|
|
987
1168
|
const item = items[i];
|
|
988
1169
|
widths[i + 1] = widths[i] + item.width;
|
|
989
1170
|
if (item.type === ItemType.PENALTY) {
|
|
1171
|
+
stretches[i + 1] = stretches[i];
|
|
1172
|
+
shrinks[i + 1] = shrinks[i];
|
|
990
1173
|
minWidths[i + 1] = minWidths[i];
|
|
991
1174
|
}
|
|
992
1175
|
else if (item.type === ItemType.GLUE) {
|
|
@@ -1208,6 +1391,9 @@ function convertFontFeaturesToString(features) {
|
|
|
1208
1391
|
}
|
|
1209
1392
|
|
|
1210
1393
|
class TextMeasurer {
|
|
1394
|
+
// Measures text width including letter spacing
|
|
1395
|
+
// Letter spacing is added uniformly after each glyph during measurement,
|
|
1396
|
+
// so the widths given to the line-breaking algorithm already account for tracking
|
|
1211
1397
|
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1212
1398
|
const buffer = loadedFont.hb.createBuffer();
|
|
1213
1399
|
buffer.addText(text);
|
|
@@ -1234,9 +1420,11 @@ class TextLayout {
|
|
|
1234
1420
|
this.loadedFont = loadedFont;
|
|
1235
1421
|
}
|
|
1236
1422
|
computeLines(options) {
|
|
1237
|
-
const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness,
|
|
1423
|
+
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;
|
|
1238
1424
|
let lines;
|
|
1239
1425
|
if (width) {
|
|
1426
|
+
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
1427
|
+
// so widths passed into LineBreak.breakText account for tracking
|
|
1240
1428
|
lines = LineBreak.breakText({
|
|
1241
1429
|
text,
|
|
1242
1430
|
width,
|
|
@@ -1258,9 +1446,11 @@ class TextLayout {
|
|
|
1258
1446
|
exhyphenpenalty,
|
|
1259
1447
|
doublehyphendemerits,
|
|
1260
1448
|
looseness,
|
|
1261
|
-
|
|
1449
|
+
disableShortLineDetection,
|
|
1450
|
+
shortLineThreshold,
|
|
1262
1451
|
unitsPerEm: this.loadedFont.upem,
|
|
1263
|
-
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing
|
|
1452
|
+
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1453
|
+
)
|
|
1264
1454
|
});
|
|
1265
1455
|
}
|
|
1266
1456
|
else {
|
|
@@ -2225,7 +2415,11 @@ class Tessellator {
|
|
|
2225
2415
|
if (removeOverlaps) {
|
|
2226
2416
|
logger.log('Two-pass: boundary extraction then triangulation');
|
|
2227
2417
|
// Extract boundaries to remove overlaps
|
|
2418
|
+
perfLogger.start('Tessellator.boundaryPass', {
|
|
2419
|
+
contourCount: contours.length
|
|
2420
|
+
});
|
|
2228
2421
|
const boundaryResult = this.performTessellation(contours, 'boundary');
|
|
2422
|
+
perfLogger.end('Tessellator.boundaryPass');
|
|
2229
2423
|
if (!boundaryResult) {
|
|
2230
2424
|
logger.warn('libtess returned empty result from boundary pass');
|
|
2231
2425
|
return { triangles: { vertices: [], indices: [] }, contours: [] };
|
|
@@ -2238,7 +2432,11 @@ class Tessellator {
|
|
|
2238
2432
|
logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
|
|
2239
2433
|
}
|
|
2240
2434
|
// Triangulate the contours
|
|
2435
|
+
perfLogger.start('Tessellator.triangulationPass', {
|
|
2436
|
+
contourCount: contours.length
|
|
2437
|
+
});
|
|
2241
2438
|
const triangleResult = this.performTessellation(contours, 'triangles');
|
|
2439
|
+
perfLogger.end('Tessellator.triangulationPass');
|
|
2242
2440
|
if (!triangleResult) {
|
|
2243
2441
|
const warning = removeOverlaps
|
|
2244
2442
|
? 'libtess returned empty result from triangulation pass'
|
|
@@ -2425,10 +2623,16 @@ const OVERLAP_EPSILON = 1e-3;
|
|
|
2425
2623
|
class BoundaryClusterer {
|
|
2426
2624
|
constructor() { }
|
|
2427
2625
|
cluster(glyphContoursList, positions) {
|
|
2626
|
+
perfLogger.start('BoundaryClusterer.cluster', {
|
|
2627
|
+
glyphCount: glyphContoursList.length
|
|
2628
|
+
});
|
|
2428
2629
|
if (glyphContoursList.length === 0) {
|
|
2630
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2429
2631
|
return [];
|
|
2430
2632
|
}
|
|
2431
|
-
|
|
2633
|
+
const result = this.clusterSweepLine(glyphContoursList, positions);
|
|
2634
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2635
|
+
return result;
|
|
2432
2636
|
}
|
|
2433
2637
|
clusterSweepLine(glyphContoursList, positions) {
|
|
2434
2638
|
const n = glyphContoursList.length;
|
|
@@ -3069,6 +3273,11 @@ class GlyphContourCollector {
|
|
|
3069
3273
|
this.currentGlyphBounds.max.set(-Infinity, -Infinity);
|
|
3070
3274
|
// Record position for this glyph
|
|
3071
3275
|
this.glyphPositions.push(this.currentPosition.clone());
|
|
3276
|
+
// Time polygonization + path optimization per glyph
|
|
3277
|
+
perfLogger.start('Glyph.polygonizeAndOptimize', {
|
|
3278
|
+
glyphId,
|
|
3279
|
+
textIndex
|
|
3280
|
+
});
|
|
3072
3281
|
}
|
|
3073
3282
|
finishGlyph() {
|
|
3074
3283
|
if (this.currentPath) {
|
|
@@ -3092,6 +3301,8 @@ class GlyphContourCollector {
|
|
|
3092
3301
|
// Track textIndex separately
|
|
3093
3302
|
this.glyphTextIndices.push(this.currentTextIndex);
|
|
3094
3303
|
}
|
|
3304
|
+
// Stop timing for this glyph (even if it ended up empty)
|
|
3305
|
+
perfLogger.end('Glyph.polygonizeAndOptimize');
|
|
3095
3306
|
this.currentGlyphPaths = [];
|
|
3096
3307
|
}
|
|
3097
3308
|
onMoveTo(x, y) {
|
|
@@ -3326,6 +3537,184 @@ class DrawCallbackHandler {
|
|
|
3326
3537
|
}
|
|
3327
3538
|
}
|
|
3328
3539
|
|
|
3540
|
+
// Generic LRU (Least Recently Used) cache with optional memory-based eviction
|
|
3541
|
+
class LRUCache {
|
|
3542
|
+
constructor(options = {}) {
|
|
3543
|
+
this.cache = new Map();
|
|
3544
|
+
this.head = null;
|
|
3545
|
+
this.tail = null;
|
|
3546
|
+
this.stats = {
|
|
3547
|
+
hits: 0,
|
|
3548
|
+
misses: 0,
|
|
3549
|
+
evictions: 0,
|
|
3550
|
+
size: 0,
|
|
3551
|
+
memoryUsage: 0
|
|
3552
|
+
};
|
|
3553
|
+
this.options = {
|
|
3554
|
+
maxEntries: options.maxEntries ?? Infinity,
|
|
3555
|
+
maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
|
|
3556
|
+
calculateSize: options.calculateSize ?? (() => 0),
|
|
3557
|
+
onEvict: options.onEvict
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
get(key) {
|
|
3561
|
+
const node = this.cache.get(key);
|
|
3562
|
+
if (node) {
|
|
3563
|
+
this.stats.hits++;
|
|
3564
|
+
this.moveToHead(node);
|
|
3565
|
+
return node.value;
|
|
3566
|
+
}
|
|
3567
|
+
else {
|
|
3568
|
+
this.stats.misses++;
|
|
3569
|
+
return undefined;
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
has(key) {
|
|
3573
|
+
return this.cache.has(key);
|
|
3574
|
+
}
|
|
3575
|
+
set(key, value) {
|
|
3576
|
+
// If key already exists, update it
|
|
3577
|
+
const existingNode = this.cache.get(key);
|
|
3578
|
+
if (existingNode) {
|
|
3579
|
+
const oldSize = this.options.calculateSize(existingNode.value);
|
|
3580
|
+
const newSize = this.options.calculateSize(value);
|
|
3581
|
+
this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
|
|
3582
|
+
existingNode.value = value;
|
|
3583
|
+
this.moveToHead(existingNode);
|
|
3584
|
+
return;
|
|
3585
|
+
}
|
|
3586
|
+
const size = this.options.calculateSize(value);
|
|
3587
|
+
// Evict entries if we exceed limits
|
|
3588
|
+
this.evictIfNeeded(size);
|
|
3589
|
+
// Create new node
|
|
3590
|
+
const node = {
|
|
3591
|
+
key,
|
|
3592
|
+
value,
|
|
3593
|
+
prev: null,
|
|
3594
|
+
next: null
|
|
3595
|
+
};
|
|
3596
|
+
this.cache.set(key, node);
|
|
3597
|
+
this.addToHead(node);
|
|
3598
|
+
this.stats.size = this.cache.size;
|
|
3599
|
+
this.stats.memoryUsage += size;
|
|
3600
|
+
}
|
|
3601
|
+
delete(key) {
|
|
3602
|
+
const node = this.cache.get(key);
|
|
3603
|
+
if (!node)
|
|
3604
|
+
return false;
|
|
3605
|
+
const size = this.options.calculateSize(node.value);
|
|
3606
|
+
this.removeNode(node);
|
|
3607
|
+
this.cache.delete(key);
|
|
3608
|
+
this.stats.size = this.cache.size;
|
|
3609
|
+
this.stats.memoryUsage -= size;
|
|
3610
|
+
if (this.options.onEvict) {
|
|
3611
|
+
this.options.onEvict(key, node.value);
|
|
3612
|
+
}
|
|
3613
|
+
return true;
|
|
3614
|
+
}
|
|
3615
|
+
clear() {
|
|
3616
|
+
if (this.options.onEvict) {
|
|
3617
|
+
for (const [key, node] of this.cache) {
|
|
3618
|
+
this.options.onEvict(key, node.value);
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
this.cache.clear();
|
|
3622
|
+
this.head = null;
|
|
3623
|
+
this.tail = null;
|
|
3624
|
+
this.stats = {
|
|
3625
|
+
hits: 0,
|
|
3626
|
+
misses: 0,
|
|
3627
|
+
evictions: 0,
|
|
3628
|
+
size: 0,
|
|
3629
|
+
memoryUsage: 0
|
|
3630
|
+
};
|
|
3631
|
+
}
|
|
3632
|
+
getStats() {
|
|
3633
|
+
const total = this.stats.hits + this.stats.misses;
|
|
3634
|
+
const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
|
|
3635
|
+
const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
|
|
3636
|
+
return {
|
|
3637
|
+
...this.stats,
|
|
3638
|
+
hitRate,
|
|
3639
|
+
memoryUsageMB
|
|
3640
|
+
};
|
|
3641
|
+
}
|
|
3642
|
+
keys() {
|
|
3643
|
+
const keys = [];
|
|
3644
|
+
let current = this.head;
|
|
3645
|
+
while (current) {
|
|
3646
|
+
keys.push(current.key);
|
|
3647
|
+
current = current.next;
|
|
3648
|
+
}
|
|
3649
|
+
return keys;
|
|
3650
|
+
}
|
|
3651
|
+
get size() {
|
|
3652
|
+
return this.cache.size;
|
|
3653
|
+
}
|
|
3654
|
+
evictIfNeeded(requiredSize) {
|
|
3655
|
+
// Evict by entry count
|
|
3656
|
+
while (this.cache.size >= this.options.maxEntries && this.tail) {
|
|
3657
|
+
this.evictTail();
|
|
3658
|
+
}
|
|
3659
|
+
// Evict by memory usage
|
|
3660
|
+
if (this.options.maxMemoryBytes < Infinity) {
|
|
3661
|
+
while (this.tail &&
|
|
3662
|
+
this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
|
|
3663
|
+
this.evictTail();
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
evictTail() {
|
|
3668
|
+
if (!this.tail)
|
|
3669
|
+
return;
|
|
3670
|
+
const nodeToRemove = this.tail;
|
|
3671
|
+
const size = this.options.calculateSize(nodeToRemove.value);
|
|
3672
|
+
this.removeTail();
|
|
3673
|
+
this.cache.delete(nodeToRemove.key);
|
|
3674
|
+
this.stats.size = this.cache.size;
|
|
3675
|
+
this.stats.memoryUsage -= size;
|
|
3676
|
+
this.stats.evictions++;
|
|
3677
|
+
if (this.options.onEvict) {
|
|
3678
|
+
this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
addToHead(node) {
|
|
3682
|
+
if (!this.head) {
|
|
3683
|
+
this.head = this.tail = node;
|
|
3684
|
+
}
|
|
3685
|
+
else {
|
|
3686
|
+
node.next = this.head;
|
|
3687
|
+
this.head.prev = node;
|
|
3688
|
+
this.head = node;
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
removeNode(node) {
|
|
3692
|
+
if (node.prev) {
|
|
3693
|
+
node.prev.next = node.next;
|
|
3694
|
+
}
|
|
3695
|
+
else {
|
|
3696
|
+
this.head = node.next;
|
|
3697
|
+
}
|
|
3698
|
+
if (node.next) {
|
|
3699
|
+
node.next.prev = node.prev;
|
|
3700
|
+
}
|
|
3701
|
+
else {
|
|
3702
|
+
this.tail = node.prev;
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
removeTail() {
|
|
3706
|
+
if (this.tail) {
|
|
3707
|
+
this.removeNode(this.tail);
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
moveToHead(node) {
|
|
3711
|
+
if (node === this.head)
|
|
3712
|
+
return;
|
|
3713
|
+
this.removeNode(node);
|
|
3714
|
+
this.addToHead(node);
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3329
3718
|
class GlyphGeometryBuilder {
|
|
3330
3719
|
constructor(cache, loadedFont) {
|
|
3331
3720
|
this.fontId = 'default';
|
|
@@ -3338,6 +3727,16 @@ class GlyphGeometryBuilder {
|
|
|
3338
3727
|
this.collector = new GlyphContourCollector();
|
|
3339
3728
|
this.drawCallbacks = new DrawCallbackHandler();
|
|
3340
3729
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
3730
|
+
this.contourCache = new LRUCache({
|
|
3731
|
+
maxEntries: 1000,
|
|
3732
|
+
calculateSize: (contours) => {
|
|
3733
|
+
let size = 0;
|
|
3734
|
+
for (const path of contours.paths) {
|
|
3735
|
+
size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
|
|
3736
|
+
}
|
|
3737
|
+
return size + 64; // bounds overhead
|
|
3738
|
+
}
|
|
3739
|
+
});
|
|
3341
3740
|
}
|
|
3342
3741
|
getOptimizationStats() {
|
|
3343
3742
|
return this.collector.getOptimizationStats();
|
|
@@ -3482,30 +3881,37 @@ class GlyphGeometryBuilder {
|
|
|
3482
3881
|
};
|
|
3483
3882
|
}
|
|
3484
3883
|
getContoursForGlyph(glyphId) {
|
|
3884
|
+
const cached = this.contourCache.get(glyphId);
|
|
3885
|
+
if (cached) {
|
|
3886
|
+
return cached;
|
|
3887
|
+
}
|
|
3485
3888
|
this.collector.reset();
|
|
3486
3889
|
this.collector.beginGlyph(glyphId, 0);
|
|
3487
3890
|
this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
|
|
3488
3891
|
this.collector.finishGlyph();
|
|
3489
3892
|
const collected = this.collector.getCollectedGlyphs()[0];
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
}
|
|
3501
|
-
return collected;
|
|
3893
|
+
const contours = collected || {
|
|
3894
|
+
glyphId,
|
|
3895
|
+
paths: [],
|
|
3896
|
+
bounds: {
|
|
3897
|
+
min: { x: 0, y: 0 },
|
|
3898
|
+
max: { x: 0, y: 0 }
|
|
3899
|
+
}
|
|
3900
|
+
};
|
|
3901
|
+
this.contourCache.set(glyphId, contours);
|
|
3902
|
+
return contours;
|
|
3502
3903
|
}
|
|
3503
3904
|
tessellateGlyphCluster(paths, depth, isCFF) {
|
|
3504
3905
|
const processedGeometry = this.tessellator.process(paths, true, isCFF);
|
|
3505
3906
|
return this.extrudeAndPackage(processedGeometry, depth);
|
|
3506
3907
|
}
|
|
3507
3908
|
extrudeAndPackage(processedGeometry, depth) {
|
|
3909
|
+
perfLogger.start('Extruder.extrude', {
|
|
3910
|
+
depth,
|
|
3911
|
+
upem: this.loadedFont.upem
|
|
3912
|
+
});
|
|
3508
3913
|
const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
|
|
3914
|
+
perfLogger.end('Extruder.extrude');
|
|
3509
3915
|
// Compute bounding box from vertices
|
|
3510
3916
|
const vertices = extrudedResult.vertices;
|
|
3511
3917
|
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
@@ -3547,6 +3953,7 @@ class GlyphGeometryBuilder {
|
|
|
3547
3953
|
pathCount: glyphContours.paths.length
|
|
3548
3954
|
});
|
|
3549
3955
|
const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
|
|
3956
|
+
perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
|
|
3550
3957
|
return this.extrudeAndPackage(processedGeometry, depth);
|
|
3551
3958
|
}
|
|
3552
3959
|
updatePlaneBounds(glyphBounds, planeBounds) {
|
|
@@ -3619,8 +4026,10 @@ class TextShaper {
|
|
|
3619
4026
|
let currentClusterText = '';
|
|
3620
4027
|
let clusterStartPosition = new Vec3();
|
|
3621
4028
|
let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
|
|
4029
|
+
// Apply letter spacing between glyphs (must match what was used in width measurements)
|
|
3622
4030
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
3623
4031
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
4032
|
+
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
3624
4033
|
for (let i = 0; i < glyphInfos.length; i++) {
|
|
3625
4034
|
const glyph = glyphInfos[i];
|
|
3626
4035
|
const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
|
|
@@ -3666,6 +4075,29 @@ class TextShaper {
|
|
|
3666
4075
|
if (isWhitespace) {
|
|
3667
4076
|
cursor.x += spaceAdjustment;
|
|
3668
4077
|
}
|
|
4078
|
+
// CJK glue adjustment (must match exactly where LineBreak adds glue)
|
|
4079
|
+
if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
|
|
4080
|
+
const currentChar = lineInfo.text[glyph.cl];
|
|
4081
|
+
const nextGlyph = glyphInfos[i + 1];
|
|
4082
|
+
const nextChar = lineInfo.text[nextGlyph.cl];
|
|
4083
|
+
const isCJKChar = LineBreak.isCJK(currentChar);
|
|
4084
|
+
const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
|
|
4085
|
+
if (isCJKChar && nextIsCJKChar) {
|
|
4086
|
+
let shouldApply = true;
|
|
4087
|
+
if (LineBreak.isCJClosingPunctuation(nextChar)) {
|
|
4088
|
+
shouldApply = false;
|
|
4089
|
+
}
|
|
4090
|
+
if (LineBreak.isCJOpeningPunctuation(currentChar)) {
|
|
4091
|
+
shouldApply = false;
|
|
4092
|
+
}
|
|
4093
|
+
if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
|
|
4094
|
+
shouldApply = false;
|
|
4095
|
+
}
|
|
4096
|
+
if (shouldApply) {
|
|
4097
|
+
cursor.x += cjkAdjustment;
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
3669
4101
|
}
|
|
3670
4102
|
if (currentClusterGlyphs.length > 0) {
|
|
3671
4103
|
clusters.push({
|
|
@@ -3698,6 +4130,23 @@ class TextShaper {
|
|
|
3698
4130
|
}
|
|
3699
4131
|
return spaceAdjustment;
|
|
3700
4132
|
}
|
|
4133
|
+
calculateCJKAdjustment(lineInfo, align) {
|
|
4134
|
+
if (lineInfo.adjustmentRatio === undefined ||
|
|
4135
|
+
align !== 'justify' ||
|
|
4136
|
+
lineInfo.isLastLine) {
|
|
4137
|
+
return 0;
|
|
4138
|
+
}
|
|
4139
|
+
const baseCharWidth = this.loadedFont.upem;
|
|
4140
|
+
const glueStretch = baseCharWidth * 0.04;
|
|
4141
|
+
const glueShrink = baseCharWidth * 0.04;
|
|
4142
|
+
if (lineInfo.adjustmentRatio > 0) {
|
|
4143
|
+
return lineInfo.adjustmentRatio * glueStretch;
|
|
4144
|
+
}
|
|
4145
|
+
else if (lineInfo.adjustmentRatio < 0) {
|
|
4146
|
+
return lineInfo.adjustmentRatio * glueShrink;
|
|
4147
|
+
}
|
|
4148
|
+
return 0;
|
|
4149
|
+
}
|
|
3701
4150
|
clearCache() {
|
|
3702
4151
|
this.geometryBuilder.clearCache();
|
|
3703
4152
|
}
|
|
@@ -4815,7 +5264,7 @@ class Text {
|
|
|
4815
5264
|
throw new Error('Font not loaded. Use Text.create() with a font option');
|
|
4816
5265
|
}
|
|
4817
5266
|
const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
|
|
4818
|
-
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,
|
|
5267
|
+
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;
|
|
4819
5268
|
let widthInFontUnits;
|
|
4820
5269
|
if (width !== undefined) {
|
|
4821
5270
|
widthInFontUnits = width * (this.loadedFont.upem / size);
|
|
@@ -4845,7 +5294,8 @@ class Text {
|
|
|
4845
5294
|
exhyphenpenalty,
|
|
4846
5295
|
doublehyphendemerits,
|
|
4847
5296
|
looseness,
|
|
4848
|
-
|
|
5297
|
+
disableShortLineDetection,
|
|
5298
|
+
shortLineThreshold,
|
|
4849
5299
|
letterSpacing
|
|
4850
5300
|
});
|
|
4851
5301
|
const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
|