diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 9e36b9ae8..ea2200e21 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -160,6 +160,28 @@ impl Automation { json!({ "redone": label, "document": summary(self.doc(&a)?) }) } "command_list" => self.command_list(&a)?, + "page_number" => { + use printcraft_organize::LabelStyle as L; + let n = self.doc(&a)?.info.pages.len(); + let (from, to) = (a.int("from")?, a.int("to")?); + if from < 1 || to < from || to as usize > n { + return Err(ToolError::InvalidArgs(format!("from and to must satisfy 1 ≤ from ≤ to ≤ {n}"))); + } + let style = match a.opt_str("style")?.unwrap_or("decimal") { + "decimal" => L::Decimal, + "upper-roman" => L::UpperRoman, + "lower-roman" => L::LowerRoman, + "upper-alpha" => L::UpperAlpha, + "lower-alpha" => L::LowerAlpha, + "none" => L::None, + other => return Err(ToolError::InvalidArgs(format!("unknown style {other:?}"))), + }; + let prefix = a.opt_str("prefix")?.unwrap_or_default().to_string(); + let first = a.opt_int("start")?.unwrap_or(1).clamp(1, u32::MAX as i64) as u32; + let mut out = self.apply(&a, Edit::NumberPages { from: from as usize - 1, to: to as usize - 1, style, prefix, first })?; + out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::>()); + out + } "bookmark_list" => json!({ "bookmarks": bookmark_tree(&self.doc(&a)?.info.outline, &[]) }), "bookmark_add" => { let page = self.page(&a)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6c3da1614..401d2c76b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -166,6 +166,17 @@ pub fn tools() -> Vec { )), t("bookmark_set_page", "Set a bookmark's page", "Point a bookmark at another page. Undoable.") .with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "page": { "type": "integer", "minimum": 1 } }), &["doc", "path", "page"])), + t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema( + json!({ + "doc": doc(), + "from": { "type": "integer", "minimum": 1 }, + "to": { "type": "integer", "minimum": 1 }, + "style": { "type": "string", "enum": ["decimal", "upper-roman", "lower-roman", "upper-alpha", "lower-alpha", "none"], "description": "Numbering style (default decimal; none = prefix only)." }, + "prefix": { "type": "string" }, + "start": { "type": "integer", "minimum": 1, "description": "Number of the first page in the range (default 1)." }, + }), + &["doc", "from", "to"], + )), t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])), t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 9b0716241..00aab952e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -327,3 +327,16 @@ fn bookmarks_through_tools() { let list = ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"].clone(); assert_eq!((list[0]["title"].as_str(), list[0]["children"][0]["title"].as_str()), (Some("Body"), Some("Details"))); } + +#[test] +fn numbering_pages_through_tools() { + let dir = workdir("labels"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "page_number", json!({ "doc": doc, "from": 1, "to": 1, "style": "upper-roman" })); + assert_eq!(r["labels"], json!(["I", "2", "3"])); + let r = ok(&mut a, "page_number", json!({ "doc": doc, "from": 2, "to": 3, "prefix": "B-", "start": 5 })); + assert_eq!(r["labels"], json!(["I", "B-5", "B-6"])); + assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 2, "to": 4 })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0e18fcc82..7d223a7da 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -154,6 +154,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"), c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), + c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), c("help.about", "About PrintCraft", HELP, None, Nothing, "info"), ]; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 77c514879..da5f73b9c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,6 +16,8 @@ pub mod commands; pub use printcraft_organize::{SplitBy, split_ranges}; /// One file produced by a split: (1-based first page, last page, PDF bytes). +pub use printcraft_organize::LabelStyle; + pub type SplitPart = (usize, usize, Arc>); use std::sync::Arc; @@ -196,6 +198,14 @@ pub enum Edit { path: Vec, page: usize, }, + /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. + NumberPages { + from: usize, + to: usize, + style: printcraft_organize::LabelStyle, + prefix: String, + first: u32, + }, /// Several edits applied as one undoable step (all or nothing). Batch { label: String, @@ -218,6 +228,7 @@ impl Edit { Edit::DeleteBookmark { .. } => "Delete bookmark".into(), Edit::MoveBookmark { .. } => "Move bookmark".into(), Edit::SetBookmarkPage { .. } => "Set bookmark destination".into(), + Edit::NumberPages { .. } => "Number pages".into(), Edit::Batch { label, .. } => label.clone(), } } @@ -236,7 +247,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::RenameBookmark { .. } | Edit::DeleteBookmark { .. } | Edit::MoveBookmark { .. } - | Edit::SetBookmarkPage { .. } => { + | Edit::SetBookmarkPage { .. } + | Edit::NumberPages { .. } => { if p.assemble() { Ok(()) } else { @@ -281,6 +293,7 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit) -> Result<(), EditE printcraft_organize::move_bookmark(doc, from, to_parent, *index)?; } Edit::SetBookmarkPage { path, page } => printcraft_organize::set_bookmark_page(doc, path, *page)?, + Edit::NumberPages { from, to, style, prefix, first } => printcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, Edit::Batch { edits, .. } => { for e in edits { run_edit(doc, e)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 122115c73..e890499c3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -368,3 +368,17 @@ fn bookmark_edits_show_in_the_viewer_undo_and_save() { let id2 = again.open("again.pdf", None, saved, None).unwrap(); assert_eq!(outline_titles(&again.get(id2).unwrap().info.outline), ["Start→1", "Finish→3"]); } + +#[test] +fn number_pages_shows_in_the_viewer_and_undoes() { + let (mut s, id) = session_with(5); + let labels = |s: &Session| s.get(id).unwrap().info.pages.iter().map(|p| p.label.clone()).collect::>(); + use printcraft_organize::LabelStyle; + s.apply(id, Edit::NumberPages { from: 0, to: 1, style: LabelStyle::LowerRoman, prefix: String::new(), first: 1 }).unwrap(); + s.apply(id, Edit::NumberPages { from: 2, to: 4, style: LabelStyle::Decimal, prefix: "§".into(), first: 10 }).unwrap(); + // The inspector (lopdf-based, independent) formats them the same way. + assert_eq!(labels(&s), ["i", "ii", "§10", "§11", "§12"]); + s.undo(id).unwrap(); + assert_eq!(labels(&s), ["i", "ii", "3", "4", "5"]); + assert!(s.apply(id, Edit::NumberPages { from: 3, to: 9, style: LabelStyle::Decimal, prefix: String::new(), first: 1 }).is_err()); +} diff --git a/crates/organize/src/labels.rs b/crates/organize/src/labels.rs new file mode 100644 index 000000000..50b8beca8 --- /dev/null +++ b/crates/organize/src/labels.rs @@ -0,0 +1,254 @@ +//! Page labels (ISO 32000-2 §12.4.2): "i, ii, iii, 1, 2, A-1…", execution plan M4.4. +//! +//! Labels are a number tree of ranges: from page `start` on, use a numbering `style`, a +//! `prefix` and a first number. [`number_pages`] is Acrobat's "Number pages": relabel pages +//! `from..=to`, and keep every later page showing the label it had before. The tree is written +//! back as one flat `/Nums` array, with ranges that merely continue the previous one merged. + +use printcraft_cos::{Dict, Document, Object, PdfString}; + +use crate::{OrganizeError, page_count, pages_root}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LabelStyle { + /// 1, 2, 3 + Decimal, + /// I, II, III + UpperRoman, + /// i, ii, iii + LowerRoman, + /// A … Z, AA … ZZ + UpperAlpha, + /// a … z, aa … zz + LowerAlpha, + /// Prefix only (no number). + None, +} + +impl LabelStyle { + fn from_name(n: &[u8]) -> Self { + match n { + b"D" => Self::Decimal, + b"R" => Self::UpperRoman, + b"r" => Self::LowerRoman, + b"A" => Self::UpperAlpha, + b"a" => Self::LowerAlpha, + _ => Self::None, + } + } + + fn name(self) -> Option<&'static str> { + match self { + Self::Decimal => Some("D"), + Self::UpperRoman => Some("R"), + Self::LowerRoman => Some("r"), + Self::UpperAlpha => Some("A"), + Self::LowerAlpha => Some("a"), + Self::None => None, + } + } + + /// Format `n` (≥ 1) in this style. + pub fn format(self, n: u32) -> String { + match self { + Self::Decimal => n.to_string(), + Self::UpperRoman => roman(n).to_uppercase(), + Self::LowerRoman => roman(n), + // §12.4.2: A to Z, then AA to ZZ, and so on (the letter repeated). + Self::UpperAlpha => alpha(n).to_uppercase(), + Self::LowerAlpha => alpha(n), + Self::None => String::new(), + } + } +} + +fn roman(mut n: u32) -> String { + const T: [(u32, &str); 13] = [ + (1000, "m"), + (900, "cm"), + (500, "d"), + (400, "cd"), + (100, "c"), + (90, "xc"), + (50, "l"), + (40, "xl"), + (10, "x"), + (9, "ix"), + (5, "v"), + (4, "iv"), + (1, "i"), + ]; + let mut s = String::new(); + for (v, r) in T { + while n >= v { + s.push_str(r); + n -= v; + } + } + s +} + +fn alpha(n: u32) -> String { + let n = n.max(1) - 1; + let letter = char::from(b'a' + (n % 26) as u8); + std::iter::repeat_n(letter, (n / 26 + 1).min(1000) as usize).collect() +} + +/// One labelling range. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LabelRange { + /// First page (0-based) of the range. + pub start: usize, + pub style: LabelStyle, + pub prefix: String, + /// The number of the range's first page (`/St`, at least 1). + pub first: u32, +} + +impl LabelRange { + fn label(&self, page: usize) -> String { + let n = self.first.saturating_add((page - self.start) as u32); + format!("{}{}", self.prefix, self.style.format(n)) + } +} + +fn default_range() -> LabelRange { + LabelRange { start: 0, style: LabelStyle::Decimal, prefix: String::new(), first: 1 } +} + +/// The document's label ranges, sorted by start page (empty when it has no `/PageLabels`). +pub fn page_label_ranges(doc: &Document) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let Some(tree) = doc.get(root).as_dict().and_then(|c| c.get(b"PageLabels").cloned()) else { return Vec::new() }; + let mut entries = Vec::new(); + let mut stack = vec![(tree, 0u8)]; + let mut budget = 10_000; + while let Some((node, depth)) = stack.pop() { + budget -= 1; + if budget == 0 || depth > 32 { + break; + } + let node = doc.resolve(&node); + let Some(d) = node.as_dict() else { continue }; + if let Some(nums) = d.get(b"Nums").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) { + for pair in nums.chunks_exact(2) { + let (Some(start), Some(spec)) = (pair[0].as_int(), doc.resolve(&pair[1]).as_dict().cloned()) else { continue }; + if start < 0 { + continue; + } + entries.push(LabelRange { + start: start as usize, + style: spec.name(b"S").map_or(LabelStyle::None, LabelStyle::from_name), + prefix: spec.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_string().map(|s| s.to_text())).unwrap_or_default(), + first: spec.int(b"St").unwrap_or(1).clamp(1, u32::MAX as i64) as u32, + }); + } + } + if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + stack.extend(kids.into_iter().map(|k| (k, depth + 1))); + } + } + entries.sort_by_key(|r| r.start); + entries.dedup_by_key(|r| r.start); + entries +} + +/// Every page's label, as a viewer shows it. +pub fn page_labels(doc: &Document) -> Result, OrganizeError> { + let n = page_count(doc)?; + let ranges = page_label_ranges(doc); + Ok((0..n) + .map(|p| match ranges.iter().rev().find(|r| r.start <= p) { + Some(r) => r.label(p), + // Pages before the first range have no label: viewers show the page number. + None => (p + 1).to_string(), + }) + .collect()) +} + +/// Replace all label ranges (an empty list removes `/PageLabels`). +pub fn set_page_label_ranges(doc: &mut Document, ranges: &[LabelRange]) -> Result<(), OrganizeError> { + pages_root(doc)?; + let root = doc.root().ok_or(OrganizeError::NoPageTree)?; + if ranges.is_empty() { + doc.update_dict(root, |c| { + c.remove(b"PageLabels"); + })?; + return Ok(()); + } + let mut nums = Vec::new(); + for r in ranges { + let mut spec = Dict::new(); + if let Some(s) = r.style.name() { + spec.set(b"S".to_vec(), Object::name(s)); + } + if !r.prefix.is_empty() { + spec.set(b"P".to_vec(), Object::String(PdfString::text(&r.prefix))); + } + if r.first != 1 { + spec.set(b"St".to_vec(), Object::Int(i64::from(r.first))); + } + nums.push(Object::Int(r.start as i64)); + nums.push(Object::Dict(spec)); + } + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(nums)); + let tree = doc.add(Object::Dict(tree)); + doc.update_dict(root, |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree)))?; + Ok(()) +} + +/// Acrobat's "Number pages": label pages `from..=to` (0-based) with `style`, `prefix` and +/// starting number `first`; pages after `to` keep the labels they had. +pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyle, prefix: &str, first: u32) -> Result<(), OrganizeError> { + let n = page_count(doc)?; + if to >= n { + return Err(OrganizeError::NoSuchPage(to)); + } + if from > to { + return Err(OrganizeError::NoSuchPage(from)); + } + let mut old = page_label_ranges(doc); + if old.first().is_none_or(|r| r.start > 0) { + old.insert(0, default_range()); + } + let at = |p: usize| old.iter().rev().find(|r| r.start <= p).cloned().expect("a range starts at 0"); + let mut ranges: Vec = old.iter().filter(|r| r.start < from).cloned().collect(); + ranges.push(LabelRange { start: from, style, prefix: prefix.to_string(), first: first.max(1) }); + if to + 1 < n { + let r = at(to + 1); + let first = r.first.saturating_add((to + 1 - r.start) as u32); + ranges.push(LabelRange { start: to + 1, first, ..r }); + ranges.extend(old.iter().filter(|r| r.start > to + 1).cloned()); + } + // Drop ranges that only continue the previous one. + let mut merged: Vec = Vec::new(); + for r in ranges { + let continues = merged + .last() + .is_some_and(|p| p.style == r.style && p.prefix == r.prefix && u64::from(p.first) + (r.start - p.start) as u64 == u64::from(r.first)); + if !continues { + merged.push(r); + } + } + // Plain 1, 2, 3 everywhere is what a document without labels shows: remove them. + if merged.len() == 1 && merged[0] == default_range() { + merged.clear(); + } + set_page_label_ranges(doc, &merged) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn styles_format_like_the_spec() { + assert_eq!(LabelStyle::LowerRoman.format(14), "xiv"); + assert_eq!(LabelStyle::UpperRoman.format(1994), "MCMXCIV"); + assert_eq!(LabelStyle::UpperAlpha.format(1), "A"); + assert_eq!(LabelStyle::UpperAlpha.format(27), "AA"); + assert_eq!(LabelStyle::LowerAlpha.format(53), "aaa"); + assert_eq!(LabelStyle::None.format(5), ""); + } +} diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index d257d45ff..fa158292c 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -13,10 +13,12 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; mod dedupe; mod import; +mod labels; mod outline; pub use dedupe::dedupe_resources; pub use import::{SplitBy, combine, extract_pages, import_pages, split, split_ranges}; +pub use labels::{LabelRange, LabelStyle, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; pub use outline::{ Bookmark, OutlineError, add_bookmark, bookmarks, delete_bookmark, move_bookmark, rename_bookmark, set_bookmark_open, set_bookmark_page, }; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 47eab051d..f18c0fe0f 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -548,3 +548,33 @@ fn bookmark_edits_keep_unknown_keys_and_touch_few_objects() { assert!(d.get(b[0].obj).as_dict().unwrap().get(b"C").is_some(), "colour kept"); assert_eq!(d.modified_objects(), vec![b[0].obj.num], "a rename rewrites only that item"); } + +// ---- page labels (M4.4) ------------------------------------------------------------------------ + +#[test] +fn number_pages_like_acrobat() { + use crate::LabelStyle::*; + let mut d = doc_a(); + crate::insert_blank_page(&mut d, 3, 300.0, 400.0).unwrap(); + crate::insert_blank_page(&mut d, 4, 300.0, 400.0).unwrap(); + crate::insert_blank_page(&mut d, 5, 300.0, 400.0).unwrap(); + assert_eq!(crate::page_labels(&d).unwrap(), ["1", "2", "3", "4", "5", "6"]); + // Front matter in roman numerals; later pages keep their labels. + crate::number_pages(&mut d, 0, 1, LowerRoman, "", 1).unwrap(); + assert_eq!(crate::page_labels(&d).unwrap(), ["i", "ii", "3", "4", "5", "6"]); + // The body restarts at 1, an appendix gets a prefix. + crate::number_pages(&mut d, 2, 5, Decimal, "", 1).unwrap(); + crate::number_pages(&mut d, 4, 5, Decimal, "A-", 1).unwrap(); + assert_eq!(crate::page_labels(&d).unwrap(), ["i", "ii", "1", "2", "A-1", "A-2"]); + assert_eq!(crate::page_label_ranges(&d).len(), 3, "continuations are merged"); + // Relabelling the middle keeps the pages after it. + crate::number_pages(&mut d, 3, 3, UpperAlpha, "Fig ", 3).unwrap(); + assert_eq!(crate::page_labels(&d).unwrap(), ["i", "ii", "1", "Fig C", "A-1", "A-2"]); + assert!(crate::number_pages(&mut d, 4, 9, Decimal, "", 1).is_err()); + // Survives a save and reopen; back to plain numbers removes /PageLabels. + let back = full_roundtrip(&d); + assert_eq!(crate::page_labels(&back).unwrap(), ["i", "ii", "1", "Fig C", "A-1", "A-2"]); + crate::number_pages(&mut d, 0, 5, Decimal, "", 1).unwrap(); + assert!(crate::page_label_ranges(&d).is_empty()); + assert!(d.get(d.root().unwrap()).as_dict().unwrap().get(b"PageLabels").is_none()); +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index a1642b46e..f5c9da30f 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -48,6 +48,15 @@ impl PrintCraftApp { } "file.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Description)), "protect.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Security)), + "page.number" => { + if let Some(i) = active { + let v = &self.views[i]; + let pages: Vec = if v.selected.is_empty() { vec![v.current] } else { v.selected.iter().copied().collect() }; + let (lo, hi) = (pages.iter().min().copied().unwrap_or(0), pages.iter().max().copied().unwrap_or(0)); + self.number_draft = crate::NumberDraft { from: lo + 1, to: hi + 1, ..self.number_draft.clone() }; + } + self.dialog = Some(Dialog::NumberPages); + } "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), "edit.undo" => self.undo(), "edit.redo" => self.redo(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f439185bf..c60eb6fc5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -27,6 +27,8 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let mut split_now: Option = None; let mut split_ready: Option = None; let mut recover: Option = None; + let mut number_now: Option = None; + let mut apply_number = false; let t = Tokens::get(ctx); let mut close = false; let mut next = dialog; @@ -191,6 +193,68 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { split_ready = Some(by); } } + Dialog::NumberPages => { + use printcraft_engine::LabelStyle as L; + ui.label(egui::RichText::new("Number pages").font(theme::semibold(18.0))); + ui.add_space(8.0); + let Some((_, id)) = app.active_ids() else { return }; + let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1); + let d = &mut app.number_draft; + d.to = d.to.clamp(1, n); + d.from = d.from.clamp(1, d.to); + // Editable values get a visible border (the dialog and field fills are alike). + let boxed = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui) -> egui::Response| { + egui::Frame::new() + .stroke(egui::Stroke::new(1.0, t.border)) + .corner_radius(egui::CornerRadius::same(5)) + .inner_margin(egui::Margin::symmetric(4, 1)) + .show(ui, |ui| add(ui)) + .inner + }; + egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label("Pages"); + ui.horizontal(|ui| { + boxed(ui, &mut |ui| ui.add(egui::DragValue::new(&mut d.from).range(1..=n))); + ui.label("to"); + boxed(ui, &mut |ui| ui.add(egui::DragValue::new(&mut d.to).range(1..=n))); + ui.label(egui::RichText::new(format!("of {n}")).color(t.text_muted)); + }); + ui.end_row(); + ui.label("Style"); + let styles = [ + (L::Decimal, "1, 2, 3"), + (L::LowerRoman, "i, ii, iii"), + (L::UpperRoman, "I, II, III"), + (L::LowerAlpha, "a, b, c"), + (L::UpperAlpha, "A, B, C"), + (L::None, "None (prefix only)"), + ]; + let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l); + egui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { + for (s, l) in styles { + ui.selectable_value(&mut d.style, s, l); + } + }); + ui.end_row(); + let l = ui.label("Prefix"); + boxed(ui, &mut |ui| ui.add(egui::TextEdit::singleline(&mut d.prefix).desired_width(160.0).frame(egui::Frame::NONE))) + .labelled_by(l.id); + ui.end_row(); + ui.label("Start"); + boxed(ui, &mut |ui| ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999))); + ui.end_row(); + }); + d.to = d.to.max(d.from); + let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k)); + ui.add_space(8.0); + let preview = if d.from == d.to { + label(d.start) + } else { + format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32)) + }; + ui.label(egui::RichText::new(format!("Labels: {preview}. Later pages keep their labels.")).color(t.text_muted)); + number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); + } Dialog::Recovery => { ui.horizontal(|ui| { ui.add(crate::icons::image("clock-3", 22.0, t.accent)); @@ -276,6 +340,14 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { recover = Some(false); close = true; } + } else if dialog == Dialog::NumberPages { + if widgets::pill_button(ui, "OK", true).clicked() { + apply_number = true; + close = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + close = true; + } } else if dialog == Dialog::Split { if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, "Split", true)).inner.clicked() { split_now = split_ready.clone(); @@ -305,6 +377,9 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { app.discard_recovered(&keys); } } + if apply_number && let Some(edit) = number_now { + app.apply_edit(edit); + } if let Some(by) = split_now { app.split_active(&by); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2270a371f..daa595f2b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -70,6 +70,8 @@ pub enum Dialog { About, Shortcuts, Split, + /// Pages ▸ Number pages… (page labels). + NumberPages, /// Documents from a session that ended unexpectedly. Recovery, } @@ -153,6 +155,18 @@ pub struct PrintCraftApp { control: Option, /// A bookmark being renamed in the Bookmarks panel: (path, text so far). pub bookmark_rename: Option<(Vec, String)>, + /// Number pages dialog settings (1-based pages). + pub number_draft: NumberDraft, +} + +/// Settings for the Number pages dialog. +#[derive(Clone, Debug, PartialEq)] +pub struct NumberDraft { + pub from: usize, + pub to: usize, + pub style: printcraft_engine::LabelStyle, + pub prefix: String, + pub start: u32, } impl Default for PrintCraftApp { @@ -201,6 +215,7 @@ impl PrintCraftApp { fonts_ready: false, control: None, bookmark_rename: None, + number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, } } @@ -481,6 +496,11 @@ impl PrintCraftApp { "advanced" => Some(Dialog::Properties(PropsTab::Advanced)), "shortcuts" => Some(Dialog::Shortcuts), "split" => Some(Dialog::Split), + "number-pages" => { + // Same path as the menu, so the page range is seeded. + self.execute("page.number"); + Some(Dialog::NumberPages) + } "none" => None, _ => Some(Dialog::About), } diff --git a/crates/ui-egui/tests/bookmarks.rs b/crates/ui-egui/tests/bookmarks.rs index 5a16e19d9..20079d2fe 100644 --- a/crates/ui-egui/tests/bookmarks.rs +++ b/crates/ui-egui/tests/bookmarks.rs @@ -107,3 +107,23 @@ fn escape_cancels_a_rename() { h.run_steps(3); assert_eq!(outline(&h)[0].0, "Keep"); } + +#[test] +fn number_pages_dialog_labels_the_selected_pages() { + let mut h = harness(); + h.state_mut().views[0].select_pages(&[0, 1]); + assert!(h.state_mut().execute("page.number")); + h.run_steps(3); + h.get_by_label("Number pages"); + h.get_by_value("1, 2, 3").click(); // the style menu + h.run_steps(2); + h.get_by_label("i, ii, iii").click(); + h.run_steps(2); + h.get_by_label_contains("Labels: i, ii"); + h.get_by_label("OK").click(); + h.run_steps(3); + let id = h.state().views[0].id; + let labels: Vec = h.state().session.get(id).unwrap().info.pages.iter().map(|p| p.label.clone()).collect(); + assert_eq!(labels, ["i", "ii", "3"]); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Number pages")); +}