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.
Files changed (115) hide show
  1. package/FEATURES.md +1 -0
  2. package/README.md +29 -0
  3. package/dist/animation/animator.js +25 -14
  4. package/dist/animation/animator.test.js +54 -21
  5. package/dist/animation/cursor-overlay.js +0 -2
  6. package/dist/capture/emoji.js +29 -18
  7. package/dist/capture/index.js +5 -4
  8. package/dist/capture/script/color-norm.d.ts +1 -0
  9. package/dist/capture/script/color-norm.js +43 -1
  10. package/dist/capture/script/emoji-detect.js +14 -0
  11. package/dist/capture/script/index.js +593 -65
  12. package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
  13. package/dist/capture/script/walker/borders-backgrounds.js +123 -7
  14. package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
  15. package/dist/capture/script/walker/counter-style-resolver.js +218 -0
  16. package/dist/capture/script/walker/input-value.js +14 -1
  17. package/dist/capture/script/walker/lists-counters.d.ts +3 -1
  18. package/dist/capture/script/walker/lists-counters.js +22 -2
  19. package/dist/capture/script/walker/masks-clips.d.ts +2 -0
  20. package/dist/capture/script/walker/masks-clips.js +41 -1
  21. package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
  22. package/dist/capture/script/walker/pseudo-content.js +301 -61
  23. package/dist/capture/script/walker/pseudo-inject.js +20 -0
  24. package/dist/capture/script/walker/text-segments.js +98 -4
  25. package/dist/capture/script/walker/transforms.d.ts +1 -0
  26. package/dist/capture/script/walker/transforms.js +16 -0
  27. package/dist/capture/script.generated.js +1 -1
  28. package/dist/capture/types.d.ts +213 -2
  29. package/dist/cli/animate.js +151 -15
  30. package/dist/mask.test.js +12 -7
  31. package/dist/render/borders.d.ts +9 -13
  32. package/dist/render/borders.js +379 -14
  33. package/dist/render/element-tree-to-svg.d.ts +11 -12
  34. package/dist/render/element-tree-to-svg.js +2046 -241
  35. package/dist/render/embedded-font-builder.d.ts +49 -0
  36. package/dist/render/embedded-font-builder.js +149 -0
  37. package/dist/render/form-controls.js +45 -24
  38. package/dist/render/gradients.d.ts +15 -0
  39. package/dist/render/gradients.js +103 -2
  40. package/dist/render/gradients.test.js +34 -0
  41. package/dist/render/text-to-path.d.ts +38 -1
  42. package/dist/render/text-to-path.js +654 -29
  43. package/dist/render/text-to-path.test.js +230 -9
  44. package/dist/render/text.d.ts +14 -0
  45. package/dist/render/text.js +344 -40
  46. package/dist/scroll/composer.d.ts +26 -0
  47. package/dist/scroll/composer.js +199 -11
  48. package/dist/scroll/composer.test.js +293 -16
  49. package/dist/scroll/executor.d.ts +3 -1
  50. package/dist/scroll/executor.js +15 -6
  51. package/dist/scroll/executor.test.js +25 -0
  52. package/dist/scroll/hoist-fixed.d.ts +48 -0
  53. package/dist/scroll/hoist-fixed.js +85 -0
  54. package/dist/scroll/hoist-fixed.test.d.ts +1 -0
  55. package/dist/scroll/hoist-fixed.test.js +103 -0
  56. package/dist/scroll/hoist-sticky.d.ts +45 -0
  57. package/dist/scroll/hoist-sticky.js +157 -0
  58. package/dist/scroll/hoist-sticky.test.d.ts +1 -0
  59. package/dist/scroll/hoist-sticky.test.js +154 -0
  60. package/dist/scroll/pattern.d.ts +22 -5
  61. package/dist/scroll/pattern.js +55 -7
  62. package/dist/scroll/pattern.test.js +48 -1
  63. package/dist/tree-ops/frame-merge.d.ts +10 -0
  64. package/dist/tree-ops/frame-merge.js +23 -5
  65. package/dist/tree-ops/frame-merge.test.js +45 -0
  66. package/dist/tree-ops/tree-diff.js +1 -1
  67. package/dist/tree-ops/viewbox-culling.js +32 -18
  68. package/dist/tree-ops/viewbox-culling.test.js +40 -6
  69. package/package.json +8 -2
  70. package/src/animation/animator.test.ts +56 -21
  71. package/src/animation/animator.ts +25 -14
  72. package/src/animation/cursor-overlay.ts +0 -2
  73. package/src/capture/emoji.ts +28 -18
  74. package/src/capture/index.ts +15 -14
  75. package/src/capture/script/color-norm.ts +38 -1
  76. package/src/capture/script/emoji-detect.ts +14 -0
  77. package/src/capture/script/index.ts +555 -48
  78. package/src/capture/script/walker/borders-backgrounds.ts +114 -7
  79. package/src/capture/script/walker/counter-style-resolver.ts +184 -0
  80. package/src/capture/script/walker/input-value.ts +14 -1
  81. package/src/capture/script/walker/lists-counters.ts +24 -2
  82. package/src/capture/script/walker/masks-clips.ts +40 -1
  83. package/src/capture/script/walker/pseudo-content.ts +297 -55
  84. package/src/capture/script/walker/pseudo-inject.ts +20 -0
  85. package/src/capture/script/walker/text-segments.ts +93 -4
  86. package/src/capture/script/walker/transforms.ts +14 -0
  87. package/src/capture/script.generated.ts +1 -1
  88. package/src/capture/types.ts +202 -2
  89. package/src/cli/animate.ts +135 -15
  90. package/src/mask.test.ts +12 -7
  91. package/src/render/borders.ts +383 -17
  92. package/src/render/element-tree-to-svg.ts +2051 -238
  93. package/src/render/embedded-font-builder.ts +221 -0
  94. package/src/render/form-controls.ts +45 -24
  95. package/src/render/gradients.test.ts +46 -0
  96. package/src/render/gradients.ts +94 -2
  97. package/src/render/opentype.js.d.ts +7 -0
  98. package/src/render/text-to-path.test.ts +246 -9
  99. package/src/render/text-to-path.ts +702 -31
  100. package/src/render/text.ts +344 -40
  101. package/src/scroll/composer.test.ts +322 -16
  102. package/src/scroll/composer.ts +246 -13
  103. package/src/scroll/executor.test.ts +27 -0
  104. package/src/scroll/executor.ts +19 -10
  105. package/src/scroll/hoist-fixed.test.ts +117 -0
  106. package/src/scroll/hoist-fixed.ts +95 -0
  107. package/src/scroll/hoist-sticky.test.ts +173 -0
  108. package/src/scroll/hoist-sticky.ts +193 -0
  109. package/src/scroll/pattern.test.ts +58 -1
  110. package/src/scroll/pattern.ts +71 -8
  111. package/src/tree-ops/frame-merge.test.ts +51 -0
  112. package/src/tree-ops/frame-merge.ts +24 -6
  113. package/src/tree-ops/tree-diff.ts +3 -1
  114. package/src/tree-ops/viewbox-culling.test.ts +42 -6
  115. 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
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"${schemeAttr}>${rootBgRect}${inner}</svg>`;
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
- const shape = translateClipPath(clipPathCss, el.x, el.y, el.width, el.height);
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
- if (oxClips || oyClips) {
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 inflate_ = ohas ? Math.max(0, oOffset_ + ow_) : 0;
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(el.x - inflate_)}" y="${r(el.y - inflate_)}" width="${r(el.width + 2 * inflate_)}" height="${r(el.height + 2 * inflate_)}"/></clipPath>`);
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
- const maskImage = el.styles.maskImage;
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 (maskImage != null && maskImage !== "none" && maskImage !== "") {
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
- const maskDef = buildMaskDef(`${idPrefix}mk${clipIdx++}`, maskImage, el.x, el.y, el.width, el.height, el.styles.maskMode ?? "match-source", el.styles.maskSize ?? "auto", el.styles.maskPosition ?? "0% 0%", el.styles.maskRepeat ?? "repeat", el.styles.maskComposite ?? "add", elementMaskRasters);
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
- if (sh.spread < 0)
385
- continue;
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: each axis grows by `spread` (clamped at 0)
394
- // since the shadow rect extends beyond the border-box by spread on
395
- // every side. Per-corner radii grow uniformly so each corner stays
396
- // proportional to its source.
397
- const shadowCorners = insetCornerRadii(corners, -sh.spread, -sh.spread, -sh.spread, -sh.spread);
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 (!suppressEmptyCell && bgColor != null && bgColor.a > 0.01) {
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
- let textBgClipFill = null;
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, originBox.x, originBox.y, originBox.w, originBox.h, layerSize, layerPos, layerRepeat, layerIntrinsic, layerAttachment, captureViewport);
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
- if (textBgClipFill == null)
491
- textBgClipFill = `url(#${defId})`;
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
- svgParts.push(`${indent}${roundedRectSvg(clipBox.x, clipBox.y, clipBox.w, clipBox.h, innerCorners, `fill="url(#${defId})"`)}`);
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 (SK-1111): per CSS paint order this sits ON TOP of
506
- // the background layers but BEHIND the border. Outset shadows would sit
507
- // underneath the entire box and aren't supported in this pass only
508
- // inset, no blur, with non-negative spread is handled (sufficient for the
509
- // common "padding visualizer" pattern: box-shadow: inset 0 0 0 NNpx
510
- // <color>). Anything fancier falls through silently and becomes a
511
- // follow-up. See SK-1111.
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
- for (const sh of shadows) {
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
- // Render the inset shadow as a stroked rect at the inside-the-border
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
- const strokeColor = colorStr(parseColor(sh.color) ?? { r: 0, g: 0, b: 0, a: 0 });
578
- svgParts.push(`${indent}<g clip-path="url(#${cid})">${roundedRectSvg(ibLeft, ibTop, ibW, ibH, innerCorners, `fill="none" stroke="${strokeColor}" stroke-width="${r(ringWidth)}"${filterAttr}`)}</g>`);
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 = renderBorderImage(el, indent, idPrefix, defsParts, clipIdx);
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
- // For thick (≥ 5 px) dashed/dotted borders, shorten each side by
726
- // `inset` (= half stroke width) at both ends so adjacent sides
727
- // meet at corners without overlap. With butt linecaps the line
728
- // ink stops at exactly the line endpoint, so top + left don'\\'t
729
- // both paint the same corner pixel. Chrome'\\'s BoxBorderPainter
730
- // does the equivalent via per-side clipping — without this trim
731
- // our 4-line dashed/dotted emit double-paints the corners as a
732
- // darker square (DM-402, visible on the 10 px dashed border in
733
- // `17-bg-color-image`). Thin borders skip the trim because the
734
- // half-stroke gap (~1.5 px on a 3 px border) leaves a visible
735
- // hole at corners.
736
- const cornerTrim = bt.w >= 8 ? inset : 0;
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 - 2 * cornerTrim],
739
- [bR - inset, bT + cornerTrim, bR - inset, bB - cornerTrim, bB - bT - 2 * cornerTrim],
740
- [bL + cornerTrim, bB - inset, bR - cornerTrim, bB - inset, bR - bL - 2 * cornerTrim],
741
- [bL + inset, bT + cornerTrim, bL + inset, bB - cornerTrim, bB - bT - 2 * cornerTrim],
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 && !collapse) {
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
- 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)}" />`);
878
- 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)}" />`);
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
- 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} />`);
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). Without these closes, an inline-SVG element with `opacity < 1`
991
- // (or any other group-triggering style) emits an unbalanced `<g>` and
992
- // breaks the document observable on resend/stripe whose nav chevrons
993
- // sit inside `opacity: 0.7` wrappers.
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
- const clipId = `${idPrefix}ifn${clipIdx++}`;
1065
- defsParts.push(`<clipPath id="${clipId}"><rect x="${r(contentX)}" y="${r(contentY)}" width="${r(contentW)}" height="${r(contentH)}" /></clipPath>`);
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
- svgParts.push(`${indent}<image href="${esc(embedResizedDataUri(el.imageSrc, contentW, contentH))}" x="${r(contentX)}" y="${r(contentY)}" width="${r(contentW)}" height="${r(contentH)}" preserveAspectRatio="${par}" />`);
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
- // Position: marker right-aligned just left of the li's content edge,
1217
- // mirroring the text-marker branch below.
1218
- const smallGap = 4;
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 - smallGap : el.x + borderL + padL;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1224
- 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}">${escLabel}</text>`);
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 default ::marker is right-aligned within the marker box
1267
- // with a small UA-defined gap to the content (~4px on macOS Chrome).
1268
- // Anchor to `el.x - smallGap` with text-anchor="end" so the marker's
1269
- // right edge sits just left of the principal block — guessing the
1270
- // marker's rendered width is unreliable across font fallbacks
1271
- // ("1." is 11.5px in Helvetica, 13.6px in Inter, 18px in Courier),
1272
- // and getting it ~10px wrong drives the entire visible offset.
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
- // DM-447: numeric / alpha / roman markers are painted with a ~8px
1275
- // gap from the principal-block edge in Chrome (vs the previous
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 - smallGap : el.x + borderL + padL;
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
- const fillColor = (textBgClipFill != null && textIsTransparent)
1444
- ? textBgClipFill
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(opts, opts.el.textSegments);
2409
+ return renderMultiSegmentText(optsWithEmit, opts.el.textSegments);
1467
2410
  if (isMultiLine)
