From 6d03a7acdf2bb99a6380d5f031a2ed7b858cae04 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:32:12 +0100 Subject: [PATCH 1/8] render: read a link's JavaScript action as LinkTarget::JavaScript Ported from #2081: link annotations whose action is JavaScript keep the script (up to 256 KiB each, 4 MiB per document; larger ones are rejected and reported once). Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com> --- crates/render/src/inspect/annotations.rs | 198 ++++++++++++++++++++++- crates/render/src/inspect/model.rs | 3 + crates/render/src/inspect/tests.rs | 109 +++++++++++++ 3 files changed, 306 insertions(+), 4 deletions(-) diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 7313716a2e..ade4969c0c 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -6,6 +6,56 @@ use super::model::{ use super::navigation::text_string; use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; +/// Negative cache of failed script-stream decompressions during one structure inspection. +/// Callers must supply stream addresses that stay stable for that inspection; these are +/// opaque identities, never dereferenced here. Do not reuse the cache across documents, +/// edits or inspections, where an address may identify a different stream. +/// FIFO eviction bounds it to 4096 identities and allows an evicted failure to be retried; +/// a cache hit does not refresh its position. This bounds retained failures, not the +/// total decoding work, and does not cache successful sources or spend their text budget. +#[derive(Default)] +pub(super) struct RejectedJavaScript { + identities: HashSet<*const lopdf::Stream>, + order: std::collections::VecDeque<*const lopdf::Stream>, +} + +impl RejectedJavaScript { + pub(super) const CAPACITY: usize = 4096; + + pub(super) fn contains(&self, identity: *const lopdf::Stream) -> bool { + self.identities.contains(&identity) + } + + pub(super) fn insert(&mut self, identity: *const lopdf::Stream) { + if self.identities.contains(&identity) { + return; + } + if self.order.len() >= Self::CAPACITY + && let Some(oldest) = self.order.pop_front() + { + self.identities.remove(&oldest); + } + self.identities.insert(identity); + self.order.push_back(identity); + } +} + +/// What link JavaScript an inspection may still keep: 4 MiB of source across the document, +/// and the streams that failed to decode. +pub(super) struct LinkScripts { + remaining: usize, + rejected: RejectedJavaScript, +} + +impl Default for LinkScripts { + fn default() -> Self { + Self { remaining: 4 << 20, rejected: RejectedJavaScript::default() } + } +} + +/// Most bytes of one link's script string or decompressed stream. +const MAX_LINK_SCRIPT: usize = 256 << 10; + /// At most this many `/State` entries of a set-layer-visibility action are read. const MAX_LAYER_STATE: usize = 1024; @@ -124,6 +174,9 @@ impl<'a> Inspector<'a> { // Once the comment budget is spent, comments are no longer built, but links and attachments // are still listed. let mut comments_full = false; + // Link JavaScript has its own source budget, apart from link text (#2081). + let mut scripts = LinkScripts::default(); + let mut javascript_limit_reported = false; for (page, index, a) in entries { let Some(d) = self.dict(a) else { continue }; // An annotation's `/Type`, when present, is `/Annot` (ISO 32000-2 §12.5.2, Table 166): @@ -144,10 +197,18 @@ impl<'a> Inspector<'a> { info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback)); } if subtype == "Link" { - if let Some(link) = self.link(page, index, d, info.uri_base.as_deref(), text_budget, &mut unfit_quads) { - if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { + if let Some(link) = self.link(page, index, d, info.uri_base.as_deref(), text_budget, &mut unfit_quads, &mut scripts) { + let refused = matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript"); + if refused || matches!(&link.target, LinkTarget::JavaScript(_)) { info.has_javascript = true; } + if refused && scripts.remaining == 0 && !javascript_limit_reported { + info.warnings.push( + "Link JavaScript sources reached the 4 MiB document inspection limit; remaining JavaScript actions are unavailable." + .into(), + ); + javascript_limit_reported = true; + } info.links.push(link); } continue; @@ -298,7 +359,9 @@ impl<'a> Inspector<'a> { /// goes, else what its action `/A` does ([`Self::action_target`]). `None` when it has no /// rectangle, or a destination or action that leads nowhere. `base` is the catalog's /// `/URI /Base`, which a relative URI action is resolved against. `unfit_quads` holds the - /// indirect `/QuadPoints` arrays already found too large for the budget. + /// indirect `/QuadPoints` arrays already found too large for the budget. A JavaScript + /// action's source is kept as data, never run here, within `scripts` ([`Self::link_script`]). + #[allow(clippy::too_many_arguments)] fn link( &self, page: usize, @@ -307,13 +370,17 @@ impl<'a> Inspector<'a> { base: Option<&str>, text_budget: &mut usize, unfit_quads: &mut HashSet, + scripts: &mut LinkScripts, ) -> Option { let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); + let action = d.get(b"A").ok().and_then(|a| self.dict(a)); let target = if let Ok(dest) = d.get(b"Dest") { let (page, view) = self.dest(dest, 0)?; LinkTarget::Page(page, view) + } else if let Some(a) = action.filter(|a| self.name(a, b"S").as_deref() == Some("JavaScript")) { + self.link_script(a, scripts).map_or_else(|| LinkTarget::Other("JavaScript".into()), LinkTarget::JavaScript) } else { - match self.action_target(d.get(b"A").ok().and_then(|a| self.dict(a))?, text_budget)? { + match self.action_target(action?, text_budget)? { LinkTarget::Uri(uri) => match crate::resolve_uri(base, &uri) { // The raw address is spent already. What the base adds is spent too, or the // link keeps the raw address. @@ -360,6 +427,45 @@ impl<'a> Inspector<'a> { /// this document; a remote go-to's names a page or destination of another file (ISO 32000-2 /// §12.6.4.3). `None` for an action without a type, or a go-to whose destination is not here. /// A web address is spent from `text_budget` (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]). + /// The source of a link's JavaScript action (`/JS`, a string or a stream), as data. A + /// string or decompressed stream over 256 KiB, a stream that fails to decode, or source + /// past what is left of the document's 4 MiB is `None` (the link stays + /// `Other("JavaScript")`); a failed stream is remembered so links sharing it don't decode it + /// again. An action's `/Next` chain is not followed. + fn link_script(&self, a: &Dictionary, scripts: &mut LinkScripts) -> Option { + if scripts.remaining == 0 { + return None; + } + let bytes = match self.resolve(a.get(b"JS").ok()?) { + Object::String(bytes, _) if bytes.len() <= MAX_LINK_SCRIPT => bytes.clone(), + Object::Stream(stream) => { + // The structure's objects stay put for the whole inspection, so the resolved + // stream's address identifies it, aliases included. + let identity = std::ptr::from_ref(stream); + if scripts.rejected.contains(identity) { + return None; + } + #[cfg(test)] + JS_DECODE_COUNT.with(|n| n.set(n.get() + 1)); + match stream.decompressed_content_with_limit(MAX_LINK_SCRIPT) { + Ok(bytes) => bytes, + Err(_) => { + scripts.rejected.insert(identity); + return None; + } + } + } + _ => return None, + }; + let source = pdfcraft_cos::PdfString::literal(bytes).to_text(); + let Some(remaining) = scripts.remaining.checked_sub(source.len()) else { + scripts.remaining = 0; + return None; + }; + scripts.remaining = remaining; + Some(source) + } + pub(super) fn action_target(&self, a: &Dictionary, text_budget: &mut usize) -> Option { Some(match self.name(a, b"S").as_deref()? { "GoTo" => { @@ -755,3 +861,87 @@ thread_local! { /// `/QuadPoints` arrays whose values were read. pub(super) static QUAD_ARRAYS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +#[cfg(test)] +thread_local! { static JS_DECODE_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +#[cfg(test)] +mod remediation_probes { + use super::*; + use crate::inspect::{Arc, inspect}; + use lopdf::{Document, dictionary}; + #[test] + fn remediation_rejected_stream_cache_has_a_fixed_bound() { + let streams: Vec<_> = (0..8192).map(|_| lopdf::Stream::new(dictionary! {}, vec![])).collect(); + let mut cache = RejectedJavaScript::default(); + for stream in &streams { + cache.insert(std::ptr::from_ref(stream)); + assert!(cache.identities.len() <= 4096); + assert!(cache.order.len() <= 4096); + } + assert!(!cache.contains(std::ptr::from_ref(streams.first().unwrap()))); + assert!(cache.contains(std::ptr::from_ref(streams.last().unwrap()))); + } + fn shared_bad_stream(n: usize, repeated_annotation: bool) -> Vec { + let mut d = Document::with_version("1.7"); + let pages = d.new_object_id(); + let mut stream = lopdf::Stream::new(dictionary! {}, vec![b'x'; 300 << 10]); + stream.compress().unwrap(); + let stream_id = d.add_object(stream); + let action = d.add_object(dictionary! {"S"=>"JavaScript","JS"=>stream_id}); + let mut annots = Vec::new(); + let mut first = None; + for _ in 0..n { + let annotation_action = if n == 33 { + let alias = d.add_object(Object::Reference(stream_id)); + d.add_object(dictionary! {"S"=>"JavaScript","JS"=>alias}) + } else { + action + }; + let id = if let Some(id) = first.filter(|_| repeated_annotation) { + id + } else { + let id = d.add_object( + dictionary! {"Type"=>"Annot","Subtype"=>"Link","Rect"=>vec![0.into(),0.into(),50.into(),50.into()],"A"=>annotation_action}, + ); + first = Some(id); + id + }; + annots.push(Object::Reference(id)); + } + let good=d.add_object(dictionary!{"Subtype"=>"Link","Rect"=>vec![0.into(),0.into(),50.into(),50.into()],"A"=>dictionary!{"S"=>"JavaScript","JS"=>Object::string_literal("console.println('valid');")}}); + annots.push(good.into()); + let page = d.add_object(dictionary! {"Type"=>"Page","Parent"=>pages,"Annots"=>annots}); + d.objects.insert( + pages, + dictionary! {"Type"=>"Pages","Kids"=>vec![page.into()],"Count"=>1,"MediaBox"=>vec![0.into(),0.into(),200.into(),200.into()]}.into(), + ); + let root = d.add_object(dictionary! {"Type"=>"Catalog","Pages"=>pages}); + d.trailer.set("Root", root); + let mut bytes = Vec::new(); + d.save_to(&mut bytes).unwrap(); + bytes + } + #[test] + fn remediation_rejected_stream_decode_is_cached_and_preserves_budget() { + for (n, repeated) in [(1, false), (33, false), (256, false), (1024, false), (1024, true)] { + let bytes = shared_bad_stream(n, repeated); + let len = bytes.len(); + + let mut times = Vec::new(); + let mut calls = 0; + for _ in 0..3 { + JS_DECODE_COUNT.with(|n| n.set(0)); + let now = std::time::Instant::now(); + let info = inspect(Arc::new(bytes.clone()), None).unwrap(); + times.push(now.elapsed().as_micros()); + calls = JS_DECODE_COUNT.with(|n| n.get()); + assert_eq!(calls, 1); + assert_eq!(info.links.len(), if repeated { 2 } else { n + 1 }); + assert!(matches!(&info.links.last().unwrap().target,LinkTarget::JavaScript(s) if s=="console.println('valid');")); + assert!(!info.warnings.iter().any(|w| w.contains("4 MiB"))); + } + println!("REJECT n={n} repeated_annotation={repeated} bytes={len} decode_calls={calls} micros={times:?}"); + } + } +} diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 18b7533f2c..00f967153a 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -230,6 +230,9 @@ pub enum LinkTarget { /// reference ([`crate::resolve_uri`]); not yet checked, as the opener must /// (`pdfcraft_engine::links::document_url`). Uri(String), + /// Source of a JavaScript action, decoded with a bounded stream reader (never run while + /// inspecting). + JavaScript(String), /// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in /// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned /// on turns off the other layers of its radio-button groups. diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 68486d8097..76bcc2cf3d 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -2921,3 +2921,112 @@ fn efopen_filters_named_by_the_file_open_and_older_handlers_do_not() { let legacy = Arc::new(efopen_pdf_with("EFOpen", false, "/V 2 /R 3 /Length 128")); assert!(matches!(check_pages(legacy, None, 1), Err(OpenError::NeedsPassword))); } + +#[cfg(test)] +mod javascript_links_tests { + use super::*; + use lopdf::dictionary; + + #[test] + fn shared_javascript_actions_have_a_document_wide_source_budget() { + let mut d = Document::with_version("1.7"); + let pages = d.new_object_id(); + let page = d.new_object_id(); + let action = d.add_object(dictionary! { "S" => "JavaScript", "JS" => Object::string_literal(vec![b'x'; 256 << 10]) }); + // Distinct annotations share the action: repeated references to the same annotation + // are intentionally deduplicated by inspection before the source budget is charged. + let mut annots: Vec<_> = (0..33) + .map(|_| { + Object::Reference(d.add_object( + dictionary! { "Type" => "Annot", "Subtype" => "Link", "Rect" => vec![0.into(),0.into(),50.into(),50.into()], "A" => action }, + )) + }) + .collect(); + let ordinary = d.add_object(dictionary! { "Subtype" => "Link", "Rect" => vec![0.into(),0.into(),50.into(),50.into()], "Dest" => vec![Object::Reference(page), Object::Name(b"Fit".to_vec())] }); + annots.push(Object::Reference(ordinary)); + d.objects.insert(page, dictionary! { "Type" => "Page", "Parent" => pages, "Annots" => annots }.into()); + d.objects.insert( + pages, + dictionary! { "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1, "MediaBox" => vec![0.into(),0.into(),200.into(),200.into()] } + .into(), + ); + let root = d.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + d.trailer.set("Root", root); + let mut bytes = Vec::new(); + d.save_to(&mut bytes).unwrap(); + let info = inspect(Arc::new(bytes), None).unwrap(); + assert_eq!(info.links.len(), 34, "the resource limit must not drop annotations"); + assert!(info.has_javascript); + let total: usize = info + .links + .iter() + .map(|link| match &link.target { + LinkTarget::JavaScript(source) => source.len(), + _ => 0, + }) + .sum(); + assert!(total <= 4 << 20, "shared action sources expanded to {total} bytes"); + assert!(matches!(&info.links[0].target, LinkTarget::JavaScript(_))); + assert!(matches!(&info.links[32].target, LinkTarget::Other(kind) if kind == "JavaScript")); + assert!(matches!(&info.links[33].target, LinkTarget::Page(0, _)), "ordinary links after the limit must still resolve"); + assert_eq!(info.warnings.iter().filter(|warning| warning.contains("4 MiB")).count(), 1); + } + + #[test] + fn javascript_link_sources_direct_indirect_stream_and_utf16() { + for storage in 0..7 { + let mut d = Document::with_version("1.7"); + let pages = d.new_object_id(); + let source = "app.alert('é');"; + let string = if storage == 3 { + { + let mut bytes = vec![0xfe, 0xff]; + for unit in source.encode_utf16() { + bytes.extend_from_slice(&unit.to_be_bytes()); + } + Object::String(bytes, lopdf::StringFormat::Hexadecimal) + } + } else { + Object::string_literal(source) + }; + let js = match storage { + 1 => Object::Reference(d.add_object(string)), + 2 => { + let mut stream = lopdf::Stream::new(dictionary! {}, source.as_bytes().to_vec()); + stream.compress().unwrap(); + Object::Reference(d.add_object(stream)) + } + 4 => { + let cycle = d.new_object_id(); + d.objects.insert(cycle, Object::Reference(cycle)); + Object::Reference(cycle) + } + 5 => { + let mut stream = lopdf::Stream::new(dictionary! {}, vec![b'x'; 300 << 10]); + stream.compress().unwrap(); + Object::Reference(d.add_object(stream)) + } + 6 => Object::Integer(42), + _ => string, + }; + let action = d.add_object(dictionary! { "S" => "JavaScript", "JS" => js }); + let annotation = d.add_object( + dictionary! { "Type" => "Annot", "Subtype" => "Link", "Rect" => vec![0.into(),0.into(),50.into(),50.into()], "A" => action }, + ); + let page = d.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "Annots" => vec![annotation.into()] }); + d.objects.insert(pages, dictionary!{ "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1, "MediaBox" => vec![0.into(),0.into(),200.into(),200.into()] }.into()); + let root = d.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + d.trailer.set("Root", root); + let mut bytes = Vec::new(); + d.save_to(&mut bytes).unwrap(); + let info = inspect(Arc::new(bytes), None).unwrap(); + assert!(info.has_javascript); + if storage >= 4 { + assert!(matches!(&info.links[0].target, LinkTarget::Other(kind) if kind == "JavaScript")); + continue; + } + let LinkTarget::JavaScript(script) = &info.links[0].target else { panic!("missing source") }; + assert_eq!(script, source); + } + } +} From d9c895a32fa18ce5caa2a19722ceb8d9a7cffb0a Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:32:31 +0100 Subject: [PATCH 2/8] js: app.openDoc request, a link event's document target, empty popUpMenuEx Ported from #2081: app.openDoc only queues Request::OpenDoc for the current document's folder (the viewer asks first) and answers null; a Link event's target is the document; a popup menu with no items answers null without asking. Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com> --- crates/js/src/globals.rs | 55 +++++++++++++++++++++++----- crates/js/src/host.rs | 3 +- crates/js/src/interaction.rs | 19 +++++++--- crates/js/src/model.rs | 3 ++ crates/js/src/tests.rs | 70 ++++++++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 14 deletions(-) diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 3dfbf3924e..f6ee89bca0 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -140,6 +140,41 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { + let unset = |v: &JsValue| v.is_undefined() || v.is_null(); + // Only this context's own document object (not a lookalike, proxy or inherited object). + let this_document = |v: &JsValue, ctx: &Context| unset(v) || v.as_object().is_some_and(|o| JsObject::equals(&o, &ctx.global_object())); + let first = arg(args, 0); + let path = match first.as_object().filter(|o| !o.is_array() && !o.is_callable()) { + Some(o) => { + if !args.iter().skip(1).all(unset) { + return Err(error("unsupported openDoc options")); + } + let fs = o.get(js_string!("cFS"), ctx)?; + let base = o.get(js_string!("oDoc"), ctx)?; + if (!fs.is_undefined() && !text(&fs, ctx)?.is_empty()) || !this_document(&base, ctx) { + return Err(error("openDoc supports only the current document's local file system")); + } + text(&o.get(js_string!("cPath"), ctx)?, ctx)? + } + None => { + if !this_document(&arg(args, 1), ctx) || !args.iter().skip(2).all(unset) { + return Err(error("unsupported openDoc options")); + } + text(&first, ctx)? + } + }; + if path.is_empty() || path.len() > 4096 { + return Err(error("invalid openDoc path")); + } + host(ctx)?.borrow_mut().request(Request::OpenDoc(path)); + Ok(JsValue::null()) +} + /// The attached file `exportDataObject` names, and whether to open it: from an object /// (`{cName, nLaunch}`) or from the arguments (`cName, cDIPath, bAllowAuth, nLaunch`). `nLaunch` /// 1 and 2 open the file; 0 (the default) and any other value save it. @@ -555,6 +590,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { .function(NativeFunction::from_fn_ptr(crate::interaction::response), js_string!("response"), 1) .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) .function(NativeFunction::from_fn_ptr(crate::interaction::popup_menu), js_string!("popUpMenuEx"), 0) + .function(NativeFunction::from_fn_ptr(open_doc), js_string!("openDoc"), 1) .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) @@ -622,13 +658,16 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject, .build(); let value_get = function(ctx, NativeFunction::from_copy_closure_with_captures(get, store.clone())); let value_set = function(ctx, NativeFunction::from_copy_closure_with_captures(set, store.clone())); - let target = e.target.as_ref().map_or(JsValue::null(), |t| { - if e.target_id.is_some() { - crate::document::field_object_by_id(ctx, t, e.target_id).into() - } else { - crate::document::field_object(ctx, t).into() - } - }); + // A link action's target is the document (#2081); other events without a field have none. + let link_document = e.target.is_none() && e.kind == "Link"; + let target = match e.target.as_ref() { + Some(t) if e.target_id.is_some() => crate::document::field_object_by_id(ctx, t, e.target_id).into(), + Some(t) => crate::document::field_object(ctx, t).into(), + None if link_document => ctx.global_object().into(), + None => JsValue::null(), + }; + // Not enumerable there, so `JSON.stringify(event)` doesn't walk back into the document. + let target_attributes = if link_document { Attribute::WRITABLE | Attribute::CONFIGURABLE } else { Attribute::all() }; let event = ObjectInitializer::new(ctx) .property(js_string!("name"), s(&e.name), Attribute::all()) .property(js_string!("type"), s(&e.kind), Attribute::all()) @@ -637,7 +676,7 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject, .property(js_string!("changeEx"), s(&e.change), Attribute::all()) .property(js_string!("willCommit"), e.will_commit, Attribute::all()) .property(js_string!("rc"), true, Attribute::all()) - .property(js_string!("target"), target, Attribute::all()) + .property(js_string!("target"), target, target_attributes) .property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all()) .property(js_string!("selStart"), 0, Attribute::all()) .property(js_string!("selEnd"), 0, Attribute::all()) diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 5966e63545..e2861c3d67 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -76,12 +76,13 @@ impl Host { Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), Request::Submit(u) => Request::Submit(xfa::clip(u)), Request::Focus(n) => Request::Focus(xfa::clip(n)), + Request::OpenDoc(p) => Request::OpenDoc(xfa::clip(p)), Request::FocusField { name, object_id } => Request::FocusField { name: xfa::clip(name), object_id }, other => other, }; let bytes = match &r { Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), - Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) | Request::OpenDoc(u) => u.len(), Request::FocusField { name, .. } => name.len(), Request::OpenAttachment { name, .. } => name.len(), _ => 0, diff --git a/crates/js/src/interaction.rs b/crates/js/src/interaction.rs index 525acf930a..38f452fac7 100644 --- a/crates/js/src/interaction.rs +++ b/crates/js/src/interaction.rs @@ -31,9 +31,9 @@ pub(crate) fn has_host() -> bool { pub(crate) fn set_host(host: Option>) { HOST.with(|h| *h.borrow_mut() = host.map(|h| (h, 0))); } -/// Send a bounded prompt and await its reply, treating disconnect/timeout as cancellation. -pub(crate) fn ask(prompt: Prompt) -> JsResult> { - let sender = HOST.with(|h| { +/// Charge one prompt to the invocation's budget: the channel to send it on, if the worker has one. +fn reserve() -> JsResult>> { + HOST.with(|h| { let mut h = h.borrow_mut(); let Some((tx, n)) = h.as_mut() else { return Ok(None) }; if *n >= 32 { @@ -41,8 +41,11 @@ pub(crate) fn ask(prompt: Prompt) -> JsResult> { } *n += 1; Ok(Some(tx.clone())) - })?; - let Some(sender) = sender else { return Ok(None) }; + }) +} +/// Send a bounded prompt and await its reply, treating disconnect/timeout as cancellation. +pub(crate) fn ask(prompt: Prompt) -> JsResult> { + let Some(sender) = reserve()? else { return Ok(None) }; let (tx, rx) = mpsc::channel(); sender.send(InteractiveEvent::Prompt(prompt, tx)).map_err(|_| error("interactive host closed"))?; let answer = rx.recv_timeout(std::time::Duration::from_secs(300)).ok().flatten(); @@ -102,6 +105,12 @@ pub(crate) fn popup_menu(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> Js for a in args { items.push(read_menu(a, ctx, 0, &mut remaining)?); } + // A menu with no items has nothing to pick: it answers null at once, without showing an empty + // menu or using up a later menu's answer, but still counts toward the prompt limit (#2081). + if items.is_empty() { + reserve()?; + return Ok(JsValue::null()); + } match ask(Prompt::Menu(items.clone()))? { Some(answer) if allowed(&items, &answer) => Ok(s(&answer)), Some(_) => Err(error("invalid popup menu selection")), diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 70bb677d8a..95a1076215 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -311,6 +311,9 @@ pub enum Request { Highlight(bool), /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, + /// `app.openDoc(cPath)`: the path as the script gave it (relative to this document's + /// folder). A request only: the viewer asks the user before opening any file. + OpenDoc(String), /// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch` /// 1 or 2) or to save (0). OpenAttachment { diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index c79e609967..2e780486b7 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -2702,3 +2702,73 @@ fn af_simple_calculate_bounds_selector_work_before_expansion() { run("AFSimple_Calculate('SUM',new Array(4096).fill('n'));", &Event::doc("Console"), &Default::default(), &fields, &[], Default::default()); assert!(out.error.as_deref().is_some_and(|e| e.contains("calculation field lookup exceeds"))); } + +/// A link action's `event.target` is the document (#2081, ported), and the event can still be +/// serialized: the target isn't enumerable, so `JSON.stringify` doesn't walk back into it. +#[test] +fn a_link_events_target_is_the_document() { + let link = Event { kind: "Link".into(), ..Event::doc("Mouse Up") }; + let o = go("JSON.stringify(event); JSON.stringify(this); (event.target === this) + ':' + event.type + ':' + event.name", &link); + assert_eq!(o.error, None); + assert_eq!(o.result.as_deref(), Some("true:Link:Mouse Up")); + assert_eq!(go("event.target === null", &Event::doc("Console")).result.as_deref(), Some("true"), "other document events keep none"); +} + +/// `app.openDoc` only queues a request (the viewer asks before opening a file) and answers null; +/// another document as the base, another file system or extra options are refused (#2081). +#[test] +fn open_doc_queues_a_request_for_the_current_documents_folder_only() { + let link = Event { kind: "Link".into(), ..Event::doc("Mouse Up") }; + for call in [ + "app.openDoc({cPath:'other.pdf',oDoc:this})", + "app.openDoc('other.pdf',this)", + "app.openDoc('other.pdf',event.target)", + "app.openDoc({cPath:'other.pdf',oDoc:this,cFS:''})", + "app.openDoc('other.pdf',null)", + "app.openDoc({cPath:'other.pdf'},null,undefined)", + ] { + let o = go(&format!("getField('price').value='17'; {call} === null"), &link); + assert_eq!(o.error, None, "{call}"); + assert_eq!(o.result.as_deref(), Some("true"), "{call}"); + assert_eq!(o.requests, [Request::OpenDoc("other.pdf".into())], "{call}"); + } + for call in [ + "app.openDoc({cPath:'other.pdf',oDoc:{}})", + "app.openDoc('other.pdf',{})", + "app.openDoc('other.pdf',Object.create(this))", + "app.openDoc('other.pdf',new Proxy(this,{}))", + "app.openDoc('other.pdf',getField('price'))", + "app.openDoc('other.pdf',this,true)", + "app.openDoc({cPath:'other.pdf',oDoc:this,cFS:'CHTTP'})", + "app.openDoc({cPath:'other.pdf'},this)", + "app.openDoc('')", + ] { + let o = go(call, &link); + assert!(o.error.as_deref().is_some_and(|e| e.contains("openDoc")), "{call}: {:?}", o.error); + assert!(o.requests.is_empty(), "{call}"); + } +} + +/// A popup menu with no items answers null without asking, so the next menu still gets its own +/// answer, and empty menus count toward the prompt limit (#2081). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn an_empty_popup_menu_answers_null_without_asking() { + let job = |script: &str| crate::session::Job { + script: script.into(), + event: Event { kind: "Link".into(), ..Event::doc("Mouse Up") }, + doc: doc(), + fields: Arc::new(Vec::new()), + doc_scripts: Vec::new(), + calculations: Vec::new(), + }; + let mut s = crate::session::ScriptSession::default(); + let o = s.run( + job("var a = app.popUpMenuEx(); var b = app.popUpMenuEx({cName:'B', cReturn:'b'}); app.popUpMenuEx.apply(app, []); String(a) + ':' + b"), + &[Some("b".into())], + ); + assert_eq!(o.error, None); + assert_eq!(o.result.as_deref(), Some("null:b")); + let o = s.run(job("for (var i = 0; i < 33; i++) app.popUpMenuEx();"), &[]); + assert!(o.error.as_deref().is_some_and(|e| e.contains("too many interactive prompts")), "{:?}", o.error); +} From 52ca67c8c4f283aef388e8156c6bee9f2b4761bc Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:43:21 +0100 Subject: [PATCH 3/8] engine: run a link's JavaScript action in the document realm Ported from #2081: Session::run_link_javascript / start_link_javascript run a link's action as event Link/Mouse Up on its page, with the document as target, in the per-document ScriptSession; a failed link commits no field change or request; read-only documents' links run too. Field scripts may not open another document; resolve_javascript_document_path resolves openDoc paths as remote-link paths (Acrobat /C/... paths on Windows). Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com> --- crates/engine/src/js.rs | 110 +++++++++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/link_javascript_tests.rs | 140 +++++++++++++++++++++ 3 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/link_javascript_tests.rs diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index cb4c0caf65..e661ade887 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -172,6 +172,9 @@ impl JsOutput { Request::OpenAttachment { name, .. } => { notes.push(format!("A field script asked to open the attached file {name}; only a button may open an attached file")) } + Request::OpenDoc(path) => notes.push(format!( + "A field script asked to open the document {path}; only a button, a link or the console may open another document" + )), r => self.requests.push(r.clone()), } } @@ -1448,9 +1451,100 @@ mod caption_tests { } } +/// A failed link action commits nothing: its field changes and requests are dropped, its +/// alerts, console lines and error kept (#2081). +fn drop_failed_link_effects(o: &mut Outcome) { + if o.error.is_some() { + o.changed.clear(); + o.formatted.clear(); + o.requests.clear(); + } +} + +impl Session { + /// The job for link `index` (its raw annotation index) on page `page` (both 0-based) when + /// its action is JavaScript: event "Link"/"Mouse Up" with the document as its target, on + /// the link's page. + fn link_job( + &self, + id: DocId, + page: usize, + index: usize, + ) -> Result<(pdfcraft_js::session::Job, std::sync::Arc>), EditError> { + if self.js_off { + return Err(EditError::Invalid("JavaScript is turned off (Preferences ▸ JavaScript)".into())); + } + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let link = doc.info.links.iter().find(|l| l.page == page && l.index == index).ok_or_else(|| EditError::Invalid("link not found".into()))?; + let pdfcraft_render::LinkTarget::JavaScript(script) = &link.target else { + return Err(EditError::Invalid("the link has no JavaScript action".into())); + }; + // A read-only document's links run too (#2081): a script that changes a field then fails + // to commit, as an edit there does, while its requests (go to a page, say) still apply. + let cos = match &doc.editor { + Some(editor) => editor.cos.clone(), + None => pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) + .map_err(|e| EditError::Invalid(e.to_string()))?, + }; + let cos = &cos; + let mut runner = JsRunner::new(cos, &doc.name, self.field_highlight); + runner.doc.zoom = doc.js_zoom; + runner.doc.page = page; + let fields = pdfcraft_forms::fields(cos); + let states = std::sync::Arc::new(fields.iter().map(|f| field_state(cos, f, &runner.doc)).collect::>()); + let job = pdfcraft_js::session::Job { + script: script.clone(), + event: pdfcraft_js::Event { kind: "Link".into(), ..pdfcraft_js::Event::doc("Mouse Up") }, + doc: runner.doc, + fields: states.clone(), + doc_scripts: runner.doc_scripts, + calculations: calculation_scripts(cos, &fields), + }; + Ok((job, states)) + } + + /// Run link `index` on page `page` (0-based; the index is the link's place in the page's + /// annotations) in the document's script realm, answering its prompts from `answers` in + /// order (none: cancelled). Field changes and `resetForm` are applied as one undoable step, + /// unless the script failed: then nothing is committed and its requests are dropped. + pub fn run_link_javascript(&mut self, id: DocId, page: usize, index: usize, answers: &[Option]) -> Result { + let (job, states) = self.link_job(id, page, index)?; + let runtime = self.get(id).ok_or(EditError::NoDocument)?.js_runtime.clone(); + let mut o = runtime.lock().map_err(|_| EditError::Invalid("document script runtime is unavailable".into()))?.run(job, answers); + drop_failed_link_effects(&mut o); + let discard = o.execution_refused && !o.realm_untouched; + let result = self.apply_script_outcome(id, &states, o, false); + if discard || result.is_err() { + self.invalidate_javascript(id); + } + result + } +} + +/// The local file `app.openDoc` names, resolved as a link to another PDF is +/// ([`crate::resolve_remote_document_path`], relative to the document `source`'s folder). No +/// file is touched. On Windows, Acrobat's device-independent `/C/dir/file.pdf` (and `/C:/…`) +/// reads as `C:/dir/file.pdf` (#2081). +pub fn resolve_javascript_document_path(source: Option<&std::path::Path>, raw: &str) -> Result { + #[cfg(windows)] + let normalized = { + let b = raw.as_bytes(); + match (b.first(), b.get(1), b.get(2), b.get(3)) { + (Some(b'/'), Some(d), Some(b'/'), _) if d.is_ascii_alphabetic() => raw.get(2..).map(|rest| format!("{}:{rest}", char::from(*d))), + (Some(b'/'), Some(d), Some(b':'), Some(b'/')) if d.is_ascii_alphabetic() => raw.get(1..).map(str::to_string), + _ => None, + } + }; + #[cfg(windows)] + let raw = normalized.as_deref().unwrap_or(raw); + crate::resolve_remote_document_path(source, raw) +} + #[cfg(not(target_arch = "wasm32"))] pub struct PendingScript { pub id: DocId, + /// A link action's: a failed one commits nothing. + link: bool, generation: u64, runtime: Runtime, states: std::sync::Arc>, @@ -1498,7 +1592,16 @@ impl Session { }; let receiver = doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.start(job).map_err(EditError::Invalid)?; - Ok(PendingScript { id, generation: doc.generation, runtime: doc.js_runtime.clone(), states, receiver }) + Ok(PendingScript { id, link: false, generation: doc.generation, runtime: doc.js_runtime.clone(), states, receiver }) + } + /// Start link `index` on page `page` as [`Session::run_link_javascript`] runs it, without + /// blocking on its prompts; [`Session::finish_javascript`] commits nothing if it failed. + pub fn start_link_javascript(&mut self, id: DocId, page: usize, index: usize) -> Result { + let (job, states) = self.link_job(id, page, index)?; + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let receiver = + doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.start(job).map_err(EditError::Invalid)?; + Ok(PendingScript { id, link: true, generation: doc.generation, runtime: doc.js_runtime.clone(), states, receiver }) } /// Discard only the captured runtime, preserving a newer realm created by an intervening edit. pub fn discard_javascript(&mut self, p: &PendingScript) { @@ -1520,7 +1623,10 @@ impl Session { } /// Apply a current worker result, or discard it and reset the realm when the document changed. - pub fn finish_javascript(&mut self, p: PendingScript, out: Outcome) -> Result { + pub fn finish_javascript(&mut self, p: PendingScript, mut out: Outcome) -> Result { + if p.link { + drop_failed_link_effects(&mut out); + } if !self.script_pending_current(&p) { self.discard_javascript(&p); return Err(EditError::Invalid("document changed while the script was running".into())); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 62ea42cd54..7340d7d65c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -200,5 +200,7 @@ fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::Re /// How long a custom stamp's text line may be, in characters (#1741). pub const MAX_STAMP_LINE: usize = 200; +#[cfg(test)] +mod link_javascript_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/link_javascript_tests.rs b/crates/engine/src/link_javascript_tests.rs new file mode 100644 index 0000000000..a19b8d8b79 --- /dev/null +++ b/crates/engine/src/link_javascript_tests.rs @@ -0,0 +1,140 @@ +//! A link's JavaScript action (ported from #2081): it runs in the document's script realm as +//! event "Link"/"Mouse Up" with the document as its target, on the link's page; a failed one +//! commits nothing; field scripts may not open another document. + +use std::sync::Arc; + +use crate::{DocId, Edit, Session, js}; + +/// A PDF of `objs`, numbered from 1, with a cross-reference table. +fn pdf_of(objs: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// Two pages; page 2 has a text field `log` (4) and, as its second annotation, a link (7) whose +/// action runs `script`; the document script defines `note(s)`. +fn linked(script: &str) -> Vec { + pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript << /Names [(init) 8 0 R] >> >> /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(), + "<< /Type /Pages /Kids [6 0 R 3 0 R] /Count 2 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + format!("<< /Type /Annot /Subtype /Link /Rect [20 20 180 60] /Border [0 0 0] /A << /S /JavaScript /JS ({script}) >> >>"), + "<< /S /JavaScript /JS (function note\\(s\\) { getField\\('log'\\).value += s; }) >>".into(), + ]) +} + +fn log(s: &Session, id: DocId) -> String { + s.get(id).unwrap().form.iter().find(|f| f.name == "log").unwrap().value.join(",") +} + +fn open(script: &str) -> (Session, DocId) { + let mut s = Session::new(); + let id = s.open("link.pdf", None, Arc::new(linked(script)), None).unwrap(); + s.take_js_output(id); + (s, id) +} + +/// The link reads as a JavaScript link, and its action runs as "Link"/"Mouse Up" on its page +/// with the document as target and the document scripts defined; its change is one Undo step. +#[test] +fn a_link_runs_its_javascript_in_the_document_realm() { + let script = "note\\(event.type + ':' + event.name + ':' + \\(event.target === this\\) + ':' + this.pageNum\\); this.pageNum = 0;"; + let (mut s, id) = open(script); + let link = s.get(id).unwrap().info.links.iter().find(|l| matches!(l.target, pdfcraft_render::LinkTarget::JavaScript(_))).cloned().unwrap(); + assert_eq!((link.page, link.index), (1, 1)); + let o = s.run_link_javascript(id, 1, 1, &[]).unwrap(); + assert_eq!(o.error, None); + assert_eq!(log(&s, id), "Link:Mouse Up:true:1"); + assert_eq!(o.requests, [js::Request::GoToPage(0)]); + s.undo(id).unwrap(); + assert_eq!(log(&s, id), ""); + assert!(s.run_link_javascript(id, 1, 0, &[]).is_err(), "the field is no link"); + s.set_javascript(false); + assert!(s.run_link_javascript(id, 1, 1, &[]).is_err()); +} + +/// A link script that fails commits nothing: neither its field change nor its requests. +#[test] +fn a_failed_link_script_commits_nothing() { + let (mut s, id) = open("note\\('x'\\); this.pageNum = 0; app.openDoc\\('b.pdf'\\); console.println\\('said'\\); missing\\(\\);"); + let o = s.run_link_javascript(id, 1, 1, &[]).unwrap(); + assert!(o.error.is_some()); + assert_eq!(log(&s, id), ""); + assert!(o.requests.is_empty(), "{:?}", o.requests); + assert_eq!(o.console, ["said"], "what it said is kept"); + assert!(!s.get(id).unwrap().dirty); +} + +/// A link's popup menu waits for its answer, and openDoc comes back as a request only. +#[test] +fn a_link_menu_is_answered_and_open_doc_is_only_requested() { + let (mut s, id) = open("note\\(app.popUpMenuEx\\({cName:'A', cReturn:'a'}, {cName:'B', cReturn:'b'}\\)\\); app.openDoc\\('other.pdf'\\);"); + let o = s.run_link_javascript(id, 1, 1, &[Some("b".into())]).unwrap(); + assert_eq!(o.error, None); + assert_eq!(log(&s, id), "b"); + assert_eq!(o.requests, [js::Request::OpenDoc("other.pdf".into())]); +} + +/// The interactive start commits through `finish_javascript` with the same failure policy, and +/// a result for a document that changed meanwhile is refused. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_started_link_script_finishes_like_a_run_one() { + let wait = |p: &js::PendingScript| loop { + match p.receiver.recv_timeout(std::time::Duration::from_secs(10)).unwrap() { + js::InteractiveEvent::Prompt(_, reply) => reply.send(Some("a".into())).unwrap(), + js::InteractiveEvent::Complete(o) => break o, + } + }; + let (mut s, id) = open("note\\(app.popUpMenuEx\\({cName:'A', cReturn:'a'}\\)\\);"); + let p = s.start_link_javascript(id, 1, 1).unwrap(); + let o = wait(&p); + s.finish_javascript(p, o).unwrap(); + assert_eq!(log(&s, id), "a"); + let p = s.start_link_javascript(id, 1, 1).unwrap(); + let mut o = wait(&p); + o.error = Some("failed".into()); + s.finish_javascript(p, o).unwrap(); + assert_eq!(log(&s, id), "a", "a failed link commits nothing"); + let p = s.start_link_javascript(id, 1, 1).unwrap(); + let o = wait(&p); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert!(s.finish_javascript(p, o).is_err(), "the document changed meanwhile"); +} + +/// A read-only document's link still runs: requests apply, a field change fails to commit. +#[test] +fn a_read_only_documents_link_runs() { + let (mut s, id) = open("this.pageNum = 0;"); + s.docs.iter_mut().find(|d| d.id == id).unwrap().editor = None; + let o = s.run_link_javascript(id, 1, 1, &[]).unwrap(); + assert_eq!((o.error, o.requests), (None, vec![js::Request::GoToPage(0)])); +} + +/// A field's own script (here a format script) may not open another document: it is noted on +/// the console and dropped; a button's may. +#[test] +fn only_an_explicit_action_may_open_a_document() { + let (mut s, id) = open(""); + s.apply(id, Edit::SetFieldScript { name: "log".into(), event: "format".into(), script: Some("app.openDoc('other.pdf');".into()) }).unwrap(); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "log".into(), value: crate::FieldValue::Text("4".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.iter().all(|r| !matches!(r, js::Request::OpenDoc(_))), "{out:?}"); + assert!(out.console.iter().any(|c| c.contains("open the document other.pdf")), "{out:?}"); +} From e47e9fbc5e72914ed37065b970a38c2beb1c1110 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:55:47 +0100 Subject: [PATCH 4/8] automation: link_run_javascript, open_doc requests, links' JavaScript in doc_info Ported from #2081 on main's ScriptSession: link_run_javascript runs a link's action with ordered answers (as js_run takes them) and reports its requests without carrying them out. Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com> --- crates/automation/src/a11y/form_tools.rs | 52 +++++++++++---- crates/automation/src/dispatch.rs | 1 + crates/automation/src/json.rs | 1 + crates/automation/src/tools.rs | 3 + crates/automation/tests/it/link_javascript.rs | 65 +++++++++++++++++++ crates/automation/tests/it/main.rs | 1 + 6 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 crates/automation/tests/it/link_javascript.rs diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 31234a28ed..fa07b686de 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -16,11 +16,28 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Beep => json!({ "beep": true }), R::Highlight(on) => json!({ "highlight": on }), R::SaveAs => json!({ "save_as": true }), + R::OpenDoc(path) => json!({ "open_doc": path }), R::OpenAttachment { name, open } => json!({ "attachment": name, "open": open }), R::ShowPanel(p) => json!({ "panel": p.id() }), } } +/// The optional `answers` argument: the prompts' answers in order (null cancels one). +fn answers(a: &Args) -> Result>> { + match a.0.get("answers") { + None => Ok(Vec::new()), + Some(Value::Array(values)) if values.len() <= 32 => values + .iter() + .map(|v| match v { + Value::Null => Ok(None), + Value::String(s) if s.len() <= 4096 => Ok(Some(s.clone())), + _ => Err(ToolError::InvalidArgs("answers must contain strings or null".into())), + }) + .collect(), + _ => Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())), + } +} + /// A message a script showed, as `js_run` reports it: its text, title (null when it gave none), /// icon and buttons. pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value { @@ -32,18 +49,7 @@ impl Automation { /// messages, console lines, requests, error, the failing document scripts' errors and result. pub(crate) fn js_run(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; - let answers = match a.0.get("answers") { - None => Vec::new(), - Some(Value::Array(values)) if values.len() <= 32 => values - .iter() - .map(|v| match v { - Value::Null => Ok(None), - Value::String(s) if s.len() <= 4096 => Ok(Some(s.clone())), - _ => Err(ToolError::InvalidArgs("answers must contain strings or null".into())), - }) - .collect::>>()?, - _ => return Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())), - }; + let answers = answers(a)?; let identity = a.0.get("field_id") .map(|v| { @@ -66,6 +72,28 @@ impl Automation { )) } + /// `link_run_javascript`: run the JavaScript action of link `index` on page `page` (both + /// 1-based, as link_list gives them), answering its prompts from `answers`, and report what + /// it showed and asked for. A failed script commits nothing (#2081). + pub(crate) fn link_run_javascript(&mut self, a: &Args) -> Result { + let answers = answers(a)?; + let one_based = |key: &str| -> Result { + let n = a.int(key)?; + usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| ToolError::InvalidArgs(format!("{key} must be 1 or more"))) + }; + let (page, index) = (one_based("page")?, one_based("index")?); + let doc = self.doc(a)?; + let id = doc.id; + if !doc.info.links.iter().any(|l| l.page == page && l.index == index && matches!(l.target, pdfcraft_render::LinkTarget::JavaScript(_))) { + return Err(failed(format!("page {} has no JavaScript link {} (see link_list)", page + 1, index + 1))); + } + let (o, edit) = self.session.with_own_js_output(id, |s| s.run_link_javascript(id, page, index, &answers)).map_err(failed)?; + let reqs: Vec = o.requests.iter().chain(&edit.requests).map(request_json).collect(); + let alerts: Vec = o.alerts.iter().chain(&edit.alerts).map(alert_json).collect(); + let console: Vec<&String> = o.console.iter().chain(&edit.console).collect(); + Ok(with_warnings(json!({ "alerts": alerts, "console": console, "requests": reqs, "error": o.error, "result": o.result }), edit.errors)) + } + pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { let list: Vec = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect(); Ok(json!({ "scripts": list })) diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 9644684772..2b5f4a181a 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -733,6 +733,7 @@ impl Automation { "link_add" => self.link_add(&a)?, "link_edit" => self.link_edit(&a)?, "link_delete" => self.link_delete(&a)?, + "link_run_javascript" => self.link_run_javascript(&a)?, "links_from_urls" => self.links_from_urls(&a)?, "links_remove" => self.links_remove(&a)?, "object_list" => self.object_list(&a)?, diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index f612c3050d..a2c4dce823 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -411,6 +411,7 @@ fn link_target(i: &pdfcraft_render::DocInfo, target: &pdfcraft_render::LinkTarge })).collect::>(), "preserve_rb": preserve_rb, }), + pdfcraft_render::LinkTarget::JavaScript(script) => json!({ "javascript": script }), pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ "action": if *launch { "Launch" } else { "GoToR" }, "remote_document": file, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 7d948858cd..9876403a91 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -901,6 +901,9 @@ pub fn tools() -> Vec { }), &["doc", "page", "index"], )), + t("link_run_javascript", "Run a link's JavaScript", "Run the JavaScript action of link `index` on page `page` (1-based, from link_list: index is the link's place among the page's annotations), as clicking it does: event.type Link, event.name Mouse Up, event.target the document, this.pageNum the link's page, in the document's script realm. answers reply to its popup menus, response dialogs and alerts in order (null cancels a menu or response; missing answers do too); a menu with no items answers null without using one. Field changes and resetForm are one undoable step; a script that fails commits nothing and its requests are dropped. Returns alerts, console, requests (page, url, open_doc and others, never carried out), error and result.") + .destructive() + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "answers": {"type":"array","maxItems":32,"items":{"type":["string","null"],"maxLength":4096}} }), &["doc", "page", "index"])), t("link_delete", "Delete a link", "Delete one link (page, index from link_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), diff --git a/crates/automation/tests/it/link_javascript.rs b/crates/automation/tests/it/link_javascript.rs new file mode 100644 index 0000000000..dc6c42e4e7 --- /dev/null +++ b/crates/automation/tests/it/link_javascript.rs @@ -0,0 +1,65 @@ +//! A link's JavaScript action through the tool table (#2081, ported): link_list shows the +//! script, link_run_javascript runs it with ordered answers and reports requests, never +//! carrying them out. + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +fn pdf_of(objs: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn link_run_javascript_answers_menus_and_reports_requests() { + let script = "var c = app.popUpMenuEx\\({cName:'Red', cReturn:'r'}, {cName:'Blue', cReturn:'b'}\\); getField\\('log'\\).value = c + ':' + event.type; app.openDoc\\('other.pdf'\\);"; + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Link /Rect [20 20 180 60] /Border [0 0 0] /A << /S /JavaScript /JS ({script}) >> >>"), + "<< /Type /Annot /Subtype /Link /Rect [20 80 180 120] /Border [0 0 0] /A << /S /JavaScript /JS (missing\\(\\);) >> >>".into(), + ]); + let dir = std::env::temp_dir().join(format!("pdfcraft-link-js-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("link.pdf"), pdf).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = tool(&mut a, "doc_open", json!({ "path": "link.pdf" }))["doc"].as_u64().unwrap(); + let info = tool(&mut a, "doc_info", json!({ "doc": doc })); + assert!(info.to_string().contains("\"javascript\""), "links show their script: {info}"); + let log = |a: &mut Automation| { + let f = tool(a, "form_fields", json!({ "doc": doc })); + f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "log").map(|f| f["value"].clone()).unwrap() + }; + + let out = tool(&mut a, "link_run_javascript", json!({ "doc": doc, "page": 1, "index": 2, "answers": ["b"] })); + assert_eq!(out["error"], Value::Null, "{out}"); + assert_eq!(out["requests"], json!([{ "open_doc": "other.pdf" }]), "{out}"); + assert_eq!(log(&mut a), "b:Link"); + + let failed = tool(&mut a, "link_run_javascript", json!({ "doc": doc, "page": 1, "index": 3 })); + assert!(failed["error"].is_string(), "{failed}"); + assert!(a.call("link_run_javascript", &json!({ "doc": doc, "page": 1, "index": 1 })).is_err(), "a widget is no JavaScript link"); + assert!(a.call("link_run_javascript", &json!({ "doc": doc, "page": 0, "index": 2 })).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 35b9d988f7..2b8498f719 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -12,6 +12,7 @@ mod button_caption; mod find_text; mod form_bulk; mod hostile_args; +mod link_javascript; mod list_selection; mod model_3d; mod model_render; From c6646e3469fda9e2d50d8966b56c3b53bed8b2e1 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:07:19 +0100 Subject: [PATCH 5/8] ui: clicking a JavaScript link runs its action; openDoc asks first Ported from #2081 onto main's script prompt UI: a JavaScript link click starts the link's action like a button's (menus and questions without blocking the window; a failed one changes nothing); its tip reads "JavaScript action"; app.openDoc queues the existing consent prompt for another PDF (web: unavailable). No new strings. Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com> --- crates/ui-egui/src/canvas.rs | 11 ++++ crates/ui-egui/src/canvas/page_input.rs | 3 + crates/ui-egui/src/script_dialog.rs | 77 ++++++++++++++++++++++++- crates/ui-egui/src/tools/link_ui.rs | 1 + crates/ui-egui/src/workflows/js_ui.rs | 55 ++++++++++++++++++ 5 files changed, 146 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 90840225ef..015ed58c36 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1402,6 +1402,8 @@ struct PageEnv<'a> { struct PageOut { canvas_action: Option, clicked_link: Option, + /// The JavaScript link clicked, by page and annotation index (0-based), to run its action. + javascript_link: Option<(usize, usize)>, comment_notice: Option<&'static str>, copy_comments: bool, copy_selected: bool, @@ -1959,6 +1961,7 @@ fn draw_pages( let PageOut { mut canvas_action, clicked_link, + javascript_link, copy_selected, mut field_menu, field_placed, @@ -2209,6 +2212,7 @@ fn draw_pages( *out = PageOut { canvas_action, clicked_link, + javascript_link, comment_notice, copy_comments, copy_selected, @@ -2903,6 +2907,8 @@ pub(crate) fn follow_link( LinkTarget::Uri(u) => app.request_document_url(&u, crate::LinkOrigin::Link), LinkTarget::SetLayers { changes, preserve_rb } => set_layers(app, index, &changes, preserve_rb), LinkTarget::RemoteGoTo { file, destination, .. } => return Some((view_id, file, destination)), + // Run by `PdfCraftApp::run_link_script`, which needs the link's page and index. + LinkTarget::JavaScript(_) => {} LinkTarget::Other(s) => app.notify_fmt("This PDF uses the unsupported {s} action.", &[("s", &s)]), } None @@ -3447,6 +3453,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let PageOut { canvas_action, clicked_link, + javascript_link, comment_notice, copy_comments, mut copy_selected, @@ -3553,6 +3560,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } schedule_presentation_turn(ui, view, present, clicked_link.is_some() || on_object && present.is_some()); let remote_open = follow_link(app, index, clicked_link); + if let Some((page, link)) = javascript_link { + let id = app.views[index].id; + app.run_link_script(id, page, link); + } if tool == QuickTool::Crop && cropped { tool = QuickTool::Select; } diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index b39c16fde0..e171515569 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -986,6 +986,9 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P && let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index) { frame.out.clicked_link = Some(l.target.clone()); + if matches!(l.target, LinkTarget::JavaScript(_)) { + frame.out.javascript_link = Some((l.page, l.index)); + } } // A web or email address written as plain text (#4207): the same click as a link annotation, // and the same check and confirmation on opening. diff --git a/crates/ui-egui/src/script_dialog.rs b/crates/ui-egui/src/script_dialog.rs index 2249f7bfd7..c7ab61e5a5 100644 --- a/crates/ui-egui/src/script_dialog.rs +++ b/crates/ui-egui/src/script_dialog.rs @@ -69,6 +69,18 @@ fn reply_to_prompt(ctx: &egui::Context, pending: &mut Pending, answer: Option, identity: Option) { + self.start_script_with(id, |s| s.start_javascript_target(id, script, field, identity)); + } + /// Start link `index` on page `page`'s JavaScript action (0-based) as a button's runs: its + /// menus and questions are asked without blocking the window (#2081). + pub(crate) fn start_link_script(&mut self, id: DocId, page: usize, index: usize) { + self.start_script_with(id, |s| s.start_link_javascript(id, page, index)); + } + fn start_script_with( + &mut self, + id: DocId, + start: impl FnOnce(&mut pdfcraft_engine::Session) -> Result, + ) { if self.script_pending.is_some() { self.notify("Another form script is active; this click was not run"); return; @@ -76,7 +88,7 @@ impl PdfCraftApp { let Some(view) = self.views.iter().find(|v| v.id == id) else { return }; let (zoom, page, highlight) = (view.zoom, view.current, view.highlight_fields); self.session.set_javascript_view(id, zoom as f64 * 100.0, highlight, page); - match self.session.start_javascript_target(id, script, field, identity) { + match start(&mut self.session) { Ok(run) => { self.script_pending = Some(Pending { run, prompt: None, text: String::new(), focus: false, started: Instant::now(), zoom, page, serial: 0 }) @@ -410,3 +422,66 @@ mod review13_ui_tests { assert_eq!(view.forms.focus.as_ref().unwrap().name, "later"); } } + +#[cfg(test)] +mod link_script_tests { + use super::*; + + /// A one-page PDF whose only annotation is a link running `script`, and a field `log`. + fn linked(script: &str) -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Link /Rect [20 20 180 60] /Border [0 0 0] /A << /S /JavaScript /JS ({script}) >> >>"), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + pdf + } + + /// Clicking a JavaScript link runs its action without blocking: its popup menu is the + /// window's prompt, the answer resumes it, and its field change and openDoc consent apply. + #[test] + fn a_javascript_link_asks_its_menu_and_applies_its_result() { + let script = "getField\\('log'\\).value = app.popUpMenuEx\\({cName:'A', cReturn:'a'}\\) + event.type; app.openDoc\\('/abs/other.pdf'\\);"; + let mut app = PdfCraftApp::new(); + app.open_bytes("link.pdf", None, linked(script)).unwrap(); + let id = app.active_ids().unwrap().1; + app.run_link_script(id, 0, 0); + let ctx = egui::Context::default(); + for _ in 0..400 { + app.poll_script_dialog(&ctx); + if app.script_pending.as_ref().is_some_and(|p| p.prompt.is_some()) { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + let (prompt, reply) = app.script_pending.as_mut().unwrap().prompt.take().unwrap(); + assert!(matches!(&prompt, Prompt::Menu(items) if items.len() == 1 && items[0].value == "a"), "{prompt:?}"); + reply.send(Some("a".into())).unwrap(); + for _ in 0..400 { + app.poll_script_dialog(&ctx); + if app.script_pending.is_none() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(app.script_pending.is_none()); + let log = app.session.get(id).unwrap().form.iter().find(|f| f.name == "log").unwrap().value.clone(); + assert_eq!(log, ["aLink"]); + assert!(app.pending_remote_link.as_ref().is_some_and(|p| p.path.ends_with("other.pdf")), "openDoc waits for consent"); + } +} diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 8237fa0cf3..0ed184a44d 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -133,6 +133,7 @@ fn target_label(info: &DocInfo, target: &LinkTarget) -> String { LinkTarget::Uri(u) => u.clone(), LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), LinkTarget::RemoteGoTo { file, .. } => file.clone(), + LinkTarget::JavaScript(_) => crate::i18n::fmt(tl!("{s} action"), &[("s", "JavaScript")]), LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), } } diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 3547c511f2..eddd155fc7 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -83,6 +83,7 @@ impl PdfCraftApp { self.views[i].go_to_page(p); } } + Request::OpenDoc(path) => self.request_script_document(id, &path), Request::LaunchUrl(u) => self.request_document_url(&u, crate::LinkOrigin::Script), Request::Submit(u) => self.notify_fmt( "The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries.", @@ -235,6 +236,60 @@ impl PdfCraftApp { } } + /// `app.openDoc(path)`: resolve the path against document `id`'s folder (no file is touched) + /// and ask before opening it, as for a link to another PDF given by an absolute path; a script + /// never opens a file unasked. A request while another link waits for an answer is dropped, + /// as a link's is (#2081). + #[cfg(not(target_arch = "wasm32"))] + fn request_script_document(&mut self, id: DocId, path: &str) { + if self.pending_remote_link.is_some() || self.pending_link.is_some() { + return; + } + let source = self.session.get(id).and_then(|d| d.path.clone()); + match pdfcraft_engine::js::resolve_javascript_document_path(source.as_deref().map(std::path::Path::new), path) { + Ok(path) => { + self.pending_remote_link = + Some(crate::PendingRemoteLink { source: id, path, destination: pdfcraft_render::RemoteDestination::Default }) + } + Err(e) => self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &e.to_string())]), + } + } + #[cfg(target_arch = "wasm32")] + fn request_script_document(&mut self, _id: DocId, _path: &str) { + self.notify_tr("Local cross-PDF links require the desktop app"); + } + + /// Run the JavaScript action of link `index` on page `page` (0-based; its place in the page's + /// annotations), clicked in document `id` (#2081). A failed script changes nothing. + pub fn run_link_script(&mut self, id: DocId, page: usize, index: usize) { + if !self.commit_form_typing() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + self.start_link_script(id, page, index); + } + #[cfg(target_arch = "wasm32")] + match self.session.run_link_javascript(id, page, index, &[]) { + Ok(o) => { + if let Some(i) = self.views.iter().position(|v| v.id == id) + && let Some(info) = self.session.get(id).map(|d| d.info.clone()) + { + self.views[i].document_changed(&info); + } + let out = JsOutput { + alerts: o.alerts, + console: o.console, + requests: o.requests, + errors: o.error.into_iter().collect(), + doc_errors: Vec::new(), + }; + self.handle_js(id, out); + } + Err(e) => self.notify(e.to_string()), + } + } + /// Prepare a form ▸ detect fields from the page's blanks, lines and boxes. pub fn detect_fields(&mut self) { let Some((i, id)) = self.active_ids() else { return }; From 12bc215e12ceea939c1d105d0318c68905cf17dc Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:12:58 +0100 Subject: [PATCH 6/8] docs: link JavaScript actions and app.openDoc (#2081 port) Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com> --- docs/acroform-script-workflows.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/acroform-script-workflows.md b/docs/acroform-script-workflows.md index 200a59966a..37cbc9e45f 100644 --- a/docs/acroform-script-workflows.md +++ b/docs/acroform-script-workflows.md @@ -83,6 +83,34 @@ host path cancels unanswered popups and response dialogs; no web-interaction par This work does not add timer execution, PDF annotations/layers to the JS object model, or the remaining document action triggers. +## Link actions + +A link annotation whose action is JavaScript (`/S /JavaScript`) runs it when clicked. The script +runs in the document's realm, so document scripts and earlier button or field scripts are in +scope. `event.type` is `Link`, `event.name` is `Mouse Up`, `event.target` is the document +(`this`; not enumerable on the event, so `JSON.stringify(event)` works), and `this.pageNum` is +the link's page. Its menus, questions and alerts use the same worker prompts as a button's. A +script that fails commits nothing: its field changes and requests are dropped, and what it +printed to the console and its error are kept. A read-only document's links run too, but a +field change there fails to commit as any edit does. The link's tip reads "JavaScript action". +A script is read up to 256 KiB per link and 4 MiB per document. Longer ones are rejected and +reported once. + +`app.openDoc(cPath)` and `app.openDoc({cPath, oDoc, cFS})` only queue a request, then return +null. The base may only be this document, and only the local file system (`cFS` empty) is +supported; other options are refused with a script error. The path resolves against the +document's folder as a link to another PDF does (`/C/dir/file.pdf` reads as `C:/dir/file.pdf` +on Windows). The viewer always asks before opening it. A request that arrives while another +link is waiting for an answer is dropped. Field scripts (format, keystroke, validate, +calculate) may not open documents; the request is noted on the console. The web build says +local cross-PDF links need the desktop app. A popup menu with no items returns null without +asking, but it still counts toward the 32-prompt limit. + +`link_run_javascript` (doc, 1-based page and annotation index from `link_list`, and optional +ordered `answers`) runs a link's action headlessly. It returns alerts, console output, +requests (including `open_doc`) and the error, and never carries requests out. `doc_info` +shows such links as `{ "javascript": … }`. + ## Automation The existing `js_run` accepts `answers`, an optional array of up to 32 strings or null values: From ad153f5ded83fff756417db6f7a3636a55fe19dc Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:22:55 +0100 Subject: [PATCH 7/8] test(ui): the link test's openDoc path is absolute on Windows too --- crates/ui-egui/src/script_dialog.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/script_dialog.rs b/crates/ui-egui/src/script_dialog.rs index c7ab61e5a5..64251d51de 100644 --- a/crates/ui-egui/src/script_dialog.rs +++ b/crates/ui-egui/src/script_dialog.rs @@ -456,9 +456,12 @@ mod link_script_tests { /// window's prompt, the answer resumes it, and its field change and openDoc consent apply. #[test] fn a_javascript_link_asks_its_menu_and_applies_its_result() { - let script = "getField\\('log'\\).value = app.popUpMenuEx\\({cName:'A', cReturn:'a'}\\) + event.type; app.openDoc\\('/abs/other.pdf'\\);"; + // An absolute path on this system (the document has no folder): on Windows, Acrobat's + // `/C/…` form, which reads as `C:/…`. + let path = if cfg!(windows) { "/C/abs/other.pdf" } else { "/abs/other.pdf" }; + let script = format!("getField\\('log'\\).value = app.popUpMenuEx\\({{cName:'A', cReturn:'a'}}\\) + event.type; app.openDoc\\('{path}'\\);"); let mut app = PdfCraftApp::new(); - app.open_bytes("link.pdf", None, linked(script)).unwrap(); + app.open_bytes("link.pdf", None, linked(&script)).unwrap(); let id = app.active_ids().unwrap().1; app.run_link_script(id, 0, 0); let ctx = egui::Context::default(); From 1771844b84ecb52985440c7048b6d69ada064b36 Mon Sep 17 00:00:00 2001 From: FredApps Date: Fri, 13 Nov 2026 18:48:33 +0100 Subject: [PATCH 8/8] fix(link-js): address the review on the #2081 port - A link that fails puts the realm's timers back as they were before its own script ran, and turns no layer: timers it set are dropped, timers it cleared come back (timers::save/restore in Realm::run_job). - In a read-only document a link's requests still apply when its field changes can't; the outcome carries the read-only error. - app.openDoc during a calculation (calculateNow) is noted on the console and not queued, as for a field script running on its own. - Link script streams are decoded once per stream, whatever their source length, and /Filter [] reads as the raw source. - The web build sets the view's zoom, page and highlight before a link script runs, as the desktop does. --- crates/engine/src/js.rs | 27 +++++- crates/engine/src/link_javascript_tests.rs | 97 ++++++++++++++++++++-- crates/js/src/globals.rs | 10 ++- crates/js/src/session.rs | 21 ++++- crates/js/src/timers.rs | 17 +++- crates/render/src/inspect/annotations.rs | 76 +++++++++++++---- crates/ui-egui/src/workflows/js_ui.rs | 7 ++ 7 files changed, 225 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index ff295bd176..301f9ce25e 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -1645,17 +1645,33 @@ mod caption_tests { } } -/// A failed link action commits nothing: its field changes and requests are dropped, its -/// alerts, console lines and error kept (#2081). +/// A failed link action commits nothing: its field changes, requests and layer changes are +/// dropped (the realm put its timers back), its alerts, console lines and error kept (#2081). fn drop_failed_link_effects(o: &mut Outcome) { if o.error.is_some() { o.changed.clear(); o.formatted.clear(); o.requests.clear(); + o.layers.clear(); } } impl Session { + /// A read-only document takes none of a link's field changes or resets, but its other + /// requests (go to a page, say) still apply: the changes are dropped and, if there were any, + /// the outcome says why, as an edit there would (#2081). + fn keep_read_only_link_requests(&self, id: DocId, states: &[pdfcraft_js::FieldState], o: &mut Outcome) { + let Some(doc) = self.get(id).filter(|d| d.editor.is_none()) else { return }; + let resets = o.requests.iter().any(|r| matches!(r, Request::Reset(_))); + let edited = resets || !changes(states, &o.changed, &o.formatted).is_empty(); + o.changed.clear(); + o.formatted.clear(); + o.requests.retain(|r| !matches!(r, Request::Reset(_))); + if edited && o.error.is_none() { + o.error = Some(EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()).to_string()); + } + } + /// The job for link `index` (its raw annotation index) on page `page` (both 0-based) when /// its action is JavaScript: event "Link"/"Mouse Up" with the document as its target, on /// the link's page. @@ -1704,12 +1720,14 @@ impl Session { /// Run link `index` on page `page` (0-based; the index is the link's place in the page's /// annotations) in the document's script realm, answering its prompts from `answers` in /// order (none: cancelled). Field changes and `resetForm` are applied as one undoable step, - /// unless the script failed: then nothing is committed and its requests are dropped. + /// unless the script failed: then nothing is committed and its requests are dropped. In a + /// read-only document the requests apply and the changes are refused. pub fn run_link_javascript(&mut self, id: DocId, page: usize, index: usize, answers: &[Option]) -> Result { let (job, states) = self.link_job(id, page, index)?; let runtime = self.get(id).ok_or(EditError::NoDocument)?.js_runtime.clone(); let mut o = runtime.lock().map_err(|_| EditError::Invalid("document script runtime is unavailable".into()))?.run(job, answers); drop_failed_link_effects(&mut o); + self.keep_read_only_link_requests(id, &states, &mut o); let discard = o.execution_refused && !o.realm_untouched; let result = self.apply_script_outcome(id, &states, o, false); if discard || result.is_err() { @@ -1832,6 +1850,9 @@ impl Session { return Err(EditError::Invalid("document changed while the script was running".into())); } p.runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.acknowledge_completion(); + if p.link { + self.keep_read_only_link_requests(p.id, &p.states, &mut out); + } let discard = out.execution_refused && !out.realm_untouched; let result = self.apply_script_outcome(p.id, &p.states, out, false); if discard || result.is_err() { diff --git a/crates/engine/src/link_javascript_tests.rs b/crates/engine/src/link_javascript_tests.rs index a19b8d8b79..252358bb38 100644 --- a/crates/engine/src/link_javascript_tests.rs +++ b/crates/engine/src/link_javascript_tests.rs @@ -26,16 +26,36 @@ fn pdf_of(objs: &[String]) -> Vec { /// Two pages; page 2 has a text field `log` (4) and, as its second annotation, a link (7) whose /// action runs `script`; the document script defines `note(s)`. fn linked(script: &str) -> Vec { - pdf_of(&[ - "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript << /Names [(init) 8 0 R] >> >> /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(), + linked_with(script, Extra::default()) +} + +/// Entries added to [`linked`]'s objects, and objects added from 9 on. +#[derive(Default)] +struct Extra<'a> { + catalog: &'a str, + form: &'a str, + field: &'a str, + objects: &'a [&'a str], +} + +/// [`linked`], with `extra` added. +fn linked_with(script: &str, extra: Extra) -> Vec { + let Extra { catalog, form, field, objects } = extra; + let mut objs = vec![ + format!( + "<< /Type /Catalog /Pages 2 0 R {catalog} /Names << /JavaScript << /Names [(init) 8 0 R] >> >> \ + /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> {form} >> >>" + ), "<< /Type /Pages /Kids [6 0 R 3 0 R] /Count 2 /MediaBox [0 0 200 300] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R] >>".into(), - "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 {field} >>"), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Page /Parent 2 0 R >>".into(), format!("<< /Type /Annot /Subtype /Link /Rect [20 20 180 60] /Border [0 0 0] /A << /S /JavaScript /JS ({script}) >> >>"), "<< /S /JavaScript /JS (function note\\(s\\) { getField\\('log'\\).value += s; }) >>".into(), - ]) + ]; + objs.extend(objects.iter().map(|o| (*o).to_string())); + pdf_of(&objs) } fn log(s: &Session, id: DocId) -> String { @@ -43,8 +63,12 @@ fn log(s: &Session, id: DocId) -> String { } fn open(script: &str) -> (Session, DocId) { + open_pdf(linked(script)) +} + +fn open_pdf(pdf: Vec) -> (Session, DocId) { let mut s = Session::new(); - let id = s.open("link.pdf", None, Arc::new(linked(script)), None).unwrap(); + let id = s.open("link.pdf", None, Arc::new(pdf), None).unwrap(); s.take_js_output(id); (s, id) } @@ -138,3 +162,66 @@ fn only_an_explicit_action_may_open_a_document() { assert!(out.requests.iter().all(|r| !matches!(r, js::Request::OpenDoc(_))), "{out:?}"); assert!(out.console.iter().any(|c| c.contains("open the document other.pdf")), "{out:?}"); } + +/// A link that fails before it is done leaves no timer it set and clears none: the time-out +/// never runs and the interval it cleared still does (review on #7144). +#[test] +fn a_failed_link_leaves_the_timers_as_they_were() { + let (mut s, id) = open("app.clearInterval\\(kept\\); app.setTimeOut\\('note\\(1\\)', 5\\); missing\\(\\);"); + s.show_page(id, 1, 0.0); + let set = s.run_javascript(id, "var kept = app.setInterval('note(2)', 1000);", None).unwrap(); + assert_eq!(set.error, None); + assert_eq!(s.next_timer(id), Some(1000.0)); + let o = s.run_link_javascript(id, 1, 1, &[]).unwrap(); + assert!(o.error.is_some()); + assert_eq!(s.next_timer(id), Some(1000.0), "the interval stays and the time-out is gone"); + s.run_timers(id, 1000.0); + assert_eq!(log(&s, id), "2"); +} + +/// A link that fails before it is done turns no layer (review on #7144). +#[test] +fn a_failed_link_turns_no_layer() { + let pdf = linked_with( + "this.getOCGs\\(\\)[0].state = false; missing\\(\\);", + Extra { + catalog: "/OCProperties << /OCGs [9 0 R] /D << /Order [9 0 R] >> >>", + objects: &["<< /Type /OCG /Name (Red) >>"], + ..Extra::default() + }, + ); + let (mut s, id) = open_pdf(pdf); + assert!(s.get(id).unwrap().info.layers[0].visible); + let o = s.run_link_javascript(id, 1, 1, &[]).unwrap(); + assert!(o.error.is_some()); + assert!(o.layers.is_empty(), "{:?}", o.layers); + assert!(s.get(id).unwrap().info.layers[0].visible); +} + +/// In a read-only document a link's request still applies when its field change can't, and +/// the outcome says why the change wasn't made (review on #7144). +#[test] +fn a_read_only_documents_link_goes_to_its_page_after_a_field_change() { + let (mut s, id) = open("note\\('x'\\); this.pageNum = 0;"); + s.docs.iter_mut().find(|d| d.id == id).unwrap().editor = None; + let o = s.run_link_javascript(id, 1, 1, &[]).unwrap(); + assert_eq!(o.requests, [js::Request::GoToPage(0)]); + let read_only = crate::EditError::ReadOnly(String::new()).to_string(); + assert_eq!(o.error.as_deref(), Some(read_only.as_str())); + assert_eq!(log(&s, id), ""); +} + +/// A field's calculation that a link sets off (`calculateNow`) may not open another document, +/// as when it runs on its own; the link itself still may (review on #7144). +#[test] +fn a_calculation_a_link_runs_may_not_open_a_document() { + let pdf = linked_with( + "this.calculateNow\\(\\); app.openDoc\\('own.pdf'\\);", + Extra { form: "/CO [4 0 R]", field: "/AA << /C << /S /JavaScript /JS (app.openDoc\\('calc.pdf'\\);) >> >>", ..Extra::default() }, + ); + let (mut s, id) = open_pdf(pdf); + let o = s.run_link_javascript(id, 1, 1, &[]).unwrap(); + assert_eq!(o.error, None); + assert_eq!(o.requests, [js::Request::OpenDoc("own.pdf".into())]); + assert!(o.console.iter().any(|c| c.contains("open the document calc.pdf")), "{:?}", o.console); +} diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index d8f2ba0965..22f3bb7799 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -171,7 +171,15 @@ fn open_doc(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult 4096 { return Err(error("invalid openDoc path")); } - host(ctx)?.borrow_mut().request(Request::OpenDoc(path)); + let shared = host(ctx)?; + let mut h = shared.borrow_mut(); + // A field's calculation runs unasked, here through `calculateNow`: as when it runs on its + // own (`JsOutput::absorb`), it may not open another document. + if h.calculate_depth > 0 { + h.print(format!("A field script asked to open the document {path}; only a button, a link or the console may open another document")); + } else { + h.request(Request::OpenDoc(path)); + } Ok(JsValue::null()) } diff --git a/crates/js/src/session.rs b/crates/js/src/session.rs index 8235706d3d..61896f811e 100644 --- a/crates/js/src/session.rs +++ b/crates/js/src/session.rs @@ -182,8 +182,11 @@ impl Realm { } }) .collect(); + // A link action that fails commits nothing (#2081), timers included: the realm's are saved + // once the document scripts ran, and put back if the link's own script fails. + let link = job.event.kind == "Link"; let Some(ctx) = self.context.as_mut() else { return failed("script realm unavailable".into()) }; - if initialize { + let (mut out, saved) = if initialize { let mut initial = crate::run::run_in_context( ctx, "", @@ -211,6 +214,7 @@ impl Realm { (h.fields.clone(), h.reset_calls, h.calculation_evaluations) }) .unwrap_or((job.fields, 0, 0)); + let saved = link.then(|| crate::timers::save(ctx)).flatten(); let mut out = crate::run::run_in_context( ctx, &job.script, @@ -249,9 +253,10 @@ impl Realm { if out.error.is_none() { out.error = initial.error; } - out + (out, saved) } else { - crate::run::run_in_context( + let saved = link.then(|| crate::timers::save(ctx)).flatten(); + let out = crate::run::run_in_context( ctx, &job.script, &job.event, @@ -268,8 +273,16 @@ impl Realm { call: None, }, Limits::default(), - ) + ); + (out, saved) + }; + if out.error.is_some() + && let Some(saved) = saved + { + crate::timers::restore(ctx, saved); + out.timers = crate::timers::report(ctx); } + out } } diff --git a/crates/js/src/timers.rs b/crates/js/src/timers.rs index 88c7d65caf..2e42bf45f1 100644 --- a/crates/js/src/timers.rs +++ b/crates/js/src/timers.rs @@ -35,6 +35,7 @@ pub(crate) enum Code { Function(JsObject), } +#[derive(Clone)] struct Entry { /// The object `set*` returned, which `clear*` is given back. handle: JsObject, @@ -44,7 +45,7 @@ struct Entry { } /// A realm's timers by id, in the order they were set (ids only grow). -#[derive(Default)] +#[derive(Clone, Default)] pub(crate) struct Timers { last: u32, table: BTreeMap, @@ -66,6 +67,20 @@ pub(crate) fn report(ctx: &Context) -> Vec { .unwrap_or_default() } +/// The realm's timers as they stand, to put back with [`restore`]; `None` in a one-off run. +pub(crate) fn save(ctx: &Context) -> Option { + ctx.get_data::().map(|t| t.borrow().clone()) +} + +/// Put the realm's timers back as [`save`] found them: those set since go, those cleared since +/// come back. Ids stay unused once given out. +pub(crate) fn restore(ctx: &Context, saved: Timers) { + if let Some(timers) = ctx.get_data::() { + let mut timers = timers.borrow_mut(); + timers.table = saved.table; + } +} + /// What timer `id` runs now that it fell due; a `setTimeOut` timer is gone after this. `None` /// when it was cleared (or the realm has no timers). pub(crate) fn due(ctx: &Context, id: u32) -> Option { diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 04e468a40d..13f194ee67 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -11,8 +11,8 @@ use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, Obj /// opaque identities, never dereferenced here. Do not reuse the cache across documents, /// edits or inspections, where an address may identify a different stream. /// FIFO eviction bounds it to 4096 identities and allows an evicted failure to be retried; -/// a cache hit does not refresh its position. This bounds retained failures, not the -/// total decoding work, and does not cache successful sources or spend their text budget. +/// a cache hit does not refresh its position. This bounds retained failures; streams that +/// decode are remembered in [`LinkScripts`] instead. #[derive(Default)] pub(super) struct RejectedJavaScript { identities: HashSet<*const lopdf::Stream>, @@ -41,17 +41,22 @@ impl RejectedJavaScript { } /// What link JavaScript an inspection may still keep: 4 MiB of source across the document, -/// and the streams that failed to decode. +/// the streams that failed to decode, and the source of those that did. pub(super) struct LinkScripts { remaining: usize, rejected: RejectedJavaScript, + /// Each stream's source once decoded, by its address (see [`RejectedJavaScript`]): many links + /// can share one stream, whose decoding costs its encoded length however short the source + /// (review on #7144). Every link keeping a source still spends its length from `remaining`, + /// so what is kept here stays within the budget, plus at most one source. + decoded: HashMap<*const lopdf::Stream, String>, /// Whether the warning that the budget ran out has been given. limit_reported: bool, } impl Default for LinkScripts { fn default() -> Self { - Self { remaining: 4 << 20, rejected: RejectedJavaScript::default(), limit_reported: false } + Self { remaining: 4 << 20, rejected: RejectedJavaScript::default(), decoded: HashMap::new(), limit_reported: false } } } @@ -456,14 +461,14 @@ impl<'a> Inspector<'a> { /// The source of a link's JavaScript action (`/JS`, a string or a stream), as data. A /// string or decompressed stream over 256 KiB, a stream that fails to decode, or source /// past what is left of the document's 4 MiB is `None` (the link stays - /// `Other("JavaScript")`); a failed stream is remembered so links sharing it don't decode it - /// again. An action's `/Next` chain is not followed. + /// `Other("JavaScript")`); a stream is decoded once, its source or failure remembered for + /// links sharing it. An action's `/Next` chain is not followed. fn link_script(&self, a: &Dictionary, scripts: &mut LinkScripts) -> Option { if scripts.remaining == 0 { return None; } - let bytes = match self.resolve(a.get(b"JS").ok()?) { - Object::String(bytes, _) if bytes.len() <= MAX_LINK_SCRIPT => bytes.clone(), + let source = match self.resolve(a.get(b"JS").ok()?) { + Object::String(bytes, _) if bytes.len() <= MAX_LINK_SCRIPT => pdfcraft_cos::PdfString::literal(bytes.clone()).to_text(), Object::Stream(stream) => { // The structure's objects stay put for the whole inspection, so the resolved // stream's address identifies it, aliases included. @@ -471,19 +476,27 @@ impl<'a> Inspector<'a> { if scripts.rejected.contains(identity) { return None; } - #[cfg(test)] - JS_DECODE_COUNT.with(|n| n.set(n.get() + 1)); - match stream.decompressed_content_with_limit(MAX_LINK_SCRIPT) { - Ok(bytes) => bytes, - Err(_) => { - scripts.rejected.insert(identity); - return None; + if let Some(source) = scripts.decoded.get(&identity) { + source.clone() + } else { + #[cfg(test)] + JS_DECODE_COUNT.with(|n| n.set(n.get() + 1)); + // An empty `/Filter []` is no filter: the stream is the source. + match stream.get_plain_content_with_limit(MAX_LINK_SCRIPT) { + Ok(bytes) => { + let source = pdfcraft_cos::PdfString::literal(bytes).to_text(); + scripts.decoded.insert(identity, source.clone()); + source + } + Err(_) => { + scripts.rejected.insert(identity); + return None; + } } } } _ => return None, }; - let source = pdfcraft_cos::PdfString::literal(bytes).to_text(); let Some(remaining) = scripts.remaining.checked_sub(source.len()) else { scripts.remaining = 0; return None; @@ -1059,4 +1072,35 @@ mod remediation_probes { println!("REJECT n={n} repeated_annotation={repeated} bytes={len} decode_calls={calls} micros={times:?}"); } } + + /// Links sharing a stream that decodes decode it once, even when its source is empty and + /// spends none of the budget; a stream whose `/Filter` is an empty array is its own source + /// (review on #7144). + #[test] + fn a_shared_script_stream_is_decoded_once() { + let mut d = Document::with_version("1.7"); + let pages = d.new_object_id(); + // Whitespace decodes to nothing, at the cost of reading all of it. + let blank = d.add_object(lopdf::Stream::new(dictionary! {"Filter"=>"ASCIIHexDecode"}, [vec![b' '; 64 << 10], b">".to_vec()].concat())); + let raw = d.add_object(lopdf::Stream::new(dictionary! {"Filter"=>Vec::::new()}, b"app.alert(1);".to_vec())); + let rect = || vec![0.into(), 0.into(), 50.into(), 50.into()]; + let annots: Vec = std::iter::repeat_n(blank, 100) + .chain([raw]) + .map(|js| { + d.add_object(dictionary! {"Type"=>"Annot","Subtype"=>"Link","Rect"=>rect(),"A"=>dictionary! {"S"=>"JavaScript","JS"=>js}}).into() + }) + .collect(); + let page = d.add_object(dictionary! {"Type"=>"Page","Parent"=>pages,"Annots"=>annots}); + d.objects.insert(pages, dictionary! {"Type"=>"Pages","Kids"=>vec![page.into()],"Count"=>1,"MediaBox"=>rect()}.into()); + let root = d.add_object(dictionary! {"Type"=>"Catalog","Pages"=>pages}); + d.trailer.set("Root", root); + let mut bytes = Vec::new(); + d.save_to(&mut bytes).unwrap(); + JS_DECODE_COUNT.with(|n| n.set(0)); + let info = inspect(Arc::new(bytes), None).unwrap(); + assert_eq!(JS_DECODE_COUNT.with(std::cell::Cell::get), 2, "one decode per stream"); + assert_eq!(info.links.len(), 101); + assert!(info.links[..100].iter().all(|l| matches!(&l.target, LinkTarget::JavaScript(s) if s.is_empty()))); + assert!(matches!(&info.links[100].target, LinkTarget::JavaScript(s) if s == "app.alert(1);"), "{:?}", info.links[100].target); + } } diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 9cf1900faa..18f9bcb753 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -342,6 +342,13 @@ impl PdfCraftApp { { self.start_link_script(id, page, index); } + // Script getters read the clicked view's zoom, page and field highlight, as on the + // desktop, where starting the script sets them. + #[cfg(target_arch = "wasm32")] + if let Some(view) = self.views.iter().find(|v| v.id == id) { + let (zoom, current, highlight) = (view.zoom, view.current, view.highlight_fields); + self.session.set_javascript_view(id, zoom as f64 * 100.0, highlight, current); + } #[cfg(target_arch = "wasm32")] match self.session.run_link_javascript(id, page, index, &[]) { Ok(o) => {