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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 02:55:08 -04:00
parent 695e460bbb
commit c2f53d5595
17 changed files with 716 additions and 28 deletions

View File

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

View File

@ -816,6 +816,28 @@ pub fn tools() -> Vec<ToolDef> {
.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")

View File

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

View File

@ -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<u8>), 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<ObjRef>, out: &mut Vec<ObjRef>) {
if depth > 12 {

View File

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

182
crates/edit/src/images.rs Normal file
View File

@ -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<ObjRef>,
/// Pixel size.
pub width: u32,
pub height: u32,
stream: usize,
op: usize,
}
fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
let list: Vec<Object> = 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::Page, EditError> {
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<Vec<PageImage>, 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<Op> = 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<Object> = 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(())
}

View File

@ -587,6 +587,8 @@ pub fn marks_present(doc: &Document) -> Vec<MarkKind> {
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;

View File

@ -342,3 +342,75 @@ fn page_content_bytes(doc: &Document, page: usize) -> Vec<u8> {
_ => 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<String> = 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());
}

View File

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

View File

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

View File

@ -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<Vec<u8>>,
},
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<printcraft_edit::PageImage> {
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<u8>), 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<printcraft_edit::TextBlock> {
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)?;
}

View File

@ -141,6 +141,9 @@ pub struct DocView {
/// line being edited.
pub(crate) edit_lines: HashMap<usize, (u64, Vec<printcraft_engine::TextBlock>)>,
pub line_editor: Option<crate::edit_text_ui::LineEditor>,
/// Edit text & images: the images per page (by document generation), and the selected one.
pub(crate) edit_images: HashMap<usize, (u64, Vec<printcraft_engine::PageImage>)>,
pub image_selection: Option<crate::edit_text_ui::ImageSelection>,
/// 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<crate::edit_text_ui::ImageAction> = 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);

View File

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

View File

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

View File

@ -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<Pos2>)>,
}
/// 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<ImageAction>,
) -> bool {
let boxes: Vec<Rect> = 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<E>); 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(

View File

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

View File

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