Integrate remaining six PdfCraft parity PRs with conflict resolutions

Includes #4325 #1952 #4180 #2529 #4320 #4805, preserving their original histories.
Keep paragraph alignment and hard/soft wrap metadata together; retain CCITT/downsampling/RGB ownership guards with bounded JBIG2 export and exact-copy symbol encoding.
Combined interaction regressions added. Validation pending.
This commit is contained in:
FredApps
2026-10-30 11:02:07 +01:00
129 changed files with 7949 additions and 415 deletions
+2 -2
View File
@@ -19,10 +19,10 @@ mod script;
#[cfg(test)]
use calc::{Notation, eval_notation, notation_name, notation_tokens, unique};
pub use calc::{calculate, calculation_reads, notation_js, rename_in_calculation};
pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation};
pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate};
use script::{Arg, call};
pub use script::{calculate_js, format_js, parse_calculate, parse_format, parse_validate, validate_js};
pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js};
/// The Format tab.
#[derive(Clone, Debug, PartialEq, Default)]
+62 -20
View File
@@ -206,6 +206,10 @@ enum Site {
Commas(String),
/// A name in the simplified field notation of a `BVCALC` comment.
Notation(String),
/// A field read by a name built at run time: a `getField` whose argument is more than one
/// string literal (`getField("row" + i)`), or an `AFSimple_Calculate` list that isn't
/// written out. Which field it is isn't known.
Built,
}
impl Site {
@@ -213,6 +217,7 @@ impl Site {
match self {
Site::Literal(name) | Site::Notation(name) => vec![name.as_str()],
Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(),
Site::Built => Vec::new(),
}
}
@@ -229,14 +234,16 @@ impl Site {
js_str(&parts.join(","))
})
}
Site::Built => None,
}
}
}
/// Where a Calculate script names fields, in order: the simplified field notation in its comment
/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of
/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal.
/// A name built at run time (`getField("row" + i)`) is not a site.
/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. A name
/// built at run time (`getField("row" + i)`) is a [`Site::Built`], at its `getField` or
/// `AFSimple_Calculate`.
fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
let mut sites = Vec::new();
let (tokens, comments) = js_tokens(js);
@@ -255,35 +262,64 @@ fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
Some((range, Js::Str(text))) => Some((range.clone(), text.clone())),
_ => None,
};
let built = |k: usize| tokens.get(k).map(|(range, _)| (range.clone(), Site::Built));
for k in 0..tokens.len() {
if ident(k, "getField")
&& punct(k + 1, '(')
&& punct(k + 3, ')')
&& let Some((range, name)) = string(k + 2)
{
sites.push((range, Site::Literal(name)));
if ident(k, "getField") && punct(k + 1, '(') {
match string(k + 2) {
Some((range, name)) if punct(k + 3, ')') => sites.push((range, Site::Literal(name))),
_ => sites.extend(built(k)),
}
}
if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) {
if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(')) {
continue;
}
if !(string(k + 2).is_some() && punct(k + 3, ',')) {
sites.extend(built(k));
continue;
}
// One string of comma-separated names, when it is the whole list (not `"row" + suffix`).
// An argument after it leaves the list as written, as after an array: its names are read,
// and what follows is a name built at run time.
if let Some((range, list)) = string(k + 4) {
sites.push((range, Site::Commas(list)));
if punct(k + 5, ')') || punct(k + 5, ',') {
sites.push((range, Site::Commas(list)));
}
if !punct(k + 5, ')') {
sites.extend(built(k));
}
continue;
}
// `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string.
// Nothing else is read, so no token is read for two calls.
let first = if punct(k + 4, '[') {
k + 5
// `new Array("a", "b")` or `["a", "b"]`: each item that is one string, and the list has
// to end the call. An item that is more than a string (`"row" + i`), or anything after
// the list (`.concat(more)`), is a name built at run time. Nothing else is read, so no
// token is read for two calls.
let (first, close) = if punct(k + 4, '[') {
(k + 5, ']')
} else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') {
k + 7
(k + 7, ')')
} else {
sites.extend(built(k));
continue;
};
for (range, token) in tokens.iter().skip(first) {
match token {
Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))),
Js::Punct(',') => {}
_ => break,
let mut j = first;
loop {
match tokens.get(j) {
// An item ends at a comma or the list's end, or where a script cut short ends.
Some((range, Js::Str(name))) if punct(j + 1, ',') || punct(j + 1, close) || tokens.get(j + 1).is_none() => {
sites.push((range.clone(), Site::Literal(name.clone())));
j += 1;
}
Some((_, Js::Punct(','))) => j += 1,
Some((_, Js::Punct(c))) if *c == close => {
if !punct(j + 1, ')') {
sites.extend(built(k));
}
break;
}
_ => {
sites.extend(built(k));
break;
}
}
}
}
@@ -315,6 +351,12 @@ pub fn calculation_reads(js: &str) -> Vec<String> {
unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned))
}
/// Whether a Calculate script also reads fields by names it builds at run time
/// (`getField("row" + i)`), which [`calculation_reads`] can't list.
pub fn reads_built_names(js: &str) -> bool {
calculation_sites(js).iter().any(|(_, site)| *site == Site::Built)
}
impl Calculate {
/// The fields it reads, each once, in the order it first names them. A name also stands for
/// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them.
+25 -2
View File
@@ -1,4 +1,4 @@
use super::calc::{js_tokens, notation_js, notation_range};
use super::calc::{js_tokens, notation_js, notation_range, notation_tokens};
use super::format::{DATE_PRESETS, TIME_PRESETS};
use super::{CalcOp, Calculate, Format, Validate};
@@ -255,7 +255,7 @@ pub fn parse_calculate(js: &str) -> Option<Calculate> {
if js.contains("BVCALC")
&& let Some(range) = notation_range(js, &js_tokens(js).1)
{
return Some(Calculate::Notation(js.get(range)?.trim().to_string()));
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() {
@@ -325,6 +325,29 @@ pub fn validate_js(v: &Validate) -> Option<String> {
}
}
/// 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,
+76
View File
@@ -156,6 +156,57 @@ fn a_regex_after_a_condition_is_not_read_as_code() {
assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\"")));
}
/// A `getField` whose argument is more than one string, or an `AFSimple_Calculate` list that
/// isn't written out, reads a field whose name isn't known until the script runs.
#[test]
fn names_built_at_run_time_are_noticed() {
for js in [
"event.value = getField(\"row\" + i).value;",
"event.value = this.getField(name).value;",
"AFSimple_Calculate(\"SUM\", list);",
"AFSimple_Calculate(\"SUM\", new Array(\"a\", b));",
"AFSimple_Calculate(\"SUM\", [\"a\", prefix + \"b\"]);",
"AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\" + c));",
"AFSimple_Calculate(op, new Array(\"a\"));",
"AFSimple_Calculate(\"SUM\", \"row\" + suffix);",
"AFSimple_Calculate(\"SUM\", [\"a\"].concat(more));",
"AFSimple_Calculate(\"SUM\", [\"row\" + i]);",
] {
assert!(reads_built_names(js), "{js}");
}
// A string that is only the start of a name or a list isn't read, or renamed, as one.
for (js, reads) in [
("AFSimple_Calculate(\"SUM\", \"row\" + suffix);", vec![]),
("AFSimple_Calculate(\"SUM\", [\"row\" + i]);", vec![]),
("AFSimple_Calculate(\"SUM\", [\"a\", \"row\" + i]);", vec!["a"]),
("AFSimple_Calculate(\"SUM\", [\"a\", \"b\"].concat(more));", vec!["a", "b"]),
] {
assert_eq!(parse_calculate(js), None, "{js} is a script");
assert_eq!(calculation_reads(js), reads, "{js}");
assert_eq!(rename_in_calculation(js, "row", "line"), None, "{js}");
}
for js in [
"event.value = getField(\"a\").value + getField('b').value;",
"AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\"));",
"AFSimple_Calculate(\"SUM\", [\"a\", \"b\",]);",
"AFSimple_Calculate(\"SUM\", \"a, b\");",
"// getField(\"row\" + i)\nvar s = 'getField(name)'; event.value = 1;",
"/** BVCALC a + b EVCALC **/ event.value = getField(\"a\").value + getField(\"b\").value;",
] {
assert!(!reads_built_names(js), "{js}");
}
// The names it does know are still read and renamed.
let js = "event.value = getField(\"a\").value + getField(\"row\" + i).value + AFSimple_Calculate(\"SUM\", new Array(\"a\", b));";
assert_eq!(calculation_reads(js), ["a"]);
assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a\"", "\"qty\"")));
// So are a list's, string or array, with another argument after it.
for js in ["AFSimple_Calculate(\"SUM\", \"a, b\", x);", "AFSimple_Calculate(\"SUM\", [\"a\", \"b\"], x);"] {
assert!(reads_built_names(js), "{js}");
assert_eq!(calculation_reads(js), ["a", "b"], "{js}");
assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a", "\"qty")), "{js}");
}
}
/// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in
/// one another's field lists, don't make a rename read the rest of the script again for each.
#[test]
@@ -166,6 +217,21 @@ fn hostile_scripts_are_read_in_one_pass() {
assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000))));
}
/// A notation is stored only when it reads back from the script's comment as itself.
#[test]
fn a_notation_is_stored_only_when_it_reads_back() {
assert!(notation_storable("Price * Qty"));
assert!(notation_storable(" a + b "), "the spaces around it are left out when it is read");
// A last name that ends in a space keeps it, escaped, when the notation is read back.
let total = "Qty * Total\\ ";
assert!(notation_storable(total));
let read = parse_calculate(&calculate_js(&Calculate::Notation(total.into())).unwrap());
assert_eq!(read.as_ref().map(Calculate::reads), Some(vec!["Qty".to_owned(), "Total ".to_owned()]));
for expr in ["", " ", "a EVCALC b", "a */ x /*", "a */ b", "a\\"] {
assert!(!notation_storable(expr), "{expr:?}");
}
}
/// Notation is read through one lexer: field names with escapes, numbers and operators.
#[test]
fn notation_is_read_by_one_lexer() {
@@ -213,6 +279,16 @@ proptest::proptest! {
proptest::prop_assert_eq!(back, Some(js));
}
/// A notation of any names, written into a script, reads back from it as written.
#[test]
fn a_notation_reads_back_as_written(names in proptest::collection::vec("\\PC{1,8}", 1..5)) {
proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC")));
let expr = names.iter().map(|n| notation_name(n)).collect::<Vec<_>>().join(" + ");
let js = calculate_js(&Calculate::Notation(expr.clone())).unwrap();
proptest::prop_assert_eq!(parse_calculate(&js), Some(Calculate::Notation(expr.clone())));
proptest::prop_assert!(notation_storable(&expr), "{expr:?}");
}
/// What a formula reads is the same whether it is read from the tab's settings or from
/// the script they are written as.
#[test]
+3
View File
@@ -88,6 +88,9 @@ pub struct FieldProps {
pub max_len: Option<Option<usize>>,
/// Choice options (display texts; export values equal them).
pub options: Option<Vec<String>>,
/// Change one button widget's export value without changing its appearance state.
/// Widgets sharing that state are kept in sync. The index is zero-based.
pub export_value: Option<(usize, String)>,
/// Font size (0 = auto).
pub font_size: Option<f64>,
/// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in
+48
View File
@@ -77,6 +77,35 @@ fn write_sorted_opt(d: &mut Dict, items: &[(String, String)], old: &[usize]) ->
(sorted.into_iter().map(|(e, _)| e.clone()).collect(), carried)
}
/// Build /Opt before modifying the document, preserving appearance states and selection.
fn button_export_options(f: &Field, index: usize, value: &str) -> Result<Vec<Object>, FormError> {
if !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
return invalid("only check boxes and radio buttons have an export value");
}
if value.is_empty() || value == "Off" || value.len() > 4096 {
return invalid("export value must be non-empty, at most 4096 bytes, and not Off");
}
let widget = f.widgets.get(index).ok_or_else(|| FormError::Invalid("button widget index is out of range".into()))?;
let state = widget.on_state.as_deref().ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?;
if !f.options.is_empty() && f.options.len() != f.widgets.len() {
return invalid("button export options do not match its widgets");
}
let previous = f.export_of(index);
let mut out = Vec::with_capacity(f.widgets.len());
for (i, w) in f.widgets.iter().enumerate() {
let export = f.export_of(i).ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?;
let same_state = w.on_state.as_deref() == Some(state);
if !same_state && (export == value || w.on_state.as_deref() == Some(value)) {
return invalid("export value conflicts with another button choice");
}
if same_state && Some(export) != previous {
return invalid("widgets with the same appearance state have conflicting export values");
}
out.push(Object::String(PdfString::text(if same_state { value } else { export })));
}
Ok(out)
}
/// Change a field's properties (General and Options tabs) and redraw it.
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
@@ -88,8 +117,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
patch.validate()?;
}
// Refused here, before anything is written, rather than stored as a different formula.
if let Some(crate::af::Calculate::Notation(n)) = &props.calculate
&& !crate::af::notation_storable(n)
{
return invalid(crate::af::NOTATION_NOT_STORABLE);
}
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
let export_options = props.export_value.as_ref().map(|(index, value)| button_export_options(&f, *index, value)).transpose()?;
if props.export_value.is_some() && props.options.is_some() {
return invalid("give export_value or options, not both");
}
// Refuse a wrong type/index before any other requested property is written.
if let Some((wi, _)) = &props.caption {
if f.kind != FieldKind::PushButton {
@@ -120,6 +159,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) {
return invalid(format!("{name} is locked: unlock it first"));
}
if let Some(options) = export_options.as_ref()
&& *props == (FieldProps { export_value: props.export_value.clone(), ..FieldProps::default() })
{
doc.update_dict(f.obj, |d| d.set(b"Opt".to_vec(), Object::Array(options.clone())))?;
return Ok(name.to_string());
}
if let Some(lock) = props.locked {
for w in &f.widgets {
let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0);
@@ -209,6 +254,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
// The export values in `/Opt` order once it is rewritten, for re-pointing `/I`.
let mut new_exports: Option<Vec<String>> = None;
let mut kept = Vec::new();
if let Some(options) = &export_options {
d.set(b"Opt".to_vec(), Object::Array(options.clone()));
}
if let Some(opts) = &props.options {
d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect()));
new_exports = Some(opts.clone());
+42 -4
View File
@@ -17,18 +17,24 @@ const MAX_LINKS: usize = 1 << 20;
pub struct CalculatedField {
/// The field's full name.
pub name: String,
/// The field object. Two calculated fields can share a name in a malformed form; this tells
/// them apart.
pub obj: ObjRef,
/// The fields its formula reads, each once, as the formula names them: a name also stands for
/// the fields under it. A script's names built at run time (`getField("row" + i)`) aren't here.
pub reads: Vec<String>,
/// `/CO` lists it. A field it leaves out calculates after the listed ones.
pub listed: bool,
/// What is wrong with its place in the order.
/// What is wrong with its formula or its place in the order.
pub issues: Vec<CalculationIssue>,
}
/// Something wrong with a calculated field's place in the calculation order.
/// Something wrong with a calculated field's formula or its place in the calculation order.
#[derive(Clone, Debug, PartialEq)]
pub enum CalculationIssue {
/// It reads `field`, which the form doesn't have (a field deleted, renamed outside PdfCraft,
/// or mistyped), so the formula gets no value there.
MissingField { field: String },
/// It reads `field`, which calculates after it, so it uses that field's previous value.
RunsTooEarly { field: String },
/// Its formula reads its own result: it reads `fields`, which read it back (itself, when it
@@ -37,6 +43,9 @@ pub enum CalculationIssue {
/// `/CO` leaves it out. PdfCraft calculates it after the listed fields, but other PDF viewers
/// may not calculate it at all.
NotInOrder,
/// Its script also reads fields by names it builds at run time (`getField("row" + i)`). Those
/// aren't in `reads`, so the order isn't checked against them.
BuiltNames,
}
/// A form's calculation order and what is wrong with it.
@@ -152,11 +161,14 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
.collect();
let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads);
let component = components(&links);
let mut all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
all.sort_unstable();
let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default();
let mut out = Vec::with_capacity(calculated.len());
for (k, ((f, listed), reads)) in calculated.iter().zip(reads).enumerate() {
let own = component.get(k).copied();
let mut issues = Vec::new();
let mut issues: Vec<CalculationIssue> =
reads.iter().filter(|name| !found(&all, name)).map(|name| CalculationIssue::MissingField { field: name.clone() }).collect();
let read = links.get(k).map(Vec::as_slice).unwrap_or_default();
for &j in read {
if j > k && component.get(j).copied() != own {
@@ -172,12 +184,25 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
if !listed {
issues.push(CalculationIssue::NotInOrder);
}
out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues });
if f.actions.scripts.calculate.as_deref().is_some_and(crate::af::reads_built_names) {
issues.push(CalculationIssue::BuiltNames);
}
out.push(CalculatedField { name: f.name.clone(), obj: f.obj, reads, listed: *listed, issues });
}
let fixed = fixed_order(&links, &component);
CalculationOrder { fields: out, extra, fixed }
}
/// Whether a formula's read `name` finds a field in `names` (sorted): the field of that name or a
/// field under it (`row` for `row.1`), as [`links`], [`crate::recalculate`] and the scripts'
/// `getField` look names up. Acrobat's widget names (`price.0`) aren't fields to any of them, so
/// a read of one gets no value and is missing too.
fn found(names: &[&str], name: &str) -> bool {
let prefix = format!("{name}.");
let under = names.partition_point(|n| *n < prefix.as_str());
names.binary_search(&name).is_ok() || names.get(under).is_some_and(|n| n.starts_with(&prefix))
}
/// For each calculated field (by its place in `names`), the places of the calculated fields it
/// reads, sorted. A read name finds the field of that name and the fields under it, as
/// [`crate::recalculate`] looks values up.
@@ -383,6 +408,19 @@ mod tests {
assert_eq!(links(names.into_iter(), &reads)[5], [0, 1, 2], "row, row.1 and row.10; not rows or row-1");
}
#[test]
fn a_read_name_finds_a_field_or_a_group() {
let mut names = vec!["price", "row.1", "row.2", "rows", "total"];
names.sort_unstable();
for name in ["price", "row", "row.1", "rows"] {
assert!(found(&names, name), "{name}");
}
// A widget name finds nothing: recalculation and getField don't resolve it either.
for name in ["pric", "ro", "row.3", "price.", "price.0", "price.12", "total.0.1", ""] {
assert!(!found(&names, name), "{name}");
}
}
/// Two thousand formulas each reading a group of two thousand fields would be four million
/// reads; the checks stop at their cap.
#[test]
+9
View File
@@ -220,6 +220,15 @@ impl Field {
self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state)
}
/// Values for data export, resolving button appearance names through /Opt.
pub fn export_values(&self) -> Vec<String> {
if matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) {
self.value.iter().map(|v| self.export_for_state(v).to_string()).collect()
} else {
self.value.clone()
}
}
/// The value as one string: text, the state name, or the selected display texts.
pub fn display_value(&self) -> String {
match self.kind {
+129
View File
@@ -1711,6 +1711,73 @@ fn the_calculation_order_is_checked_fixed_and_kept() {
assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total");
}
/// `set_props` refuses a notation it couldn't store as written before it writes anything: the
/// tooltip given with it, which is written before the calculation, isn't set either.
#[test]
fn a_notation_that_cannot_be_stored_changes_nothing() {
use crate::af::Calculate;
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(),
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (total) /Rect [50 700 140 711] /P 3 0 R >>".into(),
];
let mut doc = document(&objs);
for n in ["", "a EVCALC b", "a */ b", "a\\"] {
let props = FieldProps { tooltip: Some("Total".into()), calculate: Some(Calculate::Notation(n.into())), ..Default::default() };
assert!(set_props(&mut doc, "total", &props).is_err(), "{n:?}");
let all = fields(&doc);
let f = field(&all, "total");
assert_eq!((f.tooltip.as_deref(), &f.actions.calculate), (None, &Calculate::None), "{n:?} changed nothing");
}
}
/// A formula that reads a field the form doesn't have is flagged, whichever kind of formula it
/// is: a field deleted from under a sum, a typo in notation, a script's `getField`. So is a
/// script that builds the names it reads. Neither is an order Fix order can fix.
#[test]
fn missing_fields_and_built_names_are_flagged() {
use crate::af::{CalcOp, Calculate};
use crate::calc_order::CalculationIssue::{BuiltNames, MissingField};
let mut doc = one_page();
let text = NewField::Text { multiline: false };
for (i, name) in ["qty", "gone", "sum", "notation", "script", "built"].into_iter().enumerate() {
let y = 700.0 - 40.0 * i as f64;
add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap();
}
let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() };
set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "gone".into()] })).unwrap();
set_props(&mut doc, "notation", &calculate(Calculate::Notation("qty * nope".into()))).unwrap();
// qty has one widget, but a widget name is no field: recalculation and getField give qty.0
// nothing, so it is as missing as qty.1.
let script = "event.value = getField(\"qty\").value + getField(\"ghost\").value + getField(\"qty.0\").value + getField(\"qty.1\").value;";
set_field_script(&mut doc, "script", "calculate", Some(script)).unwrap();
set_field_script(&mut doc, "built", "calculate", Some("event.value = getField(\"qty\").value + getField(\"row\" + 1).value;")).unwrap();
let issues = |doc: &Document| {
let order = calculation_order(doc, &fields(doc));
assert!(!order.fixable(), "nothing Fix order can fix");
order.fields.into_iter().map(|f| (f.name, f.issues)).collect::<Vec<_>>()
};
let expected = |sum: Vec<_>| {
vec![
("sum".to_owned(), sum),
("notation".to_owned(), vec![MissingField { field: "nope".into() }]),
(
"script".to_owned(),
vec![MissingField { field: "ghost".into() }, MissingField { field: "qty.0".into() }, MissingField { field: "qty.1".into() }],
),
("built".to_owned(), vec![BuiltNames]),
]
};
assert_eq!(issues(&doc), expected(vec![]));
delete_field(&mut doc, "gone").unwrap();
assert_eq!(issues(&doc), expected(vec![MissingField { field: "gone".into() }]), "a deleted field leaves the sum reading nothing");
assert_eq!(issues(&reopen(&doc)), expected(vec![MissingField { field: "gone".into() }]));
let order = calculation_order(&doc, &fields(&doc));
assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read");
}
/// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an
/// entry repeated, a reference to nothing and a number. The order reads around them, and setting
/// the order writes `/CO` afresh.
@@ -1775,6 +1842,12 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() {
};
let row = |name: &str, reads: &str, ok: bool| (name.to_owned(), vec![reads.to_owned()], ok);
assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)]);
// Each row is its own field object, so a caller can find the right formula for each dup.
let all = fields(&doc);
for c in calculation_order(&doc, &all).fields {
let f = all.iter().find(|f| f.obj == c.obj).unwrap();
assert_eq!((&f.name, f.actions.calculate.reads()), (&c.name, c.reads.clone()));
}
let current: Vec<String> = ["dup", "src", "dup"].map(String::from).into();
set_calculation_order(&mut doc, &current).unwrap();
assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)], "the current order sets back unchanged");
@@ -5356,3 +5429,59 @@ endstream".into(),
assert!(f.value.iter().all(String::is_empty), "{kind:?}: reset leaves it off, not the parent's Yes: {:?}", f.value);
}
}
#[test]
fn editing_button_export_values_preserves_selection_and_appearance() {
let mut doc = fixture();
set_value(&mut doc, "size", &FieldValue::Radio(Some("S".into()))).unwrap();
let before = field(&fields(&doc), "size").clone();
let appearances: Vec<_> = before.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect();
set_props(&mut doc, "size", &FieldProps { export_value: Some((0, "Small å".into())), ..Default::default() }).unwrap();
let after = field(&fields(&doc), "size").clone();
assert_eq!(after.value, before.value);
assert_eq!(after.default, before.default);
assert_eq!(after.widgets, before.widgets);
assert_eq!(after.export_of(0), Some("Small å"));
assert_eq!(after.export_of(1), before.export_of(1));
assert_eq!(after.export_values(), vec!["Small å"]);
assert_eq!(appearances, after.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect::<Vec<_>>());
let mut saved = reopen(&doc);
assert_eq!(field(&fields(&saved), "size").export_of(0), Some("Small å"));
set_value(&mut saved, "size", &FieldValue::Radio(Some("Small å".into()))).unwrap();
reset(&mut saved, None).unwrap();
assert_eq!(field(&fields(&saved), "size").value, before.default);
set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap();
set_props(&mut doc, "agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap();
let check = field(&fields(&doc), "agree").clone();
assert_eq!(check.value, vec!["Yes"]);
assert_eq!(check.export_values(), vec!["Accepted"]);
assert_eq!(field(&fields(&reopen(&doc)), "agree").export_of(0), Some("Accepted"));
}
#[test]
fn invalid_button_export_edits_leave_document_unchanged() {
let mut doc = fixture();
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
for (field_name, index, value) in [("size", 99, "new"), ("size", 0, ""), ("size", 0, "Off"), ("size", 0, "L"), ("name", 0, "new")] {
assert!(set_props(&mut doc, field_name, &FieldProps { export_value: Some((index, value.into())), ..Default::default() }).is_err());
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
}
}
#[test]
fn locked_button_export_edits_leave_the_document_unchanged() {
for name in ["agree", "size"] {
let mut doc = fixture();
set_props(&mut doc, name, &FieldProps { locked: Some(true), ..Default::default() }).unwrap();
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
let error = set_props(
&mut doc,
name,
&FieldProps { export_value: Some((0, "Changed".into())), tooltip: Some("Must not change".into()), ..Default::default() },
)
.unwrap_err();
assert!(error.to_string().contains("locked"));
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
}
}