Belén Albeza 2045fc4900
🐛 Keep text image fills in place when pasting (#12552)
A text image fill was always laid over the whole text box, so a pasted
image span showed another part of the picture in a box of another size.

Text image fills now honour the image transform, as shape fills do: the
image covers the transform rect instead of the whole box. Paste gives
each pasted image span a transform that keeps the image at the same
pixel offset from the span, like the gradients.

The SVG export emits one linked image per span transform, rather than
one per image id, and the style panel keeps the transform it reads back.

AI-assisted-by: claude-opus-5-5
2026-10-23 13:00:53 +02:00

500 lines
16 KiB
Rust

use crate::math::Rect as MathRect;
use crate::shapes::ImageFill;
use crate::uuid::Uuid;
use crate::error::Result;
use crate::get_gpu_state;
use skia_safe::gpu::{surfaces, Budgeted, DirectContext};
use skia_safe::{self as skia, Codec, ISize, Size};
use std::cell::Cell;
use std::collections::HashMap;
pub type Image = skia::Image;
pub fn get_dest_rect(container: &MathRect, delta: f32) -> MathRect {
MathRect::from_ltrb(
container.left - delta,
container.top - delta,
container.right + delta,
container.bottom + delta,
)
}
pub fn get_image_dest_rect(container: &MathRect, image_fill: &ImageFill) -> MathRect {
image_fill.dest_rect(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;
// Container size
let container_width = container.width();
let container_height = container.height();
let mut source_width = image_width;
let mut source_height = image_height;
let mut source_x = 0.;
let mut source_y = 0.;
let source_scale_y = image_height / container_height;
let source_scale_x = image_width / container_width;
if image_fill.keep_aspect_ratio() {
// Calculate scale to ensure the image covers the container
let image_aspect_ratio = image_width / image_height;
let container_aspect_ratio = container_width / container_height;
if image_aspect_ratio > container_aspect_ratio {
// Image is taller, scale based on width to cover container
source_width = container_width * source_scale_y;
source_x = (image_width - source_width) / 2.0;
} else {
// Image is wider, scale based on height to cover container
source_height = container_height * source_scale_x;
source_y = (image_height - source_height) / 2.0;
};
}
MathRect::from_xywh(source_x, source_y, source_width, source_height)
}
enum StoredImage {
Raw(Vec<u8>),
Gpu(Image),
Svg {
dom: skia::svg::Dom,
size: Size,
// Lazy raster for consumers that need a texture (stroke fills,
// exports). The shape fill path draws the DOM directly instead.
raster: Option<Image>,
},
}
struct StoredEntry {
image: StoredImage,
/// Approximate retained cost: encoded byte length (raw/svg) or the
/// decoded RGBA size for images registered from a GL texture.
bytes: usize,
/// LRU tick; `Cell` so read paths can touch it without `&mut self`.
last_used: Cell<u64>,
}
pub struct ImageStore {
images: HashMap<(Uuid, bool), StoredEntry>,
total_bytes: usize,
tick: Cell<u64>,
/// gpu-only
context: Option<Box<DirectContext>>,
/// Source URL registered when the image was fetched (SVG export references
/// this in linked `<image>` elements).
source_urls: HashMap<Uuid, String>,
}
/// Creates a Skia image from an existing WebGL texture.
/// This avoids re-decoding the image, as the browser has already decoded
/// and uploaded it to the GPU.
fn create_image_from_gl_texture(
context: &mut Box<DirectContext>,
texture_id: u32,
width: i32,
height: i32,
) -> Result<Image> {
use skia_safe::gpu;
use skia_safe::gpu::gl::TextureInfo;
// Create a TextureInfo describing the existing GL texture
let texture_info = TextureInfo {
target: gl::TEXTURE_2D,
id: texture_id,
format: gl::RGBA8,
protected: gpu::Protected::No,
};
// Create a backend texture from the GL texture using the new API
let label = format!("shared_texture_{}", texture_id);
let backend_texture = unsafe {
gpu::backend_textures::make_gl((width, height), gpu::Mipmapped::No, texture_info, label)
};
// Create a Skia image from the backend texture
// Use TopLeft origin because HTML images have their origin at top-left,
// while WebGL textures traditionally use bottom-left
let image = Image::from_texture(
context.as_mut(),
&backend_texture,
gpu::SurfaceOrigin::TopLeft,
skia::ColorType::RGBA8888,
skia::AlphaType::Premul,
None,
)
.ok_or(crate::error::Error::CriticalError(
"Failed to create Skia image from GL texture".to_string(),
))?;
Ok(image)
}
// Decode and upload to GPU
fn decode_image(context: &mut Box<DirectContext>, raw_data: &[u8]) -> Option<Image> {
let data = unsafe { skia::Data::new_bytes(raw_data) };
let codec = Codec::from_data(&data)?;
let image = Image::from_encoded(&data)?;
let mut dimensions = codec.dimensions();
if codec.origin().swaps_width_height() {
dimensions.width = codec.dimensions().height;
dimensions.height = codec.dimensions().width;
}
let image_info = skia::ImageInfo::new_n32_premul(dimensions, None);
let mut surface = surfaces::render_target(
context,
Budgeted::Yes,
&image_info,
None,
None,
None,
true,
false,
)?;
let dest_rect: MathRect =
MathRect::from_xywh(0.0, 0.0, dimensions.width as f32, dimensions.height as f32);
surface
.canvas()
.draw_image_rect(&image, None, dest_rect, &skia::Paint::default());
Some(surface.image_snapshot())
}
// Size for SVGs without intrinsic dimensions nor a viewBox.
const DEFAULT_SVG_SIZE: f32 = 512.0;
// Parse an SVG and resolve its natural size. Skia codecs don't handle SVG,
// so this is the fallback when `decode_image` fails.
fn parse_svg(raw_data: &[u8]) -> Option<(skia::svg::Dom, Size)> {
// An empty font manager: <text> elements inside SVG image fills won't
// resolve typefaces. Wire the render state's font provider here if that
// ever becomes a need. Must be custom_empty (not FontMgr::new): NewSystem
// scans font dirs and aborts under -sFILESYSTEM=0.
let font_mgr = skia::FontMgr::custom_empty()?;
let mut dom = skia::svg::Dom::from_bytes(raw_data, font_mgr).ok()?;
let mut size = dom.root().intrinsic_size();
if size.is_empty() {
// SVGs without width/height attributes have no intrinsic size;
// fall back to the viewBox dimensions.
size = dom
.root()
.view_box()
.map(|vb| Size::new(vb.width(), vb.height()))
.unwrap_or_else(|| Size::new(DEFAULT_SVG_SIZE, DEFAULT_SVG_SIZE));
}
// Ceil so the size matches the integer dimensions used when rasterizing.
let size = Size::new(size.width.ceil(), size.height.ceil());
if size.is_empty() {
return None;
}
dom.set_container_size(size);
Some((dom, size))
}
fn rasterize_svg(
context: &mut Box<DirectContext>,
dom: &skia::svg::Dom,
size: Size,
) -> Option<Image> {
let dimensions = ISize::new(size.width as i32, size.height as i32);
let image_info = skia::ImageInfo::new_n32_premul(dimensions, None);
let mut surface = surfaces::render_target(
context,
Budgeted::Yes,
&image_info,
None,
None,
None,
true,
false,
)?;
dom.render(surface.canvas());
Some(surface.image_snapshot())
}
impl ImageStore {
pub fn new() -> Self {
let gpu_state = get_gpu_state();
let context = &gpu_state.context;
Self {
images: HashMap::with_capacity(2048),
total_bytes: 0,
tick: Cell::new(0),
context: Some(Box::new(context.clone())),
source_urls: HashMap::new(),
}
}
/// GPU-free image store for the headless export path: no GPU context, so
/// images are kept as encoded bytes and decoded on the CPU at draw time
/// (see `get_cpu_image`).
pub fn new_without_gpu() -> Self {
Self {
images: HashMap::with_capacity(16),
total_bytes: 0,
tick: Cell::new(0),
context: None,
source_urls: HashMap::new(),
}
}
/// Bumps the LRU clock and returns the new tick.
fn next_tick(&self) -> u64 {
let t = self.tick.get() + 1;
self.tick.set(t);
t
}
fn insert_entry(&mut self, key: (Uuid, bool), image: StoredImage, bytes: usize) {
let last_used = Cell::new(self.next_tick());
self.total_bytes += bytes;
self.images.insert(
key,
StoredEntry {
image,
bytes,
last_used,
},
);
}
/// Evicts least-recently-used images until the store retains at most
/// `max_bytes`. Meant to be called by the headless exporter *between*
/// requests, so an image can never disappear under a running render;
/// evicted images are simply re-provisioned by a later request that
/// needs them (`is_image_cached` reports them as missing). Returns the
/// number of evicted images.
pub fn evict_to_budget(&mut self, max_bytes: usize) -> usize {
if self.total_bytes <= max_bytes {
return 0;
}
// Order the keys once instead of rescanning the whole store for the
// minimum on every eviction.
let mut keys: Vec<_> = self
.images
.iter()
.map(|(key, entry)| (entry.last_used.get(), *key))
.collect();
keys.sort_unstable_by_key(|(last_used, _)| *last_used);
let mut evicted = 0;
for (_, key) in keys {
if self.total_bytes <= max_bytes {
break;
}
if let Some(entry) = self.images.remove(&key) {
self.total_bytes -= entry.bytes;
evicted += 1;
}
}
evicted
}
pub fn add(
&mut self,
id: Uuid,
is_thumbnail: bool,
image_data: &[u8],
) -> crate::error::Result<()> {
let key = (id, is_thumbnail);
if self.images.contains_key(&key) {
return Ok(());
}
let raw_data = image_data.to_vec();
let bytes = raw_data.len();
match self.context.as_mut() {
Some(context) => {
if let Some(gpu_image) = decode_image(context, &raw_data) {
self.insert_entry(key, StoredImage::Gpu(gpu_image), bytes);
} else if let Some((dom, size)) = parse_svg(&raw_data) {
self.insert_entry(
key,
StoredImage::Svg {
dom,
size,
raster: None,
},
bytes,
);
} else {
// The lazy re-decode in `get_internal` only retries raster codecs,
// so SVGs that fail to parse here stay raw.
self.insert_entry(key, StoredImage::Raw(raw_data), bytes);
}
}
// GPU-free: keep the encoded bytes; decoded on the CPU at draw time.
// SVGs still get parsed up front since that needs no GPU context.
None => {
if let Some((dom, size)) = parse_svg(&raw_data) {
self.insert_entry(
key,
StoredImage::Svg {
dom,
size,
raster: None,
},
bytes,
);
} else {
self.insert_entry(key, StoredImage::Raw(raw_data), bytes);
}
}
}
Ok(())
}
/// Creates a Skia image from an existing WebGL texture, avoiding re-decoding.
/// This is much more efficient as it reuses the texture that was already
/// decoded and uploaded to GPU by the browser.
pub fn add_image_from_gl_texture(
&mut self,
id: Uuid,
is_thumbnail: bool,
texture_id: u32,
width: i32,
height: i32,
) -> Result<()> {
let key = (id, is_thumbnail);
if self.images.contains_key(&key) {
return Ok(());
}
// Create a Skia image from the existing GL texture
let Some(context) = self.context.as_mut() else {
return Err(crate::error::Error::CriticalError(
"Cannot register a GL texture without a GPU context".to_string(),
));
};
let image = create_image_from_gl_texture(context, texture_id, width, height)?;
let bytes = (width as usize) * (height as usize) * 4;
self.insert_entry(key, StoredImage::Gpu(image), bytes);
Ok(())
}
pub fn contains(&self, id: &Uuid, is_thumbnail: bool) -> bool {
self.images.contains_key(&(*id, is_thumbnail))
}
pub fn get(&mut self, id: &Uuid) -> Option<&Image> {
// Try to get full image first, fallback to thumbnail
let has_full = self.images.contains_key(&(*id, false));
if has_full {
self.get_internal(id, false)
} else {
self.get_internal(id, true)
}
}
pub fn get_cpu_image(&mut self, id: &Uuid) -> Option<Image> {
// GPU path: promote to a texture, then copy to a CPU image.
if self.context.is_some() {
let gpu_image = self.get(id)?.clone();
let context = self.context.as_mut()?;
return gpu_image.make_non_texture_image(context.as_mut());
}
// Headless (no GPU context): decode the stored encoded bytes directly to
// a CPU image, which draws fine on a raster/PDF canvas. Try full first,
// then thumbnail.
self.decode_raw_cpu_image(id, false)
.or_else(|| self.decode_raw_cpu_image(id, true))
}
fn decode_raw_cpu_image(&self, id: &Uuid, is_thumbnail: bool) -> Option<Image> {
let entry = self.images.get(&(*id, is_thumbnail))?;
entry.last_used.set(self.next_tick());
match &entry.image {
StoredImage::Raw(raw_data) => {
let data = unsafe { skia::Data::new_bytes(raw_data) };
Image::from_encoded(&data)
}
StoredImage::Gpu(img) => Some(img.clone()),
StoredImage::Svg { dom, size, .. } => {
// No GPU context in the headless path: rasterize on a CPU
// surface instead of `rasterize_svg` (which needs one).
let dimensions = ISize::new(size.width as i32, size.height as i32);
let mut surface = skia::surfaces::raster_n32_premul(dimensions)?;
dom.render(surface.canvas());
Some(surface.image_snapshot())
}
}
}
/// Vector access for SVG images: the fill render path draws the DOM
/// directly so it stays crisp at any zoom level.
pub fn get_svg(&self, id: &Uuid) -> Option<(&skia::svg::Dom, Size)> {
let entry = self
.images
.get(&(*id, false))
.or_else(|| self.images.get(&(*id, true)))?;
entry.last_used.set(self.next_tick());
match &entry.image {
StoredImage::Svg { dom, size, .. } => Some((dom, *size)),
_ => None,
}
}
fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> {
let key = (*id, is_thumbnail);
let tick = self.tick.get() + 1;
self.tick.set(tick);
// Use entry API to mutate the HashMap in-place if needed
if let Some(entry) = self.images.get_mut(&key) {
entry.last_used.set(tick);
let image = &mut entry.image;
match image {
StoredImage::Gpu(ref img) => Some(img),
StoredImage::Raw(raw_data) => {
let context = self.context.as_mut()?;
let gpu_image = decode_image(context, raw_data)?;
*image = StoredImage::Gpu(gpu_image);
if let StoredImage::Gpu(ref img) = image {
Some(img)
} else {
None
}
}
StoredImage::Svg { dom, size, raster } => {
if raster.is_none() {
let context = self.context.as_mut()?;
*raster = rasterize_svg(context, dom, *size);
}
raster.as_ref()
}
}
} else {
None
}
}
pub(crate) fn set_source_url(&mut self, id: Uuid, url: String) {
if !url.is_empty() {
self.source_urls.insert(id, url);
}
}
pub(crate) fn source_url(&self, id: &Uuid) -> Option<&str> {
self.source_urls.get(id).map(String::as_str)
}
}