diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 92af4f15..1066b2e0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (217) +## In this repository (219) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -14,6 +14,8 @@ Every asset PrintCraft includes, bundles or uses to build its published material | `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/flip-horizontal-2.svg` | Lucide icon "flip-horizontal-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/flip-vertical-2.svg` | Lucide icon "flip-vertical-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 058fba17..fa733d80 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -74,6 +74,28 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "401a4ceb06a88210638e3264e05f2a5281acad42df5c96363160db75f7fd105a" +[[asset]] +path = "assets/icons/flip-horizontal-2.svg" +title = "Lucide icon \"flip-horizontal-2\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "b50af0f3281f7d639a2958c12f0336e41ba889a6f4ff277de16d5c3324c43778" + +[[asset]] +path = "assets/icons/flip-vertical-2.svg" +title = "Lucide icon \"flip-vertical-2\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "1372eabd5f05d58ec554a6f67da83337367f8c818b2ada7d1591c0ba227ca0a6" + [[asset]] path = "assets/icons/app-window.svg" title = "Lucide icon \"app-window\"" diff --git a/assets/icons/flip-horizontal-2.svg b/assets/icons/flip-horizontal-2.svg new file mode 100644 index 00000000..c33c0de7 --- /dev/null +++ b/assets/icons/flip-horizontal-2.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/assets/icons/flip-vertical-2.svg b/assets/icons/flip-vertical-2.svg new file mode 100644 index 00000000..0264c3ea --- /dev/null +++ b/assets/icons/flip-vertical-2.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 454c33a0..52e872a8 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -149,13 +149,47 @@ impl Automation { Some(r) => content.with_rect(to_display(r, ph)), None => content, }; - if let AddedContent::Text(t) = &mut content { - if let Some(s) = a.opt_str("text")? { - t.text = s.to_string(); + match &mut content { + AddedContent::Text(t) => { + if let Some(s) = a.opt_str("text")? { + t.text = s.to_string(); + } + style(a, t)?; + if ["rotate", "flip_h", "flip_v", "crop", "image"].iter().any(|k| a.get(k).is_some()) { + return Err(bad("rotate, flip, crop and image apply to images")); + } + } + AddedContent::Image(i) => { + if a.get("text").is_some() { + return Err(bad("images have no text")); + } + if let Some(deg) = a.opt_int("rotate")? { + if deg.rem_euclid(90) != 0 { + return Err(bad("rotate must be a multiple of 90")); + } + i.rotation = ((i64::from(i.rotation) * 90 + deg).rem_euclid(360) / 90) as u8; + } + if a.opt_bool("flip_h")? == Some(true) { + i.flip_h = !i.flip_h; + } + if a.opt_bool("flip_v")? == Some(true) { + i.flip_v = !i.flip_v; + } + if let Some(c) = rect_arg(a, "crop")? { + if !c.iter().all(|v| (0.0..0.5).contains(v)) { + return Err(bad("crop takes fractions from 0 to under 0.5 (left, bottom, right, top)")); + } + i.crop = c; + } + if let Some(p) = a.opt_str("image")? { + let path = self.resolve(p, false)?; + let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + // Box and transforms first, then the new picture. + self.apply(a, Edit::UpdateContent { page, index, content: content.clone() })?; + return self.apply(a, Edit::ReplaceImage { page, index, name, bytes: std::sync::Arc::new(bytes) }); + } } - style(a, t)?; - } else if a.get("text").is_some() { - return Err(bad("images have no text")); } self.apply(a, Edit::UpdateContent { page, index, content }) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b2b1efc9..522c171f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -378,7 +378,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "path": { "type": "string" }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 } }), &["doc", "page", "path"], )), - t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align) an added item (page, index from content_list). Undoable.") + t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, @@ -386,6 +386,9 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right"] }, + "rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" }, + "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 }, + "image": { "type": "string", "description": "Replace the picture with this file." }, }), &["doc", "page", "index"], )), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 80abdefa..5f9c71e5 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -709,6 +709,15 @@ fn adding_content_through_tools() { ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "text": "DRAFT", "font": "times" })); let text = page_text(&mut a, doc)[0].clone(); assert!(text.contains("DRAFT") && !text.contains("CONFIDENTIAL"), "{text}"); + // Image tools: rotate, flip, crop, replace. + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true, "crop": [0.1, 0, 0.1, 0] })); + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "image": "logo.png" })); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], true); + assert!(matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "rotate": 45 })), Err(ToolError::InvalidArgs(_)))); + assert!( + matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 1, "rotate": 90 })), Err(ToolError::InvalidArgs(_))), + "text doesn't rotate" + ); ok(&mut a, "content_delete", json!({ "doc": doc, "page": 1, "index": 2 })); assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1); assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_)))); diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index fb95f9ac..ab676c87 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] printcraft-cos = { workspace = true } +printcraft-content = { workspace = true } printcraft-fonts = { workspace = true } printcraft-model = { workspace = true } thiserror = { workspace = true } diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 852b1b77..11673092 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -116,6 +116,39 @@ pub struct AddedImage { pub rect: [f64; 4], /// The image XObject. pub image: ObjRef, + /// Quarter turns counter-clockwise (0–3). + pub rotation: u8, + pub flip_h: bool, + pub flip_v: bool, + /// The fraction trimmed from each side of the image: left, bottom, right, top. + pub crop: [f64; 4], +} + +impl AddedImage { + pub fn new(rect: [f64; 4], image: ObjRef) -> Self { + AddedImage { rect, image, rotation: 0, flip_h: false, flip_v: false, crop: [0.0; 4] } + } + + /// The matrix from image space (the unit square) to display space: crop, flip, rotate, + /// then fill the box. + fn matrix(&self) -> printcraft_content::Matrix { + use printcraft_content::Matrix as M; + let [l, b, r, t] = self.crop.map(|v| v.clamp(0.0, 0.45)); + let (cw, ch) = ((1.0 - l - r).max(0.05), (1.0 - b - t).max(0.05)); + let mut m = M([1.0 / cw, 0.0, 0.0, 1.0 / ch, -l / cw, -b / ch]); + if self.flip_h { + m = m.then(&M([-1.0, 0.0, 0.0, 1.0, 1.0, 0.0])); + } + if self.flip_v { + m = m.then(&M([1.0, 0.0, 0.0, -1.0, 0.0, 1.0])); + } + for _ in 0..self.rotation % 4 { + // A quarter turn counter-clockwise within the unit square. + m = m.then(&M([0.0, 1.0, -1.0, 0.0, 1.0, 0.0])); + } + let [x0, y0, x1, y1] = norm(self.rect); + m.then(&M([x1 - x0, 0.0, 0.0, y1 - y0, x0, y0])) + } } #[derive(Clone, Debug, PartialEq)] @@ -234,7 +267,24 @@ fn draw(doc: &Document, c: &Content, view: [f64; 6]) -> Result<(Vec, Dict), let mut xo = Dict::new(); xo.set(name.clone().into_bytes(), Object::Ref(i.image)); res.set(b"XObject".to_vec(), Object::Dict(xo)); - out.extend(format!("{} 0 0 {} {} {} cm /{name} Do\n", n(r[2] - r[0]), n(r[3] - r[1]), n(r[0]), n(r[1])).bytes()); + let [a, b, c, d, e, f] = i.matrix().0; + // Clip to the box: the cropped-away parts stay hidden. + out.extend( + format!( + "{} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do\n", + n(r[0]), + n(r[1]), + n(r[2] - r[0]), + n(r[3] - r[1]), + n(a), + n(b), + n(c), + n(d), + n(e), + n(f) + ) + .bytes(), + ); } } out.extend_from_slice(b"Q\n"); @@ -265,6 +315,18 @@ fn params(c: &Content) -> Dict { d.set(b"Kind".to_vec(), Object::name("Image")); d.set(b"Image".to_vec(), Object::Ref(i.image)); d.set(b"Rect".to_vec(), arr(&norm(i.rect))); + if i.rotation % 4 != 0 { + d.set(b"Rotate".to_vec(), Object::Int(i64::from(i.rotation % 4) * 90)); + } + if i.flip_h { + d.set(b"FlipH".to_vec(), Object::Bool(true)); + } + if i.flip_v { + d.set(b"FlipV".to_vec(), Object::Bool(true)); + } + if i.crop != [0.0; 4] { + d.set(b"Crop".to_vec(), arr(&i.crop)); + } } } d @@ -292,7 +354,17 @@ fn parse(doc: &Document, d: &Dict) -> Option { }, })) } - b"Image" => Some(Content::Image(AddedImage { rect, image: d.get(b"Image")?.as_ref()? })), + b"Image" => { + let crop = nums(doc, d.get(b"Crop")); + Some(Content::Image(AddedImage { + rect, + image: d.get(b"Image")?.as_ref()?, + rotation: (d.int(b"Rotate").unwrap_or(0).rem_euclid(360) / 90) as u8, + flip_h: matches!(d.get(b"FlipH"), Some(Object::Bool(true))), + flip_v: matches!(d.get(b"FlipV"), Some(Object::Bool(true))), + crop: crop.try_into().unwrap_or([0.0; 4]), + })) + } _ => None, } } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 5d0874fd..68302113 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -186,7 +186,7 @@ fn added_text_and_images_are_page_content_that_stays_editable() { d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); let img = doc.add(Object::Stream(Stream::from_raw(d, vec![128]))); - add_content(&mut doc, 1, &Content::Image(AddedImage { rect: [10.0, 10.0, 110.0, 60.0], image: img })).unwrap(); + add_content(&mut doc, 1, &Content::Image(AddedImage::new([10.0, 10.0, 110.0, 60.0], img))).unwrap(); let doc2 = reopen(&doc); let all = list_added(&doc2); assert_eq!(all.len(), 2); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 33acf8fe..729b3585 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,7 +22,7 @@ pub use printcraft_organize::LabelStyle; pub use printcraft_cos::Algorithm; pub use printcraft_edit::{ - Added, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, + Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use printcraft_forms::{ BorderStyle, Field as FormField, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, NewField, TabOrder, @@ -595,6 +595,13 @@ pub enum Edit { page: usize, index: usize, }, + /// Replace an added image with another file (keeping its box and transforms). + ReplaceImage { + page: usize, + index: usize, + name: String, + bytes: Arc>, + }, /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. ApplyRedactions { pages: Option>, @@ -670,6 +677,7 @@ impl Edit { Edit::AddImage { .. } => "Add image".into(), Edit::UpdateContent { .. } => "Edit content".into(), Edit::DeleteContent { .. } => "Delete content".into(), + Edit::ReplaceImage { .. } => "Replace image".into(), Edit::ApplyRedactions { .. } => "Apply redactions".into(), Edit::ClearRedactions => "Remove redaction marks".into(), Edit::RemoveHidden { .. } => "Remove hidden information".into(), @@ -779,6 +787,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::AddImage { .. } | Edit::UpdateContent { .. } | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } | Edit::ClearRedactions | Edit::RemoveHidden { .. } | Edit::Sanitize @@ -948,10 +957,18 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] } }; - printcraft_edit::add_content(doc, *page, &AddedContent::Image(printcraft_edit::AddedImage { rect, image }))?; + printcraft_edit::add_content(doc, *page, &AddedContent::Image(printcraft_edit::AddedImage::new(rect, image)))?; } Edit::UpdateContent { page, index, content } => printcraft_edit::update_content(doc, *page, *index, content)?, Edit::DeleteContent { page, index } => printcraft_edit::delete_content(doc, *page, *index)?, + Edit::ReplaceImage { page, index, name, bytes } => { + let item = printcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); + let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { + return Err(EditError::Edit(printcraft_edit::EditError::Invalid("that item is not an image".into()))); + }; + let (image, _) = printcraft_create::image_xobject(doc, name, bytes)?; + printcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(printcraft_edit::AddedImage { image, ..old }))?; + } Edit::ApplyRedactions { pages } => { printcraft_redact::apply(doc, pages.as_deref())?; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0caa76c0..a59808e3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -880,3 +880,47 @@ fn added_text_and_images_render_and_stay_editable() { s.undo(id).unwrap(); assert_eq!(s.get(id).unwrap().added.len(), 2); } + +#[test] +fn added_images_rotate_flip_and_crop_as_drawn() { + // A 20×10 image: left half red, right half blue. + let mut px = Vec::new(); + for _y in 0..10 { + for x in 0..20 { + px.extend_from_slice(if x < 10 { &[255, 0, 0] } else { &[0, 0, 255] }); + } + } + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 20, 10); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&px).unwrap(); + } + let (mut s, id) = session_with(1); + // A 100×100 box at (50, 100) on the 200×300 page. + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "rb.png".into(), bytes: Arc::new(png) }).unwrap(); + let colour_at = |s: &Session, x: u32, y_from_top: u32| -> [u8; 3] { + let doc = s.get(id).unwrap(); + let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let out = r.render(printcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + let i = ((y_from_top * out.width + x) * 4) as usize; + [out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]] + }; + let red = |c: [u8; 3]| c[0] > 200 && c[2] < 60; + let blue = |c: [u8; 3]| c[2] > 200 && c[0] < 60; + // Display y 100–200 is rows 100–200 from the top on a 300 pt page. + assert!(red(colour_at(&s, 70, 150)) && blue(colour_at(&s, 130, 150)), "as placed: red left, blue right"); + let item = |s: &Session| s.get(id).unwrap().added[0].content.clone(); + let AddedContent::Image(mut img) = item(&s) else { panic!() }; + img.rotation = 1; + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap(); + assert!(red(colour_at(&s, 100, 180)) && blue(colour_at(&s, 100, 120)), "a quarter turn left: red at the bottom"); + img.rotation = 0; + img.flip_h = true; + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap(); + assert!(blue(colour_at(&s, 70, 150)) && red(colour_at(&s, 130, 150)), "flipped"); + img.flip_h = false; + img.crop = [0.0, 0.0, 0.5, 0.0]; + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap(); + assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box"); +} diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 7893b2b5..527840c7 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -400,6 +400,34 @@ impl crate::PrintCraftApp { self.notify("Adding images arrives on the web with file pickers for images"); } + /// Edit image ▸ Replace: pick a file for the selected image. + pub fn replace_image_dialog(&mut self, page: usize, index: usize) { + #[cfg(not(target_arch = "wasm32"))] + { + let picked = match self.save_override.clone() { + Some(p) if p.ends_with(".png") || p.ends_with(".jpg") => Some(std::path::PathBuf::from(p)), + Some(_) => None, + None => rfd::FileDialog::new() + .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp"]) + .set_title("Replace image") + .pick_file(), + }; + let Some(path) = picked else { return }; + match std::fs::read(&path) { + Ok(bytes) => { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.apply_edit(Edit::ReplaceImage { page, index, name, bytes: std::sync::Arc::new(bytes) }); + } + Err(e) => self.notify(format!("Couldn't read {}: {e}", path.display())), + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = (page, index); + self.notify("Replacing images arrives on the web with image file pickers"); + } + } + /// Place image `bytes` in the middle of the current page and select it. pub fn add_image(&mut self, name: String, bytes: Vec) { let Some((i, id)) = self.active_ids() else { return }; @@ -412,3 +440,63 @@ impl crate::PrintCraftApp { } } } + +/// What the image tools ask for. +pub(crate) enum ImageAction { + Update(printcraft_engine::AddedContent), + Replace, +} + +/// Edit image: rotate, flip, crop, replace (shown while an added image is selected). +pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &printcraft_engine::AddedImage) -> Option { + let mut out = None; + widgets::section_title(ui, "Edit image"); + ui.horizontal(|ui| { + let mut i = img.clone(); + let r = i.rect; + let turn = |i: &mut printcraft_engine::AddedImage, k: u8| { + i.rotation = (i.rotation + k) % 4; + // The box turns with the picture, around its centre. + let (cx, cy, w, h) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0, r[2] - r[0], r[3] - r[1]); + i.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0]; + }; + if crate::icons::button(ui, "rotate-ccw", 28.0, false, "Rotate counterclockwise").clicked() { + turn(&mut i, 1); + out = Some(ImageAction::Update(AddedContent::Image(i.clone()))); + } + if crate::icons::button(ui, "rotate-cw", 28.0, false, "Rotate clockwise").clicked() { + turn(&mut i, 3); + out = Some(ImageAction::Update(AddedContent::Image(i.clone()))); + } + if crate::icons::button(ui, "flip-horizontal-2", 28.0, false, "Flip horizontal").clicked() { + i.flip_h = !i.flip_h; + out = Some(ImageAction::Update(AddedContent::Image(i.clone()))); + } + if crate::icons::button(ui, "flip-vertical-2", 28.0, false, "Flip vertical").clicked() { + i.flip_v = !i.flip_v; + out = Some(ImageAction::Update(AddedContent::Image(i.clone()))); + } + if crate::icons::button(ui, "replace", 28.0, false, "Replace image").clicked() { + out = Some(ImageAction::Replace); + } + }); + ui.label(egui::RichText::new("Crop (% trimmed from each side)").small().color(t.text_muted)); + let mut crop = img.crop.map(|v| (v * 100.0).round()); + let mut changed = false; + ui.horizontal(|ui| { + for (k, label) in ["L", "B", "R", "T"].into_iter().enumerate() { + ui.label(label); + // Applied when the drag ends, so a drag is one undo step. + let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=45.0).speed(0.5).suffix("%")); + changed |= r.drag_stopped() || (r.changed() && !r.dragged()); + } + }); + if changed { + let mut i = img.clone(); + i.crop = crop.map(|v| v / 100.0); + if i.crop != img.crop { + out = Some(ImageAction::Update(AddedContent::Image(i))); + } + } + out +} diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 88f96c1c..9f408a0f 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -48,6 +48,8 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("copy", include_bytes!("../../../assets/icons/copy.svg")), ("copy-plus", include_bytes!("../../../assets/icons/copy-plus.svg")), ("crop", include_bytes!("../../../assets/icons/crop.svg")), + ("flip-horizontal-2", include_bytes!("../../../assets/icons/flip-horizontal-2.svg")), + ("flip-vertical-2", include_bytes!("../../../assets/icons/flip-vertical-2.svg")), ("droplet", include_bytes!("../../../assets/icons/droplet.svg")), ("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")), ("ellipsis-vertical", include_bytes!("../../../assets/icons/ellipsis-vertical.svg")), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ade753bd..4622c5e2 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -193,6 +193,26 @@ fn tool_detail(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'stat /// Edit a PDF ▸ Format text: for the selected added text (one undoable change), or the style /// new text gets. fn format_section(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens) { + let image = app.active_ids().and_then(|(i, id)| { + let (page, index) = app.views[i].content.selected?; + let doc = app.session.get(id)?; + match &doc.added.iter().filter(|a| a.page == page).nth(index)?.content { + printcraft_engine::AddedContent::Image(img) => Some((page, index, img.clone())), + _ => None, + } + }); + if let Some((page, index, img)) = image { + match crate::content_ui::image_panel(ui, t, &img) { + Some(crate::content_ui::ImageAction::Update(content)) => { + app.apply_edit(printcraft_engine::Edit::UpdateContent { page, index, content }); + } + Some(crate::content_ui::ImageAction::Replace) => app.replace_image_dialog(page, index), + None => {} + } + ui.add_space(6.0); + ui.separator(); + return; + } let selected = app.active_ids().and_then(|(i, id)| { let (page, index) = app.views[i].content.selected?; let doc = app.session.get(id)?; diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index e5b933ae..b0d6ee45 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -92,4 +92,14 @@ fn typing_moving_styling_and_deleting_added_content() { assert_eq!(a.len(), 1); assert_eq!(a[0].content.rect(), [135.0, 190.0, 165.0, 210.0]); assert_eq!(h.state().views[0].content.selected, Some((0, 0))); + // Edit image: rotate clockwise turns the box around its centre. + h.get_by_label("Rotate clockwise").click(); + h.run_steps(3); + let a = added(&h); + let printcraft_engine::AddedContent::Image(img) = &a[0].content else { panic!() }; + assert_eq!((img.rotation, img.rect), (3, [140.0, 185.0, 160.0, 215.0])); + h.get_by_label("Flip horizontal").click(); + h.run_steps(3); + let printcraft_engine::AddedContent::Image(img) = &added(&h)[0].content else { panic!() }; + assert!(img.flip_h); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cdd02272..188099f3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1983,11 +1983,11 @@ title = "Replace an image (keep position and size)" area = "C" tier = "P0" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.image"] +tools = ["content_update"] +evidence = ["crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"] +notes = "for images added in PrintCraft (box and transforms kept); existing page images need the M7 content editor" [[feature]] id = "edit.image-crop" @@ -1995,11 +1995,11 @@ title = "Crop an image" area = "C" tier = "P0" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.image"] +tools = ["content_update"] +evidence = ["crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"] +notes = "for added images: trim each side, clipped" [[feature]] id = "edit.image-rotate-flip" @@ -2007,11 +2007,11 @@ title = "Rotate and flip images" area = "C" tier = "P0" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.image"] +tools = ["content_update"] +evidence = ["crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/automation/tests/automation.rs::adding_content_through_tools"] +notes = "for added images: quarter turns (box turns around its centre), horizontal/vertical flips" [[feature]] id = "edit.image-move-resize"