From 91a32730ef88214d3a9a3e9beac3548274ff1de9 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:02:23 -0400 Subject: [PATCH 1/7] fix: allow deleting parent pages (#956) --- ROADMAP.md | 4 +- crates/doc/src/build.rs | 49 ++++++- crates/engine/src/cmd/layout.rs | 25 ++++ crates/engine/src/cmd/parent_delete_tests.rs | 127 +++++++++++++++++++ crates/ui-egui/src/panels/pages.rs | 42 +++++- docs/agents.md | 9 ++ 6 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/parent_delete_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 2aacdaf9..fa42b4c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -214,7 +214,7 @@ same references), and from the app's JSON control channel. See [docs/agents.md]( | # | Milestone | State | **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (undoable parent deletion) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text stroke behind fill) · **Target:** Adobe InDesign 2026 @@ -343,6 +343,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **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-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. Newest first. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 9fb07555..43e0c7be 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -1,7 +1,7 @@ //! Creating documents (File → New → Document), presets, and page/spread structure edits //! (insert/delete/move pages, repagination into spreads, parents). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; use designcraft_geom::{Affine, Unit, units}; @@ -251,6 +251,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(()) + } + pub fn parent_index(&self, id: SpreadId) -> Option { self.parents.iter().position(|p| p.id == id) } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index fe06ad3e..f1313dc4 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -246,6 +246,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", [], 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); @@ -1264,3 +1285,7 @@ mod spread_rotation_input_tests { assert_eq!(rotation(&s), 0, "zero resets the spread view"); } } + +#[cfg(test)] +#[path = "parent_delete_tests.rs"] +mod parent_delete_tests; diff --git a/crates/engine/src/cmd/parent_delete_tests.rs b/crates/engine/src/cmd/parent_delete_tests.rs new file mode 100644 index 00000000..0bfd89e7 --- /dev/null +++ b/crates/engine/src/cmd/parent_delete_tests.rs @@ -0,0 +1,127 @@ +//! Regression coverage for deleting parent spreads through the command boundary. + +use std::sync::Arc; + +use designcraft_doc::{ItemId, Selection, SpreadId, TextSel}; +use serde_json::{Value, json}; + +use crate::Session; + +fn frame(s: &mut Session, parent: usize, text: Option<&str>) -> 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})).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")); + 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.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::default()); + s.execute("edit.redo", &Value::Null).unwrap(); + assert!(s.doc().unwrap().doc.parents.is_empty()); + assert!(!s.doc().unwrap().editing_parents); + // 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/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 2d6d56da..c3d60b07 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -81,12 +81,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let mut drop: Option<(PageDrag, usize)> = None; ui.spacing_mut().item_spacing.y = 0.0; // ---- Parents: [None] and each parent, 29 pt rows with a blank thumbnail at the right. - let mut rows: Vec<(String, Option, usize)> = vec![("[None]".into(), None, 1)]; + let mut rows = vec![("[None]".to_string(), None, 1, None)]; for p in &doc.parents { let label = p.parent.as_ref().map(|i| i.label()).unwrap_or_default(); - rows.push((label, p.parent.as_ref().map(|i| i.prefix.clone()), p.pages.len())); + rows.push((label, p.parent.as_ref().map(|i| i.prefix.clone()), p.pages.len(), Some(p.id))); } - for (label, prefix, n) in rows { + for (label, prefix, n, id) in rows { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); let active = editing_parents && prefix.is_some(); if active { @@ -104,6 +104,17 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if resp.dragged() { drag_ghost(ui, &label, &t); } + if let Some(id) = id { + 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(); + } + }); + } } ui.add_space(4.0); let r = ui.available_rect_before_wrap(); @@ -424,6 +435,31 @@ mod tests { h } + #[test] + fn parent_context_menu_deletes_the_clicked_id_even_with_a_shared_prefix() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + 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); + // [None], A-Parent, then A-Other: target the actual row, not a menu helper. + let at = egui::pos2(40.0, ROW_H * 2.5); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: egui::Modifiers::NONE }); + 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 a_page_in_a_spread_offers_delete_page_and_delete_spread() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/agents.md b/docs/agents.md index b737ebb8..1930055c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -74,3 +74,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. From 17848dd1e9f21d6f475c2875256dfeaf50a855eb Mon Sep 17 00:00:00 2001 From: Paul G <4waymedia@gmail.com> Date: Sat, 17 Oct 2026 17:11:31 -0700 Subject: [PATCH 2/7] Package: link the packaged document and IDML relative to the package (fixes #340) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EDKUo6t8zLKhzeuPrJxa3n --- ROADMAP.md | 1 + crates/engine/src/cmd/interchange.rs | 12 ++++++- crates/engine/src/cmd/package.rs | 51 +++++++++++++++++++++++----- docs/gaps.md | 5 ++- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274e..d3729509 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,7 @@ # DesignCraft roadmap **Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) +- PK340: 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. - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 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/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(" **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (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 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 From b20009076ef5249bc022c4e540ccacf9feefa312 Mon Sep 17 00:00:00 2001 From: Paul G <4waymedia@gmail.com> Date: Sat, 17 Oct 2026 17:20:45 -0700 Subject: [PATCH 3/7] Contributors: credit Paul Gemignani --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) 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" From b8ae284a4a69cd84b65b7a32453602922651fc0d Mon Sep 17 00:00:00 2001 From: Hamza Date: Sun, 18 Oct 2026 20:47:01 +0500 Subject: [PATCH 4/7] View: Bleed and Slug screen modes show their area; draw the slug guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bleed and Slug were selectable from the toolbar, the View menu and `view.screenMode`, and were stored, but the canvas only ever asked whether the mode was Preview or Presentation. Both therefore drew exactly what Normal drew: guides, frame edges, the other non-printing marks, and content reaching across the pasteboard. They are preview modes now, and what each spread shows reaches out to its box: the bleed for Bleed, the bleed and the slug for Slug (Slug shows every printing element, and a slug narrower than the bleed does not pull the edge back in). Preview and Presentation still stop at the trim. The slug guide was never drawn either, so the Slug colour in Preferences › Guides & Pasteboard was stored, exported and round-tripped while nothing read it. It is drawn beside the bleed guide, in that colour. `Spread::bleed_rect`'s body becomes `outset_rect`, which measures either offset from the page edges, with `bleed_rect` and `slug_rect` as its named callers. --- ROADMAP.md | 1 + crates/doc/src/page.rs | 23 +++++-- crates/tools/src/lib.rs | 2 +- crates/ui-egui/src/canvas.rs | 113 ++++++++++++++++++++++++++++++++--- 4 files changed, 124 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274e..a14b6da0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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-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. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. 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/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| { From 287b65f788de73996610609ec9a8391edaf6fd7c Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:15:45 -0600 Subject: [PATCH 5/7] Object: reject malformed stroke types --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 85 +++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274e..98616051 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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-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. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 2d581760..bf3716d3 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), @@ -4799,6 +4820,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; From b741d0dabb6dcfd5675ce1a8d35bd07470231cdc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:38:43 -0400 Subject: [PATCH 6/7] ROADMAP: move the package-links note into the progress log Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3729509..6eb84f3d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,6 @@ # DesignCraft roadmap **Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -- PK340: 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. - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 @@ -349,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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-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. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. From 4e848d9727da6cc7ba55d106d94aa1ae38faa14c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:38:44 -0400 Subject: [PATCH 7/7] ROADMAP: drop the duplicate review line and the stray blank line Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 866d8fc3..535c6b21 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -215,7 +215,6 @@ same references), and from the app's JSON control channel. See [docs/agents.md]( | # | Milestone | State | **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (undoable parent deletion) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (A0, A1 and A2 poster presets) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026 @@ -350,7 +349,6 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **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-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. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change.