Merge parent-based-on: Parents: a parent can be based on another parent (#652)

# Conflicts:
#	ROADMAP.md
#	crates/compose/src/cache.rs
#	crates/doc/src/build.rs
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/cmd/layout.rs
#	crates/idml/src/import.rs
#	crates/idml/src/tests.rs
#	crates/pdf/src/tests.rs
#	crates/render/src/lib.rs
#	crates/render/src/text.rs
#	crates/tools/src/snap.rs
#	docs/gaps.md
#	docs/target-app-parity.md
This commit is contained in:
aiongg
2026-10-13 01:00:08 +00:00
22 changed files with 653 additions and 244 deletions
+1
View File
@@ -128,6 +128,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** · The Effects dialog, as in InDesign: Object › Effects (Transparency…, Drop Shadow… and the rest, and their shortcuts) and the Effects panel's fx button open it on the chosen effect, turned on. Transparency and every effect in one window, in the document's units, with X/Y offsets, Preview on the page, Cancel putting things back, and OK as one undo step (`object.effects`). Shadows default to InDesign's 120° light angle. No readiness percentage change.
- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change.
- **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change.
+40 -11
View File
@@ -14,9 +14,29 @@ use designcraft_doc::{Document, Item, Story, StoryId, Styles};
use crate::vars::RunningIndex;
use crate::{ComposeOptions, ComposedStory};
/// A story, composed for one absolute page (parent-page items) or for its own frames (`None`),
/// with other stories' anchored objects wrapping it (`true`) or not.
type Key = (StoryId, Option<usize>, bool);
/// Where a story's frames are shown, which decides what its page-number markers and variables
/// read.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ShownOn {
/// In its own frames.
#[default]
Own,
/// As a parent item on this absolute document page.
Page(usize),
/// As a base parent's item on this parent spread (index into `parents`): markers show that
/// parent's prefix.
Parent(usize),
}
impl From<Option<usize>> for ShownOn {
fn from(page: Option<usize>) -> Self {
page.map_or(ShownOn::Own, ShownOn::Page)
}
}
/// A story, composed for where it is shown, with other stories' anchored objects wrapping it
/// (`true`) or not.
type Key = (StoryId, ShownOn, bool);
struct Entry {
sig: Vec<usize>,
@@ -63,11 +83,16 @@ impl Cache {
/// `None` for a story's own frames. Pages are identified by index, not name: names repeat when
/// sections restart their numbering.
pub fn get(&self, doc: &Document, sid: StoryId, page: Option<usize>) -> Arc<ComposedStory> {
self.get_with(doc, sid, page, true)
self.get_on(doc, sid, page.into())
}
/// [`Cache::get`], wrapped by other stories' anchored objects only when `cross` is set.
fn get_with(&self, doc: &Document, sid: StoryId, page: Option<usize>, cross: bool) -> Arc<ComposedStory> {
/// [`Cache::get`] for any [`ShownOn`].
pub fn get_on(&self, doc: &Document, sid: StoryId, on: ShownOn) -> Arc<ComposedStory> {
self.get_with(doc, sid, on, true)
}
/// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set.
fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool) -> Arc<ComposedStory> {
let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) };
let (mut sig, mut keep_items) = signature(doc, story);
let neighbours = if cross { crate::wrap::neighbours(doc, sid) } else { Vec::new() };
@@ -81,10 +106,14 @@ impl Cache {
keep_stories.push(ns.clone());
}
}
let key = (sid, page, !neighbours.is_empty());
let key = (sid, on, !neighbours.is_empty());
// The key holds the page's index, which a renumbering doesn't change: its name (section
// start number, style, prefix) is part of the signature.
let page_name = page.map(|p| doc.page_name(p));
let (page_name, page) = match on {
ShownOn::Own => (None, None),
ShownOn::Page(p) => (Some(doc.page_name(p)), Some(p)),
ShownOn::Parent(pi) => (doc.parents.get(pi).and_then(|sp| sp.parent.as_ref()).map(|i| i.prefix.clone()), None),
};
let section_marker = page.map(|p| crate::section_marker(doc, p));
if let Some(name) = &page_name {
use std::hash::{Hash, Hasher};
@@ -184,7 +213,7 @@ impl Cache {
let replaced = g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items, keep_stories), out: out.clone(), stamp });
drop(g);
// Only the composition that is shown (not one made for another story's wrap).
if key.1.is_none()
if key.1 == ShownOn::Own
&& cross
&& let Some(old) = replaced
{
@@ -203,7 +232,7 @@ impl Cache {
{
let g = self.map.lock().unwrap_or_else(|e| e.into_inner());
for cross in [true, false] {
if let Some(e) = g.0.get(&(sid, None, cross))
if let Some(e) = g.0.get(&(sid, ShownOn::Own, cross))
&& matches(&e.sig)
{
return Some(e.out.clone());
@@ -258,7 +287,7 @@ impl Cache {
fn neighbour(&self, doc: &Document, sid: StoryId) -> Arc<ComposedStory> {
let indexed =
doc.stories.get(&sid).is_some_and(|st| crate::xref::has_xrefs(st) || (crate::vars::has_vars(st) && crate::vars::has_running(doc, st)));
if indexed { Arc::new(crate::wrap::compose_among(doc, sid, &ComposeOptions::default(), &[])) } else { self.get_with(doc, sid, None, false) }
if indexed { Arc::new(crate::wrap::compose_among(doc, sid, &ComposeOptions::default(), &[])) } else { self.get_with(doc, sid, ShownOn::Own, false) }
}
pub fn clear(&self) {
+1 -1
View File
@@ -45,7 +45,7 @@ use designcraft_fonts::{FontDb, ScopedFonts};
use designcraft_geom::{Affine, Point, Rect};
use crate::breaker::{Break, Spacing};
pub use crate::cache::Cache;
pub use crate::cache::{Cache, ShownOn};
use crate::notes::Notes;
pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret};
pub use crate::outline::FrameOutline;
+144
View File
@@ -644,8 +644,88 @@ impl Document {
}
}
}
/// 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<usize> {
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, self.parents.get(i)?.parent_page_for_side(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 {
let Some(mut cur) = self.parent_index(base) else { return true };
// Not `parent_chain`: its depth cap would miss a long cycle. Every parent is visited at most once.
let mut seen = std::collections::HashSet::new();
loop {
if cur == pi {
return true;
}
if !seen.insert(cur) || seen.len() > self.parents.len() {
return false;
}
let next = self.parents.get(cur).and_then(|ps| ps.parent.as_ref()).and_then(|info| info.based_on).and_then(|b| self.parent_index(b));
let Some(n) = next else { return false };
cur = n;
}
}
/// 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<String> {
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;
#[cfg(test)]
mod tests {
use super::*;
@@ -749,4 +829,68 @@ 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<SpreadId>) {
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);
}
/// A cycle longer than the drawing depth cap is still found and broken.
#[test]
fn fix_parent_bases_breaks_a_long_cycle() {
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() });
for i in 1..65 {
d.add_parent(&format!("P{i}"), "Parent", 1, 12.0, Margins::uniform(36.0));
}
assert_eq!(d.parents.len(), 65);
let ids: Vec<SpreadId> = d.parents.iter().map(|p| p.id).collect();
for i in 0..65 {
base(&mut d, i, Some(ids[(i + 1) % 65]));
}
assert_eq!(d.parent_chain(0).len(), MAX_PARENT_DEPTH);
let w = d.fix_parent_bases();
assert_eq!(w.len(), 1, "{w:?}");
assert_eq!(d.parents[0].parent.as_ref().unwrap().based_on, None);
assert!((0..65).all(|i| d.parent_base_cycles(i, ids[(i + 1) % 65]) == (i == 0)), "only the cleared link would close it again");
}
}
+2 -1
View File
@@ -240,7 +240,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<designcraft_doc::SpreadId>| {
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 =
+16 -7
View File
@@ -39,9 +39,9 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"),
"{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile and, for IDML and InDesign files, package parts missing}",
"{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}",
always, file_open),
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt", always, file_open_bytes),
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} — a `path` without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} — a path without the .designcraft extension gets it → {path: the file written, bytes}", 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; a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, |s, p| {
@@ -152,7 +152,15 @@ pub fn is_document(bytes: &[u8]) -> bool {
}
pub fn from_bytes(b: &[u8]) -> Result<Document> {
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<String>)> {
let mut d = designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string()))?;
let warnings = d.fix_parent_bases();
Ok((d, warnings))
}
/// The longest saved preset name, and how many presets are kept.
@@ -272,10 +280,11 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
if !native && ext != designcraft_format::EXTENSION {
return Err(bad("file.open", format!("{path}: not a DesignCraft or IDML file")));
}
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, mut warnings) = load_document_fonts(&mut d, path);
let (fonts, faces, font_warnings) = load_document_fonts(&mut d, path);
warnings.extend(font_warnings);
warnings.extend(super::style::grep_style_warnings(&d));
let mut st = DocState::new(d, Some(path.to_string()));
st.fonts = fonts;
@@ -333,13 +342,13 @@ fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
if designcraft_idml::is_idml(&b) || super::interchange::is_indd(str_param(p, "name").unwrap_or_default(), &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<Value> {
+4 -1
View File
@@ -92,7 +92,10 @@ fn inspect(s: &mut Session, _p: &Value) -> Result<Value> {
"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::<Vec<_>>(),
"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::<Vec<_>>(),
"layers": d.layers,
"stories": stories,
"paragraphStyles": d.styles.paragraph.iter().map(|p| &p.name).collect::<Vec<_>>(),
+136
View File
@@ -232,6 +232,15 @@ pub fn specs() -> Vec<CommandSpec> {
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…",
@@ -738,6 +747,31 @@ fn parent_ref(cmd: &str, d: &designcraft_doc::Document, v: Option<&Value>) -> Re
}
}
/// 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<Value> {
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<()>) {}
@@ -1599,3 +1633,105 @@ mod parent_tests {
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));
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));
}
}
+93 -16
View File
@@ -54,35 +54,49 @@ fn page_param(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
Ok(page)
}
/// The parent items that show on a document page, with the offset from parent spread to
/// document spread.
fn parent_items(d: &Document, page: usize) -> Option<(Vec<ItemId>, Vec2)> {
/// The parent items that show on a document page (its parent's and that parent's bases', back to
/// front), each with the offset from its parent spread to the document spread.
fn parent_items(d: &Document, page: usize) -> Option<Vec<(ItemId, Vec2)>> {
let (si, pi) = d.page_loc(page)?;
let pg = &d.spreads[si].pages[pi];
let (ppi, ppage) = d.parent_page_for(page)?;
let parent = d.parents.get(ppi)?;
let dx = pg.x - parent.pages.get(ppage)?.x;
let ids =
parent.items.iter().filter(|it| parent.pages.len() <= 1 || parent.page_at_x(it.bounds().center().x) == Some(ppage)).map(|it| it.id).collect();
Some((ids, Vec2::new(dx, 0.0)))
let pg = d.spreads.get(si)?.pages.get(pi)?;
let shown = d.shown_parent_pages(SpreadRef::Doc(si), pi);
if shown.is_empty() {
return None;
}
let mut out = Vec::new();
for (ppi, ppage) in shown {
let Some(parent) = d.parents.get(ppi) else { continue };
let Some(ppg) = parent.pages.get(ppage) else { continue };
let off = Vec2::new(pg.x - ppg.x, 0.0);
out.extend(
parent
.items
.iter()
.filter(|it| parent.pages.len() <= 1 || parent.page_at_x(it.bounds().center().x) == Some(ppage))
.map(|it| (it.id, off)),
);
}
Some(out)
}
fn override_items(s: &mut Session, p: &Value) -> Result<Value> {
let page = page_param(s, p, "layout.overrideParentItems")?;
let wanted: Option<Vec<ItemId>> = super::ids_param(p, "ids");
s.edit(|d, sel| {
let (all, off) = parent_items(d, page).ok_or_else(|| bad("layout.overrideParentItems", "the page has no parent"))?;
let all = parent_items(d, page).ok_or_else(|| bad("layout.overrideParentItems", "the page has no parent"))?;
let (si, pi) = d.page_loc(page).ok_or_else(|| bad("layout.overrideParentItems", "no such page"))?;
let already = d.spreads[si].pages[pi].overridden.clone();
let ids: Vec<ItemId> = all.into_iter().filter(|i| wanted.as_ref().is_none_or(|w| w.contains(i)) && !already.contains(i)).collect();
if ids.is_empty() {
let picked: Vec<(ItemId, Vec2)> =
all.into_iter().filter(|(i, _)| wanted.as_ref().is_none_or(|w| w.contains(i)) && !already.contains(i)).collect();
if picked.is_empty() {
return Ok(json!({"ids": []}));
}
let ids: Vec<ItemId> = picked.iter().map(|(i, _)| *i).collect();
let src = d.clone();
// Copies on the document spread (stories and images come along).
// Copies on the document spread (stories and images come along), base parents' first.
let mut new = Vec::new();
for id in &ids {
let copy = super::object::duplicate_from(d, &src, &[*id], SpreadRef::Doc(si), off)?;
for (id, off) in &picked {
let copy = super::object::duplicate_from(d, &src, &[*id], SpreadRef::Doc(si), *off)?;
if let Some(nid) = copy.first() {
if let Some(it) = d.item_mut(*nid) {
it.overrides = Some(*id);
@@ -173,4 +187,67 @@ mod tests {
assert!(d.spreads[si].pages[pi].overridden.is_empty());
d.check().unwrap();
}
/// On a page whose parent B is based on A, A's items can be overridden too.
#[test]
fn override_items_from_a_base_parent() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap();
let on = |s: &mut Session, index: usize, rect: [f64; 4]| {
let r = s.execute("frame.create", &json!({"spread": {"kind": "parent", "index": index}, "rect": rect, "content": "unassigned"})).unwrap();
ItemId(r["id"].as_u64().unwrap())
};
let a_item = on(&mut s, 0, [72.0, 72.0, 200.0, 200.0]);
let b_item = on(&mut s, 1, [300.0, 72.0, 400.0, 200.0]);
s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "A"})).unwrap();
s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap();
let r = s.execute("layout.overrideParentItems", &json!({"page": 1, "ids": [a_item.0]})).unwrap();
let copy = ItemId(r["ids"][0].as_u64().unwrap());
let d = s.doc().unwrap().doc.clone();
assert_eq!(d.item(copy).unwrap().overrides, Some(a_item));
assert_eq!(d.page_of_item(copy), Some(1));
let b = d.item(copy).unwrap().bounds();
assert!((b.x0 - 72.0).abs() < 1e-9 && (b.y0 - 72.0).abs() < 1e-9, "{b:?}");
// Overriding the rest picks up B's item only.
let r = s.execute("layout.overrideParentItems", &json!({"page": 1})).unwrap();
let rest: Vec<ItemId> = r["ids"].as_array().unwrap().iter().map(|v| ItemId(v.as_u64().unwrap())).collect();
let d = s.doc().unwrap().doc.clone();
assert_eq!(rest.iter().map(|i| d.item(*i).unwrap().overrides).collect::<Vec<_>>(), vec![Some(b_item)]);
d.check().unwrap();
}
/// Facing pages: 2-page B based on 2-page A. Each document page offers only the items on its
/// side of A, and the copy lands where the item showed: page 1 (a single right page at x 0)
/// shows A's right-page items 612 pt to the left.
#[test]
fn override_base_items_on_facing_pages_keeps_their_place() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap();
s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap();
s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "A"})).unwrap();
s.execute("layout.pages.applyParent", &json!({"pages": [0, 1, 2], "parent": "B"})).unwrap();
let on_a = |s: &mut Session, rect: [f64; 4]| {
let r = s.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": rect, "content": "unassigned"})).unwrap();
ItemId(r["id"].as_u64().unwrap())
};
let left = on_a(&mut s, [72.0, 72.0, 200.0, 200.0]);
let right = on_a(&mut s, [684.0, 72.0, 812.0, 200.0]);
let copy_of = |s: &mut Session, page: usize, id: ItemId| -> Option<ItemId> {
let r = s.execute("layout.overrideParentItems", &json!({"page": page, "ids": [id.0]})).unwrap();
r["ids"].as_array().unwrap().first().map(|v| ItemId(v.as_u64().unwrap()))
};
assert_eq!(copy_of(&mut s, 0, left), None, "A's left-page item does not show on a right page");
let c = copy_of(&mut s, 0, right).unwrap();
let x0 = |s: &Session, id: ItemId| s.doc().unwrap().doc.item(id).unwrap().bounds().x0;
assert!((x0(&s, c) - 72.0).abs() < 1e-9, "{}", x0(&s, c));
// Page 2 is the left page of the second spread, page 3 its right page at x 612.
let c = copy_of(&mut s, 1, left).unwrap();
assert!((x0(&s, c) - 72.0).abs() < 1e-9, "{}", x0(&s, c));
assert_eq!(s.doc().unwrap().doc.page_of_item(c), Some(1));
let c = copy_of(&mut s, 2, right).unwrap();
assert!((x0(&s, c) - 684.0).abs() < 1e-9, "{}", x0(&s, c));
assert_eq!(s.doc().unwrap().doc.page_of_item(c), Some(2));
s.doc().unwrap().doc.check().unwrap();
}
}
+18 -2
View File
@@ -129,11 +129,13 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u
im.repaired_tab_lists,
));
}
let document = im.finish(&root).map_err(|e| match e {
warnings.append(&mut im.warnings);
let mut document = im.finish(&root).map_err(|e| match e {
// Without its spread parts there is nothing to open: say which parts were missing.
IdmlError::Invalid(msg) if !warnings.is_empty() => IdmlError::Invalid(format!("{msg} ({})", warnings.join("; "))),
e => e,
})?;
warnings.extend(document.fix_parent_bases());
document.check().map_err(|e| IdmlError::Invalid(e.to_string()))?;
Ok(Imported { document, warnings })
}
@@ -239,6 +241,8 @@ struct Importer<'r> {
stories: BTreeMap<StoryId, Story>,
story_ids: HashMap<String, StoryId>,
parents: Vec<Spread>,
/// Things read but not kept as they were, for the import report.
warnings: Vec<String>,
parent_ids: HashMap<String, SpreadId>,
spreads: Vec<Spread>,
page_index: HashMap<String, usize>,
@@ -411,6 +415,7 @@ impl<'r> Importer<'r> {
stories: BTreeMap::new(),
story_ids: HashMap::new(),
parents: Vec::new(),
warnings: Vec::new(),
parent_ids: HashMap::new(),
spreads: Vec::new(),
page_index: HashMap::new(),
@@ -2321,7 +2326,18 @@ impl<'r> Importer<'r> {
let n = e.get("Name").unwrap_or("Parent");
n.split_once('-').map(|(_, b)| b.to_string()).unwrap_or_else(|| n.to_string())
});
let based_on = pages.first().and_then(|(_, p)| p.get("AppliedMaster")).and_then(|m| self.parent_ids.get(m)).copied().filter(|b| *b != id);
// A parent spread has one base: its first page's. Pages that disagree lose theirs.
let applied: Vec<Option<SpreadId>> =
pages.iter().map(|(_, p)| p.get("AppliedMaster").and_then(|m| self.parent_ids.get(m)).copied()).collect();
let label = format!("{prefix}-{name}");
if applied.windows(2).any(|w| w[0] != w[1]) {
self.warnings.push(format!("parent {label}: its pages are based on different parents; only the first page's is kept"));
}
let based_on = applied.first().copied().flatten();
if applied.contains(&Some(id)) {
self.warnings.push(format!("parent {label}: a page names its own parent as its base, which is ignored"));
}
let based_on = based_on.filter(|b| *b != id);
Some(ParentInfo { prefix, name, based_on })
} else {
None
+24
View File
@@ -3456,3 +3456,27 @@ fn type_on_a_path_keeps_its_start_and_end_brackets() {
assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket");
}
}
/// Based on Parent travels as the parent pages' `AppliedMaster`; a cycle in a file is broken on
/// import with a warning.
#[test]
fn parent_based_on_round_trips_and_cycles_are_broken() {
let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() });
let a = d.parents[0].id;
let b = d.add_parent("B", "Opening", 2, 12.0, designcraft_doc::Margins::uniform(36.0));
d.add_parent("C", "Closing", 2, 12.0, designcraft_doc::Margins::uniform(36.0));
let set = |d: &mut Document, pi: usize, on| std::sync::Arc::make_mut(&mut d.parents[pi]).parent.as_mut().unwrap().based_on = on;
set(&mut d, 1, Some(a));
set(&mut d, 2, Some(b));
let back = import_idml(&export_idml(&d)).unwrap();
let base_of = |d: &Document, pi: usize| {
let b = d.parents[pi].parent.as_ref().unwrap().based_on?;
d.parents.iter().find(|p| p.id == b).and_then(|p| p.parent.as_ref()).map(|i| i.prefix.clone())
};
assert_eq!((base_of(&back, 0), base_of(&back, 1), base_of(&back, 2)), (None, Some("A".into()), Some("B".into())));
set(&mut d, 0, Some(b));
let r = import_idml_report(&export_idml(&d), &|_| None).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("A-Parent") && w.contains("cycle")), "{:?}", r.warnings);
assert_eq!((base_of(&r.document, 0), base_of(&r.document, 1)), (None, Some("A".into())));
}
+15 -12
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use designcraft_color::swatch::{ColorType, SwatchValue};
use designcraft_color::{BlendMode as DcBlend, Color, GradientKind};
use designcraft_compose::Cache;
use designcraft_doc::{AssetId, Content, Document, Item, PageSide, Spread, StrokeAlign, StrokeType};
use designcraft_doc::{AssetId, Content, Document, Item, PageSide, Spread, SpreadRef, StrokeAlign, StrokeType};
use designcraft_geom::kurbo::{PathEl, Shape as _};
use designcraft_geom::{Affine, BezPath, Rect, Vec2, corners};
use krilla::color::{cmyk, luma, rgb};
@@ -628,18 +628,21 @@ impl Exporter<'_> {
if !page.show_parent_items {
continue;
}
let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue };
let parent = &doc.parents[ppi];
let dx = page.x - parent.pages[ppage].x;
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
// The parent's bases first, then the parent.
for (ppi, ppage) in doc.shown_parent_pages(SpreadRef::Doc(si), pi) {
let Some(parent) = doc.parents.get(ppi) else { continue };
let Some(ppg) = parent.pages.get(ppage) else { continue };
let dx = page.x - ppg.x;
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
}
// Parent items belong to the parent page they sit on.
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
continue;
}
self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true);
}
// Parent items belong to the parent page they sit on.
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
continue;
}
self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true);
}
}
for it in &sp.items {
+22
View File
@@ -634,3 +634,25 @@ fn clipping_path_clips_the_image() {
set(&mut d, &clip);
assert!(uncompressed(&d, PdfOptions::default()).lines().any(|l| l.trim() == "W*"), "an inverted clip is even-odd");
}
/// A page whose parent B is based on A carries A's items, then B's.
#[test]
fn based_on_parent_items_export_base_first() {
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..NewDocument::default() });
d.swatches.push(Swatch::color("Base Cyan", Color::cmyk(1.0, 0.0, 0.0, 0.0)));
d.swatches.push(Swatch::color("Parent Magenta", Color::cmyk(0.0, 1.0, 0.0, 0.0)));
let a = d.parents[0].id;
let b = d.add_parent("B", "Opening", 1, 12.0, designcraft_doc::Margins::uniform(36.0));
std::sync::Arc::make_mut(&mut d.parents[1]).parent.as_mut().unwrap().based_on = Some(a);
d.apply_parent(&[0], Some(b)).unwrap();
let lid = d.default_layer();
for (pi, swatch, r) in [(0, "Base Cyan", Rect::new(36.0, 36.0, 136.0, 136.0)), (1, "Parent Magenta", Rect::new(200.0, 36.0, 300.0, 136.0))] {
let mut it = Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.fill = Fill::swatch(swatch);
d.insert_item(SpreadRef::Parent(pi), it, None).unwrap();
}
let s = uncompressed(&d, PdfOptions::default());
let (cyan, magenta) = (s.find("1 0 0 0 k"), s.find("0 1 0 0 k"));
assert!(cyan.is_some() && magenta.is_some(), "both parents' items are on the page");
assert!(cyan < magenta, "the base parent's item is drawn first");
}
+2 -1
View File
@@ -23,6 +23,7 @@ use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{RenderContext, kurbo};
use crate::{Frame, Renderer, color_of};
use designcraft_compose::ShownOn;
/// Shadow offset (spread units) for an InDesign angle (degrees, light direction) and distance.
pub(crate) fn offset(angle: f64, distance: f64) -> Vec2 {
@@ -88,7 +89,7 @@ pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Ite
impl Renderer {
/// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow.
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option<usize>) {
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn) {
let doc = f.doc;
let e = &it.effects;
let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() };
+29 -21
View File
@@ -13,7 +13,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use designcraft_color::BlendMode as DcBlend;
use designcraft_compose::{Cache, ComposedStory};
use designcraft_compose::{Cache, ComposedStory, ShownOn};
use designcraft_doc::{Content, Document, Item, PageSide, SpreadRef, StoryId, StrokeAlign, StrokeType};
use designcraft_geom::corners;
use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
@@ -223,7 +223,7 @@ pub struct Renderer {
resources: Resources,
glyphs: text::GlyphCache,
/// Composed stories looked up during the current render.
stories: HashMap<(StoryId, Option<usize>), Arc<ComposedStory>>,
stories: HashMap<(StoryId, ShownOn), Arc<ComposedStory>>,
drawing_items: Vec<designcraft_doc::ItemId>,
pub threads: u16,
pub stats: FrameStats,
@@ -415,21 +415,30 @@ impl Renderer {
if isolate {
ctx.push_layer(None, None, None, None, None);
}
// Layers back to front; within each layer parent items first (document spreads only).
// Layers back to front; within each layer parent items first.
for layer in doc.layers.iter().rev() {
if !layer.visible || f.opts.printing_only && !layer.printable {
continue;
}
if let SpreadRef::Doc(si) = sr {
let first = doc.first_page_of_spread(si);
for (pi, page) in sp.pages.iter().enumerate() {
if !page.show_parent_items {
continue;
}
let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue };
let parent = &doc.parents[ppi];
let dx = page.x - parent.pages[ppage].x;
let pr = parent.pages[ppage].bounds();
// Document pages show their parent and its bases; a parent spread shows its bases.
let first = match sr {
SpreadRef::Doc(si) => Some(doc.first_page_of_spread(si)),
SpreadRef::Parent(_) => None,
};
// Base items compose for where they show: the document page, or this parent spread.
let shown_on = |pi: usize| match (sr, first) {
(SpreadRef::Parent(p), _) => ShownOn::Parent(p),
(_, Some(a)) => ShownOn::Page(a + pi),
(_, None) => ShownOn::Own,
};
for (pi, page) in sp.pages.iter().enumerate() {
if !page.show_parent_items {
continue;
}
for (ppi, ppage) in doc.shown_parent_pages(sr, pi) {
let Some(parent) = doc.parents.get(ppi) else { continue };
let Some(ppg) = parent.pages.get(ppage) else { continue };
let dx = page.x - ppg.x;
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
@@ -438,14 +447,13 @@ impl Renderer {
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
continue;
}
let _ = pr;
self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(first + pi));
self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), shown_on(pi));
}
}
}
for it in &sp.items {
if it.layer == layer.id {
self.draw_item(ctx, f, it, Affine::IDENTITY, None);
self.draw_item(ctx, f, it, Affine::IDENTITY, ShownOn::Own);
}
}
}
@@ -457,7 +465,7 @@ impl Renderer {
}
}
fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: Option<usize>) {
fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: ShownOn) {
if self.drawing_items.len() >= 64 || self.drawing_items.contains(&it.id) {
return;
}
@@ -466,7 +474,7 @@ impl Renderer {
self.drawing_items.pop();
}
fn draw_item_inner(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: Option<usize>) {
fn draw_item_inner(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: ShownOn) {
if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting {
return;
}
@@ -576,18 +584,18 @@ impl Renderer {
/// The composed story for a frame (memoised for the current render: the cache lookup
/// validates the whole thread each time).
fn story(&mut self, f: &Frame, sid: StoryId, page: Option<usize>) -> Arc<ComposedStory> {
fn story(&mut self, f: &Frame, sid: StoryId, page: ShownOn) -> Arc<ComposedStory> {
let key = (sid, page);
if let Some(cs) = self.stories.get(&key) {
return cs.clone();
}
let cs = f.cache.get(f.doc, sid, page);
let cs = f.cache.get_on(f.doc, sid, page);
self.stories.insert(key, cs.clone());
cs
}
/// Fill, content and stroke of a leaf item (no opacity, blend or effects).
pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option<usize>) {
pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn) {
let doc = f.doc;
// Fill.
if !it.fill.is_none() {
+2 -2
View File
@@ -9,7 +9,7 @@
use std::collections::HashMap;
use std::sync::{Arc, Weak};
use designcraft_compose::{ComposedStory, FrameText, Line};
use designcraft_compose::{ComposedStory, FrameText, Line, ShownOn};
use designcraft_doc::ItemId;
use designcraft_fonts::FontDb;
use designcraft_geom::{Affine, BezPath, Rect, Shape};
@@ -408,7 +408,7 @@ impl Renderer {
}
// Anchored objects ride on their lines.
for o in &ft.objects {
self.draw_item(ctx, f, &o.object.item, xf * Affine::translate(o.origin.to_vec2()), None);
self.draw_item(ctx, f, &o.object.item, xf * Affine::translate(o.origin.to_vec2()), ShownOn::Own);
}
}
}
+26 -9
View File
@@ -242,16 +242,33 @@ impl ToolContext<'_> {
let spread = self.doc.spreads.get(si)?;
let pi = spread.page_at_x(sp.x)?;
let page = self.doc.first_page_of_spread(si) + pi;
let (ppi, ppg) = self.doc.parent_page_for(page)?;
let parent = &self.doc.parents[ppi];
let pg = &spread.pages[pi];
let at = sp - designcraft_geom::Vec2::new(pg.x - parent.pages[ppg].x, 0.0);
parent
.items
.iter()
let pg = spread.pages.get(pi)?;
if !pg.show_parent_items {
return None;
}
// What the renderer draws on this page, in its order: by layer, then the parent's bases
// before the parent. The last hit is the topmost.
let mut shown = Vec::new();
for (ppi, ppg) in self.doc.shown_parent_pages(SpreadRef::Doc(si), pi) {
let Some(parent) = self.doc.parents.get(ppi) else { continue };
let Some(parent_page) = parent.pages.get(ppg) else { continue };
let off = designcraft_geom::Vec2::new(pg.x - parent_page.x, 0.0);
for it in &parent.items {
if pg.overridden.contains(&it.id) || parent.pages.len() > 1 && parent.page_at_x(it.bounds().center().x) != Some(ppg) {
continue;
}
shown.push((it, off));
}
}
shown.sort_by_key(|(it, _)| self.doc.layer_rank(it.layer));
shown
.into_iter()
.rev()
.find(|it| !it.locked && !pg.overridden.contains(&it.id) && designcraft_doc::edit_hit(it, at, self.tol(4.0)))
.map(|it| (page, it.id))
.find(|(it, off)| {
let layer = self.doc.layer(it.layer);
!it.locked && !it.hidden && !layer.is_some_and(|l| !l.visible || l.locked) && designcraft_doc::edit_hit(it, sp - *off, self.tol(4.0))
})
.map(|(it, _)| (page, it.id))
}
fn hit_parent(&self, sr: SpreadRef, sp: Point) -> Option<(SpreadRef, ItemId)> {
+42 -150
View File
@@ -10,7 +10,7 @@
use std::collections::HashSet;
use designcraft_doc::{Item, ItemId, Orientation, PageSide, Selection, SpreadId, SpreadRef, StrokeAlign};
use designcraft_doc::{Document, Item, ItemId, Orientation, Selection, SpreadRef, StrokeAlign};
use designcraft_geom::snap::snap_to_grid;
use designcraft_geom::{Affine, Point, Rect, Vec2, format_measure};
@@ -206,15 +206,13 @@ fn match_rotation(cx: &ToolContext, req: &SnapRequest<'_>, zone_length: f64) ->
push_rotation(&mut best, cx.selection, req, item, Affine::IDENTITY, false, 0, &mut seen, proposed, tol);
}
}
// Parent editing already lists those items on the spread. Do not add them again.
if !layout_edits_parents(cx) {
if shows_parent_items(cx, req) {
push_parent_rotations(&mut best, cx, req, &mut seen, proposed, tol);
}
best.map(|(_, angle)| angle)
}
/// Shown parent items, same page and `based_on` walk as parent lengths.
/// Parent items are only translated onto the page, so the angle is still `item.xf`.
/// Shown parent items. Parent items are only translated onto the page, so the angle is still `item.xf`.
fn push_parent_rotations(
best: &mut Option<(f64, f64)>,
cx: &ToolContext,
@@ -223,57 +221,11 @@ fn push_parent_rotations(
proposed: f64,
tol: f64,
) {
let SpreadRef::Doc(si) = req.spread else { return };
let page_count = cx.doc.spreads.get(si).map(|sp| sp.pages.len()).unwrap_or(0);
let first = cx.doc.first_page_of_spread(si);
for pi in 0..page_count {
let Some(abs) = first.checked_add(pi) else { continue };
let Some(page) = cx.doc.page(abs) else { continue };
if !page.show_parent_items {
continue;
}
let doc_side = page.side;
let overridden = page.overridden.clone();
let Some((ppi, _)) = cx.doc.parent_page_for(abs) else { continue };
let mut seen_spreads = HashSet::new();
push_parent_rotation_chain(best, cx, req, doc_side, &overridden, ppi, seen_items, &mut seen_spreads, proposed, tol);
for (item, _) in shown_parent_items(cx.doc, req.spread) {
push_rotation(best, cx.selection, req, item, Affine::IDENTITY, false, 0, seen_items, proposed, tol);
}
}
fn push_parent_rotation_chain(
best: &mut Option<(f64, f64)>,
cx: &ToolContext,
req: &SnapRequest<'_>,
doc_side: PageSide,
overridden: &[ItemId],
index: usize,
seen_items: &mut HashSet<ItemId>,
seen_spreads: &mut HashSet<SpreadId>,
proposed: f64,
tol: f64,
) {
let Some(parent) = cx.doc.parents.get(index) else { return };
if !seen_spreads.insert(parent.id) {
return;
}
let next = parent.parent.as_ref().and_then(|info| info.based_on);
if let Some(page_idx) = parent.parent_page_for_side(doc_side) {
for item in &parent.items {
if overridden.contains(&item.id) {
continue;
}
// A facing parent only shows items whose center sits on this document page's parent page.
if parent.pages.len() > 1 && parent.page_at_x(item.bounds().center().x) != Some(page_idx) {
continue;
}
push_rotation(best, cx.selection, req, item, Affine::IDENTITY, false, 0, seen_items, proposed, tol);
}
}
let Some(next_id) = next else { return };
let Some(next_index) = cx.doc.parent_index(next_id) else { return };
push_parent_rotation_chain(best, cx, req, doc_side, overridden, next_index, seen_items, seen_spreads, proposed, tol);
}
fn push_rotation(
best: &mut Option<(f64, f64)>,
selection: &Selection,
@@ -956,7 +908,7 @@ fn dimension_lengths(cx: &ToolContext, req: &SnapRequest<'_>) -> Vec<f64> {
push_own_lengths(&mut out, cx.selection, req, item, Affine::IDENTITY, false, 0, &mut seen);
}
}
if !layout_edits_parents(cx) {
if shows_parent_items(cx, req) {
push_parent_lengths(&mut out, cx, req, &mut seen);
}
out
@@ -1039,54 +991,11 @@ fn stroke_outset(item: &Item) -> f64 {
}
fn push_parent_lengths(out: &mut Vec<f64>, cx: &ToolContext, req: &SnapRequest<'_>, seen_items: &mut HashSet<ItemId>) {
let SpreadRef::Doc(si) = req.spread else { return };
let page_count = cx.doc.spreads.get(si).map(|sp| sp.pages.len()).unwrap_or(0);
let first = cx.doc.first_page_of_spread(si);
for pi in 0..page_count {
let Some(abs) = first.checked_add(pi) else { continue };
let Some(page) = cx.doc.page(abs) else { continue };
if !page.show_parent_items {
continue;
}
let doc_side = page.side;
let overridden = page.overridden.clone();
let Some((ppi, _)) = cx.doc.parent_page_for(abs) else { continue };
let mut seen_spreads = HashSet::new();
push_parent_length_chain(out, cx, req, doc_side, &overridden, ppi, seen_items, &mut seen_spreads);
for (item, _) in shown_parent_items(cx.doc, req.spread) {
push_own_lengths(out, cx.selection, req, item, Affine::IDENTITY, false, 0, seen_items);
}
}
fn push_parent_length_chain(
out: &mut Vec<f64>,
cx: &ToolContext,
req: &SnapRequest<'_>,
doc_side: PageSide,
overridden: &[ItemId],
index: usize,
seen_items: &mut HashSet<ItemId>,
seen_spreads: &mut HashSet<SpreadId>,
) {
let Some(parent) = cx.doc.parents.get(index) else { return };
if !seen_spreads.insert(parent.id) {
return;
}
let next = parent.parent.as_ref().and_then(|info| info.based_on);
if let Some(page_idx) = parent.parent_page_for_side(doc_side) {
for item in &parent.items {
if overridden.contains(&item.id) {
continue;
}
if parent.pages.len() > 1 && parent.page_at_x(item.bounds().center().x) != Some(page_idx) {
continue;
}
push_own_lengths(out, cx.selection, req, item, Affine::IDENTITY, false, 0, seen_items);
}
}
let Some(next_id) = next else { return };
let Some(next_index) = cx.doc.parent_index(next_id) else { return };
push_parent_length_chain(out, cx, req, doc_side, overridden, next_index, seen_items, seen_spreads);
}
/// Ruler guides, margins, and column sides. No overlay.
///
/// A page guide attracts when the moving rect overlaps that page on the other axis. A spread
@@ -1162,7 +1071,7 @@ fn pass_align(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) ->
offer_box(&mut best, req.rect, flags, axis, rect, tol, cx.snap.align_edges, cx.snap.align_centers);
}
}
if !layout_edits_parents(cx) {
if shows_parent_items(cx, req) {
offer_parent_items(&mut best, cx, req, flags, axis, tol);
}
best.map(|b| b.hit)
@@ -1178,6 +1087,12 @@ fn layout_edits_parents(cx: &ToolContext) -> bool {
cx.layout.slots.iter().any(|slot| matches!(slot.spread, SpreadRef::Parent(_)))
}
/// Whether to offer the parent items drawn on `req.spread`. A parent spread shows its bases' items,
/// which are not on it.
fn shows_parent_items(cx: &ToolContext, req: &SnapRequest<'_>) -> bool {
matches!(req.spread, SpreadRef::Parent(_)) || !layout_edits_parents(cx)
}
/// Visible box used for alignment. Groups with children contribute the union of the children.
fn item_target_rect(selection: &Selection, req: &SnapRequest<'_>, item: &Item) -> Option<Rect> {
if item.hidden || align_skips_item(selection, req, item.id) {
@@ -1247,67 +1162,44 @@ fn group_box(item: &Item, seen: &mut HashSet<ItemId>, depth: usize, filter: Opti
acc.filter(|r| rect_finite(*r))
}
/// Parent items shown on the document spread, shifted into that spread's space.
/// Parent items shown on the spread, shifted into that spread's space.
fn offer_parent_items(best: &mut Option<Best>, cx: &ToolContext, req: &SnapRequest<'_>, flags: [bool; 3], axis: Axis, tol: f64) {
let SpreadRef::Doc(si) = req.spread else { return };
let page_count = cx.doc.spreads.get(si).map(|sp| sp.pages.len()).unwrap_or(0);
let first = cx.doc.first_page_of_spread(si);
for pi in 0..page_count {
let Some(abs) = first.checked_add(pi) else { continue };
let Some(page) = cx.doc.page(abs) else { continue };
if !page.show_parent_items {
continue;
}
let doc_x = page.x;
let doc_side = page.side;
let overridden = page.overridden.clone();
let Some((ppi, _)) = cx.doc.parent_page_for(abs) else { continue };
let mut seen = HashSet::new();
offer_parent_chain(best, cx, req, flags, axis, tol, doc_x, doc_side, &overridden, ppi, &mut seen);
for (item, dx) in shown_parent_items(cx.doc, req.spread) {
let Some(rect) = item_target_rect(cx.selection, req, item) else { continue };
offer_box(best, req.rect, flags, axis, shift_x(rect, dx), tol, cx.snap.align_edges, cx.snap.align_centers);
}
}
/// Walk `based_on`. A repeated spread id ends the walk.
fn offer_parent_chain(
best: &mut Option<Best>,
cx: &ToolContext,
req: &SnapRequest<'_>,
flags: [bool; 3],
axis: Axis,
tol: f64,
doc_x: f64,
doc_side: PageSide,
overridden: &[ItemId],
index: usize,
seen: &mut HashSet<SpreadId>,
) {
let Some(parent) = cx.doc.parents.get(index) else { return };
if !seen.insert(parent.id) {
return;
}
let next = parent.parent.as_ref().and_then(|info| info.based_on);
if let Some(page_idx) = parent.parent_page_for_side(doc_side)
&& let Some(parent_page) = parent.pages.get(page_idx)
{
let dx = doc_x - parent_page.x;
if dx.is_finite() {
/// Parent items drawn on `spread`, back to front, each with the x shift from its parent spread:
/// a document page's parent and its bases, or a parent spread's bases. Same page and override
/// filters as the renderer.
fn shown_parent_items(doc: &Document, spread: SpreadRef) -> Vec<(&Item, f64)> {
let Some(sp) = doc.spread(spread) else { return Vec::new() };
let mut out = Vec::new();
for (pi, page) in sp.pages.iter().enumerate() {
if !page.show_parent_items {
continue;
}
for (ppi, ppage) in doc.shown_parent_pages(spread, pi) {
let Some(parent) = doc.parents.get(ppi) else { continue };
let Some(parent_page) = parent.pages.get(ppage) else { continue };
let dx = page.x - parent_page.x;
if !dx.is_finite() {
continue;
}
for item in &parent.items {
if overridden.contains(&item.id) {
if page.overridden.contains(&item.id) {
continue;
}
// Same other-page skip as the renderer: a facing parent only shows items
// whose center sits on the parent page this document page uses.
if parent.pages.len() > 1 && parent.page_at_x(item.bounds().center().x) != Some(page_idx) {
// A facing parent only shows items whose center sits on the parent page this page uses.
if parent.pages.len() > 1 && parent.page_at_x(item.bounds().center().x) != Some(ppage) {
continue;
}
let Some(rect) = item_target_rect(cx.selection, req, item) else { continue };
offer_box(best, req.rect, flags, axis, shift_x(rect, dx), tol, cx.snap.align_edges, cx.snap.align_centers);
out.push((item.as_ref(), dx));
}
}
}
let Some(next_id) = next else { return };
let Some(next_index) = cx.doc.parent_index(next_id) else { return };
offer_parent_chain(best, cx, req, flags, axis, tol, doc_x, doc_side, overridden, next_index, seen);
out
}
fn shift_x(r: Rect, dx: f64) -> Rect {
@@ -1450,7 +1342,7 @@ mod tests {
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, Guide, Item, Margins, Selection, Shape, Stroke};
use designcraft_doc::{Document, Guide, Item, Margins, PageSide, Selection, Shape, Stroke};
use designcraft_geom::shapes;
use designcraft_geom::{Affine, Unit};
+23
View File
@@ -118,6 +118,29 @@ fn cmd_shift_click_overrides_parent_item() {
assert_eq!(a, vec![Action::Exec("layout.overrideParentItems".into(), serde_json::json!({"page": 0, "ids": [pid.0]}))]);
}
/// On a page whose parent B is based on A, A's items are hit too, B's win where they overlap, and
/// nothing is hit when the page hides parent items.
#[test]
fn parent_hit_reaches_base_parent_items_topmost_first() {
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() });
let lid = d.default_layer();
let a = d.parents[0].id;
let b = d.add_parent("B", "Opening", 1, 12.0, designcraft_doc::Margins::uniform(36.0));
Arc::make_mut(&mut d.parents[1]).parent.as_mut().unwrap().based_on = Some(a);
d.apply_parent(&[0], Some(b)).unwrap();
let (a_item, _) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "a", ParaFormat::default()).unwrap();
let (b_item, _) = d.add_text_frame(SpreadRef::Parent(1), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "b", ParaFormat::default()).unwrap();
let hit = |d: &Document, x: f64, y: f64| {
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(d, false));
let off = l.xf(SpreadRef::Doc(0)).translation();
ctx(d, &s, &c, &l).hit_parent_on_page(Point::new(x + off.x, y + off.y))
};
assert_eq!(hit(&d, 120.0, 120.0), Some((0, a_item)));
assert_eq!(hit(&d, 175.0, 175.0), Some((0, b_item)), "B's item is drawn over A's");
Arc::make_mut(&mut d.spreads[0]).pages[0].show_parent_items = false;
assert_eq!(hit(&d, 120.0, 120.0), None);
}
#[test]
fn gradient_tool_drag_sets_the_vector() {
let mut d = Document::new(&NewDocument::default());
+6 -7
View File
@@ -895,13 +895,12 @@ fn draw_frames(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document
for slot in &layout.slots {
let Some(sp) = doc.spread(slot.spread) else { continue };
let a = slot.xf;
// Parent items on document pages: dotted edges.
if let SpreadRef::Doc(si) = slot.spread {
let first = doc.first_page_of_spread(si);
for (pi, page) in sp.pages.iter().enumerate() {
let Some((ppi, ppg)) = doc.parent_page_for(first + pi) else { continue };
let parent = &doc.parents[ppi];
let dx = page.x - parent.pages[ppg].x;
// Parent items on document pages, and base parents' items on a parent spread: dotted edges.
for (pi, page) in sp.pages.iter().enumerate() {
for (ppi, ppg) in doc.shown_parent_pages(slot.spread, pi) {
let Some(parent) = doc.parents.get(ppi) else { continue };
let Some(ppage) = parent.pages.get(ppg) else { continue };
let dx = page.x - ppage.x;
for it in &parent.items {
if (parent.pages.len() > 1 && parent.page_at_x(it.bounds().center().x) != Some(ppg)) || page.overridden.contains(&it.id) {
continue;
+5 -2
View File
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (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) · **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.
@@ -167,7 +167,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User
Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After,
Convert Rows/Columns, Object ▸ Interactive ▸ Convert to (form fields), Flex Layout (new in 2026),
View ▸ Extras badges and Story Editor options.
View ▸ Extras badges and Story Editor options. Pages panel ▸ Parent Options (Based on Parent is
the `layout.parents.options` command only); overriding a base parent's items on a parent spread
itself (overrides work on document pages only).
- **Estimate:** 40–70 h (most are thin wrappers over existing engine features).
- **Doc:** [ui-parity.md](ui-parity.md)
@@ -244,6 +246,7 @@ Cue. These are excluded from every percentage.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Gap 16: Parent Options dialog and overrides of base items on a parent spread |
| 2026-10-12 | minor | Gap 0 shrunk to the Primary Text Frame page fixes (#165): text threads by hand; gaps added for autoflow onto existing pages and threading into grouped frames |
| 2026-10-10 | minor | Gap 0 added and marked alpha blocker: manual text threading is missing (stage re-normalized to pre-alpha) |
| 2026-10-10 | major | Created: 25 ranked gaps from the full re-measure against InDesign 2026 21.6 and issues #1–#352 |
+2 -1
View File
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
@@ -438,6 +438,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.
- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
- Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu.
- Based on Parent (`layout.parents.options {parent, basedOn}`): a parent shows its base parent's items under its own, on its pages and on the parent itself; chains work, cycles are refused, and items from any parent in the chain can be overridden. IDML carries it as the parent pages' `AppliedMaster`; a cycle or missing base in a file is cleared with a warning on open. The Pages panel has no Parent Options dialog yet.
- Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `<PDF>` in IDML.
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.