From d3b18465f94574e674e48202c6cade9674a1853d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:45:32 -0800 Subject: [PATCH] New Document: file.new and artboard.new take the same largest artboard side file.new refused a side over 163,830 pt, while artboard.new took larger artboards. Both now take each side more than 0 and at most 3,999,856 pt: the 4,000,000 pt the canvas reaches from the origin, less a 72 pt bleed on both sides. A side outside that range is refused with an error that names the limit in points and, when the document's units are not points, in those units, rounded down so the value shown is accepted. artboard.new now refuses a supplied width or height of 0 or less instead of making a 1 pt side, and refuses a size taken from a larger artboard. Saved and Recent presets are read with sides up to the new limit. Follows #6643. --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/layer.rs | 9 +++++- crates/engine/src/cmd/newdoc.rs | 29 ++++++++++++++++--- crates/engine/src/tests/newdoc.rs | 47 +++++++++++++++++++++++++++++-- crates/ui-egui/src/i18n/hi.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- docs/mcp.md | 6 +++- 7 files changed, 86 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4f3dc3403a..b7affffa81 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 593d325460..43d9a8e05f 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", + "{x?, y?, width?, height? (each more than 0 and at most 3,999,856 pt, as file.new takes them), name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", has_doc, artboard_new ), @@ -926,6 +926,9 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { let guide_label = artboard_guide_label(p, "artboard.new")?.flatten(); check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?; + if ["width", "height"].iter().filter_map(|k| p.get(*k).and_then(Value::as_f64)).any(|side| !super::newdoc::side_in_range(side)) { + return Err(super::newdoc::side_error("artboard.new", d0.units)); + } let r = match artboard_side(p, "artboard.new")? { Some(side) => { if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { @@ -941,6 +944,10 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")), None => rect_from(p, def), }; + // A size taken from another artboard (`side`, or width or height left out) keeps to the limit too. + if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) { + return Err(super::newdoc::side_error("artboard.new", d0.units)); + } let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty()); let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false)); let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false); diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index bd15d748ca..58c7974460 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -241,8 +241,8 @@ impl DocSettings { if let Some(h) = length("height", self.units)? { self.height = h; } - if !(self.width > 0.0 && self.height > 0.0) || self.width > MAX_SIDE || self.height > MAX_SIDE { - return Err(bad(cmd, "width/height must be positive and within the canvas")); + if !side_in_range(self.width) || !side_in_range(self.height) { + return Err(side_error(cmd, self.units)); } match p.get("orientation").and_then(Value::as_str) { None => {} @@ -359,8 +359,29 @@ impl DocSettings { } } -/// Largest artboard side `file.new` accepts, points. -const MAX_SIDE: f64 = 16383.0 * 10.0; +/// Largest artboard side `file.new` and `artboard.new` accept, points: the span of the canvas +/// ([`crate::MAX_COORD`] from the origin) less the largest bleed on both sides, 3,999,856 pt. +pub(crate) const MAX_SIDE: f64 = crate::MAX_COORD - 2.0 * vectorcraft_doc::setup::MAX_BLEED; + +/// Is `side` an artboard side `file.new` and `artboard.new` accept: more than 0 and at most [`MAX_SIDE`]? +pub(crate) fn side_in_range(side: f64) -> bool { + side > 0.0 && side <= MAX_SIDE +} + +/// The error of an artboard side outside [`side_in_range`]: the limit in points, and in `units` as +/// well when they aren't points, rounded down to the decimals [`Unit::number`] shows so the value +/// shown is one the commands take. +pub(crate) fn side_error(cmd: &str, units: Unit) -> crate::EngineError { + let limit = match units { + Unit::Points => format!("{MAX_SIDE} pt"), + u => { + let step = if matches!(u, Unit::Pixels | Unit::Picas | Unit::Q | Unit::Ha) { 1e3 } else { 1e4 }; + let shown = (u.from_pt(MAX_SIDE) * step).floor() / step; + format!("{MAX_SIDE} pt ({} {})", u.number(u.to_pt(shown)), u.suffix()) + } + }; + bad(cmd, format!("width and height must be more than 0 and at most {limit}")) +} /// Largest artboard spacing, points. const MAX_SPACING: f64 = 1.0e5; diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 4bc58e5f6b..0b573a96f3 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -233,7 +233,7 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { 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]), + (3_999_856.0, 200000.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]), ] ); @@ -242,7 +242,7 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { 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])); + assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 3_999_856.0, 200000.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(); @@ -250,6 +250,49 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { assert_eq!(boards(&s).iter().map(|r| r.x0).collect::>(), [0.0, 100.0, 200.0], "side by side, not overlapping"); } +/// `file.new` and `artboard.new` take the same largest artboard side (the canvas less the largest +/// bleed on both sides) and refuse a side just past it, or of 0 or less, with an error that names +/// the limit in points and, when the document's units aren't points, in those units too. +#[test] +fn file_new_and_artboard_new_take_the_same_largest_side() { + let max = 3_999_856.0; + let in_meters = "at most 3999856 pt (1411.0603 m)"; + let s = new_doc(json!({"width": max, "height": 170078})); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, max, 170078.0)]); + let mut s = Session::new(); + let err = s.execute("file.new", &json!({"width": max + 1.0, "height": 100})).unwrap_err().to_string(); + assert!(err.contains("width and height must be more than 0 and at most 3999856 pt") && !err.contains('('), "{err}"); + let err = s.execute("file.new", &json!({"width": 100, "height": "1412 m", "units": "Meters"})).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{err}"); + // The value in other units is rounded down, so it is one file.new takes. + let err = s.execute("file.new", &json!({"width": 100, "height": "56000 in", "units": "Inches"})).unwrap_err().to_string(); + assert!(err.contains("at most 3999856 pt (55553.5555 in)"), "{err}"); + assert!(s.execute("file.new", &json!({"width": 0, "height": 100})).is_err()); + assert!(s.doc().is_err(), "no document was made"); + s.execute("file.new", &json!({"width": "55553.5555 in", "height": 100, "units": "Inches"})).unwrap(); + + let mut s = new_doc(json!({"units": "Meters"})); + s.execute("artboard.new", &json!({"x": -2_000_000, "y": 0, "width": max, "height": 100})).unwrap(); + assert_eq!(boards(&s)[1], Rect::new(-2_000_000.0, 0.0, max - 2_000_000.0, 100.0)); + for p in [ + json!({"x": -2_000_000, "y": 0, "width": max + 1.0, "height": 100}), + json!({"x": 0, "y": 2000, "width": 100, "height": max + 1.0}), + json!({"width": 0}), + json!({"height": -1}), + ] { + let err = s.execute("artboard.new", &p).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{p}: {err}"); + } + assert_eq!(boards(&s).len(), 2, "the refused artboards were not added"); + // A size taken from a wider artboard is refused too. + s.execute("artboard.setProps", &json!({"index": 1, "x": -2_000_000, "y": 0, "width": max + 1.0, "height": 100})).unwrap(); + for p in [json!({}), json!({"x": 0, "y": 5000, "height": 100}), json!({"side": "bottom", "beside": 1})] { + let err = s.execute("artboard.new", &p).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{p}: {err}"); + } + assert_eq!(boards(&s).len(), 2); +} + #[test] fn saved_presets_live_in_the_preferences() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 8f66a1b354..098d1917d6 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8116,7 +8116,7 @@ antiAlias Sharp शार्प @msg width must be positive width: चौड़ाई सकारात्मक होना चाहिए @msg width out of range सीमा से बाहर चौड़ाई @msg width/height must be > 0 चौड़ाई / ऊंचाई होना चाहिए > 0 -@msg width/height must be positive and within the canvas चौड़ाई / ऊंचाई सकारात्मक होना चाहिए और कैनवास के भीतर होना चाहिए +@msg width and height must be more than 0 and at most {limit} चौड़ाई और ऊंचाई 0 से अधिक और अधिकतम {limit} होनी चाहिए @msg workspace name can't be empty कार्यक्षेत्र का नाम खाली नहीं हो सकता है @msg x and y must be finite x और y को परिमित होना चाहिए @msg x and y must be finite numbers x और y होना चाहिए परिमित संख्या diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 48d1cced76..cfa885c8e4 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8366,7 +8366,7 @@ digits Hindi Hindi @msg width must be positive width måste vara positiv @msg width out of range bredden ligger utanför tillåtet omfång @msg width/height must be > 0 bredd/höjd måste vara > 0 -@msg width/height must be positive and within the canvas bredd/höjd måste vara positiva och inom arbetsytan +@msg width and height must be more than 0 and at most {limit} bredd och höjd måste vara större än 0 och högst {limit} @msg x and y must be finite numbers x och y måste vara ändliga tal @msg x and y must be given together x och y måste anges tillsammans @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone behöver ett ankare: docka bredvid en panel med anchor eller i en ny dockkolumn med side (Start eller End) diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..b5904d0367 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5402,7 +5402,11 @@ value outside that range is read as the nearer end), `previewMode` and `alignNew `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. A saved or Recent preset whose size (up to 163,830 pt), `artboards` (1 to +(mobile, web, video, social) in Pixels. `file.new` and `artboard.new` take the same artboard sizes: each side more than 0 +and at most 3,999,856 pt (the 4,000,000 pt the canvas reaches from the origin, less a 72 pt bleed on both sides). A side outside that range is +refused with `width and height must be more than 0 and at most 3999856 pt`, followed by the limit in the document's +units in parentheses when they are not points, such as `(1411.0603 m)`, and no document or artboard is made. +A saved or Recent preset whose size (up to 3,999,856 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. The desktop app's `file.newDialog` (Cmd/Ctrl+N) opens the New Document dialog on the first Recent entry, or on the