Merge remote-tracking branch 'upstream/main' into fix/exec-menu-attachments

# Conflicts:
#	crates/js/src/lib.rs
#	crates/js/src/tests.rs
#	parity/acrobat-features.toml
This commit is contained in:
Eric Kaden
2026-10-23 10:43:34 -04:00
20 changed files with 348 additions and 43 deletions
+7 -3
View File
@@ -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)
Generated
+2 -2
View File
@@ -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",
+22
View File
@@ -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")
);
}
}
+18
View File
@@ -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();
+3 -2
View File
@@ -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),
+13
View File
@@ -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> =
+9 -1
View File
@@ -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),
+14
View File
@@ -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);
+14 -4
View File
@@ -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);
+16 -5
View File
@@ -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)
+30 -1
View File
@@ -16,7 +16,7 @@ fn fields() -> Vec<FieldState> {
}
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"]);
}
+5 -2
View File
@@ -150,11 +150,14 @@ fn referenced_names(doc: &Document) -> HashSet<Vec<u8>> {
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);
}
+42
View File
@@ -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<Vec<u8>> {
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()
}
+4 -1
View File
@@ -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(),
+58 -8
View File
@@ -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<String> = 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::<Vec<_>>(), file.chain.iter().map(|c| &c.raw).collect::<Vec<_>>());
}));
// 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();
}
+39
View File
@@ -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);
+1 -1
View File
@@ -235,7 +235,7 @@ fn hex(c: &[f64]) -> Option<String> {
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);
+9
View File
@@ -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<u8> {
let mut out = format!("%FDF-1.2\n1 0 obj\n<< /FDF << {body} >> >>\nendobj\n");
+38 -9
View File
@@ -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/<triple>/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 <bin>/../share/pdfcraft/models.
stage_models "$STAGE/share/pdfcraft/models"
# OCR models: the app finds them at <bin>/../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"
+4 -4
View File
@@ -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"