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:
parent
695e460bbb
commit
c2f53d5595
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue