render-tag 0.1.10 → 0.1.12
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 +309 -53
- 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,80 @@ 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 (",.)]}…"), an inline span's right
|
|
899
|
+
// padding/border (empty boxClose markers), and a word continuation that
|
|
900
|
+
// abuts this word across a run boundary with no soft-wrap opportunity
|
|
901
|
+
// (noBreakBefore — e.g. one word split across two inline spans with
|
|
902
|
+
// different font sizes). The browser includes all of it when deciding
|
|
903
|
+
// whether this word fits, so the unit wraps together: if "Music Experie"
|
|
904
|
+
// doesn't leave room for the glued "nce", the whole word wraps as one.
|
|
905
|
+
// Stops at whitespace or the next breakable word.
|
|
906
|
+
let gluedTailWidth = 0;
|
|
907
|
+
for (let j = wordIndex + 1; j < words.length; j++) {
|
|
908
|
+
const nw = words[j];
|
|
909
|
+
if (nw.isSpace || nw.text === '\n')
|
|
910
|
+
break;
|
|
911
|
+
const isPunct = !!nw.text && TRAILING_PUNCT.test(nw.text);
|
|
912
|
+
const isCloseMarker = !nw.text && !!nw.boxClose;
|
|
913
|
+
const isGluedCont = !!nw.text && !!nw.noBreakBefore;
|
|
914
|
+
if (isPunct || isCloseMarker || isGluedCont) {
|
|
915
|
+
gluedTailWidth += nw.width;
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
break;
|
|
919
|
+
}
|
|
737
920
|
for (const piece of pieces) {
|
|
921
|
+
const isLastPiece = piece === pieces[pieces.length - 1];
|
|
922
|
+
// Only the last piece of the word carries the glued tail.
|
|
923
|
+
const tail = isLastPiece ? gluedTailWidth : 0;
|
|
738
924
|
// Trailing punctuation (e.g. comma after </span>) should not wrap
|
|
739
925
|
// independently — browsers keep it with the preceding word.
|
|
740
926
|
const isTrailingPunct = !piece.isSpace && piece.text.length > 0 &&
|
|
741
|
-
|
|
927
|
+
TRAILING_PUNCT.test(piece.text) &&
|
|
742
928
|
currentLine.words.length > 0 &&
|
|
743
929
|
!currentLine.words[currentLine.words.length - 1].isSpace;
|
|
930
|
+
// A word that abuts the previous run with no whitespace has no soft-wrap
|
|
931
|
+
// opportunity before it — keep it with the preceding word like trailing
|
|
932
|
+
// punctuation. Only the FIRST piece carries the flag; a break-word split
|
|
933
|
+
// inside the word may still wrap mid-word.
|
|
934
|
+
const isGlued = piece === pieces[0] && piece.noBreakBefore &&
|
|
935
|
+
currentLine.words.length > 0 &&
|
|
936
|
+
!currentLine.words[currentLine.words.length - 1].isSpace;
|
|
937
|
+
// Leading inline padding/border (an empty boxOpen marker) must not be
|
|
938
|
+
// stranded at the end of a line — it belongs with the span's following
|
|
939
|
+
// content (CSS applies padding-left at the box's start). Include the next
|
|
940
|
+
// content word's width in this marker's fit test so the two wrap together
|
|
941
|
+
// and the left padding lands on the new line with the content.
|
|
942
|
+
let headExtra = 0;
|
|
943
|
+
if (!piece.text && piece.boxOpen) {
|
|
944
|
+
const next = words[wordIndex + 1];
|
|
945
|
+
if (next && !next.isSpace && next.text) {
|
|
946
|
+
// Only the next word's first BREAKABLE unit must stay with the leading
|
|
947
|
+
// padding — the whole word for unbreakable Latin, but just the first
|
|
948
|
+
// character for CJK / break-word (which wrap per character). Using the
|
|
949
|
+
// whole word here would over-wrap a long CJK run that follows padding.
|
|
950
|
+
const np = next.text.length > 1
|
|
951
|
+
? breakWordIfNeeded(ctx, next, effWidth(), 0)
|
|
952
|
+
: [next];
|
|
953
|
+
headExtra = np[0].width;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
// A soft-hyphen break point draws a visible '-' when the line breaks
|
|
957
|
+
// right after this piece. Chrome only allows a break there if the prefix
|
|
958
|
+
// PLUS the hyphen fits, so reserve the hyphen advance in the overflow
|
|
959
|
+
// test — otherwise we pack one extra segment and the appended hyphen
|
|
960
|
+
// overflows the line (breaking one segment later than the browser).
|
|
961
|
+
let shReserve = 0;
|
|
962
|
+
if (piece.isSoftHyphenBreak) {
|
|
963
|
+
applyFont(ctx, piece.style);
|
|
964
|
+
ctx.letterSpacing = formatLetterSpacing(piece.style.letterSpacing);
|
|
965
|
+
shReserve = cachedMeasureWidth(ctx, '-');
|
|
966
|
+
}
|
|
744
967
|
// 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();
|
|
968
|
+
if (!piece.isSpace && !isTrailingPunct && !isGlued && currentLine.words.length > 0 &&
|
|
969
|
+
currentLine.totalWidth + piece.width + shReserve + tail + headExtra > effWidth()) {
|
|
970
|
+
const overflow = currentLine.totalWidth + piece.width + shReserve + tail + headExtra - effWidth();
|
|
748
971
|
// For borderline cases (overflow < 1px), word-by-word delta
|
|
749
972
|
// accumulation may introduce rounding errors. Re-measure the
|
|
750
973
|
// full candidate line as a single string for accuracy.
|
|
@@ -753,11 +976,23 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
753
976
|
let reallyOverflows = true;
|
|
754
977
|
if (overflow < 1 && !hasMixedFonts([...currentLine.words, piece])) {
|
|
755
978
|
applyFont(ctx, piece.style);
|
|
756
|
-
const fullText = currentLine.words.map(w => w.text).join('') + piece.text
|
|
757
|
-
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
|
|
979
|
+
const fullText = currentLine.words.map(w => w.text).join('') + piece.text +
|
|
980
|
+
(piece.isSoftHyphenBreak ? '-' : '');
|
|
981
|
+
// Empty-text words carry non-glyph advance (inline padding/border
|
|
982
|
+
// markers, inline-block margins) that measureText(fullText) misses —
|
|
983
|
+
// add them back so padded inline spans aren't under-measured.
|
|
984
|
+
let markerWidth = 0;
|
|
985
|
+
for (const w of currentLine.words)
|
|
986
|
+
if (!w.text)
|
|
987
|
+
markerWidth += w.width;
|
|
988
|
+
if (!piece.text)
|
|
989
|
+
markerWidth += piece.width;
|
|
990
|
+
const fullWidth = cachedMeasureWidth(ctx, fullText) + markerWidth + tail + headExtra;
|
|
991
|
+
// Allow only a hair of sub-pixel overflow. measureText matches the
|
|
992
|
+
// browser's rendered width to ~0.01px, so a larger slack would keep
|
|
993
|
+
// lines the browser actually wraps (packing one extra word per
|
|
994
|
+
// borderline line and drifting the whole document's breaks).
|
|
995
|
+
if (fullWidth <= effWidth() + 0.02) {
|
|
761
996
|
reallyOverflows = false;
|
|
762
997
|
}
|
|
763
998
|
}
|
|
@@ -765,7 +1000,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
765
1000
|
// try fitting a hyphen prefix on the current line. Browsers prefer
|
|
766
1001
|
// keeping content on the current line by splitting at hyphens.
|
|
767
1002
|
if (reallyOverflows && piece.text.includes('-')) {
|
|
768
|
-
const parts = piece.text.split(/(?<=-)/);
|
|
1003
|
+
const parts = piece.text.split(/(?<=-)(?!\d)|(?<=[^\d]-)/);
|
|
769
1004
|
if (parts.length > 1) {
|
|
770
1005
|
applyFont(ctx, piece.style);
|
|
771
1006
|
let fitted = '';
|
|
@@ -826,7 +1061,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
826
1061
|
// Hyphen break on a fresh line when word still too wide.
|
|
827
1062
|
if (currentLine.words.length === 0 && pieceWidth > effWidth() &&
|
|
828
1063
|
!piece.isSpace && piece.text.includes('-')) {
|
|
829
|
-
const subParts = piece.text.split(/(?<=-)/);
|
|
1064
|
+
const subParts = piece.text.split(/(?<=-)(?!\d)|(?<=[^\d]-)/);
|
|
830
1065
|
if (subParts.length > 1) {
|
|
831
1066
|
applyFont(ctx, piece.style);
|
|
832
1067
|
// Inject sub-parts as individual pieces — they'll flow through
|
|
@@ -951,19 +1186,27 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
951
1186
|
// wrap at letter boundaries (no break-word/break-all), where centering
|
|
952
1187
|
// would put glyphs at negative x. Sub-pixel tolerance avoids switching
|
|
953
1188
|
// to start for rounding noise on lines that visually fit.
|
|
1189
|
+
// Start edge differs by direction. LTR lines start at the left (x+indent).
|
|
1190
|
+
// RTL lines are anchored at the right, inset from the content's right edge
|
|
1191
|
+
// by text-indent — and lineMaxWidth already subtracts indent, so the RTL
|
|
1192
|
+
// right edge is x+lineMaxWidth. `align` here is physically resolved
|
|
1193
|
+
// (start/end → left/right via resolveDir), so RTL with align==='left'
|
|
1194
|
+
// (explicit left, or end) correctly falls through to left alignment.
|
|
954
1195
|
const overflows = line.totalWidth > lineMaxWidth + 0.5;
|
|
955
1196
|
let curX = x + indent;
|
|
956
1197
|
if (overflows) {
|
|
957
|
-
|
|
1198
|
+
// Overflow fallback: pin to the start edge (CSS Text 3 §7.1).
|
|
1199
|
+
curX = isRTL ? x + lineMaxWidth - line.totalWidth : x + indent;
|
|
958
1200
|
}
|
|
959
1201
|
else if (align === 'center') {
|
|
960
1202
|
curX = x + indent + (lineMaxWidth - line.totalWidth) / 2;
|
|
961
1203
|
}
|
|
962
|
-
else if (align === 'right'
|
|
963
|
-
curX = x + indent + lineMaxWidth - line.totalWidth;
|
|
1204
|
+
else if (align === 'right') {
|
|
1205
|
+
curX = (isRTL ? x + lineMaxWidth : x + indent + lineMaxWidth) - line.totalWidth;
|
|
964
1206
|
}
|
|
965
|
-
else if (isRTL) {
|
|
966
|
-
|
|
1207
|
+
else if (align === 'justify' && isRTL) {
|
|
1208
|
+
// RTL justify: anchor the right edge at the inset start; spaces expand left.
|
|
1209
|
+
curX = x + lineMaxWidth - line.totalWidth;
|
|
967
1210
|
}
|
|
968
1211
|
// Snapshot the line's left edge before LTR emission advances curX.
|
|
969
1212
|
const lineLeftX = curX;
|
|
@@ -977,16 +1220,17 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
977
1220
|
for (const word of line.words) {
|
|
978
1221
|
if (word.text === '')
|
|
979
1222
|
continue;
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1223
|
+
// Off-baseline content (sub/sup/middle/lengths/...) does not establish
|
|
1224
|
+
// the line's baseline position — only baseline-aligned content does.
|
|
1225
|
+
if (isShiftedVAlign(word.style.verticalAlign))
|
|
1226
|
+
continue;
|
|
983
1227
|
const { ascent: a, descent: d } = getFontMetrics(ctx, word.style);
|
|
984
1228
|
if (a > maxAscent)
|
|
985
1229
|
maxAscent = a;
|
|
986
1230
|
if (d > maxDescent)
|
|
987
1231
|
maxDescent = d;
|
|
988
1232
|
}
|
|
989
|
-
// If only
|
|
1233
|
+
// If only off-baseline words on the line, use the first word's metrics
|
|
990
1234
|
if (maxAscent === 0) {
|
|
991
1235
|
for (const word of line.words) {
|
|
992
1236
|
if (word.text === '')
|
|
@@ -1000,31 +1244,27 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1000
1244
|
// Center the text block (ascent + descent) within the lineHeight
|
|
1001
1245
|
const textBlockHeight = maxAscent + maxDescent;
|
|
1002
1246
|
let lineBaselineY = curY + (lineHeight - textBlockHeight) / 2 + maxAscent;
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1247
|
+
// Parent font size for vertical-align positioning (used in expansion + text
|
|
1248
|
+
// emit) — the largest baseline-aligned font on the line.
|
|
1249
|
+
const lineNormalWords = line.words.filter(w => w.text !== '' && !isShiftedVAlign(w.style.verticalAlign));
|
|
1005
1250
|
const parentFontSize = lineNormalWords.length > 0
|
|
1006
1251
|
? Math.max(...lineNormalWords.map(w => w.style.fontSize)) : 0;
|
|
1007
|
-
// Expand line height if
|
|
1008
|
-
// Browsers grow the line box to fit all content, but keep
|
|
1009
|
-
//
|
|
1252
|
+
// Expand line height if vertically-shifted content extends beyond the line
|
|
1253
|
+
// box. Browsers grow the line box to fit all content, but keep the normal
|
|
1254
|
+
// text baseline position unchanged.
|
|
1010
1255
|
let lineTop = curY;
|
|
1011
1256
|
let lineBottom = curY + lineHeight;
|
|
1012
1257
|
for (const word of line.words) {
|
|
1013
1258
|
if (word.text === '')
|
|
1014
1259
|
continue;
|
|
1015
1260
|
const va = word.style.verticalAlign;
|
|
1016
|
-
if (va
|
|
1261
|
+
if (!isShiftedVAlign(va))
|
|
1017
1262
|
continue;
|
|
1018
1263
|
if (parentFontSize === 0)
|
|
1019
1264
|
break;
|
|
1020
1265
|
const { ascent: wAscent, descent: wDescent } = getFontMetrics(ctx, word.style);
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
shiftedBaseline -= parentFontSize * 0.4;
|
|
1024
|
-
}
|
|
1025
|
-
else {
|
|
1026
|
-
shiftedBaseline += parentFontSize * 0.26;
|
|
1027
|
-
}
|
|
1266
|
+
const shiftedBaseline = lineBaselineY +
|
|
1267
|
+
verticalAlignShift(va, wAscent, wDescent, parentFontSize, maxAscent, maxDescent, lineHeight);
|
|
1028
1268
|
const wordTop = shiftedBaseline - wAscent;
|
|
1029
1269
|
const wordBottom = shiftedBaseline + wDescent;
|
|
1030
1270
|
if (wordTop < lineTop)
|
|
@@ -1111,6 +1351,19 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1111
1351
|
pendingPad += word.width;
|
|
1112
1352
|
continue;
|
|
1113
1353
|
}
|
|
1354
|
+
if (word.isSpace && justifyExtraPerSpace > 0) {
|
|
1355
|
+
// Justify only: break the shaping group at the space and fold the
|
|
1356
|
+
// expansion into the inter-group advance so the line fills the width.
|
|
1357
|
+
// (Arabic does not join across spaces, so this is shaping-safe.)
|
|
1358
|
+
// When not justifying, spaces stay merged into the group text below
|
|
1359
|
+
// so the canvas BiDi engine can reorder embedded LTR runs/numbers.
|
|
1360
|
+
if (currentGroup) {
|
|
1361
|
+
groups.push(currentGroup);
|
|
1362
|
+
currentGroup = null;
|
|
1363
|
+
}
|
|
1364
|
+
pendingPad += word.width + justifyExtraPerSpace;
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1114
1367
|
if (currentGroup && sameTextStyle(currentGroup.style, word.style)) {
|
|
1115
1368
|
currentGroup.text += word.text;
|
|
1116
1369
|
currentGroup.width += word.width;
|
|
@@ -1169,13 +1422,20 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1169
1422
|
if (hasBidiMix) {
|
|
1170
1423
|
applyFont(ctx, textWords[0].style);
|
|
1171
1424
|
const measuredWidth = cachedMeasureWidth(ctx, lineText);
|
|
1425
|
+
// This line belongs to an LTR block (we're in the !isRTL branch), so it
|
|
1426
|
+
// must be painted with an LTR base direction even when its first word is
|
|
1427
|
+
// RTL (an RTL span that wrapped onto this line). Without forcing LTR the
|
|
1428
|
+
// node inherits the first word's direction:'rtl' and the paint path
|
|
1429
|
+
// right-aligns the whole line at the left edge (x=curX), drawing it
|
|
1430
|
+
// off-screen. The canvas BiDi engine still reorders the embedded
|
|
1431
|
+
// Arabic/Hebrew runs within the LTR line.
|
|
1172
1432
|
results.push({
|
|
1173
1433
|
type: 'text',
|
|
1174
1434
|
text: lineText,
|
|
1175
1435
|
x: curX,
|
|
1176
1436
|
y: lineBaselineY,
|
|
1177
1437
|
width: measuredWidth,
|
|
1178
|
-
style: textWords[0].style,
|
|
1438
|
+
style: { ...textWords[0].style, direction: 'ltr' },
|
|
1179
1439
|
});
|
|
1180
1440
|
}
|
|
1181
1441
|
else {
|
|
@@ -1203,14 +1463,10 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1203
1463
|
// Adjust baseline for vertical-align
|
|
1204
1464
|
let baselineY = lineBaselineY;
|
|
1205
1465
|
const va = word.style.verticalAlign;
|
|
1206
|
-
if (va
|
|
1466
|
+
if (isShiftedVAlign(va)) {
|
|
1207
1467
|
const pfs = parentFontSize || word.style.fontSize;
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
}
|
|
1211
|
-
else {
|
|
1212
|
-
baselineY += pfs * 0.26;
|
|
1213
|
-
}
|
|
1468
|
+
const { ascent: wA, descent: wD } = getFontMetrics(ctx, word.style);
|
|
1469
|
+
baselineY += verticalAlignShift(va, wA, wD, pfs, maxAscent, maxDescent, lineHeight);
|
|
1214
1470
|
}
|
|
1215
1471
|
const effectiveWidth = word.width + (word.isSpace ? justifyExtraPerSpace : 0);
|
|
1216
1472
|
results.push({
|