diff --git a/ROADMAP.md b/ROADMAP.md index 31985c01..ab0749c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (atomic desktop and CLI exports) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. - **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change. - **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index ad0d44bd..fbf80ed9 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -47,20 +47,176 @@ fn load(path: &str) -> Result { if path.to_lowercase().ends_with(".idml") { return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); } - super::file::from_bytes(&bytes) + super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } -fn store(path: &str, d: &designcraft_doc::Document) -> Result<()> { - #[cfg(not(target_arch = "wasm32"))] - return crate::atomic::write_file(path, &super::file::to_bytes(d)); - #[cfg(target_arch = "wasm32")] - { - let _ = (path, d); - Err(EngineError::Other("books need a file system".into())) +#[cfg(not(target_arch = "wasm32"))] +const MAX_BATCH_CHAPTERS: usize = 4096; + +/// A batch keeps only bounded path records in memory. Chapter snapshots live in a private +/// temporary directory; only one document and serialized blob are live while staging. +#[cfg(not(target_arch = "wasm32"))] +struct ChapterBatch { + directory: tempfile::TempDir, + chapters: Vec<(String, tempfile::TempPath)>, + command: &'static str, +} + +#[cfg(not(target_arch = "wasm32"))] +impl ChapterBatch { + fn new(chapters: usize, command: &'static str) -> Result { + if chapters > MAX_BATCH_CHAPTERS { + return Err(bad(command, format!("a book update supports at most {MAX_BATCH_CHAPTERS} chapters"))); + } + let directory = tempfile::Builder::new() + .prefix(".designcraft-book-") + .tempdir() + .map_err(|e| EngineError::Other(format!("{command}: cannot stage chapter changes: {e}")))?; + Ok(Self { directory, chapters: Vec::with_capacity(chapters), command }) + } + + fn stage(&mut self, path: &str, doc: &designcraft_doc::Document) -> Result<()> { + use std::io::Write; + + // Propagate serialization errors; an empty fallback must never replace a chapter. + let bytes = designcraft_format::save(doc).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut file = tempfile::Builder::new() + .prefix("chapter-") + .tempfile_in(self.directory.path()) + .map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?; + file.write_all(&bytes).map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?; + self.chapters.push((path.to_string(), file.into_temp_path())); + Ok(()) + } + + fn commit(self) -> Result<()> { + self.commit_with(write_staged_chapter) + } + + fn commit_with(self, mut write: impl FnMut(&std::path::Path, &std::path::Path) -> std::io::Result<()>) -> Result<()> { + let mut completed = Vec::with_capacity(self.chapters.len()); + for (path, staged) in self.chapters { + if let Err(e) = write(std::path::Path::new(&path), &staged) { + // Each file is atomic; the whole book is not. Keep the successful prefix visible + // rather than pretending that an error undid earlier chapter replacements. + return Err(EngineError::Other(format!( + "{}: failed to save chapter {path}: {e}; completed chapter paths: {completed:?}", + self.command + ))); + } + completed.push(path); + } + Ok(()) } } +#[cfg(not(target_arch = "wasm32"))] +fn write_staged_chapter(path: &std::path::Path, staged: &std::path::Path) -> std::io::Result<()> { + let mut input = std::fs::File::open(staged)?; + crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ())) +} + +/// Batch commands only write closed native chapters. IDML is an imported source, and an open +/// chapter's model (including unsaved edits) cannot be reconciled by a disk-only book update. +#[cfg(not(target_arch = "wasm32"))] +fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> { + use std::path::Path; + + if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) { + return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first"))); + } + let target = std::fs::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| std::fs::canonicalize(open).is_ok_and(|open| open == target))) { + return Err(bad(command, format!("chapter {path} is open; close it before updating the book"))); + } + if !std::fs::metadata(&target).is_ok_and(|metadata| metadata.is_file()) { + return Err(bad(command, format!("chapter {path} is not a regular file"))); + } + // Check both the file and its resolved parent without truncating it. Replacement needs a + // writable directory even when an in-place write would be permitted. + std::fs::OpenOptions::new().write(true).open(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let parent = target.parent().ok_or_else(|| bad(command, format!("chapter {path} has no parent folder")))?; + tempfile::Builder::new().prefix(".designcraft-book-check-").tempfile_in(parent).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn paginate(s: &Session) -> Result { + let b = s.book.as_ref().ok_or_else(no_book)?; + if b.documents.is_empty() { + return Ok(json!({"pages": 0})); + } + let mut batch = ChapterBatch::new(b.documents.len(), "book.paginate")?; + let mut next = 1u32; + for path in &b.documents { + writable_chapter(s, path, "book.paginate")?; + let mut d = load(path)?; + start_at(&mut d, next); + let pages = u32::try_from(d.page_count()).map_err(|_| bad("book.paginate", "too many pages for continuous numbering"))?; + next = next.checked_add(pages).ok_or_else(|| bad("book.paginate", "too many pages for continuous numbering"))?; + batch.stage(path, &d)?; + } + batch.commit()?; + Ok(json!({"pages": next - 1})) +} + +#[cfg(not(target_arch = "wasm32"))] +fn sync_styles(s: &Session) -> Result { + let b = s.book.as_ref().ok_or_else(no_book)?; + let mut batch = ChapterBatch::new(b.documents.len(), "book.syncStyles")?; + let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?; + let src = load(src_path)?; + let styles = src.styles.clone(); + let swatches = src.swatches.clone(); + drop(src); + let mut n = 0; + for (i, path) in b.documents.iter().enumerate() { + if i == b.style_source { + continue; + } + writable_chapter(s, path, "book.syncStyles")?; + let mut d = load(path)?; + { + let st = d.styles_mut(); + for ps in &styles.paragraph { + st.paragraph.retain(|x| x.name != ps.name); + st.paragraph.push(ps.clone()); + } + for cs in &styles.character { + st.character.retain(|x| x.name != cs.name); + st.character.push(cs.clone()); + } + } + for sw in &swatches { + match d.swatches.iter_mut().find(|w| w.name == sw.name) { + Some(w) if !w.locked => *w = sw.clone(), + Some(_) => {} + None => d.swatches.push(sw.clone()), + } + } + batch.stage(path, &d)?; + n += 1; + } + batch.commit()?; + Ok(json!({"synced": n})) +} + +#[cfg(target_arch = "wasm32")] +fn paginate(_: &Session) -> Result { + Err(EngineError::Other("books need a file system".into())) +} + +#[cfg(target_arch = "wasm32")] +fn sync_styles(_: &Session) -> Result { + Err(EngineError::Other("books need a file system".into())) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +#[path = "book/safety_tests.rs"] +mod safety_tests; + /// Make page numbering of `d` start at `n` (the first section). +#[cfg(not(target_arch = "wasm32"))] fn start_at(d: &mut designcraft_doc::Document, n: u32) { if !d.sections.iter().any(|x| x.start == 0) { d.sections.insert( @@ -152,48 +308,10 @@ pub fn specs() -> Vec { Ok(json!({"path": b.path, "styleSource": b.style_source, "documents": docs})) }), cmd!(noundo "book.paginate", "Update Numbering", [], None, "{} — each document starts numbering where the previous one ended (files are saved)", has_book, |s, _| { - let b = s.book.clone().ok_or_else(no_book)?; - let mut next = 1u32; - for p in &b.documents { - let mut d = load(p)?; - start_at(&mut d, next); - next += d.page_count() as u32; - store(p, &d)?; - } - Ok(json!({"pages": next - 1})) + paginate(s) }), cmd!(noundo "book.syncStyles", "Synchronize Book", [], None, "{} — paragraph and character styles and swatches of the style source go into every document (by name)", has_book, |s, _| { - let b = s.book.clone().ok_or_else(no_book)?; - let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?; - let src = load(src_path)?; - let mut n = 0; - for (i, p) in b.documents.iter().enumerate() { - if i == b.style_source { - continue; - } - let mut d = load(p)?; - { - let st = d.styles_mut(); - for ps in &src.styles.paragraph { - st.paragraph.retain(|x| x.name != ps.name); - st.paragraph.push(ps.clone()); - } - for cs in &src.styles.character { - st.character.retain(|x| x.name != cs.name); - st.character.push(cs.clone()); - } - } - for sw in &src.swatches { - match d.swatches.iter_mut().find(|w| w.name == sw.name) { - Some(w) if !w.locked => *w = sw.clone(), - Some(_) => {} - None => d.swatches.push(sw.clone()), - } - } - store(p, &d)?; - n += 1; - } - Ok(json!({"synced": n})) + sync_styles(s) }), cmd!(noundo "book.exportPdf", "Export Book to PDF…", [], None, "{path?} — every document in order as one PDF → {path, bytes, pages} (no path: {base64, …})", has_book, |s, p| { let b = s.book.clone().ok_or_else(no_book)?; @@ -238,6 +356,7 @@ mod tests { s.execute("file.new", &json!({"pages": pages})).unwrap(); s.execute("style.paragraph.create", &json!({"name": style, "chars": {"size": 20}})).unwrap(); s.execute("file.saveAs", &json!({"path": p(name)})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); } s.execute("book.new", &json!({"path": p("book.dcbook")})).unwrap(); s.execute("book.add", &json!({"path": p("a.designcraft")})).unwrap(); diff --git a/crates/engine/src/cmd/book/safety_tests.rs b/crates/engine/src/cmd/book/safety_tests.rs new file mode 100644 index 00000000..e4ec20ef --- /dev/null +++ b/crates/engine/src/cmd/book/safety_tests.rs @@ -0,0 +1,52 @@ +use super::{ChapterBatch, MAX_BATCH_CHAPTERS, write_staged_chapter}; +use crate::Session; +use serde_json::json; + +#[test] +fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("first.designcraft"); + let second = dir.path().join("second.designcraft"); + let third = dir.path().join("third.designcraft"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let old = (*s.doc().unwrap().doc).clone(); + let mut changed = old.clone(); + changed.sections.first_mut().unwrap().start_number = Some(9); + let old_bytes = designcraft_format::save(&old).unwrap(); + for path in [&first, &second, &third] { + std::fs::write(path, &old_bytes).unwrap(); + } + let mut batch = ChapterBatch::new(3, "book.paginate").unwrap(); + for path in [&first, &second, &third] { + batch.stage(path.to_str().unwrap(), &changed).unwrap(); + } + let staging = batch.directory.path().to_path_buf(); + let mut calls = 0; + let error = batch + .commit_with(|path, staged| { + calls += 1; + if path == second { Err(std::io::Error::other("synthetic disk full")) } else { write_staged_chapter(path, staged) } + }) + .unwrap_err() + .to_string(); + assert_eq!(calls, 2); + assert!(error.contains("synthetic disk full"), "{error}"); + assert!(error.contains(second.to_str().unwrap()), "{error}"); + let completed = error.split("completed chapter paths:").nth(1).unwrap(); + assert!(completed.contains(first.to_str().unwrap()), "{error}"); + assert!(!completed.contains(second.to_str().unwrap()) && !completed.contains(third.to_str().unwrap()), "{error}"); + assert_eq!(designcraft_format::load(&std::fs::read(first).unwrap()).unwrap().sections.first().unwrap().start_number, Some(9)); + assert_eq!(std::fs::read(second).unwrap(), old_bytes); + assert_eq!(std::fs::read(third).unwrap(), old_bytes); + assert!(!staging.exists(), "temporary chapter snapshots must be removed on a partial failure"); +} + +#[test] +fn chapter_record_limit_is_checked_before_staging_allocation() { + let error = match ChapterBatch::new(MAX_BATCH_CHAPTERS + 1, "book.paginate") { + Err(e) => e, + Ok(_) => panic!("oversized book batch accepted"), + }; + assert!(error.to_string().contains("4096")); +} diff --git a/crates/engine/tests/book_safety.rs b/crates/engine/tests/book_safety.rs new file mode 100644 index 00000000..74c96815 --- /dev/null +++ b/crates/engine/tests/book_safety.rs @@ -0,0 +1,188 @@ +#![cfg(not(target_arch = "wasm32"))] + +use designcraft_engine::Session; +use serde_json::json; +use std::path::Path; +fn dir(name: &str) -> tempfile::TempDir { + tempfile::Builder::new().prefix(&format!("dc-book-safety-{name}-")).tempdir().unwrap() +} +fn document(start: u32, custom: bool) -> designcraft_doc::Document { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages":2})).unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + d.sections.first_mut().unwrap().start_number = Some(start); + if custom { + let mut style = d.styles.paragraph.get(1).unwrap().clone(); + style.name = "Synthetic Book Style".into(); + d.styles_mut().paragraph.push(style); + } + d +} +fn native(p: &Path, start: u32, custom: bool) { + std::fs::write(p, designcraft_format::save(&document(start, custom)).unwrap()).unwrap(); +} +fn idml(p: &Path, start: u32) { + std::fs::write(p, designcraft_idml::export_idml(&document(start, false))).unwrap(); + assert!(designcraft_idml::import_idml(&std::fs::read(p).unwrap()).is_ok()); +} +fn book(d: &Path, chapters: &[&Path]) -> Session { + let mut s = Session::new(); + s.execute("book.new", &json!({"path":d.join("book.dcbook").to_string_lossy()})).unwrap(); + for p in chapters { + s.execute("book.add", &json!({"path":p.to_string_lossy()})).unwrap(); + } + s +} +#[test] +fn late_missing_chapter_does_not_partially_paginate_earlier_chapters() { + let d = dir("late-missing"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&first, 9, false); + native(&later, 1, false); + let mut s = book(d.path(), &[&first, &later]); + let before = std::fs::read(&first).unwrap(); + std::fs::remove_file(later).unwrap(); + let result = s.execute("book.paginate", &json!({})); + assert!(result.is_err()); + let after = std::fs::read(&first).unwrap(); + assert!( + before == after, + "book.paginate changed the first chapter before error: {result:?}; new first number {:?}", + designcraft_format::load(&after).unwrap().sections.first().unwrap().start_number + ); +} +#[test] +fn late_corrupt_chapter_does_not_partially_sync_earlier_chapters() { + let d = dir("late-corrupt"); + let source = d.path().join("source.designcraft"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&source, 1, true); + native(&first, 1, false); + native(&later, 1, false); + let mut s = book(d.path(), &[&source, &first, &later]); + let before = std::fs::read(&first).unwrap(); + std::fs::write(later, b"not a document").unwrap(); + let result = s.execute("book.syncStyles", &json!({})); + assert!(result.is_err()); + let after = std::fs::read(&first).unwrap(); + assert!( + before == after, + "book.syncStyles changed the first target before error: {result:?}; copied custom style {}", + designcraft_format::load(&after).unwrap().styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style") + ); +} +#[test] +fn paginate_does_not_replace_imported_idml_with_native_format() { + let d = dir("idml-paginate"); + let chapter = d.path().join("chapter.idml"); + idml(&chapter, 9); + let mut s = book(d.path(), &[&chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.paginate", &json!({})); + let after = std::fs::read(&chapter).unwrap(); + assert!( + before == after, + "book.paginate rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}", + designcraft_idml::import_idml(&after).is_ok(), + designcraft_format::load(&after).is_ok() + ); +} +#[test] +fn style_sync_does_not_replace_imported_idml_with_native_format() { + let d = dir("idml-sync"); + let source = d.path().join("source.designcraft"); + let chapter = d.path().join("chapter.idml"); + native(&source, 1, true); + idml(&chapter, 1); + let mut s = book(d.path(), &[&source, &chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.syncStyles", &json!({})); + let after = std::fs::read(&chapter).unwrap(); + assert!( + before == after, + "book.syncStyles rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}", + designcraft_idml::import_idml(&after).is_ok(), + designcraft_format::load(&after).is_ok() + ); +} + +#[test] +fn an_open_unsaved_chapter_blocks_batch_writes_before_any_files_change() { + let d = dir("open-unsaved"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&first, 9, false); + native(&later, 5, false); + let mut s = book(d.path(), &[&first, &later]); + s.execute("file.open", &json!({"path":later.to_string_lossy()})).unwrap(); + s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":"Unsaved chapter edit"})).unwrap(); + let first_before = std::fs::read(&first).unwrap(); + let later_before = std::fs::read(&later).unwrap(); + let revision = s.doc().unwrap().revision; + let result = s.execute("book.paginate", &json!({})); + assert!( + result.is_err(), + "book.paginate accepted an open dirty chapter: {result:?}; first changed {}, open backing file changed {}", + std::fs::read(&first).unwrap() != first_before, + std::fs::read(&later).unwrap() != later_before + ); + assert_eq!(std::fs::read(&first).unwrap(), first_before); + assert_eq!(std::fs::read(&later).unwrap(), later_before); + assert_eq!(s.doc().unwrap().revision, revision); + assert!(s.doc().unwrap().is_dirty()); + let e = result.unwrap_err().to_string(); + assert!(e.contains("later.designcraft") && e.contains("close"), "{e}"); +} + +#[cfg(unix)] +#[test] +fn an_alias_of_an_open_chapter_blocks_all_batch_writes() { + let d = dir("open-alias"); + let first = d.path().join("first.designcraft"); + let real = d.path().join("real.designcraft"); + let alias = d.path().join("alias.designcraft"); + native(&first, 9, false); + native(&real, 5, false); + std::os::unix::fs::symlink("real.designcraft", &alias).unwrap(); + let mut s = book(d.path(), &[&first, &alias]); + s.execute("file.open", &json!({"path":real.to_string_lossy()})).unwrap(); + let first_before = std::fs::read(&first).unwrap(); + let real_before = std::fs::read(&real).unwrap(); + let result = s.execute("book.paginate", &json!({})); + assert!(result.is_err(), "book.paginate accepted alias of open chapter: {result:?}"); + assert_eq!(std::fs::read(&first).unwrap(), first_before); + assert_eq!(std::fs::read(&real).unwrap(), real_before); + let e = result.unwrap_err().to_string(); + assert!(e.contains("alias.designcraft") && e.contains("close"), "{e}"); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); +} +#[test] +fn imported_idml_remains_a_read_only_style_source() { + let d = dir("idml-style-source"); + let source = d.path().join("source.idml"); + let target = d.path().join("target.designcraft"); + std::fs::write(&source, designcraft_idml::export_idml(&document(1, true))).unwrap(); + native(&target, 1, false); + let mut s = book(d.path(), &[&source, &target]); + let before = std::fs::read(&source).unwrap(); + let result = s.execute("book.syncStyles", &json!({})).unwrap(); + assert_eq!(result["synced"], 1); + assert_eq!(std::fs::read(&source).unwrap(), before); + assert!(designcraft_idml::import_idml(&before).is_ok()); + let target = designcraft_format::load(&std::fs::read(target).unwrap()).unwrap(); + assert!(target.styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style")); +} +#[test] +fn imported_idml_still_exports_to_book_pdf_without_source_changes() { + let d = dir("idml-pdf"); + let chapter = d.path().join("chapter.idml"); + idml(&chapter, 1); + let mut s = book(d.path(), &[&chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.exportPdf", &json!({})).unwrap(); + assert_eq!(result["pages"], 2); + assert!(result["base64"].as_str().is_some_and(|s| !s.is_empty())); + assert_eq!(std::fs::read(chapter).unwrap(), before); +} diff --git a/docs/gaps.md b/docs/gaps.md index 6f92c550..ebd2d7da 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **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. @@ -149,8 +149,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ## Medium ### 15. Long documents in depth (F) -- **Missing:** Books beyond a list of documents: style/swatch synchronization, book-wide - pagination, book PDF export, book TOC and index; Table of Contents Styles. +- **Missing:** book-wide TOC and index; Table of Contents Styles and deeper production workflows. +- **Present:** basic style synchronization, continuous book numbering and merged PDF export. + Chapter updates prepare the full batch and write only closed native chapters; IDML is a + read-only source. Each file is atomic, but a later I/O failure can leave completed chapters; + the error lists them. Cross-file transactional saving remains unavailable. - **Estimate:** 30–50 h. ### 16. Menu items still missing (U) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18188e64..2ef2588d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **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 @@ -351,6 +351,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. - Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. +- Book numbering/style updates validate and serialize all targets before changing the first chapter. They write only closed native chapters, checking canonical paths for open aliases; IDML remains a read-only style source and PDF input. Snapshots are staged on disk with at most 4,096 chapter records in memory. Each replacement is atomic; a later write failure explicitly lists completed chapters rather than claiming whole-book rollback. Import/serialization memory limits and advanced book TOC/index work remain separate. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`).