render-tag 0.1.23 → 0.1.25

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.d.ts CHANGED
@@ -9,6 +9,10 @@ export declare function getFontMetrics(ctx: CanvasRenderingContext2D, style: Res
9
9
  ascent: number;
10
10
  descent: number;
11
11
  };
12
+ /** True for an element declaring `background-clip:text` with a visible
13
+ * background (gradient image or solid color) — the fill/decorations of every
14
+ * glyph it covers must sample that background instead of painting it as a box. */
15
+ export declare function hasTextClip(style: ResolvedStyle): boolean;
12
16
  /**
13
17
  * Build the layout tree from the styled tree using pure canvas measurement.
14
18
  * No DOM measurements used — all positions computed from CSS values + canvas.measureText.
@@ -1 +1 @@
1
- {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,SAAS,EAAc,aAAa,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AA4C3G;;GAEG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,wBAAwB,EAAE,KAAK,EAAE,aAAa,GAAG,IAAI,CAGnF;AAgBD,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAc5D;AA8HD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpD;AAMD,wBAAgB,cAAc,CAAC,GAAG,EAAE,wBAAwB,EAAE,KAAK,EAAE,aAAa,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAWvH;AAyjED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,wBAAwB,EAC7B,UAAU,EAAE,UAAU,EACtB,cAAc,EAAE,MAAM,EACtB,kBAAkB,UAAO,EACzB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,YAAY,EAAE,UAAU,KAAK,IAAI,GACvD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,UAAU,EAAE,CAAA;CAAE,CAmD1D"}
1
+ {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,SAAS,EAAc,aAAa,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AA4C3G;;GAEG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,wBAAwB,EAAE,KAAK,EAAE,aAAa,GAAG,IAAI,CAGnF;AAgBD,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAc5D;AA8HD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpD;AAMD,wBAAgB,cAAc,CAAC,GAAG,EAAE,wBAAwB,EAAE,KAAK,EAAE,aAAa,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAWvH;AAmFD;;kFAEkF;AAClF,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAIzD;AA2oED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,wBAAwB,EAC7B,UAAU,EAAE,UAAU,EACtB,cAAc,EAAE,MAAM,EACtB,kBAAkB,UAAO,EACzB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,YAAY,EAAE,UAAU,KAAK,IAAI,GACvD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,UAAU,EAAE,CAAA;CAAE,CAmD1D"}
package/lib/layout.js CHANGED
@@ -280,6 +280,14 @@ function hasVisibleBoxStyles(style) {
280
280
  return true;
281
281
  return false;
282
282
  }
283
+ /** True for an element declaring `background-clip:text` with a visible
284
+ * background (gradient image or solid color) — the fill/decorations of every
285
+ * glyph it covers must sample that background instead of painting it as a box. */
286
+ export function hasTextClip(style) {
287
+ return style.webkitBackgroundClip === 'text' &&
288
+ ((!!style.backgroundImage && style.backgroundImage !== 'none') ||
289
+ !isTransparent(style.backgroundColor));
290
+ }
283
291
  /** True for atomic inline-block words (boxOpen && boxClose && text together). */
