diff --git a/ROADMAP.md b/ROADMAP.md index 972925a6..0128924c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,10 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. +- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. +- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. +- **2026-10-18** · View › Screen Mode: Bleed and Slug do what they say. Both were selectable from the toolbar, the View menu and `view.screenMode` but drew the canvas exactly as Normal did, guides and all; they are preview modes now, and what each page shows reaches the bleed box or the slug box instead of stopping at the trim. The slug guide is drawn as well, in the Slug colour from Preferences › Guides & Pasteboard, which nothing had read. No readiness percentage change. +- **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change. - **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. diff --git a/contributors/people.toml b/contributors/people.toml index 7a33de65..2f44f93d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -65,3 +65,6 @@ sync_github_name = false real_name = "Marco Arnulfo" display_name = "Marco Arnulfo" sync_github_name = false + +[people.4waymedia] +real_name = "Paul Gemignani" diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index db61e586..f8ae0ec1 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -2,7 +2,7 @@ //! (insert/delete/move pages, repagination into spreads, parents). use std::collections::BTreeMap; -use std::sync::Arc; +use std::{collections::HashSet, sync::Arc}; use designcraft_geom::{Affine, Unit, units}; use serde::{Deserialize, Serialize}; @@ -258,6 +258,53 @@ impl Document { id } + /// Delete a parent spread and unapply it wherever it is used. Local overrides survive as + /// ordinary items; the deleted parent's own items leave their text threads normally. + pub fn delete_parent(&mut self, id: SpreadId) -> Result<()> { + let index = self.parent_index(id).ok_or_else(|| DocError::Invalid(format!("no parent {id}")))?; + let parent = self.parents.get(index).ok_or_else(|| DocError::Invalid(format!("no parent {id}")))?; + let roots: Vec<_> = parent.items.iter().map(|item| item.id).collect(); + let mut deleted = HashSet::new(); + for item in &parent.items { + item.walk(&mut |item| { + deleted.insert(item.id); + }); + } + for item in roots { + self.remove_item(item)?; + } + self.parents.remove(index); + + let mut detached = Vec::new(); + for spread in self.spreads.iter_mut().chain(self.parents.iter_mut()) { + let spread = Arc::make_mut(spread); + if let Some(parent) = &mut spread.parent + && parent.based_on == Some(id) + { + parent.based_on = None; + } + for page in &mut spread.pages { + if page.parent == Some(id) { + page.parent = None; + } + page.overridden.retain(|item| !deleted.contains(item)); + } + for item in &spread.items { + item.walk(&mut |item| { + if item.overrides.is_some_and(|source| deleted.contains(&source)) { + detached.push(item.id); + } + }); + } + } + for item in detached { + if let Some(item) = self.item_mut(item) { + item.overrides = None; + } + } + Ok(()) + } + /// List the pages of each facing parent spread in binding order: left then right, or right /// then left when bound right to left. Positions stay, so the parent items keep their pages. pub fn order_parent_pages(&mut self) { diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index 89465448..862a1cda 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -301,17 +301,27 @@ impl Spread { pub fn bounds(&self) -> Rect { self.pages.iter().map(Page::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO) } - /// The bleed guide: one rectangle around the spread. `bleed` is the document's top, bottom, + /// The bleed guide: one rectangle around the spread. None without pages or without any bleed. + pub fn bleed_rect(&self, bleed: [f64; 4]) -> Option { + self.outset_rect(bleed) + } + /// The slug guide. Slug offsets are measured from the page edges, like the bleed, so a slug + /// narrower than the bleed sits inside it, as in InDesign. None without pages or without any + /// slug. + pub fn slug_rect(&self, slug: [f64; 4]) -> Option { + self.outset_rect(slug) + } + /// The spread's bounds grown by a document outset (the bleed or the slug): top, bottom, /// inside, outside; the inside value applies only at an outer edge on the spine side (a lone /// left page's right edge). The outer edges are the leftmost and rightmost pages, whichever - /// binding ordered the list. None without pages or without any bleed. - pub fn bleed_rect(&self, bleed: [f64; 4]) -> Option { + /// binding ordered the list. + pub fn outset_rect(&self, outset: [f64; 4]) -> Option { let (l, r) = self.outer_page_indices()?; let (first, last) = (self.pages.get(l)?, self.pages.get(r)?); - if !bleed.iter().any(|v| *v > 0.0) { + if !outset.iter().any(|v| *v > 0.0) { return None; } - let [top, bottom, inside, outside] = bleed; + let [top, bottom, inside, outside] = outset; let l = if first.side == PageSide::Left { outside } else { inside }; let r = if matches!(last.side, PageSide::Right | PageSide::Single) { outside } else { inside }; let b = self.bounds(); @@ -464,6 +474,9 @@ mod tests { let s = Spread { id: SpreadId(1), pages, items: vec![], parent: None, allow_shuffle: true }; assert_eq!(s.bleed_rect(bleed), Some(Rect::new(-4.0, -1.0, 1228.0, 794.0))); assert_eq!(s.bleed_rect([0.0; 4]), None); + // The slug is measured the same way, from the page edges. + assert_eq!(s.slug_rect(bleed), s.bleed_rect(bleed)); + assert_eq!(s.slug_rect([0.0; 4]), None); } let lone_left = Spread { id: SpreadId(1), pages: vec![page(PageSide::Left, 0.0)], items: vec![], parent: None, allow_shuffle: true }; assert_eq!(lone_left.bleed_rect(bleed), Some(Rect::new(-4.0, -1.0, 615.0, 794.0))); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index a33525aa..bf0e011a 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -254,7 +254,17 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, package: Option<&s let Some(package) = package else { return }; for asset in document.assets.values_mut() { let Some(link) = asset.link.as_deref() else { continue }; - if link.len() > MAX_LINK_PATH_BYTES || crate::io::document_path(link, Some(package)).is_ok_and(crate::io::is_file) { + if link.len() > MAX_LINK_PATH_BYTES { + continue; + } + if let Ok(path) = crate::io::document_path(link, Some(package)) + && crate::io::is_file(&path) + { + // A link relative to the document (what File › Package writes, `Links/logo.png`) is + // held as the file it names, as every other link is, so preflight and updates find it. + if std::path::Path::new(link).is_relative() { + std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned()); + } continue; } let Some(path) = packaged_link_candidates(link, Some(package)).into_iter().find(|p| crate::io::is_file(p)) else { continue }; diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 6a4e7f05..615e7c1d 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -257,6 +257,27 @@ pub fn specs() -> Vec { Ok(json!({"id": id.0, "prefix": prefix})) }) }), + cmd!( + "layout.parents.delete", + "Delete Parent", + [], + None, + "{id: parent spread id} — unapply the parent and keep local overrides as ordinary items", + has_doc, + |s, p| { + let id = p.get("id").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad("layout.parents.delete", "missing parent id"))?; + s.edit(|d, sel| { + d.delete_parent(id)?; + *sel = Default::default(); + ok() + })?; + let st = s.doc_mut()?; + if st.doc.parents.is_empty() { + st.editing_parents = false; + } + ok() + } + ), cmd!( noundo "layout.parents.edit", "Edit Parents", @@ -2030,3 +2051,7 @@ mod parent_base_tests { assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(a)); } } + +#[cfg(test)] +#[path = "parent_delete_tests.rs"] +mod parent_delete_tests; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 77a32487..82f33a6d 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -259,9 +259,28 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}", + "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed|stripes|style, pattern?: [dash, gap, …] (dashed: required, points), bands?: [[start, width], …] (stripes: required, fractions of stroke weight), name?: string (style: required, document stroke style name)} (other kinds take no extra fields; unknown type fields are errors), cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}", has_doc, |s, p| { + let kind: Option = p + .get("type") + .map(|v| { + if let Some(fields) = v.as_object() { + let field = match v.get("kind").and_then(Value::as_str) { + Some("dashed") => Some("pattern"), + Some("stripes") => Some("bands"), + Some("style") => Some("name"), + _ => None, + }; + for key in fields.keys() { + if key != "kind" && Some(key.as_str()) != field { + return Err(bad("object.stroke", format!("type: unknown field `{key}`"))); + } + } + } + serde_json::from_value(v.clone()).map_err(|e| bad("object.stroke", format!("type: {e}"))) + }) + .transpose()?; for key in ["swatch", "gapSwatch"] { if let Some(name) = p.get(key).and_then(Value::as_str) { known_swatch(&s.doc()?.doc, name, "object.stroke", key)?; @@ -314,11 +333,13 @@ pub fn specs() -> Vec { if let Some(v) = p2.get("gapOverprint").and_then(Value::as_bool) { st.gap_overprint = v; } - for (k, apply) in [("align", 0), ("type", 1), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] { + if let Some(kind) = &kind { + st.kind = kind.clone(); + } + for (k, apply) in [("align", 0), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] { if let Some(v) = p2.get(k).cloned() { let _ = match apply { 0 => serde_json::from_value(v).map(|x| st.align = x), - 1 => serde_json::from_value(v).map(|x| st.kind = x), 2 => serde_json::from_value(v).map(|x| st.cap = x), 3 => serde_json::from_value(v).map(|x| st.join = x), 4 => serde_json::from_value(v).map(|x| st.start = x), @@ -4824,6 +4845,64 @@ mod group_tests { } } +#[cfg(test)] +mod stroke_type_tests { + use serde_json::json; + + #[test] + fn malformed_stroke_types_return_bad_params_without_editing() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone(); + for (kind, hint) in [ + (json!({"kind": "dashed", "dash": [8, 2.5]}), "dash"), + (json!({"kind": "zigzag"}), "zigzag"), + (json!({"kind": "solid", "dash": [8, 2.5]}), "dash"), + (json!({"kind": "dashed", "pattern": [8, 2.5], "dash": [1, 2]}), "dash"), + (json!({"kind": "dashed"}), "pattern"), + (json!({"kind": "dashed", "pattern": "bad"}), "type"), + (json!({"kind": "stripes", "bands": [[0, "bad"]]}), "type"), + (json!({"kind": "style"}), "name"), + (json!({}), "kind"), + (json!("solid"), "type"), + (json!(null), "type"), + ] { + let before = s.doc().unwrap().doc.clone(); + let error = s.execute("object.stroke", &json!({"ids": [id], "weight": 5, "type": kind})).unwrap_err(); + assert!(matches!(error, crate::EngineError::BadParams { .. }), "{error}"); + assert!(error.to_string().contains(hint), "{kind}: {error}"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{kind}: nothing changed"); + } + } + + #[test] + fn stroke_types_apply_all_supported_variants() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + for kind in [ + json!({"kind": "solid"}), + json!({"kind": "dashed", "pattern": [8, 2.5]}), + json!({"kind": "dotted"}), + json!({"kind": "thickThin"}), + json!({"kind": "thinThick"}), + json!({"kind": "thinThin"}), + json!({"kind": "thickThick"}), + json!({"kind": "thinThickThin"}), + json!({"kind": "thickThinThick"}), + json!({"kind": "wavy"}), + json!({"kind": "hashed"}), + json!({"kind": "stripes", "bands": [[0, 0.3], [0.7, 0.3]]}), + json!({"kind": "style", "name": "Rails"}), + ] { + let result = s.execute("object.stroke", &json!({"ids": [id], "type": kind})).unwrap(); + assert_eq!(result["changed"], 1); + let item = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(item.stroke.kind, serde_json::from_value::(kind).unwrap()); + } + } +} + #[cfg(test)] mod swatch_name_tests { use serde_json::json; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index b953fdc7..f2d33ccf 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -18,6 +18,9 @@ use serde_json::{Value, json}; const MAX_OUTPUT_COMPONENT_BYTES: usize = 200; +/// The folder of a package that holds the files its document places. +const LINKS_FOLDER: &str = "Links"; + fn path_error(path: &Path, message: impl std::fmt::Display) -> EngineError { EngineError::Other(format!("{}: {message}", path.display())) } @@ -282,7 +285,10 @@ fn unique_name(base: String, taken: &mut Vec) -> String { } /// Write each asset's bytes into `dir`; returns the relinked document and the paths written. -fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec)> { +/// `relative_to` names the folder `dir` is, beside the saved document: the links then read +/// `Links/logo.png`, so a package holds no path from the machine that made it (#340). `None` links +/// the copies by their full paths (Links › Copy Link(s) To, which relinks the open document). +fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>, relative_to: Option<&str>) -> Result<(Document, Vec)> { root.validate_directory(dir)?; let mut out = d.clone(); let mut taken = Vec::new(); @@ -303,7 +309,10 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset let path = dir.join(&name); root.write(&path, a.data.as_slice())?; let mut na = (**a).clone(); - na.link = Some(path.to_string_lossy().to_string()); + na.link = Some(match relative_to { + Some(folder) => format!("{folder}/{name}"), + None => path.to_string_lossy().to_string(), + }); out.assets.insert(id, Arc::new(na)); written.push(path); } @@ -387,8 +396,8 @@ fn package(s: &mut Session, p: &Value) -> Result { let links = s.execute("links.list", &json!({}))?; let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null); let d = s.doc()?.doc.clone(); - let links_dir = root.child("Links")?; - let (packed, mut files) = write_links(&d, &root, &links_dir, None)?; + let links_dir = root.child(LINKS_FOLDER)?; + let (packed, mut files) = write_links(&d, &root, &links_dir, None, Some(LINKS_FOLDER))?; let doc_path = package_output_path(&dir, &title, "designcraft")?; root.write(&doc_path, &super::to_bytes(&packed)?)?; files.insert(0, doc_path); @@ -463,7 +472,7 @@ fn copy_links(s: &mut Session, p: &Value) -> Result { let only: Option> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(AssetId).collect()); let d = s.doc()?.doc.clone(); let root = OutputRoot::new(dir.clone())?; - let (relinked, written) = write_links(&d, &root, &dir, only.as_deref())?; + let (relinked, written) = write_links(&d, &root, &dir, only.as_deref(), None)?; s.edit(|doc, _| { doc.assets = relinked.assets; Ok(()) @@ -520,14 +529,17 @@ mod tests { std::io::Read::read_to_string(&mut f, &mut spreads).unwrap(); } } - let linked = dir.join("Links").join("my photo.png"); - let uri = format!("file:{}", linked.to_string_lossy().replace(' ', "%20")); + // Relative to the package: no path from the machine that made it (#340). + let uri = "file:Links/my%20photo.png"; + let folder = dir.file_name().unwrap().to_string_lossy().into_owned(); + assert!(!spreads.contains(&folder), "the package's own folder is not written: {spreads}"); assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}"); assert!(!spreads.contains(") -> ItemId { + let mut p = json!({"spread": {"kind": "parent", "index": parent}, "rect": [36, 36, 200, 100]}); + if let Some(text) = text { + p["content"] = json!("text"); + p["text"] = json!(text); + } + ItemId(s.execute("frame.create", &p).unwrap()["id"].as_u64().unwrap()) +} + +#[test] +fn parent_delete_rejects_invalid_ids_without_editing_or_adding_history() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("layout.parents.edit", &json!({"on": true})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + for params in [json!({}), json!({"id": null}), json!({"id": "A"}), json!({"id": -1}), json!({"id": 999999})] { + assert!(s.execute("layout.parents.delete", ¶ms).is_err(), "{params}"); + let st = s.doc().unwrap(); + assert!(Arc::ptr_eq(&st.doc, &before)); + assert_eq!(st.history.undo.len(), history); + assert!(st.editing_parents); + } +} + +#[test] +fn parent_delete_preserves_local_copies_cleans_references_and_roundtrips_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let a = s.doc().unwrap().doc.parents[0].id; + let text = frame(&mut s, 0, Some("Parent folio")); + let story = s.doc().unwrap().doc.item(text).unwrap().text_frame().unwrap().story; + let shape = frame(&mut s, 0, None); + let group = ItemId(s.execute("object.group", &json!({"ids": [text.0, shape.0]})).unwrap()["id"].as_u64().unwrap()); + let local = ItemId(s.execute("layout.overrideParentItems", &json!({"page": 0})).unwrap()["ids"][0].as_u64().unwrap()); + let local_text = s.doc().unwrap().doc.item(local).unwrap().children().iter().find(|i| i.text_frame().is_some()).unwrap().id; + let local_story = s.doc().unwrap().doc.item(local_text).unwrap().text_frame().unwrap().story; + assert_ne!(story, local_story); + + let b = SpreadId(s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap()); + let c = SpreadId(s.execute("layout.parents.new", &json!({"prefix": "C"})).unwrap()["id"].as_u64().unwrap()); + let inherited = frame(&mut s, 1, Some("Local parent edit")); + s.edit(|d, _| { + // Imported parents can base themselves on another parent and carry local overrides. + let bp = Arc::make_mut(&mut d.parents[1]); + bp.parent.as_mut().unwrap().based_on = Some(a); + bp.pages[0].parent = Some(a); + bp.pages[0].overridden.push(text); + Arc::make_mut(&mut d.parents[2]).parent.as_mut().unwrap().based_on = Some(b); + d.item_mut(inherited).unwrap().overrides = Some(text); + Ok(()) + }) + .unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap(); + let other_local = ItemId(s.execute("layout.overrideParentItems", &json!({"page": 1, "ids": [inherited.0]})).unwrap()["ids"][0].as_u64().unwrap()); + s.execute("layout.parents.edit", &json!({"on": true})).unwrap(); + s.doc_mut().unwrap().selection = Selection::text(TextSel::caret(story, 0)); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + before.check().unwrap(); + + s.execute("layout.parents.delete", &json!({"id": a.0})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), history + 1); + assert_eq!(st.selection, Selection::default()); + assert!(st.editing_parents, "other parents remain editable"); + let d = &st.doc; + assert_eq!(d.parents.iter().map(|p| p.id).collect::>(), [b, c]); + assert_eq!(d.page(0).unwrap().parent, None); + assert_eq!(d.page(1).unwrap().parent, Some(b)); + assert_eq!(d.page(2).unwrap().parent, None); + assert_eq!(d.parents[0].parent.as_ref().unwrap().based_on, None); + assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(b)); + assert_eq!(d.parents[0].pages[0].parent, None); + assert!(d.parents[0].pages[0].overridden.is_empty()); + assert!([group, text, shape].iter().all(|id| d.item(*id).is_none())); + assert!(d.story(story).is_none(), "nested text frames leave no dangling story"); + assert_eq!(d.item(local).unwrap().overrides, None); + assert_eq!(d.story(local_story).unwrap().text, "Parent folio"); + assert!(d.page(0).unwrap().overridden.is_empty()); + assert_eq!(d.item(inherited).unwrap().overrides, None); + assert_eq!(d.item(other_local).unwrap().overrides, Some(inherited)); + assert_eq!(d.page(1).unwrap().overridden, [inherited]); + d.check().unwrap(); + let after = d.clone(); + + s.execute("edit.undo", &Value::Null).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.doc().unwrap().doc.check().unwrap(); + s.execute("edit.redo", &Value::Null).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn parent_delete_last_parent_returns_to_document_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + let id = s.doc().unwrap().doc.parents[0].id; + s.execute("layout.parents.edit", &json!({"on": true})).unwrap(); + frame(&mut s, 0, Some("Last parent")); + let selection_before = s.doc().unwrap().selection.clone(); + assert!(selection_before.text.is_some(), "creating the text frame leaves a caret"); + s.execute("layout.parents.delete", &json!({"id": id.0})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.parents.is_empty()); + assert!(!st.editing_parents); + assert_eq!(st.selection, Selection::default()); + assert_eq!(st.doc.page_count(), 2); + assert!(st.doc.spreads.iter().flat_map(|sp| &sp.pages).all(|page| page.parent.is_none())); + s.execute("edit.undo", &Value::Null).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].id, id); + assert_eq!(s.doc().unwrap().selection, selection_before); + s.execute("edit.redo", &Value::Null).unwrap(); + assert!(s.doc().unwrap().doc.parents.is_empty()); + assert!(!s.doc().unwrap().editing_parents); + assert_eq!(s.doc().unwrap().selection, Selection::default()); + // A parent-free document still supports creating another parent. + s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents.len(), 1); + s.doc().unwrap().doc.check().unwrap(); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 33784f6f..a92f1c13 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -32,7 +32,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; pub use catalog::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info}; -pub use layout::CanvasLayout; +pub use layout::{CanvasLayout, SpreadSlot}; pub use nav::ZoomTool; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34e1aa2f..8d94f099 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5,7 +5,7 @@ use designcraft_compose as compose; use designcraft_doc::{Content, Document, Item, SpreadRef}; use designcraft_engine::doc::Selection; use designcraft_geom::{Affine, Point, Rect as DRect, Vec2 as DVec2}; -use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolKey}; +use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, SpreadSlot, ToolKey}; use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value, json}; @@ -477,7 +477,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { layout.slots = layout.slots.get(cur).copied().into_iter().collect(); } let painter = ui.painter_at(screen); - let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation); + // Preview, Bleed, Slug and Presentation are all preview modes: guides, frame edges and the + // other non-printing marks go, and only the shown area of each page keeps its content. They + // differ in how much of the page shows, which `shown_area` gives. + let preview = app.ui.screen_mode != ScreenMode::Normal; let bg = if app.ui.screen_mode == ScreenMode::Presentation { Color32::BLACK } else { t.pasteboard }; painter.rect_filled(screen, 0.0, bg); // Page shadow: a hard 1.5 pt black offset on the right and bottom (InDesign 2026). @@ -514,9 +517,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } mesh.indices.extend([0, 1, 2, 0, 2, 3]); if preview { - // Preview: only page areas (trim) show content. + // Preview: only the shown area of each spread keeps its content. for slot in &layout.slots { - let r = xf.rect(slot.bounds); + let r = shown_area(app, &doc, &xf, slot); painter.with_clip_rect(r.intersect(screen)).add(egui::Shape::mesh(mesh.clone())); } } else { @@ -933,6 +936,20 @@ fn baseline_grid_lines(start: f64, increment: f64, height: f64) -> impl Iterator (0..MAX_BASELINES).map(move |n| first + n as f64 * step).take_while(move |y| *y < height) } +/// What a screen mode shows of a spread, in screen coordinates: the trim, or out to the bleed or +/// the slug box. Slug shows every printing element, so the bleed as well — and a slug narrower +/// than the bleed does not pull the edge back in. +fn shown_area(app: &DesignApp, doc: &Document, xf: &Xf, slot: &SpreadSlot) -> Rect { + let trim = xf.rect(slot.bounds); + let boxes: &[[f64; 4]] = match app.ui.screen_mode { + ScreenMode::Bleed => &[doc.settings.bleed], + ScreenMode::Slug => &[doc.settings.bleed, doc.settings.slug], + _ => &[], + }; + let Some(sp) = doc.spread(slot.spread) else { return trim }; + boxes.iter().filter_map(|o| sp.outset_rect(*o)).map(|r| xf.rect(slot.xf.transform_rect_bbox(r))).fold(trim, |a, b| a.union(b)) +} + fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let s = &doc.settings; for slot in &layout.slots { @@ -941,11 +958,14 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document let m = slot.xf; let pt = |p: Point| xf.to_screen(m * p); let rect = |r: DRect| xf.rect(m.transform_rect_bbox(r)); - // Bleed: one rectangle around the spread (inside bleed only applies at the spread's outer edges). - if app.ui.guides - && let Some(br) = sp.bleed_rect(s.bleed) - { - painter.rect_stroke(rect(br), 0.0, Stroke::new(hair(painter), c32(s.bleed_color)), StrokeKind::Middle); + // Bleed, then slug: one rectangle each around the spread (the inside offset only applies + // at the spread's outer edges). + if app.ui.guides { + for (r, color) in [(sp.bleed_rect(s.bleed), s.bleed_color), (sp.slug_rect(s.slug), s.slug_color)] { + if let Some(r) = r { + painter.rect_stroke(rect(r), 0.0, Stroke::new(hair(painter), c32(color)), StrokeKind::Middle); + } + } } let pasteboard = m.inverse().transform_rect_bbox(layout.pasteboard); for (pi, p) in sp.pages.iter().enumerate() { @@ -3028,6 +3048,81 @@ mod tests { } } + /// Bleed and Slug are preview modes: like Preview they hide the guides, and what the page + /// shows widens to the bleed or the slug box. The slug guide is drawn in Normal, in its own + /// colour, beside the bleed guide. + #[test] + fn bleed_and_slug_are_preview_modes_that_show_their_area() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"bleed": 9, "slug": 18})).unwrap(); + let strokes = |app: &mut DesignApp, want: [u8; 3]| -> usize { + let ctx = egui::Context::default(); + let mut shapes = Vec::new(); + for _ in 0..2 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + shapes = out.shapes; + } + let want = c32(want); + shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(s) if s.stroke.color == want)).count() + }; + let s = app.session.active().unwrap().doc.settings.clone(); + // Normal draws every guide, the slug's among them. + app.ui.screen_mode = ScreenMode::Normal; + assert!(strokes(&mut app, s.margin_color) > 0, "Normal draws the margin guide"); + assert!(strokes(&mut app, s.bleed_color) > 0, "Normal draws the bleed guide"); + assert!(strokes(&mut app, s.slug_color) > 0, "Normal draws the slug guide"); + // Every preview mode hides them. + for mode in [ScreenMode::Preview, ScreenMode::Bleed, ScreenMode::Slug, ScreenMode::Presentation] { + app.ui.screen_mode = mode; + assert_eq!(strokes(&mut app, s.margin_color), 0, "{mode:?} hides the margin guide"); + assert_eq!(strokes(&mut app, s.slug_color), 0, "{mode:?} hides the slug guide"); + } + } + + /// Each preview mode's shown area: Preview stops at the trim, Bleed reaches the bleed box and + /// Slug the slug box. A slug narrower than the bleed never pulls the edge back in. + #[test] + fn shown_area_widens_to_the_bleed_and_slug_boxes() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"bleed": 9, "slug": 18})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let layout = CanvasLayout::new(&doc, false); + let xf = Xf::new(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0)), &View::default()); + let slot = &layout.slots[0]; + let area = |app: &mut DesignApp, mode| { + app.ui.screen_mode = mode; + shown_area(app, &doc, &xf, slot) + }; + let trim = xf.rect(slot.bounds); + assert_eq!(area(&mut app, ScreenMode::Preview), trim, "Preview stops at the trim"); + assert_eq!(area(&mut app, ScreenMode::Presentation), trim, "Presentation stops at the trim"); + let bleed = area(&mut app, ScreenMode::Bleed); + let slug = area(&mut app, ScreenMode::Slug); + assert!(bleed.contains_rect(trim) && bleed != trim, "Bleed reaches past the trim"); + assert!(slug.contains_rect(bleed) && slug != bleed, "Slug reaches past the bleed"); + // 9 pt of bleed and 18 pt of slug at this zoom, on every edge. + let grew = |r: Rect, pt: f64| { + let d = (pt * xf.zoom) as f32; + (r.min - trim.min + vec2(d, d)).length() < 0.01 && (r.max - trim.max - vec2(d, d)).length() < 0.01 + }; + assert!(grew(bleed, 9.0), "Bleed grows by the bleed: {bleed:?}"); + assert!(grew(slug, 18.0), "Slug grows by the slug: {slug:?}"); + // A slug inside the bleed: Slug still shows every printing element, so the bleed too. + app.run("file.new", serde_json::json!({"bleed": 18, "slug": 9})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + app.ui.screen_mode = ScreenMode::Slug; + assert!(grew(shown_area(&app, &doc, &xf, slot), 18.0), "the wider of the two boxes wins"); + // No bleed and no slug: nothing to widen to. + app.run("file.new", serde_json::json!({})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + for mode in [ScreenMode::Bleed, ScreenMode::Slug] { + app.ui.screen_mode = mode; + assert_eq!(shown_area(&app, &doc, &xf, slot), trim, "{mode:?} without a bleed or slug"); + } + } + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 0562b4ed..4b2360f8 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -377,6 +377,17 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if resp.dragged() { drag_ghost(ui, label, &t); } + if let Some((_, id)) = row.parent { + resp.context_menu(|ui| { + let label = crate::i18n::tr(&app.ui.language, "Delete Parent"); + if ui.button(crate::rtl::widget(ui, label)).clicked() { + if app.run("layout.parents.delete", json!({"id": id.0})).is_ok() { + app.views.clear(); + } + ui.close(); + } + }); + } }; if opts.parents_vertical { for row in &rows { @@ -778,6 +789,30 @@ mod tests { use super::*; use crate::test_window; + #[test] + fn parent_context_menu_deletes_the_clicked_id_even_with_a_shared_prefix() { + for vertical in [true, false] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + app.ui.pages_panel.parents_vertical = vertical; + let original = app.session.doc().unwrap().doc.parents[0].id; + let added = app.run("layout.parents.new", json!({"prefix": "A", "name": "Other"})).unwrap()["id"].as_u64().unwrap(); + let mut h = harness(app); + // Target the actual parent row in either panel layout, not a menu helper. + h.get_by_label("A-Other").click_secondary(); + h.run_steps(3); + h.get_by_label("Delete Parent").click(); + h.run_steps(3); + let doc = &h.state().session.doc().unwrap().doc; + assert_eq!(doc.parents.len(), 1); + assert_eq!(doc.parents[0].id, original); + assert!(doc.parents.iter().all(|p| p.id.0 != added)); + assert_eq!(doc.page_count(), 2, "document pages are not deleted"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.doc().unwrap().doc.parents.len(), 2); + } + } + #[test] fn double_clicking_a_parent_shows_that_parent() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/agents.md b/docs/agents.md index d55a1d38..2b8d6e51 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -87,3 +87,12 @@ designcraft-cli app --method ui.screenshot '{"path": "/tmp/w.png"}' # any contro `ui.menu.invoke {command}` without params acts like choosing the menu item: a command labelled "…" opens its dialog, which `ui.dialog.set` / `ui.dialog.confirm` fill in and close like a user would. + +## Parent pages + +In the Pages panel, right-click a parent row and choose **Delete Parent**. The equivalent +command is `layout.parents.delete {id}`, using the parent spread's numeric ID. It deletes that +parent's shared artwork and clears its assignments from document pages and other parents. +Local override copies remain as ordinary editable objects; document pages and their own content +remain. Undo restores the parent and its relationships in one step. Deleting the last parent +returns the canvas to document pages. Parent navigation is a separate operation. diff --git a/docs/gaps.md b/docs/gaps.md index d328cd4c..5738d14c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -57,8 +57,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Missing:** a corpus test of real InDesign-authored IDML (via `storytold/photocraft-corpus` or a sibling) measuring what survives import → export → reopen in InDesign. Open bugs: frames not rendered without a warning (#193), `$ID/` font names reported missing (#338), no - `PDFAttribute` on export (#339), Package writes build-machine absolute paths (#340), drag-drop - open of IDML on the app icon (#211). + `PDFAttribute` on export (#339), drag-drop open of IDML on the app icon (#211). - **Evidence:** importer coverage is broad (styles, tables, footnotes, xrefs, index, CJK, Arabic, verified in InDesign 2026 with synthetic files), but nothing is measured on real documents. - **Impact:** IDML is the main interchange format until INDD lands, and the only one that goes