mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user