P: Page Numbering View (section/absolute) and Go to Page by name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 22:21:30 -04:00
co-authored by Claude Opus 5.5
parent 3609862c2e
commit 11212736f0
11 changed files with 101 additions and 22 deletions
+1
View File
@@ -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.
+23
View File
@@ -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::<Vec<_>>(), ["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::<Vec<_>>(), ["1", "2", "3", "4"]);
assert_eq!(s.resolve_page("1"), Some(0));
assert_eq!(s.resolve_page("9"), None);
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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()))?;
+30
View File
@@ -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<usize> {
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<usize> {
let d = self.active()?;
let n = d.doc.page_count();
let s = s.trim();
let pos = |t: &str| t.parse::<usize>().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))
}
+2 -2
View File
@@ -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<String> = app.session.active().map(|d| (0..n).map(|i| d.doc.page_name(i)).collect()).unwrap_or_default();
let names: Vec<String> = (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() {
+18 -4
View File
@@ -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<Value, String> {
)
}
"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<Value, String> {
"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",
+8 -4
View File
@@ -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<Result<Value,
"view.textThreads" => 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)
}
+8 -3
View File
@@ -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() {
+2 -2
View File
@@ -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 {
+5 -3
View File
@@ -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<String> = (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<String> = (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 {
+3 -3
View File
@@ -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 |