render-tag 0.1.9 → 0.1.11
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/lib/css-resolver.d.ts.map +1 -1
- package/lib/css-resolver.js +17 -2
- package/lib/css-resolver.js.map +1 -1
- package/lib/layout.d.ts.map +1 -1
- package/lib/layout.js +315 -54
- package/lib/layout.js.map +1 -1
- package/lib/render-tag.umd.js +8 -6
- package/lib/render-tag.umd.js.map +1 -1
- package/lib/render.d.ts.map +1 -1
- package/lib/render.js +28 -9
- package/lib/render.js.map +1 -1
- package/lib/types.d.ts +4 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +1 -1
package/lib/layout.js
CHANGED
|
@@ -10,8 +10,9 @@ let _lines = [];
|
|
|
10
10
|
// Cleared at the start of each buildLayoutTree() call.
|
|
11
11
|
const _measureCache = new Map();
|
|
12
12
|
function cachedMeasureWidth(ctx, text) {
|
|
13
|
-
// ctx.font must already be set by caller
|
|
14
|
-
|
|
13
|
+
// ctx.font and ctx.letterSpacing must already be set by caller.
|
|
14
|
+
// letterSpacing is part of the key because it changes measured width.
|
|
15
|
+
const key = ctx.font + '\0' + (ctx.letterSpacing || '') + '\0' + text;
|
|
15
16
|
const cached = _measureCache.get(key);
|
|
16
17
|
if (cached !== undefined)
|
|
17
18
|
return cached;
|
|
@@ -42,18 +43,30 @@ export function applyFont(ctx, style) {
|
|
|
42
43
|
ctx.font = buildCanvasFont(style);
|
|
43
44
|
ctx.fontKerning = style.fontKerning === 'none' ? 'none' : 'normal';
|
|
44
45
|
}
|
|
46
|
+
/** Format a letter-spacing value (px) as a canvas `ctx.letterSpacing` string. */
|
|
47
|
+
function formatLetterSpacing(value) {
|
|
48
|
+
// Negative letter-spacing is valid and narrows text — Chrome applies it per
|
|
49
|
+
// character (trailing included). Clamping it to 0 measured text wider than
|
|
50
|
+
// the browser renders it, causing earlier/extra line wraps. Guard against
|
|
51
|
+
// non-finite values (undefined/NaN), which would produce an invalid
|
|
52
|
+
// "undefinedpx"/"NaNpx" string that canvas silently ignores.
|
|
53
|
+
return Number.isFinite(value) && value !== 0 ? `${value}px` : '0px';
|
|
54
|
+
}
|
|
45
55
|
/**
|
|
46
56
|
* Build a canvas font string from resolved style. Results are cached.
|
|
47
57
|
*/
|
|
48
58
|
const _fontStringCache = new Map();
|
|
49
59
|
export function buildCanvasFont(style) {
|
|
50
|
-
const key = `${style.fontStyle}|${style.fontWeight}|${style.fontSize}|${style.fontFamily}`;
|
|
60
|
+
const key = `${style.fontStyle}|${style.fontVariantCaps}|${style.fontWeight}|${style.fontSize}|${style.fontFamily}`;
|
|
51
61
|
const cached = _fontStringCache.get(key);
|
|
52
62
|
if (cached)
|
|
53
63
|
return cached;
|
|
54
64
|
const parts = [];
|
|
65
|
+
// CSS font shorthand order: style, variant, weight, size, family.
|
|
55
66
|
if (style.fontStyle !== 'normal')
|
|
56
67
|
parts.push(style.fontStyle);
|
|
68
|
+
if (style.fontVariantCaps === 'small-caps')
|
|
69
|
+
parts.push('small-caps');
|
|
57
70
|
if (style.fontWeight !== 400)
|
|
58
71
|
parts.push(String(style.fontWeight));
|
|
59
72
|
parts.push(`${style.fontSize}px`);
|
|
@@ -152,7 +165,9 @@ function applyTextTransform(text, transform) {
|
|
|
152
165
|
switch (transform) {
|
|
153
166
|
case 'uppercase': return text.toUpperCase();
|
|
154
167
|
case 'lowercase': return text.toLowerCase();
|
|
155
|
-
|
|
168
|
+
// Capitalize the first letter of each word. A mid-word apostrophe is NOT a
|
|
169
|
+
// word boundary (UAX#29), so "o'clock" → "O'clock", not "O'Clock".
|
|
170
|
+
case 'capitalize': return text.replace(/(^|[\s\p{P}])(\p{L})/gu, (m, p, c) => p === "'" || p === '’' ? m : p + c.toUpperCase());
|
|
156
171
|
default: return text;
|
|
157
172
|
}
|
|
158
173
|
}
|
|
@@ -185,6 +200,39 @@ export function getFontMetrics(ctx, style) {
|
|
|
185
200
|
_fontMetricsCache.set(font, result);
|
|
186
201
|
return result;
|
|
187
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Baseline shift (canvas pixels, positive = downward) for a vertical-align
|
|
205
|
+
* value, applied on top of the line baseline. Returns 0 for 'baseline' and for
|
|
206
|
+
* the line-box-relative keywords 'top'/'bottom' — those need a second layout
|
|
207
|
+
* pass (the box position depends on the final line box it helps size), so they
|
|
208
|
+
* fall back to baseline rather than being approximated wrongly.
|
|
209
|
+
*
|
|
210
|
+
* - super/sub legacy fixed fractions of the parent font size
|
|
211
|
+
* - text-top/-bottom align the box's ascent/descent edge with the line's
|
|
212
|
+
* - middle box midpoint at parent baseline + half the x-height
|
|
213
|
+
* - <length>/<%> raise (positive value) by the length / % of line-height
|
|
214
|
+
*/
|
|
215
|
+
function verticalAlignShift(va, wAscent, wDescent, parentFontSize, maxAscent, maxDescent, lineHeight) {
|
|
216
|
+
switch (va) {
|
|
217
|
+
case 'super': return -parentFontSize * 0.4;
|
|
218
|
+
case 'sub': return parentFontSize * 0.26;
|
|
219
|
+
case 'text-top': return -(maxAscent - wAscent);
|
|
220
|
+
case 'text-bottom': return maxDescent - wDescent;
|
|
221
|
+
case 'middle': return -(parentFontSize * 0.25) - (wDescent - wAscent) / 2;
|
|
222
|
+
default: {
|
|
223
|
+
// baseline / top / bottom / '' all parseFloat to NaN → 0 (callers gate
|
|
224
|
+
// on isShiftedVAlign, so those never actually reach here).
|
|
225
|
+
const n = parseFloat(va);
|
|
226
|
+
if (!Number.isFinite(n))
|
|
227
|
+
return 0;
|
|
228
|
+
return va.endsWith('%') ? -(n / 100) * lineHeight : -n;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** True when a vertical-align value moves content off the baseline. */
|
|
233
|
+
function isShiftedVAlign(va) {
|
|
234
|
+
return va !== 'baseline' && va !== 'top' && va !== 'bottom' && va !== '';
|
|
235
|
+
}
|
|
188
236
|
/**
|
|
189
237
|
* Check if two styles have the same text rendering properties.
|
|
190
238
|
*/
|
|
@@ -320,6 +368,13 @@ function collectTextRuns(node) {
|
|
|
320
368
|
});
|
|
321
369
|
return;
|
|
322
370
|
}
|
|
371
|
+
// unicode-bidi: bidi-override (e.g. <bdo dir="rtl">) forces visual order.
|
|
372
|
+
// For an RTL override, reverse both the characters of each descendant run
|
|
373
|
+
// and the order of the runs, so the subtree renders right-to-left.
|
|
374
|
+
const ub = n.style.unicodeBidi;
|
|
375
|
+
const overrideRtl = (ub === 'bidi-override' || ub === 'isolate-override') &&
|
|
376
|
+
n.style.direction === 'rtl';
|
|
377
|
+
const overrideStart = runs.length;
|
|
323
378
|
if (hasHorizSpacing) {
|
|
324
379
|
runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxOpen: n.style });
|
|
325
380
|
}
|
|
@@ -329,6 +384,20 @@ function collectTextRuns(node) {
|
|
|
329
384
|
if (hasHorizSpacing) {
|
|
330
385
|
runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxClose: n.style });
|
|
331
386
|
}
|
|
387
|
+
if (overrideRtl && runs.length > overrideStart) {
|
|
388
|
+
const seg = runs.splice(overrideStart);
|
|
389
|
+
for (const r of seg) {
|
|
390
|
+
if (r.text) {
|
|
391
|
+
r.text = [...r.text].reverse().join('');
|
|
392
|
+
// The glyphs are now in visual (reversed) order, so render them
|
|
393
|
+
// left-to-right; otherwise renderText would right-anchor x and the
|
|
394
|
+
// LTR emission (which set x as the left edge) would misposition them.
|
|
395
|
+
r.style = { ...r.style, direction: 'ltr' };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
seg.reverse();
|
|
399
|
+
runs.push(...seg);
|
|
400
|
+
}
|
|
332
401
|
}
|
|
333
402
|
for (const child of node.children) {
|
|
334
403
|
walk(child);
|
|
@@ -432,8 +501,16 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
|
|
|
432
501
|
}
|
|
433
502
|
}
|
|
434
503
|
else {
|
|
435
|
-
// Split on whitespace but NOT on non-breaking spaces (\u00A0)
|
|
436
|
-
|
|
504
|
+
// Split on whitespace but NOT on non-breaking spaces (\u00A0).
|
|
505
|
+
// Then add a break opportunity AFTER "?" inside an otherwise-unbreakable
|
|
506
|
+
// token (the URL query delimiter): Chrome wraps "\u2026/q3?" | "lang=ar&\u2026"
|
|
507
|
+
// even with overflow-wrap:normal. It does NOT break at "/", "&", "=", "."
|
|
508
|
+
// or ":" (verified against the browser), so only "?" is split here. The
|
|
509
|
+
// "?" stays with the preceding fragment; a trailing "?" (no follower) is
|
|
510
|
+
// left intact. Fragments measure cumulatively so kerning stays accurate.
|
|
511
|
+
const words = text
|
|
512
|
+
.split(/([ \t\n\r\f\v]+)/)
|
|
513
|
+
.flatMap((w) => /^[ \t\n\r\f\v]+$/.test(w) ? [w] : w.split(/(?<=\?)(?=.)/));
|
|
437
514
|
// Use cumulative measurement to avoid rounding error accumulation
|
|
438
515
|
// within a single text run. When cumState is provided (from \u200B/\u00AD
|
|
439
516
|
// split), continue from the previous cumulative position to preserve
|
|
@@ -525,7 +602,7 @@ function tokenizeRuns(ctx, runs) {
|
|
|
525
602
|
// Must check before boxOpen/boxClose handlers since atomic has both set.
|
|
526
603
|
if (run.boxOpen && run.boxClose && run.text) {
|
|
527
604
|
applyFont(ctx, run.style);
|
|
528
|
-
ctx.letterSpacing = run.style.letterSpacing
|
|
605
|
+
ctx.letterSpacing = formatLetterSpacing(run.style.letterSpacing);
|
|
529
606
|
const text = applyTextTransform(run.text, run.style.textTransform);
|
|
530
607
|
const s = run.style;
|
|
531
608
|
const textWidth = cachedMeasureWidth(ctx, text);
|
|
@@ -558,8 +635,33 @@ function tokenizeRuns(ctx, runs) {
|
|
|
558
635
|
continue;
|
|
559
636
|
}
|
|
560
637
|
applyFont(ctx, run.style);
|
|
561
|
-
ctx.letterSpacing = run.style.letterSpacing
|
|
638
|
+
ctx.letterSpacing = formatLetterSpacing(run.style.letterSpacing);
|
|
562
639
|
const text = applyTextTransform(run.text, run.style.textTransform);
|
|
640
|
+
// Mark the first word produced from `startLen` as having no soft-wrap
|
|
641
|
+
// opportunity before it when it directly abuts real text from a previous
|
|
642
|
+
// run (adjacent inline elements with no whitespace between them). The
|
|
643
|
+
// preceding word must be actual text — not a space, newline, empty
|
|
644
|
+
// box-padding marker, or box edge — so a whitespace/padding boundary still
|
|
645
|
+
// allows a break.
|
|
646
|
+
const markGlue = (startLen) => {
|
|
647
|
+
const first = allWords[startLen];
|
|
648
|
+
if (!first || first.isSpace || !first.text || first.text === '\n')
|
|
649
|
+
return;
|
|
650
|
+
const prev = allWords[startLen - 1];
|
|
651
|
+
if (!prev || prev.isSpace || !prev.text.trim() ||
|
|
652
|
+
prev.boxOpen || prev.boxClose)
|
|
653
|
+
return;
|
|
654
|
+
// CJK and segmenter-driven scripts (Thai/Khmer/…) have break
|
|
655
|
+
// opportunities between characters regardless of element boundaries, so
|
|
656
|
+
// an element edge between them is NOT a no-break point. Only glue when
|
|
657
|
+
// both sides are ordinary (Latin-like) text with no intrinsic break.
|
|
658
|
+
const firstChar = [...first.text][0];
|
|
659
|
+
const prevChar = [...prev.text][prev.text.length - 1];
|
|
660
|
+
if (isCJK(firstChar) || isCJK(prevChar) ||
|
|
661
|
+
needsSegmenter(first.text) || needsSegmenter(prev.text))
|
|
662
|
+
return;
|
|
663
|
+
first.noBreakBefore = true;
|
|
664
|
+
};
|
|
563
665
|
// Handle explicit newlines (from <br> or pre-wrap) — always force line break
|
|
564
666
|
if (text.includes('\n')) {
|
|
565
667
|
const parts = text.split('\n');
|
|
@@ -568,12 +670,16 @@ function tokenizeRuns(ctx, runs) {
|
|
|
568
670
|
allWords.push({ text: '\n', width: 0, style: run.style, isSpace: false, boxStyle: run.boxStyle });
|
|
569
671
|
}
|
|
570
672
|
if (parts[i]) {
|
|
673
|
+
const startLen = allWords.length;
|
|
571
674
|
tokenizeString(ctx, parts[i], run, allWords);
|
|
675
|
+
markGlue(startLen);
|
|
572
676
|
}
|
|
573
677
|
}
|
|
574
678
|
}
|
|
575
679
|
else {
|
|
680
|
+
const startLen = allWords.length;
|
|
576
681
|
tokenizeString(ctx, text, run, allWords);
|
|
682
|
+
markGlue(startLen);
|
|
577
683
|
}
|
|
578
684
|
}
|
|
579
685
|
return allWords;
|
|
@@ -593,27 +699,78 @@ function isCJK(char) {
|
|
|
593
699
|
(code >= 0x20000 && code <= 0x2A6DF) // CJK Extension B
|
|
594
700
|
);
|
|
595
701
|
}
|
|
702
|
+
let _graphemeSegmenter;
|
|
703
|
+
function getGraphemeSegmenter() {
|
|
704
|
+
if (_graphemeSegmenter)
|
|
705
|
+
return _graphemeSegmenter;
|
|
706
|
+
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
|
707
|
+
_graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
708
|
+
return _graphemeSegmenter;
|
|
709
|
+
}
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
const EMOJI_PICTOGRAPHIC = /\p{Extended_Pictographic}/u;
|
|
596
713
|
/**
|
|
597
|
-
*
|
|
714
|
+
* Is this grapheme cluster an emoji that creates a line-break opportunity?
|
|
715
|
+
* Restricted to emoji-presentation clusters (emoji planes, regional-indicator
|
|
716
|
+
* flags, and ZWJ/VS16 sequences) so plain text symbols like ©/®/™ — which are
|
|
717
|
+
* Extended_Pictographic but render as text and do NOT break — are excluded.
|
|
718
|
+
*/
|
|
719
|
+
function isEmojiCluster(s) {
|
|
720
|
+
for (const ch of s) {
|
|
721
|
+
const cp = ch.codePointAt(0);
|
|
722
|
+
if (cp >= 0x1f000)
|
|
723
|
+
return true; // emoji planes (incl. regional indicators)
|
|
724
|
+
}
|
|
725
|
+
if (s.includes('\u200D') || s.includes('\uFE0F')) {
|
|
726
|
+
return EMOJI_PICTOGRAPHIC.test(s); // ZWJ sequence or VS16 emoji presentation
|
|
727
|
+
}
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Break a word into character-level pieces if it contains CJK/emoji or if
|
|
598
732
|
* overflow-wrap: break-word is set and the word is too wide.
|
|
599
733
|
*/
|
|
600
734
|
function breakWordIfNeeded(ctx, word, contentWidth, currentLineWidth) {
|
|
601
735
|
// Check if word has CJK characters — always break at character level
|
|
602
736
|
const hasCJK = [...word.text].some(isCJK);
|
|
737
|
+
// Emoji form their own break opportunities (a run of emoji wraps between
|
|
738
|
+
// clusters). Only meaningful when a grapheme segmenter is available so ZWJ
|
|
739
|
+
// sequences / skin-tone / flag pairs stay intact.
|
|
740
|
+
const hasEmoji = EMOJI_PICTOGRAPHIC.test(word.text) && !!getGraphemeSegmenter();
|
|
603
741
|
// Check if word needs break-word splitting — when it won't fit on a fresh line
|
|
604
742
|
const needsBreak = word.width > contentWidth &&
|
|
605
743
|
(word.style.overflowWrap === 'break-word' || word.style.wordBreak === 'break-all');
|
|
606
|
-
if (!hasCJK && !needsBreak)
|
|
744
|
+
if (!hasCJK && !hasEmoji && !needsBreak)
|
|
607
745
|
return [word];
|
|
608
746
|
// Split into characters using cumulative measurement for accuracy.
|
|
609
747
|
// Measuring each char individually ignores kerning — the sum of individual
|
|
610
748
|
// widths diverges from the true string width over many characters.
|
|
611
749
|
ctx.font = buildCanvasFont(word.style);
|
|
612
|
-
|
|
750
|
+
// Re-assert letter-spacing: tokenizeRuns may have left ctx at a later run's
|
|
751
|
+
// value, but break points must use THIS word's letter-spacing.
|
|
752
|
+
ctx.letterSpacing = formatLetterSpacing(word.style.letterSpacing);
|
|
753
|
+
// When the word contains emoji, iterate by GRAPHEME cluster so multi-codepoint
|
|
754
|
+
// emoji (ZWJ families, skin tones, flags) are never split mid-cluster.
|
|
755
|
+
const seg = hasEmoji ? getGraphemeSegmenter() : null;
|
|
756
|
+
const chars = seg
|
|
757
|
+
? [...seg.segment(word.text)].map((s) => s.segment)
|
|
758
|
+
: [...word.text];
|
|
613
759
|
const pieces = [];
|
|
614
760
|
let current = '';
|
|
615
761
|
let currentWidth = 0;
|
|
616
762
|
for (const char of chars) {
|
|
763
|
+
// Emoji clusters each get their own word — a break opportunity between
|
|
764
|
+
// adjacent emoji, matching the browser line breaker.
|
|
765
|
+
if (hasEmoji && isEmojiCluster(char)) {
|
|
766
|
+
if (current) {
|
|
767
|
+
pieces.push({ ...word, text: current, width: currentWidth });
|
|
768
|
+
current = '';
|
|
769
|
+
currentWidth = 0;
|
|
770
|
+
}
|
|
771
|
+
pieces.push({ ...word, text: char, width: cachedMeasureWidth(ctx, char) });
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
617
774
|
// CJK chars always get their own word for wrapping
|
|
618
775
|
if (isCJK(char)) {
|
|
619
776
|
if (current) {
|
|
@@ -643,6 +800,8 @@ function breakWordIfNeeded(ctx, word, contentWidth, currentLineWidth) {
|
|
|
643
800
|
}
|
|
644
801
|
return pieces;
|
|
645
802
|
}
|
|
803
|
+
/** Punctuation that cannot start a line — stays with the preceding word. */
|
|
804
|
+
const TRAILING_PUNCT = /^[,.\;:!?\)\]\}'"»›]+$/;
|
|
646
805
|
/**
|
|
647
806
|
* Flow words into lines that fit within contentWidth.
|
|
648
807
|
* Handles: word wrapping, nowrap, break-word, CJK character wrapping.
|
|
@@ -701,7 +860,8 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
701
860
|
currentLine = { words: [], totalWidth: 0, lineHeight: 0 };
|
|
702
861
|
}
|
|
703
862
|
let afterHardBreak = true; // start of content is like after a hard break
|
|
704
|
-
for (
|
|
863
|
+
for (let wordIndex = 0; wordIndex < words.length; wordIndex++) {
|
|
864
|
+
const word = words[wordIndex];
|
|
705
865
|
let wordLineHeight = getLineHeight(ctx, word.style, useBulletProbe);
|
|
706
866
|
// Inline-block elements expand line height with their vertical padding+margin
|
|
707
867
|
if (word.boxStyle && word.boxStyle.display === 'inline-block') {
|
|
@@ -734,17 +894,75 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
734
894
|
const pieces = (!word.isSpace && word.text.length > 1)
|
|
735
895
|
? breakWordIfNeeded(ctx, word, effWidth(), currentLine.totalWidth)
|
|
736
896
|
: [word];
|
|
897
|
+
// Glued tail: content immediately after this word that cannot start a new
|
|
898
|
+
// line — trailing punctuation (",.)]}…") and an inline span's right
|
|
899
|
+
// padding/border (empty boxClose markers). The browser includes it when
|
|
900
|
+
// deciding whether this word fits, so a word + its trailing "," / right
|
|
901
|
+
// padding wraps as a unit. Stops at whitespace or the next breakable word.
|
|
902
|
+
let gluedTailWidth = 0;
|
|
903
|
+
for (let j = wordIndex + 1; j < words.length; j++) {
|
|
904
|
+
const nw = words[j];
|
|
905
|
+
if (nw.isSpace || nw.text === '\n')
|
|
906
|
+
break;
|
|
907
|
+
const isPunct = !!nw.text && TRAILING_PUNCT.test(nw.text);
|
|
908
|
+
const isCloseMarker = !nw.text && !!nw.boxClose;
|
|
909
|
+
if (isPunct || isCloseMarker) {
|
|
910
|
+
gluedTailWidth += nw.width;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
737
915
|
for (const piece of pieces) {
|
|
916
|
+
const isLastPiece = piece === pieces[pieces.length - 1];
|
|
917
|
+
// Only the last piece of the word carries the glued tail.
|
|
918
|
+
const tail = isLastPiece ? gluedTailWidth : 0;
|
|
738
919
|
// Trailing punctuation (e.g. comma after </span>) should not wrap
|
|
739
920
|
// independently — browsers keep it with the preceding word.
|
|
740
921
|
const isTrailingPunct = !piece.isSpace && piece.text.length > 0 &&
|
|
741
|
-
|
|
922
|
+
TRAILING_PUNCT.test(piece.text) &&
|
|
742
923
|
currentLine.words.length > 0 &&
|
|
743
924
|
!currentLine.words[currentLine.words.length - 1].isSpace;
|
|
925
|
+
// A word that abuts the previous run with no whitespace has no soft-wrap
|
|
926
|
+
// opportunity before it — keep it with the preceding word like trailing
|
|
927
|
+
// punctuation. Only the FIRST piece carries the flag; a break-word split
|
|
928
|
+
// inside the word may still wrap mid-word.
|
|
929
|
+
const isGlued = piece === pieces[0] && piece.noBreakBefore &&
|
|
930
|
+
currentLine.words.length > 0 &&
|
|
931
|
+
!currentLine.words[currentLine.words.length - 1].isSpace;
|
|
932
|
+
// Leading inline padding/border (an empty boxOpen marker) must not be
|
|
933
|
+
// stranded at the end of a line — it belongs with the span's following
|
|
934
|
+
// content (CSS applies padding-left at the box's start). Include the next
|
|
935
|
+
// content word's width in this marker's fit test so the two wrap together
|
|
936
|
+
// and the left padding lands on the new line with the content.
|
|
937
|
+
let headExtra = 0;
|
|
938
|
+
if (!piece.text && piece.boxOpen) {
|
|
939
|
+
const next = words[wordIndex + 1];
|
|
940
|
+
if (next && !next.isSpace && next.text) {
|
|
941
|
+
// Only the next word's first BREAKABLE unit must stay with the leading
|
|
942
|
+
// padding — the whole word for unbreakable Latin, but just the first
|
|
943
|
+
// character for CJK / break-word (which wrap per character). Using the
|
|
944
|
+
// whole word here would over-wrap a long CJK run that follows padding.
|
|
945
|
+
const np = next.text.length > 1
|
|
946
|
+
? breakWordIfNeeded(ctx, next, effWidth(), 0)
|
|
947
|
+
: [next];
|
|
948
|
+
headExtra = np[0].width;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
// A soft-hyphen break point draws a visible '-' when the line breaks
|
|
952
|
+
// right after this piece. Chrome only allows a break there if the prefix
|
|
953
|
+
// PLUS the hyphen fits, so reserve the hyphen advance in the overflow
|
|
954
|
+
// test — otherwise we pack one extra segment and the appended hyphen
|
|
955
|
+
// overflows the line (breaking one segment later than the browser).
|
|
956
|
+
let shReserve = 0;
|
|
957
|
+
if (piece.isSoftHyphenBreak) {
|
|
958
|
+
applyFont(ctx, piece.style);
|
|
959
|
+
ctx.letterSpacing = formatLetterSpacing(piece.style.letterSpacing);
|
|
960
|
+
shReserve = cachedMeasureWidth(ctx, '-');
|
|
961
|
+
}
|
|
744
962
|
// Would this piece overflow?
|
|
745
|
-
if (!piece.isSpace && !isTrailingPunct && currentLine.words.length > 0 &&
|
|
746
|
-
currentLine.totalWidth + piece.width > effWidth()) {
|
|
747
|
-
const overflow = currentLine.totalWidth + piece.width - effWidth();
|
|
963
|
+
if (!piece.isSpace && !isTrailingPunct && !isGlued && currentLine.words.length > 0 &&
|
|
964
|
+
currentLine.totalWidth + piece.width + shReserve + tail + headExtra > effWidth()) {
|
|
965
|
+
const overflow = currentLine.totalWidth + piece.width + shReserve + tail + headExtra - effWidth();
|
|
748
966
|
// For borderline cases (overflow < 1px), word-by-word delta
|
|
749
967
|
// accumulation may introduce rounding errors. Re-measure the
|
|
750
968
|
// full candidate line as a single string for accuracy.
|
|
@@ -753,11 +971,23 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
753
971
|
let reallyOverflows = true;
|
|
754
972
|
if (overflow < 1 && !hasMixedFonts([...currentLine.words, piece])) {
|
|
755
973
|
applyFont(ctx, piece.style);
|
|
756
|
-
const fullText = currentLine.words.map(w => w.text).join('') + piece.text
|
|
757
|
-
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
|
|
974
|
+
const fullText = currentLine.words.map(w => w.text).join('') + piece.text +
|
|
975
|
+
(piece.isSoftHyphenBreak ? '-' : '');
|
|
976
|
+
// Empty-text words carry non-glyph advance (inline padding/border
|
|
977
|
+
// markers, inline-block margins) that measureText(fullText) misses —
|
|
978
|
+
// add them back so padded inline spans aren't under-measured.
|
|
979
|
+
let markerWidth = 0;
|
|
980
|
+
for (const w of currentLine.words)
|
|
981
|
+
if (!w.text)
|
|
982
|
+
markerWidth += w.width;
|
|
983
|
+
if (!piece.text)
|
|
984
|
+
markerWidth += piece.width;
|
|
985
|
+
const fullWidth = cachedMeasureWidth(ctx, fullText) + markerWidth + tail + headExtra;
|
|
986
|
+
// Allow only a hair of sub-pixel overflow. measureText matches the
|
|
987
|
+
// browser's rendered width to ~0.01px, so a larger slack would keep
|
|
988
|
+
// lines the browser actually wraps (packing one extra word per
|
|
989
|
+
// borderline line and drifting the whole document's breaks).
|
|
990
|
+
if (fullWidth <= effWidth() + 0.02) {
|
|
761
991
|
reallyOverflows = false;
|
|
762
992
|
}
|
|
763
993
|
}
|
|
@@ -765,7 +995,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
765
995
|
// try fitting a hyphen prefix on the current line. Browsers prefer
|
|
766
996
|
// keeping content on the current line by splitting at hyphens.
|
|
767
997
|
if (reallyOverflows && piece.text.includes('-')) {
|
|
768
|
-
const parts = piece.text.split(/(?<=-)/);
|
|
998
|
+
const parts = piece.text.split(/(?<=-)(?!\d)|(?<=[^\d]-)/);
|
|
769
999
|
if (parts.length > 1) {
|
|
770
1000
|
applyFont(ctx, piece.style);
|
|
771
1001
|
let fitted = '';
|
|
@@ -826,7 +1056,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
826
1056
|
// Hyphen break on a fresh line when word still too wide.
|
|
827
1057
|
if (currentLine.words.length === 0 && pieceWidth > effWidth() &&
|
|
828
1058
|
!piece.isSpace && piece.text.includes('-')) {
|
|
829
|
-
const subParts = piece.text.split(/(?<=-)/);
|
|
1059
|
+
const subParts = piece.text.split(/(?<=-)(?!\d)|(?<=[^\d]-)/);
|
|
830
1060
|
if (subParts.length > 1) {
|
|
831
1061
|
applyFont(ctx, piece.style);
|
|
832
1062
|
// Inject sub-parts as individual pieces — they'll flow through
|
|
@@ -944,16 +1174,34 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
944
1174
|
justifyExtraPerSpace = (lineMaxWidth - line.totalWidth) / spaceCount;
|
|
945
1175
|
}
|
|
946
1176
|
}
|
|
947
|
-
// text-align (with first-line indent baked into curX)
|
|
1177
|
+
// text-align (with first-line indent baked into curX).
|
|
1178
|
+
// When the line overflows its container, browsers fall back to start
|
|
1179
|
+
// alignment (per CSS Text 3 §7.1) instead of pushing the line outside
|
|
1180
|
+
// the box. Common trigger: wide letter-spacing on text that doesn't
|
|
1181
|
+
// wrap at letter boundaries (no break-word/break-all), where centering
|
|
1182
|
+
// would put glyphs at negative x. Sub-pixel tolerance avoids switching
|
|
1183
|
+
// to start for rounding noise on lines that visually fit.
|
|
1184
|
+
// Start edge differs by direction. LTR lines start at the left (x+indent).
|
|
1185
|
+
// RTL lines are anchored at the right, inset from the content's right edge
|
|
1186
|
+
// by text-indent — and lineMaxWidth already subtracts indent, so the RTL
|
|
1187
|
+
// right edge is x+lineMaxWidth. `align` here is physically resolved
|
|
1188
|
+
// (start/end → left/right via resolveDir), so RTL with align==='left'
|
|
1189
|
+
// (explicit left, or end) correctly falls through to left alignment.
|
|
1190
|
+
const overflows = line.totalWidth > lineMaxWidth + 0.5;
|
|
948
1191
|
let curX = x + indent;
|
|
949
|
-
if (
|
|
1192
|
+
if (overflows) {
|
|
1193
|
+
// Overflow fallback: pin to the start edge (CSS Text 3 §7.1).
|
|
1194
|
+
curX = isRTL ? x + lineMaxWidth - line.totalWidth : x + indent;
|
|
1195
|
+
}
|
|
1196
|
+
else if (align === 'center') {
|
|
950
1197
|
curX = x + indent + (lineMaxWidth - line.totalWidth) / 2;
|
|
951
1198
|
}
|
|
952
|
-
else if (align === 'right'
|
|
953
|
-
curX = x + indent + lineMaxWidth - line.totalWidth;
|
|
1199
|
+
else if (align === 'right') {
|
|
1200
|
+
curX = (isRTL ? x + lineMaxWidth : x + indent + lineMaxWidth) - line.totalWidth;
|
|
954
1201
|
}
|
|
955
|
-
else if (isRTL) {
|
|
956
|
-
|
|
1202
|
+
else if (align === 'justify' && isRTL) {
|
|
1203
|
+
// RTL justify: anchor the right edge at the inset start; spaces expand left.
|
|
1204
|
+
curX = x + lineMaxWidth - line.totalWidth;
|
|
957
1205
|
}
|
|
958
1206
|
// Snapshot the line's left edge before LTR emission advances curX.
|
|
959
1207
|
const lineLeftX = curX;
|
|
@@ -967,16 +1215,17 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
967
1215
|
for (const word of line.words) {
|
|
968
1216
|
if (word.text === '')
|
|
969
1217
|
continue;
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1218
|
+
// Off-baseline content (sub/sup/middle/lengths/...) does not establish
|
|
1219
|
+
// the line's baseline position — only baseline-aligned content does.
|
|
1220
|
+
if (isShiftedVAlign(word.style.verticalAlign))
|
|
1221
|
+
continue;
|
|
973
1222
|
const { ascent: a, descent: d } = getFontMetrics(ctx, word.style);
|
|
974
1223
|
if (a > maxAscent)
|
|
975
1224
|
maxAscent = a;
|
|
976
1225
|
if (d > maxDescent)
|
|
977
1226
|
maxDescent = d;
|
|
978
1227
|
}
|
|
979
|
-
// If only
|
|
1228
|
+
// If only off-baseline words on the line, use the first word's metrics
|
|
980
1229
|
if (maxAscent === 0) {
|
|
981
1230
|
for (const word of line.words) {
|
|
982
1231
|
if (word.text === '')
|
|
@@ -990,31 +1239,27 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
990
1239
|
// Center the text block (ascent + descent) within the lineHeight
|
|
991
1240
|
const textBlockHeight = maxAscent + maxDescent;
|
|
992
1241
|
let lineBaselineY = curY + (lineHeight - textBlockHeight) / 2 + maxAscent;
|
|
993
|
-
//
|
|
994
|
-
|
|
1242
|
+
// Parent font size for vertical-align positioning (used in expansion + text
|
|
1243
|
+
// emit) — the largest baseline-aligned font on the line.
|
|
1244
|
+
const lineNormalWords = line.words.filter(w => w.text !== '' && !isShiftedVAlign(w.style.verticalAlign));
|
|
995
1245
|
const parentFontSize = lineNormalWords.length > 0
|
|
996
1246
|
? Math.max(...lineNormalWords.map(w => w.style.fontSize)) : 0;
|
|
997
|
-
// Expand line height if
|
|
998
|
-
// Browsers grow the line box to fit all content, but keep
|
|
999
|
-
//
|
|
1247
|
+
// Expand line height if vertically-shifted content extends beyond the line
|
|
1248
|
+
// box. Browsers grow the line box to fit all content, but keep the normal
|
|
1249
|
+
// text baseline position unchanged.
|
|
1000
1250
|
let lineTop = curY;
|
|
1001
1251
|
let lineBottom = curY + lineHeight;
|
|
1002
1252
|
for (const word of line.words) {
|
|
1003
1253
|
if (word.text === '')
|
|
1004
1254
|
continue;
|
|
1005
1255
|
const va = word.style.verticalAlign;
|
|
1006
|
-
if (va
|
|
1256
|
+
if (!isShiftedVAlign(va))
|
|
1007
1257
|
continue;
|
|
1008
1258
|
if (parentFontSize === 0)
|
|
1009
1259
|
break;
|
|
1010
1260
|
const { ascent: wAscent, descent: wDescent } = getFontMetrics(ctx, word.style);
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
shiftedBaseline -= parentFontSize * 0.4;
|
|
1014
|
-
}
|
|
1015
|
-
else {
|
|
1016
|
-
shiftedBaseline += parentFontSize * 0.26;
|
|
1017
|
-
}
|
|
1261
|
+
const shiftedBaseline = lineBaselineY +
|
|
1262
|
+
verticalAlignShift(va, wAscent, wDescent, parentFontSize, maxAscent, maxDescent, lineHeight);
|
|
1018
1263
|
const wordTop = shiftedBaseline - wAscent;
|
|
1019
1264
|
const wordBottom = shiftedBaseline + wDescent;
|
|
1020
1265
|
if (wordTop < lineTop)
|
|
@@ -1101,6 +1346,19 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1101
1346
|
pendingPad += word.width;
|
|
1102
1347
|
continue;
|
|
1103
1348
|
}
|
|
1349
|
+
if (word.isSpace && justifyExtraPerSpace > 0) {
|
|
1350
|
+
// Justify only: break the shaping group at the space and fold the
|
|
1351
|
+
// expansion into the inter-group advance so the line fills the width.
|
|
1352
|
+
// (Arabic does not join across spaces, so this is shaping-safe.)
|
|
1353
|
+
// When not justifying, spaces stay merged into the group text below
|
|
1354
|
+
// so the canvas BiDi engine can reorder embedded LTR runs/numbers.
|
|
1355
|
+
if (currentGroup) {
|
|
1356
|
+
groups.push(currentGroup);
|
|
1357
|
+
currentGroup = null;
|
|
1358
|
+
}
|
|
1359
|
+
pendingPad += word.width + justifyExtraPerSpace;
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1104
1362
|
if (currentGroup && sameTextStyle(currentGroup.style, word.style)) {
|
|
1105
1363
|
currentGroup.text += word.text;
|
|
1106
1364
|
currentGroup.width += word.width;
|
|
@@ -1159,13 +1417,20 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1159
1417
|
if (hasBidiMix) {
|
|
1160
1418
|
applyFont(ctx, textWords[0].style);
|
|
1161
1419
|
const measuredWidth = cachedMeasureWidth(ctx, lineText);
|
|
1420
|
+
// This line belongs to an LTR block (we're in the !isRTL branch), so it
|
|
1421
|
+
// must be painted with an LTR base direction even when its first word is
|
|
1422
|
+
// RTL (an RTL span that wrapped onto this line). Without forcing LTR the
|
|
1423
|
+
// node inherits the first word's direction:'rtl' and the paint path
|
|
1424
|
+
// right-aligns the whole line at the left edge (x=curX), drawing it
|
|
1425
|
+
// off-screen. The canvas BiDi engine still reorders the embedded
|
|
1426
|
+
// Arabic/Hebrew runs within the LTR line.
|
|
1162
1427
|
results.push({
|
|
1163
1428
|
type: 'text',
|
|
1164
1429
|
text: lineText,
|
|
1165
1430
|
x: curX,
|
|
1166
1431
|
y: lineBaselineY,
|
|
1167
1432
|
width: measuredWidth,
|
|
1168
|
-
style: textWords[0].style,
|
|
1433
|
+
style: { ...textWords[0].style, direction: 'ltr' },
|
|
1169
1434
|
});
|
|
1170
1435
|
}
|
|
1171
1436
|
else {
|
|
@@ -1193,14 +1458,10 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1193
1458
|
// Adjust baseline for vertical-align
|
|
1194
1459
|
let baselineY = lineBaselineY;
|
|
1195
1460
|
const va = word.style.verticalAlign;
|
|
1196
|
-
if (va
|
|
1461
|
+
if (isShiftedVAlign(va)) {
|
|
1197
1462
|
const pfs = parentFontSize || word.style.fontSize;
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
}
|
|
1201
|
-
else {
|
|
1202
|
-
baselineY += pfs * 0.26;
|
|
1203
|
-
}
|
|
1463
|
+
const { ascent: wA, descent: wD } = getFontMetrics(ctx, word.style);
|
|
1464
|
+
baselineY += verticalAlignShift(va, wA, wD, pfs, maxAscent, maxDescent, lineHeight);
|
|
1204
1465
|
}
|
|
1205
1466
|
const effectiveWidth = word.width + (word.isSpace ? justifyExtraPerSpace : 0);
|
|
1206
1467
|
results.push({
|