Merge upstream main dbc7130b into pr/attachments

Only ATTRIBUTION.toml met this branch: main's copy, hashes recomputed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Osvaldo Duran Melendez
2026-10-21 20:19:16 -04:00
co-authored by Claude Opus 5.5
60 changed files with 1201 additions and 194 deletions
+1 -1
View File
@@ -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
+22 -22
View File
@@ -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 = "bc45c479c079545e95b4da0bb3b87b2e4055b5e18b574120229eb417880f6e5d"
sha256 = "338bde04236d08e7e36eb7aca7f51d345c573d38a83bf504e208d1d828067411"
# 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 = "ecbdbff5a9ee655b281767f9d3b1c3d722bced24bd90855876691e52e86e530a"
sha256 = "5c10fb9a7b77da9f3142070ee4c16ff1aa8373cc58b2f9516a239ea0e388e24e"
[[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 = "94ee72fcb5813e689c359b8614381b6d2a73ba31277017e6f698148c32eec5e7"
sha256 = "72fc45bb9cfca74d401325a9f03de084584d1aa9e0459156faa7ec0ce6f649fe"
[[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 = "e4beb285d7c512407f753a28f7578bf7ab021371dedf742af782e923936ddb79"
sha256 = "031af5bb4262f1cf7c3f5c81b5d4c41022689ad715e34d1137335cb3d30e070a"
[[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 = "fbf6ddbe8c06817ec47777a121d73b8b9111b0a10e5b2ebc83c8643b34571e09"
sha256 = "b6e4a2f420e57aede449b491836fb658710f5de150c1596e27cc6be579aa334a"
[[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 = "1bd51621a011a7bb0dced484b47a0a0b2cf22a9c803d5248e492545887c87628"
sha256 = "776b179da358e5d5fd64f293d947a2c600782056e66e2c752299b8768e41c71d"
[[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 = "f0d4a84f9177f7700e1237911be0b1ac4e0a3ecf59066248bfd0d0640c1ef19b"
sha256 = "ef6303de273d5a3fd30e3ad82fce2019656b19e224596a1d537bc49e71ed9a1b"
[[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 = "c0ff9e1e02ac4a5125d7d5631bd4510f1ed120605ae8e91e1eb665af235e80f7"
sha256 = "0b999a945c1a98114dbeabe8a1c40f22a7e981f941aa201865e329f09c59b730"
[[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 = "cb6778a07e46e363c537c309aac2eec0e623fd25c06393d17b4e1ea88572735e"
sha256 = "caa52d39715ded8db0038c6b86e69e58ab8b8fd0b97a120e8a33d626085c37ec"
[[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 = "50f4517c42025e82f5c75baf35e56b0afbff1b6511e50f394cbdcb1dd5b0a08b"
sha256 = "a8e0d748cc315293777ad6f76bbd23158719fd0303ef9107fbc4094505216df4"
[[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 = "62fbf2fe87bf85dfa97fbfcb79c288d496a6f85e53aa1b76d7b7ebd3c7c644b8"
sha256 = "311e74349167d9e8b674a179558b94e168bb6ea436bb41712a21cdc81a25abd9"
[[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 = "7d1db25bf2da9275ce94f9377e8ab669c20d65b3996eaf528c62adef92b97773"
sha256 = "2ffd60310ba2acb79741a1a8ebfdd17266f106e965dce7fcec48a16b96e6d6ca"
[[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 = "28de6c61e4e7cc7272e4e4f7bff6cbf5488ab43cdb06dfc2eb0c6517aa2c3545"
sha256 = "fdbe1c947d54fcc25d33ec67b7ec533236984d458f46a34fa9d079fcfc91ca7c"
[[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 = "e57813d452a740359edf7dc32b6c15fffb922755e8e0fd564e5dd21f938b2d5a"
sha256 = "2538752e8101ee47fb9e2886cc83d14f6cfb1148e89809177bcbdf02b0921616"
[[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 = "1a1a4a9326560b7f7838b154b03629ee2d13ebbe863928e965f73d394042d58a"
sha256 = "ebae0ebc3e80b97e1ddbc46d5b4a9f0ca91a3fb18391d4922661d7f605bacb42"
[[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 = "39848bd12c71644540efcd2b525ede6722194286c43b8ab1ebf75b84b7f4c2dc"
sha256 = "aa1e7ac8c3e50fef8971b0fa2a8191bc1136ca59a3449550ccab58f9bc1e29fa"
[[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 = "5ebf01dd94a63e5ceb4902b2f79c242a09df9b45fa1c2a8a29900f2c1a7287a4"
sha256 = "ee7b2759ffe9c973b894fd4fb370a38372ffcfd3cb587c0b953970106943c7ec"
[[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 = "9d0ee86d90b8da3b9dc7b5a8a50cc61228d2f043c9e6a5a2205aed371033ab59"
sha256 = "adfbf93ff67e5dcc2ba27a43a4edeb278b0c11b9d376ef881a8eaca59caed95c"
[[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 = "a5148fa3ac60f20af86e7675e1c4953dbd9ae3825656a9e81dc80b9c7f6a5411"
sha256 = "981dee0e25b88c333b3303a0c3fd3a70ac6fe898abfb15195100e9fe560b0fa6"
[[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 = "084085ddd3dee33723f754d34ec54c542d228815afb781d774ac7cfeefcfba5a"
sha256 = "39d95bd550cdf9a3275b1860f6deeb7f0ef6f19bb05affa00885d8086b3b064b"
[[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 = "2f958caeec497690d1ebdce9d0bafc81cef9d5a83b2582a9d763bc018a9685c3"
sha256 = "6bdb285dbd7d94277534a023374de19717c2707c343ba95c5f7f4d13de7f620d"
[[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 = "2106b2e838e4c5d439fe8a382e4b464fb7b6efaa0cece745c705b986c1d35164"
sha256 = "9b474f9bbf1a6524f1e4546041c14da631e0958e38acef586b7ed1ba7ef4faf8"
[[asset]]
+1
View File
@@ -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.
Generated
+6
View File
@@ -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",
+2
View File
@@ -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"
+1
View File
@@ -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.
+2
View File
@@ -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
+35 -15
View File
@@ -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]
+49 -1
View File
@@ -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<Value> {
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<Vec<String>> = 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<String> = 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::<Vec<_>>());
out["fields"] = order
.fields
.iter()
.map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::<Vec<_>>() }))
.collect();
out["extra"] = json!(order.extra);
out["fixable"] = json!(order.fixable());
Ok(out)
}
fn tab_sequence(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let mut seq: Vec<(usize, &str)> =
+1
View File
@@ -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)?,
+5
View File
@@ -416,6 +416,11 @@ pub fn tools() -> Vec<ToolDef> {
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"])),
+58
View File
@@ -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");
+2
View File
@@ -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 }
+44 -13
View File
@@ -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] {
+3 -2
View File
@@ -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"] }
+27 -5
View File
@@ -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,
}
@@ -587,6 +587,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<a11y::Figure> {
self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default()
@@ -1631,6 +1637,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<String>,
},
/// Add a header and footer (with `replace`, existing ones on those pages go first).
AddHeaderFooter {
pages: Vec<usize>,
@@ -1900,6 +1911,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",
@@ -2026,6 +2038,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::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()),
Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()),
@@ -2225,6 +2238,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::DuplicateField { .. }
| Edit::SetTabOrder { .. }
| Edit::MoveInTabOrder { .. }
| Edit::SetCalculationOrder { .. }
| Edit::SetInitialView(_)
| Edit::AddAttachment { .. }
| Edit::DeleteAttachment { .. }
@@ -2519,6 +2533,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()))?;
+41
View File
@@ -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::<Vec<_>>(), ["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<u8> {
+8
View File
@@ -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
+53
View File
@@ -698,6 +698,34 @@ pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option<String> {
(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<String> {
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<String> {
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<Item = String>) -> Vec<String> {
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::<Vec<_>>().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::<String>::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]
+1 -20
View File
@@ -1140,26 +1140,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if let Some(js) = crate::af::calculate_js(c) {
aa.set(b"C".to_vec(), action(&js));
}
// The calculation order lists calculated fields.
let af_ref = ensure_form(doc)?;
let mut co: Vec<Object> = 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() {
+429
View File
@@ -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<String>,
/// `/CO` lists it. A field it leaves out calculates after the listed ones.
pub listed: bool,
/// What is wrong with its place in the order.
pub issues: Vec<CalculationIssue>,
}
/// 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<String> },
/// `/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<CalculatedField>,
/// `/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<String>,
/// Fix order's order, as indexes into `fields`.
fixed: Vec<usize>,
}
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<String> {
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<Object> {
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<Object>) -> 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<String>) {
let mut by_obj: HashMap<ObjRef, usize> = 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<Vec<String>> = 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<String> = 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<Item = &'a str>, reads: &[Vec<String>]) -> Vec<Vec<usize>> {
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<usize>]) -> Vec<usize> {
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<usize>, 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<usize>], component: &[usize]) -> Vec<usize> {
let n = links.len();
let mut waits = vec![0usize; n];
let mut readers: Vec<Vec<usize>> = 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<Reverse<usize>> = 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<usize> = 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<usize>> = 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<usize>]) -> Vec<usize> {
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<Vec<usize>> = (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<String> = (0..n).map(|i| format!("g.{i}")).chain((0..n).map(|i| format!("r{i}"))).collect();
let reads: Vec<Vec<String>> = (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<Vec<usize>> = 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<Vec<bool>> = (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::<Vec<_>>(), "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<Vec<usize>> = fixed.iter().map(|&k| { let mut l: Vec<usize> = links[k].iter().map(|&j| at[j]).collect(); l.sort_unstable(); l }).collect();
proptest::prop_assert_eq!(fix(&renumbered), (0..n).collect::<Vec<_>>());
}
}
}
+8 -17
View File
@@ -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<usize, FormError> {
/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`.
pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result<usize, FormError> {
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<ObjRef> = 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<String> = 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<ObjRef> = 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<String> {
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;
+1 -19
View File
@@ -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<Object> = 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(())
}
+153
View File
@@ -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::<Vec<_>>(), ["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::<Vec<_>>();
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<String> = 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<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 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<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 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<String>, 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<String> = ["dup", "src", "dup"].map(String::from).into();
set_calculation_order(&mut doc, &current).unwrap();
assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)], "the current order sets back unchanged");
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<Vec<String>> = 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<String> = ["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
+3
View File
@@ -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"] }
+65 -23
View File
@@ -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)));
+11
View File
@@ -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");
+2
View File
@@ -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"
+38 -17
View File
@@ -285,6 +285,9 @@ fn contents_len(input: &[u8], form: Form, depth: usize) -> Result<usize, SignErr
}
}
/// `NaiveDate::num_days_from_ce` of 1970-01-01, so the count can be rebased onto the Unix epoch.
const UNIX_EPOCH_DAYS_FROM_CE: i64 = 719_163;
/// A UTC time.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Time {
@@ -327,27 +330,37 @@ impl Time {
/// Seconds since 1970-01-01 (proleptic Gregorian).
pub fn unix(&self) -> 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 });
}
}
+3
View File
@@ -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"] }
+49 -13
View File
@@ -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<u8> {
let objs = [
+1
View File
@@ -990,6 +990,7 @@ fn is_form_edit(edit: &Edit) -> bool {
| Edit::SetFieldProps { .. }
| Edit::SetFieldImage { .. }
| Edit::SetFieldScript { .. }
| Edit::SetCalculationOrder { .. }
| Edit::ApplyScriptChanges { .. }
)
}
+15 -9
View File
@@ -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());
+1
View File
@@ -1927,6 +1927,7 @@
Delete field حذف الحقل
Duplicate field تكرار الحقل
Set tab order ضبط ترتيب التنقل
Set calculation order تعيين ترتيب الحساب
Change initial view تغيير العرض الأولي
Set alternate text تعيين النص البديل
Mark figure as decorative تعليم الشكل كزخرفي
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 Квадрат
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -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í
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -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 Σήμανση σχήματος ως διακοσμητικού
Can't render this file because it contains an unexpected character in line 477 and column 9.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1940,6 +1940,7 @@
Delete field フィールドを削除
Duplicate field フィールドを複製
Set tab order タブ順序を設定
Set calculation order 計算順序を設定
Change initial view 初期表示を変更
Set alternate text 代替テキストを設定
Mark figure as decorative 図を装飾として指定
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1851,6 +1851,7 @@
Delete field Өрісті жою
Duplicate field Өрістің көшірмесін жасау
Set tab order Қойынды ретін орнату
Set calculation order Есептеу ретін орнату
Change initial view Бастапқы көріністі өзгерту
Set alternate text Балама мәтінді орнату
Mark figure as decorative Кескінді декоративті деп белгілеу
Can't render this file because it contains an unexpected character in line 826 and column 9.
+1
View File
@@ -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ą
Can't render this file because it contains an unexpected character in line 404 and column 9.
+1
View File
@@ -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ą
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1928,6 +1928,7 @@
Delete field Удалить поле
Duplicate field Дублировать поле
Set tab order Задать порядок табуляции
Set calculation order Задать порядок вычислений
Change initial view Изменить начальный вид
Set alternate text Задать альтернативный текст
Mark figure as decorative Пометить фигуру как декоративную
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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č
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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 చిత్రాన్ని అలంకరణగా గుర్తించు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 825 and column 9.
+1
View File
@@ -1925,6 +1925,7 @@
Delete field Видалити поле
Duplicate field Дублювати поле
Set tab order Задати порядок табуляції
Set calculation order Задати порядок обчислень
Change initial view Змінити початковий вигляд
Set alternate text Задати альтернативний текст
Mark figure as decorative Позначити рисунок як декоративний
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 491 and column 12.
+1
View File
@@ -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 正方形
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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 將圖片標記為裝飾用途
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2 -1
View File
@@ -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.<br>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.<br>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<br>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.<br>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<br>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.<br>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<br>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.<br>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.<br>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. |
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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"
+2
View File
@@ -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 }
+31 -12
View File
@@ -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]