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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 01:07:37 -04:00
co-authored by Claude Opus 5.5
parent 0d21de431a
commit 24f5768be4
13 changed files with 1807 additions and 65 deletions
+110
View File
@@ -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<String>) -> 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<Format> {
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<Validate> {
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<Calculate> {
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<String> =
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::<Vec<_>>()));
@@ -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()));
+4 -1
View File
@@ -236,7 +236,7 @@ pub fn tools() -> Vec<ToolDef> {
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<ToolDef> {
"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"],
)),
+32
View File
@@ -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(_))
));
}
+1 -1
View File
@@ -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.
+983
View File
@@ -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<f64>, max: Option<f64> },
}
#[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<String> },
/// 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<Arg>),
}
impl Arg {
fn num(&self) -> Option<f64> {
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<String> {
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<String> {
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<Arg> {
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<Vec<Arg>> {
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<Vec<Arg>> {
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<Format> {
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<Validate> {
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<Calculate> {
// 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<String> {
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<String> {
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::<Vec<_>>().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<f64> {
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<char> {
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<DateTime> {
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<u32> = Vec::new();
let mut month_name: Option<u32> = None;
let mut cur = String::new();
let flush = |cur: &mut String, nums: &mut Vec<u32>, month: &mut Option<u32>| {
if cur.is_empty() {
return;
}
if let Ok(n) = cur.parse::<u32>() {
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<char> = 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<char> = text.chars().collect();
let m: Vec<char> = 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<String> {
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<String, String> {
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<f64> {
#[derive(Debug, Clone, PartialEq)]
enum T {
N(f64),
Op(char),
}
let mut toks = Vec::new();
let cs: Vec<char> = 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::<String>().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<f64> {
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<f64> {
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<f64> {
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<String>) -> Option<String> {
let num = |s: &String| make_number(s).unwrap_or(0.0);
match c {
Calculate::None => None,
Calculate::Simple { op, fields } => {
let xs: Vec<f64> = 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::<f64>() / 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<String> {
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);
}
}
+9
View File
@@ -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<String>;
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));
+74
View File
@@ -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<crate::af::Format>,
pub validate: Option<crate::af::Validate>,
pub calculate: Option<crate::af::Calculate>,
}
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
@@ -469,6 +473,73 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?;
}
if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() {
if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) {
return invalid("only text fields and drop-down lists have format, validate and calculate scripts");
}
let mut aa = doc
.get(f.obj)
.as_dict()
.and_then(|d| d.get(b"AA").cloned())
.map(|a| doc.resolve(&a))
.and_then(|a| a.as_dict().cloned())
.unwrap_or_default();
let action = |js: &str| -> 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<Object> = 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<S
}
}
redraw_field(doc, &new_name)?;
if props.calculate.is_some() {
crate::recalculate(doc)?;
}
Ok(new_name)
}
+108 -1
View File
@@ -13,6 +13,7 @@
use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
pub mod af;
pub mod appearance;
mod author;
pub use author::{FieldProps, NewField, add_field, delete_field, redraw_field, set_props};
@@ -97,6 +98,8 @@ pub struct Field {
pub quadding: i64,
pub tooltip: Option<String>,
pub widgets: Vec<Widget>,
/// 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<String> {
}
}
/// The JavaScript of an action dictionary (`/JS` string or stream).
fn script(doc: &Document, action: &Object) -> Option<String> {
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<String> {
let Some(o) = o else { return Vec::new() };
match &*doc.resolve(o) {
@@ -280,6 +341,7 @@ fn walk(
return;
}
let widget_refs: Vec<ObjRef> = 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<usize, FormError> {
let all = fields(doc);
if !all.iter().any(|f| f.actions.calculate != af::Calculate::None) {
return Ok(0);
}
let co: Vec<ObjRef> = 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<String> = 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<String> {
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<T>(m: impl Into<String>) -> Result<T, FormError> {
@@ -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<usize, Form
write_value(doc, &tmp, &v)?;
changed += 1;
}
recalculate(doc)?;
Ok(changed)
}
+100
View File
@@ -320,3 +320,103 @@ fn deleting_works_with_a_form_dictionary_inside_the_catalog() {
assert_eq!(all.len(), before - 1);
assert!(!all.iter().any(|f| f.name == "name"));
}
fn one_page() -> 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);
}
+1
View File
@@ -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
+294 -2
View File
@@ -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<String> =
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<String>,
original: Box<Option<FieldDraft>>,
}
@@ -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));
}
}
+31
View File
@@ -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()));
}
+60 -60
View File
@@ -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"