From 4bf314426d0e212e8abe8e37a1a21bd66a882573 Mon Sep 17 00:00:00 2001 From: Bhaskar Gurram Date: Sat, 10 Oct 2026 19:26:14 +0000 Subject: [PATCH] Fix feDisplacementMap applying scale twice and reading a premultiplied map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apply_displacement_map` converts `scale` into pixels via `scale_coordinates(fe.scale(), fe.scale(), ts)` and then `displacement_map::apply` multiplied the offset by `fe.scale()` again, so the effective displacement was `scale^2`. For `scale="30"` that is a ±450px offset, which samples outside the filter region for almost every pixel and leaves only scattered specks of the source. The map is now read with non-premultiplied color values, as the spec and the kernel's own contract require. Fixes #1019 --- CHANGELOG.md | 2 ++ crates/resvg/src/filter/displacement_map.rs | 11 ++++----- crates/resvg/src/filter/mod.rs | 6 ++++- crates/resvg/tests/integration/render.rs | 5 +++++ .../primitiveUnits=objectBoundingBox.png | Bin 0 -> 444 bytes .../primitiveUnits=objectBoundingBox.svg | 21 ++++++++++++++++++ .../filters/feDisplacementMap/scale=0.png | Bin 0 -> 362 bytes .../filters/feDisplacementMap/scale=0.svg | 15 +++++++++++++ .../semi-transparent-map.png | Bin 0 -> 444 bytes .../semi-transparent-map.svg | 21 ++++++++++++++++++ .../feDisplacementMap/turbulence-map.png | Bin 0 -> 2461 bytes .../feDisplacementMap/turbulence-map.svg | 19 ++++++++++++++++ .../filters/feDisplacementMap/uniform-map.png | Bin 0 -> 444 bytes .../filters/feDisplacementMap/uniform-map.svg | 21 ++++++++++++++++++ crates/usvg/src/parser/filter.rs | 4 ++-- 15 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 crates/resvg/tests/tests/filters/feDisplacementMap/primitiveUnits=objectBoundingBox.png create mode 100644 crates/resvg/tests/tests/filters/feDisplacementMap/primitiveUnits=objectBoundingBox.svg create mode 100644 crates/resvg/tests/tests/filters/feDisplacementMap/scale=0.png create mode 100644 crates/resvg/tests/tests/filters/feDisplacementMap/scale=0.svg create mode 100644 crates/resvg/tests/tests/filters/feDisplacementMap/semi-transparent-map.png create mode 100644 crates/resvg/tests/tests/filters/feDisplacementMap/semi-transparent-map.svg create mode 100644 crates/resvg/tests/tests/filters/feDisplacementMap/turbulence-map.png create mode 100644 crates/resvg/tests/tests/filters/feDisplacementMap/turbulence-map.svg create mode 100644 crates/resvg/tests/tests/filters/feDisplacementMap/uniform-map.png create mode 100644 crates/resvg/tests/tests/filters/feDisplacementMap/uniform-map.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 72121491a..c93c7dcef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ This changelog also contains important changes in dependencies. - Incorrect transform order on the outermost `svg` element with a `viewBox`. (#899) - Relative positioning of right-to-left text with `dx` and `dy`, including text on a path and text decorations. (#1093) - `text-anchor` now uses the actual extent of a text chunk, which `dx` and `textLength` can change. (#1145) +- `feDisplacementMap` applied `scale` twice, displacing almost everything out of the filter region, + and read the displacement map with premultiplied colors. (#1019) ## [0.48.1] 2026-08-02 diff --git a/crates/resvg/src/filter/displacement_map.rs b/crates/resvg/src/filter/displacement_map.rs index 2fb5326c4..eed038076 100644 --- a/crates/resvg/src/filter/displacement_map.rs +++ b/crates/resvg/src/filter/displacement_map.rs @@ -9,13 +9,14 @@ use usvg::filter::{ColorChannel, DisplacementMap}; /// - `map` pixels should have a **unpremultiplied alpha**. /// - `src` pixels can have any alpha method. /// -/// `sx` and `sy` indicate canvas scale. +/// `scale_x` and `scale_y` are the `scale` attribute already converted +/// into the canvas coordinate system, i.e. the maximum displacement in pixels. /// /// `src`, `map` and `dest` dimensions must match. pub fn apply( fe: &DisplacementMap, - sx: f32, - sy: f32, + scale_x: f32, + scale_y: f32, src: ImageRef, map: ImageRef, dest: ImageRefMut, @@ -42,8 +43,8 @@ pub fn apply( let dx = calc_offset(fe.x_channel_selector()); let dy = calc_offset(fe.y_channel_selector()); - let ox = (x as f32 + dx * sx * fe.scale()).round() as i32; - let oy = (y as f32 + dy * sy * fe.scale()).round() as i32; + let ox = (x as f32 + dx * scale_x).round() as i32; + let oy = (y as f32 + dy * scale_y).round() as i32; // TODO: we should use some kind of anti-aliasing when offset is on a pixel border diff --git a/crates/resvg/src/filter/mod.rs b/crates/resvg/src/filter/mod.rs index 28f92f9ac..aa9c4709b 100644 --- a/crates/resvg/src/filter/mod.rs +++ b/crates/resvg/src/filter/mod.rs @@ -976,10 +976,14 @@ fn apply_displacement_map( input2: Image, ) -> Result { let pixmap1 = input1.into_color_space(cs)?.take()?; - let pixmap2 = input2.into_color_space(cs)?.take()?; + let mut pixmap2 = input2.into_color_space(cs)?.take()?; + + // The displacement map must be read using non-premultiplied color values. + demultiply_alpha(pixmap2.data_mut().as_rgba_mut()); let mut pixmap = tiny_skia::Pixmap::try_create(region.width(), region.height())?; + // `scale` is in user units, convert it into pixels. let (sx, sy) = match scale_coordinates(fe.scale(), fe.scale(), ts) { Some(v) => v, None => return Ok(Image::from_image(pixmap1, cs)), diff --git a/crates/resvg/tests/integration/render.rs b/crates/resvg/tests/integration/render.rs index adb80acdc..584067cc5 100644 --- a/crates/resvg/tests/integration/render.rs +++ b/crates/resvg/tests/integration/render.rs @@ -145,7 +145,12 @@ use crate::render; #[test] fn filters_feDiffuseLighting_surfaceScale_eq_1_33() { assert_eq!(render("tests/filters/feDiffuseLighting/surfaceScale=1.33"), 0); } #[test] fn filters_feDiffuseLighting_surfaceScale_eq_5() { assert_eq!(render("tests/filters/feDiffuseLighting/surfaceScale=5"), 0); } #[test] fn filters_feDiffuseLighting_tiny_element_with_spot_light() { assert_eq!(render("tests/filters/feDiffuseLighting/tiny-element-with-spot-light"), 0); } +#[test] fn filters_feDisplacementMap_primitiveUnits_eq_objectBoundingBox() { assert_eq!(render("tests/filters/feDisplacementMap/primitiveUnits=objectBoundingBox"), 0); } +#[test] fn filters_feDisplacementMap_scale_eq_0() { assert_eq!(render("tests/filters/feDisplacementMap/scale=0"), 0); } +#[test] fn filters_feDisplacementMap_semi_transparent_map() { assert_eq!(render("tests/filters/feDisplacementMap/semi-transparent-map"), 0); } #[test] fn filters_feDisplacementMap_simple_case() { assert_eq!(render("tests/filters/feDisplacementMap/simple-case"), 0); } +#[test] fn filters_feDisplacementMap_turbulence_map() { assert_eq!(render("tests/filters/feDisplacementMap/turbulence-map"), 0); } +#[test] fn filters_feDisplacementMap_uniform_map() { assert_eq!(render("tests/filters/feDisplacementMap/uniform-map"), 0); } #[test] fn filters_feDistantLight_default_attributes() { assert_eq!(render("tests/filters/feDistantLight/default-attributes"), 0); } #[test] fn filters_feDistantLight_negative_azimuth_and_elevation() { assert_eq!(render("tests/filters/feDistantLight/negative-azimuth-and-elevation"), 0); } #[test] fn filters_feDistantLight_only_azimuth() { assert_eq!(render("tests/filters/feDistantLight/only-azimuth"), 0); } diff --git a/crates/resvg/tests/tests/filters/feDisplacementMap/primitiveUnits=objectBoundingBox.png b/crates/resvg/tests/tests/filters/feDisplacementMap/primitiveUnits=objectBoundingBox.png new file mode 100644 index 0000000000000000000000000000000000000000..69e11c428079110680550ca4fbc3432085fdca07 GIT binary patch literal 444 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&7G|JGckpC4Af*!E6XFV_F~ENYhW`u=3=9ef z7!>vcWdr8&?Ez98B|(0{4h#bJ`33tA6b3js0S&bGba4!+xRn%OCf9PHqp|wC0*kc7 z4rU29*#uUr?!bZzjEoG{ + primitiveUnits=objectBoundingBox + + `scale` is relative to the object bounding box, so 0.4 of a 100x100 rect is 40, + and the green rect should match the dashed blue outline. + + + + + + + + + + + + diff --git a/crates/resvg/tests/tests/filters/feDisplacementMap/scale=0.png b/crates/resvg/tests/tests/filters/feDisplacementMap/scale=0.png new file mode 100644 index 0000000000000000000000000000000000000000..01d667d03c5d02f9955d066480c633fcfc4b3075 GIT binary patch literal 362 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&7G|JGckpC4ASDst6XFV_(Ln=9;Q7ThJAf2h zNswQ#1A~Ble!-dz|7HV4etWt&hE&{odu}7Alc5CLfq2JDS;@LP8Vi}l3s|LJmqgy@ zdD#5^;9ZvR?{Y5!bJ?V>-V=SkC92(f&gN-Ef@^ls*Wcf-*_NA08^GF;Pqe{%tL8k< gI_t1FPxQqP=H@)Pt9g73t3d(e>FVdQ&MBb@06YGRA^-pY literal 0 HcmV?d00001 diff --git a/crates/resvg/tests/tests/filters/feDisplacementMap/scale=0.svg b/crates/resvg/tests/tests/filters/feDisplacementMap/scale=0.svg new file mode 100644 index 000000000..bb27d19c8 --- /dev/null +++ b/crates/resvg/tests/tests/filters/feDisplacementMap/scale=0.svg @@ -0,0 +1,15 @@ + + scale=0 + A zero scale must not displace anything. + + + + + + + + + + diff --git a/crates/resvg/tests/tests/filters/feDisplacementMap/semi-transparent-map.png b/crates/resvg/tests/tests/filters/feDisplacementMap/semi-transparent-map.png new file mode 100644 index 0000000000000000000000000000000000000000..69e11c428079110680550ca4fbc3432085fdca07 GIT binary patch literal 444 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&7G|JGckpC4Af*!E6XFV_F~ENYhW`u=3=9ef z7!>vcWdr8&?Ez98B|(0{4h#bJ`33tA6b3js0S&bGba4!+xRn%OCf9PHqp|wC0*kc7 z4rU29*#uUr?!bZzjEoG{ + Semi-transparent displacement map + + The map must be read with non-premultiplied color values, + so a half-transparent red map displaces exactly like an opaque one. + The green rect should match the dashed blue outline. + + + + + + + + + + + + diff --git a/crates/resvg/tests/tests/filters/feDisplacementMap/turbulence-map.png b/crates/resvg/tests/tests/filters/feDisplacementMap/turbulence-map.png new file mode 100644 index 0000000000000000000000000000000000000000..5366b5382ab75e70c040eab4a397d094f8159af6 GIT binary patch literal 2461 zcmZ{mdpHvcAIG`k_V3*1ONb%&QA7j002u;IBb8=&`)$k*94xXxNligREoYW5t`iE#wnn9@k3MsvGy#%c`Yz zFsj;qZR?aD%LQ{xZ?SuK8d)<3LwU30TO)(EA6!rwN4Z9)C5sG|X&daXc9|z+2i0XI zoV2q0q%@kp)sO82^XgH7?PakwnDEHxNyK#Gf`iymCsM+e@<`W3pFAh_PnCw4n+?2h z$TPERwuY*~nDr32p6Fq_lT9(-W?!MO z`fKZ0Gf(}g{(Hgk7ZSL=I({Acq)NV#-V;8b5f>cT^&Ca}=)`pkWCtdH^s5j8)^S-T zdQGyGe(pM_O4Kw;Ifre05i)AOaA^gr~Qz$qMQvmj&I-jlcntiPk%%<}? z`q9+V+wVNyp($nKf|xY|=CW&*N5S(POZev$D)s!0&UCAn9egvwe2Hqa66JDg^{`Cs zggZrq$LnJ?->vXK-nV#bo^SME_1)!x$zUBIiq9y)RO|3{t&@)96zMu^Cb{0>W7~^M z3NO}H=1oSs@BL(KsQmW%*4j{N2$_eBq{cj4f3q<;>(ww%5(wv#$t<<_!S?!WtLcze zQ5D$v+lSxigGzm6gCaGo7Ek=OMfADng3`|FZV7O@P{3%h)*XO@}r>+U_V#>_oHr({l*)b>#Be&InjiT9a z>5Uc?I9X#f)X7^9IRrX%Q|_16bW~B6nAB0 z>#SpcKy8uydbw2M^wxa_&5*sJ0T|8Yis!$HU7LMWdHtn%{>tYOO+BR~*LtQhNDD47 zkD1Jq9#ceGj-q{+f$v>{kmtS)OhV2YBk|G;;e0lbzNzU$&L4_^t7kcqWIo(9Q^Qe3 zv=srUvl(R!_0P5tJr|CXbZEK_-AW^)a;9Go!Pv0&tGR<&&bGX) z5r|~8Gm>LD!@h5wWj^NHHLI`*{6R6o4hufSD{~T+d32JO57K>-rfBM#2X5c=5fms1 zi^_yroi9E9`R537KZK&&;y8;ZmMV5`-adANA4z7yRi1!f+nP>PU2lNAem5P+YW9z4 z(z;b!8d_|J9*ly!OvG+guSG~9{QTscWn3=K}FXcoin zTsa-nttEJ?3VMOPtnA#qL|mT9toz_AZC-9ct|uykVK#aFvs>0MWt>pPoUJRxQV$yp*ji7QILXKhg`TQ@=1y z(g~Ps=BEslQ#m2)6jNs+!(U&@sq8?+1-K;Mz4Mi6XWPtc1<+sPu}7|2#X4S0INt8} zAXu|`8jm9|XQ$RVGCyxd)qh-hZxiChXUgT;b){p_HocJJpD;A)(|-RP*G)n|xFI1a z4vS6~)n(Rxw>rP{6#-jESK)=x{v`ZLFze8Js99TyN3^@io0;Me7YZP~1t_4j;grI1 zP@G|J-3z4N;Yr@(uw1bAHsT7O1DVh?W5UPXlluhD!A|dkWDBu+8x87^iE2~6R?tK& zVaZ03aUS>SOl$Al!bV4tZ;gv3z9a{Ab=4v-D{+A0GQur+feP;w$DgC#tm>AK3=#hR z3YJvLYBs+W2^)-V5?c+8YKvM5C-vZJEb`(vNsGPrEovU670oP}BA>)V#=~n)c24)M zPZ3+Ya=D6XsW02w4TXe>l`Z46*39x^j&w@Uw)%LXLjO(G&mivf(!}3xYaWE_H=sEg zQ(vqsRVHEwvAM)uwT~^({1qjF$nR;(#$W8(VJ{)Qbh9~POk3o{5NZtbHR)gl^oP+G zfd_Uo*{DR5E#9XLMwU}VBTS^6OyYuIr~Ao42oKk5l25D+x)3$ylHUi;^wt*oGC%r1 z@-G8F=4VcQ!}~C;dZ{qCy}2f&9dC!Ne!?dBs|2r#J9A|gPwVVPWTZVivZmMHTrwa* zW+O3Da3uyukSUB$X;uf-*1t1c!KH_ssVRX`@z^V5&je!{*|KUmBYYQwDBLKILb6m) zLL&joEn9eSJo_nhhVHRDgR|Kde|@{|u@lE%_^@QB@vEx8IlylV`bE+oZ!?L(j;eg^Z8dkcx|yFq-f^S5TE--2`WVMK-X9a|EvlGK_D@>@tV^ z#Nx;CG37YFP*1v_`+e>H`4;25EHEjeS*~v*b;SYq!Jp&-5)c1h^e^lZnFo#G7ZtBacD1%!7KqWd*|JR@V ze#K06fW$#i0z|q&L#+HFa4?d)-=-y6>=1q#^qwVxmjv!#pdU2>sAZAvrPS4ki-5xV z-ge?hYkr%k*kZu1+U|W3o;!gnF2>ut@H-bEAztPqVfy z9Ja8+zIl~QAi%~K5Rs>0-eYa>X3)J{*yR1VulIkknP0$gAZ@@;_RxG{hDF82s7K}E Sr{ey7vYgLB?P<1%$NvFk%Hoy) literal 0 HcmV?d00001 diff --git a/crates/resvg/tests/tests/filters/feDisplacementMap/turbulence-map.svg b/crates/resvg/tests/tests/filters/feDisplacementMap/turbulence-map.svg new file mode 100644 index 000000000..8204d60c4 --- /dev/null +++ b/crates/resvg/tests/tests/filters/feDisplacementMap/turbulence-map.svg @@ -0,0 +1,19 @@ + + feTurbulence as a displacement map + + A fractal noise map with scale=30 should only erode the edges of the rect + by up to 15 units, leaving the interior fully opaque. + + + + + + + + + + + diff --git a/crates/resvg/tests/tests/filters/feDisplacementMap/uniform-map.png b/crates/resvg/tests/tests/filters/feDisplacementMap/uniform-map.png new file mode 100644 index 0000000000000000000000000000000000000000..69e11c428079110680550ca4fbc3432085fdca07 GIT binary patch literal 444 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&7G|JGckpC4Af*!E6XFV_F~ENYhW`u=3=9ef z7!>vcWdr8&?Ez98B|(0{4h#bJ`33tA6b3js0S&bGba4!+xRn%OCf9PHqp|wC0*kc7 z4rU29*#uUr?!bZzjEoG{ + Uniform displacement map + + A solid red map (R=1, G=0) samples the source at (x + scale/2, y - scale/2), + so the green rect should be shifted 20 units to the left and 20 units down, + matching the dashed blue outline. + + + + + + + + + + + + diff --git a/crates/usvg/src/parser/filter.rs b/crates/usvg/src/parser/filter.rs index 958955482..b0ae3017a 100644 --- a/crates/usvg/src/parser/filter.rs +++ b/crates/usvg/src/parser/filter.rs @@ -721,8 +721,8 @@ fn convert_displacement_map(fe: SvgNode, scale: Size, primitives: &[Primitive]) _ => ColorChannel::A, }; - // TODO: should probably split scale to scale_x and scale_y, - // but resvg doesn't support displacement map anyway... + // TODO: should probably split scale to scale_x and scale_y + // to handle `primitiveUnits=objectBoundingBox` with a non-square bbox. let scale = (scale.width() + scale.height()) / 2.0; Kind::DisplacementMap(DisplacementMap {