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"