From 8f3a4f18bdffe6fcf2fce5d6f20bb0d9a6e91842 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Wed, 28 Oct 2026 14:48:29 -0700 Subject: [PATCH] =?UTF-8?q?M6:=20Organize=20pages=20=E2=96=B8=20Page=20pro?= =?UTF-8?q?perties=20sets=20the=20selected=20pages'=20tab=20order=20(#4516?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M6: Organize pages ▸ Page properties sets the selected pages' tab order Page properties in the Organize pages panel was listed as planned. In the app, a page's tab order could only be set for every page at once, with the form.tab_order commands, which offer no Unspecified. (The form_tab_order tool already set chosen pages, Unspecified included.) - Page properties (page.properties) opens a Tab Order dialog for the selected pages, with Use Row Order, Use Column Order, Use Document Structure and Unspecified. OK writes Edit::SetTabOrder for those pages as one undo step. The dialog opens on the pages' order, or with no choice made when they differ, and then OK is off until one is chosen. OK on the order the pages already have adds no undo step. - Rows and columns run right to left in a document bound on the right, as Use Row Order and Use Column Order are described. PdfCraft reads a right-to-left binding from /ViewerPreferences /Direction /R2L (ISO 32000-1, 12.2, Table 150). Before, rank_tabs ranked them left to right. - A page whose /Tabs is an indirect object now takes its row, column or structure order. Before, rank_tabs read only a direct name and ranked the page in annotation order. - pdfcraft_forms::tab_orders and Document::tab_orders read each page's /Tabs. form_tab_order returns them as page_tabs, and reads without changing anything when it is given no order or field. - set_option("dialog", "page-properties") opens the dialog. - Parity: organize.page-tab-order is shipped, and its notes say the Actions tab of Page Properties (organize.page-actions) is still planned; the Organize pages grid notes list Page properties. * Page properties: OK applies only to the document the dialog was opened on The draft keeps the document's id. When another document is active by OK, the dialog closes without changing either document. * Menus: list Page properties… in the Pages menu The menu bar now lists its commands explicitly, and every command with a menu group must appear in it. * Page properties: apply the tab order only to an unchanged document An edit, undo or redo made while the dialog is open can move the pages at the stored indices. OK now closes the dialog without changing a page when the document's edit generation differs from when it opened. * Page properties: say when a document change cancels the tab order OK shows a notice when the document changed or another document became active while the dialog was open, so the user knows to open it again. --- crates/automation/src/forms.rs | 36 ++++-- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 17 +++ crates/engine/src/commands.rs | 1 + crates/engine/src/document.rs | 5 + crates/forms/src/lib.rs | 2 +- crates/forms/src/tab_order.rs | 33 +++++- crates/forms/src/tests.rs | 67 +++++++++++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 5 + crates/ui-egui/src/commands.rs | 14 +++ crates/ui-egui/src/dialogs.rs | 23 ++++ crates/ui-egui/src/lib.rs | 6 +- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels/organize_panel.rs | 28 ++++- crates/ui-egui/src/state.rs | 14 +++ crates/ui-egui/tests/it/editing.rs | 119 ++++++++++++++++++++ parity/acrobat-features.toml | 12 +- 18 files changed, 365 insertions(+), 21 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 71fbc67b3b..b4ce82df59 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -600,17 +600,23 @@ impl Automation { }; let mut out = self.apply(a, Edit::MoveInTabOrder { name: field.to_owned(), earlier })?; out["tab_order"] = self.tab_sequence(a)?; + out["page_tabs"] = self.page_tabs(a)?; return Ok(out); } - let order = match a.str("order")? { - "row" | "rows" => pdfcraft_engine::TabOrder::Row, - "column" | "columns" => pdfcraft_engine::TabOrder::Column, - "structure" => pdfcraft_engine::TabOrder::Structure, - "annotations" | "unspecified" => pdfcraft_engine::TabOrder::Annotations, - o => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))), + let order = match a.opt_str("order")? { + None => None, + Some("row" | "rows") => Some(pdfcraft_engine::TabOrder::Row), + Some("column" | "columns") => Some(pdfcraft_engine::TabOrder::Column), + Some("structure") => Some(pdfcraft_engine::TabOrder::Structure), + Some("annotations" | "unspecified") => Some(pdfcraft_engine::TabOrder::Annotations), + Some(o) => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))), + }; + let mut out = match order { + Some(order) => self.apply(a, Edit::SetTabOrder { pages, order })?, + None => json!({}), }; - let mut out = self.apply(a, Edit::SetTabOrder { pages, order })?; out["tab_order"] = self.tab_sequence(a)?; + out["page_tabs"] = self.page_tabs(a)?; Ok(out) } @@ -671,6 +677,22 @@ impl Automation { Ok(json!(seq.iter().map(|x| x.1).collect::>())) } + /// Each page's tab order (Page properties ▸ Tab order), in page order. + fn page_tabs(&self, a: &Args) -> Result { + let names: Vec<&str> = self + .doc(a)? + .tab_orders() + .into_iter() + .map(|o| match o { + pdfcraft_engine::TabOrder::Row => "row", + pdfcraft_engine::TabOrder::Column => "column", + pdfcraft_engine::TabOrder::Structure => "structure", + pdfcraft_engine::TabOrder::Annotations => "annotations", + }) + .collect(); + Ok(json!(names)) + } + pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result { let name = a.str("field")?.to_owned(); if !self.doc(a)?.form.iter().any(|f| f.name == name) { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 78a6805bb3..c4994aace1 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -464,7 +464,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.") + t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right; right to left in a document bound on the right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Without `order` or `field` it only reads. Returns the resulting order of fields (`tab_order`) and each page's tab order (`page_tabs`, as Page properties ▸ Tab Order shows it). Undoable.") .with(schema( json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }), &["doc"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index fb904bacfe..382bda1f4b 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4940,6 +4940,23 @@ fn the_calculation_order_is_read_fixed_and_set() { assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated"); } +#[test] +fn page_tab_orders_are_read_and_set_per_page_through_tools() { + let dir = workdir("pagetabs"); + 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, "form_tab_order", json!({ "doc": doc })); + assert_eq!(r["page_tabs"], json!(["annotations", "annotations", "annotations"])); + assert!(r["undo"].is_null(), "reading changes nothing: {r}"); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "column", "pages": [1, 3] })); + assert_eq!(r["page_tabs"], json!(["column", "annotations", "column"])); + assert_eq!((r["undo"].as_str(), r["dirty"].as_bool()), (Some("Set tab order"), Some(true))); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "structure", "pages": [2] })); + assert_eq!(r["page_tabs"], json!(["column", "structure", "column"])); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "unspecified", "pages": [1] })); + assert_eq!(r["page_tabs"], json!(["annotations", "structure", "column"])); +} + #[test] fn tab_order_and_field_appearance_through_tools() { let dir = workdir("taborder"); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 1f25c23c98..2412658240 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -392,6 +392,7 @@ pub const COMMANDS: &[CommandSpec] = &[ 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("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"), c("help.app_page", "PdfCraft web page", HELP, None, Nothing, "globe"), diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 7023caa55e..e66b0d0f17 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -248,6 +248,11 @@ impl Document { self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() } + /// Page properties ▸ Tab order: each page's tab order, in page order. + pub fn tab_orders(&self) -> Vec { + self.editor.as_ref().map(|e| pdfcraft_forms::tab_orders(&e.cos)).unwrap_or_default() + } + /// Add alternate text: the figures, in document order. pub fn figures(&self) -> Vec { self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 6d8cf9c99d..863376af3f 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -46,7 +46,7 @@ pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopte #[cfg(test)] use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads}; use read::{script, script_budget}; -pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order}; +pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders}; #[cfg(test)] use values::has_password_flag; use values::write_value; diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs index 77234ee094..5b60e3927b 100644 --- a/crates/forms/src/tab_order.rs +++ b/crates/forms/src/tab_order.rs @@ -4,9 +4,11 @@ use super::{Document, Field, FormError, ObjRef, Object, invalid}; /// A page's tab order (`/Tabs`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TabOrder { - /// Rows, top to bottom, left to right within a row (`/R`). + /// Rows, top to bottom, left to right within a row (`/R`; right to left in a document bound + /// on the right). Row, - /// Columns, left to right, top to bottom within a column (`/C`). + /// Columns, left to right, top to bottom within a column (`/C`; columns right to left in a + /// document bound on the right). Column, /// The document structure (`/S`; annotation order when the file has none). Structure, @@ -23,6 +25,12 @@ impl TabOrder { _ => TabOrder::Annotations, } } + + /// The tab order of the page at `r`, with an indirect `/Tabs` resolved. + fn of_page(doc: &Document, r: ObjRef) -> TabOrder { + let tabs = doc.get(r).as_dict().and_then(|d| d.get(b"Tabs")).map(|o| doc.resolve(o)); + TabOrder::of(tabs.as_deref().and_then(Object::as_name)) + } } /// A widget on a page for tab ordering: (page, field, widget, rect, annotation index). @@ -38,13 +46,20 @@ pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::col } } } + // Rows and columns run right to left in a document bound on the right: rank the mirrored + // rectangles there. + if right_to_left(doc) { + for i in &mut items { + i.3 = [-i.3[2], i.3[1], -i.3[0], i.3[3]]; + } + } let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len()); for (pi, p) in pages.iter().enumerate() { let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect(); if on.is_empty() { continue; } - let tabs = TabOrder::of(doc.get(*p).as_dict().and_then(|d| d.name(b"Tabs"))); + let tabs = TabOrder::of_page(doc, *p); match tabs { TabOrder::Row | TabOrder::Column => { let row = tabs == TabOrder::Row; @@ -87,6 +102,13 @@ pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::col } } +/// Whether the document is bound on the right (`/ViewerPreferences /Direction /R2L`). +fn right_to_left(doc: &Document) -> bool { + let Some(root) = doc.root() else { return false }; + let Some(prefs) = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences")).map(|o| doc.resolve(o)) else { return false }; + prefs.as_dict().and_then(|p| p.get(b"Direction")).map(|o| doc.resolve(o)).is_some_and(|d| d.as_name() == Some(b"R2L")) +} + /// Set the tab order of pages (0-based). pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> { let refs = page_refs(doc); @@ -104,6 +126,11 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re Ok(()) } +/// The tab order of each page, in page order (`/Tabs`; a page without one is unspecified). +pub fn tab_orders(doc: &Document) -> Vec { + page_refs(doc).into_iter().map(|r| TabOrder::of_page(doc, r)).collect() +} + /// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field): /// move field `name` one place earlier or later in its page's tab order. The page switches to /// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index d8199f15d3..e8ab3b57ea 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1875,6 +1875,73 @@ fn tab_order_follows_the_page_setting() { assert!(set_tab_order(&mut doc.clone(), &[3], TabOrder::Row).is_err()); } +#[test] +fn rows_and_columns_tab_right_to_left_in_a_document_bound_on_the_right() { + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + // A (50,700) B (300,700) + // C (50,600) D (300,600) + for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] { + add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap(); + } + let order = |doc: &Document| -> String { + let mut all: Vec<(usize, String)> = fields(doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect(); + all.sort(); + all.into_iter().map(|x| x.1).collect() + }; + let root = doc.root().unwrap(); + let mut prefs = pdfcraft_cos::Dict::new(); + prefs.set(b"Direction".to_vec(), Object::name("R2L")); + let prefs = doc.add(prefs); + doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), Object::Ref(prefs))).unwrap(); + set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap(); + assert_eq!(order(&doc), "BADC", "rows top to bottom, each right to left"); + set_tab_order(&mut doc, &[0], TabOrder::Column).unwrap(); + assert_eq!(order(&doc), "BDAC", "columns right to left, each top to bottom"); + assert_eq!(order(&reopen(&doc)), "BDAC"); + set_tab_order(&mut doc, &[0], TabOrder::Annotations).unwrap(); + assert_eq!(order(&doc), "CADB", "unspecified keeps annotation order"); + doc.update_dict(prefs, |p| p.set(b"Direction".to_vec(), Object::name("L2R"))).unwrap(); + set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap(); + assert_eq!(order(&doc), "ABCD", "bound on the left"); +} + +#[test] +fn tab_orders_are_read_for_each_page() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /C >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /S >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /W >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs 8 0 R >>".into(), + "/R".into(), + ]); + use TabOrder::{Annotations, Column, Row, Structure}; + assert_eq!(tab_orders(&doc), [Column, Annotations, Structure, Annotations, Row], "no /Tabs, or an unknown name: unspecified"); + set_tab_order(&mut doc, &[1], Row).unwrap(); + set_tab_order(&mut doc, &[0, 4], Annotations).unwrap(); + let doc = reopen(&doc); + assert_eq!(tab_orders(&doc), [Annotations, Row, Structure, Annotations, Annotations]); +} + +#[test] +fn an_indirect_tabs_value_orders_the_page() { + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] { + add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap(); + } + let column = doc.add(Object::name("C")); + let page = page_refs(&doc)[0]; + doc.update_dict(page, |p| p.set(b"Tabs".to_vec(), Object::Ref(column))).unwrap(); + let mut all: Vec<(usize, String)> = fields(&doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect(); + all.sort(); + assert_eq!(all.into_iter().map(|x| x.1).collect::(), "ACBD", "columns, as a direct /C orders them"); + assert_eq!(tab_orders(&doc), [TabOrder::Column]); +} + #[test] fn options_tab_flags_alignment_and_defaults() { let mut doc = one_page(); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5de2485857..f846b489b8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -194,6 +194,7 @@ impl PdfCraftApp { v_pressed: false, attach_override: None, duplicate_draft: None, + page_props_draft: None, field_table_draft: None, form_preview: false, window_title: String::new(), diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index bb5a65cb34..c94e77ffbb 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -120,6 +120,11 @@ impl PdfCraftApp { self.execute("page.number"); Some(Dialog::NumberPages) } + "page-properties" => { + // Same path as the panel, so the selected pages and their order are seeded. + self.execute("page.properties"); + self.dialog + } "none" => None, _ => Some(Dialog::About), } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index acccbcc55a..0889a447d2 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -355,6 +355,20 @@ impl PdfCraftApp { } self.dialog = Some(Dialog::NumberPages); } + "page.properties" => { + if let Some(i) = active + && let Some(doc) = self.session.get(self.views[i].id) + { + let pages = self.views[i].target_pages(); + let orders = doc.tab_orders(); + let mut theirs = pages.iter().map(|p| orders.get(*p).copied()); + let first = theirs.next().flatten(); + let before = if theirs.all(|o| o == first) { first } else { None }; + let generation = doc.edit_generation(); + self.page_props_draft = Some(crate::PagePropsDraft { doc: self.views[i].id, generation, pages, before, tab_order: before }); + self.dialog = Some(Dialog::PageProps); + } + } "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), "page.organize" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c21604a4da..2a1c16003c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -52,6 +52,7 @@ struct Pending { summarize_now: bool, optimize_now: bool, duplicate_now: Option, + page_props_now: Option<(pdfcraft_engine::DocId, u64, Edit)>, replace_now: bool, open_revision: Option, a11y_now: bool, @@ -218,6 +219,19 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m 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; @@ -1643,6 +1657,15 @@ fn finish_actions(app: &mut PdfCraftApp, req: &mut Pending) { 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(); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a151f8ecd6..71dd140752 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -127,8 +127,8 @@ use state::CopyPage; #[cfg(not(target_arch = "wasm32"))] use state::OpenRequest; pub use state::{ - CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PasswordPrompt, PendingLink, - PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt, + CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt, + PendingLink, PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt, }; use state::{DocumentCopyJob, GridFile}; pub use tools::bulk_fields; @@ -529,6 +529,8 @@ pub struct PdfCraftApp { pub attach_override: Option<(String, Vec)>, /// Duplicate Field: which field and onto which pages. pub duplicate_draft: Option, + /// Page Properties: which pages, and the tab order chosen for them. + pub page_props_draft: Option, /// Text field table: the table being set up, and the page it goes on. pub field_table_draft: Option, /// Prepare a form ▸ Preview: fill the form instead of editing its fields. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f58a7d972f..7c5b2a03d7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -190,6 +190,7 @@ const PAGES: &[&str] = &[ "page.crop", "page.boxes", "page.number", + "page.properties", SEP, "bookmark.add", "bookmark.from_structure", diff --git a/crates/ui-egui/src/panels/organize_panel.rs b/crates/ui-egui/src/panels/organize_panel.rs index 338f64bc14..77f1b250d5 100644 --- a/crates/ui-egui/src/panels/organize_panel.rs +++ b/crates/ui-egui/src/panels/organize_panel.rs @@ -159,7 +159,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { if row(ui, t, "tag", tl!("Page labels"), Availability::Ready, false).clicked() { act = Some(Act::Command("page.number")); } - row(ui, t, "info", tl!("Page properties"), Availability::Later, false); + if row(ui, t, "info", tl!("Page properties"), Availability::Ready, false).clicked() { + act = Some(Act::Command("page.properties")); + } }); if close { @@ -182,6 +184,30 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } } +/// Page Properties ▸ Tab Order for the pages it opened on: Use Row Order, Use Column Order, Use +/// Document Structure and Unspecified, then OK and Cancel. OK is off until a choice is made. +/// Returns (OK, Cancel). +pub(crate) fn page_props_body(ui: &mut egui::Ui, d: &mut crate::PagePropsDraft) -> (bool, bool) { + use pdfcraft_engine::TabOrder; + ui.label(egui::RichText::new(tl!("Page Properties")).font(theme::semibold(18.0))); + widgets::section_title(ui, tl!("Tab Order")); + for (order, label) in [ + (TabOrder::Row, tl!("Use Row Order")), + (TabOrder::Column, tl!("Use Column Order")), + (TabOrder::Structure, tl!("Use Document Structure")), + (TabOrder::Annotations, tl!("Unspecified")), + ] { + widgets::radio_value(ui, &mut d.tab_order, Some(order), label); + } + let (mut ok, mut cancel) = (false, false); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ok = ui.add_enabled_ui(d.tab_order.is_some(), |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked(); + cancel = widgets::pill_button(ui, tl!("Cancel"), false).clicked(); + }); + (ok, cancel) +} + /// Whether a row works in this build. #[derive(Clone, Copy, PartialEq, Eq)] enum Availability { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index dd3b3e90aa..d41d511a97 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -109,6 +109,8 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Organize pages ▸ Page properties (the tab order of the selected pages). + PageProps, /// Protect Using Password. Protect, /// Set Page Boxes (crop, trim, bleed, art, media). @@ -213,6 +215,18 @@ pub struct DuplicateDraft { pub to: usize, } +/// Page Properties: the document and pages it applies to (0-based), the document's edit +/// generation when the dialog opened, and the pages' tab order as they have it (`None` when they +/// differ) and as chosen (`None` until one is chosen). +#[derive(Clone, Debug, PartialEq)] +pub struct PagePropsDraft { + pub doc: DocId, + pub generation: u64, + pub pages: Vec, + pub before: Option, + pub tab_order: Option, +} + /// Copied pages: their document's name and bytes (as it was when copied) and the pages. #[derive(Clone, Debug)] pub struct PageClip { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index e5e104d674..b181bcb22e 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6206,6 +6206,125 @@ fn organize_panel_comes_with_the_grid_and_closes_it() { assert!(h.query_by_label("Page properties").is_none(), "the panel is gone"); } +/// Organize pages ▸ Page properties ▸ Tab Order sets the tab order of the selected pages only, +/// as one undo step, and opens on the order the pages have. +#[test] +fn page_properties_sets_the_tab_order_of_the_selected_pages() { + use pdfcraft_engine::TabOrder::{Annotations, Column, Row}; + let tabs = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().tab_orders(); + // The undo and redo labels: a new step replaces the redo step an undo left. + let undo = |h: &Harness<'static, PdfCraftApp>| { + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + (doc.can_undo().map(str::to_owned), doc.can_redo().map(str::to_owned)) + }; + let mut h = organize(3); + h.get_by_label("Select page 1").click(); + h.run_steps(2); + h.get_by_label("Select page 3").click(); + h.run_steps(2); + h.get_by_label("Page properties").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::PageProps)); + assert_eq!(h.state().page_props_draft.as_ref().unwrap().tab_order, Some(Annotations), "opens on Unspecified, as the pages have it"); + h.get_by_label("Use Column Order").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + assert_eq!(tabs(&h), [Column, Annotations, Column]); + assert_eq!(undo(&h).0.as_deref(), Some("Set tab order")); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(tabs(&h), [Annotations; 3], "one undo step"); + + // Pages with different orders open with no choice made, and OK is off until one is. + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetTabOrder { pages: vec![0], order: Row }); + h.run_steps(2); + h.state_mut().views[0].select_pages(&[0, 1]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + assert_eq!(h.state().page_props_draft.as_ref().unwrap().tab_order, None); + assert!(h.get_by_label("OK").accesskit_node().is_disabled()); + h.get_by_label("Use Row Order").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(tabs(&h), [Row, Row, Annotations]); + + // OK on the order the pages already have adds no undo step, and Cancel changes nothing. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + let before = (tabs(&h), undo(&h)); + assert_eq!(before.1.1.as_deref(), Some("Set tab order"), "the undone step can be redone"); + h.state_mut().views[0].select_pages(&[2]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!((tabs(&h), undo(&h)), before); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("Use Document Structure").click(); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + assert_eq!((tabs(&h), undo(&h)), before); +} + +/// Page properties sets the tab order of the document it was opened on: when another document +/// is active by OK, neither document changes. +#[test] +fn page_properties_does_not_apply_to_another_active_document() { + use pdfcraft_engine::TabOrder::Annotations; + let mut h = organize(3); + let first = h.state().views[0].id; + h.state_mut().views[0].select_pages(&[0]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("Use Column Order").click(); + h.run_steps(2); + h.state_mut().open_bytes("other.pdf", None, fixture(3)).unwrap(); + h.run_steps(4); + let other = h.state().views[1].id; + assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(other)); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + for id in [first, other] { + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.tab_orders(), [Annotations; 3]); + assert!(doc.can_undo().is_none()); + } +} + +/// Page properties sets the tab order of the pages it was opened on: when an edit changes the +/// document while the dialog is open, OK closes it without changing a page. +#[test] +fn page_properties_does_not_apply_after_the_document_changes() { + use pdfcraft_engine::TabOrder::Annotations; + let mut h = organize(3); + let id = h.state().views[0].id; + h.state_mut().views[0].select_pages(&[0]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("Use Column Order").click(); + h.run_steps(2); + // Page 1 becomes page 3, so index 0 is another page now. + assert!(h.state_mut().execute("page.reverse")); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.tab_orders(), [Annotations; 3]); + assert_ne!(doc.can_undo(), Some("Set tab order")); + assert_eq!( + h.state().toast.as_ref().map(|t| t.0.as_str()), + Some("The document changed while the dialog was open, so nothing was changed. Open Page properties again to set the tab order.") + ); +} + /// Leaving Organize (a page opened from the grid, or the grid's Close) goes back to the /// workspace's panel: under the Edit tab, the Edit panel, not All tools. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d9fbbd1291..15a2ed7d8d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2656,7 +2656,7 @@ status = "shipped" commands = ["page.organize"] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/it/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/it/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/it/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/it/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/it/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"] -notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages and Page labels. Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions, Page templates and Page properties are listed but planned." +notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages, Page labels and Page properties (the selected pages' tab order). Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions and Page templates are listed but planned." [[feature]] id = "organize.multi-select-pages" @@ -3240,11 +3240,11 @@ title = "Page tab order property" area = "D" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.properties"] +tools = ["form_tab_order"] +evidence = ["crates/ui-egui/tests/it/editing.rs::page_properties_sets_the_tab_order_of_the_selected_pages", "crates/forms/src/tests.rs::tab_orders_are_read_for_each_page", "crates/forms/src/tests.rs::rows_and_columns_tab_right_to_left_in_a_document_bound_on_the_right", "crates/forms/src/tests.rs::an_indirect_tabs_value_orders_the_page", "crates/automation/tests/it/automation.rs::page_tab_orders_are_read_and_set_per_page_through_tools"] +notes = "Organize pages ▸ Page properties ▸ Tab Order sets the selected pages' order: Use Row Order, Use Column Order, Use Document Structure or Unspecified, as one undo step. It opens on the pages' order, or with no choice when they differ. Rows and columns run right to left in a document bound on the right. form_tab_order reads each page's order (page_tabs) and sets it for chosen pages. Acrobat's Page Properties also has an Actions tab (organize.page-actions), still planned." [[feature]] id = "organize.page-actions"