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.umd.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
|
|
@@ -259,15 +259,15 @@
|
|
|
259
259
|
const INF_BAD = 10000;
|
|
260
260
|
// Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
|
|
261
261
|
const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
|
|
262
|
-
// Another non TeX default:
|
|
263
|
-
const
|
|
264
|
-
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
|
|
265
265
|
class LineBreak {
|
|
266
266
|
// Calculate badness according to TeX's formula (tex.web §108, line 2337)
|
|
267
267
|
// Given t (desired adjustment) and s (available stretch/shrink)
|
|
268
268
|
// Returns approximation to 100(t/s)³, representing how "bad" a line is
|
|
269
269
|
// Constants are derived from TeX's fixed-point arithmetic:
|
|
270
|
-
//
|
|
270
|
+
// 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
|
|
271
271
|
static badness(t, s) {
|
|
272
272
|
if (t === 0)
|
|
273
273
|
return 0;
|
|
@@ -326,6 +326,8 @@
|
|
|
326
326
|
const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
|
|
327
327
|
return filteredPoints;
|
|
328
328
|
}
|
|
329
|
+
// Converts text into items (boxes, glues, penalties) for line breaking
|
|
330
|
+
// The measureText function should return widths that include any letter spacing
|
|
329
331
|
static itemizeText(text, measureText, // function to measure text width
|
|
330
332
|
hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
|
|
331
333
|
const items = [];
|
|
@@ -349,16 +351,214 @@
|
|
|
349
351
|
});
|
|
350
352
|
return items;
|
|
351
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
|
+
}
|
|
352
507
|
static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
|
|
353
508
|
const items = [];
|
|
354
|
-
|
|
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 = [];
|
|
355
556
|
const tokens = text.match(/\S+|\s+/g) || [];
|
|
356
557
|
let currentIndex = 0;
|
|
357
558
|
for (let i = 0; i < tokens.length; i++) {
|
|
358
559
|
const token = tokens[i];
|
|
359
|
-
const tokenStartIndex = currentIndex;
|
|
560
|
+
const tokenStartIndex = startOffset + currentIndex;
|
|
360
561
|
if (/\s+/.test(token)) {
|
|
361
|
-
// Handle spaces
|
|
362
562
|
const width = measureText(token);
|
|
363
563
|
items.push({
|
|
364
564
|
type: ItemType.GLUE,
|
|
@@ -371,22 +571,19 @@
|
|
|
371
571
|
currentIndex += token.length;
|
|
372
572
|
}
|
|
373
573
|
else {
|
|
374
|
-
// Process word, splitting on explicit hyphens
|
|
375
|
-
// Split on hyphens while keeping them in the result
|
|
376
574
|
const segments = token.split(/(-)/);
|
|
377
575
|
let segmentIndex = tokenStartIndex;
|
|
378
576
|
for (let j = 0; j < segments.length; j++) {
|
|
379
577
|
const segment = segments[j];
|
|
380
578
|
if (!segment)
|
|
381
|
-
continue;
|
|
579
|
+
continue;
|
|
382
580
|
if (segment === '-') {
|
|
383
|
-
// Handle explicit hyphen as discretionary break
|
|
384
581
|
items.push({
|
|
385
582
|
type: ItemType.DISCRETIONARY,
|
|
386
|
-
width: measureText('-'),
|
|
387
|
-
preBreak: '-',
|
|
388
|
-
postBreak: '',
|
|
389
|
-
noBreak: '-',
|
|
583
|
+
width: measureText('-'),
|
|
584
|
+
preBreak: '-',
|
|
585
|
+
postBreak: '',
|
|
586
|
+
noBreak: '-',
|
|
390
587
|
preBreakWidth: measureText('-'),
|
|
391
588
|
penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
|
|
392
589
|
flagged: true,
|
|
@@ -396,8 +593,6 @@
|
|
|
396
593
|
segmentIndex += 1;
|
|
397
594
|
}
|
|
398
595
|
else {
|
|
399
|
-
// Process non-hyphen segment
|
|
400
|
-
// First handle soft hyphens (U+00AD)
|
|
401
596
|
if (segment.includes('\u00AD')) {
|
|
402
597
|
const partsWithMarkers = segment.split('\u00AD');
|
|
403
598
|
let runningIndex = 0;
|
|
@@ -415,23 +610,23 @@
|
|
|
415
610
|
if (k < partsWithMarkers.length - 1) {
|
|
416
611
|
items.push({
|
|
417
612
|
type: ItemType.DISCRETIONARY,
|
|
418
|
-
width: 0,
|
|
419
|
-
preBreak: '-',
|
|
420
|
-
postBreak: '',
|
|
421
|
-
noBreak: '',
|
|
613
|
+
width: 0,
|
|
614
|
+
preBreak: '-',
|
|
615
|
+
postBreak: '',
|
|
616
|
+
noBreak: '',
|
|
422
617
|
preBreakWidth: measureText('-'),
|
|
423
618
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
424
619
|
flagged: true,
|
|
425
620
|
text: '',
|
|
426
621
|
originIndex: segmentIndex + runningIndex
|
|
427
622
|
});
|
|
428
|
-
runningIndex += 1;
|
|
623
|
+
runningIndex += 1;
|
|
429
624
|
}
|
|
430
625
|
}
|
|
431
626
|
}
|
|
432
627
|
else if (hyphenate &&
|
|
433
|
-
segment.length >= lefthyphenmin + righthyphenmin
|
|
434
|
-
|
|
628
|
+
segment.length >= lefthyphenmin + righthyphenmin &&
|
|
629
|
+
/^\p{L}+$/u.test(segment)) {
|
|
435
630
|
const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
|
|
436
631
|
if (hyphenPoints.length > 0) {
|
|
437
632
|
let lastPoint = 0;
|
|
@@ -445,10 +640,10 @@
|
|
|
445
640
|
});
|
|
446
641
|
items.push({
|
|
447
642
|
type: ItemType.DISCRETIONARY,
|
|
448
|
-
width: 0,
|
|
449
|
-
preBreak: '-',
|
|
450
|
-
postBreak: '',
|
|
451
|
-
noBreak: '',
|
|
643
|
+
width: 0,
|
|
644
|
+
preBreak: '-',
|
|
645
|
+
postBreak: '',
|
|
646
|
+
noBreak: '',
|
|
452
647
|
preBreakWidth: measureText('-'),
|
|
453
648
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
454
649
|
flagged: true,
|
|
@@ -466,7 +661,6 @@
|
|
|
466
661
|
});
|
|
467
662
|
}
|
|
468
663
|
else {
|
|
469
|
-
// No hyphenation points, add as single box
|
|
470
664
|
items.push({
|
|
471
665
|
type: ItemType.BOX,
|
|
472
666
|
width: measureText(segment),
|
|
@@ -476,7 +670,6 @@
|
|
|
476
670
|
}
|
|
477
671
|
}
|
|
478
672
|
else {
|
|
479
|
-
// No hyphenation, add as single box
|
|
480
673
|
items.push({
|
|
481
674
|
type: ItemType.BOX,
|
|
482
675
|
width: measureText(segment),
|
|
@@ -492,27 +685,22 @@
|
|
|
492
685
|
}
|
|
493
686
|
return items;
|
|
494
687
|
}
|
|
495
|
-
// Detect if breakpoints create problematic
|
|
496
|
-
static
|
|
688
|
+
// Detect if breakpoints create problematic short lines
|
|
689
|
+
static hasShortLines(items, breakpoints, lineWidth, threshold) {
|
|
497
690
|
// Check each line segment (except the last, which can naturally be short)
|
|
498
691
|
let lineStart = 0;
|
|
499
692
|
for (let i = 0; i < breakpoints.length - 1; i++) {
|
|
500
693
|
const breakpoint = breakpoints[i];
|
|
501
|
-
// Count glue items (spaces) between line start and breakpoint
|
|
502
|
-
let glueCount = 0;
|
|
503
694
|
let totalWidth = 0;
|
|
504
695
|
for (let j = lineStart; j < breakpoint; j++) {
|
|
505
|
-
if (items[j].type === ItemType.GLUE) {
|
|
506
|
-
glueCount++;
|
|
507
|
-
}
|
|
508
696
|
if (items[j].type !== ItemType.PENALTY) {
|
|
509
697
|
totalWidth += items[j].width;
|
|
510
698
|
}
|
|
511
699
|
}
|
|
512
|
-
//
|
|
513
|
-
if (
|
|
700
|
+
// Check if line is narrow relative to target width
|
|
701
|
+
if (totalWidth > 0) {
|
|
514
702
|
const widthRatio = totalWidth / lineWidth;
|
|
515
|
-
if (widthRatio <
|
|
703
|
+
if (widthRatio < threshold) {
|
|
516
704
|
return true;
|
|
517
705
|
}
|
|
518
706
|
}
|
|
@@ -527,7 +715,7 @@
|
|
|
527
715
|
align: options.align || 'left',
|
|
528
716
|
hyphenate: options.hyphenate || false
|
|
529
717
|
});
|
|
530
|
-
const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0,
|
|
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;
|
|
531
719
|
// Handle multiple paragraphs by processing each independently
|
|
532
720
|
if (respectExistingBreaks && text.includes('\n')) {
|
|
533
721
|
const paragraphs = text.split('\n');
|
|
@@ -606,39 +794,32 @@
|
|
|
606
794
|
}
|
|
607
795
|
];
|
|
608
796
|
}
|
|
609
|
-
// Itemize
|
|
610
|
-
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);
|
|
611
799
|
if (allItems.length === 0) {
|
|
612
800
|
return [];
|
|
613
801
|
}
|
|
614
|
-
// Iteratively increase emergency stretch to eliminate short single-word lines.
|
|
615
|
-
// Post-processing approach preserves TeX algorithm integrity while itemization
|
|
616
|
-
// (the expensive part) happens once
|
|
617
802
|
const MAX_ITERATIONS = 5;
|
|
618
803
|
let iteration = 0;
|
|
619
804
|
let currentEmergencyStretch = initialEmergencyStretch;
|
|
620
805
|
let resultLines = null;
|
|
621
|
-
const
|
|
806
|
+
const shortLineDetectionEnabled = !disableShortLineDetection;
|
|
622
807
|
while (iteration < MAX_ITERATIONS) {
|
|
623
|
-
// Three-pass approach
|
|
624
|
-
// First pass:
|
|
625
|
-
// Second pass:
|
|
626
|
-
//
|
|
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
|
|
627
812
|
// First pass: no hyphenation
|
|
628
|
-
let currentItems =
|
|
629
|
-
? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
|
|
630
|
-
item.penalty !==
|
|
631
|
-
(context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
|
|
632
|
-
: allItems;
|
|
813
|
+
let currentItems = allItems;
|
|
633
814
|
let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
|
|
634
|
-
// Second pass:
|
|
815
|
+
// Second pass: compute hyphenation only if needed
|
|
635
816
|
if (breaks.length === 0 && useHyphenation) {
|
|
636
|
-
|
|
817
|
+
const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
818
|
+
currentItems = itemsWithHyphenation;
|
|
637
819
|
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
|
|
638
820
|
}
|
|
639
|
-
//
|
|
821
|
+
// Emergency pass: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
|
|
640
822
|
if (breaks.length === 0) {
|
|
641
|
-
currentItems = allItems;
|
|
642
823
|
breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
|
|
643
824
|
}
|
|
644
825
|
// Force with infinite tolerance if still no breaks found
|
|
@@ -649,13 +830,13 @@
|
|
|
649
830
|
if (breaks.length > 0) {
|
|
650
831
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
|
|
651
832
|
resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
|
|
652
|
-
// Check for
|
|
653
|
-
if (
|
|
833
|
+
// Check for short lines if detection is enabled
|
|
834
|
+
if (shortLineDetectionEnabled &&
|
|
654
835
|
breaks.length > 1 &&
|
|
655
|
-
LineBreak.
|
|
836
|
+
LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
|
|
656
837
|
// Increase emergency stretch and try again
|
|
657
838
|
currentEmergencyStretch +=
|
|
658
|
-
width *
|
|
839
|
+
width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
|
|
659
840
|
iteration++;
|
|
660
841
|
continue;
|
|
661
842
|
}
|
|
@@ -989,6 +1170,8 @@
|
|
|
989
1170
|
const item = items[i];
|
|
990
1171
|
widths[i + 1] = widths[i] + item.width;
|
|
991
1172
|
if (item.type === ItemType.PENALTY) {
|
|
1173
|
+
stretches[i + 1] = stretches[i];
|
|
1174
|
+
shrinks[i + 1] = shrinks[i];
|
|
992
1175
|
minWidths[i + 1] = minWidths[i];
|
|
993
1176
|
}
|
|
994
1177
|
else if (item.type === ItemType.GLUE) {
|
|
@@ -1210,6 +1393,9 @@
|
|
|
1210
1393
|
}
|
|
1211
1394
|
|
|
1212
1395
|
class TextMeasurer {
|
|
1396
|
+
// Measures text width including letter spacing
|
|
1397
|
+
// Letter spacing is added uniformly after each glyph during measurement,
|
|
1398
|
+
// so the widths given to the line-breaking algorithm already account for tracking
|
|
1213
1399
|
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1214
1400
|
const buffer = loadedFont.hb.createBuffer();
|
|
1215
1401
|
buffer.addText(text);
|
|
@@ -1236,9 +1422,11 @@
|
|
|
1236
1422
|
this.loadedFont = loadedFont;
|
|
1237
1423
|
}
|
|
1238
1424
|
computeLines(options) {
|
|
1239
|
-
const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness,
|
|
1425
|
+
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;
|
|
1240
1426
|
let lines;
|
|
1241
1427
|
if (width) {
|
|
1428
|
+
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
1429
|
+
// so widths passed into LineBreak.breakText account for tracking
|
|
1242
1430
|
lines = LineBreak.breakText({
|
|
1243
1431
|
text,
|
|
1244
1432
|
width,
|
|
@@ -1260,9 +1448,11 @@
|
|
|
1260
1448
|
exhyphenpenalty,
|
|
1261
1449
|
doublehyphendemerits,
|
|
1262
1450
|
looseness,
|
|
1263
|
-
|
|
1451
|
+
disableShortLineDetection,
|
|
1452
|
+
shortLineThreshold,
|
|
1264
1453
|
unitsPerEm: this.loadedFont.upem,
|
|
1265
|
-
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing
|
|
1454
|
+
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1455
|
+
)
|
|
1266
1456
|
});
|
|
1267
1457
|
}
|
|
1268
1458
|
else {
|
|
@@ -2229,7 +2419,11 @@
|
|
|
2229
2419
|
if (removeOverlaps) {
|
|
2230
2420
|
logger.log('Two-pass: boundary extraction then triangulation');
|
|
2231
2421
|
// Extract boundaries to remove overlaps
|
|
2422
|
+
perfLogger.start('Tessellator.boundaryPass', {
|
|
2423
|
+
contourCount: contours.length
|
|
2424
|
+
});
|
|
2232
2425
|
const boundaryResult = this.performTessellation(contours, 'boundary');
|
|
2426
|
+
perfLogger.end('Tessellator.boundaryPass');
|
|
2233
2427
|
if (!boundaryResult) {
|
|
2234
2428
|
logger.warn('libtess returned empty result from boundary pass');
|
|
2235
2429
|
return { triangles: { vertices: [], indices: [] }, contours: [] };
|
|
@@ -2242,7 +2436,11 @@
|
|
|
2242
2436
|
logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
|
|
2243
2437
|
}
|
|
2244
2438
|
// Triangulate the contours
|
|
2439
|
+
perfLogger.start('Tessellator.triangulationPass', {
|
|
2440
|
+
contourCount: contours.length
|
|
2441
|
+
});
|
|
2245
2442
|
const triangleResult = this.performTessellation(contours, 'triangles');
|
|
2443
|
+
perfLogger.end('Tessellator.triangulationPass');
|
|
2246
2444
|
if (!triangleResult) {
|
|
2247
2445
|
const warning = removeOverlaps
|
|
2248
2446
|
? 'libtess returned empty result from triangulation pass'
|
|
@@ -2429,10 +2627,16 @@
|
|
|
2429
2627
|
class BoundaryClusterer {
|
|
2430
2628
|
constructor() { }
|
|
2431
2629
|
cluster(glyphContoursList, positions) {
|
|
2630
|
+
perfLogger.start('BoundaryClusterer.cluster', {
|
|
2631
|
+
glyphCount: glyphContoursList.length
|
|
2632
|
+
});
|
|
2432
2633
|
if (glyphContoursList.length === 0) {
|
|
2634
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2433
2635
|
return [];
|
|
2434
2636
|
}
|
|
2435
|
-
|
|
2637
|
+
const result = this.clusterSweepLine(glyphContoursList, positions);
|
|
2638
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2639
|
+
return result;
|
|
2436
2640
|
}
|
|
2437
2641
|
clusterSweepLine(glyphContoursList, positions) {
|
|
2438
2642
|
const n = glyphContoursList.length;
|
|
@@ -3073,6 +3277,11 @@
|
|
|
3073
3277
|
this.currentGlyphBounds.max.set(-Infinity, -Infinity);
|
|
3074
3278
|
// Record position for this glyph
|
|
3075
3279
|
this.glyphPositions.push(this.currentPosition.clone());
|
|
3280
|
+
// Time polygonization + path optimization per glyph
|
|
3281
|
+
perfLogger.start('Glyph.polygonizeAndOptimize', {
|
|
3282
|
+
glyphId,
|
|
3283
|
+
textIndex
|
|
3284
|
+
});
|
|
3076
3285
|
}
|
|
3077
3286
|
finishGlyph() {
|
|
3078
3287
|
if (this.currentPath) {
|
|
@@ -3096,6 +3305,8 @@
|
|
|
3096
3305
|
// Track textIndex separately
|
|
3097
3306
|
this.glyphTextIndices.push(this.currentTextIndex);
|
|
3098
3307
|
}
|
|
3308
|
+
// Stop timing for this glyph (even if it ended up empty)
|
|
3309
|
+
perfLogger.end('Glyph.polygonizeAndOptimize');
|
|
3099
3310
|
this.currentGlyphPaths = [];
|
|
3100
3311
|
}
|
|
3101
3312
|
onMoveTo(x, y) {
|
|
@@ -3330,6 +3541,184 @@
|
|
|
3330
3541
|
}
|
|
3331
3542
|
}
|
|
3332
3543
|
|
|
3544
|
+
// Generic LRU (Least Recently Used) cache with optional memory-based eviction
|
|
3545
|
+
class LRUCache {
|
|
3546
|
+
constructor(options = {}) {
|
|
3547
|
+
this.cache = new Map();
|
|
3548
|
+
this.head = null;
|
|
3549
|
+
this.tail = null;
|
|
3550
|
+
this.stats = {
|
|
3551
|
+
hits: 0,
|
|
3552
|
+
misses: 0,
|
|
3553
|
+
evictions: 0,
|
|
3554
|
+
size: 0,
|
|
3555
|
+
memoryUsage: 0
|
|
3556
|
+
};
|
|
3557
|
+
this.options = {
|
|
3558
|
+
maxEntries: options.maxEntries ?? Infinity,
|
|
3559
|
+
maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
|
|
3560
|
+
calculateSize: options.calculateSize ?? (() => 0),
|
|
3561
|
+
onEvict: options.onEvict
|
|
3562
|
+
};
|
|
3563
|
+
}
|
|
3564
|
+
get(key) {
|
|
3565
|
+
const node = this.cache.get(key);
|
|
3566
|
+
if (node) {
|
|
3567
|
+
this.stats.hits++;
|
|
3568
|
+
this.moveToHead(node);
|
|
3569
|
+
return node.value;
|
|
3570
|
+
}
|
|
3571
|
+
else {
|
|
3572
|
+
this.stats.misses++;
|
|
3573
|
+
return undefined;
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
has(key) {
|
|
3577
|
+
return this.cache.has(key);
|
|
3578
|
+
}
|
|
3579
|
+
set(key, value) {
|
|
3580
|
+
// If key already exists, update it
|
|
3581
|
+
const existingNode = this.cache.get(key);
|
|
3582
|
+
if (existingNode) {
|
|
3583
|
+
const oldSize = this.options.calculateSize(existingNode.value);
|
|
3584
|
+
const newSize = this.options.calculateSize(value);
|
|
3585
|
+
this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
|
|
3586
|
+
existingNode.value = value;
|
|
3587
|
+
this.moveToHead(existingNode);
|
|
3588
|
+
return;
|
|
3589
|
+
}
|
|
3590
|
+
const size = this.options.calculateSize(value);
|
|
3591
|
+
// Evict entries if we exceed limits
|
|
3592
|
+
this.evictIfNeeded(size);
|
|
3593
|
+
// Create new node
|
|
3594
|
+
const node = {
|
|
3595
|
+
key,
|
|
3596
|
+
value,
|
|
3597
|
+
prev: null,
|
|
3598
|
+
next: null
|
|
3599
|
+
};
|
|
3600
|
+
this.cache.set(key, node);
|
|
3601
|
+
this.addToHead(node);
|
|
3602
|
+
this.stats.size = this.cache.size;
|
|
3603
|
+
this.stats.memoryUsage += size;
|
|
3604
|
+
}
|
|
3605
|
+
delete(key) {
|
|
3606
|
+
const node = this.cache.get(key);
|
|
3607
|
+
if (!node)
|
|
3608
|
+
return false;
|
|
3609
|
+
const size = this.options.calculateSize(node.value);
|
|
3610
|
+
this.removeNode(node);
|
|
3611
|
+
this.cache.delete(key);
|
|
3612
|
+
this.stats.size = this.cache.size;
|
|
3613
|
+
this.stats.memoryUsage -= size;
|
|
3614
|
+
if (this.options.onEvict) {
|
|
3615
|
+
this.options.onEvict(key, node.value);
|
|
3616
|
+
}
|
|
3617
|
+
return true;
|
|
3618
|
+
}
|
|
3619
|
+
clear() {
|
|
3620
|
+
if (this.options.onEvict) {
|
|
3621
|
+
for (const [key, node] of this.cache) {
|
|
3622
|
+
this.options.onEvict(key, node.value);
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
this.cache.clear();
|
|
3626
|
+
this.head = null;
|
|
3627
|
+
this.tail = null;
|
|
3628
|
+
this.stats = {
|
|
3629
|
+
hits: 0,
|
|
3630
|
+
misses: 0,
|
|
3631
|
+
evictions: 0,
|
|
3632
|
+
size: 0,
|
|
3633
|
+
memoryUsage: 0
|
|
3634
|
+
};
|
|
3635
|
+
}
|
|
3636
|
+
getStats() {
|
|
3637
|
+
const total = this.stats.hits + this.stats.misses;
|
|
3638
|
+
const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
|
|
3639
|
+
const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
|
|
3640
|
+
return {
|
|
3641
|
+
...this.stats,
|
|
3642
|
+
hitRate,
|
|
3643
|
+
memoryUsageMB
|
|
3644
|
+
};
|
|
3645
|
+
}
|
|
3646
|
+
keys() {
|
|
3647
|
+
const keys = [];
|
|
3648
|
+
let current = this.head;
|
|
3649
|
+
while (current) {
|
|
3650
|
+
keys.push(current.key);
|
|
3651
|
+
current = current.next;
|
|
3652
|
+
}
|
|
3653
|
+
return keys;
|
|
3654
|
+
}
|
|
3655
|
+
get size() {
|
|
3656
|
+
return this.cache.size;
|
|
3657
|
+
}
|
|
3658
|
+
evictIfNeeded(requiredSize) {
|
|
3659
|
+
// Evict by entry count
|
|
3660
|
+
while (this.cache.size >= this.options.maxEntries && this.tail) {
|
|
3661
|
+
this.evictTail();
|
|
3662
|
+
}
|
|
3663
|
+
// Evict by memory usage
|
|
3664
|
+
if (this.options.maxMemoryBytes < Infinity) {
|
|
3665
|
+
while (this.tail &&
|
|
3666
|
+
this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
|
|
3667
|
+
this.evictTail();
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
evictTail() {
|
|
3672
|
+
if (!this.tail)
|
|
3673
|
+
return;
|
|
3674
|
+
const nodeToRemove = this.tail;
|
|
3675
|
+
const size = this.options.calculateSize(nodeToRemove.value);
|
|
3676
|
+
this.removeTail();
|
|
3677
|
+
this.cache.delete(nodeToRemove.key);
|
|
3678
|
+
this.stats.size = this.cache.size;
|
|
3679
|
+
this.stats.memoryUsage -= size;
|
|
3680
|
+
this.stats.evictions++;
|
|
3681
|
+
if (this.options.onEvict) {
|
|
3682
|
+
this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
addToHead(node) {
|
|
3686
|
+
if (!this.head) {
|
|
3687
|
+
this.head = this.tail = node;
|
|
3688
|
+
}
|
|
3689
|
+
else {
|
|
3690
|
+
node.next = this.head;
|
|
3691
|
+
this.head.prev = node;
|
|
3692
|
+
this.head = node;
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
removeNode(node) {
|
|
3696
|
+
if (node.prev) {
|
|
3697
|
+
node.prev.next = node.next;
|
|
3698
|
+
}
|
|
3699
|
+
else {
|
|
3700
|
+
this.head = node.next;
|
|
3701
|
+
}
|
|
3702
|
+
if (node.next) {
|
|
3703
|
+
node.next.prev = node.prev;
|
|
3704
|
+
}
|
|
3705
|
+
else {
|
|
3706
|
+
this.tail = node.prev;
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
removeTail() {
|
|
3710
|
+
if (this.tail) {
|
|
3711
|
+
this.removeNode(this.tail);
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
moveToHead(node) {
|
|
3715
|
+
if (node === this.head)
|
|
3716
|
+
return;
|
|
3717
|
+
this.removeNode(node);
|
|
3718
|
+
this.addToHead(node);
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3333
3722
|
class GlyphGeometryBuilder {
|
|
3334
3723
|
constructor(cache, loadedFont) {
|
|
3335
3724
|
this.fontId = 'default';
|
|
@@ -3342,6 +3731,16 @@
|
|
|
3342
3731
|
this.collector = new GlyphContourCollector();
|
|
3343
3732
|
this.drawCallbacks = new DrawCallbackHandler();
|
|
3344
3733
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
3734
|
+
this.contourCache = new LRUCache({
|
|
3735
|
+
maxEntries: 1000,
|
|
3736
|
+
calculateSize: (contours) => {
|
|
3737
|
+
let size = 0;
|
|
3738
|
+
for (const path of contours.paths) {
|
|
3739
|
+
size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
|
|
3740
|
+
}
|
|
3741
|
+
return size + 64; // bounds overhead
|
|
3742
|
+
}
|
|
3743
|
+
});
|
|
3345
3744
|
}
|
|
3346
3745
|
getOptimizationStats() {
|
|
3347
3746
|
return this.collector.getOptimizationStats();
|
|
@@ -3486,30 +3885,37 @@
|
|
|
3486
3885
|
};
|
|
3487
3886
|
}
|
|
3488
3887
|
getContoursForGlyph(glyphId) {
|
|
3888
|
+
const cached = this.contourCache.get(glyphId);
|
|
3889
|
+
if (cached) {
|
|
3890
|
+
return cached;
|
|
3891
|
+
}
|
|
3489
3892
|
this.collector.reset();
|
|
3490
3893
|
this.collector.beginGlyph(glyphId, 0);
|
|
3491
3894
|
this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
|
|
3492
3895
|
this.collector.finishGlyph();
|
|
3493
3896
|
const collected = this.collector.getCollectedGlyphs()[0];
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
}
|
|
3505
|
-
return collected;
|
|
3897
|
+
const contours = collected || {
|
|
3898
|
+
glyphId,
|
|
3899
|
+
paths: [],
|
|
3900
|
+
bounds: {
|
|
3901
|
+
min: { x: 0, y: 0 },
|
|
3902
|
+
max: { x: 0, y: 0 }
|
|
3903
|
+
}
|
|
3904
|
+
};
|
|
3905
|
+
this.contourCache.set(glyphId, contours);
|
|
3906
|
+
return contours;
|
|
3506
3907
|
}
|
|
3507
3908
|
tessellateGlyphCluster(paths, depth, isCFF) {
|
|
3508
3909
|
const processedGeometry = this.tessellator.process(paths, true, isCFF);
|
|
3509
3910
|
return this.extrudeAndPackage(processedGeometry, depth);
|
|
3510
3911
|
}
|
|
3511
3912
|
extrudeAndPackage(processedGeometry, depth) {
|
|
3913
|
+
perfLogger.start('Extruder.extrude', {
|
|
3914
|
+
depth,
|
|
3915
|
+
upem: this.loadedFont.upem
|
|
3916
|
+
});
|
|
3512
3917
|
const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
|
|
3918
|
+
perfLogger.end('Extruder.extrude');
|
|
3513
3919
|
// Compute bounding box from vertices
|
|
3514
3920
|
const vertices = extrudedResult.vertices;
|
|
3515
3921
|
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
@@ -3551,6 +3957,7 @@
|
|
|
3551
3957
|
pathCount: glyphContours.paths.length
|
|
3552
3958
|
});
|
|
3553
3959
|
const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
|
|
3960
|
+
perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
|
|
3554
3961
|
return this.extrudeAndPackage(processedGeometry, depth);
|
|
3555
3962
|
}
|
|
3556
3963
|
updatePlaneBounds(glyphBounds, planeBounds) {
|
|
@@ -3623,8 +4030,10 @@
|
|
|
3623
4030
|
let currentClusterText = '';
|
|
3624
4031
|
let clusterStartPosition = new Vec3();
|
|
3625
4032
|
let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
|
|
4033
|
+
// Apply letter spacing between glyphs (must match what was used in width measurements)
|
|
3626
4034
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
3627
4035
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
4036
|
+
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
3628
4037
|
for (let i = 0; i < glyphInfos.length; i++) {
|
|
3629
4038
|
const glyph = glyphInfos[i];
|
|
3630
4039
|
const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
|
|
@@ -3670,6 +4079,29 @@
|
|
|
3670
4079
|
if (isWhitespace) {
|
|
3671
4080
|
cursor.x += spaceAdjustment;
|
|
3672
4081
|
}
|
|
4082
|
+
// CJK glue adjustment (must match exactly where LineBreak adds glue)
|
|
4083
|
+
if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
|
|
4084
|
+
const currentChar = lineInfo.text[glyph.cl];
|
|
4085
|
+
const nextGlyph = glyphInfos[i + 1];
|
|
4086
|
+
const nextChar = lineInfo.text[nextGlyph.cl];
|
|
4087
|
+
const isCJKChar = LineBreak.isCJK(currentChar);
|
|
4088
|
+
const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
|
|
4089
|
+
if (isCJKChar && nextIsCJKChar) {
|
|
4090
|
+
let shouldApply = true;
|
|
4091
|
+
if (LineBreak.isCJClosingPunctuation(nextChar)) {
|
|
4092
|
+
shouldApply = false;
|
|
4093
|
+
}
|
|
4094
|
+
if (LineBreak.isCJOpeningPunctuation(currentChar)) {
|
|
4095
|
+
shouldApply = false;
|
|
4096
|
+
}
|
|
4097
|
+
if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
|
|
4098
|
+
shouldApply = false;
|
|
4099
|
+
}
|
|
4100
|
+
if (shouldApply) {
|
|
4101
|
+
cursor.x += cjkAdjustment;
|
|
4102
|
+
}
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
3673
4105
|
}
|
|
3674
4106
|
if (currentClusterGlyphs.length > 0) {
|
|
3675
4107
|
clusters.push({
|
|
@@ -3702,6 +4134,23 @@
|
|
|
3702
4134
|
}
|
|
3703
4135
|
return spaceAdjustment;
|
|
3704
4136
|
}
|
|
4137
|
+
calculateCJKAdjustment(lineInfo, align) {
|
|
4138
|
+
if (lineInfo.adjustmentRatio === undefined ||
|
|
4139
|
+
align !== 'justify' ||
|
|
4140
|
+
lineInfo.isLastLine) {
|
|
4141
|
+
return 0;
|
|
4142
|
+
}
|
|
4143
|
+
const baseCharWidth = this.loadedFont.upem;
|
|
4144
|
+
const glueStretch = baseCharWidth * 0.04;
|
|
4145
|
+
const glueShrink = baseCharWidth * 0.04;
|
|
4146
|
+
if (lineInfo.adjustmentRatio > 0) {
|
|
4147
|
+
return lineInfo.adjustmentRatio * glueStretch;
|
|
4148
|
+
}
|
|
4149
|
+
else if (lineInfo.adjustmentRatio < 0) {
|
|
4150
|
+
return lineInfo.adjustmentRatio * glueShrink;
|
|
4151
|
+
}
|
|
4152
|
+
return 0;
|
|
4153
|
+
}
|
|
3705
4154
|
clearCache() {
|
|
3706
4155
|
this.geometryBuilder.clearCache();
|
|
3707
4156
|
}
|
|
@@ -4819,7 +5268,7 @@
|
|
|
4819
5268
|
throw new Error('Font not loaded. Use Text.create() with a font option');
|
|
4820
5269
|
}
|
|
4821
5270
|
const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
|
|
4822
|
-
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,
|
|
5271
|
+
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;
|
|
4823
5272
|
let widthInFontUnits;
|
|
4824
5273
|
if (width !== undefined) {
|
|
4825
5274
|
widthInFontUnits = width * (this.loadedFont.upem / size);
|
|
@@ -4849,7 +5298,8 @@
|
|
|
4849
5298
|
exhyphenpenalty,
|
|
4850
5299
|
doublehyphendemerits,
|
|
4851
5300
|
looseness,
|
|
4852
|
-
|
|
5301
|
+
disableShortLineDetection,
|
|
5302
|
+
shortLineThreshold,
|
|
4853
5303
|
letterSpacing
|
|
4854
5304
|
});
|
|
4855
5305
|
const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
|