M6: calculations read their notation only from a comment, and a regex after a condition as a regex (#3993)

* M6: calculations read their notation only from a comment, and a regex after a condition as a regex

Two findings from CodeRabbit's review of #3550, both reproduced first:

- parse_calculate and calculation_sites found BVCALC and EVCALC anywhere
  in the script, so `var s = "BVCALC a EVCALC";` read as the notation `a`:
  renaming `a` rewrote the string, and the form calculated `a` instead of
  running the script. The lexer now keeps the text of each comment, and
  the notation is read from the first comment that holds BVCALC and the
  EVCALC after it (notation_range), by both the Calculate tab and a
  rename. A line comment counts, and a comment that never ends runs to
  the end of the script, as before.
- A `/` after any `)` divided, so after the condition of `if`, `while`,
  `for` or `with` a regular expression was read as code: renaming `a`
  rewrote `if (flag) /getField("a")/.test(x)`, and in
  `if (flag) /"/.test(x)` the quote started a string that hid the
  script's real getField calls. The lexer now notes which `(` opened
  such a condition (not a method of that name, `o.if(x)`), and a `/`
  after its `)` starts a regular expression.

af::tests::notation_is_read_only_from_a_comment,
af::tests::a_regex_after_a_condition_is_not_read_as_code and
tests::a_script_that_spells_the_markers_in_a_string_runs_as_a_script
fail without the fix. Parity, ROADMAP.md and docs/roadmap.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z

* M6: form.props-calculate notes that a regex right after a brace is still read as code

From CodeRabbit's review of this PR. The lexer still divides after `}`,
which ends a block as often as an object literal, so in
`if (x) { } /"/.test(s); event.value = getField("a").value;` the quote
starts a string and the getField is hidden from a rename and from the
calculation order. Checked with a scratch test before writing it down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z

* M6: a line comment ends at any line terminator, so code after `\r` is still read

From CodeRabbit's review of this PR. A `//` comment ran to the next `\n`,
so with `\r` line endings it hid the rest of the script: a rename missed
the getField calls after it (since #3550), and a string after it that
spells BVCALC ... EVCALC read as the notation. A `/` taken for the start
of a regular expression likewise looked past `\r` for its end, and could
swallow the next line's code.

Both now stop at any of JavaScript's line terminators (`\n`, `\r`,
U+2028, U+2029; line_ends_at), and U+2028 and U+2029 between tokens are
skipped rather than read as the start of a word.

af::tests::a_line_comment_ends_at_any_line_terminator fails without each
of the three changes: with `\r`, the string read as notation `a`; with
the comment fixed but not the regex scan, `i++ / 2;\r` swallowed the
getField after it; without skipping U+2028, the getField after it was
not a call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z

* M6: a word, number or regex's flags end at U+2028 and U+2029

From CodeRabbit's review of this PR. The scans for an identifier, a
number and a regular expression's flags took every byte past ASCII as
part of a word, U+2028 and U+2029 included, so in `x\u{2028}getField("a")`
the getField was part of one word and not a call: a rename missed it, and
the calculation order didn't read it.

`word` now takes a position and is false where line_ends_at is true, so
the three scans share one rule. The arm that skips U+2028 and U+2029
between tokens stays: without it, the punctuation arm would step one byte
into the character.

af::tests::a_token_ends_at_a_line_separator fails without the fix, for a
word, a number and a regex's flags alike; it and
a_line_comment_ends_at_any_line_terminator fail without the skipping arm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z

* M6: the `while` that ends a `do` loop counts as a condition, said and tested

A `/` after the `)` of a do-while starts the next statement, so it starts a
regular expression there too. The comment on BEFORE_CONDITION says so, and
a_regex_after_a_condition_is_not_read_as_code covers it (from review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Piohrun
2026-10-23 16:42:48 +02:00
committed by GitHub
parent 7169160df9
commit ba01244987
5 changed files with 185 additions and 33 deletions
+1
View File
@@ -207,6 +207,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-17 (M8, gap 6, #2662):** A filled cover over a long line counts when it hides half of any one word, not only 40% of the line's words or box, so a black box over one number in a long line — or an unapplied redaction mark over it — no longer audits clean while the number is still extractable; a sliver under half of every word it touches and under 40% of the line is still not flagged
- **2026-10-13 (M10, #1757):** Office export keeps each dotted-leader contents entry in one paragraph, with its page number, instead of inferring table cells from its separate text objects.
- **2026-10-22 (M6, calculation scripts):** The Calculate tab and a rename read simplified field notation only from a comment, where Acrobat writes it: a string that spells `BVCALC … EVCALC` is no longer taken for the notation, or rewritten by a rename. A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, so a rename no longer rewrites a name inside one, and a quote inside one no longer hides the `getField` calls after it. Both found in review of #3550. A `//` comment ends at `\r`, U+2028 or U+2029 as well as `\n`, so in a script with `\r` line endings the code after one is no longer hidden from a rename, or taken for the notation. Estimates unchanged.
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
- **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged.
+154 -29
View File
@@ -379,10 +379,12 @@ pub fn parse_validate(js: &str) -> Option<Validate> {
}
pub fn parse_calculate(js: &str) -> Option<Calculate> {
// Simplified field notation: Acrobat keeps the expression in a comment.
if let (Some(a), Some(b)) = (js.find("BVCALC"), js.find("EVCALC")) {
let expr = js.get(a + 6..b)?.trim().to_string();
return Some(Calculate::Notation(expr));
// Simplified field notation: Acrobat keeps the expression in a comment. A script is read for
// its comments only when it spells the marker.
if js.contains("BVCALC")
&& let Some(range) = notation_range(js, &js_tokens(js).1)
{
return Some(Calculate::Notation(js.get(range)?.trim().to_string()));
}
let args = call(js, "AFSimple_Calculate")?;
let op = match args.first().and_then(Arg::string)?.as_str() {
@@ -479,21 +481,41 @@ enum Js {
Other,
}
/// A [`Js`] token with its byte range in the script.
type Token = (Range<usize>, Js);
/// Words after which `/` starts a regular expression instead of dividing.
const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"];
/// JavaScript's tokens with their byte ranges, comments left out: enough of the language to tell
/// code from strings, comments and regular expressions. The tokens stop where a string, comment
/// or template literal never ends.
fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a
/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so
/// a `/` after it starts the next statement, and can't divide.
const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"];
/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029.
fn line_ends_at(s: &[u8], j: usize) -> bool {
matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..]))
}
/// JavaScript's tokens with their byte ranges, and apart from them the text of each comment
/// (inside its `//`, or its `/*` and `*/`): enough of the language to tell code from strings,
/// comments and regular expressions. The tokens stop where a string, comment or template literal
/// never ends; a comment that never ends runs to the end of the script.
fn js_tokens(js: &str) -> (Vec<Token>, Vec<Range<usize>>) {
let s = js.as_bytes();
// Bytes past ASCII are parts of identifiers, so a range never ends inside a character.
let word = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii();
let mut out: Vec<(Range<usize>, Js)> = Vec::new();
// Whether `s[j]` is part of a word. Bytes past ASCII are parts of identifiers, except
// U+2028 and U+2029, which end a line: a range never ends inside a character.
let word = |j: usize| s.get(j).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()) && !line_ends_at(s, j);
let mut out: Vec<Token> = Vec::new();
let mut comments = Vec::new();
let mut i = 0;
// Where a regular expression that never ended stopped being read: every `/` before it
// divides, so a line of them is read once, not once for each.
let mut divides_until = 0;
// For each `(` still open, whether it opened the condition of `if`, `while`, `for` or
// `with`; and whether the last token is the `)` that closed one.
let mut conditions: Vec<bool> = Vec::new();
let mut after_condition = false;
'scan: while let Some(&c) = s.get(i) {
let start = i;
let token = match c {
@@ -501,16 +523,27 @@ fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
i += 1;
continue;
}
// U+2028 or U+2029 between tokens ends a line, and is skipped whole: read a byte at a
// time, the rest of it would be read from inside the character.
0xE2 if line_ends_at(s, i) => {
i += 3;
continue;
}
b'/' if s.get(i + 1) == Some(&b'/') => {
i = s.iter().skip(i).position(|b| *b == b'\n').map_or(s.len(), |n| i + n);
i = (i + 2..s.len()).find(|&j| line_ends_at(s, j)).unwrap_or(s.len());
comments.push(start + 2..i);
continue;
}
b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) {
Some(n) => {
comments.push(i + 2..i + 2 + n);
i += n + 4;
continue;
}
None => break 'scan,
None => {
comments.push(i + 2..s.len());
break 'scan;
}
},
b'"' | b'\'' => {
let mut p = Parser { s, i, depth: 0 };
@@ -531,13 +564,14 @@ fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
i = j + 1;
Js::Other
}
b'/' if i >= divides_until && regex_may_follow(js, &out) => {
b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => {
// A regular expression ends at a `/` outside a class; a line break first means it
// was a division after all.
let (mut j, mut class) = (i + 1, false);
let end = loop {
match s.get(j) {
None | Some(b'\n') => break None,
None => break None,
Some(_) if line_ends_at(s, j) => break None,
Some(b'\\') => j += 2,
Some(b'/') if !class => break Some(j),
Some(b) => {
@@ -553,7 +587,7 @@ fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
match end {
Some(e) => {
i = e + 1;
while s.get(i).is_some_and(|b| word(*b)) {
while word(i) {
i += 1;
}
Js::Other
@@ -566,13 +600,13 @@ fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
}
}
_ if c.is_ascii_digit() => {
while s.get(i).is_some_and(|b| word(*b) || *b == b'.') {
while word(i) || s.get(i) == Some(&b'.') {
i += 1;
}
Js::Other
}
_ if word(c) => {
while s.get(i).is_some_and(|b| word(*b)) {
_ if word(i) => {
while word(i) {
i += 1;
}
Js::Ident
@@ -582,22 +616,52 @@ fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
Js::Punct(char::from(c))
}
};
after_condition = match token {
Js::Punct('(') => {
conditions.push(opens_condition(js, &out));
false
}
Js::Punct(')') => conditions.pop().unwrap_or(false),
_ => false,
};
out.push((start..i, token));
}
out
(out, comments)
}
/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening
/// bracket or a keyword such as `return`, not after a value.
fn regex_may_follow(js: &str, tokens: &[(Range<usize>, Js)]) -> bool {
/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not
/// after a value. `after_condition` says the last token is the `)` that ends such a condition.
fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool {
match tokens.last() {
None => true,
Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)),
Some((_, Js::Punct(c))) => !matches!(c, ')' | ']' | '}'),
Some((_, Js::Punct(')'))) => after_condition,
Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'),
Some((_, Js::Str(_) | Js::Other)) => false,
}
}
/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not
/// the arguments of a method that has one of those names (`o.if(x)`).
fn opens_condition(js: &str, tokens: &[Token]) -> bool {
let mut back = tokens.iter().rev();
let Some((range, Js::Ident)) = back.next() else { return false };
js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.'))))
}
/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the
/// `EVCALC` after it, in the first of `comments` that holds both, as Acrobat writes it
/// (`/** BVCALC a + b EVCALC **/`). A string or code that spells the markers is not the notation.
fn notation_range(js: &str, comments: &[Range<usize>]) -> Option<Range<usize>> {
comments.iter().find_map(|comment| {
let text = js.get(comment.clone())?;
let a = text.find("BVCALC")? + 6;
let b = a + text.get(a..)?.find("EVCALC")?;
Some(comment.start + a..comment.start + b)
})
}
/// A place where a Calculate script names a field.
#[derive(Clone, Debug, PartialEq)]
enum Site {
@@ -635,22 +699,22 @@ impl Site {
}
}
/// Where a Calculate script names fields, in order: the simplified field notation between
/// `BVCALC` and `EVCALC` (where [`parse_calculate`] reads it), the field list of
/// 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.
fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
let mut sites = Vec::new();
if let (Some(a), Some(b)) = (js.find("BVCALC"), js.find("EVCALC"))
&& let Some(expr) = js.get(a + 6..b)
let (tokens, comments) = js_tokens(js);
if let Some(at) = notation_range(js, &comments)
&& let Some(expr) = js.get(at.clone())
{
for (range, token) in notation_tokens(expr) {
if let Notation::Name(name) = token {
sites.push((a + 6 + range.start..a + 6 + range.end, Site::Notation(name)));
sites.push((at.start + range.start..at.start + range.end, Site::Notation(name)));
}
}
}
let tokens = js_tokens(js);
let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word));
let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c));
let string = |k: usize| match tokens.get(k) {
@@ -1522,6 +1586,67 @@ mod tests {
}
}
/// The notation is read only from a comment, where Acrobat writes it: a string or code that
/// spells the markers is left alone, by the Calculate tab and by a rename.
#[test]
fn notation_is_read_only_from_a_comment() {
let string = "var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;";
assert_eq!(parse_calculate(string), None);
assert_eq!(rename_in_calculation(string, "a", "qty"), None);
// A comment after such a string holds the notation.
let both = "var s = 'BVCALC x EVCALC'; /** BVCALC a + 1 EVCALC **/ event.value = 1;";
assert_eq!(parse_calculate(both), Some(Calculate::Notation("a + 1".into())));
assert_eq!(rename_in_calculation(both, "x", "y"), None);
assert_eq!(rename_in_calculation(both, "a", "qty"), Some(both.replace("a + 1", "qty + 1")));
// A line comment is a comment too, and an unfinished one still holds it.
assert_eq!(parse_calculate("// BVCALC a EVCALC\nevent.value = 1;"), Some(Calculate::Notation("a".into())));
assert_eq!(parse_calculate("/** BVCALC a EVCALC"), Some(Calculate::Notation("a".into())));
}
/// A line comment ends at any of JavaScript's line terminators, not only `\n`, so with `\r` line
/// endings the code after one is still code; and a `/` read as starting a regular expression
/// divides after all when the line ends first.
#[test]
fn a_line_comment_ends_at_any_line_terminator() {
for end in ["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"] {
let string = format!("// header{end}var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;");
assert_eq!(parse_calculate(&string), None, "{end:?}");
assert_eq!(rename_in_calculation(&string, "a", "qty"), None, "{end:?}");
let script = format!("// note{end}getField(\"a\").value;");
assert_eq!(rename_in_calculation(&script, "a", "qty"), Some(script.replace("\"a\"", "\"qty\"")), "{end:?}");
let divides = format!("var n = i++ / 2;{end}event.value = getField(\"a\").value / 4;");
assert_eq!(rename_in_calculation(&divides, "a", "qty"), Some(divides.replace("\"a\"", "\"qty\"")), "{end:?}");
}
}
/// U+2028 and U+2029 end a word, a number or a regular expression's flags, though as bytes
/// past ASCII they could be part of one, so a `getField` right after one is still a call.
#[test]
fn a_token_ends_at_a_line_separator() {
for end in ["\u{2028}", "\u{2029}"] {
let scripts = ["x", "1", "/r/g"].map(|token| format!("var t = {token}{end}getField(\"a\").value;"));
let renamed = scripts.clone().map(|js| rename_in_calculation(&js, "a", "qty"));
assert_eq!(renamed, scripts.map(|js| Some(js.replace("\"a\"", "\"qty\""))), "{end:?}");
}
}
/// A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression,
/// as after an operator; after a call's parentheses it still divides.
#[test]
fn a_regex_after_a_condition_is_not_read_as_code() {
let renamed = |js: &str| rename_in_calculation(js, "a", "qty");
assert_eq!(renamed("if (flag) /getField(\"a\")/.test(x); event.value = 1;"), None);
// A quote inside it doesn't start a string that hides the rest of the script.
let quote = "while (i--) /\"/.test(s); event.value = getField(\"a\").value;";
assert_eq!(renamed(quote), Some(quote.replace("\"a\"", "\"qty\"")));
// The same after the `while` that ends a `do` loop.
let after_do = "do i++; while (i < n) /\"/.test(s); event.value = getField(\"a\").value;";
assert_eq!(renamed(after_do), Some(after_do.replace("\"a\"", "\"qty\"")));
// After a call, or a method that happens to be called `if`, the `/` divides.
let divides = "event.value = Math.abs(getField(\"b\").value) / 2 + getField(\"a\").value / 3 + o.if(x) / 4 + getField(\"a\").value / 5;";
assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\"")));
}
/// 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]
+24
View File
@@ -1289,6 +1289,30 @@ fn formats_validation_and_calculations_run_like_acrobat() {
assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None);
}
/// A Calculate script that spells the notation's markers in a string, not in a comment, is kept
/// as a script and runs: the form used to calculate the string's notation instead.
#[test]
fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() {
struct Ran;
impl Scripts for Ran {
fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: "ran".into(), changes: Vec::new(), message: None }
}
}
let mut doc = one_page();
let text = NewField::Text { multiline: false };
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &text, Some("a")).unwrap();
add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &text, Some("total")).unwrap();
let js = "var note = \"BVCALC a EVCALC\"; event.value = 7;";
set_field_script(&mut doc, "total", "calculate", Some(js)).unwrap();
set_value(&mut doc, "a", &FieldValue::Text("5".into())).unwrap();
let all = fields(&doc);
let total = &field(&all, "total").actions;
assert_eq!((&total.calculate, total.scripts.calculate.as_deref()), (&af::Calculate::None, Some(js)));
recalculate_with(&mut doc, &mut Ran).unwrap();
assert_eq!(field(&fields(&doc), "total").value, ["ran"]);
}
/// Renaming a field renames it in the calculations that read it: a sum dropped it, notation
/// read it as 0 and a script read a field that no longer existed.
#[test]
+2
View File
@@ -17,6 +17,7 @@
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged)
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M10: dotted-leader contents entries export as complete paragraphs; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M6: calculation scripts read their notation only from a comment, and a regular expression after a condition as one; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** patch (M2 #3086: dotted TOC leaders no longer hide word spaces; estimates unchanged)
@@ -244,6 +245,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. |
| 2026-10-17 | minor | M8, gap 6 #2662: a filled cover that hides half of any one word of a long line counts as a redaction-audit finding, applied or not; a sliver under half of every word stays unflagged. Estimates unchanged. |
| 2026-10-13 | minor | M10: dotted-leader contents entries retain titles and page numbers in separate paragraphs (#1757); estimates unchanged. |
| 2026-10-22 | minor | M6: calculation scripts: simplified field notation is read only from a comment, and a regular expression after `if (…)`, `while (…)`, `for (…)` or `with (…)` as one, so a rename leaves strings and regular expressions alone; a `//` comment ends at any line terminator, not only `\n`. Estimates unchanged. |
| 2026-10-20 | minor | M6: Prepare a form ▸ Calculation order: formulas and values, what is wrong and Fix order, Up/Down, Alt+↑/↓ and drag, links on the page. Estimates unchanged. |
| 2026-10-20 | minor | M6: `form_calculation_order` reads, checks, fixes and sets the calculation order; an edited formula keeps its place in `/CO`. Estimates unchanged. |
| 2026-10-20 | minor | M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections, or a predefined non-Identity CMap; unknown names and foreign registries do not establish a mapping route. Synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph validation and embedded-font extraction remain separate; estimates unchanged. |
+4 -4
View File
@@ -4596,8 +4596,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props", "form_fill"]
evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"]
notes = "BVCALC expressions with + - * / and parentheses, escaped names"
evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/af.rs::notation_is_read_only_from_a_comment"]
notes = "BVCALC expressions with + - * / and parentheses, escaped names, read from the script's comment as Acrobat writes it; a string or code that spells the markers is not notation"
[[feature]]
id = "form.custom-scripts"
@@ -4992,8 +4992,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props", "form_fill"]
evidence = ["crates/ui-egui/tests/it/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/it/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/it/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name"]
notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending. Renaming a field renames it in the calculations that read it (field lists, notation, and getField calls naming it in a string literal), in the rename's undo step; Field Properties says how many changed and form_set_props lists them. Not rewritten: names built at run time, other events' scripts, document-level scripts. Whether Acrobat does this is not observed."
evidence = ["crates/ui-egui/tests/it/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/it/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/it/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name", "crates/forms/src/af.rs::a_regex_after_a_condition_is_not_read_as_code", "crates/forms/src/af.rs::a_line_comment_ends_at_any_line_terminator", "crates/forms/src/af.rs::a_token_ends_at_a_line_separator"]
notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending. Renaming a field renames it in the calculations that read it (field lists, notation, and getField calls naming it in a string literal), in the rename's undo step; Field Properties says how many changed and form_set_props lists them. Not rewritten: names built at run time, other events' scripts, document-level scripts. A regular expression that starts a statement right after a `}` is still read as code, so a quote inside one can hide the getField calls after it from a rename and from the calculation order. Whether Acrobat does this is not observed."
[[feature]]
id = "form.props-selection-change"