284
292
  function isAtomicInlineBlock(w) {
285
293
  return !!(w.boxOpen && w.boxClose && w.text);
@@ -364,15 +372,21 @@ function applyEllipsisToLine(ctx, line, maxWidth) {
364
372
  */
365
373
  function collectTextRuns(node) {
366
374
  const runs = [];
367
- function walk(n, boxStyle) {
375
+ function walk(n, boxStyle, clipStyle, strokeImageStyle) {
368
376
  if (n.tagName === '#text' && n.textContent) {
369
- runs.push({ text: n.textContent, style: n.style, boxStyle });
377
+ runs.push({ text: n.textContent, style: n.style, boxStyle, clipStyle, strokeImageStyle });
370
378
  return;
371
379
  }
372
380
  const isInlineBlock = n.style.display === 'inline-block';
373
381
  // Inline-block always needs box treatment (padding/margin affect layout)
374
382
  const isBox = isInlineBlock || (isInline(n) && hasVisibleBoxStyles(n.style));
375
383
  const newBoxStyle = isBox ? n.style : boxStyle;
384
+ // Track the nearest inline element declaring a background-clip:text
385
+ // background or a --rt-text-stroke-image, so those paints reach descendant
386
+ // runs that don't carry the (non-inheriting) properties themselves.
387
+ const newClipStyle = isInline(n) && hasTextClip(n.style) ? n.style : clipStyle;
388
+ const newStrokeImageStyle = isInline(n) && n.style.webkitTextStrokeImage && n.style.webkitTextStrokeImage !== 'none'
389
+ ? n.style : strokeImageStyle;
376
390
  const hasHorizSpacing = isBox && (n.style.paddingLeft > 0 || n.style.paddingRight > 0 ||
377
391
  n.style.borderLeftWidth > 0 || n.style.borderRightWidth > 0);
378
392
  if (isInlineBlock) {
@@ -384,6 +398,8 @@ function collectTextRuns(node) {
384
398
  text: allText,
385
399
  style: n.style,
386
400
  boxStyle: newBoxStyle,
401
+ clipStyle: newClipStyle,
402
+ strokeImageStyle: newStrokeImageStyle,
387
403
  // Store the full box info for atomic inline-block handling
388
404
  boxOpen: n.style, // signals this is a boxed element
389
405
  boxClose: n.style,
@@ -401,7 +417,7 @@ function collectTextRuns(node) {
401
417
  runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxOpen: n.style });
402
418
  }
403
419
  for (const child of n.children) {
404
- walk(child, isBox ? newBoxStyle : boxStyle);
420
+ walk(child, isBox ? newBoxStyle : boxStyle, newClipStyle, newStrokeImageStyle);
405
421
  }
406
422
  if (hasHorizSpacing) {
407
423
  runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxClose: n.style });
@@ -509,6 +525,8 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
509
525
  isSpace: true,
510
526
  isTab: true,
511
527
  boxStyle: run.boxStyle,
528
+ clipStyle: run.clipStyle,
529
+ strokeImageStyle: run.strokeImageStyle,
512
530
  });
513
531
  continue;
514
532
  }
@@ -519,6 +537,8 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
519
537
  style: run.style,
520
538
  isSpace,
521
539
  boxStyle: run.boxStyle,
540
+ clipStyle: run.clipStyle,
541
+ strokeImageStyle: run.strokeImageStyle,
522
542
  });
523
543
  }
524
544
  }
@@ -554,6 +574,8 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
554
574
  style: run.style,
555
575
  isSpace: true,
556
576
  boxStyle: run.boxStyle,
577
+ clipStyle: run.clipStyle,
578
+ strokeImageStyle: run.strokeImageStyle,
557
579
  });
558
580
  continue;
559
581
  }
@@ -572,6 +594,8 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
572
594
  style: run.style,
573
595
  isSpace: false,
574
596
  boxStyle: run.boxStyle,
597
+ clipStyle: run.clipStyle,
598
+ strokeImageStyle: run.strokeImageStyle,
575
599
  });
576
600
  }
577
601
  continue;
@@ -595,6 +619,8 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
595
619
  style: run.style,
596
620
  isSpace: false,
597
621
  boxStyle: run.boxStyle,
622
+ clipStyle: run.clipStyle,
623
+ strokeImageStyle: run.strokeImageStyle,
598
624
  });
599
625
  }
600
626
  // Propagate cumulative state back to caller (for \u200B/\u00AD splits)
@@ -638,6 +664,8 @@ function tokenizeRuns(ctx, runs) {
638
664
  boxStyle: run.boxStyle,
639
665
  boxOpen: run.boxOpen,
640
666
  boxClose: run.boxClose,
667
+ clipStyle: run.clipStyle,
668
+ strokeImageStyle: run.strokeImageStyle,
641
669
  });
642
670
  continue;
643
671
  }
@@ -854,9 +882,17 @@ const TRAILING_PUNCT = /^[,.\;:!?\)\]\}'"»›]+$/;
854
882
  * Flow words into lines that fit within contentWidth.
855
883
  * Handles: word wrapping, nowrap, break-word, CJK character wrapping.
856
884
  */
857
- function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe = false, textIndent = 0, tabMetrics) {
885
+ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe = false, textIndent = 0, tabMetrics, strutLineHeight = 0) {
858
886
  const lines = [];
859
- let currentLine = { words: [], totalWidth: 0, lineHeight: 0 };
887
+ // Every line box starts at the block's own "strut" height (its font +
888
+ // line-height), so a line whose only content is a SMALLER inline font is
889
+ // still at least the block's line-height tall — matching CSS. See callers.
890
+ const newLine = () => ({
891
+ words: [],
892
+ totalWidth: 0,
893
+ lineHeight: strutLineHeight,
894
+ });
895
+ let currentLine = newLine();
860
896
  const noWrap = whiteSpace === 'nowrap' || whiteSpace === 'pre';
861
897
  // text-indent reduces the first line's width budget; subsequent lines use full width.
862
898
  const effWidth = () => contentWidth - (lines.length === 0 ? textIndent : 0);
@@ -905,7 +941,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
905
941
  }
