Artboards: Fit to Artwork Bounds fits each selected artboard to the art on it

Fit to Artwork Bounds resized the active artboard to the bounds of all
the art in the document, wherever that art was. With art on two
artboards, fitting one of them stretched it over both.

artboard.fitToArt now takes `indices` and fits each artboard to the
art on it, in one undo step. An object counts for every artboard its
drawn bounds overlap, and the artboard is fitted to all of its bounds,
also where it reaches past the artboard's edge. Art flush with an edge
from outside does not count. An artboard with no art on it keeps its
size and place, and the command fails with "no artwork" when none of
its artboards has art. Object › Artboards and the Artboards panel menu
pass the selected artboards (the Artboard tool's, else the active one),
and a recorded action leaves them out. Fit to Selected Art still
resizes the active artboard.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 14:58:09 +07:00
committed by bflatastic
parent 467c47b719
commit cc3299dc0f
9 changed files with 149 additions and 32 deletions
+33 -7
View File
@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fit to Artwork Bounds",
["Object", "Artboards"],
None,
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the art that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count)",
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art stays as it is, and the command fails when no artboard it names has art",
has_doc,
artboard_fit_art
),
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fit to Selected Art",
["Object", "Artboards"],
None,
"{index?: n (0-based; the app passes the active artboard; default: the first)}",
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes)",
has_selection,
artboard_fit_sel
),
@@ -898,12 +898,38 @@ fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
xf.is_finite().then_some(xf)
}
/// The artboard fits the art that exports draw: hidden objects and layers, guides and template
/// layers don't count.
/// Each artboard of `index` or `indices` (default: the first) fits the art on it that exports
/// draw ([`vectorcraft_render::encode::art_bounds_on`]): hidden objects and layers, guides and
/// template layers don't count. An artboard with no art on it stays as it is, and the command
/// fails when none has art. The fits are one undo step named Artboard Options.
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = vectorcraft_render::encode::art_bounds(&s.doc()?.doc).ok_or_else(|| EngineError::Other("no artwork".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
let d = &s.doc()?.doc;
let indices =
if p.get("index").is_none() && p.get("indices").is_none() { vec![0] } else { super::panelcmds::artboard_indices(d, p, "artboard.fitToArt")? };
let mut fits = vec![];
for i in indices {
let was = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
if let Some(b) = vectorcraft_render::encode::art_bounds_on(d, was) {
// At least 1 pt a side, as `rect_from` makes Artboard Options' sizes.
fits.push((i, was, Rect::new(b.x0, b.y0, b.x0 + b.width().max(1.0), b.y0 + b.height().max(1.0))));
}
}
if fits.is_empty() {
return Err(EngineError::Other("no artwork".into()));
}
s.edit("Artboard Options", |d, _| {
for &(i, was, now) in &fits {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = now;
let id = a.id;
// Moved and not resized, it takes its guides along, as in Artboard Options.
if (now.width() - was.width()).abs() <= 1e-6 && (now.height() - was.height()).abs() <= 1e-6 {
d.move_artboard_guides(id, now.origin() - was.origin());
}
}
Ok(())
})?;
ok()
}
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
+7 -5
View File
@@ -563,16 +563,17 @@ pub const ON_ACTIVE_ARTBOARD: [&str; 7] = [
"select.allOnArtboard",
];
/// The commands that resize the active artboard, which the app passes as their `index`: Fit to
/// Artwork Bounds and Fit to Selected Art (Object › Artboards). A `command.batch` given `artboard`
/// passes it on, as `index`, to each of their steps that names none.
/// The commands that resize the artboards the app passes (Object › Artboards): the app passes the
/// selected artboards to Fit to Artwork Bounds as its `indices`, and the active artboard to Fit to
/// Selected Art as its `index`. A `command.batch` given `artboard` passes it on, as `index`, to
/// each of their steps that names none.
pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitToSelection"];
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
/// (dates from the clock: `cmd::clock_date`; the active artboard the app passes to the
/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves
/// them out ([`Session::journal_for_action`]).
const REPLAY_ONLY: [(&str, &str); 13] = [
const REPLAY_ONLY: [(&str, &str); 14] = [
("file.new", "created"),
("document.save", "modified"),
("file.saveAs", "modified"),
@@ -583,6 +584,7 @@ const REPLAY_ONLY: [(&str, &str); 13] = [
("object.distribute", "artboard"),
("object.distributeSpacing", "artboard"),
("artboard.fitToArt", "index"),
("artboard.fitToArt", "indices"),
("artboard.fitToSelection", "index"),
("select.allOnArtboard", "artboard"),
("command.batch", "artboard"),
+10 -6
View File
@@ -130,16 +130,17 @@ fn a_batch_passes_its_artboard_to_each_step_when_it_runs() {
/// A batch passes its `artboard` to each Fit to Artwork Bounds and Fit to Selected Art step that
/// names no `index`, as their `index` (past the document's last artboard, the last one), and a
/// step's own `index` is kept. A recorded action leaves out the `index` the app passes them, so
/// the action resizes the artboard active when it plays.
/// step's own `index` is kept. A recorded action leaves out the `index` or `indices` the app
/// passes them, so the action resizes the artboard active when it plays.
#[test]
fn fit_steps_take_the_batchs_artboard_and_actions_leave_out_their_index() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 30})).unwrap();
// Across the three artboards, so it is on each.
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 610, "height": 30})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let (was, art) = (boards(&s), vectorcraft_geom::Rect::new(10.0, 10.0, 30.0, 40.0));
let (was, art) = (boards(&s), vectorcraft_geom::Rect::new(10.0, 10.0, 620.0, 40.0));
let step = |command: &str, params: Value| json!({"command": command, "params": params});
s.execute("command.batch", &json!({"artboard": 9, "commands": [step("artboard.fitToArt", json!({}))]})).unwrap();
assert_eq!(boards(&s), [was[0], was[1], art], "artboard 3, the last one, for a batch's artboard past it");
@@ -149,10 +150,13 @@ fn fit_steps_take_the_batchs_artboard_and_actions_leave_out_their_index() {
assert_eq!(boards(&s)[0], art, "an index the step names is kept");
let start = s.journal.len();
s.execute("artboard.fitToArt", &json!({"index": 1})).unwrap();
s.execute("artboard.fitToArt", &json!({"indices": [1, 2]})).unwrap();
s.execute("artboard.fitToSelection", &json!({"index": 1})).unwrap();
assert!(s.journal.since(start).iter().all(|(_, p)| p["index"] == json!(1)), "the journal keeps it: {:?}", s.journal.since(start));
let kept: Vec<Value> = s.journal.since(start).iter().map(|(_, p)| p.clone()).collect();
assert_eq!(kept, [json!({"index": 1}), json!({"indices": [1, 2]}), json!({"index": 1})], "the journal keeps them");
let action = s.journal_for_action(start);
assert_eq!(action, [("artboard.fitToArt".to_string(), json!({})), ("artboard.fitToSelection".to_string(), json!({}))]);
let fit = |id: &str| (id.to_string(), json!({}));
assert_eq!(action, [fit("artboard.fitToArt"), fit("artboard.fitToArt"), fit("artboard.fitToSelection")]);
}
#[test]
+31
View File
@@ -705,6 +705,37 @@ fn convert_to_artboards_and_rearrange() {
assert_eq!(bounds(&s, b), Rect::new(50.0, 675.0, 60.0, 685.0));
}
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
#[test]
fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
s.execute("artboard.new", &json!({"x": 0, "y": 1000, "width": 300, "height": 300})).unwrap();
rect(&mut s, 100.0, 100.0, 50.0, 50.0);
rect(&mut s, 1100.0, 50.0, 100.0, 100.0);
// Across the gap between artboards 1 and 2.
rect(&mut s, 700.0, 250.0, 350.0, 30.0);
// Flush with artboard 1's right edge, and on no artboard.
rect(&mut s, 800.0, 500.0, 100.0, 50.0);
rect(&mut s, 3000.0, 3000.0, 10.0, 10.0);
s.execute("select.all", &json!({})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let was = boards(&s);
let n = undo_len(&s);
s.execute("artboard.fitToArt", &json!({"indices": [0, 1, 2]})).unwrap();
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), Rect::new(700.0, 50.0, 1200.0, 280.0), was[2]]);
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(boards(&s), was);
assert!(s.execute("artboard.fitToArt", &json!({"index": 2})).is_err(), "no art on artboard 3");
assert_eq!(boards(&s), was);
s.execute("artboard.fitToArt", &json!({})).unwrap();
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default");
}
// ---------- Edit Colors ----------
#[test]
+18
View File
@@ -198,6 +198,24 @@ pub fn art_bounds(doc: &Document) -> Option<Rect> {
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, drawn_bounds(&symbols, l)))
}
/// Bounds of the art an export draws ([`art_bounds`]) that is on artboard rectangle `rect`: each
/// object of a layer or sublayer whose drawn bounds overlap `rect`, with all of its bounds; a group
/// or a clipping layer counts as one object. `None` when no object overlaps `rect`.
pub fn art_bounds_on(doc: &Document, rect: Rect) -> Option<Rect> {
// Art flush with an edge from outside can overlap it by a rounding error (#1295).
let rect = rect.inflate(-1e-6, -1e-6);
fn on(symbols: &SymbolBoxes, n: &Node, rect: Rect) -> Option<Rect> {
match &n.kind {
NodeKind::Layer { template: false, clip: false, children, .. } if n.visible => {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, on(symbols, c, rect)))
}
_ => drawn_bounds(symbols, n).filter(|b| b.x0 < rect.x1 && b.x1 > rect.x0 && b.y0 < rect.y1 && b.y1 > rect.y0),
}
}
let symbols = doc.symbol_boxes();
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, on(&symbols, l, rect)))
}
fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
if !n.visible {
return None;
+9 -3
View File
@@ -687,15 +687,21 @@ impl VectorcraftApp {
{
p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n)));
}
// Object › Artboards › Fit to Artwork Bounds and Fit to Selected Art resize the active
// artboard, which the app passes as their `index`.
// Object › Artboards › Fit to Artwork Bounds resizes the selected artboards (the Artboard
// tool's, else the active one), which the app passes as its `indices`, and Fit to Selected
// Art resizes the active artboard, which the app passes as its `index`.
if vectorcraft_engine::FIT_ACTIVE_ARTBOARD.contains(&id)
&& params.get("index").is_none()
&& params.get("indices").is_none()
&& self.view().is_some()
&& let Some(n) = self.session.active().map(|d| d.doc.artboards.len())
&& let Some(p) = params.as_object_mut()
{
p.insert("index".into(), serde_json::json!(panels::artboards::selected(self, n)));
if id == "artboard.fitToArt" {
p.insert("indices".into(), serde_json::json!(panels::artboards::picked(self, n)));
} else {
p.insert("index".into(), serde_json::json!(panels::artboards::selected(self, n)));
}
}
if id.starts_with("edit.paste") {
if let Err(e) = self.adopt_system_clipboard() {
+25 -3
View File
@@ -260,7 +260,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.separator();
if menu_item(ui, tl!("Fit to Artwork Bounds"), n > 0, false) {
app.run("artboard.fitToArt", json!({"index": sel})).ok();
app.run("artboard.fitToArt", json!({ "indices": picked(app, n) })).ok();
}
}
@@ -328,10 +328,11 @@ mod tests {
for id in ["artboard.fitToArt", "artboard.fitToSelection"] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 2})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 130, "y": 10, "width": 20, "height": 30})).unwrap();
// Partly on artboard 1 and partly on artboard 2.
app.session.execute("shape.rectangle", &json!({"x": 90, "y": 10, "width": 60, "height": 30})).unwrap();
app.session.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let (first, art) = (boards(&app)[0], vectorcraft_geom::Rect::new(130.0, 10.0, 150.0, 40.0));
let (first, art) = (boards(&app)[0], vectorcraft_geom::Rect::new(90.0, 10.0, 150.0, 40.0));
select(&mut app, 1);
crate::menus::invoke(&mut app, id, json!({}));
assert_eq!(boards(&app), [first, art], "{id} resizes Artboard 2, the active one");
@@ -340,6 +341,27 @@ mod tests {
}
}
/// With artboards 1 and 3 selected with the Artboard tool, Object › Artboards › Fit to Artwork
/// Bounds fits each of them to the art on it and leaves artboard 2 as it was.
#[test]
fn fit_to_artwork_bounds_fits_each_selected_artboard_to_its_own_art() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 3})).unwrap();
for (x, h) in [(10, 30), (130, 20), (250, 40)] {
app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": h})).unwrap();
}
app.session.execute("select.all", &json!({})).unwrap();
app.session.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let was = boards(&app);
app.select_tool("artboard");
select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
crate::menus::invoke(&mut app, "artboard.fitToArt", json!({}));
let rect = vectorcraft_geom::Rect::new;
assert_eq!(boards(&app), [rect(10.0, 10.0, 30.0, 40.0), was[1], rect(250.0, 10.0, 270.0, 50.0)]);
}
/// With the Artboard tool, Shift-click on a row adds its artboard to the tool's selection or
/// takes it out (#1139), and every selected row is highlighted.
#[test]
+14 -7
View File
@@ -1835,13 +1835,20 @@ Direct Selection or Group Selection tool inside an artboard makes that artboard
tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. Choosing the Artboard
tool (`tool.select {tool: "artboard"}`) starts it on the active artboard: its `active` option is that artboard's index.
`artboard.fitToArt {index?}` (Object › Artboards › Fit to Artwork Bounds) and `artboard.fitToSelection {index?}`
(Object › Artboards › Fit to Selected Art) resize artboard `index` (a 0-based index), or the first artboard when the
call gives none. The desktop app adds its active artboard as `index` to the calls of these two commands that it runs
without `index`, MCP's `run_command` calls in remote mode included. A batch passes its `artboard` on to each of their
steps that names no `index`, as the step's `index`. When the document a step runs in has no artboard at that index,
the step gets that document's last artboard. An action recorded in the Actions panel leaves `index` out of these
steps, and they resize the artboard that is active when the action plays.
`artboard.fitToArt {index? | indices?}` (Object › Artboards › Fit to Artwork Bounds) fits each artboard it names to
the art on that artboard, in one undo step. The art is what an export draws, measured with its strokes and with the
effects on paths, compound paths and symbol instances. An object counts for every artboard its bounds overlap, also
when it reaches past the artboard's edge, and a group or a clipping layer counts whole. An artboard with no art on it
keeps its size and place, and the call fails with `no artwork` when none of its artboards has art.
`artboard.fitToSelection {index?}` (Object › Artboards › Fit to Selected Art) fits artboard `index` to the selected
art. Both commands take 0-based indexes and resize the first artboard when the call names none. The desktop app adds
the selected artboards (those the Artboard tool has selected, else the active one) as `indices` to the
`artboard.fitToArt` calls it runs without `index` or `indices`. It adds its active artboard as `index` to the
`artboard.fitToSelection` calls it runs without `index`. Both include MCP's `run_command` calls in remote mode. The
Artboards panel menu's Fit to Artwork Bounds also passes the selected artboards. A batch passes its `artboard` on to
each of their steps that names no `index`, as the step's `index`. When the document a step runs in has no artboard at
that index, the step gets that document's last artboard. An action recorded in the Actions panel leaves `index` and
`indices` out of these steps, and they resize the artboard that is active when the action plays.
```json
{"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","to":"artboard","artboard":1}}}
+2 -1
View File
@@ -511,7 +511,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
With Align to Artboard chosen, the Align panel's Distribute Objects and Distribute Spacing buttons use the active artboard (`object.distribute` and `object.distributeSpacing` with `to: "artboard"` and `artboard`). The first object's left or top edge goes to the artboard's left or top edge, and the last object's right or bottom edge goes to the artboard's right or bottom edge. Distribute Objects spaces the other objects evenly by its button's line, and two objects are enough. Distribute Spacing makes the gaps between the objects equal. With anchor points selected, the buttons space the points among themselves and do not use the artboard. When an action plays, its Distribute Objects and Distribute Spacing steps with Align to Artboard use the artboard that is active then.
With a key object, the Align panel's Distribute Objects buttons use the Distribute Spacing value (`object.distribute {spacing}`): the key object, or the compound path or selected group that contains it, stays in place, and the objects' left edges, centers or right edges (top edges, centers or bottom edges) end up that far apart, outward from the key object's, in their order. With the field's default value of 0, a click puts every object on the key object's line (behavior reference: [Ask Mordy: Distributing Objects](http://rwillustrator.blogspot.com/2008/01/ask-mordy-distribute.html)).
Choosing the Artboard tool (Shift+O, its toolbar button, Edit Artboards in Properties or Document Setup, or the Artboards panel menu's Artboard Options…) starts it on the active artboard: the canvas outlines that artboard, and Delete, Cut, Copy, the Transform panel and Artboard Options act on it.
Object › Artboards › Fit to Artwork Bounds and Fit to Selected Art resize the active artboard, as the Artboards panel menu's Fit to Artwork Bounds does (`artboard.fitToArt {index}`, `artboard.fitToSelection {index}`). They used to resize artboard 1 whichever artboard was active. When an action plays, its Fit to Artwork Bounds and Fit to Selected Art steps that name no artboard resize the artboard that is active then.
Object › Artboards › Fit to Selected Art resizes the active artboard (`artboard.fitToSelection {index}`). It used to resize artboard 1 whichever artboard was active. When an action plays, its Fit to Artwork Bounds and Fit to Selected Art steps that name no artboard resize the artboard that is active then.
Object › Artboards › Fit to Artwork Bounds and the Artboards panel menu's Fit to Artwork Bounds fit each selected artboard (those the Artboard tool has selected, else the active one) to the art on it, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes (`artboard.fitToArt {indices}`). An object partly on an artboard counts for each artboard it overlaps, and an artboard with no art on it keeps its size and place. They used to fit the active artboard to all the art in the document.
The canvas draws the active artboard's edge in black, as the public [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html) page describes. It draws the other artboards' edges in gray. With several artboards selected by the Artboard tool, the canvas draws the black edge around the active one, which the tool shows with handles and its name. It used to draw artboard 1's edge in black whichever artboard was active.
View › Rotate View turns the view to the status bar's preset angles (`view.rotate {angle}`, which takes any angle). View › Rotate View to Selection turns the view to the angle of the selected objects' bounding box, so the box is square to the window (`view.rotateToSelection`).
When the view angle changes (a Rotate View drag, the status bar's angle menu, Reset Rotate View or a saved view) and the desktop app renders the frame at the new angle on the render worker, the canvas draws the last frame turned to the new angle until that frame is ready. It used to draw no art until then.