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/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,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
- /^[,.\;:!?\)\]\}'"»›]+$/.test(piece.text) &&
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
- 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) {
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
- curX = isRTL ? x + indent + lineMaxWidth - line.totalWidth : x + indent;
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' || (align !== 'justify' && isRTL)) {
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
- curX = x + indent + lineMaxWidth - line.totalWidth;
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
- const va = word.style.verticalAlign;
981
- if (va === 'super' || va === 'sub')
982
- continue; // skip sub/sup for baseline calc
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 sub/sup words on the line, use the first word's metrics
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
- // 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');
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 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.
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 !== 'super' && va !== 'sub')
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
- let shiftedBaseline = lineBaselineY;
1022
- if (va === 'super') {
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 === 'super' || va === 'sub') {
1466
+ if (isShiftedVAlign(va)) {
1207
1467
  const pfs = parentFontSize || word.style.fontSize;
1208
- if (va === 'super') {
1209
- baselineY -= pfs * 0.4;
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({