906
942
  lines.push(currentLine);
907
943
  }
908
- currentLine = { words: [], totalWidth: 0, lineHeight: 0 };
944
+ currentLine = newLine();
909
945
  }
910
946
  let afterHardBreak = true; // start of content is like after a hard break
911
947
  for (let wordIndex = 0; wordIndex < words.length; wordIndex++) {
@@ -919,10 +955,10 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
919
955
  }
920
956
  if (word.text === '\n') {
921
957
  if (currentLine.words.length === 0) {
922
- currentLine.lineHeight = wordLineHeight;
958
+ currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
923
959
  currentLine.endedByHardBreak = true;
924
960
  lines.push(currentLine);
925
- currentLine = { words: [], totalWidth: 0, lineHeight: 0 };
961
+ currentLine = newLine();
926
962
  }
927
963
  else {
928
964
  currentLine.endedByHardBreak = true;
@@ -1292,6 +1328,12 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
1292
1328
  */
1293
1329
  function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = false, clamp) {
1294
1330
  const results = [];
1331
+ // Text nodes covered by an inline element declaring background-clip:text
1332
+ // (clipRuns) or --rt-text-stroke-image (strokeImageRuns), mapped to that
1333
+ // declaring element's style. A post-pass turns each per-line run of
1334
+ // same-declarer nodes into a fragment-spanning paint box.
1335
+ const clipRuns = new Map();
1336
+ const strokeImageRuns = new Map();
1295
1337
  if (clamp && (clamp.exhausted || clamp.remaining <= 0)) {
1296
1338
  // An ancestor's clamp already used its line budget — drop this content.
1297
1339
  clamp.exhausted = true;
@@ -1315,7 +1357,10 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1315
1357
  interval: (blockSpaceWidth + (node.style.letterSpacing || 0) + (node.style.wordSpacing || 0)) * 8,
1316
1358
  halfSpace: blockSpaceWidth / 2,
1317
1359
  };
1318
- const lines = flowWordsIntoLines(ctx, words, contentWidth, node.style.whiteSpace, useBulletProbe, textIndent, tabMetrics);
1360
+ // The block's own font + line-height set the strut: the minimum height of
1361
+ // every line box, even a line holding only smaller inline content.
1362
+ const strutLineHeight = getLineHeight(ctx, node.style, useBulletProbe);
1363
+ const lines = flowWordsIntoLines(ctx, words, contentWidth, node.style.whiteSpace, useBulletProbe, textIndent, tabMetrics, strutLineHeight);
1319
1364
  // `-webkit-line-clamp` / `line-clamp`: truncate to N lines and append a
1320
1365
  // CSS-style ellipsis ("…") to the Nth line, back-trimming trailing words
1321
1366
  // until the ellipsis fits within contentWidth. The budget comes from an
@@ -1358,6 +1403,12 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1358
1403
  else {
1359
1404
  textAlignLast = resolveDir(textAlignLast);
1360
1405
  }
1406
+ // The block strut also participates in the line's baseline, not just its
1407
+ // height: inline content aligns to the block-font baseline, so a line whose
1408
+ // only content is a SMALLER inline font sits on the strut baseline (lower in
1409
+ // the box), not centered in it. Seed each line's ascent/descent with the
1410
+ // block font's metrics so the baseline lands where the DOM puts it.
1411
+ const strutMetrics = getFontMetrics(ctx, node.style);
1361
1412
  let curY = y;
1362
1413
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
1363
1414
  const line = lines[lineIdx];
@@ -1418,9 +1469,11 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1418
1469
  // (below) so that emitInlineBox can use line-level metrics for alignment.
1419
1470
  // Compute a single shared baseline for the entire line.
1420
1471
  // Exclude sub/sup words — they sit above/below the baseline and
1421
- // shouldn't influence where the baseline is positioned.
1422
- let maxAscent = 0;
1423
- let maxDescent = 0;
1472
+ // shouldn't influence where the baseline is positioned. Seeded with the
1473
+ // block strut (block font) so smaller-only lines align to the block
1474
+ // baseline rather than centering in the taller strut line box.
1475
+ let maxAscent = strutMetrics.ascent;
1476
+ let maxDescent = strutMetrics.descent;
1424
1477
  for (const word of line.words) {
1425
1478
  if (word.text === '')
1426
1479
  continue;
@@ -1575,7 +1628,7 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1575
1628
  else {
1576
1629
  if (currentGroup)
1577
1630
  groups.push(currentGroup);
1578
- currentGroup = { text: word.text, style: word.style, width: word.width, boxStyle: word.boxStyle, x: 0, padBefore: pendingPad };
1631
+ currentGroup = { text: word.text, style: word.style, width: word.width, boxStyle: word.boxStyle, clipStyle: word.clipStyle, strokeImageStyle: word.strokeImageStyle, x: 0, padBefore: pendingPad };
1579
1632
  pendingPad = 0;
1580
1633
  }
1581
1634
  }
@@ -1604,14 +1657,19 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1604
1657
  }
1605
1658
  // Emit text groups
1606
1659
  for (const group of groups) {
1607
- results.push({
1660
+ const node = {
1608
1661
  type: 'text',
1609
1662
  text: group.text,
1610
1663
  x: group.x + group.width, // x = right edge for RTL textAlign
1611
1664
  y: lineBaselineY,
1612
1665
  width: group.width,
1613
1666
  style: { ...group.style, direction: 'rtl' },
1614
- });
1667
+ };
1668
+ results.push(node);
1669
+ if (group.clipStyle)
1670
+ clipRuns.set(node, group.clipStyle);
1671
+ if (group.strokeImageStyle)
1672
+ strokeImageRuns.set(node, group.strokeImageStyle);
1615
1673
  }
1616
1674
  }