1468
- return renderMultiLineText(opts);
1469
- if (opts.el.tag === "input" || opts.el.tag === "textarea")
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
- if (textBgClipFill != null && textIsTransparent) {
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
- svgParts.push(`${indent}<rect x="${r(el.x)}" y="${r(el.y)}" width="${r(el.width)}" height="${r(el.height)}" fill="${textBgClipFill}" mask="url(#${mid})" />`);
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
- // Textarea resize handle (DM-339): when CSS `resize` is non-none on a
1676
- // <textarea>, Chrome's UA stylesheet paints a small ~7×7 diagonal-line
1677
- // pattern in the bottom-right corner indicating the user can drag to
1678
- // resize. Empirical: 3 diagonal lines from the corner extending up-left,
1679
- // ~1.5px stroke, mid-gray (#999), inside the padding-box. Matches what
1680
- // Chrome paints across resize: vertical / horizontal / both / inline /
1681
- // block (only `none` suppresses).
1682
- if (el.tag === "textarea" && el.styles.resize != null && el.styles.resize !== "none") {
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 at the textarea's
1686
- // border-box bottom-right minus a 2px inset (matches Chrome's painted
1687
- // offset).
1688
- const cx = el.x + el.width - 2;
1689
- const cy = el.y + el.height - 2;
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="0.7" />`);
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
- defsParts.push(`<clipPath id="${overflowClipId}">${roundedRectSvg(el.x + cbl, el.y + cbt, Math.max(0, el.width - cbl - cbr), Math.max(0, el.height - cbt - cbb), corners, "")}</clipPath>`);
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, true);
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
- const sortedChildren = sortChildrenByPaintOrder(childrenForSort, childParentDisplay, el.styles.flexDirection);
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
- renderElement(child, depth + 1, childParentDisplay);
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 topLevelFlat = gatherStackingContextChildren(elements, hoistedFromAncestor);
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
- renderElement(el, 1);
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, hoistTargetIsRealSC = false) {
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
- const collectFromNonSC = (parent) => {
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
- for (const c of parent.children) {
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
- // Only hoist when the hoist target is a real SC: at the implicit
2038
- // top-level (no enclosing SC element captured), the eventual sort
2039
- // can't know `parentDisplay`, so a hoisted flex-item-z would lose
2040
- // its z-bucket and paint in DOM order. In that case we leave the
2041
- // element in place and let its parent's local flex sort handle the
2042
- // z-ordering naturally DM-525's local-sort path covers the
2043
- // direct-flex-child case correctly.
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 = hoistTargetIsRealSC && parentIsFlexGrid && hasExplicitZ;
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
- if (positioned || flexGridItemSC || isFloat) {
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
- if (!establishesStackingContext(c, childParentDisplay)) {
2062
- collectFromNonSC(c);
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
- if (!establishesStackingContext(c, parentDisplay)) {
2071
- collectFromNonSC(c);
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, w, h, elX, elY) };
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, elX, elY, w, h) };
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
- * Translate a CSS mask-image value + mask-* siblings into an SVG <mask>.
3037
- * Handles single-layer gradients and url() sources. Position/size/repeat are
3038
- * applied via an internal <pattern> for url sources; gradients use direct
3039
- * gradient fills sized to the element box.
3040
- *
3041
- * SVG <mask> uses luminance by default (bright pixels visible). CSS mask-mode
3042
- * 'alpha' makes the alpha channel control visibility. We set mask-type on the
3043
- * <mask> element accordingly. Note: Chromium may render mask-mode:'match-source'
3044
- * differently depending on the source; we pick alpha for gradients and url()
3045
- * (common case) and respect explicit mask-mode when given.
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
- gradH = sizeTok.length > 1 ? resolveSize(sizeTok[1], h, h) : gradW;
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
- let rx = 0, ry = 0;
3537
- if (radiusStr !== "") {
3538
- // border-radius shorthand here can be 1-4 values, optionally with a `/`
3539
- // separator for rx/ry pairs. Common cases: single value, two values.
3540
- // We collapse to a uniform rx=ry using the first horizontal radius.
3541
- const slashIdx = radiusStr.indexOf("/");
3542
- const hPart = (slashIdx >= 0 ? radiusStr.slice(0, slashIdx) : radiusStr).trim();
3543
- const vPart = (slashIdx >= 0 ? radiusStr.slice(slashIdx + 1) : hPart).trim();
3544
- const hTok = hPart.split(/\s+/);
3545
- const vTok = vPart.split(/\s+/);
3546
- rx = resolvePx(hTok[0] ?? "0", w);
3547
- ry = resolvePx(vTok[0] ?? hTok[0] ?? "0", h);
3548
- }
3549
- const rectAttrs = `x="${r(x + left)}" y="${r(y + top)}" width="${r(w - left - right)}" height="${r(h - top - bottom)}"`;
3550
- const radiusAttrs = rx > 0 || ry > 0 ? ` rx="${r(rx)}" ry="${r(ry)}"` : "";
3551
- return `<rect ${rectAttrs}${radiusAttrs} />`;
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
- // Chromium's `StyledStrokeData::DashLengthRatio` / `DashGapRatio` (in
3845
- // `third_party/blink/renderer/platform/graphics/styled_stroke_data.cc`):
3846
- // dash = thickness >= 3 ? 2.0 * width : 3.0 * width
3847
- // gap = thickness >= 3 ? 1.0 * width : 2.0 * width
3848
- // So thick borders (≥ 3 px) get 2:1 dash:gap, thin borders (1-2 px)
3849
- // get 3:2. Verified directly from Chromium source — DM-437 / DM-420.
3850
- const idealDash = width >= 3 ? width * 2 : width * 3;
3851
- const idealGap = width >= 3 ? width : width * 2;
3852
- const idealPeriod = idealDash + idealGap;
3853
- const cycles = Math.max(1, Math.round(sideLength / idealPeriod));
3854
- const scale = sideLength / (cycles * idealPeriod);
3855
- const dash = idealDash * scale;
3856
- const gap = idealGap * scale;
3857
- // Center the dash pattern so each side has gap/2 of margin at each
3858
- // corner. stroke-dashoffset specifies the distance into the cycle where
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
- // Dot diameter = width (round-cap on near-zero dash). Dot center spacing
3866
- // = `2 * width`, so each cycle (dot + gap) = 2 * width.
3867
- // Empirical re-probe (DM-419): Chrome paints `ceil(sideLength / period)`
3868
- // cycles, not `round`. For a 3 px dotted border on an 80 px side,
3869
- // Chrome paints 14 dots while our `round(80/6) = 13` was off-by-one.
3870
- const idealPeriod = width * 2;
3871
- const cycles = Math.max(1, Math.ceil(sideLength / idealPeriod));
3872
- const adjustedPeriod = sideLength / cycles;
3873
- // Shift the cycle so the first dot is at adjustedPeriod / 2 from the
3874
- // start, matching Chrome's centered-dot painting. The cycle is
3875
- // `0.01 adjustedPeriod`, total adjustedPeriod; an offset of
3876
- // adjustedPeriod / 2 starts mid-gap.
3877
- return { array: `0.01 ${r(adjustedPeriod)}`, offset: adjustedPeriod / 2 };
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
  }