three-text 0.2.7 → 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 +12 -9
- package/dist/index.cjs +295 -74
- package/dist/index.d.ts +2 -1
- package/dist/index.js +295 -74
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +295 -74
- package/dist/index.umd.min.js +2 -2
- package/dist/three/react.d.ts +2 -1
- package/dist/types/core/layout/LineBreak.d.ts +9 -2
- package/dist/types/core/shaping/TextShaper.d.ts +1 -0
- package/dist/types/core/types.d.ts +2 -1
- package/dist/types/utils/PerformanceLogger.d.ts +0 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -395,7 +395,8 @@ The Knuth-Plass algorithm provides extensive control over line breaking quality:
|
|
|
395
395
|
- **tolerance** (800): Maximum badness for the second pass with hyphenation
|
|
396
396
|
- **emergencyStretch** (0): Additional stretchability for difficult paragraphs
|
|
397
397
|
- **autoEmergencyStretch** (0.1): Emergency stretch as percentage of line width (e.g., 0.1 = 10%). Defaults to 10% for non-hyphenated text
|
|
398
|
-
- **
|
|
398
|
+
- **disableShortLineDetection** (false): Disable automatic prevention of short lines
|
|
399
|
+
- **shortLineThreshold** (0.5): Width ratio threshold for short line detection (0.0 to 1.0)
|
|
399
400
|
|
|
400
401
|
#### Advanced parameters
|
|
401
402
|
|
|
@@ -416,9 +417,9 @@ The Knuth-Plass algorithm provides extensive control over line breaking quality:
|
|
|
416
417
|
|
|
417
418
|
Lower penalty/tolerance values produce tighter spacing but may fail to find acceptable breaks for challenging text
|
|
418
419
|
|
|
419
|
-
####
|
|
420
|
+
#### Short line detection
|
|
420
421
|
|
|
421
|
-
By default, the library detects and prevents short
|
|
422
|
+
By default, the library detects and prevents short lines (lines occupying less than 50% of the target width on non-final lines) by iteratively applying emergency stretch. This can be customized or disabled:
|
|
422
423
|
|
|
423
424
|
```javascript
|
|
424
425
|
const text = await Text.create({
|
|
@@ -426,7 +427,9 @@ const text = await Text.create({
|
|
|
426
427
|
font: '/fonts/Font.ttf',
|
|
427
428
|
layout: {
|
|
428
429
|
width: 1000,
|
|
429
|
-
|
|
430
|
+
shortLineThreshold: 0.6, // Only flag lines < 60% width (more lenient)
|
|
431
|
+
// Or disable entirely:
|
|
432
|
+
// disableShortLineDetection: true,
|
|
430
433
|
},
|
|
431
434
|
});
|
|
432
435
|
```
|
|
@@ -754,9 +757,9 @@ interface LayoutOptions {
|
|
|
754
757
|
pretolerance?: number; // Maximum badness for first pass (default: 100)
|
|
755
758
|
emergencyStretch?: number; // Additional stretchability for difficult paragraphs
|
|
756
759
|
autoEmergencyStretch?: number; // Emergency stretch as percentage of line width (defaults to 10% for non-hyphenated)
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
//
|
|
760
|
+
disableShortLineDetection?: boolean; // Disable automatic short line prevention (default: false)
|
|
761
|
+
shortLineThreshold?: number; // Width ratio threshold for short line detection (default: 0.5)
|
|
762
|
+
lefthyphenmin?: number; // Minimum characters before hyphen (default: 2)
|
|
760
763
|
righthyphenmin?: number; // Minimum characters after hyphen (default: 4)
|
|
761
764
|
linepenalty?: number; // Base penalty per line (default: 10)
|
|
762
765
|
adjdemerits?: number; // Penalty for incompatible fitness classes (default: 10000)
|
|
@@ -940,9 +943,9 @@ While `three-text` runs on all modern browsers, performance varies significantly
|
|
|
940
943
|
|
|
941
944
|
**Chrome** provides the best experience
|
|
942
945
|
|
|
943
|
-
**Firefox** also delivers great performance but may exhibit less responsive mouse interactions
|
|
946
|
+
**Firefox** also delivers great performance but may exhibit less responsive mouse interactions
|
|
944
947
|
|
|
945
|
-
**Safari** for macOS shows reduced performance, which is likely due to the platform's conservative resource management
|
|
948
|
+
**Safari** for macOS shows reduced performance, which is likely due to the platform's conservative resource management; 120FPS is not acheivable
|
|
946
949
|
|
|
947
950
|
The library was also tested on a Brightsign 223HD, which took a long time to generate the initial geometry but seemed fine after that
|
|
948
951
|
|
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
|
|
@@ -125,9 +125,6 @@ class PerformanceLogger {
|
|
|
125
125
|
this.metrics.length = 0;
|
|
126
126
|
this.activeTimers.clear();
|
|
127
127
|
}
|
|
128
|
-
reset() {
|
|
129
|
-
this.clear();
|
|
130
|
-
}
|
|
131
128
|
time(name, fn, metadata) {
|
|
132
129
|
if (!isLogEnabled)
|
|
133
130
|
return fn();
|
|
@@ -260,15 +257,15 @@ const DEFAULT_RIGHT_HYPHEN_MIN = 4;
|
|
|
260
257
|
const INF_BAD = 10000;
|
|
261
258
|
// Non TeX default: emergency stretch for non-hyphenated text (10% of line width)
|
|
262
259
|
const DEFAULT_EMERGENCY_STRETCH_NO_HYPHEN = 0.1;
|
|
263
|
-
// Another non TeX default:
|
|
264
|
-
const
|
|
265
|
-
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
|
|
266
263
|
class LineBreak {
|
|
267
264
|
// Calculate badness according to TeX's formula (tex.web §108, line 2337)
|
|
268
265
|
// Given t (desired adjustment) and s (available stretch/shrink)
|
|
269
266
|
// Returns approximation to 100(t/s)³, representing how "bad" a line is
|
|
270
267
|
// Constants are derived from TeX's fixed-point arithmetic:
|
|
271
|
-
//
|
|
268
|
+
// 297³ ≈ 100×2¹⁸, so (297t/s)³/2¹⁸ ≈ 100(t/s)³
|
|
272
269
|
static badness(t, s) {
|
|
273
270
|
if (t === 0)
|
|
274
271
|
return 0;
|
|
@@ -327,8 +324,8 @@ class LineBreak {
|
|
|
327
324
|
const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
|
|
328
325
|
return filteredPoints;
|
|
329
326
|
}
|
|
330
|
-
// Converts text into items (boxes, glues, penalties) for line breaking
|
|
331
|
-
// The measureText function should return widths that include any letter spacing
|
|
327
|
+
// Converts text into items (boxes, glues, penalties) for line breaking
|
|
328
|
+
// The measureText function should return widths that include any letter spacing
|
|
332
329
|
static itemizeText(text, measureText, // function to measure text width
|
|
333
330
|
hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
|
|
334
331
|
const items = [];
|
|
@@ -352,16 +349,214 @@ class LineBreak {
|
|
|
352
349
|
});
|
|
353
350
|
return items;
|
|
354
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
|
+
}
|
|
355
505
|
static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
|
|
356
506
|
const items = [];
|
|
357
|
-
|
|
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 = [];
|
|
358
554
|
const tokens = text.match(/\S+|\s+/g) || [];
|
|
359
555
|
let currentIndex = 0;
|
|
360
556
|
for (let i = 0; i < tokens.length; i++) {
|
|
361
557
|
const token = tokens[i];
|
|
362
|
-
const tokenStartIndex = currentIndex;
|
|
558
|
+
const tokenStartIndex = startOffset + currentIndex;
|
|
363
559
|
if (/\s+/.test(token)) {
|
|
364
|
-
// Handle spaces
|
|
365
560
|
const width = measureText(token);
|
|
366
561
|
items.push({
|
|
367
562
|
type: ItemType.GLUE,
|
|
@@ -374,22 +569,19 @@ class LineBreak {
|
|
|
374
569
|
currentIndex += token.length;
|
|
375
570
|
}
|
|
376
571
|
else {
|
|
377
|
-
// Process word, splitting on explicit hyphens
|
|
378
|
-
// Split on hyphens while keeping them in the result
|
|
379
572
|
const segments = token.split(/(-)/);
|
|
380
573
|
let segmentIndex = tokenStartIndex;
|
|
381
574
|
for (let j = 0; j < segments.length; j++) {
|
|
382
575
|
const segment = segments[j];
|
|
383
576
|
if (!segment)
|
|
384
|
-
continue;
|
|
577
|
+
continue;
|
|
385
578
|
if (segment === '-') {
|
|
386
|
-
// Handle explicit hyphen as discretionary break
|
|
387
579
|
items.push({
|
|
388
580
|
type: ItemType.DISCRETIONARY,
|
|
389
|
-
width: measureText('-'),
|
|
390
|
-
preBreak: '-',
|
|
391
|
-
postBreak: '',
|
|
392
|
-
noBreak: '-',
|
|
581
|
+
width: measureText('-'),
|
|
582
|
+
preBreak: '-',
|
|
583
|
+
postBreak: '',
|
|
584
|
+
noBreak: '-',
|
|
393
585
|
preBreakWidth: measureText('-'),
|
|
394
586
|
penalty: context?.exHyphenPenalty ?? DEFAULT_EX_HYPHEN_PENALTY,
|
|
395
587
|
flagged: true,
|
|
@@ -399,8 +591,6 @@ class LineBreak {
|
|
|
399
591
|
segmentIndex += 1;
|
|
400
592
|
}
|
|
401
593
|
else {
|
|
402
|
-
// Process non-hyphen segment
|
|
403
|
-
// First handle soft hyphens (U+00AD)
|
|
404
594
|
if (segment.includes('\u00AD')) {
|
|
405
595
|
const partsWithMarkers = segment.split('\u00AD');
|
|
406
596
|
let runningIndex = 0;
|
|
@@ -418,23 +608,23 @@ class LineBreak {
|
|
|
418
608
|
if (k < partsWithMarkers.length - 1) {
|
|
419
609
|
items.push({
|
|
420
610
|
type: ItemType.DISCRETIONARY,
|
|
421
|
-
width: 0,
|
|
422
|
-
preBreak: '-',
|
|
423
|
-
postBreak: '',
|
|
424
|
-
noBreak: '',
|
|
611
|
+
width: 0,
|
|
612
|
+
preBreak: '-',
|
|
613
|
+
postBreak: '',
|
|
614
|
+
noBreak: '',
|
|
425
615
|
preBreakWidth: measureText('-'),
|
|
426
616
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
427
617
|
flagged: true,
|
|
428
618
|
text: '',
|
|
429
619
|
originIndex: segmentIndex + runningIndex
|
|
430
620
|
});
|
|
431
|
-
runningIndex += 1;
|
|
621
|
+
runningIndex += 1;
|
|
432
622
|
}
|
|
433
623
|
}
|
|
434
624
|
}
|
|
435
625
|
else if (hyphenate &&
|
|
436
|
-
segment.length >= lefthyphenmin + righthyphenmin
|
|
437
|
-
|
|
626
|
+
segment.length >= lefthyphenmin + righthyphenmin &&
|
|
627
|
+
/^\p{L}+$/u.test(segment)) {
|
|
438
628
|
const hyphenPoints = LineBreak.findHyphenationPoints(segment, language, availablePatterns, lefthyphenmin, righthyphenmin);
|
|
439
629
|
if (hyphenPoints.length > 0) {
|
|
440
630
|
let lastPoint = 0;
|
|
@@ -448,10 +638,10 @@ class LineBreak {
|
|
|
448
638
|
});
|
|
449
639
|
items.push({
|
|
450
640
|
type: ItemType.DISCRETIONARY,
|
|
451
|
-
width: 0,
|
|
452
|
-
preBreak: '-',
|
|
453
|
-
postBreak: '',
|
|
454
|
-
noBreak: '',
|
|
641
|
+
width: 0,
|
|
642
|
+
preBreak: '-',
|
|
643
|
+
postBreak: '',
|
|
644
|
+
noBreak: '',
|
|
455
645
|
preBreakWidth: measureText('-'),
|
|
456
646
|
penalty: context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY,
|
|
457
647
|
flagged: true,
|
|
@@ -469,7 +659,6 @@ class LineBreak {
|
|
|
469
659
|
});
|
|
470
660
|
}
|
|
471
661
|
else {
|
|
472
|
-
// No hyphenation points, add as single box
|
|
473
662
|
items.push({
|
|
474
663
|
type: ItemType.BOX,
|
|
475
664
|
width: measureText(segment),
|
|
@@ -479,7 +668,6 @@ class LineBreak {
|
|
|
479
668
|
}
|
|
480
669
|
}
|
|
481
670
|
else {
|
|
482
|
-
// No hyphenation, add as single box
|
|
483
671
|
items.push({
|
|
484
672
|
type: ItemType.BOX,
|
|
485
673
|
width: measureText(segment),
|
|
@@ -495,27 +683,22 @@ class LineBreak {
|
|
|
495
683
|
}
|
|
496
684
|
return items;
|
|
497
685
|
}
|
|
498
|
-
// Detect if breakpoints create problematic
|
|
499
|
-
static
|
|
686
|
+
// Detect if breakpoints create problematic short lines
|
|
687
|
+
static hasShortLines(items, breakpoints, lineWidth, threshold) {
|
|
500
688
|
// Check each line segment (except the last, which can naturally be short)
|
|
501
689
|
let lineStart = 0;
|
|
502
690
|
for (let i = 0; i < breakpoints.length - 1; i++) {
|
|
503
691
|
const breakpoint = breakpoints[i];
|
|
504
|
-
// Count glue items (spaces) between line start and breakpoint
|
|
505
|
-
let glueCount = 0;
|
|
506
692
|
let totalWidth = 0;
|
|
507
693
|
for (let j = lineStart; j < breakpoint; j++) {
|
|
508
|
-
if (items[j].type === ItemType.GLUE) {
|
|
509
|
-
glueCount++;
|
|
510
|
-
}
|
|
511
694
|
if (items[j].type !== ItemType.PENALTY) {
|
|
512
695
|
totalWidth += items[j].width;
|
|
513
696
|
}
|
|
514
697
|
}
|
|
515
|
-
//
|
|
516
|
-
if (
|
|
698
|
+
// Check if line is narrow relative to target width
|
|
699
|
+
if (totalWidth > 0) {
|
|
517
700
|
const widthRatio = totalWidth / lineWidth;
|
|
518
|
-
if (widthRatio <
|
|
701
|
+
if (widthRatio < threshold) {
|
|
519
702
|
return true;
|
|
520
703
|
}
|
|
521
704
|
}
|
|
@@ -530,7 +713,7 @@ class LineBreak {
|
|
|
530
713
|
align: options.align || 'left',
|
|
531
714
|
hyphenate: options.hyphenate || false
|
|
532
715
|
});
|
|
533
|
-
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;
|
|
534
717
|
// Handle multiple paragraphs by processing each independently
|
|
535
718
|
if (respectExistingBreaks && text.includes('\n')) {
|
|
536
719
|
const paragraphs = text.split('\n');
|
|
@@ -609,39 +792,32 @@ class LineBreak {
|
|
|
609
792
|
}
|
|
610
793
|
];
|
|
611
794
|
}
|
|
612
|
-
// Itemize
|
|
613
|
-
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);
|
|
614
797
|
if (allItems.length === 0) {
|
|
615
798
|
return [];
|
|
616
799
|
}
|
|
617
|
-
// Iteratively increase emergency stretch to eliminate short single-word lines.
|
|
618
|
-
// Post-processing approach preserves TeX algorithm integrity while itemization
|
|
619
|
-
// (the expensive part) happens once
|
|
620
800
|
const MAX_ITERATIONS = 5;
|
|
621
801
|
let iteration = 0;
|
|
622
802
|
let currentEmergencyStretch = initialEmergencyStretch;
|
|
623
803
|
let resultLines = null;
|
|
624
|
-
const
|
|
804
|
+
const shortLineDetectionEnabled = !disableShortLineDetection;
|
|
625
805
|
while (iteration < MAX_ITERATIONS) {
|
|
626
|
-
// Three-pass approach
|
|
627
|
-
// First pass:
|
|
628
|
-
// Second pass:
|
|
629
|
-
//
|
|
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
|
|
630
810
|
// First pass: no hyphenation
|
|
631
|
-
let currentItems =
|
|
632
|
-
? allItems.filter((item) => item.type !== ItemType.DISCRETIONARY ||
|
|
633
|
-
item.penalty !==
|
|
634
|
-
(context?.hyphenPenalty ?? DEFAULT_HYPHEN_PENALTY))
|
|
635
|
-
: allItems;
|
|
811
|
+
let currentItems = allItems;
|
|
636
812
|
let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
|
|
637
|
-
// Second pass:
|
|
813
|
+
// Second pass: compute hyphenation only if needed
|
|
638
814
|
if (breaks.length === 0 && useHyphenation) {
|
|
639
|
-
|
|
815
|
+
const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
|
|
816
|
+
currentItems = itemsWithHyphenation;
|
|
640
817
|
breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
|
|
641
818
|
}
|
|
642
|
-
//
|
|
819
|
+
// Emergency pass: use currentItems as-is (preserves hyphenation from pass 2 if it ran)
|
|
643
820
|
if (breaks.length === 0) {
|
|
644
|
-
currentItems = allItems;
|
|
645
821
|
breaks = LineBreak.findBreakpoints(currentItems, width, INF_BAD + 1, looseness, true, currentEmergencyStretch, context);
|
|
646
822
|
}
|
|
647
823
|
// Force with infinite tolerance if still no breaks found
|
|
@@ -652,13 +828,13 @@ class LineBreak {
|
|
|
652
828
|
if (breaks.length > 0) {
|
|
653
829
|
const cumulativeWidths = LineBreak.computeCumulativeWidths(currentItems);
|
|
654
830
|
resultLines = LineBreak.createLines(text, currentItems, breaks, width, align, direction, cumulativeWidths, context);
|
|
655
|
-
// Check for
|
|
656
|
-
if (
|
|
831
|
+
// Check for short lines if detection is enabled
|
|
832
|
+
if (shortLineDetectionEnabled &&
|
|
657
833
|
breaks.length > 1 &&
|
|
658
|
-
LineBreak.
|
|
834
|
+
LineBreak.hasShortLines(currentItems, breaks, width, shortLineThreshold)) {
|
|
659
835
|
// Increase emergency stretch and try again
|
|
660
836
|
currentEmergencyStretch +=
|
|
661
|
-
width *
|
|
837
|
+
width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
|
|
662
838
|
iteration++;
|
|
663
839
|
continue;
|
|
664
840
|
}
|
|
@@ -992,6 +1168,8 @@ class LineBreak {
|
|
|
992
1168
|
const item = items[i];
|
|
993
1169
|
widths[i + 1] = widths[i] + item.width;
|
|
994
1170
|
if (item.type === ItemType.PENALTY) {
|
|
1171
|
+
stretches[i + 1] = stretches[i];
|
|
1172
|
+
shrinks[i + 1] = shrinks[i];
|
|
995
1173
|
minWidths[i + 1] = minWidths[i];
|
|
996
1174
|
}
|
|
997
1175
|
else if (item.type === ItemType.GLUE) {
|
|
@@ -1242,7 +1420,7 @@ class TextLayout {
|
|
|
1242
1420
|
this.loadedFont = loadedFont;
|
|
1243
1421
|
}
|
|
1244
1422
|
computeLines(options) {
|
|
1245
|
-
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;
|
|
1246
1424
|
let lines;
|
|
1247
1425
|
if (width) {
|
|
1248
1426
|
// Line breaking uses a measureText function that already includes letterSpacing,
|
|
@@ -1268,7 +1446,8 @@ class TextLayout {
|
|
|
1268
1446
|
exhyphenpenalty,
|
|
1269
1447
|
doublehyphendemerits,
|
|
1270
1448
|
looseness,
|
|
1271
|
-
|
|
1449
|
+
disableShortLineDetection,
|
|
1450
|
+
shortLineThreshold,
|
|
1272
1451
|
unitsPerEm: this.loadedFont.upem,
|
|
1273
1452
|
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1274
1453
|
)
|
|
@@ -3850,6 +4029,7 @@ class TextShaper {
|
|
|
3850
4029
|
// Apply letter spacing between glyphs (must match what was used in width measurements)
|
|
3851
4030
|
const letterSpacingFU = letterSpacing * this.loadedFont.upem;
|
|
3852
4031
|
const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
|
|
4032
|
+
const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
|
|
3853
4033
|
for (let i = 0; i < glyphInfos.length; i++) {
|
|
3854
4034
|
const glyph = glyphInfos[i];
|
|
3855
4035
|
const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
|
|
@@ -3895,6 +4075,29 @@ class TextShaper {
|
|
|
3895
4075
|
if (isWhitespace) {
|
|
3896
4076
|
cursor.x += spaceAdjustment;
|
|
3897
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
|
+
}
|
|
3898
4101
|
}
|
|
3899
4102
|
if (currentClusterGlyphs.length > 0) {
|
|
3900
4103
|
clusters.push({
|
|
@@ -3927,6 +4130,23 @@ class TextShaper {
|
|
|
3927
4130
|
}
|
|
3928
4131
|
return spaceAdjustment;
|
|
3929
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
|
+
}
|
|
3930
4150
|
clearCache() {
|
|
3931
4151
|
this.geometryBuilder.clearCache();
|
|
3932
4152
|
}
|
|
@@ -5044,7 +5264,7 @@ class Text {
|
|
|
5044
5264
|
throw new Error('Font not loaded. Use Text.create() with a font option');
|
|
5045
5265
|
}
|
|
5046
5266
|
const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
|
|
5047
|
-
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;
|
|
5048
5268
|
let widthInFontUnits;
|
|
5049
5269
|
if (width !== undefined) {
|
|
5050
5270
|
widthInFontUnits = width * (this.loadedFont.upem / size);
|
|
@@ -5074,7 +5294,8 @@ class Text {
|
|
|
5074
5294
|
exhyphenpenalty,
|
|
5075
5295
|
doublehyphendemerits,
|
|
5076
5296
|
looseness,
|
|
5077
|
-
|
|
5297
|
+
disableShortLineDetection,
|
|
5298
|
+
shortLineThreshold,
|
|
5078
5299
|
letterSpacing
|
|
5079
5300
|
});
|
|
5080
5301
|
const metrics = FontMetadataExtractor.getVerticalMetrics(this.loadedFont.metrics);
|
package/dist/index.d.ts
CHANGED
|
@@ -315,7 +315,8 @@ interface LayoutOptions {
|
|
|
315
315
|
exhyphenpenalty?: number;
|
|
316
316
|
doublehyphendemerits?: number;
|
|
317
317
|
looseness?: number;
|
|
318
|
-
|
|
318
|
+
disableShortLineDetection?: boolean;
|
|
319
|
+
shortLineThreshold?: number;
|
|
319
320
|
}
|
|
320
321
|
|
|
321
322
|
interface GlyphData {
|