diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a21cbdfd6..f94d7dbe4 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 @@ -74,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' @@ -112,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 @@ -136,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) 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", 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/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), 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/engine/src/js.rs b/crates/engine/src/js.rs index 238079c34..c564421fe 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -143,8 +143,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 24d51947b..b43fc4cc6 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/formcalc.rs b/crates/js/src/formcalc.rs index 4ff77b74c..2348ce220 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 }) } @@ -2520,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 }; @@ -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); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index aaacce0c9..8272918c9 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. @@ -1037,9 +1039,12 @@ fn color_object(ctx: &mut Context) -> JsObject { o.build() } -/// Installs the form object model's globals: the document's functions and properties -/// (`getField`, `numFields`, `pageNum`, `info`, …), `app`, `util`, `console`, `display` and -/// `color`. +/// The XFA version scripts see as `xfa_version`. +const XFA_VERSION: f64 = 3.5; + +/// Installs the globals every script sees: the document's functions and properties +/// (`getField`, `numFields`, `pageNum`, `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)), @@ -1060,9 +1065,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 { @@ -1075,6 +1080,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 5190c3ca8..c46921c7f 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 { @@ -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")); @@ -1507,3 +1510,29 @@ mod menu_items { assert!(o.effects.contains(&XfaEffect::ShowPanel(Panel::Attachments)), "{:?}", o.effects); } } + +/// 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/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() +} 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 b7de2a4d3..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] @@ -665,12 +683,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 +709,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 +731,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 +746,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 +778,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(); } 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); 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"); 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" diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d58fa14d1..553388c5b 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 (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), 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 (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), 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" @@ -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"