From 606c560d240f82ae246b2b03f1c159a01b8b77ea Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 19:36:46 -0400 Subject: [PATCH 1/4] Use chrono instead of hand-rolled UTC conversion (#3824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil_from_days/days_from_civil copies and the leap-year/month math around them (log timestamp, PDF /Info dates, ASN.1 UTCTime/GeneralizedTime, FormCalc Date/Num2Date, the Combine modified-time stamp, the form date picker, and the demo-PDF timestamps) with chrono as a workspace dependency. The engine's existing chrono (clock) is lifted to the workspace. Every output format is unchanged and pinned by tests. CLAUDE.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Guard chrono date paths: no panic on overflow, no '+' on 5-digit years Systemic chrono follow-ups to the UTC/calendar PR: - logging::timestamp and combine_ui::utc_stamp dropped chrono's From (which unwraps internally and panics on a clock past year 262143) for the fallible DateTime::from_timestamp, reading an out-of-range clock as the epoch, as the old clamping code did. - timestamp, utc_stamp, pdf_date and the xtask Stamp now write the year with {:04} instead of %Y, which prefixes a '+' before years past 9999; the old hand-rolled code wrote the plain zero-padded year. - forms_ui calendar navigation and length, and formcalc days_in, use checked_add for the December year successor so y + 1 can't overflow at i32::MAX / i64::MAX (reachable from a parsed or persisted date). Docstrings added to the chrono-touched civil_from_days and Time::from_unix for the docstring-coverage gate. Tests cover the out-of-range-epoch and 10000-01-01 cases for each. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * Keep FormCalc dates exact past chrono's range MAX_DAYS accepts day numbers up to 1e8 (years past 270000), but NaiveDate stops near ±262143, so Num2Date(99000000) and Format fell back to 1900-01-01. days_from_civil, civil_from_days and days_in now have chrono convert within the date's 400-year Gregorian cycle and add whole cycles back (via i128), which gives the pre-chrono results for every year. Tests cover accepted inputs past chrono's range on both sides of the epoch, through Num2Date and Format, with values from the old formula. * Bump rustls-webpki and webpki-root-certs for RUSTSEC-2026-0382/0383 rustls-webpki 0.103.15 -> 0.103.16 and webpki-root-certs 1.0.9 -> 1.0.10 (both semver-compatible, lockfile only) drop the distrusted emSign intermediates and root. Only these two entries change; cargo deny check advisories passes. Upstream main fails the same check. * logging/cos/sign/combine/demo: keep extended-year dates, no epoch fallback For consistency with the stricter no-epoch-fallback rule now agreed across the sibling chrono PRs (wordcraft#3427, filmcraft#2662): `DateTime::from_timestamp(..).unwrap_or_default()` changes a timestamp the old code converted past chrono's year 262143 range — it collapses to 1970-01-01 instead of the real proleptic-Gregorian date. The FormCalc Num2Date path was already fixed (FredApps approved); this applies the same fix to every remaining conversion. Each site uses the 400-year Gregorian cycle (146_097 days) with chrono as the only calendar logic: chrono converts the day within one cycle, whole cycles carry in the year (i128, saturated into i64). PDF/ASN.1 format-specific year handling is kept exactly as the old code had it. Verified the cycle result equals the old formula across a wide range and at the extremes. - apps/pdfcraft/src/logging.rs `timestamp`: cycle `civil_from_days(u64)`; pre-1970 epoch clamp and ms/time-of-day format kept. Pins: +10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z (was 1970); checked_add u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch). - crates/cos/src/writer.rs `pdf_date` (i64): cycle converter, no year clamp (old code wrote `y` as-is, so a negative year keeps its sign). Pins: pdf_date(10_000_000_000_000)="D:3188570520174640Z", pdf_date(-10_000_000_000_000)="D:-3149180813061320Z". - crates/sign/src/der.rs `Time::from_unix` (i64): cycle converter; the `year` field is a u32, so the year is cast `as u32` (wrapping) exactly as the old code did — ASN.1 year width preserved byte-for-byte. Pins: from_unix(10e12).year=318857, from_unix(-10e12).year=4_294_652_378, from_unix(i64::MAX).year=219_250_468, from_unix(i64::MIN).year=4_075_720_767. - crates/ui-egui/src/combine_ui.rs `utc_stamp` (u64): cycle converter; pre-1970 epoch clamp kept. Pins: utc_stamp(10e12)="318857-05-20 17:46 UTC", u64::MAX/2 s="292277026596-12-04 15:30 UTC". - xtask/src/demo_pdf/pdf.rs `Stamp::from_unix` (i64): cycle converter, no year clamp. Pins: from_unix(10e12).iso="318857-05-20T17:46:40Z"/.pdf="D:3188570520174640Z", from_unix(-10e12).iso="-314918-08-13T06:13:20Z". cos/sign/xtask built, tested (pdf_dates_are_utc_and_byte_identical, der::tests::times, stamps_round_trip), fmt and clippy clean. pdfcraft-ui-egui and the pdfcraft app can't link here (unrelated LLVM intrinsic bug in the rten-gemm dependency, 1.98.1), so their logging and combine_ui functions + tests were copied verbatim into a scratch crate with chrono 0.4.45 and pass there; fmt --check clean on all five packages. CI builds these on all platforms. * Don't build a year-318857 SystemTime unchecked in the logging test Windows' SystemTime only reaches about year 30828, so UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before timestamp() ran and failed the Windows test job. Build it with checked_add and assert where the platform can hold it. The year-10000 case fits everywhere and stays unconditional. * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does. --- CLAUDE.md | 1 + Cargo.lock | 6 +++ Cargo.toml | 2 + apps/pdfcraft/Cargo.toml | 2 + apps/pdfcraft/src/logging.rs | 50 ++++++++++++------ crates/cos/Cargo.toml | 2 + crates/cos/src/writer.rs | 57 ++++++++++++++++----- crates/engine/Cargo.toml | 5 +- crates/js/Cargo.toml | 3 ++ crates/js/src/formcalc.rs | 88 +++++++++++++++++++++++--------- crates/js/src/tests.rs | 11 ++++ crates/sign/Cargo.toml | 2 + crates/sign/src/der.rs | 55 ++++++++++++++------ crates/ui-egui/Cargo.toml | 3 ++ crates/ui-egui/src/combine_ui.rs | 62 +++++++++++++++++----- crates/ui-egui/src/forms_ui.rs | 24 +++++---- xtask/Cargo.toml | 2 + xtask/src/demo_pdf/pdf.rs | 43 +++++++++++----- 18 files changed, 314 insertions(+), 104 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 12779b1e35..34f8ab85cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - No `unsafe` (`unsafe_code = "forbid"`). - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`). - **Rust only** in the product and build (`xtask`). No handwritten JS/TS. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening. - Tests mirror the zooms: property → unit → crate integration → end-to-end. - **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path. diff --git a/Cargo.lock b/Cargo.lock index 449b1f82b5..a439c0b805 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3940,6 +3940,7 @@ dependencies = [ name = "pdfcraft" version = "0.11.0" dependencies = [ + "chrono", "craft-quit", "eframe", "egui", @@ -4048,6 +4049,7 @@ dependencies = [ name = "pdfcraft-cos" version = "0.11.0" dependencies = [ + "chrono", "hayro-syntax", "pdfcraft-crypt", "pdfcraft-filters", @@ -4190,6 +4192,7 @@ name = "pdfcraft-js" version = "0.11.0" dependencies = [ "boa_engine", + "chrono", ] [[package]] @@ -4327,6 +4330,7 @@ dependencies = [ "bp256", "bp384", "cbc", + "chrono", "crypto-bigint", "des", "ecdsa", @@ -4362,6 +4366,7 @@ dependencies = [ "arboard", "base64 0.22.1", "bytemuck", + "chrono", "craft-quit", "craft-ui", "eframe", @@ -8035,6 +8040,7 @@ name = "xtask" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "lopdf", "pdfcraft-cos", "pdfcraft-oracle", diff --git a/Cargo.toml b/Cargo.toml index 18afce999f..0106433dfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,8 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" log = "0.4" kurbo = "0.13" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } # testing proptest = "1" diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 3ce1c96ef3..6943ad6e44 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -23,6 +23,8 @@ egui = { workspace = true } serde_json = { workspace = true } # Records from every crate go to standard error and the log file (src/logging.rs). log = { workspace = true } +# The log timestamp goes through chrono (no hand-rolled calendar code). +chrono = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] # Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs index 71ed1d2d2f..bef27de169 100644 --- a/apps/pdfcraft/src/logging.rs +++ b/apps/pdfcraft/src/logging.rs @@ -90,7 +90,10 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the +/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic +/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the +/// output never changes for a value the old formatter accepted and logging never panics. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let secs = since.as_secs(); @@ -99,20 +102,25 @@ pub fn timestamp(t: SystemTime) -> String { format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). `days` is at most -/// `u64::MAX / 86_400`, so none of the arithmetic below can overflow. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + u64::from(m <= 2); - (y, m, d) +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run +/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years +/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the +/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and +/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01 +/// is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -337,6 +345,18 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_767_225_599)), "2025-12-31T23:59:59.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date + // the old formatter gave, rather than falling back to the epoch. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index 9ef9126ca8..a2fb3f4c4e 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -12,6 +12,8 @@ workspace = true pdfcraft-crypt = { workspace = true } pdfcraft-filters = { workspace = true } thiserror = { workspace = true } +# PDF date strings (`pdf_date`) go through chrono (no hand-rolled calendar code). +chrono = { workspace = true } [dev-dependencies] pdfcraft-oracle = { workspace = true } diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 331f97d443..786dcd9683 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -578,22 +578,33 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { /// A PDF date string for "now" given seconds since the Unix epoch (UTC). pub fn pdf_date(unix_secs: i64) -> String { - let days = unix_secs.div_euclid(86_400); - let secs = unix_secs.rem_euclid(86_400); - // Civil-from-days (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; + let (days, secs) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400)); + let (y, m, d) = civil_from_days(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (and a PDF date is a + // bare four-plus-digit year, never signed). No year clamp — the old code wrote `y` as-is. format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60) } +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A `from_timestamp` epoch fallback would change dates past `NaiveDate`'s ±262143-year range, +/// which the old hand-rolled converter formatted directly. The Gregorian calendar repeats every +/// 400 years (146_097 days), so chrono converts the day within one cycle and whole cycles go back +/// into the year (an `i128` so the extremes can't overflow), keeping chrono as the only calendar +/// logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } +} + #[cfg(test)] mod tests { use proptest::prelude::*; @@ -656,6 +667,26 @@ mod tests { } } + #[test] + fn pdf_dates_are_utc_and_byte_identical() { + // `D:YYYYMMDDHHmmSSZ` (ISO 32000-2 §7.9.4), the same strings the hand-rolled code wrote. + assert_eq!(pdf_date(0), "D:19700101000000Z"); + assert_eq!(pdf_date(1_790_000_000), "D:20260921141320Z"); + // Leap days, including the 400-year rule, and the last second of a year. + assert_eq!(pdf_date(1_709_164_800), "D:20240229000000Z"); + assert_eq!(pdf_date(951_782_400), "D:20000229000000Z"); + assert_eq!(pdf_date(1_767_225_599), "D:20251231235959Z"); + // Before the epoch (seconds into a day are counted forward from midnight UTC). + assert_eq!(pdf_date(-1), "D:19691231235959Z"); + // Years past 9999 keep the plain zero-padded year (no `+`), as the hand-rolled code did. + assert_eq!(pdf_date(253_402_300_800), "D:100000101000000Z"); + // Past chrono's ±262143-year range the proleptic Gregorian date is kept, not the epoch. + // Pinned to the old hand-rolled output (which had no year clamp — a negative year keeps + // its sign, exactly as before). + assert_eq!(pdf_date(10_000_000_000_000), "D:3188570520174640Z"); + assert_eq!(pdf_date(-10_000_000_000_000), "D:-3149180813061320Z"); + } + #[test] fn reals_keep_full_precision_without_exponents() { for r in [0.1, -0.000_000_123_456_789, 1e-12, 123_456.789_012_345, 1.0 / 3.0, -2.5e7 + 0.25] { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ff5e7611d8..bd92d3dca4 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -48,5 +48,6 @@ icu_properties = "2.3" unicode-segmentation = "1.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -# Already in the graph through lopdf; used only for the local UTC offset ("today"). -chrono = { version = "0.4", default-features = false, features = ["clock"] } +# Already in the graph through lopdf; used only for the local UTC offset ("today"). The workspace +# entry is std-only; this target adds `clock` for `chrono::Local::now`. +chrono = { workspace = true, features = ["clock"] } diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index ee09b8a8e9..c0df850575 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -12,6 +12,9 @@ workspace = true [dependencies] boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b"] } +# FormCalc Date/Num2Date calendar conversion goes through chrono (no hand-rolled calendar code); +# std-only, so it stays wasm-safe. +chrono = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b", "js"] } diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index dc32d457bf..1ab7ca24e5 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1838,30 +1838,50 @@ impl Interp<'_> { // ── built-in functions ────────────────────────────────────────────────────────────────────── +/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so +/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568). +const FORMCALC_EPOCH_FROM_CE: i64 = 693_595; + +/// Days in one 400-year Gregorian cycle; the calendar repeats after it. +const DAYS_PER_400_YEARS: i64 = 146_097; + /// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. +/// +/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS` +/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date +/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian +/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated; +/// anything else reads as 0. fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - // Howard Hinnant's algorithm, epoch 1970-01-01, shifted to FormCalc's 1900-01-01 = 1. - let y = if m <= 2 { y - 1 } else { y }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = y - era * 400; - let mp = (i64::from(m) + 9) % 12; - let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let unix = era * 146_097 + doe - 719_468; - unix + 25_568 + use chrono::Datelike; + let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) + .and_then(|date| { + let days = i128::from(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS); + i64::try_from(days).ok() + }) + .unwrap_or(0) } +/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any +/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years. fn civil_from_days(n: i64) -> (i64, u32, u32) { - let z = n - 25_568 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - (if m <= 2 { y + 1 } else { y }, m, d) + use chrono::Datelike; + // Days since CE day 0 (0000-12-31), in i128 so no `n` overflows. + let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE); + let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); + // `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31). + let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + match date { + Some(date) => { + let y = i128::from(date.year()) + cycles * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) + } + // Unreachable: every `rest` in 0..146_097 is a valid CE day. + None => (1900, 1, 1), + } } /// Day numbers (and years) beyond this are refused rather than computed. @@ -1925,10 +1945,15 @@ fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { /// Number of days in month `m` (1-12) of year `y`, leap years included. fn days_in(y: i64, m: u32) -> u32 { - match m { - 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, + // The length of the month from chrono (the first of the next month, less the first of this + // one). An invalid month keeps the old 31-day default. + // Month lengths repeat every 400 years, so the year within its cycle is enough (and always + // in chrono's range, whatever `y` is). + let y = y.rem_euclid(400); + let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1)); + let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }; + match (first(y, m), next) { + (Some(a), Some(b)) => (b - a).num_days() as u32, _ => 31, } } @@ -2591,6 +2616,23 @@ mod tests { assert_eq!(days_from_civil(1900, 1, 1), 1); assert_eq!(days_from_civil(1970, 1, 1), 25_568); assert_eq!(civil_from_days(days_from_civil(2024, 2, 29)), (2024, 2, 29)); + // Past chrono's ±262143 the 400-year cycles carry the year; values from the pre-chrono + // formula, both directions, both sides of the epoch. + for (n, date) in [ + (99_000_000, (272_952, 10, 16)), + (99_999_999, (275_690, 9, 11)), + (-99_000_000, (-269_153, 3, 16)), + (-99_999_999, (-271_891, 4, 20)), + (72_354_905, (200_000, 12, 31)), + (-73_742_460, (-200_000, 1, 1)), + ] { + assert_eq!(civil_from_days(n), date, "{n}"); + assert_eq!(days_from_civil(date.0, date.1, date.2), n, "{date:?}"); + } + assert_eq!(days_in(272_952, 2), 29, "272952 is a leap year"); + assert_eq!(days_in(-269_153, 2), 28); + assert_eq!(days_in(i64::MAX, 12), 31); + let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1)); assert_eq!(parse_date("2001-02-03", "YYYY-MM-DD"), Some((2001, 2, 3))); assert_eq!(parse_date("Feb 3, 2001", "MMM D, YYYY"), Some((2001, 2, 3))); assert_eq!(parse_date("2/3/01", "M/D/YY"), Some((2001, 2, 3))); diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 61d3a71e51..f6bbdb3523 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -651,6 +651,17 @@ mod formcalc { assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x"); } + /// Accepted day numbers past chrono's own date range (about year 262143) keep the dates the + /// pre-chrono algorithm gave, on both sides of the epoch, through Num2Date and Format. + #[test] + fn date_numbers_beyond_chronos_range_keep_their_dates() { + assert_eq!(calc(r#"Num2Date(99000000, "YYYY-MM-DD")"#), "272952-10-16"); + assert_eq!(calc(r#"Format("YYYY-MM-DD", 99000000)"#), "272952-10-16"); + assert_eq!(calc(r#"Num2Date(99999999, "YYYY-MM-DD")"#), "275690-09-11"); + assert_eq!(calc(r#"Num2Date(-99000000, "YYYY-MM-DD")"#), "-269153-03-16"); + assert_eq!(calc(r#"Num2Date(-99999999, "YYYY-MM-DD")"#), "-271891-04-20"); + } + #[test] fn str_and_format_round_exact_ties_away_from_zero() { assert_eq!(calc("Str(2.5, 3, 0)"), " 3"); diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index 73210cff4e..d58811d552 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -15,6 +15,8 @@ pdfcraft-cos = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-fonts = { workspace = true } thiserror.workspace = true +# ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code). +chrono = { workspace = true } base64 = "0.22" sha1 = "0.11" sha2 = "0.11" diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs index 5c816eae9e..6248c1e9e1 100644 --- a/crates/sign/src/der.rs +++ b/crates/sign/src/der.rs @@ -285,6 +285,9 @@ fn contents_len(input: &[u8], form: Form, depth: usize) -> Result i64 { - let (y, m) = if self.month <= 2 { (self.year as i64 - 1, self.month as i64 + 9) } else { (self.year as i64, self.month as i64 - 3) }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * m + 2) / 5 + self.day as i64 - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days = era * 146_097 + doe - 719_468; + use chrono::Datelike; + // The calendar date goes through chrono; the time of day is a plain second count, as + // before. A date chrono cannot represent (an out-of-range field) reads as the epoch day, + // which no well-formed UTCTime/GeneralizedTime produces. + let days = i32::try_from(self.year) + .ok() + .and_then(|y| chrono::NaiveDate::from_ymd_opt(y, self.month, self.day)) + .map_or(0, |d| i64::from(d.num_days_from_ce()) - UNIX_EPOCH_DAYS_FROM_CE); days * 86_400 + self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64 } + /// The UTC time at `t` seconds since 1970-01-01. + /// + /// The calendar date goes through chrono's 400-year Gregorian cycle (146_097 days) so a `t` + /// past `NaiveDate`'s ±262143-year range keeps the proleptic Gregorian date the old hand-rolled + /// converter produced instead of falling back to the epoch. The `year` field is a `u32`, so the + /// year is cast exactly as the old code did (`as u32`, wrapping) — no well-formed signing time + /// is anywhere near that range, and this preserves byte-for-byte the old output everywhere. pub fn from_unix(t: i64) -> Time { - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = (doy - (153 * mp + 2) / 5 + 1) as u32; - let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let year = (yoe + era * 400 + if month <= 2 { 1 } else { 0 }) as u32; + use chrono::Datelike; + let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + let (year, month, day) = match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + // Match the old `as u32` cast: saturate i128→i64 first (the old year was i64), then wrap into u32. + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }) as u32, d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + }; Time { year, month, day, hour: (secs / 3600) as u32, minute: (secs % 3600 / 60) as u32, second: (secs % 60) as u32 } } @@ -591,5 +604,13 @@ mod tests { assert_eq!(Tlv::parse_all(&far.encode()).unwrap().tag, tag::GENERALIZED_TIME); assert_eq!(Time::from_pdf("D:20261002113005+02'00'"), Some(Time { year: 2026, month: 10, day: 2, hour: 9, minute: 30, second: 5 })); assert_eq!(Time::from_unix(0), Time { year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0 }); + // Past chrono's ±262143-year range `from_unix` keeps the proleptic Gregorian date the old + // hand-rolled converter gave, not the epoch. No real signing time is near this; the `year` + // field is a `u32`, so the year wraps exactly as the old `as u32` cast did (values pinned + // from the old formula). This also covers both i64 extremes so neither div_euclid panics. + assert_eq!(Time::from_unix(10_000_000_000_000), Time { year: 318857, month: 5, day: 20, hour: 17, minute: 46, second: 40 }); + assert_eq!(Time::from_unix(-10_000_000_000_000), Time { year: 4_294_652_378, month: 8, day: 13, hour: 6, minute: 13, second: 20 }); + assert_eq!(Time::from_unix(i64::MAX), Time { year: 219_250_468, month: 12, day: 4, hour: 15, minute: 30, second: 7 }); + assert_eq!(Time::from_unix(i64::MIN), Time { year: 4_075_720_767, month: 1, day: 27, hour: 8, minute: 29, second: 52 }); } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 42e9474f07..8b091945ab 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,9 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +# UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono +# (no hand-rolled calendar code); std-only, so it stays wasm-safe. +chrono = { workspace = true } # Hands rendered rasters to egui as its pixels, without copying; already in the tree through egui and wgpu. bytemuck = { version = "1", features = ["extern_crate_alloc"] } diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 6704ce2276..62d9163154 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -1861,24 +1861,37 @@ pub(crate) fn ago(time: SystemTime) -> String { } } -/// `time` as "2026-10-08 14:05 UTC". +/// `time` as "2026-10-08 14:05 UTC". A clock before 1970 reads as the epoch; past chrono's year +/// 262143 range the proleptic Gregorian date is kept via the 400-year cycle, not the epoch. fn utc_stamp(time: SystemTime) -> String { let secs = time.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let days = i64::try_from(secs / 86_400).unwrap_or(0); - let rem = secs % 86_400; - // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. format!("{y:04}-{m:02}-{d:02} {:02}:{:02} UTC", rem / 3600, rem % 3600 / 60) } +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A pre-1970 time already clamps to the epoch (`unwrap_or(0)`), so `days` is an unsigned count +/// that can run past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter still +/// produced a date. The Gregorian calendar repeats every 400 years (146_097 days), so chrono +/// converts the day within one cycle and whole cycles go back into the year (an `i128`, then +/// saturated into `i64`), keeping chrono as the only calendar logic and reproducing the old +/// result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } +} + impl PdfCraftApp { /// Show the Combine files tab (File ▸ Combine files…), opening it if needed. pub fn open_combine_tab(&mut self) { @@ -2596,6 +2609,29 @@ impl PdfCraftApp { mod tests { use super::*; + #[test] + fn utc_stamp_is_utc_minutes_and_clamps_pre_epoch() { + use std::time::Duration; + let at = |s: u64| utc_stamp(SystemTime::UNIX_EPOCH + Duration::from_secs(s)); + assert_eq!(at(0), "1970-01-01 00:00 UTC"); + assert_eq!(at(1_791_446_357), "2026-10-08 07:59 UTC"); + // The 400-year leap rule. + assert_eq!(at(951_782_400), "2000-02-29 00:00 UTC"); + // A clock before 1970 reads as the epoch, as the old `unwrap_or(0)` did. + assert_eq!(utc_stamp(SystemTime::UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01 00:00 UTC"); + // Past chrono's range keeps the proleptic Gregorian date the old code gave, not the epoch. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(utc_stamp(far), "318857-05-20 17:46 UTC"); + } + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(utc_stamp(far), "292277026596-12-04 15:30 UTC"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(at(253_402_300_800), "10000-01-01 00:00 UTC"); + } + /// A one-page PDF that opens with "pw". fn locked() -> Vec { let objs = [ diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 612b985e65..27c97a240e 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -290,19 +290,25 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st .strong(), ); if ui.small_button("›").on_hover_text(tl!("Next month")).clicked() { - (y, m) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + // `y + 1` would overflow at `i32::MAX` (a date parsed or persisted with + // that year); stay put on December of the last year instead of panicking. + (y, m) = if m == 12 { y.checked_add(1).map_or((y, m), |next| (next, 1)) } else { (y, m + 1) }; } }); // Weekday of the 1st (0 = Sunday), Zeller-style via days since 1970-01-01 (a Thursday). - let days_from_civil = |y: i32, m: u32, d: u32| -> i64 { - let (y, m) = if m <= 2 { (y as i64 - 1, m as i64 + 9) } else { (y as i64, m as i64 - 3) }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * m + 2) / 5 + d as i64 - 1; - era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468 + use chrono::Datelike; + // The month the calendar shows: the weekday of its 1st (0 = Sunday) and its + // length, both from chrono. A year chrono can't represent falls back to a plain + // 31-day month starting on Sunday, which only the arrows can reach. + let first_of = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1); + let first = first_of(y, m).map_or(0, |d| d.weekday().num_days_from_sunday() as usize); + // `y + 1` for December would overflow at `i32::MAX`; a year chrono can't represent + // (or that overflows here) falls back to the plain 31-day month. + let next_y = if m == 12 { y.checked_add(1) } else { Some(y) }; + let len = match (first_of(y, m), next_y.and_then(|next| first_of(next, if m == 12 { 1 } else { m + 1 }))) { + (Some(a), Some(b)) => (b - a).num_days() as usize, + _ => 31, }; - let first = (days_from_civil(y, m, 1) + 4).rem_euclid(7) as usize; - let len = (days_from_civil(if m == 12 { y + 1 } else { y }, if m == 12 { 1 } else { m + 1 }, 1) - days_from_civil(y, m, 1)) as usize; egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] { ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small()); diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 7c6ca7aedb..7a33bad875 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -15,3 +15,5 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } sha2 = "0.11" toml = "1" +# The demo-PDF time stamp goes through chrono (no hand-rolled calendar code). +chrono = { workspace = true } diff --git a/xtask/src/demo_pdf/pdf.rs b/xtask/src/demo_pdf/pdf.rs index 297d4ce93a..b51ea1a643 100644 --- a/xtask/src/demo_pdf/pdf.rs +++ b/xtask/src/demo_pdf/pdf.rs @@ -75,22 +75,33 @@ impl Stamp { } pub fn from_unix(secs: i64) -> Self { - let days = secs.div_euclid(86_400); - let rem = secs.rem_euclid(86_400); + let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); let (h, mi, s) = (rem / 3600, rem % 3600 / 60, rem % 60); - // Civil-from-days (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); + let (y, m, d) = Self::civil_from_days(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. Stamp { unix: secs, pdf: format!("D:{y:04}{m:02}{d:02}{h:02}{mi:02}{s:02}Z"), iso: format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") } } + /// Days since 1970-01-01 → proleptic Gregorian (year, month, day). + /// + /// Past `NaiveDate`'s ±262143-year range the old hand-rolled converter still produced a date, + /// which both stamps formatted directly; a `from_timestamp` epoch fallback would change it. + /// The Gregorian calendar repeats every 400 years (146_097 days), so chrono converts the day + /// within one cycle and whole cycles go back into the year (an `i128` so the extremes can't + /// overflow), keeping chrono as the only calendar logic. 1970-01-01 is `num_days_from_ce` 719_163. + fn civil_from_days(days: i64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } + } + /// The same moment shifted by `minutes` (used to order threaded replies). pub fn plus_minutes(&self, minutes: i64) -> Self { Self::from_unix(self.unix + minutes * 60) @@ -429,6 +440,14 @@ mod tests { assert_eq!(s.iso, "2026-09-21T14:13:20Z"); assert_eq!(s.plus_minutes(0).pdf, s.pdf); assert_eq!(s.plus_minutes(60 * 24).iso, "2026-09-22T14:13:20Z"); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(Stamp::from_unix(253_402_300_800).iso, "10000-01-01T00:00:00Z"); + assert_eq!(Stamp::from_unix(253_402_300_800).pdf, "D:100000101000000Z"); + // Past chrono's ±262143-year range the proleptic Gregorian date is kept, not the epoch. + // Pinned to the old hand-rolled output (no year clamp — a negative year keeps its sign). + assert_eq!(Stamp::from_unix(10_000_000_000_000).iso, "318857-05-20T17:46:40Z"); + assert_eq!(Stamp::from_unix(10_000_000_000_000).pdf, "D:3188570520174640Z"); + assert_eq!(Stamp::from_unix(-10_000_000_000_000).iso, "-314918-08-13T06:13:20Z"); } #[test] From e451934f7217f09d4ea6942a1d9d36f80d5a7ac2 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Thu, 22 Oct 2026 01:56:27 +0200 Subject: [PATCH 2/4] M6: calculation order: read, check, fix and set the order fields calculate in (#3708) * M6: renaming a field renames it in the calculations that read it A rename silently broke every formula that used the field: a sum dropped it, notation read it as 0 and a script kept a stale value. set_props now rewrites the name in each field's Calculate script, in the same edit: AFSimple_Calculate field lists, simplified field notation (the BVCALC comment and its translation) and getField calls naming it in one string literal. The rest of each script is kept as written; names built at run time, non-UTF-8 script streams and a name another field still has are left alone, and a self-check keeps a script whose meaning would change. Notation is read by one lexer shared by notation_js, eval_notation and the rename. form_set_props returns formulas_updated; Field Properties says how many calculations now use the new name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * M6: form_set_props leaves the renamed field out of formulas_updated by object The renamed field was matched by the name given, but set_props stores it trimmed and under its parent's prefix. When the same call changed the field's own formula, formulas_updated listed the field as one the rename had rewritten. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * M6: calculation order: read, check, fix and set the order fields calculate in A form's calculations run in its calculation order (/CO), which a field joins when it gets a calculation, so a chain defined in the wrong order shows stale values. calculation_order reads the order against the fields, with what each formula reads (field lists, notation names, literal getField names) and what is wrong: a field reading one that calculates later, a loop (strongly connected components, found without recursion), a field /CO leaves out, and /CO entries that calculate nothing. Fix order is a stable topological sort that moves only what must move and leaves loops alone. set_calculation_order writes /CO afresh; a name two fields share (a malformed form) stands for the next of them in the current order. Edit::SetCalculationOrder sets it in one undo step and recalculates, and form_calculation_order reads, sets, moves one field or fixes the order headlessly. An edited formula now keeps its field's place in /CO, a field /CO lists twice calculates once, and recalculation runs each field by object, so two fields sharing a name both calculate. The /CO code of the Calculate tab, field scripts and recalculation goes through one place. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: aiongg --- ATTRIBUTION.toml | 44 +-- ROADMAP.md | 1 + crates/automation/src/forms.rs | 50 ++- crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 5 + crates/automation/tests/automation.rs | 58 ++++ crates/engine/src/lib.rs | 32 +- crates/engine/src/tests.rs | 41 +++ crates/forms/README.md | 8 + crates/forms/src/af.rs | 53 ++++ crates/forms/src/author.rs | 21 +- crates/forms/src/calc_order.rs | 429 ++++++++++++++++++++++++++ crates/forms/src/lib.rs | 25 +- crates/forms/src/scripting.rs | 20 +- crates/forms/src/tests.rs | 153 +++++++++ crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/kk.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/roadmap.md | 3 +- parity/acrobat-features.toml | 6 +- 40 files changed, 885 insertions(+), 88 deletions(-) create mode 100644 crates/forms/src/calc_order.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 361a453ecc..d512414448 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8be1b81993259177ec53d3ffcf4da073e395e50ad6644eb55ce636b7488712d8" +sha256 = "18d2b88bb336ec840dc460ad28ccea620a96eeea26e427cd302eb03e964e6ed2" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b770b000410c1213a8560424105429ac3504554974525b10e74470cd6c646f8f" +sha256 = "14be17199efc810682bd021ec807c6488e96dd5e1c97a009f4c31fae125da0bc" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4f44b9d3f17c8bed1728c5afc4b16dc7c38e7c1fab4754e8b886a9c49f3ff0fe" +sha256 = "483c060d64d68431afd0a0eae3ef7b356cf29fcbd6a94c7242ef225c814a0077" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bd7f9eb26e1ec98c7899bcf1fd6eeb3b6f5b7b866e97f083f10bee7676c5ddb5" +sha256 = "7e8158add249b105d85a398dd8b5ee573f9af6631ade6e4457799d9371be747e" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2aaeb565b95f1471feaca31497d4da61257f098f4ef7dbe5c4a8a48e7cc1c76e" +sha256 = "4b3520cb42f50e3ac221c1ccf40e86f955e900f16f5b3251b54f80b9d068df81" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "acc8d3e7f132d5bafaed3ad6ae10e2392d1bcdd6c96ac92e01c2daae555b96f0" +sha256 = "b6f566905f14d7669d8c220c5cd84886abe4b6edfa8cbdd213350f4ad1923e7a" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3e7f4ef94a55fa11abc042312566267a13172895931b8a7b5af3c0ca41f9c8f4" +sha256 = "383a20170ea9483c032fc40979b5b5bbd0d38b1af29b4d9153c68f2280ecb576" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1e38ddb698123f152c4eaf6f567e29fcefb4cefde4121924d1fc604f40dcea54" +sha256 = "6f0852d52d2498b1b7713dbb67b34584f365c1ec9504964f8da28c40e1875d70" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f8225ad3d5b25c465a3cd555fedfe1bb0bfbcfa026586653a8df1be38f7f7b7e" +sha256 = "a52bad8366e4911d49deb1ba0f29f8844ae24eac372a1f0040eb1a4909dc32f3" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "caa382d3fad6d00c87fe8760bfec1c3130a6ccbea7cc687ab8b660a521993c65" +sha256 = "c401a6505dadfa078cf45ee1c1fa834fe4aa21eb7656b641a10dd7da2613fd35" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5db94802049eac0b0321fb784a060b9f939343075db2dfd07b7293868ef9ed88" +sha256 = "a1e65941b372579a4f1bb63cac4d350620715357fd24e457a8e649ff611b5e25" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c63009ccf8afb975a7749f313c58fce4b8b41d290d80633f44cb08873195d86f" +sha256 = "518c3db5cfef1bd1f3cd6249b2faf783b2301d2a77931315a77e28bfab8250e3" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "14f743ecfd488fc87e058b41df72f0835dc13b21b230c8b88054d39846ad4e31" +sha256 = "c22f0dab633f832d0ed9d7ad9c97f5d1b636505144593917fc18bc4dff40458c" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ba43b383c8c8eb97f6bc39f4d635fe2282d92cb4a4bfdcaf81bba81c82fc64a3" +sha256 = "90226cb9e54441a926da35eeaa3f8272dcf0b7da4e269ba5ccb37c0404291778" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2ba2a772b2991b306253ad5b853698af2502487fcc354d3906454f58fc3be4ba" +sha256 = "e0874fddf6833e6005604dbc16f4b9dbb72d07cff3ce18bb8cbece0fee2fc5e0" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "71217d26f3ea73d3746a76066365877a9964340981f5b1547073f9cb634c2e98" +sha256 = "1ae379d3bb19741c50e05a967f3b33f73d34df0b23a55257a04524c917c0a53d" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1029628f81cce97ddd7468629fdfcfab13987742efc585f308b9af1ffa3f0f97" +sha256 = "6efd057033876643b4a4a5a9179ad9c4291f6201c04c2cc7948f4d454e6f90e1" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "76478b1a56a92f6527fd13babb3b013463a0dc70a2be6a50fca0ab25665b8e6c" +sha256 = "b929e99180a0fa23cdfb8c8fa77f11e458b9ef9adc269890792ac066f0f0713c" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e3346071d10f3f2d42c6f5fec56c4e7b5f8988a2a9e3a08d1c38e399fa6218b0" +sha256 = "f46a9aa4c7b34ef97d5f0fa7dc93ce36cdece9498a8bf51cd83fecf002b98b6e" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7275aa4245985cbcd72b15ae6ba81fef28e0df74ccafbf6dc0e4075fe1b0f8cc" +sha256 = "49275046f26850714e87a4c862cd0cac8eee175a39ec839f6b348e1e3ccc6bef" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f6aff113d8c5a43e4c52d465ee106f36238dd8581423cb0cd39cdcf991cbead2" +sha256 = "31fbec695e02ea651daae01f2673a5dadfc1430080f2adb2c6d441bc06900cf4" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cb2cd5e2bc11c23f4f56eef1f836eecf0710b77d77b992d795a1dfa5bbebd9a8" +sha256 = "cd4d613d1e8d9b5cc1bc82c5a9c35968048319027b0470d1c644c6a3bc617243" [[asset]] diff --git a/ROADMAP.md b/ROADMAP.md index 4d84708532..7cb7c17dc3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **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-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index bd70b65431..baeb4dd521 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -2,7 +2,7 @@ //! prepare a form (add, change and delete fields). use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate}; -use pdfcraft_engine::{Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; +use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; use serde_json::{Value, json}; use crate::{Args, Automation, Result, ToolError, failed}; @@ -536,6 +536,54 @@ impl Automation { Ok(out) } + pub(crate) fn form_calculation_order(&mut self, a: &Args) -> Result { + let current = self.doc(a)?.calculation_order(); + let fix = a.opt_bool("fix")?.unwrap_or(false); + let field = a.opt_str("field")?; + let order = a.get("order").map(|_| a.strs("order")).transpose()?; + if usize::from(fix) + usize::from(field.is_some()) + usize::from(order.is_some()) > 1 { + return Err(bad("give one change at a time: order, field with move, or fix")); + } + let names: Option> = if let Some(order) = order { + Some(order.into_iter().map(str::to_owned).collect()) + } else if let Some(field) = field { + let earlier = match a.opt_str("move")?.unwrap_or("earlier") { + "earlier" => true, + "later" => false, + m => return Err(bad(format!("move must be earlier or later, not {m:?}"))), + }; + let mut names: Vec = current.fields.iter().map(|f| f.name.clone()).collect(); + let i = names.iter().position(|n| n == field).ok_or_else(|| bad(format!("{field:?} is not a calculated field")))?; + // Already first or last: nothing moves. + let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; + j.map(|j| { + names.swap(i, j); + names + }) + } else { + (fix && current.fixable()).then(|| current.fixed()) + }; + let mut out = match names { + Some(names) => self.apply(a, Edit::SetCalculationOrder { names })?, + None => json!({}), + }; + let order = self.doc(a)?.calculation_order(); + let issue = |i: &CalculationIssue| match i { + CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }), + CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }), + CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }), + }; + out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::>()); + out["fields"] = order + .fields + .iter() + .map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::>() })) + .collect(); + out["extra"] = json!(order.extra); + out["fixable"] = json!(order.fixable()); + Ok(out) + } + fn tab_sequence(&self, a: &Args) -> Result { let doc = self.doc(a)?; let mut seq: Vec<(usize, &str)> = diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index af0583717f..31bb8da6ea 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -729,6 +729,7 @@ impl Automation { "form_set_props" => self.form_set_props(&a)?, "form_delete_field" => self.form_delete_field(&a)?, "form_tab_order" => self.form_tab_order(&a)?, + "form_calculation_order" => self.form_calculation_order(&a)?, "doc_export_data" => self.doc_export_data(&a)?, "doc_import_data" => self.doc_import_data(&a)?, "redact_mark" => self.redact_mark(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 597882d2f5..cd8694e1d2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -410,6 +410,11 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }), &["doc"], )), + t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with the fields its formula reads (`reads`; a script's names built at run time aren't known) and its `issues`: runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.") + .with(schema( + json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }), + &["doc"], + )), t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.") .destructive() .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 91a2656f48..5bd3a758f9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3853,6 +3853,64 @@ fn renaming_a_field_keeps_the_formulas_that_read_it() { assert_eq!(r["formulas_updated"], json!([])); } +/// The calculation order through the tool: read with what each formula reads and what is wrong, +/// fixed so stale values come out right, moved, set, and undone. +#[test] +fn the_calculation_order_is_read_fixed_and_set() { + let dir = workdir("calcorder"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (i, name) in ["qty", "price", "sub", "total", "tax", "x", "y"].into_iter().enumerate() { + let y = 20 + 30 * i; + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + // Defined backwards: total and tax calculate before the fields they read. + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["sub", "tax"] } })); + ok( + &mut a, + "form_set_script", + json!({ "doc": doc, "field": "tax", "event": "calculate", "script": "event.value = getField(\"sub\").value / 10;" }), + ); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sub", "calculate": { "notation": "qty * price" } })); + // x and y read each other. + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "x", "calculate": { "notation": "y + 1" } })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "y", "calculate": { "notation": "x + 1" } })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "3", "price": "10" } })); + let value = |a: &mut Automation, name: &str| { + let f = ok(a, "form_fields", json!({ "doc": doc })); + f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).map(|x| x["value"].clone()).unwrap() + }; + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "stale: they calculate too early"); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["total", "tax", "sub", "x", "y"])); + assert_eq!(r["fields"][0]["reads"], json!(["sub", "tax"])); + assert_eq!(r["fields"][0]["issues"], json!([{ "issue": "runs_too_early", "field": "tax" }, { "issue": "runs_too_early", "field": "sub" }])); + assert_eq!(r["fields"][1]["issues"], json!([{ "issue": "runs_too_early", "field": "sub" }]), "a script's reads are checked too"); + assert_eq!(r["fields"][3]["issues"], json!([{ "issue": "cycle", "fields": ["y"] }])); + assert_eq!((r["fixable"].clone(), r["extra"].clone()), (json!(true), json!([]))); + assert!(r.get("undo").is_none(), "reading changes nothing"); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "fix": true })); + assert_eq!(r["calculation_order"], json!(["sub", "tax", "total", "x", "y"]), "only what must move moves; the loop stays"); + assert_eq!(r["undo"], "Set calculation order"); + assert_eq!(r["fixable"], false); + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("33"), json!("3")), "recalculated in the new order"); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "field": "y", "move": "earlier" })); + assert_eq!(r["calculation_order"], json!(["sub", "tax", "total", "y", "x"])); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "order": ["x"] })); + assert_eq!(r["calculation_order"], json!(["x", "sub", "tax", "total", "y"]), "the fields named first, the others after them"); + for bad in [json!({ "order": ["qty"] }), json!({ "order": ["sub", "sub"] }), json!({ "field": "qty" }), json!({ "fix": true, "field": "x" })] { + let mut args = bad.clone(); + args["doc"] = json!(doc); + assert!(a.call("form_calculation_order", &args).is_err(), "{bad}"); + } + for _ in 0..3 { + ok(&mut a, "edit_undo", json!({ "doc": doc })); + } + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["total", "tax", "sub", "x", "y"])); + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated"); +} + #[test] fn tab_order_and_field_appearance_through_tools() { let dir = workdir("taborder"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdc7eb646f..2125a5b9d8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,10 +56,10 @@ pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use pdfcraft_forms::{ - BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, - Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, - appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, calculations_changed as form_calculations_changed, - flags as field_flags, + BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, + FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, + Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, + calculations_changed as form_calculations_changed, flags as field_flags, }; pub use pdfcraft_a11y as a11y; @@ -240,7 +240,7 @@ enum Scope { /// Edits during which field scripts run. fn uses_scripts(edit: &Edit) -> bool { match edit { - Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } => true, + Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), _ => false, } @@ -585,6 +585,12 @@ impl Document { self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() } + /// The form's calculation order: its calculated fields in the order they calculate, what each + /// formula reads, what is wrong with the order, and the order Fix order writes. + pub fn calculation_order(&self) -> CalculationOrder { + self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() + } + /// Add alternate text: the figures, in document order. pub fn figures(&self) -> Vec { self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() @@ -1617,6 +1623,11 @@ pub enum Edit { name: String, earlier: bool, }, + /// Set the calculation order: these calculated fields first, in this order, then the others + /// in their current order (see [`Document::calculation_order`]). The form recalculates. + SetCalculationOrder { + names: Vec, + }, /// Add a header and footer (with `replace`, existing ones on those pages go first). AddHeaderFooter { pages: Vec, @@ -1886,6 +1897,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Delete field", "Duplicate field", "Set tab order", + "Set calculation order", "Change initial view", "Set alternate text", "Mark figure as decorative", @@ -2012,6 +2024,7 @@ impl Edit { Edit::DeleteField { .. } => Label::of("Delete field"), Edit::DuplicateField { .. } => Label::of("Duplicate field"), Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), + Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), Edit::SetInitialView(_) => Label::of("Change initial view"), Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), @@ -2209,6 +2222,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::DuplicateField { .. } | Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } + | Edit::SetCalculationOrder { .. } | Edit::SetInitialView(_) | Edit::AutoTagText | Edit::SetAltText { .. } @@ -2497,6 +2511,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, + // As after a script edit: the values the new order gives show at once. + Edit::SetCalculationOrder { names } => { + pdfcraft_forms::set_calculation_order(doc, names)?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, Edit::AutoTagText => { a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41dac..080dc32dd3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1940,6 +1940,47 @@ fn preparing_a_form_adds_renames_and_deletes_fields() { assert_eq!(names(&s), ["Text1", "Check Box1", "email"]); } +/// Setting the calculation order is one undoable edit that recalculates: values that ran too +/// early come out right at once, a script calculation included, and undo brings back the old +/// order and values together. +#[test] +fn setting_the_calculation_order_recalculates_and_undoes() { + use form_scripts::{CalcOp, Calculate}; + let (mut s, id) = session_with(1); + for (i, name) in ["qty", "sub", "total", "tax"].into_iter().enumerate() { + let y = 250.0 - 40.0 * i as f64; + let kind = NewField::Text { multiline: false }; + s.apply(id, Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: Some(name.into()) }).unwrap(); + } + let calculate = |name: &str, c: Calculate| Edit::SetFieldProps { + name: name.into(), + props: Box::new(FieldProps { calculate: Some(c), ..Default::default() }), + }; + // Defined backwards: total, which reads sub, calculates first. + s.apply(id, calculate("total", Calculate::Simple { op: CalcOp::Sum, fields: vec!["sub".into()] })).unwrap(); + s.apply(id, calculate("sub", Calculate::Notation("qty * 2".into()))).unwrap(); + let script = Some("event.value = this.getField(\"total\").value / 10;".to_string()); + s.apply(id, Edit::SetFieldScript { name: "tax".into(), event: "calculate".into(), script }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("5".into()) }).unwrap(); + let values = |s: &Session| { + let form = &s.get(id).unwrap().form; + ["sub", "total", "tax"].map(|n| form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()).unwrap_or_default()) + }; + assert_eq!(values(&s), ["10", "0", "0"], "total and tax ran before sub"); + let order = s.get(id).unwrap().calculation_order(); + assert_eq!(order.fields.iter().map(|f| f.name.as_str()).collect::>(), ["total", "sub", "tax"]); + assert_eq!(order.fields[0].issues, [CalculationIssue::RunsTooEarly { field: "sub".into() }]); + assert_eq!(order.fixed(), ["sub", "total", "tax"]); + s.apply(id, Edit::SetCalculationOrder { names: order.fixed() }).unwrap(); + assert_eq!(values(&s), ["10", "10", "1"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Set calculation order")); + assert!(s.get(id).unwrap().calculation_order().fields.iter().all(|f| f.issues.is_empty())); + s.undo(id).unwrap(); + assert_eq!(values(&s), ["10", "0", "0"]); + assert_eq!(s.get(id).unwrap().calculation_order().fixed(), ["sub", "total", "tax"], "the old order is back"); + assert!(matches!(s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into()] }), Err(EditError::Form(_)))); +} + /// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what /// the page's /Resources holds. fn one_line_page(resources: &str, body: &str) -> Vec { diff --git a/crates/forms/README.md b/crates/forms/README.md index 2ba38097ed..b567009f8c 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -63,6 +63,14 @@ delete_field(&mut doc, "email")?; script stream that isn't UTF-8, other events' scripts and a name another field still has are left alone. `calculations_changed` compares two readings of a form to say which formulas an edit rewrote. +- The calculation order (`/CO`, §12.7.3): `calculation_order` lists the calculated fields in the + order `recalculate` runs them, with the fields each formula reads (`Calculate::reads`, + `af::calculation_reads`; names built at run time aren't known) and what is wrong with the + order: a field reading one that calculates later, a loop, a field `/CO` leaves out, and `/CO` + entries that calculate nothing. `CalculationOrder::fixed` is Fix order: a stable topological + sort that keeps the current order wherever it can and leaves loops as they are. + `set_calculation_order` writes `/CO` afresh. A field that gets a calculation joins the end of + the order; one whose calculation changes keeps its place. - `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values). `FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears that label, independently of the field name. The index is 0-based. A label-only edit redraws diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index ab7fc327c9..6147b3d482 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -698,6 +698,34 @@ pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { (got == expected).then_some(out) } +/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the +/// order it first names them. +pub fn calculation_reads(js: &str) -> Vec { + let sites = calculation_sites(js); + unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) +} + +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. + pub fn reads(&self) -> Vec { + match self { + Calculate::None => Vec::new(), + Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), + Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { + Notation::Name(name) => Some(name), + _ => None, + })), + } + } +} + +/// `names` without repeats, in order. +fn unique(names: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + names.into_iter().filter(|n| seen.insert(n.clone())).collect() +} + // ── running them ──────────────────────────────────────────────────────────────────────────── /// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a @@ -1528,6 +1556,31 @@ mod tests { let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old)); proptest::prop_assert_eq!(back, Some(js)); } + + /// 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] + fn a_formula_reads_the_same_fields_as_its_script(names in proptest::collection::vec("\\PC{1,8}", 1..5), sum in proptest::bool::ANY) { + proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); + let c = if sum { + Calculate::Simple { op: CalcOp::Product, fields: names.clone() } + } else { + Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" + ")) + }; + let js = calculate_js(&c).unwrap(); + proptest::prop_assert_eq!(calculation_reads(&js), c.reads()); + proptest::prop_assert_eq!(c.reads(), unique(names)); + } + } + + #[test] + fn a_calculation_reads_each_named_field_once() { + assert_eq!(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into(), "a".into()] }.reads(), ["a", "b"]); + assert_eq!(Calculate::Notation("(Unit\\ Price + 2) * qty / qty".into()).reads(), ["Unit Price", "qty"]); + assert_eq!(Calculate::None.reads(), Vec::::new()); + let script = "var t = getField(\"sub\").value; // getField(\"x\")\nevent.value = t + getField(\"tax\").value + getField(\"row\" + 1).value;"; + assert_eq!(calculation_reads(script), ["sub", "tax"], "names built at run time aren't known"); + assert_eq!(calculation_reads("AFSimple_Calculate(\"SUM\", \"a, b\");"), ["a", "b"]); } #[test] diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index b4fddb589d..53c90b0fcc 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1140,26 +1140,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result = doc - .get(af_ref) - .as_dict() - .and_then(|d| d.get(b"CO").cloned()) - .map(|c| doc.resolve(&c)) - .and_then(|c| c.as_array().cloned()) - .unwrap_or_default(); - co.retain(|o| o.as_ref() != Some(f.obj)); - if *c != crate::af::Calculate::None { - co.push(Object::Ref(f.obj)); - } - doc.update_dict(af_ref, |d| { - if co.is_empty() { - d.remove(b"CO"); - } else { - d.set(b"CO".to_vec(), Object::Array(co)); - } - })?; + crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?; } doc.update_dict(f.obj, |d| { if aa.is_empty() { diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs new file mode 100644 index 0000000000..e75a05d475 --- /dev/null +++ b/crates/forms/src/calc_order.rs @@ -0,0 +1,429 @@ +//! The calculation order (`/CO`, ISO 32000-2 §12.7.3): the calculated fields in the order they +//! recalculate, the fields each one's formula reads, and what is wrong with that order. + +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque}; + +use pdfcraft_cos::{Document, ObjRef, Object}; + +use crate::{Field, FormError, acroform, fields}; + +/// At most this many reads between calculated fields are checked. A group name stands for every +/// field under it, so a form could otherwise make a check build billions of them. +const MAX_LINKS: usize = 1 << 20; + +/// A calculated field, at its place in the calculation order. +#[derive(Clone, Debug, PartialEq)] +pub struct CalculatedField { + /// The field's full name. + pub name: String, + /// 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, + /// `/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. + pub issues: Vec, +} + +/// Something wrong with a calculated field's place in the calculation order. +#[derive(Clone, Debug, PartialEq)] +pub enum CalculationIssue { + /// 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 + /// reads itself directly). No order is right for a loop: one of the formulas has to change. + Cycle { fields: Vec }, + /// `/CO` leaves it out. PdfCraft calculates it after the listed fields, but other PDF viewers + /// may not calculate it at all. + NotInOrder, +} + +/// A form's calculation order and what is wrong with it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CalculationOrder { + /// The calculated fields in the order [`crate::recalculate`] runs them: as `/CO` lists them, + /// then the ones it leaves out, in field order. + pub fields: Vec, + /// `/CO` entries that calculate nothing: a field without a calculation, a second entry for a + /// field, or an object that isn't a field (written as `12 0 R`). + pub extra: Vec, + /// Fix order's order, as indexes into `fields`. + fixed: Vec, +} + +impl CalculationOrder { + /// The names in the order Fix order writes: each field after the calculated fields it reads, + /// keeping the current order wherever that allows. Reads within a loop are left as they are. + pub fn fixed(&self) -> Vec { + self.fixed.iter().filter_map(|&i| self.fields.get(i)).map(|f| f.name.clone()).collect() + } + + /// Whether Fix order changes anything: a field moves, joins `/CO`, or an extra entry goes. + pub fn fixable(&self) -> bool { + !self.extra.is_empty() || self.fields.iter().any(|f| !f.listed) || self.fixed.iter().enumerate().any(|(k, &i)| k != i) + } +} + +/// Whether a field has a calculation: a Calculate tab formula or a custom Calculate script. +pub(crate) fn is_calculated(f: &Field) -> bool { + f.actions.calculate != crate::af::Calculate::None || f.actions.scripts.calculate.is_some() +} + +/// The entries of `/CO`, as written. +pub(crate) fn co_entries(doc: &Document) -> Vec { + acroform(doc).and_then(|af| af.get(b"CO").map(|c| doc.resolve(c)).and_then(|c| c.as_array().cloned())).unwrap_or_default() +} + +/// Keep `/CO` in step with whether field `obj` calculates: a field that gets a calculation joins +/// the end of the order, one that loses it leaves, and one whose calculation changes keeps its +/// place, so editing a formula doesn't undo an order set by hand. +pub(crate) fn keep_listed(doc: &mut Document, obj: ObjRef, calculates: bool) -> Result<(), FormError> { + let mut co = co_entries(doc); + match (co.iter().any(|o| o.as_ref() == Some(obj)), calculates) { + (false, true) => co.push(Object::Ref(obj)), + (true, false) => co.retain(|o| o.as_ref() != Some(obj)), + _ => return Ok(()), + } + write_co(doc, co) +} + +/// Write `/CO` (none when `co` is empty), creating the form if needed. +fn write_co(doc: &mut Document, co: Vec) -> Result<(), FormError> { + let af_ref = crate::author::ensure_form(doc)?; + doc.update_dict(af_ref, |d| { + if co.is_empty() { + d.remove(b"CO"); + } else { + d.set(b"CO".to_vec(), Object::Array(co)); + } + })?; + Ok(()) +} + +/// The calculated fields in the order they recalculate, as indexes into `fields` paired with +/// whether `/CO` lists them, and the `/CO` entries that calculate nothing. +pub(crate) fn run_order(doc: &Document, fields: &[Field]) -> (Vec<(usize, bool)>, Vec) { + let mut by_obj: HashMap = HashMap::with_capacity(fields.len()); + for (i, f) in fields.iter().enumerate() { + by_obj.entry(f.obj).or_insert(i); + } + let mut placed = HashSet::new(); + let (mut run, mut extra) = (Vec::new(), Vec::new()); + for entry in co_entries(doc) { + let field = entry.as_ref().and_then(|r| by_obj.get(&r).copied()).and_then(|i| Some((i, fields.get(i)?))); + match field { + Some((i, f)) if is_calculated(f) && placed.insert(i) => run.push((i, true)), + Some((_, f)) => extra.push(f.name.clone()), + None => extra.push(entry.as_ref().map_or_else(|| "not a reference".to_owned(), |r| r.to_string())), + } + } + run.extend(fields.iter().enumerate().filter(|(i, f)| is_calculated(f) && !placed.contains(i)).map(|(i, _)| (i, false))); + (run, extra) +} + +/// The form's calculation order (`fields` as [`fields`] reads them), with each calculated field's +/// reads and issues, and Fix order's order. +pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { + let (run, extra) = run_order(doc, fields); + let calculated: Vec<(&Field, bool)> = run.iter().filter_map(|&(i, listed)| Some((fields.get(i)?, listed))).collect(); + let reads: Vec> = calculated + .iter() + .map(|(f, _)| { + let mut names = f.actions.calculate.reads(); + if let Some(js) = &f.actions.scripts.calculate { + for name in crate::af::calculation_reads(js) { + if !names.contains(&name) { + names.push(name); + } + } + } + names + }) + .collect(); + let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads); + let component = components(&links); + 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 read = links.get(k).map(Vec::as_slice).unwrap_or_default(); + for &j in read { + if j > k && component.get(j).copied() != own { + issues.push(CalculationIssue::RunsTooEarly { field: name(j) }); + } + } + // Only the loop's fields it reads itself are named, so a loop through thousands of fields + // isn't listed again for each of them. + let looped: Vec = read.iter().filter(|&&j| component.get(j).copied() == own).map(|&j| name(j)).collect(); + if !looped.is_empty() { + issues.push(CalculationIssue::Cycle { fields: looped }); + } + if !listed { + issues.push(CalculationIssue::NotInOrder); + } + out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues }); + } + let fixed = fixed_order(&links, &component); + CalculationOrder { fields: out, extra, fixed } +} + +/// 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. +fn links<'a>(names: impl Iterator, reads: &[Vec]) -> Vec> { + let mut sorted: Vec<(&str, usize)> = names.enumerate().map(|(k, n)| (n, k)).collect(); + sorted.sort_unstable(); + let mut budget = MAX_LINKS; + reads + .iter() + .map(|names| { + let mut out = Vec::new(); + for name in names { + let prefix = format!("{name}."); + let exact = sorted.partition_point(|(n, _)| *n < name.as_str()); + let under = sorted.partition_point(|(n, _)| *n < prefix.as_str()); + let same = sorted.get(exact..).unwrap_or_default().iter().take_while(|(n, _)| n == name); + let below = sorted.get(under..).unwrap_or_default().iter().take_while(|(n, _)| n.starts_with(&prefix)); + for &(_, k) in same.chain(below) { + if budget == 0 { + break; + } + budget -= 1; + out.push(k); + } + } + out.sort_unstable(); + out.dedup(); + out + }) + .collect() +} + +/// The strongly connected components of `links` (Tarjan's algorithm, without recursion): one +/// component number per node. Fields in one component read each other in a loop. +fn components(links: &[Vec]) -> Vec { + const UNSEEN: usize = usize::MAX; + let n = links.len(); + let (mut index, mut low, mut component) = (vec![UNSEEN; n], vec![0; n], vec![UNSEEN; n]); + let mut on_stack = vec![false; n]; + let (mut stack, mut work): (Vec, Vec<(usize, usize)>) = (Vec::new(), Vec::new()); + let (mut next, mut count) = (0, 0); + // Every index below is a node: `links` only holds places below `n` (see `links`). + for root in 0..n { + if index[root] != UNSEEN { + continue; + } + work.push((root, 0)); + while let Some((v, e)) = work.pop() { + if e == 0 { + index[v] = next; + low[v] = next; + next += 1; + stack.push(v); + on_stack[v] = true; + } + if let Some(&w) = links[v].get(e) { + work.push((v, e + 1)); + if index[w] == UNSEEN { + work.push((w, 0)); + } else if on_stack[w] { + low[v] = low[v].min(index[w]); + } + continue; + } + if low[v] == index[v] { + while let Some(w) = stack.pop() { + on_stack[w] = false; + component[w] = count; + if w == v { + break; + } + } + count += 1; + } + if let Some(&(parent, _)) = work.last() { + low[parent] = low[parent].min(low[v]); + } + } + } + component +} + +/// Fix order: every node after the nodes it reads in other components, the earliest ready node +/// first, so an order that is already right stays as it is and otherwise only what must move +/// moves. Reads within a component (a loop) are left out: no order satisfies them. +fn fixed_order(links: &[Vec], component: &[usize]) -> Vec { + let n = links.len(); + let mut waits = vec![0usize; n]; + let mut readers: Vec> = vec![Vec::new(); n]; + for (k, read) in links.iter().enumerate() { + for &j in read { + if component.get(j) != component.get(k) + && let (Some(w), Some(r)) = (waits.get_mut(k), readers.get_mut(j)) + { + *w += 1; + r.push(k); + } + } + } + let mut ready: BinaryHeap> = waits.iter().enumerate().filter(|(_, w)| **w == 0).map(|(k, _)| Reverse(k)).collect(); + let mut out = Vec::with_capacity(n); + while let Some(Reverse(k)) = ready.pop() { + out.push(k); + for &r in readers.get(k).map(Vec::as_slice).unwrap_or_default() { + if let Some(w) = waits.get_mut(r) { + *w = w.saturating_sub(1); + if *w == 0 { + ready.push(Reverse(r)); + } + } + } + } + // Without the reads inside loops no loop is left, so every node is placed; kept total anyway. + if out.len() < n { + let placed: HashSet = out.iter().copied().collect(); + out.extend((0..n).filter(|k| !placed.contains(k))); + } + out +} + +/// Set the calculation order (Acrobat: Set Field Calculation Order): `names` calculate first, in +/// that order, then the calculated fields left out, in their current order. `/CO` is written +/// afresh, so it lists every calculated field once and nothing else. +/// +/// Two calculated fields can share a full name in a malformed form, and the order then names +/// both: a repeated name stands for the next of them in the current order, so the current order +/// sets back unchanged. +pub fn set_calculation_order(doc: &mut Document, names: &[String]) -> Result<(), FormError> { + let all = fields(doc); + let (run, _) = run_order(doc, &all); + let mut by_name: HashMap<&str, VecDeque> = HashMap::with_capacity(run.len()); + for &(i, _) in &run { + if let Some(f) = all.get(i) { + by_name.entry(f.name.as_str()).or_default().push_back(i); + } + } + let mut chosen = HashSet::new(); + let mut co = Vec::with_capacity(names.len()); + for name in names { + let Some(next) = by_name.get_mut(name.as_str()) else { + return Err(if all.iter().any(|f| f.name == *name) { + FormError::Invalid(format!("{name} has no calculation")) + } else { + FormError::NoSuchField(name.clone()) + }); + }; + let i = next.pop_front().ok_or_else(|| FormError::Invalid(format!("{name} is in the order twice")))?; + if let Some(f) = all.get(i) { + chosen.insert(i); + co.push(Object::Ref(f.obj)); + } + } + co.extend(run.iter().filter(|(i, _)| !chosen.contains(i)).filter_map(|&(i, _)| all.get(i)).map(|f| Object::Ref(f.obj))); + write_co(doc, co) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fix order on `links` (node `k` reads `links[k]`), as places. + fn fix(links: &[Vec]) -> Vec { + fixed_order(links, &components(links)) + } + + #[test] + fn loops_are_found_without_recursion() { + // 0 ↔ 1, 2 reads itself, 3 reads 0, 4 alone. + let c = components(&[vec![1], vec![0], vec![2], vec![0], vec![]]); + assert_eq!(c[0], c[1]); + assert!(c[2] != c[0] && c[3] != c[0] && c[4] != c[0] && c[3] != c[4]); + // A chain a million fields long is one pass, not a million nested calls. + let n = 1_000_000; + let chain: Vec> = (0..n).map(|k| if k + 1 < n { vec![k + 1] } else { vec![0] }).collect(); + let c = components(&chain); + assert!(c.iter().all(|x| *x == c[0]), "one loop through every field"); + } + + #[test] + fn fix_order_moves_only_what_must_move() { + // Already right: unchanged. + assert_eq!(fix(&[vec![], vec![0], vec![1], vec![]]), [0, 1, 2, 3]); + // 0 reads 2: it moves after 2, the rest keep their order. + assert_eq!(fix(&[vec![2], vec![], vec![], vec![]]), [1, 2, 0, 3]); + // A chain written backwards comes out forwards. + assert_eq!(fix(&[vec![1], vec![2], vec![]]), [2, 1, 0]); + // A loop (0 ↔ 1) keeps its order; 2, which reads it, goes after it. + assert_eq!(fix(&[vec![1], vec![0], vec![]]), [0, 1, 2]); + assert_eq!(fix(&[vec![], vec![2], vec![1]]), [0, 1, 2]); + assert_eq!(fix(&[vec![1, 2], vec![0], vec![]]), [1, 2, 0], "a loop member still waits for what it reads outside the loop"); + // Reading itself is a loop, not a reason to move. + assert_eq!(fix(&[vec![0], vec![]]), [0, 1]); + } + + #[test] + fn a_name_finds_the_fields_under_it() { + let names = ["row.1", "row", "row.10", "rows", "row-1", "total"]; + let reads = vec![vec![], vec![], vec![], vec![], vec![], vec!["row".to_owned(), "missing".to_owned(), "row".to_owned()]]; + assert_eq!(links(names.into_iter(), &reads)[5], [0, 1, 2], "row, row.1 and row.10; not rows or row-1"); + } + + /// Two thousand formulas each reading a group of two thousand fields would be four million + /// reads; the checks stop at their cap. + #[test] + fn reads_of_a_large_group_are_capped() { + let n = 2000; + let names: Vec = (0..n).map(|i| format!("g.{i}")).chain((0..n).map(|i| format!("r{i}"))).collect(); + let reads: Vec> = (0..2 * n).map(|i| if i < n { Vec::new() } else { vec!["g".to_owned()] }).collect(); + let total: usize = links(names.iter().map(String::as_str), &reads).iter().map(Vec::len).sum(); + assert_eq!(total, MAX_LINKS); + } + + proptest::proptest! { + /// Fix order is a reordering of the fields after which none reads a field that calculates + /// later, except within a loop; an order that is already right comes back unchanged. + #[test] + fn fix_order_leaves_nothing_too_early(links in proptest::collection::vec(proptest::collection::vec(0usize..12, 0..4), 1..12)) { + let n = links.len(); + let links: Vec> = links.into_iter().map(|mut l| { l.retain(|j| *j < n); l.sort_unstable(); l.dedup(); l }).collect(); + let component = components(&links); + // Components are exactly the nodes that reach each other. + let reach = |from: usize| { + let mut seen = vec![false; n]; + let mut todo = vec![from]; + while let Some(v) = todo.pop() { + for &w in &links[v] { + if !seen[w] { seen[w] = true; todo.push(w); } + } + } + seen + }; + let reaches: Vec> = (0..n).map(reach).collect(); + for a in 0..n { + for b in 0..n { + if a != b { + proptest::prop_assert_eq!(component[a] == component[b], reaches[a][b] && reaches[b][a]); + } + } + } + let fixed = fixed_order(&links, &component); + let mut sorted = fixed.clone(); + sorted.sort_unstable(); + proptest::prop_assert_eq!(sorted, (0..n).collect::>(), "a reordering"); + let mut at = vec![0; n]; + for (place, &k) in fixed.iter().enumerate() { + at[k] = place; + } + for (k, read) in links.iter().enumerate() { + for &j in read { + proptest::prop_assert!(component[j] == component[k] || at[j] < at[k], "{k} reads {j}: {fixed:?}"); + } + } + // Fixed once, the order needs no more moves. + let renumbered: Vec> = fixed.iter().map(|&k| { let mut l: Vec = links[k].iter().map(|&j| at[j]).collect(); l.sort_unstable(); l }).collect(); + proptest::prop_assert_eq!(fix(&renumbered), (0..n).collect::>()); + } + } +} diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 5647d32c4f..4c924e2f97 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -19,6 +19,7 @@ mod actions; pub mod af; pub mod appearance; mod author; +mod calc_order; pub mod detect; mod scripting; pub use actions::{FieldAction, Trigger, field_actions, set_field_actions}; @@ -26,6 +27,7 @@ pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, NewField, add_field, check_style, delete_field, duplicate_field, look, redraw_field, set_button_icon, set_props, }; +pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script, set_field_script, @@ -920,25 +922,14 @@ pub fn recalculate(doc: &mut Document) -> Result { /// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { let mut all = fields(doc); - let calculated = |f: &Field| f.actions.calculate != af::Calculate::None || f.actions.scripts.calculate.is_some(); - if !all.iter().any(calculated) { + if !all.iter().any(calc_order::is_calculated) { return Ok(0); } - let co: Vec = acroform(doc) - .and_then(|af| af.get(b"CO").map(|c| doc.resolve(c)).and_then(|c| c.as_array().cloned())) - .unwrap_or_default() - .iter() - .filter_map(Object::as_ref) - .collect(); - let mut order: Vec = co.iter().filter_map(|r| all.iter().find(|f| f.obj == *r)).map(|f| f.name.clone()).collect(); - for f in &all { - if calculated(f) && !order.contains(&f.name) { - order.push(f.name.clone()); - } - } + // By object: two fields can share a name in a malformed form, and each calculates. + let order: Vec = calc_order::run_order(doc, &all).0.into_iter().filter_map(|(i, _)| all.get(i)).map(|f| f.obj).collect(); let mut changed = 0; - for name in order { - let Some(f) = all.iter().find(|f| f.name == name).cloned() else { continue }; + for obj in order { + let Some(f) = all.iter().find(|f| f.obj == obj).cloned() else { continue }; let lookup = |n: &str| -> Vec { let prefix = format!("{n}."); all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect() @@ -961,7 +952,7 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; redraw(doc, &f, std::slice::from_ref(&v), scripts)?; - if let Some(target) = all.iter_mut().find(|x| x.name == name) { + if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { target.value = vec![v]; } changed += 1; diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 9a857510b9..f5b8274459 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -300,25 +300,7 @@ pub fn set_field_script(doc: &mut Document, name: &str, event: &str, js: Option< } })?; if key == b"C" { - let af_ref = crate::author::ensure_form(doc)?; - let mut co: Vec = doc - .get(af_ref) - .as_dict() - .and_then(|d| d.get(b"CO").cloned()) - .map(|c| doc.resolve(&c)) - .and_then(|c| c.as_array().cloned()) - .unwrap_or_default(); - co.retain(|o| o.as_ref() != Some(f.obj)); - if js.is_some() { - co.push(Object::Ref(f.obj)); - } - doc.update_dict(af_ref, |d| { - if co.is_empty() { - d.remove(b"CO"); - } else { - d.set(b"CO".to_vec(), Object::Array(co)); - } - })?; + crate::calc_order::keep_listed(doc, f.obj, js.is_some())?; } Ok(()) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index f8410bf896..b4fe48b0a6 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1126,6 +1126,159 @@ fn a_rename_reaches_every_stored_form_of_a_calculation() { ); } +/// The calculation order is read with what each formula reads and what is wrong with the order; +/// Fix order moves only what must move, and the order it writes is kept across a save. +#[test] +fn the_calculation_order_is_checked_fixed_and_kept() { + use crate::af::{CalcOp, Calculate}; + use crate::calc_order::CalculationIssue::{Cycle, RunsTooEarly}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["qty", "price", "shipping", "sub", "total", "grand", "tax"].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() }; + let sum = |names: &[&str]| calculate(Calculate::Simple { op: CalcOp::Sum, fields: names.iter().map(|n| n.to_string()).collect() }); + // Each calculation joins the end of the order, so this chain is written backwards. + set_props(&mut doc, "grand", &sum(&["total"])).unwrap(); + set_props(&mut doc, "total", &sum(&["sub", "shipping"])).unwrap(); + set_props(&mut doc, "sub", &calculate(Calculate::Notation("qty * price".into()))).unwrap(); + set_field_script(&mut doc, "tax", "calculate", Some("event.value = getField(\"total\").value * 0.2; // getField(\"grand\")")).unwrap(); + for (name, v) in [("qty", "2"), ("price", "10"), ("shipping", "5")] { + set_value(&mut doc, name, &FieldValue::Text(v.into())).unwrap(); + } + let value = |doc: &Document, name: &str| field(&fields(doc), name).value.clone(); + assert_eq!((value(&doc, "total"), value(&doc, "grand")), (vec!["25".to_string()], vec!["0".to_string()]), "grand runs too early: stale"); + let order = calculation_order(&doc, &fields(&doc)); + let names: Vec<&str> = order.fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["grand", "total", "sub", "tax"]); + let issues: Vec<_> = order.fields.iter().map(|f| f.issues.clone()).collect(); + assert_eq!(issues, [vec![RunsTooEarly { field: "total".into() }], vec![RunsTooEarly { field: "sub".into() }], vec![], vec![]]); + assert_eq!(order.fields[1].reads, ["sub", "shipping"]); + assert_eq!(order.fields[3].reads, ["total"], "a name in a comment isn't read"); + assert!(order.fields.iter().all(|f| f.listed) && order.extra.is_empty()); + assert!(order.fixable()); + assert_eq!(order.fixed(), ["sub", "total", "grand", "tax"]); + set_calculation_order(&mut doc, &order.fixed()).unwrap(); + recalculate(&mut doc).unwrap(); + assert_eq!((value(&doc, "total"), value(&doc, "grand")), (vec!["25".to_string()], vec!["25".to_string()])); + let mut doc = reopen(&doc); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fields.iter().map(|f| f.name.as_str()).collect::>(), ["sub", "total", "grand", "tax"], "kept across a save"); + assert!(order.fields.iter().all(|f| f.issues.is_empty()) && !order.fixable()); + // Names given first, the others after them in their order. + set_calculation_order(&mut doc, &["tax".into()]).unwrap(); + let names = |doc: &Document| calculation_order(doc, &fields(doc)).fields.into_iter().map(|f| f.name).collect::>(); + assert_eq!(names(&doc), ["tax", "sub", "total", "grand"]); + for (bad, error) in [ + (vec!["qty"], FormError::Invalid("qty has no calculation".into())), + (vec!["nope"], FormError::NoSuchField("nope".into())), + (vec!["sub", "sub"], FormError::Invalid("sub is in the order twice".into())), + ] { + let bad: Vec = bad.into_iter().map(String::from).collect(); + assert_eq!(set_calculation_order(&mut doc, &bad), Err(error)); + } + assert_eq!(names(&doc), ["tax", "sub", "total", "grand"], "a refused order changes nothing"); + // A loop: sub now reads grand, which reads total, which reads sub. Its new formula keeps its + // place in the order. Fix order can't fix a loop, and leaves it in its order. + set_props(&mut doc, "sub", &calculate(Calculate::Notation("qty * price + grand".into()))).unwrap(); + let order = calculation_order(&doc, &fields(&doc)); + let issues: Vec<(&str, &[_])> = order.fields.iter().map(|f| (f.name.as_str(), f.issues.as_slice())).collect(); + assert_eq!(issues[0], ("tax", &[RunsTooEarly { field: "total".into() }][..])); + assert_eq!( + issues[1..], + [ + ("sub", &[Cycle { fields: vec!["grand".into()] }][..]), + ("total", &[Cycle { fields: vec!["sub".into()] }][..]), + ("grand", &[Cycle { fields: vec!["total".into()] }][..]), + ] + ); + assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total"); +} + +/// `/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. +#[test] +fn a_calculation_order_with_gaps_and_extra_entries() { + let widget = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20) + }; + let sum = |names: &str| format!("/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{names}\"\\);) >> >>"); + let objs: Vec = 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 6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 5 0 R 6 0 R 99 0 R 12] >>".into(), + widget("a", 700, "/V (3)"), + widget("listed", 660, &sum("a")), + widget("unlisted", 620, &sum("listed")), + widget("b", 580, "/V (4)"), + ]; + let mut doc = document(&objs); + let order = calculation_order(&doc, &fields(&doc)); + let rows: Vec<(&str, bool, &[_])> = order.fields.iter().map(|f| (f.name.as_str(), f.listed, f.issues.as_slice())).collect(); + assert_eq!(rows, [("listed", true, &[][..]), ("unlisted", false, &[crate::calc_order::CalculationIssue::NotInOrder][..])]); + assert_eq!(order.extra, ["a", "listed", "99 0 R", "not a reference"]); + assert!(order.fixable()); + assert_eq!(order.fixed(), ["listed", "unlisted"]); + assert_eq!(recalculate(&mut doc), Ok(2), "the unlisted field calculates too, after the listed one"); + assert_eq!(field(&fields(&doc), "unlisted").value, ["3"]); + set_calculation_order(&mut doc, &[]).unwrap(); + let doc = reopen(&doc); + let co = acroform(&doc).and_then(|af| af.get(b"CO").cloned()).unwrap(); + let refs: Vec<_> = doc.resolve(&co).as_array().unwrap().iter().filter_map(Object::as_ref).collect(); + assert_eq!(refs, [field(&fields(&doc), "listed").obj, field(&fields(&doc), "unlisted").obj], "every calculated field once, nothing else"); + assert!(!calculation_order(&doc, &fields(&doc)).fixable()); +} + +/// Two calculated fields with one full name (a malformed form: a full name is one field). The +/// order names them both, so a repeated name stands for the next of them in the current order: +/// the current order sets back unchanged, and Fix order and moves work around them. +#[test] +fn two_fields_with_one_name_keep_their_places_in_the_order() { + let widget = |name: &str, y: u32, reads: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>", + y + 20 + ) + }; + let objs: Vec = 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 6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>".into(), + widget("dup", 700, "x"), + widget("dup", 660, "src"), + widget("src", 620, "x"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>".into(), + ]; + let mut doc = document(&objs); + // Each dup told apart by what it reads. + let rows = |doc: &Document| -> Vec<(String, Vec, bool)> { + calculation_order(doc, &fields(doc)).fields.into_iter().map(|f| (f.name, f.reads, f.issues.is_empty())).collect() + }; + 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)]); + let current: Vec = ["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"); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fixed(), ["src", "dup", "dup"]); + set_calculation_order(&mut doc, &order.fixed()).unwrap(); + assert_eq!(rows(&doc), [row("src", "x", true), row("dup", "src", true), row("dup", "x", true)], "the dup that reads src moves after it"); + // Each dup calculates, not the first of them twice. + assert_eq!(recalculate(&mut doc), Ok(3)); + let values: Vec> = fields(&doc).into_iter().filter(|f| f.name == "dup").map(|f| f.value).collect(); + assert_eq!(values, [["3"], ["3"]]); + // A name given alone stands for the first of its fields in the current order. + set_calculation_order(&mut doc, &["dup".into()]).unwrap(); + assert_eq!(rows(&doc)[0], row("dup", "src", false)); + let thrice: Vec = ["dup", "dup", "dup"].map(String::from).into(); + assert_eq!(set_calculation_order(&mut doc, &thrice), Err(FormError::Invalid("dup is in the order twice".into()))); +} + #[test] fn new_text_fields_fit_their_box() { // #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 91480ac7b5..4aed1c086d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -990,6 +990,7 @@ fn is_form_edit(edit: &Edit) -> bool { | Edit::SetFieldProps { .. } | Edit::SetFieldImage { .. } | Edit::SetFieldScript { .. } + | Edit::SetCalculationOrder { .. } | Edit::ApplyScriptChanges { .. } ) } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 12e9293268..894e127be4 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1927,6 +1927,7 @@ Delete field حذف الحقل Duplicate field تكرار الحقل Set tab order ضبط ترتيب التنقل + Set calculation order تعيين ترتيب الحساب Change initial view تغيير العرض الأولي Set alternate text تعيين النص البديل Mark figure as decorative تعليم الشكل كزخرفي diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4d6ae90a4e..e00febe68c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2043,6 +2043,7 @@ Set status Rejected Задай състояние „Отхвърлено“ Set status Unmarked Задай състояние „Неотбелязано“ Set tab order Задай реда на обхождане + Set calculation order Задаване на ред на изчисляване Set the image of {name} Задай изображението на {name} Spreadsheet (.xlsx) Електронна таблица (.xlsx) Square Квадрат diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8b0dd03fe9..292c14a614 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1974,6 +1974,7 @@ key Space Mezerník Delete field Odstranit pole Duplicate field Duplikovat pole Set tab order Nastavit pořadí tabulátoru + Set calculation order Nastavit pořadí výpočtů Change initial view Změnit úvodní zobrazení Set alternate text Nastavit alternativní text Mark figure as decorative Označit obrázek jako dekorativní diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5274c8c745..2348e7df2b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1863,6 +1863,7 @@ Delete field Feld löschen Duplicate field Feld duplizieren Set tab order Tab-Reihenfolge festlegen + Set calculation order Berechnungsreihenfolge festlegen Change initial view Anfangsansicht ändern Set alternate text Alternativtext festlegen Mark figure as decorative Abbildung als dekorativ markieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f3abed3047..c12ba3bea7 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1343,6 +1343,7 @@ Delete field Διαγραφή πεδίου Duplicate field Δημιουργία αντιγράφου πεδίου Set tab order Ορισμός σειράς μετακίνησης με Tab + Set calculation order Ορισμός σειράς υπολογισμού Change initial view Αλλαγή αρχικής προβολής Set alternate text Ορισμός εναλλακτικού κειμένου Mark figure as decorative Σήμανση σχήματος ως διακοσμητικού diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f1bee4bac..3aaea3a825 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1942,6 +1942,7 @@ action wizard Start Iniciar Delete field Eliminar campo Duplicate field Duplicar campo Set tab order Definir orden de tabulación + Set calculation order Establecer el orden de cálculo Change initial view Cambiar vista inicial Set alternate text Definir texto alternativo Mark figure as decorative Marcar figura como decorativa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5717ae203e..aaff44cb51 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1858,6 +1858,7 @@ Delete field Supprimer le champ Duplicate field Dupliquer le champ Set tab order Définir l'ordre de tabulation + Set calculation order Définir l'ordre de calcul Change initial view Modifier l'affichage initial Set alternate text Définir le texte de remplacement Mark figure as decorative Marquer la figure comme décorative diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4d9b65860d..ff23633508 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1848,6 +1848,7 @@ Delete field Mező törlése Duplicate field Mező megkettőzése Set tab order Tab-sorrend beállítása + Set calculation order Számítási sorrend beállítása Change initial view Kezdeti nézet módosítása Set alternate text Helyettesítő szöveg beállítása Mark figure as decorative Ábra megjelölése dekoratívként diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 74771c264e..cf65e3167a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1933,6 +1933,7 @@ Delete field Elimina campo Duplicate field Duplica campo Set tab order Imposta ordine di tabulazione + Set calculation order Imposta ordine di calcolo Change initial view Modifica vista iniziale Set alternate text Imposta testo alternativo Mark figure as decorative Contrassegna figura come decorativa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9b9ed9a928..2dd0482591 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1940,6 +1940,7 @@ Delete field フィールドを削除 Duplicate field フィールドを複製 Set tab order タブ順序を設定 + Set calculation order 計算順序を設定 Change initial view 初期表示を変更 Set alternate text 代替テキストを設定 Mark figure as decorative 図を装飾として指定 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 43d0a9e4fc..e72f8c2611 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1851,6 +1851,7 @@ Delete field Өрісті жою Duplicate field Өрістің көшірмесін жасау Set tab order Қойынды ретін орнату + Set calculation order Есептеу ретін орнату Change initial view Бастапқы көріністі өзгерту Set alternate text Балама мәтінді орнату Mark figure as decorative Кескінді декоративті деп белгілеу diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 3519dddd26..981cf685c3 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2281,6 +2281,7 @@ comment menu Edit Redaguoti Set status Rejected Nustatyti būseną „Atmesta“ Set status Unmarked Nustatyti būseną „Nežymėta“ Set tab order Nustatyti tabuliavimo tvarką + Set calculation order Nustatyti skaičiavimo tvarką Set the image of {name} Nustatyti {name} paveikslėlį Shadings Spalvų perėjimai Show a field Rodyti lauką diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fc7f9500bc..df1cd556e3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1921,6 +1921,7 @@ Delete field Usuń pole Duplicate field Powiel pole Set tab order Ustaw kolejność tabulacji + Set calculation order Ustaw kolejność obliczeń Change initial view Zmień widok początkowy Set alternate text Ustaw tekst alternatywny Mark figure as decorative Oznacz ilustrację jako dekoracyjną diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 36c9c6b816..c32f9a5118 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1915,6 +1915,7 @@ Set status Rejected Definir status como Rejeitado Set status Unmarked Definir status como Não marcado Set tab order Definir ordem de tabulação + Set calculation order Definir ordem de cálculo Set the image of {name} Definir a imagem de {name} Spreadsheet (.xlsx) Planilha (.xlsx) Square Quadrado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index eee29a7121..3b5fbf628a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1928,6 +1928,7 @@ Delete field Удалить поле Duplicate field Дублировать поле Set tab order Задать порядок табуляции + Set calculation order Задать порядок вычислений Change initial view Изменить начальный вид Set alternate text Задать альтернативный текст Mark figure as decorative Пометить фигуру как декоративную diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index a6793e5e0f..cc92401df2 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1651,6 +1651,7 @@ Set status Rejected Nastavi status Zavrnjeno Set status Unmarked Nastavi status Neoznačeno Set tab order Nastavi vrstni red tabulatorja + Set calculation order Nastavi vrstni red izračunov Set the image of {name} Nastavi sliko za {name} Set to current page ({n}) Nastavi na trenutno stran ({n}) Set to zero Nastavi na nič diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index a81c5ad5e9..72c7df714f 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1847,6 +1847,7 @@ action wizard Start ప్రారంభించు Delete field ఫీల్డ్‌ను తొలగించు Duplicate field ఫీల్డ్‌ను నకలు చేయి Set tab order ట్యాబ్ క్రమాన్ని సెట్ చేయి + Set calculation order గణన క్రమాన్ని సెట్ చేయండి Change initial view ప్రారంభ వీక్షణను మార్చు Set alternate text ప్రత్యామ్నాయ టెక్స్ట్‌ను సెట్ చేయి Mark figure as decorative చిత్రాన్ని అలంకరణగా గుర్తించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 71f8754cbc..69546ed05d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1823,6 +1823,7 @@ Delete field Alanı sil Duplicate field Alanı çoğalt Set tab order Sekme sırasını ayarla + Set calculation order Hesaplama sırasını ayarla Change initial view İlk görünümü değiştir Set alternate text Alternatif metni ayarla Mark figure as decorative Şekli dekoratif olarak işaretle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e44127cc80..58094f7c43 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1925,6 +1925,7 @@ Delete field Видалити поле Duplicate field Дублювати поле Set tab order Задати порядок табуляції + Set calculation order Задати порядок обчислень Change initial view Змінити початковий вигляд Set alternate text Задати альтернативний текст Mark figure as decorative Позначити рисунок як декоративний diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 32b34de010..f6e693a231 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2271,6 +2271,7 @@ comment menu Edit Sửa Set status Rejected Đặt trạng thái Đã từ chối Set status Unmarked Đặt trạng thái Chưa đánh dấu Set tab order Đặt thứ tự tab + Set calculation order Đặt thứ tự tính toán Set the image of {name} Đặt hình ảnh của {name} Shadings Chuyển sắc Show a field Hiện một trường diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3ec20a88ed..e8e91eb70c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2053,6 +2053,7 @@ Set status Rejected 将状态设为已拒绝 Set status Unmarked 将状态设为未标记 Set tab order 设置 Tab 键顺序 + Set calculation order 设置计算顺序 Set the image of {name} 设置 {name} 的图像 Spreadsheet (.xlsx) 电子表格(.xlsx) Square 正方形 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9098f14081..16a9b04aa6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1927,6 +1927,7 @@ Delete field 刪除欄位 Duplicate field 複製欄位 Set tab order 設定 Tab 順序 + Set calculation order 設定計算順序 Change initial view 變更初始檢視 Set alternate text 設定替代文字 Mark figure as decorative 將圖片標記為裝飾用途 diff --git a/docs/roadmap.md b/docs/roadmap.md index d3cd66800e..7407ecbd59 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -143,7 +143,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. The calculation order is read, checked, fixed and set (`form_calculation_order`): fields that calculate too early, loops, fields missing from `/CO`; Fix order moves only what must move, and an edited formula keeps its place. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | @@ -202,6 +202,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 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-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | | 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2cfaa518e7..f165b33165 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4595,9 +4595,9 @@ tier = "P0" milestone = "M6" status = "shipped" commands = ["form.field.properties"] -tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] -notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form" +tools = ["form_set_props", "form_fill", "form_calculation_order"] +evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::the_calculation_order_is_checked_fixed_and_kept", "crates/forms/src/tests.rs::a_calculation_order_with_gaps_and_extra_entries", "crates/forms/src/tests.rs::two_fields_with_one_name_keep_their_places_in_the_order", "crates/engine/src/tests.rs::setting_the_calculation_order_recalculates_and_undoes", "crates/automation/tests/automation.rs::the_calculation_order_is_read_fixed_and_set"] +notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form. form_calculation_order reads the order with what each formula reads, says which fields read a field calculated later, which read each other in a loop and which /CO leaves out, and sets it, moves one field (as the Up/Down of Acrobat's Set Field Calculation Order dialog) or fixes it (Fix order moves only what must move). A new calculation joins the end of /CO and an edited one keeps its place. The app has no way to see or change the order yet. Acrobat's dialog is known from Adobe's help summary only, not yet observed." [[feature]] id = "form.event-order" From 78ef66636d5427c24c35fe5babb0296aeba4bf52 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 20:13:46 -0400 Subject: [PATCH 3/4] security: enforce HTTPS on curl downloads (SonarCloud S6506) (#3933) Add --proto '=https' to curl invocations so redirects cannot downgrade to plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions). --- packaging/linux/package.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 7513d3c3d6..92813c3a82 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -137,7 +137,7 @@ if has appimage; then if [ -z "$TOOL" ]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi From dbc7130b3673977c5c87acc7a1f2b2e5cac72a7f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 20:14:11 -0400 Subject: [PATCH 4/4] ci: pin GitHub Actions to full commit SHA (SonarCloud githubactions:S7637) (#3932) Pin third-party actions to full-length commit SHAs (with version comments) instead of mutable tags/branches to prevent supply-chain tampering via retagged releases. Resolves SonarCloud rule githubactions:S7637. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 388b44f032..a21cbdfd65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: EmbarkStudios/cargo-deny-action@v2 + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 with: command: check