diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index cdc390b1f..c5af5460c 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -9,12 +9,17 @@ //! from: a 12-megapixel photo drawn a few hundred pixels wide costs its proxy, not 48 MB, so a //! book of photos fits the budget instead of evicting itself on every edit. [`mip_now`] is the //! canvas's version: it never waits for a decode. +//! +//! Linked bytes are loaded ([`AssetData::load`]) only to decode a level that isn't cached, and +//! on the canvas only on a decode thread. A linked file that can't be had ([`Unavailable`]) is +//! remembered apart from failed decodes: it is asked for again once +//! [`designcraft_doc::asset_data::link_generation`] has advanced (a relink, an update). use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex}; -use designcraft_doc::{AssetData, SourceKey}; +use designcraft_doc::{AssetData, SourceKey, Unavailable}; use vello_cpu::Pixmap; use crate::decode::{decode_pixmap_reduced, decode_sizes, drawn_pixels}; @@ -26,6 +31,9 @@ const BUDGET: usize = 768 << 20; /// Failed assets remembered; the oldest is forgotten (and retried if drawn again) beyond this. const MAX_BAD: usize = 64; +/// Linked files remembered as unavailable; those recorded before the last link change go first. +const MAX_UNAVAILABLE: usize = 1024; + /// Coarsest mip level. const MAX_LEVEL: u8 = 8; @@ -61,6 +69,8 @@ struct Cache { epoch: u64, /// Full decodes kept for a zoom-in ([`KEEP_FULL`]), oldest first; at most [`MAX_KEPT_FULL`]. kept_full: std::collections::VecDeque<(SourceKey, u32)>, + /// Linked bytes that couldn't be loaded: why, the link generation then, and a stamp. + unavailable: HashMap, } impl Cache { @@ -73,6 +83,19 @@ impl Cache { self.bad.remove(&oldest); } } + + /// Remember that the bytes `id` names can't be had, as of link generation `generation`. + fn mark_unavailable(&mut self, id: SourceKey, why: Unavailable, generation: u64) { + self.clock += 1; + self.unavailable.insert(id, (why, generation, self.clock)); + if self.unavailable.len() > MAX_UNAVAILABLE { + self.unavailable.retain(|_, (_, g, _)| *g == generation); + } + while self.unavailable.len() > MAX_UNAVAILABLE { + let Some(oldest) = self.unavailable.iter().min_by_key(|(_, (_, _, stamp))| *stamp).map(|(k, _)| *k) else { break }; + self.unavailable.remove(&oldest); + } + } } static CACHE: Mutex> = Mutex::new(None); @@ -103,6 +126,11 @@ fn lookup(key: (SourceKey, u32, u8)) -> Option> { }) } +/// Whether any level of the bytes `id` names is cached. +pub(crate) fn has_decodes(id: SourceKey) -> bool { + with(|c| c.map.keys().any(|k| k.0 == id)) +} + /// Whether `key` is cached (without marking it used). fn is_cached(key: (SourceKey, u32, u8)) -> bool { with(|c| c.map.contains_key(&key)) @@ -118,6 +146,37 @@ fn is_bad(id: SourceKey, page: u32) -> bool { with(|c| c.bad.contains_key(&(id, page))) } +/// Why the bytes `id` names couldn't be loaded, when that was found since the links last +/// changed. +fn unavailable_now(id: SourceKey) -> Option { + let generation = designcraft_doc::asset_data::link_generation(); + with(|c| c.unavailable.get(&id).filter(|(_, g, _)| *g == generation).map(|(why, ..)| *why)) +} + +/// Why `data` couldn't be loaded for a decode, when it couldn't (since the links last changed): +/// it is drawn from its proxy meanwhile. +pub fn unavailable(data: &AssetData) -> Option { + if data.is_linked() { unavailable_now(data.key()) } else { None } +} + +/// The bytes of `data` to decode. Linked bytes the store doesn't hold are loaded, `gate`d at +/// [`GATE`] for their size; a failure is remembered ([`unavailable_now`]). +fn load_bytes(data: &AssetData, gate: bool) -> Option>> { + if let Some(bytes) = data.resident() { + return Some(bytes); + } + let generation = designcraft_doc::asset_data::link_generation(); + let fits = data.len() <= designcraft_doc::asset_data::MAX_LINKED_BYTES; + let _reserved = (gate && fits).then(|| reserve(usize::try_from(data.len()).unwrap_or(usize::MAX), data.key(), None)); + match data.load() { + Ok(bytes) => Some(bytes), + Err(why) => { + with(|c| c.mark_unavailable(data.key(), why, generation)); + None + } + } +} + /// Cache `pm` as `level` of the bytes `id` names. `colour` is the /// [`designcraft_color::cms::generation`] read before the pixels were looked up or decoded: /// pixels converted under settings changed since are not kept (the next request decodes them @@ -175,24 +234,44 @@ fn set_dims(id: SourceKey, page: u32, size: (u32, u32), exact: bool) { }); } -/// The full-resolution pixel size of `data`: as decoded, else as its header gives it. -fn dims_of(data: &AssetData, page: u32) -> Option<(u32, u32)> { - let id = data.key(); - if let Some(size) = with(|c| c.dims.get(&(id, page)).copied()) { +/// The remembered full-resolution pixel size of the bytes `id` names. +fn known_dims(id: SourceKey, page: u32) -> Option<(u32, u32)> { + with(|c| c.dims.get(&(id, page)).copied()) +} + +/// The full-resolution pixel size of `bytes` (named by `id`): as decoded, else as its header +/// gives it. +fn dims_from(id: SourceKey, bytes: &[u8], page: u32) -> Option<(u32, u32)> { + if let Some(size) = known_dims(id, page) { return Some(size); } - let size = drawn_pixels(&data.load().ok()?, page)?; + let size = drawn_pixels(bytes, page)?; set_dims(id, page, size, false); Some(size) } -/// Decode `data` at full resolution without caching the pixels (its size is remembered); a -/// failure marks it bad. -fn decode_full(data: &AssetData, page: u32) -> Option> { - let id = data.key(); - let bytes = data.load().ok()?; +/// The full-resolution pixel size of `data` (loading it if need be: a failure is remembered). +fn dims_of(data: &AssetData, page: u32) -> Option<(u32, u32)> { + match known_dims(data.key(), page) { + Some(size) => Some(size), + None if unavailable_now(data.key()).is_some() => None, + None => dims_from(data.key(), &load_bytes(data, false)?, page), + } +} + +/// [`dims_of`] without loading anything: `None` for linked bytes not in memory. +fn dims_now(data: &AssetData, page: u32) -> Option<(u32, u32)> { + match known_dims(data.key(), page) { + Some(size) => Some(size), + None => dims_from(data.key(), &data.resident()?, page), + } +} + +/// Decode `bytes` (named by `id`) at full resolution without caching the pixels (its size is +/// remembered); a failure marks it bad. +fn decode_full(id: SourceKey, bytes: &[u8], page: u32) -> Option> { // Decode outside the lock: other threads keep drawing cached images meanwhile. - let pm = decode_pixmap_page(&bytes, page); + let pm = decode_pixmap_page(bytes, page); log_decode(id); match pm { Some(pm) => { @@ -236,10 +315,11 @@ pub fn decoded(data: &AssetData, page: u32) -> Option> { if let Some(pm) = lookup((id, page, 0)) { return Some(pm); } - if is_bad(id, page) { + if is_bad(id, page) || unavailable_now(id).is_some() { return None; } - let pm = decode_full(data, page)?; + let bytes = load_bytes(data, false)?; + let pm = decode_full(id, &bytes, page)?; store(id, page, 0, pm.clone(), colour); Some(pm) } @@ -294,28 +374,44 @@ pub fn mip(data: &AssetData, page: u32, width_pt: f64, on_screen: f64) -> Option /// [`mip`]; with `gate`, a decode first waits at [`GATE`] for its peak bytes ([`reserve`]), /// counted for the decode path it takes. +/// +/// The bytes are loaded only when the level isn't cached (nor a finer one to halve). Gated, a +/// load of linked bytes waits at [`GATE`] for their size first, and the decode counts them too +/// when the store didn't keep them (two reservations one after the other, never one inside the +/// other: a decode holding one while waiting for another could wait for itself). fn mip_gated(data: &AssetData, page: u32, width_pt: f64, on_screen: f64, gate: bool) -> Option> { let id = data.key(); - if is_bad(id, page) { + if is_bad(id, page) || unavailable_now(id).is_some() { return None; } let colour = designcraft_color::cms::generation(); - let mut zoom_in = false; - if let Some(size) = dims_of(data, page) { + let cached = |size: (u32, u32)| { let level = level_for(size, width_pt, on_screen); - if let Some(pm) = cached_level(id, page, level, colour) { - if level == 0 { - drawn_full((id, page)); - } + let pm = cached_level(id, page, level, colour)?; + if level == 0 { + drawn_full((id, page)); + } + Some(pm) + }; + if let Some(pm) = known_dims(id, page).and_then(cached) { + return Some(pm); + } + let bytes = load_bytes(data, gate)?; + // Encoded bytes this decode alone holds. + let held = if gate && data.is_linked() && data.resident().is_none() { bytes.len() } else { 0 }; + let mut zoom_in = false; + if let Some(size) = dims_from(id, &bytes, page) { + if let Some(pm) = cached(size) { return Some(pm); } - if let Some(pm) = reduced(data, page, level, gate, colour) { + let level = level_for(size, width_pt, on_screen); + if let Some(pm) = reduced(id, &bytes, page, level, gate.then_some(held), colour) { return Some(pm); } zoom_in = (level + 1..=MAX_LEVEL).any(|l| is_cached((id, page, l))); } - let _reserved = gate.then(|| reserve(data.load().map_or(0, |b| decode_sizes(&b, page).1).saturating_mul(3) / 2, id, None)); - let full = decode_full(data, page)?; + let _reserved = gate.then(|| reserve((decode_sizes(&bytes, page).1.saturating_mul(3) / 2).saturating_add(held), id, None)); + let full = decode_full(id, &bytes, page)?; let level = level_for((u32::from(full.width()), u32::from(full.height())), width_pt, on_screen); if level == 0 { store(id, page, 0, full.clone(), colour); @@ -356,10 +452,10 @@ fn keep_full(key: (SourceKey, u32)) { pub enum Mip { /// The level asked for. Ready(Arc), - /// Still decoding; another cached level to draw in its place (the nearest coarser one, - /// else the nearest finer one), if any. + /// Still decoding (or loading); another cached level to draw in its place (the nearest + /// coarser one, else the nearest finer one), if any. Pending(Option>), - /// The image can't be decoded. + /// The image can't be decoded, or its linked file can't be had ([`unavailable`]). Failed, } @@ -368,10 +464,11 @@ pub enum Mip { /// decodes in place. pub fn mip_now(data: &AssetData, page: u32, width_pt: f64, on_screen: f64) -> Mip { let id = data.key(); - if is_bad(id, page) { + if is_bad(id, page) || unavailable_now(id).is_some() { return Mip::Failed; } - let level = dims_of(data, page).map(|size| level_for(size, width_pt, on_screen)); + // Linked bytes not in memory are left to the decode thread to load. + let level = dims_now(data, page).map(|size| level_for(size, width_pt, on_screen)); // Only the exact level: building it from a finer one is left to the decode thread too. if let Some((l, pm)) = level.and_then(|l| lookup((id, page, l)).map(|pm| (l, pm))) { if l == 0 { @@ -645,7 +742,8 @@ mod background { // with it, nor leave the image drawn as decoding. let run = std::panic::AssertUnwindSafe(|| { super::fault(key.0); - super::mip_gated(&w.bytes, w.page, w.width_pt, w.on_screen, true).is_some() + // An unavailable file is drawn again too: from its proxy. + super::mip_gated(&w.bytes, w.page, w.width_pt, w.on_screen, true).is_some() || super::unavailable_now(key.0).is_some() }); let ok = std::panic::catch_unwind(run).unwrap_or_else(|_| { super::with(|c| c.mark_bad(key)); @@ -912,8 +1010,9 @@ fn plan(wants: Vec) -> Vec<(usize, Vec)> { // An asset is cached when every drawing of it finds its level, or a finer one to halve. held.retain(|k, _| levels.get(k).is_some_and(|(_, ls)| ls.iter().all(|l| (0..=*l).any(|f| c.map.contains_key(&(k.0, k.1, f)))))); let in_view = held.values().sum::(); + let generation = designcraft_doc::asset_data::link_generation(); for k in index.keys() { - if c.bad.contains_key(k) { + if c.bad.contains_key(k) || c.unavailable.get(&k.0).is_some_and(|(_, g, _)| *g == generation) { held.entry(*k).or_default(); } } @@ -1006,30 +1105,31 @@ fn waited(id: SourceKey) { /// Level `level` of a JPEG for [`mip`], decoded at 1/2, 1/4 or 1/8 of its size straight from /// its blocks and halved from there, so a screen-sized level needs no full-size decode. `None` /// when the full size is drawn, for other formats and for JPEGs only a full decode reads (CMYK). -fn reduced(data: &AssetData, page: u32, level: u8, gate: bool, colour: u64) -> Option> { +/// +/// `gate`: wait at [`GATE`] for the decode's peak bytes, plus the encoded bytes it holds. +fn reduced(id: SourceKey, bytes: &[u8], page: u32, level: u8, gate: Option, colour: u64) -> Option> { if level == 0 || page != 0 { return None; } - let bytes = data.load().ok()?; if !bytes.starts_with(&[0xFF, 0xD8]) { return None; } - let id = data.key(); let shrink = level.min(designcraft_images::MAX_REDUCED_SHRINK); let mut pm = match lookup((id, page, shrink)) { Some(p) => p, None => { // Cut to `halve`'s size (it rounds down, the decoder up), as the level a full // decode makes. - let (sw, sh) = halved(crate::image_size(&bytes)?, shrink); + let (sw, sh) = halved(crate::image_size(bytes)?, shrink); // Gated, a JPEG without an estimate (one only a full decode reads) isn't tried here. - let _reserved = if gate { - let (_, _, peak) = designcraft_images::reduced_jpeg_estimate(&bytes, shrink)?; - Some(reserve(peak.saturating_mul(3) / 2, id, None)) - } else { - None + let _reserved = match gate { + Some(held) => { + let (_, _, peak) = designcraft_images::reduced_jpeg_estimate(bytes, shrink)?; + Some(reserve((peak.saturating_mul(3) / 2).saturating_add(held), id, None)) + } + None => None, }; - let p = decode_pixmap_reduced(&bytes, shrink)?; + let p = decode_pixmap_reduced(bytes, shrink)?; log_decode(id); let p = Arc::new(crop(p, sw, sh)?); store(id, page, shrink, p.clone(), colour); @@ -1068,6 +1168,7 @@ pub fn clear() { with(|c| { c.map.clear(); c.bad.clear(); + c.unavailable.clear(); c.dims.clear(); c.kept_full.clear(); c.bytes = 0; @@ -1558,4 +1659,144 @@ mod tests { let full: Vec = images.iter().map(|img| is_cached((img.key(), 0, 0))).collect(); assert_eq!(full, [false, true, true], "the oldest is let go"); } + + /// Linked files for these tests: bytes by path, served by a loader that counts its reads. + mod linked { + use super::*; + use designcraft_doc::asset_data::{Loaded, Loader}; + use designcraft_doc::{Fingerprint, LinkedSource}; + use std::collections::HashMap; + + static FILES: Mutex>>> = Mutex::new(None); + static READS: Mutex> = Mutex::new(Vec::new()); + + struct Files; + + impl Loader for Files { + fn load(&self, source: &LinkedSource, _: Option) -> Result { + READS.lock().unwrap().push(source.path.clone()); + let bytes = FILES.lock().unwrap().as_ref().and_then(|f| f.get(&source.path).cloned()).ok_or(Unavailable::Missing)?; + Ok(Loaded { bytes, fingerprint: None }) + } + } + + /// A linked asset whose file is `bytes` (`None`: missing for now) at the unique `path`. + pub(super) fn file(path: &str, bytes: Option>, len: u64) -> AssetData { + static INSTALL: std::sync::Once = std::sync::Once::new(); + INSTALL.call_once(|| designcraft_doc::asset_data::install_loader(Arc::new(Files))); + if let Some(b) = bytes { + put(path, b); + } + AssetData::linked(LinkedSource { path: path.into(), relative: None, document: None, len, fingerprint: None, hash: None }) + } + + pub(super) fn put(path: &str, bytes: Vec) { + FILES.lock().unwrap().get_or_insert_with(HashMap::new).insert(path.into(), bytes); + } + + pub(super) fn reads(path: &str) -> usize { + READS.lock().unwrap().iter().filter(|p| *p == path).count() + } + } + + fn png_bytes(w: u32, h: u32, rgba: [u8; 4]) -> Vec { + let mut out = Vec::new(); + image::RgbaImage::from_pixel(w, h, image::Rgba(rgba)).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + /// A linked file is read on the decode thread, and a level once decoded is drawn again + /// without reading the file, even after the store let go of its bytes. + #[test] + fn a_linked_image_is_loaded_off_the_canvas_and_its_cached_levels_need_no_reload() { + let bytes = png_bytes(256, 256, [0, 0, 255, 255]); + let data = linked::file("mem:/cached.png", Some(bytes.clone()), bytes.len() as u64); + assert!(matches!(mip_now(&data, 0, 128.0, 1.0), Mip::Pending(None)), "not loaded on the drawing thread"); + assert_eq!(ready(&data, 128.0, 1.0).map(|pm| pm.width()), Some(256)); + assert_eq!(linked::reads("mem:/cached.png"), 1); + data.evict(); + assert!(data.resident().is_none()); + assert!(matches!(mip_now(&data, 0, 128.0, 1.0), Mip::Ready(_))); + assert!(mip(&data, 0, 128.0, 1.0).is_some()); + assert_eq!(mip(&data, 0, 128.0, 0.25).map(|pm| pm.width()), Some(64), "a coarser level is halved from the cached one"); + assert_eq!(linked::reads("mem:/cached.png"), 1, "no level drawn needed the file again"); + } + + /// A linked file that can't be had isn't a bad image: it is asked for again once the links + /// changed (the file came back). + #[test] + fn an_unavailable_linked_file_is_tried_again_after_the_links_change() { + let bytes = png_bytes(8, 8, [0, 255, 0, 255]); + let data = linked::file("mem:/comes-back.png", None, bytes.len() as u64); + assert!(mip(&data, 0, 8.0, 1.0).is_none()); + assert_eq!(unavailable(&data), Some(Unavailable::Missing)); + assert!(!is_bad(data.key(), 0)); + assert!(matches!(mip_now(&data, 0, 8.0, 1.0), Mip::Failed)); + assert!(mip(&data, 0, 8.0, 1.0).is_none()); + assert_eq!(linked::reads("mem:/comes-back.png"), 1, "not read again before the links change"); + linked::put("mem:/comes-back.png", bytes); + designcraft_doc::asset_data::links_changed(); + assert_eq!(unavailable(&data), None); + assert_eq!(mip(&data, 0, 8.0, 1.0).map(|pm| pm.width()), Some(8)); + } + + /// The page draws a missing linked image from its proxy, at the graphic's place. + #[test] + fn a_missing_linked_image_draws_its_proxy() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Document, Item, SpreadRef}; + use designcraft_geom::kurbo::{Affine, Rect}; + let original = png_bytes(64, 64, [0, 0, 255, 255]); + for defer in [false, true] { + let path = format!("mem:/missing-{defer}.png"); + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let aid = designcraft_doc::AssetId(d.alloc()); + let proxy = + designcraft_doc::Proxy { mime: "image/png".into(), pixels: (4, 4), data: AssetData::embedded(png_bytes(4, 4, [255, 0, 0, 255])) }; + d.assets.insert( + aid, + Arc::new(designcraft_doc::Asset { + id: aid, + name: "photo.png".into(), + mime: "image/png".into(), + link: Some(path.clone()), + storage: designcraft_doc::Storage::Linked, + data: linked::file(&path, None, original.len() as u64), + proxy: Some(proxy), + pixels: Some((64, 64)), + ..Default::default() + }), + ); + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = + Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, + asset: aid, + size: (100.0, 100.0), + xf: Affine::translate((100.0, 100.0)), + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = crate::Cache::new(); + let mut r = crate::Renderer::new(); + r.threads = 0; + let opts = crate::RenderOptions { defer_images: defer, ..Default::default() }; + let mut px = [0; 4]; + for _ in 0..500 { + px = r.render_page(&d, &cache, 0, 1.0, false, &opts).unwrap().pixel(150, 150); + if px[..3] == [255, 0, 0] { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(px[..3], [255, 0, 0], "defer {defer}: the proxy stands in"); + } + } } diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs index a583f45cd..eaf2e0a64 100644 --- a/crates/render/src/pdf_layers.rs +++ b/crates/render/src/pdf_layers.rs @@ -240,39 +240,54 @@ pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { } /// Shared copies of layer variants (by source and hidden set). A variant keeps its identity -/// (the same [`SourceKey`]) for as long as anything holds it: the image cache keys decodes by -/// it, so a variant rebuilt under a new key would miss decodes it already has and be decoded -/// again. +/// (the same [`SourceKey`]) across rebuilds: the image cache keys decodes by it, so a variant +/// rebuilt under a new key would miss decodes it already has and be decoded again. The bytes of +/// a variant are a function of its source's and the hidden set, so the same key always names the +/// same bytes. Recent variants are held here; older ones are made again when asked for. pub(crate) fn layered(data: &AssetData, hidden: &[String]) -> Option { type Key = (SourceKey, Vec); - /// Variants by key (`None`: the file couldn't be rewritten), weak; `KEEP` holds recent ones. - type Known = HashMap>; + enum Variant { + /// The file couldn't be rewritten with these layers off. + Failed, + Made(WeakAssetData), + } + type Known = HashMap; static CACHE: Mutex)>> = Mutex::new(None); /// How many recent variants are kept alive here (each is a copy of its file). const KEEP: usize = 64; let key = (data.key(), hidden.to_vec()); let mut c = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let (known, keep) = c.get_or_insert_with(Default::default); - if let Some(variant) = known.get(&key) { - // `None`: this file couldn't be rewritten with these layers off. - if let Some(v) = variant.as_ref()?.upgrade() { - return Some(v); - } - } - let v = pdf_hide_layers(&data.load().ok()?, hidden).map(AssetData::embedded); - known.insert(key, v.as_ref().map(AssetData::downgrade)); + let id = match known.get(&key) { + Some(Variant::Failed) => return None, + Some(Variant::Made(weak)) => match weak.upgrade() { + Some(v) => return Some(v), + None => weak.key(), + }, + None => SourceKey::fresh(), + }; + // A source that can't be loaded now isn't a file that can't be rewritten: asked again later. + let bytes = data.load().ok()?; + let v = pdf_hide_layers(&bytes, hidden).map(|b| AssetData::embedded_under(id, b)); + known.insert(key, v.as_ref().map_or(Variant::Failed, |v| Variant::Made(v.downgrade()))); if let Some(v) = &v { keep.push_back(v.clone()); while keep.len() > KEEP { keep.pop_front(); } } - // Forget variants nothing holds any more. + // Forget variants nothing holds and nothing decoded is cached of. if known.len() > 4 * KEEP { - known.retain(|_, variant| variant.as_ref().is_none_or(|v| v.upgrade().is_some())); + known.retain(|_, variant| match variant { + Variant::Failed => true, + Variant::Made(weak) => weak.upgrade().is_some() || crate::images::has_decodes(weak.key()), + }); // Failures are cheap to find again; a hostile run of hidden sets doesn't grow this. if known.len() > 4 * KEEP { - known.retain(|_, variant| variant.is_some()); + known.retain(|_, variant| matches!(variant, Variant::Made(_))); + } + if known.len() > 4 * KEEP { + known.retain(|_, variant| matches!(variant, Variant::Made(weak) if weak.upgrade().is_some())); } } v @@ -282,6 +297,7 @@ pub(crate) fn layered(data: &AssetData, hidden: &[String]) -> Option mod tests { use super::*; use craft_pdf::syntax::reader::{Reader, ReaderExt}; + use std::sync::Arc; /// A one-page PDF with two layers, Blue and Red. fn two_layer_pdf() -> Vec { @@ -324,12 +340,18 @@ mod tests { } assert_eq!(first.key(), layered(&data, &blue).unwrap().key(), "still the same variant"); let red = vec!["Red".to_string()]; - let weak = layered(&data, &red).unwrap().downgrade(); - for i in 0..200 { + let variant = layered(&data, &red).unwrap(); + assert!(crate::images::mip(&variant, 0, 100.0, 1.0).is_some(), "the variant draws"); + let weak = variant.downgrade(); + drop(variant); + for i in 0..300 { let _ = layered(&data, &["Blue".to_string(), format!("other {i}")]); } assert!(weak.upgrade().is_none(), "a variant nothing holds is let go"); - assert!(layered(&data, &red).is_some(), "and made again when asked for"); + let again = layered(&data, &red).expect("made again when asked for"); + assert_eq!(again.key(), weak.key(), "under the key its decodes are cached by"); + assert!(crate::images::has_decodes(again.key())); + assert_eq!(again.load().unwrap(), pdf_hide_layers(&data.load().unwrap(), &red).map(Arc::new).unwrap()); } #[test] diff --git a/crates/render/src/stroke.rs b/crates/render/src/stroke.rs index 1779b05c2..898b3a12f 100644 --- a/crates/render/src/stroke.rs +++ b/crates/render/src/stroke.rs @@ -17,7 +17,9 @@ pub(crate) fn image_want(f: &Frame, asset: &designcraft_doc::Asset, g: &designcr let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt(); let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen }; let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone()); - let data = match hidden.as_deref().filter(|_| asset.data.load().is_ok_and(|b| is_pdf(&b))) { + // Linked bytes not in memory aren't loaded to ask: their type says. + let pdf = asset.data.resident().map_or(asset.mime == "application/pdf", |b| is_pdf(&b)); + let data = match hidden.as_deref().filter(|_| pdf) { // An explicit choice (including all-on) that cannot be rewritten fails closed: // stale persisted/relinked choices must not reveal hidden source content. Some(hidden) => pdf_layers::layered(&asset.data, hidden)?, @@ -30,6 +32,38 @@ pub(crate) fn image_want(f: &Frame, asset: &designcraft_doc::Asset, g: &designcr Some((images::Want { bytes: data, page: asset.page, width_pt: page_w, on_screen }, page, page_h)) } +/// What a placed graphic draws. +enum Drawn<'a> { + /// Pixels decoded from these bytes (the graphic's, or its proxy's). + Pixels(std::sync::Arc, &'a designcraft_doc::AssetData), + /// Nothing decoded yet: a light box where it goes. + Loading, + Nothing, +} + +/// The pixels to draw for `want`: its own, else (still loading or decoding, or its linked file +/// can't be had) those of `proxy`, the asset's preview, which spans the same page. +fn pixels<'a>(f: &Frame, want: &'a images::Want, proxy: Option<&'a designcraft_doc::AssetData>) -> Drawn<'a> { + let (width_pt, on_screen) = (want.width_pt, want.on_screen); + if !f.opts.defer_images { + if let Some(pm) = images::mip(&want.bytes, want.page, width_pt, on_screen) { + return Drawn::Pixels(pm, &want.bytes); + } + return proxy.and_then(|p| Some(Drawn::Pixels(images::mip(p, 0, width_pt, on_screen)?, p))).unwrap_or(Drawn::Nothing); + } + let loading = match images::mip_now(&want.bytes, want.page, width_pt, on_screen) { + images::Mip::Ready(pm) | images::Mip::Pending(Some(pm)) => return Drawn::Pixels(pm, &want.bytes), + images::Mip::Pending(None) => true, + images::Mip::Failed => false, + }; + match proxy.map(|p| (p, images::mip_now(p, 0, width_pt, on_screen))) { + Some((p, images::Mip::Ready(pm) | images::Mip::Pending(Some(pm)))) => Drawn::Pixels(pm, p), + Some((_, images::Mip::Pending(None))) => Drawn::Loading, + _ if loading => Drawn::Loading, + _ => Drawn::Nothing, + } +} + /// `stroke` as round dots `weight` wide, one every `2 × weight`. Kurbo's stroker drops /// zero-length dashes, so each dot is a dash a hair long, centred where the zero-length dash /// would be (so the path's ends get dots too). @@ -196,24 +230,20 @@ impl Renderer { } let Some((want, page, page_h)) = image_want(f, asset, g, xf) else { return }; let page_w = want.width_pt; - let pm = if f.opts.defer_images { - match images::mip_now(&want.bytes, want.page, page_w, want.on_screen) { - images::Mip::Ready(pm) | images::Mip::Pending(Some(pm)) => pm, - images::Mip::Pending(None) => { - // Still decoding: a light box where it goes. - ctx.set_transform(f.view * xf * g.xf); - ctx.set_paint(screen_color([222, 222, 222], 255)); - ctx.fill_rect(&Rect::new(0.0, 0.0, g.size.0, g.size.1).intersect(page)); - return; - } - images::Mip::Failed => return, + // A layer variant has no proxy: the asset's shows the file's default layers. + let proxy = asset.proxy.as_ref().map(|p| &p.data).filter(|_| want.bytes.key() == asset.data.key()); + let (pm, data) = match pixels(f, &want, proxy) { + Drawn::Pixels(pm, data) => (pm, data), + Drawn::Loading => { + ctx.set_transform(f.view * xf * g.xf); + ctx.set_paint(screen_color([222, 222, 222], 255)); + ctx.fill_rect(&Rect::new(0.0, 0.0, g.size.0, g.size.1).intersect(page)); + return; } - } else { - let Some(pm) = images::mip(&want.bytes, want.page, page_w, want.on_screen) else { return }; - pm + Drawn::Nothing => return, }; // A coloured image is already in the plate's inks (its colours come from `color_of`). - let pm = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(std::cell::Cell::get)) { + let pm = match (colorize::colorized(f.doc, &g.fill, data, &pm), PLATE.with(std::cell::Cell::get)) { (Some(coloured), _) => coloured, (None, Some(plate)) => plate_pixmap(&pm, plate), (None, None) => pm,