From e01b9a20d95845d9bdebce3591f71ef51b0a490b Mon Sep 17 00:00:00 2001 From: fastmikey99 <218057203+fastmikey99@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:05:40 +1300 Subject: [PATCH 1/2] Crop Image trim: decode print-size rasters, and leave CMYK images alone Trim decoded the image 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 fine failed with "the image's pixels can't be read". It now decodes with the allowance opening already has (MAX_RASTER_ALLOC: 2 GB, 512 MB on the web). A CMYK image, kept as its ink amounts, has no alpha: trim leaves it alone (trimmed: false) instead of failing to decode it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/load.rs | 2 +- crates/engine/src/cmd/fileio/mod.rs | 1 + crates/engine/src/cmd/menucmds.rs | 13 ++++++++++++- crates/engine/src/tests_menucmds.rs | 12 ++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 64fde0e5c..449b96377 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -392,7 +392,7 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result { /// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px) /// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps /// that default: wasm32 has 4 GB to address and aborts when an allocation fails. -const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 }; +pub(crate) const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 }; /// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as /// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index ce0fd2950..7a13657c1 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -28,6 +28,7 @@ pub mod eps; mod export; pub(crate) mod imagemap; mod load; +pub(crate) use load::MAX_RASTER_ALLOC; mod metafile; mod native; pub mod pdf; diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index c84d78a26..fa78bc67a 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -625,7 +625,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { const C: &str = "object.cropImage"; let st = s.doc()?; let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; - let px = image::load_from_memory(&blob.bytes).map_err(|_| bad(C, "the image's pixels can't be read"))?.to_rgba8(); + // A CMYK image (kept as its ink amounts) has no alpha: nothing to cut. + if blob.is_cmyk() { + return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false })); + } + // Print rasters run to gigabytes decoded: the same allowance opening them has. + let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())) + .with_guessed_format() + .map_err(|_| bad(C, "the image's pixels can't be read"))?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC); + reader.limits(limits); + let px = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?.to_rgba8(); let (w, h) = px.dimensions(); let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); for (x, y, p) in px.enumerate_pixels() { diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 3a23dda12..76f0e7a3b 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -1826,3 +1826,15 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() { assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64()), (Some(false), Some(30)), "{r}"); assert_eq!(bounds(&s, img), b); } + +#[test] +fn crop_image_trim_leaves_a_cmyk_image_alone() { + // CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error. + let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [255, 0, 0, 0].repeat(8)).unwrap(); + let tiff = vectorcraft_doc::ImageBlob::cmyk_tiff(&inks).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "inks.tif", "dataBase64": vectorcraft_format::base64_encode(&tiff.bytes)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap(); + assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64(), r["height"].as_u64()), (Some(false), Some(4), Some(2)), "{r}"); +} 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 2/2] 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 03e2358a2..452149cea 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 527a44d36..98ecb10c1 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 ceb153f2e..7d6d94897 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 19dea73af..427d39d8a 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 {