From 8d89b349d84a02b30d4836f62e76f4aa751ec1a2 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:39:33 +0200 Subject: [PATCH 1/3] feat(engine): Document chapter numbering A document's chapter number now has a style (1, I, i, A, a, 01, 001, 0001) and a source, as in Numbering & Section Options > Document Chapter Numbering: Automatic Chapter Numbering (one more than the previous document in the book; 1 first), Start Chapter Numbering at, or Same as Previous Document in the Book. `layout.chapterNumbering {style?, start?, source?}` sets them (undoable) and, with no parameters, reports them with the open book that holds the document. The book's Update Numbering (`book.paginate`) resolves chapter numbers in book order and reports them. Chapter Number variables and cross-reference chapter numbers use the style. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 28 +++++++++- crates/doc/src/vars.rs | 2 +- crates/doc/src/xref.rs | 2 +- crates/engine/src/cmd/book.rs | 50 ++++++++++++++++-- crates/engine/src/cmd/layout.rs | 93 ++++++++++++++++++++++++++++++++- 5 files changed, 168 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a643cc08..cbde78da 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -303,6 +303,20 @@ impl Default for AdvancedType { } /// File → Document Setup + the guide/grid/unit preferences stored with the document. +/// Where a document's chapter number comes from (Numbering & Section Options › Document Chapter +/// Numbering). A book's Update Numbering resolves it in book order. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChapterSource { + /// Automatic Chapter Numbering: one more than the previous document in the book (1 first). + #[default] + Automatic, + /// Start Chapter Numbering at `chapter_number`. + UserDefined, + /// Same as Previous Document in the Book: the document continues its chapter. + SameAsPrevious, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct DocSettings { @@ -334,8 +348,13 @@ pub struct DocSettings { /// The story in the primary text frames (Smart Text Reflow adds and removes pages for it). #[serde(skip_serializing_if = "Option::is_none")] pub primary_story: Option, - /// Chapter number (Numbering & Section Options › Document Chapter Numbering). + /// Chapter number (Numbering & Section Options › Document Chapter Numbering): the one set by + /// Start Chapter Numbering at, or the one a book last gave the document. pub chapter_number: u32, + /// The chapter number's style (Chapter Number variables, cross-references). + pub chapter_style: NumberStyle, + /// Where the chapter number comes from. + pub chapter_source: ChapterSource, /// Object › Effects › Global Light: the angle (degrees) shadows that use it share. pub global_light: f64, /// Preferences › Advanced Type: superscript and subscript size and position (percent of the @@ -379,6 +398,8 @@ impl Default for DocSettings { primary_story: None, lists: Vec::new(), chapter_number: 1, + chapter_style: NumberStyle::Arabic, + chapter_source: ChapterSource::Automatic, global_light: 120.0, advanced_type: AdvancedType::default(), blend_space: BlendSpace::Cmyk, @@ -666,6 +687,11 @@ impl Document { } /// The displayed page name ("1", "iv", "A-3"…) for an absolute page index, per sections. + /// The chapter number in its style (`3`, `III`, `c`, `03`). + pub fn chapter_label(&self) -> String { + self.settings.chapter_style.format(self.settings.chapter_number.max(1)) + } + pub fn page_name(&self, abs: usize) -> String { let Some(sec) = self.section_of(abs) else { return (abs + 1).to_string() }; let num = sec.style.format(self.page_number(abs)); diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs index 119b1dbd..7a6f77ab 100644 --- a/crates/doc/src/vars.rs +++ b/crates/doc/src/vars.rs @@ -186,7 +186,7 @@ impl Document { }; self.page_name(last) } - VarKind::ChapterNumber => self.settings.chapter_number.max(1).to_string(), + VarKind::ChapterNumber => self.chapter_label(), VarKind::FileName { extension } => { if *extension { format!("{}.designcraft", self.title) diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index a2301b1c..96e58486 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -367,7 +367,7 @@ impl Document { para_num, page: String::new(), anchor_name: self.anchor(id).map(|a| a.name.clone()).unwrap_or_default(), - chapter: self.settings.chapter_number.to_string(), + chapter: self.chapter_label(), file_name: self.title.clone(), }) } diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index fbf80ed9..98b380ee 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -144,20 +144,30 @@ fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<() 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})); + 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::new(); 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 numbers: automatic is one more than the previous document's. + 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})) + Ok(json!({"pages": next - 1, "chapters": chapters})) } #[cfg(not(target_arch = "wasm32"))] @@ -307,7 +317,7 @@ 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, _| { + 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, _| { 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, _| { @@ -346,6 +356,40 @@ mod tests { use crate::Session; + #[test] + fn book_numbering_resolves_chapter_numbers_in_order() { + let dir = std::env::temp_dir().join(format!("dc-book-chapters-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = |n: &str| dir.join(n).to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("book.new", &json!({"path": p("Field Guide.dcbook")})).unwrap(); + let chapters = [ + ("a.designcraft", json!({"style": "upperRoman"})), + ("b.designcraft", json!({"source": "sameAsPrevious"})), + ("c.designcraft", json!({})), + ("d.designcraft", json!({"start": 7, "style": "lowerLetters"})), + ("e.designcraft", json!({})), + ]; + for (name, numbering) in &chapters { + s.execute("file.new", &json!({})).unwrap(); + if numbering.as_object().is_some_and(|o| !o.is_empty()) { + s.execute("layout.chapterNumbering", numbering).unwrap(); + } + s.execute("file.saveAs", &json!({"path": p(name)})).unwrap(); + s.execute("book.add", &json!({"path": p(name)})).unwrap(); + // The book updates closed chapters only. + s.execute("file.close", &json!({})).unwrap(); + } + let r = s.execute("book.paginate", &json!({})).unwrap(); + // Same as previous takes the number, written in the document's own style. + assert_eq!(r["chapters"], json!(["I", "1", "2", "g", "8"])); + assert_eq!(super::load(&p("e.designcraft")).unwrap().settings.chapter_number, 8); + // A chapter opened again names its book. + s.execute("file.open", &json!({"path": p("e.designcraft")})).unwrap(); + assert_eq!(s.execute("layout.chapterNumbering", &json!({})).unwrap()["book"], "Field Guide"); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn book_paginates_syncs_and_exports() { let dir = std::env::temp_dir().join(format!("dc-book-{}", std::process::id())); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd532079..81963d9e 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -4,7 +4,7 @@ use designcraft_doc::{LAYER_COLORS, Layer, LayerId, Margins, SpreadId}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, ok, str_param}; -use crate::Result; +use crate::{Result, Session}; /// Each page's margin box, by spread and page id. fn margin_boxes(d: &designcraft_doc::Document) -> Vec<(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)> { @@ -432,6 +432,60 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "layout.chapterNumbering", + "Document Chapter Numbering", + [], + None, + "{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits, start?: n (Start Chapter Numbering at), source?: automatic|userDefined|sameAsPrevious} → {chapterNumber, label, style, source, book: the open book holding the document, or null}; no parameters reports the setting", + has_doc, + |s, p| { + if p.as_object().is_none_or(|o| o.is_empty()) { + return chapter_numbering(s); + } + let style = match p.get("style") { + Some(v) => match serde_json::from_value::(v.clone()) { + Ok(designcraft_doc::NumberStyle::Symbols) | Err(_) => { + return Err(bad("layout.chapterNumbering", format!("unknown chapter style {v}"))); + } + Ok(style) => Some(style), + }, + None => None, + }; + let mut source = match p.get("source") { + Some(v) => Some( + serde_json::from_value::(v.clone()) + .map_err(|_| bad("layout.chapterNumbering", format!("unknown chapter number source {v}")))?, + ), + None => None, + }; + let start = match p.get("start") { + Some(v) => Some( + v.as_u64() + .filter(|n| (1..=MAX_CHAPTER).contains(n)) + .ok_or_else(|| bad("layout.chapterNumbering", format!("start: 1 to {MAX_CHAPTER}")))? as u32, + ), + None => None, + }; + // A start number alone means Start Chapter Numbering at. + if start.is_some() && source.is_none() { + source = Some(designcraft_doc::ChapterSource::UserDefined); + } + s.edit(|d, _| { + if let Some(style) = style { + d.settings.chapter_style = style; + } + if let Some(source) = source { + d.settings.chapter_source = source; + } + if let Some(n) = start { + d.settings.chapter_number = n; + } + ok() + })?; + chapter_numbering(s) + } + ), cmd!("layer.move", "Move Layer", [], None, "{id, to: index (0 = top/frontmost)}", has_doc, |s, p| { let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); let to = p.get("to").and_then(Value::as_u64).unwrap_or(0) as usize; @@ -708,10 +762,47 @@ fn document_setup(d: &designcraft_doc::Document) -> Value { "binding": if st.right_to_left_binding { "rightToLeft" } else { "leftToRight" }, "intent": st.intent, "bleed": st.bleed, "slug": st.slug}) } +/// The largest chapter number (as for page numbers). +const MAX_CHAPTER: u64 = 99_999; + +/// The active document's chapter numbering, and the open book that holds it. +fn chapter_numbering(s: &Session) -> Result { + let st = s.doc()?; + let set = &st.doc.settings; + let book = s + .book + .as_ref() + .filter(|b| st.path.as_deref().is_some_and(|p| b.documents.iter().any(|d| std::path::Path::new(d) == std::path::Path::new(p)))) + .map(|b| std::path::Path::new(&b.path).file_stem().map_or_else(|| b.path.clone(), |n| n.to_string_lossy().to_string())); + Ok( + json!({"chapterNumber": set.chapter_number.max(1), "label": st.doc.chapter_label(), "style": set.chapter_style, "source": set.chapter_source, "book": book}), + ) +} + #[cfg(test)] mod setup_tests { use serde_json::json; + #[test] + fn chapter_numbering_sets_style_and_start() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("layout.chapterNumbering", &json!({})).unwrap(); + assert_eq!(r, json!({"chapterNumber": 1, "label": "1", "style": "arabic", "source": "automatic", "book": null})); + let r = s.execute("layout.chapterNumbering", &json!({"start": 4, "style": "upperRoman"})).unwrap(); + assert_eq!((&r["label"], &r["source"]), (&json!("IV"), &json!("userDefined")), "a start number means Start Chapter Numbering at"); + s.execute("variables.define", &json!({"name": "Chapter", "type": "chapterNumber"})).unwrap(); + let d = &s.doc().unwrap().doc; + let chapter = d.variable("Chapter").unwrap(); + assert_eq!(d.variable_value(chapter, Some(0)).as_deref(), Some("IV"), "the Chapter Number variable uses the style"); + for bad in [json!({"style": "symbols"}), json!({"start": 0}), json!({"start": 100_000}), json!({"source": "nextBook"})] { + assert!(s.execute("layout.chapterNumbering", &bad).is_err(), "{bad}"); + } + s.execute("layout.chapterNumbering", &json!({"source": "sameAsPrevious"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("layout.chapterNumbering", &json!({})).unwrap()["source"], "userDefined", "undoable"); + } + #[test] fn document_setup_pages_start_bleed_slug() { let mut s = crate::Session::new(); From 0c869924a34e37b708585156825253d7ccf0483f Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:39:38 +0200 Subject: [PATCH 2/3] fix(engine): Keep section prefixes to InDesign's rules A section prefix is at most eight characters and contains no plus or comma (page references and ranges use them); `layout.section` refuses others and leaves the section unchanged. Symbols, a footnote style, no longer number pages. The parameter documentation lists the 01, 001 and 0001 styles, and a test pins the fields the generated dialogs read from both numbering commands. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layout.rs | 27 +++++++++++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 7 +++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 81963d9e..56be6aa2 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -385,7 +385,7 @@ pub fn specs() -> Vec { "Numbering & Section Options…", ["Layout"], None, - "{page (1-based; starts a section there), startNumber?: n|null (continue), style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters, prefix?, includePrefix?, marker?, remove?: bool}", + "{page (1-based; starts a section there), startNumber?: n|null (continue), style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits, prefix? (up to 8 characters; no + or comma), includePrefix?, marker?, remove?: bool}", has_doc, |s, p| { let page = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad("layout.section", "missing page"))? as usize; @@ -394,6 +394,11 @@ pub fn specs() -> Vec { return Err(bad("layout.section", format!("no page {page}"))); } let start = page - 1; + if let Some(prefix) = p.get("prefix").and_then(Value::as_str) + && (prefix.chars().count() > 8 || prefix.contains(['+', ','])) + { + return Err(bad("layout.section", "section prefix: up to 8 characters, without + or ,")); + } let p = p.clone(); s.edit(|d, _| { d.sections.retain(|x| x.start != start || start == 0); @@ -414,7 +419,11 @@ pub fn specs() -> Vec { None => {} } if let Some(v) = p.get("style") { - sec.style = serde_json::from_value(v.clone()).map_err(|e| bad("layout.section", e.to_string()))?; + sec.style = match serde_json::from_value(v.clone()) { + Ok(designcraft_doc::NumberStyle::Symbols) => return Err(bad("layout.section", "symbols don't number pages")), + Ok(style) => style, + Err(e) => return Err(bad("layout.section", e.to_string())), + }; } if let Some(v) = p.get("prefix").and_then(Value::as_str) { sec.prefix = v.into(); @@ -986,6 +995,20 @@ mod page_numbering_view_tests { assert_eq!(s.resolve_page("1"), Some(0)); assert_eq!(s.resolve_page("9"), None); } + + #[test] + fn section_prefixes_are_short_and_avoid_plus_and_comma() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + for prefix in ["A-", "Appendix", "§ 1"] { + s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).unwrap(); + } + for prefix in ["Appendix1", "A+", "1,2"] { + assert!(s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).is_err(), "{prefix}"); + } + assert!(s.execute("layout.section", &json!({"page": 2, "style": "symbols"})).is_err()); + assert_eq!(s.doc().unwrap().doc.sections.iter().find(|x| x.start == 1).unwrap().prefix, "§ 1", "a refused prefix changes nothing"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af..33669e88 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4317,6 +4317,13 @@ mod tests { } } + #[test] + fn numbering_commands_document_their_fields() { + let names = |id: &str| command_fields(designcraft_engine::find_command(id).unwrap().params).into_iter().map(|f| f.key).collect::>(); + assert_eq!(names("layout.section"), ["page", "startNumber", "style", "prefix", "includePrefix", "marker", "remove"]); + assert_eq!(names("layout.chapterNumbering"), ["style", "start", "source"]); + } + #[test] fn generic_command_preserves_array_geometry() { let mut app = command_app("file.new", json!({})); From 2265c45a950aa676cc0bfe1893da8aa52d4a3055 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:11:20 +0200 Subject: [PATCH 3/3] docs: Chapter numbering in the progress docs Rebased on the progress-docs restructure: the ROADMAP bullet moved to the inventory in docs/target-app-parity.md (Books), with a progress-log line. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 ++ docs/target-app-parity.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..805dfeb6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -153,6 +153,8 @@ Newest first. - **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change. - **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change. - **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. +- **2026-10-11** · Document chapter numbering (#341): style, automatic, start at, same as previous + document in the book; Update Numbering resolves it in book order. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c..bc51c9e3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -350,7 +350,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). - 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. +- 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). - 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.