M7: select and edit grouped Form XObject figures

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
FlowershangfromthebranchesandCodex committed 2026-10-09 15:30:39 +08:00
1 parent a6bc63a497
commit 91614c01e6
13 files changed
+242 -31

No files matched your search

Generated
+2
View File
@@ -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",
+2
View File
@@ -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%).
+2
View File
@@ -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"] }
+1 -1
View File
@@ -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 })
+2 -2
View File
@@ -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!({
+45
View File
@@ -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
View File
@@ -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));
}
+50
View File
@@ -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");
+11 -1
View File
@@ -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)?;
+3 -2
View File
@@ -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();
}
+27 -1
View File
@@ -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);
+23
View File
@@ -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.
+3 -3
View File
@@ -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"