Browse / github.com/storytold/designcraft / patch
Buttons: Go To State, Next/Previous State and Destination actions (#1798)
Buttons: Go To State, Next/Previous State and Destination actions (#1798) Part of #1798 (the reporter’s “options are missing” half; state execution follows in two PRs). What ButtonAction + GoToDestination { anchor }, GoToState { object, state }, GoToNextState / GoToPreviousState { object, wrap } (wrap is serde(default), so existing documents load unchanged). button.set {action: destination|state|nextState|previousState, anchor?, object?, state?, wrap?} — object defaults to the document’s only multi-state object and is required when there are several; state by index or name; unknown states/objects are refused. Buttons and Forms panel: the four entries, with O
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- 4waymedia (imported from pull request #1816, open)
- Base
0f1ff65e7ad4f47a330731c3f39f88b84e2adb25- Size
- +220 −9 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
9069fb9f515bb696b6745b1d
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/doc/src/item.rs crates/engine/src/cmd/buttons.rs crates/pdf/src/links.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/panels/interactive.rs docs/target-app-parity.md
@@ -361,6 +361,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-25** · Buttons and Forms gains the actions the Object States panel was missing a partner for (#1798): Go To State (object and state), Go To Next State and Go To Previous State (object, Loop), and Go To Destination (a text anchor). `button.set` takes `action: state|nextState|previousState|destination` with `object` (defaulting to the document's only multi-state object), `state` (index or name), `wrap` and `anchor`. Destination buttons export as PDF links to the anchor's page; state actions stay in the document until states export as optional content. No readiness percentage change. - **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. - **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change.@@ -972,6 +972,22 @@ pub enum ButtonAction { GoToNextPage, GoToPreviousPage, GoToUrl { url: String },+ /// A text anchor (a Hyperlinks panel destination): its page.+ GoToDestination { anchor: u64 },+ /// Show state `state` (0-based) of the multi-state object `object`.+ GoToState { object: ItemId, state: usize },+ /// Show the state after the active one of `object`; `wrap` goes round from the last to the first.+ GoToNextState {+ object: ItemId,+ #[serde(default)]+ wrap: bool,+ },+ /// Show the state before the active one of `object`; `wrap` goes round from the first to the last.+ GoToPreviousState {+ object: ItemId,+ #[serde(default)]+ wrap: bool,+ }, } fn is_zero_usize(v: &usize) -> bool {@@ -1,13 +1,33 @@ //! Window › Interactive › Buttons and Forms: objects that act when clicked in an interactive PDF-//! (go to a page, the next / previous / first / last page, or a URL).+//! (go to a page, the next / previous / first / last page, a URL, a text anchor, or a state of a+//! multi-state object). -use designcraft_doc::ButtonAction;+use designcraft_doc::{ButtonAction, ItemId}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_selection, targets};-use crate::Result;+use crate::{Result, Session}; -fn parse(p: &Value) -> Result<Option<ButtonAction>> {+/// The multi-state object a state action targets: `object`, or the document's only one.+fn state_object(s: &Session, p: &Value) -> Result<(ItemId, Vec<String>)> {+ let d = &s.doc()?.doc;+ let mso = |id: ItemId| d.item(id).filter(|it| !it.states.is_empty()).map(|it| (id, it.states.clone()));+ if let Some(id) = super::id_param(p, "object") {+ return mso(id).ok_or_else(|| bad("button.set", format!("{id} is not a multi-state object")));+ }+ let mut all = d.all_items().into_iter().filter_map(mso);+ match (all.next(), all.next()) {+ (Some(one), None) => Ok(one),+ (None, _) => Err(bad("button.set", "no multi-state object in the document (Object States: Convert Selection to Multi-State Object)")),+ _ => Err(bad("button.set", "`object` required: the document has several multi-state objects")),+ }+}++fn wrap(p: &Value) -> bool {+ p.get("wrap").and_then(Value::as_bool).unwrap_or(false)+}++fn parse(s: &Session, p: &Value) -> Result<Option<ButtonAction>> { let a = p.get("action").and_then(Value::as_str).unwrap_or("nextPage"); Ok(Some(match a { "none" => return Ok(None),@@ -19,6 +39,23 @@ fn parse(p: &Value) -> Result<Option<ButtonAction>> { "nextPage" => ButtonAction::GoToNextPage, "previousPage" => ButtonAction::GoToPreviousPage, "url" => ButtonAction::GoToUrl { url: p.get("url").and_then(Value::as_str).ok_or_else(|| bad("button.set", "`url` required"))?.to_string() },+ "destination" => ButtonAction::GoToDestination {+ anchor: p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("button.set", "`anchor` (text anchor id) required"))?,+ },+ "state" => {+ let (object, states) = state_object(s, p)?;+ let state = match (p.get("state").and_then(Value::as_u64), p.get("state").and_then(Value::as_str)) {+ (Some(i), _) => i as usize,+ (None, Some(n)) => states.iter().position(|x| x == n).ok_or_else(|| bad("button.set", format!("no state `{n}`")))?,+ _ => return Err(bad("button.set", "`state` (index or name) required")),+ };+ if state >= states.len() {+ return Err(bad("button.set", format!("no state {state}")));+ }+ ButtonAction::GoToState { object, state }+ }+ "nextState" => ButtonAction::GoToNextState { object: state_object(s, p)?.0, wrap: wrap(p) },+ "previousState" => ButtonAction::GoToPreviousState { object: state_object(s, p)?.0, wrap: wrap(p) }, o => return Err(bad("button.set", format!("unknown action `{o}`"))), })) }@@ -30,11 +67,11 @@ pub fn specs() -> Vec<CommandSpec> { "Convert to Button", ["Object", "Interactive"], None,- "{ids?, action: page|firstPage|lastPage|nextPage|previousPage|url|none, page?, url?} — the selected objects act on release in interactive PDF",+ "{ids?, action: page|firstPage|lastPage|nextPage|previousPage|url|destination|state|nextState|previousState|none, page? (0-based), url?, anchor? (text anchor id), object? (multi-state object; default: the document's only one), state? (index or name), wrap?} — the selected objects act on release in interactive PDF (page, URL and destination actions export as PDF links; state actions are kept in the document for interactive export)", has_selection, |s, p| { let ids = targets(s, p)?;- let action = parse(p)?;+ let action = parse(s, p)?; s.edit(|d, _| { for id in &ids { if let Some(it) = d.item_mut(*id) {@@ -179,4 +216,58 @@ mod tests { assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(3), "the update still reads"); assert!(s.execute("button.list", &json!({})).unwrap().as_array().unwrap().is_empty()); }++ /// #1798: buttons reach a multi-state object's states and a text anchor, not only pages and URLs.+ #[test]+ fn buttons_go_to_states_and_destinations() {+ let mut s = Session::new();+ s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();+ let button = s.execute("frame.create", &json!({"rect": [72, 72, 200, 120]})).unwrap()["id"].clone();+ // Without a multi-state object there is nothing to go to.+ let e = s.execute("button.set", &json!({"ids": [button], "action": "nextState"})).expect_err("no multi-state object").to_string();+ assert!(e.contains("no multi-state object"), "{e}");++ let a = s.execute("frame.create", &json!({"rect": [100, 200, 200, 300]})).unwrap()["id"].clone();+ let b = s.execute("frame.create", &json!({"rect": [100, 200, 200, 300]})).unwrap()["id"].clone();+ let mso = s.execute("states.create", &json!({"ids": [a, b]})).unwrap()["id"].clone();++ // The document's only multi-state object is the default target.+ s.execute("button.set", &json!({"ids": [button], "action": "nextState", "wrap": true})).unwrap();+ let act = s.execute("button.list", &json!({})).unwrap()[0]["action"].clone();+ assert_eq!(act, json!({"kind": "goToNextState", "object": mso, "wrap": true}));+ s.execute("button.set", &json!({"ids": [button], "action": "previousState"})).unwrap();+ assert_eq!(s.execute("button.list", &json!({})).unwrap()[0]["action"]["wrap"], false, "wrap defaults to off");+ // A state by name or by index; unknown states and objects are refused.+ s.execute("button.set", &json!({"ids": [button], "action": "state", "object": mso, "state": "State 2"})).unwrap();+ assert_eq!(s.execute("button.list", &json!({})).unwrap()[0]["action"], json!({"kind": "goToState", "object": mso, "state": 1}));+ s.execute("button.set", &json!({"ids": [button], "action": "state", "state": 0})).unwrap();+ assert_eq!(s.execute("button.list", &json!({})).unwrap()[0]["action"]["state"], 0);+ assert!(s.execute("button.set", &json!({"ids": [button], "action": "state", "state": "State 9"})).is_err());+ assert!(s.execute("button.set", &json!({"ids": [button], "action": "state", "state": 2})).is_err());+ assert!(s.execute("button.set", &json!({"ids": [button], "action": "state", "object": button, "state": 0})).is_err(), "a plain frame is not a multi-state object");+ assert!(s.execute("button.set", &json!({"ids": [button], "action": "state"})).is_err(), "`state` required");+ // With two multi-state objects the target has to be named.+ let c = s.execute("frame.create", &json!({"rect": [300, 200, 400, 300]})).unwrap()["id"].clone();+ let d = s.execute("frame.create", &json!({"rect": [300, 200, 400, 300]})).unwrap()["id"].clone();+ s.execute("states.create", &json!({"ids": [c, d]})).unwrap();+ let e = s.execute("button.set", &json!({"ids": [button], "action": "nextState"})).expect_err("ambiguous").to_string();+ assert!(e.contains("`object` required"), "{e}");+ s.execute("button.set", &json!({"ids": [button], "action": "nextState", "object": mso})).unwrap();++ // Go To Destination: a text anchor's page becomes the link target in the PDF.+ let text = s.execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 200], "content": "text", "text": "Appendix"})).unwrap();+ s.execute("text.select", &json!({"story": text["story"], "anchor": 0, "focus": 0})).unwrap();+ let anchor = s.execute("anchor.create", &json!({"name": "Appendix"})).unwrap()["id"].clone();+ assert!(s.execute("button.set", &json!({"ids": [button], "action": "destination"})).is_err(), "`anchor` required");+ let links = |s: &mut Session| {+ let r = s.execute("file.exportPdf", &json!({})).unwrap();+ let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap());+ String::from_utf8_lossy(&bytes).matches("/Link").count()+ };+ s.execute("button.clear", &json!({"ids": [button]})).unwrap();+ let without = links(&mut s);+ s.execute("button.set", &json!({"ids": [button], "action": "destination", "anchor": anchor})).unwrap();+ assert_eq!(s.execute("button.list", &json!({})).unwrap()[0]["action"], json!({"kind": "goToDestination", "anchor": anchor}));+ assert!(links(&mut s) > without, "the destination button exports a link annotation");+ } }@@ -107,6 +107,10 @@ pub(crate) fn annotations(doc: &Document, cache: &designcraft_compose::Cache, sh B::GoToLastPage => total.checked_sub(1), B::GoToNextPage => here.map(|h| h + 1).filter(|p| *p < total), B::GoToPreviousPage => here.and_then(|h| h.checked_sub(1)),+ // A text anchor's page, as cross-references resolve it.+ B::GoToDestination { anchor } => cache.xref_index(doc).page_index(*anchor),+ // A PDF shows one state of a multi-state object (#1798: states as optional content to come).+ B::GoToState { .. } | B::GoToNextState { .. } | B::GoToPreviousState { .. } => continue, }; let Some(i) = page.and_then(|p| sheet_of_page(doc, sheets, p)) else { continue }; let target = Target::Destination(XyzDestination::new(i, krilla::geom::Point::from_xy(0.0, 0.0)).into());@@ -3577,6 +3577,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Go To Last Page", ["Go To Last Page", "Atteindre la dernière page", "Go To Last Page", "Go To Last Page", "转到最后一页"]), ("Go To Page", ["Go To Page", "Atteindre la page", "Go To Page", "Go To Page", "转到页面"]), ("Go To URL", ["Go To URL", "Atteindre l'URL", "Go To URL", "Go To URL", "转到网址"]),+ ("Go To Destination", ["Gehe zu Ziel", "Atteindre la destination", "Ir a destino", "リンク先へ移動", "转到目标"]),+ ("Go To State", ["Gehe zu Status", "Atteindre l'état", "Ir a estado", "ステートへ移動", "转到状态"]),+ ("Go To Next State", ["Gehe zu nächstem Status", "Atteindre l'état suivant", "Ir a estado siguiente", "次のステートへ移動", "转到下一状态"]),+ ("Go To Previous State", ["Gehe zu vorherigem Status", "Atteindre l'état précédent", "Ir a estado anterior", "前のステートへ移動", "转到上一状态"]),+ ("State", ["Status", "État", "Estado", "ステート", "状态"]),+ ("Destination", ["Ziel", "Destination", "Destino", "リンク先", "目标"]), ("Not a form field", ["Not a form field", "Pas un champ de formulaire", "Not a form field", "Not a form field", "非表单域"]), ("Text Field", ["Text Field", "Champ de texte", "Text Field", "Text Field", "文本域"]), ("Check Box", ["Check Box", "Case à cocher", "Check Box", "Check Box", "复选框"]),@@ -352,7 +352,36 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { Some(B::GoToNextPage) => ("nextPage", 0, String::new()), Some(B::GoToPreviousPage) => ("previousPage", 0, String::new()), Some(B::GoToUrl { url }) => ("url", 0, url.clone()),+ Some(B::GoToDestination { .. }) => ("destination", 0, String::new()),+ Some(B::GoToState { .. }) => ("state", 0, String::new()),+ Some(B::GoToNextState { .. }) => ("nextState", 0, String::new()),+ Some(B::GoToPreviousState { .. }) => ("previousState", 0, String::new()), };+ // What a state or destination action points at (#1798).+ let (object, state, wrap) = match &cur {+ Some(B::GoToState { object, state }) => (Some(object.0), *state, false),+ Some(B::GoToNextState { object, wrap } | B::GoToPreviousState { object, wrap }) => (Some(object.0), 0, *wrap),+ _ => (None, 0, false),+ };+ let anchor = match &cur {+ Some(B::GoToDestination { anchor }) => Some(*anchor),+ _ => None,+ };+ // The document's multi-state objects (id, name, states) and text anchors (id, name).+ let (msos, anchors): (Vec<(u64, String, Vec<String>)>, Vec<(u64, String)>) = app.session.active().map_or((vec![], vec![]), |d| {+ let msos = d+ .doc+ .all_items()+ .into_iter()+ .filter_map(|i| {+ let it = d.doc.item(i)?;+ let name = if it.name.is_empty() { format!("Multi-state object {}", i.0) } else { it.name.clone() };+ (!it.states.is_empty()).then(|| (i.0, name, it.states.clone()))+ })+ .collect();+ let anchors = d.doc.stories.values().flat_map(|st| st.anchors.iter().map(|a| (a.id, a.name.clone()))).collect();+ (msos, anchors)+ }); let labels = [ ("none", "Not a button"), ("nextPage", "Go To Next Page"),@@ -361,6 +390,10 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { ("lastPage", "Go To Last Page"), ("page", "Go To Page"), ("url", "Go To URL"),+ ("destination", "Go To Destination"),+ ("state", "Go To State"),+ ("nextState", "Go To Next State"),+ ("previousState", "Go To Previous State"), ]; let mut pick = None; ui.horizontal(|ui| {@@ -377,7 +410,67 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { }); let mut params = None; if let Some(k) = pick {- params = Some(json!({"action": k, "page": page, "url": if url.is_empty() { "https://" } else { &url }}));+ let mut p = json!({"action": k, "page": page, "url": if url.is_empty() { "https://" } else { &url }, "wrap": wrap});+ // A state action starts on the current object (or the first), a destination on the first anchor.+ if let Some(o) = object.or_else(|| msos.first().map(|m| m.0)) {+ p["object"] = json!(o);+ }+ if k == "state" {+ p["state"] = json!(if object.is_some() { state } else { 0 });+ }+ if let Some(a) = anchor.or_else(|| anchors.first().map(|a| a.0)) {+ p["anchor"] = json!(a);+ }+ params = Some(p);+ }+ if matches!(kind, "state" | "nextState" | "previousState") {+ let current = msos.iter().find(|m| Some(m.0) == object);+ ui.horizontal(|ui| {+ crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Object"));+ egui::ComboBox::from_id_salt("button_state_object")+ .selected_text(crate::rtl::widget(ui, current.map_or("", |m| m.1.as_str())))+ .show_ui(ui, |ui| {+ for m in &msos {+ if ui.selectable_label(Some(m.0) == object, crate::rtl::widget(ui, m.1.as_str())).clicked() {+ params = Some(json!({"action": kind, "object": m.0, "state": 0, "wrap": wrap}));+ }+ }+ });+ });+ if kind == "state" {+ let states: &[String] = current.map_or(&[], |m| m.2.as_slice());+ ui.horizontal(|ui| {+ crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "State"));+ egui::ComboBox::from_id_salt("button_state_index")+ .selected_text(crate::rtl::widget(ui, states.get(state).map_or("", String::as_str)))+ .show_ui(ui, |ui| {+ for (i, st) in states.iter().enumerate() {+ if ui.selectable_label(i == state, crate::rtl::widget(ui, st.as_str())).clicked() {+ params = Some(json!({"action": "state", "object": object, "state": i}));+ }+ }+ });+ });+ } else {+ let mut w = wrap;+ if ui.checkbox(&mut w, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Loop"))).changed() {+ params = Some(json!({"action": kind, "object": object, "wrap": w}));+ }+ }+ }+ if kind == "destination" {+ ui.horizontal(|ui| {+ crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Destination"));+ egui::ComboBox::from_id_salt("button_destination")+ .selected_text(crate::rtl::widget(ui, anchors.iter().find(|a| Some(a.0) == anchor).map_or("", |a| a.1.as_str())))+ .show_ui(ui, |ui| {+ for (aid, name) in &anchors {+ if ui.selectable_label(Some(*aid) == anchor, crate::rtl::widget(ui, name.as_str())).clicked() {+ params = Some(json!({"action": "destination", "anchor": aid}));+ }+ }+ });+ }); } if kind == "page" { let total = app.session.active().map_or(1, |d| d.doc.page_count());@@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (button actions Go To State / Next State / Previous State / Destination, #1798; Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature@@ -373,7 +373,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Cell strokes: with cells selected (or the cursor in a cell) the Stroke panel edits the chosen cell edges, picked on a drawn edge proxy (top, bottom, left, right, inner horizontal, inner vertical); Table › Cell Options › Strokes and Fills… does the same with the cell fill. Weight, every stroke type, colour, tint, gap colour and gap tint draw on screen and in PDF and round-trip through IDML (`table.setCell` `stroke.edges`, `table.getCellStroke`). - Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text. - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF.-- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X or PDF/A).+- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL / destination (a text anchor's page) as PDF link annotations; Go To State / Next State / Previous State (object, state, Loop) point at a multi-state object and are kept in the document (#1798; a PDF shows one state, so they export nothing yet). Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X or PDF/A). - Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. - Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF; IDML export writes them as an inline graphic in the cell. - Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds.