diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 5a8934383..441a64dec 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -192,6 +192,16 @@ impl Background { /// Largest bleed on any side, in points (1 inch). pub const MAX_BLEED: f64 = 72.0; +/// Largest superscript or subscript size, and the farthest its baseline moves either way, in +/// percent of the font size. +pub const MAX_SCRIPT: f64 = 500.0; +/// Largest synthesized small capitals size, in percent of the font size. +pub const MAX_SMALL_CAPS: f64 = 200.0; + +/// Returns `bleed` with each side kept to 0–[`MAX_BLEED`] points; a side that isn't finite becomes 0. +pub fn bleed_in_range(bleed: [f64; 4]) -> [f64; 4] { + bleed.map(|b| if b.is_finite() { b.clamp(0.0, MAX_BLEED) } else { 0.0 }) +} /// File → Document Setup. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -274,6 +284,18 @@ impl DocSetup { pub fn flattener(&self) -> &str { self.flattener_preset.as_deref().unwrap_or(DEFAULT_FLATTENER) } + /// Moves the bleed and the type proportions into the ranges Document Setup takes, each to the + /// nearer end: the bleed by [`bleed_in_range`], superscript and subscript sizes to + /// 1–[`MAX_SCRIPT`] and positions to ±[`MAX_SCRIPT`], the small caps size to + /// 1–[`MAX_SMALL_CAPS`]. + pub fn keep_in_range(&mut self) { + self.bleed = bleed_in_range(self.bleed); + for m in [&mut self.superscript, &mut self.subscript] { + m.size = m.size.clamp(1.0, MAX_SCRIPT); + m.position = m.position.clamp(-MAX_SCRIPT, MAX_SCRIPT); + } + self.small_caps_size = self.small_caps_size.clamp(1.0, MAX_SMALL_CAPS); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/docsetup.rs b/crates/engine/src/cmd/docsetup.rs index c8fcea00b..4446bc0ad 100644 --- a/crates/engine/src/cmd/docsetup.rs +++ b/crates/engine/src/cmd/docsetup.rs @@ -121,7 +121,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value "typographersQuotes" => s.typographers_quotes = flag(k, v)?, "superscript" => s.superscript = script_param(k, v, s.superscript)?, "subscript" => s.subscript = script_param(k, v, s.subscript)?, - "smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, 200.0)?, + "smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, vectorcraft_doc::setup::MAX_SMALL_CAPS)?, "exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?, "backgroundContents" => s.background = background_param(v, C)?, // Read-only values `document.setup` reports. @@ -223,8 +223,8 @@ fn script_param(k: &str, v: &Value, mut m: ScriptMetrics) -> Result m.size = percent(&format!("{k}.size"), x, 1.0, 500.0)?, - "position" => m.position = percent(&format!("{k}.position"), x, -500.0, 500.0)?, + "size" => m.size = percent(&format!("{k}.size"), x, 1.0, vectorcraft_doc::setup::MAX_SCRIPT)?, + "position" => m.position = percent(&format!("{k}.position"), x, -vectorcraft_doc::setup::MAX_SCRIPT, vectorcraft_doc::setup::MAX_SCRIPT)?, _ => return Err(bad(C, format!("unknown {k} key `{key}` (size, position)"))), } } diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index d6c104076..a662f6cfd 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -114,19 +114,29 @@ fn de_ppi<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result &'static str { } } +/// A saved or Recent preset's artboard side: up to [`MAX_SIDE`] points, and 1 when it is 0 or less. +fn de_side<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + let v = f64::deserialize(d)?; + Ok(if v > 0.0 { v.min(MAX_SIDE) } else { 1.0 }) +} + +/// A saved or Recent preset's artboard count, rounded and kept to 1 to [`MAX_ARTBOARDS`] as +/// `file.new` reads its `artboards` param. +fn de_artboards<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(f64::deserialize(d)?.round().clamp(1.0, MAX_ARTBOARDS as f64) as u32) +} + +/// A saved or Recent preset's artboard spacing, kept to 0 to [`MAX_SPACING`] points. +fn de_spacing<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(f64::deserialize(d)?.clamp(0.0, MAX_SPACING)) +} + +/// A saved or Recent preset's bleed, each side kept to 0 to `MAX_BLEED` points. +fn de_bleed<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<[f64; 4], D::Error> { + Ok(vectorcraft_doc::setup::bleed_in_range(<[f64; 4]>::deserialize(d)?)) +} + /// Top-left corners for artboards of `sizes` from `origin`: `columns` per row, `spacing` apart, /// each column as wide and each row as tall as its largest artboard; `by_column` fills columns /// top to bottom first, `rtl` puts the first column at the right. diff --git a/crates/engine/src/tests_docsetup.rs b/crates/engine/src/tests_docsetup.rs index f937bf402..64d279275 100644 --- a/crates/engine/src/tests_docsetup.rs +++ b/crates/engine/src/tests_docsetup.rs @@ -213,6 +213,28 @@ fn setup_survives_save_and_load() { assert_eq!(back.setup.grid_colors_name(), "Custom"); } +/// A file whose bleed, superscript, subscript or small caps size lies outside the range +/// `document.setup` takes opens with it at the nearer end of that range, and the setup it reports +/// can be fed back. +#[test] +fn a_file_with_setup_values_out_of_range_opens_with_them_in_range() { + let mut s = session(); + let mut d = (*s.doc().unwrap().doc).clone(); + d.setup.bleed = [-5.0, 100.0, f64::MAX, 9.0]; + d.setup.superscript = ScriptMetrics { size: 0.0, position: f64::MAX }; + d.setup.subscript = ScriptMetrics { size: f64::MAX, position: -1000.0 }; + d.setup.small_caps_size = -5.0; + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d)); + s.execute("document.open", &json!({"name": "setup.vectorcraft", "dataBase64": data})).unwrap(); + let st = setup(&s); + assert_eq!(st.bleed, [0.0, 72.0, 72.0, 9.0]); + assert_eq!(st.superscript, ScriptMetrics { size: 1.0, position: 500.0 }); + assert_eq!(st.subscript, ScriptMetrics { size: 500.0, position: -500.0 }); + assert_eq!(st.small_caps_size, 1.0); + let q = s.execute("document.setup", &json!({})).unwrap(); + assert_eq!(s.execute("document.setup", &q).unwrap(), q); +} + #[test] fn svg_text_export_follows_the_export_text_setting() { let mut s = session(); diff --git a/crates/engine/src/tests_newdoc.rs b/crates/engine/src/tests_newdoc.rs index 380accfe5..80978a2d3 100644 --- a/crates/engine/src/tests_newdoc.rs +++ b/crates/engine/src/tests_newdoc.rs @@ -194,6 +194,48 @@ fn recent_remembers_the_last_sizes() { assert_eq!(presets(&mut s, "Recent").len(), 12); } +/// A saved preset or a Recent entry whose size, artboards, spacing or bleed the preferences hold +/// outside the range `file.new` takes reads as the nearer end of that range (a size of 0 or less +/// reads as 1 pt), so `file.new` makes a document from it. +#[test] +fn a_saved_preset_with_values_out_of_range_reads_in_range() { + let mut s = Session::new(); + let prefs = json!({ + "newDocPresets": [ + {"name": "Low", "width": -5, "height": 0, "artboards": 0, "spacing": -50, "bleed": [-5, -1, 0, 9]}, + {"name": "High", "width": f64::MAX, "height": 200000, "spacing": f64::MAX, "bleed": [100, f64::MAX, 72, 0]}, + {"name": "Many", "width": 10, "height": 10, "artboards": 5000, "spacing": 0}, + ], + "recentNewDocs": [{"name": "Custom", "width": 100, "height": 50, "artboards": 3, "spacing": -50}], + }); + s.apply_prefs(serde_json::from_value(prefs).unwrap()); + let read = |p: &Value| { + let n = |v: &Value| v.as_f64().unwrap(); + let bleed: Vec = p["bleed"].as_array().unwrap().iter().map(n).collect(); + (n(&p["width"]), n(&p["height"]), n(&p["artboards"]), n(&p["artboardLayout"]["spacing"]), bleed) + }; + let saved: Vec<_> = presets(&mut s, "Saved").iter().map(read).collect(); + assert_eq!( + saved, + [ + (1.0, 1.0, 1.0, 0.0, vec![0.0, 0.0, 0.0, 9.0]), + (163830.0, 163830.0, 1.0, 1.0e5, vec![72.0, 72.0, 72.0, 0.0]), + (10.0, 10.0, 1000.0, 0.0, vec![0.0; 4]), + ] + ); + assert_eq!(read(&presets(&mut s, "Recent")[0]), (100.0, 50.0, 3.0, 0.0, vec![0.0; 4])); + // file.new makes each one with those values. + s.execute("file.new", &json!({"preset": "Low"})).unwrap(); + assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 1.0, 1.0)], [0.0, 0.0, 0.0, 9.0])); + s.execute("file.new", &json!({"preset": "High"})).unwrap(); + assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 163830.0, 163830.0)], [72.0, 72.0, 72.0, 0.0])); + s.execute("file.new", &json!({"preset": "Many"})).unwrap(); + assert_eq!(boards(&s).len(), 1000); + let custom = presets(&mut s, "Recent").into_iter().find(|p| p["name"] == "Custom").unwrap(); + s.execute("file.new", &custom).unwrap(); + assert_eq!(boards(&s).iter().map(|r| r.x0).collect::>(), [0.0, 100.0, 200.0], "side by side, not overlapping"); +} + #[test] fn saved_presets_live_in_the_preferences() { let mut s = Session::new(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 713984b73..02b685252 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -549,6 +549,8 @@ pub fn load_file(bytes: &[u8]) -> Result { // Saved before v3: the assigned profiles move out of `unknown`. doc.migrate_color_profiles(); doc.fix_next_id(); + // Untrusted: the bleed and type proportions, within the ranges Document Setup takes. + doc.setup.keep_in_range(); // Untrusted: capped, named and pointing at objects the file has. doc.tidy_saved_selections(); doc.tidy_graph_designs(); diff --git a/docs/mcp.md b/docs/mcp.md index 83c94d2c5..80ca954d7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2366,6 +2366,8 @@ paper colour), the flattener preset and Discard White Overprint (Overprint Previ it is on), the type options (language and its quotes, Use Typographer's Quotes for typed quotes, superscript, subscript and small caps proportions, SVG text export) and the background contents (white: raster exports are white behind the art). Pass any of these keys to change them in one undo step; `document.setUnits` stays as an alias for `units`. +A file whose bleed (0 to 72 pt a side), superscript and subscript sizes (1 to 500%) and positions (-500 to 500%) or +small caps size (1 to 200%) lies outside those ranges opens with the nearer end of each range. ```json {"name":"run_command","arguments":{"command":"document.setup","params":{"gridColors":"Blue","gridSize":"large","language":"German","bleed":9}}} @@ -2380,7 +2382,9 @@ 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 -(mobile, web, video, social) in Pixels. +(mobile, web, video, social) in Pixels. A saved or Recent preset whose size (up to 163,830 pt), `artboards` (1 to +1,000), spacing (0 to 100,000 pt) or bleed (0 to 72 pt a side) lies outside those ranges is read with the nearer end of +each range, and a size of 0 or less is read as 1 pt. ```json {"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}}