diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 02616822b..6ed1a3285 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -201,6 +201,9 @@ impl Automation { if let Some(t) = &f.tooltip { o.insert("tooltip".into(), json!(t)); } + if let Some(s) = doc.field_check_style(&f.name) { + o.insert("check_style".into(), json!(s.label().to_lowercase())); + } if f.actions.format != Format::None { o.insert("format".into(), format_json(&f.actions.format)); o.insert("display".into(), json!(f.value.first().map(|v| printcraft_engine::form_scripts::format_value(&f.actions.format, v)))); @@ -391,6 +394,15 @@ impl Automation { } }, actions: None, + check_style: match a.opt_str("check_style")? { + None => None, + Some(s) => Some( + printcraft_engine::CheckStyle::ALL + .into_iter() + .find(|c| c.label().eq_ignore_ascii_case(s)) + .ok_or_else(|| bad(format!("unknown check style {s:?} (check, circle, cross, diamond, square, star)")))?, + ), + }, }; if props == FieldProps::default() { return Err(ToolError::InvalidArgs("nothing to change".into())); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index a465b8f26..f27e993fd 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -274,7 +274,7 @@ pub fn tools() -> Vec { 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), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Options tab: align (left, center, right), default (the value Reset form restores; for check boxes and radio buttons their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }. Only the given ones change. Undoable.", + "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), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Options tab: align (left, center, right), default (the value Reset form restores; for check boxes and radio buttons their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes and radio buttons: check, circle, cross, diamond, square, star). Only the given ones change. Undoable.", ) .with(schema( json!({ @@ -290,6 +290,7 @@ pub fn tools() -> Vec { "align": { "type": "string", "enum": ["left", "center", "right"] }, "locked": { "type": "boolean", "description": "Lock the field's properties (only unlocking is accepted while locked)." }, "default": { "type": "string" }, + "check_style": { "type": "string", "enum": ["check", "circle", "cross", "diamond", "square", "star"], "description": "Check boxes and radio buttons: the mark shown when on." }, "flags": { "type": "object", "additionalProperties": { "type": "boolean" } }, "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." }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 97c6821d4..578a1b235 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -823,6 +823,12 @@ fn preparing_a_form_through_tools() { 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"])); + // #94: the mark a check box or radio button shows. + assert_eq!(f[1]["check_style"], "circle", "radio buttons default to a circle"); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "size", "check_style": "star" })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][1]["check_style"], "star"); + assert!(matches!(a.call("form_set_props", &json!({ "doc": doc, "field": "size", "check_style": "heart" })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("form_set_props", &json!({ "doc": doc, "field": "full name", "check_style": "star" })), Err(ToolError::Failed(_)))); ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "full name": "Ada", "size": "L" } })); assert!(page_text(&mut a, doc)[0].contains("Ada")); // Options tab: alignment, default, flags (comb needs a limit). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 363056e89..5767330b6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -32,8 +32,8 @@ pub use printcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use printcraft_forms::{ - BorderStyle, Field as FormField, FieldAction, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, NewField, - TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, flags as field_flags, + BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, + NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, flags as field_flags, }; pub use printcraft_a11y as a11y; @@ -386,6 +386,13 @@ impl Document { Some(printcraft_forms::look(&e.cos, f)) } + /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. + pub fn field_check_style(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| printcraft_forms::check_style(&e.cos, f)) + } + /// Remove Hidden Information: what each category would remove. pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { self.editor.as_ref().map(|e| printcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 7adca4166..97b895d88 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -320,6 +320,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name 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 style = crate::author::CheckStyle::of_widget(doc, &wd, kind); let mut form = |content: String| -> Object { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); @@ -328,51 +329,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name 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); - let mark = if kind == FieldKind::Radio { - let r = s * 0.25; - let k = 0.5523 * r; - format!( - "0 g\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", - n(cx + r), - n(cy), - n(cx + r), - n(cy + k), - n(cx + k), - n(cy + r), - n(cx), - n(cy + r), - n(cx - k), - n(cy + r), - n(cx - r), - n(cy + k), - n(cx - r), - n(cy), - n(cx - r), - n(cy - k), - n(cx - k), - n(cy - r), - n(cx), - n(cy - r), - n(cx + k), - n(cy - r), - n(cx + r), - n(cy - k), - n(cx + r), - n(cy) - ) - } else { - format!( - "0 G\n{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", - n((s * 0.1).max(1.0)), - n(cx - s * 0.28), - n(cy), - n(cx - s * 0.08), - n(cy - s * 0.22), - n(cx + s * 0.3), - n(cy + s * 0.25) - ) - }; + let mark = check_mark(style, width / 2.0, height / 2.0, s); let mut nd = Dict::new(); nd.set(on_name.as_bytes().to_vec(), form(format!("{frame_c}{mark}"))); nd.set(b"Off".to_vec(), form(frame_c)); @@ -381,6 +338,100 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name ap } +/// The on-state mark for `style` (#94), centred on (`cx`, `cy`) in a box whose smaller side is +/// `s`, drawn in black. +fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> String { + use crate::author::CheckStyle; + // A filled polygon through `pts`. + let polygon = |pts: &[(f64, f64)]| { + let mut c = String::from("0 g\n"); + for (i, (x, y)) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {} ", n(*x), n(*y), if i == 0 { "m" } else { "l" })); + } + c.push_str("h f\n"); + c + }; + let line = n((s * 0.1).max(1.0)); + match style { + CheckStyle::Check => format!( + "0 G\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + n(cx - s * 0.28), + n(cy), + n(cx - s * 0.08), + n(cy - s * 0.22), + n(cx + s * 0.3), + n(cy + s * 0.25) + ), + CheckStyle::Cross => { + let d = s * 0.25; + format!( + "0 G\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n", + n(cx - d), + n(cy - d), + n(cx + d), + n(cy + d), + n(cx - d), + n(cy + d), + n(cx + d), + n(cy - d) + ) + } + CheckStyle::Square => { + let d = s * 0.22; + format!("0 g\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d)) + } + CheckStyle::Diamond => { + let d = s * 0.3; + polygon(&[(cx, cy + d), (cx + d, cy), (cx, cy - d), (cx - d, cy)]) + } + CheckStyle::Star => { + // Five points: outer and inner radii alternate, the first point straight up. + let (outer, inner) = (s * 0.32, s * 0.32 * 0.382); + let pts: Vec<(f64, f64)> = (0..10) + .map(|i| { + let a = std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::PI / 5.0; + let r = if i % 2 == 0 { outer } else { inner }; + (cx + r * a.cos(), cy + r * a.sin()) + }) + .collect(); + polygon(&pts) + } + CheckStyle::Circle => { + let r = s * 0.25; + let k = 0.5523 * r; + format!( + "0 g\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", + n(cx + r), + n(cy), + n(cx + r), + n(cy + k), + n(cx + k), + n(cy + r), + n(cx), + n(cy + r), + n(cx - k), + n(cy + r), + n(cx - r), + n(cy + k), + n(cx - r), + n(cy), + n(cx - r), + n(cy - k), + n(cx - k), + n(cy - r), + n(cx), + n(cy - r), + n(cx + k), + n(cy - r), + n(cx + r), + n(cy - k), + n(cx + r), + n(cy) + ) + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 18c9b4fde..7fd7ab7e2 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -89,6 +89,8 @@ pub struct FieldProps { pub locked: Option, /// Actions tab: every trigger's action (replacing the field's). pub actions: Option>, + /// Options tab: the mark of a check box or radio button (every widget). + pub check_style: Option, } fn invalid(m: impl Into) -> Result { @@ -215,6 +217,63 @@ impl FieldFont { } } +/// The mark a check box or radio button shows when on (Field Properties ▸ Options ▸ style). +/// Stored as `/MK /CA`, the ZapfDingbats character Acrobat uses for it; PrintCraft draws the +/// mark as paths, so no symbol font is needed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CheckStyle { + Check, + Circle, + Cross, + Diamond, + Square, + Star, +} + +impl CheckStyle { + pub const ALL: [CheckStyle; 6] = + [CheckStyle::Check, CheckStyle::Circle, CheckStyle::Cross, CheckStyle::Diamond, CheckStyle::Square, CheckStyle::Star]; + + fn code(self) -> &'static str { + match self { + CheckStyle::Check => "4", + CheckStyle::Circle => "l", + CheckStyle::Cross => "8", + CheckStyle::Diamond => "u", + CheckStyle::Square => "n", + CheckStyle::Star => "H", + } + } + + pub fn label(self) -> &'static str { + match self { + CheckStyle::Check => "Check", + CheckStyle::Circle => "Circle", + CheckStyle::Cross => "Cross", + CheckStyle::Diamond => "Diamond", + CheckStyle::Square => "Square", + CheckStyle::Star => "Star", + } + } + + /// A widget's style: its `/MK /CA`, else a check for check boxes and a circle for radios. + pub(crate) fn of_widget(doc: &Document, wd: &Dict, kind: FieldKind) -> Self { + let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); + let ca = mk.get(b"CA").and_then(|c| doc.resolve(c).as_string().map(|s| s.to_text())); + CheckStyle::ALL.into_iter().find(|s| ca.as_deref() == Some(s.code())).unwrap_or(if kind == FieldKind::Radio { + CheckStyle::Circle + } else { + CheckStyle::Check + }) + } +} + +/// A check box's or radio button's style (from its first widget). +pub fn check_style(doc: &Document, f: &Field) -> CheckStyle { + let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); + CheckStyle::of_widget(doc, &wd, f.kind) +} + fn rgb_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 3]> { let v: Vec = doc.resolve(o?).as_array()?.iter().filter_map(Object::as_f64).collect(); match v.len() { @@ -702,6 +761,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result = self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default(); @@ -665,6 +667,8 @@ pub struct FieldDraft { /// Left, bottom, width, height in points. pub position: [f64; 4], pub look: Option, + /// Check boxes and radio buttons: the mark when on (Options tab). + pub check_style: Option, pub format: Format, pub validate: Validate, pub calculate: Calculate, @@ -706,6 +710,7 @@ impl FieldDraft { font_size: da_size(&f.da), position: [r[0], r[1], r[2] - r[0], r[3] - r[1]], look: None, + check_style: None, format: f.actions.format.clone(), validate: f.actions.validate.clone(), calculate: f.actions.calculate.clone(), @@ -760,6 +765,7 @@ impl FieldDraft { (self.widget, [x, y, x + w.max(4.0), y + h.max(4.0)]) }), look: (self.look != o.look).then_some(self.look).flatten(), + check_style: (self.check_style != o.check_style).then_some(self.check_style).flatten(), format: (self.format != o.format).then(|| self.format.clone()), validate: (self.validate != o.validate).then(|| self.validate.clone()), calculate: (self.calculate != o.calculate).then(|| self.calculate.clone()), @@ -981,6 +987,20 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke if ui.checkbox(&mut checked, label).changed() { d.default = if checked { on } else { String::new() }; } + if let Some(style) = d.check_style.as_mut() { + ui.horizontal(|ui| { + let l = ui.label(if d.kind == FormFieldKind::CheckBox { "Check Box Style:" } else { "Button Style:" }); + egui::ComboBox::from_id_salt("check-style") + .selected_text(style.label()) + .show_ui(ui, |ui| { + for s in printcraft_engine::CheckStyle::ALL { + ui.selectable_value(style, s, s.label()); + } + }) + .response + .labelled_by(l.id); + }); + } if d.kind == FormFieldKind::Radio { flag_box(ui, &mut d.flags, ff::RADIOS_IN_UNISON, false, "Buttons with the same name and value are selected in unison"); } diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index ca0b25213..a15e5af46 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -170,6 +170,30 @@ fn appearance_tab_restyles_the_field() { assert_eq!((l.style, l.font, l.fill), (BorderStyle::Dashed, FieldFont::Courier, Some([1.0, 1.0, 0.8]))); } +#[test] +fn options_tab_sets_the_check_box_style() { + // #94: Check Box Style on the Options tab. + use printcraft_engine::CheckStyle; + let mut h = harness(); + h.state_mut().apply_edit(printcraft_engine::Edit::AddField { + page: 0, + rect: [40.0, 300.0, 56.0, 316.0], + kind: printcraft_engine::NewField::CheckBox, + name: Some("agree".into()), + }); + h.state_mut().open_field_props("agree", 0); + h.run_steps(2); + h.get_by_label("Options").click(); + h.run_steps(2); + h.get_by_label("Check Box Style:"); + assert_eq!(h.state().field_props.as_ref().unwrap().check_style, Some(CheckStyle::Check)); + h.state_mut().field_props.as_mut().unwrap().check_style = Some(CheckStyle::Cross); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().field_check_style("agree"), Some(CheckStyle::Cross)); +} + #[test] fn options_tab_sets_flags_alignment_and_defaults() { use printcraft_engine::field_flags as ff; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 80ee7b1f3..2e00cfad7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4689,6 +4689,18 @@ 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.check-box-style" +title = "Check box and radio button styles" +area = "F" +tier = "P1" +milestone = "M6" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fields"] +evidence = ["crates/forms/src/tests.rs::check_box_styles_change_the_mark", "crates/ui-egui/tests/prepare.rs::options_tab_sets_the_check_box_style", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +notes = "Field Properties ▸ Options: check, circle, cross, diamond, square, star (/MK /CA, Acrobat's codes), drawn as paths; defaults check (check boxes) and circle (radio buttons)" + [[feature]] id = "form.add-dropdown" title = "Add drop-down lists"