From d8209fcbd2b5cd76789f3751bbf7c291d1c24a4f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 00:04:25 -0400 Subject: [PATCH] =?UTF-8?q?M6.6:=20Prepare=20a=20form:=20add=20text,=20dat?= =?UTF-8?q?e,=20check=20box,=20radio=20(new=20group=20or=20join=20the=20se?= =?UTF-8?q?lected=20one),=20drop-down,=20list=20box,=20button=20and=20sign?= =?UTF-8?q?ature=20fields=20with=20Acrobat's=20names=20(Text1,=20Check=20B?= =?UTF-8?q?ox1,=20Group1=E2=80=A6)=20by=20click=20or=20drag;=20fields=20sh?= =?UTF-8?q?own=20with=20blue=20fill=20and=20name=20tags,=20select/move/res?= =?UTF-8?q?ize/Delete;=20Field=20Properties=20(General,=20Appearance=20fon?= =?UTF-8?q?t=20size,=20Position,=20Options);=20engine=20AddField/SetFieldP?= =?UTF-8?q?rops/DeleteField;=20form=5Fadd=5Ffield/form=5Fset=5Fprops/form?= =?UTF-8?q?=5Fdelete=5Ffield=20tools=20(54=20tools).=20Fix:=20check=20box?= =?UTF-8?q?=20appearance=20states=20were=20direct=20streams=20that=20read?= =?UTF-8?q?=20back=20as=20null=20after=20saving?= 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 | 96 +++- crates/automation/src/lib.rs | 3 + crates/automation/src/tools.rs | 46 ++ crates/automation/tests/automation.rs | 29 ++ crates/engine/src/catalog.rs | 19 +- crates/engine/src/commands.rs | 10 + crates/engine/src/lib.rs | 34 +- crates/engine/src/tests.rs | 28 ++ crates/forms/README.md | 12 +- crates/forms/src/appearance.rs | 12 +- crates/forms/src/author.rs | 527 +++++++++++++++++++++ crates/forms/src/lib.rs | 4 +- crates/forms/src/tests.rs | 106 +++++ crates/ui-egui/src/canvas.rs | 47 +- crates/ui-egui/src/commands.rs | 20 + crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/dialogs.rs | 21 + crates/ui-egui/src/lib.rs | 10 + crates/ui-egui/src/prepare.rs | 630 ++++++++++++++++++++++++++ crates/ui-egui/tests/prepare.rs | 115 +++++ parity/acrobat-features.toml | 130 +++--- 21 files changed, 1814 insertions(+), 86 deletions(-) create mode 100644 crates/forms/src/author.rs create mode 100644 crates/ui-egui/src/prepare.rs create mode 100644 crates/ui-egui/tests/prepare.rs diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 9c37d7d0..53731390 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -1,6 +1,7 @@ -//! Form tools: list fields, fill them (several at once, one undo step) and clear the form. +//! Form tools: list fields, fill them (several at once, one undo step), clear the form, and +//! prepare a form (add, change and delete fields). -use printcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, field_flags}; +use printcraft_engine::{Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; use serde_json::{Value, json}; use crate::{Args, Automation, Result, ToolError, failed}; @@ -112,4 +113,95 @@ impl Automation { let names = a.get("fields").map(|_| a.strs("fields").map(|v| v.into_iter().map(str::to_owned).collect::>())).transpose()?; self.apply(a, Edit::ResetForm { names }) } + + pub(crate) fn form_add_field(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let doc = self.doc(a)?; + let options = || -> Result> { + Ok(a.get("options").map(|_| a.strs("options")).transpose()?.unwrap_or_default().into_iter().map(str::to_owned).collect()) + }; + let kind = match a.str("type")? { + "text" => NewField::Text { multiline: a.opt_bool("multiline")?.unwrap_or(false) }, + "date" => NewField::Date, + "checkbox" => NewField::CheckBox, + "radio" => { + NewField::Radio { group: a.opt_str("group")?.map(str::to_owned), export: a.opt_str("export")?.unwrap_or("Choice1").to_owned() } + } + "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() }, + "signature" => NewField::Signature, + t => return Err(ToolError::InvalidArgs(format!("unknown field type {t:?}"))), + }; + let r: Vec = 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("rect must be 4 numbers".into()))?; + // Top-left-origin points on the displayed page → user space. + let p = &doc.info.pages[page]; + let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); + let rect = [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]; + if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(ToolError::InvalidArgs("rect is empty".into())); + } + let before: Vec = doc.form.iter().map(|f| f.name.clone()).collect(); + let name = a.opt_str("name")?.map(str::to_owned); + let mut out = self.apply(a, Edit::AddField { page, rect, kind, name })?; + let doc = self.doc(a)?; + let added = doc + .form + .iter() + .map(|f| &f.name) + .find(|n| !before.contains(n)) + .or_else(|| a.opt_str("group").ok().flatten().and_then(|g| doc.form.iter().map(|f| &f.name).find(|n| *n == g))); + out["field"] = json!(added); + Ok(out) + } + + pub(crate) fn form_set_props(&mut self, a: &Args) -> Result { + let name = a.str("field")?.to_owned(); + let doc = self.doc(a)?; + let f = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?; + // Position: top-left-origin points on the displayed page → user space. + let rect = match a.get("rect") { + None => None, + Some(v) => { + let r: Vec = v.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("rect must be 4 numbers".into()))?; + let page = f.widgets.first().and_then(|w| w.page).ok_or_else(|| failed(format!("{name} is not on a page")))?; + let p = &doc.info.pages[page]; + let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); + Some((0, [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])) + } + }; + let props = FieldProps { + rect, + name: a.opt_str("name")?.map(str::to_owned), + tooltip: a.opt_str("tooltip")?.map(str::to_owned), + read_only: a.opt_bool("read_only")?, + required: a.opt_bool("required")?, + multiline: a.opt_bool("multiline")?, + max_len: match a.opt_int("max_length")? { + None => None, + Some(0) => Some(None), + Some(n) if n > 0 => Some(Some(n as usize)), + Some(_) => return Err(ToolError::InvalidArgs("max_length must be 0 (no limit) or more".into())), + }, + options: a.get("options").map(|_| a.strs("options")).transpose()?.map(|v| v.into_iter().map(str::to_owned).collect()), + font_size: a.opt_num("font_size")?, + }; + if props == FieldProps::default() { + return Err(ToolError::InvalidArgs("nothing to change".into())); + } + let new_name = props.name.clone().unwrap_or(name.clone()); + let mut out = self.apply(a, Edit::SetFieldProps { name, props })?; + out["field"] = json!(new_name); + Ok(out) + } + + pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result { + let name = a.str("field")?.to_owned(); + if !self.doc(a)?.form.iter().any(|f| f.name == name) { + return Err(failed(format!("there is no field named {name:?} (see form_fields)"))); + } + self.apply(a, Edit::DeleteField { name }) + } } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 93abcdf1..9c96216a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -253,6 +253,9 @@ impl Automation { "form_fields" => self.form_fields(&a)?, "form_fill" => self.form_fill(&a)?, "form_reset" => self.form_reset(&a)?, + "form_add_field" => self.form_add_field(&a)?, + "form_set_props" => self.form_set_props(&a)?, + "form_delete_field" => self.form_delete_field(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, "comment_list" => self.comment_list(&a)?, "comment_add" => self.comment_add(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0640b22e..c0029261 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -211,6 +211,52 @@ pub fn tools() -> Vec { t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "fields": { "type": "array", "items": { "type": "string" } } }), &["doc"])), + 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.", + ) + .with(schema( + json!({ + "doc": doc(), + "page": { "type": "integer", "minimum": 1 }, + "type": { "type": "string", "enum": ["text", "date", "checkbox", "radio", "combo", "list", "button", "signature"] }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + "name": { "type": "string" }, + "multiline": { "type": "boolean" }, + "group": { "type": "string", "description": "Radio group to join." }, + "export": { "type": "string", "description": "Radio button export value (default Choice1)." }, + "options": { "type": "array", "items": { "type": "string" }, "description": "Combo box / list box items." }, + "editable": { "type": "boolean", "description": "Combo box accepts typed text." }, + "multi_select": { "type": "boolean" }, + "caption": { "type": "string", "description": "Button label." }, + }), + &["doc", "page", "type", "rect"], + )), + t( + "form_set_props", + "Field properties", + "Change a field's properties (General and Options tabs): name (renames it), tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), rect (position). Only the given ones change. Undoable.", + ) + .with(schema( + json!({ + "doc": doc(), + "field": { "type": "string", "description": "The field's current name." }, + "name": { "type": "string" }, + "tooltip": { "type": "string" }, + "read_only": { "type": "boolean" }, + "required": { "type": "boolean" }, + "multiline": { "type": "boolean" }, + "max_length": { "type": "integer", "minimum": 0 }, + "options": { "type": "array", "items": { "type": "string" } }, + "font_size": { "type": "number", "minimum": 0 }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." }, + }), + &["doc", "field"], + )), + t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.") + .destructive() + .with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])), t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index ebaa5946..146f3917 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -587,3 +587,32 @@ fn replacing_pages_through_tools() { "b.pdf has only 2 pages" ); } + +#[test] +fn preparing_a_form_through_tools() { + let dir = workdir("prepare"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let add = |a: &mut Automation, args: Value| ok(a, "form_add_field", args)["field"].as_str().unwrap().to_owned(); + assert_eq!(add(&mut a, json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42] })), "Text1"); + assert_eq!(add(&mut a, json!({ "doc": doc, "page": 1, "type": "combo", "rect": [20, 60, 180, 82], "options": ["Red", "Green"] })), "Dropdown1"); + assert_eq!(add(&mut a, json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" })), "size"); + assert_eq!(add(&mut a, json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "size", "export": "L" })), "size"); + let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "Text1", "name": "full name", "required": true, "tooltip": "Your name" })); + assert_eq!(r["field"], "full name"); + ok(&mut a, "form_delete_field", json!({ "doc": doc, "field": "Dropdown1" })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "full name", "rect": [30, 20, 190, 42] })); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let f = fields["fields"].as_array().unwrap(); + assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::>(), ["full name", "size"]); + assert_eq!((f[0]["required"].as_bool(), f[0]["tooltip"].as_str()), (Some(true), Some("Your name"))); + assert_eq!(f[0]["rect"], json!([30.0, 20.0, 190.0, 42.0]), "moved; the rect round-trips in view coordinates"); + assert_eq!(f[1]["options"], json!(["S", "L"])); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "full name": "Ada", "size": "L" } })); + assert!(page_text(&mut a, doc)[0].contains("Ada")); + assert!(matches!( + a.call("form_add_field", &json!({ "doc": doc, "page": 1, "type": "slider", "rect": [0, 0, 9, 9] })), + Err(ToolError::InvalidArgs(_)) + )); + assert!(matches!(a.call("form_delete_field", &json!({ "doc": doc, "field": "nope" })), Err(ToolError::Failed(_)))); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index f31b9005..b2f281eb 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -311,27 +311,28 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ icon: "text-cursor-input", hue: PURPLE, badge: None, - availability: Planned("M6"), + availability: Ready, sections: &[ ToolSection { title: "Fields", items: &[ item("Field list", "list", "form.fields", Ready), item("Clear form", "eraser", "form.clear", Ready), + item("Field properties", "settings-2", "form.field.properties", Ready), item("Flatten form fields", "layers", "form.flatten", Ready), ], }, ToolSection { title: "Add form components", items: &[ - item("Text field", "text-cursor-input", "form.add.text", Planned("M6")), - item("Checkbox", "check-circle-2", "form.add.checkbox", Planned("M6")), - item("Radio button", "circle", "form.add.radio", Planned("M6")), - item("Drop-down list", "chevron-down", "form.add.combo", Planned("M6")), - item("List box", "list", "form.add.list", Planned("M6")), - item("Button", "square", "form.add.button", Planned("M6")), - item("Date field", "clock-3", "form.add.date", Planned("M6")), - item("Digital signature", "signature", "form.add.signature", Planned("M6")), + item("Text field", "text-cursor-input", "form.add.text", Ready), + item("Checkbox", "check-circle-2", "form.add.checkbox", Ready), + item("Radio button", "circle", "form.add.radio", Ready), + 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("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 9b7c60b3..6de6ead9 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -174,6 +174,16 @@ pub const COMMANDS: &[CommandSpec] = &[ c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"), c("comment.flatten", "Flatten comments", None, None, HasComments, "layers"), c("form.flatten", "Flatten form fields", None, None, HasFields, "layers"), + c("form.prepare", "Prepare a form", None, None, Modification, "text-cursor-input"), + c("form.field.properties", "Field properties…", None, None, HasFields, "settings-2"), + c("form.add.text", "Add a text field", None, None, Modification, "text-cursor-input"), + c("form.add.checkbox", "Add a checkbox", None, None, Modification, "check-circle-2"), + c("form.add.radio", "Add a radio button", None, None, Modification, "circle"), + 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.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.fill.text", "Fill & Sign: add text", None, None, Annotate, "type"), c("sign.fill.check", "Fill & Sign: checkmark", None, None, Annotate, "check"), c("sign.fill.cross", "Fill & Sign: cross", None, None, Annotate, "x"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f667c227..e556020c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,7 +22,9 @@ pub use printcraft_organize::LabelStyle; pub use printcraft_cos::Algorithm; pub use printcraft_edit::{Background, HeaderFooter, MarkKind, Watermark}; -pub use printcraft_forms::{Field as FormField, FieldKind as FormFieldKind, FieldValue, Widget as FormWidget, flags as field_flags}; +pub use printcraft_forms::{ + Field as FormField, FieldKind as FormFieldKind, FieldProps, FieldValue, NewField, Widget as FormWidget, flags as field_flags, +}; /// Comment geometry helpers (text-box line breaking) for frontends. pub use printcraft_annot::appearance as annot_text; @@ -498,6 +500,23 @@ pub enum Edit { ResetForm { names: Option>, }, + /// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to + /// Acrobat's next free name ("Text1", "Check Box2", …). + AddField { + page: usize, + rect: [f64; 4], + kind: NewField, + name: Option, + }, + /// Field Properties ▸ General / Options. + SetFieldProps { + name: String, + props: FieldProps, + }, + /// Delete a field and all its widgets. + DeleteField { + name: String, + }, /// Add a header and footer (with `replace`, existing ones on those pages go first). AddHeaderFooter { pages: Vec, @@ -564,6 +583,9 @@ impl Edit { Edit::StyleAnnotation { .. } | Edit::SetAnnotationInfo { .. } => "Change comment properties".into(), Edit::SetFieldValue { name, .. } => format!("Fill in {name}"), Edit::ResetForm { .. } => "Clear form".into(), + Edit::AddField { .. } => "Add field".into(), + Edit::SetFieldProps { .. } => "Change field properties".into(), + Edit::DeleteField { .. } => "Delete field".into(), Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(), Edit::AddHeaderFooter { .. } => "Update header & footer".into(), Edit::AddWatermark { replace: false, .. } => "Add watermark".into(), @@ -671,6 +693,9 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } + | Edit::AddField { .. } + | Edit::SetFieldProps { .. } + | Edit::DeleteField { .. } | Edit::Flatten { .. } => { if p.modify() { Ok(()) @@ -799,6 +824,13 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::ResetForm { names } => { printcraft_forms::reset(doc, names.as_deref())?; } + Edit::AddField { page, rect, kind, name } => { + printcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; + } + Edit::SetFieldProps { name, props } => { + printcraft_forms::set_props(doc, name, props)?; + } + Edit::DeleteField { name } => printcraft_forms::delete_field(doc, name)?, Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; printcraft_edit::add_header_footer(doc, pages, settings, *replace, &printcraft_edit::Context { date })?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 70d76760..3bcb39d8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -662,6 +662,34 @@ fn form_edits_refresh_field_values_without_a_full_inspection() { assert_eq!(values(&d.info.fields), [("name".to_string(), Some("Ada".to_string())), ("ok".to_string(), Some("Yes".to_string()))]); } +#[test] +fn preparing_a_form_adds_renames_and_deletes_fields() { + let (mut s, id) = session_with(1); + assert!(s.get(id).unwrap().form.is_empty()); + let add = |kind: NewField, y: f64| Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: None }; + s.apply(id, add(NewField::Text { multiline: false }, 250.0)).unwrap(); + s.apply(id, add(NewField::CheckBox, 200.0)).unwrap(); + s.apply(id, add(NewField::Text { multiline: false }, 150.0)).unwrap(); + let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); + assert_eq!(names(&s), ["Text1", "Check Box1", "Text2"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add field")); + let props = FieldProps { name: Some("email".into()), tooltip: Some("Your e-mail".into()), required: Some(true), ..Default::default() }; + s.apply(id, Edit::SetFieldProps { name: "Text2".into(), props }).unwrap(); + s.apply(id, Edit::DeleteField { name: "Check Box1".into() }).unwrap(); + assert_eq!(names(&s), ["Text1", "email"]); + s.apply(id, Edit::SetFieldValue { name: "email".into(), value: FieldValue::Text("ada@example.org".into()) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("f.pdf", None, saved, None).unwrap(); + let d = s2.get(id2).unwrap(); + assert_eq!(d.form.iter().map(|f| f.name.as_str()).collect::>(), ["Text1", "email"]); + assert_eq!(d.form[1].value, ["ada@example.org"]); + assert!(page_texts(&s2, id2).iter().any(|t| t.contains("ada@example.org"))); + s.undo(id).unwrap(); + s.undo(id).unwrap(); + assert_eq!(names(&s), ["Text1", "Check Box1", "email"]); +} + #[test] fn crop_and_duplicate_pages_show_in_the_viewer() { let (mut s, id) = session_with(2); diff --git a/crates/forms/README.md b/crates/forms/README.md index 15fdc8ad..e59a945a 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -12,6 +12,11 @@ set_value(&mut doc, "agree", &FieldValue::Check(true))?; set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?; set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into()]))?; reset(&mut doc, None)?; // Clear form (back to /DV) + +// Prepare a form +let name = add_field(&mut doc, 0, [72.0, 700.0, 216.0, 722.0], &NewField::Text { multiline: false }, None)?; // "Text1" +set_props(&mut doc, &name, &FieldProps { name: Some("email".into()), required: Some(true), ..Default::default() })?; +delete_field(&mut doc, "email")?; ``` ## Behaviour @@ -26,8 +31,13 @@ reset(&mut doc, None)?; // Clear form (back - Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a failed call changes nothing. - `/RV` (rich text) is removed when a value is set so it can't contradict `/V`. +- New fields follow Acrobat: names Text1, Check Box1, Group1 (radio groups), Dropdown1, List Box1, + Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio + buttons joining a group become kid widgets with their own export state. Renaming changes only + the last part of a dotted name. ## Not yet JavaScript actions (format, keystroke, validate, calculate; M6.4/M6.5), rich-text values, -`/MK /R` rotation, field creation and properties (M6.6), FDF/XFDF import and export (M6.7). +`/MK /R` rotation, the Appearance (colours, fonts), Actions, Format, Validate and Calculate +property tabs, FDF/XFDF import and export (M6.7). diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 0d76ddcf..054fc342 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -21,6 +21,11 @@ fn n(v: f64) -> String { if s == "-0" { "0".into() } else { s.into() } } +/// Format a number for content streams (up to three decimals). +pub fn fmt(v: f64) -> String { + n(v) +} + /// A parsed default appearance string: font resource name, size (0 = auto) and colour operator. #[derive(Clone, Debug, PartialEq)] pub struct Da { @@ -250,17 +255,18 @@ pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String] /// On/Off appearances for a check box or radio button that has none (a check mark or a dot, /// drawn as paths, so no symbol font is needed). -pub fn check_box_states(doc: &Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict { +/// The streams are added to `doc` as indirect objects (streams can't be direct objects). +pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict { let wobj = doc.get(w.obj); let wd = wobj.as_dict().cloned().unwrap_or_default(); let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); let (frame_c, _) = frame(doc, &wd, width, height); - let form = |content: String| -> Object { + let mut form = |content: String| -> Object { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect())); - Object::Stream(Stream::flate(d, content.as_bytes())) + Object::Ref(doc.add(Object::Stream(Stream::flate(d, content.as_bytes())))) }; let s = width.min(height); let (cx, cy) = (width / 2.0, height / 2.0); diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs new file mode 100644 index 00000000..e810e562 --- /dev/null +++ b/crates/forms/src/author.rs @@ -0,0 +1,527 @@ +//! Prepare a form: create, change and delete fields (execution plan M6.6). +//! +//! New fields follow Acrobat's conventions: names "Text1", "Check Box1", "Group1" (radio groups), +//! "Dropdown1", "List Box1", "Button1", "Signature1", "Date1"; 12 pt Helvetica (auto-size for +//! check boxes), a thin grey border and a white background, and appearance streams generated +//! immediately so every viewer shows the empty field. + +use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use printcraft_fonts::{helvetica_width, literal, win_ansi}; + +use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs}; + +/// The kind of field to add. +#[derive(Clone, Debug, PartialEq)] +pub enum NewField { + Text { + multiline: bool, + }, + /// A date field: a text field with Acrobat's date format action (`AFDate_FormatEx`). + Date, + CheckBox, + /// One radio button in `group` (a new group is created when none of that name exists), + /// selected by `export`. + Radio { + group: Option, + export: String, + }, + Combo { + options: Vec, + editable: bool, + }, + List { + options: Vec, + multi: bool, + }, + Button { + caption: String, + }, + Signature, +} + +impl NewField { + fn base_name(&self) -> &'static str { + match self { + NewField::Text { .. } => "Text", + NewField::Date => "Date", + NewField::CheckBox => "Check Box", + NewField::Radio { .. } => "Group", + NewField::Combo { .. } => "Dropdown", + NewField::List { .. } => "List Box", + NewField::Button { .. } => "Button", + NewField::Signature => "Signature", + } + } +} + +/// Field properties the Properties dialog edits (`None` leaves a value unchanged). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FieldProps { + pub name: Option, + pub tooltip: Option, + pub read_only: Option, + pub required: Option, + pub multiline: Option, + pub max_len: Option>, + /// Choice options (display texts; export values equal them). + pub options: Option>, + /// Font size (0 = auto). + pub font_size: Option, + /// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in + /// user space. + pub rect: Option<(usize, [f64; 4])>, +} + +fn invalid(m: impl Into) -> Result { + Err(FormError::Invalid(m.into())) +} + +fn rgb(c: [f64; 3]) -> Object { + Object::Array(c.iter().map(|v| Object::Real(*v)).collect()) +} + +/// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. +fn ensure_form(doc: &mut Document) -> Result { + let root = doc.root().ok_or(FormError::NoForm)?; + let existing = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()); + let r = match existing { + Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => r, + other => { + let d = other.and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let r = doc.add(Object::Dict(d)); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; + r + } + }; + let mut af = doc.get(r).as_dict().cloned().unwrap_or_default(); + if !af.contains(b"Fields") { + af.set(b"Fields".to_vec(), Object::Array(Vec::new())); + } + if !af.contains(b"DA") { + af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + } + let mut dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let mut fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + if !fonts.contains(b"Helv") { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts.set(b"Helv".to_vec(), Object::Dict(f)); + dr.set(b"Font".to_vec(), Object::Dict(fonts)); + af.set(b"DR".to_vec(), Object::Dict(dr)); + } + doc.set(r, Object::Dict(af)); + Ok(r) +} + +fn add_to_page(doc: &mut Document, page: ObjRef, widget: ObjRef) -> Result<(), FormError> { + let existing = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()); + match existing.as_ref().and_then(|o| o.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) { + Some(r) => { + let mut a = doc.get(r).as_array().cloned().unwrap_or_default(); + a.push(Object::Ref(widget)); + doc.set(r, Object::Array(a)); + } + None => { + let mut a = existing.and_then(|o| o.as_array().cloned()).unwrap_or_default(); + a.push(Object::Ref(widget)); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; + } + } + Ok(()) +} + +fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), FormError> { + let mut d = doc.get(af).as_dict().cloned().unwrap_or_default(); + let mut list = d.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.push(Object::Ref(field)); + d.set(b"Fields".to_vec(), Object::Array(list)); + doc.set(af, Object::Dict(d)); + Ok(()) +} + +/// The first free "N" name. +fn free_name(existing: &[Field], base: &str) -> String { + (1..).map(|n| format!("{base}{n}")).find(|c| !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))).expect("unbounded") +} + +fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"F".to_vec(), Object::Int(4)); + let mut mk = Dict::new(); + mk.set(b"BC".to_vec(), rgb([0.55, 0.55, 0.55])); + mk.set(b"BG".to_vec(), rgb([1.0, 1.0, 1.0])); + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Int(1)); + bs.set(b"S".to_vec(), Object::name("S")); + d.set(b"BS".to_vec(), Object::Dict(bs)); + d +} + +/// Add a field on `page` (0-based) in `rect` (user space). Returns its full name. +pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewField, name: Option<&str>) -> Result { + let pages = page_refs(doc); + let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?; + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 4.0 || rect[3] - rect[1] < 4.0 { + return invalid("the field is too small"); + } + let existing = fields(doc); + // A radio button names its group: a new group takes that name. + let name = match (name, kind) { + (None, NewField::Radio { group: Some(g), .. }) => Some(g.as_str()), + _ => name, + }; + let name = match name.map(str::trim) { + Some(n) if !n.is_empty() => { + if n.contains('.') { + return invalid("field names can't contain a period"); + } + n.to_string() + } + _ => free_name(&existing, kind.base_name()), + }; + let radio_group = match kind { + NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), + _ => None, + }; + if radio_group.is_none() && existing.iter().any(|f| f.name == name) { + return invalid(format!("a field named {name:?} already exists")); + } + let af = ensure_form(doc)?; + let mut w = widget_dict(page_ref, rect); + let field_name = match kind { + NewField::Radio { group: Some(g), .. } if radio_group.is_some() => g.clone(), + _ => name.clone(), + }; + match kind { + NewField::Text { multiline } => { + w.set(b"FT".to_vec(), Object::name("Tx")); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); + if *multiline { + w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64)); + } + } + NewField::Date => { + w.set(b"FT".to_vec(), Object::name("Tx")); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); + // Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine). + let js = |s: &str| { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(s.as_bytes().to_vec())); + Object::Dict(a) + }; + let mut aa = Dict::new(); + aa.set(b"F".to_vec(), js("AFDate_FormatEx(\"mm/dd/yyyy\");")); + aa.set(b"K".to_vec(), js("AFDate_KeystrokeEx(\"mm/dd/yyyy\");")); + w.set(b"AA".to_vec(), Object::Dict(aa)); + } + NewField::CheckBox => { + w.set(b"FT".to_vec(), Object::name("Btn")); + w.set(b"V".to_vec(), Object::name("Off")); + w.set(b"AS".to_vec(), Object::name("Off")); + w.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); + } + NewField::Radio { export, .. } => { + w.set(b"AS".to_vec(), Object::name("Off")); + if export.trim().is_empty() || export == "Off" { + return invalid("a radio button needs an export value other than Off"); + } + } + NewField::Combo { options, editable } | NewField::List { options, multi: editable } => { + w.set(b"FT".to_vec(), Object::name("Ch")); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); + let combo = matches!(kind, NewField::Combo { .. }); + let mut ff = 0; + if combo { + ff |= flags::COMBO; + if *editable { + ff |= flags::EDIT; + } + } else if *editable { + ff |= flags::MULTI_SELECT; + } + w.set(b"Ff".to_vec(), Object::Int(ff as i64)); + w.set(b"Opt".to_vec(), Object::Array(options.iter().map(|o| Object::String(PdfString::text(o))).collect())); + } + NewField::Button { caption } => { + 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(); + mk.set(b"BG".to_vec(), rgb([0.86, 0.86, 0.86])); + mk.set(b"CA".to_vec(), PdfString::text(caption)); + w.set(b"MK".to_vec(), Object::Dict(mk)); + } + NewField::Signature => { + w.set(b"FT".to_vec(), Object::name("Sig")); + } + } + let widget = doc.add(Object::Dict(w)); + match kind { + NewField::Radio { export, .. } => { + let group = match &radio_group { + Some(g) => g.obj, + None => { + let mut g = Dict::new(); + g.set(b"FT".to_vec(), Object::name("Btn")); + g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); + g.set(b"T".to_vec(), PdfString::text(&field_name)); + g.set(b"V".to_vec(), Object::name("Off")); + g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); + g.set(b"Kids".to_vec(), Object::Array(Vec::new())); + let g = doc.add(Object::Dict(g)); + add_to_fields(doc, af, g)?; + g + } + }; + doc.update_dict(widget, |d| d.set(b"Parent".to_vec(), Object::Ref(group)))?; + doc.update_dict(group, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.push(Object::Ref(widget)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + })?; + let w = Widget { obj: widget, page: Some(page), rect, on_state: Some(export.clone()), state: Some("Off".into()) }; + let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); + doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; + } + _ => { + doc.update_dict(widget, |d| d.set(b"T".to_vec(), PdfString::text(&name)))?; + add_to_fields(doc, af, widget)?; + } + } + add_to_page(doc, page_ref, widget)?; + redraw_field(doc, &field_name)?; + Ok(field_name) +} + +/// Regenerate every widget appearance of a field from its current value and settings. +pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + for w in &f.widgets { + let ap = match f.kind { + FieldKind::Text | FieldKind::Combo | FieldKind::List => { + let s = appearance::field_appearance(doc, &f, w, &f.value); + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + FieldKind::CheckBox | FieldKind::Radio => { + let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); + appearance::check_box_states(doc, w, f.kind, &on) + } + FieldKind::PushButton => { + let s = button_appearance(doc, w); + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + FieldKind::Signature => { + let s = empty_box(doc, w); + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + }; + doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; + } + Ok(()) +} + +fn frame_only(doc: &Document, w: &Widget) -> (String, f64, f64) { + let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); + let col = |k: &[u8]| -> Option { + let v: Vec = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); + (v.len() == 3).then(|| format!("{} {} {}", crate::appearance::fmt(v[0]), crate::appearance::fmt(v[1]), crate::appearance::fmt(v[2]))) + }; + let mut c = String::new(); + if let Some(bg) = col(b"BG") { + c.push_str(&format!("{bg} rg 0 0 {width:.3} {height:.3} re f\n")); + } + if let Some(bc) = col(b"BC") { + c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0)); + } + (c, width, height) +} + +fn form_stream(width: f64, height: f64, content: Vec, resources: Dict) -> Stream { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + Stream::flate(d, &content) +} + +/// A push button: background, border and its centred caption. +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 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 font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let mut fonts = Dict::new(); + fonts.set(b"Helv".to_vec(), Object::Dict(font)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + form_stream(width, height, content, res) +} + +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()) +} + +/// Change a field's properties (General and Options tabs) and redraw it. +pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); + let mut new_name = name.to_string(); + if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { + if n.is_empty() || n.contains('.') { + return invalid("field names can't be empty or contain a period"); + } + if all.iter().any(|x| x.name == n) { + return invalid(format!("a field named {n:?} already exists")); + } + // Rename the terminal part (the field's own /T). + let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + new_name = format!("{prefix}{n}"); + doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; + } + let mut ff = f.flags; + let mut set_flag = |flag: u32, on: Option| { + if let Some(on) = on { + if on { + ff |= flag; + } else { + ff &= !flag; + } + } + }; + set_flag(flags::READ_ONLY, props.read_only); + set_flag(flags::REQUIRED, props.required); + if f.kind == FieldKind::Text { + set_flag(flags::MULTILINE, props.multiline); + } + if props.options.as_ref().is_some_and(|o| o.is_empty()) && matches!(f.kind, FieldKind::Combo | FieldKind::List) { + return invalid("a list needs at least one option"); + } + doc.update_dict(f.obj, |d| { + d.set(b"Ff".to_vec(), Object::Int(ff as i64)); + if let Some(t) = &props.tooltip { + if t.is_empty() { + d.remove(b"TU"); + } else { + d.set(b"TU".to_vec(), PdfString::text(t)); + } + } + if let Some(m) = props.max_len { + match m { + Some(m) => d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), + None => { + d.remove(b"MaxLen"); + } + } + } + if let Some(opts) = &props.options { + d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); + } + if let Some(size) = props.font_size { + d.set(b"DA".to_vec(), PdfString::literal(format!("/Helv {} Tf 0 g", crate::appearance::fmt(size.clamp(0.0, 100.0))).into_bytes())); + } + })?; + if let Some((wi, r)) = props.rect { + let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; + let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < 4.0 || r[3] - r[1] < 4.0 { + return invalid("the field is too small"); + } + doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; + } + // Widgets may carry their own /DA; keep them in step with the field. + if let Some(size) = props.font_size { + for w in &f.widgets { + if w.obj != f.obj { + doc.update_dict(w.obj, |d| { + if d.contains(b"DA") { + d.set( + b"DA".to_vec(), + PdfString::literal(format!("/Helv {} Tf 0 g", crate::appearance::fmt(size.clamp(0.0, 100.0))).into_bytes()), + ); + } + })?; + } + } + } + redraw_field(doc, &new_name)?; + Ok(new_name) +} + +/// Delete a field: its widgets leave their pages and the field leaves the form. +pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); + let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); + for p in page_refs(doc) { + let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; + let list = doc.resolve(&a).as_array().cloned().unwrap_or_default(); + let kept: Vec = list.iter().filter(|o| !o.as_ref().is_some_and(|r| widgets.contains(&r))).cloned().collect(); + if kept.len() != list.len() { + match a.as_ref() { + Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), + _ => doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?, + } + } + } + // Out of the field tree: from its parent's /Kids, or from /Fields. + let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); + let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { + let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); + d.set(key.to_vec(), Object::Array(kept)); + doc.set(holder, Object::Dict(d)); + Ok(()) + }; + match parent { + Some(p) => remove_from(doc, p, b"Kids")?, + None => { + let root = doc.root().ok_or(FormError::NoForm)?; + if let Some(af) = doc.get(root).as_dict().and_then(|d| d.reference(b"AcroForm")) { + remove_from(doc, af, b"Fields")?; + } + } + } + Ok(()) +} diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 85b502e7..750388fd 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -14,6 +14,8 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; pub mod appearance; +mod author; +pub use author::{FieldProps, NewField, add_field, delete_field, redraw_field, set_props}; #[cfg(test)] mod tests; @@ -157,7 +159,7 @@ fn nums(doc: &Document, o: Option<&Object>) -> Option> { } /// Leaf page objects in order (a small walker; `organize` and `annot` have their own). -fn page_refs(doc: &Document) -> Vec { +pub(crate) fn page_refs(doc: &Document) -> Vec { let Some(root) = doc.root() else { return Vec::new() }; let Some(pages) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() }; let mut out = Vec::new(); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index a6802545..56e12852 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -201,3 +201,109 @@ fn documents_without_forms_say_so() { assert!(fields(&doc).is_empty()); assert_eq!(set_value(&mut doc, "x", &FieldValue::Text("y".into())), Err(FormError::NoForm)); } + +#[test] +fn every_field_type_can_be_added_named_and_drawn() { + let mut doc = Document::new_empty(); + { + // One page. + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut p = printcraft_cos::Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + let r = doc.add(p); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(r)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + } + let r = |y: f64| [50.0, y, 250.0, y + 20.0]; + let names: Vec = [ + (NewField::Text { multiline: false }, r(700.0)), + (NewField::Text { multiline: true }, [50.0, 600.0, 250.0, 680.0]), + (NewField::Date, r(560.0)), + (NewField::CheckBox, [50.0, 520.0, 64.0, 534.0]), + (NewField::Radio { group: None, export: "Small".into() }, [50.0, 480.0, 64.0, 494.0]), + (NewField::Combo { options: vec!["Red".into(), "Blue".into()], editable: false }, r(440.0)), + (NewField::List { options: vec!["A".into(), "B".into(), "C".into()], multi: true }, [50.0, 340.0, 250.0, 420.0]), + (NewField::Button { caption: "Submit".into() }, r(300.0)), + (NewField::Signature, [50.0, 220.0, 250.0, 270.0]), + ] + .iter() + .map(|(k, rect)| add_field(&mut doc, 0, *rect, k, None).unwrap()) + .collect(); + assert_eq!(names, ["Text1", "Text2", "Date1", "Check Box1", "Group1", "Dropdown1", "List Box1", "Button1", "Signature1"]); + // A second radio button joins the group. + assert_eq!( + add_field(&mut doc, 0, [80.0, 480.0, 94.0, 494.0], &NewField::Radio { group: Some("Group1".into()), export: "Large".into() }, None).unwrap(), + "Group1" + ); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(all.len(), 9); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Small", "Large"]); + assert!(field(&all, "Text2").has(flags::MULTILINE)); + assert_eq!(field(&all, "Dropdown1").options.len(), 2); + assert!(field(&all, "List Box1").has(flags::MULTI_SELECT)); + assert_eq!(field(&all, "Button1").kind, FieldKind::PushButton); + assert!(ap(&doc, &field(&all, "Button1").widgets[0]).contains("(Submit) Tj")); + // Every widget is on the page and has an appearance. + for f in &all { + for w in &f.widgets { + assert_eq!(w.page, Some(0), "{}", f.name); + assert!(doc.get(w.obj).as_dict().unwrap().contains(b"AP"), "{} has an appearance", f.name); + } + } + // The new fields can be filled. + let mut doc = doc; + set_value(&mut doc, "Text1", &FieldValue::Text("hello".into())).unwrap(); + set_value(&mut doc, "Group1", &FieldValue::Radio(Some("Large".into()))).unwrap(); + assert!(add_field(&mut doc, 0, r(100.0), &NewField::CheckBox, Some("Text1")).is_err(), "names are unique"); + assert!(add_field(&mut doc, 0, [0.0, 0.0, 2.0, 2.0], &NewField::CheckBox, None).is_err(), "too small"); +} + +#[test] +fn properties_rename_and_delete() { + let mut doc = fixture(); + let renamed = set_props( + &mut doc, + "name", + &FieldProps { + name: Some("full_name".into()), + tooltip: Some("Your name".into()), + required: Some(true), + max_len: Some(Some(20)), + font_size: Some(9.0), + ..FieldProps::default() + }, + ) + .unwrap(); + assert_eq!(renamed, "full_name"); + let all = fields(&doc); + let f = field(&all, "full_name"); + assert!(f.has(flags::REQUIRED) && f.max_len == Some(20) && f.tooltip.as_deref() == Some("Your name")); + assert!(ap(&doc, &f.widgets[0]).contains("/Helv 9 Tf"), "redrawn with the new size"); + let nested = set_props(&mut doc, "address.city", &FieldProps { name: Some("town".into()), ..FieldProps::default() }).unwrap(); + assert_eq!(nested, "address.town", "only the last part changes"); + set_props(&mut doc, "country", &FieldProps { options: Some(vec!["Japan".into()]), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "country").options, [("Japan".to_string(), "Japan".to_string())]); + assert!(set_props(&mut doc, "zip", &FieldProps { name: Some("pin".into()), ..FieldProps::default() }).is_err(), "taken"); + set_props(&mut doc, "full_name", &FieldProps { rect: Some((0, [10.0, 10.0, 110.0, 30.0])), ..FieldProps::default() }).unwrap(); + let f = field(&fields(&doc), "full_name").clone(); + assert_eq!(f.widgets[0].rect, [10.0, 10.0, 110.0, 30.0]); + assert!(ap(&doc, &f.widgets[0]).contains("100"), "the appearance takes the new size"); + assert!(set_props(&mut doc, "full_name", &FieldProps { rect: Some((0, [0.0, 0.0, 2.0, 2.0])), ..FieldProps::default() }).is_err()); + let before = fields(&doc).len(); + delete_field(&mut doc, "size").unwrap(); + delete_field(&mut doc, "address.town").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(all.len(), before - 2); + assert!(!all.iter().any(|f| f.name == "size" || f.name == "address.town")); + let p = &page_refs(&doc)[0]; + let annots = doc.resolve(doc.get(*p).as_dict().unwrap().get(b"Annots").unwrap()); + assert_eq!(annots.as_array().unwrap().len(), 13 - 3, "the deleted widgets left the page"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d391e2b6..33a9ca1c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -130,6 +130,8 @@ pub struct DocView { pub comments: crate::comments::CommentView, /// Form filling state: the focused field. pub forms: crate::forms_ui::FormView, + /// Prepare a form: the selected field and the gesture in progress. + pub prepare: crate::prepare::PrepareView, /// A crop rectangle being dragged (Crop tool). pub crop_drag: crate::crop::CropDrag, /// Fill & Sign text being typed. @@ -203,6 +205,7 @@ impl DocView { pending_action: None, comments: Default::default(), forms: Default::default(), + prepare: Default::default(), crop_drag: None, fill_text: None, } @@ -714,6 +717,8 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { return; } let want_thumbs = app.right == Some(RightPanel::Pages) || app.views[index].organize; + // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. + let preparing = (app.left_open && app.left == crate::LeftPanel::Tool("form")) || matches!(app.quick_tool, QuickTool::Field(_)); let view = &mut app.views[index]; // Opened without the owner password and something is restricted. let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); @@ -779,9 +784,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let tool = app.quick_tool; // Text selection runs for the Select tool and for the markup tools (highlight…). let selects_text = match tool { - QuickTool::Select => true, QuickTool::Comment(t) => t.markup().is_some(), - QuickTool::Hand | QuickTool::Crop | QuickTool::Fill(_) => false, + QuickTool::Select => !preparing, + QuickTool::Hand | QuickTool::Crop | QuickTool::Fill(_) | QuickTool::Field(_) => false, }; let prefs = &app.comment_prefs; let allowed = doc.allows_annotation(); @@ -797,6 +802,14 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let mut clicked_link: Option = None; let mut canvas_action: Option = None; let mut open_props: Option<(usize, usize)> = None; + let mut field_props = false; + let mut field_placed = false; + let can_modify = doc.allows_modification(); + if preparing { + crate::prepare::after_refresh(view, &form); + } else { + view.prepare.selected = None; + } let out = scroll.show_viewport(ui, |ui, viewport| { let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); @@ -929,7 +942,18 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { view.current = i; open_boxes = true; } - let on_field = tool == QuickTool::Select && crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view); + let on_field = if preparing { + let field_tool = match tool { + QuickTool::Field(f) => Some(f), + _ => None, + }; + let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, can_modify, view); + field_props |= o.properties; + field_placed |= o.placed; + o.consumed || field_tool.is_some() + } else { + tool == QuickTool::Select && crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view) + }; let consumed = on_field || comments::page_input(ui, &resp, &pcx, view); // Text layer: find matches, selection, I-beam and drag-to-select. @@ -1001,7 +1025,11 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { comments::page_after_text(&resp, &pcx, view); comments::paint_page(ui, painter, &pcx, view); - crate::forms_ui::paint_page(ui, painter, &xf, i, info, &form, view); + if preparing { + crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view); + } else { + crate::forms_ui::paint_page(ui, painter, &xf, i, info, &form, view); + } // Form-field highlight (Acrobat's "Highlight existing fields"). if view.highlight_fields { @@ -1142,6 +1170,14 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, printcraft_engine::Edit::SetPageBox { .. })); let mut tool = app.quick_tool; comments::keys(ui.ctx(), view, &mut tool, allowed); + if preparing { + crate::prepare::keys(ui.ctx(), view); + } + // One field, then back to selecting (Acrobat's default without "Keep tools pinned"). + if field_placed { + tool = QuickTool::Select; + } + let field_props = field_props.then(|| view.prepare.selected.clone()).flatten(); match canvas_action { Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(*e), Some(comments::CanvasAction::OpenComments) => app.right = Some(RightPanel::Comments), @@ -1161,6 +1197,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((p, i)) = open_props { app.open_comment_props(p, i); } + if let Some((name, w)) = field_props { + app.open_field_props(&name, w); + } if open_signature { app.signature_draft.clear(); app.dialog = Some(crate::Dialog::Signature); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 923fcd2c..af9245bc 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -207,6 +207,26 @@ impl PrintCraftApp { } "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), + "form.prepare" => { + self.left = crate::LeftPanel::Tool("form"); + self.left_open = true; + self.right = Some(RightPanel::Fields); + } + "form.field.properties" => { + if let Some((name, w)) = active.and_then(|i| self.views[i].prepare.selected.clone()) { + self.open_field_props(&name, w); + } + } + field if crate::prepare::FieldTool::from_command(field).is_some() => { + let tool = crate::prepare::FieldTool::from_command(field).expect("checked"); + self.quick_tool = crate::QuickTool::Field(tool); + self.left = crate::LeftPanel::Tool("form"); + self.left_open = true; + if let Some(i) = active { + self.views[i].forms.focus = None; + } + self.notify(format!("Click on the page to add a {}, or drag to set its size", tool.label().to_lowercase())); + } fill if crate::fill_sign::FillTool::from_command(fill).is_some() => { let tool = crate::fill_sign::FillTool::from_command(fill).expect("checked"); self.quick_tool = crate::QuickTool::Fill(tool); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 99e69692..4c8b6b9f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -539,6 +539,7 @@ impl Host for crate::PrintCraftApp { crate::QuickTool::Hand => "hand".to_string(), crate::QuickTool::Crop => "crop".to_string(), crate::QuickTool::Fill(f) => format!("fill-{}", f.command().trim_start_matches("sign.fill.")), + crate::QuickTool::Field(f) => format!("field-{}", f.command().trim_start_matches("form.add.")), crate::QuickTool::Comment(t) => t.command().trim_start_matches("comment.").to_string(), }, "home": self.active.is_none(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 707a471c..6e94bf89 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -35,6 +35,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let mut marks_now = false; let mut export_now = false; let mut props_now = false; + let mut field_props_now = false; let mut replace_now = false; let t = Tokens::get(ctx); let mut close = false; @@ -276,6 +277,16 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { }); return; } + Dialog::FieldProps => { + let Some(d) = app.field_props.as_mut() else { + close = true; + return; + }; + let (apply, cancel) = crate::prepare::body(ui, d, &t); + field_props_now = apply; + close = apply || cancel; + return; + } Dialog::CommentProps => { let (apply, cancel) = crate::comment_props::body(ui, app, &t); props_now = apply; @@ -524,6 +535,16 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { src_pages: (d.src_from - 1..d.src_from - 1 + n).collect(), }); } + if field_props_now + && let Some(d) = app.field_props.take() + && let Some(props) = d.props() + && app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props }) + && let Some(i) = app.active + { + // Keep the (possibly renamed) field selected. + let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + app.views[i].prepare.selected = Some((format!("{prefix}{}", d.name.trim()), d.widget)); + } if props_now && let Some(d) = app.comment_props.take() { let mut edits = crate::comment_props::edits(&d); match edits.len() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bc56d9d9..789907f4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,6 +31,7 @@ pub mod icons; mod pageboxes; mod palette; mod panels; +pub mod prepare; mod protect; mod recovery; pub mod theme; @@ -83,6 +84,8 @@ pub enum QuickTool { Crop, /// A Fill & Sign tool. Fill(fill_sign::FillTool), + /// A Prepare a form field tool. + Field(prepare::FieldTool), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -107,6 +110,8 @@ pub enum Dialog { CommentProps, /// Replace Pages (after choosing the file). ReplacePages, + /// Prepare a form ▸ Field Properties. + FieldProps, /// Documents from a session that ended unexpectedly. Recovery, } @@ -210,6 +215,7 @@ pub struct PrintCraftApp { pub signature_draft: Vec>, /// The Comment Properties dialog's state. pub comment_props: Option, + pub field_props: Option, /// The Replace Pages dialog's state. pub replace_draft: Option, /// The last web link the app asked the system to open (tests and automation). @@ -282,6 +288,7 @@ impl PrintCraftApp { signature: None, signature_draft: Vec::new(), comment_props: None, + field_props: None, replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, } @@ -657,6 +664,9 @@ impl PrintCraftApp { "select" => QuickTool::Select, "hand" => QuickTool::Hand, "crop" => QuickTool::Crop, + field if field.starts_with("field-") => QuickTool::Field( + prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?, + ), fill if fill.starts_with("fill-") => QuickTool::Fill( fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?, ), diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs new file mode 100644 index 00000000..3f984e58 --- /dev/null +++ b/crates/ui-egui/src/prepare.rs @@ -0,0 +1,630 @@ +//! Prepare a form (Acrobat's Prepare Form, execution plan M6.6): add fields with the field +//! tools (click for the default size, or drag a rectangle), select, move and resize them, see +//! their name tags, delete them, and edit them in Field Properties. +//! +//! While the Prepare a form panel is open the page shows every field with a light-blue fill and +//! its name; clicks select fields instead of filling them in, as in Acrobat. + +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; +use printcraft_engine::{Edit, FieldProps, FormField, FormFieldKind, NewField}; +use printcraft_render::DocInfo; + +use crate::canvas::{DocView, PageXform}; +use crate::theme; + +const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +/// Acrobat's field fill in Prepare Form (light blue). +const FIELD_FILL: Color32 = Color32::from_rgba_premultiplied(0x1B, 0x34, 0x54, 0x59); + +/// The Add form components tools. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FieldTool { + Text, + CheckBox, + Radio, + Combo, + List, + Button, + Date, + Signature, +} + +pub const FIELD_TOOLS: [FieldTool; 8] = [ + FieldTool::Text, + FieldTool::CheckBox, + FieldTool::Radio, + FieldTool::Combo, + FieldTool::List, + FieldTool::Button, + FieldTool::Date, + FieldTool::Signature, +]; + +impl FieldTool { + pub fn command(self) -> &'static str { + match self { + FieldTool::Text => "form.add.text", + FieldTool::CheckBox => "form.add.checkbox", + FieldTool::Radio => "form.add.radio", + FieldTool::Combo => "form.add.combo", + FieldTool::List => "form.add.list", + FieldTool::Button => "form.add.button", + FieldTool::Date => "form.add.date", + FieldTool::Signature => "form.add.signature", + } + } + + pub fn from_command(id: &str) -> Option { + FIELD_TOOLS.into_iter().find(|t| t.command() == id) + } + + pub fn label(self) -> &'static str { + match self { + FieldTool::Text => "Text field", + FieldTool::CheckBox => "Checkbox", + FieldTool::Radio => "Radio button", + FieldTool::Combo => "Drop-down list", + FieldTool::List => "List box", + FieldTool::Button => "Button", + FieldTool::Date => "Date field", + FieldTool::Signature => "Digital signature", + } + } + + /// The field a click or drag creates. Radio buttons dropped next to a selected radio group + /// join it with the next free choice. + pub fn new_field(self, join: Option<&FormField>) -> NewField { + match self { + FieldTool::Text => NewField::Text { multiline: false }, + FieldTool::CheckBox => NewField::CheckBox, + FieldTool::Radio => match join.filter(|f| f.kind == FormFieldKind::Radio) { + Some(g) => { + let taken: Vec<&str> = g.widgets.iter().filter_map(|w| w.on_state.as_deref()).collect(); + let export = (1..).map(|i| format!("Choice{i}")).find(|c| !taken.contains(&c.as_str())).expect("unbounded"); + NewField::Radio { group: Some(g.name.clone()), export } + } + None => NewField::Radio { group: None, export: "Choice1".into() }, + }, + 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::Date => NewField::Date, + FieldTool::Signature => NewField::Signature, + } + } + + /// Width and height in points of a field placed with a single click. + pub fn default_size(self) -> (f64, f64) { + match self { + FieldTool::CheckBox | FieldTool::Radio => (14.0, 14.0), + FieldTool::List => (144.0, 54.0), + FieldTool::Button => (72.0, 22.0), + FieldTool::Signature => (180.0, 36.0), + FieldTool::Date => (108.0, 22.0), + FieldTool::Text | FieldTool::Combo => (144.0, 22.0), + } + } +} + +/// What the pointer is doing to a field. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Grab { + /// Drawing a new field from this screen point. + Draw(Pos2), + Move, + /// Resizing by a corner: (moves left edge, moves top edge) in screen terms. + Corner(bool, bool), +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PrepareView { + /// The selected field and which of its widgets. + pub selected: Option<(String, usize)>, + /// Select the field an Add field edit creates (the form's length before it). + pub select_added: Option, + grab: Option<(usize, Grab, Pos2)>, +} + +fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { + xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]) +} + +fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { + let (vx, vy) = xf.screen_to_view(p); + let u = info.pages[page].view_to_user(vx, vy); + [u[0] as f64, u[1] as f64] +} + +/// A screen rect → a normalised user-space rect. +fn user_rect(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] { + let (a, b) = (to_user(xf, info, page, r.min), to_user(xf, info, page, r.max)); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] +} + +fn handles(r: Rect) -> [(Pos2, bool, bool); 4] { + [(r.left_top(), true, true), (r.right_top(), false, true), (r.left_bottom(), true, false), (r.right_bottom(), false, false)] +} + +/// The rect while a move or resize is in progress. +fn dragged(r: Rect, grab: Grab, delta: egui::Vec2) -> Rect { + match grab { + Grab::Move => r.translate(delta), + Grab::Corner(left, top) => { + let mut r = r; + if left { + r.min.x += delta.x; + } else { + r.max.x += delta.x; + } + if top { + r.min.y += delta.y; + } else { + r.max.y += delta.y; + } + Rect::from_two_pos(r.min, r.max) + } + Grab::Draw(_) => r, + } +} + +pub(crate) struct Outcome { + /// The click or drag belonged to Prepare a form. + pub consumed: bool, + /// Open Field Properties for the selected field. + pub properties: bool, + /// A field was placed: go back to the Select tool. + pub placed: bool, +} + +/// Pointer input on one page while preparing a form. +#[allow(clippy::too_many_arguments)] +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + page: usize, + info: &DocInfo, + form: &[FormField], + tool: Option, + allowed: bool, + view: &mut DocView, +) -> Outcome { + let mut out = Outcome { consumed: false, properties: false, placed: false }; + let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let prep = &mut view.prepare; + + // A grab in progress on this page. + if let Some((gp, grab, start)) = prep.grab + && gp == page + { + out.consumed = true; + if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + prep.grab = None; + let end = pointer.unwrap_or(start); + match grab { + Grab::Draw(from) => { + let Some(tool) = tool else { return out }; + let r = Rect::from_two_pos(from, end).intersect(xf.rect); + let rect = user_rect(xf, info, page, r); + let rect = if r.width() < 4.0 || r.height() < 4.0 { + // A click: the default size, top-left at the pointer. + let at = to_user(xf, info, page, from); + let (w, h) = tool.default_size(); + [at[0], at[1] - h, at[0] + w, at[1]] + } else { + rect + }; + let join = prep.selected.as_ref().and_then(|(n, _)| form.iter().find(|f| &f.name == n)); + prep.select_added = Some(form.len()); + view.pending_edit = Some(Edit::AddField { page, rect, kind: tool.new_field(join), name: None }); + out.placed = true; + } + Grab::Move | Grab::Corner(..) => { + let Some((name, wi)) = prep.selected.clone() else { return out }; + let Some(w) = form.iter().find(|f| f.name == name).and_then(|f| f.widgets.get(wi)) else { return out }; + let delta = end - start; + if delta.length() >= 1.0 { + let r = dragged(screen_rect(xf, info, page, w.rect), grab, delta); + let rect = user_rect(xf, info, page, r); + view.pending_edit = Some(Edit::SetFieldProps { name, props: FieldProps { rect: Some((wi, rect)), ..Default::default() } }); + } + } + } + } + return out; + } + + let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return out }; + if !allowed { + return out; + } + if let Some(tool) = tool { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.drag_started() || resp.clicked() { + let origin = ui.input(|i| i.pointer.press_origin()).filter(|o| xf.rect.contains(*o)).unwrap_or(p); + prep.grab = Some((page, Grab::Draw(origin), origin)); + out.consumed = true; + if resp.clicked() { + // A click without a drag: finish at once. + prep.grab = None; + let at = to_user(xf, info, page, p); + let (w, h) = tool.default_size(); + let join = prep.selected.as_ref().and_then(|(n, _)| form.iter().find(|f| &f.name == n)); + prep.select_added = Some(form.len()); + view.pending_edit = Some(Edit::AddField { page, rect: [at[0], at[1] - h, at[0] + w, at[1]], kind: tool.new_field(join), name: None }); + out.placed = true; + } + } + return out; + } + + // The Select tool: corner handles of the selection, then fields. + let selected_rect = prep + .selected + .as_ref() + .and_then(|(n, wi)| form.iter().find(|f| &f.name == n)?.widgets.get(*wi).filter(|w| w.page == Some(page))) + .map(|w| screen_rect(xf, info, page, w.rect)); + let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)); + let hit = form.iter().find_map(|f| { + f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i)) + }); + if let Some((_, left, top)) = corner { + ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); + } else if hit.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + } + if resp.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) { + prep.grab = Some((page, Grab::Corner(left, top), origin)); + out.consumed = true; + } else if let Some((f, wi)) = form.iter().find_map(|f| { + f.widgets + .iter() + .enumerate() + .find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(origin)) + .map(|(i, _)| (f, i)) + }) { + prep.selected = Some((f.name.clone(), wi)); + prep.grab = Some((page, Grab::Move, origin)); + out.consumed = true; + } + return out; + } + if resp.double_clicked() && hit.is_some() { + out.consumed = true; + out.properties = true; + return out; + } + if resp.clicked() { + match hit { + Some((f, wi)) => { + prep.selected = Some((f.name.clone(), wi)); + out.consumed = true; + } + None if corner.is_none() => prep.selected = None, + None => out.consumed = true, + } + } + out.consumed |= hit.is_some() && resp.is_pointer_button_down_on(); + out +} + +/// Fields with their light-blue fill and name tags, the selection with its handles, and the +/// rectangle being drawn or dragged. +pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) { + let prep = &view.prepare; + let pointer = ui.input(|i| i.pointer.hover_pos()); + let grab = prep.grab.filter(|(gp, ..)| *gp == page); + let zoom = (xf.rect.width() / xf.pw.max(1.0)).clamp(0.5, 3.0); + for f in form { + for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page)) { + let mut r = screen_rect(xf, info, page, w.rect); + let selected = prep.selected.as_ref().is_some_and(|(n, i)| n == &f.name && *i == wi); + if selected && let (Some((_, g, start)), Some(p)) = (grab, pointer) { + r = dragged(r, g, p - start); + } + painter.rect_filled(r, CornerRadius::ZERO, FIELD_FILL); + painter.rect_stroke( + r, + CornerRadius::ZERO, + Stroke::new(1.0, if selected { SELECT_BLUE } else { Color32::from_gray(90) }), + egui::StrokeKind::Inside, + ); + // The name tag: white text on black, clipped to the field. + let label = f.name.rsplit('.').next().unwrap_or(&f.name); + let font = theme::regular((9.0 * zoom).clamp(7.0, 13.0)); + let galley = painter.layout_no_wrap(label.to_string(), font, Color32::WHITE); + let size = galley.size() + vec2(6.0, 2.0); + let tag = Rect::from_center_size(r.center(), size); + let clip = painter.clip_rect().intersect(r); + let p2 = painter.with_clip_rect(clip); + p2.rect_filled(tag, CornerRadius::same(1), if selected { SELECT_BLUE } else { Color32::BLACK }); + p2.galley(tag.min + vec2(3.0, 1.0), galley, Color32::WHITE); + if selected { + painter.rect_stroke(r.expand(1.0), CornerRadius::ZERO, Stroke::new(2.0, SELECT_BLUE), egui::StrokeKind::Outside); + for (c, ..) in handles(r) { + painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE)); + } + } + } + } + if let (Some((_, Grab::Draw(from), _)), Some(p)) = (grab, pointer) { + let r = Rect::from_two_pos(from, p).intersect(xf.rect); + painter.rect_filled(r, CornerRadius::ZERO, FIELD_FILL); + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Inside); + } +} + +/// Delete removes the selected field; Escape clears the selection. +pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) { + if view.prepare.selected.is_none() || ctx.egui_wants_keyboard_input() { + return; + } + let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape))); + if del && let Some((name, _)) = view.prepare.selected.take() { + view.pending_edit = Some(Edit::DeleteField { name }); + } else if esc { + view.prepare.selected = None; + } +} + +/// After an Add field edit: select the new field (the form's last). +pub(crate) fn after_refresh(view: &mut DocView, form: &[FormField]) { + if let Some(n) = view.prepare.select_added.take() { + if form.len() > n + && let Some(f) = form.last() + { + view.prepare.selected = Some((f.name.clone(), 0)); + } else if let Some((name, _)) = &view.prepare.selected + && let Some(f) = form.iter().find(|f| &f.name == name) + { + // A radio button joined the selected group: select the new button. + view.prepare.selected = Some((f.name.clone(), f.widgets.len().saturating_sub(1))); + } + } + if let Some((name, wi)) = &view.prepare.selected + && !form.iter().any(|f| &f.name == name && *wi < f.widgets.len()) + { + view.prepare.selected = None; + } +} + +// ───────────────────────────────────────────────────────────────────────── Field Properties + +impl crate::PrintCraftApp { + /// Open Field Properties for a field of the active document. + pub fn open_field_props(&mut self, name: &str, widget: usize) { + let Some((_, id)) = self.active_ids() else { return }; + let Some(f) = self.session.get(id).and_then(|d| d.form.iter().find(|f| f.name == name).cloned()) else { return }; + self.field_props = Some(FieldDraft::new(&f, widget)); + self.dialog = Some(crate::Dialog::FieldProps); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldTab { + General, + Appearance, + Position, + Options, +} + +/// The Field Properties dialog's working copy. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldDraft { + pub field: String, + pub kind: FormFieldKind, + pub widget: usize, + pub tab: FieldTab, + pub name: String, + pub tooltip: String, + pub read_only: bool, + pub required: bool, + pub multiline: bool, + pub limit: bool, + pub max_len: usize, + pub options: Vec, + pub new_option: String, + /// 0 = auto. + pub font_size: f64, + /// Left, bottom, width, height in points. + pub position: [f64; 4], + original: Box>, +} + +fn da_size(da: &str) -> f64 { + let toks: Vec<&str> = da.split_whitespace().collect(); + toks.windows(2).rev().find(|w| w[1] == "Tf").and_then(|w| w[0].parse().ok()).unwrap_or(0.0) +} + +impl FieldDraft { + pub fn new(f: &FormField, widget: usize) -> Self { + let r = f.widgets.get(widget).map(|w| w.rect).unwrap_or_default(); + let mut d = FieldDraft { + field: f.name.clone(), + kind: f.kind, + widget, + tab: FieldTab::General, + name: f.name.rsplit('.').next().unwrap_or(&f.name).to_string(), + tooltip: f.tooltip.clone().unwrap_or_default(), + read_only: f.read_only(), + required: f.has(printcraft_engine::field_flags::REQUIRED), + multiline: f.has(printcraft_engine::field_flags::MULTILINE), + limit: f.max_len.is_some(), + max_len: f.max_len.unwrap_or(0), + options: f.options.iter().map(|(_, d)| d.clone()).collect(), + new_option: String::new(), + font_size: da_size(&f.da), + position: [r[0], r[1], r[2] - r[0], r[3] - r[1]], + original: Box::new(None), + }; + d.original = Box::new(Some(d.clone())); + d + } + + pub fn title(&self) -> &'static str { + match self.kind { + FormFieldKind::Text => "Text Field Properties", + FormFieldKind::CheckBox => "Check Box Properties", + FormFieldKind::Radio => "Radio Button Properties", + FormFieldKind::Combo => "Dropdown Properties", + FormFieldKind::List => "List Box Properties", + FormFieldKind::PushButton => "Button Properties", + FormFieldKind::Signature => "Digital Signature Properties", + } + } + + fn tabs(&self) -> Vec<(FieldTab, &'static str)> { + let mut t = vec![(FieldTab::General, "General"), (FieldTab::Appearance, "Appearance"), (FieldTab::Position, "Position")]; + if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List) { + t.push((FieldTab::Options, "Options")); + } + t + } + + /// The properties that changed (`None` when nothing did). + pub fn props(&self) -> Option { + let o = self.original.as_ref().as_ref()?; + let ch = |a: bool, b: bool| (a != b).then_some(a); + let p = FieldProps { + name: (self.name.trim() != o.name).then(|| self.name.trim().to_string()), + tooltip: (self.tooltip != o.tooltip).then(|| self.tooltip.clone()), + read_only: ch(self.read_only, o.read_only), + required: ch(self.required, o.required), + multiline: ch(self.multiline, o.multiline), + max_len: ((self.limit, self.max_len) != (o.limit, o.max_len)).then_some((self.limit && self.max_len > 0).then_some(self.max_len)), + options: (self.options != o.options).then(|| self.options.clone()), + font_size: ((self.font_size - o.font_size).abs() > 1e-6).then_some(self.font_size), + rect: (self.position != o.position).then(|| { + let [x, y, w, h] = self.position; + (self.widget, [x, y, x + w.max(4.0), y + h.max(4.0)]) + }), + }; + (p != FieldProps::default()).then_some(p) + } +} + +/// Draw Field Properties; returns (apply, cancel). +pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) -> (bool, bool) { + use crate::widgets; + ui.set_width(520.0); + ui.label(egui::RichText::new(d.title()).font(theme::semibold(18.0))); + ui.add_space(6.0); + ui.horizontal(|ui| { + for (tab, label) in d.tabs() { + if widgets::mode_tab(ui, label, d.tab == tab).clicked() { + d.tab = tab; + } + } + }); + ui.separator(); + ui.add_space(6.0); + let mut enter = false; + match d.tab { + FieldTab::General => { + egui::Grid::new("field-general").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + let l = ui.label("Name:"); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(340.0)).labelled_by(l.id); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + let l = ui.label("Tooltip:"); + ui.add(egui::TextEdit::singleline(&mut d.tooltip).desired_width(340.0)).labelled_by(l.id); + ui.end_row(); + }); + ui.add_space(12.0); + widgets::section_title(ui, "Common Properties"); + ui.checkbox(&mut d.read_only, "Read Only"); + ui.checkbox(&mut d.required, "Required"); + } + FieldTab::Appearance => { + egui::Grid::new("field-appearance").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + ui.label("Font Size:"); + let mut auto = d.font_size == 0.0; + ui.horizontal(|ui| { + if ui.checkbox(&mut auto, "Auto").changed() { + d.font_size = if auto { 0.0 } else { 12.0 }; + } + ui.add_enabled(!auto, egui::DragValue::new(&mut d.font_size).range(2.0..=100.0).speed(0.25).suffix(" pt")); + }); + ui.end_row(); + ui.label("Font:"); + ui.label("Helvetica"); + ui.end_row(); + }); + } + FieldTab::Position => { + egui::Grid::new("field-position").num_columns(4).spacing([12.0, 10.0]).show(ui, |ui| { + for (i, label) in ["Left:", "Bottom:", "Width:", "Height:"].into_iter().enumerate() { + ui.label(label); + let range = if i < 2 { -14_400.0..=14_400.0 } else { 4.0..=14_400.0 }; + ui.add(egui::DragValue::new(&mut d.position[i]).range(range).speed(0.5).suffix(" pt")); + if i % 2 == 1 { + ui.end_row(); + } + } + }); + ui.label(egui::RichText::new("Points from the page's bottom-left corner.").small().color(t.text_faint)); + } + FieldTab::Options => match d.kind { + FormFieldKind::Text => { + ui.checkbox(&mut d.multiline, "Multi-line"); + ui.horizontal(|ui| { + ui.checkbox(&mut d.limit, "Limit of"); + ui.add_enabled(d.limit, egui::DragValue::new(&mut d.max_len).range(0..=10_000)); + ui.label("characters"); + }); + } + _ => { + ui.horizontal(|ui| { + let l = ui.label("Item:"); + let r = ui.add(egui::TextEdit::singleline(&mut d.new_option).desired_width(240.0)).labelled_by(l.id); + let typed = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if (widgets::pill_button(ui, "Add", false).clicked() || typed) && !d.new_option.trim().is_empty() { + d.options.push(d.new_option.trim().to_string()); + d.new_option.clear(); + } + }); + ui.add_space(6.0); + widgets::section_title(ui, "Item List"); + let mut remove = None; + let mut up = None; + egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { + for (i, o) in d.options.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(o); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Delete").on_hover_text(format!("Delete {o}")).clicked() { + remove = Some(i); + } + if i > 0 && ui.small_button("Up").clicked() { + up = Some(i); + } + }); + }); + } + }); + if let Some(i) = remove { + d.options.remove(i); + } + if let Some(i) = up { + d.options.swap(i - 1, i); + } + if d.options.is_empty() { + ui.label(egui::RichText::new("Add the choices people pick from.").color(t.text_muted)); + } + } + }, + } + ui.add_space(14.0); + let (mut apply, mut cancel) = (enter, false); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::pill_button(ui, "OK", true).clicked() { + apply = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + cancel = true; + } + }); + (apply, cancel) +} diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs new file mode 100644 index 00000000..7b773615 --- /dev/null +++ b/crates/ui-egui/tests/prepare.rs @@ -0,0 +1,115 @@ +//! Prepare a form in the real shell (egui_kittest): field tools, placing, selecting, moving, +//! Field Properties and deleting. + +use egui::Pos2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_ui_egui::{PrintCraftApp, QuickTool}; + +fn harness() -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + for _ in 0..60 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h +} + +/// A point on page 1 at (x, y) points from its top-left. +fn at(h: &Harness<'static, PrintCraftApp>, x: f32, y: f32) -> Pos2 { + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 300.0; + r.min + egui::vec2(x * k, y * k) +} + +fn drag(h: &mut Harness<'static, PrintCraftApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + h.run_steps(4); +} + +fn names(h: &Harness<'static, PrintCraftApp>) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().map(|f| f.name.clone()).collect() +} + +fn rect_of(h: &Harness<'static, PrintCraftApp>, name: &str) -> [f64; 4] { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().widgets[0].rect +} + +#[test] +fn placing_moving_editing_and_deleting_a_field() { + let mut h = harness(); + let before = names(&h).len(); + assert!(h.state_mut().execute("form.add.text")); + h.run_steps(2); + h.get_by_label("Prepare a form"); + // Drag out a text field in the empty lower part of the page. + let (a, b) = (at(&h, 40.0, 300.0), at(&h, 200.0, 322.0)); + drag(&mut h, a, b); + let all = names(&h); + assert_eq!(all.len(), before + 1, "{all:?}"); + assert_eq!(all.last().map(String::as_str), Some("Text1")); + assert_eq!(h.state().quick_tool, QuickTool::Select, "back to Select after one field"); + h.run_steps(2); + assert_eq!(h.state().views[0].prepare.selected.as_ref().map(|s| s.0.as_str()), Some("Text1"), "the new field is selected"); + let r = rect_of(&h, "Text1"); + assert!((r[2] - r[0] - 160.0).abs() < 2.0 && (r[3] - r[1] - 22.0).abs() < 2.0, "{r:?}"); + // Move it 20 pt right. + let c = at(&h, 120.0, 311.0); + let d = c + egui::vec2(at(&h, 20.0, 0.0).x - at(&h, 0.0, 0.0).x, 0.0); + drag(&mut h, c, d); + let m = rect_of(&h, "Text1"); + assert!((m[0] - r[0] - 20.0).abs() < 1.5, "moved: {r:?} → {m:?}"); + // Field Properties: rename and make it required (one undo step). + h.state_mut().execute("form.field.properties"); + h.run_steps(2); + h.get_by_label("Text Field Properties"); + { + let d = h.state_mut().field_props.as_mut().expect("open"); + d.name = "email".into(); + d.required = true; + } + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(names(&h).contains(&"email".to_string())); + { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.can_undo(), Some("Change field properties")); + assert!(doc.form.iter().find(|f| f.name == "email").unwrap().has(printcraft_engine::field_flags::REQUIRED)); + assert_eq!(s.views[0].prepare.selected.as_ref().map(|s| s.0.as_str()), Some("email")); + } + // Delete removes the selected field. + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert_eq!(names(&h).len(), before); + // A click with the check box tool places a default-size one. + h.state_mut().execute("form.add.checkbox"); + h.run_steps(1); + let p = at(&h, 40.0, 250.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(4); + let r = rect_of(&h, "Check Box1"); + assert_eq!((r[2] - r[0], r[3] - r[1]), (14.0, 14.0)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 227bf72d..78093b1f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4623,11 +4623,11 @@ title = "Prepare a form mode" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.prepare"] +tools = ["form_add_field", "form_set_props", "form_delete_field"] +evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields"] +notes = "fields shown with blue fill and name tags; select, move, resize by corners, Delete; field tools return to Select after one field" [[feature]] id = "form.add-text-field" @@ -4635,11 +4635,11 @@ title = "Add text fields" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.add.text"] +tools = ["form_add_field"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +notes = "click for the default size or drag; named Text1, Text2…" [[feature]] id = "form.add-checkbox" @@ -4647,11 +4647,11 @@ title = "Add checkboxes" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.add.checkbox"] +tools = ["form_add_field"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field"] +notes = "Check Box1…, Yes/Off appearance states" [[feature]] id = "form.add-radio" @@ -4659,11 +4659,11 @@ title = "Add radio buttons" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.add.radio"] +tools = ["form_add_field"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +notes = "new group Group1… or join the selected group with the next ChoiceN" [[feature]] id = "form.add-dropdown" @@ -4671,11 +4671,11 @@ title = "Add drop-down lists" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.add.combo"] +tools = ["form_add_field"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +notes = "Dropdown1…; items in Field Properties ▸ Options" [[feature]] id = "form.add-listbox" @@ -4683,11 +4683,11 @@ title = "Add list boxes" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.add.list"] +tools = ["form_add_field"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"] +notes = "List Box1…" [[feature]] id = "form.add-button" @@ -4695,11 +4695,11 @@ title = "Add push buttons" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.add.button"] +tools = ["form_add_field"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"] +notes = "Button1…; actions arrive with the JavaScript engine" [[feature]] id = "form.add-image-field" @@ -4719,11 +4719,11 @@ title = "Add date fields" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.add.date"] +tools = ["form_add_field"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"] +notes = "text field with AFDate_FormatEx(\"mm/dd/yyyy\") format/keystroke actions" [[feature]] id = "form.add-signature-field" @@ -4731,11 +4731,11 @@ title = "Add digital signature fields" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.add.signature"] +tools = ["form_add_field"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn"] +notes = "unsigned /Sig field; signing is M9" [[feature]] id = "form.add-barcode-field" @@ -4755,11 +4755,11 @@ title = "Field properties: general (name, tooltip, visibility, read-only, requir area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props"] +evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +notes = "name, tooltip, read only, required (visibility/orientation pending)" [[feature]] id = "form.props-appearance" @@ -4767,11 +4767,11 @@ title = "Field properties: appearance (border, fill, font)" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["form.field.properties"] +tools = ["form_set_props"] +evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete"] +notes = "font size (incl. Auto); border/fill colours and fonts pending" [[feature]] id = "form.props-position" @@ -4779,11 +4779,11 @@ title = "Field properties: position and size" area = "F" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props"] +evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field"] +notes = "left/bottom/width/height in points; drag to move, corners to resize" [[feature]] id = "form.props-options" @@ -4791,11 +4791,11 @@ title = "Field properties: options per field type" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["form.field.properties"] +tools = ["form_set_props"] +evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +notes = "text: multi-line, character limit; choices: item list add/delete/reorder" [[feature]] id = "form.props-actions"