From c2f53d5595848d010340dc9330d309de08c0c7fa Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:55:08 -0400 Subject: [PATCH] M7: Edit text & images: select existing page images to move, resize, rotate, flip, replace, save or delete; page_images, image_edit, image_save tools Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/lib.rs | 61 +++++++++ crates/automation/src/tools.rs | 22 ++++ crates/automation/tests/automation.rs | 25 ++++ crates/create/src/extract.rs | 8 ++ crates/create/src/lib.rs | 2 +- crates/edit/src/images.rs | 182 ++++++++++++++++++++++++++ crates/edit/src/lib.rs | 2 + crates/edit/src/tests.rs | 72 ++++++++++ crates/engine/src/catalog.rs | 2 +- crates/engine/src/commands.rs | 2 +- crates/engine/src/lib.rs | 62 ++++++++- crates/ui-egui/src/canvas.rs | 23 +++- crates/ui-egui/src/commands.rs | 2 +- crates/ui-egui/src/content_ui.rs | 42 ++++++ crates/ui-egui/src/edit_text_ui.rs | 150 ++++++++++++++++++++- crates/ui-egui/tests/editing.rs | 47 +++++++ parity/acrobat-features.toml | 40 +++--- 17 files changed, 716 insertions(+), 28 deletions(-) create mode 100644 crates/edit/src/images.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5f9f117d..f918f854 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -297,6 +297,67 @@ impl Automation { .collect(); json!({ "page": page + 1, "count": lines.len(), "lines": lines }) } + "page_images" => { + let page = self.page(&a)?; + let doc = self.doc(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let list: Vec = doc + .page_images(page) + .iter() + .enumerate() + .map(|(i, im)| { + let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32)); + json!({ "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name }) + }) + .collect(); + json!({ "page": page + 1, "count": list.len(), "images": list }) + } + "image_edit" | "image_save" => { + let page = self.page(&a)?; + let n = self.doc(&a)?.page_images(page).len(); + let k = a.int("image")?; + if k < 1 || k as usize > n { + return Err(ToolError::InvalidArgs(format!("image {k} is out of range: page {} has {n} images", page + 1))); + } + let index = k as usize - 1; + if name == "image_save" { + let (ext, bytes) = self.doc(&a)?.page_image_file(page, index).map_err(failed)?; + let mut path = self.resolve(a.str("path")?, true)?; + if path.extension().is_none() { + path.set_extension(ext); + } + write_atomic(&path, &bytes)?; + json!({ "path": path.to_string_lossy(), "format": ext, "bytes": bytes.len() }) + } else { + use printcraft_engine::ImageEdit; + let change = match a.str("action")? { + "move" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("move needs rect: 4 numbers".into()))?; + // Top-left-origin points → user space. + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), + "flip_horizontal" => ImageEdit::Flip { horizontal: true }, + "flip_vertical" => ImageEdit::Flip { horizontal: false }, + "delete" => ImageEdit::Delete, + "replace" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + ImageEdit::Replace { + name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + bytes: Arc::new(bytes), + } + } + other => return Err(ToolError::InvalidArgs(format!("unknown action {other:?}"))), + }; + self.apply(&a, Edit::EditPageImage { page, index, change })? + } + } "text_paragraphs" => { let page = self.page(&a)?; let doc = self.doc(&a)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e1f6222e..41ca3870 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -816,6 +816,28 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), + t("page_images", "List page images", "The images a page draws (Edit a PDF): number, box (top-left-origin points), pixel size and resource name.") + .ro() + .cmd("edit.edit_text") + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), + t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new box in top-left-origin points), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable.") + .cmd("edit.edit_text") + .with(schema( + json!({ + "doc": doc(), + "page": { "type": "integer", "minimum": 1 }, + "image": { "type": "integer", "minimum": 1 }, + "action": { "type": "string", "enum": ["move", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + "quarters": { "type": "integer" }, + "path": { "type": "string" }, + }), + &["doc", "page", "image", "action"], + )), + t("image_save", "Save image as", "Write one of a page's images to path: JPEG images unchanged, others as PNG (the extension is added when missing).") + .ro() + .cmd("edit.edit_text") + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, "path": { "type": "string" } }), &["doc", "page", "image", "path"])), t("text_paragraphs", "List paragraphs", "The paragraphs on a page (lines grouped by font, size, alignment and spacing): number, text, its line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.") .ro() .cmd("edit.edit_text") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 77073839..a2ba56ff 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -654,6 +654,31 @@ fn editing_existing_text_through_tools() { assert!(matches!(a.call("text_edit", &json!({ "doc": doc, "page": 3, "paragraph": 4, "text": "x" })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn editing_page_images_through_tools() { + let dir = workdir("page-images"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1, 2] })); + let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 })); + assert_eq!(list["count"], 1); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "move", "rect": [5, 5, 25, 35] })); + assert_eq!(r["undo"], "Move image"); + let moved = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); + assert_eq!(moved, json!([5.0, 5.0, 25.0, 35.0])); + ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" })); + ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" })); + let saved = ok(&mut a, "image_save", json!({ "doc": made, "page": 1, "image": 1, "path": "out/picture" })); + assert_eq!(saved["format"], "png"); + assert!(std::fs::read(dir.join("out/picture.png")).unwrap().starts_with(b"\x89PNG")); + ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "replace", "path": "src/a_page_2.png" })); + assert_eq!(ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["count"], 1); + ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "delete" })); + assert_eq!(ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["count"], 0); + assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "delete" })), Err(ToolError::InvalidArgs(_)))); +} + #[test] fn auditing_space_through_tools() { let dir = workdir("audit"); diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index eca04f57..6eb53a76 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -61,6 +61,14 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx out } +/// One image XObject as a file: JPEG as is, else PNG ("Save image as"). +pub fn image_file(doc: &Document, image: ObjRef) -> Result<(&'static str, Vec), String> { + match &*doc.get(image) { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s), + _ => Err("not an image".into()), + } +} + /// The image XObjects reachable from `res`, through form XObjects, in resource order. fn collect(doc: &Document, res: &Dict, depth: usize, forms: &mut HashSet, out: &mut Vec) { if depth > 12 { diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 9b0442a5..ed4d4077 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -3,7 +3,7 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; mod extract; -pub use extract::{ExtractedImage, ImageExport, extract_images}; +pub use extract::{ExtractedImage, ImageExport, extract_images, image_file}; use printcraft_fonts::{literal, win_ansi, wrap}; #[derive(Debug, thiserror::Error, PartialEq)] diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs new file mode 100644 index 00000000..b112c840 --- /dev/null +++ b/crates/edit/src/images.rs @@ -0,0 +1,182 @@ +//! Edit a PDF ▸ edit existing images: the images a page draws (image XObjects painted by `Do` +//! in its own content streams), and changing one: move/resize/rotate/flip (the `Do` is wrapped in +//! `q … cm … Q` with the extra transform), replace (it draws another XObject in the same place), +//! or delete. Only the stream that draws it is rewritten, as a new object. + +use printcraft_content::{Matrix, Op, parse, serialize_ops}; +use printcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use crate::EditError; + +/// An image drawn on a page. +#[derive(Clone, Debug, PartialEq)] +pub struct PageImage { + /// Its box in user space. + pub rect: [f64; 4], + /// The placement: the unit square → user space. + pub matrix: [f64; 6], + /// The XObject resource name and object. + pub name: String, + pub object: Option, + /// Pixel size. + pub width: u32, + pub height: u32, + stream: usize, + op: usize, +} + +fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { + let list: Vec = match page.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + list.into_iter() + .map(|o| { + let data = match &*doc.resolve(&o) { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }; + (o, data) + }) + .collect() +} + +fn page_of(doc: &Document, page: usize) -> Result { + printcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page)) +} + +fn xobjects(doc: &Document, page: &Dict) -> Dict { + page.get(b"Resources") + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_dict().and_then(|d| d.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()))) + .unwrap_or_default() +} + +/// The images page `page` (0-based) draws, in drawing order. +pub fn page_images(doc: &Document, page: usize) -> Result, EditError> { + let p = page_of(doc, page)?; + let xo = xobjects(doc, &p.dict); + let mut out = Vec::new(); + // The graphics state carries over from one stream to the next. + let mut ctm = Matrix::IDENTITY; + let mut stack = Vec::new(); + for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { + for (i, op) in parse(&data).ops.iter().enumerate() { + match op.op.as_slice() { + b"q" => stack.push(ctm), + b"Q" => ctm = stack.pop().unwrap_or(ctm), + b"cm" => { + if let Some(m) = op.nums::<6>() { + ctm = Matrix(m).then(&ctm); + } + } + b"Do" => { + let Some(name) = op.name(0) else { continue }; + let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.name(b"Subtype") != Some(b"Image") { + continue; + } + out.push(PageImage { + rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]), + matrix: ctm.0, + name: String::from_utf8_lossy(name).into_owned(), + object: Some(r), + width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32, + height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32, + stream: si, + op: i, + }); + } + _ => {} + } + } + } + Ok(out) +} + +/// What to do with an image. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageChange { + /// Apply a user-space transform after its placement (move, resize, rotate, flip). + Transform([f64; 6]), + /// Draw this image XObject instead, in the same place. + Replace(ObjRef), + Delete, +} + +/// The user-space transform that maps box `from` onto box `to` (move and resize). +pub fn rect_to_rect(from: [f64; 4], to: [f64; 4]) -> [f64; 6] { + let (fw, fh) = ((from[2] - from[0]).max(1e-6), (from[3] - from[1]).max(1e-6)); + let (sx, sy) = ((to[2] - to[0]) / fw, (to[3] - to[1]) / fh); + [sx, 0.0, 0.0, sy, to[0] - from[0] * sx, to[1] - from[1] * sy] +} + +/// Turn the image a quarter turn clockwise (`quarters` = 1, 2, 3) about its centre, or flip it. +pub fn turn_about_centre(rect: [f64; 4], quarters: i32, flip_h: bool, flip_v: bool) -> [f64; 6] { + let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0); + let (a, b, c, d) = match quarters.rem_euclid(4) { + 1 => (0.0, -1.0, 1.0, 0.0), + 2 => (-1.0, 0.0, 0.0, -1.0), + 3 => (0.0, 1.0, -1.0, 0.0), + _ => (1.0, 0.0, 0.0, 1.0), + }; + let (fx, fy) = (if flip_h { -1.0 } else { 1.0 }, if flip_v { -1.0 } else { 1.0 }); + let (a, b, c, d) = (a * fx, b * fy, c * fx, d * fy); + // Translate the centre to the origin, turn, translate back. + [a, b, c, d, cx - a * cx - c * cy, cy - b * cx - d * cy] +} + +/// Change image `index` (from [`page_images`]) on `page`. +pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &ImageChange) -> Result<(), EditError> { + let images = page_images(doc, page)?; + let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; + let p = page_of(doc, page)?; + let all = streams(doc, &p.dict); + let (obj, data) = all[img.stream].clone(); + let mut ops = parse(&data).ops; + let mut resources = None; + let replacement: Vec = match change { + ImageChange::Delete => Vec::new(), + ImageChange::Transform(t) => { + // New CTM = placement × T; as a `cm` before the Do: M = P · T · P⁻¹. + let pm = Matrix(img.matrix); + let inv = pm.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; + let m = pm.then(&Matrix(*t)).then(&inv); + vec![ + Op::new("q", vec![]), + Op::new("cm", m.0.iter().map(|v| printcraft_content::num(*v)).collect()), + ops[img.op].clone(), + Op::new("Q", vec![]), + ] + } + ImageChange::Replace(r) => { + let name = format!("PCImg{}", r.num); + let mut res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let mut xo = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + xo.set(name.clone().into_bytes(), Object::Ref(*r)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + resources = Some(res); + vec![Op::new("Do", vec![Object::name(&name)])] + } + }; + ops.splice(img.op..=img.op, replacement); + let mut dict = match &*doc.resolve(&obj) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&ops)))); + let contents: Vec = all.iter().enumerate().map(|(i, (o, _))| if i == img.stream { Object::Ref(new) } else { o.clone() }).collect(); + doc.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), if contents.len() == 1 { contents[0].clone() } else { Object::Array(contents) }); + if let Some(res) = resources { + d.set(b"Resources".to_vec(), Object::Dict(res)); + } + })?; + Ok(()) +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index d6e7ec47..1ad7ffd4 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -587,6 +587,8 @@ pub fn marks_present(doc: &Document) -> Vec { mod flatten; pub use flatten::flatten; +pub mod images; +pub use images::{ImageChange, PageImage, change_image, page_images, rect_to_rect, turn_about_centre}; pub mod text; pub use text::{LineEdit, TextBlock, TextLine, replace_block, replace_line, text_blocks, text_lines}; pub mod added; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index c93248c4..9dbbaeca 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -342,3 +342,75 @@ fn page_content_bytes(doc: &Document, page: usize) -> Vec { _ => Vec::new(), } } + +/// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2). +fn image_page() -> Document { + let content = "q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn close(a: [f64; 4], b: [f64; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) +} + +#[test] +fn page_images_move_turn_replace_and_delete() { + let mut doc = image_page(); + let imgs = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs.len(), 1); + assert!(close(imgs[0].rect, [100.0, 100.0, 300.0, 200.0]), "{:?}", imgs[0].rect); + assert_eq!((imgs[0].width, imgs[0].height, imgs[0].name.as_str()), (4, 2, "Im0")); + // Move and resize. + let t = images::rect_to_rect(imgs[0].rect, [50.0, 400.0, 150.0, 450.0]); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap(); + let doc = reopen(&doc); + let r = images::page_images(&doc, 0).unwrap()[0].rect; + assert!(close(r, [50.0, 400.0, 150.0, 450.0]), "{r:?}"); + // A quarter turn about the centre: 100 × 50 becomes 50 × 100 around (100, 425). + let mut doc = doc; + let t = images::turn_about_centre(r, 1, false, false); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap(); + let r = images::page_images(&doc, 0).unwrap()[0].rect; + assert!(close(r, [75.0, 375.0, 125.0, 475.0]), "{r:?}"); + // The text after it is untouched. + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption"); + // Replace with another image object, in the same place. + let mut d = printcraft_cos::Dict::new(); + for (k, v) in [ + (&b"Type"[..], printcraft_cos::Object::name("XObject")), + (b"Subtype", printcraft_cos::Object::name("Image")), + (b"Width", printcraft_cos::Object::Int(1)), + (b"Height", printcraft_cos::Object::Int(1)), + ] { + d.set(k.to_vec(), v); + } + let other = doc.add(printcraft_cos::Object::Stream(printcraft_cos::Stream::from_raw(d, vec![0]))); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Replace(other)).unwrap(); + let imgs = images::page_images(&doc, 0).unwrap(); + assert_eq!((imgs[0].object, imgs[0].width), (Some(other), 1)); + assert!(close(imgs[0].rect, r)); + // Delete. + images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap(); + assert!(images::page_images(&doc, 0).unwrap().is_empty()); + assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err()); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index dd479935..7afa5cfb 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -122,7 +122,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ ToolSection { title: "Add content", items: &[ - item("Edit text", "text-cursor-input", "edit.edit_text", Ready), + item("Edit text & images", "text-cursor-input", "edit.edit_text", Ready), item("Text", "type", "edit.text", Ready), item("Image", "image-plus", "edit.image", Ready), item("Header and footer", "heading", "edit.header_footer", Ready), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index ebe5489c..a1d2802e 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -207,7 +207,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("form.tab_order.column", "Tab order: by columns", None, None, HasFields, "columns-3"), c("form.tab_order.structure", "Tab order: by document structure", None, None, HasFields, "list"), c("edit.text", "Add text", None, None, Modification, "type"), - c("edit.edit_text", "Edit text", None, None, Modification, "text-cursor-input"), + c("edit.edit_text", "Edit text & images", None, None, Modification, "text-cursor-input"), c("edit.link", "Add or edit links", None, None, Modification, "link-2"), c("edit.links_from_urls", "Create links from URLs", None, None, Modification, "link-2"), c("edit.remove_links", "Remove all links", None, None, Modification, "trash-2"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5c6aa94a..e6b3c707 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -31,7 +31,25 @@ pub use printcraft_forms::{ }; pub use printcraft_a11y as a11y; -pub use printcraft_edit::{TextBlock, TextLine}; +pub use printcraft_edit::{PageImage, TextBlock, TextLine}; + +/// A change to an existing page image. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageEdit { + /// Move and resize it to this box (user space). + Move([f64; 4]), + /// Quarter turns clockwise about its centre. + Rotate(i32), + Flip { + horizontal: bool, + }, + /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. + Replace { + name: String, + bytes: Arc>, + }, + Delete, +} pub use printcraft_fonts::{ScriptOutline, script_outline}; /// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user @@ -183,6 +201,18 @@ impl Document { self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() } + /// Edit a PDF: the images `page` draws (0-based). + pub fn page_images(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| printcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() + } + + /// Save image as: image `index` on `page` as a file (extension, bytes). + pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; + printcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) + } + /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). pub fn text_blocks(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| printcraft_edit::text_blocks(&e.cos, page).ok()).unwrap_or_default() @@ -752,6 +782,12 @@ pub enum Edit { line: usize, text: String, }, + /// Edit a PDF ▸ an existing image on `page` (index from `Document::page_images`). + EditPageImage { + page: usize, + index: usize, + change: ImageEdit, + }, /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), /// rewrapped to the box's width. EditTextBlock { @@ -926,6 +962,13 @@ impl Edit { Edit::SetAltText { .. } => "Set alternate text".into(), Edit::MarkDecorative { .. } => "Mark figure as decorative".into(), Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => "Edit text".into(), + Edit::EditPageImage { change, .. } => match change { + ImageEdit::Move(_) => "Move image".into(), + ImageEdit::Rotate(_) => "Rotate image".into(), + ImageEdit::Flip { .. } => "Flip image".into(), + ImageEdit::Replace { .. } => "Replace image".into(), + ImageEdit::Delete => "Delete image".into(), + }, Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(), Edit::AddHeaderFooter { .. } => "Update header & footer".into(), Edit::AddWatermark { replace: false, .. } => "Add watermark".into(), @@ -1087,6 +1130,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::MarkDecorative { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } + | Edit::EditPageImage { .. } | Edit::Flatten { .. } => { if p.modify() { Ok(()) @@ -1270,6 +1314,22 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::EditTextLine { page, line, text } => { printcraft_edit::replace_line(doc, *page, *line, text)?; } + Edit::EditPageImage { page, index, change } => { + let img = printcraft_edit::page_images(doc, *page)? + .into_iter() + .nth(*index) + .ok_or_else(|| EditError::Edit(printcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; + let c = match change { + ImageEdit::Move(to) => printcraft_edit::ImageChange::Transform(printcraft_edit::rect_to_rect(img.rect, *to)), + ImageEdit::Rotate(q) => printcraft_edit::ImageChange::Transform(printcraft_edit::turn_about_centre(img.rect, *q, false, false)), + ImageEdit::Flip { horizontal } => { + printcraft_edit::ImageChange::Transform(printcraft_edit::turn_about_centre(img.rect, 0, *horizontal, !*horizontal)) + } + ImageEdit::Replace { name, bytes } => printcraft_edit::ImageChange::Replace(printcraft_create::image_xobject(doc, name, bytes)?.0), + ImageEdit::Delete => printcraft_edit::ImageChange::Delete, + }; + printcraft_edit::change_image(doc, *page, *index, &c)?; + } Edit::EditTextBlock { page, block, text } => { printcraft_edit::replace_block(doc, *page, *block, text)?; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 77f36d78..986e2362 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -141,6 +141,9 @@ pub struct DocView { /// line being edited. pub(crate) edit_lines: HashMap)>, pub line_editor: Option, + /// Edit text & images: the images per page (by document generation), and the selected one. + pub(crate) edit_images: HashMap)>, + pub image_selection: Option, /// Commenting state: selected comment, gestures, composer. pub comments: crate::comments::CommentView, /// Form filling state: the focused field. @@ -242,6 +245,8 @@ impl DocView { pending_edit: None, edit_lines: HashMap::new(), line_editor: None, + edit_images: HashMap::new(), + image_selection: None, pending_action: None, comments: Default::default(), forms: Default::default(), @@ -987,6 +992,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let today = app.session.today(); let by_line = app.session.stamp_by_line(&author); let mut stamp_placed = false; + let mut image_action: Option = None; let custom_stamp = match app.quick_tool { QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), _ => None, @@ -1233,7 +1239,17 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { l } }; - crate::edit_text_ui::page_input(ui, &resp, &xf, i, info, &lines, view) + let images = match view.edit_images.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.page_images(i); + view.edit_images.insert(i, (generation, l.clone())); + l + } + }; + // Images first (they can sit under text boxes' corners); then paragraphs. + crate::edit_text_ui::image_input(ui, &resp, &xf, i, info, &images, view, &mut image_action) + || crate::edit_text_ui::page_input(ui, &resp, &xf, i, info, &lines, view) }; let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view); let consumed = on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); @@ -1623,6 +1639,11 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { app.redact_prefs.mark(page, quads, &author) }); } + match image_action { + Some(crate::edit_text_ui::ImageAction::Replace(page, index)) => app.replace_page_image_dialog(page, index), + Some(crate::edit_text_ui::ImageAction::Save(page, index)) => app.save_page_image(page, index), + None => {} + } if open_signature || open_initials { app.signature_draft = crate::fill_sign::SigDraft::new(open_initials, &app.comment_prefs.author); app.dialog = Some(crate::Dialog::Signature); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 1193993b..d33035de 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -225,7 +225,7 @@ impl PrintCraftApp { "a11y.check" => self.start_accessibility_check(), "edit.edit_text" => { self.quick_tool = crate::QuickTool::EditText; - self.notify("Click a line of text to edit it"); + self.notify("Click text or an image to edit it"); } "edit.advanced_search" => { if let Some(i) = self.active { diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 26f84f48..3b3963a1 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -400,6 +400,48 @@ impl crate::PrintCraftApp { self.notify("Adding images arrives on the web with file pickers for images"); } + /// Edit text & images ▸ Replace Image: pick a file to draw in a page image's place. + pub(crate) fn replace_page_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", "jp2", "j2k", "jpx"]) + .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::EditPageImage { + page, + index, + change: printcraft_engine::ImageEdit::Replace { name, bytes: std::sync::Arc::new(bytes) }, + }); + } + Err(e) => self.notify(format!("Couldn't read {}: {e}", path.display())), + } + } + #[cfg(target_arch = "wasm32")] + self.notify(format!("Replacing images on page {} arrives on the web with image pickers ({index})", page + 1)); + } + + /// Edit text & images ▸ Save Image As. + pub(crate) fn save_page_image(&mut self, page: usize, index: usize) { + let Some((_, id)) = self.active_ids() else { return }; + let file = self.session.get(id).map(|d| d.page_image_file(page, index)); + match file { + Some(Ok((ext, bytes))) => { + self.write_files(&[(format!("Image page {} #{}.{ext}", page + 1, index + 1), std::sync::Arc::new(bytes))], "Save image"); + } + Some(Err(e)) => self.notify(format!("Couldn't save the image: {e}")), + None => {} + } + } + /// Click an image field: pick a picture for it. pub fn choose_field_image(&mut self, name: &str) { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 5192e8ba..e9d7e1cd 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1,5 +1,7 @@ -//! Edit a PDF ▸ Edit text: boxes around the paragraphs of existing text; click one to edit it in -//! place (⌘Enter or clicking away applies and rewraps it to the box, Esc cancels). +//! Edit a PDF ▸ Edit text & images: boxes around the paragraphs and images already on the page. +//! Click a paragraph to edit it in place (⌘Enter or clicking away applies and rewraps it to the +//! box, Esc cancels). Click an image to select it: drag to move, drag a corner to resize (keeping +//! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use printcraft_engine::Edit; @@ -21,6 +23,150 @@ pub struct LineEditor { focus: bool, } +/// A selected page image, and what the pointer is doing to it. +#[derive(Clone, Debug, PartialEq)] +pub struct ImageSelection { + pub page: usize, + pub index: usize, + /// Dragging: the start point and, for a corner, the opposite corner (screen). + drag: Option<(Pos2, Option)>, +} + +/// What a right-click on a selected image asks for. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum ImageAction { + Replace(usize, usize), + Save(usize, usize), +} + +fn user_box(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] { + let p = &info.pages[page]; + let (a, b) = (xf.screen_to_view(r.min), xf.screen_to_view(r.max)); + let (u, v) = (p.view_to_user(a.0, a.1), p.view_to_user(b.0, b.1)); + [u[0].min(v[0]) as f64, u[1].min(v[1]) as f64, u[0].max(v[0]) as f64, u[1].max(v[1]) as f64] +} + +/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. +#[allow(clippy::too_many_arguments)] +pub(crate) fn image_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + page: usize, + info: &DocInfo, + images: &[printcraft_engine::PageImage], + view: &mut DocView, + action: &mut Option, +) -> bool { + let boxes: Vec = images.iter().map(|im| xf.user_rect(info, page, im.rect.map(|v| v as f32))).collect(); + let painter = ui.painter(); + for b in &boxes { + painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); + } + let pointer = ui.input(|i| i.pointer.hover_pos()); + let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); + // The selected image: frame, corner handles, dragging. + if let Some(i) = selected { + let b = boxes[i]; + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; + for c in corners { + painter.rect( + Rect::from_center_size(c, egui::vec2(8.0, 8.0)), + CornerRadius::ZERO, + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } + let origin = ui.input(|i| i.pointer.press_origin()); + if resp.drag_started() + && let Some(o) = origin + { + let corner = corners.iter().position(|c| c.distance(o) < 8.0); + if corner.is_some() || b.contains(o) { + let opposite = corner.map(|k| corners[3 - k]); + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some((o, opposite)); + } + } + } + if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag) + && let Some(p) = pointer + { + let preview = match opposite { + // Resize from the opposite corner, keeping the aspect ratio. + Some(fixed) => { + let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); + let k = ((p.x - fixed.x).abs() / w0).max((p.y - fixed.y).abs() / h0).max(0.05); + let (w, h) = (w0 * k, h0 * k); + let x = if p.x < fixed.x { fixed.x - w } else { fixed.x }; + let y = if p.y < fixed.y { fixed.y - h } else { fixed.y }; + Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) + } + None => b.translate(p - start), + }; + painter.rect_stroke(preview, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); + if resp.drag_stopped() { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + } + if preview != b { + view.pending_edit = + Some(Edit::EditPageImage { page, index: i, change: printcraft_engine::ImageEdit::Move(user_box(xf, info, page, preview)) }); + } + } + return true; + } + if ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) && !ui.ctx().egui_wants_keyboard_input() { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: printcraft_engine::ImageEdit::Delete }); + return true; + } + } + let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; + let Some(hit) = boxes.iter().rposition(|b| b.contains(p)) else { return false }; + if selected != Some(hit) { + painter.rect_stroke(boxes[hit], CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); + } + ui.ctx().set_cursor_icon(if selected == Some(hit) { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }); + if resp.clicked() || resp.secondary_clicked() { + view.image_selection = Some(ImageSelection { page, index: hit, drag: None }); + } + if selected == Some(hit) { + resp.context_menu(|ui| { + use printcraft_engine::ImageEdit as E; + let items: [(&str, Option); 4] = [ + ("Rotate Clockwise", Some(E::Rotate(1))), + ("Rotate Counterclockwise", Some(E::Rotate(3))), + ("Flip Horizontal", Some(E::Flip { horizontal: true })), + ("Flip Vertical", Some(E::Flip { horizontal: false })), + ]; + for (label, change) in items { + if ui.button(label).clicked() { + view.pending_edit = change.map(|c| Edit::EditPageImage { page, index: hit, change: c }); + ui.close(); + } + } + if ui.button("Replace Image…").clicked() { + *action = Some(ImageAction::Replace(page, hit)); + ui.close(); + } + if ui.button("Save Image As…").clicked() { + *action = Some(ImageAction::Save(page, hit)); + ui.close(); + } + ui.separator(); + if ui.button("Delete").clicked() { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete }); + ui.close(); + } + }); + } + true +} + /// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns /// `true` when the pointer was used. pub(crate) fn page_input( diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 25214bba..c7f8e88f 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -618,3 +618,50 @@ fn editing_existing_text_in_place() { assert_eq!(doc.text_lines(0)[0].text, "Chapter One"); assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it"); } + +#[test] +fn editing_existing_images_on_the_page() { + // A page made from a 40 × 20 image (at 72 dpi: a 40 × 20 pt page filled by it). + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().page_images(0)[0].rect; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + // Select it, then drag it a quarter of the page to the right. + let c = r.center(); + h.hover_at(c); + h.run_steps(1); + h.drag_at(c); + h.run_steps(1); + h.drop_at(c); + h.run_steps(2); + assert!(h.state().views[0].image_selection.is_some(), "selected"); + let to = c + egui::vec2(r.width() / 4.0, 0.0); + h.hover_at(c); + h.run_steps(1); + h.drag_at(c); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(c + (to - c) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Move image")); + let moved = doc.page_images(0)[0].rect; + let dx = moved[0] - before[0]; + assert!((dx - (before[2] - before[0]) / 4.0).abs() < 1.5, "moved {dx} pt: {before:?} → {moved:?}"); + // Delete with the Delete key. + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert!(h.state().session.get(id).unwrap().page_images(0).is_empty()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7c61a89b..308245f1 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 = "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" +status = "shipped" +commands = ["edit.edit_text", "edit.image"] +tools = ["image_edit"] +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools"] +notes = "existing page images (right-click ▸ Replace Image) and added images: the new picture is drawn in the same place" [[feature]] id = "edit.image-crop" @@ -2007,11 +2007,11 @@ title = "Rotate and flip images" area = "C" tier = "P0" milestone = "M7" -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" +status = "shipped" +commands = ["edit.edit_text"] +tools = ["image_edit"] +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools"] +notes = "existing page images: rotate either way about the centre, flip horizontally or vertically (a q/cm/Q wrapper); added images too" [[feature]] id = "edit.image-move-resize" @@ -2019,11 +2019,11 @@ title = "Move and resize images" area = "C" tier = "P0" milestone = "M7" -status = "partial" -commands = ["edit.image"] -tools = ["content_update"] -evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] -notes = "images added in PrintCraft (aspect kept on corner drags); existing page images need the M7 content editor" +status = "shipped" +commands = ["edit.edit_text"] +tools = ["image_edit"] +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools"] +notes = "existing page images: select, drag to move, drag a corner to resize keeping proportions; Delete removes; added images too" [[feature]] id = "edit.image-extract" @@ -2031,11 +2031,11 @@ title = "Save an image to a file" area = "C" tier = "P1" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.edit_text"] +tools = ["image_save"] +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png"] +notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG" [[feature]] id = "edit.image-external-editor"