From 1f8811761a08d853be4af0c36d52a551bd274054 Mon Sep 17 00:00:00 2001 From: Jayesh-Prajapati9 Date: Fri, 23 Oct 2026 18:15:48 +0530 Subject: [PATCH 01/13] M14: update ctutils to 0.4.5 for RUSTSEC-2026-0385 The dependency-advisory gate rejects ctutils 0.4.2 (non-constant-time mask generation and u128 comparisons, fixed in 0.4.4). It comes in through crypto-bigint for the signing crates. Only its version and checksum change in Cargo.lock. --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47d73be44..9129e85d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1291,9 +1291,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", "subtle", From c32c2ce09f00cc90955d1bd8520a7d30e61a25fc Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:18:45 +0200 Subject: [PATCH 02/13] Fix FormCalc Str() printing small negative numbers as "-0" (#4108) Found while testing crates/js/src/formcalc.rs:716-720 (fixed), used by Str at :2216 Co-authored-by: Claude Haiku 5.5 --- crates/js/src/formcalc.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 4ff77b74c..4b955ef9f 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -716,6 +716,8 @@ fn num_text(n: f64) -> String { fn fixed(v: f64, decimals: usize) -> String { let k = 10f64.powi(decimals.min(20) as i32); let scaled = (v * k).round() / k; + // A small negative value rounds to -0.0, which would print as "-0". + let scaled = if scaled == 0.0 { 0.0 } else { scaled }; format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) } @@ -2627,6 +2629,14 @@ pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: X mod tests { use super::*; + #[test] + fn fixed_does_not_print_negative_zero() { + assert_eq!(fixed(-0.4, 0), "0"); + assert_eq!(fixed(-0.004, 2), "0.00"); + assert_eq!(fixed(-0.0, 0), "0"); + assert_eq!(fixed(-1.5, 0), "-2"); + } + #[test] fn dates_round_trip_through_formcalc_day_numbers() { assert_eq!(days_from_civil(1900, 1, 1), 1); From 3ee6a83e09357d34f06782d3d868e82e73f42da0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:18:55 +0200 Subject: [PATCH 03/13] Accept long unit names in FormCalc UnitValue (#4116) Found while testing crates/js/src/formcalc.rs:2526-2532 (UnitValue per_in table; UnitType at :2510-2516) Co-authored-by: Claude Haiku 5.5 --- crates/js/src/formcalc.rs | 8 ++++---- crates/js/src/tests.rs | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 4b955ef9f..2348ce220 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -2522,10 +2522,10 @@ impl Interp<'_> { let v = parse_num(&s).unwrap_or(0.0); let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); let per_in = |u: &str| match u.trim().to_ascii_lowercase().as_str() { - "mm" => 25.4, - "cm" => 2.54, - "pt" => 72.0, - "pc" => 6.0, + "mm" | "millimeter" | "millimeters" => 25.4, + "cm" | "centimeter" | "centimeters" => 2.54, + "pt" | "point" | "points" => 72.0, + "pc" | "pica" => 6.0, _ => 1.0, }; let target = if to.trim().is_empty() { unit.clone() } else { to }; diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 01a436240..7d63b4998 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -990,6 +990,9 @@ mod formcalc { assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51"); assert_eq!(calc(r#"UnitValue("2in", "mm")"#), "50.8"); assert_eq!(calc(r#"UnitType("2.5cm")"#), "cm"); + assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1"); + assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1"); + assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72"); assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network")); From 30331b811db7d965e9f9f0b6552807d5cdae1d5b Mon Sep 17 00:00:00 2001 From: ffishman Date: Fri, 23 Oct 2026 11:19:00 -0300 Subject: [PATCH 04/13] test(sign): serialize the Keychain search-list tests (#4124) * test(sign): serialize the Keychain search-list tests Both ignored Keychain tests save the user's keychain search list, change it and restore what they saved. Run together (`--ignored` runs them in parallel), one could restore its list while the other needed its temporary keychain, or put back a keychain the other had already deleted. A shared lock now runs them one at a time. With the lock, the issuer test still failed about one run in ten when it came second: the Security framework caches the search list in-process and learns of `security list-keychains` asynchronously, so `keychain::find` did not yet look in the new keychain. The test now waits up to five seconds for the identity to appear. Follows up a CodeRabbit review comment on #3716. Co-Authored-By: Claude Opus 5.5 (1M context) * test(sign): restore the keychain search list before deleting the keychain If deleting the temporary keychain panicked, the test left it on the user's search list, and the next Keychain test (which accepts the poisoned lock) saved that list and later restored it. Restore the list first. Co-Authored-By: Claude Opus 5.5 (1M context) * test(sign): fail when the keychain search list isn't restored Both Keychain tests ignored the result of the `security list-keychains` that restores the user's search list, so a failed restore went unnoticed and left the deleted temporary keychain on the list. They now check it after deleting the keychain. Co-Authored-By: Claude Opus 5.5 (1M context) * test(sign): keep the issuer test's keychain at one path trustd remembers which keychain file it found an issuer in. Each run of `keychain_identities_carry_their_issuers` created its keychain at a path with the process id and deleted it afterwards, so the next run's trust evaluation kept looking in the deleted file and built an empty chain, until trustd forgot it. The test failed every run after the first for a while. The keychain now lives at the same path every run, and a copy left by a killed run is deleted first. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: ffishman <20211677+ffishman@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- crates/sign/tests/it/pdf.rs | 48 ++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index b7de2a4d3..3cad0fa75 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -665,12 +665,20 @@ trailer << /Root 1 0 R >> } } +/// Held by every test that changes the user's keychain search list. Each saves the list, changes +/// it and restores what it saved, so two at once could undo each other's temporary keychain or +/// put back one already deleted. +#[cfg(target_os = "macos")] +static KEYCHAIN_SEARCH_LIST: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Signing with Keychain identities, in a throwaway keychain file. Ignored by default: creating /// a keychain touches the user's keychain search list (restored afterwards). #[cfg(target_os = "macos")] #[test] #[ignore = "creates a temporary macOS keychain; run with --ignored"] fn signing_with_keychain_identities() { + // A test that failed while holding it leaves the list restored, so a poisoned lock is fine. + let _list = KEYCHAIN_SEARCH_LIST.lock().unwrap_or_else(std::sync::PoisonError::into_inner); use std::process::Command; let dir = std::env::temp_dir().join(format!("pdfcraft-keychain-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); @@ -683,7 +691,7 @@ fn signing_with_keychain_identities() { let restore = || { let mut args = vec!["list-keychains", "-d", "user", "-s"]; args.extend(saved.iter().map(String::as_str)); - sec(&args); + sec(&args) }; restore(); // Everything that can fail runs before the clean-up below. @@ -705,9 +713,11 @@ fn signing_with_keychain_identities() { assert_eq!(s.status, Status::Valid, "{:?}", s.details); } })); + // The list first: it is what outlives the test if deleting the keychain goes wrong. + let restored = restore(); sec(&["delete-keychain", k]); - restore(); let _ = std::fs::remove_dir_all(&dir); + assert!(restored.status.success(), "the keychain search list wasn't restored: {}", String::from_utf8_lossy(&restored.stderr)); result.unwrap(); } @@ -718,20 +728,28 @@ fn signing_with_keychain_identities() { #[test] #[ignore = "creates a temporary macOS keychain; run with --ignored"] fn keychain_identities_carry_their_issuers() { + // A test that failed while holding it leaves the list restored, so a poisoned lock is fine. + let _list = KEYCHAIN_SEARCH_LIST.lock().unwrap_or_else(std::sync::PoisonError::into_inner); use std::process::Command; - let dir = std::env::temp_dir().join(format!("pdfcraft-keychain-chain-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + // The same path every run: trustd remembers which keychain file it found an issuer in, and + // after a run's keychain is deleted it keeps looking there, so a keychain at a new path + // would get an empty chain until that memory expires. + let dir = std::env::temp_dir().join("pdfcraft-keychain-chain"); let kc = dir.join("test.keychain-db"); let sec = |args: &[&str]| Command::new("security").args(args).output().unwrap(); + let k = kc.to_str().unwrap(); + // Left over by a run that was killed before its clean-up. + sec(&["delete-keychain", k]); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); let list = String::from_utf8(sec(&["list-keychains", "-d", "user"]).stdout).unwrap(); let saved: Vec = list.lines().map(|l| l.trim().trim_matches('"').to_string()).filter(|l| !l.is_empty()).collect(); - let k = kc.to_str().unwrap(); assert!(sec(&["create-keychain", "-p", "pc-test", k]).status.success()); let set_list = |extra: Option<&str>| { let mut args = vec!["list-keychains", "-d", "user", "-s"]; args.extend(saved.iter().map(String::as_str)); args.extend(extra); - sec(&args); + sec(&args) }; // macOS finds an identity's issuers in the keychains on the search list. set_list(Some(k)); @@ -742,15 +760,29 @@ fn keychain_identities_carry_their_issuers() { assert!(out.status.success(), "chain-legacy.p12: {}", String::from_utf8_lossy(&out.stderr)); let listed = pdfcraft_sign::keychain::identities(Some(&kc)).unwrap(); assert_eq!(listed.len(), 1, "{listed:?}"); - let id = pdfcraft_sign::keychain::find(&pdfcraft_sign::keychain::reference(&listed[0].certificate)).unwrap(); + // The Security framework caches the search list in-process and learns of `security + // list-keychains` asynchronously, so after another Keychain test it takes a moment to look + // in the new keychain. + let reference = pdfcraft_sign::keychain::reference(&listed[0].certificate); + let mut found = pdfcraft_sign::keychain::find(&reference); + for _ in 0..50 { + if found.is_ok() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + found = pdfcraft_sign::keychain::find(&reference); + } + let id = found.unwrap(); let issuers: Vec<_> = id.chain.iter().map(|c| c.subject.common_name()).collect(); assert_eq!(issuers, [Some("PrintCraft Test Root CA")]); let file = pkcs12::open(&data("chain.p12"), "test").unwrap(); assert_eq!(id.chain.iter().map(|c| &c.raw).collect::>(), file.chain.iter().map(|c| &c.raw).collect::>()); })); + // The list first: it is what outlives the test if deleting the keychain goes wrong. + let restored = set_list(None); sec(&["delete-keychain", k]); - set_list(None); let _ = std::fs::remove_dir_all(&dir); + assert!(restored.status.success(), "the keychain search list wasn't restored: {}", String::from_utf8_lossy(&restored.stderr)); result.unwrap(); } From 1b247a481254de320c9953dd135e286e96e38a02 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:19:04 -0400 Subject: [PATCH 05/13] M9: sign_list lists a hidden or NoView signature field as not visible (#4141) --- crates/sign/src/pdf.rs | 5 ++++- crates/sign/tests/it/pdf.rs | 18 ++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index f4fe2c601..cb0069330 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -587,7 +587,10 @@ pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &Dig .and_then(|(_, w)| nums(doc, w, b"Rect")) .filter(|r| r.len() == 4) .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]); - let visible = rect.is_some_and(|r| r[2] - r[0] > 0.5 && r[3] - r[1] > 0.5); + // Shown when it has a box, and its widget isn't Hidden (2) or NoView (32), as the forms + // crate reads a widget's flags. + let flags = widget.and_then(|(_, w)| w.get(b"F")).and_then(|o| doc.resolve(o).as_int()).unwrap_or(0); + let visible = rect.is_some_and(|r| r[2] - r[0] > 0.5 && r[3] - r[1] > 0.5) && flags & (2 | 32) == 0; let v = f.signature(doc); let mut info = SignatureInfo { field: f.name.clone(), diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 3cad0fa75..41f9c58e1 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -163,6 +163,24 @@ fn a_field_with_a_null_value_is_unsigned_and_can_be_signed() { } } +/// A signature field whose widget is hidden (annotation flag Hidden, 2) or not shown on screen +/// (NoView, 32) is listed as not visible, whatever its rectangle, as form widgets are: a form's +/// requestor box with flags 96 (NoView and ReadOnly) has no box on the page to show. A widget +/// that is only printable (4) stays visible. +#[test] +fn a_hidden_signature_field_is_listed_as_not_visible() { + let field = pdfcraft_cos::ObjRef { num: 6, generation: 0 }; + let visible_with = |flags: i64| { + let bytes = edit_after(&fixture(), |doc| doc.update_dict(field, |d| d.set(b"F".to_vec(), Object::Int(flags))).unwrap()); + let listed = signatures(&open(&bytes), &bytes, &TrustStore::default()); + listed.iter().find(|s| s.field == "Approval").map(|s| s.visible).expect("listed") + }; + assert!(visible_with(4), "printable only: shown"); + for flags in [2, 32, 96, 34] { + assert!(!visible_with(flags), "flags {flags}: not shown"); + } +} + /// Follow-up to #1374: a signature field whose /V is neither null nor a signature dictionary is /// refused by name with an error saying so, instead of being overwritten or called signed. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6a2d0ccf9..6200bac36 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6241,8 +6241,8 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] -notes = "" +evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/sign/tests/it/pdf.rs::a_hidden_signature_field_is_listed_as_not_visible"] +notes = "sign_list reports a signature as visible only when its widget has a box and isn't Hidden or NoView, as form widgets are read, so a hidden field gets no badge on the page" [[feature]] id = "sign.validation-banner" From 8f292c66ae32e8c765258d1f412ecb3eba63261a Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:19:09 -0400 Subject: [PATCH 06/13] M6: scripts see xfa_version, and xfa_installed in XFA forms (#4142) --- crates/engine/src/js.rs | 10 +++++++++- crates/engine/src/tests.rs | 14 ++++++++++++++ crates/js/src/lib.rs | 17 +++++++++++++++-- crates/js/src/tests.rs | 28 +++++++++++++++++++++++++++- parity/acrobat-features.toml | 4 ++-- 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index c80537fba..a59743dbb 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -129,8 +129,16 @@ pub struct JsRunner { } impl JsRunner { + /// A runner for `cos`: what scripts read about the document (pages, info, whether it is + /// an XFA form) and its document-level scripts, read once. pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner { - let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos) }; + let doc = pdfcraft_js::DocInfo { + file_name: file_name.to_string(), + num_pages: pdfcraft_model::pages(cos).len(), + page: 0, + info: info(cos), + xfa: pdfcraft_xfa::xfa_shape(cos).is_some(), + }; JsRunner { doc, doc_scripts: pdfcraft_forms::document_scripts(cos), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index afe6fd06c..c8f0eb886 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4076,6 +4076,20 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.run_javascript(id, "1", None).is_err()); } +/// XFA forms check `xfa_installed` and `xfa_version` in a document script that runs before +/// each of their scripts, and alert when either is missing. +#[test] +fn scripts_in_an_xfa_form_see_the_xfa_plugin_installed() { + let check = "typeof xfa_installed + ' ' + typeof xfa_version + ' ' + xfa_version"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let xfa = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))), None).unwrap(); + let o = s.run_javascript(xfa, check, None).unwrap(); + assert_eq!((o.error, o.result.as_deref()), (None, Some("boolean number 3.5"))); + let plain = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let o = s.run_javascript(plain, check, None).unwrap(); + assert_eq!((o.error, o.result.as_deref()), (None, Some("undefined number 3.5"))); +} + #[test] fn edit_history_does_not_replay_field_script_side_effects() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index f2221f750..13bdc4ba3 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -144,6 +144,8 @@ pub struct DocInfo { pub page: usize, /// Document information (`this.info.title` etc.): lower-case key, value. pub info: Vec<(String, String)>, + /// The document has an XFA form, so scripts see `xfa_installed`. + pub xfa: bool, } /// Something a script asked the viewer to do. @@ -971,6 +973,11 @@ fn color_object(ctx: &mut Context) -> JsObject { o.build() } +/// The XFA version scripts see as `xfa_version`. +const XFA_VERSION: f64 = 3.5; + +/// Define the globals every script sees: the document's functions and properties (`getField`, +/// `numPages`, `info`, the XFA version), and `app`, `util`, `console`, `display` and `color`. fn install(ctx: &mut Context) -> JsResult<()> { let globals: [(&str, usize, NativeFunction); 9] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), @@ -991,9 +998,9 @@ fn install(ctx: &mut Context) -> JsResult<()> { let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields)); let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get)); let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set)); - let (file, pages, info) = { + let (file, pages, info, xfa) = { let h = host(ctx)?.borrow(); - (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone()) + (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone(), h.doc.xfa) }; let mut io = ObjectInitializer::new(ctx); for (k, v) in &info { @@ -1006,6 +1013,12 @@ fn install(ctx: &mut Context) -> JsResult<()> { ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?; ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?; ctx.register_global_property(js_string!("info"), info, rw)?; + // XFA forms check both before they run: every document sees the XFA version, and only an + // XFA form sees `xfa_installed`. + ctx.register_global_property(js_string!("xfa_version"), JsValue::from(XFA_VERSION), rw)?; + if xfa { + ctx.register_global_property(js_string!("xfa_installed"), JsValue::from(true), rw)?; + } let app = ObjectInitializer::new(ctx) .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 7d63b4998..b2e866fae 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -16,7 +16,7 @@ fn fields() -> Vec { } fn doc() -> DocInfo { - DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())] } + DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], xfa: false } } fn go(script: &str, event: &Event) -> Outcome { @@ -1454,3 +1454,29 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() { assert_eq!(read.value, "undefined:4:false"); assert!(read.changed.is_empty()); } + +/// The check XFA forms run on opening: without both globals, or with a version below 2.8, it +/// alerts that the viewer can't show the form. +const XFA_CHECK: &str = "if (typeof(xfa_installed) == 'undefined' || typeof(xfa_version) == 'undefined' || xfa_version < 2.8) app.alert('old viewer'); \ + event.value = typeof xfa_installed + ' ' + typeof xfa_version + ' ' + xfa_version;"; + +/// An XFA form sees `xfa_installed` (true) and `xfa_version` (3.5), so the check passes. +#[test] +fn an_xfa_form_sees_the_xfa_plugin_installed() { + let xfa = DocInfo { xfa: true, ..doc() }; + let o = run(XFA_CHECK, &Event::doc("Open"), &xfa, &fields(), &[], Limits::default()); + assert_eq!(o.error, None); + assert_eq!(o.value, "boolean number 3.5"); + assert!(o.alerts.is_empty(), "{:?}", o.alerts); + let o = run("event.value = String(xfa_installed);", &Event::doc("Open"), &xfa, &fields(), &[], Limits::default()); + assert_eq!(o.value, "true"); +} + +/// A form without XFA sees `xfa_version` but no `xfa_installed`, so the check alerts. +#[test] +fn a_plain_form_sees_the_xfa_version_but_not_installed() { + let o = go(XFA_CHECK, &Event::doc("Open")); + assert_eq!(o.error, None); + assert_eq!(o.value, "undefined number 3.5"); + assert_eq!(o.alerts, ["old viewer"]); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6200bac36..973e0cc69 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5165,8 +5165,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent." [[feature]] id = "form.js-console" From f2e33db1137fc26b8473ef74ec232a1434c6a836 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 23 Oct 2026 08:19:13 -0600 Subject: [PATCH 07/13] Regression tests for #246, #843 and #2028 (#4143) * #2028: test that Edit text & images is the first Modify page item Edit text & images was listed under Add content, though it changes what is already on the page. The test checks that the Edit a PDF catalogue lists it first under Modify page and not under Add content. With the item back under Add content it fails on the section assertion (left: Add content, right: Modify page). * #246: test that start-up and the panic hook never use eprintln eprintln! panics when standard error is a broken pipe, and a panic inside the panic hook aborts the process. The test scans the product code of the desktop main.rs (everything before the test module) and fails on any eprintln! outside a comment. With the old eprintln! calls restored in the panic hook and the log-file warning, it fails listing main.rs:188 and main.rs:227. * #843: test that text typed into an open paragraph counts as unsaved work An open paragraph with a draft that was not applied yet was not counted as unsaved work, so closing the tab or quitting skipped the save prompt. The test opens a paragraph, types into it, and checks that first_dirty() reports the tab (and not before typing, nor after the original text is typed back). Without the line_editor check in has_unsaved_work, first_dirty() stays None and the assertion fails (left: None, right: Some(0)). --- apps/pdfcraft/src/main.rs | 22 +++++++++++++++++ crates/engine/src/catalog.rs | 13 ++++++++++ crates/ui-egui/tests/it/editing.rs | 39 ++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 1a36d64bf..420683942 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1414,4 +1414,26 @@ mod tests { let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); assert_eq!(class, Some(super::APP_ID)); } + + // Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI + // launch on Windows), and a panic inside the panic hook aborts the process. The start-up + // path and the hook must write to standard error without it. + #[test] + fn start_up_and_the_panic_hook_never_use_eprintln() { + let source = include_str!("main.rs"); + // Everything before the top-level test module is product code. + let product = source.split("\n#[cfg(test)]\nmod tests {").next().unwrap_or(source); + assert!(product.len() < source.len(), "found the end of the product code"); + let offenders: Vec<_> = product + .lines() + .enumerate() + .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!")) + .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim())) + .collect(); + assert!( + offenders.is_empty(), + "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}", + offenders.join("\n") + ); + } } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index c19e1e42c..cfee2e3ba 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -596,6 +596,19 @@ mod tests { assert!(menus >= 3); } + // Regression test for #2028: Edit text & images sat under "Add content", though it changes + // what is already on the page; it belongs first under "Modify page". + #[test] + fn edit_text_and_images_is_the_first_item_under_modify_page() { + let edit = group("edit").expect("the Edit a PDF tool"); + let section_of = |command: &str| edit.sections.iter().find(|s| s.items.iter().any(|i| i.command == command)).map(|s| s.title); + assert_eq!(section_of("edit.edit_text"), Some("Modify page"), "Edit text & images is a Modify page tool"); + let modify = edit.sections.iter().find(|s| s.title == "Modify page").expect("a Modify page section"); + assert_eq!(modify.items.first().map(|i| i.command), Some("edit.edit_text"), "and the first one, so it is not below the fold"); + let add = edit.sections.iter().find(|s| s.title == "Add content").expect("an Add content section"); + assert!(add.items.iter().all(|i| i.command != "edit.edit_text"), "it adds nothing, so it is not listed under Add content"); + } + #[test] fn every_update_and_remove_command_is_reachable_from_a_tool_entry() { let reachable: HashSet<&str> = diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 71ba9ccda..73464d33e 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -3090,6 +3090,45 @@ fn switching_straight_to_another_box_applies_the_open_paragraph() { assert_eq!(ed.text, "Bottom"); } +// Regression test for #811: text typed into an open paragraph was not counted as unsaved work, so +// closing the tab or quitting discarded it without the save prompt. +#[test] +fn text_typed_into_an_open_paragraph_counts_as_unsaved_work() { + let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET"); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("box.pdf", None, bytes).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 300.0; + let block = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().text_blocks(0).into_iter().next().expect("one editable box") + }; + let center = egui::pos2( + r.left() + ((block.rect[0] + block.rect[2]) / 2.0) as f32 * k, + r.top() + (300.0 - (block.rect[1] + block.rect[3]) / 2.0) as f32 * k, + ); + click_at(&mut h, center); + assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Top"), "the paragraph is open for typing"); + assert_eq!(h.state().first_dirty(), None, "opening a paragraph changes nothing"); + + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Top, and a draft not applied yet".into(); + h.run_steps(1); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "the document itself is untouched until the draft is applied"); + assert_eq!(h.state().first_dirty(), Some(0), "the draft in the open paragraph is unsaved work"); + + // Typing the original text back leaves nothing to lose. + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Top".into(); + h.run_steps(1); + assert_eq!(h.state().first_dirty(), None, "a draft equal to the page text is not unsaved work"); +} + /// Dark pixels in `r`: glyph ink on a white page. fn ink(pixels: &image::RgbaImage, r: egui::Rect) -> usize { let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32); From cfda92233d187e2cf56a96b760501a7643b0fa59 Mon Sep 17 00:00:00 2001 From: Matej Vrzala Date: Fri, 23 Oct 2026 16:19:17 +0200 Subject: [PATCH 08/13] fix(optimize): keep a destination referenced only through an indirect /Dest (#4212) Reduce File Size removes named destinations that nothing refers to. It collected the referenced names by reading /Dest and a GoTo's /D directly, so when a link's /Dest was an indirect reference to the name string (valid PDF) rather than the string itself, the name was not counted. The destination it used was then treated as unreferenced and deleted, silently breaking the link. referenced_names now resolves /Dest and /D before reading the name, so an indirectly referenced destination is kept. Test: a link whose /Dest is a reference to the name string keeps its destination through optimize(); an unused destination is still removed. Reported-by: BS.ehlas.cz --- crates/optimize/src/links.rs | 7 ++++-- crates/optimize/tests/optimize.rs | 42 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/optimize/src/links.rs b/crates/optimize/src/links.rs index 744881df7..082d56e7d 100644 --- a/crates/optimize/src/links.rs +++ b/crates/optimize/src/links.rs @@ -150,11 +150,14 @@ fn referenced_names(doc: &Document) -> HashSet> { while let Some(o) = stack.pop() { match o { Object::Dict(d) => { - if let Some(n) = d.get(b"Dest").and_then(dest_name) { + // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name + // string, and an unresolved ref has no name, so the destination it uses would + // look unreferenced and be removed. + if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { out.insert(n); } if matches!(d.name(b"S"), Some(b"GoTo" | b"GoToR" | b"GoToE")) - && let Some(n) = d.get(b"D").and_then(dest_name) + && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) { out.insert(n); } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 6cf53d4fc..6d9a9bb44 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1097,3 +1097,45 @@ fn a_hostile_soft_mask_leaves_its_image_unchanged() { assert_eq!(stream(&doc, photo), before); assert_eq!(stream(&doc, mask), hostile); } + +#[test] +fn indirectly_referenced_destination_is_kept() { + // A link's /Dest can be an indirect reference to the name string, not the string itself. Reduce + // File Size read the name only from a direct string, so it deleted a destination the link still + // used, silently breaking the link. + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + // A link whose /Dest is `ref -> "target"`, and an unused destination for contrast. + let name_ref = Object::Ref(doc.add(s("target"))); + let mut link = Dict::new(); + link.set(b"Type".to_vec(), Object::name("Annot")); + link.set(b"Subtype".to_vec(), Object::name("Link")); + link.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + link.set(b"Dest".to_vec(), name_ref); + let link = Object::Ref(doc.add(Object::Dict(link))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![link]))).unwrap(); + let fit = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![s("target"), fit.clone(), s("unused"), fit])); + let tree = doc.add(Object::Dict(tree)); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + // Only "unused" is removed; the indirectly-referenced "target" is kept. + assert_eq!(report.unreferenced_dests, 1); + let left = named_dest_keys(&doc); + assert!(left.contains(&b"target".to_vec()), "indirectly referenced destination was kept: {left:?}"); + assert!(!left.contains(&b"unused".to_vec())); +} + +/// The keys in the catalog's /Names /Dests name tree (one level, as the optimizer writes it). +fn named_dest_keys(doc: &Document) -> Vec> { + let cat = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let names = doc.resolve(cat.get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let tree = doc.resolve(names.get(b"Dests").unwrap()).as_dict().cloned().unwrap(); + let arr = doc.resolve(tree.get(b"Names").unwrap()).as_array().cloned().unwrap(); + arr.chunks(2).filter_map(|c| doc.resolve(&c[0]).as_string().map(|s| s.bytes.clone())).collect() +} From 05ac6a911c4b1e2604bc5841f4fd3745e263a383 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:19:22 +0300 Subject: [PATCH 09/13] ci: read-only token for the CI workflow (#4026) ci.yml had no permissions block, so its token got the repository default. Its jobs only check out code, restore caches and upload artifacts, so contents: read is enough. The permissions half of item 6 in #1114. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a21cbdfd6..79ba4a2cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,10 @@ on: schedule: - cron: "17 3 * * *" # nightly fuzzing +# The token is only used to check out the code: no job writes to the repository. +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 From f4b5a909bdc65cc40e9d292db1976ed282bdbd6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:19:28 -0400 Subject: [PATCH 10/13] ci(deps): bump Swatinem/rust-cache (#4227) Bumps the actions group with 1 update: [Swatinem/rust-cache](https://github.com/swatinem/rust-cache). Updates `Swatinem/rust-cache` from 2.9.2 to 2.9.3 - [Release notes](https://github.com/swatinem/rust-cache/releases) - [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md) - [Commits](https://github.com/swatinem/rust-cache/compare/6323deb102c322ba6fcbdcafc7e3dddab59af2b6...d1527c8f67ef9af357290475d4af4b0fab8f12e0) --- updated-dependencies: - dependency-name: Swatinem/rust-cache dependency-version: 2.9.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79ba4a2cf..f94d7dbe4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: run: Add-Content -Path $env:GITHUB_ENV -Value 'PDFCRAFT_REQUIRE_WINRES=1' shell: pwsh - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - name: Format if: runner.os == 'Linux' @@ -116,7 +116,7 @@ jobs: with: toolchain: stable targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: wasm32 - name: Check library crates for wasm32 @@ -140,7 +140,7 @@ jobs: - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: toolchain: stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: fuzz - name: Fetch the pdf.js corpus (seeds) From ddc63d704b86edfaea7371b81637e4bd1a5160b1 Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:19:34 -0500 Subject: [PATCH 11/13] FreeBSD: package the aarch64 cross build (craft-common-actions#39) (#4137) * FreeBSD package.sh: package the aarch64 cross build The release workflow now cross-compiles FreeBSD aarch64 inside the x86_64 VM (storytold/craft-common-actions#50) and sets CARGO_BUILD_TARGET=aarch64-unknown-freebsd. package.sh now reads the binaries from target//release when CARGO_BUILD_TARGET is set, names the archive after the target instead of uname -m, and skips the --version run when the binary is for another architecture than the machine. x86_64 packages are unchanged. The OCR models come from cargo xtask, which has to run on the build machine, so it is built for the host with CARGO_BUILD_TARGET unset. Part of storytold/craft-common-actions#39. * FreeBSD package.sh: accept only FreeBSD targets, check cross-built binaries Review follow-up. CARGO_BUILD_TARGET must now be a *-unknown-freebsd triple, so a Linux or macOS target can no longer be packaged under a FreeBSD name, and it is read before uname -m, so an unsupported host does not block a valid cross build. When the binaries are for another architecture and cannot run --version, file(1) must report each as a FreeBSD ELF executable for the target architecture. Part of storytold/craft-common-actions#39. * FreeBSD package.sh: accept only executables in the cross-build check Review follow-up. The file(1) check for binaries that cannot run here now needs an executable (file says "executable" or "pie executable") or an ELF with an interpreter, which is how an older file(1) still shows a PIE it calls a shared object. FreeBSD libraries, object files and core files for the target architecture are refused. Part of storytold/craft-common-actions#39. * FreeBSD package.sh: never run a FreeBSD target's binary on another OS Review follow-up. With CARGO_BUILD_TARGET set on a host that is not FreeBSD (say an x86_64 Mac building x86_64-unknown-freebsd), the architectures match but the binary still cannot run, so the --version run is skipped there and the file(1) check is used instead. Part of storytold/craft-common-actions#39. * FreeBSD package.sh: accept only the two supported FreeBSD targets Review follow-up. CARGO_BUILD_TARGET must be aarch64-unknown-freebsd or x86_64-unknown-freebsd, the two the release builds; anything else, including made-up triples such as aarch64-foo-unknown-freebsd, stops with exit 2. Part of storytold/craft-common-actions#39. --- packaging/freebsd/package.sh | 47 +++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 63b981b24..e22bca4ea 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -22,12 +22,26 @@ while [[ $# -gt 0 ]]; do esac done -ARCH="$(uname -m)" -case "$ARCH" in - amd64 | x86_64) ARCH=x86_64 ;; - arm64 | aarch64) ARCH=aarch64 ;; - *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +release_arch() { + case "$1" in + amd64 | x86_64) echo x86_64 ;; + arm64 | aarch64) echo aarch64 ;; + *) echo "unsupported architecture $1" >&2; return 2 ;; + esac +} +# The release workflow cross-compiles aarch64 in an x86_64 VM with CARGO_BUILD_TARGET set to +# aarch64-unknown-freebsd (craft-common-actions#39): name the archive after the target. +case "${CARGO_BUILD_TARGET:-}" in + "") ARCH="$(release_arch "$(uname -m)")" ;; + aarch64-unknown-freebsd | x86_64-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;; + *) echo "CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET is not a supported FreeBSD target" >&2; exit 2 ;; esac +# Only binaries for this machine run here (the --version check at the end); a FreeBSD target built +# on another OS never does. +HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="$(uname -m)" +if [ -n "${CARGO_BUILD_TARGET:-}" ] && [ "$(uname -s)" != FreeBSD ]; then + HOST_ARCH="$(uname -s) $HOST_ARCH" +fi BASENAME="pdfcraft-$VERSION-freebsd-$ARCH" echo "==> PdfCraft $VERSION for FreeBSD $ARCH" @@ -35,7 +49,8 @@ echo "==> PdfCraft $VERSION for FreeBSD $ARCH" if [[ "$SKIP_BUILD" = 0 ]]; then (cd "$ROOT" && cargo build --release --locked -p pdfcraft -p pdfcraft-cli) fi -BIN="$CARGO_TARGET_DIR/release" +# Cargo puts a cross build under target//release. +BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release" WORK="$CARGO_TARGET_DIR/freebsd-package" STAGE="$WORK/$BASENAME" rm -rf "$WORK" @@ -51,10 +66,24 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$PDFCRAFT_BUILD_DATE/g" \ "$LINUX/$APP_ID.metainfo.xml.in" >"$STAGE/share/metainfo/$APP_ID.metainfo.xml" cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/" copy_docs "$STAGE/share/doc/pdfcraft" -# OCR models: the app finds them at /../share/pdfcraft/models. -stage_models "$STAGE/share/pdfcraft/models" +# OCR models: the app finds them at /../share/pdfcraft/models. They come from cargo xtask, +# which has to run here: build it for this machine, not for CARGO_BUILD_TARGET. +(unset CARGO_BUILD_TARGET; stage_models "$STAGE/share/pdfcraft/models") -"$STAGE/bin/pdfcraft" --version +if [ "$ARCH" = "$HOST_ARCH" ]; then + "$STAGE/bin/pdfcraft" --version +else + # They cannot run here, so check what they are instead: executables, not libraries or objects. + # An older file(1) calls a PIE a shared object; its interpreter still marks it as a program. + want="$ARCH"; [ "$ARCH" != x86_64 ] || want=x86-64 + for b in "$STAGE"/bin/*; do + case "$(file -b "$b")" in + ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;; + *) echo "error: $b is not a FreeBSD $ARCH executable: $(file -b "$b")" >&2; exit 1 ;; + esac + done + echo "skipping --version: an $ARCH binary does not run on this $HOST_ARCH machine" +fi mkdir -p "$DIST" tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME" echo "wrote $DIST/$BASENAME.tar.gz" From b174b12b0ec9bb1033d038bfbea89b84def1bf9b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:20:14 +0200 Subject: [PATCH 12/13] Reject non-hex characters in XFDF colour parsing (#4145) Found while testing crates/xfdf/src/lib.rs:236-243 (parse_hex) Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/xfdf/src/lib.rs | 2 +- crates/xfdf/src/tests.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 5c0be5232..a6da88f4d 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -235,7 +235,7 @@ fn hex(c: &[f64]) -> Option { fn parse_hex(s: &str) -> Option<[f64; 3]> { let h = s.trim().trim_start_matches('#'); - if h.len() != 6 { + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let p = |i: usize| h.get(i..i + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map(|v| f64::from(v) / 255.0); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index fb88da37b..80e7d8049 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1759,6 +1759,15 @@ fn an_imported_redaction_rect_with_a_non_number_is_not_used() { assert!(mark.get(b"QuadPoints").is_none(), "quads were drawn from a malformed /Rect: {mark:?}"); } +#[test] +fn a_colour_with_non_hex_digits_is_rejected() { + assert!(parse_hex("#+f+f+f").is_none()); + assert!(parse_hex("#+1+2+3").is_none()); + assert!(parse_hex("#-1-2-3").is_none()); + assert!(parse_hex("#ff00zz").is_none()); + assert_eq!(parse_hex("#FF8000").map(|c| c[0]), Some(1.0)); +} + /// An FDF body with `body` as its `/FDF` dictionary contents and `objects` (numbered from 2). fn fdf_with(body: &str, objects: &[String]) -> Vec { let mut out = format!("%FDF-1.2\n1 0 obj\n<< /FDF << {body} >> >>\nendobj\n"); From 75207dc8ed02ec91bc6bed35574f113b18452aba Mon Sep 17 00:00:00 2001 From: Matej Vrzala Date: Fri, 23 Oct 2026 16:20:35 +0200 Subject: [PATCH 13/13] fix(edit): setting a paragraph's width no longer panics on text larger than a page (#4110) rewrite_block clamps a requested paragraph width between the text's size in user space (size * k) and the largest page PDF allows (14 400 pt). f64::clamp panics when its minimum is the larger, so text set above 14 400 pt crashed the app as soon as its paragraph was given a width. The size comes from the file (`20000 Tf`, or `1 Tf` scaled up by `Tm`) or from the request (the automation tools pass `size` through). The lower bound is now capped at 14 400 pt too, so such a paragraph gets the widest width allowed. `f64::min` also turns a NaN size into the cap. Test: a 20000 pt Tf, a 1 pt Tf scaled 20000x by Tm, and a requested size of 20000 pt each take a width of 200 without panicking (all three panicked before). Reported-by: BS.ehlas.cz Co-authored-by: Brandon Thomas --- crates/edit/src/tests.rs | 18 ++++++++++++++++++ crates/edit/src/text.rs | 5 +++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b1c463a6a..aa785024e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6356,6 +6356,24 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() { ); } +/// Text set larger than the widest page PDF allows (14 400 pt) panicked when its paragraph was given +/// a width: the width is clamped between the font size and 14 400 pt, and `f64::clamp` panics when +/// its minimum is the larger. The width is now capped at 14 400 pt whatever the font size: from the +/// file (`Tf`, or a small `Tf` scaled up by `Tm`) or asked for (the automation tools pass it through). +#[test] +fn width_on_a_paragraph_set_larger_than_a_page_is_capped_not_a_panic() { + let wide = text::BlockStyle { width: Some(200.0), ..Default::default() }; + let huge = text::BlockStyle { size: Some(20_000.0), width: Some(200.0), ..Default::default() }; + for (content, style) in [ + ("BT /F2 20000 Tf 72 700 Td (ab) Tj ET", &wide), + ("BT /F2 1 Tf 20000 0 0 20000 72 700 Tm (ab) Tj ET", &wide), + ("BT /F2 12 Tf 72 700 Td (ab) Tj ET", &huge), + ] { + let mut doc = text_page(content); + text::rewrite_block(&mut doc, 0, 0, None, style).unwrap(); + } +} + #[test] fn added_text_is_wrapped_once_per_write() { let mut doc = fixture(); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 29ac90abc..d00393531 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -2780,8 +2780,9 @@ fn rewrite_block_from( // A paragraph keeps its width (or takes the one asked for); a single line grows to the right // (to the left when right-to-left), up to the page's margin. let width = match style.width { - // Capped at the largest page PDF allows (14 400 pt). - Some(w) if w.is_finite() => w.clamp(size * k, 14_400.0), + // Capped at the largest page PDF allows (14 400 pt), even for text set larger than that + // (`clamp` panics when its minimum is the larger, or NaN). + Some(w) if w.is_finite() => w.clamp((size * k).min(14_400.0), 14_400.0), Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), // Turned text: the page's margins are along another axis. A single line grows (below). None if !upright => (frame[2] - frame[0]).max(size * k),