Files
designcraft/crates/engine/src/cmd/guides.rs
T
aiongg d7b9f716f1 View: Lock Guides and Lock Column Guides are commands (#1096) (#1174)
* View: Lock Guides and Lock Column Guides are commands (#1096)

view.lockGuides {on?} (Cmd+Alt+;) sets the Lock Guides UI state the Properties panel and ruler-guide drags use. view.lockColumnGuides {on?} is a document setting, on by default, saved with the document, not an undo step, and read from and written to IDML ColumnGuideLocked. Both are in View > Grids & Guides with checkmarks.

* View: Lock Column Guides is translated wherever Lock Guides is (#1096)

* View: Lock Column Guides is undoable (#1096)

* ROADMAP: the progress line lands in one commit after the guide PRs
2026-10-22 02:12:42 -04:00

1022 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Ruler guides: add (dragged from the rulers), select, move, delete, list; Layout › Create
//! Guides.
//!
//! Guides are stored on their page in spread coordinates; a spread guide (dropped on the
//! pasteboard) lives on the spread's first page with `spread: true` and crosses the pasteboard.
//! Selected guides are [`Selection::guides`], addressed as (spread, page, index): the commands
//! that delete guides renumber it, and [`keep_selection_valid`] drops what another command made
//! stale.
use std::collections::{HashMap, HashSet};
use designcraft_doc::{Document, Guide, GuideSel, Orientation, Selection, SpreadRef};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
use crate::{DocState, Result, Session};
const MAX_GUIDE_DIVISIONS: u64 = 1000;
const MAX_GUIDES_CREATED: usize = 10_000;
const MAX_GUIDE_GUTTER: f64 = 1_000_000.0;
/// The farthest a guide may be from the spread origin, in points, either way.
const MAX_GUIDE_POSITION: f64 = 1_000_000.0;
/// The most guides one `guides` list may name.
const MAX_GUIDES_NAMED: usize = 10_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"guide.add",
"New Guide",
[],
None,
"{orientation: horizontal|vertical, position (spread coordinate, at most 1000000 pt either way), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}. The new guides become the selection; objects are deselected",
has_doc,
add
),
cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position (at most 1000000 pt either way)}", has_doc, move_guide),
cmd!(
"guide.moveBy",
"Move Guides",
[],
None,
"{dx?, dy?, guides?: [{spread?, page, index}] (default: the selected guides)} — vertical guides move by dx, horizontal ones by dy, as one undo step",
has_doc,
move_by
),
cmd!(
"guide.delete",
"Delete Guide",
[],
None,
"{spread?, page, index}, or {} for the selected guides; an address without page or index is an error",
has_doc,
delete
),
cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| {
let r = super::spread_param(p, "guide.deleteAll")?;
s.edit(|d, sel| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?;
let mut n = 0;
for pg in &mut sp.pages {
n += pg.guides.len();
pg.guides.clear();
}
sel.guides.retain(|g| g.spread != r);
Ok(json!({"deleted": n}))
})
}),
cmd!(
"view.lockColumnGuides",
"Lock Column Guides",
["View", "Grids & Guides"],
None,
"{on?: bool (default: toggle)} → {locked} — locked column guides can't be dragged; saved with the document (IDML ColumnGuideLocked), on in new documents",
has_doc,
lock_column_guides
),
cmd!(
query "guide.list",
"Guides",
[],
None,
"{spread?} → [{page, index, orientation, position, spread, selected}]",
has_doc,
|s, p| {
let st = s.doc()?;
let r = super::spread_param(p, "guide.list")?;
let sp = st.doc.spread(r).ok_or_else(|| bad("guide.list", "no such spread"))?;
let picked: HashSet<GuideSel> = st.selection.guides.iter().copied().collect();
let mut out = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
for (i, g) in pg.guides.iter().enumerate() {
let selected = picked.contains(&GuideSel { spread: r, page: pi, index: i });
out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread, "selected": selected}));
}
}
Ok(Value::Array(out))
}
),
cmd!(
noundo "guide.select",
"Select Guide",
[],
None,
"{spread?, page, index, guides?: [{spread?, page, index}] (several, in place of page and index), add?: bool (keep the guides already selected), toggle?: bool (deselect a selected guide instead: Shift-click)} — selects ruler guides and deselects objects → {selected: n}",
has_doc,
select
),
cmd!(
noundo "guide.selectAll",
"Select All Guides",
["Edit"],
Some("Cmd+Alt+G"),
"{spread?} — every guide on the spread that is not locked or on a locked or hidden layer → {selected: n}",
has_doc,
select_all
),
cmd!(noundo "guide.deselect", "Deselect Guides", [], None, "{}", has_doc, |s, _| {
let st = s.doc_mut()?;
st.selection.guides.clear();
st.bump_revision();
super::ok()
}),
cmd!(
"layout.createGuides",
"Create Guides…",
["Layout"],
None,
"{rows?: 0..1000, columns?: 0..1000, rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)} — at most 10000 guides per command",
has_doc,
create_guides
),
]
}
fn lock_column_guides(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.doc()?.doc.settings.column_guides_locked;
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!cur);
if on == cur {
return Ok(json!({"locked": cur}));
}
s.edit(|d, _| {
d.settings.column_guides_locked = on;
Ok(json!({"locked": on}))
})
}
fn checked_usize(value: &Value, key: &str, command: &str) -> Result<usize> {
let n = value.as_u64().ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))?;
usize::try_from(n).map_err(|_| bad(command, format!("`{key}` is too large for this platform")))
}
fn optional_usize(p: &Value, key: &str, command: &str) -> Result<Option<usize>> {
p.get(key).map(|value| checked_usize(value, key, command)).transpose()
}
fn finite_number(p: &Value, key: &str, command: &str) -> Result<Option<f64>> {
let Some(value) = p.get(key) else { return Ok(None) };
let number = value.as_f64().ok_or_else(|| bad(command, format!("`{key}` must be a finite number")))?;
if !number.is_finite() {
return Err(bad(command, format!("`{key}` must be a finite number")));
}
Ok(Some(number))
}
/// `position` as a guide position: finite and within [`MAX_GUIDE_POSITION`] of the origin.
fn guide_position(position: f64, command: &str) -> Result<f64> {
if !position.is_finite() || position.abs() > MAX_GUIDE_POSITION {
return Err(bad(command, format!("a guide position must be between -{MAX_GUIDE_POSITION} and {MAX_GUIDE_POSITION}")));
}
Ok(position)
}
fn orientation(p: &Value) -> Result<Orientation> {
match str_param(p, "orientation") {
Some("horizontal") => Ok(Orientation::Horizontal),
Some("vertical") => Ok(Orientation::Vertical),
_ => Err(bad("guide", "orientation: horizontal|vertical")),
}
}
/// The page of a spread at a spread x coordinate (nearest page).
fn page_at(d: &Document, r: SpreadRef, x: f64) -> usize {
d.spread(r).and_then(|sp| sp.page_at_x(x)).unwrap_or(0)
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let r = super::spread_param(p, "guide.add")?;
let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false);
let liquid = p.get("liquid").and_then(Value::as_bool).unwrap_or(false);
let page = optional_usize(p, "page", "guide.add")?;
// (orientation, position, x used to find the page)
let guides: Vec<(Orientation, f64, f64)> = if str_param(p, "orientation") == Some("both") {
let point = p.get("point").and_then(Value::as_array).ok_or_else(|| bad("guide.add", "orientation `both` needs point: [x, y]"))?;
let coord = |i: usize| point.get(i).and_then(Value::as_f64).filter(|v| v.is_finite());
let (Some(x), Some(y), 2) = (coord(0), coord(1), point.len()) else {
return Err(bad("guide.add", "`point` must be two finite numbers [x, y]"));
};
vec![(Orientation::Horizontal, y, x), (Orientation::Vertical, x, x)]
} else {
let o = orientation(p)?;
let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?;
let x = match o {
Orientation::Vertical => pos,
Orientation::Horizontal => finite_number(p, "at", "guide.add")?.unwrap_or(0.0),
};
vec![(o, pos, x)]
};
for (_, pos, _) in &guides {
guide_position(*pos, "guide.add")?;
}
let d = &s.doc()?.doc;
let placed: Vec<(Orientation, f64, usize)> = guides
.into_iter()
.map(|(o, pos, x)| {
let pi = match page {
Some(pi) => pi,
None if spread_guide => 0,
None => page_at(d, r, x),
};
(o, pos, pi)
})
.collect();
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?;
let mut out = Vec::new();
let mut added = Vec::new();
for (o, pos, pi) in placed {
let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?;
let index = pg.guides.len();
pg.guides.push(Guide {
orientation: o,
position: pos,
spread: spread_guide,
locked: false,
layer: Some(layer),
liquid: liquid && !spread_guide,
});
out.push(json!({"page": pi, "index": index}));
added.push(GuideSel { spread: r, page: pi, index });
}
// A new guide is selected, as in InDesign; nothing else stays selected.
*sel = Selection { guides: added, ..Default::default() };
match <[Value; 1]>::try_from(out) {
Ok([one]) => Ok(one),
Err(out) => Ok(json!({"guides": out})),
}
})
}
fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> {
let pi = optional_usize(p, "page", cmd)?.ok_or_else(|| bad(cmd, "missing page"))?;
let i = optional_usize(p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))?;
Ok((super::spread_param(p, cmd)?, pi, i))
}
/// Commands must use the same guide/layer protection as pointer editing.
fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str) -> Result<()> {
let g = d.spread(r).and_then(|sp| sp.pages.get(pi)).and_then(|pg| pg.guides.get(i)).ok_or_else(|| bad(command, "no such guide"))?;
if !g.editable_in(d) {
return Err(bad(command, "the guide or its layer is locked or hidden"));
}
Ok(())
}
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.move")?;
let pos = finite_number(p, "position", "guide.move")?.ok_or_else(|| bad("guide.move", "missing position"))?;
let pos = guide_position(pos, "guide.move")?;
s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.move")?;
let g = d
.spread_mut(r)
.and_then(|sp| sp.pages.get_mut(pi))
.and_then(|pg| pg.guides.get_mut(i))
.ok_or_else(|| bad("guide.move", "no such guide"))?;
g.position = pos;
Ok(Value::Null)
})
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
// An address names one guide: a partial one (only `spread`, say) is an error, not the selection.
let targets = if ["spread", "page", "index"].iter().all(|k| p.get(k).is_none()) {
s.doc()?.selection.guides.clone()
} else {
let (spread, page, index) = guide_ref(p, "guide.delete")?;
vec![GuideSel { spread, page, index }]
};
if targets.is_empty() {
return Err(bad("guide.delete", "no guide selected"));
}
s.edit(|d, sel| {
for g in &targets {
check_editable(d, g.spread, g.page, g.index, "guide.delete")?;
}
let deleted = remove_guides(d, sel, &targets, "guide.delete")?;
Ok(json!({"deleted": deleted}))
})
}
/// Removes `targets` (existing guides; repeats count once) and renumbers the guide selection to
/// match. Returns how many guides it removed.
fn remove_guides(d: &mut Document, sel: &mut Selection, targets: &[GuideSel], command: &str) -> Result<usize> {
let gone: HashSet<GuideSel> = targets.iter().copied().collect();
// Per page, the removed indices in ascending order.
let mut by_page: HashMap<(SpreadRef, usize), Vec<usize>> = HashMap::new();
for g in &gone {
by_page.entry((g.spread, g.page)).or_default().push(g.index);
}
for ((spread, page), indices) in &mut by_page {
indices.sort_unstable();
let pg = d.spread_mut(*spread).and_then(|sp| sp.pages.get_mut(*page)).ok_or_else(|| bad(command, "no such page"))?;
for &i in indices.iter().rev() {
if i >= pg.guides.len() {
return Err(bad(command, "no such guide"));
}
pg.guides.remove(i);
}
}
sel.guides = sel
.guides
.iter()
.filter(|g| !gone.contains(g))
.map(|g| {
let below = by_page.get(&(g.spread, g.page)).map_or(0, |indices| indices.partition_point(|&i| i < g.index));
GuideSel { index: g.index.saturating_sub(below), ..*g }
})
.collect();
Ok(gone.len())
}
/// The guides a `guides: [{spread?, page, index}]` list names, if the params have one.
fn guides_param(p: &Value, command: &str) -> Result<Option<Vec<GuideSel>>> {
let Some(list) = p.get("guides") else { return Ok(None) };
let list = list.as_array().ok_or_else(|| bad(command, "`guides` must be a list of {spread?, page, index}"))?;
if list.len() > MAX_GUIDES_NAMED {
return Err(bad(command, format!("`guides` may name at most {MAX_GUIDES_NAMED} guides")));
}
list.iter()
.map(|g| {
let (spread, page, index) = guide_ref(g, command)?;
Ok(GuideSel { spread, page, index })
})
.collect::<Result<Vec<_>>>()
.map(Some)
}
/// The guide `g` addresses, if there is one.
fn guide_of(d: &Document, g: GuideSel) -> Option<&Guide> {
d.spread(g.spread)?.pages.get(g.page)?.guides.get(g.index)
}
/// A guide that can be selected: it exists, and neither it nor its layer is locked or hidden.
pub(crate) fn selectable(d: &Document, g: GuideSel) -> bool {
guide_of(d, g).is_some_and(|guide| guide.editable_in(d))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let named = match guides_param(p, "guide.select")? {
Some(list) => list,
None => {
let (spread, page, index) = guide_ref(p, "guide.select")?;
vec![GuideSel { spread, page, index }]
}
};
let (add, toggle) = (bool_or(p, "add", false), bool_or(p, "toggle", false));
let st = s.doc_mut()?;
for g in &named {
check_editable(&st.doc, g.spread, g.page, g.index, "guide.select")?;
}
let mut guides = if add || toggle { st.selection.guides.clone() } else { Vec::new() };
let mut have: HashSet<GuideSel> = guides.iter().copied().collect();
let mut seen = HashSet::new();
let mut dropped = HashSet::new();
for g in named.into_iter().filter(|g| seen.insert(*g)) {
if !have.insert(g) {
if toggle {
dropped.insert(g);
}
} else {
guides.push(g);
}
}
guides.retain(|g| !dropped.contains(g));
let n = guides.len();
st.selection = Selection { guides, ..Default::default() };
st.bump_revision();
Ok(json!({"selected": n}))
}
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
let r = super::spread_param(p, "guide.selectAll")?;
let st = s.doc_mut()?;
let sp = st.doc.spread(r).ok_or_else(|| bad("guide.selectAll", "no such spread"))?;
let guides: Vec<GuideSel> = sp
.pages
.iter()
.enumerate()
.flat_map(|(page, pg)| (0..pg.guides.len()).map(move |index| GuideSel { spread: r, page, index }))
.filter(|g| selectable(&st.doc, *g))
.collect();
let n = guides.len();
st.selection = Selection { guides, ..Default::default() };
st.bump_revision();
Ok(json!({"selected": n}))
}
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
let dx = finite_number(p, "dx", "guide.moveBy")?.unwrap_or(0.0);
let dy = finite_number(p, "dy", "guide.moveBy")?.unwrap_or(0.0);
let mut targets = match guides_param(p, "guide.moveBy")? {
Some(list) => list,
None => s.doc()?.selection.guides.clone(),
};
let mut seen = HashSet::new();
targets.retain(|g| seen.insert(*g));
if targets.is_empty() {
return Err(bad("guide.moveBy", "no guide selected"));
}
s.edit(|d, _| {
for g in &targets {
check_editable(d, g.spread, g.page, g.index, "guide.moveBy")?;
}
for g in &targets {
let guide = d
.spread_mut(g.spread)
.and_then(|sp| sp.pages.get_mut(g.page))
.and_then(|pg| pg.guides.get_mut(g.index))
.ok_or_else(|| bad("guide.moveBy", "no such guide"))?;
let to = guide.position
+ match guide.orientation {
Orientation::Vertical => dx,
Orientation::Horizontal => dy,
};
guide.position = guide_position(to, "guide.moveBy").map_err(|_| bad("guide.moveBy", "the guide would move out of range"))?;
}
Ok(json!({"moved": targets.len()}))
})
}
/// After a command: guides stay selected only while nothing else is (objects, text, cells, a
/// page), and only those that are still there and can be selected. A command that changed
/// which guides a page holds without keeping the guide selection up to date clears it, since
/// its (page, index) addresses may name other guides now. `before` is the document and
/// selection before the command, when it ran on the same document.
pub(crate) fn keep_selection_valid(st: &mut DocState, before: Option<(&Document, &Selection)>, id: &str) {
let sel = &mut st.selection;
if sel.guides.is_empty() {
return;
}
if !sel.items.is_empty() || sel.text.is_some() || sel.cells.is_some() || sel.page.is_some() {
sel.guides.clear();
return;
}
let kept_up = id.starts_with("guide.") || matches!(id, "edit.undo" | "edit.redo") || before.is_some_and(|(_, old)| old.guides != sel.guides);
if !kept_up
&& let Some((old, _)) = before
&& !std::ptr::eq(old, st.doc.as_ref())
&& !same_guide_addresses(old, &st.doc)
{
sel.guides.clear();
return;
}
let mut seen = HashSet::new();
sel.guides.retain(|g| selectable(&st.doc, *g) && seen.insert(*g));
}
/// Every (spread, page, index) of `old` still names a guide of the same orientation on the same
/// page in `new`: guides were only added at the end of their pages, or moved.
fn same_guide_addresses(old: &Document, new: &Document) -> bool {
let same = |a: &[std::sync::Arc<designcraft_doc::Spread>], b: &[std::sync::Arc<designcraft_doc::Spread>]| {
a.len() == b.len()
&& a.iter().zip(b).all(|(a, b)| {
a.pages.len() == b.pages.len()
&& a.pages.iter().zip(&b.pages).all(|(a, b)| {
a.id == b.id
&& a.guides.len() <= b.guides.len()
&& a.guides.iter().zip(&b.guides).all(|(a, b)| a.orientation == b.orientation)
})
})
};
same(&old.spreads, &new.spreads) && same(&old.parents, &new.parents)
}
fn guide_divisions(p: &Value, key: &str) -> Result<u64> {
let Some(value) = p.get(key) else { return Ok(0) };
let count = value.as_u64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be an integer")))?;
if count > MAX_GUIDE_DIVISIONS {
return Err(bad("layout.createGuides", format!("`{key}` must not exceed {MAX_GUIDE_DIVISIONS}")));
}
Ok(count)
}
fn guide_gutter(p: &Value, key: &str) -> Result<f64> {
let Some(value) = p.get(key) else { return Ok(12.0) };
let gutter = value.as_f64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be a finite number")))?;
if !gutter.is_finite() || !(0.0..=MAX_GUIDE_GUTTER).contains(&gutter) {
return Err(bad("layout.createGuides", format!("`{key}` must be between 0 and {MAX_GUIDE_GUTTER}")));
}
Ok(gutter)
}
fn guide_count(divisions: u64, gutter: f64) -> Result<usize> {
let edges = divisions.saturating_sub(1);
let count = if gutter > 0.0 { edges.checked_mul(2) } else { Some(edges) }
.ok_or_else(|| bad("layout.createGuides", "the requested guide count is too large"))?;
usize::try_from(count).map_err(|_| bad("layout.createGuides", "the requested guide count is too large for this platform"))
}
/// Evenly spaced rows/columns with gutters inside `area`: the guide positions.
fn grid_positions(a: f64, b: f64, n: u64, gutter: f64) -> Result<Vec<f64>> {
if n < 2 {
return Ok(vec![]);
}
if !a.is_finite() || !b.is_finite() || !gutter.is_finite() || n > MAX_GUIDE_DIVISIONS {
return Err(bad("layout.createGuides", "guide geometry must be finite and within the supported range"));
}
let cell = ((b - a) - gutter * (n - 1) as f64) / n as f64;
if !cell.is_finite() {
return Err(bad("layout.createGuides", "guide geometry is outside the supported range"));
}
let mut v = Vec::with_capacity(guide_count(n, gutter)?);
for k in 1..n {
let edge = a + k as f64 * cell + (k - 1) as f64 * gutter;
if !edge.is_finite() || (gutter > 0.0 && !(edge + gutter).is_finite()) {
return Err(bad("layout.createGuides", "guide geometry is outside the supported range"));
}
v.push(edge);
if gutter > 0.0 {
v.push(edge + gutter);
}
}
Ok(v)
}
fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
let rows = guide_divisions(p, "rows")?;
let cols = guide_divisions(p, "columns")?;
let rg = guide_gutter(p, "rowGutter")?;
let cg = guide_gutter(p, "columnGutter")?;
let to_page = str_param(p, "fitTo") == Some("page");
let remove = p.get("removeExisting").and_then(Value::as_bool).unwrap_or(false);
let r = super::spread_param(p, "layout.createGuides")?;
let only = optional_usize(p, "page", "layout.createGuides")?;
let page_count = {
let sp = s.doc()?.doc.spread(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?;
match only {
Some(page) => {
if sp.pages.get(page).is_none() {
return Err(bad("layout.createGuides", "no such page"));
}
1
}
None => sp.pages.len(),
}
};
let per_page = guide_count(rows, rg)?
.checked_add(guide_count(cols, cg)?)
.ok_or_else(|| bad("layout.createGuides", "the requested guide count is too large"))?;
per_page
.checked_mul(page_count)
.filter(|count| *count <= MAX_GUIDES_CREATED)
.ok_or_else(|| bad("layout.createGuides", format!("a single command may create at most {MAX_GUIDES_CREATED} guides")))?;
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
if remove {
// The cleared pages' guide addresses name the new guides now.
sel.guides.retain(|g| g.spread != r || only.is_some_and(|o| o != g.page));
}
let sp = d.spread_mut(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?;
let mut n = 0usize;
for (pi, pg) in sp.pages.iter_mut().enumerate() {
if only.is_some_and(|o| o != pi) {
continue;
}
if remove {
pg.guides.clear();
}
let area: Rect = if to_page { pg.bounds() } else { pg.margin_rect() };
for y in grid_positions(area.y0, area.y1, rows, rg)? {
pg.guides.push(Guide {
orientation: Orientation::Horizontal,
position: y,
spread: false,
locked: false,
layer: Some(layer),
liquid: false,
});
n += 1;
}
for x in grid_positions(area.x0, area.x1, cols, cg)? {
pg.guides.push(Guide {
orientation: Orientation::Vertical,
position: x,
spread: false,
locked: false,
layer: Some(layer),
liquid: false,
});
n += 1;
}
}
Ok(json!({"guides": n}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lock_column_guides_toggles_sets_and_undoes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let locked = |s: &Session| s.doc().unwrap().doc.settings.column_guides_locked;
let undo_steps = |s: &Session| s.doc().unwrap().history.undo.len();
assert!(locked(&s), "locked in new documents");
let before = undo_steps(&s);
assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false);
assert!(!locked(&s));
assert_eq!(undo_steps(&s), before + 1, "one undo step per toggle");
s.execute("edit.undo", &json!({})).unwrap();
assert!(locked(&s), "undo restores the lock");
s.execute("edit.redo", &json!({})).unwrap();
assert!(!locked(&s), "redo unlocks again");
// Setting the current value changes nothing and adds no undo step.
let steps = undo_steps(&s);
assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap()["locked"], false);
assert_eq!(undo_steps(&s), steps);
assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap()["locked"], true);
assert!(locked(&s));
assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false);
// Saved with the document; files without the setting read as locked.
let json = serde_json::to_string(&*s.doc().unwrap().doc).unwrap();
assert!(json.contains("\"columnGuidesLocked\":false"), "{json}");
let back: Document = serde_json::from_str(&json).unwrap();
assert!(!back.settings.column_guides_locked);
let old = json.replace("\"columnGuidesLocked\":false,", "").replace(",\"columnGuidesLocked\":false", "");
assert!(!old.contains("columnGuidesLocked"));
let back: Document = serde_json::from_str(&old).unwrap();
assert!(back.settings.column_guides_locked);
}
#[test]
fn guides_add_move_delete_and_grid() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3})).unwrap();
// Spread 1 holds pages 2–3 side by side from x = 0.
let r = s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 100.0})).unwrap();
assert_eq!(r["page"], 0);
let r = s.execute("guide.add", &json!({"spread": 1, "orientation": "horizontal", "position": 200.0, "at": 700.0})).unwrap();
assert_eq!(r["page"], 1);
s.execute("guide.move", &json!({"spread": 1, "page": 1, "index": 0, "position": 250.0})).unwrap();
let l = s.execute("guide.list", &json!({"spread": 1})).unwrap();
assert_eq!(l.as_array().unwrap().len(), 2);
assert_eq!(l[1]["position"], 250.0);
s.execute("guide.delete", &json!({"spread": 1, "page": 0, "index": 0})).unwrap();
assert_eq!(s.execute("guide.list", &json!({"spread": 1})).unwrap().as_array().unwrap().len(), 1);
// 3 columns with 12 pt gutters inside the margins: 4 guides; 2 rows without gutter: 1 guide.
let r = s.execute("layout.createGuides", &json!({"spread": 0, "columns": 3, "rows": 2, "rowGutter": 0.0})).unwrap();
assert_eq!(r["guides"], 5);
let d = &s.doc().unwrap().doc;
let pg = &d.spreads[0].pages[0];
let m = pg.margin_rect();
let v: Vec<f64> = pg.guides.iter().filter(|g| g.orientation == Orientation::Vertical).map(|g| g.position).collect();
let cell = (m.width() - 24.0) / 3.0;
assert!((v[0] - (m.x0 + cell)).abs() < 1e-9 && (v[1] - (m.x0 + cell + 12.0)).abs() < 1e-9);
s.execute("guide.deleteAll", &json!({"spread": 0})).unwrap();
assert!(s.doc().unwrap().doc.spreads[0].pages[0].guides.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].guides.len(), 5);
}
#[test]
fn both_orientations_are_one_undo_step() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("guide.add", &json!({"orientation": "both", "point": [120.0, 80.0], "spreadGuide": true})).unwrap();
assert_eq!(r["guides"].as_array().unwrap().len(), 2);
let l = s.execute("guide.list", &json!({})).unwrap();
assert_eq!((l[0]["orientation"].as_str(), l[0]["position"].as_f64()), (Some("horizontal"), Some(80.0)));
assert_eq!((l[1]["orientation"].as_str(), l[1]["position"].as_f64()), (Some("vertical"), Some(120.0)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("guide.list", &json!({})).unwrap(), json!([]));
assert!(s.execute("guide.add", &json!({"orientation": "both", "point": [1.0]})).is_err());
assert!(s.execute("guide.add", &json!({"orientation": "both", "position": 1.0})).is_err());
}
#[test]
fn create_guides_validates_generation_limits() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
assert!(s.execute("layout.createGuides", &json!({"columns": MAX_GUIDE_DIVISIONS + 1})).is_err());
assert!(s.execute("layout.createGuides", &json!({"columns": 2, "columnGutter": -1})).is_err());
assert!(s.execute("layout.createGuides", &json!({"columns": "many"})).is_err());
let r = s.execute("layout.createGuides", &json!({"columns": MAX_GUIDE_DIVISIONS, "columnGutter": 0})).unwrap();
assert_eq!(r["guides"], MAX_GUIDE_DIVISIONS - 1);
}
#[test]
fn mutating_guide_commands_reject_invalid_indices() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 100})).unwrap();
assert!(s.execute("guide.add", &json!({"orientation": "vertical", "position": 100, "page": -1})).is_err());
assert!(s.execute("guide.move", &json!({"page": 0.5, "index": 0, "position": 200})).is_err());
assert!(s.execute("guide.delete", &json!({"page": 0, "index": -1})).is_err());
assert!(s.execute("guide.deleteAll", &json!({"spread": "0"})).is_err());
assert!(s.execute("layout.createGuides", &json!({"spread": -1, "columns": 2})).is_err());
assert!(s.execute("layout.createGuides", &json!({"page": u64::MAX, "columns": 2})).is_err());
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 1);
s.execute("guide.add", &json!({"spread": {"kind": "doc", "index": 0}, "page": 0, "orientation": "horizontal", "position": 150})).unwrap();
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
}
}
#[cfg(test)]
mod selection_tests {
use serde_json::{Value, json};
use crate::Session;
/// A page with vertical guides at 100, 200, 300 and a horizontal one at 400.
fn guides() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for x in [100.0, 200.0, 300.0] {
s.execute("guide.add", &json!({"orientation": "vertical", "position": x})).unwrap();
}
s.execute("guide.add", &json!({"orientation": "horizontal", "position": 400.0})).unwrap();
s
}
fn selected(s: &Session) -> Vec<usize> {
s.doc().unwrap().selection.guides.iter().map(|g| g.index).collect()
}
fn positions(s: &mut Session) -> Vec<f64> {
let l = s.execute("guide.list", &json!({})).unwrap();
l.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect()
}
#[test]
fn selecting_guides_clears_objects_and_the_other_way_round() {
let mut s = guides();
let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("guide.select", &json!({"page": 0, "index": 1})).unwrap();
assert!(s.doc().unwrap().selection.items.is_empty());
assert_eq!(selected(&s), [1]);
// Shift-click adds, Shift-click again removes.
s.execute("guide.select", &json!({"page": 0, "index": 3, "toggle": true})).unwrap();
assert_eq!(selected(&s), [1, 3]);
s.execute("guide.select", &json!({"page": 0, "index": 1, "toggle": true})).unwrap();
assert_eq!(selected(&s), [3]);
let r = s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}], "add": true})).unwrap();
assert_eq!(r["selected"], 3);
let l = s.execute("guide.list", &json!({})).unwrap();
assert_eq!(l.as_array().unwrap().iter().map(|g| g["selected"] == true).collect::<Vec<_>>(), [true, false, true, true]);
// Selecting an object (Shift-click too) deselects the guides.
s.execute("selection.toggle", &json!({"id": id})).unwrap();
assert!(selected(&s).is_empty());
s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
assert!(selected(&s).is_empty());
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("guide.deselect", &json!({})).unwrap();
assert!(selected(&s).is_empty());
// No such guide.
assert!(s.execute("guide.select", &json!({"page": 0, "index": 9})).is_err());
assert!(s.execute("guide.select", &json!({"page": 3, "index": 0})).is_err());
assert!(s.execute("guide.select", &json!({"guides": "all"})).is_err());
}
#[test]
fn select_all_guides_skips_locked_ones() {
let mut s = guides();
let layer = s.execute("layer.new", &json!({"name": "Grid"})).unwrap()["id"].as_u64().unwrap();
s.execute("layer.activate", &json!({"id": layer})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 500.0})).unwrap();
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
assert_eq!(s.execute("guide.selectAll", &json!({})).unwrap()["selected"], 4);
assert_eq!(selected(&s), [0, 1, 2, 3]);
assert!(s.execute("guide.select", &json!({"page": 0, "index": 4})).is_err());
// Locking the layer of a selected guide deselects it.
s.execute("layer.set", &json!({"id": layer, "locked": false})).unwrap();
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
assert_eq!(selected(&s), [0, 1, 2, 3]);
let spec = crate::cmd::find_command("guide.selectAll").unwrap();
assert_eq!((spec.menu, spec.shortcut), (&["Edit"][..], Some("Cmd+Alt+G")));
}
#[test]
fn selected_guides_move_and_delete_as_one_undo_step() {
let mut s = guides();
s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap();
// Vertical guides move by dx, horizontal ones by dy.
s.execute("guide.moveBy", &json!({"dx": 10.0, "dy": -5.0})).unwrap();
assert_eq!(positions(&mut s), [110.0, 200.0, 310.0, 395.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]);
assert_eq!(selected(&s), [0, 2, 3]);
// Delete (edit.clear, as the Delete key) takes all three; the guide left is the one at 200.
assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 3);
assert_eq!(positions(&mut s), [200.0]);
assert!(selected(&s).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]);
assert_eq!(selected(&s), [0, 2, 3]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [200.0]);
assert!(s.execute("guide.moveBy", &json!({"dx": 1.0})).is_err(), "nothing selected");
// A guide stays within 1000000 pt of the origin.
assert!(s.execute("guide.moveBy", &json!({"dx": f64::MAX, "guides": [{"page": 0, "index": 0}]})).is_err());
assert!(s.execute("guide.moveBy", &json!({"dx": 1e6, "guides": [{"page": 0, "index": 0}]})).is_err());
assert_eq!(positions(&mut s), [200.0]);
s.execute("guide.moveBy", &json!({"dx": -1e6 - 200.0, "guides": [{"page": 0, "index": 0}]})).unwrap();
assert_eq!(positions(&mut s), [-1e6]);
assert!(s.execute("guide.move", &json!({"page": 0, "index": 0, "position": 1e6 + 1.0})).is_err());
assert!(s.execute("guide.add", &json!({"orientation": "vertical", "position": -2e6})).is_err());
assert!(s.execute("guide.add", &json!({"orientation": "both", "point": [0.0, 1e300]})).is_err());
assert_eq!(positions(&mut s), [-1e6]);
}
#[test]
fn the_guide_selection_follows_deletes_and_drops_stale_guides() {
let mut s = guides();
s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap();
// Deleting a guide before them renumbers them.
s.execute("guide.delete", &json!({"page": 0, "index": 0})).unwrap();
assert_eq!(selected(&s), [1, 2]);
assert_eq!(positions(&mut s), [200.0, 300.0, 400.0]);
// Deleting a selected one deselects it.
s.execute("guide.delete", &json!({"page": 0, "index": 1})).unwrap();
assert_eq!(selected(&s), [1]);
// Undo puts back the selection that went with the document.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [1, 2]);
// A new guide is the selection.
s.execute("guide.add", &json!({"orientation": "vertical", "position": 50.0})).unwrap();
assert_eq!(selected(&s), [3]);
// Undoing the add to before the first delete: those addresses held other guides then.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [2, 3]);
// Create Guides replacing the page's guides: their addresses name new guides, so the
// selection goes.
s.execute("layout.createGuides", &json!({"columns": 2, "removeExisting": true})).unwrap();
assert!(selected(&s).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap();
s.execute("layout.createGuides", &json!({"columns": 2, "columnGutter": 0})).unwrap();
assert_eq!(selected(&s), [0], "guides only added: the addresses still hold");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("guide.deleteAll", &json!({})).unwrap();
assert!(selected(&s).is_empty());
// Deleting the page goes with it.
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 100.0})).unwrap();
s.execute("guide.selectAll", &json!({"spread": 1})).unwrap();
assert_eq!(selected(&s), [0]);
s.execute("layout.pages.delete", &json!({"pages": [1]})).unwrap();
assert!(selected(&s).is_empty());
let _: Value = s.execute("guide.list", &json!({})).unwrap();
}
}
#[cfg(test)]
mod new_guide_tests {
use designcraft_doc::GuideSel;
use serde_json::json;
use crate::Session;
fn selected(s: &Session) -> Vec<GuideSel> {
s.doc().unwrap().selection.guides.clone()
}
#[test]
fn a_new_guide_is_the_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 100.0})).unwrap();
let spread = s.doc().unwrap().selection.guides[0].spread;
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]);
assert!(s.doc().unwrap().selection.items.is_empty(), "objects are deselected");
// Both ways (Cmd-drag from the ruler corner): the two new guides.
s.execute("guide.add", &json!({"orientation": "both", "point": [50.0, 60.0], "spreadGuide": true})).unwrap();
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 1 }, GuideSel { spread, page: 0, index: 2 }]);
// Undo gives back the selection from before.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]);
}
#[test]
fn delete_takes_a_whole_address_or_the_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3})).unwrap();
for spread in [0, 1] {
s.execute("guide.add", &json!({"spread": spread, "orientation": "vertical", "position": 100.0})).unwrap();
}
s.execute("guide.select", &json!({"spread": 1, "page": 0, "index": 0})).unwrap();
// A spread without page and index is not "the selection".
assert!(s.execute("guide.delete", &json!({"spread": 0})).is_err());
assert!(s.execute("guide.delete", &json!({"spread": 1, "page": 0})).is_err());
assert_eq!(selected(&s).len(), 1);
// A selection naming a guide twice, apart, deletes it once.
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 200.0})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 300.0})).unwrap();
let spread = selected(&s)[0].spread;
let at = |index| GuideSel { spread, page: 0, index };
s.doc_mut().unwrap().selection.guides = vec![at(0), at(2), at(0)];
assert_eq!(s.execute("guide.delete", &json!({})).unwrap()["deleted"], 2);
let left = s.execute("guide.list", &json!({"spread": 1})).unwrap();
assert_eq!(left.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect::<Vec<_>>(), [200.0]);
assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap().as_array().unwrap().len(), 1);
}
}
#[cfg(test)]
mod layer_tests {
use serde_json::json;
use crate::Session;
#[test]
fn guide_commands_respect_layer_locks() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let layer = s.execute("layer.new", &json!({"name": "Grid"})).unwrap()["id"].as_u64().unwrap();
s.execute("layer.activate", &json!({"id": layer})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 100})).unwrap();
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
for command in ["guide.move", "guide.delete"] {
assert!(s.execute(command, &json!({"page": 0, "index": 0, "position": 200})).is_err(), "{command} changed a locked layer");
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[0]["position"], 100.0);
}
s.execute("layer.set", &json!({"id": layer, "locked": false})).unwrap();
s.execute("guide.move", &json!({"page": 0, "index": 0, "position": 200})).unwrap();
s.execute("guide.delete", &json!({"page": 0, "index": 0})).unwrap();
assert_eq!(s.execute("guide.list", &json!({})).unwrap(), json!([]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[0]["position"], 200.0);
}
#[test]
fn guides_follow_their_layer() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let l = s.execute("layer.new", &json!({"name": "Grid"})).unwrap()["id"].as_u64().unwrap();
s.execute("layer.activate", &json!({"id": l})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 100})).unwrap();
let g = |s: &Session| s.doc().unwrap().doc.spreads[0].pages[0].guides[0].clone();
assert_eq!(g(&s).layer.map(|x| x.0), Some(l));
assert!(g(&s).visible_in(&s.doc().unwrap().doc));
s.execute("layer.set", &json!({"id": l, "visible": false})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(!g(&s).visible_in(&d) && !g(&s).editable_in(&d));
s.execute("layer.set", &json!({"id": l, "visible": true, "locked": true})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(g(&s).visible_in(&d) && !g(&s).editable_in(&d));
}
#[test]
fn create_guides_goes_to_the_named_spread_and_page_only() {
use designcraft_doc::SpreadRef;
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3})).unwrap();
// The UI passes the spread/page being worked on as a serialised SpreadRef (page 3).
let spread = serde_json::to_value(SpreadRef::Doc(1)).unwrap();
s.execute("layout.createGuides", &json!({"spread": spread, "page": 1, "columns": 2, "columnGutter": 0.0})).unwrap();
let l = s.execute("guide.list", &json!({"spread": 1})).unwrap();
let l = l.as_array().unwrap();
assert!(!l.is_empty() && l.iter().all(|g| g["page"] == 1), "only page 3: {l:?}");
let first = s.execute("guide.list", &json!({"spread": 0})).unwrap();
assert!(first.as_array().unwrap().is_empty(), "nothing on the first page");
}
#[test]
fn guides_on_a_parent_page_show_on_the_pages_that_use_it() {
use designcraft_doc::{PageSide, SpreadRef};
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4, "facingPages": true})).unwrap();
// A vertical guide 88 pt inside the parent's right page, and a horizontal one.
let parent = serde_json::to_value(SpreadRef::Parent(0)).unwrap();
let right_x = s.doc().unwrap().doc.parents[0].pages.last().unwrap().x;
s.execute("guide.add", &json!({"spread": parent, "orientation": "vertical", "position": right_x + 88.0})).unwrap();
s.execute("guide.add", &json!({"spread": parent, "orientation": "horizontal", "position": 200.0, "at": right_x + 10.0})).unwrap();
let d = &s.doc().unwrap().doc;
let mut seen = 0;
for abs in 0..d.page_count() {
let page = d.page(abs).unwrap();
let (pi, qi) = d.parent_page_for(abs).unwrap();
let inherited = d.inherited_guides(abs);
if d.parents[pi].pages[qi].guides.is_empty() {
assert!(inherited.is_empty(), "page {abs} uses the parent's left page, which has no guides");
continue;
}
seen += 1;
assert_eq!(page.side, PageSide::Right);
let v = inherited.iter().find(|g| g.orientation == designcraft_doc::Orientation::Vertical).unwrap();
// Moved to this page's own place in its spread: still 88 pt inside the page.
assert!((v.position - (page.x + 88.0)).abs() < 1e-6, "page {abs}: {v:?} vs x {}", page.x);
let h = inherited.iter().find(|g| g.orientation == designcraft_doc::Orientation::Horizontal).unwrap();
assert!((h.position - 200.0).abs() < 1e-6);
}
assert!(seen >= 2, "pages on the parent's right side inherit: {seen}");
// The guides stay on the parent: nothing is copied onto document pages.
assert!((0..d.page_count()).all(|a| d.page(a).unwrap().guides.is_empty()));
}
}