From 80803e3ae3d546b5b9ee360c4a08c73e227b1944 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 00:21:48 +0000 Subject: [PATCH 1/2] feat(parents): a parent can be based on another parent (#652) Pages draw the base parent's items, then the parent's, then their own, on the canvas, in PDF and image export, and on the parent spread itself. Chains are followed base first with a depth bound. Overrides, parent hit testing and the flattener's transparency check include base items. layout.parents.options {parent, basedOn} sets or clears the base and refuses cycles; document.inspect reports basedOn per parent. Opening a .designcraft or IDML file clears a cyclic or dangling base with a warning. IDML already carried the base as the parent pages' AppliedMaster; a test covers the round trip. --- ROADMAP.md | 1 + crates/doc/src/build.rs | 124 ++++++++++++++++++++++++++++- crates/engine/src/cmd/export.rs | 3 +- crates/engine/src/cmd/file.rs | 23 ++++-- crates/engine/src/cmd/inspect.rs | 5 +- crates/engine/src/cmd/layout.rs | 118 +++++++++++++++++++++++++++ crates/engine/src/cmd/overrides.rs | 75 +++++++++++++---- crates/idml/src/import.rs | 3 +- crates/idml/src/tests.rs | 24 ++++++ crates/pdf/src/export.rs | 27 ++++--- crates/render/src/lib.rs | 28 ++++--- crates/tools/src/lib.rs | 22 ++--- crates/ui-egui/src/canvas.rs | 13 ++- docs/target-app-parity.md | 3 +- 14 files changed, 396 insertions(+), 73 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 91a95161..6881d5ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · A parent can be based on another parent (`layout.parents.options`). Pages draw the base parent's items, then the parent's, then their own, on screen and in PDF and image export; chains work, cycles are refused, overrides reach base items, `document.inspect` reports `basedOn`, and IDML round-trips it. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c274f213..4045819b 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -547,11 +547,82 @@ impl Document { pub fn parent_page_for(&self, abs: usize) -> Option<(usize, usize)> { let page = self.page(abs)?; let pi = self.parent_index(page.parent?)?; - let ps = &self.parents[pi]; - // A parent spread can come without pages from a file. - let idx = if ps.pages.len() >= 2 && page.side == PageSide::Left { 0 } else { ps.pages.len().checked_sub(1)? }; - Some((pi, idx)) + Some((pi, shown_parent_page(self.parents.get(pi)?.pages.len(), page.side)?)) } + + /// Parent `pi` and the parents it is based on, base first: `[A, B]` for B based on A. + /// A dangling id, a repeated parent or [`MAX_PARENT_DEPTH`] ends the walk. + pub fn parent_chain(&self, pi: usize) -> Vec { + let mut out = Vec::new(); + let mut cur = Some(pi); + while let Some(i) = cur { + let Some(ps) = self.parents.get(i) else { break }; + if out.contains(&i) || out.len() >= MAX_PARENT_DEPTH { + break; + } + out.push(i); + cur = ps.parent.as_ref().and_then(|info| info.based_on).and_then(|b| self.parent_index(b)); + } + out.reverse(); + out + } + + /// Parent pages drawn behind page `page` of spread `sr`, back to front, as (parent index, + /// parent page index). A document page shows its parent and that parent's bases; a parent page + /// shows the parents its spread is based on. + pub fn shown_parent_pages(&self, sr: SpreadRef, page: usize) -> Vec<(usize, usize)> { + let Some(pg) = self.spread(sr).and_then(|sp| sp.pages.get(page)) else { return Vec::new() }; + let chain = match sr { + SpreadRef::Doc(_) => pg.parent.and_then(|id| self.parent_index(id)).map(|pi| self.parent_chain(pi)).unwrap_or_default(), + SpreadRef::Parent(pi) => { + let mut c = self.parent_chain(pi); + // The spread's own items are not part of what shows behind it. + if c.last() == Some(&pi) { + c.pop(); + } + c + } + }; + chain.into_iter().filter_map(|i| Some((i, shown_parent_page(self.parents.get(i)?.pages.len(), pg.side)?))).collect() + } + + /// Whether basing parent `pi` on `base` would make a `based_on` cycle (or names no parent). + pub fn parent_base_cycles(&self, pi: usize, base: SpreadId) -> bool { + self.parent_index(base).is_none_or(|bi| bi == pi || self.parent_chain(bi).contains(&pi)) + } + + /// Clear `based_on` links that name a missing parent or close a cycle (hand-edited or damaged + /// files). Returns one warning per link cleared. + pub fn fix_parent_bases(&mut self) -> Vec { + let mut warnings = Vec::new(); + for pi in 0..self.parents.len() { + let Some(ps) = self.parents.get(pi) else { continue }; + let Some(base) = ps.parent.as_ref().and_then(|info| info.based_on) else { continue }; + let label = ps.parent.as_ref().map(ParentInfo::label).unwrap_or_default(); + let why = if self.parent_index(base).is_none() { + "names a missing parent" + } else if self.parent_base_cycles(pi, base) { + "makes a cycle" + } else { + continue; + }; + if let Some(info) = self.parents.get_mut(pi).map(Arc::make_mut).and_then(|ps| ps.parent.as_mut()) { + info.based_on = None; + } + warnings.push(format!("parent {label}: Based on Parent {why}; it was set to [None]")); + } + warnings + } +} + +/// Deepest `based_on` chain followed. +pub const MAX_PARENT_DEPTH: usize = 64; + +/// The page of a parent spread with `count` pages that shows behind a page on `side`: the left +/// page of a facing parent for a left page, otherwise the last page. A parent spread can come +/// without pages from a file. +fn shown_parent_page(count: usize, side: PageSide) -> Option { + if count >= 2 && side == PageSide::Left { Some(0) } else { count.checked_sub(1) } } #[cfg(test)] @@ -631,4 +702,49 @@ mod tests { d.apply_parent(&[0], None).unwrap(); assert_eq!(d.parent_page_for(0), None); } + + fn base(d: &mut Document, pi: usize, on: Option) { + Arc::make_mut(&mut d.parents[pi]).parent.as_mut().unwrap().based_on = on; + } + + /// C based on B based on A: pages using C show A, then B, then C; C itself shows A and B. + #[test] + fn parent_chain_is_base_first() { + let mut d = Document::new(&NewDocument { pages: 2, facing_pages: false, ..Default::default() }); + let a = d.parents[0].id; + let b = d.add_parent("B", "Parent", 1, 12.0, Margins::uniform(36.0)); + let c = d.add_parent("C", "Parent", 1, 12.0, Margins::uniform(36.0)); + base(&mut d, 1, Some(a)); + base(&mut d, 2, Some(b)); + d.apply_parent(&[1], Some(c)).unwrap(); + assert_eq!(d.parent_chain(2), vec![0, 1, 2]); + assert_eq!(d.shown_parent_pages(SpreadRef::Doc(1), 0), vec![(0, 0), (1, 0), (2, 0)]); + assert_eq!(d.shown_parent_pages(SpreadRef::Doc(0), 0), vec![(0, 0)]); + assert_eq!(d.shown_parent_pages(SpreadRef::Parent(2), 0), vec![(0, 0), (1, 0)]); + assert!(d.parent_base_cycles(0, c), "A on C closes C → B → A"); + assert!(d.parent_base_cycles(0, a)); + assert!(!d.parent_base_cycles(2, a)); + assert!(d.fix_parent_bases().is_empty()); + } + + /// A cycle or a dangling base from a file is cleared with a warning; walks stay bounded before that. + #[test] + fn fix_parent_bases_breaks_cycles_and_dangling_ids() { + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() }); + let a = d.parents[0].id; + let b = d.add_parent("B", "Parent", 1, 12.0, Margins::uniform(36.0)); + d.add_parent("C", "Parent", 1, 12.0, Margins::uniform(36.0)); + base(&mut d, 0, Some(b)); + base(&mut d, 1, Some(a)); + base(&mut d, 2, Some(SpreadId(99_999))); + assert_eq!(d.parent_chain(0), vec![1, 0]); + assert_eq!(d.parent_chain(2), vec![2]); + let w = d.fix_parent_bases(); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w[0].contains("A-Parent") && w[0].contains("cycle"), "{w:?}"); + assert!(w[1].contains("C-Parent") && w[1].contains("missing"), "{w:?}"); + 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)); + assert_eq!(d.parents[2].parent.as_ref().unwrap().based_on, None); + } } diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d6..b7891d51 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -239,7 +239,8 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp rr.threads = designcraft_render::default_threads(); let k = ppi / 72.0; let parent_transparent = |pid: Option| { - pid.and_then(|id| d.parents.iter().find(|p| p.id == id)).is_some_and(|p| p.items.iter().any(|it| transparent(it))) + pid.and_then(|id| d.parent_index(id)) + .is_some_and(|pi| d.parent_chain(pi).iter().filter_map(|&i| d.parents.get(i)).any(|p| p.items.iter().any(|it| transparent(it)))) }; // An opaque image item showing `img` over `r`. let image_item = diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 692c363e..7cc0fe8e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -18,9 +18,9 @@ pub fn specs() -> Vec { cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())), cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), - "{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped (and, for .idml, package parts missing)}", + "{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, Based on Parent links cleared (and, for .idml, package parts missing)}", always, file_open), - cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes), + cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package → {index, warnings}", always, file_open_bytes), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save), cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state", has_doc, |s, p| { @@ -114,7 +114,15 @@ pub fn to_bytes(d: &Document) -> Vec { } pub fn from_bytes(b: &[u8]) -> Result { - designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string())) + load_bytes(b).map(|(d, _)| d) +} + +/// Read `.designcraft` bytes and repair what a hand-edited file can get wrong without failing the +/// open, with a warning for each repair. +fn load_bytes(b: &[u8]) -> Result<(Document, Vec)> { + let mut d = designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string()))?; + let warnings = d.fix_parent_bases(); + Ok((d, warnings)) } fn file_new(s: &mut Session, p: &Value) -> Result { @@ -165,10 +173,11 @@ fn file_open(s: &mut Session, p: &Value) -> Result { return super::interchange::open_idml(s, p); } let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - let mut d = from_bytes(&bytes)?; + let (mut d, mut warnings) = load_bytes(&bytes)?; super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent()); super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); - let (fonts, faces, warnings) = load_document_fonts(&mut d, path); + let (fonts, faces, font_warnings) = load_document_fonts(&mut d, path); + warnings.extend(font_warnings); let mut st = DocState::new(d, Some(path.to_string())); st.fonts = fonts; let i = s.add_document(st); @@ -224,13 +233,13 @@ fn file_open_bytes(s: &mut Session, p: &Value) -> Result { if designcraft_idml::is_idml(&b) { return super::interchange::open_idml(s, p); } - let mut d = from_bytes(&b)?; + let (mut d, warnings) = load_bytes(&b)?; super::datamerge::resolve_sources_on_open(&mut d, None); if let Some(n) = str_param(p, "name") { d.title = n.to_string(); } let i = s.add_document(DocState::new(d, None)); - Ok(json!({"index": i})) + Ok(json!({"index": i, "warnings": warnings})) } fn file_save(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f3c27336..4b960ad2 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -92,7 +92,10 @@ fn inspect(s: &mut Session, _p: &Value) -> Result { "title": st.title(), "path": st.path, "dirty": st.is_dirty(), "pageCount": d.page_count(), "settings": {"pageWidth": d.settings.page_width, "pageHeight": d.settings.page_height, "facingPages": d.settings.facing_pages, "intent": d.settings.intent}, "spreads": spreads, - "parents": d.parents.iter().map(|p| json!({"id": p.id.0, "label": p.parent.as_ref().map(|i| i.label()), "items": p.items.len()})).collect::>(), + "parents": d.parents.iter().map(|p| { + let based_on = p.parent.as_ref().and_then(|i| i.based_on).and_then(|b| d.parents.iter().find(|x| x.id == b)).and_then(|x| x.parent.as_ref()).map(|i| i.label()); + json!({"id": p.id.0, "label": p.parent.as_ref().map(|i| i.label()), "basedOn": based_on, "items": p.items.len()}) + }).collect::>(), "layers": d.layers, "stories": stories, "paragraphStyles": d.styles.paragraph.iter().map(|p| &p.name).collect::>(), diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd532079..7ff43c5d 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -179,6 +179,15 @@ pub fn specs() -> Vec { Ok(json!({"id": id.0, "prefix": prefix})) }) }), + cmd!( + "layout.parents.options", + "Parent Options…", + [], + None, + "{parent: \"B\" (prefix or label), basedOn: \"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!(noundo "layout.parents.edit", "Edit Parents", [], None, "{on: bool} — show parent spreads on the canvas", has_doc, |s, p| { let st = s.doc_mut()?; st.editing_parents = p.get("on").and_then(Value::as_bool).unwrap_or(!st.editing_parents); @@ -619,6 +628,41 @@ fn parent_ref(d: &designcraft_doc::Document, v: Option<&Value>) -> Option