M4.4: page labels (Number pages): organize label ranges with Acrobat semantics (later pages keep labels, continuations merged), undoable NumberPages edit, page_number tool, Number pages dialog (Pages menu)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 14:02:40 -04:00
co-authored by Claude Opus 5.5
parent 6089e58051
commit 6616ccdead
13 changed files with 485 additions and 1 deletions
+22
View File
@@ -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::<Vec<_>>());
out
}
"bookmark_list" => json!({ "bookmarks": bookmark_tree(&self.doc(&a)?.info.outline, &[]) }),
"bookmark_add" => {
let page = self.page(&a)?;
+11
View File
@@ -166,6 +166,17 @@ pub fn tools() -> Vec<ToolDef> {
)),
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.")
+13
View File
@@ -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(_))));
}
+1
View File
@@ -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"),
];
+14 -1
View File
@@ -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<Vec<u8>>);
use std::sync::Arc;
@@ -196,6 +198,14 @@ pub enum Edit {
path: Vec<usize>,
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)?;
+14
View File
@@ -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::<Vec<_>>();
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());
}
+254
View File
@@ -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<LabelRange> {
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<Vec<String>, 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<LabelRange> = 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<LabelRange> = 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), "");
}
}
+2
View File
@@ -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,
};
+30
View File
@@ -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());
}
+9
View File
@@ -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<usize> = 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(),
+75
View File
@@ -27,6 +27,8 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
let mut split_now: Option<printcraft_engine::SplitBy> = None;
let mut split_ready: Option<printcraft_engine::SplitBy> = None;
let mut recover: Option<bool> = None;
let mut number_now: Option<Edit> = 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);
}
+20
View File
@@ -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<control::Control>,
/// A bookmark being renamed in the Bookmarks panel: (path, text so far).
pub bookmark_rename: Option<(Vec<usize>, 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),
}
+20
View File
@@ -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<String> = 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"));
}