Facing pages take their side from their section's page numbers

Chris Nielsen's "Numbering Page Sections" (7:03): starting a section at
page 1 on a left page (the spread 8–9 selected) turns it into a lone
right-hand page 1 in InDesign, and the pages after it shuffle. DesignCraft
kept the page on the left, so odd page numbers sat on left pages.

- Document::repaginate gives each facing page the side of its number in
  its section (odd: right, or left bound right to left). Document Setup's
  Start Page # is one case of it.
- layout.section repaginates when a section starts, changes or is removed;
  objects go with their pages, in one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-14 12:03:58 +02:00
co-authored by Claude Opus 5.5
parent 43a46c04bf
commit 188c4f7007
3 changed files with 106 additions and 5 deletions
+1
View File
@@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-14** · In a facing document, pages take their side from their section's page numbers, as InDesign shuffles them: a section that starts at 1 on a left page moves it to the right and the pages after it follow, with their objects. No readiness percentage change.
- **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load
the text cursor, then click a frame to thread it, click empty space for a column-wide frame or
drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the
+15 -5
View File
@@ -267,15 +267,26 @@ impl Document {
/// Redistribute document pages into spreads (facing: page 1 alone on the right, then pairs).
/// Items move with the page their centre is on. Spreads with `allow_shuffle == false` are kept.
/// Facing pages take their side from their page number, as InDesign shuffles them: an odd
/// number is a right page (a left page bound right to left), so a section that starts at 1 on
/// a left page moves it to the right and the pages after it follow.
pub fn repaginate(&mut self) {
// Collect pages with their items (items in page-relative coordinates).
let odd: Vec<bool> = (0..self.page_count()).map(|i| self.page_number(i) % 2 == 1).collect();
// Collect pages with their items (items in page-relative coordinates) and their numbers' parity.
let mut pages: Vec<(Page, Vec<Arc<Item>>)> = Vec::new();
let mut parity: Vec<bool> = Vec::new();
let mut kept: Vec<(usize, Arc<Spread>)> = Vec::new();
let mut abs = 0;
for sp in self.spreads.drain(..) {
if !sp.allow_shuffle && sp.pages.len() > 1 {
abs += sp.pages.len();
kept.push((pages.len(), sp));
continue;
}
for _ in &sp.pages {
parity.push(odd.get(abs).copied().unwrap_or(abs % 2 == 0));
abs += 1;
}
let mut sp = Arc::unwrap_or_clone(sp);
let mut per: Vec<Vec<Arc<Item>>> = vec![Vec::new(); sp.pages.len()];
for it in std::mem::take(&mut sp.items) {
@@ -326,13 +337,12 @@ impl Document {
}
spreads.push(sp);
};
// An even start page number (Document Setup › Start Page #) begins with a left page.
let shift = usize::from(self.sections.iter().find(|x| x.start == 0).and_then(|x| x.start_number).is_some_and(|n| n % 2 == 0));
for (i, (mut p, items)) in pages.into_iter().enumerate() {
if facing {
// Page i (0-based) is a right page when its number is odd (1, 3, 5 …).
// A page is a right page when its number is odd (1, 3, 5 …), counting each
// section from its start number (Document Setup › Start Page # included).
// Bound right to left, odd pages are left pages and spreads start on the right.
let odd = (i + shift) % 2 == 0;
let odd = parity.get(i).copied().unwrap_or(i % 2 == 0);
p.side = if odd != rtl { PageSide::Right } else { PageSide::Left };
let starts = if rtl { PageSide::Right } else { PageSide::Left };
if p.side == starts && !cur.is_empty() {
+90
View File
@@ -398,6 +398,8 @@ pub fn specs() -> Vec<CommandSpec> {
s.edit(|d, _| {
d.sections.retain(|x| x.start != start || start == 0);
if p.get("remove").and_then(Value::as_bool).unwrap_or(false) && start != 0 {
// The pages after it follow the previous section's numbers again.
d.repaginate();
return ok();
}
let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section {
@@ -428,6 +430,8 @@ pub fn specs() -> Vec<CommandSpec> {
d.sections.retain(|x| x.start != start);
d.sections.push(sec);
d.sections.sort_by_key(|x| x.start);
// Facing pages take their sides from their numbers: odd on the right.
d.repaginate();
Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::<Vec<_>>()}))
})
}
@@ -921,3 +925,89 @@ mod adjust_tests {
assert_eq!(b, b2);
}
}
#[cfg(test)]
mod section_side_tests {
use serde_json::json;
use crate::Session;
/// Each spread's pages as (name, side), side `L`, `R` or `S`.
fn spreads(s: &Session) -> Vec<Vec<(String, char)>> {
let d = &s.doc().unwrap().doc;
let mut abs = 0;
d.spreads
.iter()
.map(|sp| {
sp.pages
.iter()
.map(|p| {
let side = match p.side {
designcraft_doc::PageSide::Left => 'L',
designcraft_doc::PageSide::Right => 'R',
designcraft_doc::PageSide::Single => 'S',
};
abs += 1;
(d.page_name(abs - 1), side)
})
.collect()
})
.collect()
}
fn pages(v: &[&[(&str, char)]]) -> Vec<Vec<(String, char)>> {
v.iter().map(|sp| sp.iter().map(|(n, c)| (n.to_string(), *c)).collect()).collect()
}
#[test]
fn a_section_starting_at_one_on_a_left_page_moves_it_to_the_right() {
// Chris Nielsen's "Numbering Page Sections" (7:03): with the spread 8–9 selected, the
// section starts on page 8, which becomes a lone right-hand page 1; the pages after it
// shuffle and their objects go with them. Undo puts them back.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": true, "pages": 10})).unwrap();
let id = s.execute("frame.create", &json!({"spread": 4, "rect": [700, 100, 800, 200], "content": "text", "text": "TOPIC"})).unwrap()["id"]
.as_u64()
.unwrap();
let page_of = |s: &Session| {
let d = &s.doc().unwrap().doc;
d.page_of_item(designcraft_doc::ItemId(id)).unwrap()
};
assert_eq!(page_of(&s), 8, "page 9, the right page of the spread 8–9");
let before = spreads(&s);
s.execute("layout.section", &json!({"page": 8, "startNumber": 1})).unwrap();
assert_eq!(
spreads(&s),
pages(&[
&[("1", 'R')],
&[("2", 'L'), ("3", 'R')],
&[("4", 'L'), ("5", 'R')],
&[("6", 'L'), ("7", 'R')],
&[("1", 'R')],
&[("2", 'L'), ("3", 'R')],
])
);
assert_eq!(page_of(&s), 8, "the object stays on its page, now the left page of a spread");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spreads(&s), before);
// On a right page (the video's page 9) nothing moves; an even start on a right page moves
// it to the left.
s.execute("layout.section", &json!({"page": 9, "startNumber": 1})).unwrap();
assert_eq!(spreads(&s)[4], pages(&[&[("8", 'L'), ("1", 'R')]])[0]);
s.execute("layout.section", &json!({"page": 9, "startNumber": 2})).unwrap();
assert_eq!(spreads(&s)[4..], pages(&[&[("8", 'L')], &[("2", 'L'), ("3", 'R')]]));
// Removing the section numbers the pages on from 9 again.
s.execute("layout.section", &json!({"page": 9, "remove": true})).unwrap();
assert_eq!(spreads(&s), before);
}
#[test]
fn single_sided_documents_keep_their_pages() {
let mut s = Session::new();
s.execute("file.new", &json!({"facingPages": false, "pages": 4})).unwrap();
s.execute("layout.section", &json!({"page": 2, "startNumber": 1})).unwrap();
let sp = spreads(&s);
assert_eq!(sp.len(), 4);
assert!(sp.iter().all(|p| p.len() == 1 && p[0].1 == 'S'), "{sp:?}");
}
}