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.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* three-text v0.2.
|
|
2
|
+
* three-text v0.2.8
|
|
3
3
|
* Copyright (C) 2025 Countertype LLC
|
|
4
4
|
*
|
|
5
5
|
* This program is free software: you can redistribute it and/or modify
|
|
@@ -254,15 +254,15 @@ const DEFAULT_RIGHT_HYPHEN_MIN = 4;
|
|
|
254
254
|
const INF_BAD = 10000;
|
|
255
255
|
// Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
|
|
256
256
|
const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
|
|
257
|
-
// Another non TeX default:
|
|
258
|
-
const
|
|
259
|
-
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
|
|
260
260
|
class LineBreak {
|
|
261
261
|
// Calculate badness according to TeX's formula (tex.web §108, line 2337)
|
|
262
262
|
// Given t (desired adjustment) and s (available stretch/shrink)
|
|
263
263
|
// Returns approximation to 100(t/s)³, representing how "bad" a line is
|
|
264
264
|
// Constants are derived from TeX's fixed-point arithmetic:
|
|
265
|
-
//
|
|
265
|
+
// 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
|
|
266
266
|
static badness(t, s) {
|
|
267
267
|
if (t === 0)
|
|
268
268
|
return 0;
|
|
@@ -321,6 +321,8 @@ class LineBreak {
|
|
|
321
321
|
const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
|
|
322
322
|
return filteredPoints;
|
|
323
323
|
}
|
|
324
|
+
// Converts text into items (boxes, glues, penalties) for line breaking
|
|
325
|
+
// The measureText function should return widths that include any letter spacing
|
|
324
326
|
static itemizeText(text, measureText, // function to measure text width
|
|
325
327
|
hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
|
|
326
328
|
const items = [];
|
|
@@ -344,16 +346,214 @@ class LineBreak {
|
|
|
344
346
|
});
|
|
345
347
|
return items;
|
|
346
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
|
+
}
|
|
347
502
|
static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
|
|
348
503
|
const items = [];
|
|
349
|
-
|
|
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 = [];
|
|
350
551
|
const tokens = text.match(/\S+|\s+/g) || [];
|
|
351
552
|
let currentIndex = 0;
|
|
352
553
|
for (let i = 0; i < tokens.length; i++) {
|
|
353
554
|
const token = tokens[i];
|
|
354
|
-
const tokenStartIndex = currentIndex;
|
|
555
|
+
const tokenStartIndex = startOffset + currentIndex;
|
|
355
556
|
if (/\s+/.test(token)) {
|
|
356
|
-
// Handle spaces
|
|
357
557
|
const width = measureText(token);
|
|
358
558
|
items.push({
|
|
359
559
|
type: ItemType.GLUE,
|
|
@@ -366,22 +566,19 @@ class LineBreak {
|
|
|
366
566
|
currentIndex += token.length;
|
|
367
567
|
}
|
|
368
568
|
else {
|
|
369
|
-
// Process word, splitting on explicit hyphens
|
|
370
|
-
// Split on hyphens while keeping them in the result
|
|
371
569
|
const segments = token.split(/(-)/);
|
|
372
570
|
let segmentIndex = tokenStartIndex;
|
|
373
571
|
for (let j = 0; j < segments.length; j++) {
|
|
374
572
|
const segment = segments[j];
|
|
375
573
|
if (!segment)
|
|
376
|
-
continue;
|
|
574
|
+
continue;
|
|
377
575
|
if (segment === '-') {
|
|
378
|
-
// Handle explicit hyphen as discretionary break
|
|
379
576
|
items.push({
|
|
380
577
|
type: ItemType.DISCRETIONARY,
|
|
381
|
-
width: measureText('-'),
|
|
382
|
-
preBreak: '-',
|
|
383
|
-
postBreak: '',
|
|
384
|
-
noBreak: '-',
|
|
578
|
+
width: measureText('-'),
|
|
579
|
+
preBreak: '-',
|
|
580
|
+
postBreak: '',
|
|
581
|
+
noBreak: '-',
|
|
385
582
|
preBreakWidth: measureText('-'),
|
|
386
583
|
penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
|
|
387
584
|
flagged: true,
|
|
@@ -391,8 +588,6 @@ class LineBreak {
|
|
|
391
588
|
segmentIndex += 1;
|
|
392
589
|
}
|
|
393
590
|
else {
|
|
394
|
-
// Process non-hyphen segment
|
|
395
|
-
// First handle soft hyphens (U+00AD)
|
|
396
591
|
if (segment.includes('\u00AD')) {
|
|
397
592
|
const partsWithMarkers = segment.split('\u00AD');
|
|
398
593
|
let runningIndex = 0;
|
|
@@ -410,23 +605,23 @@ class LineBreak {
|
|
|
410
605
|
if (k < partsWithMarkers.length - 1) {
|
|
411
606
|
items.push({
|
|
412
607
|
type: ItemType.DISCRETIONARY,
|
|
413
|
-
width: 0,
|
|
414
|
-
preBreak: '-',
|
|
415
|
-
postBreak: '',
|
|
416
|
-
noBreak: '',
|
|
608
|
+
width: 0,
|
|
609
|
+
preBreak: '-',
|
|
610
|
+
postBreak: '',
|
|
611
|
+
noBreak: '',
|
|
417
612
|
preBreakWidth: measureText('-'),
|
|
418
613
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
419
614
|
flagged: true,
|
|
420
615
|
text: '',
|
|
421
616
|
originIndex: segmentIndex + runningIndex
|
|
422
617
|
});
|
|
423
|
-
runningIndex += 1;
|
|
618
|
+
runningIndex += 1;
|
|
424
619
|
}
|
|
425
620
|
}
|
|
426
621
|
}
|
|
427
622
|
else if (hyphenate &&
|
|
428
|
-
segment.length >= lefthyphenmin + righthyphenmin
|
|
429
|
-
|
|
623
|
+
segment.length >= lefthyphenmin + righthyphenmin &&
|
|
624
|
+
/^\p{L}+$/u.test(segment)) {
|
|
430
625
|
const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
|
|
431
626
|
if (hyphenPoints.length > 0) {
|
|
432
627
|
let lastPoint = 0;
|
|
@@ -440,10 +635,10 @@ class LineBreak {
|
|
|
440
635
|
});
|
|
441
636
|
items.push({
|
|
442
637
|
type: ItemType.DISCRETIONARY,
|
|
443
|
-
width: 0,
|
|
444
|
-
preBreak: '-',
|
|
445
|
-
postBreak: '',
|
|
446
|
-
noBreak: '',
|
|
638
|
+
width: 0,
|
|
639
|
+
preBreak: '-',
|
|
640
|
+
postBreak: '',
|
|
641
|
+
noBreak: '',
|
|
447
642
|
preBreakWidth: measureText('-'),
|
|
448
643
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
449
644
|
flagged: true,
|
|
@@ -461,7 +656,6 @@ class LineBreak {
|
|
|
461
656
|
});
|
|
462
657
|
}
|
|
463
658
|
else {
|
|
464
|
-
// No hyphenation points, add as single box
|
|
465
659
|
items.push({
|
|
466
660
|
type: ItemType.BOX,
|
|
467
661
|
width: measureText(segment),
|
|
@@ -471,7 +665,6 @@ class LineBreak {
|
|
|
471
665
|
}
|
|
472
666
|
}
|
|
473
667
|
else {
|
|
474
|
-
// No hyphenation, add as single box
|
|
475
668
|
items.push({
|
|
476
669
|
type: ItemType.BOX,
|
|
477
670
|
width: measureText(segment),
|
|
@@ -487,27 +680,22 @@ class LineBreak {
|
|
|
487
680
|
}
|
|
488
681
|
return items;
|
|
489
682
|
}
|
|
490
|
-
// Detect if breakpoints create problematic
|
|
491
|
-
static
|
|
683
|
+
// Detect if breakpoints create problematic short lines
|
|
684
|
+
static hasShortLines(items, breakpoints, lineWidth, threshold) {
|
|
492
685
|
// Check each line segment (except the last, which can naturally be short)
|
|
493
686
|
let lineStart = 0;
|
|
494
687
|
for (let i = 0; i < breakpoints.length - 1; i++) {
|
|
495
688
|
const breakpoint = breakpoints[i];
|
|
496
|
-
// Count glue items (spaces) between line start and breakpoint
|
|
497
|
-
let glueCount = 0;
|
|
498
689
|
let totalWidth = 0;
|
|
499
690
|
for (let j = lineStart; j < breakpoint; j++) {
|
|
500
|
-
if (items[j].type === ItemType.GLUE) {
|
|
501
|
-
glueCount++;
|
|
502
|
-
}
|
|
503
691
|
if (items[j].type !== ItemType.PENALTY) {
|
|
504
692
|
totalWidth += items[j].width;
|
|
505
693
|
}
|
|
506
694
|
}
|
|
507
|
-
//
|
|
508
|
-
if (
|
|
695
|
+
// Check if line is narrow relative to target width
|
|
696
|
+
if (totalWidth > 0) {
|
|
509
697
|
const widthRatio = totalWidth / lineWidth;
|
|
510
|
-
if (widthRatio <
|
|
698
|
+
if (widthRatio < threshold) {
|
|
511
699
|
return true;
|
|
512
700
|
}
|
|
513
701
|
}
|
|
@@ -522,7 +710,7 @@ class LineBreak {
|
|
|
522
710
|
align: options.align || 'left',
|
|
523
711
|
hyphenate: options.hyphenate || false
|
|
524
712
|
});
|
|
525
|
-
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;
|
|
526
714
|
// Handle multiple paragraphs by processing each independently
|
|
527
715
|
if (respectExistingBreaks && text.includes('\n')) {
|
|
528
716
|
const paragraphs = text.split('\n');
|
|
@@ -601,39 +789,32 @@ class LineBreak {
|
|
|
601
789
|
}
|
|
602
790
|
];
|
|
603
791
|
}
|
|
604
|
-
// Itemize
|
|
605
|
-
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);
|
|
606
794
|
if (allItems.length === 0) {
|
|
607
795
|
return [];
|
|
608
796
|
}
|
|
609
|
-
// Iteratively increase emergency stretch to eliminate short single-word lines.
|
|
610
|
-
// Post-processing approach preserves TeX algorithm integrity while itemization
|
|
611
|
-
// (the expensive part) happens once
|
|
612
797
|
const MAX_ITERATIONS = 5;
|
|
613
798
|
let iteration = 0;
|
|
614
799
|
let currentEmergencyStretch = initialEmergencyStretch;
|
|
615
800
|
let resultLines = null;
|
|
616
|
-
const
|
|
801
|
+
const shortLineDetectionEnabled = !disableShortLineDetection;
|
|
617
802
|
while (iteration < MAX_ITERATIONS) {
|
|
618
|
-
// Three-pass approach
|
|
619
|
-
// First pass:
|
|
620
|
-
// Second pass:
|
|
621
|
-
//
|
|
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
|
|
622
807
|
// First pass: no hyphenation
|
|
623
|
-
let currentItems =
|
|
624
|
-
? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
|
|
625
|
-
item.penalty !==
|
|
626
|
-
(context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
|
|
627
|
-
: allItems;
|
|
808
|
+
let currentItems = allItems;
|
|
628
809
|
let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
|
|
629
|
-
// Second pass:
|
|
810
|
+
// Second pass: compute hyphenation only if needed
|
|
630
811
|
if (breaks.length === 0 && useHyphenation) {
|
|
631
|
-
|
|
812
|
+
const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
813
|
+
currentItems = itemsWithHyphenation;
|
|
632
814
|
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
|
|
633
815
|
}
|
|
634
|
-
//
|
|
816
|
+
// Emergency pass: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
|
|
635
817
|
if (breaks.length === 0) {
|
|
636
|
-
currentItems = allItems;
|
|
637
818
|
breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
|
|
638
819
|
}
|
|
639
820
|
// Force with infinite tolerance if still no breaks found
|
|
@@ -644,13 +825,13 @@ class LineBreak {
|
|
|
644
825
|
if (breaks.length > 0) {
|
|
645
826
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
|
|
646
827
|
resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
|
|
647
|
-
// Check for
|
|
648
|
-
if (
|
|
828
|
+
// Check for short lines if detection is enabled
|
|
829
|
+
if (shortLineDetectionEnabled &&
|
|
649
830
|
breaks.length > 1 &&
|
|
650
|
-
LineBreak.
|
|
831
|
+
LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
|
|
651
832
|
// Increase emergency stretch and try again
|
|
652
833
|
currentEmergencyStretch +=
|
|
653
|
-
width *
|
|
834
|
+
width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
|
|
654
835
|
iteration++;
|
|
655
836
|
continue;
|
|
656
837
|
}
|
|
@@ -984,6 +1165,8 @@ class LineBreak {
|
|
|
984
1165
|
const item = items[i];
|
|
985
1166
|
widths[i + 1] = widths[i] + item.width;
|
|
986
1167
|
if (item.type === ItemType.PENALTY) {
|
|
1168
|
+
stretches[i + 1] = stretches[i];
|
|
1169
|
+
shrinks[i + 1] = shrinks[i];
|
|
987
1170
|
minWidths[i + 1] = minWidths[i];
|
|
988
1171
|
}
|
|
989
1172
|
else if (item.type === ItemType.GLUE) {
|
|
@@ -1205,6 +1388,9 @@ function convertFontFeaturesToString(features) {
|
|
|
1205
1388
|
}
|
|
1206
1389
|
|
|
1207
1390
|
class TextMeasurer {
|
|
1391
|
+
// Measures text width including letter spacing
|
|
1392
|
+
// Letter spacing is added uniformly after each glyph during measurement,
|
|
1393
|
+
// so the widths given to the line-breaking algorithm already account for tracking
|
|
1208
1394
|
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1209
1395
|
const buffer = loadedFont.hb.createBuffer();
|
|
1210
1396
|
buffer.addText(text);
|
|
@@ -1231,9 +1417,11 @@ class TextLayout {
|
|
|
1231
1417
|
this.loadedFont = loadedFont;
|
|
1232
1418
|
}
|
|
1233
1419
|
computeLines(options) {
|
|
1234
|
-
const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness,
|
|
1420
|
+
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;
|
|
1235
1421
|
let lines;
|
|
1236
1422
|
if (width) {
|
|
1423
|
+
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
1424
|
+
// so widths passed into LineBreak.breakText account for tracking
|
|
1237
1425
|
lines = LineBreak.breakText({
|
|
1238
1426
|
text,
|
|
1239
1427
|
width,
|
|
@@ -1255,9 +1443,11 @@ class TextLayout {
|
|
|
1255
1443
|
exhyphenpenalty,
|
|
1256
1444
|
doublehyphendemerits,
|
|
1257
1445
|
looseness,
|
|
1258
|
-
|
|
1446
|
+
disableShortLineDetection,
|
|
1447
|
+
shortLineThreshold,
|
|
1259
1448
|
unitsPerEm: this.loadedFont.upem,
|
|
1260
|
-
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing
|
|
1449
|
+
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1450
|
+
)
|
|
1261
1451
|
});
|
|
1262
1452
|
}
|
|
1263
1453
|
else {
|
|
@@ -2222,7 +2412,11 @@ class Tessellator {
|
|
|
2222
2412
|
if (removeOverlaps) {
|
|
2223
2413
|
logger.log('Two-pass: boundary extraction then triangulation');
|
|
2224
2414
|
// Extract boundaries to remove overlaps
|
|
2415
|
+
perfLogger.start('Tessellator.boundaryPass', {
|
|
2416
|
+
contourCount: contours.length
|
|
2417
|
+
});
|
|
2225
2418
|
const boundaryResult = this.performTessellation(contours, 'boundary');
|
|
2419
|
+
perfLogger.end('Tessellator.boundaryPass');
|
|
2226
2420
|
if (!boundaryResult) {
|
|
2227
2421
|
logger.warn('libtess returned empty result from boundary pass');
|
|
2228
2422
|
return { triangles: { vertices: [], indices: [] }, contours: [] };
|
|
@@ -2235,7 +2429,11 @@ class Tessellator {
|
|
|
2235
2429
|
logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
|
|
2236
2430
|
}
|
|
2237
2431
|
// Triangulate the contours
|
|
2432
|
+
perfLogger.start('Tessellator.triangulationPass', {
|
|
2433
|
+
contourCount: contours.length
|
|
2434
|
+
});
|
|
2238
2435
|
const triangleResult = this.performTessellation(contours, 'triangles');
|
|
2436
|
+
perfLogger.end('Tessellator.triangulationPass');
|
|
2239
2437
|
if (!triangleResult) {
|
|
2240
2438
|
const warning = removeOverlaps
|
|
2241
2439
|
? 'libtess returned empty result from triangulation pass'
|
|
@@ -2422,10 +2620,16 @@ const OVERLAP_EPSILON = 1e-3;
|
|
|
2422
2620
|
class BoundaryClusterer {
|
|
2423
2621
|
constructor() { }
|
|
2424
2622
|
cluster(glyphContoursList, positions) {
|
|
2623
|
+
perfLogger.start('BoundaryClusterer.cluster', {
|
|
2624
|
+
glyphCount: glyphContoursList.length
|
|
2625
|
+
});
|
|
2425
2626
|
if (glyphContoursList.length === 0) {
|
|
2627
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2426
2628
|
return [];
|
|
2427
2629
|
}
|
|
2428
|
-
|
|
2630
|
+
const result = this.clusterSweepLine(glyphContoursList, positions);
|
|
2631
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2632
|
+
return result;
|
|
2429
2633
|
}
|
|
2430
2634
|
clusterSweepLine(glyphContoursList, positions) {
|
|
2431
2635
|
const n = glyphContoursList.length;
|
|
@@ -3066,6 +3270,11 @@ class GlyphContourCollector {
|
|
|
3066
3270
|
this.currentGlyphBounds.max.set(-Infinity, -Infinity);
|
|
3067
3271
|
// Record position for this glyph
|
|
3068
3272
|
this.glyphPositions.push(this.currentPosition.clone());
|
|
3273
|
+
// Time polygonization + path optimization per glyph
|
|
3274
|
+
perfLogger.start('Glyph.polygonizeAndOptimize', {
|
|
3275
|
+
glyphId,
|
|
3276
|
+
textIndex
|
|
3277
|
+
});
|
|
3069
3278
|
}
|
|
3070
3279
|
finishGlyph() {
|
|
3071
3280
|
if (this.currentPath) {
|
|
@@ -3089,6 +3298,8 @@ class GlyphContourCollector {
|
|
|
3089
3298
|
// Track textIndex separately
|
|
3090
3299
|
this.glyphTextIndices.push(this.currentTextIndex);
|
|
3091
3300
|
}
|
|
3301
|
+
// Stop timing for this glyph (even if it ended up empty)
|
|
3302
|
+
perfLogger.end('Glyph.polygonizeAndOptimize');
|
|
3092
3303
|
this.currentGlyphPaths = [];
|
|
3093
3304
|
}
|
|
3094
3305
|
onMoveTo(x, y) {
|
|
@@ -3323,6 +3534,184 @@ class DrawCallbackHandler {
|
|
|
3323
3534
|
}
|
|
3324
3535
|
}
|
|
3325
3536
|
|
|
3537
|
+
// Generic LRU (Least Recently Used) cache with optional memory-based eviction
|
|
3538
|
+
class LRUCache {
|
|
3539
|
+
constructor(options = {}) {
|
|
3540
|
+
this.cache = new Map();
|
|
3541
|
+
this.head = null;
|
|
3542
|
+
this.tail = null;
|
|
3543
|
+
this.stats = {
|
|
3544
|
+
hits: 0,
|
|
3545
|
+
misses: 0,
|
|
3546
|
+
evictions: 0,
|
|
3547
|
+
size: 0,
|
|
3548
|
+
memoryUsage: 0
|
|
3549
|
+
};
|
|
3550
|
+
this.options = {
|
|
3551
|
+
maxEntries: options.maxEntries ?? Infinity,
|
|
3552
|
+
maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
|
|
3553
|
+
calculateSize: options.calculateSize ?? (() => 0),
|
|
3554
|
+
onEvict: options.onEvict
|
|
3555
|
+
};
|
|
3556
|
+
}
|
|
3557
|
+
get(key) {
|
|
3558
|
+
const node = this.cache.get(key);
|
|
3559
|
+
if (node) {
|
|
3560
|
+
this.stats.hits++;
|
|
3561
|
+
this.moveToHead(node);
|
|
3562
|
+
return node.value;
|
|
3563
|
+
}
|
|
3564
|
+
else {
|
|
3565
|
+
this.stats.misses++;
|
|
3566
|
+
return undefined;
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
has(key) {
|
|
3570
|
+
return this.cache.has(key);
|
|
3571
|
+
}
|
|
3572
|
+
set(key, value) {
|
|
3573
|
+
// If key already exists, update it
|
|
3574
|
+
const existingNode = this.cache.get(key);
|
|
3575
|
+
if (existingNode) {
|
|
3576
|
+
const oldSize = this.options.calculateSize(existingNode.value);
|
|
3577
|
+
const newSize = this.options.calculateSize(value);
|
|
3578
|
+
this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
|
|
3579
|
+
existingNode.value = value;
|
|
3580
|
+
this.moveToHead(existingNode);
|
|
3581
|
+
return;
|
|
3582
|
+
}
|
|
3583
|
+
const size = this.options.calculateSize(value);
|
|
3584
|
+
// Evict entries if we exceed limits
|
|
3585
|
+
this.evictIfNeeded(size);
|
|
3586
|
+
// Create new node
|
|
3587
|
+
const node = {
|
|
3588
|
+
key,
|
|
3589
|
+
value,
|
|
3590
|
+
prev: null,
|
|
3591
|
+
next: null
|
|
3592
|
+
};
|
|
3593
|
+
this.cache.set(key, node);
|
|
3594
|
+
this.addToHead(node);
|
|
3595
|
+
this.stats.size = this.cache.size;
|
|
3596
|
+
this.stats.memoryUsage += size;
|
|
3597
|
+
}
|
|
3598
|
+
delete(key) {
|
|
3599
|
+
const node = this.cache.get(key);
|
|
3600
|
+
if (!node)
|
|
3601
|
+
return false;
|
|
3602
|
+
const size = this.options.calculateSize(node.value);
|
|
3603
|
+
this.removeNode(node);
|
|
3604
|
+
this.cache.delete(key);
|
|
3605
|
+
this.stats.size = this.cache.size;
|
|
3606
|
+
this.stats.memoryUsage -= size;
|
|
3607
|
+
if (this.options.onEvict) {
|
|
3608
|
+
this.options.onEvict(key, node.value);
|
|
3609
|
+
}
|
|
3610
|
+
return true;
|
|
3611
|
+
}
|
|
3612
|
+
clear() {
|
|
3613
|
+
if (this.options.onEvict) {
|
|
3614
|
+
for (const [key, node] of this.cache) {
|
|
3615
|
+
this.options.onEvict(key, node.value);
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
this.cache.clear();
|
|
3619
|
+
this.head = null;
|
|
3620
|
+
this.tail = null;
|
|
3621
|
+
this.stats = {
|
|
3622
|
+
hits: 0,
|
|
3623
|
+
misses: 0,
|
|
3624
|
+
evictions: 0,
|
|
3625
|
+
size: 0,
|
|
3626
|
+
memoryUsage: 0
|
|
3627
|
+
};
|
|
3628
|
+
}
|
|
3629
|
+
getStats() {
|
|
3630
|
+
const total = this.stats.hits + this.stats.misses;
|
|
3631
|
+
const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
|
|
3632
|
+
const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
|
|
3633
|
+
return {
|
|
3634
|
+
...this.stats,
|
|
3635
|
+
hitRate,
|
|
3636
|
+
memoryUsageMB
|
|
3637
|
+
};
|
|
3638
|
+
}
|
|
3639
|
+
keys() {
|
|
3640
|
+
const keys = [];
|
|
3641
|
+
let current = this.head;
|
|
3642
|
+
while (current) {
|
|
3643
|
+
keys.push(current.key);
|
|
3644
|
+
current = current.next;
|
|
3645
|
+
}
|
|
3646
|
+
return keys;
|
|
3647
|
+
}
|
|
3648
|
+
get size() {
|
|
3649
|
+
return this.cache.size;
|
|
3650
|
+
}
|
|
3651
|
+
evictIfNeeded(requiredSize) {
|
|
3652
|
+
// Evict by entry count
|
|
3653
|
+
while (this.cache.size >= this.options.maxEntries && this.tail) {
|
|
3654
|
+
this.evictTail();
|
|
3655
|
+
}
|
|
3656
|
+
// Evict by memory usage
|
|
3657
|
+
if (this.options.maxMemoryBytes < Infinity) {
|
|
3658
|
+
while (this.tail &&
|
|
3659
|
+
this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
|
|
3660
|
+
this.evictTail();
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
evictTail() {
|
|
3665
|
+
if (!this.tail)
|
|
3666
|
+
return;
|
|
3667
|
+
const nodeToRemove = this.tail;
|
|
3668
|
+
const size = this.options.calculateSize(nodeToRemove.value);
|
|
3669
|
+
this.removeTail();
|
|
3670
|
+
this.cache.delete(nodeToRemove.key);
|
|
3671
|
+
this.stats.size = this.cache.size;
|
|
3672
|
+
this.stats.memoryUsage -= size;
|
|
3673
|
+
this.stats.evictions++;
|
|
3674
|
+
if (this.options.onEvict) {
|
|
3675
|
+
this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
addToHead(node) {
|
|
3679
|
+
if (!this.head) {
|
|
3680
|
+
this.head = this.tail = node;
|
|
3681
|
+
}
|
|
3682
|
+
else {
|
|
3683
|
+
node.next = this.head;
|
|
3684
|
+
this.head.prev = node;
|
|
3685
|
+
this.head = node;
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
removeNode(node) {
|
|
3689
|
+
if (node.prev) {
|
|
3690
|
+
node.prev.next = node.next;
|
|
3691
|
+
}
|
|
3692
|
+
else {
|
|
3693
|
+
this.head = node.next;
|
|
3694
|
+
}
|
|
3695
|
+
if (node.next) {
|
|
3696
|
+
node.next.prev = node.prev;
|
|
3697
|
+
}
|
|
3698
|
+
else {
|
|
3699
|
+
this.tail = node.prev;
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
removeTail() {
|
|
3703
|
+
if (this.tail) {
|
|
3704
|
+
this.removeNode(this.tail);
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
moveToHead(node) {
|
|
3708
|
+
if (node === this.head)
|
|
3709
|
+
return;
|
|
3710
|
+
this.removeNode(node);
|
|
3711
|
+
this.addToHead(node);
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3326
3715
|
class GlyphGeometryBuilder {
|
|
3327
3716
|
constructor(cache, loadedFont) {
|
|
3328
3717
|
this.fontId = 'default';
|
|
@@ -3335,6 +3724,16 @@ class GlyphGeometryBuilder {
|
|
|
3335
3724
|
this.collector = new GlyphContourCollector();
|
|
3336
3725
|
this.drawCallbacks = new DrawCallbackHandler();
|
|
3337
3726
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
3727
|
+
this.contourCache = new LRUCache({
|
|
3728
|
+
maxEntries: 1000,
|
|
3729
|
+
calculateSize: (contours) => {
|
|
3730
|
+
let size = 0;
|
|
3731
|
+
for (const path of contours.paths) {
|
|
3732
|
+
size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
|
|
3733
|
+
}
|
|
3734
|
+
return size + 64; // bounds overhead
|
|
3735
|
+
}
|
|
3736
|
+
});
|
|
3338
3737
|
}
|
|
3339
3738
|
getOptimizationStats() {
|
|
3340
3739
|
return this.collector.getOptimizationStats();
|
|
@@ -3479,30 +3878,37 @@ class GlyphGeometryBuilder {
|
|
|
3479
3878
|
};
|
|
3480
3879
|
}
|
|
3481
3880
|
getContoursForGlyph(glyphId) {
|
|
3881
|
+
const cached = this.contourCache.get(glyphId);
|
|
3882
|
+
if (cached) {
|
|
3883
|
+
return cached;
|
|
3884
|
+
}
|
|
3482
3885
|
this.collector.reset();
|
|
3483
3886
|
this.collector.beginGlyph(glyphId, 0);
|
|
3484
3887
|
this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
|
|
3485
3888
|
this.collector.finishGlyph();
|
|
3486
3889
|
const collected = this.collector.getCollectedGlyphs()[0];
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
}
|
|
3498
|
-
return collected;
|
|
3890
|
+
const contours = collected || {
|
|
3891
|
+
glyphId,
|
|
3892
|
+
paths: [],
|
|
3893
|
+
bounds: {
|
|
3894
|
+
min: { x: 0, y: 0 },
|
|
3895
|
+
max: { x: 0, y: 0 }
|
|
3896
|
+
}
|
|
3897
|
+
};
|
|
3898
|
+
this.contourCache.set(glyphId, contours);
|
|
3899
|
+
return contours;
|
|
3499
3900
|
}
|
|
3500
3901
|
tessellateGlyphCluster(paths, depth, isCFF) {
|
|
3501
3902
|
const processedGeometry = this.tessellator.process(paths, true, isCFF);
|
|
3502
3903
|
return this.extrudeAndPackage(processedGeometry, depth);
|
|
3503
3904
|
}
|
|
3504
3905
|
extrudeAndPackage(processedGeometry, depth) {
|
|
3906
|
+
perfLogger.start('Extruder.extrude', {
|
|
3907
|
+
depth,
|
|
3908
|
+
upem: this.loadedFont.upem
|
|
3909
|
+
});
|
|
3505
3910
|
const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
|
|
3911
|
+
perfLogger.end('Extruder.extrude');
|
|
3506
3912
|
// Compute bounding box from vertices
|
|
3507
3913
|
const vertices = extrudedResult.vertices;
|
|
3508
3914
|
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
@@ -3544,6 +3950,7 @@ class GlyphGeometryBuilder {
|
|
|
3544
3950
|
pathCount: glyphContours.paths.length
|
|
3545
3951
|
});
|
|
3546
3952
|
const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
|
|
3953
|
+
perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
|
|
3547
3954
|
return this.extrudeAndPackage(processedGeometry, depth);
|
|
3548
3955
|
}
|
|
3549
3956
|
updatePlaneBounds(glyphBounds, planeBounds) {
|
|
@@ -3616,8 +4023,10 @@ class TextShaper {
|
|
|
3616
4023
|
let currentClusterText = '';
|
|
3617
4024
|
let clusterStartPosition = new Vec3();
|
|
3618
4025
|
let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
|
|
4026
|
+
// Apply letter spacing between glyphs (must match what was used in width measurements)
|
|
3619
4027
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
3620
4028
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
4029
|
+
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
3621
4030
|
for (let i = 0; i < glyphInfos.length; i++) {
|
|
3622
4031
|
const glyph = glyphInfos[i];
|
|
3623
4032
|
const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
|
|
@@ -3663,6 +4072,29 @@ class TextShaper {
|
|
|
3663
4072
|
if (isWhitespace) {
|
|
3664
4073
|
cursor.x += spaceAdjustment;
|
|
3665
4074
|
}
|
|
4075
|
+
// CJK glue adjustment (must match exactly where LineBreak adds glue)
|
|
4076
|
+
if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
|
|
4077
|
+
const currentChar = lineInfo.text[glyph.cl];
|
|
4078
|
+
const nextGlyph = glyphInfos[i + 1];
|
|
4079
|
+
const nextChar = lineInfo.text[nextGlyph.cl];
|
|
4080
|
+
const isCJKChar = LineBreak.isCJK(currentChar);
|
|
4081
|
+
const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
|
|
4082
|
+
if (isCJKChar && nextIsCJKChar) {
|
|
4083
|
+
let shouldApply = true;
|
|
4084
|
+
if (LineBreak.isCJClosingPunctuation(nextChar)) {
|
|
4085
|
+
shouldApply = false;
|
|
4086
|
+
}
|
|
4087
|
+
if (LineBreak.isCJOpeningPunctuation(currentChar)) {
|
|
4088
|
+
shouldApply = false;
|
|
4089
|
+
}
|
|
4090
|
+
if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
|
|
4091
|
+
shouldApply = false;
|
|
4092
|
+
}
|
|
4093
|
+
if (shouldApply) {
|
|
4094
|
+
cursor.x += cjkAdjustment;
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
3666
4098
|
}
|
|
3667
4099
|
if (currentClusterGlyphs.length > 0) {
|
|
3668
4100
|
clusters.push({
|
|
@@ -3695,6 +4127,23 @@ class TextShaper {
|
|
|
3695
4127
|
}
|
|
3696
4128
|
return spaceAdjustment;
|
|
3697
4129
|
}
|
|
4130
|
+
calculateCJKAdjustment(lineInfo, align) {
|
|
4131
|
+
if (lineInfo.adjustmentRatio === undefined ||
|
|
4132
|
+
align !== 'justify' ||
|
|
4133
|
+
lineInfo.isLastLine) {
|
|
4134
|
+
return 0;
|
|
4135
|
+
}
|
|
4136
|
+
const baseCharWidth = this.loadedFont.upem;
|
|
4137
|
+
const glueStretch = baseCharWidth * 0.04;
|
|
4138
|
+
const glueShrink = baseCharWidth * 0.04;
|
|
4139
|
+
if (lineInfo.adjustmentRatio > 0) {
|
|
4140
|
+
return lineInfo.adjustmentRatio * glueStretch;
|
|
4141
|
+
}
|
|
4142
|
+
else if (lineInfo.adjustmentRatio < 0) {
|
|
4143
|
+
return lineInfo.adjustmentRatio * glueShrink;
|
|
4144
|
+
}
|
|
4145
|
+
return 0;
|
|
4146
|
+
}
|
|
3698
4147
|
clearCache() {
|
|
3699
4148
|
this.geometryBuilder.clearCache();
|
|
3700
4149
|
}
|
|
@@ -4812,7 +5261,7 @@ class Text {
|
|
|
4812
5261
|
throw new Error('Font not loaded. Use Text.create() with a font option');
|
|
4813
5262
|
}
|
|
4814
5263
|
const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
|
|
4815
|
-
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,
|
|
5264
|
+
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;
|
|
4816
5265
|
let widthInFontUnits;
|
|
4817
5266
|
if (width !== undefined) {
|
|
4818
5267
|
widthInFontUnits = width * (this.loadedFont.upem / size);
|
|
@@ -4842,7 +5291,8 @@ class Text {
|
|
|
4842
5291
|
exhyphenpenalty,
|
|
4843
5292
|
doublehyphendemerits,
|
|
4844
5293
|
looseness,
|
|
4845
|
-
|
|
5294
|
+
disableShortLineDetection,
|
|
5295
|
+
shortLineThreshold,
|
|
4846
5296
|
letterSpacing
|
|
4847
5297
|
});
|
|
4848
5298
|
const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
|