render-tag 0.1.22 → 0.1.24

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;AAsnED;;;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
  }
@@ -1292,6 +1320,12 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
1292
1320
  */
1293
1321
  function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = false, clamp) {
1294
1322
  const results = [];
1323
+ // Text nodes covered by an inline element declaring background-clip:text
1324
+ // (clipRuns) or --rt-text-stroke-image (strokeImageRuns), mapped to that
1325
+ // declaring element's style. A post-pass turns each per-line run of
1326
+ // same-declarer nodes into a fragment-spanning paint box.
1327
+ const clipRuns = new Map();
1328
+ const strokeImageRuns = new Map();
1295
1329
  if (clamp && (clamp.exhausted || clamp.remaining <= 0)) {
1296
1330
  // An ancestor's clamp already used its line budget — drop this content.
1297
1331
  clamp.exhausted = true;
@@ -1575,7 +1609,7 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1575
1609
  else {
1576
1610
  if (currentGroup)
1577
1611
  groups.push(currentGroup);
1578
- currentGroup = { text: word.text, style: word.style, width: word.width, boxStyle: word.boxStyle, x: 0, padBefore: pendingPad };
1612
+ currentGroup = { text: word.text, style: word.style, width: word.width, boxStyle: word.boxStyle, clipStyle: word.clipStyle, strokeImageStyle: word.strokeImageStyle, x: 0, padBefore: pendingPad };
1579
1613
  pendingPad = 0;
1580
1614
  }
1581
1615
  }
@@ -1604,14 +1638,19 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1604
1638
  }
1605
1639
  // Emit text groups
1606
1640
  for (const group of groups) {
1607
- results.push({
1641
+ const node = {
1608
1642
  type: 'text',
1609
1643
  text: group.text,
1610
1644
  x: group.x + group.width, // x = right edge for RTL textAlign
1611
1645
  y: lineBaselineY,
1612
1646
  width: group.width,
1613
1647
  style: { ...group.style, direction: 'rtl' },
1614
- });
1648
+ };
1649
+ results.push(node);
1650
+ if (group.clipStyle)
1651
+ clipRuns.set(node, group.clipStyle);
1652
+ if (group.strokeImageStyle)
1653
+ strokeImageRuns.set(node, group.strokeImageStyle);
1615
1654
  }
1616
1655
  }
1617
1656
  else {
@@ -1633,14 +1672,19 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1633
1672
  // right-aligns the whole line at the left edge (x=curX), drawing it
1634
1673
  // off-screen. The canvas BiDi engine still reorders the embedded
1635
1674
  // Arabic/Hebrew runs within the LTR line.
1636
- results.push({
1675
+ const node = {
1637
1676
  type: 'text',
1638
1677
  text: lineText,
1639
1678
  x: curX,
1640
1679
  y: lineBaselineY,
1641
1680
  width: measuredWidth,
1642
1681
  style: { ...textWords[0].style, direction: 'ltr' },
1643
- });
1682
+ };
1683
+ results.push(node);
1684
+ if (textWords[0].clipStyle)
1685
+ clipRuns.set(node, textWords[0].clipStyle);
1686
+ if (textWords[0].strokeImageStyle)
1687
+ strokeImageRuns.set(node, textWords[0].strokeImageStyle);
1644
1688
  }
1645
1689
  else {
1646
1690
  // Mixed styles: word by word
@@ -1653,14 +1697,19 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1653
1697
  if (word.boxOpen && word.boxClose) {
1654
1698
  const s = word.style;
1655
1699
  const textX = curX + s.marginLeft + s.borderLeftWidth + s.paddingLeft;
1656
- results.push({
1700
+ const node = {
1657
1701
  type: 'text',
1658
1702
  text: word.text,
1659
1703
  x: textX,
1660
1704
  y: lineBaselineY,
1661
1705
  width: cachedMeasureWidth(ctx, word.text),
1662
1706
  style: word.style,
1663
- });
1707
+ };
1708
+ results.push(node);
1709
+ if (word.clipStyle)
1710
+ clipRuns.set(node, word.clipStyle);
1711
+ if (word.strokeImageStyle)
1712
+ strokeImageRuns.set(node, word.strokeImageStyle);
1664
1713
  curX += word.width;
1665
1714
  continue;
1666
1715
  }
@@ -1673,14 +1722,19 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1673
1722
  baselineY += verticalAlignShift(va, wA, wD, pfs, maxAscent, maxDescent, lineHeight);
1674
1723
  }
1675
1724
  const effectiveWidth = word.width + (word.isSpace ? justifyExtraPerSpace : 0);