1617
1675
  else {
@@ -1633,14 +1691,19 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1633
1691
  // right-aligns the whole line at the left edge (x=curX), drawing it
1634
1692
  // off-screen. The canvas BiDi engine still reorders the embedded
1635
1693
  // Arabic/Hebrew runs within the LTR line.
1636
- results.push({
1694
+ const node = {
1637
1695
  type: 'text',
1638
1696
  text: lineText,
1639
1697
  x: curX,
1640
1698
  y: lineBaselineY,
1641
1699
  width: measuredWidth,
1642
1700
  style: { ...textWords[0].style, direction: 'ltr' },
1643
- });
1701
+ };
1702
+ results.push(node);
1703
+ if (textWords[0].clipStyle)
1704
+ clipRuns.set(node, textWords[0].clipStyle);
1705
+ if (textWords[0].strokeImageStyle)
1706
+ strokeImageRuns.set(node, textWords[0].strokeImageStyle);
1644
1707
  }
1645
1708
  else {
1646
1709
  // Mixed styles: word by word
@@ -1653,14 +1716,19 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1653
1716
  if (word.boxOpen && word.boxClose) {
1654
1717
  const s = word.style;
1655
1718
  const textX = curX + s.marginLeft + s.borderLeftWidth + s.paddingLeft;
1656
- results.push({
1719
+ const node = {
1657
1720
  type: 'text',
1658
1721
  text: word.text,
1659
1722
  x: textX,
1660
1723
  y: lineBaselineY,
1661
1724
  width: cachedMeasureWidth(ctx, word.text),
1662
1725
  style: word.style,
1663
- });
1726
+ };
1727
+ results.push(node);
1728
+ if (word.clipStyle)
1729
+ clipRuns.set(node, word.clipStyle);
1730
+ if (word.strokeImageStyle)
1731
+ strokeImageRuns.set(node, word.strokeImageStyle);
1664
1732
  curX += word.width;
1665
1733
  continue;
1666
1734
  }
@@ -1673,14 +1741,19 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1673
1741
  baselineY += verticalAlignShift(va, wA, wD, pfs, maxAscent, maxDescent, lineHeight);
1674
1742
  }
1675
1743
  const effectiveWidth = word.width + (word.isSpace ? justifyExtraPerSpace : 0);
1676
- results.push({
1744
+ const node = {
1677
1745
  type: 'text',
1678
1746
  text: word.text,
1679
1747
  x: curX,
1680
1748
  y: baselineY,
1681
1749
  width: effectiveWidth,
1682
1750
  style: word.style,
1683
- });
1751
+ };
1752
+ results.push(node);
1753
+ if (word.clipStyle)
1754
+ clipRuns.set(node, word.clipStyle);
1755
+ if (word.strokeImageStyle)
1756
+ strokeImageRuns.set(node, word.strokeImageStyle);
1684
1757
  curX += effectiveWidth;
1685
1758
  }
1686
1759
  }
