mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge upstream/main into fix/xfa-open-scripts-complete
Main's DataOp::Value now replaces a multi-select list's value-group picks in the packet and in the data copy, so the reset writes through it and the separate DataOp::Reset (and Want::picks) is dropped. The reset keeps the PR's indexed targets and its pass over fields without widgets, beside main's floating-field pass (which the unshown pass leaves embedded fields to), and finish() lays out again for either an owed relayout or a changed embedded value. Parity notes and evidence take both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
commit
f996aef12f
654 files changed
+99220
-5792
No files matched your search
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "pdfcraft-js-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
# Detached from the PdfCraft workspace: run with `cargo +nightly fuzz run run_script`
|
||||
# from crates/js/.
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
pdfcraft-js = { path = ".." }
|
||||
|
||||
[[bin]]
|
||||
name = "run_script"
|
||||
path = "fuzz_targets/run_script.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Feeds arbitrary bytes, read as a script, to the JavaScript run entry with fixed event and document values.
|
||||
//!
|
||||
//! The run's result is discarded: the target only checks that no input panics or hangs past the limits.
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use pdfcraft_js::{DocInfo, Event, Limits, run};
|
||||
|
||||
/// The largest script the target runs (64 KiB); larger inputs are skipped.
|
||||
const MAX_INPUT: usize = 64 << 10;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if data.len() > MAX_INPUT {
|
||||
return;
|
||||
}
|
||||
let script = String::from_utf8_lossy(data).into_owned();
|
||||
let event = Event {
|
||||
name: "Keystroke".into(),
|
||||
kind: "Field".into(),
|
||||
target: Some("field1".into()),
|
||||
value: "value".into(),
|
||||
change: String::new(),
|
||||
will_commit: true,
|
||||
};
|
||||
let _outcome = run(&script, &event, &DocInfo::default(), &[], &[], Limits::default());
|
||||
});
|
||||
@@ -94,6 +94,36 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
}
|
||||
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
|
||||
};
|
||||
let rect_get = getter!(ctx, n, |f, c| match f.rect {
|
||||
Some(rect) => JsArray::from_iter(rect.into_iter().map(JsValue::from).collect::<Vec<_>>(), c).into(),
|
||||
None => JsValue::null(),
|
||||
});
|
||||
let rect_set = {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
let value = arg(args, 0);
|
||||
let object = value.as_object().filter(|o| o.is_array()).ok_or_else(|| error("rect must be an array of four finite numbers"))?;
|
||||
let array = JsArray::from_object(object.clone())?;
|
||||
if array.length(c)? != 4 {
|
||||
return Err(error("rect must be an array of four finite numbers"));
|
||||
}
|
||||
let mut rect = [0.0; 4];
|
||||
for (i, coordinate) in rect.iter_mut().enumerate() {
|
||||
*coordinate = array.get(i as u64, c)?.to_number(c)?;
|
||||
}
|
||||
if !rect.iter().all(|coordinate| coordinate.is_finite()) {
|
||||
return Err(error("rect coordinates must be finite"));
|
||||
}
|
||||
let width = (rect[2] - rect[0]).abs();
|
||||
let height = (rect[3] - rect[1]).abs();
|
||||
if !width.is_finite() || !height.is_finite() || width < 4.0 || height < 4.0 {
|
||||
return Err(error("rect must be at least 4 points wide and high"));
|
||||
}
|
||||
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
|
||||
write(c, name, |f| f.rect = Some(rect))?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
|
||||
};
|
||||
|
||||
fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
let f = read(c, name)?;
|
||||
@@ -150,6 +180,7 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
.accessor(js_string!("currentValueIndices"), Some(indices), None, rw)
|
||||
.accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw)
|
||||
.accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw)
|
||||
.accessor(js_string!("rect"), Some(rect_get), Some(rect_set), rw)
|
||||
.function(get_item_at, js_string!("getItemAt"), 2)
|
||||
.function(is_box_checked, js_string!("isBoxChecked"), 1)
|
||||
.function(check_this_box, js_string!("checkThisBox"), 2)
|
||||
|
||||
+186
-9
@@ -134,6 +134,177 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun
|
||||
|
||||
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
/// Month and weekday names (weekdays from Sunday): full, then short.
|
||||
type Names = ([&'static str; 12], [&'static str; 12], [&'static str; 7], [&'static str; 7]);
|
||||
|
||||
const ENGLISH: Names = (
|
||||
MONTHS,
|
||||
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
DAYS,
|
||||
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||
);
|
||||
|
||||
static NAMES: std::sync::RwLock<Names> = std::sync::RwLock::new(ENGLISH);
|
||||
|
||||
/// Sets the month and weekday names `util.printd` writes for `mmmm`, `mmm`, `dddd` and `ddd`
|
||||
/// (weekdays from Sunday), as a localized Acrobat writes its own language's (#5303).
|
||||
/// `util.scand` reads them as well as English.
|
||||
pub fn set_date_names(months: [&'static str; 12], months_short: [&'static str; 12], days: [&'static str; 7], days_short: [&'static str; 7]) {
|
||||
*NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = (months, months_short, days, days_short);
|
||||
}
|
||||
|
||||
/// `text` with the month and weekday names of `lists` (in the order months, short months,
|
||||
/// weekdays, short weekdays) written as their English names, so a parser that reads English
|
||||
/// reads them. The longest name wins at each place, case ignored, and only a whole name: one
|
||||
/// that doesn't continue a word, nor end in a digit that a number continues. A name may hold
|
||||
/// digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs, which a
|
||||
/// word-by-word reader would split (#5303). Where a weekday and a month share a name (Italian
|
||||
/// "mar"), the order of the format's name fields (`mmm`, `ddd`) decides. English names are
|
||||
/// found too, so that one typed ("Tue" before "Mar") fills its field of the format as a
|
||||
/// localized one does.
|
||||
fn english_names(text: &str, fmt: &str, lists: [&[&str]; 4]) -> String {
|
||||
const EN: [&[&str]; 4] = [&MONTHS, &MONTHS, &DAYS, &DAYS];
|
||||
// No date is this long: hostile text is left as it is rather than scanned name by name.
|
||||
if text.len() > MAX_DATE_TEXT_BYTES {
|
||||
return text.to_string();
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let typed_english: [&[&str]; 4] = [&ENGLISH.0, &ENGLISH.1, &ENGLISH.2, &ENGLISH.3];
|
||||
// Each name as folded chars, whether it is a month, and its English name.
|
||||
let mut names: Vec<(Vec<String>, bool, &str)> = Vec::new();
|
||||
for (k, (list, en)) in lists.into_iter().zip(EN).chain(typed_english.into_iter().zip(EN)).enumerate() {
|
||||
for (name, english) in list.iter().zip(en.iter()) {
|
||||
// A short name ending in a dot ("mar.") is also read typed without it, as the same
|
||||
// kind of name, so that where it equals another ("Mar", March) the format decides.
|
||||
let bare = name.trim_end_matches('.');
|
||||
for name in std::iter::once(*name).chain((bare.len() < name.len()).then_some(bare)) {
|
||||
let n: Vec<String> = name.chars().map(fold).collect();
|
||||
if !n.is_empty() && n.len() <= MAX_NAME_CHARS {
|
||||
names.push((n, k % 4 < 2, english));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort_by_key(|(n, ..)| std::cmp::Reverse(n.len()));
|
||||
// The format's name fields in order: true for a month, false for a weekday.
|
||||
let f: Vec<char> = fmt.chars().collect();
|
||||
let mut expected = Vec::new();
|
||||
let mut j = 0;
|
||||
while let Some(&c) = f.get(j) {
|
||||
let run = f.get(j..).map_or(1, |r| r.iter().take_while(|x| **x == c).count()).max(1);
|
||||
if run >= 3 && (c == 'm' || c == 'd') {
|
||||
expected.push(c == 'm');
|
||||
}
|
||||
j = j.saturating_add(run);
|
||||
}
|
||||
let mut slot = 0;
|
||||
// A name's first letter may not continue a word; its last may not run on into a letter, nor
|
||||
// (a digit) into a number.
|
||||
let letter = |c: Option<&char>| c.is_some_and(|c| c.is_alphabetic());
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
let fits = |n: &Vec<String>| {
|
||||
let end = i.saturating_add(n.len());
|
||||
let first = n.first().and_then(|e| e.chars().next());
|
||||
let last = n.last().and_then(|e| e.chars().next());
|
||||
chars.get(i..end).is_some_and(|t| t.iter().map(|c| fold(*c)).eq(n.iter().cloned()))
|
||||
&& !(first.is_some_and(char::is_alphabetic) && letter(i.checked_sub(1).and_then(|p| chars.get(p))))
|
||||
&& !match last {
|
||||
Some(l) if l.is_ascii_digit() => chars.get(end).is_some_and(char::is_ascii_digit),
|
||||
Some(l) if l.is_alphabetic() => letter(chars.get(end)),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
let longest = names.iter().find(|(n, ..)| fits(n)).map(|(n, ..)| n.len());
|
||||
let hit = longest.and_then(|len| {
|
||||
let mut same = names.iter().filter(|(n, ..)| n.len() == len && fits(n));
|
||||
let want = expected.get(slot).copied();
|
||||
let all: Vec<_> = same.by_ref().collect();
|
||||
all.iter().find(|(_, month, _)| Some(*month) == want).or_else(|| all.first()).copied()
|
||||
});
|
||||
match hit {
|
||||
Some((n, month, english)) => {
|
||||
if expected.get(slot) == Some(month) {
|
||||
slot += 1;
|
||||
}
|
||||
// Apart from what touches it ("10月18"), but no space added at either end.
|
||||
if out.ends_with(char::is_alphanumeric) {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(english);
|
||||
i = i.saturating_add(n.len());
|
||||
if chars.get(i).is_some_and(|c| c.is_alphanumeric()) {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `c` as names are compared: lower case, with the capital I and the Turkish dotted and dotless
|
||||
/// i all "i", and a combining dot above (U+0307) dropped, so that "EKİM", "SALI" and "MAYIS" are
|
||||
/// Turkish "Ekim", "Salı" and "Mayıs", which lower-casing alone makes "ekim" with a dotted i,
|
||||
/// "sali" and "mayis" (#5303). The Greek final sigma is a sigma, since a capital Σ, which
|
||||
/// lower-cases to σ on its own, ends "ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος". One piece for each char, so
|
||||
/// positions in the text stay aligned.
|
||||
fn fold(c: char) -> String {
|
||||
match c {
|
||||
'I' | 'ı' | 'İ' => "i".to_string(),
|
||||
'ς' => "σ".to_string(),
|
||||
_ => c.to_lowercase().filter(|&l| l != '\u{307}').collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` [`fold`]ed char by char.
|
||||
fn folded(s: &str) -> String {
|
||||
s.chars().map(fold).collect()
|
||||
}
|
||||
|
||||
/// Typed dates longer than this are not searched for names.
|
||||
const MAX_DATE_TEXT_BYTES: usize = 1024;
|
||||
|
||||
/// Names longer than this are not looked for (settings are trusted, but a cap keeps the scan cheap).
|
||||
const MAX_NAME_CHARS: usize = 32;
|
||||
|
||||
fn names() -> Names {
|
||||
*NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
std::thread_local! {
|
||||
/// Whether `util.printd` on this thread writes English names, whatever the date language.
|
||||
static PRINTD_ENGLISH: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Runs `run` with `util.printd` writing English month and weekday names when `english` (scripts
|
||||
/// it starts carry this to their own thread): for an appearance whose fonts can't draw the date
|
||||
/// language's names, which would come out as `?` (#5303). `util.scand` still reads both.
|
||||
pub fn with_english_printd<R>(english: bool, run: impl FnOnce() -> R) -> R {
|
||||
struct Restore(bool);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
PRINTD_ENGLISH.with(|e| e.set(self.0));
|
||||
}
|
||||
}
|
||||
let _restore = Restore(PRINTD_ENGLISH.with(|e| e.replace(english)));
|
||||
run()
|
||||
}
|
||||
|
||||
/// Whether [`with_english_printd`] is on for this thread.
|
||||
pub(crate) fn printd_english() -> bool {
|
||||
PRINTD_ENGLISH.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// The names `util.printd` writes.
|
||||
fn printd_names() -> Names {
|
||||
if printd_english() { ENGLISH } else { names() }
|
||||
}
|
||||
|
||||
/// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt,
|
||||
/// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt).
|
||||
pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
@@ -161,6 +332,7 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu
|
||||
let (y, mo, day, wd, h, mi, sec) =
|
||||
(part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?);
|
||||
let mut out = String::new();
|
||||
let (months, months_short, days, days_short) = printd_names();
|
||||
let b = fmt.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
@@ -186,13 +358,13 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu
|
||||
}
|
||||
}
|
||||
b'm' => match run {
|
||||
r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]),
|
||||
3 => out.push_str(&MONTHS[mo as usize % 12][..3]),
|
||||
r if r >= 4 => out.push_str(months.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()),
|
||||
3 => out.push_str(months_short.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()),
|
||||
n => push_num(&mut out, mo + 1, n),
|
||||
},
|
||||
b'd' => match run {
|
||||
r if r >= 4 => out.push_str(DAYS[wd as usize % 7]),
|
||||
3 => out.push_str(&DAYS[wd as usize % 7][..3]),
|
||||
r if r >= 4 => out.push_str(days.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()),
|
||||
3 => out.push_str(days_short.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()),
|
||||
n => push_num(&mut out, day, n),
|
||||
},
|
||||
b'H' => push_num(&mut out, h, run),
|
||||
@@ -435,11 +607,13 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option<Scanned> {
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => {
|
||||
v.mo = Some(named(&MONTHS, l)?.saturating_add(1));
|
||||
let (months, months_short, ..) = names();
|
||||
v.mo = Some(named(&months, l).or_else(|| named(&months_short, l)).or_else(|| named(&MONTHS, l))?.saturating_add(1));
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => {
|
||||
named(&DAYS, l)?;
|
||||
let (.., days, days_short) = names();
|
||||
named(&days, l).or_else(|| named(&days_short, l)).or_else(|| named(&DAYS, l))?;
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => {
|
||||
@@ -509,10 +683,11 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option<Scanned> {
|
||||
|
||||
/// The index of the name `word` is, in full or as its first three letters, in any case.
|
||||
fn named(names: &[&str], word: &str) -> Option<u32> {
|
||||
let word = word.to_lowercase();
|
||||
let word = folded(word);
|
||||
let i = names.iter().position(|n| {
|
||||
let n = n.to_lowercase();
|
||||
word == n || n.get(..3) == Some(word.as_str())
|
||||
let n = folded(n);
|
||||
// A short name may end in a dot ("Okt."); three letters may be cut from any name.
|
||||
word == n || word == n.trim_end_matches('.') || n.chars().take(3).collect::<String>() == word
|
||||
})?;
|
||||
u32::try_from(i).ok()
|
||||
}
|
||||
@@ -524,6 +699,8 @@ pub(crate) fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResul
|
||||
return Ok(JsValue::null());
|
||||
};
|
||||
let fmt = text(&arg(args, 0), ctx)?;
|
||||
let (months, months_short, days, days_short) = names();
|
||||
let typed = english_names(&typed, &fmt, [&months, &months_short, &days, &days_short]);
|
||||
let date = JsDate::new(ctx);
|
||||
let this_year = date.get_full_year(ctx)?.to_number(ctx)?;
|
||||
let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 };
|
||||
|
||||
@@ -246,4 +246,42 @@ mod tests {
|
||||
assert_eq!(num_text(-1e20), "-100000000000000000000");
|
||||
assert_eq!(num_text(f64::MAX).len(), 309);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_xml_and_html_entities() {
|
||||
let eval = |script: &str| {
|
||||
let root = XfaNode::default();
|
||||
let out = run_formcalc(script, &XfaEvent::default(), &XfaDoc::default(), &root, Limits::default());
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
out.result.unwrap_or_default()
|
||||
};
|
||||
|
||||
// `'` in both xml and html
|
||||
assert_eq!(eval(r#"Decode("It's <ok> & "fine"", "xml")"#), "It's <ok> & \"fine\"");
|
||||
assert_eq!(eval(r#"Decode("It's", "html")"#), "It's");
|
||||
|
||||
// Decimal and hex numeric character references (case-insensitive #x/#X and hex digits, leading zeros)
|
||||
assert_eq!(eval(r#"Decode("ABCDe€😀", "xml")"#), "ABCDe€😀");
|
||||
assert_eq!(eval(r#"Decode("ABCé", "html")"#), "ABCé");
|
||||
|
||||
// Invalid / surrogate / out-of-range / zero / malformed numeric references stay literal without panicking
|
||||
assert_eq!(
|
||||
eval(r#"Decode("� � � � � &#; &#x; A G; &;", "xml")"#),
|
||||
"� � � � � &#; &#x; A G; &;"
|
||||
);
|
||||
|
||||
// Single-pass decoding: `&lt;` and `&lt;` must decode to `<`, never `<`
|
||||
assert_eq!(eval(r#"Decode("&lt; &#65; &lt; &apos;", "xml")"#), "< A < '");
|
||||
assert_eq!(eval(r#"Decode("&nbsp; &eacute;", "html")"#), " é");
|
||||
|
||||
// HTML named entities decode in "html" mode but remain literal in "xml" mode
|
||||
assert_eq!(eval(r#"Decode("café ©€—αΩ♥", "html")"#), "café\u{00A0}©€—αΩ♥");
|
||||
assert_eq!(eval(r#"Decode("café ©€", "xml")"#), "café ©€");
|
||||
// Case sensitivity of HTML named entities (`É` vs `é`, `″` vs `′`, `‡` vs `†`)
|
||||
assert_eq!(eval(r#"Decode("Éé″′‡†&bogus;", "html")"#), "Éé″′‡†&bogus;");
|
||||
// Non-ASCII characters after `&` or `&#` and long runs of `&` without `;`
|
||||
assert_eq!(eval(r#"Decode("&é; &#é; &#xé; &é", "html")"#), "&é; &#é; &#xé; &é");
|
||||
let amps = "&".repeat(5_000);
|
||||
assert_eq!(eval(&format!(r#"Decode("{amps}'", "xml")"#)), format!("{amps}'"));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use super::ast::Expr;
|
||||
use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, parse_date};
|
||||
use super::parser::parse;
|
||||
use super::value::{Val, fixed, is_numeric_text, parse_num};
|
||||
use super::words::word_num;
|
||||
use super::words::{decode_entities, word_num};
|
||||
use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit};
|
||||
|
||||
impl Interp<'_> {
|
||||
@@ -200,7 +200,8 @@ impl Interp<'_> {
|
||||
let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() };
|
||||
Val::Str(match (lower.as_str(), kind.as_str()) {
|
||||
("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """),
|
||||
("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"),
|
||||
("decode", "xml") => decode_entities(&s, false),
|
||||
("decode", "html") => decode_entities(&s, true),
|
||||
("encode", _) => s
|
||||
.bytes()
|
||||
.map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") })
|
||||
|
||||
@@ -292,15 +292,23 @@ impl Interp<'_> {
|
||||
}
|
||||
_ => Ok(Val::Null),
|
||||
},
|
||||
Val::Layout => match m.as_str() {
|
||||
"page" | "abspage" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })),
|
||||
"pagecount" | "abspagecount" => Ok(Val::Num(self.h.doc.page_count as f64)),
|
||||
"relayout" | "relayoutpagearea" => {
|
||||
self.h.push(XfaEffect::Relayout);
|
||||
Ok(Val::Null)
|
||||
Val::Layout => {
|
||||
// No layout method reads its arguments, but each is evaluated once, for what it
|
||||
// does (a message box in an argument still shows), as the other objects' are.
|
||||
for i in 0..args.len() {
|
||||
self.arg(args, i)?;
|
||||
}
|
||||
_ => Ok(Val::Num(0.0)),
|
||||
},
|
||||
match m.as_str() {
|
||||
// On screen every page is a sheet of its own (see the JavaScript `sheet`).
|
||||
"page" | "abspage" | "sheet" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })),
|
||||
"pagecount" | "abspagecount" | "sheetcount" => Ok(Val::Num(self.h.doc.page_count as f64)),
|
||||
"relayout" | "relayoutpagearea" => {
|
||||
self.h.push(XfaEffect::Relayout);
|
||||
Ok(Val::Null)
|
||||
}
|
||||
_ => Ok(Val::Num(0.0)),
|
||||
}
|
||||
}
|
||||
Val::Xfa => match m.as_str() {
|
||||
"resolvenode" | "resolvenodes" => {
|
||||
let s = self.arg_str(args, 0)?;
|
||||
|
||||
+123
-44
@@ -49,52 +49,12 @@ pub(super) fn lex(src: &str) -> Result<Vec<Token>, String> {
|
||||
}
|
||||
}
|
||||
'"' => {
|
||||
let mut s = String::new();
|
||||
i += 1;
|
||||
loop {
|
||||
match chars.get(i) {
|
||||
None => return Err(format!("line {line}: unterminated string")),
|
||||
Some('"') if chars.get(i + 1) == Some(&'"') => {
|
||||
s.push('"');
|
||||
i += 2;
|
||||
}
|
||||
Some('"') => {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
Some(&ch) => {
|
||||
if ch == '\n' {
|
||||
line += 1;
|
||||
}
|
||||
s.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if s.len() > MAX_STRING {
|
||||
return Err(format!("line {line}: a string is too long"));
|
||||
}
|
||||
}
|
||||
out.push(Token { tok: Tok::Str(s), line });
|
||||
let tok_line = line;
|
||||
let s = lex_string(&chars, &mut i, &mut line)?;
|
||||
out.push(Token { tok: Tok::Str(s), line: tok_line });
|
||||
}
|
||||
c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => {
|
||||
let start = i;
|
||||
while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) {
|
||||
i += 1;
|
||||
}
|
||||
if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {
|
||||
let mut j = i + 1;
|
||||
if j < chars.len() && (chars[j] == '+' || chars[j] == '-') {
|
||||
j += 1;
|
||||
}
|
||||
if j < chars.len() && chars[j].is_ascii_digit() {
|
||||
i = j;
|
||||
while i < chars.len() && chars[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let text: String = chars[start..i].iter().collect();
|
||||
let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?;
|
||||
let n = lex_number(&chars, &mut i, line)?;
|
||||
out.push(Token { tok: Tok::Num(n), line });
|
||||
}
|
||||
'!' => {
|
||||
@@ -127,3 +87,122 @@ pub(super) fn lex(src: &str) -> Result<Vec<Token>, String> {
|
||||
out.push(Token { tok: Tok::Eof, line });
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn hex_u32(digits: &[char]) -> Option<u32> {
|
||||
let mut v = 0u32;
|
||||
for &c in digits {
|
||||
v = (v << 4) | c.to_digit(16)?;
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// FormCalc string escape (`\uXXXX` or `\UXXXXXXXX`, XFA 3.3 part 8 p. 1054); any other `\` stays literal.
|
||||
fn unicode_escape(chars: &[char], at: usize) -> Option<(char, usize)> {
|
||||
if chars.get(at) != Some(&'\\') {
|
||||
return None;
|
||||
}
|
||||
match chars.get(at + 1)? {
|
||||
'u' => {
|
||||
let hi = hex_u32(chars.get(at + 2..at + 6)?)?;
|
||||
if (0xD800..=0xDBFF).contains(&hi)
|
||||
&& chars.get(at + 6..at + 8) == Some(&['\\', 'u'])
|
||||
&& let Some(lo) = chars.get(at + 8..at + 12).and_then(hex_u32).filter(|lo| (0xDC00..=0xDFFF).contains(lo))
|
||||
{
|
||||
let scalar = 0x1_0000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
|
||||
return Some((char::from_u32(scalar)?, 12));
|
||||
}
|
||||
Some((char::from_u32(hi)?, 6))
|
||||
}
|
||||
'U' => {
|
||||
let code = hex_u32(chars.get(at + 2..at + 10)?)?;
|
||||
Some((char::from_u32(code)?, 10))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn lex_string(chars: &[char], i: &mut usize, line: &mut usize) -> Result<String, String> {
|
||||
let mut s = String::new();
|
||||
*i += 1;
|
||||
loop {
|
||||
match chars.get(*i) {
|
||||
None => return Err(format!("line {line}: unterminated string")),
|
||||
Some('"') if chars.get(*i + 1) == Some(&'"') => {
|
||||
s.push('"');
|
||||
*i += 2;
|
||||
}
|
||||
Some('"') => {
|
||||
*i += 1;
|
||||
return Ok(s);
|
||||
}
|
||||
Some(&ch) => {
|
||||
if let Some((decoded, step)) = unicode_escape(chars, *i) {
|
||||
s.push(decoded);
|
||||
*i += step;
|
||||
} else {
|
||||
if ch == '\n' {
|
||||
*line += 1;
|
||||
}
|
||||
s.push(ch);
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.len() > MAX_STRING {
|
||||
return Err(format!("line {line}: a string is too long"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `c` may start a FormCalc identifier.
|
||||
fn is_ident_start(c: char) -> bool {
|
||||
c.is_alphabetic() || c == '_' || c == '$'
|
||||
}
|
||||
|
||||
/// End index of a numeric exponent starting at `at` (`e`/`E`, optional sign, digits), or `None`
|
||||
/// if no valid exponent starts there or if identifier characters immediately follow the digits.
|
||||
fn exponent_end(chars: &[char], at: usize) -> Option<usize> {
|
||||
if !matches!(chars.get(at), Some('e' | 'E')) {
|
||||
return None;
|
||||
}
|
||||
let mut j = at + 1;
|
||||
if matches!(chars.get(j), Some('+' | '-')) {
|
||||
j += 1;
|
||||
}
|
||||
if !chars.get(j).is_some_and(char::is_ascii_digit) {
|
||||
return None;
|
||||
}
|
||||
while chars.get(j).is_some_and(char::is_ascii_digit) {
|
||||
j += 1;
|
||||
}
|
||||
if chars.get(j).is_some_and(|&c| c.is_alphanumeric() || c == '_' || c == '$') {
|
||||
return None;
|
||||
}
|
||||
Some(j)
|
||||
}
|
||||
|
||||
/// Scan a FormCalc number literal starting at `*i`, stopping before `.member` or `..` while
|
||||
/// rejecting malformed multi-dot literals such as `1.2.3`.
|
||||
fn lex_number(chars: &[char], i: &mut usize, line: usize) -> Result<f64, String> {
|
||||
let start = *i;
|
||||
let mut seen_dot = false;
|
||||
while *i < chars.len() {
|
||||
let next = chars.get(*i + 1).copied();
|
||||
let next_is_member = next.is_some_and(is_ident_start) && exponent_end(chars, *i + 1).is_none();
|
||||
if chars[*i].is_ascii_digit() {
|
||||
*i += 1;
|
||||
} else if chars[*i] == '.' && !seen_dot && next != Some('.') && !next_is_member {
|
||||
seen_dot = true;
|
||||
*i += 1;
|
||||
} else if chars[*i] == '.' && seen_dot && next.is_some_and(|c| c.is_ascii_digit()) {
|
||||
return Err(format!("line {line}: bad number"));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(end) = exponent_end(chars, *i) {
|
||||
*i = end;
|
||||
}
|
||||
let text: String = chars[start..*i].iter().collect();
|
||||
text.parse().map_err(|_| format!("line {line}: bad number {text}"))
|
||||
}
|
||||
@@ -341,10 +341,7 @@ impl Parser {
|
||||
Ok(Expr::Neg(Box::new(self.unary()?)))
|
||||
} else if self.eat_op("+") {
|
||||
self.unary()
|
||||
} else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") {
|
||||
if matches!(self.peek(), Tok::Ident(w) if w == "!") {
|
||||
self.at += 1;
|
||||
}
|
||||
} else if self.eat_kw("not") || self.eat_kw("!") {
|
||||
Ok(Expr::Not(Box::new(self.unary()?)))
|
||||
} else {
|
||||
self.postfix()
|
||||
@@ -449,3 +446,31 @@ pub(super) fn parse(src: &str) -> Result<Vec<Stmt>, String> {
|
||||
t => Err(format!("line {}: unexpected {}", p.line(), describe(t))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unary_not_does_not_swallow_following_bang() {
|
||||
assert_eq!(parse("not !1"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Num(1.0))))))]));
|
||||
assert_eq!(parse("not !flag"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Ident("flag".into()))))))]));
|
||||
assert_eq!(parse("!!1"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Num(1.0))))))]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_literals_decode_xfa_unicode_escapes_and_keep_other_backslashes() {
|
||||
assert_eq!(parse(r#""\u0041\u20ac\U00000042\U0001F600""#), Ok(vec![Stmt::Expr(Expr::Str("A\u{20ac}B\u{1F600}".into()))]));
|
||||
assert_eq!(parse(r#""\uD83D\uDE00""#), Ok(vec![Stmt::Expr(Expr::Str("\u{1F600}".into()))]));
|
||||
assert_eq!(parse(r#""C:\temp\new\u12""#), Ok(vec![Stmt::Expr(Expr::Str(r"C:\temp\new\u12".into()))]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_literals_stop_before_second_dot_for_member_access() {
|
||||
assert_eq!(parse("1.5.rawValue"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.5)), "rawValue".into()))]));
|
||||
assert_eq!(parse("1.rawValue"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.0)), "rawValue".into()))]));
|
||||
assert_eq!(parse("1.e2foo"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.0)), "e2foo".into()))]));
|
||||
assert_eq!(parse("1.e2"), Ok(vec![Stmt::Expr(Expr::Num(100.0))]));
|
||||
assert!(parse("1.2.3").is_err(), "a second decimal point followed by digits is a bad number");
|
||||
}
|
||||
}
|
||||
@@ -73,3 +73,140 @@ pub(super) fn word_num(n: f64, cents: bool) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode XML (`html == false`) or HTML (`html == true`) character and entity references in a
|
||||
/// single pass so `&lt;` becomes `<` rather than `<`, and invalid or unrecognized
|
||||
/// references remain literal without panicking.
|
||||
///
|
||||
/// When `html` is `false`, only the five predefined XML entities (`<`, `>`, `&`,
|
||||
/// `"`, `'`) and numeric character references (`&#...;`, `&#x...;`, `&#X...;`) are
|
||||
/// decoded. When `html` is `true`, W3C HTML 4 / XHTML 1.0 named entities are decoded as well.
|
||||
pub(super) fn decode_entities(s: &str, html: bool) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(rest.get(..at).unwrap_or_default());
|
||||
rest = rest.get(at..).unwrap_or_default();
|
||||
match entity_at(rest, html) {
|
||||
Some((c, len)) => {
|
||||
out.push(c);
|
||||
rest = rest.get(len..).unwrap_or_default();
|
||||
}
|
||||
None => {
|
||||
out.push('&');
|
||||
rest = rest.get(1..).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a character reference starting at the leading `&` of `s`, returning the decoded `char`
|
||||
/// and the byte length of the reference (including `&` and `;`).
|
||||
fn entity_at(s: &str, html: bool) -> Option<(char, usize)> {
|
||||
if let Some(body) = s.strip_prefix("&#") {
|
||||
let (radix, digits, prefix_len) = match body.strip_prefix(['x', 'X']) {
|
||||
Some(hex) => (16u32, hex, 3usize),
|
||||
None => (10u32, body, 2usize),
|
||||
};
|
||||
let mut value = 0u32;
|
||||
let mut any_digit = false;
|
||||
for (i, c) in digits.char_indices() {
|
||||
match c.to_digit(radix) {
|
||||
Some(d) => {
|
||||
any_digit = true;
|
||||
value = value.checked_mul(radix)?.checked_add(d)?;
|
||||
if value > 0x10_FFFF {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None if c == ';' && any_digit && value != 0 => {
|
||||
return Some((char::from_u32(value)?, prefix_len + i + 1));
|
||||
}
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let body = s.strip_prefix('&')?;
|
||||
let semi = body.bytes().take(9).position(|b| b == b';')?;
|
||||
let name = body.get(..semi).filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_alphanumeric()))?;
|
||||
let ch = match name {
|
||||
"lt" => '<',
|
||||
"gt" => '>',
|
||||
"amp" => '&',
|
||||
"quot" => '"',
|
||||
"apos" => '\'',
|
||||
_ if html => html_named_entity(name)?,
|
||||
_ => return None,
|
||||
};
|
||||
Some((ch, 1 + semi + 1))
|
||||
}
|
||||
|
||||
/// Look up a W3C HTML 4 / XHTML 1.0 named entity (excluding the five XML entities).
|
||||
fn html_named_entity(name: &str) -> Option<char> {
|
||||
if let Some(idx) = LATIN1_ENTITIES.iter().position(|&n| n == name) {
|
||||
return char::from_u32(0xA0 + idx as u32);
|
||||
}
|
||||
HTML_ENTITIES.iter().find(|&&(n, _)| n == name).map(|&(_, c)| c)
|
||||
}
|
||||
|
||||
/// ISO-8859-1 named entities in code-point order `U+00A0..=U+00FF`. Entries must remain ordered
|
||||
/// by successive code points starting at `0xA0` and must not be reordered alphabetically, since
|
||||
/// [`html_named_entity`] maps each entry's index to `0xA0 + idx as u32`.
|
||||
#[rustfmt::skip]
|
||||
const LATIN1_ENTITIES: [&str; 96] = [
|
||||
"nbsp", "iexcl", "cent", "pound", "curren", "yen", "brvbar", "sect", "uml", "copy", "ordf", "laquo", "not", "shy", "reg", "macr",
|
||||
"deg", "plusmn", "sup2", "sup3", "acute", "micro", "para", "middot", "cedil", "sup1", "ordm", "raquo", "frac14", "frac12", "frac34", "iquest",
|
||||
"Agrave", "Aacute", "Acirc", "Atilde", "Auml", "Aring", "AElig", "Ccedil", "Egrave", "Eacute", "Ecirc", "Euml", "Igrave", "Iacute", "Icirc", "Iuml",
|
||||
"ETH", "Ntilde", "Ograve", "Oacute", "Ocirc", "Otilde", "Ouml", "times", "Oslash", "Ugrave", "Uacute", "Ucirc", "Uuml", "Yacute", "THORN", "szlig",
|
||||
"agrave", "aacute", "acirc", "atilde", "auml", "aring", "aelig", "ccedil", "egrave", "eacute", "ecirc", "euml", "igrave", "iacute", "icirc", "iuml",
|
||||
"eth", "ntilde", "ograve", "oacute", "ocirc", "otilde", "ouml", "divide", "oslash", "ugrave", "uacute", "ucirc", "uuml", "yacute", "thorn", "yuml",
|
||||
];
|
||||
|
||||
#[rustfmt::skip]
|
||||
const HTML_ENTITIES: &[(&str, char)] = &[
|
||||
("OElig", '\u{0152}'), ("oelig", '\u{0153}'), ("Scaron", '\u{0160}'), ("scaron", '\u{0161}'), ("Yuml", '\u{0178}'), ("fnof", '\u{0192}'), ("circ", '\u{02C6}'), ("tilde", '\u{02DC}'),
|
||||
("Alpha", '\u{0391}'), ("Beta", '\u{0392}'), ("Gamma", '\u{0393}'), ("Delta", '\u{0394}'), ("Epsilon", '\u{0395}'), ("Zeta", '\u{0396}'), ("Eta", '\u{0397}'), ("Theta", '\u{0398}'),
|
||||
("Iota", '\u{0399}'), ("Kappa", '\u{039A}'), ("Lambda", '\u{039B}'), ("Mu", '\u{039C}'), ("Nu", '\u{039D}'), ("Xi", '\u{039E}'), ("Omicron", '\u{039F}'), ("Pi", '\u{03A0}'),
|
||||
("Rho", '\u{03A1}'), ("Sigma", '\u{03A3}'), ("Tau", '\u{03A4}'), ("Upsilon", '\u{03A5}'), ("Phi", '\u{03A6}'), ("Chi", '\u{03A7}'), ("Psi", '\u{03A8}'), ("Omega", '\u{03A9}'),
|
||||
("alpha", '\u{03B1}'), ("beta", '\u{03B2}'), ("gamma", '\u{03B3}'), ("delta", '\u{03B4}'), ("epsilon", '\u{03B5}'), ("zeta", '\u{03B6}'), ("eta", '\u{03B7}'), ("theta", '\u{03B8}'),
|
||||
("iota", '\u{03B9}'), ("kappa", '\u{03BA}'), ("lambda", '\u{03BB}'), ("mu", '\u{03BC}'), ("nu", '\u{03BD}'), ("xi", '\u{03BE}'), ("omicron", '\u{03BF}'), ("pi", '\u{03C0}'),
|
||||
("rho", '\u{03C1}'), ("sigmaf", '\u{03C2}'), ("sigma", '\u{03C3}'), ("tau", '\u{03C4}'), ("upsilon", '\u{03C5}'), ("phi", '\u{03C6}'), ("chi", '\u{03C7}'), ("psi", '\u{03C8}'),
|
||||
("omega", '\u{03C9}'), ("thetasym", '\u{03D1}'), ("upsih", '\u{03D2}'), ("piv", '\u{03D6}'), ("ensp", '\u{2002}'), ("emsp", '\u{2003}'), ("thinsp", '\u{2009}'), ("zwnj", '\u{200C}'),
|
||||
("zwj", '\u{200D}'), ("lrm", '\u{200E}'), ("rlm", '\u{200F}'), ("ndash", '\u{2013}'), ("mdash", '\u{2014}'), ("lsquo", '\u{2018}'), ("rsquo", '\u{2019}'), ("sbquo", '\u{201A}'),
|
||||
("ldquo", '\u{201C}'), ("rdquo", '\u{201D}'), ("bdquo", '\u{201E}'), ("dagger", '\u{2020}'), ("Dagger", '\u{2021}'), ("bull", '\u{2022}'), ("hellip", '\u{2026}'), ("permil", '\u{2030}'),
|
||||
("prime", '\u{2032}'), ("Prime", '\u{2033}'), ("lsaquo", '\u{2039}'), ("rsaquo", '\u{203A}'), ("oline", '\u{203E}'), ("frasl", '\u{2044}'), ("euro", '\u{20AC}'), ("image", '\u{2111}'),
|
||||
("weierp", '\u{2118}'), ("real", '\u{211C}'), ("trade", '\u{2122}'), ("alefsym", '\u{2135}'), ("larr", '\u{2190}'), ("uarr", '\u{2191}'), ("rarr", '\u{2192}'), ("darr", '\u{2193}'),
|
||||
("harr", '\u{2194}'), ("crarr", '\u{21B5}'), ("lArr", '\u{21D0}'), ("uArr", '\u{21D1}'), ("rArr", '\u{21D2}'), ("dArr", '\u{21D3}'), ("hArr", '\u{21D4}'), ("forall", '\u{2200}'),
|
||||
("part", '\u{2202}'), ("exist", '\u{2203}'), ("empty", '\u{2205}'), ("nabla", '\u{2207}'), ("isin", '\u{2208}'), ("notin", '\u{2209}'), ("ni", '\u{220B}'), ("prod", '\u{220F}'),
|
||||
("sum", '\u{2211}'), ("minus", '\u{2212}'), ("lowast", '\u{2217}'), ("radic", '\u{221A}'), ("prop", '\u{221D}'), ("infin", '\u{221E}'), ("ang", '\u{2220}'), ("and", '\u{2227}'),
|
||||
("or", '\u{2228}'), ("cap", '\u{2229}'), ("cup", '\u{222A}'), ("int", '\u{222B}'), ("there4", '\u{2234}'), ("sim", '\u{223C}'), ("cong", '\u{2245}'), ("asymp", '\u{2248}'),
|
||||
("ne", '\u{2260}'), ("equiv", '\u{2261}'), ("le", '\u{2264}'), ("ge", '\u{2265}'), ("sub", '\u{2282}'), ("sup", '\u{2283}'), ("nsub", '\u{2284}'), ("sube", '\u{2286}'),
|
||||
("supe", '\u{2287}'), ("oplus", '\u{2295}'), ("otimes", '\u{2297}'), ("perp", '\u{22A5}'), ("sdot", '\u{22C5}'), ("lceil", '\u{2308}'), ("rceil", '\u{2309}'), ("lfloor", '\u{230A}'),
|
||||
("rfloor", '\u{230B}'), ("lang", '\u{2329}'), ("rang", '\u{232A}'), ("loz", '\u{25CA}'), ("spades", '\u{2660}'), ("clubs", '\u{2663}'), ("hearts", '\u{2665}'), ("diams", '\u{2666}'),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{HTML_ENTITIES, LATIN1_ENTITIES, decode_entities};
|
||||
|
||||
#[test]
|
||||
fn every_listed_html_entity_decodes_including_eight_byte_boundary() {
|
||||
for (i, &name) in LATIN1_ENTITIES.iter().enumerate() {
|
||||
let expected = char::from_u32(0xA0 + i as u32).unwrap().to_string();
|
||||
let input = format!("&{name};");
|
||||
assert_eq!(decode_entities(&input, true), expected, "entity {name}");
|
||||
assert_eq!(decode_entities(&input, false), input, "xml mode leaves {name} literal");
|
||||
}
|
||||
for &(name, ch) in HTML_ENTITIES {
|
||||
assert!(name.len() <= 8, "entity name {name} exceeds 8-byte parser limit");
|
||||
let input = format!("&{name};");
|
||||
assert_eq!(decode_entities(&input, true), ch.to_string(), "entity {name}");
|
||||
assert_eq!(decode_entities(&input, false), input, "xml mode leaves {name} literal");
|
||||
}
|
||||
assert_eq!(decode_entities("ϑ", true), "\u{03D1}");
|
||||
assert_eq!(decode_entities("&thetasymx;", true), "&thetasymx;");
|
||||
}
|
||||
}
|
||||
@@ -276,12 +276,29 @@ pub(crate) fn platform() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsObject {
|
||||
/// The `event` object for `e`, and the store behind its `value`: `value` holds what the script
|
||||
/// reads and writes, and `set` turns true once the script assigns `event.value`, even the value it
|
||||
/// already had. The store is out of the script's reach.
|
||||
pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject, JsObject)> {
|
||||
fn get(_: &JsValue, _: &[JsValue], store: &JsObject, c: &mut Context) -> JsResult<JsValue> {
|
||||
store.get(js_string!("value"), c)
|
||||
}
|
||||
fn set(_: &JsValue, args: &[JsValue], store: &JsObject, c: &mut Context) -> JsResult<JsValue> {
|
||||
store.set(js_string!("value"), arg(args, 0), false, c)?;
|
||||
store.set(js_string!("set"), true, false, c)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
let store = ObjectInitializer::new(ctx)
|
||||
.property(js_string!("value"), s(&e.value), Attribute::all())
|
||||
.property(js_string!("set"), false, Attribute::all())
|
||||
.build();
|
||||
let value_get = function(ctx, NativeFunction::from_copy_closure_with_captures(get, store.clone()));
|
||||
let value_set = function(ctx, NativeFunction::from_copy_closure_with_captures(set, store.clone()));
|
||||
let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into());
|
||||
ObjectInitializer::new(ctx)
|
||||
let event = ObjectInitializer::new(ctx)
|
||||
.property(js_string!("name"), s(&e.name), Attribute::all())
|
||||
.property(js_string!("type"), s(&e.kind), Attribute::all())
|
||||
.property(js_string!("value"), s(&e.value), Attribute::all())
|
||||
.accessor(js_string!("value"), Some(value_get), Some(value_set), Attribute::CONFIGURABLE | Attribute::ENUMERABLE)
|
||||
.property(js_string!("change"), s(&e.change), Attribute::all())
|
||||
.property(js_string!("changeEx"), s(&e.change), Attribute::all())
|
||||
.property(js_string!("willCommit"), e.will_commit, Attribute::all())
|
||||
@@ -293,5 +310,6 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsObject {
|
||||
.property(js_string!("commitKey"), 0, Attribute::all())
|
||||
.property(js_string!("modifier"), false, Attribute::all())
|
||||
.property(js_string!("shift"), false, Attribute::all())
|
||||
.build()
|
||||
.build();
|
||||
Ok((event, store))
|
||||
}
|
||||
@@ -44,6 +44,7 @@ mod run;
|
||||
#[cfg(test)]
|
||||
pub(crate) use format::PRINTD_SCANNED;
|
||||
use format::{printd, printf, printx, scand};
|
||||
pub use format::{set_date_names, with_english_printd};
|
||||
pub(crate) use globals::menu_item;
|
||||
use globals::platform;
|
||||
use host::{arg, as_number, error, function, s, text};
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct FieldState {
|
||||
/// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set.
|
||||
pub text_color: Option<Vec<String>>,
|
||||
pub fill_color: Option<Vec<String>>,
|
||||
/// Rectangle of the first widget, in PDF user space.
|
||||
pub rect: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
impl FieldState {
|
||||
@@ -68,6 +70,7 @@ impl FieldState {
|
||||
char_limit: None,
|
||||
text_color: None,
|
||||
fill_color: None,
|
||||
rect: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +278,8 @@ pub struct Outcome {
|
||||
pub rc: bool,
|
||||
/// `event.value` after the script.
|
||||
pub value: String,
|
||||
/// Whether the script assigned `event.value`, even the value it already had.
|
||||
pub value_set: bool,
|
||||
pub change: String,
|
||||
/// Fields whose value or properties the script changed, as they are now.
|
||||
pub changed: Vec<FieldState>,
|
||||
|
||||
@@ -129,11 +129,13 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<Fi
|
||||
let (kept, skipped) = (&kept, &skipped);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// The script's thread writes dates as this one asked (`with_english_printd`).
|
||||
let english = crate::format::printd_english();
|
||||
std::thread::scope(|scope| {
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfcraft-js".into())
|
||||
.stack_size(SCRIPT_STACK)
|
||||
.spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits));
|
||||
.spawn_scoped(scope, || crate::format::with_english_printd(english, || run_here(script, event, doc, fields, kept, skipped, limits)));
|
||||
match spawned {
|
||||
Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())),
|
||||
Err(e) => failed(format!("the script engine could not start: {e}")),
|
||||
@@ -215,7 +217,7 @@ fn run_here(
|
||||
let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect();
|
||||
let result = (|| -> JsResult<()> {
|
||||
install(&mut ctx)?;
|
||||
let ev = event_object(&mut ctx, event);
|
||||
let (ev, value_store) = event_object(&mut ctx, event)?;
|
||||
ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?;
|
||||
// One document script that throws (calling a method PdfCraft lacks, say) must not keep
|
||||
// every field script in the form from running: what it defined before the error, and
|
||||
@@ -233,6 +235,7 @@ fn run_here(
|
||||
out.rc = rc.to_boolean();
|
||||
let v = ev.get(js_string!("value"), &mut ctx)?;
|
||||
out.value = text(&v, &mut ctx)?;
|
||||
out.value_set = value_store.get(js_string!("set"), &mut ctx)?.to_boolean();
|
||||
let c = ev.get(js_string!("change"), &mut ctx)?;
|
||||
out.change = text(&c, &mut ctx)?;
|
||||
Ok(())
|
||||
|
||||
@@ -31,6 +31,21 @@ fn calculate_scripts_read_fields_and_set_the_value() {
|
||||
assert!(o.rc);
|
||||
}
|
||||
|
||||
/// `event.value` reads and writes as a plain value, numbers included, and the outcome says whether
|
||||
/// the script assigned it, even to the value it already had.
|
||||
#[test]
|
||||
fn the_outcome_says_whether_the_script_assigned_event_value() {
|
||||
let ev = Event::field("Calculate", "total", "7");
|
||||
let o = go("event.value = event.value;", &ev);
|
||||
assert_eq!((o.value.as_str(), o.value_set), ("7", true));
|
||||
let o = go("event.value = 2; event.value += 3; typeof event.value;", &ev);
|
||||
assert_eq!((o.value.as_str(), o.value_set, o.result.as_deref()), ("5", true, Some("number")));
|
||||
let o = go("var v = event.value; this.getField('price').value = 3;", &ev);
|
||||
assert_eq!((o.value.as_str(), o.value_set), ("7", false));
|
||||
let o = go("Object.keys(event).indexOf('value') >= 0;", &ev);
|
||||
assert_eq!(o.result.as_deref(), Some("true"), "value stays an enumerable property of event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_with_leading_zeros_stay_text() {
|
||||
let o = go("event.value = typeof getField('zip').value + ':' + typeof getField('qty').value;", &Event::field("Calculate", "total", ""));
|
||||
@@ -73,6 +88,22 @@ fn scripts_change_other_fields_and_their_properties() {
|
||||
assert_eq!(o.changed.iter().find(|f| f.name == "size").unwrap().value, ["l"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_rect_reads_and_writes_a_four_number_array() {
|
||||
let o = go(
|
||||
"var t = getField('total'); t.rect = [10, 20, 110, 50]; event.value = getField('total').rect.join(',');",
|
||||
&Event::field("Mouse Up", "agree", ""),
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "10,20,110,50");
|
||||
assert_eq!(o.changed.iter().find(|f| f.name == "total").and_then(|f| f.rect), Some([10.0, 20.0, 110.0, 50.0]));
|
||||
let o = go("getField('total').rect = [110, 50, 10, 20];", &Event::field("Mouse Up", "agree", ""));
|
||||
assert_eq!(o.changed.iter().find(|f| f.name == "total").and_then(|f| f.rect), Some([10.0, 20.0, 110.0, 50.0]));
|
||||
let o = go("getField('total').rect = [0, 0, Infinity, 10];", &Event::field("Mouse Up", "agree", ""));
|
||||
assert!(o.error.is_some());
|
||||
assert!(o.changed.iter().all(|f| f.name != "total"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_and_check_box_object_model() {
|
||||
let o = go(
|
||||
@@ -792,6 +823,19 @@ mod xfa_model {
|
||||
assert_eq!(o.result.as_deref(), Some("7"));
|
||||
}
|
||||
|
||||
/// On screen every page is a sheet of its own: `xfa.layout.sheet` counts it from 0, as
|
||||
/// `absPage` does (forms show `sheet(this) + 1`), and `sheetCount` is the page count.
|
||||
#[test]
|
||||
fn the_sheet_is_the_page_on_screen() {
|
||||
let o = go(
|
||||
"[xfa.layout.sheet(this) + 1, xfa.layout.sheetCount(), xfa.layout.absPage(this)].join(',')",
|
||||
"form1[0].page1[0].total[0]",
|
||||
"calculate",
|
||||
);
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.result.as_deref(), Some("2,3,1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_names_never_shadow_javascript_builtins() {
|
||||
let mut f = form();
|
||||
@@ -1011,6 +1055,23 @@ mod formcalc {
|
||||
run_formcalc(script, &ev, &doc, &form(), Limits::default())
|
||||
}
|
||||
|
||||
/// `xfa.layout.sheet` and `sheetCount` give what the JavaScript ones give.
|
||||
#[test]
|
||||
fn the_sheet_is_the_page_on_screen() {
|
||||
assert_eq!(calc("xfa.layout.sheet($) + 1"), "2");
|
||||
assert_eq!(calc("xfa.layout.sheetCount()"), "3");
|
||||
}
|
||||
|
||||
/// A layout method's arguments are evaluated, once each, for what they do: a message box
|
||||
/// in one still shows.
|
||||
#[test]
|
||||
fn layout_method_arguments_are_evaluated_once() {
|
||||
let o = go("xfa.layout.sheet($host.messageBox(\"Hi\"), $host.messageBox(\"Yo\")) + 1", "form1[0].page1[0].total[0]", "calculate");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.result.as_deref(), Some("2"));
|
||||
assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 2, "each argument once: {:?}", o.effects);
|
||||
}
|
||||
|
||||
/// The value of a script run as a calculate on `total`.
|
||||
fn calc(script: &str) -> String {
|
||||
let o = go(script, "form1[0].page1[0].total[0]", "calculate");
|
||||
|
||||
@@ -174,6 +174,11 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
|
||||
.function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0)
|
||||
// On screen every page is a sheet of its own: the sheet is the page, counted from 0
|
||||
// as absPage counts (a form shows `sheet(this) + 1` as the sheet number), and the
|
||||
// sheet count is the page count.
|
||||
.function(NativeFunction::from_fn_ptr(layout_page0), js_string!("sheet"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("sheetCount"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2)
|
||||
|
||||
Reference in new issue
Block a user