1676
- results.push({
1725
+ const node = {
1677
1726
  type: 'text',
1678
1727
  text: word.text,
1679
1728
  x: curX,
1680
1729
  y: baselineY,
1681
1730
  width: effectiveWidth,
1682
1731
  style: word.style,
1683
- });
1732
+ };
1733
+ results.push(node);
1734
+ if (word.clipStyle)
1735
+ clipRuns.set(node, word.clipStyle);
1736
+ if (word.strokeImageStyle)
1737
+ strokeImageRuns.set(node, word.strokeImageStyle);
1684
1738
  curX += effectiveWidth;
1685
1739
  }
1686
1740
  }
@@ -1705,8 +1759,70 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1705
1759
  });
1706
1760
  curY += effectiveLineHeight;
1707
1761
  }
1762
+ assignInlineFragmentBoxes(ctx, results, clipRuns, (node, s, box) => {
1763
+ node.clip = {
1764
+ image: s.backgroundImage && s.backgroundImage !== 'none' ? s.backgroundImage : undefined,
1765
+ color: !isTransparent(s.backgroundColor) ? s.backgroundColor : undefined,
1766
+ ...box,
1767
+ };
1768
+ });
1769
+ assignInlineFragmentBoxes(ctx, results, strokeImageRuns, (node, s, box) => {
1770
+ node.strokeImage = { image: s.webkitTextStrokeImage, ...box };
1771
+ });
1708
1772
  return { nodes: results, height: curY - y };
1709
1773
  }
1774
+ /**
1775
+ * Give each text run covered by an inline paint declarer (background-clip:text
1776
+ * background, --rt-text-stroke-image) a paint box spanning the declaring
1777
+ * element's fragment on its line.
1778
+ *
1779
+ * Browsers paint the declaring element's background over its inline fragment
1780
+ * (the run of glyphs it covers on one line) and clip it to the text; with
1781
+ * `background-size:100% 100%` the gradient fills that fragment box. Consecutive
1782
+ * text nodes sharing the same declaring element (same style object) on the
1783
+ * same baseline form one fragment; a wrap to the next line starts a new one
1784
+ * (box-decoration-break:clone semantics — Chrome's default `slice` continues
1785
+ * the gradient across line fragments; accepted approximation), and unlike a
1786
+ * per-run gradient it never restarts per word.
1787
+ */
1788
+ function assignInlineFragmentBoxes(ctx, results, runs, assign) {
1789
+ if (runs.size === 0)
1790
+ return;
1791
+ const edges = (n) => n.style.direction === 'rtl'
1792
+ ? { left: n.x - n.width, right: n.x } // RTL x is the right edge
1793
+ : { left: n.x, right: n.x + n.width };
1794
+ for (let i = 0; i < results.length;) {
1795
+ const first = results[i];
1796
+ const declarer = first.type === 'text' ? runs.get(first) : undefined;
1797
+ if (!declarer) {
1798
+ i++;
1799
+ continue;
1800
+ }
1801
+ let j = i;
1802
+ let left = Infinity, right = -Infinity;
1803
+ while (j < results.length) {
1804
+ const n = results[j];
1805
+ if (n.type !== 'text' || runs.get(n) !== declarer || n.y !== first.y)
1806
+ break;
1807
+ const e = edges(n);
1808
+ if (e.left < left)
1809
+ left = e.left;
1810
+ if (e.right > right)
1811
+ right = e.right;
1812
+ j++;
1813
+ }
1814
+ const { ascent, descent } = getFontMetrics(ctx, declarer);
1815
+ const box = {
1816
+ x: left,
1817
+ y: first.y - ascent,
1818
+ width: right - left,
1819
+ height: ascent + descent,
1820
+ };
1821
+ for (let k = i; k < j; k++)
1822
+ assign(results[k], declarer, box);
1823
+ i = j;
1824
+ }
1825
+ }
1710
1826
  // ─── Block layout ──────────────────────────────────────────────────────
