foreignObject under filter is zoomed twice

#450845901 Filed 2025-10-10 by pdr@chromium.org P3 / S3 / New

Summary

When an SVG <foreignObject> is rendered indirectly through an feImage filter primitive that references an ancestor group containing it, the foreignObject HTML content is scaled by the page/SVG zoom twice. The rest of the referenced group (here a gradient-filled <rect>) is rendered at the correct size.

repro

Setup

body has zoom: 1.5 applied to the <svg>. A group #g contains a gradient <rect> and a <foreignObject> with text "hello world". A second <rect> at (100, 100) uses filter="url(#filter)" where the filter is a single <feImage href="#g">.

Expected: two equally sized blue boxes with "hello world" - one at top-left (direct paint of #g) and one at bottom-right (the filtered copy via feImage).

Actual: the foreignObject HTML content in the bottom-right (filtered) copy renders at the wrong scale and ends up clipped to a tiny mark in the top-left of the filter region. The gradient <rect> in the same group renders at the correct size, so the bug is specific to <foreignObject> when reached through feImage.

Side-by-side (live)

Reproducer (filter -> feImage href="#g")

Reference (duplicate group via <g transform>)

Captured screenshots (Chromium headless)

Actual (reproducer)

actual reproducer rendering

Expected (reference)

expected rendering
Open repro Open reference Issue 450845901

Where the double zoom comes from

The feImage code path that renders a referenced layout subtree lives in third_party/blink/renderer/core/svg/graphics/filters/svg_fe_image.cc in FEImage::CreateImageFilterForLayoutObject:

const AffineTransform transform =
    SourceToDestinationTransform(layout_object, dst_rect);
...
PaintRecordBuilder builder;
SVGObjectPainter(layout_object, nullptr)
    .PaintResourceSubtree(builder.Context());
builder.EndRecording(*canvas);

SourceToDestinationTransform applies a scale of GetFilter()->Scale() (which equals the effective device-scale, i.e. the page zoom for this test) to the recorded canvas, on the assumption that PaintResourceSubtree draws unscaled SVG content.

PaintResourceSubtree walks the referenced subtree via SVGContainerPainter. For most SVG children that assumption holds. But <foreignObject> is special: LayoutSVGForeignObject lays its HTML children out at the zoomed size and compensates in LayoutSVGForeignObject::LocalToSVGParentTransform() by scaling by 1 / EffectiveZoom(). When the foreignObject is reached through PaintResourceSubtree + an outer canvas-level SourceToDestinationTransform scale, the zoom factor in the foreignObject's paint-property chain is no longer cancelled in the same way it is during a normal SVG paint pass. The recorded chunks for the foreignObject HTML content end up in a different effective scale than the sibling SVG shapes - in practice the content collapses to a tiny clipped mark inside the filter region. The issue was filed as "zoomed twice" from the symmetric case where the foreignObject content overflows.

Status