mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
6089e58051
commit
6616ccdead
@@ -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)?;
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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), "");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user