Files
pdfcraft/crates/ui-egui/src/dialogs.rs
T
FredApps 2f2d69be17 M10: merge main into feat/scan-clean; classify mixed scan pages in one pass
Merge origin/main. Conflicts: docs/changelog headers keep both histories;
edit/run.rs keeps EnhanceScan beside main's run_edit_page_image; optimize
images.rs keeps main's scaled() and the crate-visible jpeg(); the German
strings in ui-egui/src/i18n/de.tsv go with that file, which main moved to
craft-i18n. decode_samples now takes main's ImageBudget; a scan clean-up
run shares one sized by the file.

Review (CodeRabbit, scan_clean.rs): a page whose own images and a
scan-holding Form only together cover it now cleans the images and reports
the Form; a page whose images alone cover it reports its Form too. New
regression fails on the old classification.
2026-11-05 12:14:01 +01:00

2054 lines
90 KiB
Rust

//! Modal dialogs: Document Properties, Keyboard Shortcuts, About (with the Contributors and Models credits).
use egui::{Align, Layout};
use crate::theme::{self, Tokens};
use pdfcraft_engine::Edit;
use crate::{CloseRequest, Dialog, PdfCraftApp, PropsTab, panels::human_size, widgets};
pub(super) mod appearance;
pub mod bookmark_styles_ui;
pub(super) mod comment_props;
pub(super) mod credits;
pub(super) mod marks_ui;
pub(super) mod pageboxes;
pub(super) mod palette;
pub(super) mod preferences;
mod prompts;
pub(super) mod protect;
pub(super) mod standards_ui;
pub(super) mod word_count_ui;
#[cfg(test)]
mod tests;
pub(crate) use prompts::{PromptButton, save_prompt_key};
use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, save_prompt, unlock};
const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"];
/// What the dialog's widgets asked for this frame; acted on after the modal is drawn.
#[derive(Default)]
struct Pending {
apply: bool,
split_now: Option<crate::SplitPlan>,
split_ready: Option<crate::SplitPlan>,
extract_now: bool,
rotate_now: bool,
link_now: Option<Edit>,
recover: Option<bool>,
number_now: Option<Edit>,
apply_number: bool,
link_command: Option<&'static str>,
protect_now: bool,
boxes_now: bool,
marks_now: bool,
export_now: bool,
props_now: bool,
field_props_now: bool,
bulk_field_props_now: bool,
redact_now: Option<Dialog>,
import_words: bool,
print_go: bool,
revert_now: bool,
overwrite_now: bool,
overwrite_elsewhere: bool,
summarize_now: bool,
optimize_now: bool,
duplicate_now: Option<Edit>,
page_props_now: Option<(pdfcraft_engine::DocId, u64, Edit)>,
replace_now: bool,
open_revision: Option<usize>,
a11y_now: bool,
ocr_now: bool,
scan_clean_now: bool,
compare_now: bool,
images_now: bool,
stamp_now: bool,
alt_now: bool,
close: bool,
/// The dialog to switch to, when a widget asked for another one.
next: Option<Dialog>,
}
pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
// The page sets hover cursors from the raw pointer position (form fields, links, comments)
// before any dialog is drawn, and nothing under a modal's backdrop takes clicks. Start from
// the arrow; the modal's own widgets set theirs as they are drawn.
if app.modal_open() || ctx.memory(|m| m.top_modal_layer().is_some()) {
ctx.set_cursor_icon(egui::CursorIcon::Default);
}
dialogs(app, ctx);
// Last, so it sits above Document Properties when Security's Unlock… asked for it.
unlock(app, ctx);
}
/// Draw the active modal dialog and apply the action the user chose.
fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
password(app, ctx);
save_prompt(app, ctx);
link_prompt(app, ctx);
remote_link_prompt(app, ctx);
full_screen_prompt(app, ctx);
crate::updates::dialog(app, ctx);
if app.dialog != Some(Dialog::WordCount) {
app.word_count_draft.cancel();
}
if app.dialog != Some(Dialog::FieldTable) {
app.field_table_draft = None;
}
// A movable window, not this modal: the page under it shows the table as it changes.
if app.dialog == Some(Dialog::FieldTable) {
crate::field_table_ui::show(app, ctx);
return;
}
let Some(dialog) = app.dialog else {
app.props_draft = None;
app.view_draft = None;
app.custom_draft = None;
return;
};
// Properties ▸ Fonts shows the fonts as they are now, after any content edits.
if let (Dialog::Properties(PropsTab::Fonts), Some((_, id))) = (dialog, app.active_ids()) {
app.session.refresh_fonts(id);
}
// Properties ▸ Space Audit walks the whole file, so it is measured once per edit, not per frame.
if dialog == Dialog::Properties(PropsTab::SpaceAudit) {
app.refresh_space_audit();
}
// Seed the editable Description fields from the document when the dialog opens.
if let (Dialog::Properties(_), Some((_, id))) = (dialog, app.active_ids())
&& app.props_draft.as_ref().is_none_or(|(d, _)| *d != id)
&& let Some(doc) = app.session.get(id)
{
app.props_draft = Some((id, INFO_KEYS.map(|k| doc.info_value(k).unwrap_or_default())));
app.view_draft = Some((id, doc.initial_view()));
app.custom_draft = Some((id, crate::CustomDraft { props: doc.custom_info(), ..Default::default() }));
}
let mut req = Pending::default();
let modal = widgets::show_modal(ctx, "dialog", |ui| {
ui.set_width(match dialog {
Dialog::Properties(_) => 640.0,
Dialog::Print => 820.0,
Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
Dialog::Preferences => 610.0,
Dialog::About => 820.0,
Dialog::PinTool => 540.0,
_ => 520.0,
});
if dialog_body(ui, app, dialog, &mut req) {
dialog_footer(ui, app, dialog, &mut req);
}
});
finish_dialog(app, dialog, modal.should_close(), &mut req);
}
/// The dialog's content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
match dialog {
Dialog::WordCount => {
req.close = crate::word_count_ui::body(ui, app);
}
Dialog::CreateImages => {
let (go, cancel) = crate::create_ui::image_import_body(ui, app);
req.images_now = go;
req.close = go || cancel;
return false;
}
Dialog::Properties(tab) => {
if !dialog_properties(ui, app, req, tab) {
return false;
}
}
Dialog::Split => {
if !dialog_split(ui, app, req) {
return false;
}
}
Dialog::ReplacePages => {
if !dialog_replace_pages(ui, app, req) {
return false;
}
}
Dialog::RedactPages => {
let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
let (ok, cancel) = crate::redact_ui::pages_body(ui, &mut app.redact_pages_draft, n, &t);
if ok {
req.redact_now = Some(dialog);
}
req.close = ok || cancel;
return false;
}
Dialog::RedactSearch => {
let (go, cancel, import) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t);
if go {
req.redact_now = Some(dialog);
}
req.import_words = import;
req.close = cancel;
return false;
}
Dialog::RedactProps => {
if !dialog_redact_props(ui, app, req) {
return false;
}
}
Dialog::RedactApply => {
let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks());
let (ok, cancel) = crate::redact_ui::apply_body(ui, marks, &t);
if ok {
req.redact_now = Some(dialog);
}
req.close = ok || cancel;
return false;
}
Dialog::LinkProps => {
let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
let Some(d) = app.link_draft.as_mut() else {
req.close = true;
return false;
};
let (ok, cancel) = crate::link_ui::body(ui, d, pages, &t);
if ok {
req.link_now = Some(crate::link_ui::edit_for(d));
}
req.close = ok || cancel;
return false;
}
Dialog::Extract => {
if !dialog_extract(ui, app, req) {
return false;
}
}
Dialog::RotatePages => {
if !dialog_rotate_pages(ui, app, req) {
return false;
}
}
Dialog::PageProps => {
let Some(d) = app.page_props_draft.as_mut() else {
req.close = true;
return false;
};
let (ok, cancel) = crate::organize_panel::page_props_body(ui, d);
// An order every page already has adds no undo step.
if ok && let Some(order) = d.tab_order.filter(|o| d.before != Some(*o)) {
req.page_props_now = Some((d.doc, d.generation, Edit::SetTabOrder { pages: d.pages.clone(), order }));
}
req.close = ok || cancel;
return false;
}
Dialog::DuplicateField => {
if !dialog_duplicate_field(ui, app, req) {
return false;
}
}
// Never reached: `dialogs` shows this movable window above and returns before the modal.
// The arm keeps the match exhaustive.
Dialog::FieldTable => req.close = true,
Dialog::Optimize => {
let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t);
req.optimize_now = ok;
req.close = ok || cancel;
if std::mem::take(&mut app.optimize_draft.audit) {
app.refresh_space_audit();
req.next = Some(Dialog::AuditSpace);
}
return false;
}
Dialog::CertificateViewer => {
if !dialog_certificate_viewer(ui, app, req) {
return false;
}
}
Dialog::AuditSpace => {
if crate::optimize_ui::audit_body(ui, &app.space_audit, &t) {
req.next = Some(Dialog::Optimize);
}
return false;
}
Dialog::Sign => {
if crate::sign_ui::dialog(ui, app, &t) {
app.sign_draft = None;
req.close = true;
}
return false;
}
Dialog::SummarizeComments => {
if !dialog_summarize_comments(ui, app, req) {
return false;
}
}
Dialog::Revert => {
if !dialog_revert(ui, app, req) {
return false;
}
}
Dialog::OverwriteChanged => {
if !dialog_overwrite_changed(ui, app, req) {
return false;
}
}
Dialog::Print => {
if !dialog_print(ui, app, req) {
return false;
}
}
Dialog::RemoveHidden => {
let (ok, cancel) = crate::redact_ui::hidden_body(ui, &mut app.hidden_draft, &t);
if ok {
req.redact_now = Some(dialog);
}
req.close = ok || cancel;
return false;
}
Dialog::Sanitize => {
let (ok, cancel) = crate::redact_ui::sanitize_body(ui, &t);
if ok {
req.redact_now = Some(dialog);
}
req.close = ok || cancel;
return false;
}
Dialog::FieldProps => {
let Some(d) = app.field_props.as_mut() else {
req.close = true;
return false;
};
let (go, cancel) = crate::prepare::body(ui, d, &t);
req.apply = go;
req.field_props_now = req.apply;
req.close = req.apply || cancel;
return false;
}
Dialog::BulkFieldProps => {
let Some(d) = app.bulk_field_props.as_mut() else {
req.close = true;
return false;
};
let (go, cancel) = crate::bulk_fields::body(ui, d, &t);
req.apply = go;
req.bulk_field_props_now = req.apply;
req.close = cancel;
return false;
}
Dialog::CommentProps => {
let (go, cancel) = crate::comment_props::body(ui, app, &t);
req.apply = go;
req.props_now = req.apply;
req.close = req.apply || cancel;
return false;
}
Dialog::Signature => {
if !dialog_signature(ui, app, req) {
return false;
}
}
Dialog::AltText => {
let (save, cancel) = crate::a11y_ui::alt_body(ui, app, &t);
req.alt_now = save;
req.close = save || cancel;
return false;
}
Dialog::CreateStamp => {
let (save, cancel) = crate::stamps_ui::create_body(ui, app, &t);
req.stamp_now = save;
req.close = save || cancel;
return false;
}
Dialog::PdfA => {
req.close = crate::standards_ui::body(ui, app, &t);
return false;
}
Dialog::ActionWizard => {
ui.set_width(620.0);
req.close = crate::actions_ui::body(ui, app, &t);
return false;
}
Dialog::CompareFiles => {
let (go, cancel) = crate::compare_ui::body(ui, app, &t);
req.compare_now = go;
req.close = go || cancel;
return false;
}
Dialog::JsConsole => {
ui.set_width(640.0);
req.close = crate::js_ui::console_body(ui, app, &t);
return false;
}
Dialog::DocumentJs => {
ui.set_width(640.0);
req.close = crate::js_ui::document_js_body(ui, app, &t);
return false;
}
Dialog::Preferences => {
if !dialog_preferences(ui, app, req) {
return false;
}
}
Dialog::RecognizeText => {
let (go, cancel) = crate::ocr_ui::body(ui, app, &t);
req.ocr_now = go;
req.close = go || cancel;
return false;
}
Dialog::EnhanceScan => {
let (go, cancel) = crate::scan_clean_ui::body(ui, app, &t);
req.scan_clean_now = go;
req.close = go || cancel;
return false;
}
Dialog::AccessibilityOptions => {
let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t);
req.a11y_now = start;
req.close = start || cancel;
return false;
}
Dialog::Export(kind) => {
let (go, cancel) = crate::export_ui::body(ui, app, &t, kind);
req.apply = go;
req.export_now = req.apply;
req.close = req.apply || cancel;
return false;
}
Dialog::Marks(kind) => {
ui.set_width(720.0);
let (go, cancel) = crate::marks_ui::body(ui, app, &t, kind);
req.apply = go;
req.marks_now = req.apply;
req.close = req.apply || cancel;
return false;
}
Dialog::PageBoxes => {
let (go, cancel) = crate::pageboxes::body(ui, app, &t);
req.apply = go;
req.boxes_now = req.apply;
req.close = req.apply || cancel;
return false;
}
Dialog::Protect => {
let (go, cancel) = crate::protect::body(ui, app, &t);
req.apply = go;
req.protect_now = req.apply;
req.close = req.apply || cancel;
return false;
}
Dialog::BookmarksFromTextStyles => {
req.close = crate::bookmark_styles_ui::body(ui, app, &t);
return false;
}
Dialog::NumberPages => {
if !dialog_number_pages(ui, app, req) {
return false;
}
}
Dialog::Recovery => {
if !dialog_recovery(ui, app) {
return false;
}
}
Dialog::Shortcuts => {
if !dialog_shortcuts(ui) {
return false;
}
}
Dialog::About => {
if !dialog_about(ui, req) {
return false;
}
}
Dialog::PinTool => {
if !dialog_pin_tool(ui, app, req) {
return false;
}
}
}
true
}
/// `Dialog::Properties(tab)` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_properties(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending, tab: PropsTab) -> bool {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!("Document Properties")).font(theme::semibold(18.0)));
ui.add_space(8.0);
ui.horizontal_wrapped(|ui| {
for (tb, label) in [
(PropsTab::Description, tl!("Description")),
(PropsTab::InitialView, tl!("Initial View")),
(PropsTab::Security, tl!("Security")),
(PropsTab::Fonts, tl!("Fonts")),
(PropsTab::Custom, tl!("Custom")),
(PropsTab::Advanced, tl!("Advanced")),
(PropsTab::SpaceAudit, tl!("Space Audit")),
] {
if widgets::mode_tab(ui, label, tab == tb).clicked() {
req.next = Some(Dialog::Properties(tb));
}
}
});
ui.separator();
let units = app.units;
let Some((_, id)) = app.active_ids() else { return false };
let Some(doc) = app.session.get(id) else { return false };
let i = &doc.info;
// Description shows plain values among text fields; they start where the fields' text does.
let editable_info = tab == PropsTab::Description && doc.allows_modification() && app.props_draft.is_some();
let inset = if editable_info { widgets::FIELD_TEXT_INSET } else { 0.0 };
let row = |ui: &mut egui::Ui, k: &str, v: String| {
// Row labels are UI chrome; values are document data and stay as they are.
ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
ui.horizontal(|ui| {
ui.add_space(inset);
ui.add(egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap());
});
ui.end_row();
};
// A file name or path is read left to right even in an RTL UI: the extension
// stays at its visual end, as in the tab strip.
let path_row = |ui: &mut egui::Ui, k: &str, v: String| {
ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
ui.scope(|ui| {
ui.set_max_width(420.0);
// Align path text with editable fields while keeping its LTR text order.
ui.add_space(inset);
if v.is_empty() {
ui.label(egui::RichText::new("—").color(t.text_faint));
} else {
crate::widgets::label_ltr(ui, v, crate::theme::regular(13.0), t.text);
}
});
ui.end_row();
};
egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| {
if tab == PropsTab::SpaceAudit {
ui.add_space(4.0);
crate::optimize_ui::audit_table(ui, &app.space_audit, &t);
return;
}
egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab {
PropsTab::Description => {
path_row(ui, "File", doc.name.clone());
match app.props_draft.as_mut() {
Some((_, draft)) if editable_info => {
for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) {
let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
// Subject and Keywords are often a sentence or a list: they wrap and grow
// instead of hiding most of the value past the field's edge.
let field = if matches!(*k, "Subject" | "Keywords") { widgets::block(v).desired_rows(1) } else { widgets::line(v) };
ui.add(field.desired_width(f32::INFINITY).background_color(t.field).id_salt(("info", *k))).labelled_by(l.id);
ui.end_row();
}
}
_ => {
row(ui, "Title", i.title.clone().unwrap_or_default());
row(ui, "Author", i.author.clone().unwrap_or_default());
row(ui, "Subject", i.subject.clone().unwrap_or_default());
row(ui, "Keywords", i.keywords.clone().unwrap_or_default());
}
}
row(ui, "Application", i.creator.clone().unwrap_or_default());
row(ui, "PDF producer", i.producer.clone().unwrap_or_default());
}
PropsTab::InitialView => {
use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N};
let editable = doc.allows_modification();
let pages = i.pages.len();
let Some((_, v)) = app.view_draft.as_mut() else { return };
ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5)));
ui.end_row();
let label = ui.label(tl!("Navigation tab"));
ui.add_enabled_ui(editable, |ui| {
craft_ui::ComboBox::from_id_salt("iv-nav")
.selected_text(tl!(format!("{:?}", v.navigation)
.replace("PageOnly", "Page Only")
.replace("Pages", "Pages Panel and Page")
.as_str()))
.show_ui(ui, |ui| {
for (n, l) in [
(N::PageOnly, tl!("Page Only")),
(N::Bookmarks, tl!("Bookmarks Panel and Page")),
(N::Pages, tl!("Pages Panel and Page")),
(N::Attachments, tl!("Attachments Panel and Page")),
(N::Layers, tl!("Layers Panel and Page")),
] {
ui.selectable_value(&mut v.navigation, n, l);
}
})
.response
.labelled_by(label.id);
});
ui.end_row();
let label = ui.label(tl!("Page layout"));
ui.add_enabled_ui(editable, |ui| {
let names = [
(L::Default, tl!("Default")),
(L::SinglePage, tl!("Single Page")),
(L::SinglePageContinuous, tl!("Single Page Continuous")),
(L::TwoUp, tl!("Two-Up (Facing)")),
(L::TwoUpContinuous, tl!("Two-Up Continuous (Facing)")),
(L::TwoUpCoverPage, tl!("Two-Up (Cover Page)")),
(L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")),
];
let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n);
craft_ui::ComboBox::from_id_salt("iv-layout")
.selected_text(shown)
.show_ui(ui, |ui| {
for (l, n) in names {
ui.selectable_value(&mut v.layout, l, n);
}
})
.response
.labelled_by(label.id);
});
ui.end_row();
let label = ui.label(tl!("Magnification"));
ui.add_enabled_ui(editable, |ui| {
ui.horizontal(|ui| {
let names = [
(M::Default, tl!("Default")),
(M::ActualSize, tl!("Actual Size")),
(M::FitPage, tl!("Fit Page")),
(M::FitWidth, tl!("Fit Width")),
(M::FitHeight, tl!("Fit Height")),
(M::FitVisible, tl!("Fit Visible")),
];
let shown = match v.magnification {
M::Percent(p) => format!("{p:.0}%"),
m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()),
};
craft_ui::ComboBox::from_id_salt("iv-mag")
.selected_text(shown)
.show_ui(ui, |ui| {
for (m, n) in names {
ui.selectable_value(&mut v.magnification, m, n);
}
for p in [50.0, 75.0, 125.0, 150.0, 200.0] {
ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%"));
}
})
.response
.labelled_by(label.id);
});
});
ui.end_row();
let label = ui.label(tl!("Open to page"));
ui.add_enabled_ui(editable, |ui| {
let mut p = v.page + 1;
if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).labelled_by(label.id).changed() {
v.page = p - 1;
}
ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())]));
});
ui.end_row();
ui.label(egui::RichText::new(tl!("Window Options")).font(theme::semibold(12.5)));
ui.end_row();
ui.label("");
ui.add_enabled_ui(editable, |ui| {
ui.vertical(|ui| {
ui.add(widgets::checkbox(&mut v.fit_window, tl!("Resize window to initial page")));
ui.add(widgets::checkbox(&mut v.center_window, tl!("Center window on screen")));
ui.add(widgets::checkbox(&mut v.full_screen, tl!("Open in Full Screen mode")));
ui.horizontal(|ui| {
ui.label(tl!("Show:"));
widgets::radio_value(ui, &mut v.display_title, false, tl!("File Name"));
widgets::radio_value(ui, &mut v.display_title, true, tl!("Document Title"));
});
});
});
ui.end_row();
ui.label(egui::RichText::new(tl!("User Interface Options")).font(theme::semibold(12.5)));
ui.end_row();
ui.label("");
ui.add_enabled_ui(editable, |ui| {
ui.vertical(|ui| {
ui.add(widgets::checkbox(&mut v.hide_menubar, tl!("Hide menu bar")));
ui.add(widgets::checkbox(&mut v.hide_toolbar, tl!("Hide toolbars")));
ui.add(widgets::checkbox(&mut v.hide_window_ui, tl!("Hide window controls")));
});
});
ui.end_row();
}
PropsTab::Security => match doc.security_summary() {
None => {
row(ui, "Security method", tl!("No security").to_string());
row(ui, "Restrictions", tl!("None — everything is allowed").to_string());
if doc.allows_security_change() && ui.button(tl!("Protect using password…")).clicked() {
req.link_command = Some("protect.password");
}
}
Some(sec) => {
row(ui, "Security method", tl!("Password security").to_string());
row(ui, "Encryption", sec.method.clone());
row(
ui,
"Opened with",
if sec.pending {
tl!("— (protection is applied when you save)").to_string()
} else if sec.owner {
tl!("Owner password (no restrictions apply)").to_string()
} else {
tl!("User password").to_string()
},
);
// A restricted document asks for its permissions password first.
if doc.allows_security_change() || doc.can_unlock() {
ui.horizontal(|ui| {
if doc.can_unlock() && ui.button(tl!("Unlock…")).clicked() {
req.link_command = Some("protect.unlock");
}
if ui.button(tl!("Change settings…")).clicked() {
req.link_command = Some("protect.password");
}
if ui.button(tl!("Remove security")).clicked() {
req.link_command = Some("protect.remove");
}
});
}
let p = sec.permissions;
let yes = |b: bool| tl!(if b { "Allowed" } else { "Not allowed" }).to_string();
row(
ui,
"Printing",
if !p.print() {
tl!("Not allowed").to_string()
} else if p.print_high_quality() {
tl!("High resolution").to_string()
} else {
tl!("Low resolution").to_string()
},
);
row(ui, "Changing the document", yes(p.modify()));
row(ui, "Document assembly", yes(p.assemble()));
row(ui, "Content copying", yes(p.copy()));
row(ui, "Content copying for accessibility", yes(p.extract_for_accessibility()));
row(ui, "Commenting", yes(p.annotate()));
row(ui, "Filling of form fields", yes(p.fill_forms()));
}
},
PropsTab::Fonts => {
if i.fonts.is_empty() {
row(ui, "Fonts", tl!("No fonts are referenced by the pages.").to_string());
}
for f in &i.fonts {
let mut detail = f.kind.clone();
if let Some(e) = &f.encoding {
detail.push_str(&format!(" · {e}"));
}
detail.push_str(if f.subset {
" · Embedded subset"
} else if f.embedded {
" · Embedded"
} else {
" · Not embedded (substituted)"
});
row(ui, &f.name, detail);
}
}
PropsTab::Custom => {
// Add takes a new name, Update changes the value of the listed property the
// Name field names, Delete removes it; OK writes the changes as one step.
let editable = doc.allows_modification();
let Some((_, d)) = app.custom_draft.as_mut() else { return };
let l = ui.label(egui::RichText::new(tl!("Name:")).color(t.text_muted));
ui.add_enabled(editable, widgets::line(&mut d.name).desired_width(420.0).background_color(t.field).id_salt("custom-name"))
.labelled_by(l.id);
ui.end_row();
let l = ui.label(egui::RichText::new(tl!("Value:")).color(t.text_muted));
ui.add_enabled(editable, widgets::line(&mut d.value).desired_width(420.0).background_color(t.field).id_salt("custom-value"))
.labelled_by(l.id);
ui.end_row();
// Untrimmed: a file's `/Project#20` is not `/Project`, and a typed space is refused.
let name = d.name.clone();
let value = d.value.trim().to_owned();
let listed = d.props.iter().position(|(k, _)| *k == name);
// An entry the list leaves out (not text, only padding, or past its 256) keeps its value.
let file_props = doc.custom_info();
let unlisted = doc.has_info(&name) && !file_props.iter().any(|(k, _)| *k == name);
let refused = pdfcraft_engine::check_custom_key(&name).err().filter(|_| !name.is_empty()).map(|e| e.to_string());
ui.label("");
// A file can hold a custom name that `check_custom_key` refuses, such as one
// with a space: the tab lists it, and Add, Update and Delete stay off for it.
let why = |b: egui::Response| match &refused {
Some(why) => b.on_disabled_hover_text(crate::i18n::words(why)),
None => b,
};
// A name that isn't UTF-8 is listed with U+FFFD and can't be written back as it is.
let editable = editable && refused.is_none() && !name.contains(char::REPLACEMENT_CHARACTER);
// `custom_info` lists at most 256 properties, and a new name goes after the file's
// entries, so Add is off once the draft or the file's own list holds 256.
let room = d.props.len() < 256 && file_props.len() < 256;
ui.horizontal(|ui| {
let add = editable && room && listed.is_none() && !unlisted && !name.is_empty() && !value.is_empty();
if why(ui.add_enabled(add, egui::Button::new(tl!("Add")))).clicked() {
d.props.push((name.clone(), value.clone()));
d.name.clear();
d.value.clear();
}
let update = editable && !value.is_empty() && listed.and_then(|n| d.props.get(n)).is_some_and(|(_, v)| *v != value);
if why(ui.add_enabled(update, egui::Button::new(tl!("Update")))).clicked()
&& let Some(p) = listed.and_then(|n| d.props.get_mut(n))
{
p.1 = value.clone();
}
if why(ui.add_enabled(editable && listed.is_some(), egui::Button::new(tl!("Delete")))).clicked()
&& let Some(n) = listed.filter(|n| *n < d.props.len())
{
d.props.remove(n);
d.name.clear();
d.value.clear();
}
});
ui.end_row();
if d.props.is_empty() {
ui.label("");
ui.label(egui::RichText::new(tl!("None")).color(t.text_faint));
ui.end_row();
}
// A click on a property fills the fields with it; values are document data.
let mut picked = None;
for (k, v) in &d.props {
if ui.selectable_label(*k == name, k.as_str()).clicked() {
picked = Some((k.clone(), v.clone()));
}
ui.scope(|ui| {
ui.set_max_width(420.0);
ui.add(egui::Label::new(v.as_str()).wrap());
});
ui.end_row();
}
if let Some((k, v)) = picked {
d.name = k;
d.value = v;
}
}
PropsTab::Advanced => {
row(ui, "PDF version", i.pdf_version.clone());
path_row(ui, "Location", doc.path.clone().unwrap_or_default());
row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size)));
let p = &i.pages[0];
row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height)));
row(ui, "Number of pages", i.pages.len().to_string());
row(ui, "Tagged PDF", yes(i.tagged));
row(ui, "Form fields", i.fields.len().to_string());
row(ui, "Comments", i.annotations.len().to_string());
row(ui, "Layers", i.layers.len().to_string());
row(ui, "Attachments", i.attachments.len().to_string());
row(ui, "JavaScript", yes(i.has_javascript));
// Each incremental update is a revision; earlier ones open as their own document.
let ends = doc.revision_ends();
if ends.len() > 1 {
ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted));
// Reserve the full wrapped height before the grid starts the next row.
ui.vertical(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(ends.len().to_string());
for n in (1..ends.len()).rev().take(12) {
if ui
.small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())]))
.on_hover_text(tl!("Open the file as it was saved then"))
.clicked()
{
req.open_revision = Some(n);
}
}
});
});
ui.end_row();
}
// Reading Options: binding and language.
if let Some((_, v)) = app.view_draft.as_mut() {
let editable = doc.allows_modification();
ui.label(egui::RichText::new(tl!("Binding")).color(t.text_muted));
ui.add_enabled_ui(editable, |ui| {
ui.horizontal(|ui| {
widgets::radio_value(ui, &mut v.right_to_left, false, tl!("Left Edge"));
widgets::radio_value(ui, &mut v.right_to_left, true, tl!("Right Edge"));
});
});
ui.end_row();
let l = ui.label(egui::RichText::new(tl!("Language")).color(t.text_muted));
let mut lang = v.language.clone().unwrap_or_default();
if ui
.add_enabled(editable, widgets::line(&mut lang).hint_text(tl!("e.g. en-US")).desired_width(160.0))
.labelled_by(l.id)
.changed()
{
v.language = (!lang.trim().is_empty()).then(|| lang.trim().to_string());
}
ui.end_row();
}
// What was repaired while reading a damaged file (fidelity: never silent).
let repairs = doc.repair_log();
row(ui, "Repairs", if repairs.is_empty() { tl!("None").to_string() } else { repairs.len().to_string() });
if !repairs.is_empty() {
ui.label("");
egui::CollapsingHeader::new(tl!("Repair log")).show(ui, |ui| {
for r in &repairs {
ui.add(egui::Label::new(egui::RichText::new(r).small()).wrap());
}
});
ui.end_row();
}
}
// Laid out above, outside the two-column grid.
PropsTab::SpaceAudit => {}
});
});
true
}
/// `Dialog::Split` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_split(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!("Split document")).font(theme::semibold(18.0)));
ui.add_space(8.0);
let Some((vi, id)) = app.active_ids() else { return false };
let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
let selected: Vec<usize> = app.views[vi].selected.iter().copied().filter(|p| *p > 0).collect();
let marks = app.session.bookmark_splits(id);
let draft = &mut app.split_draft;
use crate::SplitMode as M;
if selected.is_empty() && draft.mode == M::Selection {
draft.mode = M::Pages;
}
widgets::radio_value(ui, &mut draft.mode, M::Pages, tl!("Number of pages"));
ui.add_enabled_ui(draft.mode == M::Pages, |ui| {
ui.horizontal(|ui| {
ui.add_space(24.0);
ui.label(tl!("Pages per file"));
ui.add(egui::DragValue::new(&mut draft.every).range(1..=n.max(1)));
});
});
widgets::radio_value(ui, &mut draft.mode, M::Size, tl!("File size"));
ui.add_enabled_ui(draft.mode == M::Size, |ui| {
ui.horizontal(|ui| {
ui.add_space(24.0);
ui.label(tl!("At most"));
ui.add(egui::DragValue::new(&mut draft.size_mb).range(0.05..=2000.0).speed(0.1).suffix(" MB"));
});
});
ui.add_enabled_ui(!marks.is_empty(), |ui| {
widgets::radio_value(
ui,
&mut draft.mode,
M::Bookmarks,
crate::i18n::fmt(tl!("Top-level bookmarks ({n})"), &[("n", &marks.len().to_string())]),
)
});
ui.add_enabled_ui(!selected.is_empty(), |ui| {
widgets::radio_value(ui, &mut draft.mode, M::Selection, tl!("Before each selected page (select pages in Organize)"))
});
let plan = match draft.mode {
M::Pages => crate::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(draft.every)),
M::Selection => crate::SplitPlan::By(pdfcraft_engine::SplitBy::Before(selected)),
M::Size => crate::SplitPlan::Size((draft.size_mb * 1_048_576.0) as usize),
M::Bookmarks => crate::SplitPlan::Bookmarks,
};
let files = match &plan {
crate::SplitPlan::By(by) => Some(pdfcraft_engine::split_ranges(n, by).len()),
crate::SplitPlan::Bookmarks => {
Some(pdfcraft_engine::split_ranges(n, &pdfcraft_engine::SplitBy::Before(marks.iter().map(|m| m.0).collect())).len())
}
crate::SplitPlan::Size(_) => None,
};
ui.add_space(8.0);
let text = match files {
Some(f) => {
if f == 1 {
crate::i18n::fmt(tl!("Creates 1 file from {n} pages."), &[("n", &n.to_string())])
} else {
crate::i18n::fmt(tl!("Creates {f} files from {n} pages."), &[("f", &f.to_string()), ("n", &n.to_string())])
}
}
None => crate::i18n::fmt(tl!("Each file holds as many of the {n} pages as fit."), &[("n", &n.to_string())]),
};
ui.label(egui::RichText::new(text).color(t.text_muted));
if files.is_none_or(|f| f > 1) {
req.split_ready = Some(plan);
}
true
}
/// `Dialog::ReplacePages` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_replace_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
let count = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.info.pages.len()).unwrap_or(1).max(1);
let Some(d) = app.replace_draft.as_mut() else {
req.close = true;
return false;
};
ui.label(egui::RichText::new(tl!("Replace Pages")).font(theme::semibold(18.0)));
ui.add_space(8.0);
d.to = d.to.clamp(1, count);
d.from = d.from.clamp(1, d.to);
ui.horizontal(|ui| {
let from_label = ui.label(tl!("Original: replace pages"));
let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)).labelled_by(from_label.id);
let to_label = ui.label(tl!("to"));
let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)).labelled_by(to_label.id);
if from.changed() || to.changed() {
d.range_edited = true;
}
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &count.to_string())])).color(t.text_muted));
});
let n = if d.range_edited { d.to - d.from + 1 } else { d.pages.len().max(1) };
let max_start = d.src_pages.saturating_sub(n) + 1;
d.src_from = d.src_from.clamp(1, max_start.max(1));
ui.horizontal(|ui| {
let replacement_label = ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)]));
ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))).labelled_by(replacement_label.id);
ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())]));
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted));
});
let fits = n <= d.src_pages;
ui.add_space(6.0);
ui.label(
egui::RichText::new(if fits {
tl!("Only the page content changes: links, comments, form fields and bookmarks on the original pages stay.")
} else {
tl!("The replacement file doesn't have that many pages.")
})
.small()
.color(t.text_faint),
);
ui.add_space(10.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled_ui(fits, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() {
req.replace_now = true;
req.close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
});
false
}
/// `Dialog::RedactProps` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_redact_props(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
let mut prefs = app.redact_prefs.clone();
let (ok, cancel) = crate::redact_ui::props_body(ui, &mut prefs, &t);
app.redact_prefs = prefs;
if ok && app.redact_prefs.remember {
app.redact_defaults = app.redact_prefs.clone();
}
if cancel && let Some(before) = app.redact_before.take() {
app.redact_prefs = before;
}
if ok {
app.redact_before = None;
}
req.close = ok || cancel;
false
}
/// `Dialog::Extract` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_extract(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let (selected, total) = app.active_ids().map_or((Vec::new(), 0), |(i, _)| (app.views[i].target_pages(), app.views[i].page_count()));
ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0)));
ui.add_space(8.0);
// Page ranges to take instead of the selection (#3593); the hint is the selection.
let hint = match crate::combine_ui::range_of(&selected, total) {
all if all.is_empty() && total > 1 => format!("1-{total}"),
all if all.is_empty() => "1".to_string(),
some => some,
};
ui.horizontal(|ui| {
let l = ui.label(tl!("Pages"));
ui.add(egui::TextEdit::singleline(&mut app.extract_draft.pages).hint_text(hint).desired_width(240.0)).labelled_by(l.id);
});
let chosen = app.extract_draft.pages_to_extract(selected, total);
match &chosen {
Ok(pages) if pages.len() == 1 => {
ui.label(tl!("1 page selected."));
}
Ok(pages) => {
ui.label(crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &pages.len().to_string())]));
}
Err(e) => {
ui.colored_label(ui.visuals().error_fg_color, e);
}
}
ui.add_space(4.0);
// The name the pages are saved under (#737); separate files add " (page N)".
ui.horizontal(|ui| {
let l = ui.label(tl!("File name"));
ui.add(egui::TextEdit::singleline(&mut app.extract_draft.name).desired_width(240.0)).labelled_by(l.id);
});
ui.add(widgets::checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting")));
ui.add(widgets::checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files")));
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled_ui(chosen.is_ok(), |ui| widgets::pill_button(ui, tl!("Extract"), true)).inner.clicked() {
req.extract_now = true;
req.close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
});
false
}
/// `Dialog::RotatePages` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
use pdfcraft_engine::{PageOrientation as O, PageParity as P};
let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
let d = &mut app.rotate_draft;
let mut typing_to = false;
ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0)));
ui.add_space(8.0);
egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
ui.label(tl!("Direction:"));
craft_ui::ComboBox::from_id_salt("rotate-dir")
.selected_text(match d.degrees {
270 => tl!("Counterclockwise 90 degrees"),
180 => tl!("180 degrees"),
_ => tl!("Clockwise 90 degrees"),
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut d.degrees, 90, tl!("Clockwise 90 degrees"));
ui.selectable_value(&mut d.degrees, 270, tl!("Counterclockwise 90 degrees"));
ui.selectable_value(&mut d.degrees, 180, tl!("180 degrees"));
});
ui.end_row();
ui.label(tl!("Pages:"));
ui.vertical(|ui| {
widgets::radio_value(ui, &mut d.which, 0, tl!("All"));
widgets::radio_value(ui, &mut d.which, 1, tl!("Selection"));
ui.horizontal(|ui| {
let from_label = widgets::radio_value(ui, &mut d.which, 2, tl!("From"));
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)).labelled_by(from_label.id);
let to_label = ui.label(tl!("to"));
typing_to =
widgets::typing_into(&ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)).labelled_by(to_label.id));
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
});
});
ui.end_row();
ui.label(tl!("Rotate:"));
craft_ui::ComboBox::from_id_salt("rotate-parity")
.selected_text(match d.parity {
P::Both => tl!("Even and Odd Pages"),
P::Even => tl!("Even Pages Only"),
P::Odd => tl!("Odd Pages Only"),
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut d.parity, P::Both, tl!("Even and Odd Pages"));
ui.selectable_value(&mut d.parity, P::Even, tl!("Even Pages Only"));
ui.selectable_value(&mut d.parity, P::Odd, tl!("Odd Pages Only"));
});
ui.end_row();
ui.label("");
craft_ui::ComboBox::from_id_salt("rotate-orient")
.selected_text(match d.orientation {
O::Both => tl!("Landscape and Portrait Pages"),
O::Landscape => tl!("Landscape Pages"),
O::Portrait => tl!("Portrait Pages"),
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut d.orientation, O::Both, tl!("Landscape and Portrait Pages"));
ui.selectable_value(&mut d.orientation, O::Landscape, tl!("Landscape Pages"));
ui.selectable_value(&mut d.orientation, O::Portrait, tl!("Portrait Pages"));
});
ui.end_row();
});
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
// OK while the end is still being typed (a screen reader can press it).
widgets::keep_range_ordered(d.from, &mut d.to, false);
req.rotate_now = true;
req.close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
});
false
}
/// `Dialog::DuplicateField` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_duplicate_field(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
let Some(d) = app.duplicate_draft.as_mut() else {
req.close = true;
return false;
};
ui.label(egui::RichText::new(tl!("Duplicate Field")).font(theme::semibold(18.0)));
ui.add_space(8.0);
ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)]));
widgets::radio_value(ui, &mut d.all, true, tl!("All pages"));
let typing_to = ui
.horizontal(|ui| {
widgets::radio_value(ui, &mut d.all, false, tl!("From"));
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n));
ui.label(tl!("to"));
let typing_to = widgets::typing_into(&ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)));
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
typing_to
})
.inner;
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
widgets::keep_range_ordered(d.from, &mut d.to, false);
let pages: Vec<usize> = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() };
req.duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages });
req.close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
});
false
}
/// `Dialog::CertificateViewer` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_certificate_viewer(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
ui.set_width(700.0);
let trusted = app.session.trusted_certificates().to_vec();
let Some(v) = app.cert_viewer.as_mut() else {
req.close = true;
return false;
};
let (done, action) = crate::sign_ui::cert_viewer(ui, v, &trusted, &t);
req.close = done;
match action {
Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c),
Some(crate::sign_ui::CertAction::Export(c)) => {
app.export_certificate(&c);
}
None => {}
}
false
}
/// `Dialog::SummarizeComments` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_summarize_comments(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
ui.label(egui::RichText::new(tl!("Summarize Options")).font(theme::semibold(18.0)));
ui.add_space(8.0);
ui.label(tl!("Choose a layout:"));
let _ = ui.add(widgets::radio(true, tl!("Comments only")));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(tl!("Sort comments by:"));
craft_ui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| {
for s in pdfcraft_engine::SummarySort::ALL {
ui.selectable_value(&mut app.summary_sort, s, tl!(s.name()));
}
});
});
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Create PDF Comment Summary"), true).clicked() {
req.summarize_now = true;
req.close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
});
false
}
/// `Dialog::Revert` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_revert(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default();
ui.label(egui::RichText::new(tl!("Revert")).font(theme::semibold(18.0)));
ui.add_space(8.0);
ui.label(crate::i18n::fmt(
tl!("Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards."),
&[("name", &name)],
));
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Revert"), true).clicked() {
req.revert_now = true;
req.close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
});
false
}
/// `Dialog::OverwriteChanged` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_overwrite_changed(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
#[cfg(not(target_arch = "wasm32"))]
let name = app.pending_overwrite.as_ref().and_then(|p| app.session.get(p.id)).map(|d| d.name.clone()).unwrap_or_default();
#[cfg(target_arch = "wasm32")]
let name = String::new();
ui.label(egui::RichText::new(tl!("Save over the changed file?")).font(theme::semibold(18.0)));
ui.add_space(8.0);
ui.label(crate::i18n::fmt(
tl!("“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one."),
&[("name", &name)],
));
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Save anyway"), true).clicked() {
req.overwrite_now = true;
req.close = true;
}
if widgets::pill_button(ui, tl!("Save as…"), false).clicked() {
req.overwrite_elsewhere = true;
req.close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
});
false
}
/// `Dialog::Print` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_print(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
let Some((i, id)) = app.active_ids() else {
req.close = true;
return false;
};
if app.print_draft.handling == crate::print_ui::Handling::Label {
app.read_label_crops(id);
}
let Some(doc) = app.session.get(id) else { return false };
let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
let labels: Vec<String> = doc.info.pages.iter().map(|p| p.label.clone()).collect();
let rasters = crate::print_ui::preview_rasters(
&app.print_draft,
&sizes,
&labels,
ui.ctx().pixels_per_point(),
ui.ctx().input(|i| i.max_texture_side) as f32,
);
let view = &mut app.views[i];
view.set_print_grayscale(app.print_draft.grayscale);
view.queue_print_previews(&rasters);
let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| {
view.need_thumbnail(p, true);
view.page_preview(p)
});
req.print_go = go;
req.close = go || cancel;
false
}
/// `Dialog::Signature` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
let (go, cancel, browse) =
crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview, &mut app.signature_photo);
req.apply = go;
if browse {
app.pick_signature_image();
}
if req.apply {
let d = std::mem::take(&mut app.signature_draft);
let tool = if d.initials {
app.initials = d.saved();
crate::fill_sign::FillTool::Initials
} else {
app.signature = d.saved();
crate::fill_sign::FillTool::Signature
};
app.fill_tool = tool;
app.quick_tool = crate::QuickTool::Fill(tool);
app.toast = None;
}
req.close = req.apply || cancel;
false
}
/// `Dialog::Preferences` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
ui.set_width(610.0);
let t = Tokens::get(ui.ctx());
preferences::preferences_header(ui, &t);
ui.add_space(10.0);
// The settings outgrow a short window: scroll them rather than run off the screen.
// The OK row stays below the scroll, in reach however far the settings are scrolled.
let max = (ui.ctx().content_rect().height() - 120.0 - preferences::PREFERENCES_BUTTON_ROW - 40.0).max(preferences::PREFERENCES_MIN_SCROLL);
// A scroll area starts from the room the dialog offers it, and a modal offers more
// each frame for the first few, so with OK outside the scroll the dialog would grow
// over those frames. Asking for the full height as the least it may scroll in
// (which only applies when the settings overflow it) opens the dialog at full size.
// A scrollbar of its own: the floating one lies over the right edge of the theme
// cards and other full-width rows (#3557).
preferences_scroll(ui, max, |ui| preferences::preferences_body(ui, app, &t));
req.close = preferences::preferences_buttons(ui);
false
}
fn preferences_scroll(ui: &mut egui::Ui, max: f32, add_contents: impl FnOnce(&mut egui::Ui)) {
ui.scope(|ui| {
ui.spacing_mut().scroll.floating = false;
ui.spacing_mut().scroll.bar_inner_margin = 14.0;
ui.spacing_mut().scroll.bar_outer_margin = 2.0;
if ui.style().scroll_animation != egui::style::ScrollAnimation::none() {
ui.style_mut().scroll_animation = egui::style::ScrollAnimation::duration(0.25);
}
egui::ScrollArea::vertical()
.id_salt("preferences-dialog-scroll")
.max_height(max)
.min_scrolled_height(max)
.animated(true)
.show(ui, add_contents);
});
}
/// `Dialog::NumberPages` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
use pdfcraft_engine::LabelStyle as L;
ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0)));
ui.add_space(8.0);
let Some((_, id)) = app.active_ids() else { return false };
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, n);
let mut typing_to = false;
egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
ui.label(tl!("Pages"));
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut d.from).range(1..=n));
ui.label(tl!("to"));
typing_to = widgets::typing_into(&ui.add(egui::DragValue::new(&mut d.to).range(1..=n)));
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted));
});
ui.end_row();
ui.label(tl!("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, tl!("None (prefix only)")),
];
let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l);
craft_ui::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(tl!("Prefix"));
ui.add(widgets::line(&mut d.prefix).desired_width(160.0)).labelled_by(l.id);
ui.end_row();
ui.label(tl!("Start"));
ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999));
ui.end_row();
});
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k));
ui.add_space(8.0);
// `to` can be below `from` while it is being typed.
let preview = if d.to <= d.from {
label(d.start)
} else {
format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start.saturating_add(u32::try_from(d.to - d.from).unwrap_or(u32::MAX))))
};
ui.label(
egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])).color(t.text_muted),
);
// Ordered here too: the footer's OK can be pressed while the end is still being typed.
req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to.max(d.from) - 1, style: d.style, prefix: d.prefix.clone(), first: d.start });
true
}
/// `Dialog::Recovery` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_recovery(ui: &mut egui::Ui, app: &mut PdfCraftApp) -> bool {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.add(crate::icons::image("clock-3", 22.0, t.accent));
ui.label(egui::RichText::new(tl!("Recover unsaved documents?")).font(theme::semibold(18.0)));
});
ui.add_space(6.0);
ui.label(tl!("PdfCraft didn't shut down normally. These documents had changes that were autosaved:"));
ui.add_space(8.0);
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
egui::Grid::new("recoverable").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| {
for m in &app.recoverable {
ui.label(egui::RichText::new(&m.name).font(theme::medium(13.0)));
let mins = now.saturating_sub(m.saved_at) / 60;
let when = match mins {
0 => tl!("just now").to_string(),
1..=59 => crate::i18n::fmt(tl!("{n} min ago"), &[("n", &mins.to_string())]),
_ => crate::i18n::fmt(tl!("{n} h ago"), &[("n", &(mins / 60).to_string())]),
};
let lock = if m.encrypted { tl!(" · password-protected") } else { "" };
ui.label(egui::RichText::new(format!("{when}{lock}")).color(t.text_muted));
ui.end_row();
}
});
true
}
/// `Dialog::Shortcuts` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_shortcuts(ui: &mut egui::Ui) -> bool {
ui.label(egui::RichText::new(tl!("Keyboard shortcuts")).font(theme::semibold(18.0)));
ui.add_space(8.0);
use crate::commands::{
ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIT_WIDTH, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, SELECT_ALL, ZOOM_IN, ZOOM_OUT,
};
// Registered commands first (always in sync with the real bindings), then the
// keys the document view handles itself.
let mut rows: Vec<(String, String)> = pdfcraft_engine::commands::COMMANDS
.iter()
.filter_map(|c| c.shortcut.map(|k| (crate::commands::shortcut_label(ui.ctx(), k), tl!(c.label).trim_end_matches('…').to_string())))
.collect();
let key = |s: pdfcraft_engine::commands::Shortcut| crate::commands::shortcut_label(ui.ctx(), s);
let pair = |a, b| format!("{} / {}", key(a), key(b));
// Key names, translated where a catalog has them (not scanned as UI literals).
let named = |k: &str| tl!(k).to_string();
// Alt+← / Alt+→ (`canvas::shortcuts`): not a registered command, which has no Alt.
let alt_arrows = if crate::commands::mac_shortcuts(ui.ctx()) {
"⌥← / ⌥→".to_owned()
} else {
let alt = crate::i18n::key_name("Alt");
format!("{alt}+← / {alt}+→")
};
let scroll = crate::i18n::fmt(tl!("Zoom in / out (also pinch or {key}-scroll)"), &[("key", crate::commands::command_modifier_label(ui.ctx()))]);
for (k, v) in [
(pair(FIND_NEXT, FIND_PREV), tl!("Next / previous match").to_string()),
(key(COPY), tl!("Copy selected text").to_string()),
(named("Double-click"), tl!("Select a word").to_string()),
(crate::i18n::key_name("Esc").to_string(), tl!("Clear selection / close find").to_string()),
(key(ACTUAL_SIZE), tl!("Actual size").to_string()),
(key(PAGE_LEVEL), tl!("Zoom to page level").to_string()),
(key(FIT_WIDTH), tl!("Fit to width").to_string()),
(pair(ZOOM_IN, ZOOM_OUT), scroll),
(pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()),
(format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()),
(format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()),
(alt_arrows, format!("{} / {}", tl!("Previous view"), tl!("Next view"))),
(named("V"), tl!("Select (V)").to_string()),
(named("H / Space (hold)"), tl!("Hand (H)").to_string()),
(crate::i18n::key_name("Delete").to_string(), tl!("Delete selected pages (Organize)").to_string()),
(key(SELECT_ALL), tl!("Select all pages (Organize)").to_string()),
] {
rows.push((k, v));
}
egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| {
egui::Grid::new("keys").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| {
for (k, v) in &rows {
ui.label(egui::RichText::new(k).font(egui::FontId::monospace(12.5)));
ui.label(v);
ui.end_row();
}
});
});
true
}
/// `Dialog::About` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_about(ui: &mut egui::Ui, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
// Tabs About · Contributors · Models.
let tab_id = egui::Id::unique("about_tab");
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
ui.horizontal_wrapped(|ui| {
for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() {
let i = i as u8;
if widgets::mode_tab(ui, tl!(label), tab == i).clicked() {
tab = i;
}
}
});
ui.data_mut(|d| d.insert_temp(tab_id, tab));
ui.separator();
match tab {
1 => crate::credits::contributors_ui(ui),
2 => crate::credits::models_ui(ui),
_ => {
ui.horizontal(|ui| {
widgets::artcraft_mark(ui, 40.0);
ui.vertical(|ui| {
ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0)));
ui.label(crate::i18n::fmt(tl!("Version {v}"), &[("v", &pdfcraft_engine::build_info::version())]));
});
});
ui.add_space(6.0);
ui.label(tl!("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0."));
ui.label(
egui::RichText::new(
"Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL)",
)
.color(t.text_muted)
.small(),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Part of")).color(t.text_muted));
widgets::artcraft_logo(ui, 16.0);
});
ui.add_space(6.0);
if let Some(cmd) = widgets::community_links(ui) {
req.link_command = Some(cmd);
}
}
}
true
}
fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Pin tools to Home")).font(theme::semibold(18.0)));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::ghost_button(ui, "x", tl!("Close")).clicked() {
req.close = true;
}
});
});
ui.add_space(4.0);
ui.label(egui::RichText::new(tl!("Select tools to display on your Home page for quick access.")).color(t.text_muted));
ui.add_space(10.0);
ui.horizontal(|ui| {
let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
crate::icons::paint(ui, icon_rect, "search", 16.0, t.text_muted);
let search_resp =
ui.add(egui::TextEdit::singleline(&mut app.pin_tool_search).hint_text(tl!("Search tools…")).desired_width(ui.available_width() - 32.0));
search_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), tl!("Search tools…")));
if !app.pin_tool_search.is_empty() && widgets::ghost_button(ui, "x", tl!("Clear search")).clicked() {
app.pin_tool_search.clear();
}
});
ui.add_space(8.0);
ui.separator();
ui.add_space(6.0);
let query = app.pin_tool_search.trim().to_lowercase();
egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| {
let mut toggle = None;
for g in pdfcraft_engine::catalog::TOOL_GROUPS {
let label = tl!(g.label);
let blurb = g.sections.first().map(|s| s.items.iter().take(4).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" • ")).unwrap_or_default();
if !query.is_empty()
&& !label.to_lowercase().contains(&query)
&& !blurb.to_lowercase().contains(&query)
&& !g.id.to_lowercase().contains(&query)
{
continue;
}
let pinned = app.is_tool_pinned(g.id);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 44.0), egui::Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
let press = theme::Press::track(ui, &resp);
press.wash(ui, rect, 6, false);
let body = rect.translate(press.offset());
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
crate::icons::paint(ui, egui::Rect::from_min_size(body.min + egui::vec2(8.0, 11.0), egui::vec2(22.0, 22.0)), g.icon, 20.0, color);
let btn_w = 78.0;
let btn_rect = egui::Rect::from_center_size(body.right_center() - egui::vec2(btn_w / 2.0 + 8.0, 0.0), egui::vec2(btn_w, 26.0));
let btn_resp = ui.interact(btn_rect, ui.scope_id().with(("pin_modal_btn", g.id)), egui::Sense::click());
let btn_hovered = btn_resp.hovered();
let (btn_fill, btn_stroke, text_color, btn_label) = if pinned {
(t.accent, egui::Stroke::NONE, egui::Color32::WHITE, tl!("Pinned"))
} else if btn_hovered {
(t.hover, egui::Stroke::new(1.0, t.border), t.text, tl!("Pin"))
} else {
(egui::Color32::TRANSPARENT, egui::Stroke::new(1.0, t.border), t.text_muted, tl!("Pin"))
};
let tip = if pinned { tl!("Unpin from Home") } else { tl!("Pin to Home") };
btn_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, pinned, format!("{tip}: {label}")));
ui.painter().rect(btn_rect, egui::CornerRadius::same(6), btn_fill, btn_stroke, egui::StrokeKind::Inside);
ui.painter().text(btn_rect.center(), egui::Align2::CENTER_CENTER, btn_label, theme::medium(12.0), text_color);
let max_w = (btn_rect.left() - body.left() - 44.0).max(10.0);
widgets::row_text(ui, body.min + egui::vec2(38.0, 6.0), label, theme::semibold(13.5), t.text, max_w);
widgets::row_text(ui, body.min + egui::vec2(38.0, 24.0), &blurb, theme::regular(11.0), t.text_muted, max_w);
if btn_resp.clicked() || resp.clicked() {
toggle = Some(g.id);
}
}
if let Some(id) = toggle {
app.toggle_pin_tool(id);
}
});
true
}
fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
ui.add_space(16.0);
let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
// One row high: on its own, a right-to-left layout centres the buttons in all the height
// left, which after a taller Properties tab is the room that tab left behind.
ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if dialog == Dialog::Recovery {
if widgets::pill_button(ui, tl!("Recover"), true).clicked() {
req.recover = Some(true);
req.close = true;
}
if widgets::pill_button(ui, tl!("Discard"), false).clicked() {
req.recover = Some(false);
req.close = true;
}
} else if dialog == Dialog::PinTool {
if widgets::pill_button(ui, tl!("Done"), true).clicked() {
req.close = true;
}
} else if dialog == Dialog::NumberPages {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
req.apply_number = true;
req.close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
} else if dialog == Dialog::Split {
if ui.add_enabled_ui(req.split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() {
req.split_now = req.split_ready.clone();
req.close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
} else if changed {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
req.apply = true;
req.close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
} else if widgets::pill_button(ui, tl!("Close"), true).clicked() {
req.close = true;
}
})
});
}
/// Acts on what the dialog asked for, once the modal is drawn.
fn finish_dialog(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) {
finish_requests(app, dialog, modal_closed, req);
finish_actions(app, req);
finish_redactions_and_fields(app, req);
finish_edits(app, dialog, req);
finish_close(app, dialog, modal_closed, req);
}
/// Runs the one-shot requests the dialog made.
fn finish_requests(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) {
if let Some(yes) = req.recover {
let keys: Vec<String> = app.recoverable.iter().map(|m| m.key.clone()).collect();
if yes {
app.recover(&keys);
} else {
app.discard_recovered(&keys);
}
}
// The pages to replace belong to the document the dialog was opened on (#167).
if req.replace_now
&& let Some(d) = app.replace_draft.take()
&& app.still_pick_target(d.target)
{
let pages = if d.range_edited { (d.from - 1..d.to).collect() } else { d.pages.clone() };
let n = pages.len();
app.apply_edit(Edit::ReplacePages {
pages,
name: d.name.clone(),
bytes: d.bytes.clone(),
src_pages: (d.src_from - 1..d.src_from - 1 + n).collect(),
});
}
// A print or save that fails keeps the dialog open, with the reason in a notice.
if req.print_go && !app.print_now() {
req.close = false;
}
if req.revert_now {
app.revert_active();
}
#[cfg(not(target_arch = "wasm32"))]
{
if req.overwrite_now {
app.overwrite_changed_file();
}
if req.overwrite_elsewhere {
app.save_changed_file_elsewhere();
}
}
#[cfg(target_arch = "wasm32")]
let _ = (req.overwrite_now, req.overwrite_elsewhere);
// Cancelled or dismissed: the save is dropped.
#[cfg(not(target_arch = "wasm32"))]
if dialog == Dialog::OverwriteChanged && (modal_closed || req.close) {
app.pending_overwrite = None;
}
}
/// Runs the document actions the dialog asked for.
fn finish_actions(app: &mut PdfCraftApp, req: &mut Pending) {
if req.summarize_now {
app.summarize_comments();
}
if req.optimize_now {
app.optimize_with_draft();
}
if let Some(e) = req.duplicate_now.take() {
app.duplicate_draft = None;
app.apply_edit(e);
}
// Another document became active, or the document changed, while the dialog was open: the
// pages at those indices may not be the ones chosen.
if let Some((doc, generation, e)) = req.page_props_now.take() {
if app.active_ids().is_some_and(|(_, id)| id == doc) && app.session.get(doc).is_some_and(|d| d.edit_generation() == generation) {
app.apply_edit(e);
} else {
app.notify_tr("The document changed while the dialog was open, so nothing was changed. Open Page properties again to set the tab order.");
}
}
if req.extract_now {
app.extract_selection();
}
if let Some(e) = req.link_now.take() {
app.apply_edit(e);
app.link_draft = None;
}
if req.rotate_now {
app.rotate_with_draft();
}
if req.import_words {
app.pick_files(crate::files::FilePurpose::RedactWords, false);
}
}
/// Redaction, hidden-data and field-properties requests.
fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
match req.redact_now.take() {
Some(Dialog::RedactPages) => app.redact_pages(),
Some(Dialog::RedactSearch) => {
let n = app.redact_search();
app.redact_search.found = Some(n);
}
Some(Dialog::RemoveHidden) => {
let which: Vec<pdfcraft_engine::Hidden> = app.hidden_draft.found.iter().filter(|f| f.2 && f.1 > 0).map(|f| f.0).collect();
let n: usize = app.hidden_draft.found.iter().filter(|f| f.2).map(|f| f.1).sum();
if app.apply_edit(Edit::RemoveHidden { which }) {
if n == 1 {
app.notify_tr("Removed 1 hidden item. Save to remove them from the file.");
} else {
app.notify_fmt("Removed {n} hidden items. Save to remove them from the file.", &[("n", &n.to_string())]);
}
}
}
Some(Dialog::Sanitize) if app.apply_edit(Edit::Sanitize) => {
app.notify_tr("Document sanitized. Save to finish: saving rewrites the whole file.");
}
Some(Dialog::RedactApply) => {
let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks());
if app.apply_edit(Edit::ApplyRedactions { pages: None }) {
if marks == 1 {
app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file.");
} else {
app.notify_fmt("Applied {n} redaction marks. Save to remove the content from the file.", &[("n", &marks.to_string())]);
}
}
}
_ => {}
}
if req.bulk_field_props_now {
req.close = app.apply_bulk_field_props();
}
if req.field_props_now
&& let Some(d) = app.field_props.take()
&& let Some(props) = d.props()
{
let form = |app: &PdfCraftApp| app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|doc| doc.form.clone());
// A rename also renames the field in the formulas that read it.
let before = props.name.as_ref().and_then(|_| form(app));
// The widget being edited, by object: a radio button that joins a group by its new name
// is appended after the group's own buttons, so its index changes.
let widget = before.as_ref().and_then(|all| all.iter().find(|f| f.name == d.field)?.widgets.get(d.widget).map(|w| w.obj));
let applied = app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) });
if !applied && app.active_ids().is_some() {
// A refused edit (its reason is in the notice) leaves the dialog open with what was
// typed into it, as the several-fields dialog does, so nothing is lost meanwhile.
app.field_props = Some(d);
req.close = false;
} else if applied && let Some(i) = app.active {
// Keep the (possibly renamed) field selected.
// A dotted name is the full name; a plain one renames the field within its parent.
let typed = d.name.trim();
let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default();
let name = format!("{prefix}{typed}");
let after = form(app);
let index =
widget.and_then(|obj| after.as_ref()?.iter().find(|f| f.name == name)?.widgets.iter().position(|w| w.obj == obj)).unwrap_or(d.widget);
if let (Some(before), Some(after)) = (before, after) {
let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count();
if n > 0 {
app.notify(crate::i18n::trn(
crate::i18n::current(),
n as u64,
"{n} calculation now uses the new name.",
"{n} calculations now use the new name.",
));
}
}
app.views[i].prepare.selected = Some((name, index));
}
}
}
/// Edits built from the dialog's drafts.
fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
if req.props_now
&& let Some(d) = app.comment_props.take()
{
let mut edits = crate::comment_props::edits(&d);
match edits.len() {
0 => {}
1 => {
app.apply_edit(edits.remove(0));
}
_ => {
app.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits });
}
}
// Make Properties Default: the look chosen here, even when it was already the comment's.
if d.make_default && d.edited.restylable {
app.make_props_default(d.page, d.index, &d.edited);
}
}
if req.export_now
&& let Dialog::Export(kind) = dialog
{
app.start_export(kind);
}
if req.marks_now
&& let (Dialog::Marks(kind), Some((_, id))) = (dialog, app.active_ids())
{
let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
let edit = crate::marks_ui::edit(&app.marks_draft, kind, count);
app.apply_edit(edit);
}
if req.boxes_now
&& let Some((i, id)) = app.active_ids()
{
let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
let edit = app.boxes_draft.edit(app.views[i].current, count);
app.apply_edit(edit);
app.boxes_draft.seeded = None;
}
if req.protect_now && app.apply_edit(app.protect_draft.edit()) {
app.protect_draft = Default::default();
app.notify_tr("Password protection will be applied when you save");
}
if req.apply_number
&& let Some(edit) = req.number_now.take()
{
app.apply_edit(edit);
}
if let Some(by) = req.split_now.take() {
app.split_active(&by);
}
if req.apply
&& let Some(edits) = draft_changes(app)
{
app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits });
}
}
/// Closes (or switches) the dialog, then runs what closing it asked for.
fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) {
// Protect / Remove security replace the Properties dialog.
let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some();
if modal_closed || req.close || replaces {
// Only now: the number in Preferences can pass through 0 on its way to the one wanted.
app.recent.truncate(app.recent_limit);
app.dialog = None;
app.props_draft = None;
app.view_draft = None;
app.custom_draft = None;
app.bulk_field_props = None;
app.unmarked_page = Default::default();
} else {
app.dialog = Some(req.next.unwrap_or(dialog));
}
if let Some(cmd) = req.link_command {
app.execute(cmd);
}
if let Some(n) = req.open_revision {
app.open_revision(n);
}
if req.alt_now {
app.save_alt_text();
}
if req.stamp_now {
app.save_custom_stamp();
}
if req.images_now {
app.finish_image_import();
} else if dialog == Dialog::CreateImages && app.dialog != Some(Dialog::CreateImages) {
app.image_import = None;
}
if req.ocr_now {
app.start_ocr();
}
if req.scan_clean_now {
app.start_scan_clean();
}
if req.compare_now {
app.run_compare();
}
if req.a11y_now {
if let Some((_, id)) = app.active_ids() {
app.a11y_skipped.retain(|(doc, _)| *doc != id);
}
app.run_accessibility_check();
}
}
/// Info edits needed to make the document match the Description draft.
fn draft_changes(app: &PdfCraftApp) -> Option<Vec<Edit>> {
let (id, draft) = app.props_draft.as_ref()?;
let doc = app.session.get(*id)?;
let mut edits: Vec<Edit> = INFO_KEYS
.iter()
.zip(draft.iter())
.filter(|(k, v)| doc.info_value(k).unwrap_or_default().trim() != v.trim())
.map(|(k, v)| Edit::SetInfo { key: (*k).to_string(), value: v.clone() })
.collect();
if let Some((vid, v)) = &app.view_draft
&& vid == id
&& *v != doc.initial_view()
{
edits.push(Edit::SetInitialView(Box::new(v.clone())));
}
// Custom: deleted properties are cleared, then added and changed ones are set.
if let Some((cid, d)) = &app.custom_draft
&& cid == id
{
let before = doc.custom_info();
for (k, _) in before.iter().filter(|(k, _)| !d.props.iter().any(|(n, _)| n == k)) {
edits.push(Edit::SetInfo { key: k.clone(), value: String::new() });
}
for (k, v) in d.props.iter().filter(|(k, v)| !before.iter().any(|(n, w)| n == k && w == v)) {
edits.push(Edit::SetInfo { key: k.clone(), value: v.clone() });
}
}
Some(edits)
}
fn yes(b: bool) -> String {
tl!(if b { "Yes" } else { "No" }).to_string()
}