From 152228d76db2d701a6d873640022f407dcd3eeab Mon Sep 17 00:00:00 2001 From: storybored379 Date: Sun, 4 Oct 2026 00:44:03 -0700 Subject: [PATCH] Raster export: refuse sizes the renderer can't produce instead of crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PNG/JPEG/WebP export renders the artboard at the requested scale with no size check. The CPU rasteriser addresses at most 65535 pixels a side (and panics at that edge), so a large artboard at a print resolution, e.g. the maximum 16383 pt artboard at 300 ppi (~68 000 px), or a screenshot at scale 16, panicked inside vello_common: the desktop app or the MCP server exited and unsaved work was lost. `vectorcraft_render::raster_size` checks the pixel size first (at most 32 768 px a side and 268 megapixels, like Rasterize already refuses oversized images) and the export paths return an error that names the size: `document.export` / `exportSelection` / `serialize`, the MCP `export` and `screenshot` tools, File ▸ Export and the control protocol's `ui.render`. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 4 +++- crates/engine/src/tests_file.rs | 23 +++++++++++++++++++++++ crates/mcp/src/headless.rs | 2 ++ crates/mcp/src/tests.rs | 18 ++++++++++++++++++ crates/render/src/lib.rs | 25 +++++++++++++++++++++++++ crates/render/src/tests.rs | 13 +++++++++++++ crates/ui-egui/src/control.rs | 6 +++++- crates/ui-egui/src/io.rs | 1 + 8 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index db5836ed..8ede9b52 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -134,7 +134,9 @@ fn encode_doc_with(doc: &vectorcraft_doc::Document, format: &str, scale: f64, ar "pdf" => super::rasterfx::export_pdf(doc, &vectorcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?, "png" | "jpg" | "jpeg" | "webp" => { let r = doc.artboards.get(artboard).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; - let img = vectorcraft_render::Renderer::new().render_region(doc, r, scale.clamp(0.01, 64.0), format != "png" && format != "webp"); + let scale = scale.clamp(0.01, 64.0); + vectorcraft_render::raster_size(r, scale).map_err(EngineError::Other)?; + let img = vectorcraft_render::Renderer::new().render_region(doc, r, scale, format != "png" && format != "webp"); match format { "png" => img.to_png(), "webp" => img.to_webp(), diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests_file.rs index c287e0dd..c9ab6a62 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests_file.rs @@ -183,3 +183,26 @@ fn template_opens_as_untitled() { assert_eq!(st.doc.layers[0].children().unwrap().len(), 1); let _ = std::fs::remove_dir_all(dir); } + +#[test] +fn raster_export_too_large_is_an_error_not_a_crash() { + // The largest artboard (16383 pt) at 300 ppi is ~68 000 px a side: more than the rasteriser can + // address. The export used to panic inside the renderer (crashing the app or the MCP server). + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 16383, "height": 16383})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 100})).unwrap(); + let dir = std::env::temp_dir().join(format!("vc-raster-limit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for format in ["png", "jpg", "webp"] { + let path = dir.join(format!("big.{format}")); + let e = s.execute("document.export", &json!({"path": path.to_string_lossy(), "scale": 300.0 / 72.0})).unwrap_err().to_string(); + assert!(e.contains("pixels"), "{format}: {e}"); + assert!(!path.exists(), "{format}"); + assert!(s.execute("document.serialize", &json!({"format": format, "scale": 300.0 / 72.0})).is_err(), "{format}"); + } + // A size within the limits still exports. + let path = dir.join("ok.png"); + s.execute("document.export", &json!({"path": path.to_string_lossy(), "scale": 0.25})).unwrap(); + assert!(std::fs::read(&path).unwrap().starts_with(b"\x89PNG")); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index eacb1d76..9fd64f37 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -204,6 +204,7 @@ impl Headless { let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0); + vectorcraft_render::raster_size(r, scale)?; let img = self.renderer.render_region(&doc, r, scale, true); let png = img.to_png(); match s(p, "path") { @@ -269,6 +270,7 @@ impl Headless { let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0); + vectorcraft_render::raster_size(r, scale)?; self.renderer.render_region(&doc, r, scale, false).to_png() } "vectorcraft" => vectorcraft_format::save_file(&doc), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index f625104a..4ebca9f7 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -436,3 +436,21 @@ fn text_effect_pathfinder_transform_graph_and_wrap_tools() { call(&mut s, 15, "text_wrap", json!({"ids": [c], "release": true})); assert!(!doc_json(&mut s).to_string().contains("\"offset\":8.0")); } + +#[test] +fn oversized_screenshot_and_png_export_are_errors() { + // 16383 pt at the maximum scale (16) is 262 128 px a side: an error result, not a crashed server. + let mut s = server(); + let r = call(&mut s, 1, "run_command", json!({"command": "file.new", "params": {"width": 16383, "height": 16383}})); + assert_eq!(r["isError"], false, "{r}"); + let r = call(&mut s, 2, "screenshot", json!({"scale": 16})); + assert_eq!(r["isError"], true, "{r}"); + assert!(text_of(&r).contains("pixels"), "{r}"); + let path = tmp("huge.png"); + let r = call(&mut s, 3, "export", json!({"path": path.to_str().unwrap(), "scale": 16})); + assert_eq!(r["isError"], true, "{r}"); + assert!(!path.exists()); + // The session is still alive and a small screenshot works. + let r = call(&mut s, 4, "screenshot", json!({"scale": 0.01})); + assert_eq!(r["isError"], false, "{r}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c5f0e4e6..1fabb19f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -29,6 +29,31 @@ pub use vectorcraft_effects as effects; pub use vello_cpu; pub use width::width_outline; +/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser +/// addresses at most `u16::MAX` pixels per side (and panics at that edge). +pub const MAX_RASTER_SIDE: u32 = 32_768; +/// Largest raster an export may ask for in total (16384², 1 GiB of RGBA pixels). +pub const MAX_RASTER_PIXELS: u64 = 1 << 28; + +/// Pixel size of `region` rendered at `scale` (as [`Renderer::render_region`] rounds it), or an error +/// when it exceeds [`MAX_RASTER_SIDE`] / [`MAX_RASTER_PIXELS`]. Raster exports check this first, +/// because the renderer can only clamp (wrong picture) or fail (abort) at those sizes. +pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> { + if !(scale.is_finite() && scale > 0.0) { + return Err(format!("invalid scale {scale}")); + } + let w = (region.width() * scale).round().max(1.0); + let h = (region.height() * scale).round().max(1.0); + let max = f64::from(MAX_RASTER_SIDE); + if !(w <= max && h <= max && w * h <= MAX_RASTER_PIXELS as f64) { + return Err(format!( + "the image would be {w:.0} × {h:.0} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {} megapixels: lower the scale or resolution", + MAX_RASTER_PIXELS / 1_000_000 + )); + } + Ok((w as u32, h as u32)) +} + /// Rendering options. #[derive(Clone, Debug)] pub struct RenderOptions { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 1397f2ba..2e94d7b2 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -26,6 +26,19 @@ fn fills_red_rect() { assert_eq!(r.pixel(70, 70), [255, 255, 255, 255]); } +#[test] +fn raster_size_limits() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + assert_eq!(raster_size(r, 2.0), Ok((200, 100))); + assert_eq!(raster_size(r, 0.001), Ok((1, 1))); + assert_eq!(raster_size(Rect::new(0.0, 0.0, MAX_RASTER_SIDE as f64, 1.0), 1.0), Ok((MAX_RASTER_SIDE, 1))); + assert!(raster_size(Rect::new(0.0, 0.0, MAX_RASTER_SIDE as f64 + 1.0, 1.0), 1.0).is_err()); + assert!(raster_size(Rect::new(0.0, 0.0, 20_000.0, 20_000.0), 1.0).is_err(), "400 megapixels"); + assert!(raster_size(r, f64::NAN).is_err()); + assert!(raster_size(r, f64::INFINITY).is_err()); + assert!(raster_size(r, 0.0).is_err()); +} + #[test] fn stroke_draws_outline_only() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 15d8fdc3..b2866024 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -282,7 +282,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques let Some(st) = app.session.active() else { return err("no document") }; let doc = st.doc.clone(); let Some(r) = doc.artboards.first().map(|a| a.rect) else { return err("no artboard") }; - let img = app.canvas.renderer.render_region(&doc, r, p.get("scale").and_then(Value::as_f64).unwrap_or(1.0), true); + let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0); + if let Err(e) = vectorcraft_render::raster_size(r, scale) { + return err(e); + } + let img = app.canvas.renderer.render_region(&doc, r, scale, true); let png = img.to_png(); match s("path") { Some(path) => match app.services.write.as_mut() { diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 12fedecc..91ed761d 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -202,6 +202,7 @@ pub fn export(app: &mut VectorcraftApp, format: &str, path: Option, scal "pdf" => vectorcraft_engine::export_pdf(&doc, &vectorcraft_pdf::PdfOptions::default()).map_err(|e| e.to_string())?, "png" | "jpg" | "jpeg" | "webp" => { let r = doc.artboards.first().map(|a| a.rect).ok_or("no artboard")?; + vectorcraft_render::raster_size(r, scale)?; let img = app.canvas.renderer.render_region(&doc, r, scale, format == "jpg" || format == "jpeg"); match format { "png" => img.to_png(),