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 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-13 09:50:31 +02:00
committed by moppeeer
co-authored by Claude Opus 5.5
parent e862e0a613
commit 8d89b349d8
5 changed files with 168 additions and 7 deletions
+27 -1
View File
@@ -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<StoryId>,
/// 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));
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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(),
})
}
+47 -3
View File
@@ -144,20 +144,30 @@ fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()
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}));
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<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 (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()));
+92 -1
View File
@@ -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<CommandSpec> {
})
}
),
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::<designcraft_doc::NumberStyle>(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::<designcraft_doc::ChapterSource>(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<Value> {
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();