@@ -1705,8 +1778,70 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1705
1778
  });
1706
1779
  curY += effectiveLineHeight;
1707
1780
  }
1781
+ assignInlineFragmentBoxes(ctx, results, clipRuns, (node, s, box) => {
1782
+ node.clip = {
1783
+ image: s.backgroundImage && s.backgroundImage !== 'none' ? s.backgroundImage : undefined,
1784
+ color: !isTransparent(s.backgroundColor) ? s.backgroundColor : undefined,
1785
+ ...box,
1786
+ };
1787
+ });
1788
+ assignInlineFragmentBoxes(ctx, results, strokeImageRuns, (node, s, box) => {
1789
+ node.strokeImage = { image: s.webkitTextStrokeImage, ...box };
1790
+ });
1708
1791
  return { nodes: results, height: curY - y };
1709
1792
  }
1793
+ /**
1794
+ * Give each text run covered by an inline paint declarer (background-clip:text
1795
+ * background, --rt-text-stroke-image) a paint box spanning the declaring
1796
+ * element's fragment on its line.
1797
+ *
1798
+ * Browsers paint the declaring element's background over its inline fragment
1799
+ * (the run of glyphs it covers on one line) and clip it to the text; with
1800
+ * `background-size:100% 100%` the gradient fills that fragment box. Consecutive
1801
+ * text nodes sharing the same declaring element (same style object) on the
1802
+ * same baseline form one fragment; a wrap to the next line starts a new one
1803
+ * (box-decoration-break:clone semantics — Chrome's default `slice` continues
1804
+ * the gradient across line fragments; accepted approximation), and unlike a
1805
+ * per-run gradient it never restarts per word.
1806
+ */
1807
+ function assignInlineFragmentBoxes(ctx, results, runs, assign) {
1808
+ if (runs.size === 0)
1809
+ return;
1810
+ const edges = (n) => n.style.direction === 'rtl'
1811
+ ? { left: n.x - n.width, right: n.x } // RTL x is the right edge
1812
+ : { left: n.x, right: n.x + n.width };
1813
+ for (let i = 0; i < results.length;) {
1814
+ const first = results[i];
1815
+ const declarer = first.type === 'text' ? runs.get(first) : undefined;
1816
+ if (!declarer) {
1817
+ i++;
1818
+ continue;
1819
+ }
1820
+ let j = i;
1821
+ let left = Infinity, right = -Infinity;
1822
+ while (j < results.length) {
1823
+ const n = results[j];
1824
+ if (n.type !== 'text' || runs.get(n) !== declarer || n.y !== first.y)
1825
+ break;
1826
+ const e = edges(n);
1827
+ if (e.left < left)
1828
+ left = e.left;
1829
+ if (e.right > right)
1830
+ right = e.right;
1831
+ j++;
1832
+ }
1833
+ const { ascent, descent } = getFontMetrics(ctx, declarer);
1834
+ const box = {
1835
+ x: left,
1836
+ y: first.y - ascent,
1837
+ width: right - left,
1838
+ height: ascent + descent,
1839
+ };
1840
+ for (let k = i; k < j; k++)
1841
+ assign(results[k], declarer, box);
1842
+ i = j;
1843
+ }
1844
+ }
1710
1845
  // ─── Block layout ──────────────────────────────────────────────────────
