From 61caefbcf33b1f2246412c9194c3fadc60cd5594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 9 Sep 2026 18:27:22 +0200 Subject: [PATCH] :wrench: Support independent image bounds on wasm export (#11590) --- render-wasm/src/render/fills.rs | 24 ++-------------- render-wasm/src/render/images.rs | 12 ++++++++ render-wasm/src/render/svg/images.rs | 18 ++++++------ render-wasm/src/render/svg/tests.rs | 41 +++++++++++++++++++++++++++- render-wasm/src/render/svg/text.rs | 2 +- render-wasm/src/render/vector.rs | 6 ++-- 6 files changed, 69 insertions(+), 34 deletions(-) diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index 1b45d1c759..ac740b064d 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -3,8 +3,7 @@ use skia_safe::{self as skia, Paint, RRect}; use super::{filters, RenderState, SurfaceId}; use crate::error::Result; use crate::get_resources; -use crate::math::Rect as MathRect; -use crate::render::get_source_rect; +use crate::render::{get_image_dest_rect, get_source_rect}; use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type}; // Set the clipping area to the shape outline within the container bounds @@ -94,15 +93,7 @@ fn draw_image_fill( let container = &shape.selrect; let sampling = get_resources().sampling_options; - let dest_rect = match image_fill.transform() { - Some(tf) => MathRect::from_xywh( - container.left + tf.x * container.width(), - container.top + tf.y * container.height(), - tf.width * container.width(), - tf.height * container.height(), - ), - None => *container, - }; + let dest_rect = get_image_dest_rect(container, image_fill); let src_rect = get_source_rect(size, &dest_rect, image_fill); let needs_clip = image_fill.transform().is_some() || !is_axis_aligned_image_rect(shape); @@ -189,16 +180,7 @@ fn draw_svg_image_fill( let fill_layer = skia::canvas::SaveLayerRec::default().paint(paint); canvas.save_layer(&fill_layer); - let dest_rect = match image_fill.transform() { - Some(tf) => MathRect::from_xywh( - container.left + tf.x * container.width(), - container.top + tf.y * container.height(), - tf.width * container.width(), - tf.height * container.height(), - ), - None => *container, - }; - + let dest_rect = get_image_dest_rect(container, image_fill); let src_rect = get_source_rect(size, &dest_rect, image_fill); if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 { canvas.restore(); diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 4cdf9396b8..4b1ce66754 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -20,6 +20,18 @@ pub fn get_dest_rect(container: &MathRect, delta: f32) -> MathRect { ) } +pub fn get_image_dest_rect(container: &MathRect, image_fill: &ImageFill) -> MathRect { + match image_fill.transform() { + Some(tf) => MathRect::from_xywh( + container.left + tf.x * container.width(), + container.top + tf.y * container.height(), + tf.width * container.width(), + tf.height * container.height(), + ), + None => *container, + } +} + pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill) -> MathRect { let image_width = size.width as f32; let image_height = size.height as f32; diff --git a/render-wasm/src/render/svg/images.rs b/render-wasm/src/render/svg/images.rs index ebaa183a39..992f38d548 100644 --- a/render-wasm/src/render/svg/images.rs +++ b/render-wasm/src/render/svg/images.rs @@ -5,7 +5,8 @@ use crate::shapes::{Fill, ImageFill, Shape}; use crate::state::ShapesPoolRef; use super::document::SvgLayerCanvas; -use crate::render::RenderResources; +use crate::math::Rect as MathRect; +use crate::render::{get_image_dest_rect, RenderResources}; /// Emits fills bottom -> top for SVG export. /// @@ -63,19 +64,20 @@ fn emit_image_fill( let clip_id = builder.unique("imgclip"); builder.push_clip_path(&clip_id, shape, tree); let href = xml_escape_attr(url); - emit_linked_image_element(builder, shape, image_fill, &href, &clip_id); + let dest_rect = get_image_dest_rect(&shape.selrect(), image_fill); + emit_linked_image_element(builder, shape, image_fill, dest_rect, &href, &clip_id); Ok(()) } -/// Emits `` + `` using the shape selrect and page CTM. +/// Emits `` + `` at `dest_rect`, under the page CTM. pub(super) fn emit_linked_image_element( builder: &mut SvgLayerCanvas, shape: &Shape, image_fill: &ImageFill, + dest_rect: MathRect, href: &str, clip_id: &str, ) { - let selrect = shape.selrect(); let opacity = image_fill.opacity() as f32 / 255.0; let preserve = if image_fill.keep_aspect_ratio() { "xMidYMid slice" @@ -93,10 +95,10 @@ pub(super) fn emit_linked_image_element( builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); builder.push_raw(&format!( r#""#, - selrect.left(), - selrect.top(), - selrect.width(), - selrect.height(), + dest_rect.left(), + dest_rect.top(), + dest_rect.width(), + dest_rect.height(), )); builder.close_group(); } diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 59a45fc0ad..0343555156 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -1,6 +1,8 @@ use super::fixtures::*; -use crate::shapes::{BlendMode, Fill, ImageFill, SolidColor, StrokeCap, StrokeKind}; +use crate::shapes::{ + BlendMode, Fill, ImageFill, ImageFillTransform, SolidColor, StrokeCap, StrokeKind, +}; use crate::state::ShapesPool; use crate::uuid::Uuid; @@ -1076,6 +1078,43 @@ fn exports_image_fill_as_linked_image() { insta::assert_snapshot!(svg); } +#[test] +fn exports_image_fill_bounds_transform() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_rect_with_fills( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + vec![Fill::Image(ImageFill::new_with_transform( + image_id, + 255, + 200, + 100, + false, + Some(ImageFillTransform { + x: 0.25, + y: 0.5, + width: 0.5, + height: 0.25, + }), + ))], + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert!( + svg.contains(r#"x="25" y="40" width="50" height="20""#), + "linked image must keep the independent image bounds: {svg}" + ); +} + #[test] fn exports_mixed_solid_and_image_fills_in_order() { // Image under a translucent solid; stretch (keep-aspect off); partial image diff --git a/render-wasm/src/render/svg/text.rs b/render-wasm/src/render/svg/text.rs index 06b2bde9f9..0be0416842 100644 --- a/render-wasm/src/render/svg/text.rs +++ b/render-wasm/src/render/svg/text.rs @@ -117,6 +117,6 @@ fn emit_text_image_fill( builder.finish_clip_path_fragment(&clip_id, canvas); let href = xml_escape_attr(url); - emit_linked_image_element(builder, shape, image_fill, &href, &clip_id); + emit_linked_image_element(builder, shape, image_fill, shape.selrect(), &href, &clip_id); Ok(()) } diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index e210c0f955..f4f718064c 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -12,7 +12,7 @@ use super::shape_renderer::ShapeRenderer; use super::text; use super::RenderResources; use super::RenderState; -use super::{get_dest_rect, get_source_rect}; +use super::{get_dest_rect, get_image_dest_rect, get_source_rect}; // --------------------------------------------------------------------------- // VectorRenderer — implements ShapeRenderer for canvas-based vector export @@ -1043,8 +1043,8 @@ fn draw_image_fill( let size = image.dimensions(); let container = &shape.selrect; - let src_rect = get_source_rect(size, container, image_fill); - let dest_rect = container; + let dest_rect = get_image_dest_rect(container, image_fill); + let src_rect = get_source_rect(size, &dest_rect, image_fill); canvas.save();