Merge main: atomic desktop and CLI exports, staged book updates, recovery checkpoint retention, exit recovery

upstream/main c3859c82 (#614 e198eb5d, #615 9509b2bc, #616 8e151a2f, #617 c3859c82).

Conflict: ROADMAP.md: header keeps next's change list plus orderly native exit recovery; progress log both
Conflict: apps/designcraft-cli/src/main.rs: next's case-insensitive .jpg/.jpeg check with main's write_atomic
Conflict: apps/designcraft/src/main.rs: on_exit keeps next's startup.presented() (not an Option) and adds preserve_documents_on_exit; next's discard_marked_text and main's preserve_documents_on_exit both; Services write uses write_atomic and keeps next's clipboard_text; tests both
Conflict: crates/engine/src/cmd/book.rs: main's ChapterBatch paginate with next's chapter-number resolution and {pages, chapters} result and doc
Conflict: crates/engine/src/cmd/file.rs: revert takes main's discard_document ordering without the revision + 1 (next's DocState::new hands out a fresh revision)
Conflict: crates/engine/src/recovery.rs: main's save_tracked, write_entry and open (st.bump_revision() for next's global revisions); next's reopen-twice test assertions kept
Conflict: docs/gaps.md, docs/target-app-parity.md: headers combined; next's Language, Books and Numbering lines plus main's book update safety line
This commit is contained in:
aiongg
2026-10-12 19:46:34 +00:00
13 changed files with 1200 additions and 99 deletions
+5 -1
View File
@@ -2,7 +2,7 @@
**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%)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **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.
@@ -152,6 +152,10 @@ Newest first.
- **2026-10-12** · Clipping paths saved with images in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path, inverted or not) clip the image on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (#450). No readiness percentage change.
- **2026-10-12** · Anchored objects with text wrap push text aside: other stories' text under them and the lines of their own story after the anchor's line (#557). No readiness percentage change.
- **2026-10-12** · Hyphenation Zone applies to the Single-line Composer only, as in InDesign. With the default Paragraph Composer, ragged text hyphenates where InDesign does (aper-natem), giving the same line breaks. No readiness percentage change.
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
- **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.
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
+1 -1
View File
@@ -320,7 +320,7 @@ fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), Str
.ok_or("no such page")?;
let lower = out.to_lowercase();
let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &bytes).map_err(|e| format!("{out}: {e}"))?;
eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs);
Ok(())
}
+32
View File
@@ -219,3 +219,35 @@ fn run_export_options_come_before_their_export() {
assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn raster_export_preserves_an_existing_reader_and_private_symlink_target() {
use std::io::Read;
use std::os::unix::fs::{PermissionsExt, symlink};
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("designcraft-cli-atomic-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let fixture = Fixture(dir);
let target = fixture.0.join(format!("{}.png", "a".repeat(243)));
let old = b"previous private export";
std::fs::write(&target, old).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
let alias = fixture.0.join("linked.png");
symlink(target.file_name().unwrap(), &alias).unwrap();
let mut reader = std::fs::File::open(&target).unwrap();
let out = cli().args(["run", "--sample", "--scale", "0.1", "--export", alias.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let mut observed = Vec::new();
reader.read_to_end(&mut observed).unwrap();
assert!(observed == old, "CLI export changed bytes under an existing reader");
assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG\r\n\x1a\n"));
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
}
+189 -1
View File
@@ -86,6 +86,10 @@ impl eframe::App for App {
fn on_exit(&mut self) {
// A normal exit before the third frame is not a crash.
self.startup.presented();
if let Err(e) = preserve_documents_on_exit(&mut self.app) {
log::error!("Couldn't preserve documents on exit: {e}");
eprintln!("designcraft: couldn't preserve documents on exit: {e}");
}
save_prefs(&self.app);
}
}
@@ -101,6 +105,25 @@ fn discard_marked_text() {
}
}
/// Orderly OS termination can bypass egui's cancelable close event. Finish only a complete,
/// current quit plan; otherwise preserve every live document, including edits after the timer.
fn preserve_documents_on_exit(app: &mut DesignApp) -> Result<(), String> {
app.finish_confirmed_quit()?;
if app.session.documents().is_empty() {
return Ok(());
}
if app.session.recovery_dir.is_none() {
return if app.session.documents().iter().any(|d| d.is_dirty()) {
Err("Recovery is unavailable; unsaved documents could not be preserved on exit".into())
} else {
Ok(())
};
}
// Also discard stale recovery for clean documents (for example, Undo back to the saved
// state after the last timer tick). Never choose a fallback recovery path here.
app.run("file.recovery.save", serde_json::json!({})).map(|_| ())
}
/// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos).
fn no_prefs() -> bool {
std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some()
@@ -276,7 +299,7 @@ fn services() -> Services {
})),
pick_save: Some(Box::new(pick_save)),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))),
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())),
..Default::default()
}
@@ -542,3 +565,168 @@ mod tests {
assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension");
}
}
#[cfg(all(test, unix))]
mod atomic_writer_tests {
#[test]
fn desktop_write_preserves_an_existing_reader_and_private_symlink_target() {
use std::io::Read;
use std::os::unix::fs::{PermissionsExt, symlink};
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("designcraft-desktop-atomic-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let fixture = Fixture(dir);
let target = fixture.0.join(format!("{}.png", "a".repeat(243)));
let old = b"previous private export";
std::fs::write(&target, old).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
let alias = fixture.0.join("linked.png");
symlink(target.file_name().unwrap(), &alias).unwrap();
let mut reader = std::fs::File::open(&target).unwrap();
let mut write = super::services().write.unwrap();
write(alias.to_str().unwrap(), b"complete new export").unwrap();
let mut observed = Vec::new();
reader.read_to_end(&mut observed).unwrap();
assert!(observed == old, "desktop export changed bytes under an existing reader");
assert_eq!(std::fs::read(&target).unwrap(), b"complete new export");
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
}
}
#[cfg(test)]
mod exit_recovery_tests {
use super::*;
use serde_json::json;
fn dirty_app(label: &str) -> (DesignApp, std::path::PathBuf) {
let mut app = DesignApp::new(Session::new(), Services::default());
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
let uid = app.session.active().unwrap().uid;
let dir = std::env::temp_dir().join(format!("designcraft-exit-{label}-{}-{uid}", std::process::id()));
assert!(!dir.exists());
app.session.recovery_dir = Some(dir.clone());
(app, dir)
}
fn close_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) {
let mut raw = egui::RawInput::default();
if close {
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
}
let mut out = ctx.run_ui(raw, |ui| app.logic(ui.ctx()));
out.textures_delta.clear();
}
#[test]
fn exit_recovery_preserves_latest_dirty_document_without_mutating_it() {
let (mut app, dir) = dirty_app("latest");
app.run("file.recovery.save", json!({})).unwrap();
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
let d = app.session.active().unwrap();
let (uid, revision, saved_revision, doc, selection, undo, redo) =
(d.uid, d.revision, d.saved_revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len());
preserve_documents_on_exit(&mut app).unwrap();
let current = app.session.active().unwrap();
assert_eq!(
(current.uid, current.revision, current.saved_revision, &current.selection.items, current.history.undo.len(), current.history.redo.len()),
(uid, revision, saved_revision, &selection, undo, redo)
);
assert!(std::sync::Arc::ptr_eq(&current.doc, &doc));
assert!(current.is_dirty());
let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap();
assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn exit_recovery_removes_stale_data_after_undo_to_saved_state() {
let (mut app, dir) = dirty_app("clean");
let uid = app.session.active().unwrap().uid;
app.run("file.recovery.save", json!({})).unwrap();
let entry = dir.join(format!("{uid}.designcraft"));
assert!(entry.exists());
app.run("edit.undo", json!({})).unwrap();
assert!(!app.session.active().unwrap().is_dirty());
let doc = app.session.active().unwrap().doc.clone();
preserve_documents_on_exit(&mut app).unwrap();
assert!(!entry.exists(), "clean exit must not resurrect the pre-Undo edits");
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn exit_recovery_reports_unavailable_or_failed_storage_without_changing_documents() {
let (mut app, dir) = dirty_app("failure");
let doc = app.session.active().unwrap().doc.clone();
app.session.recovery_dir = None;
assert!(preserve_documents_on_exit(&mut app).unwrap_err().contains("unavailable"));
std::fs::create_dir(&dir).unwrap();
let blocked = dir.join("not-a-directory");
std::fs::write(&blocked, b"preserve this file").unwrap();
app.session.recovery_dir = Some(blocked.clone());
assert!(preserve_documents_on_exit(&mut app).is_err());
assert_eq!(std::fs::read(&blocked).unwrap(), b"preserve this file");
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc));
assert!(app.session.active().unwrap().is_dirty());
assert_eq!(app.session.documents().len(), 1);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn exit_recovery_respects_complete_incomplete_stale_and_new_document_quit_plans() {
for case in ["complete", "incomplete", "stale", "new"] {
let (mut app, dir) = dirty_app(case);
if case == "incomplete" {
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
}
app.run("file.recovery.save", json!({})).unwrap();
let first_uid = app.session.documents()[0].uid;
let ctx = egui::Context::default();
close_frame(&mut app, &ctx, true);
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
designcraft_ui_egui::dialogs::confirm(&mut app).unwrap();
close_frame(&mut app, &ctx, false);
if case == "stale" {
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
} else if case == "new" {
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
}
let active = app.session.active().unwrap().uid;
let before: Vec<_> = app
.session
.documents()
.iter()
.map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len()))
.collect();
preserve_documents_on_exit(&mut app).unwrap();
if case == "complete" {
assert!(app.session.documents().is_empty());
assert!(!dir.join(format!("{first_uid}.designcraft")).exists(), "accepted Don't Save must not be recovered");
} else {
assert_eq!(app.session.documents().len(), before.len(), "{case}");
assert_eq!(app.session.active().unwrap().uid, active, "{case}");
for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) {
assert_eq!(
(d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()),
(uid, revision, &selection, undo, redo),
"{case}"
);
assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}");
let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap();
assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap(), "{case}");
}
}
std::fs::remove_dir_all(dir).unwrap();
}
}
}
+176 -57
View File
@@ -49,20 +49,185 @@ fn load(path: &str) -> Result<designcraft_doc::Document> {
super::interchange::resolve_pdf_crops(&mut d);
return Ok(d);
}
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<Self> {
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<Value> {
let b = s.book.as_ref().ok_or_else(no_book)?;
if b.documents.is_empty() {
return Ok(json!({"pages": 0, "chapters": []}));
}
let mut batch = ChapterBatch::new(b.documents.len(), "book.paginate")?;
let mut next = 1u32;
let mut chapter = 0u32;
let mut chapters = Vec::with_capacity(b.documents.len());
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"))?;
chapter = match d.settings.chapter_source {
designcraft_doc::ChapterSource::Automatic => chapter.saturating_add(1),
designcraft_doc::ChapterSource::UserDefined => d.settings.chapter_number.max(1),
designcraft_doc::ChapterSource::SameAsPrevious => chapter.max(1),
};
d.settings.chapter_number = chapter;
chapters.push(d.chapter_label());
batch.stage(path, &d)?;
}
batch.commit()?;
Ok(json!({"pages": next - 1, "chapters": chapters}))
}
#[cfg(not(target_arch = "wasm32"))]
fn sync_styles(s: &Session) -> Result<Value> {
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<Value> {
Err(EngineError::Other("books need a file system".into()))
}
#[cfg(target_arch = "wasm32")]
fn sync_styles(_: &Session) -> Result<Value> {
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(
@@ -154,57 +319,10 @@ pub fn specs() -> Vec<CommandSpec> {
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, and takes its chapter number (automatic: one more than the previous document's; same as previous; or its own start) (files are saved) → {pages, chapters}", has_book, |s, _| {
let b = s.book.clone().ok_or_else(no_book)?;
let mut next = 1u32;
let mut chapter = 0u32;
let mut chapters = Vec::new();
for p in &b.documents {
let mut d = load(p)?;
start_at(&mut d, next);
next = next.saturating_add(u32::try_from(d.page_count()).unwrap_or(u32::MAX));
chapter = match d.settings.chapter_source {
designcraft_doc::ChapterSource::Automatic => chapter.saturating_add(1),
designcraft_doc::ChapterSource::UserDefined => d.settings.chapter_number.max(1),
designcraft_doc::ChapterSource::SameAsPrevious => chapter.max(1),
};
d.settings.chapter_number = chapter;
chapters.push(d.chapter_label());
store(p, &d)?;
}
Ok(json!({"pages": next.saturating_sub(1), "chapters": chapters}))
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)?;
@@ -280,6 +398,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();
@@ -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"));
}
+9 -9
View File
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| {
let dir = recovery_dir(s, p)?;
Ok(json!({"saved": crate::recovery::save(s, &dir)?}))
Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?}))
}),
cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| {
let dir = recovery_dir(s, p)?;
@@ -365,9 +365,10 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
st.saved_revision = st.revision;
st.saved_doc = st.doc.clone();
#[cfg(not(target_arch = "wasm32"))]
if let Some(dir) = &s.recovery_dir {
let uid = s.doc()?.uid;
crate::recovery::discard(dir, uid);
{
let recovery_dir = s.recovery_dir.clone();
let index = s.active_index().ok_or(EngineError::NoDocument)?;
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
}
Ok(json!({"path": path, "bytes": bytes.len()}))
}
@@ -814,14 +815,13 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
// As opened again: the fonts beside it now (unchanged files are shared, not read again).
let (fonts, _, _) = load_document_fonts(&mut d, &path);
let uid = s.doc()?.uid;
let st = s.doc_mut()?;
let recovery_dir = s.recovery_dir.clone();
let index = s.active_index().ok_or(EngineError::NoDocument)?;
let mut fresh = DocState::new(d, Some(path.clone()));
fresh.fonts = fonts;
fresh.uid = uid;
*st = fresh;
if let Some(dir) = &s.recovery_dir {
crate::recovery::discard(dir, uid);
}
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
*s.doc_mut()? = fresh;
Ok(json!({"path": path}))
}
#[cfg(target_arch = "wasm32")]
+7 -2
View File
@@ -97,6 +97,9 @@ pub struct DocState {
/// Editing parent spreads (Pages panel double-click on a parent).
pub editing_parents: bool,
pub uid: u64,
/// Recovery entries this state actually opened or published, for repeated-open and cleanup.
/// Bounded, deduplicated checkpoint addresses; never inferred from uid alone.
pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>,
/// Unfilled document while data-merge preview is showing a record. Not saved.
pub preview_stash: Option<Arc<Document>>,
/// 1-based record currently previewed.
@@ -150,6 +153,7 @@ impl DocState {
interaction: None,
editing_parents: false,
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
recovery_entries: Vec::new(),
preview_stash: None,
preview_record: None,
fonts: None,
@@ -434,8 +438,9 @@ impl Session {
self.stop_data_preview();
}
#[cfg(not(target_arch = "wasm32"))]
if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) {
recovery::discard(dir, d.uid);
{
let recovery_dir = self.recovery_dir.clone();
recovery::discard_document(self, i, recovery_dir.as_deref());
}
if i < self.docs.len() {
self.docs.remove(i);
+524 -19
View File
@@ -340,14 +340,67 @@ pub fn discard(dir: &Path, uid: u64) {
let _ = std::fs::remove_file(m);
}
/// Write recovery entries for the dirty documents (and drop those of clean ones). Returns how
/// many were written.
pub fn save(s: &Session, dir: &Path) -> Result<usize> {
/// Consume this document's checkpoint provenance after Save/Close/Revert. A default-directory
/// uid may also be another live recovered document's retained source after a failed republish.
pub(crate) fn discard_document(s: &mut Session, index: usize, default_dir: Option<&Path>) {
let Some(doc) = s.docs.get_mut(index) else { return };
let uid = doc.uid;
// A completed Save/Close/Revert consumes the provenance, even when removal fails. A stale
// source uid must never become a future claim on another document's checkpoint.
let mut entries = std::mem::take(&mut doc.recovery_entries);
if let Some(dir) = default_dir
&& !entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, dir))
{
entries.push((dir.to_path_buf(), uid));
}
for (dir, uid) in entries {
// Session ownership governs deletion as well as writes: never consume another live
// document's only source merely because its old uid matches this unrelated document.
if !s.docs.iter().enumerate().any(|(other_index, owner)| {
other_index != index && owner.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, &dir))
}) {
discard(&dir, uid);
}
}
}
const MAX_RECOVERY_ENTRIES: usize = 16;
fn same_directory(a: &Path, b: &Path) -> bool {
a == b || std::fs::canonicalize(a).ok().zip(std::fs::canonicalize(b).ok()).is_some_and(|(a, b)| a == b)
}
fn has_checkpoint(doc: &DocState, dir: &Path) -> bool {
doc.recovery_entries.iter().any(|(entry_dir, uid)| *uid == doc.uid && same_directory(entry_dir, dir))
}
fn protect_retained_sources(s: &Session, dir: &Path) -> Result<()> {
for document in &s.docs {
if let Some(owner) = s.docs.iter().find(|owner| {
owner.uid != document.uid && owner.recovery_entries.iter().any(|(entry_dir, uid)| *uid == document.uid && same_directory(entry_dir, dir))
}) {
return Err(EngineError::Other(format!("save or close recovered document {} before reusing its recovery folder", owner.title())));
}
}
Ok(())
}
fn prepare_save(s: &Session, dir: &Path) -> Result<()> {
// Check before any write or deletion: a failed republish can retain a source whose uid
// coincides with an unrelated live document in a manually configured recovery directory.
protect_retained_sources(s, dir)?;
let created = secure_recovery_dir(dir)?;
#[cfg(not(target_arch = "wasm32"))]
relock(dir, created)?;
#[cfg(target_arch = "wasm32")]
let _ = created;
Ok(())
}
/// Write recovery entries for dirty documents and drop clean checkpoints. Returns how many
/// were written. Command callers use the tracked variant without configuring automatic recovery.
pub fn save(s: &Session, dir: &Path) -> Result<usize> {
prepare_save(s, dir)?;
let mut n = 0;
for d in &s.docs {
if !d.is_dirty() {
@@ -360,14 +413,47 @@ pub fn save(s: &Session, dir: &Path) -> Result<usize> {
Ok(n)
}
/// Write `d`'s recovery entry: the document, then its metadata.
/// Command-path checkpointing also records explicitly selected directories, without configuring
/// automatic recovery. The immutable public helper remains available for fixtures and callers.
pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
// Reject the complete batch before writing rather than silently losing old ownership records.
for document in &s.docs {
if document.is_dirty() && !has_checkpoint(document, dir) && document.recovery_entries.len() >= MAX_RECOVERY_ENTRIES {
return Err(EngineError::Other(
"too many recovery checkpoints; save or close the document before choosing another recovery folder".into(),
));
}
}
prepare_save(s, dir)?;
let mut n = 0;
for index in 0..s.docs.len() {
if s.docs.get(index).is_some_and(|document| !document.is_dirty()) {
discard_document(s, index, Some(dir));
continue;
}
let Some(document) = s.docs.get_mut(index) else { continue };
let (doc, meta) = files(dir, document.uid);
let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref()))
.map_err(|e| EngineError::Other(e.to_string()))?;
write_recovery_file(&doc, &bytes)?;
// The primary is useful even if optional metadata fails next. Track it immediately.
if !has_checkpoint(document, dir) {
document.recovery_entries.push((dir.to_path_buf(), document.uid));
}
let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()});
write_recovery_file(&meta, metadata.to_string().as_bytes())?;
n += 1;
}
Ok(n)
}
/// Publish a document's recovery entry before its optional metadata.
fn write_entry(dir: &Path, d: &DocState) -> Result<()> {
let (doc, meta) = files(dir, d.uid);
let bytes = crate::cmd::to_bytes(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref()));
// Write then rename, so a crash mid-write never leaves a torn file.
let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?;
write_recovery_file(&doc, &bytes)?;
let m = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()});
write_recovery_file(&meta, m.to_string().as_bytes())
let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()});
write_recovery_file(&meta, metadata.to_string().as_bytes())
}
#[cfg(not(target_arch = "wasm32"))]
@@ -415,6 +501,11 @@ fn secure_recovery_dir(_dir: &Path) -> Result<bool> {
#[cfg(not(target_arch = "wasm32"))]
fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> {
publish_recovery_file(path, bytes, true)
}
#[cfg(not(target_arch = "wasm32"))]
fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> {
let Some(dir) = path.parent() else {
return Err(EngineError::Other(format!("{} has no parent folder", path.display())));
};
@@ -434,7 +525,8 @@ fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> {
// Validate the non-followed leaf again immediately before renaming the randomized temporary
// file to its fixed sibling destination.
validate_recovery_dir(dir)?;
tmp.persist(path).map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?;
let published = if replace { tmp.persist(path) } else { tmp.persist_noclobber(path) };
published.map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?;
sync_recovery_dir(dir)?;
Ok(())
}
@@ -494,6 +586,45 @@ fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> {
Err(EngineError::Other("recovery files are unavailable on the web".into()))
}
#[cfg(target_arch = "wasm32")]
fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<()> {
Err(EngineError::Other("recovery files are unavailable on the web".into()))
}
/// Canonical keys are only for alias comparison; operational paths stay unchanged so run
/// ownership checks still find the directory registered by the native profile.
fn directory_key(dir: &Path) -> Result<PathBuf> {
std::fs::canonicalize(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))
}
/// Include metadata-only entries as occupied, so publication cannot attach a new document to
/// somebody else's orphan metadata. No filename-derived maximum changes the uid allocator.
fn occupied_uids(dir: &Path) -> Result<std::collections::HashSet<u64>> {
let entries = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
let mut ids = std::collections::HashSet::new();
for entry in entries {
let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
let path = entry.path();
if matches!(path.extension().and_then(|s| s.to_str()), Some("json" | "designcraft"))
&& let Some(uid) = path.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse().ok())
{
ids.insert(uid);
}
}
Ok(ids)
}
fn reserve_uid(state: &mut DocState, occupied: &mut std::collections::HashSet<u64>) -> Result<()> {
// At most occupied.len() consecutive candidates can collide. The extra attempt must be free.
for _ in 0..=occupied.len() {
if occupied.insert(state.uid) {
return Ok(());
}
state.uid = crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
Err(EngineError::Other("no free recovery document uid".into()))
}
/// Recovery entries in `dir`: (uid, metadata).
pub fn list(dir: &Path) -> Vec<(u64, Value)> {
let mut out = Vec::new();
@@ -520,9 +651,9 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
out
}
/// Reopen every recovery entry as an unsaved document. Each one's entry moves to the new
/// document's uid (written before the old one is removed), so a recovered document stays on disk
/// until it is saved or closed, even when this session ends before its first recovery save.
/// Reopen recovery entries as unsaved documents. Publish each recovered document under its new
/// uid before removing the original, so another crash before the next timer preserves the edits.
/// Entries belonging to documents already open in this session's recovery folder are left alone.
/// Returns the opened documents' indices.
pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
#[cfg(not(target_arch = "wasm32"))]
@@ -530,11 +661,32 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
validate_recovery_dir(dir)?;
relock(dir, false)?;
}
let destination = s.recovery_dir.clone().unwrap_or_else(|| dir.to_path_buf());
let created = secure_recovery_dir(&destination)?;
#[cfg(not(target_arch = "wasm32"))]
relock(&destination, created)?;
#[cfg(target_arch = "wasm32")]
let _ = created;
let source_key = directory_key(dir)?;
let destination_key = directory_key(&destination)?;
let mut opened = Vec::new();
// For this run's own folder, first bring in what runs that ended left in theirs.
#[cfg(not(target_arch = "wasm32"))]
take_over(dir);
for (uid, meta) in list(dir) {
let entries = list(dir);
// Reserve every source entry before opening the first one: publication must never overwrite
// a source still waiting in this list, even after a fresh process restarts its allocator at 1.
let mut occupied = occupied_uids(dir)?;
if source_key != destination_key {
occupied.extend(occupied_uids(&destination)?);
}
occupied.extend(s.docs.iter().map(|d| d.uid));
for (uid, meta) in entries {
if s.docs.iter().any(|d| {
d.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && directory_key(entry_dir).is_ok_and(|key| key == source_key))
}) {
continue;
}
#[cfg(not(target_arch = "wasm32"))]
validate_recovery_dir(dir)?;
let (doc, _) = files(dir, uid);
@@ -547,18 +699,36 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
// Skipped font files are logged; the document opens without them.
let fonts = path.as_deref().and_then(|p| crate::cmd::load_document_fonts(&mut d, p).0);
let mut st = DocState::new(d, path);
reserve_uid(&mut st, &mut occupied)?;
st.recovery_entries.push((dir.to_path_buf(), uid));
st.fonts = fonts;
// Unsaved: the copy on disk (if any) is older than what was recovered.
st.saved_doc = std::sync::Arc::new((*st.doc).clone());
st.bump_revision();
let index = s.add_document(st);
opened.push(index);
// An entry that can't be rewritten stays under its old uid: a duplicate on the next
// recovery is better than a lost document.
match s.docs.get(index).map(|d| write_entry(dir, d)) {
Some(Ok(())) => discard(dir, uid),
Some(Err(e)) => log::warn!("recovery entry {uid} kept: {e}"),
None => {}
// New uids are published without replacement, and the original is consumed only after
// both files succeed. Track a published primary even if metadata fails, so Save/Close can
// clean up every snapshot this document actually owns rather than an unrelated same uid.
if let Some(state) = s.docs.get_mut(index) {
let (document, metadata) = files(&destination, state.uid);
let publish = designcraft_format::save(state.doc.as_ref())
.map_err(|e| EngineError::Other(e.to_string()))
.and_then(|bytes| publish_recovery_file(&document, &bytes, false));
match publish {
Ok(()) => {
state.recovery_entries.push((destination.clone(), state.uid));
let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()});
match publish_recovery_file(&metadata, meta.to_string().as_bytes(), false) {
Ok(()) => {
discard(dir, uid);
state.recovery_entries = vec![(destination.clone(), state.uid)];
}
Err(e) => log::warn!("recovery entry {uid} kept: {e}"),
}
}
Err(e) => log::warn!("recovery entry {uid} kept: {e}"),
}
}
}
Ok(opened)
@@ -612,6 +782,57 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_recovered_document_survives_a_second_crash_before_autosave() {
let dir = tempfile::tempdir().unwrap();
let original = unsaved("edits preserved after a second crash");
assert_eq!(save(&original, dir.path()).unwrap(), 1);
let mut reopened = Session::new();
assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1);
drop(reopened);
let mut recovered_again = Session::new();
assert_eq!(open(&mut recovered_again, dir.path()).unwrap().len(), 1);
assert_eq!(texts(&recovered_again), ["edits preserved after a second crash"]);
assert!(recovered_again.documents().iter().all(DocState::is_dirty));
assert_eq!(list(dir.path()).len(), 1);
}
#[test]
fn opening_recovery_again_does_not_duplicate_live_documents() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(save(&unsaved("one recovered document"), dir.path()).unwrap(), 1);
let mut reopened = Session::new();
reopened.recovery_dir = Some(dir.path().to_path_buf());
assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1);
assert!(open(&mut reopened, dir.path()).unwrap().is_empty());
assert_eq!(texts(&reopened), ["one recovered document"]);
assert_eq!(list(dir.path()).len(), 1);
}
#[cfg(unix)]
#[test]
fn a_failed_recovery_rewrite_preserves_the_original() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
assert_eq!(save(&unsaved("keep the original"), dir.path()).unwrap(), 1);
let entries = list(dir.path());
let original = files(dir.path(), entries[0].0).0;
let bytes = std::fs::read(&original).unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let mut reopened = Session::new();
let result = open(&mut reopened, dir.path());
// Restore before assertions so a failing test does not leave an unremovable fixture.
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(result.unwrap().len(), 1);
assert_eq!(texts(&reopened), ["keep the original"]);
assert_eq!(std::fs::read(original).unwrap(), bytes);
assert_eq!(list(dir.path()).len(), 1);
// Its source uid differs from the reopened uid, but explicit Close still discards it.
assert!(open(&mut reopened, dir.path()).unwrap().is_empty());
reopened.close_document(0);
assert!(list(dir.path()).is_empty());
}
#[cfg(unix)]
#[test]
fn recovery_data_is_private_to_the_user() {
@@ -705,6 +926,290 @@ mod tests {
}
}
#[cfg(unix)]
fn failed_republication_session() -> (Session, tempfile::TempDir, u64) {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let mut session = Session::new();
session.execute("file.new", &json!({})).unwrap();
let live_uid = session.docs[0].uid;
let mut original = unsaved("retained old work");
original.docs[0].uid = live_uid;
save(&original, root.path()).unwrap();
session.recovery_dir = Some(root.path().to_path_buf());
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let recovered = open(&mut session, root.path());
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(recovered.unwrap().len(), 1);
assert_ne!(session.docs[1].uid, live_uid);
(session, root, live_uid)
}
#[cfg(unix)]
#[test]
fn retained_source_survives_a_coincident_live_uid_checkpoint_failure() {
let (mut session, root, live_uid) = failed_republication_session();
let original = std::fs::read(files(root.path(), live_uid).0).unwrap();
let recovered_uid = session.docs[1].uid;
std::fs::create_dir(files(root.path(), recovered_uid).0).unwrap();
session.execute("file.activate", &json!({"index": 0})).unwrap();
session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap();
assert!(session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err());
assert_eq!(std::fs::read(files(root.path(), live_uid).0).unwrap(), original);
}
#[cfg(unix)]
#[test]
fn clean_autosave_preserves_another_documents_retained_source() {
let (mut session, root, live_uid) = failed_republication_session();
let checkpoint = files(root.path(), live_uid).0;
let original = std::fs::read(&checkpoint).unwrap();
assert!(!session.docs[0].is_dirty());
assert!(session.docs[1].is_dirty());
assert!(session.execute("file.recovery.save", &json!({})).is_err());
assert_eq!(std::fs::read(checkpoint).unwrap(), original);
}
#[cfg(unix)]
#[test]
fn closing_a_coincident_document_preserves_another_documents_retained_source() {
let (mut session, root, live_uid) = failed_republication_session();
let checkpoint = files(root.path(), live_uid).0;
let original = std::fs::read(&checkpoint).unwrap();
session.execute("file.close", &json!({"index": 0})).unwrap();
assert_eq!(std::fs::read(checkpoint).unwrap(), original);
}
#[cfg(unix)]
#[test]
fn saving_a_coincident_document_preserves_another_documents_retained_source() {
let (mut session, root, live_uid) = failed_republication_session();
let checkpoint = files(root.path(), live_uid).0;
let original = std::fs::read(&checkpoint).unwrap();
session.execute("file.activate", &json!({"index": 0})).unwrap();
session.execute("file.saveAs", &json!({"path": root.path().join("saved-other.designcraft").to_string_lossy()})).unwrap();
assert_eq!(std::fs::read(checkpoint).unwrap(), original);
}
#[cfg(unix)]
#[test]
fn reverting_a_coincident_document_preserves_another_documents_retained_source() {
let (mut session, root, live_uid) = failed_republication_session();
let checkpoint = files(root.path(), live_uid).0;
let original = std::fs::read(&checkpoint).unwrap();
let saved = root.path().join("saved-other.designcraft");
std::fs::write(&saved, designcraft_format::save(&session.docs[0].doc).unwrap()).unwrap();
session.docs[0].path = Some(saved.to_string_lossy().into_owned());
session.execute("file.activate", &json!({"index": 0})).unwrap();
session.execute("frame.create", &json!({"rect": [0,0,100,100]})).unwrap();
session.execute("file.revert", &json!({})).unwrap();
assert_eq!(std::fs::read(checkpoint).unwrap(), original);
}
#[cfg(unix)]
#[test]
fn retained_source_provenance_is_consumed_before_its_uid_is_reused() {
let (mut session, root, live_uid) = failed_republication_session();
session.execute("file.saveAs", &json!({"path": root.path().join("saved.designcraft").to_string_lossy()})).unwrap();
session.execute("file.activate", &json!({"index": 0})).unwrap();
session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap();
session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap();
let checkpoint = files(root.path(), live_uid).0;
let expected = std::fs::read(&checkpoint).unwrap();
session.execute("file.close", &json!({"index": 1})).unwrap();
assert_eq!(
std::fs::read(checkpoint).unwrap(),
expected,
"closing the saved recovered document must not delete another document's checkpoint"
);
}
#[test]
fn explicit_checkpoint_tracking_is_bounded_and_deduplicated_before_writes() {
let root = tempfile::tempdir().unwrap();
let mut session = unsaved("bounded checkpoints");
for n in 0..MAX_RECOVERY_ENTRIES {
session.execute("file.recovery.save", &json!({"dir": root.path().join(n.to_string()).to_string_lossy()})).unwrap();
}
let excess = root.path().join("excess");
assert!(session.execute("file.recovery.save", &json!({"dir": excess.to_string_lossy()})).is_err());
assert!(!excess.exists(), "reject before creating or writing the next directory");
let first_alias = root.path().join("0").join(".");
session.execute("file.recovery.save", &json!({"dir": first_alias.to_string_lossy()})).unwrap();
for n in 0..MAX_RECOVERY_ENTRIES {
assert_eq!(persisted_texts(&root.path().join(n.to_string())), ["bounded checkpoints"]);
}
assert!(session.recovery_dir.is_none());
session.execute("file.close", &json!({})).unwrap();
for n in 0..MAX_RECOVERY_ENTRIES {
assert!(list(&root.path().join(n.to_string())).is_empty());
}
}
fn restart_fixture() -> tempfile::TempDir {
let root = tempfile::tempdir().unwrap();
let mut first = unsaved("restart first");
first.docs[0].uid = 1;
save(&first, root.path()).unwrap();
let mut second = unsaved("restart second");
second.docs[0].uid = 2;
save(&second, root.path()).unwrap();
root
}
fn persisted_texts(dir: &Path) -> Vec<String> {
let mut result = Vec::new();
for (uid, _) in list(dir) {
let doc = crate::cmd::from_bytes(&std::fs::read(files(dir, uid).0).unwrap()).unwrap();
result.extend(doc.stories.values().map(|story| story.text.clone()));
}
result.sort();
result
}
#[test]
#[ignore = "subprocess helper, executed by the restart recovery regression tests"]
fn restart_recovery_child() {
let source = PathBuf::from(std::env::var("DESIGNCRAFT_TEST_RECOVERY_SOURCE").unwrap());
let mode = std::env::var("DESIGNCRAFT_TEST_RECOVERY_MODE").unwrap();
if mode == "occupied_destination" {
let owned_root = tempfile::tempdir().unwrap();
let destination = run_dir(owned_root.path()).unwrap();
let mut existing = unsaved("already in destination");
existing.docs[0].uid = 3;
save(&existing, &destination).unwrap();
let (existing_doc, existing_meta) = files(&destination, 3);
let doc_bytes = std::fs::read(&existing_doc).unwrap();
let meta_bytes = std::fs::read(&existing_meta).unwrap();
let mut session = Session::new();
session.recovery_dir = Some(destination.clone());
let opened = open(&mut session, &source).unwrap();
assert_eq!(opened.len(), 2);
assert_eq!(texts(&session), ["restart first", "restart second"]);
assert!(session.docs.iter().all(|document| document.uid != 3));
assert_eq!(persisted_texts(&destination), ["already in destination", "restart first", "restart second"]);
assert!(list(&source).is_empty());
session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap();
session.execute("file.close", &json!({"index": opened[0]})).unwrap();
assert_eq!(list(&destination).len(), 1);
assert_eq!(std::fs::read(existing_doc).unwrap(), doc_bytes);
assert_eq!(std::fs::read(existing_meta).unwrap(), meta_bytes);
return;
}
let mut session = Session::new();
if mode == "advance" || mode == "coincident_live" {
session.execute("file.new", &json!({})).unwrap();
assert_eq!(session.docs[0].uid, 1);
}
let owned_root;
let destination = match mode.as_str() {
"cross_dir" => {
owned_root = tempfile::tempdir().unwrap();
let own = run_dir(owned_root.path()).unwrap();
session.recovery_dir = Some(own.clone());
own
}
"alias" => {
let alias = source.join(".");
session.recovery_dir = Some(alias.clone());
alias
}
"coincident_live" => {
session.recovery_dir = Some(source.clone());
source.clone()
}
_ => source.clone(),
};
let opened = open(&mut session, &source).unwrap();
assert_eq!(opened.len(), 2);
assert_eq!(texts(&session), ["restart first", "restart second"]);
assert_eq!(persisted_texts(&destination), ["restart first", "restart second"]);
assert!(session.docs.iter().all(|d| d.uid > 0 && d.uid < u64::MAX));
if matches!(mode.as_str(), "cross_dir" | "manual" | "alias" | "revert") {
let was_disabled = session.recovery_dir.is_none();
if mode == "cross_dir" {
assert!(list(&source).is_empty());
}
// A copy does not consume recovery. A successful Save and a Close do.
session.execute("file.saveACopy", &json!({"path": source.join("copy.designcraft").to_string_lossy()})).unwrap();
assert_eq!(list(&destination).len(), 2);
session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap();
assert_eq!(list(&destination).len(), 1);
if mode == "revert" {
session.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "text", "text": "after saving"})).unwrap();
session.execute("file.recovery.save", &json!({"dir": destination.to_string_lossy()})).unwrap();
assert_eq!(list(&destination).len(), 2);
session.execute("file.revert", &json!({})).unwrap();
assert_eq!(list(&destination).len(), 1);
}
session.execute("file.close", &json!({"index": opened[0]})).unwrap();
assert!(list(&destination).is_empty());
assert!(list(&source).is_empty());
assert_eq!(session.recovery_dir.is_none(), was_disabled, "manual recovery must not configure autosave");
}
}
fn run_restart_case(mode: &str) {
let source = restart_fixture();
if mode == "max_uid" {
let (doc, meta) = files(source.path(), 2);
let (max_doc, max_meta) = files(source.path(), u64::MAX);
std::fs::rename(doc, max_doc).unwrap();
std::fs::rename(meta, max_meta).unwrap();
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "recovery::tests::restart_recovery_child", "--ignored", "--nocapture"])
.env("DESIGNCRAFT_TEST_RECOVERY_SOURCE", source.path())
.env("DESIGNCRAFT_TEST_RECOVERY_MODE", mode)
.output()
.unwrap();
assert!(
output.status.success(),
"fresh child failed: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let child_stdout = String::from_utf8_lossy(&output.stdout);
assert!(child_stdout.contains("test result: ok. 1 passed; 0 failed;"), "the fresh-process helper did not run exactly once: {child_stdout}");
}
#[test]
fn restart_uid_collision_cannot_delete_the_new_snapshot() {
run_restart_case("fresh");
}
#[test]
fn restart_uid_collision_cannot_overwrite_an_unopened_entry() {
run_restart_case("advance");
}
#[test]
fn cross_directory_recovery_uses_the_configured_destination() {
run_restart_case("cross_dir");
}
#[test]
fn cross_directory_recovery_preserves_occupied_destination_entries() {
run_restart_case("occupied_destination");
}
#[test]
fn manual_recovery_save_and_close_clean_up_without_enabling_autosave() {
run_restart_case("manual");
}
#[test]
fn recovery_directory_aliases_do_not_delete_new_snapshots() {
run_restart_case("alias");
}
#[test]
fn a_maximum_source_uid_does_not_advance_or_overflow_the_allocator() {
run_restart_case("max_uid");
}
#[test]
fn a_coincident_live_uid_is_not_recovered_entry_provenance() {
run_restart_case("coincident_live");
}
#[test]
fn manual_recovery_revert_removes_its_previous_checkpoint() {
run_restart_case("revert");
}
/// The recovery folder was cleared while the app runs: the next save makes the run's folder
/// again, and its lock with it.
#[cfg(unix)]
+188
View File
@@ -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);
}
+7 -3
View File
@@ -1,6 +1,6 @@
# DesignCraft architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first committed version, from the code on main and the local plan) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026
How DesignCraft is built today. The design notes behind it are in `plan/architecture.md`
(local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the
@@ -57,8 +57,11 @@ and the control channel all call `Session::execute(id, params)`. UI-only command
panels, zoom, dialogs) are `UI_COMMANDS` in `crates/ui-egui/src/menus.rs`, reachable the same way.
Tool gestures emit Begin/Preview/Commit commands so a drag is one undo step. Every journaled
command is replayable as a script. Escaped panics during a command or an import/export become an
error (`engine/src/guard.rs`), never a lost document; unsaved documents are written to a recovery
folder every 30 s.
error (`engine/src/guard.rs`), never a lost document; native recovery is written periodically
into each running app's locked folder. Orderly native shutdown finalizes complete, current quit
approvals and writes the remaining documents' latest recovery state, including clean-document
cleanup after Undo. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit
remains open (#544).
## Composition and rendering
@@ -97,4 +100,5 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap |
| 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` |
+6 -3
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)) · **Target:** Adobe InDesign 2026 (21.6)
> **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)
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.
@@ -151,8 +151,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)
+4 -3
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) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
> **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) · **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
@@ -288,9 +288,9 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample).
- Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes. Desktop Services writes and CLI raster exports still need this routing. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee.
- Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee.
- Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds.
- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
- Window close and application/menu Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Control-channel quit remains immediate; a cancelable macOS Dock Quit prompt is still missing (#544).
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
- Graphics adapter chosen by the app: the GPU that drives the primary display (Windows `EnumDisplayDevices`, Linux sysfs), then the power preference (`WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` override). An adapter that fails or hangs while the window starts is left out and the app starts again on the next one, then on the next backend, with software renderers last; wgpu's indirect-call check is off (it lost the Metal device on an Intel Mac, #334); why the app stopped is logged, also when the window never opened (#273). Ported from VectorCraft.
@@ -355,6 +355,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- 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), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause) and Norwegian with hyph-utf8's `hyph-no.tex` (permissive), all 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, chapter numbers resolved in book order (Automatic, Start Chapter Numbering at, Same as Previous Document; `layout.chapterNumbering`, with a style for Chapter Number variables and cross-references), style sync from a style source, one merged PDF. Section prefixes follow InDesign's rule (up to 8 characters, no + or comma).
- Numbering & Section Options window as in InDesign (Start Section, automatic or explicit page numbering up to 99999, prefix, style, marker, Include Prefix; the document's chapter numbering and its book); OK applies both in one undo step (`layout.numberingOptions`).
- 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`).