//! Layout menu and Pages/Layers panels: pages, spreads, parents, margins & columns, layers. use designcraft_doc::{LAYER_COLORS, Layer, LayerId, Margins, SpreadId}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, ok, str_param}; use crate::{Result, Session}; /// A start page number parameter: a whole number in 1..=[`designcraft_doc::MAX_PAGE_NUMBER`]. fn start_number(cmd: &str, key: &str, v: &Value) -> Result { v.as_u64() .and_then(|n| u32::try_from(n).ok()) .filter(|n| (1..=designcraft_doc::MAX_PAGE_NUMBER).contains(n)) .ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number from 1 to {}", designcraft_doc::MAX_PAGE_NUMBER))) } /// Each page's margin box, by spread and page id. fn margin_boxes(d: &designcraft_doc::Document) -> Vec<(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)> { d.spread_refs() .filter_map(|r| d.spread(r).map(|sp| (r, sp))) .flat_map(|(r, sp)| sp.pages.iter().map(move |pg| (r, pg.id, pg.margin_rect()))) .collect() } /// Layout Adjustment: objects on each page move and resize from its old margin box to its new /// one (content keeps its size; frames that auto-fit refit). fn adjust_layout(d: &mut designcraft_doc::Document, before: &[(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)]) { use designcraft_geom::Affine; let after = margin_boxes(d); for (r, pid, old) in before { let Some((_, _, new)) = after.iter().find(|(r2, p2, _)| r2 == r && p2 == pid) else { continue }; if old == new || old.width() < 1e-6 || old.height() < 1e-6 { continue; } let m = Affine::translate((new.x0, new.y0)) * Affine::scale_non_uniform(new.width() / old.width(), new.height() / old.height()) * Affine::translate((-old.x0, -old.y0)); let Some(sp) = d.spread(*r) else { continue }; // The page's objects (by centre, measured against the old boxes). let ids: Vec = sp .items .iter() .filter(|it| { let c = it.bounds().center(); // Pages share a spread side by side: the margin box's page is the one under the centre's x. before .iter() .filter(|(r2, _, _)| r2 == r) .min_by(|a, b| dist(a.2, c.x).total_cmp(&dist(b.2, c.x))) .is_some_and(|(_, p2, _)| p2 == pid) }) .map(|it| it.id) .collect(); for id in ids { let Some(it) = d.item_mut(id) else { continue }; if matches!(it.content, designcraft_doc::Content::Group { .. }) { it.xf = m * it.xf; } else { let inner = it.xf.inverse() * m * it.xf; it.path.transform(inner); if let Some(w) = &mut it.wrap.path { w.transform(inner); } let ib = it.inner_bounds(); if let designcraft_doc::Content::Graphic(g) = &mut it.content { // Content keeps its size but moves with the frame; auto-fit refits. let o = inner * designcraft_geom::Point::ZERO; g.xf = Affine::translate(o.to_vec2()) * g.xf; if let Some(xf) = g.fitted(ib, g.auto_fit) { g.xf = xf; } } } } } } /// Horizontal distance from `x` to a margin box (0 inside). fn dist(r: designcraft_geom::Rect, x: f64) -> f64 { if x < r.x0 { r.x0 - x } else if x > r.x1 { x - r.x1 } else { 0.0 } } pub fn specs() -> Vec { vec![ cmd!( "layout.pages.insert", "Insert Pages…", ["Layout", "Pages"], Some("Cmd+Shift+P"), "{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}", has_doc, |s, p| { let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize; let st = s.doc()?; let n = st.doc.page_count(); let after = p.get("after").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(n - 1); let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); s.edit(|d, _| { let ids = d.insert_pages(Some(after), count, parent)?; Ok(json!({"pages": ids.len(), "total": d.page_count()})) }) } ), cmd!( "layout.rotateSpreadView", "Rotate Spread", [], None, "{spread?: index (0), angle: 90 (clockwise) | -90 | 180 | 0 (clear)} — turn one spread on screen (output is unaffected)", has_doc, |s, p| { let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; let angle = match p.get("angle") { None => 90, Some(v) => v.as_i64().ok_or_else(|| bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"))?, }; if angle % 90 != 0 { return Err(bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees")); } s.edit(|d, _| { let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.rotateSpreadView", "no such spread"))?; let pg = std::sync::Arc::make_mut(sp).pages.first_mut().ok_or_else(|| bad("layout.rotateSpreadView", "empty spread"))?; pg.view_rotation = if angle == 0 { 0 } else { ((pg.view_rotation as i64 + angle.div_euclid(90)).rem_euclid(4)) as u8 }; Ok(json!({"rotation": pg.view_rotation as u32 * 90})) }) } ), cmd!("layout.pages.delete", "Delete Pages", ["Layout", "Pages"], None, "{pages: [index]}", has_doc, |s, p| { let pages: Vec = p .get("pages") .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()) .unwrap_or_default(); s.edit(|d, sel| { d.delete_pages(&pages)?; sel.items.retain(|i| d.item(*i).is_some()); sel.text = sel.text.filter(|t| d.story(t.story).is_some()); ok() }) }), cmd!("layout.pages.move", "Move Pages…", ["Layout", "Pages"], None, "{from, to}", has_doc, |s, p| { let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing from"))? as usize; let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing to"))? as usize; s.edit(|d, _| { d.move_page(from, to)?; ok() }) }), cmd!("layout.pages.duplicateSpread", "Duplicate Spread", ["Layout", "Pages"], None, "{spread}", has_doc, |s, p| { let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; s.edit(|d, _| { let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; let after = d.first_page_of_spread(si) + sp.pages.len() - 1; let parent = sp.pages.first().and_then(|p| p.parent); d.insert_pages(Some(after), sp.pages.len(), parent)?; // Copy items onto the new spread(s) at the same positions. let src = d.clone(); let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; ok() }) }), cmd!( "layout.pages.applyParent", "Apply Parent to Pages…", ["Layout", "Pages"], None, "{pages: [index], parent: id|\"A-Parent\"|\"A\"|null (null or \"[None]\" clears)}", has_doc, |s, p| { let pages: Vec = p .get("pages") .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()) .unwrap_or_default(); let parent = parent_ref("layout.pages.applyParent", &s.doc()?.doc, p.get("parent"))?.unwrap_or(None); s.edit(|d, _| { d.apply_parent(&pages, parent)?; ok() }) } ), cmd!("layout.parents.new", "New Parent…", ["Layout", "Pages"], None, "{prefix?, name?: \"Parent\"}", has_doc, |s, p| { let name = str_param(p, "name").unwrap_or("Parent").to_string(); s.edit(|d, _| { let prefix = str_param(p, "prefix").map(str::to_string).unwrap_or_else(|| d.next_parent_prefix()); let m = d.page(0).map(|p| (p.columns.count, p.columns.gutter, p.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0))); let id = d.add_parent(&prefix, &name, m.0, m.1, m.2); Ok(json!({"id": id.0, "prefix": prefix})) }) }), cmd!( noundo "layout.parents.edit", "Edit Parents", [], None, "{on?: bool, parent?: id|\"A-Parent\"|\"A\"} — show parent spreads on the canvas; `parent` shows that parent (and implies on). Turning on without `parent` shows the first parent", has_doc, |s, p| { const ID: &str = "layout.parents.edit"; let doc = s.doc()?.doc.clone(); let parent = match p.get("parent") { None | Some(Value::Null) => None, v => match parent_ref(ID, &doc, v)? { Some(Some(id)) => Some(id), _ => return Err(bad(ID, "`parent` names a parent to show")), }, }; let st = s.doc_mut()?; let was = st.editing_parents; let on = p.get("on").and_then(Value::as_bool).unwrap_or(parent.is_some() || !was); st.editing_parents = on; st.selection = Default::default(); st.bump_revision(); // The parent to show: the one asked for, or the first when editing starts. let index = match parent { Some(id) => doc.parent_index(id), None if !was && !doc.parents.is_empty() => Some(0), None => None, }; let shown = index.filter(|_| on); if let Some(i) = shown { s.ui_requests.push(crate::UiRequest::View { params: json!({"showSpread": designcraft_doc::SpreadRef::Parent(i)}) }); } Ok(json!({"on": on, "parent": shown.and_then(|i| doc.parents.get(i)).map(|x| x.id.0)})) } ), cmd!( "layout.parents.options", "Parent Options…", [], None, "{parent: id|\"B-Parent\"|\"B\", basedOn: id|\"A-Parent\"|\"A\"|null (= [None])} — the parent shows its base's items under its own, and so do the pages that use it → {parent, basedOn}", has_doc, parent_options ), cmd!( "layout.marginsAndColumns", "Margins and Columns…", ["Layout"], None, "{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins), parent? (a parent's prefix, label or index)} — document pages take these as their own margins; with `parent` the parent's pages change instead (`pages` then indexes them), and so do the document pages without their own", has_doc, |s, p| { let pages: Option> = p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()); let parent = match p.get("parent") { None | Some(Value::Null) => None, Some(v) => Some(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad("layout.marginsAndColumns", "no such parent"))?), }; let p = p.clone(); let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); s.edit(|d, _| { let before = adjust.then(|| margin_boxes(d)); if let Some(pi) = parent { let n = d.parents.get(pi).map_or(0, |sp| sp.pages.len()); let sp = d.parents.get_mut(pi).map(std::sync::Arc::make_mut); if let Some(sp) = sp { for qi in pages.clone().unwrap_or_else(|| (0..n).collect()) { if let Some(pg) = sp.pages.get_mut(qi) { set_margins_columns(pg, &p); } } } d.sync_parent_margins(); } else { let all: Vec = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect()); for abs in all { let Some((si, pi)) = d.page_loc(abs) else { continue }; let Some(pg) = d.spreads.get_mut(si).map(std::sync::Arc::make_mut).and_then(|sp| sp.pages.get_mut(pi)) else { continue }; set_margins_columns(pg, &p); pg.local_margins = true; } } if let Some(b) = before { adjust_layout(d, &b); } ok() }) } ), cmd!( "layout.documentSetup", "Document Setup…", ["File"], Some("Cmd+Alt+P"), "{width?, height?, pages?: count, startPage?: n, facingPages?, binding?: leftToRight|rightToLeft, intent?: print|web|mobile, margins?: n | {top, bottom, inside, outside} (every page and parent page), bleed?, slug?: n | [top, bottom, inside, outside], adjustLayout?: bool (objects follow the new page size and margins)} → the document setup (margins: the first page's)", has_doc, |s, p| { let p = p.clone(); if p.as_object().is_none_or(|o| o.is_empty()) { return Ok(document_setup(&s.doc()?.doc)); } let edges = |k: &str| -> Option<[f64; 4]> { match p.get(k)? { Value::Array(a) if a.len() == 4 => { let v: Vec = a.iter().filter_map(Value::as_f64).map(|v| v.max(0.0)).collect(); (v.len() == 4).then(|| [v[0], v[1], v[2], v[3]]) } v => v.as_f64().map(|b| [b.max(0.0); 4]), } }; let (bleed, slug) = (edges("bleed"), edges("slug")); for k in ["width", "height"] { if let Some(v) = p.get(k) && !v.as_f64().is_some_and(|x| x > 0.0 && x <= 15552.0) { return Err(bad("layout.documentSetup", format!("{k}: page size out of range (0 < size ≤ 216 in)"))); } } let margins: Option = match p.get("margins") { None => None, Some(Value::Number(n)) => n.as_f64().map(Margins::uniform), Some(v) => serde_json::from_value(v.clone()).ok(), }; if margins.is_some_and(|m| ![m.top, m.bottom, m.inside, m.outside].iter().all(|v| v.is_finite() && (0.0..=15552.0).contains(v))) { return Err(bad("layout.documentSetup", "margins: 0 to 216 in")); } if p.get("margins").is_some() && margins.is_none() { return Err(bad("layout.documentSetup", "margins: a number or {top, bottom, inside, outside}")); } let intent: Option = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()); let pages = p.get("pages").and_then(Value::as_u64).map(|n| n.clamp(1, 9999) as usize); let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); s.edit(|d, _| { let before = adjust.then(|| margin_boxes(d)); if let Some(f) = p.get("facingPages").and_then(Value::as_bool) { d.settings.facing_pages = f; } if let Some(b) = p.get("binding").and_then(Value::as_str) { d.settings.right_to_left_binding = b == "rightToLeft"; d.order_parent_pages(); } if let Some(b) = bleed { d.settings.bleed = b; } if let Some(b) = slug { d.settings.slug = b; } if let Some(i) = intent { d.settings.intent = i; } if let Some(n) = p.get("startPage").map(|v| start_number("layout.documentSetup", "startPage", v)).transpose()? { if !d.sections.iter().any(|x| x.start == 0) { d.sections.insert( 0, designcraft_doc::Section { start: 0, start_number: None, style: Default::default(), prefix: String::new(), marker: String::new(), include_prefix: false, }, ); } if let Some(first) = d.sections.iter_mut().find(|x| x.start == 0) { first.start_number = Some(n); } } // Number of Pages: add at the end (with the last page's parent) or remove from the end. if let Some(n) = pages { let have = d.page_count(); if n > have { let parent = d.page_loc(have - 1).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); d.insert_pages(Some(have - 1), n - have, parent)?; } else if n < have { d.delete_pages(&(n..have).collect::>())?; } } let w = p.get("width").and_then(Value::as_f64); let h = p.get("height").and_then(Value::as_f64); // Pages with a liquid rule re-flow their objects (Adjust Layout aside). let liquid_before: Vec<(usize, usize, designcraft_geom::Rect)> = if adjust { vec![] } else { d.spreads .iter() .enumerate() .flat_map(|(si, sp)| { sp.pages.iter().enumerate().filter(|(_, pg)| !pg.liquid.is_off()).map(move |(pi, pg)| (si, pi, pg.bounds())) }) .collect() }; if w.is_some() || h.is_some() { d.settings.page_width = w.unwrap_or(d.settings.page_width); d.settings.page_height = h.unwrap_or(d.settings.page_height); for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { let sp = std::sync::Arc::make_mut(sp); for pg in &mut sp.pages { pg.width = d.settings.page_width; pg.height = d.settings.page_height; } sp.relayout(); } } if let Some(m) = margins { for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { for pg in &mut std::sync::Arc::make_mut(sp).pages { pg.margins = m; } } // Pages that now match their parent page follow its later changes; local // columns stay local. d.unmark_matching_local_margins(); d.sync_parent_margins(); } d.repaginate(); if let Some(b) = before { adjust_layout(d, &b); } for (si, pi, old) in liquid_before { super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, old); } Ok(document_setup(d)) }) } ), cmd!( "layout.pages.toSpread", "Add Page to Spread", ["Layout", "Pages"], None, "{page (1-based), spread (0-based)} — the page joins that spread (up to 10 pages; the spread stops shuffling)", has_doc, |s, p| { let page = p.get("page").and_then(Value::as_u64).filter(|n| *n >= 1).ok_or_else(|| bad("layout.pages.toSpread", "missing page"))? as usize; let spread = p.get("spread").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.toSpread", "missing spread"))? as usize; s.edit(|d, _| { d.page_to_spread(page - 1, spread)?; Ok(json!({"spreads": d.spreads.len()})) }) } ), cmd!( "layout.spreadShuffle", "Allow Selected Spread to Shuffle", ["Layout", "Pages"], None, "{spread (0-based), allow: bool} — off keeps the spread's pages together when pages are added or removed", has_doc, |s, p| { let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; let allow = p.get("allow").and_then(Value::as_bool).unwrap_or(true); s.edit(|d, _| { let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.spreadShuffle", "no such spread"))?; std::sync::Arc::make_mut(sp).allow_shuffle = allow; if allow { d.repaginate(); } ok() }) } ), cmd!( "layout.pageSize", "Page Size", [], None, "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule", has_doc, page_size ), cmd!( "layout.pageOptions", "Page Options", [], None, "{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)", has_doc, page_options ), cmd!( noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { let n = s.doc()?.doc.page_count(); let page = match p.get("page") { None | Some(Value::Null) => None, Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), }; let ids: Vec = { let d = &s.doc()?.doc; p.get("ids") .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect()) .unwrap_or_default() }; let st = s.doc_mut()?; if page.is_some() { st.selection = designcraft_doc::Selection::items(ids); } st.selection.page = page; st.revision += 1; Ok(json!({"page": page.map(|p| p + 1)})) } ), cmd!( "layout.section", "Numbering & Section Options…", ["Layout"], None, "{page (1-based; starts a section there), startNumber?: n (1–99999)|null (continue), style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits|kanji, prefix? (up to 8 characters; no + or comma), includePrefix?, marker?, remove?: bool}", has_doc, |s, p| { let edit = SectionEdit::parse("layout.section", s, p)?; s.edit(|d, _| { edit.apply(d); Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>()})) }) } ), cmd!( "layout.chapterNumbering", "Document Chapter Numbering", [], None, "{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits, start?: n (Start Chapter Numbering at), source?: automatic|userDefined|sameAsPrevious} → {chapterNumber, label, style, source, book: the open book holding the document, or null}; no parameters reports the setting", has_doc, |s, p| { if p.as_object().is_none_or(|o| o.is_empty()) { return chapter_numbering(s); } let edit = ChapterEdit::parse("layout.chapterNumbering", p)?; s.edit(|d, _| { edit.apply(d); ok() })?; chapter_numbering(s) } ), cmd!( "layout.numberingOptions", "Apply Numbering & Section Options", [], None, "{page, …the settings as layout.section, chapter?: {…the settings as layout.chapterNumbering}} — the section and the document's chapter numbering in one undo step; if either is refused nothing changes", has_doc, |s, p| { let section = SectionEdit::parse("layout.numberingOptions", s, p)?; let chapter = match p.get("chapter") { Some(c) => Some(ChapterEdit::parse("layout.numberingOptions", c)?), None => None, }; s.edit(|d, _| { section.apply(d); if let Some(chapter) = &chapter { chapter.apply(d); } Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>(), "chapter": d.chapter_label()})) }) } ), cmd!("layer.move", "Move Layer", [], None, "{id, to: index (0 = top/frontmost)}", has_doc, |s, p| { let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing id"))?); let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing to"))?; s.edit(|d, _| { let i = d.layers.iter().position(|l| l.id == id).ok_or_else(|| bad("layer.move", "no such layer"))?; let to = usize::try_from(to).ok().filter(|t| *t < d.layers.len()).ok_or_else(|| bad("layer.move", "to out of range"))?; let l = d.layers.remove(i); d.layers.insert(to, l); ok() }) }), cmd!(noundo "layer.selectItems", "Select All on Layer", [], None, "{id}", has_doc, |s, p| { let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); let st = s.doc_mut()?; let ids: Vec<_> = st.doc.spreads.iter().flat_map(|sp| sp.items.iter().filter(|i| i.layer == id && !i.locked).map(|i| i.id)).collect(); st.selection = designcraft_doc::Selection::items(ids); st.bump_revision(); ok() }), cmd!("layer.new", "New Layer…", [], None, "{name?}", has_doc, |s, p| { let name = str_param(p, "name").map(str::to_string); let r = s.edit(|d, _| { let id = LayerId(d.alloc()); let n = d.layers.len(); let name = name.clone().unwrap_or_else(|| format!("Layer {}", n + 1)); d.layers.insert( 0, Layer { id, name, color: LAYER_COLORS[n % LAYER_COLORS.len()].1, visible: true, locked: false, printable: true, show_guides: true, suppress_wrap_when_hidden: false, }, ); Ok(id) })?; s.doc_mut()?.active_layer = r; Ok(json!({"id": r.0})) }), cmd!( "layer.set", "Layer Options…", [], None, "{id, name?, color?: [r,g,b] | layer colour name (\"Light Blue\", \"Red\", …), visible?, locked?, printable?, showGuides?, suppressWrapWhenHidden?} — names are unique and not empty", has_doc, set_layer ), cmd!("layer.delete", "Delete Layer", [], None, "{id}", has_doc, |s, p| { let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); s.edit(|d, sel| { if d.layers.len() < 2 { return Err(bad("layer.delete", "a document needs at least one layer")); } let doomed: Vec<_> = d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().filter(|i| i.layer == id).map(|i| i.id)).collect(); for i in doomed { let _ = d.remove_item(i); } d.layers.retain(|l| l.id != id); *sel = Default::default(); ok() }) }), cmd!( "layer.merge", "Merge Layers", [], None, "{ids: [layer ids], into?: layer id (default: the first)} — their objects move to `into`, the other layers are deleted", has_doc, |s, p| { let ids: Vec = p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(LayerId).collect()).unwrap_or_default(); let into = p .get("into") .and_then(Value::as_u64) .map(LayerId) .or_else(|| ids.first().copied()) .ok_or_else(|| bad("layer.merge", "no layers"))?; s.edit(|d, _| { if d.layer(into).is_none() || ids.iter().any(|l| d.layer(*l).is_none()) { return Err(bad("layer.merge", "no such layer")); } let gone: Vec = ids.iter().copied().filter(|l| *l != into).collect(); for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { if !sp.items.iter().any(|i| gone.contains(&i.layer)) { continue; } for it in &mut std::sync::Arc::make_mut(sp).items { if gone.contains(&it.layer) { std::sync::Arc::make_mut(it).layer = into; } } } d.layers.retain(|l| !gone.contains(&l.id)); Ok(json!({"merged": gone.len()})) }) } ), cmd!("layer.deleteUnused", "Delete Unused Layers", [], None, "{} — layers without objects (one layer always stays)", has_doc, |s, _| { s.edit(|d, _| { let used: std::collections::HashSet = d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().map(|i| i.layer)).collect(); let before = d.layers.len(); if used.is_empty() { // Nothing anywhere: keep the first layer. d.layers.truncate(1); } else { d.layers.retain(|l| used.contains(&l.id)); } Ok(json!({"deleted": before - d.layers.len()})) }) }), cmd!( "layer.others", "Hide/Lock Others", [], None, "{id, hide?: bool, lock?: bool, show?: true (Show All Layers), unlock?: true (Unlock All Layers)} — Hide Others / Lock Others act on every layer but `id`", has_doc, |s, p| { let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); let f = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); let (hide, lock, show, unlock) = (f("hide"), f("lock"), f("show"), f("unlock")); s.edit(|d, _| { for l in &mut d.layers { if show { l.visible = true; } if unlock { l.locked = false; } if l.id != id { if hide { l.visible = false; } if lock { l.locked = true; } } } ok() }) } ), cmd!(noundo "layer.activate", "Set Active Layer", [], None, "{id}", has_doc, |s, p| { let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); let st = s.doc_mut()?; if st.doc.layer(id).is_none() { return Err(bad("layer.activate", "no such layer")); } st.active_layer = id; st.bump_revision(); ok() }), ] } /// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to a page. fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) { match p.get("margins") { Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)), Some(v @ Value::Object(_)) => { if let Ok(m) = serde_json::from_value(v.clone()) { pg.margins = m; } } _ => {} } if let Some(c) = p.get("columns").and_then(Value::as_u64) { pg.columns.count = (c as u32).clamp(1, 216); } if let Some(g) = p.get("gutter").and_then(Value::as_f64) { pg.columns.gutter = g.max(0.0); } } /// A parent spread named by prefix, label (`A-Parent`) or index. fn parent_spread_index(d: &designcraft_doc::Document, v: &Value) -> Option { match v { Value::Number(n) => n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < d.parents.len()), Value::String(s) => d.parents.iter().position(|p| p.parent.as_ref().is_some_and(|i| i.prefix == *s || i.label() == *s)), _ => None, } } /// A parent named by a command parameter: its id (a number), its full label (`"A-Parent"`), or /// its prefix (`"A"`) when only one parent has it. `null` or `"[None]"` → `Some(None)` (no parent); /// missing → `None` (the command's default). Unknown or ambiguous names are errors, so a typo never /// clears a page's parent. fn parent_ref(cmd: &str, d: &designcraft_doc::Document, v: Option<&Value>) -> Result>> { let Some(v) = v else { return Ok(None) }; match v { Value::Null => Ok(Some(None)), Value::String(s) if s == "[None]" => Ok(Some(None)), Value::Number(n) => { let id = n.as_u64().ok_or_else(|| bad(cmd, format!("no parent with id {n}")))?; d.parents.iter().find(|p| p.id.0 == id).map(|p| Some(Some(p.id))).ok_or_else(|| bad(cmd, format!("no parent with id {id}"))) } Value::String(s) => { let by = |f: &dyn Fn(&designcraft_doc::ParentInfo) -> bool| -> Vec { d.parents.iter().filter(|p| p.parent.as_ref().is_some_and(f)).map(|p| p.id).collect() }; let mut hits = by(&|i| i.label() == *s); if hits.is_empty() { hits = by(&|i| i.prefix == *s); } match hits.as_slice() { [id] => Ok(Some(Some(*id))), [] => Err(bad(cmd, format!("no parent named `{s}`"))), _ => Err(bad(cmd, format!("more than one parent is named `{s}`: give its id"))), } } _ => Err(bad(cmd, "`parent` is a parent id, a parent name or null")), } } /// Layout › Pages › Parent Options: Based on Parent. A base that would make a cycle is refused. fn parent_options(s: &mut crate::Session, p: &Value) -> Result { const CMD: &str = "layout.parents.options"; let d = &s.doc()?.doc; let Some(Some(id)) = parent_ref(CMD, d, p.get("parent"))? else { return Err(bad(CMD, "`parent` names the parent to change")) }; let pi = d.parent_index(id).ok_or_else(|| bad(CMD, format!("no parent with id {}", id.0)))?; let base = parent_ref(CMD, d, p.get("basedOn"))?.ok_or_else(|| bad(CMD, "missing `basedOn` (a parent, or null for [None])"))?; let label = |d: &designcraft_doc::Document, i: usize| d.parents.get(i).and_then(|x| x.parent.as_ref()).map(|i| i.label()); if let Some(b) = base && d.parent_base_cycles(pi, b) { let (name, b) = (label(d, pi).unwrap_or_default(), d.parent_index(b).and_then(|i| label(d, i)).unwrap_or_default()); return Err(bad(CMD, format!("{name} cannot be based on {b}: that would make a cycle"))); } let out = json!({"parent": label(d, pi), "basedOn": base.and_then(|b| d.parent_index(b)).and_then(|i| label(d, i))}); if d.parents.get(pi).and_then(|x| x.parent.as_ref()).and_then(|i| i.based_on) == base { return Ok(out); } s.edit(|d, _| { let info = d.parents.get_mut(pi).map(std::sync::Arc::make_mut).and_then(|x| x.parent.as_mut()).ok_or_else(|| bad(CMD, "no such parent"))?; info.based_on = base; Ok(out) }) } #[allow(dead_code)] fn _r(_: Result<()>) {} /// Layout › Page Size (the Page tool): resize pages one by one. Left pages grow to the left /// (the spine stays put); objects move with their page. fn page_size(s: &mut crate::Session, p: &Value) -> Result { let pages: Vec = p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); let n = s.doc()?.doc.page_count(); if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { return Err(bad("layout.pageSize", "give `pages` (1-based)")); } let (mut w, mut h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64)); if let Some(name) = str_param(p, "preset") { let pr = designcraft_doc::build::PRESETS .iter() .find(|x| x.name.eq_ignore_ascii_case(name)) .ok_or_else(|| bad("layout.pageSize", format!("no preset `{name}`")))?; w = Some(pr.width); h = Some(pr.height); } let anchor = match p.get("anchor") { None | Some(Value::Null) => 0, Some(v) => v.as_u64().filter(|a| *a <= 8).ok_or_else(|| bad("layout.pageSize", "`anchor`: 0–8"))? as usize, }; s.edit(|d, _| { resize_pages_from(d, &pages, w, h, anchor); Ok(json!({"pages": pages.len()})) }) } fn page_options(s: &mut crate::Session, p: &Value) -> Result { let pages: Vec = p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); let n = s.doc()?.doc.page_count(); if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { return Err(bad("layout.pageOptions", "give `pages` (1-based)")); } let overlay = p.get("showParentOverlay").and_then(Value::as_bool); s.edit(|d, _| { for page in &pages { let Some((si, pi)) = d.page_loc(page - 1) else { continue }; let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; if let Some(v) = overlay { pg.show_parent_overlay = v; } } Ok(json!({"pages": pages.len()})) }) } /// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule /// re-flow their objects by it. pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { resize_pages_from(d, pages, w, h, 0); } /// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: /// the page grows away from it, so objects keep their place relative to it. pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option, anchor: usize) { let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); { for page in pages { let Some((si, pi)) = d.page_loc(page - 1) else { continue }; let sp = std::sync::Arc::make_mut(&mut d.spreads[si]); let old: Vec = sp.pages.iter().map(|x| x.x).collect(); let old_bounds = sp.pages[pi].bounds(); // The page's own objects (outer pages take the pasteboard beside them). let last = sp.pages.len() - 1; let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); let own: Vec = sp .items .iter() .enumerate() .filter(|(_, it)| { let c = it.bounds().center().x; c >= lo && c < hi }) .map(|(i, _)| i) .collect(); let pg = &mut sp.pages[pi]; let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width); let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height); if let Some(w) = w { pg.width = w.max(1.0); } if let Some(h) = h { pg.height = h.max(1.0); } let left = pg.side == designcraft_doc::PageSide::Left; sp.relayout(); // Keep the spine: shift the whole spread back when a left page grows. if left && dw != 0.0 { for q in &mut sp.pages { q.x -= dw; } } let new: Vec = sp.pages.iter().map(|x| x.x).collect(); // Objects follow their page. let shifts: Vec<(f64, f64, f64)> = sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect(); for it in &mut sp.items { let cx = it.bounds().center().x; if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b) && *dx != 0.0 { let it = std::sync::Arc::make_mut(it); it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf; } } // Page guides move with their page. for (k, q) in sp.pages.iter_mut().enumerate() { let dx = new[k] - old[k]; if dx != 0.0 { for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { g.position += dx; } } } // Growing away from the anchor: the page's objects and guides keep their place // relative to it. let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); if shift != designcraft_geom::Vec2::ZERO { for i in &own { if let Some(it) = sp.items.get_mut(*i) { let it = std::sync::Arc::make_mut(it); it.xf = designcraft_geom::Affine::translate(shift) * it.xf; } } for g in &mut sp.pages[pi].guides { g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; } } // Liquid rules: objects were carried along with the page's left edge (and the anchor). let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); } } } /// File › Document Setup values. fn document_setup(d: &designcraft_doc::Document) -> Value { let st = &d.settings; let start = d.sections.iter().find(|x| x.start == 0).and_then(|x| x.start_number).unwrap_or(1); json!({"width": st.page_width, "height": st.page_height, "pages": d.page_count(), "startPage": start, "facingPages": st.facing_pages, "binding": if st.right_to_left_binding { "rightToLeft" } else { "leftToRight" }, "intent": st.intent, "bleed": st.bleed, "slug": st.slug, "margins": d.page(0).map(|p| p.margins), "primaryTextFrame": st.primary_text_frame}) } /// The largest chapter number (as for page numbers). const MAX_CHAPTER: u64 = designcraft_doc::MAX_PAGE_NUMBER as u64; /// A `layout.section` edit, checked before the document changes. struct SectionEdit { /// Absolute index of the section's first page. start: usize, remove: bool, /// Some(None): continue from the previous section. start_number: Option>, style: Option, prefix: Option, include_prefix: Option, marker: Option, } impl SectionEdit { fn parse(cmd: &str, s: &Session, p: &Value) -> Result { let page = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing page"))?; let n = s.doc()?.doc.page_count() as u64; if page == 0 || page > n { return Err(bad(cmd, format!("no page {page}"))); } let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string); if prefix.as_ref().is_some_and(|x| x.chars().count() > 8 || x.contains(['+', ','])) { return Err(bad(cmd, "section prefix: up to 8 characters, without + or ,")); } let start_number = match p.get("startNumber") { None => None, Some(Value::Null) => Some(None), Some(v) => Some(Some(start_number(cmd, "startNumber", v)?)), }; let style = match p.get("style") { None => None, Some(v) => match serde_json::from_value(v.clone()) { Ok(designcraft_doc::NumberStyle::Symbols) => return Err(bad(cmd, "symbols don't number pages")), Ok(style) => Some(style), Err(e) => return Err(bad(cmd, e.to_string())), }, }; Ok(SectionEdit { start: (page - 1) as usize, remove: p.get("remove").and_then(Value::as_bool).unwrap_or(false), start_number, style, prefix, include_prefix: p.get("includePrefix").and_then(Value::as_bool), marker: p.get("marker").and_then(Value::as_str).map(str::to_string), }) } fn apply(&self, d: &mut designcraft_doc::Document) { let start = self.start; // Page 1 always starts a section. if self.remove && start != 0 { d.sections.retain(|x| x.start != start); return; } let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { start, start_number: None, style: Default::default(), prefix: String::new(), marker: String::new(), include_prefix: false, }); if let Some(n) = self.start_number { sec.start_number = n; } if let Some(style) = self.style { sec.style = style; } if let Some(prefix) = &self.prefix { sec.prefix = prefix.clone(); } if let Some(on) = self.include_prefix { sec.include_prefix = on; } if let Some(marker) = &self.marker { sec.marker = marker.clone(); } d.sections.retain(|x| x.start != start); d.sections.push(sec); d.sections.sort_by_key(|x| x.start); } } /// A `layout.chapterNumbering` edit, checked before the document changes. struct ChapterEdit { style: Option, source: Option, start: Option, } impl ChapterEdit { fn parse(cmd: &str, p: &Value) -> Result { let style = match p.get("style") { Some(v) => match serde_json::from_value::(v.clone()) { Ok(designcraft_doc::NumberStyle::Symbols) | Err(_) => return Err(bad(cmd, format!("unknown chapter style {v}"))), Ok(style) => Some(style), }, None => None, }; let mut source = match p.get("source") { Some(v) => Some( serde_json::from_value::(v.clone()) .map_err(|_| bad(cmd, format!("unknown chapter number source {v}")))?, ), None => None, }; let start = match p.get("start") { Some(v) => { Some(v.as_u64().filter(|n| (1..=MAX_CHAPTER).contains(n)).ok_or_else(|| bad(cmd, format!("chapter start: 1 to {MAX_CHAPTER}")))? as u32) } None => None, }; // A start number alone means Start Chapter Numbering at. if start.is_some() && source.is_none() { source = Some(designcraft_doc::ChapterSource::UserDefined); } Ok(ChapterEdit { style, source, start }) } fn apply(&self, d: &mut designcraft_doc::Document) { if let Some(style) = self.style { d.settings.chapter_style = style; } if let Some(source) = self.source { d.settings.chapter_source = source; } if let Some(n) = self.start { d.settings.chapter_number = n; } } } /// The active document's chapter numbering, and the open book that holds it (what /// `layout.chapterNumbering {}` reports, read without running a command). pub fn chapter_numbering(s: &Session) -> Result { let st = s.doc()?; let set = &st.doc.settings; let book = s .book .as_ref() .filter(|b| st.path.as_deref().is_some_and(|p| b.documents.iter().any(|d| std::path::Path::new(d) == std::path::Path::new(p)))) .map(|b| std::path::Path::new(&b.path).file_stem().map_or_else(|| b.path.clone(), |n| n.to_string_lossy().to_string())); Ok( json!({"chapterNumber": set.chapter_number.max(1), "label": st.doc.chapter_label(), "style": set.chapter_style, "source": set.chapter_source, "book": book}), ) } /// Layer Options: every field in one undo step; a bad name or colour changes nothing. fn set_layer(s: &mut Session, p: &Value) -> Result { const ID: &str = "layer.set"; let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(ID, "missing id"))?); let name = match p.get("name") { Some(v) => Some(v.as_str().map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(ID, "a layer needs a name"))?.to_string()), None => None, }; let color: Option<[u8; 3]> = match p.get("color") { None => None, Some(Value::String(n)) => Some( LAYER_COLORS .iter() .find(|(k, _)| k.eq_ignore_ascii_case(n)) .map(|(_, c)| *c) .ok_or_else(|| bad(ID, format!("unknown layer colour `{n}`")))?, ), Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(ID, "color is [r,g,b] (0–255) or a layer colour name"))?), }; let flag = |k: &str| match p.get(k) { None => Ok(None), Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(ID, format!("`{k}` is true or false"))), }; let (visible, locked, printable) = (flag("visible")?, flag("locked")?, flag("printable")?); let (guides, wrap) = (flag("showGuides")?, flag("suppressWrapWhenHidden")?); s.edit(|d, _| { if let Some(n) = &name && d.layers.iter().any(|l| l.id != id && l.name == *n) { return Err(bad(ID, format!("a layer named `{n}` exists"))); } let l = d.layer_mut(id).ok_or_else(|| bad(ID, "no such layer"))?; if let Some(n) = name { l.name = n; } if let Some(c) = color { l.color = c; } for (slot, v) in [ (&mut l.visible, visible), (&mut l.locked, locked), (&mut l.printable, printable), (&mut l.show_guides, guides), (&mut l.suppress_wrap_when_hidden, wrap), ] { if let Some(v) = v { *slot = v; } } ok() }) } #[cfg(test)] mod setup_tests { use serde_json::json; #[test] fn chapter_numbering_sets_style_and_start() { let mut s = crate::Session::new(); s.execute("file.new", &json!({})).unwrap(); let r = s.execute("layout.chapterNumbering", &json!({})).unwrap(); assert_eq!(r, json!({"chapterNumber": 1, "label": "1", "style": "arabic", "source": "automatic", "book": null})); let r = s.execute("layout.chapterNumbering", &json!({"start": 4, "style": "upperRoman"})).unwrap(); assert_eq!((&r["label"], &r["source"]), (&json!("IV"), &json!("userDefined")), "a start number means Start Chapter Numbering at"); s.execute("variables.define", &json!({"name": "Chapter", "type": "chapterNumber"})).unwrap(); let d = &s.doc().unwrap().doc; let chapter = d.variable("Chapter").unwrap(); assert_eq!(d.variable_value(chapter, Some(0)).as_deref(), Some("IV"), "the Chapter Number variable uses the style"); for bad in [json!({"style": "symbols"}), json!({"start": 0}), json!({"start": 100_000}), json!({"source": "nextBook"})] { assert!(s.execute("layout.chapterNumbering", &bad).is_err(), "{bad}"); } s.execute("layout.chapterNumbering", &json!({"source": "sameAsPrevious"})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("layout.chapterNumbering", &json!({})).unwrap()["source"], "userDefined", "undoable"); } #[test] fn document_setup_sets_every_pages_margins_in_one_step() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"pages": 3, "margins": 36})).unwrap(); let f = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text"})).unwrap(); let r = s.execute("layout.documentSetup", &json!({})).unwrap(); assert_eq!(r["margins"], json!({"top": 36.0, "bottom": 36.0, "inside": 36.0, "outside": 36.0})); let m = json!({"top": 72, "bottom": 54, "inside": 72, "outside": 72}); let r = s.execute("layout.documentSetup", &json!({"margins": m, "bleed": 9, "adjustLayout": true})).unwrap(); assert_eq!(r["margins"]["bottom"], 54.0); let d = &s.doc().unwrap().doc; assert!( d.spreads.iter().chain(&d.parents).flat_map(|sp| &sp.pages).all(|pg| pg.margins.top == 72.0 && pg.margins.bottom == 54.0), "every page and parent page" ); let b = d.item(designcraft_doc::ItemId(f["id"].as_u64().unwrap())).unwrap().bounds(); assert!((b.y0 - 72.0).abs() < 1e-6, "Adjust Layout: the frame follows the margins ({b:?})"); // One step: undo brings back the margins and the bleed. s.execute("edit.undo", &json!({})).unwrap(); let r = s.execute("layout.documentSetup", &json!({})).unwrap(); assert_eq!((r["margins"]["top"].clone(), r["bleed"][0].clone()), (json!(36.0), json!(0.0))); for bad in [ json!({"width": 0}), json!({"height": -5}), json!({"width": 1e9}), json!({"width": "wide"}), json!({"margins": -1}), json!({"margins": {"top": 1}}), ] { assert!(s.execute("layout.documentSetup", &bad).is_err(), "{bad}"); } assert_eq!(s.execute("layout.documentSetup", &json!({})).unwrap()["width"], 612.0, "refused values change nothing"); } #[test] fn document_setup_pages_start_bleed_slug() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"pages": 2})).unwrap(); let r = s.execute("layout.documentSetup", &json!({})).unwrap(); assert_eq!(r["pages"], 2); assert_eq!(r["startPage"], 1); let r = s.execute("layout.documentSetup", &json!({"pages": 5, "startPage": 2, "bleed": 9, "slug": [0, 18, 0, 0], "intent": "web"})).unwrap(); assert_eq!(r["pages"], 5); assert_eq!(r["startPage"], 2); assert_eq!(r["bleed"], json!([9.0, 9.0, 9.0, 9.0])); assert_eq!(r["slug"], json!([0.0, 18.0, 0.0, 0.0])); assert_eq!(r["intent"], "web"); let d = &s.doc().unwrap().doc; assert_eq!(d.page_name(0), "2"); assert_eq!(d.page(0).unwrap().side, designcraft_doc::PageSide::Left); assert_eq!(d.spreads[0].pages.len(), 2, "pages 2–3 form the first spread"); let r = s.execute("layout.documentSetup", &json!({"pages": 3})).unwrap(); assert_eq!(r["pages"], 3); s.doc().unwrap().doc.check().unwrap(); } #[test] fn right_to_left_binding_reverses_spreads() { use designcraft_doc::PageSide::{Left, Right}; let mut s = crate::Session::new(); s.execute("file.new", &json!({"pages": 4})).unwrap(); // A frame on page 2 stays on page 2. let w = s.doc().unwrap().doc.settings.page_width; let f = s.execute("frame.create", &json!({"rect": [w + 72.0, 72, w + 200.0, 200], "content": "text", "text": "two"})).unwrap(); let r = s.execute("layout.documentSetup", &json!({"binding": "rightToLeft"})).unwrap(); assert_eq!(r["binding"], "rightToLeft"); let d = &s.doc().unwrap().doc; let shape: Vec> = d.spreads.iter().map(|sp| sp.pages.iter().map(|p| (p.side, p.x)).collect()).collect(); // Page 1 alone on the left; pages 2–3 with 2 on the right; page 4 alone on the right. assert_eq!(shape, vec![vec![(Left, 0.0)], vec![(Right, w), (Left, 0.0)], vec![(Right, 0.0)]]); let (si, _) = d.page_loc(1).unwrap(); assert_eq!(si, 1); let it = d.item(designcraft_doc::ItemId(f["id"].as_u64().unwrap())).unwrap(); assert_eq!(d.spreads[si].page_at_x(it.bounds().center().x), Some(0), "the frame is still on page 2"); assert!(it.bounds().x0 >= w, "page 2 is the right page now"); d.check().unwrap(); // And back. s.execute("layout.documentSetup", &json!({"binding": "leftToRight"})).unwrap(); let d = &s.doc().unwrap().doc; assert_eq!(d.spreads[1].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Left, 0.0), (Right, w)]); } /// Bound right to left, a parent spread lists its right page first; each document page still /// shows the parent page on its own side, at its own position, also after an IDML round trip. #[test] fn right_to_left_binding_shows_the_parent_page_of_the_same_side() { use designcraft_doc::PageSide::{Left, Right}; let mut s = crate::Session::new(); s.execute("file.new", &json!({"pages": 4})).unwrap(); s.execute("layout.documentSetup", &json!({"binding": "rightToLeft"})).unwrap(); let w = s.doc().unwrap().doc.settings.page_width; let parent = json!({"kind": "parent", "index": 0}); for (rect, text) in [(json!([72, 36, 200, 60]), "left head"), (json!([w + 72.0, 36, w + 200.0, 60]), "right head")] { s.execute("frame.create", &json!({"spread": parent, "rect": rect, "content": "text", "text": text})).unwrap(); } let d = s.doc().unwrap().doc.clone(); assert_eq!(d.parents[0].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Right, w), (Left, 0.0)]); let docs = [(*d).clone(), designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap()]; for d in docs { let mut s = crate::Session::new(); s.add_document(crate::DocState::new(d, None)); for (page, side, text) in [(0, Left, "left head"), (1, Right, "right head"), (2, Left, "left head"), (3, Right, "right head")] { let r = s.execute("layout.overrideParentItems", &json!({"page": page})).unwrap(); let d = &s.doc().unwrap().doc; let ids = r["ids"].as_array().unwrap(); assert_eq!(ids.len(), 1, "page {}", page + 1); let it = d.item(designcraft_doc::ItemId(ids[0].as_u64().unwrap())).unwrap(); let story = d.story(it.text_frame().unwrap().story).unwrap(); assert_eq!((d.page(page).unwrap().side, story.text.trim_end()), (side, text), "page {}", page + 1); assert_eq!(d.page_of_item(it.id), Some(page), "page {}", page + 1); } } } /// Multi-column frames filled the left column first in a right-to-left document (#100): /// stories made in a right-to-left-bound document start their columns on the right. #[test] fn new_stories_follow_the_binding_direction() { use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; let text = (1..=12).map(|i| format!("الفقرة {i}")).collect::>().join("\n"); for (binding, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { let mut s = crate::Session::new(); s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); let f = s.execute("frame.create", &json!({"rect": [40, 170, 555, 260], "content": "text", "text": text})).unwrap(); s.execute("object.textFrameOptions", &json!({"ids": [f["id"]], "columns": 2, "gutter": 20})).unwrap(); let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); let st = s.doc().unwrap(); assert_eq!(st.doc.story(sid).unwrap().direction, expected, "{binding}"); let cs = s.cache.get(&st.doc, sid, None); let fr = &cs.frames[0]; let (first, last) = (fr.lines.first().unwrap(), fr.lines.last().unwrap()); assert!(fr.columns.len() == 2 && first.x0 != last.x0, "the text fills both columns: {:?}", fr.columns); assert_eq!(first.x0 > last.x0, expected == RightToLeft, "{binding}: the first line is in the first column"); // An empty frame given text content starts a story too. let g = s.execute("frame.create", &json!({"rect": [40, 400, 555, 500]})).unwrap(); s.execute("object.content", &json!({"ids": [g["id"]], "type": "text"})).unwrap(); let d = &s.doc().unwrap().doc; let tf = d.item(designcraft_doc::ItemId(g["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; assert_eq!(d.story(tf).unwrap().direction, expected, "{binding}: object.content"); } } } #[cfg(test)] mod layer_tests { use serde_json::json; #[test] fn layer_move_reorders_and_rejects_bad_input() { let mut s = crate::Session::new(); s.execute("file.new", &json!({})).unwrap(); let l1 = s.doc().unwrap().doc.layers[0].id.0; let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); let order = |s: &crate::Session| s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect::>(); assert_eq!(order(&s), [l2, l1]); for bad in [json!({"id": l1, "to": 2}), json!({"id": 999, "to": 0}), json!({"id": l1}), json!({"to": 0})] { assert!(s.execute("layer.move", &bad).is_err(), "{bad}"); assert_eq!(order(&s), [l2, l1]); } s.execute("layer.move", &json!({"id": l1, "to": 0})).unwrap(); assert_eq!(order(&s), [l1, l2]); } #[test] fn merge_delete_unused_hide_and_lock_others() { let mut s = crate::Session::new(); s.execute("file.new", &json!({})).unwrap(); let l1 = s.doc().unwrap().doc.layers[0].id.0; let l2 = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); let l3 = s.execute("layer.new", &json!({"name": "Empty"})).unwrap()["id"].as_u64().unwrap(); s.execute("layer.activate", &json!({"id": l2})).unwrap(); let f = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); s.execute("layer.others", &json!({"id": l2, "hide": true, "lock": true})).unwrap(); let d = s.doc().unwrap().doc.clone(); assert!(d.layers.iter().all(|l| (l.id.0 == l2) == l.visible && (l.id.0 != l2) == l.locked)); s.execute("layer.others", &json!({"id": l2, "show": true, "unlock": true})).unwrap(); assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.visible && !l.locked)); let r = s.execute("layer.deleteUnused", &json!({})).unwrap(); assert_eq!(r["deleted"], 2, "the empty default layer and Empty"); let _ = (l1, l3); let l4 = s.execute("layer.new", &json!({"name": "Into"})).unwrap()["id"].as_u64().unwrap(); s.execute("layer.merge", &json!({"ids": [l4, l2]})).unwrap(); let d = &s.doc().unwrap().doc; assert_eq!(d.layers.len(), 1); assert_eq!(d.item(designcraft_doc::ItemId(f)).unwrap().layer.0, l4); } } #[cfg(test)] mod page_size_tests { use serde_json::json; #[test] fn page_size_per_page_keeps_objects_on_their_page() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"pages": 3})).unwrap(); // Page 3 is the right page of spread 2 (pages 2–3); an object on it. let d = s.doc().unwrap().doc.clone(); let (si, pi) = d.page_loc(2).unwrap(); let x0 = d.spreads[si].pages[pi].x; let id = s.execute("frame.create", &json!({"spread": si, "rect": [x0 + 100.0, 100, x0 + 200.0, 200]})).unwrap()["id"].as_u64().unwrap(); s.execute("layout.pageSize", &json!({"pages": [2], "width": 300, "height": 500})).unwrap(); let d = s.doc().unwrap().doc.clone(); let left = &d.spreads[si].pages[0]; assert_eq!((left.width, left.height), (300.0, 500.0)); assert_eq!(d.spreads[si].pages[1].x, x0, "the spine stays"); let it = d.item(designcraft_doc::ItemId(id)).unwrap(); assert_eq!(it.bounds().x0, x0 + 100.0); assert_eq!(d.page_of_item(it.id), Some(2)); s.execute("layout.pageSize", &json!({"pages": [3], "preset": "A4"})).unwrap(); let d = s.doc().unwrap().doc.clone(); assert!((d.spreads[si].pages[1].width - 595.276).abs() < 0.01); assert!(s.execute("layout.pageSize", &json!({"pages": [9], "width": 100})).is_err()); } } #[cfg(test)] mod spread_tests { use serde_json::json; #[test] fn three_page_spread_survives_page_inserts() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"pages": 5})).unwrap(); // [1] [2 3] [4 5]: page 4 joins spread 1 → [1] [2 3 4] [5]. let d = s.doc().unwrap().doc.clone(); let (si, pi) = d.page_loc(3).unwrap(); let x = d.spreads[si].pages[pi].x; let id = s.execute("frame.create", &json!({"spread": si, "rect": [x + 50.0, 50, x + 150.0, 150]})).unwrap()["id"].as_u64().unwrap(); s.execute("layout.pages.toSpread", &json!({"page": 4, "spread": 1})).unwrap(); let d = s.doc().unwrap().doc.clone(); assert_eq!(d.spreads[1].pages.len(), 3); assert!(!d.spreads[1].allow_shuffle); assert_eq!(d.page_of_item(designcraft_doc::ItemId(id)), Some(3), "the object went with its page"); // Adding a page at the start reshuffles the others but keeps the 3-page spread. s.execute("layout.pages.insert", &json!({"after": 0, "count": 1})).unwrap(); let d = s.doc().unwrap().doc.clone(); assert!(d.spreads.iter().any(|sp| sp.pages.len() == 3 && !sp.allow_shuffle)); assert_eq!(d.page_count(), 6); d.check().unwrap(); } } #[cfg(test)] mod page_numbering_view_tests { use serde_json::json; use crate::Session; #[test] fn section_and_absolute_page_numbering_view() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 4})).unwrap(); s.execute("layout.section", &json!({"page": 1, "startNumber": 1, "style": "lowerRoman"})).unwrap(); s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["i", "ii", "1", "2"]); assert_eq!(s.resolve_page("ii"), Some(1)); assert_eq!(s.resolve_page("1"), Some(2), "section name first"); assert_eq!(s.resolve_page("+1"), Some(0), "+n is a position"); s.execute("prefs.set", &json!({"absolutePageNumbers": true})).unwrap(); assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["1", "2", "3", "4"]); assert_eq!(s.resolve_page("1"), Some(0)); assert_eq!(s.resolve_page("9"), None); } #[test] fn section_start_numbers_are_bounded() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 2})).unwrap(); for n in [json!(0), json!(100_000), json!(u32::MAX), json!(u64::from(u32::MAX) + 2), json!(-1), json!(2.5), json!("7")] { let e = s.execute("layout.section", &json!({"page": 1, "startNumber": n})).unwrap_err().to_string(); assert!(e.contains("startNumber"), "{n}: {e}"); assert!(s.execute("layout.documentSetup", &json!({"startPage": n})).is_err(), "{n}"); } assert_eq!((0..2).map(|i| s.page_label(i)).collect::>(), ["1", "2"]); let r = s.execute("layout.section", &json!({"page": 1, "startNumber": 99_999})).unwrap(); assert_eq!(r["names"], json!(["99999", "100000"])); } #[test] fn numbering_options_apply_in_one_undo_step_or_not_at_all() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"pages": 4})).unwrap(); let names = |s: &crate::Session| (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect::>(); let r = s .execute( "layout.numberingOptions", &json!({"page": 3, "startNumber": 5, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "chapter": {"start": 4, "style": "upperRoman"}}), ) .unwrap(); assert_eq!((r["names"].clone(), r["chapter"].clone()), (json!(["1", "2", "A-v", "A-vi"]), json!("IV"))); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(names(&s), ["1", "2", "3", "4"], "one undo reverts the section…"); assert_eq!(s.doc().unwrap().doc.chapter_label(), "1", "…and the chapter numbering"); // A refused part leaves the other undone too. for bad in [ json!({"page": 3, "startNumber": 5, "chapter": {"start": 100_000}}), json!({"page": 3, "startNumber": 4_294_967_295u64, "chapter": {"start": 2}}), json!({"page": 3, "prefix": "A+B", "chapter": {"start": 2}}), json!({"page": 9, "chapter": {"start": 2}}), ] { assert!(s.execute("layout.numberingOptions", &bad).is_err(), "{bad}"); assert_eq!(names(&s), ["1", "2", "3", "4"], "{bad}"); assert_eq!(s.doc().unwrap().doc.settings.chapter_source, designcraft_doc::ChapterSource::Automatic, "{bad}"); } // Removing a section keeps the chapter part. s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); s.execute("layout.numberingOptions", &json!({"page": 3, "remove": true, "chapter": {"source": "userDefined", "start": 2}})).unwrap(); assert_eq!(names(&s), ["1", "2", "3", "4"]); assert_eq!(s.doc().unwrap().doc.chapter_label(), "2"); } #[test] fn section_prefixes_are_short_and_avoid_plus_and_comma() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"pages": 2})).unwrap(); for prefix in ["A-", "Appendix", "§ 1"] { s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).unwrap(); } for prefix in ["Appendix1", "A+", "1,2"] { assert!(s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).is_err(), "{prefix}"); } assert!(s.execute("layout.section", &json!({"page": 2, "style": "symbols"})).is_err()); assert_eq!(s.doc().unwrap().doc.sections.iter().find(|x| x.start == 1).unwrap().prefix, "§ 1", "a refused prefix changes nothing"); } } #[cfg(test)] mod adjust_tests { use serde_json::json; use crate::Session; #[test] fn adjust_layout_follows_the_new_page_size_and_margins() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); // Letter, 36 pt margins: a frame filling the left half of the margin box. let id = s.execute("frame.create", &json!({"rect": [36, 36, 306, 756]})).unwrap()["id"].as_u64().unwrap(); s.execute("layout.documentSetup", &json!({"width": 1224, "adjustLayout": true})).unwrap(); let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); assert!((b.x0 - 36.0).abs() < 1e-6 && (b.x1 - 612.0).abs() < 1e-6, "half of the wider margin box: {b:?}"); s.execute("layout.marginsAndColumns", &json!({"margins": 72, "adjustLayout": true})).unwrap(); let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); assert!((b.x0 - 72.0).abs() < 1e-6 && (b.y0 - 72.0).abs() < 1e-6 && (b.y1 - 720.0).abs() < 1e-6, "{b:?}"); // Without the option objects stay put. s.execute("layout.marginsAndColumns", &json!({"margins": 36})).unwrap(); let b2 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); assert_eq!(b, b2); } } #[cfg(test)] mod spread_rotation_input_tests { use serde_json::json; #[test] fn quarter_turns_work_and_other_angles_are_rejected_without_mutation() { let mut s = crate::Session::new(); s.execute("file.new", &json!({})).unwrap(); let rotation = |s: &crate::Session| s.doc().unwrap().doc.spreads[0].pages[0].view_rotation; for bad in [json!(45), json!(-45), json!(91), json!("90"), json!(0.5)] { assert!(s.execute("layout.rotateSpreadView", &json!({"angle": bad})).is_err(), "{bad}"); assert_eq!(rotation(&s), 0, "invalid input must not change the view"); } s.execute("layout.rotateSpreadView", &json!({"angle": 90})).unwrap(); assert_eq!(rotation(&s), 1); s.execute("layout.rotateSpreadView", &json!({"angle": -90})).unwrap(); assert_eq!(rotation(&s), 0); s.execute("layout.rotateSpreadView", &json!({"angle": 180})).unwrap(); assert_eq!(rotation(&s), 2); s.execute("layout.rotateSpreadView", &json!({"angle": 0})).unwrap(); assert_eq!(rotation(&s), 0, "zero resets the spread view"); } } #[cfg(test)] mod layer_options_tests { use serde_json::json; #[test] fn layer_set_sets_every_field_and_rejects_bad_names() { let mut s = crate::Session::new(); s.execute("file.new", &json!({})).unwrap(); let first = s.doc().unwrap().doc.layers[0].id.0; let id = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); let all = json!({"id": id, "name": " Artwork ", "color": "Gold", "visible": false, "locked": true, "printable": false, "showGuides": false, "suppressWrapWhenHidden": true}); s.execute("layer.set", &all).unwrap(); let l = s.doc().unwrap().doc.layer(designcraft_doc::LayerId(id)).cloned().unwrap(); assert_eq!(l.name, "Artwork"); assert_eq!(l.color, [255, 153, 0]); assert!(!l.visible && l.locked && !l.printable && !l.show_guides && l.suppress_wrap_when_hidden); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.layer(designcraft_doc::LayerId(id)).unwrap().name, "Art", "one undo step"); let first_name = s.doc().unwrap().doc.layers.iter().find(|l| l.id.0 == first).unwrap().name.clone(); let before = s.doc().unwrap().doc.layers.clone(); for p in [ json!({"id": id, "name": ""}), json!({"id": id, "name": " "}), json!({"id": id, "name": first_name}), json!({"id": id, "name": "Fine", "color": "Plaid"}), json!({"id": id, "color": [300, 0, 0]}), json!({"id": id, "visible": "yes"}), json!({"id": 999_999, "name": "Ghost"}), ] { assert!(s.execute("layer.set", &p).is_err(), "{p}"); } assert_eq!(s.doc().unwrap().doc.layers, before); // Keeping its own name is fine. s.execute("layer.set", &json!({"id": id, "name": "Art", "color": [1, 2, 3]})).unwrap(); } } #[cfg(test)] mod parent_margin_tests { use designcraft_doc::{Document, Margins}; use serde_json::json; use crate::Session; fn margins(d: &Document) -> Vec<(Margins, u32, bool)> { (0..d.page_count()).filter_map(|i| d.page(i)).map(|p| (p.margins, p.columns.count, p.local_margins)).collect() } #[test] fn document_setup_margins_let_pages_follow_their_parent_page_again() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 3})).unwrap(); s.execute("layout.marginsAndColumns", &json!({"pages": [1], "margins": 100})).unwrap(); s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100, "columns": 3})).unwrap(); s.execute("layout.documentSetup", &json!({"margins": 20})).unwrap(); let m = margins(&s.doc().unwrap().doc); let twenty = Margins::uniform(20.0); assert_eq!(m, vec![(twenty, 1, false), (twenty, 1, false), (twenty, 3, true)], "own columns stay local"); s.execute("layout.marginsAndColumns", &json!({"parent": "A", "margins": 50})).unwrap(); let m = margins(&s.doc().unwrap().doc); assert_eq!(m[1], (Margins::uniform(50.0), 1, false), "page 2 follows the parent page again"); assert_eq!(m[2], (twenty, 3, true)); } #[test] fn pages_follow_their_parent_pages_margins_unless_set_locally() { let mut s = Session::new(); // Facing: [1] [2 3]; page 3 gets its own margins. s.execute("file.new", &json!({"pages": 3})).unwrap(); s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap(); // A's left page: only the left document page follows. let left = json!({"top": 10, "bottom": 20, "inside": 30, "outside": 40}); s.execute("layout.marginsAndColumns", &json!({"parent": "A", "pages": [0], "margins": left, "columns": 2})).unwrap(); let l = Margins { top: 10.0, bottom: 20.0, inside: 30.0, outside: 40.0 }; let m = margins(&s.doc().unwrap().doc); assert_eq!(m, vec![(Margins::uniform(36.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]); // A's right page: page 1 follows, page 3 keeps its own. s.execute("layout.marginsAndColumns", &json!({"parent": "A-Parent", "pages": [1], "margins": 50})).unwrap(); let m = margins(&s.doc().unwrap().doc); assert_eq!(m, vec![(Margins::uniform(50.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]); // A page inserted after page 1 pushes page 2 to the right: it takes the right parent page's. s.execute("layout.pages.insert", &json!({"after": 0, "count": 1, "parent": "A"})).unwrap(); let m = margins(&s.doc().unwrap().doc); assert_eq!(m[1], (l, 2, false), "the new left page"); assert_eq!(m[2], (Margins::uniform(50.0), 1, false), "old page 2, now a right page"); assert_eq!(m[3], (Margins::uniform(100.0), 1, true)); assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err()); } #[test] fn parent_guides_show_on_pages_but_stay_on_the_parent() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 2})).unwrap(); // A vertical guide 100 pt into A's right page (x = 612 in the parent spread). s.execute("guide.add", &json!({"spread": {"kind": "parent", "index": 0}, "page": 1, "orientation": "vertical", "position": 712})).unwrap(); let d = &s.doc().unwrap().doc; let right = d.page(0).unwrap(); let shown: Vec = d.parent_guides(right).map(|g| g.position).collect(); assert_eq!(shown, vec![100.0], "moved to the document page's place"); assert_eq!(d.parent_guides(d.page(1).unwrap()).count(), 0, "the left page shows A's left page's guides"); // Not the document page's own: not listed, moved or deleted from there. assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap(), json!([])); assert!(s.execute("guide.move", &json!({"spread": 0, "page": 0, "index": 0, "position": 50})).is_err()); assert!(s.execute("guide.delete", &json!({"spread": 0, "page": 0, "index": 0})).is_err()); } } #[cfg(test)] mod parent_tests { use serde_json::json; fn parent_of(s: &crate::Session, page: usize) -> Option { s.doc().unwrap().doc.page(page).unwrap().parent.map(|p| p.0) } #[test] fn parents_with_one_prefix_stay_distinct() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"pages": 2})).unwrap(); let a = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Parent"})).unwrap()["id"].as_u64().unwrap(); let b = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Half"})).unwrap()["id"].as_u64().unwrap(); s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap(); assert_eq!(parent_of(&s, 0), Some(b)); s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Half"})).unwrap(); assert_eq!(parent_of(&s, 1), Some(b)); s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Parent"})).unwrap(); assert_eq!(parent_of(&s, 1), Some(a)); // A prefix two parents share, or a name no parent has, is an error and changes nothing. assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG"})).is_err()); assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "Nope"})).is_err()); assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": 999_999})).is_err()); assert_eq!(parent_of(&s, 1), Some(a)); s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "[None]"})).unwrap(); assert_eq!(parent_of(&s, 1), None); } #[test] fn edit_parents_shows_the_named_parent() { let mut s = crate::Session::new(); s.execute("file.new", &json!({})).unwrap(); let ids: Vec = ["B", "C", "D"].iter().map(|p| s.execute("layout.parents.new", &json!({"prefix": p})).unwrap()["id"].as_u64().unwrap()).collect(); s.ui_requests.clear(); let r = s.execute("layout.parents.edit", &json!({"parent": ids[2]})).unwrap(); assert_eq!(r, json!({"on": true, "parent": ids[2]})); assert!(s.doc().unwrap().editing_parents); let n = s.doc().unwrap().doc.parent_index(designcraft_doc::SpreadId(ids[2])).unwrap(); let shown: Vec<_> = s .ui_requests .iter() .filter_map(|r| match r { crate::UiRequest::View { params } => params.get("showSpread").cloned(), _ => None, }) .collect(); assert_eq!(shown, vec![json!({"kind": "parent", "index": n})]); let r = s.execute("layout.parents.edit", &json!({"on": true, "parent": "C-Parent"})).unwrap(); assert_eq!(r["parent"], ids[1]); assert!(s.execute("layout.parents.edit", &json!({"parent": "Z-Parent"})).is_err()); let r = s.execute("layout.parents.edit", &json!({"on": false})).unwrap(); assert_eq!(r, json!({"on": false, "parent": null})); assert!(!s.doc().unwrap().editing_parents); } } #[cfg(test)] mod parent_base_tests { use serde_json::{Value, json}; fn rect(s: &mut crate::Session, spread: Value, r: [f64; 4], swatch: &str) -> u64 { let id = s.execute("frame.create", &json!({"spread": spread, "rect": r, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); s.execute("object.fill", &json!({"ids": [id], "swatch": swatch})).unwrap(); id } fn page(s: &crate::Session, abs: usize) -> designcraft_render::Rendered { let mut rr = designcraft_render::Renderer::new(); rr.threads = 0; rr.render_page(&s.doc().unwrap().doc, &s.cache, abs, 1.0, false, &Default::default()).unwrap() } /// Parent A (black), parent B based on A (cyan, half over A's square), and a page item (paper) /// over B's: the page draws A, then B, then its own items. #[test] fn based_on_parent_draws_base_items_under_parent_items() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400, "pages": 2, "facingPages": false, "margins": 0})).unwrap(); s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Opening"})).unwrap(); rect(&mut s, json!({"kind": "parent", "index": 0}), [0.0, 0.0, 200.0, 200.0], "[Black]"); rect(&mut s, json!({"kind": "parent", "index": 1}), [100.0, 0.0, 300.0, 200.0], "C=100 M=0 Y=0 K=0"); rect(&mut s, json!(1), [150.0, 0.0, 190.0, 40.0], "[Paper]"); s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap(); let black = |p: [u8; 4]| p[0] < 80 && p[1] < 80 && p[2] < 80; let cyan = |p: [u8; 4]| p[0] < 80 && p[1] > 120 && p[2] > 180; let paper = |p: [u8; 4]| p[0] > 240 && p[1] > 240 && p[2] > 240; assert!(paper(page(&s, 1).pixel(50, 100)), "B alone does not show A's items"); let r = s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "A"})).unwrap(); assert_eq!(r, json!({"parent": "B-Opening", "basedOn": "A-Parent"})); let img = page(&s, 1); assert!(black(img.pixel(50, 100)), "A's item: {:?}", img.pixel(50, 100)); assert!(cyan(img.pixel(150, 150)), "B's item over A's: {:?}", img.pixel(150, 150)); assert!(paper(img.pixel(170, 20)), "the page's item over B's: {:?}", img.pixel(170, 20)); assert!(black(page(&s, 0).pixel(50, 100)) && !cyan(page(&s, 0).pixel(250, 100)), "page 1 uses A only"); // Parent B itself shows A's items under its own. let mut rr = designcraft_render::Renderer::new(); rr.threads = 0; let placed = [designcraft_render::Placed { spread: designcraft_doc::SpreadRef::Parent(1), xf: designcraft_geom::Affine::IDENTITY }]; let opts = designcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; let img = rr.render(&s.doc().unwrap().doc, &s.cache, &placed, 400, 400, designcraft_geom::Affine::IDENTITY, &opts); assert!(black(img.pixel(50, 100)) && cyan(img.pixel(150, 150)), "{:?} {:?}", img.pixel(50, 100), img.pixel(150, 150)); let inspect = s.execute("document.inspect", &json!({})).unwrap(); assert_eq!(inspect["parents"][1]["basedOn"], "A-Parent"); assert_eq!(inspect["parents"][0]["basedOn"], Value::Null); // Cycles are refused. assert!(s.execute("layout.parents.options", &json!({"parent": "A", "basedOn": "B"})).is_err()); assert!(s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "B-Opening"})).is_err()); assert!(s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "Z"})).is_err()); s.execute("edit.undo", &json!({})).unwrap(); assert!(paper(page(&s, 1).pixel(50, 100)), "undo clears Based on Parent"); s.execute("edit.redo", &json!({})).unwrap(); s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": null})).unwrap(); assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["parents"][1]["basedOn"], Value::Null); } /// A's page-number marker shown on parent XYZ (based on A) reads XYZ, as markers on XYZ's own items do. #[test] fn base_parent_markers_read_the_showing_parent() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); s.execute("layout.parents.new", &json!({"prefix": "XYZ"})).unwrap(); s.execute("layout.parents.options", &json!({"parent": "XYZ", "basedOn": "A"})).unwrap(); let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); let r = s .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) .unwrap(); let d = s.doc().unwrap().doc.clone(); let sid = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; let glyphs = |on| -> usize { let cs = s.cache.get_on(&d, sid, on); cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() }; assert_eq!(glyphs(designcraft_compose::ShownOn::Own), 1, "A"); assert_eq!(glyphs(designcraft_compose::ShownOn::Parent(1)), 3, "XYZ"); } /// A file whose parents are based on each other opens, with the cycle broken and reported. #[test] fn opening_a_parent_cycle_breaks_it_with_a_warning() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"pages": 1})).unwrap(); s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); let mut d = s.doc().unwrap().doc.as_ref().clone(); let (a, b) = (d.parents[0].id, d.parents[1].id); std::sync::Arc::make_mut(&mut d.parents[0]).parent.as_mut().unwrap().based_on = Some(b); std::sync::Arc::make_mut(&mut d.parents[1]).parent.as_mut().unwrap().based_on = Some(a); let b64 = crate::cmd::base64_encode(&crate::cmd::to_bytes(&d).unwrap()); let r = s.execute("file.openBytes", &json!({"name": "cycle", "base64": b64})).unwrap(); assert!(r["warnings"].to_string().contains("cycle"), "{r}"); let d = &s.doc().unwrap().doc; assert_eq!(d.parents[0].parent.as_ref().unwrap().based_on, None); assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(a)); } }