From 11212736f0fa31a22e2ec5e75c8d391a2595ab09 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:21:30 -0400 Subject: [PATCH] P: Page Numbering View (section/absolute) and Go to Page by name Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 23 +++++++++++++++++++ crates/engine/src/cmd/prefs.rs | 2 +- crates/engine/src/lib.rs | 30 +++++++++++++++++++++++++ crates/ui-egui/src/chrome.rs | 4 ++-- crates/ui-egui/src/dialogs.rs | 22 ++++++++++++++---- crates/ui-egui/src/menus.rs | 12 ++++++---- crates/ui-egui/src/panels/layers.rs | 11 ++++++--- crates/ui-egui/src/panels/pages.rs | 4 ++-- crates/ui-egui/src/panels/properties.rs | 8 ++++--- docs/parity.md | 6 ++--- 11 files changed, 101 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2f602fc6c..c42088809 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,6 +39,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come. - Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically. - Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML. +- Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 167ddfec0..0486f0ddd 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -682,3 +682,26 @@ mod spread_tests { d.check().unwrap(); } } + +#[cfg(test)] +mod page_numbering_view_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn section_and_absolute_page_numbering_view() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + s.execute("layout.section", &json!({"page": 1, "startNumber": 1, "style": "lowerRoman"})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); + assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["i", "ii", "1", "2"]); + assert_eq!(s.resolve_page("ii"), Some(1)); + assert_eq!(s.resolve_page("1"), Some(2), "section name first"); + assert_eq!(s.resolve_page("+1"), Some(0), "+n is a position"); + s.execute("prefs.set", &json!({"absolutePageNumbers": true})).unwrap(); + assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["1", "2", "3", "4"]); + assert_eq!(s.resolve_page("1"), Some(0)); + assert_eq!(s.resolve_page("9"), None); + } +} diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index b3011d8a9..a0984d757 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Preferences", [], None, - "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?} → all application preferences", + "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?} → all application preferences", super::always, |s, p| { let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b51fedab5..dc36f1832 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -135,6 +135,9 @@ pub struct Prefs { /// objects (a graphic in a frame, objects in groups) are measured on the pasteboard rather /// than relative to their container. pub transformations_are_totals: bool, + /// Preferences › General › Page Numbering View: Absolute (1, 2, 3… from the first page) instead + /// of section numbering (the page names, e.g. "iv", "A-3"). + pub absolute_page_numbers: bool, /// Preferences › Type › Smart Text Reflow: pages follow the primary text frame's story /// (added while it oversets, empty ones at the end removed). pub smart_text_reflow: bool, @@ -150,6 +153,7 @@ impl Default for Prefs { scale_strokes: true, dimensions_include_stroke: true, transformations_are_totals: true, + absolute_page_numbers: false, smart_text_reflow: true, } } @@ -212,6 +216,32 @@ impl Session { pub fn active_index(&self) -> Option { self.active } + /// A page's label as the UI shows it (Page Numbering View). + pub fn page_label(&self, abs: usize) -> String { + match self.active() { + Some(d) if !self.prefs.absolute_page_numbers => d.doc.page_name(abs), + _ => (abs + 1).to_string(), + } + } + + /// The page a typed page reference means: `+n` is always absolute; otherwise a section page + /// name (section numbering) or a position (absolute numbering, or no page has that name). + pub fn resolve_page(&self, s: &str) -> Option { + let d = self.active()?; + let n = d.doc.page_count(); + let s = s.trim(); + let pos = |t: &str| t.parse::().ok().filter(|k| (1..=n).contains(k)).map(|k| k - 1); + if let Some(t) = s.strip_prefix('+') { + return pos(t); + } + if !self.prefs.absolute_page_numbers + && let Some(i) = (0..n).find(|i| d.doc.page_name(*i).eq_ignore_ascii_case(s)) + { + return Some(i); + } + pos(s) + } + pub fn active(&self) -> Option<&DocState> { self.active.and_then(|i| self.docs.get(i)) } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0c2dc9b54..343f51d05 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -466,13 +466,13 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { if icons::button(ui, "prev", 14.0, false, "Previous Spread").clicked() { crate::canvas::go_to_page(app, cur.saturating_sub(1)); } - let name = app.session.active().map(|d| d.doc.page_name(cur)).unwrap_or_default(); + let name = app.session.page_label(cur); let (fr, fresp) = ui.allocate_exact_size(vec2(80.0, 14.0), Sense::click()); ui.painter().rect(fr, 0.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); ui.painter().text(fr.left_center() + vec2(5.0, 0.0), egui::Align2::LEFT_CENTER, &name, small.clone(), t.text); ui.painter().text(fr.right_center() - vec2(5.0, 0.0), egui::Align2::RIGHT_CENTER, "▾", small.clone(), t.text); egui::Popup::menu(&fresp).show(|ui| { - let names: Vec = app.session.active().map(|d| (0..n).map(|i| d.doc.page_name(i)).collect()).unwrap_or_default(); + let names: Vec = (0..n).map(|i| app.session.page_label(i)).collect(); egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { for (i, nm) in names.iter().enumerate() { if ui.button(nm).clicked() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0542c6b4e..995c29b31 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -25,7 +25,7 @@ impl Dialog { json!({"preset": "Letter", "width": "51p0", "height": "66p0", "pages": 1, "facingPages": true, "columns": 1, "gutter": "1p0", "marginTop": "3p0", "marginBottom": "3p0", "marginInside": "3p0", "marginOutside": "3p0", "bleed": "0p0", "primaryTextFrame": false}) } - "goToPage" => json!({"page": 1}), + "goToPage" => json!({"page": "1"}), "insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}), "insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}), "findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}), @@ -519,6 +519,9 @@ fn preferences(ui: &mut egui::Ui, d: &mut Dialog) { ui.label(egui::RichText::new("Transform").font(semibold(12.0))); check(ui, d, "dimensionsIncludeStroke", "Dimensions Include Stroke Weight"); check(ui, d, "transformationsAreTotals", "Transformations are Totals"); + ui.add_space(6.0); + ui.label(egui::RichText::new("Page Numbering").font(semibold(12.0))); + check(ui, d, "absolutePageNumbers", "Absolute Numbering (instead of Section Numbering)"); } } }); @@ -821,8 +824,19 @@ pub fn confirm(app: &mut DesignApp) -> Result { ) } "goToPage" => { - crate::canvas::go_to_page(app, (d.n("page").unwrap_or(1.0) as usize).saturating_sub(1)); - Ok(Value::Null) + // A page name ("iv", "A-3"), a number, or "+n" for an absolute position. + let typed = match d.fields.get("page") { + Some(Value::String(s)) => s.clone(), + Some(v) => v.to_string(), + None => "1".into(), + }; + match app.session.resolve_page(&typed) { + Some(abs) => { + crate::canvas::go_to_page(app, abs); + Ok(Value::Null) + } + None => Err(format!("no page \"{typed}\"")), + } } "insertTable" => app.run( "table.insert", @@ -882,7 +896,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { "preferences" => { app.run( "prefs.set", - json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "transformationsAreTotals": d.b("transformationsAreTotals"), "typographersQuotes": d.b("typographersQuotes"), "smartTextReflow": d.b("smartTextReflow")}), + json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "transformationsAreTotals": d.b("transformationsAreTotals"), "absolutePageNumbers": d.b("absolutePageNumbers"), "typographersQuotes": d.b("typographersQuotes"), "smartTextReflow": d.b("smartTextReflow")}), )?; let view = match d.s("displayQuality").as_str() { "fast" => "view.fastDisplay", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index aac6be9f7..68608b886 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -56,7 +56,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.fastDisplay", "Fast Display", Some("Cmd+Alt+Shift+Z"), "{} — placed graphics as grey boxes, no effects"), ("view.typicalDisplay", "Typical Display", Some("Cmd+Alt+Z"), "{} — low-resolution image proxies"), ("view.highQualityDisplay", "High Quality Display", Some("Cmd+Alt+H"), "{} — full-resolution images"), - ("view.goToPage", "Go to Page…", Some("Cmd+J"), "{page}"), + ("view.goToPage", "Go to Page…", Some("Cmd+J"), "{page: number (position) | \"name\" (section page name, or \"+n\" for a position)}"), ( "window.panel", "Show Panel", @@ -748,9 +748,13 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.text_threads), "view.hiddenCharacters" => flag(&mut app.ui.hidden_characters), "view.goToPage" => { - match p.get("page").and_then(Value::as_u64) { - Some(n) => crate::canvas::go_to_page(app, (n as usize).saturating_sub(1)), - None => app.ui.dialog = Some(crate::dialogs::Dialog::new("goToPage", json!({}))), + match p.get("page") { + Some(Value::Number(n)) => crate::canvas::go_to_page(app, (n.as_u64().unwrap_or(1) as usize).saturating_sub(1)), + Some(Value::String(s)) => match app.session.resolve_page(s) { + Some(abs) => crate::canvas::go_to_page(app, abs), + None => return Some(Err(format!("no page \"{s}\""))), + }, + _ => app.ui.dialog = Some(crate::dialogs::Dialog::new("goToPage", json!({}))), } Ok(Value::Null) } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index efac14b20..7643d9ca6 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -207,9 +207,14 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.y = 4.0; ui.horizontal(|ui| { ui.label( - egui::RichText::new(format!("Page: {}, {} Layer{}", doc.page_name(cur), doc.layers.len(), if doc.layers.len() == 1 { "" } else { "s" })) - .size(11.0) - .color(t.text_dim), + egui::RichText::new(format!( + "Page: {}, {} Layer{}", + app.session.page_label(cur), + doc.layers.len(), + if doc.layers.len() == 1 { "" } else { "s" } + )) + .size(11.0) + .color(t.text_dim), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if icons::button(ui, "trash", 20.0, false, "Delete Layer").clicked() { diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d10a2e1c6..ec0b67435 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -156,7 +156,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // Drag and drop: pages reorder, parents apply. resp.dnd_set_drag_payload(PageDrag::Page(abs)); if resp.dragged() { - drag_ghost(ui, &doc.page_name(abs), &t); + drag_ghost(ui, &app.session.page_label(abs), &t); } if dragging && resp.contains_pointer() { ui.painter().rect_stroke(pr.expand(1.5), 0.0, Stroke::new(2.0, BADGE), StrokeKind::Outside); @@ -173,7 +173,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); // Page number under the page: white on a blue badge when selected. - let name = doc.page_name(abs); + let name = app.session.page_label(abs); let g = ui.painter().layout_no_wrap(name, crate::theme::semibold(11.0), Color32::WHITE); let c = egui::pos2(pr.center().x, pr.max.y + 11.0); if selected { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 34e4551b8..4f2697b0a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -296,8 +296,10 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // Page. section(ui, "Page", true); let b = block(ui, FIELD_H); - let names: Vec = (0..d.page_count()).map(|i| d.page_name(i)).collect(); - let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| widgets::dropdown_list(ui, &d.page_name(cur), 93.0, &names, Some(cur))); + let names: Vec = (0..d.page_count()).map(|i| app.session.page_label(i)).collect(); + let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { + widgets::dropdown_list(ui, &names[cur.min(names.len().saturating_sub(1))], 93.0, &names, Some(cur)) + }); if let Some(pg) = picked { crate::canvas::go_to_page(app, pg); } @@ -1758,7 +1760,7 @@ pub fn preflight_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); divider(ui); for i in issues { - let page = i.page.and_then(|p| app.session.active().map(|d| d.doc.page_name(p))); + let page = i.page.filter(|_| app.session.active().is_some()).map(|p| app.session.page_label(p)); let text = format!("{} {}", if i.severity == "error" { "⛔" } else { "⚠" }, i.message); let resp = ui.add(egui::Button::new(egui::RichText::new(text).size(11.0)).frame(false)); if let Some(pg) = &page { diff --git a/docs/parity.md b/docs/parity.md index baf448e56..96c0b40dc 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,13 +10,13 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 72 | 23 | 2 | 86% | +| P1 | 97 | 73 | 22 | 2 | 87% | | P2 | 76 | 12 | 13 | 51 | 24% | | Area | Parity | |---|---| | Application shell & workspace | 80% | -| Documents, pages, spreads | 84% | +| Documents, pages, spreads | 85% | | Layers | 95% | | Frames, shapes & paths | 91% | | Transform | 100% | @@ -69,7 +69,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Documents, pages, spreads | Spread rotation (view) | P2 | M | | Documents, pages, spreads | Pasteboard | P0 | D | | Documents, pages, spreads | Sections & numbering | P0 | D | -| Documents, pages, spreads | Page numbering display | P1 | P | +| Documents, pages, spreads | Page numbering display | P1 | D | | Documents, pages, spreads | Smart Text Reflow | P1 | D | | Documents, pages, spreads | Primary text frame | P1 | P | | Documents, pages, spreads | Parent pages | P0 | D |