1711
1827
  /**
1712
1828
  * Collapse margins between two adjacent block elements.
@@ -2019,21 +2135,41 @@ function addListMarker(ctx, box, node) {
2019
2135
  let markerX;
2020
2136
  let markerY = baselineY;
2021
2137
  let markerDirection = 'ltr';
2138
+ // Style/width the marker glyph is actually DRAWN with. Numbers draw at the
2139
+ // li font (unchanged); bullets scale up (see below), so keep these separate.
2140
+ let markerDrawStyle = markerStyleObj;
2141
+ let markerDrawWidth = markerWidth;
2022
2142
  const contentStartX = box.x + style.borderLeftWidth + style.paddingLeft;
2023
2143
  const boxRightEdge = box.x + box.width;
2024
2144
  if (isBullet) {
2025
2145
  const { ascent } = getFontMetrics(ctx, markerStyleObj);
2026
2146
  const m = ctx.measureText(node.listMarker);
2027
- const gap = explicitGap !== undefined ? explicitGap : 7 + ascent / 3;
2147
+ // Blink's marker unit: the disc DIAMETER, the variable part of the gap, and
2148
+ // the vertical centering all key off this one value (a 2/3·ascent marker
2149
+ // box with a half-filling disc → ascent/3). Named once so tuning one keeps
2150
+ // the trio in sync.
2151
+ const markerUnit = ascent / 3;
2152
+ const gap = explicitGap !== undefined ? explicitGap : 7 + markerUnit;
2153
+ // Chrome paints bullet symbols (disc/circle/square) as a SYNTHETIC shape of
2154
+ // that diameter, NOT the font's smaller '•'/'○'/'■' glyph (Roboto's '•' ink
2155
+ // is ~0.22em vs Chrome's ~0.31em disc). Match it by scaling the glyph so its
2156
+ // ink height equals markerUnit. Keeping the marker a text node means fill /
2157
+ // stroke / shadow / gradient still apply exactly as before.
2158
+ const inkH = (m.actualBoundingBoxAscent ?? 0) + (m.actualBoundingBoxDescent ?? 0);
2159
+ const scale = inkH > 0 ? markerUnit / inkH : 1;
2160
+ const inkRight = (m.actualBoundingBoxRight ?? markerWidth) * scale;
2161
+ const inkLeft = (m.actualBoundingBoxLeft ?? 0) * scale;
2162
+ const glyphInkCenter = (((m.actualBoundingBoxAscent ?? 0) - (m.actualBoundingBoxDescent ?? 0)) / 2) * scale;
2028
2163
  if (isRTL) {
2029
2164
  // actualBoundingBoxLeft is positive when ink extends left of origin
2030
- markerX = boxRightEdge + gap + (m.actualBoundingBoxLeft ?? 0);
2165
+ markerX = boxRightEdge + gap + inkLeft;
2031
2166
  }
2032
2167
  else {
2033
- markerX = contentStartX - gap - (m.actualBoundingBoxRight ?? markerWidth);
2168
+ markerX = contentStartX - gap - inkRight;
2034
2169
  }
2035
- const glyphInkCenter = ((m.actualBoundingBoxAscent ?? 0) - (m.actualBoundingBoxDescent ?? 0)) / 2;
2036
- markerY = baselineY - ascent / 3 + glyphInkCenter;
2170
+ markerY = baselineY - markerUnit + glyphInkCenter;
2171
+ markerDrawStyle = { ...markerStyleObj, fontSize: markerStyleObj.fontSize * scale };
2172
+ markerDrawWidth = markerWidth * scale;
2037
2173
  }
2038
2174
  else {
2039
2175
  const gap = explicitGap !== undefined
@@ -2062,22 +2198,25 @@ function addListMarker(ctx, box, node) {
2062
2198
  text: node.listMarker,
2063
2199
  x: markerX,
2064
2200
  y: markerY,
2065
- width: markerWidth,
2066
- style: { ...markerStyleObj, textDecorationLine: 'none', textDecorations: [], fontWeight: ms?.fontWeight ?? 400, fontStyle: ms?.fontStyle ?? 'normal', direction: markerDirection },
2201
+ width: markerDrawWidth,
2202
+ style: { ...markerDrawStyle, textDecorationLine: 'none', textDecorations: [], fontWeight: ms?.fontWeight ?? 400, fontStyle: ms?.fontStyle ?? 'normal', direction: markerDirection },
2067
2203
  });
2068
2204
  // Also publish the marker through the LayoutLine stream so result.lines
2069
2205
  // sees the bullet/number alongside the item text. Markers are added AFTER
2070
2206
  // inline content is laid out, so they don't go through layoutInlineContent.
2071
2207
  // The buildLayoutTree sort+merge step picks up the marker by its baseline.
2072
2208
  // RTL numbered markers store their right edge in markerX (textAlign trick).
2073
- const markerLeftX = markerDirection === 'rtl' ? markerX - markerWidth : markerX;
2209
+ // bounds.width is the (scaled) glyph ADVANCE, not its ink extent — same
2210
+ // convention as numbered markers; the ink right edge itself is pinned to
2211
+ // contentStart - gap above.
2212
+ const markerLeftX = markerDirection === 'rtl' ? markerX - markerDrawWidth : markerX;
2074
2213
  _lines.push({
2075
2214
  y: Math.round(baselineY),
2076
2215
  text: node.listMarker,
2077
2216
  bounds: {
2078
2217
  x: markerLeftX,
2079
2218
  y: box.y + style.borderTopWidth + style.paddingTop,
2080
- width: markerWidth,
2219
+ width: markerDrawWidth,
2081
2220
  height: lineHeight,
2082
2221
  },
2083
2222
  });