diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index cfec89b6a..d6c104076 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -103,6 +103,11 @@ impl PreviewMode { } } +/// A saved preset's raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite). +fn de_ppi<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(Some(f64::deserialize(d)?).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)) +} + /// Everything New Document sets: a preset (built-in, saved or recent) or `file.new`'s params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -125,6 +130,8 @@ pub struct DocSettings { pub bleed: [f64; 4], pub background: Background, pub color_mode: ColorMode, + /// 1 to 2400 ppi (a saved preset's value outside that range is read as the nearer end). + #[serde(deserialize_with = "de_ppi")] pub raster_effects_ppi: f64, pub preview_mode: PreviewMode, } diff --git a/crates/engine/src/tests_newdoc.rs b/crates/engine/src/tests_newdoc.rs index d84859b88..380accfe5 100644 --- a/crates/engine/src/tests_newdoc.rs +++ b/crates/engine/src/tests_newdoc.rs @@ -225,6 +225,17 @@ fn saved_presets_live_in_the_preferences() { assert!(s.execute("file.newPresets.save", &json!({"name": " "})).is_err()); } +#[test] +fn a_saved_preset_with_the_resolution_out_of_range_reads_in_range() { + let mut s = Session::new(); + let saved = json!({"newDocPresets": [{"name": "Zero", "rasterEffectsPpi": 0}, {"name": "Huge", "rasterEffectsPpi": 5000}]}); + s.apply_prefs(serde_json::from_value(saved).unwrap()); + let ppi: Vec> = presets(&mut s, "Saved").iter().map(|p| p["rasterEffectsPpi"].as_f64()).collect(); + assert_eq!(ppi, [Some(1.0), Some(2400.0)]); + s.execute("file.new", &json!({"preset": "Zero"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 1.0); +} + #[test] fn rearrange_keeps_its_grid() { let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3})); diff --git a/crates/engine/src/tests_rastersettings.rs b/crates/engine/src/tests_rastersettings.rs index b0d20753b..616a6bf55 100644 --- a/crates/engine/src/tests_rastersettings.rs +++ b/crates/engine/src/tests_rastersettings.rs @@ -213,3 +213,48 @@ fn raster_settings_round_trip_and_old_files_load() { let back = vectorcraft_format::load(&bytes).unwrap(); assert_eq!((back.raster_effects_ppi, back.raster_effects), (72.0, RasterEffectsSettings::default())); } + +/// Save the active document with the raster effects resolution `ppi` and open the file. +fn reopen_with_ppi(s: &mut Session, ppi: f64) { + let mut d = (*s.doc().unwrap().doc).clone(); + d.raster_effects_ppi = ppi; + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d)); + s.execute("document.open", &json!({"name": "ppi.vectorcraft", "dataBase64": data})).unwrap(); +} + +#[test] +fn a_file_with_the_resolution_out_of_range_opens_with_it_in_range() { + for (saved, opened, pixels) in [(0.0, 1.0, 1), (-5.0, 1.0, 1), (5000.0, 2400.0, 334), (f64::MAX, 2400.0, 334)] { + let mut s = session(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap()); + s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap(); + reopen_with_ppi(&mut s, saved); + assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, opened, "saved as {saved}"); + assert_eq!(s.execute(C, &json!({})).unwrap()["resolution"], opened); + assert_eq!(s.execute("document.info", &json!({})).unwrap()["document"]["rasterEffectsPpi"], opened); + // Rasterize without a ppi param renders the 10 pt square at it. + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let out = s.execute("object.rasterize", &json!({})).unwrap(); + assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(pixels), Some(pixels)), "saved as {saved}"); + } +} + +#[test] +fn a_gradient_exports_at_the_resolution_the_file_opens_with() { + for saved in [0.0, -5.0] { + let mut s = session(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 720, "height": 720})).unwrap()); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [r.0]})).unwrap(); + reopen_with_ppi(&mut s, saved); + // EMF writes the gradient as an image at the raster effects resolution: 720 pt at 1 ppi. + let region = vectorcraft_geom::Rect::new(0.0, 0.0, 720.0, 720.0); + let out = vectorcraft_metafile::export(&s.doc().unwrap().doc, region, vectorcraft_metafile::Kind::Emf).unwrap(); + let mut sizes = vec![]; + vectorcraft_metafile::import(&out.bytes).unwrap().document.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + sizes.push((im.width, im.height)); + } + }); + assert_eq!(sizes, [(10, 10)], "saved as {saved}"); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d37b07685..713984b73 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -555,6 +555,8 @@ pub fn load_file(bytes: &[u8]) -> Result { 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); + // Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite). + doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0); let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect(); Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) } diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index 7ec1806ff..66b08b264 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -225,7 +225,7 @@ fn approx_equal_documents_compare_equal() { fn bug_f64_bit_exact_roundtrip() { check_native_roundtrip_exact(&rich_doc()).unwrap(); let mut d = Document::new(126.86291501015239, 214.69463130731182); - d.raster_effects_ppi = 0.1 + 0.2; + d.raster_effects_ppi = (0.1 + 0.2) * 1000.0; check_native_roundtrip_exact(&d).unwrap(); } diff --git a/docs/mcp.md b/docs/mcp.md index bee14e10e..465a4f704 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2220,7 +2220,8 @@ art). Pass any of these keys to change them in one undo step; `document.setUnits `file.new` takes everything New Document sets: `preset` (a name from `file.newPresets`), `name`, `width`/`height` (points, or lengths such as `"210 mm"`), `units`, `orientation`, `artboards` with `artboardLayout {layout, columns, -spacing, rightToLeft}`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi` and `previewMode`. +spacing, rightToLeft}`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi` (1 to 2,400; a saved preset's +value outside that range is read as the nearer end) and `previewMode`. `file.newPresets {category?}` lists the categories and presets (Recent: the last sizes used; Saved: the user's presets, kept in the preferences by `file.newPresets.save` and removed by `file.newPresets.delete`). A listed preset can be passed straight back to `file.new`. Print presets and sizes without `units` start in `unitsGeneral`; screen presets @@ -2342,7 +2343,8 @@ written unless the whole batch succeeds puts the saves and exports last, or runs ## Document Raster Effects Settings `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` +export or Expand Appearance renders them: `resolution` (1 to 2,400 ppi, also New Document's `rasterEffectsPpi`; a +file's value outside that range opens as the nearer end), `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` (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