diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a643cc08d..cbde78daf 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 119b1dbd0..7a6f77ab3 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 a2301b1c8..96e584862 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 fbf80ed9e..98b380ee4 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 bd5320799..81963d9e0 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();