diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 04dc83cd9..be1ef0cfa 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -64,13 +64,12 @@ impl Session { let (mut ra, mut rb) = (renderer(a), renderer(b)); let scale = dpi.clamp(18.0, 150.0) / 72.0; let mut out = Vec::new(); - for page in 0..a.info.pages.len().min(b.info.pages.len()) { + for (page, info) in b.info.pages.iter().enumerate().take(a.info.pages.len()) { let req = pdfcraft_render::RenderRequest { page, scale, ..Default::default() }; let (x, y) = (ra.render(req), rb.render(req)); if x.error.is_some() || y.error.is_some() { continue; } - let info = &b.info.pages[page]; let s = y.width as f32 / info.width.max(1e-3); for r in pdfcraft_compare::visual_regions((&y.rgba, y.width, y.height), (&x.rgba, x.width, x.height), 24) { let p = info.view_to_user(r[0] as f32 / s, r[1] as f32 / s); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f829c9a3b..adb67fb52 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -499,6 +499,8 @@ pub enum ViewAction { InsertFromFile, /// Insert files at a gap of the page grid (0 = before the first page). InsertFromFileAt(usize), + /// Replace the selected pages with pages from another file. + ReplacePages, /// Save the document as the grid shows it. Save, Extract, @@ -3805,6 +3807,9 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b if icons::button(ui, "file-input", 30.0, false, tl!("Insert pages from a file…")).clicked() { view.pending_action = Some(ViewAction::InsertFromFile); } + if icons::button(ui, "replace", 30.0, false, tl!("Replace pages…")).clicked() { + view.pending_action = Some(ViewAction::ReplacePages); + } if icons::button(ui, "file-output", 30.0, false, tl!("Extract pages to a new document")).clicked() { view.pending_action = Some(ViewAction::Extract); } @@ -4085,12 +4090,21 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool if resp.double_clicked() { open_page = Some(i); } - // Right-click: Cut, Copy, Paste (on the selection, or this page). + // Right-click: Extract, Replace, Cut, Copy, Paste (on the selection, or this page). resp.context_menu(|ui| { if !view.selected.contains(&i) { view.selected = [i].into(); view.current = i; } + if ui.button(tl!("Extract pages")).clicked() { + view.pending_action = Some(ViewAction::Extract); + ui.close(); + } + if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() { + view.pending_action = Some(ViewAction::ReplacePages); + ui.close(); + } + ui.separator(); if ui.add_enabled(editable, egui::Button::new(tl!("Cut"))).clicked() { view.pending_action = Some(ViewAction::CopyPages { cut: true }); ui.close(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 41126bd05..81115124c 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -204,6 +204,7 @@ impl PdfCraftApp { match self.views.get_mut(i).and_then(|v| v.pending_action.take()) { Some(crate::canvas::ViewAction::InsertFromFile) => self.insert_from_file_dialog(), Some(crate::canvas::ViewAction::InsertFromFileAt(at)) => self.insert_from_file_at(Some(at)), + Some(crate::canvas::ViewAction::ReplacePages) => self.replace_pages_dialog(), Some(crate::canvas::ViewAction::Save) => { self.save_active(SaveTarget::InPlace); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index aebf5ec6f..f3d2cd130 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1045,6 +1045,10 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie view.pending_action = Some(crate::canvas::ViewAction::Extract); ui.close(); } + if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() { + view.pending_action = Some(crate::canvas::ViewAction::ReplacePages); + ui.close(); + } ui.separator(); if ui.add_enabled(editable, egui::Button::new(tl!("Cut"))).clicked() { view.pending_action = Some(crate::canvas::ViewAction::CopyPages { cut: true }); diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index c1ae53b76..4af458950 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -196,7 +196,7 @@ impl PdfCraftApp { let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); let mut edits = Vec::new(); let author = self.comment_prefs.author.clone(); - for page in 0..doc.info.pages.len() { + for (page, page_info) in doc.info.pages.iter().enumerate() { let out = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); let Some(text) = out.text else { continue }; let hits = match d.mode { @@ -204,7 +204,7 @@ impl PdfCraftApp { _ => needles.iter().flat_map(|w| text.find(w)).collect(), }; for h in hits { - let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| doc.info.pages[page].view_rect_to_quad(r)).collect(); + let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| page_info.view_rect_to_quad(r)).collect(); if !quads.is_empty() { edits.push(self.redact_prefs.mark(page, quads, &author)); } diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index ba4bbefa0..40e85436e 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1324,6 +1324,84 @@ fn extract_button_copies_selected_pages_to_a_new_tab() { assert!(!app.session.get(app.views[0].id).unwrap().dirty); } +#[test] +fn replace_button_replaces_selected_pages_in_organize() { + let dir = temp_path("replace-btn"); + std::fs::create_dir_all(&dir).unwrap(); + let other = dir.join("other.pdf"); + std::fs::write(&other, fixture(5)).unwrap(); + + let mut h = organize(3); + h.get_by_label("Page 2").click(); + h.run_steps(1); + h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + h.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]); + h.get_by_label("Replace pages…").click(); + h.run_steps(3); + egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages"); + assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2); + assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 3); + egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.info.pages.len(), 3); + assert_eq!(doc.can_undo(), Some("Replace pages")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn replace_pages_from_thumbnail_context_menu() { + let dir = temp_path("replace-ctx"); + std::fs::create_dir_all(&dir).unwrap(); + let other = dir.join("other.pdf"); + std::fs::write(&other, fixture(5)).unwrap(); + + let mut h = organize(3); + h.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]); + h.get_by_label("Page 2").click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Replace pages…").last().unwrap().click(); + h.run_steps(3); + egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages"); + assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2); + assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 2); + egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.info.pages.len(), 3); + assert_eq!(doc.can_undo(), Some("Replace page")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn replace_pages_from_pages_panel_context_menu() { + let dir = temp_path("replace-panel-ctx"); + std::fs::create_dir_all(&dir).unwrap(); + let other = dir.join("other.pdf"); + std::fs::write(&other, fixture(5)).unwrap(); + + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + h.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]); + let thumb = h.get_all_by_label("Page 2").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Replace pages…").last().unwrap().click(); + h.run_steps(3); + egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages"); + assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2); + assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 2); + egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.info.pages.len(), 4); + assert_eq!(doc.can_undo(), Some("Replace page")); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn inserting_a_file_goes_after_the_selection_and_undoes() { let mut h = organize(2); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1ab0ea92a..0e68fc81d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2770,7 +2770,7 @@ milestone = "M4" status = "shipped" commands = ["page.replace"] tools = ["page_replace"] -evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/automation/tests/automation.rs::replacing_pages_through_tools"] +evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools"] notes = "content, resources and boxes replaced; links, comments, fields and bookmarks kept" [[feature]]