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 +4 -0
- package/lib/layout.d.ts.map +1 -1
- package/lib/layout.js +188 -30
- package/lib/layout.js.map +1 -1
- package/lib/path/glyph-layout.d.ts +22 -0
- package/lib/path/glyph-layout.d.ts.map +1 -1
- package/lib/path/glyph-layout.js +41 -4
- package/lib/path/glyph-layout.js.map +1 -1
- package/lib/path/index.js +142 -60
- package/lib/path/index.js.map +1 -1
- package/lib/render-tag.umd.js +5 -5
- package/lib/render-tag.umd.js.map +1 -1
- package/lib/render.d.ts +1 -1
- package/lib/render.d.ts.map +1 -1
- package/lib/render.js +53 -48
- package/lib/render.js.map +1 -1
- package/lib/types.d.ts +26 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +2 -2
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.
|
package/lib/layout.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
2184
|
+
markerX = boxRightEdge + gap + inkLeft;
|
|
2031
2185
|
}
|
|
2032
2186
|
else {
|
|
2033
|
-
markerX = contentStartX - gap -
|
|
2187
|
+
markerX = contentStartX - gap - inkRight;
|
|
2034
2188
|
}
|
|
2035
|
-
|
|
2036
|
-
|
|
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:
|
|
2066
|
-
style: { ...
|
|
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
|
-
|
|
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:
|
|
2238
|
+
width: markerDrawWidth,
|
|
2081
2239
|
height: lineHeight,
|
|
2082
2240
|
},
|
|
2083
2241
|
});
|