render-tag 0.1.10 → 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/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
- const key = ctx.font + '\0' + text;
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
- case 'capitalize': return text.replace(/(^|[\s\p{P}])(\p{L})/gu, (_, p, c) => p + c.toUpperCase());
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
- const words = text.split(/([ \t\n\r\f\v]+)/);
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 > 0 ? `${run.style.letterSpacing}px` : '0px';
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 > 0 ? `${run.style.letterSpacing}px` : '0px';
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
- * Break a word into character-level pieces if it contains CJK or if
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
- const chars = [...word.text];
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 (const word of words) {
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
- /^[,.\;:!?\)\]\}'"»›]+$/.test(piece.text) &&
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
- const fullWidth = cachedMeasureWidth(ctx, fullText);
758
- // Allow tiny sub-pixel overflow canvas measureText and DOM
759
- // text layout can differ by fractions of a pixel.
760
- if (fullWidth <= effWidth() + 0.1) {
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
@@ -951,19 +1181,27 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
951
1181
  // wrap at letter boundaries (no break-word/break-all), where centering
952
1182
  // would put glyphs at negative x. Sub-pixel tolerance avoids switching
953
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.
954
1190
  const overflows = line.totalWidth > lineMaxWidth + 0.5;
955
1191
  let curX = x + indent;
956
1192
  if (overflows) {
957
- curX = isRTL ? x + indent + lineMaxWidth - line.totalWidth : x + indent;
1193
+ // Overflow fallback: pin to the start edge (CSS Text 3 §7.1).
1194
+ curX = isRTL ? x + lineMaxWidth - line.totalWidth : x + indent;
958
1195
  }
959
1196
  else if (align === 'center') {
960
1197
  curX = x + indent + (lineMaxWidth - line.totalWidth) / 2;
961
1198
  }
962
- else if (align === 'right' || (align !== 'justify' && isRTL)) {
963
- curX = x + indent + lineMaxWidth - line.totalWidth;
1199
+ else if (align === 'right') {
1200
+ curX = (isRTL ? x + lineMaxWidth : x + indent + lineMaxWidth) - line.totalWidth;
964
1201
  }
965
- else if (isRTL) {
966
- curX = x + indent + lineMaxWidth - line.totalWidth;
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;
967
1205
  }
968
1206
  // Snapshot the line's left edge before LTR emission advances curX.
969
1207
  const lineLeftX = curX;
@@ -977,16 +1215,17 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
977
1215
  for (const word of line.words) {
978
1216
  if (word.text === '')
979
1217
  continue;
980
- const va = word.style.verticalAlign;
981
- if (va === 'super' || va === 'sub')
982
- continue; // skip sub/sup for baseline calc
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;
983
1222
  const { ascent: a, descent: d } = getFontMetrics(ctx, word.style);
984
1223
  if (a > maxAscent)
985
1224
  maxAscent = a;
986
1225
  if (d > maxDescent)
987
1226
  maxDescent = d;
988
1227
  }
989
- // If only sub/sup words on the line, use the first word's metrics
1228
+ // If only off-baseline words on the line, use the first word's metrics
990
1229
  if (maxAscent === 0) {
991
1230
  for (const word of line.words) {
992
1231
  if (word.text === '')
@@ -1000,31 +1239,27 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1000
1239
  // Center the text block (ascent + descent) within the lineHeight
1001
1240
  const textBlockHeight = maxAscent + maxDescent;
1002
1241
  let lineBaselineY = curY + (lineHeight - textBlockHeight) / 2 + maxAscent;
1003
- // Compute parent font size for sub/sup positioning (used in expansion + text emit)
1004
- const lineNormalWords = line.words.filter(w => w.text !== '' && w.style.verticalAlign !== 'super' && w.style.verticalAlign !== 'sub');
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));
1005
1245
  const parentFontSize = lineNormalWords.length > 0
1006
1246
  ? Math.max(...lineNormalWords.map(w => w.style.fontSize)) : 0;
1007
- // Expand line height if sub/sup extends beyond the line box.
1008
- // Browsers grow the line box to fit all content, but keep
1009
- // the normal text baseline position unchanged.
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.
1010
1250
  let lineTop = curY;
1011
1251
  let lineBottom = curY + lineHeight;
1012
1252
  for (const word of line.words) {
1013
1253
  if (word.text === '')
1014
1254
  continue;
1015
1255
  const va = word.style.verticalAlign;
1016
- if (va !== 'super' && va !== 'sub')
1256
+ if (!isShiftedVAlign(va))
1017
1257
  continue;
1018
1258
  if (parentFontSize === 0)
1019
1259
  break;
1020
1260
  const { ascent: wAscent, descent: wDescent } = getFontMetrics(ctx, word.style);
1021
- let shiftedBaseline = lineBaselineY;
1022
- if (va === 'super') {
1023
- shiftedBaseline -= parentFontSize * 0.4;
1024
- }
1025
- else {
1026
- shiftedBaseline += parentFontSize * 0.26;
1027
- }
1261
+ const shiftedBaseline = lineBaselineY +
1262
+ verticalAlignShift(va, wAscent, wDescent, parentFontSize, maxAscent, maxDescent, lineHeight);
1028
1263
  const wordTop = shiftedBaseline - wAscent;
1029
1264
  const wordBottom = shiftedBaseline + wDescent;
1030
1265
  if (wordTop < lineTop)
@@ -1111,6 +1346,19 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1111
1346
  pendingPad += word.width;
1112
1347
  continue;
1113
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
+ }
1114
1362
  if (currentGroup && sameTextStyle(currentGroup.style, word.style)) {
1115
1363
  currentGroup.text += word.text;
1116
1364
  currentGroup.width += word.width;
@@ -1169,13 +1417,20 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1169
1417
  if (hasBidiMix) {
1170
1418
  applyFont(ctx, textWords[0].style);
1171
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.
1172
1427
  results.push({
1173
1428
  type: 'text',
1174
1429
  text: lineText,
1175
1430
  x: curX,
1176
1431
  y: lineBaselineY,
1177
1432
  width: measuredWidth,
1178
- style: textWords[0].style,
1433
+ style: { ...textWords[0].style, direction: 'ltr' },
1179
1434
  });
1180
1435
  }
1181
1436
  else {
@@ -1203,14 +1458,10 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1203
1458
  // Adjust baseline for vertical-align
1204
1459
  let baselineY = lineBaselineY;
1205
1460
  const va = word.style.verticalAlign;
1206
- if (va === 'super' || va === 'sub') {
1461
+ if (isShiftedVAlign(va)) {
1207
1462
  const pfs = parentFontSize || word.style.fontSize;
1208
- if (va === 'super') {
1209
- baselineY -= pfs * 0.4;
1210
- }
1211
- else {
1212
- baselineY += pfs * 0.26;
1213
- }
1463
+ const { ascent: wA, descent: wD } = getFontMetrics(ctx, word.style);
1464
+ baselineY += verticalAlignShift(va, wA, wD, pfs, maxAscent, maxDescent, lineHeight);
1214
1465
  }
1215
1466
  const effectiveWidth = word.width + (word.isSpace ? justifyExtraPerSpace : 0);
1216
1467
  results.push({