mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #9 from storybored379/pr/raster-export-size-limit
Raster export: refuse sizes the renderer can't produce instead of crashing
This commit is contained in:
8 files changed
+90
-2
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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") {
|
||||
@@ -276,6 +277,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),
|
||||
|
||||
@@ -448,3 +448,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}");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -202,6 +202,7 @@ pub fn export(app: &mut VectorcraftApp, format: &str, path: Option<String>, 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(),
|
||||
|
||||
Reference in new issue
Block a user