1711
1846
  /**
1712
1847
  * Collapse margins between two adjacent block elements.
@@ -2019,21 +2154,41 @@ function addListMarker(ctx, box, node) {
2019
2154
  let markerX;
2020
2155
  let markerY = baselineY;
2021
2156
  let markerDirection = 'ltr';
2157
+ // Style/width the marker glyph is actually DRAWN with. Numbers draw at the
2158
+ // li font (unchanged); bullets scale up (see below), so keep these separate.
2159
+ let markerDrawStyle = markerStyleObj;
2160
+ let markerDrawWidth = markerWidth;
2022
2161
  const contentStartX = box.x + style.borderLeftWidth + style.paddingLeft;
2023
2162
  const boxRightEdge = box.x + box.width;
2024
2163
  if (isBullet) {
2025
2164
  const { ascent } = getFontMetrics(ctx, markerStyleObj);
2026
2165
  const m = ctx.measureText(node.listMarker);
2027
- const gap = explicitGap !== undefined ? explicitGap : 7 + ascent / 3;
2166
+ // Blink's marker unit: the disc DIAMETER, the variable part of the gap, and
2167
+ // the vertical centering all key off this one value (a 2/3·ascent marker
2168
+ // box with a half-filling disc → ascent/3). Named once so tuning one keeps
2169
+ // the trio in sync.
2170
+ const markerUnit = ascent / 3;
2171
+ const gap = explicitGap !== undefined ? explicitGap : 7 + markerUnit;
2172
+ // Chrome paints bullet symbols (disc/circle/square) as a SYNTHETIC shape of
2173
+ // that diameter, NOT the font's smaller '•'/'○'/'■' glyph (Roboto's '•' ink
2174
+ // is ~0.22em vs Chrome's ~0.31em disc). Match it by scaling the glyph so its
2175
+ // ink height equals markerUnit. Keeping the marker a text node means fill /
2176
+ // stroke / shadow / gradient still apply exactly as before.
2177
+ const inkH = (m.actualBoundingBoxAscent ?? 0) + (m.actualBoundingBoxDescent ?? 0);
2178
+ const scale = inkH > 0 ? markerUnit / inkH : 1;
2179
+ const inkRight = (m.actualBoundingBoxRight ?? markerWidth) * scale;
2180
+ const inkLeft = (m.actualBoundingBoxLeft ?? 0) * scale;
2181
+ const glyphInkCenter = (((m.actualBoundingBoxAscent ?? 0) - (m.actualBoundingBoxDescent ?? 0)) / 2) * scale;
2028
2182
  if (isRTL) {
2029
2183
  // actualBoundingBoxLeft is positive when ink extends left of origin
2030
- markerX = boxRightEdge + gap + (m.actualBoundingBoxLeft ?? 0);
2184
+ markerX = boxRightEdge + gap + inkLeft;
2031
2185
  }
2032
2186
  else {
2033
- markerX = contentStartX - gap - (m.actualBoundingBoxRight ?? markerWidth);
2187
+ markerX = contentStartX - gap - inkRight;
2034
2188
  }
2035
- const glyphInkCenter = ((m.actualBoundingBoxAscent ?? 0) - (m.actualBoundingBoxDescent ?? 0)) / 2;
2036
- markerY = baselineY - ascent / 3 + glyphInkCenter;
2189
+ markerY = baselineY - markerUnit + glyphInkCenter;
2190
+ markerDrawStyle = { ...markerStyleObj, fontSize: markerStyleObj.fontSize * scale };
2191
+ markerDrawWidth = markerWidth * scale;
2037
2192
  }
2038
2193
  else {
2039
2194
  const gap = explicitGap !== undefined
@@ -2062,22 +2217,25 @@ function addListMarker(ctx, box, node) {
2062
2217
  text: node.listMarker,
2063
2218
  x: markerX,
2064
2219
  y: markerY,
2065
- width: markerWidth,
2066
- style: { ...markerStyleObj, textDecorationLine: 'none', textDecorations: [], fontWeight: ms?.fontWeight ?? 400, fontStyle: ms?.fontStyle ?? 'normal', direction: markerDirection },
2220
+ width: markerDrawWidth,
2221
+ style: { ...markerDrawStyle, textDecorationLine: 'none', textDecorations: [], fontWeight: ms?.fontWeight ?? 400, fontStyle: ms?.fontStyle ?? 'normal', direction: markerDirection },
2067
2222
  });
2068
2223
  // Also publish the marker through the LayoutLine stream so result.lines
2069
2224
  // sees the bullet/number alongside the item text. Markers are added AFTER
2070
2225
  // inline content is laid out, so they don't go through layoutInlineContent.
2071
2226
  // The buildLayoutTree sort+merge step picks up the marker by its baseline.
2072
2227
  // RTL numbered markers store their right edge in markerX (textAlign trick).
2073
- const markerLeftX = markerDirection === 'rtl' ? markerX - markerWidth : markerX;
2228
+ // bounds.width is the (scaled) glyph ADVANCE, not its ink extent — same
2229
+ // convention as numbered markers; the ink right edge itself is pinned to
2230
+ // contentStart - gap above.
2231
+ const markerLeftX = markerDirection === 'rtl' ? markerX - markerDrawWidth : markerX;
2074
2232
  _lines.push({
2075
2233
  y: Math.round(baselineY),
2076
2234
  text: node.listMarker,
2077
2235
  bounds: {
2078
2236
  x: markerLeftX,
2079
2237
  y: box.y + style.borderTopWidth + style.paddingTop,
2080
- width: markerWidth,
2238
+ width: markerDrawWidth,
2081
2239
  height: lineHeight,
2082
2240
  },
2083
2241
  });