mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
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:
co-authored by
Claude Opus 5.5
parent
0d21de431a
commit
24f5768be4
@@ -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()));
|
||||
|
||||
@@ -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"],
|
||||
)),
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user