mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 00:41:21 +00:00
485 lines
20 KiB
Rust
485 lines
20 KiB
Rust
use super::calc::{js_tokens, notation_js, notation_range, notation_tokens};
|
|
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<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::<f64>().ok().filter(|n| n.is_finite()),
|
|
Arg::List(_) => None,
|
|
}
|
|
}
|
|
|
|
pub(super) 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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<u16> {
|
|
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<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;
|
|
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' if self.s.get(self.i) == Some(&b'{') => {
|
|
self.i += 1;
|
|
let start = self.i;
|
|
let mut code = 0u32;
|
|
while let Some(&digit) = self.s.get(self.i).filter(|&&b| b != b'}') {
|
|
code = code.checked_mul(16)?.checked_add(char::from(digit).to_digit(16)?)?;
|
|
if code > 0x10FFFF {
|
|
return None;
|
|
}
|
|
self.i += 1;
|
|
}
|
|
if self.i == start || self.s.get(self.i) != Some(&b'}') {
|
|
return None;
|
|
}
|
|
self.i += 1;
|
|
// Match the existing handling of unpaired UTF-16 surrogates.
|
|
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());
|
|
}
|
|
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)
|
|
&& let Some(pair) = self.i.checked_add(6).and_then(|end| self.s.get(self.i..end))
|
|
&& pair.starts_with(b"\\u")
|
|
&& let Some(low) = hex4(&pair[2..6]).filter(|low| (0xDC00..=0xDFFF).contains(low))
|
|
{
|
|
self.i += 6;
|
|
0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00)
|
|
} 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<Arg> {
|
|
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::<f64>().ok().filter(|n| n.is_finite()).map(Arg::Num)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A list inside another, at most [`MAX_NESTING`] deep.
|
|
fn nested(&mut self, close: u8) -> Option<Vec<Arg>> {
|
|
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<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 genuine call to `name(` in `js` and its arguments.
|
|
/// An unsupported first call returns `None`, preserving the custom-script fallback; later calls
|
|
/// must not substitute a different native rule. Comments, strings and unrelated identifiers are skipped.
|
|
pub(super) fn call(js: &str, name: &str) -> Option<Vec<Arg>> {
|
|
let (tokens, _) = js_tokens(js);
|
|
for (k, (range, tok)) in tokens.iter().enumerate() {
|
|
if *tok != super::calc::Js::Ident {
|
|
continue;
|
|
}
|
|
if k > 0 && matches!(tokens.get(k - 1), Some((_, super::calc::Js::Punct('.')))) {
|
|
continue;
|
|
}
|
|
let Some(target_end) = range.start.checked_add(name.len()) else { continue };
|
|
if js.get(range.start..target_end) != Some(name) {
|
|
continue;
|
|
}
|
|
let Some(end_k) = (k..tokens.len()).take_while(|&m| tokens[m].0.end <= target_end).find(|&m| tokens[m].0.end == target_end) else {
|
|
continue;
|
|
};
|
|
let Some((open, super::calc::Js::Punct('('))) = tokens.get(end_k + 1) else { continue };
|
|
let mut p = Parser { s: js.as_bytes(), i: open.end, depth: 0 };
|
|
return p.list(b')');
|
|
}
|
|
None
|
|
}
|
|
|
|
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);
|
|
// An enabled but unparseable bound is unsupported, not an absent bound.
|
|
Some(Validate::Range { min: if flag(0) { Some(num(1)?) } else { None }, max: if flag(2) { Some(num(3)?) } else { None } })
|
|
}
|
|
|
|
pub fn parse_calculate(js: &str) -> Option<Calculate> {
|
|
// 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(notation_text(js.get(range)?).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<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)))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why [`notation_storable`] refuses a notation, in the words the Calculate tab and the tools say it.
|
|
pub const NOTATION_NOT_STORABLE: &str = "the notation can't be stored as written: it is empty, or holds EVCALC or */, or ends in a lone \\";
|
|
|
|
/// Whether simplified field notation `expr` can be stored: the script keeps it in a comment,
|
|
/// between `BVCALC` and `EVCALC`, and it has to read back from there as the same names and
|
|
/// operators. One holding `EVCALC` or `*/` ends early, and one ending in a lone `\` escapes the
|
|
/// space after it. An empty one is refused too: it would be stored as `event.value = ;`.
|
|
pub fn notation_storable(expr: &str) -> bool {
|
|
let tokens = |s: &str| notation_tokens(s).into_iter().map(|(range, _)| s.get(range).unwrap_or_default().to_owned()).collect::<Vec<_>>();
|
|
let read = calculate_js(&Calculate::Notation(expr.to_owned())).and_then(|js| parse_calculate(&js));
|
|
!tokens(expr).is_empty() && matches!(read, Some(Calculate::Notation(n)) if tokens(&n) == tokens(expr))
|
|
}
|
|
|
|
/// `src` from its first notation token to its last: the spaces around it left out, but not an
|
|
/// escaped space that ends the last name (`Total\ `), as `str::trim` would.
|
|
fn notation_text(src: &str) -> &str {
|
|
let tokens = notation_tokens(src);
|
|
match (tokens.first(), tokens.last()) {
|
|
(Some((first, _)), Some((last, _))) => src.get(first.start..last.end).unwrap_or_default(),
|
|
_ => "",
|
|
}
|
|
}
|
|
|
|
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(", ")
|
|
)),
|
|
// 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))),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn unsupported_first_validation_call_does_not_select_a_later_rule() {
|
|
let js = "if (event.value < 50) AFRange_Validate(true, 10 + 5, false, 0); else AFRange_Validate(false, 0, true, 100);";
|
|
assert_eq!(parse_validate(js), None);
|
|
assert_eq!(
|
|
parse_validate("AFRange_Validate(true, 15, false, 0); AFRange_Validate(false, 0, true, 100);"),
|
|
Some(Validate::Range { min: Some(15.0), max: None })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validation_call_still_skips_non_calls_before_literal_arguments() {
|
|
let js = r#"// AFRange_Validate(true, 10 + 5, false, 0)
|
|
"AFRange_Validate(true, 10 + 5, false, 0)";
|
|
my_AFRange_Validate(true, 10 + 5, false, 0);
|
|
other.AFRange_Validate(true, 10 + 5, false, 0);
|
|
AFRange_Validate(false, 0, true, 100);"#;
|
|
assert_eq!(parse_validate(js), Some(Validate::Range { min: None, max: Some(100.0) }));
|
|
}
|
|
|
|
#[test]
|
|
fn unpaired_high_surrogate_at_end_of_string_does_not_abort_parsing() {
|
|
let js = "AFDate_FormatEx(\"\\uD800\")";
|
|
assert_eq!(parse_format(js), Some(Format::Date("\u{FFFD}".to_owned())));
|
|
}
|
|
|
|
#[test]
|
|
fn call_skips_commented_or_prefixed_identifiers_and_rejects_infinite_numbers() {
|
|
let js = "// AFPercent_Format(0, 0);\nmy_AFPercent_Format(0, 0); $AFPercent_Format(0, 0); AFPercent_Format (2, 1);";
|
|
assert_eq!(parse_format(js), Some(Format::Percent { decimals: 2, sep: 1 }));
|
|
|
|
assert_eq!(parse_validate("AFRange_Validate(true, 1e500, false, 0);"), None);
|
|
}
|
|
#[test]
|
|
fn review_enabled_nonfinite_bound_is_unsupported() {
|
|
for script in [r#"AFRange_Validate(true, "1e500", false, 0)"#, r#"AFRange_Validate(false, 0, true, "-1e500")"#] {
|
|
assert_eq!(parse_validate(script), None);
|
|
}
|
|
assert_eq!(parse_validate(r#"AFRange_Validate(false, "1e500", false, "NaN")"#), Some(Validate::Range { min: None, max: None }));
|
|
}
|
|
|
|
#[test]
|
|
fn review_format_after_postfix_division() {
|
|
for op in ["++", "--"] {
|
|
let js = format!("var y = x{op} / 2; AFPercent_Format(2, 1); var z = 1 / 2;");
|
|
assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 }));
|
|
let commented = format!("var y = x/* comment */{op} / 2; AFPercent_Format(2, 1); var z = 1 / 2;");
|
|
assert_eq!(parse_format(&commented), Some(Format::Percent { decimals: 2, sep: 1 }));
|
|
}
|
|
assert_eq!(parse_format("var of = 8; of / 2; AFPercent_Format(2, 1); var z = 1 / 2;"), Some(Format::Percent { decimals: 2, sep: 1 }));
|
|
assert_eq!(
|
|
parse_format("for (var x of /AFPercent_Format(0, 0)/g) {} AFPercent_Format(2, 1);"),
|
|
Some(Format::Percent { decimals: 2, sep: 1 })
|
|
);
|
|
// A newline makes ++ a prefix operator; the following regex must remain opaque.
|
|
for js in ["x\n++/AFPercent_Format(2, 1)/.lastIndex;", "x/*\n*/++/AFPercent_Format(2, 1)/.lastIndex;"] {
|
|
assert_eq!(parse_format(js), None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn review_format_after_brace_unicode() {
|
|
assert_eq!(parse_format(r#"var s = "\u{1F600}"; AFPercent_Format(2, 1);"#), Some(Format::Percent { decimals: 2, sep: 1 }));
|
|
assert_eq!(parse_format(r#"AFDate_FormatEx("\u{1F600}")"#), Some(Format::Date("😀".to_owned())));
|
|
assert_eq!(parse_format(r#"AFDate_FormatEx("\u{00000041}\u0042\uD83D\uDE00")"#), Some(Format::Date("AB😀".to_owned())));
|
|
for escape in ["\\u{}", "\\u{110000}", "\\u{xyz}", "\\u{41"] {
|
|
assert_eq!(parse_format(&format!("AFDate_FormatEx(\"{escape}\")")), None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn review_notation_separates_adjacent_operators() {
|
|
let field = |name| format!("AFMakeNumber(this.getField(\"{name}\").value)");
|
|
for (expr, operators) in [("a++b", "+ +"), ("a--b", "- -"), ("a+++b", "+ + +")] {
|
|
let js = calculate_js(&Calculate::Notation(expr.to_owned())).unwrap();
|
|
let executable = js.split("event.value = ").nth(1).unwrap();
|
|
assert_eq!(executable, format!("{}{operators}{};", field("a"), field("b")));
|
|
assert_eq!(super::super::calc::calculate(&Calculate::Notation(expr.to_owned()), &|_| vec!["2".to_owned()]), Some("4".to_owned()));
|
|
}
|
|
}
|
|
}
|