collective

Browse / github.com/storytold/pdfcraft / patch

feat(ui): add option to replace existing images in a PDF (#4900)

feat(ui): add option to replace existing images in a PDF (#4900) ## What this changes Adds the "Replace Image…" action to the canvas right-click context menu and floating toolbar for selected page pictures (picked_image), allowing users to replace existing PDF images seamlessly with a chosen image file. Closes #4900 ## How you verified it - [x] cargo fmt --check - [x] clippy with -D warnings on the crates I changed - [x] tests pass on the crates I changed - [x] cargo xtask layers and cargo xtask assets (if you touched crate boundaries or any asset) Ran: - cargo fmt --check - cargo clippy -p pdfcraft-ui-egui --all-targets -- -D warnings - cargo test -p

Status
open
Score
0.00
Adopters
0 other operators
Author
YusufMntm (imported from pull request #4970, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+90 −2 in 5 files
Review
pending
Shared
2026-10-28
Id
b3dfcbf2f0518f8a30ccf41f

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/bars.rs crates/ui-egui/src/tools/comments/menu.rs crates/ui-egui/src/tools/content_ui.rs crates/ui-egui/tests/it/viewer.rs

diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -43,7 +43,7 @@ use area::{ }; #[cfg(test)] use bars::in_size_hint_zone;-use bars::{Notice, notices, page_size_hint, quick_bar};+use bars::{Notice, notices, page_size_hint, picked_image_bar, quick_bar}; pub use cache::RasterMemory; pub(crate) use cache::{BASE_SIDE, TILE_THRESHOLD, texture_image, thumbnail_rows}; #[cfg(test)]@@ -2241,6 +2241,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {                     }                 };                 if let Some(image) = hit {+                    if ui.button(tl!("Replace Image…")).clicked() {+                        copy_image = Some(comments::CanvasAction::ReplaceImage(page, image));+                        ui.close();+                    }                     if ui.button(tl!("Copy Image")).clicked() {                         copy_image = Some(comments::CanvasAction::CopyImage(page, image));                         ui.close();@@ -2496,6 +2500,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {         Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)),         Some(comments::CanvasAction::CopyImage(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::Copy(p, i)),         Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)),+        Some(comments::CanvasAction::ReplaceImage(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::Replace(p, i)),         None => {}     }     // Full screen: the click turns the page unless it did something else first. It waits out the@@ -2647,6 +2652,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {     // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459);     // the quick tools come back with the window.     if app.mode != crate::Mode::Read && !app.full_screen {+        picked_image_bar(app, ui);         quick_bar(app, avail, ui);     }     if let Some((source, file, destination)) = remote_open {diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs--- a/crates/ui-egui/src/canvas/bars.rs+++ b/crates/ui-egui/src/canvas/bars.rs@@ -485,3 +485,38 @@ pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {             });     }); }++/// Floating toolbar for a selected picture (`picked_image`).+pub(super) fn picked_image_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {+    let Some((view_idx, doc_id)) = app.active_ids() else { return };+    let Some(picked) = app.views.get(view_idx).and_then(|v| v.picked_image) else { return };+    let Some(doc) = app.session.get(doc_id) else { return };+    let Some(xf) = app.views.get(view_idx).and_then(|v| v.page_xform(picked.page)) else { return };+    if doc.info.pages.get(picked.page).is_none() || !picked.rect.iter().all(|v| v.is_finite()) {+        return;+    }+    let r = xf.user_rect(&doc.info, picked.page, picked.rect.map(|v| v as f32));+    let t = Tokens::get(ui.ctx());+    let pos = pos2(r.center().x - 40.0, (r.top() - 36.0).max(10.0));+    let mut replace = false;+    let can_modify = doc.allows_modification();+    egui::Area::new(egui::Id::unique("picked-image-bar")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {+        egui::Frame::NONE+            .fill(t.card)+            .stroke(Stroke::new(1.0, t.border))+            .corner_radius(CornerRadius::same(8))+            .shadow(egui::Shadow { offset: [0, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(if t.dark() { 80 } else { 20 }) })+            .inner_margin(egui::Margin::same(4))+            .show(ui, |ui| {+                ui.horizontal(|ui| {+                    ui.spacing_mut().item_spacing.x = 2.0;+                    if ui.add_enabled_ui(can_modify, |ui| icons::button(ui, "replace", 24.0, false, tl!("Replace image…"))).inner.clicked() {+                        replace = true;+                    }+                });+            });+    });+    if replace {+        app.replace_image_dialog(picked.page, picked.index);+    }+}diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs--- a/crates/ui-egui/src/tools/comments/menu.rs+++ b/crates/ui-egui/src/tools/comments/menu.rs@@ -137,6 +137,8 @@ pub enum CanvasAction {     CopyImage(usize, usize),     /// Copy Image in Original Format: the same image as a file (JPEG as stored, else PNG).     CopyImageFile(usize, usize),+    /// Replace image: image (page, index in `page_images`) with a chosen image file.+    ReplaceImage(usize, usize), }  /// How many custom colours the "Recent colours" row keeps.diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs--- a/crates/ui-egui/src/tools/content_ui.rs+++ b/crates/ui-egui/src/tools/content_ui.rs@@ -867,7 +867,16 @@ impl crate::PdfCraftApp {     pub fn replace_image_dialog(&mut self, page: usize, index: usize) {         #[cfg(not(target_arch = "wasm32"))]         self.pick_image(tl!("Replace image"), move |app, name, bytes| {-            app.apply_edit(Edit::ReplaceImage { page, index, name, bytes: std::sync::Arc::new(bytes) });+            let Some((_, id)) = app.active_ids() else { return };+            let is_added = app.session.get(id).is_some_and(|d| {+                d.added().iter().filter(|a| a.page == page).nth(index).is_some_and(|a| matches!(a.content, pdfcraft_engine::AddedContent::Image(_)))+            });+            let edit = if is_added {+                Edit::ReplaceImage { page, index, name, bytes: std::sync::Arc::new(bytes) }+            } else {+                Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Replace { name, bytes: std::sync::Arc::new(bytes) } }+            };+            app.apply_edit(edit);         });         #[cfg(target_arch = "wasm32")]         {diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs--- a/crates/ui-egui/tests/it/viewer.rs+++ b/crates/ui-egui/tests/it/viewer.rs@@ -1830,6 +1830,42 @@ fn right_click_on_a_picture_copies_it_at_full_size() {     assert_eq!(rgba, px.into_raw(), "the image's own pixels, not a rendering of the page"); } +#[test]+fn right_click_and_floating_bar_offer_replace_image() {+    let (_, mut h) = picture_harness();+    let i = h.state().active.unwrap();+    let at = h.state().views[i].page_screen_rect(0).expect("on screen").center();+    // 1. Right-click context menu offers "Replace Image…"+    h.hover_at(at);+    h.run_steps(1);+    h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });+    h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });+    h.run_steps(2);+    h.get_by_label("Replace Image…");+    h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });+    h.run_steps(2);++    // 2. Left-click selects the picture and floating bar offers "Replace image…"+    h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });+    h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });+    h.run_steps(2);+    assert!(h.state().views[i].picked_image.is_some(), "picked with the Select tool");+    h.get_by_label("Replace image…");++    // 3. Applying replacement updates image data+    let png = {+        let mut buf = std::io::Cursor::new(Vec::new());+        image::RgbaImage::from_pixel(10, 10, image::Rgba([0, 255, 0, 255])).write_to(&mut buf, image::ImageFormat::Png).unwrap();+        buf.into_inner()+    };+    let replace = pdfcraft_engine::Edit::EditPageImage {+        page: 0,+        index: 0,+        change: pdfcraft_engine::ImageEdit::Replace { name: "green.png".into(), bytes: std::sync::Arc::new(png) },+    };+    assert!(h.state_mut().apply_edit(replace));+}+ /// A picture drawn through grouped Form artwork with a caption over it (as Word, InDesign and /// scanners with OCR write them): one click still selects it, and ⌘C copies the picture. #[test]