mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 09:10:33 +00:00
M7: select and edit grouped Form XObject figures
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
1 parent
a6bc63a497
commit
91614c01e6
13 files changed
+242
-31
No files matched your search
Generated
+2
@@ -3486,7 +3486,9 @@ version = "0.4.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-organize",
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-render",
|
||||
|
||||
@@ -125,6 +125,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
|
||||
## Log
|
||||
|
||||
- **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged.
|
||||
- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged.
|
||||
- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
@@ -24,5 +24,7 @@ default = ["mcp"]
|
||||
mcp = ["dep:base64"]
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
@@ -332,7 +332,7 @@ impl Automation {
|
||||
.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 })
|
||||
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, "kind": if im.is_form { "form" } else { "image" } })
|
||||
})
|
||||
.collect();
|
||||
json!({ "page": page + 1, "count": list.len(), "images": list })
|
||||
|
||||
@@ -972,11 +972,11 @@ 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.")
|
||||
t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, box (top-left-origin points), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole.")
|
||||
.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.")
|
||||
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. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({
|
||||
|
||||
@@ -1327,6 +1327,51 @@ fn editing_page_images_through_tools() {
|
||||
assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "delete" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_form_artwork_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
|
||||
let dir = workdir("form-artwork");
|
||||
let mut cos = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||||
let page = pdfcraft_model::pages(&cos)[0].clone();
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array([0, 0, 80, 40].map(Object::Int).to_vec()));
|
||||
let artwork = cos.add(Object::Stream(Stream::flate(form, b"0.2 0.5 0.9 rg 0 0 80 40 re f")));
|
||||
let contents = cos.add(Object::Stream(Stream::flate(Dict::new(), b"q 1 0 0 1 20 240 cm /Figure Do Q")));
|
||||
cos.update_dict(page.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Ref(contents));
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Figure".to_vec(), Object::Ref(artwork));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
})
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("figure.pdf"), write_incremental(&cos, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path": "figure.pdf"}))["doc"].as_u64().unwrap();
|
||||
let before = ok(&mut a, "page_images", json!({"doc": doc, "page": 1}))["images"][0].clone();
|
||||
assert_eq!(before["kind"], "form");
|
||||
assert_eq!(before["pixels"], json!([0, 0]));
|
||||
assert_eq!(before["rect"], json!([20.0, 20.0, 100.0, 60.0]));
|
||||
let rendered_before = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap();
|
||||
assert!(a.call("image_save", &json!({"doc": doc, "page": 1, "image": 1, "path": "out/figure"})).is_err());
|
||||
ok(&mut a, "image_edit", json!({"doc": doc, "page": 1, "image": 1, "action": "move", "rect": [40, 50, 120, 90]}));
|
||||
assert_eq!(ok(&mut a, "page_images", json!({"doc": doc, "page": 1}))["images"][0]["rect"], json!([40.0, 50.0, 120.0, 90.0]));
|
||||
let rendered_moved = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap();
|
||||
assert_ne!(rendered_before, rendered_moved, "the rendered artwork actually moves");
|
||||
ok(&mut a, "doc_save", json!({"doc": doc, "path": "out/moved.pdf"}));
|
||||
let re = ok(&mut a, "doc_open", json!({"path": "out/moved.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "page_images", json!({"doc": re, "page": 1}))["images"][0]["rect"], json!([40.0, 50.0, 120.0, 90.0]));
|
||||
ok(&mut a, "image_edit", json!({"doc": re, "page": 1, "image": 1, "action": "delete"}));
|
||||
assert_eq!(ok(&mut a, "page_images", json!({"doc": re, "page": 1}))["count"], 0);
|
||||
ok(&mut a, "edit_undo", json!({"doc": re}));
|
||||
assert_eq!(ok(&mut a, "page_images", json!({"doc": re, "page": 1}))["count"], 1);
|
||||
ok(&mut a, "edit_undo", json!({"doc": doc}));
|
||||
assert_eq!(ok(&mut a, "page_images", json!({"doc": doc, "page": 1}))["images"][0], before);
|
||||
assert_eq!(a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap(), rendered_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auditing_space_through_tools() {
|
||||
let dir = workdir("audit");
|
||||
|
||||
+71
-21
@@ -1,4 +1,4 @@
|
||||
//! Edit a PDF ▸ edit existing images: the images a page draws (image XObjects painted by `Do`
|
||||
//! Edit a PDF ▸ edit existing images and grouped artwork (Image/Form 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.
|
||||
@@ -13,16 +13,20 @@ use crate::EditError;
|
||||
pub struct PageImage {
|
||||
/// Its box in user space.
|
||||
pub rect: [f64; 4],
|
||||
/// The placement: the unit square → user space.
|
||||
/// The placement: image unit square or Form coordinates → user space.
|
||||
pub matrix: [f64; 6],
|
||||
/// The XObject resource name and object.
|
||||
pub name: String,
|
||||
pub object: Option<ObjRef>,
|
||||
/// Pixel size.
|
||||
/// Whether this is grouped Form artwork, edited as a whole (including its nested content).
|
||||
pub is_form: bool,
|
||||
/// Pixel size; zero for vector/grouped Form artwork.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
stream: usize,
|
||||
op: usize,
|
||||
/// CTM before `Do`, excluding a Form's own Matrix.
|
||||
draw_matrix: [f64; 6],
|
||||
}
|
||||
|
||||
fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
|
||||
@@ -55,7 +59,8 @@ fn xobjects(doc: &Document, page: &Dict) -> Dict {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The images page `page` (0-based) draws, in drawing order.
|
||||
/// The images and Form artwork page `page` (0-based) draws, in drawing order.
|
||||
/// Forms stay intact: no traversal or rewriting of their internal objects is needed.
|
||||
pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditError> {
|
||||
let p = page_of(doc, page)?;
|
||||
let xo = xobjects(doc, &p.dict);
|
||||
@@ -75,21 +80,59 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
|
||||
}
|
||||
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 Some(resource) = xo.get(name) else { continue };
|
||||
let obj = doc.resolve(resource);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
if s.dict.name(b"Subtype") != Some(b"Image") {
|
||||
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
|
||||
let (matrix, bounds) = if is_form {
|
||||
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
|
||||
v.as_array().and_then(|a| {
|
||||
if a.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let mut b = [0.0; 4];
|
||||
for (n, v) in b.iter_mut().zip(a) {
|
||||
*n = v.as_f64()?;
|
||||
}
|
||||
Some(b)
|
||||
})
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let matrix = match s.dict.get(b"Matrix") {
|
||||
Some(v) => {
|
||||
let v = doc.resolve(v);
|
||||
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
|
||||
m
|
||||
}
|
||||
None => Matrix::IDENTITY,
|
||||
};
|
||||
(matrix.then(&ctm), bounds)
|
||||
} else if s.dict.name(b"Subtype") == Some(b"Image") {
|
||||
(ctm, [0.0, 0.0, 1.0, 1.0])
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let rect = matrix.bbox(bounds);
|
||||
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|
||||
|| bounds[2] <= bounds[0]
|
||||
|| bounds[3] <= bounds[1]
|
||||
|| rect[2] <= rect[0]
|
||||
|| rect[3] <= rect[1]
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(PageImage {
|
||||
rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]),
|
||||
matrix: ctm.0,
|
||||
rect,
|
||||
matrix: matrix.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,
|
||||
object: resource.as_ref(),
|
||||
is_form,
|
||||
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
||||
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
||||
stream: si,
|
||||
op: i,
|
||||
draw_matrix: ctm.0,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
@@ -137,24 +180,31 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
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 (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
|
||||
let mut ops = parse(&data).ops;
|
||||
let original = ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
|
||||
let mut resources = None;
|
||||
let replacement: Vec<Op> = match change {
|
||||
ImageChange::Delete => Vec::new(),
|
||||
ImageChange::Transform(t) => {
|
||||
if !t.iter().all(|v| v.is_finite()) {
|
||||
return Err(EditError::Invalid("the image transform must be finite".into()));
|
||||
}
|
||||
// New CTM = placement × T; as a `cm` before the Do: M = P · T · P⁻¹.
|
||||
let pm = Matrix(img.matrix);
|
||||
let pm = Matrix(img.draw_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| pdfcraft_content::num(*v)).collect()),
|
||||
ops[img.op].clone(),
|
||||
Op::new("Q", vec![]),
|
||||
]
|
||||
if !m.0.iter().all(|v| v.is_finite()) {
|
||||
return Err(EditError::Invalid("the image transform is too large".into()));
|
||||
}
|
||||
vec![Op::new("q", vec![]), Op::new("cm", m.0.iter().map(|v| pdfcraft_content::num(*v)).collect()), original, Op::new("Q", vec![])]
|
||||
}
|
||||
ImageChange::Replace(r) => {
|
||||
if img.is_form {
|
||||
return Err(EditError::Invalid(
|
||||
"grouped Form artwork can be moved, resized, rotated, flipped or deleted; replacing it with a bitmap is not supported".into(),
|
||||
));
|
||||
}
|
||||
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();
|
||||
@@ -173,7 +223,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
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) });
|
||||
d.set(b"Contents".to_vec(), if let [one] = contents.as_slice() { one.clone() } else { Object::Array(contents) });
|
||||
if let Some(res) = resources {
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
}
|
||||
|
||||
@@ -711,6 +711,56 @@ fn page_images_move_turn_replace_and_delete() {
|
||||
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_artwork_edits_only_the_selected_placement() {
|
||||
let mut doc = fixture(); // three pages share resources; pages 0 and 1 also share contents
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array([10, 20, 110, 70].map(Object::Int).to_vec()));
|
||||
form.set(b"Matrix".to_vec(), Object::Array([2, 0, 0, 3, -20, -60].map(Object::Int).to_vec()));
|
||||
let artwork = doc.add(Object::Stream(Stream::flate(form, b"1 0 0 rg 10 20 100 50 re f")));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Figure".to_vec(), Object::Ref(artwork));
|
||||
d.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
})
|
||||
.unwrap();
|
||||
let content = b"q 1 0 0 1 40 80 cm /Figure Do Q q 1 0 0 1 300 400 cm /Figure Do Q BT /F1 9 Tf (Caption) Tj ET";
|
||||
doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), content)));
|
||||
let mut doc = reopen(&doc);
|
||||
let original = doc.get(artwork);
|
||||
let imgs = images::page_images(&doc, 0).unwrap();
|
||||
assert_eq!(imgs.len(), 2);
|
||||
assert!(imgs[0].is_form);
|
||||
assert_eq!((imgs[0].width, imgs[0].height), (0, 0));
|
||||
assert!(close(imgs[0].rect, [40.0, 80.0, 240.0, 230.0]));
|
||||
let target = [60.0, 100.0, 160.0, 175.0];
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(imgs[0].rect, target))).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
let moved = images::page_images(&doc, 0).unwrap();
|
||||
assert!(close(moved[0].rect, target));
|
||||
assert_eq!(moved[1].rect, imgs[1].rect);
|
||||
assert_eq!(images::page_images(&doc, 1).unwrap()[0].rect, imgs[0].rect);
|
||||
assert_eq!(*doc.get(artwork), *original, "the shared Form and its unknown data are untouched");
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption");
|
||||
let t = images::turn_about_centre(target, 1, false, false);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap();
|
||||
assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, [72.5, 87.5, 147.5, 187.5]));
|
||||
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Replace(artwork)).is_err());
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1);
|
||||
assert_eq!(images::page_images(&doc, 1).unwrap().len(), 2);
|
||||
// Invalid bounds are skipped without interpreting the Form stream.
|
||||
let mut doc = doc;
|
||||
let mut broken = (*doc.get(artwork)).clone();
|
||||
if let Object::Stream(s) = &mut broken {
|
||||
s.dict.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0)]));
|
||||
}
|
||||
doc.set(artwork, broken);
|
||||
assert!(images::page_images(&doc, 0).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs_take_new_formatting() {
|
||||
let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET");
|
||||
|
||||
@@ -259,6 +259,9 @@ impl Document {
|
||||
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))?;
|
||||
if img.is_form {
|
||||
return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into());
|
||||
}
|
||||
pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?)
|
||||
}
|
||||
|
||||
@@ -1562,7 +1565,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
ImageEdit::Flip { horizontal } => {
|
||||
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.rect, 0, *horizontal, !*horizontal))
|
||||
}
|
||||
ImageEdit::Replace { name, bytes } => pdfcraft_edit::ImageChange::Replace(pdfcraft_create::image_xobject(doc, name, bytes)?.0),
|
||||
ImageEdit::Replace { name, bytes } => {
|
||||
if img.is_form {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
|
||||
"grouped Form artwork cannot be replaced with a bitmap".into(),
|
||||
)));
|
||||
}
|
||||
pdfcraft_edit::ImageChange::Replace(pdfcraft_create::image_xobject(doc, name, bytes)?.0)
|
||||
}
|
||||
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
|
||||
};
|
||||
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
|
||||
|
||||
@@ -316,11 +316,12 @@ pub(crate) fn image_input(
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if ui.button(tl!("Replace Image…")).clicked() {
|
||||
let raster = images.get(hit).is_some_and(|image| !image.is_form);
|
||||
if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() {
|
||||
*action = Some(ImageAction::Replace(page, hit));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Save Image As…")).clicked() {
|
||||
if ui.add_enabled(raster, egui::Button::new(tl!("Save Image As…"))).clicked() {
|
||||
*action = Some(ImageAction::Save(page, hit));
|
||||
ui.close();
|
||||
}
|
||||
|
||||
@@ -1495,9 +1495,35 @@ 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();
|
||||
check_existing_artwork("picture.png", png);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_existing_form_artwork_on_the_page() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
|
||||
let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||||
let page = pdfcraft_model::pages(&doc)[0].clone();
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::Int).to_vec()));
|
||||
let artwork = doc.add(Object::Stream(Stream::flate(form, b"0.2 0.5 0.9 rg 20 20 160 260 re f")));
|
||||
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Figure Do")));
|
||||
doc.update_dict(page.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Ref(contents));
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Figure".to_vec(), Object::Ref(artwork));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
})
|
||||
.unwrap();
|
||||
check_existing_artwork("figure.pdf", write_incremental(&doc, &SaveOptions::default()).unwrap());
|
||||
}
|
||||
|
||||
fn check_existing_artwork(name: &'static str, bytes: Vec<u8>) {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("picture.png", None, png.clone()).expect("opens");
|
||||
app.open_bytes(name, None, bytes.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Editing existing images and figures
|
||||
|
||||
In **Edit a PDF → Edit text and images**, click a figure to select it. Drag the selected
|
||||
figure to move it, drag a corner to resize it proportionally, or press Delete to remove
|
||||
that placement. The context menu also rotates or flips it. Changes support undo and
|
||||
survive saving/reopening.
|
||||
|
||||
PDF figures are not always bitmap images. Imported diagrams often use a **Form XObject**,
|
||||
which packages text, vector paths, and nested images into one object. PdfCraft selects
|
||||
this object using its `BBox` and optional `Matrix` and edits it as a whole. Its original
|
||||
content, resources, clipping box, and transparency settings remain intact. Moving or
|
||||
deleting one use of a shared figure does not alter its other placements or pages.
|
||||
|
||||
**Replace Image** and **Save Image As** apply only to raster Image XObjects; these menu
|
||||
items are disabled for grouped Form artwork. The tool does not ungroup Forms, edit their
|
||||
individual internal elements, select arbitrary ungrouped vector paths, or edit inline
|
||||
images (`BI`/`ID`/`EI`). A Form's bounding box may include whitespace.
|
||||
|
||||
For automation, `page_images` lists both kinds with `kind: "image"` or `kind: "form"`.
|
||||
Forms have `pixels: [0, 0]`, since they have no single bitmap resolution. Use the returned
|
||||
number with `image_edit` (move, rotate, flip_horizontal, flip_vertical, delete).
|
||||
Coordinates are top-left-origin page points. `image_save` and `image_edit`'s replace action
|
||||
return a clear error for Form artwork.
|
||||
@@ -2035,7 +2035,7 @@ 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"
|
||||
notes = "existing raster images and grouped Form artwork: rotate either way about the centre, flip horizontally or vertically (a q/cm/Q wrapper); Forms stay intact as groups; added images too"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.image-move-resize"
|
||||
@@ -2046,8 +2046,8 @@ milestone = "M7"
|
||||
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"
|
||||
evidence = ["crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/automation.rs::editing_form_artwork_through_tools", "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 raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.image-extract"
|
||||
|
||||
Reference in new issue
Block a user