M7.5: Edit image for added images: rotate (the box turns around its centre), flip horizontal/vertical, crop each side (clipped), replace the picture keeping box and transforms; Edit panel tools, content_update rotate/flip/crop/image; pixel-checked rendering

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 01:18:31 -04:00
parent 92d37ca696
commit 098d9ed4e1
17 changed files with 392 additions and 28 deletions

View File

@ -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 |

View File

@ -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\""

View File

@ -0,0 +1,20 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-flip-horizontal-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 12H8" />
<path d="M16 12h-2" />
<path d="M22 12h-2" />
<path d="M4 12H2" />
<path d="M7.298 20.288A1 1 0 008 22h8a1 1 0 00.703-1.712l-3.991-3.99a1 1 0 00-1.424-.001z" />
<path d="M7.298 3.712A1 1 0 018 2h8a1 1 0 01.703 1.712l-3.991 3.99a1 1 0 01-1.424.001z" />
</svg>

After

Width:  |  Height:  |  Size: 582 B

View File

@ -0,0 +1,20 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-flip-vertical-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 14v2" />
<path d="M12 20v2" />
<path d="M12 2v2" />
<path d="M12 8v2" />
<path d="M20.288 16.703A1 1 0 0022 16V8a1 1 0 00-1.712-.703l-3.99 3.991a1 1 0 00-.001 1.424z" />
<path d="M3.712 16.703A1 1 0 012 16V8a1 1 0 011.712-.703l3.99 3.991a1 1 0 01.001 1.424z" />
</svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@ -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 })
}

View File

@ -378,7 +378,7 @@ pub fn tools() -> Vec<ToolDef> {
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<ToolDef> {
"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"],
)),

View File

@ -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(_))));

View File

@ -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 }

View File

@ -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<u8>, 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<Content> {
},
}))
}
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,
}
}

View File

@ -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);

View File

@ -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<Vec<u8>>,
},
/// Apply redaction marks (all, or those on `pages`): remove what they cover for good.
ApplyRedactions {
pages: Option<Vec<usize>>,
@ -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())?;
}

View File

@ -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");
}

View File

@ -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<u8>) {
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<ImageAction> {
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
}

View File

@ -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")),

View File

@ -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)?;

View File

@ -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);
}

View File

@ -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"