diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index fc3170464..3bab565d0 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -170,6 +170,8 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 { /// fills' and strokes' raster effects included). With `knockout`, the coverage of that art is knocked out /// of the image, which then only holds what the effects add around it (a shadow to go under the /// vector object). The document's raster effects settings add room around it and finish its pixels. +/// `None` when it paints nothing, when the area it covers overflows `f64` or the scale is not +/// positive, or when its PNG can't be encoded. pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option { // What it paints (its members' effects included), and how far its fills' and strokes' raster // effects reach beyond that. @@ -177,8 +179,16 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N let look = out.raster_effects.clone(); let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around; let b = b.inflate(reach, reach); + // An area too large for f64 gives a zero scale and an image transform that is not finite. + let area = b.width() * b.height(); + if !area.is_finite() { + return None; + } // Whole pixels, and no larger than 64 Mpx. - let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt()); + let scale = scale.min((64.0e6 / area.max(1.0)).sqrt()); + if !(scale.is_finite() && scale > 0.0) { + return None; + } let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale); // Anti-alias off: hard edges (the effects themselves stay smooth). let anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None }; diff --git a/crates/engine/src/tests_rastersettings.rs b/crates/engine/src/tests_rastersettings.rs index 577c801cd..b0d20753b 100644 --- a/crates/engine/src/tests_rastersettings.rs +++ b/crates/engine/src/tests_rastersettings.rs @@ -2,6 +2,7 @@ //! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults. use serde_json::{Value, json}; +use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings}; use super::*; @@ -98,6 +99,60 @@ fn add_around_grows_the_effect_image() { assert_eq!((after.width() - before.width(), after.height() - before.height()), (72, 72)); } +/// The transforms of the images in `doc` flattened for output. +fn image_transforms(doc: &Document) -> Vec<[f64; 6]> { + let mut xfs = vec![]; + crate::flatten_raster_effects(doc).unwrap().walk(|n| { + if let NodeKind::Image(im) = &n.kind { + xfs.push(im.xf.as_coeffs()); + } + }); + xfs +} + +#[test] +fn a_file_with_add_around_out_of_range_opens_with_it_in_range() { + let mut s = session(); + shadowed(&mut s); + let mut d = (*s.doc().unwrap().doc).clone(); + d.raster_effects.add_around = 1e300; + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap(); + assert_eq!(back.raster_effects.add_around, MAX_ADD_AROUND); + let xfs = image_transforms(&back); + assert!(!xfs.is_empty() && xfs.iter().flatten().all(|c| c.is_finite()), "{xfs:?}"); + // 1,000 pt on every side at 72 ppi. + assert_eq!(effect_image(&back).width() - effect_image(&s.doc().unwrap().doc).width(), 2000); + d.raster_effects.add_around = -5.0; + assert_eq!(vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap().raster_effects.add_around, 0.0); +} + +#[test] +fn art_too_large_for_f64_gets_no_effect_image() { + let mut s = session(); + let r = shadowed(&mut s); + // Settings out of range in a document made without opening a file. + let mut d = (*s.doc().unwrap().doc).clone(); + d.raster_effects.add_around = 1e300; + assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new()); + let mut d = (*s.doc().unwrap().doc).clone(); + let NodeKind::Path { path, live, .. } = &mut d.node_mut(r).unwrap().kind else { panic!("a path") }; + (*path, *live) = (vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(-1e300, -1e300, 1e300, 1e300)), None); + assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new()); + // Expand Appearance of it by id, with nothing selected, puts no image into the document (the + // rectangle at its own size gets one). + let normal = (*s.doc().unwrap().doc).clone(); + let mut expanded_images = |d: Document| { + let st = s.doc_mut().unwrap(); + (st.doc, st.selection) = (std::sync::Arc::new(d), Default::default()); + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let mut images = 0; + s.doc().unwrap().doc.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_)))); + images + }; + assert_eq!(expanded_images(normal), 1); + assert_eq!(expanded_images(d), 0); +} + #[test] fn the_color_model_and_anti_alias_reach_the_pixels() { let mut s = session(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4811b8ca1..d37b07685 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -552,6 +552,7 @@ pub fn load_file(bytes: &[u8]) -> Result { // Untrusted: capped, named and pointing at objects the file has. doc.tidy_saved_selections(); doc.tidy_graph_designs(); + doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND); let mut depth_notes = vec![]; doc.limit_depth_noting(&mut depth_notes); let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect(); diff --git a/docs/mcp.md b/docs/mcp.md index 621301a76..8ba2017d5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2344,8 +2344,9 @@ written unless the whole batch succeeds puts the saves and exports last, or runs `document.rasterEffectsSettings` holds how raster effects (shadows, glows, blurs, feathers) become images when PDF export or Expand Appearance renders them: `resolution` (ppi, also New Document's `rasterEffectsPpi`), `colorModel` (the document's `rgb`/`cmyk`, `grayscale` or `bitmap`), `background` (`white` makes the images opaque), `antiAlias` -(off: hard edges), `clippingMask` (the white stays under the art only), `addAround` (points of room around the art) -and `preserveSpotColors` (stored). They are also the defaults of `object.rasterize`, whose params override them (its +(off: hard edges), `clippingMask` (the white stays under the art only), `addAround` (0 to 1,000 points of room +around the art; a file's value outside that range opens as the nearer end) and `preserveSpotColors` (stored). They are +also the defaults of `object.rasterize`, whose params override them (its `clippingMask` puts the image in a clip group with the art's outline). No params reports them; any of them changes them in one undo step. PDF export renders the raster effects of every kind of object this way (paths, groups, layers, type, images, symbol instances, live objects, and those in opacity masks, symbols and pattern tiles): shadows and outer glows