mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 06:28:49 -06:00
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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¤t).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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user