From 188c4f7007f0bb05dfc198d7dae478e4149f796e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 12:03:58 +0200 Subject: [PATCH] Facing pages take their side from their section's page numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ROADMAP.md | 1 + crates/doc/src/build.rs | 20 ++++++-- crates/engine/src/cmd/layout.rs | 90 +++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..f1195daed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c274f2130..6c6ab0b8c 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -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 = (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>)> = Vec::new(); + let mut parity: Vec = Vec::new(); let mut kept: Vec<(usize, Arc)> = 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![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() { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..6cf6432e9 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -398,6 +398,8 @@ pub fn specs() -> Vec { 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 { 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::>()})) }) } @@ -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> { + 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> { + 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:?}"); + } +}