Place: an image whose pixels can't be read is refused instead of placed blank

Squashed from #4257 onto main.
This commit is contained in:
dotCooCoo
2026-10-29 18:54:38 +07:00
committed by bflatastic
parent e764d770db
commit c8a7420b93
28 changed files with 589 additions and 104 deletions
+71
View File
@@ -198,6 +198,18 @@ impl ImageStore {
pm
}
/// Keep `pm` as `key` as [`ImageStore::insert`] does, holding no reference to it afterwards,
/// then drop entries over budget → whether the store holds `key`'s pixels. Pixels larger than
/// the budget are not kept.
pub(crate) fn keep(&mut self, key: ImageKey, pm: Arc<Pixmap>) -> bool {
if pixmap_bytes(&pm) > self.budget {
return false;
}
drop(self.insert(key.clone(), pm));
self.evict();
self.entries.contains_key(&key)
}
/// Drop the least recently used entries no one else holds until within budget.
fn evict(&mut self) {
if self.bytes <= self.budget {
@@ -271,6 +283,42 @@ pub fn image_cache_bytes() -> usize {
store().bytes()
}
/// Keep `img`, the decoded pixels of `blob`, in the store as a renderer keeps the pixels it
/// decodes, so the first render that draws `blob` uses them instead of decoding it. Returns whether
/// `img` was kept or the store already held `blob`'s pixels. It returns false, without looking at
/// the store, for a CMYK image and for pixels a renderer would refuse to decode: more than the
/// decode allowance as RGBA, or more than fits beside the pixels being drawn now. The store then
/// drops the least recently used entries beyond its budget, as it does whenever a renderer adds
/// pixels, and returns false when that leaves `img` out: pixels larger than the budget are never
/// kept.
pub fn keep_decoded_image(blob: &ImageBlob, img: image::DynamicImage) -> bool {
let (key, lut, _) = crate::blobs::blob_key(blob);
if lut.is_some() || !crate::paint::within_allowance(img.width(), img.height()) {
return false;
}
let kept = store().get(&key);
let size = match kept {
Some(pm) => (pm.width(), pm.height()),
None => {
let Some(pm) = crate::paint::decoded_pixmap(img) else { return false };
let size = (pm.width(), pm.height());
if !store().keep(key, Arc::new(pm)) {
return false;
}
size
}
};
remember_size(blob, size);
true
}
/// Whether the store holds the decoded pixels of `blob` at full size.
pub fn image_cached(blob: &ImageBlob) -> bool {
// The key first: working it out takes the store's lock too.
let (key, _, _) = crate::blobs::blob_key(blob);
store().peek(&key).is_some()
}
/// `blob`'s content key and kind, hashed and sniffed once per blob allocation.
pub(crate) fn blob_id(blob: &ImageBlob) -> BlobId {
if let Some(id) = store().known_id(&blob.bytes) {
@@ -370,6 +418,29 @@ mod tests {
assert_eq!(s.bytes(), 400);
}
/// Pixels kept for a first frame are dropped at once when the budget has no room for them,
/// since nothing draws them yet.
#[test]
fn kept_pixels_larger_than_the_budget_are_not_kept() {
// 10×10 pixmaps: 400 bytes each.
let mut s = ImageStore::new(300);
assert!(!s.keep(key("big"), Arc::new(Pixmap::new(10, 10))));
assert_eq!(s.bytes(), 0);
assert!(s.peek(&key("big")).is_none());
// Room for one: the older one, held by no one, goes.
let mut s = ImageStore::new(400);
assert!(s.keep(key("a"), Arc::new(Pixmap::new(10, 10))));
assert!(s.keep(key("b"), Arc::new(Pixmap::new(10, 10))));
assert!(s.peek(&key("a")).is_none() && s.peek(&key("b")).is_some());
assert_eq!(s.bytes(), 400);
// The one kept is held while drawn: a new one that doesn't fit beside it is dropped.
let held = s.get(&key("b"));
assert!(!s.keep(key("c"), Arc::new(Pixmap::new(10, 10))));
assert!(s.peek(&key("c")).is_none());
assert_eq!(s.bytes(), 400);
drop(held);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn default_budget_is_an_eighth_of_installed_memory_at_most_1_gib() {
+2 -1
View File
@@ -14,11 +14,12 @@ mod freeform;
mod fx;
mod group;
mod images;
pub use images::{image_cached, keep_decoded_image};
mod ink;
mod live;
mod lru;
mod paint;
pub use paint::{decode_failures, reduced_size};
pub use paint::{decode_failures, image_decodes, reduced_size};
mod pattern;
pub mod placed_document;
pub mod proof;
+21 -1
View File
@@ -75,6 +75,8 @@ thread_local! {
/// Images that couldn't be decoded (and so drew nothing) on this thread, counted for exports to
/// report.
static DECODE_FAILURES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
/// Images decoded, or tried, on this thread ([`image_decodes`]).
static DECODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
@@ -116,6 +118,12 @@ pub fn decode_failures() -> u64 {
DECODE_FAILURES.with(std::cell::Cell::get)
}
/// How many images [`decode_pixmap_or_why`] and [`decode_pixmap_fit`] have decoded on this thread so
/// far, failed decodes included. Images other threads decode are not counted.
pub fn image_decodes() -> u64 {
DECODES.with(std::cell::Cell::get)
}
/// [`decode_in_room`]'s image or error.
#[cfg(test)]
pub(crate) fn decode_within_allowance(bytes: &[u8]) -> image::ImageResult<image::DynamicImage> {
@@ -157,6 +165,12 @@ fn room_for(rgba: u64, pinned: u64) -> Result<(), Undecoded> {
}
}
/// Whether `w`×`h` pixels may be decoded whole ([`room_for`]) beside the images paints hold now.
pub(crate) fn within_allowance(w: u32, h: u32) -> bool {
let bytes = (u64::from(w) * u64::from(h)).saturating_mul(4);
room_for(bytes, crate::images::store().pinned_bytes() as u64).is_ok()
}
/// Whether `bytes` of decoded pixels fit beside the images paints hold, which can't be evicted from
/// the cache: a new image is decoded only while those and it fit twice [`MAX_DECODE_ALLOC`].
fn fits_beside_pinned(bytes: u64) -> bool {
@@ -397,7 +411,7 @@ pub fn decode_pixmap(bytes: &[u8]) -> Option<vello_cpu::Pixmap> {
/// [`reduced_size`] when it has one; a failure is counted in [`decode_failures`].
pub(crate) fn decode_pixmap_or_why(bytes: &[u8]) -> Result<vello_cpu::Pixmap, Undecoded> {
counted(match decode_or_reduction(bytes) {
Ok(img) => pixmap_of(fit_pixmap(img).to_rgba8()).ok_or(Undecoded::Unreadable),
Ok(img) => decoded_pixmap(img).ok_or(Undecoded::Unreadable),
Err(r) => r.and_then(|r| reduced_pixmap(bytes, &r)),
})
}
@@ -407,6 +421,7 @@ pub(crate) fn decode_pixmap_or_why(bytes: &[u8]) -> Result<vello_cpu::Pixmap, Un
fn decode_or_reduction(bytes: &[u8]) -> Result<image::DynamicImage, Result<Reduction, Undecoded>> {
#[cfg(test)]
DECODE_ATTEMPTS.with(|n| n.set(n.get() + 1));
DECODES.with(|n| n.set(n.get() + 1));
match decode_in_room(bytes) {
Ok(img) => Ok(img),
Err((image::ImageError::Limits(_), Undecoded::Unreadable)) => Err(reduction(bytes, allowance()).ok_or(Undecoded::Unreadable)),
@@ -471,6 +486,11 @@ fn pixmap_of(img: image::RgbaImage) -> Option<vello_cpu::Pixmap> {
Some(vello_cpu::Pixmap::from_parts(data, w as u16, h as u16))
}
/// Decoded image `img` as [`decode_pixmap_or_why`] makes it a pixmap.
pub(crate) fn decoded_pixmap(img: image::DynamicImage) -> Option<vello_cpu::Pixmap> {
pixmap_of(fit_pixmap(img).to_rgba8())
}
/// The substitution highlights' pink: Preferences › Highlight Substituted Fonts and Highlight
/// Alternate Glyphs, and Document Setup's Highlight Substituted Glyphs (screen only).
pub(crate) const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110);
+33
View File
@@ -903,6 +903,39 @@ fn decode_failures_counts_this_threads_images_only() {
assert_eq!(decode_failures(), before + 1, "this thread's failure");
}
/// Pixels decoded elsewhere and kept with [`keep_decoded_image`] are the pixels a renderer decodes,
/// premultiplied alike, and a render that draws the image uses them without decoding it. A CMYK
/// image's pixels are not kept.
#[test]
fn a_kept_decoded_image_is_drawn_without_decoding_it() {
use vectorcraft_doc::cmyk::Inks;
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
// The image has pixels no other test uses, since every test shares the store, and half of
// them are partly transparent.
let img = image::RgbaImage::from_fn(37, 23, |x, y| image::Rgba([(x * 7) as u8, (y * 11) as u8, 99, if (x + y) % 2 == 0 { 255 } else { 77 }]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let blob = ImageBlob::new("image/png", png.clone());
assert!(!image_cached(&blob));
assert!(keep_decoded_image(&blob, image::load_from_memory(&png).unwrap()));
assert!(image_cached(&blob));
let key = blobs::blob_key(&blob).0;
let kept = images::store().peek(&key).unwrap();
assert!(kept.data() == paint::decode_pixmap(&png).unwrap().data(), "the pixels a renderer decodes");
let mut d = Document::new(37.0, 23.0);
d.images.insert("kept".into(), blob);
let id = d.alloc_id();
let im = ImageObject { key: "kept".into(), width: 37, height: 23, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
let decodes = image_decodes();
let r = Renderer::new().render(&d, 37, 23, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(image_decodes(), decodes, "drawn from the kept pixels");
assert!(r.pixel(18, 11)[3] > 0, "the image is drawn");
let cmyk = ImageBlob::cmyk_tiff(&Inks::new(2, 2, [10, 20, 30, 40].repeat(4)).unwrap()).unwrap();
assert!(!keep_decoded_image(&cmyk, image::DynamicImage::new_rgba8(2, 2)));
assert!(!image_cached(&cmyk));
}
/// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen
/// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as
/// Anti-aliased Images off, #394).