mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 06:51:50 -06:00
* M4: lay out Organize pages as Acrobat's panel Organize pages gets Acrobat's panel: Pages selected (typed, or by kind from its menu: all, even, odd, landscape, portrait); page options as a row of icon buttons (rotate both ways, delete, extract) with Insert (a blank page, pages from a file, duplicates), Replace, Split and Bates numbering; page setup (boxes, crop, transitions, templates, printing); and page labels and properties. Items PdfCraft doesn't have yet are listed muted and say they are planned. The panel shows whenever the grid does, however Organize was opened (the tool, the command, --organize), and its Close ends Organize. The grid's own toolbar keeps the selection count, moving pages, their size, Save pages and Close; the page actions moved into the panel with the same labels, and queue on the view as before. Two new strings are translated in every catalogue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M4: Pages selected shows the current page when nothing is selected The empty field showed the example "1, 12, 16-18" in grey, which read as a selection. It now shows the page the actions apply to, as Acrobat does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(i18n): refresh the catalogue hashes in ATTRIBUTION.toml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M4: leaving Organize goes back to the workspace's panel Opening a page from the grid (or the grid's Close, or the panel's Close) always showed All tools, so Organize opened from the Edit panel dropped the user out of Edit. It now goes back to the workspace's own panel: Edit under the Edit tab, Convert's and E-Sign's under theirs, All tools otherwise. Back still goes to All tools. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M4: box the Organize panel's page edit (clippy large_enum_variant) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M4: parity notes for the Organize pages panel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refresh catalog hashes after merging main --------- Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
149 lines
5.7 KiB
Rust
149 lines
5.7 KiB
Rust
//! The Organize Pages selection, as an editable list of physical page numbers.
|
|
use std::collections::BTreeSet;
|
|
|
|
use pdfcraft_engine::print::PrintError;
|
|
|
|
#[derive(Default)]
|
|
pub(crate) struct PageSelectionInput {
|
|
text: String,
|
|
selection: BTreeSet<usize>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
/// Blank clears the explicit selection. Repeated/reversed ranges become a sorted set.
|
|
/// Bound both the input and expansion work, including repeated overlapping ranges.
|
|
pub(crate) fn parse(text: &str, count: usize) -> Result<Vec<usize>, PrintError> {
|
|
if text.len() > 16_384 {
|
|
return Err(PrintError::NotAPage(text.chars().take(80).collect()));
|
|
}
|
|
if text.trim().is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut pages = BTreeSet::new();
|
|
let mut budget = 1_000_000usize;
|
|
let number = |token: &str| {
|
|
let n = token.trim().parse::<usize>().map_err(|_| PrintError::NotAPage(token.into()))?;
|
|
if n == 0 || n > count { Err(PrintError::OutOfRange(n, count)) } else { Ok(n - 1) }
|
|
};
|
|
for part in text.split([',', ';']).map(str::trim) {
|
|
let (a, b) = if let Some((a, b)) = part.split_once('-') {
|
|
(number(a)?, number(b)?)
|
|
} else {
|
|
let p = number(part)?;
|
|
(p, p)
|
|
};
|
|
let (a, b) = (a.min(b), a.max(b));
|
|
let size = b.saturating_sub(a).saturating_add(1);
|
|
budget = budget.checked_sub(size).ok_or_else(|| PrintError::NotAPage(part.into()))?;
|
|
pages.extend(a..=b);
|
|
}
|
|
Ok(pages.into_iter().collect())
|
|
}
|
|
|
|
fn formatted(pages: &BTreeSet<usize>) -> String {
|
|
let mut parts = Vec::new();
|
|
let mut it = pages.iter().copied().peekable();
|
|
while let Some(first) = it.next() {
|
|
let mut last = first;
|
|
while it.peek().is_some_and(|p| last.checked_add(1) == Some(*p)) {
|
|
if let Some(next) = it.next() {
|
|
last = next;
|
|
}
|
|
}
|
|
let first = first.saturating_add(1);
|
|
let last = last.saturating_add(1);
|
|
parts.push(if first == last { first.to_string() } else { format!("{first}-{last}") });
|
|
}
|
|
parts.join(", ")
|
|
}
|
|
|
|
impl PageSelectionInput {
|
|
/// The field, as Acrobat's Pages selected: typed pages apply on Enter or when the field loses
|
|
/// focus, and the chevron opens `menu` (pages by kind), whose choice is returned the same way.
|
|
/// With nothing selected it shows the current page (0-based `current`), which actions apply to.
|
|
pub(crate) fn show(
|
|
&mut self,
|
|
ui: &mut egui::Ui,
|
|
selected: &BTreeSet<usize>,
|
|
count: usize,
|
|
current: usize,
|
|
menu: impl FnOnce(&mut egui::Ui) -> Option<Vec<usize>>,
|
|
) -> Option<Vec<usize>> {
|
|
if self.selection != *selected {
|
|
self.selection.clone_from(selected);
|
|
self.text = formatted(selected);
|
|
self.error = None;
|
|
}
|
|
let mut commit = false;
|
|
let id = ui.make_persistent_id("organize-page-range");
|
|
// Egui can surrender focus before this frame's widgets run on Escape.
|
|
let cancel = ui.is_enabled()
|
|
&& ui.memory(|m| m.has_focus(id) || m.had_focus_last_frame(id))
|
|
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
|
|
if cancel {
|
|
self.text = formatted(selected);
|
|
self.error = None;
|
|
ui.memory_mut(|m| m.surrender_focus(id));
|
|
}
|
|
let mut chosen = None;
|
|
ui.horizontal(|ui| {
|
|
let label = tl!("Selected pages");
|
|
let width = (ui.available_width() - 32.0).max(40.0);
|
|
let field = ui.add(
|
|
egui::TextEdit::singleline(&mut self.text)
|
|
.id(id)
|
|
.desired_width(width)
|
|
.char_limit(16_384)
|
|
.hint_text(current.saturating_add(1).to_string()),
|
|
);
|
|
field.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, ui.is_enabled(), label));
|
|
commit = ui.is_enabled() && !cancel && field.lost_focus();
|
|
let pick = crate::icons::button(ui, "chevron-down", 26.0, false, tl!("Select pages"));
|
|
chosen = egui::Popup::menu(&pick).show(menu).and_then(|r| r.inner);
|
|
});
|
|
if let Some(pages) = chosen {
|
|
self.selection = pages.iter().copied().collect();
|
|
self.text = formatted(&self.selection);
|
|
self.error = None;
|
|
return Some(pages);
|
|
}
|
|
if cancel {
|
|
self.text = formatted(selected);
|
|
self.error = None;
|
|
} else if commit {
|
|
match parse(&self.text, count) {
|
|
Ok(pages) => {
|
|
self.selection = pages.iter().copied().collect();
|
|
self.text = formatted(&self.selection);
|
|
self.error = None;
|
|
return Some(pages);
|
|
}
|
|
Err(e) => self.error = Some(e.said(&|s| crate::i18n::t(s).to_string())),
|
|
}
|
|
}
|
|
if let Some(error) = &self.error {
|
|
ui.label(error);
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn selection_ranges_are_sorted_unique_and_bounded() {
|
|
let pages = parse("1,12,16-18,89,92,93,97,99", 100).unwrap();
|
|
assert_eq!(pages, [0, 11, 15, 16, 17, 88, 91, 92, 96, 98]);
|
|
assert_eq!(formatted(&pages.into_iter().collect()), "1, 12, 16-18, 89, 92-93, 97, 99");
|
|
assert_eq!(parse("3-1,2", 3).unwrap(), [0, 1, 2]);
|
|
assert!(parse(" ", 0).unwrap().is_empty());
|
|
for text in ["0", "101", "1,", "x", "1--3", "18446744073709551616", "1-100000000"] {
|
|
assert!(parse(text, 100).is_err(), "{text}");
|
|
}
|
|
assert!(parse(&"1,".repeat(10_000), 100).is_err());
|
|
assert!(parse("1-1000000,1-1000000", 1_000_000).is_err());
|
|
}
|
|
}
|