From 24f5768be441f515e275344e0631dcb7909e82db Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 01:07:37 -0400 Subject: [PATCH] M6.3/M6.6: Form formats, validation and calculation without a JavaScript engine: Acrobat's AF calls (AFNumber/AFPercent/AFDate/AFTime/AFSpecial format and keystroke, AFRange_Validate, AFSimple_Calculate, simplified field notation) recognised and run natively in Acrobat's event order (keystroke on commit, validate with Acrobat's messages, calculate in /CO order, format for display); Field Properties Format/Validate/Calculate tabs write the same scripts; form_set_props format/validate/calculate, form_fields reports them Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/forms.rs | 110 +++ crates/automation/src/tools.rs | 5 +- crates/automation/tests/automation.rs | 32 + crates/engine/src/lib.rs | 2 +- crates/forms/src/af.rs | 983 ++++++++++++++++++++++++++ crates/forms/src/appearance.rs | 9 + crates/forms/src/author.rs | 74 ++ crates/forms/src/lib.rs | 109 ++- crates/forms/src/tests.rs | 100 +++ crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/prepare.rs | 296 +++++++- crates/ui-egui/tests/prepare.rs | 31 + parity/acrobat-features.toml | 120 ++-- 13 files changed, 1807 insertions(+), 65 deletions(-) create mode 100644 crates/forms/src/af.rs diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 53731390b..92d7261a2 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -1,11 +1,99 @@ //! 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::form_scripts::{CalcOp, Calculate, Format, Validate}; use printcraft_engine::{Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; use serde_json::{Value, json}; use crate::{Args, Automation, Result, ToolError, failed}; +fn bad(m: impl Into) -> ToolError { + ToolError::InvalidArgs(m.into()) +} + +/// `{"type": "number", "decimals": 2, "currency": "$"}`, `{"type": "date", "pattern": "mm/dd/yyyy"}`, +/// `{"type": "phone"}`, `{"type": "mask", "mask": "AA-9999"}`, `"none"`… +fn format_arg(v: &Value) -> Result { + if v.as_str() == Some("none") { + return Ok(Format::None); + } + let o = v.as_object().ok_or_else(|| bad("format must be an object such as {\"type\": \"number\", \"decimals\": 2}, or \"none\""))?; + let num = |k: &str, d: u64| o.get(k).and_then(Value::as_u64).unwrap_or(d).min(255) as u8; + let s = |k: &str| o.get(k).and_then(Value::as_str).map(str::to_owned); + Ok(match o.get("type").and_then(Value::as_str).unwrap_or("") { + "none" => Format::None, + "number" => Format::Number { + decimals: num("decimals", 2), + sep: num("separator", 0).min(4), + neg: num("negative", 0).min(3), + currency: s("currency").unwrap_or_default(), + prepend: !o.get("currency_after").and_then(Value::as_bool).unwrap_or(false), + }, + "percent" => Format::Percent { decimals: num("decimals", 2), sep: num("separator", 0).min(4) }, + "date" => Format::Date(s("pattern").unwrap_or_else(|| "mm/dd/yyyy".into())), + "time" => Format::Time(s("pattern").unwrap_or_else(|| "HH:MM".into())), + "zip" => Format::Special(0), + "zip4" => Format::Special(1), + "phone" => Format::Special(2), + "ssn" => Format::Special(3), + "mask" => Format::Mask(s("mask").ok_or_else(|| bad("mask needs \"mask\""))?), + t => return Err(bad(format!("unknown format type {t:?} (none, number, percent, date, time, zip, zip4, phone, ssn, mask)"))), + }) +} + +/// `{"min": 0, "max": 100}` or `"none"`. +fn validate_arg(v: &Value) -> Result { + if v.as_str() == Some("none") { + return Ok(Validate::None); + } + let o = v.as_object().ok_or_else(|| bad("validate must be {\"min\": …, \"max\": …} or \"none\""))?; + let (min, max) = (o.get("min").and_then(Value::as_f64), o.get("max").and_then(Value::as_f64)); + if min.is_none() && max.is_none() { + return Err(bad("validate needs min, max or both")); + } + Ok(Validate::Range { min, max }) +} + +/// `{"op": "sum", "fields": ["a", "b"]}`, `{"notation": "Price * Qty"}` or `"none"`. +fn calculate_arg(v: &Value) -> Result { + if v.as_str() == Some("none") { + return Ok(Calculate::None); + } + let o = v.as_object().ok_or_else(|| bad("calculate must be {\"op\": …, \"fields\": […]}, {\"notation\": …} or \"none\""))?; + if let Some(n) = o.get("notation").and_then(Value::as_str) { + return Ok(Calculate::Notation(n.to_string())); + } + let op = match o.get("op").and_then(Value::as_str).unwrap_or("") { + "sum" => CalcOp::Sum, + "product" => CalcOp::Product, + "average" => CalcOp::Average, + "min" | "minimum" => CalcOp::Minimum, + "max" | "maximum" => CalcOp::Maximum, + x => return Err(bad(format!("unknown op {x:?} (sum, product, average, min, max)"))), + }; + let fields: Vec = + o.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default(); + if fields.is_empty() { + return Err(bad("calculate needs fields")); + } + Ok(Calculate::Simple { op, fields }) +} + +fn format_json(f: &Format) -> Value { + match f { + Format::None => Value::Null, + Format::Number { decimals, currency, .. } => json!({ "type": "number", "decimals": decimals, "currency": currency }), + Format::Percent { decimals, .. } => json!({ "type": "percent", "decimals": decimals }), + Format::Date(p) => json!({ "type": "date", "pattern": p }), + Format::Time(p) => json!({ "type": "time", "pattern": p }), + Format::Special(n) => { + let t = ["zip", "zip4", "phone", "ssn"][(*n).min(3) as usize]; + json!({ "type": t }) + } + Format::Mask(m) => json!({ "type": "mask", "mask": m }), + } +} + fn kind_name(k: FormFieldKind) -> &'static str { match k { FormFieldKind::Text => "text", @@ -66,6 +154,25 @@ impl Automation { if let Some(t) = &f.tooltip { o.insert("tooltip".into(), json!(t)); } + 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)))); + } + if let Validate::Range { min, max } = &f.actions.validate { + o.insert("validate".into(), json!({ "min": min, "max": max })); + } + match &f.actions.calculate { + Calculate::Simple { op, fields } => { + o.insert("calculate".into(), json!({ "op": op.code().to_lowercase(), "fields": fields })); + } + Calculate::Notation(n) => { + o.insert("calculate".into(), json!({ "notation": n })); + } + Calculate::None => {} + } + if !f.actions.unsupported.is_empty() { + o.insert("unsupported_scripts".into(), json!(f.actions.unsupported)); + } match f.kind { FormFieldKind::Radio => { o.insert("options".into(), json!(f.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>())); @@ -187,6 +294,9 @@ impl Automation { }, 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")?, + format: a.get("format").map(format_arg).transpose()?, + validate: a.get("validate").map(validate_arg).transpose()?, + calculate: a.get("calculate").map(calculate_arg).transpose()?, }; 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 a5ba6a22b..8d8b9d9ed 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -236,7 +236,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). 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). Only the given ones change. Undoable.", ) .with(schema( json!({ @@ -251,6 +251,9 @@ pub fn tools() -> Vec { "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." }, + "format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." }, + "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." }, + "calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." }, }), &["doc", "field"], )), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 0fdc5fb6e..043e1ede2 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -713,3 +713,35 @@ fn adding_content_through_tools() { assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1); assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_)))); } + +#[test] +fn form_formats_and_calculations_through_tools() { + let dir = workdir("formcalc"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (name, y) in [("Price", 20), ("Qty", 60), ("Total", 100)] { + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + ok( + &mut a, + "form_set_props", + json!({ "doc": doc, "field": "Price", "format": { "type": "number", "decimals": 2, "currency": "$" }, "validate": { "min": 0 } }), + ); + ok( + &mut a, + "form_set_props", + json!({ "doc": doc, "field": "Total", "format": { "type": "number", "decimals": 2, "currency": "$" }, "calculate": { "notation": "Price * Qty" } }), + ); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Price": "19.99", "Qty": "3" } })); + let f = ok(&mut a, "form_fields", json!({ "doc": doc })); + let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone(); + assert_eq!((total["value"].as_str(), total["display"].as_str()), (Some("59.97"), Some("$59.97")), "{total}"); + assert_eq!(total["calculate"]["notation"], "Price * Qty"); + assert!(page_text(&mut a, doc)[0].contains("$59.97")); + let err = a.call("form_fill", &json!({ "doc": doc, "values": { "Price": "-5" } })).unwrap_err(); + assert!(err.to_string().contains("greater than or equal to 0"), "{err}"); + assert!(matches!( + a.call("form_set_props", &json!({ "doc": doc, "field": "Qty", "format": { "type": "roman" } })), + Err(ToolError::InvalidArgs(_)) + )); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f4c4e188f..5d9fbe5d0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -25,7 +25,7 @@ pub use printcraft_edit::{ Added, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use printcraft_forms::{ - Field as FormField, FieldKind as FormFieldKind, FieldProps, FieldValue, NewField, Widget as FormWidget, flags as field_flags, + Field as FormField, FieldKind as FormFieldKind, FieldProps, FieldValue, NewField, Widget as FormWidget, af as form_scripts, flags as field_flags, }; /// Comment geometry helpers (text-box line breaking) for frontends. diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs new file mode 100644 index 000000000..67f5b52e5 --- /dev/null +++ b/crates/forms/src/af.rs @@ -0,0 +1,983 @@ +//! Acrobat form formatting, validation and calculation without a JavaScript engine. +//! +//! Real forms carry these as JavaScript calls to the AF functions that Acrobat's JavaScript API +//! reference documents (`AFNumber_Format`, `AFPercent_Format`, `AFDate_FormatEx`, +//! `AFTime_Format`, `AFSpecial_Format`, `AFSpecial_KeystrokeEx`, `AFRange_Validate`, +//! `AFSimple_Calculate`, and simplified field notation). Acrobat itself writes exactly these +//! calls from the Format, Validate and Calculate tabs. This module recognises them, runs them +//! natively (format for display, keystroke check on commit, validation, calculation), and +//! writes them back when a form is authored. Other scripts are kept untouched and reported as +//! unsupported; they need the JavaScript engine (M6.4). + +use std::fmt::Write as _; + +/// The Format tab. +#[derive(Clone, Debug, PartialEq, Default)] +pub enum Format { + #[default] + None, + /// `AFNumber_Format(dec, sepStyle, negStyle, currStyle, strCurrency, bCurrencyPrepend)`. + Number { decimals: u8, sep: u8, neg: u8, currency: String, prepend: bool }, + /// `AFPercent_Format(dec, sepStyle)`. + Percent { decimals: u8, sep: u8 }, + /// `AFDate_FormatEx(cFormat)`. + Date(String), + /// `AFTime_FormatEx(cFormat)`. + Time(String), + /// `AFSpecial_Format(n)`: 0 zip, 1 zip+4, 2 phone, 3 SSN. + Special(u8), + /// `AFSpecial_KeystrokeEx(cMask)`: 9 digit, A letter, O letter or digit, X any. + Mask(String), +} + +/// The Validate tab. +#[derive(Clone, Debug, PartialEq, Default)] +pub enum Validate { + #[default] + None, + /// `AFRange_Validate(bGreater, nGreater, bLess, nLess)`. + Range { min: Option, max: Option }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CalcOp { + Sum, + Product, + Average, + Minimum, + Maximum, +} + +impl CalcOp { + pub fn code(self) -> &'static str { + match self { + CalcOp::Sum => "SUM", + CalcOp::Product => "PRD", + CalcOp::Average => "AVG", + CalcOp::Minimum => "MIN", + CalcOp::Maximum => "MAX", + } + } + + pub fn label(self) -> &'static str { + match self { + CalcOp::Sum => "sum (+)", + CalcOp::Product => "product (x)", + CalcOp::Average => "average", + CalcOp::Minimum => "minimum", + CalcOp::Maximum => "maximum", + } + } + + pub const ALL: [CalcOp; 5] = [CalcOp::Sum, CalcOp::Product, CalcOp::Average, CalcOp::Minimum, CalcOp::Maximum]; +} + +/// The Calculate tab. +#[derive(Clone, Debug, PartialEq, Default)] +pub enum Calculate { + #[default] + None, + /// `AFSimple_Calculate("SUM", new Array("a", "b"))`. + Simple { op: CalcOp, fields: Vec }, + /// Simplified field notation, e.g. `Price * Quantity`. + Notation(String), +} + +/// A field's recognised scripts. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Actions { + pub format: Format, + pub validate: Validate, + pub calculate: Calculate, + /// Events whose script isn't one of the AF calls (they need the JavaScript engine). + pub unsupported: Vec<&'static str>, +} + +impl Actions { + pub fn is_empty(&self) -> bool { + self.format == Format::None && self.validate == Validate::None && self.calculate == Calculate::None + } +} + +// ── parsing scripts ───────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +enum Arg { + Num(f64), + Str(String), + Bool(bool), + List(Vec), +} + +impl Arg { + fn num(&self) -> Option { + match self { + Arg::Num(n) => Some(*n), + Arg::Bool(b) => Some(f64::from(u8::from(*b))), + Arg::Str(s) => s.trim().parse().ok(), + Arg::List(_) => None, + } + } + + fn string(&self) -> Option { + match self { + Arg::Str(s) => Some(s.clone()), + Arg::Num(n) => Some(n.to_string()), + _ => None, + } + } + + fn truthy(&self) -> bool { + match self { + Arg::Bool(b) => *b, + Arg::Num(n) => *n != 0.0, + Arg::Str(s) => !s.is_empty(), + Arg::List(l) => !l.is_empty(), + } + } +} + +struct Parser<'a> { + s: &'a [u8], + i: usize, +} + +impl Parser<'_> { + fn ws(&mut self) { + while self.i < self.s.len() && (self.s[self.i].is_ascii_whitespace()) { + self.i += 1; + } + } + + fn string(&mut self) -> Option { + let q = self.s[self.i]; + self.i += 1; + let mut out = Vec::new(); + while self.i < self.s.len() { + let c = self.s[self.i]; + self.i += 1; + match c { + b'\\' if self.i < self.s.len() => { + let e = self.s[self.i]; + self.i += 1; + out.push(match e { + b'n' => b'\n', + b't' => b'\t', + other => other, + }); + } + c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()), + c => out.push(c), + } + } + None + } + + fn arg(&mut self) -> Option { + self.ws(); + let c = *self.s.get(self.i)?; + match c { + b'"' | b'\'' => self.string().map(Arg::Str), + b'[' => { + self.i += 1; + self.list(b']').map(Arg::List) + } + _ if self.s[self.i..].starts_with(b"new Array") => { + self.i += b"new Array".len(); + self.ws(); + if self.s.get(self.i) != Some(&b'(') { + return None; + } + self.i += 1; + self.list(b')').map(Arg::List) + } + _ if self.s[self.i..].starts_with(b"true") => { + self.i += 4; + Some(Arg::Bool(true)) + } + _ if self.s[self.i..].starts_with(b"false") => { + self.i += 5; + Some(Arg::Bool(false)) + } + _ => { + let start = self.i; + while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { + self.i += 1; + } + std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) + } + } + } + + fn list(&mut self, close: u8) -> Option> { + let mut out = Vec::new(); + loop { + self.ws(); + if self.s.get(self.i) == Some(&close) { + self.i += 1; + return Some(out); + } + out.push(self.arg()?); + self.ws(); + match self.s.get(self.i) { + Some(b',') => self.i += 1, + Some(c) if *c == close => { + self.i += 1; + return Some(out); + } + _ => return None, + } + } + } +} + +/// The first call to `name(` in `js` and its arguments. +fn call(js: &str, name: &str) -> Option> { + let at = js.find(&format!("{name}("))?; + // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). + if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { + return None; + } + let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1 }; + p.list(b')') +} + +/// Acrobat's AFDate_Format(n) / AFTime_Format(n) presets. +pub const DATE_PRESETS: [&str; 14] = [ + "m/d", + "m/d/yy", + "mm/dd/yy", + "mm/yy", + "d-mmm", + "d-mmm-yy", + "dd-mmm-yy", + "yy-mm-dd", + "mmm-yy", + "mmmm-yy", + "mmm d, yyyy", + "mmmm d, yyyy", + "m/d/yy h:MM tt", + "m/d/yy HH:MM", +]; +pub const TIME_PRESETS: [&str; 4] = ["HH:MM", "h:MM tt", "HH:MM:ss", "h:MM:ss tt"]; + +pub fn parse_format(js: &str) -> Option { + let a = |args: &[Arg], i: usize| args.get(i).and_then(Arg::num).unwrap_or(0.0).clamp(0.0, 255.0) as u8; + if let Some(args) = call(js, "AFNumber_Format") { + return Some(Format::Number { + decimals: a(&args, 0), + sep: a(&args, 1), + neg: a(&args, 2), + currency: args.get(4).and_then(Arg::string).unwrap_or_default(), + prepend: args.get(5).is_none_or(Arg::truthy), + }); + } + if let Some(args) = call(js, "AFPercent_Format") { + return Some(Format::Percent { decimals: a(&args, 0), sep: a(&args, 1) }); + } + if let Some(args) = call(js, "AFDate_FormatEx") { + return Some(Format::Date(args.first().and_then(Arg::string)?)); + } + if let Some(args) = call(js, "AFDate_Format") { + return Some(Format::Date(DATE_PRESETS.get(a(&args, 0) as usize)?.to_string())); + } + if let Some(args) = call(js, "AFTime_FormatEx") { + return Some(Format::Time(args.first().and_then(Arg::string)?)); + } + if let Some(args) = call(js, "AFTime_Format") { + return Some(Format::Time(TIME_PRESETS.get(a(&args, 0) as usize)?.to_string())); + } + if let Some(args) = call(js, "AFSpecial_Format") { + return Some(Format::Special(a(&args, 0).min(3))); + } + if let Some(args) = call(js, "AFSpecial_KeystrokeEx") { + return Some(Format::Mask(args.first().and_then(Arg::string)?)); + } + None +} + +pub fn parse_validate(js: &str) -> Option { + let args = call(js, "AFRange_Validate")?; + let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); + let num = |i: usize| args.get(i).and_then(Arg::num); + Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) +} + +pub fn parse_calculate(js: &str) -> Option { + // Simplified field notation: Acrobat keeps the expression in a comment. + if let (Some(a), Some(b)) = (js.find("BVCALC"), js.find("EVCALC")) { + let expr = js.get(a + 6..b)?.trim().to_string(); + return Some(Calculate::Notation(expr)); + } + let args = call(js, "AFSimple_Calculate")?; + let op = match args.first().and_then(Arg::string)?.as_str() { + "SUM" => CalcOp::Sum, + "PRD" => CalcOp::Product, + "AVG" => CalcOp::Average, + "MIN" => CalcOp::Minimum, + "MAX" => CalcOp::Maximum, + _ => return None, + }; + let fields = match args.get(1)? { + Arg::List(l) => l.iter().filter_map(Arg::string).collect(), + Arg::Str(s) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), + _ => return None, + }; + Some(Calculate::Simple { op, fields }) +} + +// ── writing scripts (what Acrobat's tabs generate) ───────────────────────────────────────── + +fn js_str(s: &str) -> String { + let mut o = String::from("\""); + for c in s.chars() { + match c { + '"' | '\\' => { + o.push('\\'); + o.push(c); + } + '\n' => o.push_str("\\n"), + c => o.push(c), + } + } + o.push('"'); + o +} + +/// The (format, keystroke) scripts for a format. +pub fn format_js(f: &Format) -> Option<(String, String)> { + Some(match f { + Format::None => return None, + Format::Number { decimals, sep, neg, currency, prepend } => { + let args = format!("{decimals}, {sep}, {neg}, 0, {}, {prepend}", js_str(currency)); + (format!("AFNumber_Format({args});"), format!("AFNumber_Keystroke({args});")) + } + Format::Percent { decimals, sep } => (format!("AFPercent_Format({decimals}, {sep});"), format!("AFPercent_Keystroke({decimals}, {sep});")), + Format::Date(f) => (format!("AFDate_FormatEx({});", js_str(f)), format!("AFDate_KeystrokeEx({});", js_str(f))), + Format::Time(f) => (format!("AFTime_FormatEx({});", js_str(f)), format!("AFTime_KeystrokeEx({});", js_str(f))), + Format::Special(n) => (format!("AFSpecial_Format({n});"), format!("AFSpecial_Keystroke({n});")), + Format::Mask(m) => (String::new(), format!("AFSpecial_KeystrokeEx({});", js_str(m))), + }) +} + +pub fn validate_js(v: &Validate) -> Option { + match v { + Validate::None => None, + Validate::Range { min, max } => { + Some(format!("AFRange_Validate({}, {}, {}, {});", min.is_some(), min.unwrap_or(0.0), max.is_some(), max.unwrap_or(0.0))) + } + } +} + +pub fn calculate_js(c: &Calculate) -> Option { + match c { + Calculate::None => None, + Calculate::Simple { op, fields } => Some(format!( + "AFSimple_Calculate({}, new Array({}));", + js_str(op.code()), + fields.iter().map(|f| js_str(f)).collect::>().join(", ") + )), + Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = AFMakeNumber(0);")), + } +} + +// ── running them ──────────────────────────────────────────────────────────────────────────── + +/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a +/// trailing % ignored; a comma decimal separator accepted). +pub fn make_number(s: &str) -> Option { + let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect(); + if t.is_empty() { + return None; + } + let neg = t.starts_with('(') && t.ends_with(')'); + let t = t.trim_start_matches('(').trim_end_matches(')'); + // "1.234,56" or "1234,56": comma is the decimal separator when it comes last. + let normalized = match (t.rfind(','), t.rfind('.')) { + (Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."), + (Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."), + _ => t.replace(',', ""), + }; + let v: f64 = normalized.parse().ok()?; + v.is_finite().then_some(if neg { -v } else { v }) +} + +/// A number as JavaScript prints it (integers without a fraction). +pub fn js_number(v: f64) -> String { + if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } +} + +fn group(int: &str, sep: char) -> String { + let mut out = String::new(); + for (i, c) in int.chars().enumerate() { + if i > 0 && (int.len() - i).is_multiple_of(3) { + out.push(sep); + } + out.push(c); + } + out +} + +/// A number with `decimals` places in separator style `sep` (0 "1,234.56", 1 "1234.56", +/// 2 "1.234,56", 3 "1234,56", 4 "1'234.56"). +fn number_text(v: f64, decimals: u8, sep: u8) -> String { + // Round half away from zero, as JavaScript's toFixed does for these values. + let p = 10f64.powi(i32::from(decimals)); + let s = format!("{:.*}", decimals as usize, (v.abs() * p).round() / p); + let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); + let (g, d) = match sep { + 1 => (None, '.'), + 2 => (Some('.'), ','), + 3 => (None, ','), + 4 => (Some('\''), '.'), + _ => (Some(','), '.'), + }; + let mut out = match g { + Some(c) => group(int, c), + None => int.to_string(), + }; + if !frac.is_empty() { + out.push(d); + out.push_str(frac); + } + out +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// A date and time read from text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DateTime { + pub y: i32, + pub m: u32, + pub d: u32, + pub hh: u32, + pub mm: u32, + pub ss: u32, +} + +fn days_in(y: i32, m: u32) -> u32 { + match m { + 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +fn weekday(y: i32, m: u32, d: u32) -> usize { + // Sakamoto's method. + let t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + let y = if m < 3 { y - 1 } else { y }; + ((y + y / 4 - y / 100 + y / 400 + t[(m - 1) as usize] + d as i32).rem_euclid(7)) as usize +} + +/// The order of the year/month/day fields in a format, as letters. +fn order(fmt: &str) -> Vec { + let mut out = Vec::new(); + // Weekday names (ddd, dddd) are shown, not typed: leave them out. + let mut cleaned = fmt.replace("dddd", " "); + cleaned = cleaned.replace("ddd", " "); + for c in cleaned.chars() { + let k = match c { + 'y' => 'y', + 'm' => 'm', + 'd' => 'd', + 'H' | 'h' => 'H', + 'M' => 'M', + 's' => 's', + _ => continue, + }; + if out.last() != Some(&k) { + out.push(k); + } + } + out +} + +/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any +/// separators, month names, two-digit years). `None` when it isn't a valid date. +pub fn parse_date(text: &str, fmt: &str) -> Option { + let t = text.trim(); + if t.is_empty() { + return None; + } + let lower = t.to_lowercase(); + let pm = lower.contains("pm") || lower.ends_with('p'); + let am = lower.contains("am"); + // Tokens: numbers and month names. + let mut nums: Vec = Vec::new(); + let mut month_name: Option = None; + let mut cur = String::new(); + let flush = |cur: &mut String, nums: &mut Vec, month: &mut Option| { + if cur.is_empty() { + return; + } + if let Ok(n) = cur.parse::() { + nums.push(n); + } else if cur.len() >= 3 + && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) + { + *month = Some(i as u32 + 1); + } + cur.clear(); + }; + for c in t.chars() { + // Digits and letters form separate tokens ("5Mar" is 5 and Mar). + let same_kind = cur.is_empty() || cur.chars().next().is_some_and(|x| x.is_ascii_digit()) == c.is_ascii_digit(); + if c.is_alphanumeric() && same_kind { + cur.push(c); + } else { + flush(&mut cur, &mut nums, &mut month_name); + if c.is_alphanumeric() { + cur.push(c); + } + } + } + flush(&mut cur, &mut nums, &mut month_name); + let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 }; + let mut it = nums.into_iter(); + let mut have_y = false; + for k in order(fmt) { + match k { + 'm' if month_name.is_some() => dt.m = month_name.expect("checked"), + 'm' => dt.m = it.next()?, + 'd' => dt.d = it.next()?, + 'y' => { + let y = it.next()?; + dt.y = if y < 100 { if y < 50 { 2000 + y as i32 } else { 1900 + y as i32 } } else { y as i32 }; + have_y = true; + } + 'H' => dt.hh = it.next().unwrap_or(0), + 'M' => dt.mm = it.next().unwrap_or(0), + 's' => dt.ss = it.next().unwrap_or(0), + _ => {} + } + } + if it.next().is_some() { + return None; + } + if !have_y { + dt.y = 2000; + } + if pm && dt.hh < 12 { + dt.hh += 12; + } + if am && dt.hh == 12 { + dt.hh = 0; + } + let has_date = fmt.contains(['y', 'm', 'd']); + if has_date && (!(1..=12).contains(&dt.m) || dt.d == 0 || dt.d > days_in(dt.y, dt.m)) { + return None; + } + if dt.hh > 23 || dt.mm > 59 || dt.ss > 59 { + return None; + } + Some(dt) +} + +/// Format a date/time with Acrobat's date format letters. +pub fn format_date(dt: DateTime, fmt: &str) -> String { + let chars: Vec = fmt.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let mut n = 1; + while i + n < chars.len() && chars[i + n] == c { + n += 1; + } + let h12 = if dt.hh.is_multiple_of(12) { 12 } else { dt.hh % 12 }; + match (c, n) { + ('y', 4..) => { + let _ = write!(out, "{:04}", dt.y); + } + ('y', _) => { + let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); + } + ('m', 4..) => out.push_str(MONTHS[(dt.m - 1) as usize]), + ('m', 3) => out.push_str(&MONTHS[(dt.m - 1) as usize][..3]), + ('m', 2) => { + let _ = write!(out, "{:02}", dt.m); + } + ('m', _) => { + let _ = write!(out, "{}", dt.m); + } + ('d', 4..) => out.push_str(DAYS[weekday(dt.y, dt.m, dt.d)]), + ('d', 3) => out.push_str(&DAYS[weekday(dt.y, dt.m, dt.d)][..3]), + ('d', 2) => { + let _ = write!(out, "{:02}", dt.d); + } + ('d', _) => { + let _ = write!(out, "{}", dt.d); + } + ('H', 2) => { + let _ = write!(out, "{:02}", dt.hh); + } + ('H', _) => { + let _ = write!(out, "{}", dt.hh); + } + ('h', 2) => { + let _ = write!(out, "{h12:02}"); + } + ('h', _) => { + let _ = write!(out, "{h12}"); + } + ('M', 2) => { + let _ = write!(out, "{:02}", dt.mm); + } + ('M', _) => { + let _ = write!(out, "{}", dt.mm); + } + ('s', 2) => { + let _ = write!(out, "{:02}", dt.ss); + } + ('s', _) => { + let _ = write!(out, "{}", dt.ss); + } + ('t', 2) => out.push_str(if dt.hh < 12 { "am" } else { "pm" }), + ('t', _) => out.push(if dt.hh < 12 { 'a' } else { 'p' }), + (c, n) => out.extend(std::iter::repeat_n(c, n)), + } + i += n; + } + out +} + +const SPECIAL_MASKS: [&str; 4] = ["99999", "99999-9999", "(999) 999-9999", "999-99-9999"]; + +fn mask_fits(mask: &str, text: &str) -> bool { + let t: Vec = text.chars().collect(); + let m: Vec = mask.chars().collect(); + t.len() == m.len() + && m.iter().zip(&t).all(|(mc, tc)| match mc { + '9' => tc.is_ascii_digit(), + 'A' => tc.is_alphabetic(), + 'O' => tc.is_alphanumeric(), + 'X' => true, + other => other == tc, + }) +} + +/// Fill a mask from the typed characters that fit its slots. +fn apply_mask(mask: &str, text: &str) -> Option { + let mut src = text.chars().filter(|c| c.is_alphanumeric()); + let mut out = String::new(); + for mc in mask.chars() { + match mc { + '9' | 'A' | 'O' | 'X' => { + let c = src.next()?; + let ok = match mc { + '9' => c.is_ascii_digit(), + 'A' => c.is_alphabetic(), + _ => true, + }; + if !ok { + return None; + } + out.push(c); + } + other => out.push(other), + } + } + src.next().is_none().then_some(out) +} + +/// The text shown for a value (the Format event). +pub fn format_value(f: &Format, value: &str) -> String { + if value.is_empty() { + return String::new(); + } + match f { + Format::None => value.to_string(), + Format::Number { decimals, sep, neg, currency, prepend } => { + let Some(v) = make_number(value) else { return value.to_string() }; + let body = number_text(v, *decimals, *sep); + let body = match (currency.is_empty(), *prepend) { + (true, _) => body, + (false, true) => format!("{currency}{body}"), + (false, false) => format!("{body}{currency}"), + }; + match (v < 0.0, neg) { + (false, _) => body, + // Styles 2 and 3 use parentheses (3 is also red, which the appearance can't + // know here); 0 and 1 a minus sign. + (true, 2 | 3) => format!("({body})"), + (true, _) => format!("-{body}"), + } + } + Format::Percent { decimals, sep } => match make_number(value) { + Some(v) => format!( + "{}%", + if v < 0.0 { format!("-{}", number_text(v * 100.0, *decimals, *sep)) } else { number_text(v * 100.0, *decimals, *sep) } + ), + None => value.to_string(), + }, + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), + Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()), + Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()), + } +} + +/// The Keystroke event on commit: does the value fit the format? Returns the value to store +/// (numbers are normalised), or Acrobat's message. +pub fn keystroke(f: &Format, field: &str, value: &str) -> Result { + let bad = || format!("The value entered does not match the format of the field [ {field} ]"); + if value.trim().is_empty() { + return Ok(String::new()); + } + match f { + Format::None => Ok(value.to_string()), + Format::Number { .. } => make_number(value).map(js_number).ok_or_else(bad), + Format::Percent { .. } => { + // Typed "12.5%" or "12.5" both mean 0.125 once stored? Acrobat stores the number + // as typed and shows it ×100: keep the typed number. + make_number(value).map(js_number).ok_or_else(bad) + } + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt) + .map(|_| value.trim().to_string()) + .ok_or_else(|| format!("Invalid date/time: please ensure that the date/time exists. Field [ {field} ] should match format {fmt}")), + Format::Special(n) => { + let m = SPECIAL_MASKS[(*n).min(3) as usize]; + apply_mask(m, value).filter(|s| mask_fits(m, s)).map(|_| value.trim().to_string()).ok_or_else(bad) + } + Format::Mask(m) => apply_mask(m, value).map(|_| value.trim().to_string()).ok_or_else(bad), + } +} + +/// The Validate event: `Err` with Acrobat's message when the value is out of range. +pub fn validate(v: &Validate, value: &str) -> Result<(), String> { + let Validate::Range { min, max } = v else { return Ok(()) }; + if value.trim().is_empty() { + return Ok(()); + } + let Some(x) = make_number(value) else { return Ok(()) }; + let fmt = |n: f64| js_number(n); + let ok = min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m); + if ok { + return Ok(()); + } + Err(match (min, max) { + (Some(a), Some(b)) => format!("Invalid value: must be greater than or equal to {} and less than or equal to {}.", fmt(*a), fmt(*b)), + (Some(a), None) => format!("Invalid value: must be greater than or equal to {}.", fmt(*a)), + (None, Some(b)) => format!("Invalid value: must be less than or equal to {}.", fmt(*b)), + (None, None) => String::new(), + }) +} + +/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names +/// (`\` escapes spaces and operators in names, as Acrobat writes them). +fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { + #[derive(Debug, Clone, PartialEq)] + enum T { + N(f64), + Op(char), + } + let mut toks = Vec::new(); + let cs: Vec = src.chars().collect(); + let mut i = 0; + while i < cs.len() { + let c = cs[i]; + if c.is_whitespace() { + i += 1; + } else if "+-*/()".contains(c) { + toks.push(T::Op(c)); + i += 1; + } else if c.is_ascii_digit() || c == '.' { + let s = i; + while i < cs.len() && (cs[i].is_ascii_digit() || cs[i] == '.') { + i += 1; + } + toks.push(T::N(cs[s..i].iter().collect::().parse().ok()?)); + } else { + let mut name = String::new(); + while i < cs.len() && !(cs[i].is_whitespace() || "+-*/()".contains(cs[i])) { + if cs[i] == '\\' && i + 1 < cs.len() { + i += 1; + } + name.push(cs[i]); + i += 1; + } + toks.push(T::N(value_of(&name))); + } + } + // Recursive descent. + fn expr(t: &[T], i: &mut usize) -> Option { + let mut v = term(t, i)?; + while let Some(T::Op(o @ ('+' | '-'))) = t.get(*i) { + *i += 1; + let r = term(t, i)?; + v = if *o == '+' { v + r } else { v - r }; + } + Some(v) + } + fn term(t: &[T], i: &mut usize) -> Option { + let mut v = factor(t, i)?; + while let Some(T::Op(o @ ('*' | '/'))) = t.get(*i) { + *i += 1; + let r = factor(t, i)?; + v = if *o == '*' { v * r } else { v / r }; + } + Some(v) + } + fn factor(t: &[T], i: &mut usize) -> Option { + match t.get(*i)? { + T::N(n) => { + *i += 1; + Some(*n) + } + T::Op('-') => { + *i += 1; + factor(t, i).map(|v| -v) + } + T::Op('(') => { + *i += 1; + let v = expr(t, i)?; + (t.get(*i) == Some(&T::Op(')'))).then(|| *i += 1)?; + Some(v) + } + _ => None, + } + } + let mut i = 0; + let v = expr(&toks, &mut i)?; + (i == toks.len() && v.is_finite()).then_some(v) +} + +/// The Calculate event: the new value, given the current values of the other fields +/// (`values(name)` returns every field named `name` or below it, for groups). +pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { + let num = |s: &String| make_number(s).unwrap_or(0.0); + match c { + Calculate::None => None, + Calculate::Simple { op, fields } => { + let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); + let v = match op { + CalcOp::Sum => xs.iter().sum(), + CalcOp::Product => xs.iter().product(), + CalcOp::Average => { + if xs.is_empty() { + 0.0 + } else { + xs.iter().sum::() / xs.len() as f64 + } + } + CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), + CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + Some(if v.is_finite() { js_number(v) } else { "0".into() }) + } + Calculate::Notation(expr) => { + let v = eval_notation(expr, &|name| values(name).first().map(num).unwrap_or(0.0))?; + Some(js_number(v)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scripts_are_recognised_and_written_back() { + assert_eq!( + parse_format("AFNumber_Format(2, 0, 0, 0, \"$\", true);"), + Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) + ); + assert_eq!(parse_format("AFPercent_Format(1, 0);"), Some(Format::Percent { decimals: 1, sep: 0 })); + assert_eq!(parse_format("AFDate_FormatEx(\"dd-mmm-yyyy\");"), Some(Format::Date("dd-mmm-yyyy".into()))); + assert_eq!(parse_format("AFDate_Format(2);"), Some(Format::Date("mm/dd/yy".into()))); + assert_eq!(parse_format("AFTime_Format(1);"), Some(Format::Time("h:MM tt".into()))); + assert_eq!(parse_format("AFSpecial_Format(2);"), Some(Format::Special(2))); + assert_eq!(parse_format("AFSpecial_KeystrokeEx(\"AA-9999\");"), Some(Format::Mask("AA-9999".into()))); + assert_eq!(parse_format("app.alert('hi')"), None); + assert_eq!(parse_validate("AFRange_Validate(true, 0, true, 100);"), Some(Validate::Range { min: Some(0.0), max: Some(100.0) })); + assert_eq!(parse_validate("AFRange_Validate(false, 0, true, 10);"), Some(Validate::Range { min: None, max: Some(10.0) })); + assert_eq!( + parse_calculate("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b.c\"));"), + Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b.c".into()] }) + ); + assert_eq!(parse_calculate("AFSimple_Calculate('AVG', ['x']);"), Some(Calculate::Simple { op: CalcOp::Average, fields: vec!["x".into()] })); + assert_eq!( + parse_calculate("/** BVCALC Price * Qty EVCALC **/ event.value = AFMakeNumber(getField(\"Price\").value) * …"), + Some(Calculate::Notation("Price * Qty".into())) + ); + // Round trips through what the tabs write. + for f in [ + Format::Number { decimals: 2, sep: 2, neg: 2, currency: "€".into(), prepend: false }, + Format::Percent { decimals: 0, sep: 0 }, + Format::Date("mmmm d, yyyy".into()), + Format::Special(3), + ] { + let (fj, _) = format_js(&f).unwrap(); + assert_eq!(parse_format(&fj), Some(f)); + } + let c = Calculate::Simple { op: CalcOp::Product, fields: vec!["q".into(), "p".into()] }; + assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); + let c = Calculate::Notation("a + b".into()); + assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); + } + + #[test] + fn numbers_format_like_acrobat() { + let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; + assert_eq!(format_value(&n(2, 0, 0, "$", true), "1234.5"), "$1,234.50"); + assert_eq!(format_value(&n(0, 1, 0, "", true), "1234567"), "1234567"); + assert_eq!(format_value(&n(2, 2, 0, " €", false), "1234.5"), "1.234,50 €"); + assert_eq!(format_value(&n(2, 0, 2, "", true), "-12"), "(12.00)"); + assert_eq!(format_value(&n(1, 0, 0, "", true), "-0.25"), "-0.3"); + assert_eq!(format_value(&Format::Percent { decimals: 1, sep: 0 }, "0.125"), "12.5%"); + assert_eq!(make_number("$1,234.50"), Some(1234.5)); + assert_eq!(make_number("1.234,5"), Some(1234.5)); + assert_eq!(make_number("(7)"), Some(-7.0)); + assert_eq!(make_number("abc"), None); + assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", "$1,000"), Ok("1000".into())); + assert_eq!( + keystroke(&n(2, 0, 0, "$", true), "Total", "ten"), + Err("The value entered does not match the format of the field [ Total ]".into()) + ); + } + + #[test] + fn dates_times_and_masks() { + let d = Format::Date("mm/dd/yyyy".into()); + assert_eq!(format_value(&d, "1/2/24"), "01/02/2024"); + assert_eq!(format_value(&Format::Date("mmmm d, yyyy".into()), "10/1/2026"), "October 1, 2026"); + assert_eq!(format_value(&Format::Date("dddd, mmm d, yyyy".into()), "10/1/2026"), "Thursday, Oct 1, 2026"); + assert_eq!(format_value(&Format::Date("dd-mmm-yy".into()), "5-Mar-21"), "05-Mar-21", "month names are read"); + assert!(keystroke(&d, "Due", "2/30/2024").is_err(), "no 30 February"); + assert!(keystroke(&d, "Due", "13/1/2024").is_err()); + assert_eq!(format_value(&Format::Time("h:MM tt".into()), "14:05"), "2:05 pm"); + assert_eq!(format_value(&Format::Time("HH:MM".into()), "2:05 pm"), "14:05"); + assert_eq!(format_value(&Format::Special(2), "5551234567"), "(555) 123-4567"); + assert_eq!(format_value(&Format::Special(3), "123-45-6789"), "123-45-6789"); + assert!(keystroke(&Format::Special(0), "Zip", "1234").is_err()); + assert_eq!(format_value(&Format::Mask("AA-9999".into()), "ab1234"), "ab-1234"); + assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err()); + } + + #[test] + fn validation_and_calculation() { + let r = Validate::Range { min: Some(0.0), max: Some(100.0) }; + assert!(validate(&r, "50").is_ok() && validate(&r, "").is_ok()); + assert_eq!(validate(&r, "120"), Err("Invalid value: must be greater than or equal to 0 and less than or equal to 100.".into())); + let vals = |name: &str| -> Vec { + match name { + "a" => vec!["2".into()], + "b" => vec!["$3.50".into()], + "row" => vec!["1".into(), "2".into(), "3".into()], + "Unit Price" => vec!["4".into()], + _ => vec![], + } + }; + let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); + assert_eq!(simple(CalcOp::Sum, &["a", "b"]), Some("5.5".into())); + assert_eq!(simple(CalcOp::Product, &["a", "b"]), Some("7".into())); + assert_eq!(simple(CalcOp::Average, &["row"]), Some("2".into()), "groups count every child"); + assert_eq!(simple(CalcOp::Maximum, &["a", "row"]), Some("3".into())); + assert_eq!(simple(CalcOp::Sum, &["missing"]), Some("0".into())); + assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); + assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); + } +} diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 054fc342f..a8e59f269 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -131,6 +131,15 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { /// The appearance of a text or choice field's widget showing `values`. pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String]) -> Stream { + // The Format event: what is shown, not what is stored. + let formatted: Vec; + let values = + if matches!(f.kind, crate::FieldKind::Text | crate::FieldKind::Combo) && values.len() == 1 && f.actions.format != crate::af::Format::None { + formatted = vec![crate::af::format_value(&f.actions.format, &values[0])]; + &formatted[..] + } else { + values + }; 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)); diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 933a0a161..b5cd8b96a 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -70,6 +70,10 @@ pub struct FieldProps { /// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in /// user space. pub rect: Option<(usize, [f64; 4])>, + /// Format, Validate and Calculate tabs (written as Acrobat's AF scripts). + pub format: Option, + pub validate: Option, + pub calculate: Option, } fn invalid(m: impl Into) -> Result { @@ -469,6 +473,73 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Object { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(js.as_bytes().to_vec())); + Object::Dict(a) + }; + if let Some(fm) = &props.format { + aa.remove(b"F"); + aa.remove(b"K"); + if let Some((fjs, kjs)) = crate::af::format_js(fm) { + if !fjs.is_empty() { + aa.set(b"F".to_vec(), action(&fjs)); + } + aa.set(b"K".to_vec(), action(&kjs)); + } + } + if let Some(v) = &props.validate { + aa.remove(b"V"); + if let Some(js) = crate::af::validate_js(v) { + aa.set(b"V".to_vec(), action(&js)); + } + } + if let Some(c) = &props.calculate { + aa.remove(b"C"); + if let Some(js) = crate::af::calculate_js(c) { + aa.set(b"C".to_vec(), action(&js)); + } + // The calculation order lists calculated fields. + let af_ref = ensure_form(doc)?; + let mut co: Vec = doc + .get(af_ref) + .as_dict() + .and_then(|d| d.get(b"CO").cloned()) + .map(|c| doc.resolve(&c)) + .and_then(|c| c.as_array().cloned()) + .unwrap_or_default(); + co.retain(|o| o.as_ref() != Some(f.obj)); + if *c != crate::af::Calculate::None { + co.push(Object::Ref(f.obj)); + } + doc.update_dict(af_ref, |d| { + if co.is_empty() { + d.remove(b"CO"); + } else { + d.set(b"CO".to_vec(), Object::Array(co)); + } + })?; + } + doc.update_dict(f.obj, |d| { + if aa.is_empty() { + d.remove(b"AA"); + } else { + d.set(b"AA".to_vec(), Object::Dict(aa)); + } + })?; + } // 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 { @@ -485,6 +556,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result, pub widgets: Vec, + /// Format, validate and calculate scripts (Acrobat's AF functions), see [`af`]. + pub actions: af::Actions, } impl Field { @@ -144,6 +147,64 @@ fn text_of(o: &Object) -> Option { } } +/// The JavaScript of an action dictionary (`/JS` string or stream). +fn script(doc: &Document, action: &Object) -> Option { + let a = doc.resolve(action); + let js = a.as_dict()?.get(b"JS")?.clone(); + match &*doc.resolve(&js) { + Object::String(s) => Some(s.to_text()), + Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + _ => None, + } +} + +/// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only +/// widget's when the field has none). +fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { + let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { + (widgets.len() == 1) + .then(|| doc.get(widgets[0])) + .and_then(|w| w.as_dict().and_then(|wd| wd.get(b"AA").map(|a| doc.resolve(a)))) + .and_then(|a| a.as_dict().cloned()) + }); + let mut out = af::Actions::default(); + let Some(aa) = aa else { return out }; + let js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a)); + if let Some(f) = js(b"F") { + match af::parse_format(&f) { + Some(fm) => out.format = fm, + None if !f.trim().is_empty() => out.unsupported.push("format"), + None => {} + } + } + if let Some(k) = js(b"K") + && out.format == af::Format::None + { + // A mask has only a keystroke script. + match af::parse_format(&k) { + Some(fm @ af::Format::Mask(_)) => out.format = fm, + Some(_) => {} + None if !k.trim().is_empty() => out.unsupported.push("keystroke"), + None => {} + } + } + if let Some(v) = js(b"V") { + match af::parse_validate(&v) { + Some(vv) => out.validate = vv, + None if !v.trim().is_empty() => out.unsupported.push("validate"), + None => {} + } + } + if let Some(c) = js(b"C") { + match af::parse_calculate(&c) { + Some(cc) => out.calculate = cc, + None if !c.trim().is_empty() => out.unsupported.push("calculate"), + None => {} + } + } + out +} + fn values_of(doc: &Document, o: Option<&Object>) -> Vec { let Some(o) = o else { return Vec::new() }; match &*doc.resolve(o) { @@ -280,6 +341,7 @@ fn walk( return; } let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; + let actions = actions_of(doc, d, &widget_refs); let ff = inh.ff.unwrap_or(0); let kind = match inh.ft.as_deref() { Some(b"Tx") => FieldKind::Text, @@ -336,6 +398,7 @@ fn walk( quadding: inh.q.unwrap_or(0), tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), widgets, + actions, }); } @@ -351,7 +414,47 @@ pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<( if f.read_only() { return Err(FormError::ReadOnly(name.into())); } - write_value(doc, f, value) + write_value(doc, f, value)?; + recalculate(doc)?; + Ok(()) +} + +/// Run every field's Calculate script in the form's calculation order (`/CO`, then the other +/// calculated fields), as Acrobat does after any value changes. Returns how many changed. +pub fn recalculate(doc: &mut Document) -> Result { + let all = fields(doc); + if !all.iter().any(|f| f.actions.calculate != af::Calculate::None) { + return Ok(0); + } + let co: Vec = acroform(doc) + .and_then(|af| af.get(b"CO").map(|c| doc.resolve(c)).and_then(|c| c.as_array().cloned())) + .unwrap_or_default() + .iter() + .filter_map(Object::as_ref) + .collect(); + let mut order: Vec = co.iter().filter_map(|r| all.iter().find(|f| f.obj == *r)).map(|f| f.name.clone()).collect(); + for f in &all { + if f.actions.calculate != af::Calculate::None && !order.contains(&f.name) { + order.push(f.name.clone()); + } + } + let mut changed = 0; + for name in order { + let now = fields(doc); + let Some(f) = now.iter().find(|f| f.name == name) else { continue }; + let lookup = |n: &str| -> Vec { + let prefix = format!("{n}."); + now.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect() + }; + let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; + if f.value.first().map(String::as_str).unwrap_or("") != v && f.kind == FieldKind::Text { + let f = f.clone(); + doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; + redraw(doc, &f, std::slice::from_ref(&v))?; + changed += 1; + } + } + Ok(changed) } fn invalid(m: impl Into) -> Result { @@ -366,6 +469,9 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue) -> Result<(), { return invalid(format!("{:?} takes at most {max} characters", f.name)); } + // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. + let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Invalid)?; + af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; doc.update_dict(f.obj, |d| { if t.is_empty() { d.remove(b"V"); @@ -527,5 +633,6 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result Document { + let mut doc = Document::new_empty(); + 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(); + doc +} + +#[test] +fn formats_validation_and_calculations_run_like_acrobat() { + use crate::af::{CalcOp, Calculate, Format, Validate}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (name, y) in [("Price", 700.0), ("Qty", 660.0), ("Total", 620.0), ("Count", 580.0), ("Due", 540.0)] { + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let money = Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }; + set_props( + &mut doc, + "Price", + &FieldProps { format: Some(money.clone()), validate: Some(Validate::Range { min: Some(0.0), max: None }), ..FieldProps::default() }, + ) + .unwrap(); + set_props( + &mut doc, + "Qty", + &FieldProps { + format: Some(Format::Number { decimals: 0, sep: 0, neg: 0, currency: String::new(), prepend: true }), + validate: Some(Validate::Range { min: Some(1.0), max: Some(99.0) }), + ..FieldProps::default() + }, + ) + .unwrap(); + set_props( + &mut doc, + "Total", + &FieldProps { + format: Some(money), + calculate: Some(Calculate::Notation("Price * Qty".into())), + read_only: Some(true), + ..FieldProps::default() + }, + ) + .unwrap(); + set_props( + &mut doc, + "Count", + &FieldProps { calculate: Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["Qty".into()] }), ..FieldProps::default() }, + ) + .unwrap(); + set_props(&mut doc, "Due", &FieldProps { format: Some(Format::Date("mmm d, yyyy".into())), ..FieldProps::default() }).unwrap(); + // The scripts are Acrobat's, so Acrobat (and we, after a save) read them back. + let doc2 = reopen(&doc); + let total = field(&fields(&doc2), "Total").clone(); + assert_eq!(total.actions.calculate, Calculate::Notation("Price * Qty".into())); + assert!(matches!(total.actions.format, Format::Number { decimals: 2, .. })); + let co = + doc2.get(doc2.root().unwrap()).as_dict().unwrap().get(b"AcroForm").cloned().map(|a| doc2.resolve(&a).as_dict().cloned().unwrap()).unwrap(); + assert_eq!(doc2.resolve(co.get(b"CO").unwrap()).as_array().unwrap().len(), 2, "both calculated fields are in the calculation order"); + let mut doc = doc2; + // Typing a formatted number stores the number and shows it formatted. + set_value(&mut doc, "Price", &FieldValue::Text("$1,234.5".into())).unwrap(); + set_value(&mut doc, "Qty", &FieldValue::Text("2".into())).unwrap(); + let all = fields(&doc); + assert_eq!(field(&all, "Price").value, ["1234.5"]); + assert!(ap(&doc, &field(&all, "Price").widgets[0]).contains("($1,234.50) Tj")); + // Calculations ran in order. + assert_eq!(field(&all, "Total").value, ["2469"]); + assert!(ap(&doc, &field(&all, "Total").widgets[0]).contains("($2,469.00) Tj")); + assert_eq!(field(&all, "Count").value, ["2"]); + // Keystroke and validation errors use Acrobat's messages and change nothing. + assert_eq!( + set_value(&mut doc, "Qty", &FieldValue::Text("lots".into())), + Err(FormError::Invalid("The value entered does not match the format of the field [ Qty ]".into())) + ); + assert_eq!( + set_value(&mut doc, "Qty", &FieldValue::Text("120".into())), + Err(FormError::Invalid("Invalid value: must be greater than or equal to 1 and less than or equal to 99.".into())) + ); + assert_eq!(field(&fields(&doc), "Qty").value, ["2"]); + // Dates: stored as typed, shown in the format; impossible dates are refused. + set_value(&mut doc, "Due", &FieldValue::Text("10/1/2026".into())).unwrap(); + assert!(ap(&doc, &field(&fields(&doc), "Due").widgets[0]).contains("(Oct 1, 2026) Tj")); + assert!(set_value(&mut doc, "Due", &FieldValue::Text("2/30/2026".into())).is_err()); + // Clear form recalculates. + reset(&mut doc, None).unwrap(); + assert_eq!(field(&fields(&doc), "Total").value, ["0"]); + // Removing a format. + set_props(&mut doc, "Price", &FieldProps { format: Some(Format::None), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 80563a881..206cb9a01 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -46,6 +46,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { ui.set_width(match dialog { Dialog::Properties(_) => 640.0, Dialog::Print => 820.0, + Dialog::FieldProps => 600.0, _ => 520.0, }); // Dialog controls are outlined (radio buttons, check boxes, combo boxes and number fields diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 3f984e582..8639a97a9 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -6,6 +6,7 @@ //! its name; clicks select fields instead of filling them in, as in Acrobat. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; +use printcraft_engine::form_scripts::{CalcOp, Calculate, DATE_PRESETS, Format, TIME_PRESETS, Validate, format_value}; use printcraft_engine::{Edit, FieldProps, FormField, FormFieldKind, NewField}; use printcraft_render::DocInfo; @@ -397,7 +398,14 @@ impl crate::PrintCraftApp { 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)); + let mut d = FieldDraft::new(&f, widget); + let others: Vec = + self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default(); + d.others = others.clone(); + if let Some(o) = d.original.as_mut() { + o.others = others; + } + self.field_props = Some(d); self.dialog = Some(crate::Dialog::FieldProps); } } @@ -408,6 +416,9 @@ pub enum FieldTab { Appearance, Position, Options, + Format, + Validate, + Calculate, } /// The Field Properties dialog's working copy. @@ -430,6 +441,11 @@ pub struct FieldDraft { pub font_size: f64, /// Left, bottom, width, height in points. pub position: [f64; 4], + pub format: Format, + pub validate: Validate, + pub calculate: Calculate, + /// Every other field's name (the Calculate tab picks from them). + pub others: Vec, original: Box>, } @@ -457,6 +473,10 @@ impl FieldDraft { new_option: String::new(), font_size: da_size(&f.da), position: [r[0], r[1], r[2] - r[0], r[3] - r[1]], + format: f.actions.format.clone(), + validate: f.actions.validate.clone(), + calculate: f.actions.calculate.clone(), + others: Vec::new(), original: Box::new(None), }; d.original = Box::new(Some(d.clone())); @@ -480,6 +500,9 @@ impl FieldDraft { if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List) { t.push((FieldTab::Options, "Options")); } + if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo) { + t.extend([(FieldTab::Format, "Format"), (FieldTab::Validate, "Validate"), (FieldTab::Calculate, "Calculate")]); + } t } @@ -500,6 +523,9 @@ impl FieldDraft { let [x, y, w, h] = self.position; (self.widget, [x, y, x + w.max(4.0), y + h.max(4.0)]) }), + 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()), }; (p != FieldProps::default()).then_some(p) } @@ -508,7 +534,7 @@ impl FieldDraft { /// 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.set_width(600.0); ui.label(egui::RichText::new(d.title()).font(theme::semibold(18.0))); ui.add_space(6.0); ui.horizontal(|ui| { @@ -566,6 +592,9 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke }); ui.label(egui::RichText::new("Points from the page's bottom-left corner.").small().color(t.text_faint)); } + FieldTab::Format => format_tab(ui, d, t), + FieldTab::Validate => validate_tab(ui, d), + FieldTab::Calculate => calculate_tab(ui, d, t), FieldTab::Options => match d.kind { FormFieldKind::Text => { ui.checkbox(&mut d.multiline, "Multi-line"); @@ -628,3 +657,266 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke }); (apply, cancel) } + +/// The categories of the Format tab. +#[derive(Clone, Copy, PartialEq)] +enum Category { + None, + Number, + Percent, + Date, + Time, + Special, +} + +fn category(f: &Format) -> Category { + match f { + Format::None => Category::None, + Format::Number { .. } => Category::Number, + Format::Percent { .. } => Category::Percent, + Format::Date(_) => Category::Date, + Format::Time(_) => Category::Time, + Format::Special(_) | Format::Mask(_) => Category::Special, + } +} + +const SEPARATORS: [&str; 5] = ["1,234.56", "1234.56", "1.234,56", "1234,56", "1'234.56"]; +const NEGATIVES: [&str; 4] = ["-1,234.01", "1,234.01 (red)", "(1,234.01)", "(1,234.01) (red)"]; +const SPECIALS: [&str; 5] = ["Zip Code", "Zip Code + 4", "Phone Number", "Social Security Number", "Arbitrary Mask"]; + +fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { + let cat = category(&d.format); + let mut picked = cat; + ui.horizontal(|ui| { + ui.label("Select format category:"); + egui::ComboBox::from_id_salt("format-cat") + .selected_text(match cat { + Category::None => "None", + Category::Number => "Number", + Category::Percent => "Percentage", + Category::Date => "Date", + Category::Time => "Time", + Category::Special => "Special", + }) + .show_ui(ui, |ui| { + for (c, l) in [ + (Category::None, "None"), + (Category::Number, "Number"), + (Category::Percent, "Percentage"), + (Category::Date, "Date"), + (Category::Time, "Time"), + (Category::Special, "Special"), + ] { + ui.selectable_value(&mut picked, c, l); + } + }); + }); + if picked != cat { + d.format = match picked { + Category::None => Format::None, + Category::Number => Format::Number { decimals: 2, sep: 0, neg: 0, currency: String::new(), prepend: true }, + Category::Percent => Format::Percent { decimals: 2, sep: 0 }, + Category::Date => Format::Date("mm/dd/yyyy".into()), + Category::Time => Format::Time("HH:MM".into()), + Category::Special => Format::Special(0), + }; + } + ui.add_space(8.0); + let sample = match &d.format { + Format::Number { .. } | Format::Percent { .. } => "-1234.5", + Format::Date(_) => "10/1/2026", + Format::Time(_) => "14:05", + Format::Special(2) => "5551234567", + Format::Special(3) => "123456789", + Format::Special(_) => "123456789", + _ => "", + }; + match &mut d.format { + Format::None => { + ui.label(egui::RichText::new("The value is shown as typed.").color(t.text_muted)); + } + Format::Number { decimals, sep, neg, currency, prepend } => { + egui::Grid::new("fmt-number").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label("Decimal places:"); + ui.add(egui::DragValue::new(decimals).range(0..=10)); + ui.end_row(); + ui.label("Separator style:"); + egui::ComboBox::from_id_salt("fmt-sep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { + for (i, s) in SEPARATORS.iter().enumerate() { + ui.selectable_value(sep, i as u8, *s); + } + }); + ui.end_row(); + ui.label("Currency symbol:"); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("fmt-cur") + .selected_text(if currency.is_empty() { "None" } else { currency.as_str() }) + .width(70.0) + .show_ui(ui, |ui| { + for c in ["", "$", "€", "£", "¥", "CHF "] { + ui.selectable_value(currency, c.to_string(), if c.is_empty() { "None" } else { c }); + } + }); + ui.checkbox(prepend, "Before the number"); + }); + ui.end_row(); + ui.label("Negative number style:"); + egui::ComboBox::from_id_salt("fmt-neg").selected_text(NEGATIVES[(*neg).min(3) as usize]).show_ui(ui, |ui| { + for (i, s) in NEGATIVES.iter().enumerate() { + ui.selectable_value(neg, i as u8, *s); + } + }); + ui.end_row(); + }); + } + Format::Percent { decimals, sep } => { + egui::Grid::new("fmt-pct").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label("Decimal places:"); + ui.add(egui::DragValue::new(decimals).range(0..=10)); + ui.end_row(); + ui.label("Separator style:"); + egui::ComboBox::from_id_salt("fmt-psep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { + for (i, s) in SEPARATORS.iter().enumerate() { + ui.selectable_value(sep, i as u8, *s); + } + }); + ui.end_row(); + }); + } + Format::Date(p) | Format::Time(p) => { + let presets: Vec<&str> = if matches!(cat, Category::Time) || picked == Category::Time { + TIME_PRESETS.to_vec() + } else { + DATE_PRESETS.iter().copied().chain(["m/d/yyyy", "mm/dd/yyyy", "yyyy-mm-dd", "dd/mm/yyyy"]).collect() + }; + ui.horizontal(|ui| { + ui.label("Format:"); + egui::ComboBox::from_id_salt("fmt-date").selected_text(p.as_str()).width(170.0).show_ui(ui, |ui| { + for pr in presets { + ui.selectable_value(p, pr.to_string(), pr); + } + }); + ui.label("Custom:"); + ui.add(egui::TextEdit::singleline(p).desired_width(120.0)); + }); + } + Format::Special(n) => { + let mut idx = *n as usize; + egui::ComboBox::from_id_salt("fmt-special").selected_text(SPECIALS[idx.min(4)]).show_ui(ui, |ui| { + for (i, s) in SPECIALS.iter().enumerate() { + ui.selectable_value(&mut idx, i, *s); + } + }); + if idx == 4 { + d.format = Format::Mask("999-999".into()); + } else { + *n = idx as u8; + } + } + Format::Mask(m) => { + let mut idx = 4usize; + egui::ComboBox::from_id_salt("fmt-special").selected_text(SPECIALS[4]).show_ui(ui, |ui| { + for (i, s) in SPECIALS.iter().enumerate() { + ui.selectable_value(&mut idx, i, *s); + } + }); + ui.horizontal(|ui| { + ui.label("Mask:"); + ui.add(egui::TextEdit::singleline(m).desired_width(160.0)); + }); + ui.label(egui::RichText::new("9 digit, A letter, O letter or digit, X any character").small().color(t.text_faint)); + if idx < 4 { + d.format = Format::Special(idx as u8); + } + } + } + if !sample.is_empty() && d.format != Format::None { + ui.add_space(6.0); + ui.label(egui::RichText::new(format!("Example: {}", format_value(&d.format, sample))).color(t.text_muted)); + } +} + +fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { + let mut ranged = matches!(d.validate, Validate::Range { .. }); + ui.radio_value(&mut ranged, false, "Field value is not validated"); + ui.radio_value(&mut ranged, true, "Field value is in range:"); + if !ranged { + d.validate = Validate::None; + return; + } + if d.validate == Validate::None { + d.validate = Validate::Range { min: Some(0.0), max: Some(100.0) }; + } + if let Validate::Range { min, max } = &mut d.validate { + ui.horizontal(|ui| { + let mut has = min.is_some(); + ui.checkbox(&mut has, "From:"); + let mut v = min.unwrap_or(0.0); + ui.add_enabled(has, egui::DragValue::new(&mut v).speed(1.0)); + *min = has.then_some(v); + let mut has = max.is_some(); + ui.checkbox(&mut has, "To:"); + let mut v = max.unwrap_or(100.0); + ui.add_enabled(has, egui::DragValue::new(&mut v).speed(1.0)); + *max = has.then_some(v); + }); + } +} + +fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { + #[derive(PartialEq, Clone, Copy)] + enum Mode { + None, + Simple, + Notation, + } + let mode = match d.calculate { + Calculate::None => Mode::None, + Calculate::Simple { .. } => Mode::Simple, + Calculate::Notation(_) => Mode::Notation, + }; + let mut m = mode; + ui.radio_value(&mut m, Mode::None, "Value is not calculated"); + ui.radio_value(&mut m, Mode::Simple, "Value is the"); + if m != mode { + d.calculate = match m { + Mode::None => Calculate::None, + Mode::Simple => Calculate::Simple { op: CalcOp::Sum, fields: Vec::new() }, + Mode::Notation => Calculate::Notation(String::new()), + }; + } + if let Calculate::Simple { op, fields } = &mut d.calculate { + ui.horizontal(|ui| { + ui.add_space(24.0); + egui::ComboBox::from_id_salt("calc-op").selected_text(op.label()).show_ui(ui, |ui| { + for o in CalcOp::ALL { + ui.selectable_value(op, o, o.label()); + } + }); + ui.label("of the following fields:"); + }); + egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { + for name in &d.others { + let mut on = fields.contains(name); + if ui.checkbox(&mut on, name).changed() { + if on { + fields.push(name.clone()); + } else { + fields.retain(|f| f != name); + } + } + } + }); + } + let mut m2 = match d.calculate { + Calculate::Notation(_) => Mode::Notation, + _ => m, + }; + if ui.radio_value(&mut m2, Mode::Notation, "Simplified field notation:").clicked() && !matches!(d.calculate, Calculate::Notation(_)) { + d.calculate = Calculate::Notation(String::new()); + } + if let Calculate::Notation(expr) = &mut d.calculate { + ui.add(egui::TextEdit::multiline(expr).hint_text("Price * Quantity").desired_rows(2).desired_width(420.0)); + ui.label(egui::RichText::new("Field names with + - * / and parentheses; put \\ before spaces in names.").small().color(t.text_faint)); + } +} diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index 7b773615a..aee7a818d 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -113,3 +113,34 @@ fn placing_moving_editing_and_deleting_a_field() { let r = rect_of(&h, "Check Box1"); assert_eq!((r[2] - r[0], r[3] - r[1]), (14.0, 14.0)); } + +#[test] +fn format_validate_and_calculate_tabs() { + use printcraft_engine::form_scripts::{Calculate, Format, Validate}; + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.state_mut().open_field_props("city", 0); + h.run_steps(2); + h.get_by_label("Format").click(); + h.run_steps(2); + h.get_by_label("Select format category:"); + { + let d = h.state_mut().field_props.as_mut().unwrap(); + d.format = Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }; + d.validate = Validate::Range { min: Some(0.0), max: None }; + d.calculate = Calculate::Notation("name".into()); + } + h.run_steps(2); + h.get_by_label_contains("Example: -$1,234.50"); + h.get_by_label("Calculate").click(); + h.run_steps(2); + h.get_by_label("Simplified field notation:"); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().clone(); + assert!(matches!(f.actions.format, Format::Number { decimals: 2, .. })); + assert_eq!(f.actions.validate, Validate::Range { min: Some(0.0), max: None }); + assert_eq!(f.actions.calculate, Calculate::Notation("name".into())); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ef300cac8..7b9ef37ae 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4383,11 +4383,11 @@ title = "Number and currency formatting (AFNumber)" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] +notes = "AFNumber_Format/Keystroke run natively: decimals, 5 separator styles, currency before/after, negative styles (red shown as minus/parentheses)" [[feature]] id = "form.af-percent" @@ -4395,11 +4395,11 @@ title = "Percentage formatting" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat"] +notes = "AFPercent_Format/Keystroke" [[feature]] id = "form.af-date-time" @@ -4407,11 +4407,11 @@ title = "Date and time formatting and parsing" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/forms/src/af.rs::dates_times_and_masks", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] +notes = "AFDate_FormatEx/KeystrokeEx, AFDate_Format presets, AFTime_Format(Ex): lenient parsing, month and day names, impossible dates refused" [[feature]] id = "form.af-special" @@ -4419,11 +4419,11 @@ title = "Special formats (zip, phone, SSN, arbitrary mask)" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/forms/src/af.rs::dates_times_and_masks"] +notes = "zip, zip+4, phone, SSN, arbitrary masks (9 A O X)" [[feature]] id = "form.af-range-validate" @@ -4431,11 +4431,11 @@ title = "Range validation" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] +notes = "AFRange_Validate with Acrobat's messages" [[feature]] id = "form.af-simple-calculate" @@ -4443,11 +4443,11 @@ title = "Sum, product, average, min, max calculations" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] +notes = "SUM, PRD, AVG, MIN, MAX over fields and groups" [[feature]] id = "form.simplified-notation" @@ -4455,11 +4455,11 @@ title = "Simplified field notation calculations" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] +notes = "BVCALC expressions with + - * / and parentheses, escaped names" [[feature]] id = "form.custom-scripts" @@ -4479,11 +4479,11 @@ title = "Calculation order" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] +notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form" [[feature]] id = "form.event-order" @@ -4491,11 +4491,11 @@ title = "Keystroke, validate, calculate, format event order" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] +notes = "keystroke (on commit) → validate → calculate → format, natively for the AF functions; custom scripts need the JavaScript engine" [[feature]] id = "form.date-picker" @@ -4815,11 +4815,11 @@ title = "Field properties: format" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] +notes = "None/Number/Percentage/Date/Time/Special with a live example; writes Acrobat's AF scripts" [[feature]] id = "form.props-validate" @@ -4827,11 +4827,11 @@ title = "Field properties: validate" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] +notes = "range from/to" [[feature]] id = "form.props-calculate" @@ -4839,11 +4839,11 @@ title = "Field properties: calculate" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.field.properties"] +tools = ["form_set_props", "form_fill"] +evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] +notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending" [[feature]] id = "form.props-selection-change"