From d5c659812ca352b0edab5f47861415ef6d684082 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 05:16:42 -0400 Subject: [PATCH] =?UTF-8?q?M6:=20Image=20fields:=20Prepare=20a=20form=20?= =?UTF-8?q?=E2=96=B8=20Image=20field=20places=20an=20icon-only=20button=20?= =?UTF-8?q?that=20asks=20for=20a=20picture=20when=20clicked=20(buttonImpor?= =?UTF-8?q?tIcon=20recognised=20natively);=20form=5Fset=5Fimage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/forms.rs | 1 + crates/automation/src/lib.rs | 5 ++ crates/automation/src/tools.rs | 6 +- crates/automation/tests/automation.rs | 13 ++++ crates/engine/src/catalog.rs | 1 + crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 19 +++++- crates/forms/src/af.rs | 6 ++ crates/forms/src/author.rs | 89 ++++++++++++++++++++++----- crates/forms/src/lib.rs | 4 +- crates/forms/src/tests.rs | 26 ++++++++ crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/content_ui.rs | 24 ++++++++ crates/ui-egui/src/prepare.rs | 8 ++- crates/ui-egui/tests/prepare.rs | 26 ++++++++ parity/acrobat-features.toml | 10 +-- 16 files changed, 214 insertions(+), 26 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 3faa7dc206..c1ce009073 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -283,6 +283,7 @@ impl Automation { "combo" => NewField::Combo { options: options()?, editable: a.opt_bool("editable")?.unwrap_or(false) }, "list" => NewField::List { options: options()?, multi: a.opt_bool("multi_select")?.unwrap_or(false) }, "button" => NewField::Button { caption: a.opt_str("caption")?.unwrap_or("").to_owned() }, + "image" => NewField::Image, "signature" => NewField::Signature, t => return Err(ToolError::InvalidArgs(format!("unknown field type {t:?}"))), }; diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index cfe29187d6..f24e55a170 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -290,6 +290,11 @@ impl Automation { } "form_fields" => self.form_fields(&a)?, "form_fill" => self.form_fill(&a)?, + "form_set_image" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? + } "form_reset" => self.form_reset(&a)?, "form_add_field" => self.form_add_field(&a)?, "form_set_props" => self.form_set_props(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 05816b5b7c..b0ec57451e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -232,13 +232,13 @@ pub fn tools() -> Vec { t( "form_add_field", "Prepare form: add a field", - "Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Signature1, Date1). Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.", + "Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, image (a picture placeholder; fill it with form_set_image), signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Image1, Signature1, Date1). Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.", ) .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, - "type": { "type": "string", "enum": ["text", "date", "checkbox", "radio", "combo", "list", "button", "signature"] }, + "type": { "type": "string", "enum": ["text", "date", "checkbox", "radio", "combo", "list", "button", "image", "signature"] }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "name": { "type": "string" }, "multiline": { "type": "boolean" }, @@ -251,6 +251,8 @@ pub fn tools() -> Vec { }), &["doc", "page", "type", "rect"], )), + t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). Undoable.") + .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])), t( "form_set_props", "Field properties", diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index c2db787ad3..05f0c60910 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -715,6 +715,19 @@ fn preparing_a_form_through_tools() { Err(ToolError::InvalidArgs(_)) )); assert!(matches!(a.call("form_delete_field", &json!({ "doc": doc, "field": "nope" })), Err(ToolError::Failed(_)))); + // An image field shows the picture it is given. + assert_eq!(add(&mut a, json!({ "doc": doc, "page": 2, "type": "image", "rect": [20, 20, 120, 120] })), "Image1"); + let png = |a: &mut Automation| match a.call("page_render", &json!({ "doc": doc, "page": 2, "dpi": 36 })).unwrap().remove(0) { + Content::Png { data, .. } => data, + other => panic!("{other:?}"), + }; + let before = png(&mut a); + ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "pics", "dpi": 18, "pages": [1] })); + ok(&mut a, "form_set_image", json!({ "doc": doc, "field": "Image1", "path": "pics/a_page_1.png" })); + let after = png(&mut a); + assert_ne!(before, after, "the picture is drawn"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Set the image of Image1"); + assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_)))); } #[test] diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 8de6a848c2..bb9a865452 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -337,6 +337,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Drop-down list", "chevron-down", "form.add.combo", Ready), item("List box", "list", "form.add.list", Ready), item("Button", "square", "form.add.button", Ready), + item("Image field", "image", "form.add.image", Ready), item("Date field", "clock-3", "form.add.date", Ready), item("Digital signature", "signature", "form.add.signature", Ready), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index da710850b5..282ec914e4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -226,6 +226,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("form.add.combo", "Add a drop-down list", None, None, Modification, "chevron-down"), c("form.add.list", "Add a list box", None, None, Modification, "list"), c("form.add.button", "Add a button", None, None, Modification, "square"), + c("form.add.image", "Add an image field", None, None, Modification, "image"), c("form.add.date", "Add a date field", None, None, Modification, "clock-3"), c("form.add.signature", "Add a digital signature field", None, None, Modification, "signature"), c("sign.digital", "Digitally sign", None, None, Document, "signature"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8897aa4932..0cf73ec91a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -97,7 +97,7 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } => Scope::Form, + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } => Scope::Form, Edit::Batch { edits, .. } => { let mut scopes = edits.iter().map(scope_of); let first = scopes.next().unwrap_or(Scope::Full); @@ -664,7 +664,11 @@ pub enum Edit { name: String, props: Box, }, - /// Delete a field and all its widgets. + /// An image field (or any button): show this image file (PNG, JPEG, TIFF, GIF, BMP). + SetFieldImage { + name: String, + image: Arc>, + }, /// Duplicate a field onto other pages (same field, shared value). DuplicateField { name: String, @@ -835,6 +839,7 @@ impl Edit { Edit::ResizeAnnotation { .. } => "Resize comment".into(), Edit::StyleAnnotation { .. } | Edit::SetAnnotationInfo { .. } => "Change comment properties".into(), Edit::SetFieldValue { name, .. } => format!("Fill in {name}"), + Edit::SetFieldImage { name, .. } => format!("Set the image of {name}"), Edit::ResetForm { .. } => "Clear form".into(), Edit::AddField { .. } => "Add field".into(), Edit::SetFieldProps { .. } => "Change field properties".into(), @@ -957,7 +962,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), Err(EditError::NotPermitted("comments")) } } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } => { + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } => { if p.fill_forms() { Ok(()) } else { @@ -1133,6 +1138,14 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - printcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; } Edit::SetFieldValue { name, value } => printcraft_forms::set_value(doc, name, value)?, + Edit::SetFieldImage { name, image } => { + let (img, _) = printcraft_create::image_xobject(doc, name, image)?; + let px = match &*doc.get(img) { + printcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), + _ => (1, 1), + }; + printcraft_forms::set_button_icon(doc, name, img, px)?; + } Edit::ResetForm { names } => { printcraft_forms::reset(doc, names.as_deref())?; } diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 8544e4e2b0..d799f01ab2 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -1002,6 +1002,8 @@ pub enum ButtonAction { Alert(String), /// Submit the form to a URL (not sent: PrintCraft never posts form data on its own). Submit(String), + /// `event.target.buttonImportIcon()`: choose an image for the button (an image field). + ImportIcon, /// A script PrintCraft can't run yet. Script(String), } @@ -1028,6 +1030,9 @@ pub fn button_script(js: &str) -> ButtonAction { return ButtonAction::Uri(u); } let compact: String = t.chars().filter(|c| !c.is_whitespace()).collect(); + if compact.trim_end_matches(';') == "event.target.buttonImportIcon()" { + return ButtonAction::ImportIcon; + } match compact.trim_end_matches(';') { "this.pageNum++" | "pageNum++" => ButtonAction::Named("NextPage".into()), "this.pageNum--" | "pageNum--" => ButtonAction::Named("PrevPage".into()), @@ -1048,6 +1053,7 @@ mod button_tests { assert_eq!(button_script("app.alert('Thanks!');"), ButtonAction::Alert("Thanks!".into())); assert_eq!(button_script("app.launchURL(\"https://example.org\", true);"), ButtonAction::Uri("https://example.org".into())); assert_eq!(button_script("this.pageNum++;"), ButtonAction::Named("NextPage".into())); + assert_eq!(button_script("event.target.buttonImportIcon();"), ButtonAction::ImportIcon); assert!(matches!(button_script("var x = 1; doStuff(x);"), ButtonAction::Script(_))); } } diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index bb5c57195b..748d2ef5eb 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -36,6 +36,8 @@ pub enum NewField { Button { caption: String, }, + /// An image field: an icon-only push button that asks for a picture when clicked. + Image, Signature, } @@ -49,6 +51,7 @@ impl NewField { NewField::Combo { .. } => "Dropdown", NewField::List { .. } => "List Box", NewField::Button { .. } => "Button", + NewField::Image => "Image", NewField::Signature => "Signature", } } @@ -405,6 +408,21 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel mk.set(b"CA".to_vec(), PdfString::text(caption)); w.set(b"MK".to_vec(), Object::Dict(mk)); } + NewField::Image => { + w.set(b"FT".to_vec(), Object::name("Btn")); + w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); + // Icon only, no border or fill: the picture is the field. + mk.remove(b"BC"); + mk.remove(b"BG"); + mk.set(b"TP".to_vec(), Object::Int(1)); + w.set(b"MK".to_vec(), Object::Dict(mk)); + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(b"event.target.buttonImportIcon();".to_vec())); + w.set(b"A".to_vec(), Object::Dict(a)); + } NewField::Signature => { w.set(b"FT".to_vec(), Object::name("Sig")); } @@ -519,24 +537,38 @@ fn form_stream(width: f64, height: f64, content: Vec, resources: Dict) -> St Stream::flate(d, &content) } -/// A push button: background, border and its centred caption. +/// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its +/// centred caption (unless the layout is icon only, `/TP 1`). fn button_appearance(doc: &Document, w: &Widget) -> Stream { let (mut c, width, height) = frame_only(doc, w); let wobj = doc.get(w.obj); - let caption = wobj - .as_dict() - .and_then(|d| d.get(b"MK")) - .and_then(|m| m.as_dict()) - .and_then(|m| m.get(b"CA")) - .and_then(|c| c.as_string()) - .map(|s| s.to_text()) - .unwrap_or_default(); - let size = ((height - 4.0) * 0.6).clamp(4.0, 14.0); - let tw = helvetica_width(&caption, size); + let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + let caption = mk.get(b"CA").and_then(|c| c.as_string()).map(|s| s.to_text()).unwrap_or_default(); + let icon_only = mk.int(b"TP") == Some(1); let mut content = std::mem::take(&mut c).into_bytes(); - content.extend(format!("BT /Helv {size:.2} Tf 0 g 1 0 0 1 {:.3} {:.3} Tm ", (width - tw) / 2.0, (height - size * 0.7) / 2.0).bytes()); - content.extend(literal(&win_ansi(&caption))); - content.extend_from_slice(b" Tj ET\n"); + let mut xobjects = Dict::new(); + if let Some(icon) = mk.get(b"I").and_then(Object::as_ref) + && let Object::Stream(s) = &*doc.get(icon) + { + let bb: Vec = s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + if let [x0, y0, x1, y1] = bb[..] + && x1 > x0 + && y1 > y0 + { + // Proportional, as large as fits (Acrobat's default icon placement), centred. + let k = ((width - 2.0) / (x1 - x0)).min((height - 2.0) / (y1 - y0)).max(0.0); + let (dx, dy) = ((width - (x1 - x0) * k) / 2.0 - x0 * k, (height - (y1 - y0) * k) / 2.0 - y0 * k); + content.extend(format!("q {k:.6} 0 0 {k:.6} {dx:.3} {dy:.3} cm /Icon Do Q\n").bytes()); + xobjects.set(b"Icon".to_vec(), Object::Ref(icon)); + } + } + if !icon_only && !caption.is_empty() { + let size = ((height - 4.0) * 0.6).clamp(4.0, 14.0); + let tw = helvetica_width(&caption, size); + content.extend(format!("BT /Helv {size:.2} Tf 0 g 1 0 0 1 {:.3} {:.3} Tm ", (width - tw) / 2.0, (height - size * 0.7) / 2.0).bytes()); + content.extend(literal(&win_ansi(&caption))); + content.extend_from_slice(b" Tj ET\n"); + } let mut font = Dict::new(); font.set(b"Type".to_vec(), Object::name("Font")); font.set(b"Subtype".to_vec(), Object::name("Type1")); @@ -546,9 +578,38 @@ fn button_appearance(doc: &Document, w: &Widget) -> Stream { fonts.set(b"Helv".to_vec(), Object::Dict(font)); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } form_stream(width, height, content, res) } +/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels): +/// it becomes the button's icon (`/MK /I`) and its appearance. +pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + if f.kind != FieldKind::PushButton { + return invalid(format!("{name} is not a button or image field")); + } + // The icon form draws the image in a box of its pixel size (keeping its aspect ratio). + let (w, h) = (px.0.max(1) as f64, px.1.max(1) as f64); + let mut xo = Dict::new(); + xo.set(b"Im0".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let icon = doc.add(Object::Stream(form_stream(w, h, format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res))); + for wd in &f.widgets { + let mut mk = + doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + mk.set(b"I".to_vec(), Object::Ref(icon)); + if !mk.contains(b"TP") { + mk.set(b"TP".to_vec(), Object::Int(1)); + } + doc.update_dict(wd.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + redraw_field(doc, name) +} + fn empty_box(doc: &Document, w: &Widget) -> Stream { let (c, width, height) = frame_only(doc, w); form_stream(width, height, c.into_bytes(), Dict::new()) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 666242003e..a0071fd15e 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -16,7 +16,9 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; pub mod af; pub mod appearance; mod author; -pub use author::{BorderStyle, FieldFont, FieldProps, Look, NewField, add_field, delete_field, duplicate_field, look, redraw_field, set_props}; +pub use author::{ + BorderStyle, FieldFont, FieldProps, Look, NewField, add_field, delete_field, duplicate_field, look, redraw_field, set_button_icon, set_props, +}; #[cfg(test)] mod tests; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 420d7e9ca5..baa00aeb00 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -608,3 +608,29 @@ fn locked_fields_only_take_unlocking() { let f = &fields(&doc)[0]; assert!(!f.locked() && f.has(flags::REQUIRED)); } + +#[test] +fn image_fields_ask_for_a_picture_and_show_it() { + let mut doc = one_page(); + let name = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 700.0], &NewField::Image, None).unwrap(); + assert_eq!(name, "Image1"); + let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); + assert_eq!((f.kind, f.button.clone()), (FieldKind::PushButton, Some(af::ButtonAction::ImportIcon))); + // A 4×2 image: the icon keeps its shape and is centred in the 200×100 box. + let mut d = Dict::new(); + for (k, v) in + [(&b"Type"[..], Object::name("XObject")), (b"Subtype", Object::name("Image")), (b"Width", Object::Int(4)), (b"Height", Object::Int(2))] + { + d.set(k.to_vec(), v); + } + let img = doc.add(Object::Stream(printcraft_cos::Stream::from_raw(d, vec![0; 24]))); + set_button_icon(&mut doc, &name, img, (4, 2)).unwrap(); + let w = doc.get(f.widgets[0].obj).as_dict().cloned().unwrap(); + let mk = w.get(b"MK").unwrap().as_dict().unwrap().clone(); + assert_eq!(mk.int(b"TP"), Some(1)); + let ap = w.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let Object::Stream(s) = &*doc.get(ap) else { panic!("appearance") }; + let text = String::from_utf8(s.decoded().unwrap()).unwrap(); + assert!(text.contains("/Icon Do") && text.contains("49.000000 0 0 49.000000 2.000 1.000 cm"), "{text}"); + assert!(set_button_icon(&mut doc, "nope", img, (1, 1)).is_err()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ffc5cf2ef7..0f4042cbd3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1604,6 +1604,7 @@ fn run_button(app: &mut PrintCraftApp, index: usize, ctx: &egui::Context, name: B::Submit(url) => { app.notify(format!("{name} submits the form to {url}; PrintCraft doesn't send form data. Save the document to keep your entries.")) } + B::ImportIcon => app.choose_field_image(name), B::Script(_) => app.notify(format!("{name} runs a script that needs the JavaScript engine (M6)")), } } diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 527840c7d2..a4c206f1d4 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -400,6 +400,30 @@ impl crate::PrintCraftApp { self.notify("Adding images arrives on the web with file pickers for images"); } + /// Click an image field: pick a picture for it. + pub fn choose_field_image(&mut self, name: &str) { + #[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"]) + .set_title("Select Icon") + .pick_file(), + }; + let Some(path) = picked else { return }; + match std::fs::read(&path) { + Ok(bytes) => { + self.apply_edit(Edit::SetFieldImage { name: name.to_string(), image: std::sync::Arc::new(bytes) }); + } + Err(e) => self.notify(format!("Couldn't read {}: {e}", path.display())), + } + } + #[cfg(target_arch = "wasm32")] + self.notify(format!("{name}: choosing images arrives on the web with file pickers for images")); + } + /// Edit image ▸ Replace: pick a file for the selected image. pub fn replace_image_dialog(&mut self, page: usize, index: usize) { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 2039f5519d..8e56474d8d 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -26,17 +26,19 @@ pub enum FieldTool { Combo, List, Button, + Image, Date, Signature, } -pub const FIELD_TOOLS: [FieldTool; 8] = [ +pub const FIELD_TOOLS: [FieldTool; 9] = [ FieldTool::Text, FieldTool::CheckBox, FieldTool::Radio, FieldTool::Combo, FieldTool::List, FieldTool::Button, + FieldTool::Image, FieldTool::Date, FieldTool::Signature, ]; @@ -50,6 +52,7 @@ impl FieldTool { FieldTool::Combo => "form.add.combo", FieldTool::List => "form.add.list", FieldTool::Button => "form.add.button", + FieldTool::Image => "form.add.image", FieldTool::Date => "form.add.date", FieldTool::Signature => "form.add.signature", } @@ -67,6 +70,7 @@ impl FieldTool { FieldTool::Combo => "Drop-down list", FieldTool::List => "List box", FieldTool::Button => "Button", + FieldTool::Image => "Image field", FieldTool::Date => "Date field", FieldTool::Signature => "Digital signature", } @@ -89,6 +93,7 @@ impl FieldTool { FieldTool::Combo => NewField::Combo { options: Vec::new(), editable: false }, FieldTool::List => NewField::List { options: Vec::new(), multi: false }, FieldTool::Button => NewField::Button { caption: String::new() }, + FieldTool::Image => NewField::Image, FieldTool::Date => NewField::Date, FieldTool::Signature => NewField::Signature, } @@ -100,6 +105,7 @@ impl FieldTool { FieldTool::CheckBox | FieldTool::Radio => (14.0, 14.0), FieldTool::List => (144.0, 54.0), FieldTool::Button => (72.0, 22.0), + FieldTool::Image => (144.0, 108.0), FieldTool::Signature => (180.0, 36.0), FieldTool::Date => (108.0, 22.0), FieldTool::Text | FieldTool::Combo => (144.0, 22.0), diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index f3ca09a7a2..522bb8243c 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -318,3 +318,29 @@ fn preview_fills_the_form_and_locked_fields_keep_their_properties() { h.run_steps(3); assert!(!locked(&h)); } + +#[test] +fn image_fields_take_a_picture_when_clicked() { + let mut h = harness(); + assert!(h.state_mut().execute("form.add.image")); + h.run_steps(2); + let (a, b) = (at(&h, 40.0, 220.0), at(&h, 140.0, 290.0)); + drag(&mut h, a, b); + assert_eq!(names(&h).last().map(String::as_str), Some("Image1")); + // A picture to choose (the picker is bypassed in tests). + let path = std::env::temp_dir().join(format!("printcraft-image-field-{}.png", std::process::id())); + image::RgbImage::from_pixel(8, 4, image::Rgb([200, 30, 30])).save(&path).unwrap(); + h.state_mut().save_override = Some(path.to_string_lossy().into_owned()); + // Fill it in as a reader would. + h.get_by_label("Preview").click(); + h.run_steps(2); + let c = at(&h, 90.0, 255.0); + h.hover_at(c); + h.run_steps(1); + h.drag_at(c); + h.run_steps(1); + h.drop_at(c); + h.run_steps(4); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Set the image of Image1")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 338924965b..dd5ade99d8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4707,11 +4707,11 @@ title = "Add image fields" area = "F" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.add.image"] +tools = ["form_add_field", "form_set_image"] +evidence = ["crates/forms/src/tests.rs::image_fields_ask_for_a_picture_and_show_it", "crates/ui-egui/tests/prepare.rs::image_fields_take_a_picture_when_clicked", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +notes = "icon-only push button with event.target.buttonImportIcon(), recognised natively; clicking asks for a PNG/JPEG/TIFF/GIF/BMP that becomes /MK /I, scaled proportionally and centred" [[feature]] id = "form.add-date-field"