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
@@ -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 {@@ -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);+ }+}@@ -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.@@ -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")] {@@ -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]