domotion-svg 0.2.2 → 0.3.2
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/FEATURES.md +1 -0
- package/README.md +29 -0
- package/dist/animation/animator.js +25 -14
- package/dist/animation/animator.test.js +54 -21
- package/dist/animation/cursor-overlay.js +0 -2
- package/dist/capture/emoji.js +29 -18
- package/dist/capture/index.js +5 -4
- package/dist/capture/script/color-norm.d.ts +1 -0
- package/dist/capture/script/color-norm.js +43 -1
- package/dist/capture/script/emoji-detect.js +14 -0
- package/dist/capture/script/index.js +593 -65
- package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
- package/dist/capture/script/walker/borders-backgrounds.js +123 -7
- package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
- package/dist/capture/script/walker/counter-style-resolver.js +218 -0
- package/dist/capture/script/walker/input-value.js +14 -1
- package/dist/capture/script/walker/lists-counters.d.ts +3 -1
- package/dist/capture/script/walker/lists-counters.js +22 -2
- package/dist/capture/script/walker/masks-clips.d.ts +2 -0
- package/dist/capture/script/walker/masks-clips.js +41 -1
- package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
- package/dist/capture/script/walker/pseudo-content.js +301 -61
- package/dist/capture/script/walker/pseudo-inject.js +20 -0
- package/dist/capture/script/walker/text-segments.js +98 -4
- package/dist/capture/script/walker/transforms.d.ts +1 -0
- package/dist/capture/script/walker/transforms.js +16 -0
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +213 -2
- package/dist/cli/animate.js +151 -15
- package/dist/mask.test.js +12 -7
- package/dist/render/borders.d.ts +9 -13
- package/dist/render/borders.js +379 -14
- package/dist/render/element-tree-to-svg.d.ts +11 -12
- package/dist/render/element-tree-to-svg.js +2046 -241
- package/dist/render/embedded-font-builder.d.ts +49 -0
- package/dist/render/embedded-font-builder.js +149 -0
- package/dist/render/form-controls.js +45 -24
- package/dist/render/gradients.d.ts +15 -0
- package/dist/render/gradients.js +103 -2
- package/dist/render/gradients.test.js +34 -0
- package/dist/render/text-to-path.d.ts +38 -1
- package/dist/render/text-to-path.js +654 -29
- package/dist/render/text-to-path.test.js +230 -9
- package/dist/render/text.d.ts +14 -0
- package/dist/render/text.js +344 -40
- package/dist/scroll/composer.d.ts +26 -0
- package/dist/scroll/composer.js +199 -11
- package/dist/scroll/composer.test.js +293 -16
- package/dist/scroll/executor.d.ts +3 -1
- package/dist/scroll/executor.js +15 -6
- package/dist/scroll/executor.test.js +25 -0
- package/dist/scroll/hoist-fixed.d.ts +48 -0
- package/dist/scroll/hoist-fixed.js +85 -0
- package/dist/scroll/hoist-fixed.test.d.ts +1 -0
- package/dist/scroll/hoist-fixed.test.js +103 -0
- package/dist/scroll/hoist-sticky.d.ts +45 -0
- package/dist/scroll/hoist-sticky.js +157 -0
- package/dist/scroll/hoist-sticky.test.d.ts +1 -0
- package/dist/scroll/hoist-sticky.test.js +154 -0
- package/dist/scroll/pattern.d.ts +22 -5
- package/dist/scroll/pattern.js +55 -7
- package/dist/scroll/pattern.test.js +48 -1
- package/dist/tree-ops/frame-merge.d.ts +10 -0
- package/dist/tree-ops/frame-merge.js +23 -5
- package/dist/tree-ops/frame-merge.test.js +45 -0
- package/dist/tree-ops/tree-diff.js +1 -1
- package/dist/tree-ops/viewbox-culling.js +32 -18
- package/dist/tree-ops/viewbox-culling.test.js +40 -6
- package/package.json +8 -2
- package/src/animation/animator.test.ts +56 -21
- package/src/animation/animator.ts +25 -14
- package/src/animation/cursor-overlay.ts +0 -2
- package/src/capture/emoji.ts +28 -18
- package/src/capture/index.ts +15 -14
- package/src/capture/script/color-norm.ts +38 -1
- package/src/capture/script/emoji-detect.ts +14 -0
- package/src/capture/script/index.ts +555 -48
- package/src/capture/script/walker/borders-backgrounds.ts +114 -7
- package/src/capture/script/walker/counter-style-resolver.ts +184 -0
- package/src/capture/script/walker/input-value.ts +14 -1
- package/src/capture/script/walker/lists-counters.ts +24 -2
- package/src/capture/script/walker/masks-clips.ts +40 -1
- package/src/capture/script/walker/pseudo-content.ts +297 -55
- package/src/capture/script/walker/pseudo-inject.ts +20 -0
- package/src/capture/script/walker/text-segments.ts +93 -4
- package/src/capture/script/walker/transforms.ts +14 -0
- package/src/capture/script.generated.ts +1 -1
- package/src/capture/types.ts +202 -2
- package/src/cli/animate.ts +135 -15
- package/src/mask.test.ts +12 -7
- package/src/render/borders.ts +383 -17
- package/src/render/element-tree-to-svg.ts +2051 -238
- package/src/render/embedded-font-builder.ts +221 -0
- package/src/render/form-controls.ts +45 -24
- package/src/render/gradients.test.ts +46 -0
- package/src/render/gradients.ts +94 -2
- package/src/render/opentype.js.d.ts +7 -0
- package/src/render/text-to-path.test.ts +246 -9
- package/src/render/text-to-path.ts +702 -31
- package/src/render/text.ts +344 -40
- package/src/scroll/composer.test.ts +322 -16
- package/src/scroll/composer.ts +246 -13
- package/src/scroll/executor.test.ts +27 -0
- package/src/scroll/executor.ts +19 -10
- package/src/scroll/hoist-fixed.test.ts +117 -0
- package/src/scroll/hoist-fixed.ts +95 -0
- package/src/scroll/hoist-sticky.test.ts +173 -0
- package/src/scroll/hoist-sticky.ts +193 -0
- package/src/scroll/pattern.test.ts +58 -1
- package/src/scroll/pattern.ts +71 -8
- package/src/tree-ops/frame-merge.test.ts +51 -0
- package/src/tree-ops/frame-merge.ts +24 -6
- package/src/tree-ops/tree-diff.ts +3 -1
- package/src/tree-ops/viewbox-culling.test.ts +42 -6
- package/src/tree-ops/viewbox-culling.ts +32 -18
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
* Uses Playwright to inspect DOM elements and recreate them as native SVG.
|
|
5
5
|
*/
|
|
6
6
|
import { renderSingleLineText, renderMultiSegmentText, renderMultiLineText, renderInputText } from "./text.js";
|
|
7
|
-
import { getGlyphDefs } from "./text-to-path.js";
|
|
7
|
+
import { getGlyphDefs, measureLastGlyphRsb } from "./text-to-path.js";
|
|
8
8
|
import { renderFormControl } from "./form-controls.js";
|
|
9
9
|
import { r, esc, stopFmt } from "./format.js";
|
|
10
10
|
import { parseColor, colorStr, sameColor } from "./colors.js";
|
|
11
|
-
import { parseCornerRadii, insetCornerRadii, roundedRectSvg, parseSide, dashArrayForStyle, renderBorderImage, injectSvgSize, } from "./borders.js";
|
|
11
|
+
import { parseCornerRadii, insetCornerRadii, outsetCornerRadiiForShadow, roundedRectPath, roundedRectSvg, parseSide, dashArrayForStyle, renderBorderImage, injectSvgSize, } from "./borders.js";
|
|
12
12
|
import { parseBoxShadow } from "./box-shadow.js";
|
|
13
13
|
import { cssTransformToSvg } from "./transforms.js";
|
|
14
14
|
import { parseCssUrl, splitTopLevelCommas } from "./css-tokens.js";
|
|
15
|
+
import { convertLegacyWebkitGradient } from "./gradients.js";
|
|
15
16
|
import { embedResizedDataUri, setActiveHiDPIFactor, } from "../capture/embed.js";
|
|
16
17
|
// Public-API re-exports kept here for backward compatibility — older imports
|
|
17
18
|
// from `./render/element-tree-to-svg.js` keep resolving. Internal consumers
|
|
@@ -40,7 +41,12 @@ export function wrapSvg(inner, width, height, opts) {
|
|
|
40
41
|
// when `rootBgComputed` is missing (back-compat with pre-DM-552 trees) or
|
|
41
42
|
// explicitly transparent (the page intends a transparent SVG output).
|
|
42
43
|
const rootBgRect = opts?.tree != null ? transparentRootBgRect(opts.tree, width, height) : "";
|
|
43
|
-
|
|
44
|
+
// Captured inline SVG subtrees may carry `xlink:href` (gradient stop
|
|
45
|
+
// inheritance, `<use>`, mask/pattern refs). Without xmlns:xlink declared,
|
|
46
|
+
// XML parsing fails with "Namespace prefix xlink for href is not defined"
|
|
47
|
+
// and Chrome refuses to render past the first occurrence.
|
|
48
|
+
const xlinkAttr = inner.includes("xlink:") ? ` xmlns:xlink="http://www.w3.org/1999/xlink"` : "";
|
|
49
|
+
return `<svg xmlns="http://www.w3.org/2000/svg"${xlinkAttr} viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"${schemeAttr}>${rootBgRect}${inner}</svg>`;
|
|
44
50
|
}
|
|
45
51
|
/**
|
|
46
52
|
* DM-552: returns ` color-scheme="dark"` (with leading space, suitable for
|
|
@@ -124,6 +130,22 @@ hiDPIFactor = 2) {
|
|
|
124
130
|
// suppressed via this set so we don't double-emit them. Populated by
|
|
125
131
|
// `gatherStackingContextChildren()` whenever we cross into an SC root.
|
|
126
132
|
const hoistedFromAncestor = new Set();
|
|
133
|
+
// DM-673: maps a hoisted positioned descendant to the
|
|
134
|
+
// `overflow != visible` ancestor it escaped through. Per CSS Overflow 3
|
|
135
|
+
// §2.2 such ancestors clip their content. When we hoist the descendant
|
|
136
|
+
// past the ancestor's `<g clip-path>` wrapper, we need to re-wrap the
|
|
137
|
+
// descendant's emission in the same clip-path so the visual effect is
|
|
138
|
+
// preserved. `position:fixed` descendants escape overflow clips per the
|
|
139
|
+
// spec, so they aren't added here.
|
|
140
|
+
const overflowClipForHoisted = new Map();
|
|
141
|
+
// DM-673: maps an overflow-clip ancestor element to the clip-path id that
|
|
142
|
+
// its `renderElement` generated when emitting its own `<g clip-path>`
|
|
143
|
+
// wrapper. The hoisted-descendant emission re-uses the same id so the
|
|
144
|
+
// `<defs>` block isn't duplicated. The ancestor renders BEFORE its
|
|
145
|
+
// hoisted descendants in `topLevelFlat` order (sections paint at body's
|
|
146
|
+
// step 3 / base bucket; positioned descendants at step 6 / zeroOrAuto
|
|
147
|
+
// bucket), so the id is populated before lookup.
|
|
148
|
+
const overflowClipPathIds = new Map();
|
|
127
149
|
// DM-493: top-level mask fragment defs collected at capture time. Map keyed
|
|
128
150
|
// by the original DOM id; the renderer mints a per-element mask def whose
|
|
129
151
|
// content is translated into the masked element's user-space coordinates,
|
|
@@ -156,6 +178,50 @@ hiDPIFactor = 2) {
|
|
|
156
178
|
}
|
|
157
179
|
let fragmentMaskCounter = 0;
|
|
158
180
|
const fragmentMaskOutputId = new Map();
|
|
181
|
+
// DM-826: top-level clip-path fragment defs (`clip-path: url("#id")`).
|
|
182
|
+
// Unlike masks, clipPath fragments don't need per-element positioning when
|
|
183
|
+
// `clipPathUnits="objectBoundingBox"` (SVG auto-scales into the masked
|
|
184
|
+
// element's bbox natively) — so the resolver returns ONE output id per
|
|
185
|
+
// source fragment and every consumer references the same def. For
|
|
186
|
+
// `userSpaceOnUse` clipPaths we currently emit the def verbatim too;
|
|
187
|
+
// faithful support across captured (x, y) origins is deferred (see
|
|
188
|
+
// docs/39).
|
|
189
|
+
const fragmentClipPathDefs = new Map();
|
|
190
|
+
for (const root of elements) {
|
|
191
|
+
if (root.clipPathDefs == null)
|
|
192
|
+
continue;
|
|
193
|
+
for (const def of root.clipPathDefs) {
|
|
194
|
+
if (!fragmentClipPathDefs.has(def.id))
|
|
195
|
+
fragmentClipPathDefs.set(def.id, def);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
let fragmentClipPathCounter = 0;
|
|
199
|
+
const fragmentClipPathOutputId = new Map();
|
|
200
|
+
function resolveFragmentClipPathRef(clipPathCss) {
|
|
201
|
+
// Strip the optional <geometry-box> keyword so `url(#id) padding-box`
|
|
202
|
+
// matches; the box keyword doesn't affect bbox-relative clipPaths and
|
|
203
|
+
// the deferred userSpaceOnUse path will fold it in separately.
|
|
204
|
+
const stripped = clipPathCss.replace(/\b(?:content-box|padding-box|border-box|margin-box|fill-box|stroke-box|view-box)\b/i, "").trim();
|
|
205
|
+
const m = /^url\(\s*(?:"|')?#([^"')\s]+)(?:"|')?\s*\)$/i.exec(stripped);
|
|
206
|
+
if (m == null)
|
|
207
|
+
return null;
|
|
208
|
+
const fragId = m[1];
|
|
209
|
+
const def = fragmentClipPathDefs.get(fragId);
|
|
210
|
+
if (def == null)
|
|
211
|
+
return null;
|
|
212
|
+
const cached = fragmentClipPathOutputId.get(fragId);
|
|
213
|
+
if (cached != null)
|
|
214
|
+
return cached;
|
|
215
|
+
const outId = `${idPrefix}cpfrag${fragmentClipPathCounter++}`;
|
|
216
|
+
fragmentClipPathOutputId.set(fragId, outId);
|
|
217
|
+
// Reuse the mask rewriter — it's element-name-agnostic at the
|
|
218
|
+
// implementation level (discovers ids, mints prefixed aliases, rewrites
|
|
219
|
+
// href / url() refs). The outer `<clipPath>` element's id becomes
|
|
220
|
+
// `outId`; descendants get the `${idPrefix}fragid-${original}` alias.
|
|
221
|
+
const rewritten = rewriteFragmentMaskDef(def.outerHTML, outId, idPrefix);
|
|
222
|
+
defsParts.push(rewritten);
|
|
223
|
+
return outId;
|
|
224
|
+
}
|
|
159
225
|
function resolveFragmentMaskRef(maskImage, elX, elY, elW, elH) {
|
|
160
226
|
const m = /^url\(\s*(?:"|')?#([^"')\s]+)(?:"|')?\s*\)$/i.exec(maskImage);
|
|
161
227
|
if (m == null)
|
|
@@ -183,6 +249,216 @@ hiDPIFactor = 2) {
|
|
|
183
249
|
defsParts.push(positioned);
|
|
184
250
|
return outId;
|
|
185
251
|
}
|
|
252
|
+
// DM-673: wraps `renderElement` with a `<g clip-path>` group when `el`
|
|
253
|
+
// was hoisted past an overflow-clip ancestor. The ancestor's clip-path
|
|
254
|
+
// id is stashed in `overflowClipPathIds` by the ancestor's own
|
|
255
|
+
// renderElement call (which runs first because sections paint at body's
|
|
256
|
+
// step 3 / base bucket, BEFORE positioned descendants at step 6 /
|
|
257
|
+
// zeroOrAuto bucket). `position:fixed` descendants are never added to
|
|
258
|
+
// the map (CSS Overflow 3 §2.2 — fixed elements escape ancestor
|
|
259
|
+
// overflow clipping), so they aren't wrapped here.
|
|
260
|
+
function renderElementWithOverflowClip(el, depth, parentDisplayForEl) {
|
|
261
|
+
const overflowClipAncestor = overflowClipForHoisted.get(el);
|
|
262
|
+
const clipId = overflowClipAncestor != null ? overflowClipPathIds.get(overflowClipAncestor) : undefined;
|
|
263
|
+
if (clipId == null) {
|
|
264
|
+
renderElement(el, depth, parentDisplayForEl);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const indent = " ".repeat(depth);
|
|
268
|
+
svgParts.push(`${indent}<g clip-path="url(#${clipId})">`);
|
|
269
|
+
renderElement(el, depth, parentDisplayForEl);
|
|
270
|
+
svgParts.push(`${indent}</g>`);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Per-fragment paint for inline elements that wrap onto multiple line
|
|
274
|
+
* boxes. Each entry in `el.inlineFragments` corresponds to one line-box
|
|
275
|
+
* fragment of the inline element. The painted shape per fragment depends
|
|
276
|
+
* on `box-decoration-break`:
|
|
277
|
+
* - `slice` (default): the inline's box is "cut" at line-box boundaries.
|
|
278
|
+
* The first fragment owns the LEFT side + TL/BL corners; the last owns
|
|
279
|
+
* the RIGHT side + TR/BR corners; intermediate fragments paint only
|
|
280
|
+
* top + bottom borders with no corner rounding.
|
|
281
|
+
* - `clone`: every fragment paints a full box (all four sides, all four
|
|
282
|
+
* corners). Outset box-shadow + background-image are also emitted
|
|
283
|
+
* per-fragment.
|
|
284
|
+
* Matches Blink's `InlineBoxFragmentPainter::PaintBoxDecorationBackground`
|
|
285
|
+
* pattern: a per-fragment slice of the inline's logical box, with the
|
|
286
|
+
* non-edge sides suppressed in slice mode.
|
|
287
|
+
*/
|
|
288
|
+
function renderInlineFragments(el, indent, bgColor, corners) {
|
|
289
|
+
const frags = el.inlineFragments;
|
|
290
|
+
const clone = (el.styles.boxDecorationBreak ?? "slice") === "clone";
|
|
291
|
+
const bgImage = el.styles.backgroundImage;
|
|
292
|
+
const hasBgImage = bgImage != null && bgImage !== "none" && bgImage !== "";
|
|
293
|
+
const shadows = parseBoxShadow(el.styles.boxShadow ?? "none");
|
|
294
|
+
// DM-754: fragment axis comes from capture-side `display` inspection —
|
|
295
|
+
// both inline-wrap and multi-column block-level fragmentation produce
|
|
296
|
+
// vertically-stacked frag rects, so we can't reliably tell them apart
|
|
297
|
+
// by geometry. `inline`: first owns LEFT + TL/BL, last owns RIGHT +
|
|
298
|
+
// TR/BR, middle paints top + bottom only. `block`: first owns TOP +
|
|
299
|
+
// TL/TR, last owns BOTTOM + BL/BR, middle paints left + right only.
|
|
300
|
+
const fragsAxisIsBlock = el.fragmentAxis === "block";
|
|
301
|
+
// Per-side captured borders. Uniformity tested for the simple stroke
|
|
302
|
+
// path; mixed-per-side borders on wrapped inlines are rare and fall
|
|
303
|
+
// back to the same per-side emit.
|
|
304
|
+
const sbt = parseSide(el.styles.borderTopWidth, el.styles.borderTopStyle, el.styles.borderTopColor);
|
|
305
|
+
const sbr = parseSide(el.styles.borderRightWidth, el.styles.borderRightStyle, el.styles.borderRightColor);
|
|
306
|
+
const sbb = parseSide(el.styles.borderBottomWidth, el.styles.borderBottomStyle, el.styles.borderBottomColor);
|
|
307
|
+
const sbl = parseSide(el.styles.borderLeftWidth, el.styles.borderLeftStyle, el.styles.borderLeftColor);
|
|
308
|
+
// Per-side border-image-source styling on wrapped inlines is rare enough
|
|
309
|
+
// that we skip it; the bbox path remains the only border-image-aware
|
|
310
|
+
// emitter and is gated off when `useInlineFragments` is set.
|
|
311
|
+
// Background-image layer setup — mirrors the bbox path but parameterised
|
|
312
|
+
// on per-fragment box. background-clip: text isn't supported on inline
|
|
313
|
+
// fragments here (uncommon and would require per-fragment glyph masks).
|
|
314
|
+
const bgImageLayers = hasBgImage ? splitTopLevelCommas(bgImage) : [];
|
|
315
|
+
const bgSizeLayers = splitTopLevelCommas(el.styles.backgroundSize ?? "auto");
|
|
316
|
+
const bgPosLayers = splitTopLevelCommas(el.styles.backgroundPosition ?? "0% 0%");
|
|
317
|
+
const bgRepeatLayers = splitTopLevelCommas(el.styles.backgroundRepeat ?? "repeat");
|
|
318
|
+
const bgClipLayers = splitTopLevelCommas(el.styles.backgroundClip ?? "border-box");
|
|
319
|
+
const bgOriginLayers = splitTopLevelCommas(el.styles.backgroundOrigin ?? "padding-box");
|
|
320
|
+
const bgAttachmentLayers = splitTopLevelCommas(el.styles.backgroundAttachment ?? "scroll");
|
|
321
|
+
const bgIntrinsicLayers = el.styles.backgroundIntrinsic ?? [];
|
|
322
|
+
for (let fi = 0; fi < frags.length; fi++) {
|
|
323
|
+
const f = frags[fi];
|
|
324
|
+
const isFirst = fi === 0;
|
|
325
|
+
const isLast = fi === frags.length - 1;
|
|
326
|
+
// In slice mode the corner radii belong only to the entry/exit edges
|
|
327
|
+
// — which edges, exactly, depends on the fragmentation axis:
|
|
328
|
+
// • inline-axis (wrapped inline): TL/BL on first, TR/BR on last
|
|
329
|
+
// • block-axis (multi-column block): TL/TR on first, BL/BR on last
|
|
330
|
+
// Middle fragments collapse to sharp 90° on all four corners. Clone
|
|
331
|
+
// treats every fragment as a complete box → keep all four corners.
|
|
332
|
+
const fragCorners = clone ? corners : (fragsAxisIsBlock ? {
|
|
333
|
+
tl: isFirst ? corners.tl : { h: 0, v: 0 },
|
|
334
|
+
tr: isFirst ? corners.tr : { h: 0, v: 0 },
|
|
335
|
+
bl: isLast ? corners.bl : { h: 0, v: 0 },
|
|
336
|
+
br: isLast ? corners.br : { h: 0, v: 0 },
|
|
337
|
+
uniform: corners.uniform && isFirst && isLast,
|
|
338
|
+
} : {
|
|
339
|
+
tl: isFirst ? corners.tl : { h: 0, v: 0 },
|
|
340
|
+
bl: isFirst ? corners.bl : { h: 0, v: 0 },
|
|
341
|
+
tr: isLast ? corners.tr : { h: 0, v: 0 },
|
|
342
|
+
br: isLast ? corners.br : { h: 0, v: 0 },
|
|
343
|
+
uniform: corners.uniform && isFirst && isLast,
|
|
344
|
+
});
|
|
345
|
+
// Outset box-shadow. Clone applies shadow to each fragment; slice
|
|
346
|
+
// applies it to the joined shape which would need per-fragment
|
|
347
|
+
// clipping to express in SVG — skip for slice (rare on wrapped
|
|
348
|
+
// inlines that aren't using `clone`).
|
|
349
|
+
if (clone) {
|
|
350
|
+
for (let si = shadows.length - 1; si >= 0; si--) {
|
|
351
|
+
const sh = shadows[si];
|
|
352
|
+
if (sh.inset)
|
|
353
|
+
continue;
|
|
354
|
+
const sx = f.x + sh.x - sh.spread;
|
|
355
|
+
const sy = f.y + sh.y - sh.spread;
|
|
356
|
+
const sw = f.width + sh.spread * 2;
|
|
357
|
+
const sh2 = f.height + sh.spread * 2;
|
|
358
|
+
if (sw <= 0 || sh2 <= 0)
|
|
359
|
+
continue;
|
|
360
|
+
const shadowCorners = outsetCornerRadiiForShadow(fragCorners, sh.spread);
|
|
361
|
+
let filterAttr = "";
|
|
362
|
+
if (sh.blur > 0) {
|
|
363
|
+
const stdDev = sh.blur / 2;
|
|
364
|
+
const fid = `${idPrefix}sh${clipIdx++}`;
|
|
365
|
+
defsParts.push(`<filter id="${fid}" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="${r(stdDev)}"/></filter>`);
|
|
366
|
+
filterAttr = ` filter="url(#${fid})"`;
|
|
367
|
+
}
|
|
368
|
+
svgParts.push(`${indent}${roundedRectSvg(sx, sy, sw, sh2, shadowCorners, `fill="${colorStr(parseColor(sh.color) ?? { r: 0, g: 0, b: 0, a: 0 })}"${filterAttr}`)}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Background color.
|
|
372
|
+
if (bgColor != null && bgColor.a > 0.01) {
|
|
373
|
+
svgParts.push(`${indent}${roundedRectSvg(f.x, f.y, f.width, f.height, fragCorners, `fill="${colorStr(bgColor)}"`)}`);
|
|
374
|
+
}
|
|
375
|
+
// Background image layers (clone only — slice would need cross-
|
|
376
|
+
// fragment continuation of the gradient/image which is out of scope
|
|
377
|
+
// here; the bbox path remains the slice-mode gradient owner via the
|
|
378
|
+
// fallback emit when fragmentation isn't detected).
|
|
379
|
+
if (clone && hasBgImage) {
|
|
380
|
+
for (let li = bgImageLayers.length - 1; li >= 0; li--) {
|
|
381
|
+
const layer = bgImageLayers[li].trim();
|
|
382
|
+
const layerSize = (bgSizeLayers[li] ?? bgSizeLayers[0] ?? "auto").trim();
|
|
383
|
+
const layerPos = (bgPosLayers[li] ?? bgPosLayers[0] ?? "0% 0%").trim();
|
|
384
|
+
const layerRepeat = (bgRepeatLayers[li] ?? bgRepeatLayers[0] ?? "repeat").trim();
|
|
385
|
+
const layerClip = (bgClipLayers[li] ?? bgClipLayers[0] ?? "border-box").trim();
|
|
386
|
+
const layerIntrinsic = bgIntrinsicLayers[li] ?? null;
|
|
387
|
+
const layerAttachment = (bgAttachmentLayers[li] ?? bgAttachmentLayers[0] ?? "scroll").trim();
|
|
388
|
+
if (layerClip === "text")
|
|
389
|
+
continue;
|
|
390
|
+
const defId = `${idPrefix}bgf${clipIdx++}`;
|
|
391
|
+
const out = buildBackgroundLayerDef(defId, layer, f.x, f.y, f.width, f.height, layerSize, layerPos, layerRepeat, layerIntrinsic, layerAttachment, captureViewport);
|
|
392
|
+
if (out.def === "")
|
|
393
|
+
continue;
|
|
394
|
+
defsParts.push(out.def);
|
|
395
|
+
svgParts.push(`${indent}${roundedRectSvg(f.x, f.y, f.width, f.height, fragCorners, `fill="url(#${defId})"`)}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Per-side borders. The suppressed sides depend on fragmentation axis:
|
|
399
|
+
// • inline-axis slice: suppress LEFT on non-first, RIGHT on non-last;
|
|
400
|
+
// keep TOP + BOTTOM on every fragment (wrapped-inline behavior).
|
|
401
|
+
// • block-axis slice: suppress TOP on non-first, BOTTOM on non-last;
|
|
402
|
+
// keep LEFT + RIGHT on every fragment (multi-column block-level).
|
|
403
|
+
// Clone always keeps all four sides. Solid-style only (the typical use
|
|
404
|
+
// cases are solid); fall back to a single inset stroke for the
|
|
405
|
+
// uniform-color case.
|
|
406
|
+
const wantTop = clone || (fragsAxisIsBlock ? isFirst : true);
|
|
407
|
+
const wantBottom = clone || (fragsAxisIsBlock ? isLast : true);
|
|
408
|
+
const wantLeft = clone || (fragsAxisIsBlock ? true : isFirst);
|
|
409
|
+
const wantRight = clone || (fragsAxisIsBlock ? true : isLast);
|
|
410
|
+
const drawSide = (side, x1, y1, x2, y2) => {
|
|
411
|
+
if (side == null || side.w <= 0 || side.color.a < 0.01)
|
|
412
|
+
return;
|
|
413
|
+
if (side.style === "none" || side.style === "hidden")
|
|
414
|
+
return;
|
|
415
|
+
const dash = dashArrayForStyle(side.style, side.w);
|
|
416
|
+
const dashAttr = dash !== "" ? ` stroke-dasharray="${dash}"` : "";
|
|
417
|
+
const linecap = side.style === "dotted" ? ` stroke-linecap="round"` : "";
|
|
418
|
+
svgParts.push(`${indent}<line x1="${r(x1)}" y1="${r(y1)}" x2="${r(x2)}" y2="${r(y2)}" stroke="${colorStr(side.color)}" stroke-width="${r(side.w)}"${dashAttr}${linecap} />`);
|
|
419
|
+
};
|
|
420
|
+
// Uniform border with rounded corners: emit a clipped <path> stroke
|
|
421
|
+
// around the per-fragment outline (skipping the suppressed sides).
|
|
422
|
+
// To keep it simple, only emit the rounded-rect stroke path when all
|
|
423
|
+
// four sides are wanted (clone, or first-and-last). Otherwise fall
|
|
424
|
+
// back to four `<line>` strokes which work correctly for square
|
|
425
|
+
// corners (the slice path on middle fragments has square corners
|
|
426
|
+
// anyway).
|
|
427
|
+
const allFourWanted = wantTop && wantBottom && wantLeft && wantRight;
|
|
428
|
+
const sidesUniformColor = sbt != null && sbr != null && sbb != null && sbl != null
|
|
429
|
+
&& sbt.w === sbr.w && sbr.w === sbb.w && sbb.w === sbl.w
|
|
430
|
+
&& sbt.style === sbr.style && sbr.style === sbb.style && sbb.style === sbl.style
|
|
431
|
+
&& sameColor(sbt.color, sbr.color) && sameColor(sbr.color, sbb.color) && sameColor(sbb.color, sbl.color);
|
|
432
|
+
const anyCorner = fragCorners.tl.h > 0 || fragCorners.tr.h > 0 || fragCorners.br.h > 0 || fragCorners.bl.h > 0;
|
|
433
|
+
if (sbt != null && sidesUniformColor && allFourWanted && anyCorner && sbt.w > 0 && sbt.style !== "none" && sbt.style !== "hidden") {
|
|
434
|
+
const half = sbt.w / 2;
|
|
435
|
+
const strokeCorners = insetCornerRadii(fragCorners, half, half, half, half);
|
|
436
|
+
const dash = dashArrayForStyle(sbt.style, sbt.w);
|
|
437
|
+
const dashAttr = dash !== "" ? ` stroke-dasharray="${dash}"` : "";
|
|
438
|
+
const linecap = sbt.style === "dotted" ? ` stroke-linecap="round"` : "";
|
|
439
|
+
svgParts.push(`${indent}${roundedRectSvg(f.x + half, f.y + half, Math.max(0, f.width - sbt.w), Math.max(0, f.height - sbt.w), strokeCorners, `fill="none" stroke="${colorStr(sbt.color)}" stroke-width="${r(sbt.w)}"${dashAttr}${linecap}`)}`);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
// Per-side strokes anchored at the inner half-width inset so they
|
|
443
|
+
// sit inside the border-box (matching Chrome). For slice-mode
|
|
444
|
+
// middle fragments there are no corners so straight lines suffice.
|
|
445
|
+
const tw = sbt?.w ?? 0;
|
|
446
|
+
const rw = sbr?.w ?? 0;
|
|
447
|
+
const bw = sbb?.w ?? 0;
|
|
448
|
+
const lw = sbl?.w ?? 0;
|
|
449
|
+
const xL = f.x, xR = f.x + f.width, yT = f.y, yB = f.y + f.height;
|
|
450
|
+
// Top / bottom span the full fragment width.
|
|
451
|
+
if (wantTop)
|
|
452
|
+
drawSide(sbt, xL, yT + tw / 2, xR, yT + tw / 2);
|
|
453
|
+
if (wantBottom)
|
|
454
|
+
drawSide(sbb, xL, yB - bw / 2, xR, yB - bw / 2);
|
|
455
|
+
if (wantLeft)
|
|
456
|
+
drawSide(sbl, xL + lw / 2, yT, xL + lw / 2, yB);
|
|
457
|
+
if (wantRight)
|
|
458
|
+
drawSide(sbr, xR - rw / 2, yT, xR - rw / 2, yB);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
186
462
|
function renderElement(el, depth, parentDisplayForEl) {
|
|
187
463
|
const indent = " ".repeat(depth);
|
|
188
464
|
const bgColor = parseColor(el.styles.backgroundColor);
|
|
@@ -209,6 +485,14 @@ hiDPIFactor = 2) {
|
|
|
209
485
|
return;
|
|
210
486
|
// empty-cells: hide — suppress bg + border on empty <td>/<th>.
|
|
211
487
|
const suppressEmptyCell = el.styles.emptyCellsHidden === true;
|
|
488
|
+
// Inline elements that wrap across multiple line boxes (CSS Backgrounds 3
|
|
489
|
+
// §3.7 box-decoration-break): capture stashes per-fragment rects in
|
|
490
|
+
// `el.inlineFragments`. When set, paint the background + border per
|
|
491
|
+
// fragment instead of once across the bbox. `slice` (default) cuts the
|
|
492
|
+
// box at fragment boundaries — the first fragment owns the left side and
|
|
493
|
+
// the last owns the right; middle fragments paint only top + bottom.
|
|
494
|
+
// `clone` paints a complete box on every fragment.
|
|
495
|
+
const useInlineFragments = el.inlineFragments != null && el.inlineFragments.length > 1;
|
|
212
496
|
// Element opacity applies to the background, border, text, and all descendants.
|
|
213
497
|
// Emit a group wrapper when opacity < 1 so the whole subtree tints uniformly.
|
|
214
498
|
// Also open a group to host CSS filter / mix-blend-mode — both are honored by
|
|
@@ -226,11 +510,55 @@ hiDPIFactor = 2) {
|
|
|
226
510
|
const clipPathCss = el.styles.clipPath && el.styles.clipPath !== "none" ? el.styles.clipPath : "";
|
|
227
511
|
let clipPathUrlId = null;
|
|
228
512
|
if (clipPathCss !== "") {
|
|
229
|
-
|
|
513
|
+
// DM-818: CSS clip-path accepts an optional `<geometry-box>` keyword
|
|
514
|
+
// (`content-box` / `padding-box` / `border-box` / `margin-box` /
|
|
515
|
+
// `fill-box` / `stroke-box` / `view-box`) that specifies which box
|
|
516
|
+
// the shape is positioned relative to. Strip it before passing the
|
|
517
|
+
// value to the shape translator and inset (x, y, w, h) accordingly.
|
|
518
|
+
// `border-box` is the default and matches the captured element rect
|
|
519
|
+
// — no inset. We don't model margin-box / fill-box / stroke-box /
|
|
520
|
+
// view-box explicitly; the first falls back to border-box (close
|
|
521
|
+
// enough for the html-test fixtures), the SVG-specific ones don't
|
|
522
|
+
// apply to HTML elements.
|
|
523
|
+
const geoBoxMatch = /\b(content-box|padding-box|border-box|margin-box|fill-box|stroke-box|view-box)\b/i.exec(clipPathCss);
|
|
524
|
+
const geoBox = geoBoxMatch != null ? geoBoxMatch[1].toLowerCase() : "border-box";
|
|
525
|
+
const shapeValue = geoBoxMatch != null ? (clipPathCss.slice(0, geoBoxMatch.index) + clipPathCss.slice(geoBoxMatch.index + geoBoxMatch[0].length)).trim() : clipPathCss;
|
|
526
|
+
const bwT = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
|
|
527
|
+
const bwR = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
|
|
528
|
+
const bwB = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
|
|
529
|
+
const bwL = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
|
|
530
|
+
const pdT = parseFloat(el.styles.paddingTop ?? "0") || 0;
|
|
531
|
+
const pdR = parseFloat(el.styles.paddingRight ?? "0") || 0;
|
|
532
|
+
const pdB = parseFloat(el.styles.paddingBottom ?? "0") || 0;
|
|
533
|
+
const pdL = parseFloat(el.styles.paddingLeft ?? "0") || 0;
|
|
534
|
+
let cpX = el.x, cpY = el.y, cpW = el.width, cpH = el.height;
|
|
535
|
+
if (geoBox === "padding-box" || geoBox === "content-box") {
|
|
536
|
+
cpX += bwL;
|
|
537
|
+
cpY += bwT;
|
|
538
|
+
cpW -= bwL + bwR;
|
|
539
|
+
cpH -= bwT + bwB;
|
|
540
|
+
if (geoBox === "content-box") {
|
|
541
|
+
cpX += pdL;
|
|
542
|
+
cpY += pdT;
|
|
543
|
+
cpW -= pdL + pdR;
|
|
544
|
+
cpH -= pdT + pdB;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const shape = translateClipPath(shapeValue, cpX, cpY, Math.max(0, cpW), Math.max(0, cpH));
|
|
230
548
|
if (shape !== "") {
|
|
231
549
|
clipPathUrlId = `${idPrefix}cp${clipIdx++}`;
|
|
232
550
|
defsParts.push(`<clipPath id="${clipPathUrlId}">${shape}</clipPath>`);
|
|
233
551
|
}
|
|
552
|
+
else {
|
|
553
|
+
// DM-826: shape translator returned "" — try the inline-`<clipPath>`
|
|
554
|
+
// fragment-ref path next. `clip-path: url(#id)` resolves against the
|
|
555
|
+
// top-level `clipPathDefs` collected at capture time; the def is
|
|
556
|
+
// emitted into `<defs>` once and the masked element's wrapper `<g>`
|
|
557
|
+
// gets `clip-path="url(#${outId})"`. See docs/39.
|
|
558
|
+
const fragId = resolveFragmentClipPathRef(clipPathCss);
|
|
559
|
+
if (fragId != null)
|
|
560
|
+
clipPathUrlId = fragId;
|
|
561
|
+
}
|
|
234
562
|
}
|
|
235
563
|
// DM-587: overflow != visible on either axis clips painted descendants
|
|
236
564
|
// at the element's box. Chrome's captured tree faithfully records every
|
|
@@ -249,7 +577,21 @@ hiDPIFactor = 2) {
|
|
|
249
577
|
const oyV = el.styles.overflowY;
|
|
250
578
|
const oxClips = oxV != null && oxV !== "visible";
|
|
251
579
|
const oyClips = oyV != null && oyV !== "visible";
|
|
252
|
-
|
|
580
|
+
// DM-650: per CSS Overflow Module Level 3 §3.3, when <body>'s overflow
|
|
581
|
+
// is non-visible and <html>'s overflow is visible (the default), the
|
|
582
|
+
// body's overflow is propagated to the viewport — i.e. body itself
|
|
583
|
+
// renders WITHOUT clipping, and the page-level scroll handles the
|
|
584
|
+
// overflow. This is what NYT desktop relies on: body { height: 100vh;
|
|
585
|
+
// overflow: hidden auto } but the page scrolls at the document level
|
|
586
|
+
// because <html> has overflow: visible. If we applied body's overflow
|
|
587
|
+
// as a clip on body's own bbox, scroll-mode segments at scrollY > 0
|
|
588
|
+
// would clip every descendant out (body.y becomes -scrollY < 0; the
|
|
589
|
+
// clip rect ends at body.y + 100vh = 0, so anything below would be
|
|
590
|
+
// hidden). We don't capture <html>'s computed style, so this skip is
|
|
591
|
+
// a conservative match against the overwhelmingly common case of
|
|
592
|
+
// html { overflow: visible }.
|
|
593
|
+
const isBodyOverflowPropagated = el.tag === "body";
|
|
594
|
+
if ((oxClips || oyClips) && !isBodyOverflowPropagated) {
|
|
253
595
|
// The CSS `outline` is painted OUTSIDE the border box and is NOT
|
|
254
596
|
// affected by the element's own overflow per CSS Backgrounds 3 §3 +
|
|
255
597
|
// Basic UI 4 §8 — outline isn't part of the element's content area.
|
|
@@ -262,15 +604,137 @@ hiDPIFactor = 2) {
|
|
|
262
604
|
const ostyle_ = el.styles.outlineStyle ?? "none";
|
|
263
605
|
const ohas = ow_ > 0 && ostyle_ !== "none" && ostyle_ !== "hidden";
|
|
264
606
|
const oOffset_ = ohas ? (parseFloat(el.styles.outlineOffset ?? "0") || 0) : 0;
|
|
265
|
-
const
|
|
607
|
+
const outlineInflate = ohas ? Math.max(0, oOffset_ + ow_) : 0;
|
|
608
|
+
// DM-745: outset box-shadow paints OUTSIDE the element's box and is
|
|
609
|
+
// also unaffected by the element's own overflow per CSS Backgrounds
|
|
610
|
+
// 3 §6.4 — only the element's content / background is clipped to
|
|
611
|
+
// its overflow region, not the decorative shadow. The popover in
|
|
612
|
+
// `niche-command-invokers` has an implicit `overflow: auto` (UA
|
|
613
|
+
// popover rule) and a `box-shadow: 0 30px 60px rgba(15, 23, 42,
|
|
614
|
+
// 0.2)`; without inflating for the shadow's max extent, the clip
|
|
615
|
+
// rect cropped the shadow ink down to a thin sliver inside the
|
|
616
|
+
// popover box. Inflate per-side by `|offset| + spread + blur` so
|
|
617
|
+
// the shadow's full painted area survives.
|
|
618
|
+
const shadowsForClip = parseBoxShadow(el.styles.boxShadow ?? "none");
|
|
619
|
+
let shadowInflateT = 0, shadowInflateR = 0, shadowInflateB = 0, shadowInflateL = 0;
|
|
620
|
+
for (const sh of shadowsForClip) {
|
|
621
|
+
if (sh.inset)
|
|
622
|
+
continue;
|
|
623
|
+
const reach = sh.spread + sh.blur;
|
|
624
|
+
// Per-side ink extent: spread + blur, plus the shadow's offset
|
|
625
|
+
// pushed in the matching direction. Clamp to 0 so an offset that
|
|
626
|
+
// pulls the shadow away from a side doesn't shrink the inflate.
|
|
627
|
+
shadowInflateT = Math.max(shadowInflateT, reach + Math.max(0, -sh.y));
|
|
628
|
+
shadowInflateR = Math.max(shadowInflateR, reach + Math.max(0, sh.x));
|
|
629
|
+
shadowInflateB = Math.max(shadowInflateB, reach + Math.max(0, sh.y));
|
|
630
|
+
shadowInflateL = Math.max(shadowInflateL, reach + Math.max(0, -sh.x));
|
|
631
|
+
}
|
|
632
|
+
// DM-761: when `overflow: clip` is set with `overflow-clip-margin`,
|
|
633
|
+
// the paint clip extends outward from a reference box (content /
|
|
634
|
+
// padding / border) by a length. The outer clip emitted here wraps
|
|
635
|
+
// the host's own paint (including overflow-clip-margin extension) so
|
|
636
|
+
// a child that overflows past the border-box stays visible up to
|
|
637
|
+
// (ref-box edge + margin). Inflate this outer rect by the part of
|
|
638
|
+
// the margin that falls OUTSIDE the border-box; the inner per-axis
|
|
639
|
+
// clip below applies the tight ref-box-relative bound.
|
|
640
|
+
let ocmInflate = 0;
|
|
641
|
+
const isClipOverflow_ = oxV === "clip" || oyV === "clip";
|
|
642
|
+
const ocmRaw_ = el.styles.overflowClipMargin;
|
|
643
|
+
if (isClipOverflow_ && ocmRaw_ != null && ocmRaw_ !== "" && ocmRaw_ !== "0px") {
|
|
644
|
+
const m = /^(?:(content-box|padding-box|border-box)\s+)?(-?\d*\.?\d+)px$/i.exec(ocmRaw_.trim());
|
|
645
|
+
if (m) {
|
|
646
|
+
const refBox = (m[1] ?? "padding-box").toLowerCase();
|
|
647
|
+
const margin = parseFloat(m[2]);
|
|
648
|
+
const cbtv = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
|
|
649
|
+
const cbrv = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
|
|
650
|
+
const cbbv = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
|
|
651
|
+
const cblv = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
|
|
652
|
+
let offT = 0, offR = 0, offB = 0, offL = 0;
|
|
653
|
+
if (refBox === "padding-box") {
|
|
654
|
+
offT = cbtv;
|
|
655
|
+
offR = cbrv;
|
|
656
|
+
offB = cbbv;
|
|
657
|
+
offL = cblv;
|
|
658
|
+
}
|
|
659
|
+
else if (refBox === "content-box") {
|
|
660
|
+
offT = cbtv + (parseFloat(el.styles.paddingTop ?? "0") || 0);
|
|
661
|
+
offR = cbrv + (parseFloat(el.styles.paddingRight ?? "0") || 0);
|
|
662
|
+
offB = cbbv + (parseFloat(el.styles.paddingBottom ?? "0") || 0);
|
|
663
|
+
offL = cblv + (parseFloat(el.styles.paddingLeft ?? "0") || 0);
|
|
664
|
+
}
|
|
665
|
+
ocmInflate = Math.max(0, margin - Math.min(offT, offR, offB, offL));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const inflateT = Math.max(outlineInflate, shadowInflateT, ocmInflate);
|
|
669
|
+
const inflateR = Math.max(outlineInflate, shadowInflateR, ocmInflate);
|
|
670
|
+
const inflateB = Math.max(outlineInflate, shadowInflateB, ocmInflate);
|
|
671
|
+
const inflateL = Math.max(outlineInflate, shadowInflateL, ocmInflate);
|
|
672
|
+
// DM-787: per-axis `overflow-x: clip; overflow-y: visible` (or the
|
|
673
|
+
// inverse) needs the outer clip to NOT bind on the visible axis. A
|
|
674
|
+
// huge ±100000 extension lets descendants paint past the border-box
|
|
675
|
+
// on that axis while the clipped axis stays bounded.
|
|
676
|
+
const UNBOUNDED_CP = 100000;
|
|
677
|
+
const xVisibleCp = oxV === "visible" && oyV === "clip";
|
|
678
|
+
const yVisibleCp = oyV === "visible" && oxV === "clip";
|
|
679
|
+
const cpX = xVisibleCp ? el.x - UNBOUNDED_CP : el.x - inflateL;
|
|
680
|
+
const cpW = xVisibleCp ? el.width + UNBOUNDED_CP * 2 : el.width + inflateL + inflateR;
|
|
681
|
+
const cpY = yVisibleCp ? el.y - UNBOUNDED_CP : el.y - inflateT;
|
|
682
|
+
const cpH = yVisibleCp ? el.height + UNBOUNDED_CP * 2 : el.height + inflateT + inflateB;
|
|
266
683
|
clipPathUrlId = `${idPrefix}cp${clipIdx++}`;
|
|
267
|
-
defsParts.push(`<clipPath id="${clipPathUrlId}"><rect x="${r(
|
|
684
|
+
defsParts.push(`<clipPath id="${clipPathUrlId}"><rect x="${r(cpX)}" y="${r(cpY)}" width="${r(cpW)}" height="${r(cpH)}"/></clipPath>`);
|
|
268
685
|
}
|
|
269
686
|
}
|
|
270
687
|
// mask: if mask-image is a gradient or url(), translate it to an SVG <mask>.
|
|
271
|
-
|
|
688
|
+
// DM-758 / DM-793: `mask-border-source` (legacy `-webkit-mask-box-image`)
|
|
689
|
+
// layers on top of `mask-image`. The two cases:
|
|
690
|
+
// 1. Simple full-image (slice 0/1 [fill] + width 0 + outset 0): emit a
|
|
691
|
+
// single `<image preserveAspectRatio="none">` inside a `<mask>` so
|
|
692
|
+
// the source stretches to the element rect (matches Chrome for the
|
|
693
|
+
// `mb-grad` gradient case and the `mb-wide` URL case).
|
|
694
|
+
// 2. True 9-slice (mb-1 / mb-2 / mb-3 / mb-outset — non-zero `width`,
|
|
695
|
+
// `outset`, or non-trivial `slice` with `round` / `space` / `stretch`
|
|
696
|
+
// repeat): construct a 9-piece mask from corner / edge / center
|
|
697
|
+
// slices, mirroring the existing `renderBorderImage` 9-slice logic
|
|
698
|
+
// in `borders.ts` but emitting the pieces inside a `<mask>` instead
|
|
699
|
+
// of as direct paint. `mask-border-mode` defaults to `alpha` per
|
|
700
|
+
// spec, so the source's alpha channel drives the mask.
|
|
701
|
+
const mbSrc = el.styles.maskBorderSource;
|
|
702
|
+
const mbHasSrc = mbSrc != null && mbSrc !== "" && mbSrc !== "none";
|
|
703
|
+
const mbWidth = (el.styles.maskBorderWidth ?? "0").trim();
|
|
704
|
+
const mbOutset = (el.styles.maskBorderOutset ?? "0").trim();
|
|
705
|
+
const mbSlice = (el.styles.maskBorderSlice ?? "").trim();
|
|
706
|
+
const mbWidthZero = mbWidth === "0" || mbWidth === "0px" || /^(0(?:px)?\s+){0,3}0(?:px)?$/.test(mbWidth);
|
|
707
|
+
const mbOutsetZero = mbOutset === "0" || mbOutset === "0px" || /^(0(?:px)?\s+){0,3}0(?:px)?$/.test(mbOutset);
|
|
708
|
+
const mbSliceFull = /^[01]\s+fill$/.test(mbSlice) || mbSlice === "1" || mbSlice === "0";
|
|
709
|
+
const mbIsGradient = mbHasSrc && /-gradient\(/i.test(mbSrc);
|
|
710
|
+
const mbUrlHref = mbHasSrc ? parseCssUrl(mbSrc) : null;
|
|
711
|
+
const mbIsUrl = mbUrlHref != null;
|
|
712
|
+
const mbIsSimple = mbHasSrc && mbWidthZero && mbOutsetZero && mbSliceFull;
|
|
713
|
+
const usingMaskBorderUrlSimple = mbIsSimple && mbIsUrl && mbUrlHref != null;
|
|
714
|
+
const usingMaskBorderGradient = mbIsSimple && mbIsGradient;
|
|
715
|
+
const usingMaskBorder9Slice = mbHasSrc && mbIsUrl && mbUrlHref != null && !mbIsSimple
|
|
716
|
+
&& el.styles.maskBorderIntrinsicWidth != null && el.styles.maskBorderIntrinsicHeight != null
|
|
717
|
+
&& el.styles.maskBorderIntrinsicWidth > 0 && el.styles.maskBorderIntrinsicHeight > 0;
|
|
718
|
+
const maskImage = usingMaskBorderGradient ? mbSrc : el.styles.maskImage;
|
|
272
719
|
let maskUrlId = null;
|
|
273
|
-
if (
|
|
720
|
+
if (usingMaskBorderUrlSimple && mbUrlHref != null) {
|
|
721
|
+
const dataUri = embedResizedDataUri(mbUrlHref, el.width, el.height);
|
|
722
|
+
const mid = `${idPrefix}mk${clipIdx++}`;
|
|
723
|
+
defsParts.push(`<mask id="${mid}" maskUnits="userSpaceOnUse" mask-type="alpha">`
|
|
724
|
+
+ `<image href="${esc(dataUri)}" x="${r(el.x)}" y="${r(el.y)}" width="${r(el.width)}" height="${r(el.height)}" preserveAspectRatio="none" />`
|
|
725
|
+
+ `</mask>`);
|
|
726
|
+
maskUrlId = mid;
|
|
727
|
+
}
|
|
728
|
+
else if (usingMaskBorder9Slice && mbUrlHref != null) {
|
|
729
|
+
const mid = `${idPrefix}mk${clipIdx++}`;
|
|
730
|
+
const built = buildMaskBorder9Slice(el, mbUrlHref, mbSlice, mbWidth, mbOutset, el.styles.maskBorderRepeat ?? "stretch", mid, idPrefix, clipIdx);
|
|
731
|
+
if (built != null) {
|
|
732
|
+
defsParts.push(built.def);
|
|
733
|
+
clipIdx = built.nextClipIdx;
|
|
734
|
+
maskUrlId = built.id;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
else if (maskImage != null && maskImage !== "none" && maskImage !== "") {
|
|
274
738
|
// DM-493: same-document fragment refs (mask-image: url("#id")) emit the
|
|
275
739
|
// captured inline <mask> verbatim with id rewriting, bypassing the
|
|
276
740
|
// gradient/url() emission path.
|
|
@@ -279,7 +743,48 @@ hiDPIFactor = 2) {
|
|
|
279
743
|
maskUrlId = fragRef;
|
|
280
744
|
}
|
|
281
745
|
else {
|
|
282
|
-
|
|
746
|
+
// DM-758: when the source comes from `mask-border-source`, force
|
|
747
|
+
// size 100% 100% / no-repeat so the mask stretches across the
|
|
748
|
+
// element — matches Chrome's paint for `slice: 1 / 0` and
|
|
749
|
+
// `slice: 0 fill / 0` patterns. The `mask-border-mode` defaults to
|
|
750
|
+
// alpha vs the regular `mask-mode` default of `match-source`, but
|
|
751
|
+
// `match-source` already does the right thing for gradient sources
|
|
752
|
+
// (alpha-mode on grayscale gradients).
|
|
753
|
+
const maskSize = usingMaskBorderGradient ? "100% 100%" : (el.styles.maskSize ?? "auto");
|
|
754
|
+
const maskPosition = usingMaskBorderGradient ? "0% 0%" : (el.styles.maskPosition ?? "0% 0%");
|
|
755
|
+
const maskRepeat = usingMaskBorderGradient ? "no-repeat" : (el.styles.maskRepeat ?? "repeat");
|
|
756
|
+
// DM-820: honor `mask-clip` by insetting the mask paint region.
|
|
757
|
+
// `border-box` (default) leaves the border-box rect; `padding-box`
|
|
758
|
+
// insets by border widths; `content-box` insets by border + padding.
|
|
759
|
+
// For uniform masks (e.g. `linear-gradient(black, black)`) this
|
|
760
|
+
// matches Chrome's "mask is transparent outside the clip box" rule
|
|
761
|
+
// exactly. For position-sensitive gradients the layer is still
|
|
762
|
+
// sized to the clip box rather than the origin box (mask-origin
|
|
763
|
+
// not yet captured), which is a visible diff only when both differ
|
|
764
|
+
// — no fixtures exercise that combination today.
|
|
765
|
+
const maskClip = el.styles.maskClip ?? "border-box";
|
|
766
|
+
let maskX = el.x, maskY = el.y, maskW = el.width, maskH = el.height;
|
|
767
|
+
if (maskClip === "padding-box" || maskClip === "content-box") {
|
|
768
|
+
const bt = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
|
|
769
|
+
const br = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
|
|
770
|
+
const bb = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
|
|
771
|
+
const bl = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
|
|
772
|
+
maskX += bl;
|
|
773
|
+
maskY += bt;
|
|
774
|
+
maskW -= bl + br;
|
|
775
|
+
maskH -= bt + bb;
|
|
776
|
+
if (maskClip === "content-box") {
|
|
777
|
+
const pt = parseFloat(el.styles.paddingTop ?? "0") || 0;
|
|
778
|
+
const pr = parseFloat(el.styles.paddingRight ?? "0") || 0;
|
|
779
|
+
const pb = parseFloat(el.styles.paddingBottom ?? "0") || 0;
|
|
780
|
+
const pl = parseFloat(el.styles.paddingLeft ?? "0") || 0;
|
|
781
|
+
maskX += pl;
|
|
782
|
+
maskY += pt;
|
|
783
|
+
maskW -= pl + pr;
|
|
784
|
+
maskH -= pt + pb;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const maskDef = buildMaskDef(`${idPrefix}mk${clipIdx++}`, maskImage, maskX, maskY, Math.max(0, maskW), Math.max(0, maskH), el.styles.maskMode ?? "match-source", maskSize, maskPosition, maskRepeat, el.styles.maskComposite ?? "add", elementMaskRasters);
|
|
283
788
|
if (maskDef.def !== "") {
|
|
284
789
|
maskUrlId = maskDef.id;
|
|
285
790
|
defsParts.push(maskDef.def);
|
|
@@ -347,8 +852,16 @@ hiDPIFactor = 2) {
|
|
|
347
852
|
// skipped from paint.
|
|
348
853
|
if (el.cullClass != null && el.cullClass !== "")
|
|
349
854
|
groupAttrs.push(`class="${esc(el.cullClass)}"`);
|
|
855
|
+
// DM-704: SVG applies `filter` BEFORE `clip-path` when both sit on the
|
|
856
|
+
// same `<g>` (the spec: "the filter is applied to the source graphic
|
|
857
|
+
// before the clip path"). For drop-shadow / blur, that means the
|
|
858
|
+
// filter's ink area extends beyond the element box but then gets
|
|
859
|
+
// clipped back to the box and never paints — the shadow vanishes. Hoist
|
|
860
|
+
// `filter` onto an OUTER wrapper so it processes already-clipped
|
|
861
|
+
// content; the unclipped ink area then renders.
|
|
862
|
+
const needsFilterOuter = filterCss !== "" && (clipPathUrlId != null || maskUrlId != null);
|
|
350
863
|
const styleParts = [];
|
|
351
|
-
if (filterCss !== "")
|
|
864
|
+
if (filterCss !== "" && !needsFilterOuter)
|
|
352
865
|
styleParts.push(`filter:${filterCss}`);
|
|
353
866
|
if (blendCss !== "")
|
|
354
867
|
styleParts.push(`mix-blend-mode:${blendCss}`);
|
|
@@ -362,6 +875,8 @@ hiDPIFactor = 2) {
|
|
|
362
875
|
if (styleParts.length > 0)
|
|
363
876
|
groupAttrs.push(`style="${esc(styleParts.join(";"))}"`);
|
|
364
877
|
const opened = needsGroup;
|
|
878
|
+
if (needsFilterOuter)
|
|
879
|
+
svgParts.push(`${indent}<g style="${esc(`filter:${filterCss}`)}">`);
|
|
365
880
|
if (opened)
|
|
366
881
|
svgParts.push(`${indent}<g ${groupAttrs.join(" ")}>`);
|
|
367
882
|
// Inner anim-class wrapper sits INSIDE any visibility/transform group so
|
|
@@ -369,20 +884,33 @@ hiDPIFactor = 2) {
|
|
|
369
884
|
// each carry their own `animation` shorthand without clobbering.
|
|
370
885
|
if (animClass !== "")
|
|
371
886
|
svgParts.push(`${indent}<g class="${animClass}">`);
|
|
887
|
+
// Inline-fragment paint: when the element wraps across multiple line
|
|
888
|
+
// boxes and the bbox-based paint would smear background + border across
|
|
889
|
+
// the whole logical inline (typically the full container width), paint
|
|
890
|
+
// each line fragment individually. The remaining bbox-based emissions
|
|
891
|
+
// (outset shadow, bg color, bg image, inset shadow, border-image,
|
|
892
|
+
// border) are gated below on `!useInlineFragments` so they don't double
|
|
893
|
+
// up. Outline still paints around the bbox — it's outside the box and
|
|
894
|
+
// CSS doesn't fragment it per inline line box.
|
|
895
|
+
if (useInlineFragments) {
|
|
896
|
+
renderInlineFragments(el, indent, bgColor, corners);
|
|
897
|
+
}
|
|
372
898
|
// Outset box-shadow (SK-1101 + SK-1113): paints BENEATH the element box.
|
|
373
899
|
// CSS spec says the first shadow in the list is closest to the element;
|
|
374
900
|
// later shadows sit further behind. SVG paints later in document order,
|
|
375
901
|
// so to get the same stacking we iterate the list in REVERSE (deepest
|
|
376
902
|
// first). Blur > 0 routes through an SVG <filter feGaussianBlur> with
|
|
377
903
|
// stdDeviation ≈ blur/2 (matches Chromes blur-to-stdDev mapping).
|
|
378
|
-
{
|
|
904
|
+
if (!useInlineFragments) {
|
|
379
905
|
const shadows = parseBoxShadow(el.styles.boxShadow ?? "none");
|
|
380
906
|
for (let si = shadows.length - 1; si >= 0; si--) {
|
|
381
907
|
const sh = shadows[si];
|
|
382
908
|
if (sh.inset)
|
|
383
909
|
continue;
|
|
384
|
-
|
|
385
|
-
|
|
910
|
+
// Negative spread is allowed: per CSS Backgrounds 3 §6.4 the shadow
|
|
911
|
+
// shape's width/height = box + 2*spread (so spread < 0 shrinks the
|
|
912
|
+
// shadow). Chromium's `BoxShadowData::ApplyToBoxOuter` shrinks the
|
|
913
|
+
// outer rect by `-spread` on each side and zero-clamps the result.
|
|
386
914
|
// Rect inflated by spread and shifted by (x, y).
|
|
387
915
|
const sx = el.x + sh.x - sh.spread;
|
|
388
916
|
const sy = el.y + sh.y - sh.spread;
|
|
@@ -390,11 +918,12 @@ hiDPIFactor = 2) {
|
|
|
390
918
|
const sh2 = el.height + sh.spread * 2;
|
|
391
919
|
if (sw <= 0 || sh2 <= 0)
|
|
392
920
|
continue;
|
|
393
|
-
// Outer shadow corners
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
921
|
+
// Outer shadow corners per CSS Backgrounds 3 §6.4 / Chromium
|
|
922
|
+
// `FloatRoundedRect::Outset`: a non-zero source corner grows by
|
|
923
|
+
// `spread`; a zero source corner STAYS sharp. The naive grow-all
|
|
924
|
+
// path produced visibly rounded corners on the concentric-outline
|
|
925
|
+
// pattern (box-shadow: 0 0 0 Npx on a 0-radius box).
|
|
926
|
+
const shadowCorners = outsetCornerRadiiForShadow(corners, sh.spread);
|
|
398
927
|
let filterAttr = "";
|
|
399
928
|
if (sh.blur > 0) {
|
|
400
929
|
const stdDev = sh.blur / 2;
|
|
@@ -413,7 +942,10 @@ hiDPIFactor = 2) {
|
|
|
413
942
|
// a comma-separated list of linear/radial gradients and url() images. The
|
|
414
943
|
// first layer paints on top — we emit in reverse so the rect order matches
|
|
415
944
|
// CSS layering. The background-color paints *under* all layers.
|
|
416
|
-
if (
|
|
945
|
+
if (useInlineFragments) {
|
|
946
|
+
// background painted per-fragment in renderInlineFragments above
|
|
947
|
+
}
|
|
948
|
+
else if (!suppressEmptyCell && bgColor != null && bgColor.a > 0.01) {
|
|
417
949
|
svgParts.push(`${indent}${roundedRectSvg(el.x, el.y, el.width, el.height, corners, `fill="${colorStr(bgColor)}"`)}`);
|
|
418
950
|
}
|
|
419
951
|
else if (!suppressEmptyCell && el.styles.frostedBgFallback != null) {
|
|
@@ -429,9 +961,15 @@ hiDPIFactor = 2) {
|
|
|
429
961
|
// fill on the text glyph group (instead of painting it as a normal
|
|
430
962
|
// <rect fill=url(#bg)> over the headline area). Initialized to null and
|
|
431
963
|
// assigned in the bg-layer loop below.
|
|
432
|
-
|
|
964
|
+
// DM-696: multiple `background-clip: text` layers must all composite into
|
|
965
|
+
// the glyph shapes (top layer on top of lower layers, same as CSS bg
|
|
966
|
+
// layering on a normal box). Collect them in CSS-source order (layer 0
|
|
967
|
+
// = topmost) and emit each as its own masked rect at render time, in
|
|
968
|
+
// REVERSE order so the topmost CSS layer is the last `<rect>` and paints
|
|
969
|
+
// on top.
|
|
970
|
+
const textBgClipFills = [];
|
|
433
971
|
const bgImage = el.styles.backgroundImage;
|
|
434
|
-
if (bgImage != null && bgImage !== "none" && bgImage !== "") {
|
|
972
|
+
if (!useInlineFragments && bgImage != null && bgImage !== "none" && bgImage !== "") {
|
|
435
973
|
const layers = splitTopLevelCommas(bgImage);
|
|
436
974
|
const sizeLayers = splitTopLevelCommas(el.styles.backgroundSize ?? "auto");
|
|
437
975
|
const posLayers = splitTopLevelCommas(el.styles.backgroundPosition ?? "0% 0%");
|
|
@@ -440,6 +978,16 @@ hiDPIFactor = 2) {
|
|
|
440
978
|
const originLayers = splitTopLevelCommas(el.styles.backgroundOrigin ?? "padding-box");
|
|
441
979
|
const attachmentLayers = splitTopLevelCommas(el.styles.backgroundAttachment ?? "scroll");
|
|
442
980
|
const intrinsicLayers = el.styles.backgroundIntrinsic ?? [];
|
|
981
|
+
// DM-817: background-blend-mode per CSS Compositing 2 §6.1 — each layer
|
|
982
|
+
// blends with the composite below using its mode. Single value applies
|
|
983
|
+
// to every layer; comma-separated values map per-layer. Capture
|
|
984
|
+
// emit-time bg-layer indexing is reversed (later index = lower in
|
|
985
|
+
// stack), so we look up by the ORIGINAL CSS layer index (`li`).
|
|
986
|
+
const blendLayers = splitTopLevelCommas(el.styles.backgroundBlendMode ?? "normal").map((s) => s.trim());
|
|
987
|
+
const hasNonNormalBlend = blendLayers.some((m) => m !== "normal" && m !== "");
|
|
988
|
+
const bgGroupOpen = hasNonNormalBlend ? `${indent}<g style="isolation:isolate">\n` : "";
|
|
989
|
+
const bgGroupClose = hasNonNormalBlend ? `\n${indent}</g>` : "";
|
|
990
|
+
const bgGroupStart = svgParts.length;
|
|
443
991
|
// Per-side borders + padding for clip/origin math.
|
|
444
992
|
const bwT = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
|
|
445
993
|
const bwR = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
|
|
@@ -473,11 +1021,29 @@ hiDPIFactor = 2) {
|
|
|
473
1021
|
const layerAttachment = (attachmentLayers[li] ?? attachmentLayers[0] ?? "scroll").trim();
|
|
474
1022
|
const originBox = boxFor(layerOrigin);
|
|
475
1023
|
const clipBox = boxFor(layerClip);
|
|
1024
|
+
// DM-821: `background-attachment: local` positions and sizes the
|
|
1025
|
+
// layer against the element's full scrollable content area, not its
|
|
1026
|
+
// visible viewport. `background-size: contain` on a 936×220 panel
|
|
1027
|
+
// whose scroll content runs ~820px tall sizes the image to fit the
|
|
1028
|
+
// 936×820 box (one width-filling tile), but using just the visible
|
|
1029
|
+
// box sizes it to 660×220 instead and tiles horizontally with a
|
|
1030
|
+
// visible second copy at the right edge. Substitute the scroll
|
|
1031
|
+
// dimensions when the element actually scrolls.
|
|
1032
|
+
let posOriginX = originBox.x, posOriginY = originBox.y;
|
|
1033
|
+
let posOriginW = originBox.w, posOriginH = originBox.h;
|
|
1034
|
+
if (layerAttachment === "local" && el.styles.scrollHeight != null && el.styles.scrollWidth != null) {
|
|
1035
|
+
const sw = el.styles.scrollWidth;
|
|
1036
|
+
const sh = el.styles.scrollHeight;
|
|
1037
|
+
if (sw > posOriginW)
|
|
1038
|
+
posOriginW = sw;
|
|
1039
|
+
if (sh > posOriginH)
|
|
1040
|
+
posOriginH = sh;
|
|
1041
|
+
}
|
|
476
1042
|
const defId = `${idPrefix}bg${clipIdx++}`;
|
|
477
1043
|
// Pattern is positioned + sized relative to the origin box (where the image starts)
|
|
478
1044
|
// then painted into a rect clipped to the clip box. For fixed attachment
|
|
479
1045
|
// the origin is the viewport instead.
|
|
480
|
-
const out = buildBackgroundLayerDef(defId, layer,
|
|
1046
|
+
const out = buildBackgroundLayerDef(defId, layer, posOriginX, posOriginY, posOriginW, posOriginH, layerSize, layerPos, layerRepeat, layerIntrinsic, layerAttachment, captureViewport);
|
|
481
1047
|
if (out.def === "")
|
|
482
1048
|
continue;
|
|
483
1049
|
defsParts.push(out.def);
|
|
@@ -487,8 +1053,9 @@ hiDPIFactor = 2) {
|
|
|
487
1053
|
// can use it as the glyph fill (the first text-clipped layer wins).
|
|
488
1054
|
// The non-text-clipped layers (if any) still emit normally.
|
|
489
1055
|
if (layerClip === "text") {
|
|
490
|
-
|
|
491
|
-
|
|
1056
|
+
// li counts down (loop iterates from layers.length-1 → 0). Storing at
|
|
1057
|
+
// index li lets us emit topmost layer last regardless of loop dir.
|
|
1058
|
+
textBgClipFills[li] = `url(#${defId})`;
|
|
492
1059
|
continue;
|
|
493
1060
|
}
|
|
494
1061
|
// Inner clip corners: subtract the corresponding border-side widths
|
|
@@ -499,72 +1066,64 @@ hiDPIFactor = 2) {
|
|
|
499
1066
|
const innerCorners = layerClip === "border-box"
|
|
500
1067
|
? corners
|
|
501
1068
|
: insetCornerRadii(corners, bwT, bwR, bwB, bwL);
|
|
502
|
-
|
|
1069
|
+
// DM-817: per-layer mix-blend-mode. Bottom layer (CSS layer
|
|
1070
|
+
// layers.length-1) always paints normal; upper layers blend.
|
|
1071
|
+
const layerBlend = blendLayers[li] ?? blendLayers[0] ?? "normal";
|
|
1072
|
+
const blendAttr = (layerBlend !== "normal" && layerBlend !== "")
|
|
1073
|
+
? ` style="mix-blend-mode:${layerBlend}"` : "";
|
|
1074
|
+
svgParts.push(`${indent}${roundedRectSvg(clipBox.x, clipBox.y, clipBox.w, clipBox.h, innerCorners, `fill="url(#${defId})"${blendAttr}`)}`);
|
|
1075
|
+
}
|
|
1076
|
+
// DM-817: wrap the bg-layer rects we just emitted in an
|
|
1077
|
+
// isolation-isolate group so the multiply / screen / etc. doesn't
|
|
1078
|
+
// bleed into siblings painted above.
|
|
1079
|
+
if (hasNonNormalBlend && svgParts.length > bgGroupStart) {
|
|
1080
|
+
const wrapped = bgGroupOpen + svgParts.slice(bgGroupStart).join("\n") + bgGroupClose;
|
|
1081
|
+
svgParts.length = bgGroupStart;
|
|
1082
|
+
svgParts.push(wrapped);
|
|
503
1083
|
}
|
|
504
1084
|
}
|
|
505
|
-
// Inset box-shadow
|
|
506
|
-
// the
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
1085
|
+
// Inset box-shadow per CSS Backgrounds 3 §6.4 + Chromium
|
|
1086
|
+
// `BoxPainterBase::PaintInsetBoxShadow`: the shadow shape is the padding
|
|
1087
|
+
// box shifted by (x, y) and inset by `spread` on each side. The shadow
|
|
1088
|
+
// paints inside the padding box BUT OUTSIDE the shadow shape — like a
|
|
1089
|
+
// donut whose hole is the shadow shape. With offset, the donut becomes
|
|
1090
|
+
// asymmetric (e.g. `inset 0 -16px 32px` darkens the bottom strip and
|
|
1091
|
+
// fades upward); with pure spread, it becomes a uniform ring; with pure
|
|
1092
|
+
// blur centered, it becomes a soft inner glow.
|
|
1093
|
+
//
|
|
1094
|
+
// Implementation: emit two subpaths with `fill-rule="evenodd"` — outer =
|
|
1095
|
+
// padding box expanded outward by enough margin to contain the blur
|
|
1096
|
+
// halo, inner = padding box shifted by (sh.x, sh.y) and inset by
|
|
1097
|
+
// sh.spread on each side. Apply Gaussian blur (stdDev = blur/2). Clip
|
|
1098
|
+
// the whole thing to the padding box so the outer-margin overflow and
|
|
1099
|
+
// the parts of the halo outside the box don't leak.
|
|
1100
|
+
if (!useInlineFragments) {
|
|
513
1101
|
const shadows = parseBoxShadow(el.styles.boxShadow ?? "none");
|
|
514
|
-
// Border widths — re-parse here because the bg-layer block above (where
|
|
515
|
-
// bwL/bwR/bwT/bwB are computed) is conditional on backgroundImage.
|
|
516
1102
|
const sbwL = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
|
|
517
1103
|
const sbwR = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
|
|
518
1104
|
const sbwT = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
|
|
519
1105
|
const sbwB = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
|
|
520
|
-
// Border-inner box (where inset shadow paints).
|
|
521
1106
|
const ibLeft = el.x + sbwL;
|
|
522
1107
|
const ibTop = el.y + sbwT;
|
|
523
1108
|
const ibW = Math.max(0, el.width - sbwL - sbwR);
|
|
524
1109
|
const ibH = Math.max(0, el.height - sbwT - sbwB);
|
|
525
|
-
// Border-inner per-corner radii: each corner shrinks by the adjacent
|
|
526
|
-
// border-side widths. Used as the basis for the inset-shadow stroke
|
|
527
|
-
// path; the final ring radius will subtract sp/2 for stroke centering.
|
|
528
1110
|
const innerCorners = insetCornerRadii(corners, sbwT, sbwR, sbwB, sbwL);
|
|
529
|
-
|
|
1111
|
+
// DM-699: CSS Backgrounds 3 §6.4 stacks shadows with the FIRST shadow
|
|
1112
|
+
// ON TOP. The outer-shadow loop above already iterates in reverse so
|
|
1113
|
+
// the topmost CSS shadow emits last; this inset loop was iterating
|
|
1114
|
+
// FORWARD, so e.g. `box-shadow: inset 0 0 0 8px #b45309, inset 0 6px
|
|
1115
|
+
// 24px rgba(0,0,0,.4)` (brown ring on top of a dark glow) painted the
|
|
1116
|
+
// brown ring FIRST and the dark glow LAST — the glow then ended up on
|
|
1117
|
+
// top, darkening the brown ring at the top of the box.
|
|
1118
|
+
for (let si = shadows.length - 1; si >= 0; si--) {
|
|
1119
|
+
const sh = shadows[si];
|
|
530
1120
|
if (!sh.inset)
|
|
531
1121
|
continue;
|
|
532
|
-
// Skip non-trivial shadows we cant emit accurately — anything with
|
|
533
|
-
// an x/y offset (asymmetric inset glow not supported), negative
|
|
534
|
-
// spread, or zero spread + zero blur (paints nothing).
|
|
535
|
-
if (sh.x !== 0 || sh.y !== 0)
|
|
536
|
-
continue;
|
|
537
|
-
if (sh.spread < 0)
|
|
538
|
-
continue;
|
|
539
1122
|
if (sh.spread === 0 && sh.blur === 0)
|
|
540
1123
|
continue;
|
|
541
1124
|
if (ibW <= 0 || ibH <= 0)
|
|
542
1125
|
continue;
|
|
543
|
-
|
|
544
|
-
// edge, clipped to inside the border-box so the stroke (and the blur
|
|
545
|
-
// halo, if any) only show on the inner side of the edge. Stroke is
|
|
546
|
-
// centered on the path; the clipPath drops the outward half so the
|
|
547
|
-
// visible thickness is half the stroke width. For pure-spread (no
|
|
548
|
-
// blur) we want a sharp `spread`-wide ring, so stroke-width = 2 *
|
|
549
|
-
// spread. For pure-blur (no spread) we use a thin baseline ring of
|
|
550
|
-
// blur-width pixels — the blur then produces the visible falloff;
|
|
551
|
-
// a larger ring overpaints, smaller produces too-faint output.
|
|
552
|
-
// Combined spread + blur sums both: visible band = spread, with the
|
|
553
|
-
// blur softening the inner edge. Previously the code used
|
|
554
|
-
// max(spread, blur) as both stroke width AND inward offset, which
|
|
555
|
-
// conflated blur with spread and painted pure-blur insets as a
|
|
556
|
-
// thick solid ring. DM-304.
|
|
557
|
-
// For pure-blur (no spread) we use a `blur`-wide ring centered on the
|
|
558
|
-
// inner edge — half (blur/2) shows inside the clip, half is clipped
|
|
559
|
-
// away on the outer side. The Gaussian then softens that band into
|
|
560
|
-
// Chrome's characteristic inset glow falloff (DM-366). Previously the
|
|
561
|
-
// ring was 1px wide and the blur made it nearly invisible.
|
|
562
|
-
// For pure-blur (no spread) we use a `blur/2`-wide ring centered on
|
|
563
|
-
// the inner edge. Half (blur/4) shows inside the clip; the Gaussian
|
|
564
|
-
// (stdDev = blur/2) softens that band into Chrome's characteristic
|
|
565
|
-
// inset-glow falloff. A 1px ring is too faint; a `blur`-wide ring is
|
|
566
|
-
// too strong (DM-366).
|
|
567
|
-
const ringWidth = Math.max(2 * sh.spread, sh.blur / 2, 1);
|
|
1126
|
+
const shadowColor = colorStr(parseColor(sh.color) ?? { r: 0, g: 0, b: 0, a: 0 });
|
|
568
1127
|
let filterAttr = "";
|
|
569
1128
|
if (sh.blur > 0) {
|
|
570
1129
|
const stdDev = sh.blur / 2;
|
|
@@ -574,14 +1133,49 @@ hiDPIFactor = 2) {
|
|
|
574
1133
|
}
|
|
575
1134
|
const cid = `${idPrefix}ishc${clipIdx++}`;
|
|
576
1135
|
defsParts.push(`<clipPath id="${cid}">${roundedRectSvg(ibLeft, ibTop, ibW, ibH, innerCorners, "")}</clipPath>`);
|
|
577
|
-
|
|
578
|
-
|
|
1136
|
+
// Pure-blur-centered inset (x=0, y=0, spread=0, blur>0): the donut
|
|
1137
|
+
// has zero area, so use the legacy stroked-rect approach which
|
|
1138
|
+
// produces the right soft glow on all sides. Stroke width = blur/2;
|
|
1139
|
+
// the Gaussian softens it into Chrome's inset-glow falloff. DM-366.
|
|
1140
|
+
if (sh.x === 0 && sh.y === 0 && sh.spread === 0) {
|
|
1141
|
+
const ringWidth = Math.max(sh.blur / 2, 1);
|
|
1142
|
+
svgParts.push(`${indent}<g clip-path="url(#${cid})">${roundedRectSvg(ibLeft, ibTop, ibW, ibH, innerCorners, `fill="none" stroke="${shadowColor}" stroke-width="${r(ringWidth)}"${filterAttr}`)}</g>`);
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
// Donut path: outer subpath = padding box expanded by a margin
|
|
1146
|
+
// sized to contain the blur halo + spread; inner subpath = padding
|
|
1147
|
+
// box shifted by (sh.x, sh.y) and inset by sh.spread on each side.
|
|
1148
|
+
// Even-odd fill paints the frame between the two subpaths with the
|
|
1149
|
+
// shadow color; blur softens; the clip-path keeps the result inside
|
|
1150
|
+
// the padding box.
|
|
1151
|
+
const innerL = ibLeft + sh.x + sh.spread;
|
|
1152
|
+
const innerT = ibTop + sh.y + sh.spread;
|
|
1153
|
+
const innerW = ibW - 2 * sh.spread;
|
|
1154
|
+
const innerH = ibH - 2 * sh.spread;
|
|
1155
|
+
if (innerW <= 0 || innerH <= 0) {
|
|
1156
|
+
// Shadow shape collapsed: per spec the entire padding box fills
|
|
1157
|
+
// with shadow color (with blur halo) — emit a solid rect.
|
|
1158
|
+
svgParts.push(`${indent}<g clip-path="url(#${cid})">${roundedRectSvg(ibLeft, ibTop, ibW, ibH, innerCorners, `fill="${shadowColor}"${filterAttr}`)}</g>`);
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
const innerC = insetCornerRadii(innerCorners, sh.spread, sh.spread, sh.spread, sh.spread);
|
|
1162
|
+
const margin = Math.max(Math.abs(sh.x), Math.abs(sh.y), sh.spread, sh.blur, 1) * 4;
|
|
1163
|
+
const outerX = Math.min(ibLeft, innerL) - margin;
|
|
1164
|
+
const outerY = Math.min(ibTop, innerT) - margin;
|
|
1165
|
+
const outerR = Math.max(ibLeft + ibW, innerL + innerW) + margin;
|
|
1166
|
+
const outerB = Math.max(ibTop + ibH, innerT + innerH) + margin;
|
|
1167
|
+
const sharp = { tl: { h: 0, v: 0 }, tr: { h: 0, v: 0 }, br: { h: 0, v: 0 }, bl: { h: 0, v: 0 }, uniform: true };
|
|
1168
|
+
const outerD = roundedRectPath(outerX, outerY, outerR - outerX, outerB - outerY, sharp);
|
|
1169
|
+
const innerD = roundedRectPath(innerL, innerT, innerW, innerH, innerC);
|
|
1170
|
+
svgParts.push(`${indent}<g clip-path="url(#${cid})"><path d="${outerD} ${innerD}" fill="${shadowColor}" fill-rule="evenodd"${filterAttr}/></g>`);
|
|
579
1171
|
}
|
|
580
1172
|
}
|
|
581
1173
|
// Border-image: if a URL source with intrinsic dimensions is present,
|
|
582
1174
|
// emit a 9-slice composition and SKIP the plain-border fallback below.
|
|
583
1175
|
// Gradient sources are not supported in this pass (tracked as follow-up).
|
|
584
|
-
const borderImageMarkup =
|
|
1176
|
+
const borderImageMarkup = useInlineFragments
|
|
1177
|
+
? { svg: "", usedIds: 0 }
|
|
1178
|
+
: renderBorderImage(el, indent, idPrefix, defsParts, clipIdx);
|
|
585
1179
|
if (borderImageMarkup.usedIds > 0)
|
|
586
1180
|
clipIdx += borderImageMarkup.usedIds;
|
|
587
1181
|
const borderImagePainted = borderImageMarkup.svg !== "";
|
|
@@ -610,6 +1204,9 @@ hiDPIFactor = 2) {
|
|
|
610
1204
|
if (suppressEmptyCell) {
|
|
611
1205
|
// empty-cells: hide — suppress the border too.
|
|
612
1206
|
}
|
|
1207
|
+
else if (useInlineFragments) {
|
|
1208
|
+
// Border painted per-fragment in renderInlineFragments above.
|
|
1209
|
+
}
|
|
613
1210
|
else if (borderImagePainted) {
|
|
614
1211
|
// Border visual came from border-image. Skip the plain-border emission.
|
|
615
1212
|
}
|
|
@@ -620,9 +1217,19 @@ hiDPIFactor = 2) {
|
|
|
620
1217
|
// separated by 1/3 gap. Our captured rect is the border box (outer
|
|
621
1218
|
// edge), so strokes need their centerlines at 1/6*w (outer) and
|
|
622
1219
|
// 5/6*w (inner) inside the border box.
|
|
1220
|
+
//
|
|
1221
|
+
// DM-689: In `border-collapse: collapse` mode Chrome paints the
|
|
1222
|
+
// border CENTERED on the cell's grid edge instead of inside the
|
|
1223
|
+
// cell box — half the border width sits outside the cell, half
|
|
1224
|
+
// inside. Match that by shifting the outer/inner offsets outward
|
|
1225
|
+
// by bt.w/2 in collapse mode (Blink's
|
|
1226
|
+
// `CollapsedBorderPainter::PaintCollapsedBorders` centers the
|
|
1227
|
+
// collapsed-border rect on the grid line).
|
|
1228
|
+
const collapse = el.styles.borderCollapse === "collapse";
|
|
1229
|
+
const collapseShift = collapse ? bt.w / 2 : 0;
|
|
623
1230
|
const strokeW = bt.w / 3;
|
|
624
|
-
const outerInset = bt.w / 6;
|
|
625
|
-
const innerInset = bt.w * 5 / 6;
|
|
1231
|
+
const outerInset = bt.w / 6 - collapseShift;
|
|
1232
|
+
const innerInset = bt.w * 5 / 6 - collapseShift;
|
|
626
1233
|
const outerCorners = insetCornerRadii(corners, outerInset, outerInset, outerInset, outerInset);
|
|
627
1234
|
const innerCorners = insetCornerRadii(corners, innerInset, innerInset, innerInset, innerInset);
|
|
628
1235
|
svgParts.push(`${indent}${roundedRectSvg(el.x + outerInset, el.y + outerInset, el.width - 2 * outerInset, el.height - 2 * outerInset, outerCorners, `fill="none" stroke="${colorStr(bt.color)}" stroke-width="${r(strokeW)}"`)}`);
|
|
@@ -722,23 +1329,34 @@ hiDPIFactor = 2) {
|
|
|
722
1329
|
const bT = collapse ? el.y : Math.round(el.y);
|
|
723
1330
|
const bR = collapse ? el.x + el.width : Math.round(el.x + el.width);
|
|
724
1331
|
const bB = collapse ? el.y + el.height : Math.round(el.y + el.height);
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
|
|
1332
|
+
// Corner trim along the side's axis. Two reasons it applies:
|
|
1333
|
+
// • Dotted (always): Chromium's `DrawLineWithStyle` moves the
|
|
1334
|
+
// line endpoints IN by width/2 before stroking thick-dotted
|
|
1335
|
+
// lines so the round endcap fits inside the line. Matching
|
|
1336
|
+
// that is necessary for `adjustedDashAttrs` (which assumes a
|
|
1337
|
+
// post-move sideLength) to compute Chrome-equivalent dot
|
|
1338
|
+
// centres. The adjacent sides' first dots overlap at the
|
|
1339
|
+
// corner, producing one visible corner dot. (DM-805.)
|
|
1340
|
+
// • Dashed thick (≥ 8 px): legacy corner-overlap prevention so
|
|
1341
|
+
// butt-cap dashes don't double-paint the corner pixel as a
|
|
1342
|
+
// darker square (DM-402, visible on the 10 px dashed border
|
|
1343
|
+
// in `17-bg-color-image`). Thin dashed borders use 0 trim
|
|
1344
|
+
// so the dashes meet flush at the corner, matching Chrome
|
|
1345
|
+
// for the common 1-3 px cases.
|
|
1346
|
+
const cornerTrim = style === "dotted" ? bt.w / 2 : (bt.w >= 8 ? inset : 0);
|
|
1347
|
+
// Each entry: [x1, y1, x2, y2, naturalLen]. naturalLen is the
|
|
1348
|
+
// PRE-cornerTrim side length — Chromium's `DrawLineWithStyle`
|
|
1349
|
+
// computes the dash pattern from the original `info.path_length`
|
|
1350
|
+
// BEFORE moving thick-dotted endpoints inward by width/2 (the move
|
|
1351
|
+
// shifts the painted line but the dash math sees the original).
|
|
1352
|
+
// For thin dashed borders cornerTrim = 0, so naturalLen == drawn
|
|
1353
|
+
// length; for dotted (cornerTrim = width/2) and thick dashed
|
|
1354
|
+
// (cornerTrim = width/2) the two differ.
|
|
737
1355
|
const sides = [
|
|
738
|
-
[bL + cornerTrim, bT + inset, bR - cornerTrim, bT + inset, bR - bL
|
|
739
|
-
[bR - inset, bT + cornerTrim, bR - inset, bB - cornerTrim, bB - bT
|
|
740
|
-
[bL + cornerTrim, bB - inset, bR - cornerTrim, bB - inset, bR - bL
|
|
741
|
-
[bL + inset, bT + cornerTrim, bL + inset, bB - cornerTrim, bB - bT
|
|
1356
|
+
[bL + cornerTrim, bT + inset, bR - cornerTrim, bT + inset, bR - bL],
|
|
1357
|
+
[bR - inset, bT + cornerTrim, bR - inset, bB - cornerTrim, bB - bT],
|
|
1358
|
+
[bL + cornerTrim, bB - inset, bR - cornerTrim, bB - inset, bR - bL],
|
|
1359
|
+
[bL + inset, bT + cornerTrim, bL + inset, bB - cornerTrim, bB - bT],
|
|
742
1360
|
];
|
|
743
1361
|
for (const [x1, y1, x2, y2, len] of sides) {
|
|
744
1362
|
const { array: dash, offset } = adjustedDashAttrs(style, bt.w, len);
|
|
@@ -854,6 +1472,119 @@ hiDPIFactor = 2) {
|
|
|
854
1472
|
[0, 1, 0, -1], // bottom: outer down, inner up
|
|
855
1473
|
[1, 0, -1, 0], // left: outer left, inner right
|
|
856
1474
|
];
|
|
1475
|
+
// DM-697: non-solid sides (double / dashed / dotted) need the same
|
|
1476
|
+
// diagonal-miter clip at corners that solid sides get from the
|
|
1477
|
+
// trapezoid emit. Per Blink's `BoxBorderPainter::PaintOneBorderSide`,
|
|
1478
|
+
// each side paints into a 4-point clip region whose corners run from
|
|
1479
|
+
// the border-box outer rect to the inner rect — i.e., the same
|
|
1480
|
+
// trapezoid shape we use for solid sides. Without it our `<line>`
|
|
1481
|
+
// strokes spill into adjacent sides' wedges and produce square
|
|
1482
|
+
// corners instead of the diagonal cut Chrome paints. Build a
|
|
1483
|
+
// clipPath per non-solid side and wrap its emission in it.
|
|
1484
|
+
const sideClipForStyle = (i, side) => {
|
|
1485
|
+
if (collapse || side == null || side.w <= 0)
|
|
1486
|
+
return "";
|
|
1487
|
+
const cid = `${idPrefix}bs${clipIdx++}`;
|
|
1488
|
+
defsParts.push(`<clipPath id="${cid}"><polygon points="${trapezoids[i][1]}"/></clipPath>`);
|
|
1489
|
+
return ` clip-path="url(#${cid})"`;
|
|
1490
|
+
};
|
|
1491
|
+
// DM-686: border-radius + per-side borders. The trapezoids and lines
|
|
1492
|
+
// above hit the sharp outer-rect corners. When the element has a
|
|
1493
|
+
// non-zero border-radius, wrap the per-side emit in a clip-path that
|
|
1494
|
+
// is the rounded outer border-box, so each side's polygon / line is
|
|
1495
|
+
// trimmed to follow the radius arc instead of squaring off. Matches
|
|
1496
|
+
// Blink, which paints sides into the rounded border outline clip.
|
|
1497
|
+
const hasOuterRadius = !collapse && (corners.tl.h > 0 || corners.tl.v > 0
|
|
1498
|
+
|| corners.tr.h > 0 || corners.tr.v > 0
|
|
1499
|
+
|| corners.br.h > 0 || corners.br.v > 0
|
|
1500
|
+
|| corners.bl.h > 0 || corners.bl.v > 0);
|
|
1501
|
+
// DM-773: when the box has rounded corners AND per-side mixed widths,
|
|
1502
|
+
// the legacy trapezoid + outer-outline-clip approach paints each side
|
|
1503
|
+
// as a straight rectangular strip clipped to the rounded outline. For
|
|
1504
|
+
// large radii (`border-radius: 50%` / circle case, or any corner whose
|
|
1505
|
+
// radius dominates the side's width) the rectangular strip sits
|
|
1506
|
+
// entirely OUTSIDE the rounded outline at most y values — the clip
|
|
1507
|
+
// erases the side, leaving only a thin sliver near the side's
|
|
1508
|
+
// midpoint. Chrome's `BoxBorderPainter` paints each side as a wedge
|
|
1509
|
+
// of the BORDER RING (outer outline minus inner outline) cut to the
|
|
1510
|
+
// side's diagonal-to-center quadrant; that approach is geometry-
|
|
1511
|
+
// correct for any radius. For solid sides we switch to that approach
|
|
1512
|
+
// here when there's a rounded corner; the non-solid branches keep
|
|
1513
|
+
// their existing line / double-stroke emit with the outer-outline
|
|
1514
|
+
// clip wrapping.
|
|
1515
|
+
const cxBox = (bxL + bxR) / 2;
|
|
1516
|
+
const cyBox = (bxT + bxB) / 2;
|
|
1517
|
+
const outerRoundedPath = hasOuterRadius
|
|
1518
|
+
? roundedRectPath(bxL, bxT, bxR - bxL, bxB - bxT, corners)
|
|
1519
|
+
: "";
|
|
1520
|
+
const innerCornersForAnnular = hasOuterRadius
|
|
1521
|
+
? insetCornerRadii(corners, tw, rw, bw, lw)
|
|
1522
|
+
: corners;
|
|
1523
|
+
const innerRoundedPath = hasOuterRadius
|
|
1524
|
+
? roundedRectPath(bxL + lw, bxT + tw, Math.max(0, bxR - bxL - lw - rw), Math.max(0, bxB - bxT - tw - bw), innerCornersForAnnular)
|
|
1525
|
+
: "";
|
|
1526
|
+
const annularPath = hasOuterRadius
|
|
1527
|
+
? `${outerRoundedPath} ${innerRoundedPath}`
|
|
1528
|
+
: "";
|
|
1529
|
+
// DM-803: per-side wedge apex = intersection of the two adjacent
|
|
1530
|
+
// corner MITER lines (not box centre). Each corner's miter line goes
|
|
1531
|
+
// from the outer corner inward along direction (lw_at_that_corner,
|
|
1532
|
+
// tw_at_that_corner) — for uniform widths this gives a 45° diagonal
|
|
1533
|
+
// and all 4 apices land at the box centre (matching the old behaviour);
|
|
1534
|
+
// for mixed widths the diagonal tilts toward the thicker adjacent
|
|
1535
|
+
// side, shifting where the colour-transition between adjacent sides
|
|
1536
|
+
// lands on the rounded-corner arc. Matches Chromium's
|
|
1537
|
+
// `box_border_painter.cc` miter-line construction (`miter_line` from
|
|
1538
|
+
// `corner.outer.Outer()` to `corner.unadjusted_inner_edge`). Without
|
|
1539
|
+
// this shift, e.g. the 8/2/8/2 border on a 50%-radius ellipse paints
|
|
1540
|
+
// the top blue and bottom green arcs narrower than Chrome (because
|
|
1541
|
+
// 45° diagonals from the rectangle corners hit the ellipse closer to
|
|
1542
|
+
// the cardinal axes than the wider-top miter lines would).
|
|
1543
|
+
const boxW = bxR - bxL;
|
|
1544
|
+
const boxH = bxB - bxT;
|
|
1545
|
+
const horizSum = lw + rw;
|
|
1546
|
+
const vertSum = tw + bw;
|
|
1547
|
+
// Top apex: NW miter (lw, tw) and NE miter (-rw, tw) meet at
|
|
1548
|
+
// (bxL + lw*boxW/(lw+rw), bxT + tw*boxW/(lw+rw)). Fall back to box
|
|
1549
|
+
// centre when the denominator is zero (no adjacent borders).
|
|
1550
|
+
const apexTopX = horizSum > 0 ? bxL + lw * boxW / horizSum : cxBox;
|
|
1551
|
+
const apexTopY = horizSum > 0 ? bxT + tw * boxW / horizSum : cyBox;
|
|
1552
|
+
const apexRightX = vertSum > 0 ? bxR - rw * boxH / vertSum : cxBox;
|
|
1553
|
+
const apexRightY = vertSum > 0 ? bxT + tw * boxH / vertSum : cyBox;
|
|
1554
|
+
const apexBottomX = horizSum > 0 ? bxL + lw * boxW / horizSum : cxBox;
|
|
1555
|
+
const apexBottomY = horizSum > 0 ? bxB - bw * boxW / horizSum : cyBox;
|
|
1556
|
+
const apexLeftX = vertSum > 0 ? bxL + lw * boxH / vertSum : cxBox;
|
|
1557
|
+
const apexLeftY = vertSum > 0 ? bxT + tw * boxH / vertSum : cyBox;
|
|
1558
|
+
const annularWedges = hasOuterRadius ? [
|
|
1559
|
+
`${r(bxL)},${r(bxT)} ${r(bxR)},${r(bxT)} ${r(apexTopX)},${r(apexTopY)}`, // top
|
|
1560
|
+
`${r(bxR)},${r(bxT)} ${r(bxR)},${r(bxB)} ${r(apexRightX)},${r(apexRightY)}`, // right
|
|
1561
|
+
`${r(bxR)},${r(bxB)} ${r(bxL)},${r(bxB)} ${r(apexBottomX)},${r(apexBottomY)}`, // bottom
|
|
1562
|
+
`${r(bxL)},${r(bxB)} ${r(bxL)},${r(bxT)} ${r(apexLeftX)},${r(apexLeftY)}`, // left
|
|
1563
|
+
] : [];
|
|
1564
|
+
// The outer-outline group still wraps the non-solid branches so their
|
|
1565
|
+
// straight `<line>` strokes get trimmed to the rounded outline at the
|
|
1566
|
+
// corners. Solid sides emit their own annular wedge BEFORE the group
|
|
1567
|
+
// opens (and use their own per-side wedge clip), so they fall outside
|
|
1568
|
+
// this wrapping — the wedge clip is tighter than the outer outline
|
|
1569
|
+
// anyway.
|
|
1570
|
+
let roundedSideGroupOpen = false;
|
|
1571
|
+
if (hasOuterRadius) {
|
|
1572
|
+
// Emit solid sides as annular wedges first.
|
|
1573
|
+
for (let i = 0; i < sides.length; i++) {
|
|
1574
|
+
const side = sides[i][0];
|
|
1575
|
+
if (side == null || side.w <= 0 || side.color.a < 0.01)
|
|
1576
|
+
continue;
|
|
1577
|
+
if (side.style !== "solid")
|
|
1578
|
+
continue;
|
|
1579
|
+
const wid = `${idPrefix}bw${clipIdx++}`;
|
|
1580
|
+
defsParts.push(`<clipPath id="${wid}"><polygon points="${annularWedges[i]}"/></clipPath>`);
|
|
1581
|
+
svgParts.push(`${indent}<path d="${annularPath}" fill="${colorStr(side.color)}" fill-rule="evenodd" clip-path="url(#${wid})"/>`);
|
|
1582
|
+
}
|
|
1583
|
+
const rcid = `${idPrefix}br${clipIdx++}`;
|
|
1584
|
+
defsParts.push(`<clipPath id="${rcid}"><path d="${roundedRectPath(el.x, el.y, el.width, el.height, corners)}"/></clipPath>`);
|
|
1585
|
+
svgParts.push(`${indent}<g clip-path="url(#${rcid})">`);
|
|
1586
|
+
roundedSideGroupOpen = true;
|
|
1587
|
+
}
|
|
857
1588
|
for (let i = 0; i < sides.length; i++) {
|
|
858
1589
|
const [side, x1, y1, x2, y2, len] = sides[i];
|
|
859
1590
|
if (side == null || side.w <= 0 || side.color.a < 0.01)
|
|
@@ -861,21 +1592,34 @@ hiDPIFactor = 2) {
|
|
|
861
1592
|
if (side.style === "none" || side.style === "hidden")
|
|
862
1593
|
continue;
|
|
863
1594
|
if (useTrapezoid(side)) {
|
|
1595
|
+
// DM-773: solid sides with rounded corners already emitted as
|
|
1596
|
+
// annular wedges above (geometry-correct for any radius). Skip
|
|
1597
|
+
// the legacy trapezoid emit so we don't double-paint.
|
|
1598
|
+
if (hasOuterRadius)
|
|
1599
|
+
continue;
|
|
864
1600
|
// Emit as a polygon trapezoid that tapers correctly at corners.
|
|
865
1601
|
svgParts.push(`${indent}<polygon points="${trapezoids[i][1]}" fill="${colorStr(side.color)}" />`);
|
|
866
1602
|
continue;
|
|
867
1603
|
}
|
|
868
|
-
if (side.style === "double" && side.w >= 3
|
|
1604
|
+
if (side.style === "double" && side.w >= 3) {
|
|
869
1605
|
// Two parallel strokes, each w/3 wide, separated by a w/3 gap.
|
|
870
1606
|
// Outer stroke center sits at (sideCenter + outerNormal * w/3),
|
|
871
1607
|
// inner at (sideCenter + innerNormal * w/3). Each stroke = w/3 thick.
|
|
1608
|
+
// DM-689: works in both collapse and non-collapse modes — the
|
|
1609
|
+
// `(x1, y1) → (x2, y2)` side endpoints are already collapse-aware
|
|
1610
|
+
// upstream (inset=0 puts the side centerline ON the cell's grid
|
|
1611
|
+
// edge in collapse mode), so adding the ±w/3 perpendicular
|
|
1612
|
+
// offsets lands the outer stroke 1/3 of the way past the edge
|
|
1613
|
+
// and the inner stroke 1/3 of the way inside — matching Blink's
|
|
1614
|
+
// `CollapsedBorderPainter::PaintCollapsedDoubleBorder`.
|
|
872
1615
|
const strokeW = side.w / 3;
|
|
873
1616
|
const offset_ = side.w / 3;
|
|
874
1617
|
const [oxN, oyN, ixN, iyN] = doubleSides[i];
|
|
875
1618
|
const ox = oxN * offset_, oy = oyN * offset_;
|
|
876
1619
|
const ix = ixN * offset_, iy = iyN * offset_;
|
|
877
|
-
|
|
878
|
-
svgParts.push(`${indent}<line x1="${r(x1 +
|
|
1620
|
+
const clipAttr = sideClipForStyle(i, side);
|
|
1621
|
+
svgParts.push(`${indent}<line x1="${r(x1 + ox)}" y1="${r(y1 + oy)}" x2="${r(x2 + ox)}" y2="${r(y2 + oy)}" stroke="${colorStr(side.color)}" stroke-width="${r(strokeW)}"${clipAttr} />`);
|
|
1622
|
+
svgParts.push(`${indent}<line x1="${r(x1 + ix)}" y1="${r(y1 + iy)}" x2="${r(x2 + ix)}" y2="${r(y2 + iy)}" stroke="${colorStr(side.color)}" stroke-width="${r(strokeW)}"${clipAttr} />`);
|
|
879
1623
|
continue;
|
|
880
1624
|
}
|
|
881
1625
|
const { array: dash, offset } = adjustedDashAttrs(side.style, side.w, len);
|
|
@@ -886,8 +1630,11 @@ hiDPIFactor = 2) {
|
|
|
886
1630
|
// butt caps so the dash:gap ratio paints flat-ended rectangles.
|
|
887
1631
|
const linecap = side.style === "dotted" ? ` stroke-linecap="round"` : "";
|
|
888
1632
|
const dashAttrs = dash !== "" ? ` stroke-dasharray="${dash}"${offset !== 0 ? ` stroke-dashoffset="${r(offset)}"` : ""}` : "";
|
|
889
|
-
|
|
1633
|
+
const clipAttr = sideClipForStyle(i, side);
|
|
1634
|
+
svgParts.push(`${indent}<line x1="${r(x1)}" y1="${r(y1)}" x2="${r(x2)}" y2="${r(y2)}" stroke="${colorStr(side.color)}" stroke-width="${r(side.w)}"${dashAttrs}${linecap}${clipAttr} />`);
|
|
890
1635
|
}
|
|
1636
|
+
if (roundedSideGroupOpen)
|
|
1637
|
+
svgParts.push(`${indent}</g>`);
|
|
891
1638
|
}
|
|
892
1639
|
else if (borderWidth > 0 && borderColor != null && borderColor.a > 0.01) {
|
|
893
1640
|
// Legacy path for elements whose per-side captures weren't parsed cleanly.
|
|
@@ -977,6 +1724,8 @@ hiDPIFactor = 2) {
|
|
|
977
1724
|
svgParts.push(`${indent}</g>`);
|
|
978
1725
|
if (opened)
|
|
979
1726
|
svgParts.push(`${indent}</g>`);
|
|
1727
|
+
if (needsFilterOuter)
|
|
1728
|
+
svgParts.push(`${indent}</g>`);
|
|
980
1729
|
return;
|
|
981
1730
|
}
|
|
982
1731
|
const sized = injectSvgSize(el.svgContent, contentW, contentH);
|
|
@@ -987,14 +1736,17 @@ hiDPIFactor = 2) {
|
|
|
987
1736
|
const iconColor = el.styles.color != null && el.styles.color !== "" ? el.styles.color : "currentColor";
|
|
988
1737
|
svgParts.push(`${indent}<g transform="translate(${r(el.x + blW + plW)}, ${r(el.y + btW + ptW)})" color="${iconColor}">${sized}</g>`);
|
|
989
1738
|
// Close the wrappers opened above (animClass + opacity/transform/clip/mask
|
|
990
|
-
// group
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
1739
|
+
// group, plus the DM-704 filter-outer wrapper when present). Without
|
|
1740
|
+
// these closes, an inline-SVG element with `opacity < 1` (or any other
|
|
1741
|
+
// group-triggering style) emits an unbalanced `<g>` and breaks the
|
|
1742
|
+
// document — observable on resend/stripe whose nav chevrons sit
|
|
1743
|
+
// inside `opacity: 0.7` wrappers.
|
|
994
1744
|
if (animClass !== "")
|
|
995
1745
|
svgParts.push(`${indent}</g>`);
|
|
996
1746
|
if (opened)
|
|
997
1747
|
svgParts.push(`${indent}</g>`);
|
|
1748
|
+
if (needsFilterOuter)
|
|
1749
|
+
svgParts.push(`${indent}</g>`);
|
|
998
1750
|
return;
|
|
999
1751
|
}
|
|
1000
1752
|
// Form control chrome (checkbox, radio, range, color, progress, meter,
|
|
@@ -1053,6 +1805,19 @@ hiDPIFactor = 2) {
|
|
|
1053
1805
|
const contentY = el.y + _bwT + _padT;
|
|
1054
1806
|
const contentW = Math.max(0, el.width - _bwL - _bwR - _padL - _padR);
|
|
1055
1807
|
const contentH = Math.max(0, el.height - _bwT - _bwB - _padT - _padB);
|
|
1808
|
+
// DM-670 / DM-672: if the `<img>` carries a border-radius, the painted
|
|
1809
|
+
// image must clip to the rounded content area — otherwise a 40×40
|
|
1810
|
+
// `border-radius: 50%` avatar paints as a square photo. Build a
|
|
1811
|
+
// rounded-content-box clip once, reuse it whether we take the
|
|
1812
|
+
// object-fit:none branch or the standard branch below.
|
|
1813
|
+
const innerCorners = (borderRadius > 0 || (corners.tl.h + corners.tr.h + corners.bl.h + corners.br.h) > 0)
|
|
1814
|
+
? insetCornerRadii(corners, _bwT, _bwR, _bwB, _bwL)
|
|
1815
|
+
: null;
|
|
1816
|
+
const roundedClipId = innerCorners != null
|
|
1817
|
+
? `${idPrefix}irc${clipIdx++}` : null;
|
|
1818
|
+
if (innerCorners != null && roundedClipId != null) {
|
|
1819
|
+
defsParts.push(`<clipPath id="${roundedClipId}">${roundedRectSvg(contentX, contentY, contentW, contentH, innerCorners, "")}</clipPath>`);
|
|
1820
|
+
}
|
|
1056
1821
|
if (fit === "none" && el.imageIntrinsic != null && el.imageIntrinsic.w > 0 && el.imageIntrinsic.h > 0) {
|
|
1057
1822
|
// object-fit: none -> render image at intrinsic size, aligned via
|
|
1058
1823
|
// object-position inside the element's content box, and clip overflow.
|
|
@@ -1061,13 +1826,34 @@ hiDPIFactor = 2) {
|
|
|
1061
1826
|
const { hPct, vPct } = parseObjectPosition(el.styles.objectPosition ?? "50% 50%");
|
|
1062
1827
|
const ix = contentX + (contentW - iw) * (hPct / 100);
|
|
1063
1828
|
const iy = contentY + (contentH - ih) * (vPct / 100);
|
|
1064
|
-
|
|
1065
|
-
|
|
1829
|
+
// When a border-radius is present, prefer the rounded clip over the
|
|
1830
|
+
// plain content-box rect (a rounded clip subsumes the rect clip:
|
|
1831
|
+
// anything inside the rounded shape is also inside the box).
|
|
1832
|
+
let clipId;
|
|
1833
|
+
if (roundedClipId != null) {
|
|
1834
|
+
clipId = roundedClipId;
|
|
1835
|
+
}
|
|
1836
|
+
else {
|
|
1837
|
+
clipId = `${idPrefix}ifn${clipIdx++}`;
|
|
1838
|
+
defsParts.push(`<clipPath id="${clipId}"><rect x="${r(contentX)}" y="${r(contentY)}" width="${r(contentW)}" height="${r(contentH)}" /></clipPath>`);
|
|
1839
|
+
}
|
|
1066
1840
|
svgParts.push(`${indent}<image href="${esc(embedResizedDataUri(el.imageSrc, iw, ih))}" x="${r(ix)}" y="${r(iy)}" width="${r(iw)}" height="${r(ih)}" preserveAspectRatio="none" clip-path="url(#${clipId})" />`);
|
|
1067
1841
|
}
|
|
1068
1842
|
else {
|
|
1069
1843
|
const par = preserveAspectRatioFor(fit, el.styles.objectPosition);
|
|
1070
|
-
|
|
1844
|
+
const clipAttr = roundedClipId != null ? ` clip-path="url(#${roundedClipId})"` : "";
|
|
1845
|
+
// DM-819: Chrome doesn't honor `preserveAspectRatio` on `<image>`
|
|
1846
|
+
// when the href is an SVG data URI — the embedded SVG paints at its
|
|
1847
|
+
// own intrinsic size (viewBox or width/height) regardless of the
|
|
1848
|
+
// outer slice/meet directive. Workaround: rewrite the inner SVG's
|
|
1849
|
+
// top-level attrs to bake in our consumer width / height and the
|
|
1850
|
+
// matching preserveAspectRatio so the inner SVG self-aligns, then
|
|
1851
|
+
// emit the outer `<image>` with `preserveAspectRatio="none"` (which
|
|
1852
|
+
// Chrome does honor for SVG sources). Pass-through for raster images.
|
|
1853
|
+
const finalSrc = embedResizedDataUri(el.imageSrc, contentW, contentH);
|
|
1854
|
+
const reHomedSrc = rewriteSvgDataUriPreserveAspectRatio(finalSrc, contentW, contentH, par);
|
|
1855
|
+
const outerPar = reHomedSrc !== finalSrc ? "none" : par;
|
|
1856
|
+
svgParts.push(`${indent}<image href="${esc(reHomedSrc)}" x="${r(contentX)}" y="${r(contentY)}" width="${r(contentW)}" height="${r(contentH)}" preserveAspectRatio="${outerPar}"${clipAttr} />`);
|
|
1071
1857
|
}
|
|
1072
1858
|
}
|
|
1073
1859
|
// DM-457: rasterized snapshot for <canvas> / <video> / <iframe> /
|
|
@@ -1213,15 +1999,30 @@ hiDPIFactor = 2) {
|
|
|
1213
1999
|
const textPresDefault = /[➤]/g;
|
|
1214
2000
|
label = label.replace(textPresDefault, (ch) => ch + "︎");
|
|
1215
2001
|
const markerFontFamily = el.markerFontFamily ?? el.styles.fontFamily;
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
|
|
2002
|
+
// DM-790: SVG `<text text-anchor="end">` places the anchor at the
|
|
2003
|
+
// last glyph's advance-end, not its visible-right edge. Chromium
|
|
2004
|
+
// paints the marker so its visible right sits ~7 px from the
|
|
2005
|
+
// content edge (`kCMarkerPaddingPx` in
|
|
2006
|
+
// `list_marker.cc::InlineMarginsForOutside`). Shape the label
|
|
2007
|
+
// through fontkit and read the last non-whitespace glyph's rsb so
|
|
2008
|
+
// the anchor compensates exactly: `mx = el.x − 7 + rsb`. The
|
|
2009
|
+
// helper trims trailing whitespace before measuring because
|
|
2010
|
+
// Chrome's SVG renderer collapses trailing whitespace under
|
|
2011
|
+
// `xml:space="preserve"` (DM-789 probed this). Built-in numeric
|
|
2012
|
+
// markers ending in `.` resolve back to `el.x − 4` via this same
|
|
2013
|
+
// formula (period rsb ≈ 3 px in system-ui).
|
|
2014
|
+
const markerLastRsb = measureLastGlyphRsb(label, markerFontSize, markerFontFamily, markerFontWeight);
|
|
1219
2015
|
const padL = parseFloat(el.styles.paddingLeft ?? "0") || 0;
|
|
1220
2016
|
const borderL = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
|
|
1221
|
-
const mx = outside ? el.x -
|
|
2017
|
+
const mx = outside ? el.x - 7 + markerLastRsb : el.x + borderL + padL;
|
|
1222
2018
|
const anchor = outside ? "end" : "start";
|
|
1223
2019
|
const escLabel = label.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1224
|
-
|
|
2020
|
+
// DM-770: `@counter-style` suffixes like `": "` carry multiple
|
|
2021
|
+
// consecutive spaces that SVG would collapse to a single space by
|
|
2022
|
+
// default. `xml:space="preserve"` keeps the marker label width
|
|
2023
|
+
// matching Chrome's paint.
|
|
2024
|
+
const xmlSpace = / {2,}/.test(label) ? ` xml:space="preserve"` : "";
|
|
2025
|
+
svgParts.push(`${indent}<text x="${r(mx)}" y="${r(my)}" text-anchor="${anchor}" font-size="${r(markerFontSize)}" font-weight="${markerFontWeight}" font-family="${esc(markerFontFamily)}" fill="${markerColor}"${xmlSpace}>${escLabel}</text>`);
|
|
1225
2026
|
}
|
|
1226
2027
|
else if (lsType === "disc" || lsType === "circle" || lsType === "square") {
|
|
1227
2028
|
// Chrome's `::marker` paints disc/circle/square at a hardcoded
|
|
@@ -1263,28 +2064,26 @@ hiDPIFactor = 2) {
|
|
|
1263
2064
|
}
|
|
1264
2065
|
else {
|
|
1265
2066
|
// Text-based marker (decimal / lower-alpha / lower-roman / etc.).
|
|
1266
|
-
// Chrome's
|
|
1267
|
-
//
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
2067
|
+
// Chrome's painted ::marker right edge sits ~7px left of li.x for
|
|
2068
|
+
// 16px sans-serif (pixel-probed on 03-lists-style-types DM-678 — the
|
|
2069
|
+
// VISIBLE last-pixel-of-"." sits at li.x - 7).
|
|
2070
|
+
//
|
|
2071
|
+
// SVG `text-anchor="end"` aligns the END of the LAST GLYPH'S ADVANCE
|
|
2072
|
+
// at `x`, not the visible right edge of that glyph. DM-790: measure
|
|
2073
|
+
// the last glyph's right-side-bearing through fontkit and add it
|
|
2074
|
+
// back to the visible-right target (`el.x − 7`, Chromium's
|
|
2075
|
+
// `kCMarkerPaddingPx`). For "01." the `.` glyph has ~3 px rsb in
|
|
2076
|
+
// system-ui Helvetica so `mx = el.x − 7 + 3 = el.x − 4` — the
|
|
2077
|
+
// previous hardcoded constant; for other suffixes (e.g. Greek-
|
|
2078
|
+
// marker styles ending in `)` or `α`) the rsb floats to whatever
|
|
2079
|
+
// the actual last glyph dictates.
|
|
1273
2080
|
const label = formatListMarker(lsType, idx) + ".";
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
// 4px estimate, which placed the marker too far right). Empirical
|
|
1277
|
-
// pixel measurement vs Chrome's painted output on the
|
|
1278
|
-
// 03-lists-marker fixture (16px monospace bold "1.") shows the
|
|
1279
|
-
// marker right edge sits ~7-8px left of li.x. The fixed-width
|
|
1280
|
-
// approximation is safer than text_width × heuristic since
|
|
1281
|
-
// monospace vs proportional font advance varies widely.
|
|
1282
|
-
const smallGap = 8;
|
|
2081
|
+
const markerFontFamily = el.markerFontFamily ?? el.styles.fontFamily;
|
|
2082
|
+
const builtinLastRsb = measureLastGlyphRsb(label, markerFontSize, markerFontFamily, markerFontWeight);
|
|
1283
2083
|
const padL = parseFloat(el.styles.paddingLeft ?? "0") || 0;
|
|
1284
2084
|
const borderL = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
|
|
1285
|
-
const mx = outside ? el.x -
|
|
2085
|
+
const mx = outside ? el.x - 7 + builtinLastRsb : el.x + borderL + padL;
|
|
1286
2086
|
const anchor = outside ? "end" : "start";
|
|
1287
|
-
const markerFontFamily = el.markerFontFamily ?? el.styles.fontFamily;
|
|
1288
2087
|
svgParts.push(`${indent}<text x="${r(mx)}" y="${r(my)}" text-anchor="${anchor}" font-size="${r(markerFontSize)}" font-weight="${markerFontWeight}" font-family="${esc(markerFontFamily)}" fill="${markerColor}">${label}</text>`);
|
|
1289
2088
|
}
|
|
1290
2089
|
}
|
|
@@ -1339,10 +2138,38 @@ hiDPIFactor = 2) {
|
|
|
1339
2138
|
// stroke="..." attribute the way the regular-element border path does.
|
|
1340
2139
|
if (el.pseudoBoxes != null) {
|
|
1341
2140
|
for (const pb of el.pseudoBoxes) {
|
|
2141
|
+
// DM-783: snapshot svgParts.length so we can wrap THIS pb's emit in
|
|
2142
|
+
// a `<g transform="…">` when pb.transform is present. The wrap pre-
|
|
2143
|
+
// bakes the rotation/scale around the captured transform-origin so
|
|
2144
|
+
// a rotate(45deg) on a `::before { border-right; border-bottom }`
|
|
2145
|
+
// paints as a check-mark instead of a backwards-L (the rotation
|
|
2146
|
+
// pivots around the box center, not the origin). When pb.transform
|
|
2147
|
+
// is absent we splice nothing — the loop body's pushes flow through
|
|
2148
|
+
// unchanged.
|
|
2149
|
+
const pbStart = svgParts.length;
|
|
1342
2150
|
if (pb.backgroundColor) {
|
|
1343
2151
|
const rxAttr = pb.borderRadius && pb.borderRadius > 0 ? ` rx="${r(pb.borderRadius)}"` : "";
|
|
1344
2152
|
svgParts.push(`${indent}<rect x="${r(pb.x)}" y="${r(pb.y)}" width="${r(pb.width)}" height="${r(pb.height)}"${rxAttr} fill="${pb.backgroundColor}" />`);
|
|
1345
2153
|
}
|
|
2154
|
+
// DM-767: pseudoBox background-image (linear-/radial-gradient).
|
|
2155
|
+
// Emit each comma-separated layer in reverse order so layer 0 (first
|
|
2156
|
+
// in CSS source) ends up on top — same convention as the regular-
|
|
2157
|
+
// element background-image path. Each layer goes through
|
|
2158
|
+
// `buildBackgroundLayerDef` to produce an SVG paint server, then a
|
|
2159
|
+
// covering `<rect>` references it.
|
|
2160
|
+
if (pb.backgroundImage != null && pb.backgroundImage !== "none" && pb.backgroundImage !== "") {
|
|
2161
|
+
const pbLayers = splitTopLevelCommas(pb.backgroundImage);
|
|
2162
|
+
for (let li = pbLayers.length - 1; li >= 0; li--) {
|
|
2163
|
+
const layer = pbLayers[li].trim();
|
|
2164
|
+
const defId = `${idPrefix}pbg${clipIdx++}`;
|
|
2165
|
+
const out = buildBackgroundLayerDef(defId, layer, pb.x, pb.y, pb.width, pb.height, "auto", "0% 0%", "repeat", null, "scroll", captureViewport);
|
|
2166
|
+
if (out.def === "")
|
|
2167
|
+
continue;
|
|
2168
|
+
defsParts.push(out.def);
|
|
2169
|
+
const rxAttr = pb.borderRadius && pb.borderRadius > 0 ? ` rx="${r(pb.borderRadius)}"` : "";
|
|
2170
|
+
svgParts.push(`${indent}<rect x="${r(pb.x)}" y="${r(pb.y)}" width="${r(pb.width)}" height="${r(pb.height)}"${rxAttr} fill="url(#${defId})" />`);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
1346
2173
|
// CSS triangle: 0×0 box with one solid border and adjacent borders
|
|
1347
2174
|
// transparent / zero. Borders meet at 45° corners and visually form
|
|
1348
2175
|
// a right triangle in the solid color. Detect + emit as <polygon>
|
|
@@ -1408,8 +2235,43 @@ hiDPIFactor = 2) {
|
|
|
1408
2235
|
const polyPts = pts.map((p) => `${r(p[0])},${r(p[1])}`).join(" ");
|
|
1409
2236
|
svgParts.push(`${indent}<polygon points="${polyPts}" fill="${color}" />`);
|
|
1410
2237
|
}
|
|
2238
|
+
flushPbTransformWrap();
|
|
1411
2239
|
continue;
|
|
1412
2240
|
}
|
|
2241
|
+
// DM-765: when all four borders are uniform AND the pseudo has a
|
|
2242
|
+
// non-zero border-radius (e.g. the `.dot::before { width: 8px;
|
|
2243
|
+
// height: 8px; border: 2px solid; border-radius: 50% }` chip in
|
|
2244
|
+
// `24-deep-pseudo-shapes`), the four straight `<line>` strokes
|
|
2245
|
+
// would form a SQUARE outline around the rounded background fill,
|
|
2246
|
+
// making a green-square-with-darker-square instead of the
|
|
2247
|
+
// green-circle-with-darker-ring Chrome paints. Emit a single
|
|
2248
|
+
// stroked `<rect rx>` in that case so the outline follows the
|
|
2249
|
+
// background's curve.
|
|
2250
|
+
const uniformBorder = bwT > 0 && bwT === bwR && bwR === bwB && bwB === bwL
|
|
2251
|
+
&& pb.borderTopColor != null
|
|
2252
|
+
&& pb.borderTopColor === pb.borderRightColor
|
|
2253
|
+
&& pb.borderRightColor === pb.borderBottomColor
|
|
2254
|
+
&& pb.borderBottomColor === pb.borderLeftColor
|
|
2255
|
+
&& (pb.borderTopStyle == null || pb.borderTopStyle === pb.borderRightStyle);
|
|
2256
|
+
if (uniformBorder && pb.borderRadius != null && pb.borderRadius > 0 && isOpaque(pb.borderTopColor)) {
|
|
2257
|
+
const style = pb.borderTopStyle ?? "solid";
|
|
2258
|
+
if (style !== "none" && style !== "hidden") {
|
|
2259
|
+
const w = bwT;
|
|
2260
|
+
const half = w / 2;
|
|
2261
|
+
// Inset the stroke rect by half the stroke width so the stroke
|
|
2262
|
+
// sits entirely inside the box (matches CSS, where borders paint
|
|
2263
|
+
// inside the border box).
|
|
2264
|
+
const sx = pb.x + half;
|
|
2265
|
+
const sy = pb.y + half;
|
|
2266
|
+
const sw = Math.max(0, pb.width - w);
|
|
2267
|
+
const sh = Math.max(0, pb.height - w);
|
|
2268
|
+
const sr = Math.max(0, pb.borderRadius - half);
|
|
2269
|
+
const dash = style === "dashed" ? ` stroke-dasharray="${r(w * 2)},${r(w * 2)}"` : style === "dotted" ? ` stroke-dasharray="${r(w)},${r(w)}"` : "";
|
|
2270
|
+
svgParts.push(`${indent}<rect x="${r(sx)}" y="${r(sy)}" width="${r(sw)}" height="${r(sh)}" rx="${r(sr)}" fill="none" stroke="${pb.borderTopColor}" stroke-width="${r(w)}"${dash} />`);
|
|
2271
|
+
flushPbTransformWrap();
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
1413
2275
|
// Per-side borders. Each painted side gets one <line> across the
|
|
1414
2276
|
// appropriate edge. For h=0 / w=0 boxes this collapses to a single
|
|
1415
2277
|
// visible hairline — the separator case.
|
|
@@ -1425,6 +2287,38 @@ hiDPIFactor = 2) {
|
|
|
1425
2287
|
side(pb.x + pb.width - (pb.borderRightWidth ?? 0) / 2, pb.y, pb.x + pb.width - (pb.borderRightWidth ?? 0) / 2, pb.y + pb.height, pb.borderRightWidth, pb.borderRightColor, pb.borderRightStyle);
|
|
1426
2288
|
side(pb.x, pb.y + pb.height - (pb.borderBottomWidth ?? 0) / 2, pb.x + pb.width, pb.y + pb.height - (pb.borderBottomWidth ?? 0) / 2, pb.borderBottomWidth, pb.borderBottomColor, pb.borderBottomStyle);
|
|
1427
2289
|
side(pb.x + (pb.borderLeftWidth ?? 0) / 2, pb.y, pb.x + (pb.borderLeftWidth ?? 0) / 2, pb.y + pb.height, pb.borderLeftWidth, pb.borderLeftColor, pb.borderLeftStyle);
|
|
2290
|
+
// DM-783: wrap whatever this iteration emitted (rect / lines /
|
|
2291
|
+
// polygon / per-side strokes) in a `<g transform="…">` that pre-
|
|
2292
|
+
// bakes the rotation/scale around the captured transform-origin.
|
|
2293
|
+
// Defined inline here so it closes over `pb` and `svgParts` /
|
|
2294
|
+
// `pbStart` from the outer scope.
|
|
2295
|
+
function flushPbTransformWrap() {
|
|
2296
|
+
if (pb.transform == null || pb.transform === "" || pb.transform === "none")
|
|
2297
|
+
return;
|
|
2298
|
+
const added = svgParts.splice(pbStart);
|
|
2299
|
+
if (added.length === 0)
|
|
2300
|
+
return;
|
|
2301
|
+
// transform-origin: resolved to px values relative to the
|
|
2302
|
+
// pseudo's box top-left (Chrome's getComputedStyle normalises
|
|
2303
|
+
// keywords / % to px). Default = box center (`50% 50%`).
|
|
2304
|
+
let ox = pb.width / 2;
|
|
2305
|
+
let oy = pb.height / 2;
|
|
2306
|
+
if (pb.transformOrigin != null && pb.transformOrigin !== "") {
|
|
2307
|
+
const oParts = pb.transformOrigin.split(/\s+/).map((p) => parseFloat(p));
|
|
2308
|
+
if (oParts.length >= 2 && Number.isFinite(oParts[0]) && Number.isFinite(oParts[1])) {
|
|
2309
|
+
ox = oParts[0];
|
|
2310
|
+
oy = oParts[1];
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
const tx = pb.x + ox;
|
|
2314
|
+
const ty = pb.y + oy;
|
|
2315
|
+
// The inner emits were already indented; we keep the same
|
|
2316
|
+
// indent for the wrapper and strip leading indent from each
|
|
2317
|
+
// inner part so the wrapping `<g>` doesn't double-indent.
|
|
2318
|
+
const inner = added.map((s) => s.startsWith(indent) ? s.slice(indent.length) : s).join("");
|
|
2319
|
+
svgParts.push(`${indent}<g transform="translate(${r(tx)} ${r(ty)}) ${pb.transform} translate(${r(-tx)} ${r(-ty)})">${inner}</g>`);
|
|
2320
|
+
}
|
|
2321
|
+
flushPbTransformWrap();
|
|
1428
2322
|
}
|
|
1429
2323
|
}
|
|
1430
2324
|
// Text rendering — delegated to text-renderer.ts based on configured mode
|
|
@@ -1440,8 +2334,26 @@ hiDPIFactor = 2) {
|
|
|
1440
2334
|
const tfcRaw = el.styles.webkitTextFillColor;
|
|
1441
2335
|
const tfc = tfcRaw != null ? parseColor(tfcRaw) : null;
|
|
1442
2336
|
const textIsTransparent = (tfc != null ? tfc.a < 0.01 : (textColor != null && textColor.a < 0.01));
|
|
1443
|
-
|
|
1444
|
-
|
|
2337
|
+
// Topmost text-clipped layer is the visible color over the glyphs when
|
|
2338
|
+
// we fall into the non-mask path; in the mask path below ALL layers
|
|
2339
|
+
// composite (DM-696). Find the topmost (lowest li) non-empty entry.
|
|
2340
|
+
// DM-749: when this element has no text-bg-clip layers of its own but
|
|
2341
|
+
// an ancestor has `background-clip: text` + a gradient (the Stripe
|
|
2342
|
+
// hds-heading pattern — span with gradient + bg-clip:text wraps a
|
|
2343
|
+
// child div with the actual text), build a gradient def from the
|
|
2344
|
+
// captured `inheritedTextFillGradient` and use it as the fill.
|
|
2345
|
+
let topmostTextBgClipFill = textBgClipFills.find((s) => s != null) ?? null;
|
|
2346
|
+
if (topmostTextBgClipFill == null && textIsTransparent && el.styles.inheritedTextFillGradient != null && el.styles.inheritedTextFillGradient !== "" && el.styles.inheritedTextFillGradient !== "none") {
|
|
2347
|
+
const layer = el.styles.inheritedTextFillGradient;
|
|
2348
|
+
const defId = `${idPrefix}bg${clipIdx++}`;
|
|
2349
|
+
const out = buildBackgroundLayerDef(defId, layer, el.x, el.y, el.width, el.height, "auto", "0% 0%", "no-repeat", null, "scroll", captureViewport);
|
|
2350
|
+
if (out.def !== "") {
|
|
2351
|
+
defsParts.push(out.def);
|
|
2352
|
+
topmostTextBgClipFill = `url(#${defId})`;
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
const fillColor = (topmostTextBgClipFill != null && textIsTransparent)
|
|
2356
|
+
? topmostTextBgClipFill
|
|
1445
2357
|
: (textColor != null ? colorStr(textColor) : "#e6edf3");
|
|
1446
2358
|
const cid = `${idPrefix}ct${clipIdx++}`;
|
|
1447
2359
|
defsParts.push(`<clipPath id="${cid}"><rect x="${r(el.x)}" y="${r(el.y)}" width="${r(el.width)}" height="${r(el.height)}" /></clipPath>`);
|
|
@@ -1459,16 +2371,45 @@ hiDPIFactor = 2) {
|
|
|
1459
2371
|
svgParts.push(`${indent}<image href="${er.dataUri}" x="${r(er.x)}" y="${r(er.y)}" width="${r(er.width)}" height="${r(er.height)}" preserveAspectRatio="none" clip-path="url(#${cid})"/>`);
|
|
1460
2372
|
}
|
|
1461
2373
|
else {
|
|
2374
|
+
// DM-782: pseudoBox gradient/url() emitter. The text renderer can't
|
|
2375
|
+
// own defsParts / clipIdx (those live in the element-tree render loop)
|
|
2376
|
+
// so we hand it a closure that produces the gradient layer rects +
|
|
2377
|
+
// appends each layer's `<linearGradient>` / `<radialGradient>` paint
|
|
2378
|
+
// server to defsParts. Same emit shape as the empty-content pseudoBox
|
|
2379
|
+
// path below — comma-separated layers walked in reverse so layer 0
|
|
2380
|
+
// (first in CSS source) ends up on top.
|
|
2381
|
+
const emitPseudoBoxBgLayers = (pb) => {
|
|
2382
|
+
const layers = splitTopLevelCommas(pb.backgroundImage);
|
|
2383
|
+
const out = [];
|
|
2384
|
+
for (let li = layers.length - 1; li >= 0; li--) {
|
|
2385
|
+
const layer = layers[li].trim();
|
|
2386
|
+
const defId = `${idPrefix}pbgt${clipIdx++}`;
|
|
2387
|
+
const built = buildBackgroundLayerDef(defId, layer, pb.x, pb.y, pb.width, pb.height, "auto", "0% 0%", "repeat", null, "scroll", captureViewport);
|
|
2388
|
+
if (built.def === "")
|
|
2389
|
+
continue;
|
|
2390
|
+
defsParts.push(built.def);
|
|
2391
|
+
const rxAttr = pb.borderRadius != null && pb.borderRadius > 0 ? ` rx="${r(pb.borderRadius)}" ry="${r(pb.borderRadius)}"` : "";
|
|
2392
|
+
out.push(`<rect x="${r(pb.x)}" y="${r(pb.y)}" width="${r(pb.width)}" height="${r(pb.height)}"${rxAttr} fill="url(#${defId})" />`);
|
|
2393
|
+
}
|
|
2394
|
+
return out.join("");
|
|
2395
|
+
};
|
|
1462
2396
|
const renderOneText = (opts) => {
|
|
2397
|
+
const optsWithEmit = { ...opts, emitPseudoBoxBgLayers };
|
|
1463
2398
|
const hasMultipleSegments = opts.el.textSegments != null && opts.el.textSegments.length > 1;
|
|
1464
2399
|
const isMultiLine = opts.el.text.includes("\n");
|
|
2400
|
+
// DM-799: input/textarea dispatch must come BEFORE the multi-line
|
|
2401
|
+
// branch. A textarea with newline-bearing value (`\n` in `el.text`)
|
|
2402
|
+
// would otherwise hit `renderMultiLineText`, which path-renders each
|
|
2403
|
+
// source line without word-wrap — Lorem-ipsum lines overflowed the
|
|
2404
|
+
// textarea's right edge instead of being painted from the captured
|
|
2405
|
+
// `elementRaster` PNG (which carries Chrome's own wrapping).
|
|
2406
|
+
if (opts.el.tag === "input" || opts.el.tag === "textarea")
|
|
2407
|
+
return renderInputText(optsWithEmit);
|
|
1465
2408
|
if (hasMultipleSegments)
|
|
1466
|
-
return renderMultiSegmentText(
|
|
2409
|
+
return renderMultiSegmentText(optsWithEmit, opts.el.textSegments);
|
|
1467
2410
|
if (isMultiLine)
|
|
1468
|
-
return renderMultiLineText(
|
|
1469
|
-
|
|
1470
|
-
return renderInputText(opts);
|
|
1471
|
-
return renderSingleLineText(opts);
|
|
2411
|
+
return renderMultiLineText(optsWithEmit);
|
|
2412
|
+
return renderSingleLineText(optsWithEmit);
|
|
1472
2413
|
};
|
|
1473
2414
|
// text-shadow (SK-1113): render each shadow as a recolored copy of
|
|
1474
2415
|
// the same text, shifted by the shadows (x, y) and wrapped in a
|
|
@@ -1521,7 +2462,8 @@ hiDPIFactor = 2) {
|
|
|
1521
2462
|
const toy = el.styles.overflowY;
|
|
1522
2463
|
const textOverflowClip = (tox != null && tox !== "visible") || (toy != null && toy !== "visible");
|
|
1523
2464
|
const renderOpts = { el, idPrefix, clipId: cid, fillColor, overflowClip: textOverflowClip };
|
|
1524
|
-
|
|
2465
|
+
const hasTextBgClip = textBgClipFills.some((s) => s != null);
|
|
2466
|
+
if (hasTextBgClip && textIsTransparent) {
|
|
1525
2467
|
// DM-462: background-clip:text — the bg-image should fill the glyph
|
|
1526
2468
|
// shapes, not the headline element rect. We render the text glyphs
|
|
1527
2469
|
// INTO an SVG <mask> (with white fill so the mask reveals the bg
|
|
@@ -1539,7 +2481,17 @@ hiDPIFactor = 2) {
|
|
|
1539
2481
|
const maskBody = renderOneText({ el: maskFillEl, idPrefix, clipId: cid, fillColor: "rgb(255,255,255)", overflowClip: textOverflowClip });
|
|
1540
2482
|
const mid = `${idPrefix}tbgm${clipIdx++}`;
|
|
1541
2483
|
defsParts.push(`<mask id="${mid}" maskUnits="userSpaceOnUse" x="${r(el.x)}" y="${r(el.y)}" width="${r(el.width)}" height="${r(el.height)}">${maskBody}</mask>`);
|
|
1542
|
-
|
|
2484
|
+
// Emit one masked rect per text-clipped layer, walking from BOTTOM
|
|
2485
|
+
// (highest li) to TOP (li = 0) so the topmost CSS layer paints last.
|
|
2486
|
+
// All rects share the same glyph mask; later rects paint over earlier
|
|
2487
|
+
// ones inside the glyph silhouettes, matching Chrome's compositing of
|
|
2488
|
+
// stacked `background-clip: text` layers (DM-696).
|
|
2489
|
+
for (let li = textBgClipFills.length - 1; li >= 0; li--) {
|
|
2490
|
+
const f = textBgClipFills[li];
|
|
2491
|
+
if (f == null)
|
|
2492
|
+
continue;
|
|
2493
|
+
svgParts.push(`${indent}<rect x="${r(el.x)}" y="${r(el.y)}" width="${r(el.width)}" height="${r(el.height)}" fill="${f}" mask="url(#${mid})" />`);
|
|
2494
|
+
}
|
|
1543
2495
|
}
|
|
1544
2496
|
else {
|
|
1545
2497
|
svgParts.push(`${indent}${renderOneText(renderOpts)}`);
|
|
@@ -1672,25 +2624,44 @@ hiDPIFactor = 2) {
|
|
|
1672
2624
|
svgParts.push(`${indent}<text x="${r(markerRightX)}" y="${r(ty)}" text-anchor="end" font-size="${r(fontSizePx)}" font-family="${esc(el.styles.fontFamily)}" fill="${fillCol}">${escMarker}</text>`);
|
|
1673
2625
|
}
|
|
1674
2626
|
}
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
1677
|
-
//
|
|
1678
|
-
//
|
|
1679
|
-
//
|
|
1680
|
-
//
|
|
1681
|
-
//
|
|
1682
|
-
|
|
2627
|
+
// Resize handle (DM-339): when CSS `resize` is non-none and the element
|
|
2628
|
+
// is a resizable type, Chrome paints a small ~7×7 diagonal-line pattern
|
|
2629
|
+
// in the bottom-right corner indicating the user can drag to resize.
|
|
2630
|
+
// Empirical: 3 diagonal lines from the corner extending up-left, ~1.5px
|
|
2631
|
+
// stroke, mid-gray (#999), inside the padding-box. Matches what Chrome
|
|
2632
|
+
// paints across resize: vertical / horizontal / both / inline / block
|
|
2633
|
+
// (only `none` suppresses).
|
|
2634
|
+
//
|
|
2635
|
+
// Per CSS UI spec §6.3 + Chrome's `LayoutBox::CanResize`: the handle
|
|
2636
|
+
// paints on any replaced element OR any block-level element with
|
|
2637
|
+
// `overflow` other than `visible` (the spec says `resize` only takes
|
|
2638
|
+
// effect when overflow != visible, and Chrome only renders the grippy
|
|
2639
|
+
// when the property is "in effect"). So textareas always qualify
|
|
2640
|
+
// (textarea UA style sets overflow:auto), but plain divs with
|
|
2641
|
+
// `overflow: auto; resize: both` qualify too.
|
|
2642
|
+
const resizeInEffect = el.styles.resize != null && el.styles.resize !== "none"
|
|
2643
|
+
&& (el.tag === "textarea"
|
|
2644
|
+
|| (el.styles.overflowX != null && el.styles.overflowX !== "visible")
|
|
2645
|
+
|| (el.styles.overflowY != null && el.styles.overflowY !== "visible"));
|
|
2646
|
+
if (resizeInEffect) {
|
|
1683
2647
|
const handleColor = "rgb(153,153,153)";
|
|
1684
2648
|
const handleSize = 7;
|
|
1685
|
-
// Position the handle so its bottom-right corner sits
|
|
1686
|
-
//
|
|
1687
|
-
//
|
|
1688
|
-
|
|
1689
|
-
|
|
2649
|
+
// Position the handle so its bottom-right corner sits just INSIDE the
|
|
2650
|
+
// inner (padding-box) corner — the diagonals then sweep up-left into
|
|
2651
|
+
// the padding area where they're visible against the content
|
|
2652
|
+
// background. Inset by the border widths plus a small 1 px gap.
|
|
2653
|
+
// (Matches Chrome's painted offset; previously we used a fixed 2 px
|
|
2654
|
+
// inset from the border-box which worked for thin-border textareas
|
|
2655
|
+
// but parked the handle on top of the dark border on thicker-bordered
|
|
2656
|
+
// divs in `30-resize`. DM-707.)
|
|
2657
|
+
const borderR = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
|
|
2658
|
+
const borderB = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
|
|
2659
|
+
const cx = el.x + el.width - borderR;
|
|
2660
|
+
const cy = el.y + el.height - borderB;
|
|
1690
2661
|
// Three diagonal strokes 2px apart sloping from bottom-right to upper-left.
|
|
1691
2662
|
for (let i = 0; i < 3; i++) {
|
|
1692
2663
|
const off = i * 2.5;
|
|
1693
|
-
svgParts.push(`${indent}<line x1="${r(cx - handleSize + off)}" y1="${r(cy)}" x2="${r(cx)}" y2="${r(cy - handleSize + off)}" stroke="${handleColor}" stroke-width="
|
|
2664
|
+
svgParts.push(`${indent}<line x1="${r(cx - handleSize + off)}" y1="${r(cy)}" x2="${r(cx)}" y2="${r(cy - handleSize + off)}" stroke="${handleColor}" stroke-width="1" />`);
|
|
1694
2665
|
}
|
|
1695
2666
|
}
|
|
1696
2667
|
// Overflow clipping: when a parent has overflow != visible (hidden/scroll/
|
|
@@ -1720,15 +2691,99 @@ hiDPIFactor = 2) {
|
|
|
1720
2691
|
const containClips = containVal != null && containVal !== "" && containVal !== "none"
|
|
1721
2692
|
&& /\b(?:paint|strict|content)\b/i.test(containVal);
|
|
1722
2693
|
const clipsOverflow = (ox != null && ox !== "visible") || (oy != null && oy !== "visible") || containClips;
|
|
2694
|
+
// DM-650: same body-overflow-propagation rule as the earlier clip-path
|
|
2695
|
+
// emission — when body has non-visible overflow it propagates to the
|
|
2696
|
+
// viewport rather than clipping body itself; skip the children-overflow
|
|
2697
|
+
// clip too so descendants positioned outside body's bbox (e.g. NYT
|
|
2698
|
+
// desktop's content wrapper, which extends below body's height: 100vh
|
|
2699
|
+
// box) stay visible after the document scroll moves body off-viewport.
|
|
2700
|
+
const isBodyOverflowPropagatedHere = el.tag === "body";
|
|
1723
2701
|
let overflowClipId = null;
|
|
1724
|
-
if (clipsOverflow && el.children.length > 0) {
|
|
2702
|
+
if (clipsOverflow && !isBodyOverflowPropagatedHere && el.children.length > 0) {
|
|
1725
2703
|
overflowClipId = `${idPrefix}ov${clipIdx++}`;
|
|
1726
2704
|
const cbt = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
|
|
1727
2705
|
const cbr = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
|
|
1728
2706
|
const cbb = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
|
|
1729
2707
|
const cbl = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
|
|
1730
|
-
|
|
2708
|
+
// DM-698: overflow clips to the inner border-radius (per CSS Backgrounds 3
|
|
2709
|
+
// — the rounded clip on the padding box uses radii inset by each side's
|
|
2710
|
+
// border width, clamped to zero). Previously we passed the OUTER `corners`
|
|
2711
|
+
// which made the clip too generous near each corner, exposing a sliver of
|
|
2712
|
+
// the parent's background between the border and the clipped child.
|
|
2713
|
+
// (e.g. `18-deep-radius-overflow` `.card` border-radius:32 / border:4 +
|
|
2714
|
+
// child `position:absolute inset:0`: 4 px gradient sliver visible inside
|
|
2715
|
+
// each rounded corner.)
|
|
2716
|
+
const overflowInnerCorners = insetCornerRadii(corners, cbt, cbr, cbb, cbl);
|
|
2717
|
+
// Default clip = padding box (border-inset). DM-761: when `overflow: clip`
|
|
2718
|
+
// (only — `hidden` ignores it) plus a non-zero `overflow-clip-margin`,
|
|
2719
|
+
// the clip extends outward from a reference box. The shorthand resolves
|
|
2720
|
+
// to either `"<length>"` (defaults to padding-box reference) or
|
|
2721
|
+
// `"<ref-box> <length>"` where ref-box is content-box / padding-box /
|
|
2722
|
+
// border-box. The clip rect grows by the length on every side from the
|
|
2723
|
+
// chosen ref-box edge. Corner radii on the expanded clip are left at
|
|
2724
|
+
// the inner-border-radius value — that's how Chrome paints it: the
|
|
2725
|
+
// outset region is a rectangular extension, not a radial expansion.
|
|
2726
|
+
let ocX = el.x + cbl;
|
|
2727
|
+
let ocY = el.y + cbt;
|
|
2728
|
+
let ocW = Math.max(0, el.width - cbl - cbr);
|
|
2729
|
+
let ocH = Math.max(0, el.height - cbt - cbb);
|
|
2730
|
+
const isClip = ox === "clip" || oy === "clip";
|
|
2731
|
+
// DM-787: CSS Overflow 3 allows mixing `overflow-x: clip; overflow-y:
|
|
2732
|
+
// visible` (only `clip` permits this — `hidden + visible` coerces to
|
|
2733
|
+
// `auto + hidden`). Chrome clips only the clipped axis; content can
|
|
2734
|
+
// still escape on the visible axis. The SVG clipPath is a single rect,
|
|
2735
|
+
// so to NOT clip on an axis we extend that axis past any plausible
|
|
2736
|
+
// paint area with `±UNBOUNDED`. Apply before the `overflow-clip-margin`
|
|
2737
|
+
// expansion so the per-axis grow happens AFTER ref-box adjustments.
|
|
2738
|
+
const UNBOUNDED = 100000;
|
|
2739
|
+
const xVisible = ox === "visible" && oy === "clip";
|
|
2740
|
+
const yVisible = oy === "visible" && ox === "clip";
|
|
2741
|
+
const ocmRaw = el.styles.overflowClipMargin;
|
|
2742
|
+
if (isClip && ocmRaw != null && ocmRaw !== "" && ocmRaw !== "0px") {
|
|
2743
|
+
const m = /^(?:(content-box|padding-box|border-box)\s+)?(-?\d*\.?\d+)px$/i.exec(ocmRaw.trim());
|
|
2744
|
+
if (m) {
|
|
2745
|
+
const refBox = (m[1] ?? "padding-box").toLowerCase();
|
|
2746
|
+
const margin = parseFloat(m[2]);
|
|
2747
|
+
// Reference-box edges relative to (el.x, el.y) border-box top-left:
|
|
2748
|
+
// border-box → 0
|
|
2749
|
+
// padding-box → border width (cbl/cbt/cbr/cbb)
|
|
2750
|
+
// content-box → border + padding
|
|
2751
|
+
let refL = 0, refT = 0, refR = 0, refB = 0;
|
|
2752
|
+
if (refBox === "padding-box") {
|
|
2753
|
+
refL = cbl;
|
|
2754
|
+
refT = cbt;
|
|
2755
|
+
refR = cbr;
|
|
2756
|
+
refB = cbb;
|
|
2757
|
+
}
|
|
2758
|
+
else if (refBox === "content-box") {
|
|
2759
|
+
const pT = parseFloat(el.styles.paddingTop ?? "0") || 0;
|
|
2760
|
+
const pR = parseFloat(el.styles.paddingRight ?? "0") || 0;
|
|
2761
|
+
const pB = parseFloat(el.styles.paddingBottom ?? "0") || 0;
|
|
2762
|
+
const pL = parseFloat(el.styles.paddingLeft ?? "0") || 0;
|
|
2763
|
+
refL = cbl + pL;
|
|
2764
|
+
refT = cbt + pT;
|
|
2765
|
+
refR = cbr + pR;
|
|
2766
|
+
refB = cbb + pB;
|
|
2767
|
+
}
|
|
2768
|
+
ocX = el.x + refL - margin;
|
|
2769
|
+
ocY = el.y + refT - margin;
|
|
2770
|
+
ocW = Math.max(0, el.width - refL - refR + margin * 2);
|
|
2771
|
+
ocH = Math.max(0, el.height - refT - refB + margin * 2);
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
if (xVisible) {
|
|
2775
|
+
ocX = el.x - UNBOUNDED;
|
|
2776
|
+
ocW = el.width + UNBOUNDED * 2;
|
|
2777
|
+
}
|
|
2778
|
+
if (yVisible) {
|
|
2779
|
+
ocY = el.y - UNBOUNDED;
|
|
2780
|
+
ocH = el.height + UNBOUNDED * 2;
|
|
2781
|
+
}
|
|
2782
|
+
defsParts.push(`<clipPath id="${overflowClipId}">${roundedRectSvg(ocX, ocY, ocW, ocH, overflowInnerCorners, "")}</clipPath>`);
|
|
1731
2783
|
svgParts.push(`${indent}<g clip-path="url(#${overflowClipId})">`);
|
|
2784
|
+
// DM-673: stash the clip-path id so hoisted descendants of this
|
|
2785
|
+
// overflow scroller can re-wrap their emission in the same clip.
|
|
2786
|
+
overflowClipPathIds.set(el, overflowClipId);
|
|
1732
2787
|
}
|
|
1733
2788
|
// Children — sorted by CSS paint order. Elements with position != static
|
|
1734
2789
|
// and an explicit integer z-index paint in z-index order (negative below,
|
|
@@ -1748,15 +2803,80 @@ hiDPIFactor = 2) {
|
|
|
1748
2803
|
// children that are flex/grid items honor z-index ≠ auto as a stacking
|
|
1749
2804
|
// context creator and z-sort accordingly.
|
|
1750
2805
|
const childParentDisplay = el.styles.display;
|
|
2806
|
+
const hoistedAsInlineForEl = new Set();
|
|
2807
|
+
const hoistedAsZSortedForEl = new Set();
|
|
1751
2808
|
if (establishesStackingContext(el, parentDisplayForEl)) {
|
|
1752
|
-
childrenForSort = gatherStackingContextChildren(baseChildren, hoistedFromAncestor, childParentDisplay,
|
|
2809
|
+
childrenForSort = gatherStackingContextChildren(baseChildren, hoistedFromAncestor, childParentDisplay, hoistedAsInlineForEl, overflowClipForHoisted, hoistedAsZSortedForEl);
|
|
1753
2810
|
}
|
|
1754
2811
|
else {
|
|
1755
2812
|
childrenForSort = baseChildren.filter((c) => !hoistedFromAncestor.has(c));
|
|
1756
2813
|
}
|
|
1757
|
-
|
|
2814
|
+
let sortedChildren = sortChildrenByPaintOrder(childrenForSort, childParentDisplay, el.styles.flexDirection, hoistedAsInlineForEl, hoistedAsZSortedForEl);
|
|
2815
|
+
// DM-751: when this element establishes a 3D rendering context
|
|
2816
|
+
// (`transform-style: preserve-3d`), CSS Transforms 2 §6 sorts children
|
|
2817
|
+
// by their Z position in 3D space — translateZ — not by z-index. Re-
|
|
2818
|
+
// sort the already-positioned children by extracted translateZ
|
|
2819
|
+
// ascending, with DOM order tie-breaks, so a child with a small
|
|
2820
|
+
// z-index but a positive translateZ paints above siblings with bigger
|
|
2821
|
+
// z-index but translateZ=0. Approximation: ignore perspective effects
|
|
2822
|
+
// (which would also shrink / shift the painted box) and just use the
|
|
2823
|
+
// captured `matrix3d` `m43` translation. Without this the
|
|
2824
|
+
// `transform-style: preserve-3d` panel in `13-deep-cross-sc-z-index`
|
|
2825
|
+
// paints orange (`translateZ(20px)`, z=1) BEHIND purple (z=5) and sky
|
|
2826
|
+
// (z=10), instead of in front of both as Chrome paints.
|
|
2827
|
+
if (el.styles.transformStyle === "preserve-3d") {
|
|
2828
|
+
const zOf = (c) => c.styles.translateZ ?? 0;
|
|
2829
|
+
sortedChildren = sortedChildren
|
|
2830
|
+
.map((c, idx) => ({ c, idx, z: zOf(c) }))
|
|
2831
|
+
.sort((a, b) => a.z - b.z || a.idx - b.idx)
|
|
2832
|
+
.map((x) => x.c);
|
|
2833
|
+
}
|
|
1758
2834
|
for (const child of sortedChildren) {
|
|
1759
|
-
|
|
2835
|
+
renderElementWithOverflowClip(child, depth + 1, childParentDisplay);
|
|
2836
|
+
}
|
|
2837
|
+
// DM-808: MathML `<mfrac>` needs a horizontal fraction bar between its
|
|
2838
|
+
// numerator (first child) and denominator (second child). Chrome's
|
|
2839
|
+
// MathML layout paints this from internal layout — there's no CSS
|
|
2840
|
+
// border on the children to capture. Synthesize the bar from the two
|
|
2841
|
+
// children's rects: span the wider of the two horizontally, place at
|
|
2842
|
+
// the midpoint between numerator bottom and denominator top, default
|
|
2843
|
+
// 1px thickness (matches MathML's `mfrac@linethickness="medium"`).
|
|
2844
|
+
if (el.tag === "mfrac" && el.children.length >= 2) {
|
|
2845
|
+
const num = el.children[0];
|
|
2846
|
+
const den = el.children[1];
|
|
2847
|
+
const barX = Math.min(num.x, den.x);
|
|
2848
|
+
const barRight = Math.max(num.x + num.width, den.x + den.width);
|
|
2849
|
+
const barY = (num.y + num.height + den.y) / 2 - 0.5;
|
|
2850
|
+
const fillCol = el.styles.color ? esc(el.styles.color) : "rgb(0,0,0)";
|
|
2851
|
+
svgParts.push(`${indent}<rect x="${r(barX)}" y="${r(barY)}" width="${r(barRight - barX)}" height="1" fill="${fillCol}" />`);
|
|
2852
|
+
}
|
|
2853
|
+
// DM-809: MathML `<msqrt>` / `<mroot>` need their radical sign + over-
|
|
2854
|
+
// bar synthesised — Chrome's MathML layout paints them from internal
|
|
2855
|
+
// layout (no border / glyph capture). The msqrt's first radicand
|
|
2856
|
+
// child's `x` is inset from `el.x` by the radical-sign width, and
|
|
2857
|
+
// its `y` is inset by the overbar height — derive the radical
|
|
2858
|
+
// geometry from those rects. Stroke a 3-segment path: leftmost mid-
|
|
2859
|
+
// height vertex → bottom-right of radical area → top of overbar; then
|
|
2860
|
+
// a horizontal overbar from the radicand's left to msqrt's right edge.
|
|
2861
|
+
// For `<mroot>` the structure is `<mroot><radicand><index></mroot>`
|
|
2862
|
+
// (index is a small superscript inside the radical's "hook"); we only
|
|
2863
|
+
// draw the radical + overbar — the index renders normally as a child
|
|
2864
|
+
// glyph.
|
|
2865
|
+
if ((el.tag === "msqrt" || el.tag === "mroot") && el.children.length >= 1) {
|
|
2866
|
+
const radicand = el.children[0];
|
|
2867
|
+
const strokeCol = el.styles.color ? esc(el.styles.color) : "rgb(0,0,0)";
|
|
2868
|
+
const radX0 = el.x;
|
|
2869
|
+
const radX1 = radicand.x;
|
|
2870
|
+
const radTop = el.y;
|
|
2871
|
+
const radBottom = el.y + el.height;
|
|
2872
|
+
const radMid = el.y + el.height * 0.6;
|
|
2873
|
+
const radRight = el.x + el.width;
|
|
2874
|
+
// Radical checkmark: enter at (radX0, radMid), descend to bottom at
|
|
2875
|
+
// 40% across the radical-sign zone, climb to top-right at radicand
|
|
2876
|
+
// start. Then overbar across the top.
|
|
2877
|
+
const vertexX = radX0 + (radX1 - radX0) * 0.4;
|
|
2878
|
+
const path = `M${r(radX0)},${r(radMid)} L${r(vertexX)},${r(radBottom - 1)} L${r(radX1)},${r(radTop)} L${r(radRight)},${r(radTop)}`;
|
|
2879
|
+
svgParts.push(`${indent}<path d="${path}" fill="none" stroke="${strokeCol}" stroke-width="1" />`);
|
|
1760
2880
|
}
|
|
1761
2881
|
if (overflowClipId != null)
|
|
1762
2882
|
svgParts.push(`${indent}</g>`);
|
|
@@ -1773,6 +2893,8 @@ hiDPIFactor = 2) {
|
|
|
1773
2893
|
svgParts.push(`${indent}</g>`);
|
|
1774
2894
|
if (opened)
|
|
1775
2895
|
svgParts.push(`${indent}</g>`);
|
|
2896
|
+
if (needsFilterOuter)
|
|
2897
|
+
svgParts.push(`${indent}</g>`);
|
|
1776
2898
|
}
|
|
1777
2899
|
// Sort top-level siblings by CSS paint order too. captureElementTree
|
|
1778
2900
|
// returns the root element's children as a flat array (body's children for
|
|
@@ -1783,7 +2905,9 @@ hiDPIFactor = 2) {
|
|
|
1783
2905
|
// DM-473: top-level element list is the implicit root stacking context —
|
|
1784
2906
|
// flatten it the same way an SC root would, so cross-parent z-index
|
|
1785
2907
|
// hoisting works at the document root.
|
|
1786
|
-
const
|
|
2908
|
+
const topLevelHoistedAsInline = new Set();
|
|
2909
|
+
const topLevelHoistedAsZSorted = new Set();
|
|
2910
|
+
const topLevelFlat = gatherStackingContextChildren(elements, hoistedFromAncestor, undefined, topLevelHoistedAsInline, overflowClipForHoisted, topLevelHoistedAsZSorted);
|
|
1787
2911
|
// DM-543: position:fixed elements paint relative to the viewport stacking
|
|
1788
2912
|
// context and escape ALL ancestor overflow clips. The standard SC-by-SC
|
|
1789
2913
|
// hoist halts at any SC ancestor (e.g. an overflow:auto section creates an
|
|
@@ -1818,13 +2942,19 @@ hiDPIFactor = 2) {
|
|
|
1818
2942
|
collectViewportFixed(e);
|
|
1819
2943
|
}
|
|
1820
2944
|
}
|
|
1821
|
-
const sortedTopLevel = sortChildrenByPaintOrder(topLevelFlat);
|
|
2945
|
+
const sortedTopLevel = sortChildrenByPaintOrder(topLevelFlat, undefined, undefined, topLevelHoistedAsInline, topLevelHoistedAsZSorted);
|
|
1822
2946
|
for (const el of sortedTopLevel) {
|
|
1823
|
-
|
|
2947
|
+
renderElementWithOverflowClip(el, 1);
|
|
1824
2948
|
}
|
|
1825
2949
|
// Prepend defs block: clipPaths + optional glyph path definitions. For
|
|
1826
2950
|
// animated multi-frame SVGs the caller passes includeGlyphDefs=false and
|
|
1827
2951
|
// collects glyph defs once at the top level via getGlyphDefs().
|
|
2952
|
+
// DM-652: embedded-font `@font-face` rules are NOT emitted here — the
|
|
2953
|
+
// base64-encoded font bytes can be megabytes each, so duplicating them
|
|
2954
|
+
// per-segment in a multi-frame SVG would balloon file size unmanageably.
|
|
2955
|
+
// Callers that drive embedded-font mode must call
|
|
2956
|
+
// `getEmbeddedFontFaceCss()` themselves once at the top level (see how
|
|
2957
|
+
// `composeScrollSvg` injects it into the outer <style>).
|
|
1828
2958
|
const glyphDefsMarkup = includeGlyphDefs ? getGlyphDefs() : "";
|
|
1829
2959
|
const allDefs = defsParts.join("") + glyphDefsMarkup;
|
|
1830
2960
|
const defs = allDefs !== "" ? ` <defs>${allDefs}</defs>\n` : "";
|
|
@@ -2009,12 +3139,73 @@ function establishesStackingContext(el, parentDisplay) {
|
|
|
2009
3139
|
* are NOT recursed into — they bring their own SC scope and their internal
|
|
2010
3140
|
* paint order resolves independently when their renderElement runs.
|
|
2011
3141
|
*/
|
|
2012
|
-
function gatherStackingContextChildren(children, hoistedOut, parentDisplay,
|
|
3142
|
+
function gatherStackingContextChildren(children, hoistedOut, parentDisplay,
|
|
3143
|
+
/**
|
|
3144
|
+
* DM-683: out-parameter populated with elements that should paint at CSS
|
|
3145
|
+
* 2.1 Appendix E step 5 (in-flow inline-level non-positioned) rather than
|
|
3146
|
+
* step 3 (block). Currently only flex/grid items are tagged here — they
|
|
3147
|
+
* paint as inline blocks per CSS Flexbox 1 §5.4 / CSS Grid 1 §17.
|
|
3148
|
+
* `sortChildrenByPaintOrder` reads this set to route members into the
|
|
3149
|
+
* inline bucket (between floats and zeroOrAuto).
|
|
3150
|
+
*/
|
|
3151
|
+
hoistedAsInline,
|
|
3152
|
+
/**
|
|
3153
|
+
* DM-673: when set, this map is populated with `{ hoistedDescendant →
|
|
3154
|
+
* overflow-clip ancestor }` for any positioned descendant that escapes an
|
|
3155
|
+
* `overflow != visible` ancestor whose ONLY SC-creating property is the
|
|
3156
|
+
* overflow. The renderer reads this map to re-wrap the descendant's
|
|
3157
|
+
* emission in the same `<g clip-path>` the ancestor would have wrapped
|
|
3158
|
+
* it in had it stayed nested.
|
|
3159
|
+
*/
|
|
3160
|
+
overflowClipForHoisted,
|
|
3161
|
+
/**
|
|
3162
|
+
* DM-712: out-parameter populated with flex/grid items that were hoisted
|
|
3163
|
+
* because they carry an explicit z-index. Once hoisted, the sort needs
|
|
3164
|
+
* to know to z-bucket these (CSS Flexbox 1 §5.4 / CSS Grid 1 §17) — the
|
|
3165
|
+
* sort's own `isFlexGrid` check fires off the immediate-parent display,
|
|
3166
|
+
* which is the SC root after hoisting (typically `block`), losing the
|
|
3167
|
+
* original "flex item with z" signal. `sortChildrenByPaintOrder` reads
|
|
3168
|
+
* this set and routes members through the positive / zeroOrAuto buckets
|
|
3169
|
+
* based on the captured z-index.
|
|
3170
|
+
*/
|
|
3171
|
+
hoistedAsZSorted) {
|
|
2013
3172
|
const out = [];
|
|
2014
|
-
|
|
3173
|
+
/**
|
|
3174
|
+
* `floatHoistBlocked` is true once the recursion descends through a
|
|
3175
|
+
* `position:relative` / `position:absolute` ancestor with `z-index: auto`
|
|
3176
|
+
* (or `0`). Per CSS 2.1 Appendix E §6, such an element paints at step 6
|
|
3177
|
+
* as if it were a stacking context, but its positioned + SC descendants
|
|
3178
|
+
* still belong to the parent SC. Floats inside an atomic positioned
|
|
3179
|
+
* ancestor stay with it (they paint at step 4 of the atomic group's
|
|
3180
|
+
* internal paint order), not at the parent SC's step 4. Without this gate
|
|
3181
|
+
* a float hoisted past its atomic positioned ancestor paints BENEATH
|
|
3182
|
+
* the ancestor's atomic content — Slashdot's mobile `<a class="login">`
|
|
3183
|
+
* float inside `.header { z-index:1000; position:static }` (descendant
|
|
3184
|
+
* of `.stages { position:relative }`) was rendering before the white
|
|
3185
|
+
* `.river-prop` page background and disappeared completely.
|
|
3186
|
+
*/
|
|
3187
|
+
const collectFromNonSC = (parent, floatHoistBlocked = false, currentOverflowAncestor = null) => {
|
|
2015
3188
|
const childParentDisplay = parent.styles.display;
|
|
2016
3189
|
const parentIsFlexGrid = isFlexOrGridContainerDisplay(childParentDisplay);
|
|
2017
|
-
|
|
3190
|
+
// DM-537: when the parent is a flex/grid container, flex items hoisted
|
|
3191
|
+
// out of it into the parent SC's flat paint list must carry their
|
|
3192
|
+
// order-modified document order with them. The post-hoist
|
|
3193
|
+
// `sortChildrenByPaintOrder` runs with the parent SC's display (often
|
|
3194
|
+
// `block`) so its own `isFlexGrid` check fires `false` and the inline
|
|
3195
|
+
// bucket falls back to DOM order — losing the flex `order` reordering
|
|
3196
|
+
// and the `flex-direction: *-reverse` paint reversal. Pre-sort the
|
|
3197
|
+
// iteration here so the hoisted items land in the right order in `out`.
|
|
3198
|
+
let iterChildren = parent.children;
|
|
3199
|
+
if (parentIsFlexGrid && iterChildren.length > 1) {
|
|
3200
|
+
const sorted = iterChildren
|
|
3201
|
+
.map((c, idx) => ({ c, idx, ord: parseInt(c.styles.order ?? "0", 10) || 0 }))
|
|
3202
|
+
.sort((a, b) => a.ord - b.ord || a.idx - b.idx)
|
|
3203
|
+
.map((x) => x.c);
|
|
3204
|
+
const fd = parent.styles.flexDirection;
|
|
3205
|
+
const reverseFlex = fd === "row-reverse" || fd === "column-reverse";
|
|
3206
|
+
iterChildren = reverseFlex ? sorted.slice().reverse() : sorted;
|
|
3207
|
+
}
|
|
3208
|
+
for (const c of iterChildren) {
|
|
2018
3209
|
// DM-543: skip elements already hoisted by a higher SC pass (e.g. a
|
|
2019
3210
|
// root-level position:fixed pre-pass added this pin to topLevelFlat;
|
|
2020
3211
|
// re-pushing it here would double-emit it inside the local clip group).
|
|
@@ -2034,16 +3225,18 @@ function gatherStackingContextChildren(children, hoistedOut, parentDisplay, hois
|
|
|
2034
3225
|
// button's z:4 hoist never fired — position:static + the legacy
|
|
2035
3226
|
// `if (positioned)` check skipped it.)
|
|
2036
3227
|
//
|
|
2037
|
-
//
|
|
2038
|
-
//
|
|
2039
|
-
//
|
|
2040
|
-
//
|
|
2041
|
-
//
|
|
2042
|
-
// z-
|
|
2043
|
-
//
|
|
3228
|
+
// Both hoist targets are real stacking contexts: a per-element SC root
|
|
3229
|
+
// (the `renderElement` call) and the implicit document root (the
|
|
3230
|
+
// top-level call) — `gatherStackingContextChildren` only ever recurses
|
|
3231
|
+
// through non-SC / overflow-only-SC ancestors, so a flex-item-z is only
|
|
3232
|
+
// reached here when its nearest *real* SC ancestor is the hoist target.
|
|
3233
|
+
// The z-bucket survives the hoist via the `hoistedAsZSorted` tag below,
|
|
3234
|
+
// which `sortChildrenByPaintOrder` reads even when `parentDisplay` isn't
|
|
3235
|
+
// flex/grid (e.g. the `block` document root) — so the item z-sorts
|
|
3236
|
+
// correctly rather than falling into the inline bucket in DOM order.
|
|
2044
3237
|
const zRaw = c.styles.zIndex;
|
|
2045
3238
|
const hasExplicitZ = zRaw != null && zRaw !== "" && zRaw !== "auto";
|
|
2046
|
-
const flexGridItemSC =
|
|
3239
|
+
const flexGridItemSC = parentIsFlexGrid && hasExplicitZ;
|
|
2047
3240
|
// DM-639: per CSS 2.1 §9.9 paint order, ALL floats in a stacking
|
|
2048
3241
|
// context paint at step 4 — AFTER all block-level non-positioned
|
|
2049
3242
|
// descendants (step 3) of the SC. Floats are not confined to their
|
|
@@ -2054,12 +3247,64 @@ function gatherStackingContextChildren(children, hoistedOut, parentDisplay, hois
|
|
|
2054
3247
|
// Real-world hit: 14-deep-float-bfc section 1's float-left FL extends
|
|
2055
3248
|
// ~60 px below the .frame and is covered by section 2's gray bar.
|
|
2056
3249
|
const isFloat = !positioned && (c.styles.float ?? "none") !== "none";
|
|
2057
|
-
|
|
3250
|
+
// DM-683: per CSS Flexbox 1 §5.4 ("Flex items paint exactly the same
|
|
3251
|
+
// as inline blocks"), flex items paint at CSS 2.1 Appendix E step 5
|
|
3252
|
+
// (in-flow inline-level non-positioned descendants) — AFTER block
|
|
3253
|
+
// siblings at step 3 + floats at step 4 within the same stacking
|
|
3254
|
+
// context. When a flex item OVERFLOWS its flex container and the
|
|
3255
|
+
// container has block-level following siblings (e.g. `15-deep-flex-
|
|
3256
|
+
// aspect-ratio` section 2: `.row.col` with a `1:2` aspect-ratio item
|
|
3257
|
+
// 388 px tall inside a 360 px tall container, followed by `.frame3`
|
|
3258
|
+
// — Chrome paints the overflowing item ON TOP of `.frame3` because
|
|
3259
|
+
// step 5 > step 3), our DOM-order paint covered the overflow with
|
|
3260
|
+
// the following sibling. Hoist flex items to the SC root paint list
|
|
3261
|
+
// so the post-sort places them after step-3 blocks, mirroring the
|
|
3262
|
+
// float-hoist (DM-639) pattern. Same atomic-positioned-ancestor
|
|
3263
|
+
// gate as floats: a `position:relative` z=auto ancestor scopes the
|
|
3264
|
+
// item to its own atomic paint.
|
|
3265
|
+
const isFlexItem = !positioned && parentIsFlexGrid;
|
|
3266
|
+
if (positioned || flexGridItemSC || (isFloat && !floatHoistBlocked) || (isFlexItem && !floatHoistBlocked)) {
|
|
2058
3267
|
out.push(c);
|
|
2059
3268
|
hoistedOut.add(c);
|
|
3269
|
+
if (isFlexItem && !positioned && !flexGridItemSC) {
|
|
3270
|
+
hoistedAsInline?.add(c);
|
|
3271
|
+
}
|
|
3272
|
+
// DM-712 / DM-687: a flex/grid item hoisted because of its explicit
|
|
3273
|
+
// z-index (not because it's positioned) loses its z-bucket info once
|
|
3274
|
+
// it's a child of the SC root for sort purposes. Tag it so the sort
|
|
3275
|
+
// still buckets it by z. Without this, e.g. resend.com's "Contact
|
|
3276
|
+
// management" card paints its z:10 content BEFORE its z:auto
|
|
3277
|
+
// absolute-positioned gradient overlay sibling (gradient ends up on
|
|
3278
|
+
// top and reads as solid black), and `13-deep-z-index-flex-grid`
|
|
3279
|
+
// painted A z:4 BEHIND D z:2 because its flex container hung off the
|
|
3280
|
+
// implicit root SC.
|
|
3281
|
+
if (flexGridItemSC && !positioned) {
|
|
3282
|
+
hoistedAsZSorted?.add(c);
|
|
3283
|
+
}
|
|
3284
|
+
// DM-673: if we hoisted `c` past an overflow-clip ancestor (and
|
|
3285
|
+
// `c` isn't `position:fixed` — which escapes overflow per CSS
|
|
3286
|
+
// Overflow 3 §2.2), remember the ancestor so the renderer can
|
|
3287
|
+
// re-wrap `c` in its clip-path.
|
|
3288
|
+
if (currentOverflowAncestor != null && c.styles.position !== "fixed") {
|
|
3289
|
+
overflowClipForHoisted?.set(c, currentOverflowAncestor);
|
|
3290
|
+
}
|
|
2060
3291
|
}
|
|
2061
|
-
|
|
2062
|
-
|
|
3292
|
+
// DM-673: also recurse THROUGH `overflow != visible` SCs whose ONLY
|
|
3293
|
+
// SC-creating property is the overflow (per `isOverflowOnlySC`). Per
|
|
3294
|
+
// Chrome's paint model these scroll containers paint atomically only
|
|
3295
|
+
// for their bg/border at step 3, while positioned descendants escape
|
|
3296
|
+
// to the parent SC's step 6 — intermixed in tree order with sibling
|
|
3297
|
+
// positioned descendants. Mark `c` as the overflow ancestor so any
|
|
3298
|
+
// descendant we hoist further down gets the clip-path applied.
|
|
3299
|
+
const cIsOverflowOnly = isOverflowOnlySC(c);
|
|
3300
|
+
if (!establishesStackingContext(c, childParentDisplay) || cIsOverflowOnly) {
|
|
3301
|
+
// Block float hoisting from this point downward if `c` itself is a
|
|
3302
|
+
// positioned z=auto/0 element — its descendants' floats paint with
|
|
3303
|
+
// it atomically, not at the parent SC. Existing float-block state
|
|
3304
|
+
// propagates downward too.
|
|
3305
|
+
const cBlocks = positioned && !hasExplicitZ;
|
|
3306
|
+
const nextOverflowAncestor = cIsOverflowOnly ? c : currentOverflowAncestor;
|
|
3307
|
+
collectFromNonSC(c, floatHoistBlocked || cBlocks, nextOverflowAncestor);
|
|
2063
3308
|
}
|
|
2064
3309
|
}
|
|
2065
3310
|
};
|
|
@@ -2067,12 +3312,92 @@ function gatherStackingContextChildren(children, hoistedOut, parentDisplay, hois
|
|
|
2067
3312
|
if (hoistedOut.has(c))
|
|
2068
3313
|
continue;
|
|
2069
3314
|
out.push(c);
|
|
2070
|
-
|
|
2071
|
-
|
|
3315
|
+
const cIsOverflowOnly = isOverflowOnlySC(c);
|
|
3316
|
+
if (!establishesStackingContext(c, parentDisplay) || cIsOverflowOnly) {
|
|
3317
|
+
// If `c` is a `position:relative/absolute` with `z-index: auto/0` it
|
|
3318
|
+
// paints atomically at step 6 — block float hoisting from its subtree
|
|
3319
|
+
// (matches the `cBlocks` rule inside collectFromNonSC).
|
|
3320
|
+
const cPositioned = c.styles.position != null && c.styles.position !== "static";
|
|
3321
|
+
const cZRaw = c.styles.zIndex;
|
|
3322
|
+
const cHasExplicitZ = cZRaw != null && cZRaw !== "" && cZRaw !== "auto";
|
|
3323
|
+
// DM-673: if `c` is an overflow-only SC, mark `c` as the overflow
|
|
3324
|
+
// ancestor for any positioned descendant we hoist out of it.
|
|
3325
|
+
const nextOverflowAncestor = cIsOverflowOnly ? c : null;
|
|
3326
|
+
collectFromNonSC(c, cPositioned && !cHasExplicitZ, nextOverflowAncestor);
|
|
2072
3327
|
}
|
|
2073
3328
|
}
|
|
2074
3329
|
return out;
|
|
2075
3330
|
}
|
|
3331
|
+
/**
|
|
3332
|
+
* DM-673: returns true when `el` is a stacking context whose ONLY reason
|
|
3333
|
+
* for being one is `overflow != visible`. Such elements are scroll
|
|
3334
|
+
* containers but not "real" SCs in Chrome's paint model — their bg/border
|
|
3335
|
+
* paint in normal flow at CSS 2.1 Appendix E step 3, while their
|
|
3336
|
+
* positioned descendants escape to the parent SC's step 6 (intermixed in
|
|
3337
|
+
* tree order with positioned descendants from sibling overflow scrollers
|
|
3338
|
+
* and `position:fixed` descendants that escape their CBs).
|
|
3339
|
+
*
|
|
3340
|
+
* Pixel-probed evidence from `13-deep-fixed-in-transform`: pin 0 (escaped
|
|
3341
|
+
* fixed-to-viewport) is visible BELOW `.frame`'s bottom at y=748-758 over
|
|
3342
|
+
* section 2's bg (so pin 0 paints AFTER section 2 bg), but `.frame`'s
|
|
3343
|
+
* beige bg covers pin 0 at y=737-746 (so `.frame` paints AFTER pin 0).
|
|
3344
|
+
* That interleaving is only possible if section 2 is non-atomic and
|
|
3345
|
+
* `.frame` hoists to body's step 6 alongside pin 0.
|
|
3346
|
+
*
|
|
3347
|
+
* For SCs that have ANY other SC-creating property (positioned, transform,
|
|
3348
|
+
* filter, opacity, etc.), we still keep them atomic — their descendants
|
|
3349
|
+
* are contained by the layer, matching Chrome's behavior.
|
|
3350
|
+
*/
|
|
3351
|
+
function isOverflowOnlySC(el) {
|
|
3352
|
+
const s = el.styles;
|
|
3353
|
+
// Must actually create an SC via overflow
|
|
3354
|
+
const ox = s.overflowX;
|
|
3355
|
+
const oy = s.overflowY;
|
|
3356
|
+
const overflowIsSC = (ox != null && ox !== "visible") || (oy != null && oy !== "visible");
|
|
3357
|
+
if (!overflowIsSC)
|
|
3358
|
+
return false;
|
|
3359
|
+
// Must have NO other SC-creating property
|
|
3360
|
+
const positioned = s.position != null && s.position !== "static";
|
|
3361
|
+
if (positioned)
|
|
3362
|
+
return false;
|
|
3363
|
+
if (s.transform != null && s.transform !== "" && s.transform !== "none")
|
|
3364
|
+
return false;
|
|
3365
|
+
if (s.transformCreatesSc)
|
|
3366
|
+
return false;
|
|
3367
|
+
if (s.transformStyle != null && s.transformStyle !== "" && s.transformStyle !== "flat")
|
|
3368
|
+
return false;
|
|
3369
|
+
const op = parseFloat(s.opacity);
|
|
3370
|
+
if (Number.isFinite(op) && op < 1)
|
|
3371
|
+
return false;
|
|
3372
|
+
if (s.filter != null && s.filter !== "" && s.filter !== "none")
|
|
3373
|
+
return false;
|
|
3374
|
+
if (s.mixBlendMode != null && s.mixBlendMode !== "" && s.mixBlendMode !== "normal")
|
|
3375
|
+
return false;
|
|
3376
|
+
if (s.maskImage != null && s.maskImage !== "" && s.maskImage !== "none")
|
|
3377
|
+
return false;
|
|
3378
|
+
if (s.clipPath != null && s.clipPath !== "" && s.clipPath !== "none")
|
|
3379
|
+
return false;
|
|
3380
|
+
if (s.isolation === "isolate")
|
|
3381
|
+
return false;
|
|
3382
|
+
if (s.contain != null && s.contain !== "" && s.contain !== "none") {
|
|
3383
|
+
if (/\b(?:paint|strict|content)\b/i.test(s.contain))
|
|
3384
|
+
return false;
|
|
3385
|
+
}
|
|
3386
|
+
if (s.willChange != null && s.willChange !== "" && s.willChange !== "auto") {
|
|
3387
|
+
const _scWcProps = new Set([
|
|
3388
|
+
"transform", "opacity", "filter", "backdrop-filter",
|
|
3389
|
+
"mask", "mask-image", "clip-path", "perspective",
|
|
3390
|
+
"top", "right", "bottom", "left",
|
|
3391
|
+
"position", "z-index", "isolation", "mix-blend-mode", "contain",
|
|
3392
|
+
]);
|
|
3393
|
+
const tokens = s.willChange.split(/[\s,]+/);
|
|
3394
|
+
for (const t of tokens) {
|
|
3395
|
+
if (_scWcProps.has(t.toLowerCase()))
|
|
3396
|
+
return false;
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
return true;
|
|
3400
|
+
}
|
|
2076
3401
|
/**
|
|
2077
3402
|
* DM-543: returns true when `el` creates a containing block for
|
|
2078
3403
|
* position:fixed descendants. Per CSS Containment 1 / Transforms 2 / Will
|
|
@@ -2111,7 +3436,22 @@ function isFixedContainingBlock(el) {
|
|
|
2111
3436
|
}
|
|
2112
3437
|
return false;
|
|
2113
3438
|
}
|
|
2114
|
-
function sortChildrenByPaintOrder(children, parentDisplay, parentFlexDirection
|
|
3439
|
+
function sortChildrenByPaintOrder(children, parentDisplay, parentFlexDirection,
|
|
3440
|
+
/**
|
|
3441
|
+
* DM-683: Set of children to route into the inline bucket (CSS 2.1
|
|
3442
|
+
* Appendix E step 5) rather than the block / base bucket (step 3).
|
|
3443
|
+
* Populated by `gatherStackingContextChildren` for flex/grid items
|
|
3444
|
+
* (which paint as inline blocks per CSS Flexbox 1 §5.4).
|
|
3445
|
+
*/
|
|
3446
|
+
paintAsInline,
|
|
3447
|
+
/**
|
|
3448
|
+
* DM-712: Set of children that should be z-sorted using their captured
|
|
3449
|
+
* z-index, even when the immediate parent display isn't flex/grid
|
|
3450
|
+
* (e.g. because the child was hoisted out of a flex parent into a real
|
|
3451
|
+
* SC root for paint-order resolution). Populated by
|
|
3452
|
+
* `gatherStackingContextChildren`'s `hoistedAsZSorted` out-parameter.
|
|
3453
|
+
*/
|
|
3454
|
+
paintAsZSorted) {
|
|
2115
3455
|
// DM-525: flex/grid items with z-index ≠ auto sort as if position:relative
|
|
2116
3456
|
// even when position:static (per CSS Flexbox 1 §5.4 / CSS Grid 1 §17).
|
|
2117
3457
|
const isFlexGrid = isFlexOrGridContainerDisplay(parentDisplay);
|
|
@@ -2141,6 +3481,7 @@ function sortChildrenByPaintOrder(children, parentDisplay, parentFlexDirection)
|
|
|
2141
3481
|
}
|
|
2142
3482
|
const negative = [];
|
|
2143
3483
|
const floats = [];
|
|
3484
|
+
const inlines = [];
|
|
2144
3485
|
const zeroOrAuto = [];
|
|
2145
3486
|
const positive = [];
|
|
2146
3487
|
const base = [];
|
|
@@ -2151,10 +3492,16 @@ function sortChildrenByPaintOrder(children, parentDisplay, parentFlexDirection)
|
|
|
2151
3492
|
const zRaw = c.styles.zIndex;
|
|
2152
3493
|
const positioned = pos != null && pos !== "static";
|
|
2153
3494
|
const z = zRaw === "auto" || zRaw === "" || zRaw == null ? NaN : parseInt(zRaw, 10);
|
|
2154
|
-
const treatAsZSorted = positioned || (isFlexGrid && !isNaN(z));
|
|
3495
|
+
const treatAsZSorted = positioned || (isFlexGrid && !isNaN(z)) || (paintAsZSorted?.has(c) === true);
|
|
2155
3496
|
if (!treatAsZSorted && flt !== "none") {
|
|
2156
3497
|
floats.push(c);
|
|
2157
3498
|
}
|
|
3499
|
+
else if (!treatAsZSorted && paintAsInline?.has(c) === true) {
|
|
3500
|
+
// DM-683: hoisted flex/grid items paint at step 5 (inline-level) of
|
|
3501
|
+
// the SC, AFTER floats and AFTER step-3 blocks — matches CSS Flexbox
|
|
3502
|
+
// 1 §5.4 "Flex items paint exactly the same as inline blocks".
|
|
3503
|
+
inlines.push(c);
|
|
3504
|
+
}
|
|
2158
3505
|
else if (!treatAsZSorted) {
|
|
2159
3506
|
base.push(c);
|
|
2160
3507
|
}
|
|
@@ -2176,7 +3523,7 @@ function sortChildrenByPaintOrder(children, parentDisplay, parentFlexDirection)
|
|
|
2176
3523
|
}
|
|
2177
3524
|
negative.sort((a, b) => a.z - b.z || a.idx - b.idx);
|
|
2178
3525
|
positive.sort((a, b) => a.z - b.z || a.idx - b.idx);
|
|
2179
|
-
return [...negative.map((x) => x.el), ...base, ...floats, ...zeroOrAuto, ...positive.map((x) => x.el)];
|
|
3526
|
+
return [...negative.map((x) => x.el), ...base, ...floats, ...inlines, ...zeroOrAuto, ...positive.map((x) => x.el)];
|
|
2180
3527
|
}
|
|
2181
3528
|
/**
|
|
2182
3529
|
* Turn a single background-image layer into an SVG <defs> entry. Returns
|
|
@@ -2188,15 +3535,73 @@ function sortChildrenByPaintOrder(children, parentDisplay, parentFlexDirection)
|
|
|
2188
3535
|
* and background-repeat (repeat/no-repeat/repeat-x/repeat-y/round/space).
|
|
2189
3536
|
*/
|
|
2190
3537
|
function buildBackgroundLayerDef(id, layer, elX, elY, w, h, sizeCss = "auto", posCss = "0% 0%", repeatCss = "repeat", intrinsic = null, attachment = "scroll", fixedViewport = null) {
|
|
3538
|
+
// Legacy `-webkit-gradient(linear, ...)` is still emitted by Chromium's
|
|
3539
|
+
// computed-style serializer for old CSS that uses it (e.g. the Slashdot
|
|
3540
|
+
// mobile header's black→#202020 titlebar). Normalize to modern
|
|
3541
|
+
// `linear-gradient(...)` text first so the existing parsers can consume it.
|
|
3542
|
+
const normalizedWebkit = convertLegacyWebkitGradient(layer);
|
|
3543
|
+
if (normalizedWebkit != null)
|
|
3544
|
+
layer = normalizedWebkit;
|
|
3545
|
+
// DM-717: `image-set(...)` / `-webkit-image-set(...)` resolution. Chrome's
|
|
3546
|
+
// computed-style serializer returns the FULL image-set string rather than
|
|
3547
|
+
// the single chosen candidate, so we have to pick one ourselves. Strategy:
|
|
3548
|
+
// prefer the lowest-density candidate (1dppx) since the offscreen capture
|
|
3549
|
+
// runs at deviceScaleFactor 1; among same-density candidates, prefer
|
|
3550
|
+
// `type("image/webp")` then `png` then `jpeg` then `gif`, matching what
|
|
3551
|
+
// Chrome would pick on a standard-density display. Falls back to the first
|
|
3552
|
+
// url(...) it finds if no density/type metadata is present.
|
|
3553
|
+
const imageSet = /^(?:-webkit-)?image-set\((.+)\)$/i.exec(layer);
|
|
3554
|
+
if (imageSet != null) {
|
|
3555
|
+
const args = splitTopLevelCommas(imageSet[1]);
|
|
3556
|
+
const cands = [];
|
|
3557
|
+
for (const a of args) {
|
|
3558
|
+
const t = a.trim();
|
|
3559
|
+
// The arg shape is `url(...) [<resolution>] [type(...)]` in any order
|
|
3560
|
+
// (per CSS Images 4); pull each piece out independently.
|
|
3561
|
+
const urlBlob = /url\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^)\s]+))\s*\)/i.exec(t);
|
|
3562
|
+
if (urlBlob == null)
|
|
3563
|
+
continue;
|
|
3564
|
+
const rawUrl = (urlBlob[1] ?? urlBlob[2] ?? urlBlob[3]).replace(/\\(.)/g, "$1");
|
|
3565
|
+
const dppxMatch = /(?<![a-z])([0-9.]+)\s*(?:dppx|x)\b/i.exec(t);
|
|
3566
|
+
const typeMatch = /type\(\s*["']?([^"')]+?)["']?\s*\)/i.exec(t);
|
|
3567
|
+
cands.push({
|
|
3568
|
+
url: `url("${rawUrl}")`,
|
|
3569
|
+
dppx: dppxMatch != null ? parseFloat(dppxMatch[1]) : 1,
|
|
3570
|
+
type: typeMatch != null ? typeMatch[1].toLowerCase() : "",
|
|
3571
|
+
});
|
|
3572
|
+
}
|
|
3573
|
+
const TYPE_RANK = {
|
|
3574
|
+
"image/webp": 4, "image/png": 3, "image/jpeg": 2, "image/jpg": 2, "image/gif": 1, "": 0,
|
|
3575
|
+
};
|
|
3576
|
+
cands.sort((a, b) => a.dppx - b.dppx || (TYPE_RANK[b.type] ?? 0) - (TYPE_RANK[a.type] ?? 0));
|
|
3577
|
+
if (cands.length > 0)
|
|
3578
|
+
layer = cands[0].url;
|
|
3579
|
+
else
|
|
3580
|
+
return { def: "" };
|
|
3581
|
+
}
|
|
3582
|
+
// DM-695: `background-attachment: fixed` anchors the bg image (gradient or
|
|
3583
|
+
// raster) to the viewport rather than the element. For gradients this
|
|
3584
|
+
// means the gradient axis spans the VIEWPORT box (0,0 → vw,vh); the
|
|
3585
|
+
// element rect with `fill="url(#…)"` then shows the portion of that
|
|
3586
|
+
// gradient that intersects the element. Previously we always computed
|
|
3587
|
+
// gradient axes off the element rect, so a `bg-attachment: fixed`
|
|
3588
|
+
// gradient looked identical to a `scroll` one — different from Chrome
|
|
3589
|
+
// which only shows a slice through the element's window onto the
|
|
3590
|
+
// viewport-spanning gradient (visible color-vibrancy diff on
|
|
3591
|
+
// `17-deep-bg-attachment-fixed` panel 1).
|
|
3592
|
+
const gradX = (attachment === "fixed" && fixedViewport != null) ? 0 : elX;
|
|
3593
|
+
const gradY = (attachment === "fixed" && fixedViewport != null) ? 0 : elY;
|
|
3594
|
+
const gradW = (attachment === "fixed" && fixedViewport != null) ? fixedViewport.w : w;
|
|
3595
|
+
const gradH = (attachment === "fixed" && fixedViewport != null) ? fixedViewport.h : h;
|
|
2191
3596
|
const linear = /^(?:repeating-)?linear-gradient\((.+)\)$/i.exec(layer);
|
|
2192
3597
|
if (linear != null) {
|
|
2193
3598
|
const repeating = /^repeating-/i.test(layer);
|
|
2194
|
-
return { def: buildLinearGradientDef(id, linear[1], repeating,
|
|
3599
|
+
return { def: buildLinearGradientDef(id, linear[1], repeating, gradW, gradH, gradX, gradY) };
|
|
2195
3600
|
}
|
|
2196
3601
|
const radial = /^(?:repeating-)?radial-gradient\((.+)\)$/i.exec(layer);
|
|
2197
3602
|
if (radial != null) {
|
|
2198
3603
|
const repeating = /^repeating-/i.test(layer);
|
|
2199
|
-
return { def: buildRadialGradientDef(id, radial[1], repeating,
|
|
3604
|
+
return { def: buildRadialGradientDef(id, radial[1], repeating, gradX, gradY, gradW, gradH) };
|
|
2200
3605
|
}
|
|
2201
3606
|
// DM-550: conic. The raster pre-pass (DM-549) populated `_conicTileCache`
|
|
2202
3607
|
// with PNG bytes for `(layerText, "${tileW}x${tileH}")` tuples; we look up
|
|
@@ -3032,18 +4437,299 @@ export function positionFragmentMaskDef(rewrittenOuterHTML, elX, elY, elW, elH)
|
|
|
3032
4437
|
attrs += ` maskUnits="userSpaceOnUse" x="${r(elX)}" y="${r(elY)}" width="${r(elW)}" height="${r(elH)}"`;
|
|
3033
4438
|
return `<mask${attrs}><g transform="translate(${r(elX)}, ${r(elY)})">${inner}</g></mask>`;
|
|
3034
4439
|
}
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
4440
|
+
const MASK_BORDER_REPEATS = new Set(["stretch", "repeat", "round", "space"]);
|
|
4441
|
+
function normalizeMaskBorderRepeat(raw) {
|
|
4442
|
+
if (raw != null && MASK_BORDER_REPEATS.has(raw))
|
|
4443
|
+
return raw;
|
|
4444
|
+
return "stretch";
|
|
4445
|
+
}
|
|
4446
|
+
function buildMaskBorder9Slice(el, url, sliceRaw, widthRaw, outsetRaw, repeatRaw, maskId, idPrefix, clipIdxStart) {
|
|
4447
|
+
const natW = el.styles.maskBorderIntrinsicWidth ?? 0;
|
|
4448
|
+
const natH = el.styles.maskBorderIntrinsicHeight ?? 0;
|
|
4449
|
+
if (natW <= 0 || natH <= 0)
|
|
4450
|
+
return null;
|
|
4451
|
+
// Slice — numbers are source pixels, percentages of source dims, optional `fill`.
|
|
4452
|
+
const fillCenter = /\bfill\b/i.test(sliceRaw);
|
|
4453
|
+
const sliceTokens = sliceRaw.replace(/\bfill\b/i, "").trim().split(/\s+/);
|
|
4454
|
+
const parseSliceTok = (t) => {
|
|
4455
|
+
if (t == null || t === "")
|
|
4456
|
+
return { px: 0 };
|
|
4457
|
+
if (/%$/.test(t))
|
|
4458
|
+
return { pct: parseFloat(t) };
|
|
4459
|
+
return { px: parseFloat(t) };
|
|
4460
|
+
};
|
|
4461
|
+
const sliceNums = sliceTokens.map(parseSliceTok);
|
|
4462
|
+
const resolveSlice = (tok, basis) => {
|
|
4463
|
+
if (tok.pct != null)
|
|
4464
|
+
return (tok.pct / 100) * basis;
|
|
4465
|
+
return tok.px ?? 0;
|
|
4466
|
+
};
|
|
4467
|
+
const st = resolveSlice(sliceNums[0] ?? { px: 0 }, natH);
|
|
4468
|
+
const sr = resolveSlice(sliceNums[1] ?? sliceNums[0] ?? { px: 0 }, natW);
|
|
4469
|
+
const sb = resolveSlice(sliceNums[2] ?? sliceNums[0] ?? { px: 0 }, natH);
|
|
4470
|
+
const sl = resolveSlice(sliceNums[3] ?? sliceNums[1] ?? sliceNums[0] ?? { px: 0 }, natW);
|
|
4471
|
+
// Width — px / % / unitless multiplier of border-width (defaults to 0 for
|
|
4472
|
+
// mask-border since masks usually have no element border).
|
|
4473
|
+
const bwTop = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
|
|
4474
|
+
const bwRight = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
|
|
4475
|
+
const bwBottom = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
|
|
4476
|
+
const bwLeft = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
|
|
4477
|
+
const parseLen = (tok, basis, borderW) => {
|
|
4478
|
+
if (tok == null || tok === "" || tok === "auto")
|
|
4479
|
+
return borderW;
|
|
4480
|
+
if (/%$/.test(tok))
|
|
4481
|
+
return (parseFloat(tok) / 100) * basis;
|
|
4482
|
+
if (/(px|em|rem|pt|pc|cm|mm|in|Q)$/.test(tok))
|
|
4483
|
+
return parseFloat(tok) || 0;
|
|
4484
|
+
const n = parseFloat(tok);
|
|
4485
|
+
return Number.isFinite(n) ? n * borderW : borderW;
|
|
4486
|
+
};
|
|
4487
|
+
const wTokens = widthRaw.trim().split(/\s+/);
|
|
4488
|
+
const wt = parseLen(wTokens[0], el.height, bwTop);
|
|
4489
|
+
const wr = parseLen(wTokens[1] ?? wTokens[0], el.width, bwRight);
|
|
4490
|
+
const wb = parseLen(wTokens[2] ?? wTokens[0], el.height, bwBottom);
|
|
4491
|
+
const wl = parseLen(wTokens[3] ?? wTokens[1] ?? wTokens[0], el.width, bwLeft);
|
|
4492
|
+
// Outset — defaults to 0.
|
|
4493
|
+
const parseOutset = (tok, basis, borderW) => {
|
|
4494
|
+
if (tok == null || tok === "")
|
|
4495
|
+
return 0;
|
|
4496
|
+
if (/%$/.test(tok))
|
|
4497
|
+
return (parseFloat(tok) / 100) * basis;
|
|
4498
|
+
if (/(px|em|rem|pt|pc|cm|mm|in|Q)$/.test(tok))
|
|
4499
|
+
return parseFloat(tok) || 0;
|
|
4500
|
+
const n = parseFloat(tok);
|
|
4501
|
+
return Number.isFinite(n) ? n * borderW : 0;
|
|
4502
|
+
};
|
|
4503
|
+
const oTokens = outsetRaw.trim().split(/\s+/);
|
|
4504
|
+
const ot = parseOutset(oTokens[0], el.height, bwTop);
|
|
4505
|
+
const or_ = parseOutset(oTokens[1] ?? oTokens[0], el.width, bwRight);
|
|
4506
|
+
const ob = parseOutset(oTokens[2] ?? oTokens[0], el.height, bwBottom);
|
|
4507
|
+
const ol = parseOutset(oTokens[3] ?? oTokens[1] ?? oTokens[0], el.width, bwLeft);
|
|
4508
|
+
// Mask region = border-box ± outset.
|
|
4509
|
+
const boxX = el.x - ol;
|
|
4510
|
+
const boxY = el.y - ot;
|
|
4511
|
+
const boxW = el.width + ol + or_;
|
|
4512
|
+
const boxH = el.height + ot + ob;
|
|
4513
|
+
if (boxW <= 0 || boxH <= 0)
|
|
4514
|
+
return null;
|
|
4515
|
+
// Repeat — `stretch` / `repeat` / `round` / `space` (per axis, optional).
|
|
4516
|
+
const rTokens = repeatRaw.trim().toLowerCase().split(/\s+/);
|
|
4517
|
+
const rH = normalizeMaskBorderRepeat(rTokens[0]);
|
|
4518
|
+
const rV = rTokens[1] != null && rTokens[1] !== "" ? normalizeMaskBorderRepeat(rTokens[1]) : rH;
|
|
4519
|
+
const x0 = boxX, x1 = boxX + wl, x2 = boxX + boxW - wr, x3 = boxX + boxW;
|
|
4520
|
+
const y0 = boxY, y1 = boxY + wt, y2 = boxY + boxH - wb, y3 = boxY + boxH;
|
|
4521
|
+
const sxL = 0, sxR = natW - sr, sxC = sl, sxW_C = natW - sl - sr;
|
|
4522
|
+
const syT = 0, syB = natH - sb, syC = st, syH_C = natH - st - sb;
|
|
4523
|
+
const maskChildren = [];
|
|
4524
|
+
const maskDefs = []; // patterns + clipPaths nested inside the <mask>
|
|
4525
|
+
let clipIdx = clipIdxStart;
|
|
4526
|
+
// For each piece, emit either an `<image>` (stretched) or a `<rect>` filled
|
|
4527
|
+
// by a `<pattern>` that tiles the source slice. clipPath is needed to
|
|
4528
|
+
// restrict the stretched-image emit to the destination rect.
|
|
4529
|
+
const emitStretched = (dxSlot, dySlot, dwSlot, dhSlot, sx, sy, sw, sh) => {
|
|
4530
|
+
if (dwSlot <= 0 || dhSlot <= 0 || sw <= 0 || sh <= 0)
|
|
4531
|
+
return;
|
|
4532
|
+
const clipId = `${idPrefix}mbic${clipIdx++}`;
|
|
4533
|
+
maskDefs.push(`<clipPath id="${clipId}"><rect x="${r(dxSlot)}" y="${r(dySlot)}" width="${r(dwSlot)}" height="${r(dhSlot)}" /></clipPath>`);
|
|
4534
|
+
const scaleX = dwSlot / sw;
|
|
4535
|
+
const scaleY = dhSlot / sh;
|
|
4536
|
+
const imgX = dxSlot - sx * scaleX;
|
|
4537
|
+
const imgY = dySlot - sy * scaleY;
|
|
4538
|
+
const imgW = natW * scaleX;
|
|
4539
|
+
const imgH = natH * scaleY;
|
|
4540
|
+
maskChildren.push(`<image href="${esc(embedResizedDataUri(url, imgW, imgH))}" x="${r(imgX)}" y="${r(imgY)}" width="${r(imgW)}" height="${r(imgH)}" preserveAspectRatio="none" clip-path="url(#${clipId})" />`);
|
|
4541
|
+
};
|
|
4542
|
+
const emitTiledEdge = (dxSlot, dySlot, dwSlot, dhSlot, sx, sy, sw, sh, axis, mode) => {
|
|
4543
|
+
if (dwSlot <= 0 || dhSlot <= 0 || sw <= 0 || sh <= 0)
|
|
4544
|
+
return;
|
|
4545
|
+
let tileW, tileH;
|
|
4546
|
+
if (axis === "x") {
|
|
4547
|
+
tileH = dhSlot;
|
|
4548
|
+
tileW = sw * (dhSlot / sh);
|
|
4549
|
+
if (mode === "round") {
|
|
4550
|
+
const count = Math.max(1, Math.round(dwSlot / tileW));
|
|
4551
|
+
tileW = dwSlot / count;
|
|
4552
|
+
}
|
|
4553
|
+
}
|
|
4554
|
+
else {
|
|
4555
|
+
tileW = dwSlot;
|
|
4556
|
+
tileH = sh * (dwSlot / sw);
|
|
4557
|
+
if (mode === "round") {
|
|
4558
|
+
const count = Math.max(1, Math.round(dhSlot / tileH));
|
|
4559
|
+
tileH = dhSlot / count;
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
let patternW = tileW, patternH = tileH;
|
|
4563
|
+
let patternX = dxSlot, patternY = dySlot;
|
|
4564
|
+
if (mode === "space") {
|
|
4565
|
+
if (axis === "x") {
|
|
4566
|
+
const count = Math.floor(dwSlot / tileW);
|
|
4567
|
+
if (count <= 0)
|
|
4568
|
+
return;
|
|
4569
|
+
patternW = dwSlot / count;
|
|
4570
|
+
patternX = dxSlot + (patternW - tileW) / 2;
|
|
4571
|
+
}
|
|
4572
|
+
else {
|
|
4573
|
+
const count = Math.floor(dhSlot / tileH);
|
|
4574
|
+
if (count <= 0)
|
|
4575
|
+
return;
|
|
4576
|
+
patternH = dhSlot / count;
|
|
4577
|
+
patternY = dySlot + (patternH - tileH) / 2;
|
|
4578
|
+
}
|
|
4579
|
+
}
|
|
4580
|
+
const patId = `${idPrefix}mbip${clipIdx++}`;
|
|
4581
|
+
const imgScaleX = tileW / sw;
|
|
4582
|
+
const imgScaleY = tileH / sh;
|
|
4583
|
+
const inImgX = -sx * imgScaleX;
|
|
4584
|
+
const inImgY = -sy * imgScaleY;
|
|
4585
|
+
const inImgW = natW * imgScaleX;
|
|
4586
|
+
const inImgH = natH * imgScaleY;
|
|
4587
|
+
const clipBgId = mode === "space" ? `${idPrefix}mbic${clipIdx++}` : "";
|
|
4588
|
+
const clipDef = mode === "space"
|
|
4589
|
+
? `<clipPath id="${clipBgId}"><rect x="0" y="0" width="${r(tileW)}" height="${r(tileH)}" /></clipPath>`
|
|
4590
|
+
: "";
|
|
4591
|
+
const imgClip = mode === "space" ? ` clip-path="url(#${clipBgId})"` : "";
|
|
4592
|
+
maskDefs.push(`<pattern id="${patId}" patternUnits="userSpaceOnUse" x="${r(patternX)}" y="${r(patternY)}" width="${r(patternW)}" height="${r(patternH)}">${clipDef}<image href="${esc(embedResizedDataUri(url, inImgW, inImgH))}" x="${r(inImgX)}" y="${r(inImgY)}" width="${r(inImgW)}" height="${r(inImgH)}" preserveAspectRatio="none"${imgClip} /></pattern>`);
|
|
4593
|
+
maskChildren.push(`<rect x="${r(dxSlot)}" y="${r(dySlot)}" width="${r(dwSlot)}" height="${r(dhSlot)}" fill="url(#${patId})" />`);
|
|
4594
|
+
};
|
|
4595
|
+
// Center 9-piece tiler — handles 2D tiling (both `repeat` / `round` / `space`
|
|
4596
|
+
// axes simultaneously). Mirrors Chromium's `NinePieceImageGrid::SetDrawInfoMiddle`
|
|
4597
|
+
// + `ComputeTileParameters` in `third_party/blink/renderer/core/paint/`:
|
|
4598
|
+
// - The center's `tile_scale` is the SAME ratio as the adjacent edge:
|
|
4599
|
+
// `scaleX = top.Scale() = wt/st` (or `wb/sb` if no top); `scaleY = wl/sl` (or `wr/sr`).
|
|
4600
|
+
// Each tile in dest = source-center-slice scaled by that factor.
|
|
4601
|
+
// - `space` distributes (dst - tiles*tile_size) across (tiles + 1) gaps —
|
|
4602
|
+
// a half-spacing gap at each end and full spacing between tiles. (NOT
|
|
4603
|
+
// "flush with edges" as the spec text suggests; Chrome's impl is the
|
|
4604
|
+
// spec it ships.)
|
|
4605
|
+
// - `repeat` centres the pattern with phase = (dst - tile) / 2.
|
|
4606
|
+
// - `round` rescales the tile so a whole number fits exactly.
|
|
4607
|
+
// - `stretch` collapses to a single tile spanning the full slot — fall
|
|
4608
|
+
// through to the existing `emitStretched`.
|
|
4609
|
+
const emitTiledCenter = (dxSlot, dySlot, dwSlot, dhSlot, sx, sy, sw, sh, scaleX, scaleY, modeH, modeV) => {
|
|
4610
|
+
if (dwSlot <= 0 || dhSlot <= 0 || sw <= 0 || sh <= 0 || scaleX <= 0 || scaleY <= 0)
|
|
4611
|
+
return;
|
|
4612
|
+
let tileW = sw * scaleX;
|
|
4613
|
+
let tileH = sh * scaleY;
|
|
4614
|
+
if (tileW <= 0 || tileH <= 0)
|
|
4615
|
+
return;
|
|
4616
|
+
let periodW = tileW, periodH = tileH;
|
|
4617
|
+
let phaseX = 0, phaseY = 0;
|
|
4618
|
+
if (modeH === "round") {
|
|
4619
|
+
const c = Math.max(1, Math.round(dwSlot / tileW));
|
|
4620
|
+
tileW = dwSlot / c;
|
|
4621
|
+
periodW = tileW;
|
|
4622
|
+
}
|
|
4623
|
+
else if (modeH === "space") {
|
|
4624
|
+
const c = Math.floor(dwSlot / tileW);
|
|
4625
|
+
if (c <= 0)
|
|
4626
|
+
return;
|
|
4627
|
+
const sp = (dwSlot - c * tileW) / (c + 1);
|
|
4628
|
+
periodW = tileW + sp;
|
|
4629
|
+
phaseX = sp;
|
|
4630
|
+
}
|
|
4631
|
+
else if (modeH === "repeat") {
|
|
4632
|
+
phaseX = (dwSlot - tileW) / 2;
|
|
4633
|
+
// Anchor the centered pattern at dxSlot for SVG's userSpaceOnUse so
|
|
4634
|
+
// tiles step out symmetrically; phaseX may go negative, that's fine.
|
|
4635
|
+
}
|
|
4636
|
+
else {
|
|
4637
|
+
// stretch on x: one tile spans the full width.
|
|
4638
|
+
tileW = dwSlot;
|
|
4639
|
+
periodW = dwSlot;
|
|
4640
|
+
}
|
|
4641
|
+
if (modeV === "round") {
|
|
4642
|
+
const c = Math.max(1, Math.round(dhSlot / tileH));
|
|
4643
|
+
tileH = dhSlot / c;
|
|
4644
|
+
periodH = tileH;
|
|
4645
|
+
}
|
|
4646
|
+
else if (modeV === "space") {
|
|
4647
|
+
const c = Math.floor(dhSlot / tileH);
|
|
4648
|
+
if (c <= 0)
|
|
4649
|
+
return;
|
|
4650
|
+
const sp = (dhSlot - c * tileH) / (c + 1);
|
|
4651
|
+
periodH = tileH + sp;
|
|
4652
|
+
phaseY = sp;
|
|
4653
|
+
}
|
|
4654
|
+
else if (modeV === "repeat") {
|
|
4655
|
+
phaseY = (dhSlot - tileH) / 2;
|
|
4656
|
+
}
|
|
4657
|
+
else {
|
|
4658
|
+
tileH = dhSlot;
|
|
4659
|
+
periodH = dhSlot;
|
|
4660
|
+
}
|
|
4661
|
+
const imgScaleX = tileW / sw;
|
|
4662
|
+
const imgScaleY = tileH / sh;
|
|
4663
|
+
const inImgX = -sx * imgScaleX;
|
|
4664
|
+
const inImgY = -sy * imgScaleY;
|
|
4665
|
+
const inImgW = natW * imgScaleX;
|
|
4666
|
+
const inImgH = natH * imgScaleY;
|
|
4667
|
+
// Clip the in-pattern image to the tile bounds whenever the pattern
|
|
4668
|
+
// period exceeds the tile size — i.e. when an axis has `space` (which
|
|
4669
|
+
// introduces gaps between tiles) — so the source extends into the gap
|
|
4670
|
+
// region don't paint into the spacing.
|
|
4671
|
+
const needsClip = modeH === "space" || modeV === "space";
|
|
4672
|
+
const patId = `${idPrefix}mbip${clipIdx++}`;
|
|
4673
|
+
let clipDef = "", imgClip = "";
|
|
4674
|
+
if (needsClip) {
|
|
4675
|
+
const clipId = `${idPrefix}mbic${clipIdx++}`;
|
|
4676
|
+
clipDef = `<clipPath id="${clipId}"><rect x="0" y="0" width="${r(tileW)}" height="${r(tileH)}" /></clipPath>`;
|
|
4677
|
+
imgClip = ` clip-path="url(#${clipId})"`;
|
|
4678
|
+
}
|
|
4679
|
+
maskDefs.push(`<pattern id="${patId}" patternUnits="userSpaceOnUse" x="${r(dxSlot + phaseX)}" y="${r(dySlot + phaseY)}" width="${r(periodW)}" height="${r(periodH)}">${clipDef}<image href="${esc(embedResizedDataUri(url, inImgW, inImgH))}" x="${r(inImgX)}" y="${r(inImgY)}" width="${r(inImgW)}" height="${r(inImgH)}" preserveAspectRatio="none"${imgClip} /></pattern>`);
|
|
4680
|
+
maskChildren.push(`<rect x="${r(dxSlot)}" y="${r(dySlot)}" width="${r(dwSlot)}" height="${r(dhSlot)}" fill="url(#${patId})" />`);
|
|
4681
|
+
};
|
|
4682
|
+
// 4 corners — always stretched.
|
|
4683
|
+
emitStretched(x0, y0, wl, wt, sxL, syT, sl, st); // NW
|
|
4684
|
+
emitStretched(x2, y0, wr, wt, sxR, syT, sr, st); // NE
|
|
4685
|
+
emitStretched(x0, y2, wl, wb, sxL, syB, sl, sb); // SW
|
|
4686
|
+
emitStretched(x2, y2, wr, wb, sxR, syB, sr, sb); // SE
|
|
4687
|
+
// Top + Bottom edges.
|
|
4688
|
+
if (rH === "stretch") {
|
|
4689
|
+
emitStretched(x1, y0, x2 - x1, wt, sxC, syT, sxW_C, st);
|
|
4690
|
+
emitStretched(x1, y2, x2 - x1, wb, sxC, syB, sxW_C, sb);
|
|
4691
|
+
}
|
|
4692
|
+
else {
|
|
4693
|
+
emitTiledEdge(x1, y0, x2 - x1, wt, sxC, syT, sxW_C, st, "x", rH);
|
|
4694
|
+
emitTiledEdge(x1, y2, x2 - x1, wb, sxC, syB, sxW_C, sb, "x", rH);
|
|
4695
|
+
}
|
|
4696
|
+
// Left + Right edges.
|
|
4697
|
+
if (rV === "stretch") {
|
|
4698
|
+
emitStretched(x0, y1, wl, y2 - y1, sxL, syC, sl, syH_C);
|
|
4699
|
+
emitStretched(x2, y1, wr, y2 - y1, sxR, syC, sr, syH_C);
|
|
4700
|
+
}
|
|
4701
|
+
else {
|
|
4702
|
+
emitTiledEdge(x0, y1, wl, y2 - y1, sxL, syC, sl, syH_C, "y", rV);
|
|
4703
|
+
emitTiledEdge(x2, y1, wr, y2 - y1, sxR, syC, sr, syH_C, "y", rV);
|
|
4704
|
+
}
|
|
4705
|
+
// Center — when `fill` is present in the slice. Chrome's
|
|
4706
|
+
// `-webkit-mask-box-image` parser implicitly adds `fill` even when CSS
|
|
4707
|
+
// doesn't write it; the capture-side reads from the webkit-prefixed
|
|
4708
|
+
// properties so that resolved `fill` flows through here. Per spec the
|
|
4709
|
+
// center's tile_scale is Edge::Scale() from the adjacent edges (wt/st on
|
|
4710
|
+
// x, wl/sl on y) — NOT a stretch-to-fill — so `space` / `round` / `repeat`
|
|
4711
|
+
// modes tile the source-center subimage across the dest area at that
|
|
4712
|
+
// scale, NOT one giant stretched tile. (See DM-825 + the `niche-mask-border`
|
|
4713
|
+
// .mb-3 fixture: 5×3 grid of 32×32 source-center tiles with 2.67 px
|
|
4714
|
+
// horizontal `space` gaps + 0 vertical gap, fused with the 16×96 left/
|
|
4715
|
+
// right edge tiles + corners to paint 7 visible vertical slats.)
|
|
4716
|
+
if (fillCenter) {
|
|
4717
|
+
if (rH === "stretch" && rV === "stretch") {
|
|
4718
|
+
emitStretched(x1, y1, x2 - x1, y2 - y1, sxC, syC, sxW_C, syH_C);
|
|
4719
|
+
}
|
|
4720
|
+
else {
|
|
4721
|
+
// Edge::Scale() for the adjacent edges; fall back to bottom/right
|
|
4722
|
+
// when top/left are zero-width (degenerate but possible).
|
|
4723
|
+
const scaleX = st > 0 && wt > 0 ? wt / st : (sb > 0 && wb > 0 ? wb / sb : 1);
|
|
4724
|
+
const scaleY = sl > 0 && wl > 0 ? wl / sl : (sr > 0 && wr > 0 ? wr / sr : 1);
|
|
4725
|
+
emitTiledCenter(x1, y1, x2 - x1, y2 - y1, sxC, syC, sxW_C, syH_C, scaleX, scaleY, rH, rV);
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
if (maskChildren.length === 0)
|
|
4729
|
+
return null;
|
|
4730
|
+
const def = `<mask id="${maskId}" maskUnits="userSpaceOnUse" mask-type="alpha">${maskDefs.join("")}${maskChildren.join("")}</mask>`;
|
|
4731
|
+
return { id: maskId, def, nextClipIdx: clipIdx };
|
|
4732
|
+
}
|
|
3047
4733
|
export function buildMaskDef(id, maskImage, elX, elY, w, h, maskMode, sizeCss, posCss, repeatCss, compositeCss,
|
|
3048
4734
|
/** DM-494: lookup table for `mask-image: element(#id)` references. Optional —
|
|
3049
4735
|
* callers without element() refs can omit it. The renderer's main caller
|
|
@@ -3117,7 +4803,14 @@ elementRasters) {
|
|
|
3117
4803
|
}
|
|
3118
4804
|
else {
|
|
3119
4805
|
gradW = resolveSize(sizeTok[0], w, w);
|
|
3120
|
-
|
|
4806
|
+
// DM-679: single-length mask-size per CSS Backgrounds 3 §3.7
|
|
4807
|
+
// means `width=N, height=auto`. For gradient layers (no intrinsic
|
|
4808
|
+
// size) `auto` resolves to the container's corresponding axis, not
|
|
4809
|
+
// to the width again. Previously we squared the box (gradH = gradW)
|
|
4810
|
+
// which made `radial-gradient(circle, …) mask-size: 80px` paint a
|
|
4811
|
+
// smaller hard circle than Chrome (radius derived from 80×80 farthest-
|
|
4812
|
+
// corner ≈ 56.6 vs Chrome's 80×containerH farthest-corner ≈ 72).
|
|
4813
|
+
gradH = sizeTok.length > 1 ? resolveSize(sizeTok[1], h, h) : h;
|
|
3121
4814
|
}
|
|
3122
4815
|
const posTok = layerPos.trim().split(/\s+/);
|
|
3123
4816
|
const resolveH = (t) => {
|
|
@@ -3533,22 +5226,62 @@ function translateClipPath(value, x, y, w, h) {
|
|
|
3533
5226
|
const right = resolvePx(parts[1] ?? parts[0], w);
|
|
3534
5227
|
const bottom = resolvePx(parts[2] ?? parts[0], h);
|
|
3535
5228
|
const left = resolvePx(parts[3] ?? parts[1] ?? parts[0], w);
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
const
|
|
3550
|
-
const
|
|
3551
|
-
|
|
5229
|
+
const insetW = w - left - right;
|
|
5230
|
+
const insetH = h - top - bottom;
|
|
5231
|
+
if (radiusStr === "") {
|
|
5232
|
+
const rectAttrs = `x="${r(x + left)}" y="${r(y + top)}" width="${r(insetW)}" height="${r(insetH)}"`;
|
|
5233
|
+
return `<rect ${rectAttrs} />`;
|
|
5234
|
+
}
|
|
5235
|
+
// CSS Backgrounds 3 §5.3 border-radius shorthand: 1-4 horizontal values,
|
|
5236
|
+
// optionally `/` then 1-4 vertical values. Map to per-corner pairs:
|
|
5237
|
+
// 1 value → all 4 corners
|
|
5238
|
+
// 2 values → TL=BR=v0, TR=BL=v1
|
|
5239
|
+
// 3 values → TL=v0, TR=BL=v1, BR=v2
|
|
5240
|
+
// 4 values → TL=v0, TR=v1, BR=v2, BL=v3
|
|
5241
|
+
const slashIdx = radiusStr.indexOf("/");
|
|
5242
|
+
const hPart = (slashIdx >= 0 ? radiusStr.slice(0, slashIdx) : radiusStr).trim();
|
|
5243
|
+
const vPart = (slashIdx >= 0 ? radiusStr.slice(slashIdx + 1) : hPart).trim();
|
|
5244
|
+
const hTok = hPart.split(/\s+/);
|
|
5245
|
+
const vTok = vPart.split(/\s+/);
|
|
5246
|
+
const pickCorner = (toks, idx) => {
|
|
5247
|
+
if (toks.length === 1)
|
|
5248
|
+
return toks[0];
|
|
5249
|
+
if (toks.length === 2)
|
|
5250
|
+
return toks[idx === 0 || idx === 2 ? 0 : 1];
|
|
5251
|
+
if (toks.length === 3)
|
|
5252
|
+
return toks[idx === 0 ? 0 : idx === 2 ? 2 : 1];
|
|
5253
|
+
return toks[idx];
|
|
5254
|
+
};
|
|
5255
|
+
const tlH = resolvePx(pickCorner(hTok, 0), insetW);
|
|
5256
|
+
const trH = resolvePx(pickCorner(hTok, 1), insetW);
|
|
5257
|
+
const brH = resolvePx(pickCorner(hTok, 2), insetW);
|
|
5258
|
+
const blH = resolvePx(pickCorner(hTok, 3), insetW);
|
|
5259
|
+
const tlV = resolvePx(pickCorner(vTok, 0), insetH);
|
|
5260
|
+
const trV = resolvePx(pickCorner(vTok, 1), insetH);
|
|
5261
|
+
const brV = resolvePx(pickCorner(vTok, 2), insetH);
|
|
5262
|
+
const blV = resolvePx(pickCorner(vTok, 3), insetH);
|
|
5263
|
+
// CSS Backgrounds 3 §5.5 corner-overlap scale-down: scale all four
|
|
5264
|
+
// corners uniformly so no pair on the same edge exceeds the edge length.
|
|
5265
|
+
const sums = [
|
|
5266
|
+
[tlH + trH, insetW], [trV + brV, insetH],
|
|
5267
|
+
[brH + blH, insetW], [blV + tlV, insetH],
|
|
5268
|
+
];
|
|
5269
|
+
let scale = 1;
|
|
5270
|
+
for (const [s, lim] of sums)
|
|
5271
|
+
if (s > 0 && lim > 0)
|
|
5272
|
+
scale = Math.min(scale, lim / s);
|
|
5273
|
+
const corners = {
|
|
5274
|
+
tl: { h: tlH * scale, v: tlV * scale },
|
|
5275
|
+
tr: { h: trH * scale, v: trV * scale },
|
|
5276
|
+
br: { h: brH * scale, v: brV * scale },
|
|
5277
|
+
bl: { h: blH * scale, v: blV * scale },
|
|
5278
|
+
uniform: tlH === trH && tlH === brH && tlH === blH && tlH === tlV && tlH === trV && tlH === brV && tlH === blV,
|
|
5279
|
+
};
|
|
5280
|
+
if (corners.uniform) {
|
|
5281
|
+
const rxAttr = corners.tl.h > 0 ? ` rx="${r(corners.tl.h)}" ry="${r(corners.tl.v)}"` : "";
|
|
5282
|
+
return `<rect x="${r(x + left)}" y="${r(y + top)}" width="${r(insetW)}" height="${r(insetH)}"${rxAttr} />`;
|
|
5283
|
+
}
|
|
5284
|
+
return `<path d="${roundedRectPath(x + left, y + top, insetW, insetH, corners)}" />`;
|
|
3552
5285
|
}
|
|
3553
5286
|
const circle = /^circle\(([^)]*)\)$/i.exec(value);
|
|
3554
5287
|
if (circle != null) {
|
|
@@ -3644,6 +5377,47 @@ function translateClipPath(value, x, y, w, h) {
|
|
|
3644
5377
|
* map to xMin/xMid/xMax + yMin/yMid/yMax. Percentages are bucketed to thirds
|
|
3645
5378
|
* since SVG has no finer-grained alignment.
|
|
3646
5379
|
*/
|
|
5380
|
+
/**
|
|
5381
|
+
* DM-819: rewrite an SVG-source data URI so its top-level `<svg>` declares
|
|
5382
|
+
* `width=consumerW height=consumerH preserveAspectRatio="<par>"`. Chrome
|
|
5383
|
+
* ignores the outer `<image>`'s `preserveAspectRatio` when the source is
|
|
5384
|
+
* SVG (paints at the SVG's own intrinsic size), but it does honor the
|
|
5385
|
+
* embedded SVG's own preserveAspectRatio. Baking the alignment into the
|
|
5386
|
+
* inner SVG lets `object-fit: cover` on an `<img>` referencing an SVG file
|
|
5387
|
+
* actually slice. Returns the input unchanged for raster sources or when
|
|
5388
|
+
* the inner SVG can't be parsed.
|
|
5389
|
+
*/
|
|
5390
|
+
export function rewriteSvgDataUriPreserveAspectRatio(dataUri, w, h, par) {
|
|
5391
|
+
if (!/^data:image\/svg\+xml/i.test(dataUri))
|
|
5392
|
+
return dataUri;
|
|
5393
|
+
// Decode payload: support base64 or URL-encoded forms.
|
|
5394
|
+
const m = /^data:image\/svg\+xml(;base64)?,(.*)$/is.exec(dataUri);
|
|
5395
|
+
if (m == null)
|
|
5396
|
+
return dataUri;
|
|
5397
|
+
const isBase64 = m[1] != null;
|
|
5398
|
+
let svgText;
|
|
5399
|
+
try {
|
|
5400
|
+
svgText = isBase64 ? Buffer.from(m[2], "base64").toString("utf8") : decodeURIComponent(m[2]);
|
|
5401
|
+
}
|
|
5402
|
+
catch {
|
|
5403
|
+
return dataUri;
|
|
5404
|
+
}
|
|
5405
|
+
// Find the first <svg ...> opening tag and rewrite its attrs.
|
|
5406
|
+
const tagMatch = /<svg\b([^>]*)>/i.exec(svgText);
|
|
5407
|
+
if (tagMatch == null)
|
|
5408
|
+
return dataUri;
|
|
5409
|
+
let attrs = tagMatch[1];
|
|
5410
|
+
const stripAttr = (name) => {
|
|
5411
|
+
const re = new RegExp(`\\s${name}\\s*=\\s*("[^"]*"|'[^']*')`, "i");
|
|
5412
|
+
attrs = attrs.replace(re, "");
|
|
5413
|
+
};
|
|
5414
|
+
stripAttr("width");
|
|
5415
|
+
stripAttr("height");
|
|
5416
|
+
stripAttr("preserveAspectRatio");
|
|
5417
|
+
const newAttrs = `${attrs.replace(/\s+$/, "")} width="${r(w)}" height="${r(h)}" preserveAspectRatio="${par}"`;
|
|
5418
|
+
const newSvg = svgText.slice(0, tagMatch.index) + `<svg${newAttrs}>` + svgText.slice(tagMatch.index + tagMatch[0].length);
|
|
5419
|
+
return `data:image/svg+xml;base64,${Buffer.from(newSvg, "utf8").toString("base64")}`;
|
|
5420
|
+
}
|
|
3647
5421
|
export function preserveAspectRatioFor(fit, pos) {
|
|
3648
5422
|
const f = (fit ?? "fill").trim();
|
|
3649
5423
|
if (f === "fill" || f === "none")
|
|
@@ -3840,41 +5614,72 @@ function adjustedDashArray(style, width, sideLength) {
|
|
|
3840
5614
|
function adjustedDashAttrs(style, width, sideLength) {
|
|
3841
5615
|
if (sideLength <= 0 || width <= 0)
|
|
3842
5616
|
return { array: "", offset: 0 };
|
|
5617
|
+
// DM-805: faithful port of Chromium's `DashEffectFromStrokeStyle` +
|
|
5618
|
+
// `SelectBestDashGap` from
|
|
5619
|
+
// `third_party/blink/renderer/platform/graphics/styled_stroke_data.cc`.
|
|
5620
|
+
// The previous implementation scaled the dash/gap pair to fit a whole
|
|
5621
|
+
// number of cycles AND offset the start by gap/2 — visually close but not
|
|
5622
|
+
// pixel-matching Chrome (Chrome keeps the natural dash size + only adjusts
|
|
5623
|
+
// the gap + starts flush at the corner). Verified against painted output
|
|
5624
|
+
// on the `18-border-styles` fixture: 6 px dashed on a 188 px side paints
|
|
5625
|
+
// 11 dashes (dash=12 / gap=5.6, flush at corner), NOT 10 dashes (12.53 /
|
|
5626
|
+
// 6.27 / mid-gap-offset) as the old algorithm emitted.
|
|
5627
|
+
const selectBestDashGap = (strokeLength, dashLength, gapLength) => {
|
|
5628
|
+
// Open path only (closed_path = false in BoxBorderPainter — each side
|
|
5629
|
+
// is drawn as a separate line, even for rounded-corner borders which
|
|
5630
|
+
// use a curved path and handle that path-length math separately).
|
|
5631
|
+
const availableLength = strokeLength + gapLength;
|
|
5632
|
+
const minNumDashes = Math.floor(availableLength / (dashLength + gapLength));
|
|
5633
|
+
const maxNumDashes = minNumDashes + 1;
|
|
5634
|
+
const minNumGaps = Math.max(1, minNumDashes - 1);
|
|
5635
|
+
const maxNumGaps = Math.max(1, maxNumDashes - 1);
|
|
5636
|
+
const minGap = (strokeLength - minNumDashes * dashLength) / minNumGaps;
|
|
5637
|
+
const maxGap = (strokeLength - maxNumDashes * dashLength) / maxNumGaps;
|
|
5638
|
+
if (maxGap <= 0)
|
|
5639
|
+
return minGap;
|
|
5640
|
+
return Math.abs(minGap - gapLength) < Math.abs(maxGap - gapLength) ? minGap : maxGap;
|
|
5641
|
+
};
|
|
3843
5642
|
if (style === "dashed") {
|
|
3844
|
-
//
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
const
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
//
|
|
3858
|
-
|
|
3859
|
-
// the line starts; cycle is `dash gap`, so an offset of `dash + gap/2`
|
|
3860
|
-
// places the line start mid-gap and the first dash visible at gap/2 —
|
|
3861
|
-
// matching Chromium's BoxBorderPainter (DM-318).
|
|
3862
|
-
return { array: `${r(dash)} ${r(gap)}`, offset: dash + gap / 2 };
|
|
5643
|
+
// dash_length = width * (width >= 3 ? 2 : 3); gap_length similarly.
|
|
5644
|
+
const dashLen = width * (width >= 3 ? 2 : 3);
|
|
5645
|
+
const gapTarget = width * (width >= 3 ? 1 : 2);
|
|
5646
|
+
if (sideLength <= dashLen * 2) {
|
|
5647
|
+
// Chrome's "no space for dashes" branch — emit a continuous solid
|
|
5648
|
+
// line (no dasharray). Below that, "exactly 2 dashes proportionally
|
|
5649
|
+
// sized" is a sub-case but the visual is nearly identical to the
|
|
5650
|
+
// pixel diff harness; collapse to solid here.
|
|
5651
|
+
return { array: "", offset: 0 };
|
|
5652
|
+
}
|
|
5653
|
+
const gap = selectBestDashGap(sideLength, dashLen, gapTarget);
|
|
5654
|
+
if (gap <= 0)
|
|
5655
|
+
return { array: "", offset: 0 };
|
|
5656
|
+
// Start flush at the corner — matches Chrome's `MakeDash` with phase 0.
|
|
5657
|
+
return { array: `${r(dashLen)} ${r(gap)}`, offset: 0 };
|
|
3863
5658
|
}
|
|
3864
5659
|
if (style === "dotted") {
|
|
3865
|
-
//
|
|
3866
|
-
//
|
|
3867
|
-
//
|
|
3868
|
-
//
|
|
3869
|
-
//
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
//
|
|
3874
|
-
//
|
|
3875
|
-
//
|
|
3876
|
-
//
|
|
3877
|
-
|
|
5660
|
+
// Chrome's thick-dotted branch (`!StrokeIsDashed(width, kDottedStroke)`
|
|
5661
|
+
// — true for width > 3):
|
|
5662
|
+
// 1. The line endpoints are first moved IN by width/2 (round endcap
|
|
5663
|
+
// fits inside the line). Caller is responsible for that inward
|
|
5664
|
+
// move via cornerTrim = width/2 (see element-tree-to-svg's per-
|
|
5665
|
+
// side emit loop) so `sideLength` here is the POST-move length.
|
|
5666
|
+
// 2. SelectBestDashGap with dash_length = gap_length = width.
|
|
5667
|
+
// 3. dasharray = [0, gap + width - epsilon] with round caps —
|
|
5668
|
+
// produces a dot of diameter `width` per cycle.
|
|
5669
|
+
// Note: the legacy `cornerTrim = bt.w >= 8 ? inset : 0` rule meant
|
|
5670
|
+
// thin (< 8 px) dotted borders skipped the inward move; the per-side
|
|
5671
|
+
// emit loop now insets dotted always so this entry point sees the
|
|
5672
|
+
// chromy effective length.
|
|
5673
|
+
if (sideLength < width * 2) {
|
|
5674
|
+
// Chrome's "Not enough space for 2 dots" branch — single dot via a
|
|
5675
|
+
// gap longer than the line.
|
|
5676
|
+
return { array: `0.01 ${r(width * 2)}`, offset: 0 };
|
|
5677
|
+
}
|
|
5678
|
+
const gap = selectBestDashGap(sideLength, width, width);
|
|
5679
|
+
if (gap <= 0)
|
|
5680
|
+
return { array: "", offset: 0 };
|
|
5681
|
+
const kEpsilon = 0.01;
|
|
5682
|
+
return { array: `0.01 ${r(gap + width - kEpsilon)}`, offset: 0 };
|
|
3878
5683
|
}
|
|
3879
5684
|
return { array: "", offset: 0 };
|
|
3880
5685
|
}
|