From d084af162f770230fb11ed97b18c2ba12c014fae Mon Sep 17 00:00:00 2001 From: fastmikey99 <218057203+fastmikey99@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:32:36 +1300 Subject: [PATCH] Render: decode print-size rasters, and say when an image is missing from an export The renderer decoded images with the decoder's default 512 MB allocation limit, so a 300 ppi roll (6614 x 59055 px, 1.56 GB as RGBA) that opens and exports to PDF fine drew nothing in a raster export - a fully transparent PNG, exit code 0, no warning. It now decodes with the allowance opening has (2 GB, 512 MB on the web), shrinks an image past a pixmap's 65535 px side to fit instead of dropping it, and counts any image it still can't decode: a raster export that drew fewer images than it should says so in its warnings. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/encode.rs | 6 +++ crates/engine/src/cmd/fileio/tests_raster.rs | 17 ++++++++ crates/render/src/lib.rs | 1 + crates/render/src/paint.rs | 45 ++++++++++++++++++-- 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 03e2358a..452149ce 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -382,7 +382,13 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { /// [`encode_all`] of `doc` as it is. fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result { let f = super::writable(C, Some(format), None)?; + let failed = vectorcraft_render::decode_failures(); let mut enc = encode_files(doc, f, p)?; + // An image the renderer couldn't decode draws nothing: say so rather than write a picture + // that silently lacks it. + if f.raster && vectorcraft_render::decode_failures() > failed { + enc.warnings.push("an image couldn't be decoded and is missing from the picture".into()); + } // Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so // themselves: SVG only when it outlines or embeds fonts). if (f.raster || matches!(f.id, "eps" | "emf" | "wmf")) diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs index 527a44d3..98ecb10c 100644 --- a/crates/engine/src/cmd/fileio/tests_raster.rs +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -163,3 +163,20 @@ fn an_image_opens_at_the_size_its_resolution_declares() { assert!((art.width() - w).abs() < 1e-6 && (art.height() - h).abs() < 1e-6, "{what}: the image fills it, {art:?}"); } } + +#[test] +fn a_raster_export_says_when_an_image_could_not_be_decoded() { + // A PNG whose header reads (so it places at its size) but whose pixel data is cut short: + // the renderer can't decode it and draws nothing, which the export must say. + let mut s = session(72.0, 36.0, 1); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let cut = png_chunk(&png, b"IDAT").map(|d| d.as_ptr() as usize - png.as_ptr() as usize).unwrap() + 4; + png.truncate(cut); + s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap(); + let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); + assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ceb153f2..7d6d9489 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -16,6 +16,7 @@ mod ink; mod live; mod lru; mod paint; +pub use paint::decode_failures; mod pattern; pub mod placed_document; pub mod proof; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 19dea73a..427d39d8 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -52,14 +52,53 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr } } -/// Decode encoded image bytes into a premultiplied pixmap. +/// The most memory decoding one image may take: print rasters run to gigabytes (a 300 ppi roll, +/// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it. +const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 }; + +/// Images that couldn't be decoded (and so drew nothing), counted for exports to report. +static DECODE_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// How many images have failed to decode so far (process-wide): an export compares the count +/// before and after to say it left images out. +pub fn decode_failures() -> u64 { + DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed) +} + +/// `bytes` decoded within [`MAX_DECODE_ALLOC`]; a failure is counted in [`decode_failures`]. +fn decode(bytes: &[u8]) -> Option { + let img = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|mut r| { + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_DECODE_ALLOC); + r.limits(limits); + r.decode().ok() + }); + if img.is_none() { + DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + img +} + +/// `img` shrunk, keeping its proportions, to fit a pixmap's sides (at most `u16::MAX` pixels). +fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage { + let (w, h) = (img.width(), img.height()); + let max = u16::MAX as u32; + if w <= max && h <= max { + return img; + } + let s = max as f64 / w.max(h) as f64; + let (nw, nh) = (((w as f64 * s).round() as u32).clamp(1, max), ((h as f64 * s).round() as u32).clamp(1, max)); + img.resize_exact(nw, nh, image::imageops::FilterType::Triangle) +} + +/// Decode encoded image bytes into a premultiplied pixmap (shrunk to fit a pixmap's sides). pub fn decode_pixmap(bytes: &[u8]) -> Option { - pixmap_of(image::load_from_memory(bytes).ok()?.to_rgba8()) + pixmap_of(fit_pixmap(decode(bytes)?).to_rgba8()) } /// [`decode_pixmap`] shrunk to `w`×`h` pixels when the image is larger (never enlarged). pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option { - let img = image::load_from_memory(bytes).ok()?; + let img = decode(bytes)?; let img = if img.width() > w as u32 || img.height() > h as u32 { img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle) } else {