use super::calc::{js_tokens, notation_js, notation_range}; use super::format::{DATE_PRESETS, TIME_PRESETS}; use super::{CalcOp, Calculate, Format, Validate}; // ── parsing scripts ───────────────────────────────────────────────────────────────────────── #[derive(Clone, Debug, PartialEq)] pub(super) enum Arg { Num(f64), Str(String), Bool(bool), List(Vec), } impl Arg { fn num(&self) -> Option { match self { Arg::Num(n) => Some(*n), Arg::Bool(b) => Some(f64::from(u8::from(*b))), Arg::Str(s) => s.trim().parse().ok(), Arg::List(_) => None, } } pub(super) fn string(&self) -> Option { match self { Arg::Str(s) => Some(s.clone()), Arg::Num(n) => Some(n.to_string()), _ => None, } } fn truthy(&self) -> bool { match self { Arg::Bool(b) => *b, Arg::Num(n) => *n != 0.0, Arg::Str(s) => !s.is_empty(), Arg::List(l) => !l.is_empty(), } } } /// How deeply script arguments and field notation may nest; deeper input is rejected rather /// than recursed into (a hostile file could otherwise overflow the stack). pub(super) const MAX_NESTING: usize = 64; fn hex4(bytes: &[u8]) -> Option { if bytes.len() != 4 { return None; } bytes.iter().try_fold(0u16, |value, byte| { let digit = match byte { b'0'..=b'9' => byte - b'0', b'a'..=b'f' => byte - b'a' + 10, b'A'..=b'F' => byte - b'A' + 10, _ => return None, }; value.checked_mul(16)?.checked_add(u16::from(digit)) }) } pub(super) struct Parser<'a> { pub(super) s: &'a [u8], pub(super) i: usize, pub(super) depth: usize, } impl Parser<'_> { fn ws(&mut self) { while self.i < self.s.len() && (self.s[self.i].is_ascii_whitespace()) { self.i += 1; } } pub(super) fn string(&mut self) -> Option { let q = self.s[self.i]; self.i += 1; let mut out = Vec::new(); while self.i < self.s.len() { let c = self.s[self.i]; self.i += 1; match c { b'\\' if self.i < self.s.len() => { let e = self.s[self.i]; self.i += 1; match e { b'n' => out.push(b'\n'), b'r' => out.push(b'\r'), b't' => out.push(b'\t'), b'b' => out.push(0x08), b'f' => out.push(0x0C), b'v' => out.push(0x0B), b'0' => out.push(0), b'x' => { let digits = self.s.get(self.i..self.i.checked_add(2)?)?; let code = hex4(&[b'0', b'0', digits[0], digits[1]])?; self.i += 2; let ch = char::from_u32(u32::from(code)).unwrap_or('\u{FFFD}'); let mut encoded = [0u8; 4]; out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); } b'u' => { let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; self.i += 4; let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; if (0xDC00..=0xDFFF).contains(&low) { self.i += 6; 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) } else { u32::from(code) } } else { u32::from(code) }; let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); let mut encoded = [0u8; 4]; out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); } other => out.push(other), } } c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()), c => out.push(c), } } None } fn arg(&mut self) -> Option { self.ws(); let c = *self.s.get(self.i)?; match c { b'"' | b'\'' => self.string().map(Arg::Str), b'[' => { self.i += 1; self.nested(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.nested(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) } } } /// A list inside another, at most [`MAX_NESTING`] deep. fn nested(&mut self, close: u8) -> Option> { if self.depth >= MAX_NESTING { return None; } self.depth += 1; let out = self.list(close); self.depth -= 1; out } fn list(&mut self, close: u8) -> Option> { let mut out = Vec::new(); loop { self.ws(); if self.s.get(self.i) == Some(&close) { self.i += 1; return Some(out); } out.push(self.arg()?); self.ws(); match self.s.get(self.i) { Some(b',') => self.i += 1, Some(c) if *c == close => { self.i += 1; return Some(out); } _ => return None, } } } } /// The first call to `name(` in `js` and its arguments. pub(super) fn call(js: &str, name: &str) -> Option> { let at = js.find(&format!("{name}("))?; // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { return None; } let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; p.list(b')') } pub fn parse_format(js: &str) -> Option { let a = |args: &[Arg], i: usize| args.get(i).and_then(Arg::num).unwrap_or(0.0).clamp(0.0, 255.0) as u8; if let Some(args) = call(js, "AFNumber_Format") { return Some(Format::Number { decimals: a(&args, 0), sep: a(&args, 1), neg: a(&args, 2), currency: args.get(4).and_then(Arg::string).unwrap_or_default(), prepend: args.get(5).is_none_or(Arg::truthy), }); } if let Some(args) = call(js, "AFPercent_Format") { return Some(Format::Percent { decimals: a(&args, 0), sep: a(&args, 1) }); } if let Some(args) = call(js, "AFDate_FormatEx") { return Some(Format::Date(args.first().and_then(Arg::string)?)); } if let Some(args) = call(js, "AFDate_Format") { return Some(Format::Date(DATE_PRESETS.get(a(&args, 0) as usize)?.to_string())); } if let Some(args) = call(js, "AFTime_FormatEx") { return Some(Format::Time(args.first().and_then(Arg::string)?)); } if let Some(args) = call(js, "AFTime_Format") { return Some(Format::Time(TIME_PRESETS.get(a(&args, 0) as usize)?.to_string())); } if let Some(args) = call(js, "AFSpecial_Format") { return Some(Format::Special(a(&args, 0).min(3))); } if let Some(args) = call(js, "AFSpecial_KeystrokeEx") { return Some(Format::Mask(args.first().and_then(Arg::string)?)); } None } pub fn parse_validate(js: &str) -> Option { let args = call(js, "AFRange_Validate")?; let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); let num = |i: usize| args.get(i).and_then(Arg::num); Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) } pub fn parse_calculate(js: &str) -> Option { // Simplified field notation: Acrobat keeps the expression in a comment. A script is read for // its comments only when it spells the marker. if js.contains("BVCALC") && let Some(range) = notation_range(js, &js_tokens(js).1) { return Some(Calculate::Notation(js.get(range)?.trim().to_string())); } 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) ───────────────────────────────────────── pub(super) 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"), '\r' => o.push_str("\\r"), '\t' => o.push_str("\\t"), c if c.is_ascii() && !c.is_control() => o.push(c), c => { let mut units = [0u16; 2]; for unit in c.encode_utf16(&mut units) { o.push_str(&format!("\\u{unit:04X}")); } } } } o.push('"'); o } /// The (format, keystroke) scripts for a format. pub fn format_js(f: &Format) -> Option<(String, String)> { Some(match f { Format::None => return None, Format::Number { decimals, sep, neg, currency, prepend } => { let args = format!("{decimals}, {sep}, {neg}, 0, {}, {prepend}", js_str(currency)); (format!("AFNumber_Format({args});"), format!("AFNumber_Keystroke({args});")) } Format::Percent { decimals, sep } => (format!("AFPercent_Format({decimals}, {sep});"), format!("AFPercent_Keystroke({decimals}, {sep});")), Format::Date(f) => (format!("AFDate_FormatEx({});", js_str(f)), format!("AFDate_KeystrokeEx({});", js_str(f))), Format::Time(f) => (format!("AFTime_FormatEx({});", js_str(f)), format!("AFTime_KeystrokeEx({});", js_str(f))), Format::Special(n) => (format!("AFSpecial_Format({n});"), format!("AFSpecial_Keystroke({n});")), Format::Mask(m) => (String::new(), format!("AFSpecial_KeystrokeEx({});", js_str(m))), }) } pub fn validate_js(v: &Validate) -> Option { match v { Validate::None => None, Validate::Range { min, max } => { Some(format!("AFRange_Validate({}, {}, {}, {});", min.is_some(), min.unwrap_or(0.0), max.is_some(), max.unwrap_or(0.0))) } } } pub fn calculate_js(c: &Calculate) -> Option { match c { Calculate::None => None, Calculate::Simple { op, fields } => Some(format!( "AFSimple_Calculate({}, new Array({}));", js_str(op.code()), fields.iter().map(|f| js_str(f)).collect::>().join(", ") )), // Acrobat's own translation follows the comment, so a viewer that runs the script instead // of reading the comment computes the same value (a placeholder here showed 0). Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), } }