diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 5f8a7021c4..157012eb29 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -12,6 +12,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Submit(u) => json!({ "submit": u }), R::Focus(f) => json!({ "focus": f }), R::Beep => json!({ "beep": true }), + R::Highlight(on) => json!({ "highlight": on }), R::SaveAs => json!({ "save_as": true }), R::ShowPanel(p) => json!({ "panel": p.id() }), } diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index e584ac2413..2c8e1a8196 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -226,6 +226,8 @@ pub(crate) struct EditCtx { pub(crate) xfa_removed: Vec, /// Pages in the document, for `xfa.layout.pageCount()`. pub(crate) pages: usize, + /// Whether the viewer highlights form fields, for `app.runtimeHighlight`. + pub(crate) highlight: bool, pub(crate) date: Option, /// Today in local time, for date tokens. pub(crate) today: (i64, u32, u32), @@ -245,6 +247,23 @@ pub(crate) struct EditCtx { } impl EditCtx { + /// The field highlight (`app.runtimeHighlight`) the edit's next script reads: the field + /// scripts' runner holds it while there is one (its scripts change it there), else the edit. + pub(crate) fn highlight(&self) -> bool { + self.js.as_ref().map_or(self.highlight, |js| js.doc.highlight) + } + + /// A script changed the field highlight: both script runtimes of the edit read the new one, + /// so a later edit of a batch sees it whichever kind of script it runs. + pub(crate) fn set_highlight(&mut self, on: bool) { + self.highlight = on; + if let Some(js) = self.js.as_mut() { + js.doc.highlight = on; + } + } + + /// The context for one edit: `now` (the session's clock) dates it, and with `salt` seeds the + /// ids it makes. pub(crate) fn new(now: Option, salt: u64) -> Self { let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; if let Some(t) = now { @@ -264,6 +283,7 @@ impl EditCtx { xfa_time_scale: 1, xfa_removed: Vec::new(), pages: 0, + highlight: false, date: now.map(pdfcraft_cos::pdf_date), today: (1970, 1, 1), seed, @@ -670,11 +690,21 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E }, Edit::XfaEvent { som, activity } => { let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; - let mut run = - xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let highlight = cx.highlight(); + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + highlight, + time_scale: cx.xfa_time_scale, + }; let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; + // The next edit of a batch reads the highlight this one's scripts set. + let on = run.highlight; + cx.set_highlight(on); ran.map_err(EditError::Invalid)?; } Edit::SetFieldImage { name, image } => { diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 0dd14a42b4..f75fddca6c 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -255,11 +255,18 @@ pub struct JsRunner { impl JsRunner { /// A runner for `cos`: what scripts read about the document (pages, info, whether it is - /// an XFA form) and its document-level scripts, read once. - pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner { + /// an XFA form) and its document-level scripts, read once. `highlight`: whether the viewer + /// highlights form fields (`app.runtimeHighlight`). + pub fn new(cos: &pdfcraft_cos::Document, file_name: &str, highlight: bool) -> JsRunner { let xfa = pdfcraft_xfa::xfa_shape(cos).is_some(); - let doc = - pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos), xfa }; + let doc = pdfcraft_js::DocInfo { + file_name: file_name.to_string(), + num_pages: pdfcraft_model::pages(cos).len(), + page: 0, + info: info(cos), + highlight, + xfa, + }; // Once XFA owns the form, the PDF container's scripts belong to its fallback view. // Keep them in the file and in Document JavaScripts, but do not initialize an // AcroForm context with them: a viewer-compatibility alert would be misleading. @@ -270,6 +277,14 @@ impl JsRunner { } } +/// The field highlight the last of `requests` that sets it asks for (`app.runtimeHighlight`). +fn last_highlight(requests: &[Request]) -> Option { + requests.iter().rev().find_map(|r| match r { + Request::Highlight(on) => Some(*on), + _ => None, + }) +} + /// The Info dictionary as `this.info` keys (lower-case). fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> { let Some(d) = cos.trailer().get(b"Info").map(|i| cos.resolve(i)).and_then(|i| i.as_dict().cloned()) else { return Vec::new() }; @@ -320,6 +335,10 @@ impl JsRunner { if log { self.output.absorb(&o); } + // The edit's later scripts read the field highlight this one set. + if let Some(on) = last_highlight(&o.requests) { + self.doc.highlight = on; + } if o.error.is_some() { return None; } @@ -361,15 +380,38 @@ impl Session { self.js_off = !on; } + /// Whether JavaScript is on (Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript). pub fn javascript(&self) -> bool { !self.js_off } + /// Whether the viewer highlights form fields (Highlight fields), which forms' scripts read as + /// `app.runtimeHighlight`. A script that changes it asks for [`Request::Highlight`]; the + /// viewer then calls this with the new setting. + pub fn set_field_highlight(&mut self, on: bool) { + self.field_highlight = on; + } + /// What scripts printed or asked for since the last call (alerts, console, print, …). pub fn take_js_output(&mut self, id: DocId) -> JsOutput { self.docs.iter_mut().find(|d| d.id == id).map(|d| std::mem::take(&mut d.js_output)).unwrap_or_default() } + /// The last field-highlight change (`app.runtimeHighlight`) scripts asked for in document + /// `id`, taken out of its waiting output; the rest stays for [`Session::take_js_output`]. + pub fn take_js_highlight(&mut self, id: DocId) -> Option { + let doc = self.docs.iter_mut().find(|d| d.id == id)?; + let mut last = None; + doc.js_output.requests.retain(|r| match r { + Request::Highlight(on) => { + last = Some(*on); + false + } + _ => true, + }); + last + } + /// Run `script` in document `id`, for a button (`target`: the field, event "Mouse Up") or /// the console (no target). Field changes and `resetForm` are applied as one undoable step; /// everything else the script asked for is returned. @@ -396,7 +438,7 @@ impl Session { ..Default::default() }); } - let runner = JsRunner::new(&cos, &doc.name); + let runner = JsRunner::new(&cos, &doc.name, self.field_highlight); let fields = pdfcraft_forms::fields(&cos); let states: Vec<_> = fields.iter().map(|f| field_state(&cos, f)).collect(); let event = match target { @@ -414,6 +456,10 @@ impl Session { }) .collect(); o.requests.retain(|r| !matches!(r, Request::Reset(_))); + // The calculations the script's changes set off read the field highlight it asked for. + if let Some(on) = last_highlight(&o.requests) { + self.field_highlight = on; + } let mut edits = Vec::new(); if !changes.is_empty() { edits.push(Edit::ApplyScriptChanges { changes }); diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index a09acc9360..d53e8d80ec 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -18,6 +18,7 @@ pub struct OpenContext { now: Option, js_off: bool, trust: Arc, + highlight: bool, captions: Arc, xfa_time_scale: u32, } @@ -39,6 +40,14 @@ struct DocumentOpenData<'a> { } impl OpenContext { + /// This context with Highlight fields `on`, which the document's scripts read as + /// `app.runtimeHighlight` while it opens. A new document opens with the remembered setting + /// while the session keeps the one of the document in front, which its scripts read. + pub fn with_highlight(mut self, on: bool) -> Self { + self.highlight = on; + self + } + /// Prepare a document without mutating the session that supplied this context. pub fn prepare_open( &self, @@ -60,6 +69,7 @@ impl Session { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), + highlight: self.field_highlight, captions: self.captions.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1), } @@ -100,6 +110,8 @@ impl Session { Ok(self.commit_open(prepared)) } + /// What [`OpenContext::prepare_open`] runs inside its panic guard: read, decrypt and lay out + /// the document, and run an XFA form's opening scripts. fn prepare_open_unguarded( context: &OpenContext, name: String, @@ -210,6 +222,7 @@ impl Session { out: &mut script_output, datasets: None, ran_away: false, + highlight: context.highlight, time_scale: context.xfa_time_scale, }; let changes = xfa::on_open(&mut work, &tpl, &mut run); diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 4f7951b236..bd83242682 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -45,6 +45,9 @@ pub struct Session { pub(crate) date_format: Option, /// Preferences ▸ Date format ▸ Language, when not following the interface language. pub(crate) date_language: Option, + /// Highlight fields, as the viewer shows it: what forms' scripts read as + /// `app.runtimeHighlight` (off by default, as in a session without a viewer). + pub(crate) field_highlight: bool, /// What the XFA scripts' time limits are multiplied by (`None`: 1); see /// [`Session::with_xfa_time_scale`]. pub(crate) xfa_time_scale: Option, diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index e9f9bec19f..20c94a0688 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -36,6 +36,7 @@ impl Session { let now = self.now(); let today = self.today(); let js_off = self.js_off; + let highlight = self.field_highlight; let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); let doc = self.doc_mut(id)?; doc.edit_notices.clear(); @@ -48,6 +49,7 @@ impl Session { } cx.xfa_time_scale = xfa_time_scale; cx.pages = doc.info.pages.len(); + cx.highlight = highlight; cx.today = today; let reason = doc.read_only_reason.clone().unwrap_or_default(); let signed = doc.is_signed(); @@ -85,7 +87,7 @@ impl Session { } let mut next = editor.cos.clone(); if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); + cx.js = Some(js::JsRunner::new(&next, &name, highlight)); } cx.renamed_fields.clear(); cx.redaction_overlays.clear(); @@ -151,11 +153,13 @@ impl Session { let mut reset = false; collect(&edit, &mut changed, &mut reset); if !changed.is_empty() || reset { + let highlight = cx.highlight(); let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, + highlight, time_scale: cx.xfa_time_scale, }; // Every changed field's scripts, then the calculations once (after a reset, @@ -164,6 +168,9 @@ impl Session { .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; + // The next edit of a batch reads the highlight this one's scripts set. + let on = run.highlight; + cx.set_highlight(on); if let Err(e) = ran { cx.xfa_out.errors.push(e); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 28976be9fa..94867f98e5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6563,7 +6563,7 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { let mut cos = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap(); pdfcraft_forms::set_document_script(&mut cos, "helper", Some("function twice(n) { return n * 2; } app.alert('PDF message');")).unwrap(); // The raw dynamic shell has not been laid out: XFA's presence alone is not enough. - let runner = js::JsRunner::new(&cos, "control.pdf"); + let runner = js::JsRunner::new(&cos, "control.pdf", false); let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default()); assert!(out.error.is_none(), "{out:?}"); assert_eq!(out.result.as_deref(), Some("6")); @@ -10469,6 +10469,8 @@ fn dynamic_calculation_reads_and_side_effects_reach_later_fields() { assert!(s.take_js_output(id).errors.is_empty()); } +/// Within one edit, a field's `display` reads as the file had it before the edit, even after a +/// script set it with the value cached; the next edit's runner reads what the script wrote. #[test] fn scripted_display_changes_survive_warm_cache_and_new_edit() { use pdfcraft_forms::{FieldEvent, Scripts}; @@ -10478,7 +10480,7 @@ fn scripted_display_changes_survive_warm_cache_and_new_edit() { let target = fields.iter().find(|f| f.name == "ranks").unwrap(); let other = fields.iter().find(|f| f.name == "trained").unwrap(); let old_display = crate::js::field_state(&cos, other).display; - let mut runner = crate::js::JsRunner::new(&cos, "synthetic.pdf"); + let mut runner = crate::js::JsRunner::new(&cos, "synthetic.pdf", false); // Populate the display cache before a script writes the same field. let warm = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields); assert_eq!(warm.value, old_display.to_string()); @@ -10498,7 +10500,7 @@ fn scripted_display_changes_survive_warm_cache_and_new_edit() { let same_edit = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields); assert_eq!(same_edit.value, old_display.to_string()); // The next edit must rebuild its cache from the newly written annotation flags. - let mut next = crate::js::JsRunner::new(&cos, "synthetic.pdf"); + let mut next = crate::js::JsRunner::new(&cos, "synthetic.pdf", false); let read = next.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &pdfcraft_forms::fields(&cos)); assert_eq!(read.value, display.to_string()); assert!(read.changes.is_empty()); @@ -10556,6 +10558,151 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); } +/// A field whose layout:ready script only reads the page count keeps the value its script gives +/// it: only a field the script gives the page call's result is numbered by the layout. +#[test] +fn a_field_that_only_reads_the_page_count_keeps_its_scripts_value() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("reads.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let value = s.get(id).unwrap().form.iter().find(|f| f.name == "reads").map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(value, vec!["pages: 1".to_string()]); +} + +/// layout:ready scripts run once on opening: the root's turns field highlighting on (only when it +/// is off), a field's sets a value and a later script reads the new highlight, while page +/// numbering is left to the layout and form:ready is another event. +#[test] +fn a_forms_layout_ready_scripts_run_once_it_is_laid_out() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::layout_ready_template())); + let mut s = Session::new(); + let id = s.open("ready.pdf", None, bytes.clone(), None).expect("opens"); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default(); + let out = s.take_js_output(id); + assert_eq!(out.requests, vec![js::Request::Highlight(true)], "the form turns field highlighting on: {out:?}"); + assert_eq!(value(&s, "stamp"), vec!["laid out".to_string()]); + assert_eq!(value(&s, "seen"), vec!["true".to_string()], "a later script reads the highlight the root's turned on"); + assert!(value(&s, "pageNo").iter().all(|v| v.is_empty()), "page numbers are the layout's: {:?}", value(&s, "pageNo")); + assert!(value(&s, "note").iter().all(|v| v.is_empty()), "form:ready is another event"); + // With highlighting already on, the form has nothing to ask for. + s.set_field_highlight(true); + let id = s.open("ready.pdf", None, bytes, None).expect("opens"); + assert!(s.take_js_output(id).requests.is_empty()); +} + +/// An edit's two script runtimes share one field highlight: a change an XFA script makes reaches +/// the field scripts' runner, and one the field scripts make is what the next XFA script reads. +#[test] +fn an_edits_xfa_and_field_scripts_share_the_field_highlight() { + let cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let mut cx = EditCtx::new(None, 0); + cx.js = Some(js::JsRunner::new(&cos, "synthetic.pdf", false)); + cx.set_highlight(true); + assert!(cx.js.as_ref().is_some_and(|js| js.doc.highlight), "an XFA script's change reaches the field scripts"); + if let Some(js) = cx.js.as_mut() { + js.doc.highlight = false; + } + assert!(!cx.highlight(), "the next XFA script reads the field scripts' change"); +} + +/// In a batch of XFA events, a later event's scripts read the field highlight an earlier one's set: +/// `on`'s click turns it on, and `look`'s click, in the same batch, records what it reads. +#[test] +fn a_later_xfa_event_in_a_batch_reads_the_highlight_an_earlier_one_set() { + let script = |js: &str| format!(r#""#); + let xdp = format!( + r##" + + + +"##, + on = script("app.runtimeHighlight = true;"), + look = script("xfa.form.form.seen.rawValue = String(app.runtimeHighlight);"), + ); + let mut s = Session::new(); + let id = s.open("batch.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&xdp)), None).expect("opens"); + let click = |som: &str| Edit::XfaEvent { som: som.into(), activity: "click".into() }; + s.apply(id, Edit::Batch { edits: vec![click("form[0].on[0]"), click("form[0].look[0]")], label: "Click".into() }).expect("clicks"); + let seen = s.get(id).unwrap().form.iter().find(|f| f.name == "seen").map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(seen, vec!["true".to_string()]); +} + +/// Field scripts of one edit read the field highlight an earlier one set: `on`'s calculation turns +/// it on, and `seen`'s, which runs after it, records what it reads. +#[test] +fn a_later_field_script_reads_the_highlight_an_earlier_one_set() { + // Fields `input` (no script), then those named in `calculated` with their calculate scripts, + // calculated in that order. + let form = |calculated: &[(&str, &str)]| { + let text = |name: &str, y: usize, aa: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {aa} >>", y + 20) + }; + let n = calculated.len(); + let refs = |from: usize| (from..from + n).map(|i| format!("{i} 0 R")).collect::>().join(" "); + let mut objs = vec![ + format!( + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R {}] /CO [{}] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv {} 0 R >> >> >> >>", + refs(5), + refs(5), + 5 + n + ), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /Annots [4 0 R {}] >>", refs(5)), + text("input", 250, ""), + ]; + for (i, (name, js)) in calculated.iter().enumerate() { + objs.push(text(name, 220 - 30 * i, &format!("/AA << /C << /S /JavaScript /JS ({js}) >> >>"))); + } + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string()); + 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()); + Arc::new(out) + }; + let seen = ("seen", "event.value = String\\(app.runtimeHighlight\\);"); + let value = |s: &Session, id: DocId, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + // `on`'s calculation turns it on, and `seen`'s, which runs after it, records what it reads. + let mut s = Session::new(); + let id = s.open("seen.pdf", None, form(&[("on", "app.runtimeHighlight = true; event.value = 'set';"), seen]), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "input".into(), value: FieldValue::Text("1".into()) }).unwrap(); + assert_eq!(value(&s, id, "on"), ["set"]); + assert_eq!(value(&s, id, "seen"), ["true"], "the later calculation reads the new setting"); + assert!(s.take_js_output(id).requests.contains(&js::Request::Highlight(true))); + // A button's script that turns it off and changes a field: the calculations it sets off read + // the new setting too. + let mut s = Session::new(); + s.set_field_highlight(true); + let id = s.open("seen.pdf", None, form(&[seen]), None).unwrap(); + let o = s.run_javascript(id, "app.runtimeHighlight = false; getField('input').value = '2';", Some("input")).unwrap(); + assert_eq!(o.requests, [js::Request::Highlight(false)]); + assert_eq!(value(&s, id, "seen"), ["false"], "the button's own change reaches the calculations"); +} + /// A date typed in another of an XFA date field's edit patterns is kept in its first: yymmdd and /// yyyymd are rewritten as yyyymmdd, and the data holds the date. Text none of them reads is kept /// as typed, in the field and the data, as another viewer keeps it. diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 4246e5b504..4495347c16 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -5,7 +5,7 @@ //! presence and access changes become overrides kept in the PDF, added or removed rows change //! the datasets, and any of those lays the form out again. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode}; @@ -128,6 +128,8 @@ pub(crate) struct XfaRun<'a> { pub datasets: Option, /// A script ran past its time limit and was abandoned: the form's scripts should stay off. pub ran_away: bool, + /// Whether the viewer highlights form fields, for `app.runtimeHighlight`. + pub highlight: bool, /// What every time limit of the scripts is multiplied by (the event's budget, the engine's /// start and each script's own): 1, unless the session was given another /// ([`crate::Session::with_xfa_time_scale`]). @@ -159,6 +161,21 @@ fn to_js(n: &FormNode) -> XfaNode { } } +/// The SOM paths of the live form's fields under `n`, at most 64 levels deep, into `out`. +fn field_soms(n: &FormNode, out: &mut HashSet, depth: usize) { + if depth > 64 { + return; + } + if n.kind == Some(NodeKind::Field) { + out.insert(n.som.clone()); + } + for c in &n.children { + field_soms(c, out, depth + 1); + } +} + +/// The subform or other container at SOM path `som` in the live form under `n` (fields are +/// skipped), searched at most 64 levels deep. fn find_container<'a>(n: &'a FormNode, som: &str, depth: usize) -> Option<&'a FormNode> { if depth > 64 { return None; @@ -275,12 +292,17 @@ struct Runner<'a, 'b> { /// rebuilt when the structure changed). session: Option, changes: Changes, + /// Fields the layout numbers (a field whose own layout:ready script puts a page number in + /// it): while layout:ready scripts run, a value written to one is dropped, so each page keeps + /// the number the layout gave it. + numbered: HashSet, /// A field embedded in rich text (a floating field) took a new value since the form was /// last laid out: the text that shows it is laid out again when the event ends. embedded_changed: bool, } impl<'a, 'b> Runner<'a, 'b> { + /// A runner for one event on `doc` and its template, its scripts sharing `budget_ms`. fn new(doc: &'a mut pdfcraft_cos::Document, tpl: &'a Template, run: &'a mut XfaRun<'b>, budget_ms: u64) -> Self { let data = pdfcraft_xfa::data_of(doc); let ov = pdfcraft_xfa::overrides(doc); @@ -307,6 +329,7 @@ impl<'a, 'b> Runner<'a, 'b> { cached: None, session: None, changes: Changes::default(), + numbered: HashSet::new(), embedded_changed: false, } } @@ -319,7 +342,7 @@ impl<'a, 'b> Runner<'a, 'b> { /// The script engine for this event. fn session(&mut self) -> &mut XfaSession { - let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count }; + let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count, highlight: self.run.highlight }; let scale = self.run.time_scale; self.session.get_or_insert_with(|| XfaSession::new(doc_info).with_time_scale(scale)) } @@ -624,7 +647,9 @@ impl<'a, 'b> Runner<'a, 'b> { } match e { XfaEffect::SetValue { som, value } => { - self.set_value(som, value); + if !self.numbered.contains(som) { + self.set_value(som, value); + } } XfaEffect::SetPresence { som, presence } => { if self.ov.presence.insert(som.clone(), presence.clone()).as_ref() != Some(presence) { @@ -672,6 +697,13 @@ impl<'a, 'b> Runner<'a, 'b> { } } XfaEffect::Beep => self.run.out.requests.push(Request::Beep), + XfaEffect::Highlight(on) => { + self.run.highlight = *on; + // The session's scripts read the setting it started with: the next script + // gets a session that reads the new one. + self.session = None; + self.run.out.requests.push(Request::Highlight(*on)); + } XfaEffect::Recalculate => {} XfaEffect::Relayout => relayout = true, } @@ -908,13 +940,34 @@ fn value_unchanged(doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, v: O } } -/// On open: initialize scripts, then calculations. Returns what they changed. +/// On open: initialize scripts, then layout:ready scripts (the form is laid out by now), then +/// calculations. Returns what they changed. +/// +/// layout:ready runs once here, where forms set themselves up (turning field highlighting on, +/// say); a viewer runs it after every layout. Every layout:ready script runs, but a field that +/// numbers pages keeps the number the layout gave each page: the value a script writes to it is +/// dropped (one value would put the same number on every page). pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result { let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS); let events: Vec = r.live().events.iter().filter(|e| e.activity == "initialize" || e.activity == "docReady").cloned().collect(); for ev in &events { r.run(ev, "")?; } + let (ready, numbered) = { + let live = r.live(); + let ready: Vec = live.events.iter().filter(|e| e.activity == "layoutReady").cloned().collect(); + // The fields, gathered in one walk rather than one per script. + let mut fields = HashSet::new(); + field_soms(&live.root, &mut fields, 0); + let numbered: HashSet = + ready.iter().filter(|e| fields.contains(&e.som) && pdfcraft_xfa::numbers_pages(&e.script, e.formcalc)).map(|e| e.som.clone()).collect(); + (ready, numbered) + }; + r.numbered = numbered; + for ev in &ready { + r.run(ev, "")?; + } + r.numbered.clear(); r.calculates()?; r.finish() } diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs index 5ceaba1cd4..10036db285 100644 --- a/crates/engine/src/xfa_form.rs +++ b/crates/engine/src/xfa_form.rs @@ -217,12 +217,22 @@ pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { /// fields take the data's values. pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { if let Some(tpl) = cx.xfa.clone() { - let mut run = - xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let highlight = cx.highlight(); + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + highlight, + time_scale: cx.xfa_time_scale, + }; let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; + // The next edit of a batch reads the highlight this one's scripts set. + let on = run.highlight; + cx.set_highlight(on); if let Err(e) = r { cx.xfa_out.errors.push(e); } diff --git a/crates/engine/tests/form_date_language.rs b/crates/engine/tests/form_date_language.rs index 1d4293e12c..6a70406cfe 100644 --- a/crates/engine/tests/form_date_language.rs +++ b/crates/engine/tests/form_date_language.rs @@ -21,7 +21,7 @@ fn names_lock() -> MutexGuard<'static, ()> { /// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads. fn printd(fmt: &str, typed: &str) -> String { - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false }; let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));"); let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); assert_eq!(o.error, None); @@ -75,7 +75,7 @@ fn form_dates_follow_the_date_language() { #[test] fn an_english_weekday_before_a_shared_month_name_reads_as_english() { let _names = names_lock(); - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false }; let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 }; let mut bad = Vec::new(); for language in ["it", "es", "ro", "sq"] { @@ -111,7 +111,7 @@ fn an_english_weekday_before_a_shared_month_name_reads_as_english() { /// letter that writes a name, through the form path and through `util.scand`. Called from the /// one test, which owns the process-wide names. fn every_date_language_reads_back_what_it_writes() { - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false }; let mut bad = Vec::new(); for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() { use_form_date_language(l.code); @@ -241,7 +241,7 @@ fn fill(doc: &mut Document, name: &str) { /// Fills `form`'s field `name` with `value` through the JavaScript runner. fn fill_with(doc: &mut Document, name: &str, value: &str) { - let mut js = JsRunner::new(doc, "form.pdf"); + let mut js = JsRunner::new(doc, "form.pdf", false); pdfcraft_forms::set_value_with(doc, name, &FieldValue::Text(value.into()), &mut js).expect("fills"); } @@ -319,7 +319,7 @@ fn an_english_run_that_prints_the_stored_value_is_drawn() { /// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`. fn scanned(fmt: &str, text: &str) -> String { - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false }; let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); assert_eq!(o.error, None); diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 4865ecf1c7..297e7604d1 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -90,6 +90,28 @@ fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::undefined()) } +/// `app.runtimeHighlight`: whether the viewer highlights form fields. +fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.highlight)) +} + +/// `app.runtimeHighlight = on`: asks the viewer to highlight form fields, or to stop. Later reads +/// in the script see the new setting. +fn set_runtime_highlight(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + let mut h = host(ctx)?.borrow_mut(); + if h.doc.highlight != on { + // Only the setting the script ends with matters: one request, the latest, under the + // host's request cap like any other. One the cap drops changes nothing, so the script + // goes on reading the setting the viewer has. + h.requests.retain(|r| !matches!(r, Request::Highlight(_))); + if h.request(Request::Highlight(on)) { + h.doc.highlight = on; + } + } + Ok(JsValue::undefined()) +} + /// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object /// with `cURL`. fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { @@ -222,7 +244,10 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { ctx.register_global_property(js_string!("xfa_installed"), JsValue::from(true), rw)?; } + let highlight_get = function(ctx, NativeFunction::from_fn_ptr(runtime_highlight)); + let highlight_set = function(ctx, NativeFunction::from_fn_ptr(set_runtime_highlight)); let app = ObjectInitializer::new(ctx) + .accessor(js_string!("runtimeHighlight"), Some(highlight_get), Some(highlight_set), rw) .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 8b3b1e6f1c..1419ba2a86 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -53,10 +53,10 @@ impl Host { /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] /// requests and the run's byte budget. - pub(crate) fn request(&mut self, r: Request) { + pub(crate) fn request(&mut self, r: Request) -> bool { if self.requests.len() >= xfa::MAX_EFFECTS { self.dropped = self.dropped.saturating_add(1); - return; + return false; } let r = match r { Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), @@ -70,9 +70,11 @@ impl Host { Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), _ => 0, }; - if self.room(bytes) { + let kept = self.room(bytes); + if kept { self.requests.push(r); } + kept } } diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 591e2f472d..d3195e6a5a 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -114,6 +114,8 @@ pub struct DocInfo { pub page: usize, /// Document information (`this.info.title` etc.): lower-case key, value. pub info: Vec<(String, String)>, + /// Whether the viewer highlights form fields (`app.runtimeHighlight`). + pub highlight: bool, /// The document has an XFA form, so scripts see `xfa_installed`. pub xfa: bool, } @@ -239,6 +241,8 @@ pub enum Request { Beep, /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, + /// `app.runtimeHighlight = on`: highlight the form's fields, or stop. + Highlight(bool), /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. ShowPanel(Panel), } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 46316f90ad..7635d69e44 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1,5 +1,7 @@ use super::*; +/// The AcroForm fields these tests' scripts read and write: prices, a quantity, a total, a ZIP +/// code, a check box and a drop-down with shown and saved items. fn fields() -> Vec { let mut agree = FieldState::new("agree", FieldType::CheckBox, vec![]); agree.options = vec![("Yes".into(), "Yes".into())]; @@ -15,10 +17,12 @@ fn fields() -> Vec { ] } +/// The document these tests' scripts run in. fn doc() -> DocInfo { - DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], xfa: false } + DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], highlight: false, xfa: false } } +/// Run AcroForm JavaScript `script` for `event` on these tests' document and fields. fn go(script: &str, event: &Event) -> Outcome { run(script, event, &doc(), &fields(), &[], Limits::default()) } @@ -626,7 +630,7 @@ mod xfa_model { /// Run the JavaScript `script` for `activity` on the test form, from the node at `target`. fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }; - let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 }; + let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3, ..Default::default() }; run_xfa(script, &ev, &doc, &form(), Limits::default()) } @@ -1049,9 +1053,10 @@ mod formcalc { use crate::xfa::*; use crate::{Limits, Message, MessageButtons, MessageIcon}; + /// Run FormCalc `script` as `target`'s `activity` script on [`form`]. fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }; - let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 }; + let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3, ..Default::default() }; run_formcalc(script, &ev, &doc, &form(), Limits::default()) } @@ -1841,6 +1846,8 @@ mod session { } } +/// Scripts run on a shared field snapshot without changing it: a script's writes and a failed +/// script's partial writes stay in its own result, and its globals don't reach the next script. #[test] fn shared_field_snapshots_isolate_writes_errors_and_script_globals() { let initial = Arc::new(fields()); @@ -1875,6 +1882,64 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() { assert!(read.changed.is_empty()); } +mod runtime_highlight { + use super::*; + use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaNode, run_xfa}; + + /// `app.runtimeHighlight` in AcroForm scripts reads the viewer's setting, and setting it asks + /// for the change once and reads back the new value. + #[test] + fn app_runtime_highlight_reads_the_viewers_setting_and_asks_to_change_it() { + let ev = Event::field("Calculate", "total", ""); + let on = DocInfo { highlight: true, ..doc() }; + let o = run("event.value = String(app.runtimeHighlight);", &ev, &on, &fields(), &[], Limits::default()); + assert_eq!((o.value.as_str(), o.requests.len()), ("true", 0)); + let o = go("var was = app.runtimeHighlight; app.runtimeHighlight = true; event.value = was + ',' + app.runtimeHighlight;", &ev); + assert_eq!(o.error, None); + assert_eq!(o.value, "false,true", "the script reads back what it set"); + assert_eq!(o.requests, vec![Request::Highlight(true)]); + // A script that toggles it over and over asks once, for where it ended. + let o = go("for (var i = 0; i < 5001; i++) { app.runtimeHighlight = !app.runtimeHighlight; }", &ev); + assert_eq!(o.error, None); + assert_eq!(o.requests, vec![Request::Highlight(true)], "one request, the last setting"); + // Past the request cap the request is dropped, and the script goes on reading the + // setting the viewer has. + let full = format!( + "for (var i = 0; i < {}; i++) app.execMenuItem('Print'); app.runtimeHighlight = true; event.value = String(app.runtimeHighlight);", + crate::xfa::MAX_EFFECTS + ); + let o = go(&full, &ev); + assert_eq!(o.error, None); + assert_eq!(o.value, "false", "the dropped request changed nothing"); + assert!(!o.requests.iter().any(|r| matches!(r, Request::Highlight(_)))); + } + + /// The same in XFA scripts, where the change is an effect; nothing is asked for when the + /// setting is already what the script wants. + #[test] + fn xfa_app_runtime_highlight_reads_the_setting_and_records_a_change() { + let ev = XfaEvent { activity: "layoutReady".into(), target: "form1[0]".into(), ..Default::default() }; + let root = XfaNode { name: "form1".into(), som: "form1[0]".into(), ..Default::default() }; + let script = "var was = app.runtimeHighlight; if (!app.runtimeHighlight) { app.runtimeHighlight = true; } was + ',' + app.runtimeHighlight"; + let o = run_xfa(script, &ev, &XfaDoc::default(), &root, Limits::default()); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("false,true")); + assert_eq!(o.effects, vec![XfaEffect::Highlight(true)]); + let o = run_xfa(script, &ev, &XfaDoc { highlight: true, ..Default::default() }, &root, Limits::default()); + assert_eq!((o.result.as_deref(), o.effects.len()), (Some("true,true"), 0), "already on: nothing to ask for"); + // Past the effect cap the request is dropped, and the script still reads the setting the + // viewer has. + let full = format!( + "for (var i = 0; i < {}; i++) {{ xfa.host.beep(); }} app.runtimeHighlight = true; String(app.runtimeHighlight)", + crate::xfa::MAX_EFFECTS + ); + let o = run_xfa(&full, &ev, &XfaDoc::default(), &root, Limits::default()); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("false")); + assert!(!o.effects.contains(&XfaEffect::Highlight(true))); + } +} + mod scand { use super::*; use crate::xfa::{XfaDoc, XfaEvent, run_xfa}; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 5ac7fec5ef..cfbade0947 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -1,10 +1,10 @@ use super::host::host; use super::instances::instance_manager; -use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, reset_names}; +use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, payload, reset_names}; use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; use super::{ - Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, js_string, platform, printd, printf, printx, s, scand, - text, + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, platform, printd, printf, printx, + s, scand, text, }; // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── @@ -15,6 +15,25 @@ fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { Ok(()) } +/// `app.runtimeHighlight`: whether the viewer highlights form fields. +fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.highlight)) +} + +/// `app.runtimeHighlight = on`: asks the viewer to highlight form fields, or to stop. +fn set_runtime_highlight(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + let mut h = host(ctx)?.borrow_mut(); + let e = XfaEffect::Highlight(on); + // Changed only when the request is kept: past the effect cap the script goes on reading the + // setting the viewer has. + if h.doc.highlight != on && h.reserve(&e, payload(&e)) { + h.doc.highlight = on; + h.record(e); + } + Ok(JsValue::undefined()) +} + /// `xfa.host.messageBox(message, title, icon, buttons)`: records the message with its title, /// icon and buttons, its text and title cut to [`MAX_LINE`] characters, and returns 1. fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { @@ -226,7 +245,10 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current ctx.register_global_property(js_string!("xfa"), xfa, rw)?; ctx.register_global_property(js_string!("event"), ev, rw)?; ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; + let highlight_get = function(ctx, NativeFunction::from_fn_ptr(runtime_highlight)); + let highlight_set = function(ctx, NativeFunction::from_fn_ptr(set_runtime_highlight)); let app = ObjectInitializer::new(ctx) + .accessor(js_string!("runtimeHighlight"), Some(highlight_get), Some(highlight_set), rw) .function(NativeFunction::from_fn_ptr(app_alert), js_string!("alert"), 4) .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs index 04ec0f8349..816753de07 100644 --- a/crates/js/src/xfa/model.rs +++ b/crates/js/src/xfa/model.rs @@ -70,6 +70,8 @@ pub struct XfaDoc { /// The current page, 0-based. pub page: usize, pub page_count: usize, + /// Whether the viewer highlights form fields (`app.runtimeHighlight`). + pub highlight: bool, } /// Something a script changed or asked for. @@ -110,6 +112,8 @@ pub enum XfaEffect { Recalculate, /// `xfa.form.remerge()`, `xfa.layout.relayout()`. Relayout, + /// `app.runtimeHighlight = on`. + Highlight(bool), } /// What running a script did. @@ -213,7 +217,13 @@ pub(super) fn payload(e: &XfaEffect) -> usize { XfaEffect::MessageBox(m) => m.bytes(), XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), - XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, + XfaEffect::Print + | XfaEffect::SaveAs + | XfaEffect::Beep + | XfaEffect::Recalculate + | XfaEffect::Relayout + | XfaEffect::Highlight(_) + | XfaEffect::ShowPanel(_) => 0, } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index fd6d2dcbc6..fb424fe5e7 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -741,6 +741,9 @@ impl PdfCraftApp { } } panes::frame(self); + // Forms' scripts read Highlight fields as `app.runtimeHighlight`. + let highlight = self.active.and_then(|i| self.views.get(i)).map_or(self.view_defaults.highlight_fields, |v| v.highlight_fields); + self.session.set_field_highlight(highlight); self.sync_titles(&ctx); // The accessible window takes the same name, so a screen reader (AT-SPI on Linux, UIA on // Windows) can tell it apart (#4614). egui builds the root node afresh every pass. diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index a86c72ef7d..889a0b8476 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -103,6 +103,8 @@ impl PdfCraftApp { self.try_open(name, path, std::sync::Arc::new(bytes), None, activate) } + /// Hand `request` to the background open worker (started on first use); the result arrives + /// later as a pending open, under the returned token. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn schedule_open(&mut self, request: OpenRequest) -> Result { if self.open_worker.is_none() { @@ -119,8 +121,10 @@ impl PdfCraftApp { if let Some(ctx) = &self.ctx { worker.set_context(ctx); } - let receiver = - worker.submit(token, self.session.open_context(), request.name.clone(), request.path.clone(), request.password, request.input)?; + // A new document opens with the remembered Highlight fields, which its scripts read; the + // session keeps the document in front's. + let context = self.session.open_context().with_highlight(self.view_defaults.highlight_fields); + let receiver = worker.submit(token, context, request.name.clone(), request.path.clone(), request.password, request.input)?; self.next_open_token = token; self.pending_opens.push(PendingOpen { token, @@ -278,6 +282,8 @@ impl PdfCraftApp { } } + /// Open `bytes` on this thread: ask for a password if the document needs one, else add it + /// as a tab (in front when `activate`). fn try_open( &mut self, name: &str, @@ -287,7 +293,10 @@ impl PdfCraftApp { activate: bool, ) -> Result<(), String> { use pdfcraft_render::OpenError; - let prepared = match self.session.prepare_open(name, path.clone(), bytes.clone(), password) { + // A new document opens with the remembered Highlight fields, which its scripts read; the + // session keeps the document in front's. + let context = self.session.open_context().with_highlight(self.view_defaults.highlight_fields); + let prepared = match context.prepare_open(name, path.clone(), bytes.clone(), password) { Ok(prepared) => prepared, Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => { let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string()); @@ -314,6 +323,8 @@ impl PdfCraftApp { self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ()) } + /// Add a prepared document as a tab: its view, the recent-files entry, and what its scripts + /// said while it opened (at once in front, or when a background tab is first selected). fn finish_open( &mut self, name: &str, @@ -400,6 +411,9 @@ impl PdfCraftApp { if activate { let out = self.session.take_js_output(id); self.handle_js(id, out); + } else if let Some(on) = self.session.take_js_highlight(id) { + // The field highlight a form asks for isn't about the current document: now. + self.script_highlight(id, on); } Ok(id) } diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index ccfc2410ee..5033fcd51c 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -82,6 +82,7 @@ impl PdfCraftApp { &[("u", &u)], ), Request::SaveAs => self.run_command("file.save_as"), + Request::Highlight(on) => self.script_highlight(id, on), Request::ShowPanel(p) => { let panel = match p { Panel::Bookmarks => crate::RightPanel::Bookmarks, @@ -113,6 +114,16 @@ impl PdfCraftApp { } } + /// A form's script turned field highlighting on or off (`app.runtimeHighlight`): as the + /// Highlight fields button does, for document `id` and the next ones. + pub(crate) fn script_highlight(&mut self, id: DocId, on: bool) { + if let Some(v) = self.views.iter_mut().find(|v| v.id == id) { + v.highlight_fields = on; + } + self.view_defaults.highlight_fields = on; + self.session.set_field_highlight(on); + } + /// Put the next waiting script message in the toast, or, once none waits, a notice of how many /// the queue had no room for. False when there is neither. pub fn next_script_message(&mut self) -> bool { diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index 0da954dd4c..7324307e57 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -944,6 +944,24 @@ fn a_background_startup_forms_script_notice_waits_until_its_tab_is_selected() { assert!(h.state().toast.is_none(), "the notice is delivered once"); } +/// A form opened behind the current document that turns field highlighting on gets it at +/// once, and so does the next document: only requests that act on the current document (a +/// print, a save, a message) wait for its tab. +#[test] +fn a_background_forms_highlight_request_applies_at_once() { + let mut h = harness(&[3]); + // Highlighting is on by default: start from a user who turned it off. + h.state_mut().view_defaults.highlight_fields = false; + h.state_mut().views[0].highlight_fields = false; + h.run_steps(1); + deliver_startup(&h, "late-form.pdf", pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::layout_ready_template())); + h.run_steps(2); + assert_eq!(h.state().active, Some(0), "it opened behind the current document"); + assert!(h.state().views[1].highlight_fields, "the background form is highlighted"); + assert!(!h.state().views[0].highlight_fields, "the current document is left as it was"); + assert!(h.state().view_defaults.highlight_fields, "the next document opens highlighted"); +} + /// Embedded bytes and the PDF's original /UF name stay independent of the Save suggestion. fn attachment_fixture() -> Vec { attachment_pdf("invoice\u{202e}fdp.exe", b"hello notes") diff --git a/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index 3b355d0bee..de01dfb82a 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -210,6 +210,8 @@ fn scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back() { assert!(!says_scripts_are_off(&h.state().toast), "{:?}", h.state().toast); } +/// On an encrypted dynamic XFA form that allows filling, the notice bar shows the layout note and +/// the security sentence together, with both the field-highlight and Security settings buttons. #[test] fn a_secured_dynamic_xfa_form_shows_its_layout_and_its_security() { // Changing and assembling withheld, filling allowed. @@ -228,3 +230,51 @@ fn a_secured_dynamic_xfa_form_shows_its_layout_and_its_security() { h.get_by_label("Hide field highlights"); h.get_by_label("Security settings"); } + +/// Opening another document doesn't change the field highlight the document in front's scripts +/// read: the new one opens with the remembered Highlight fields in its own open context. Before, +/// the open set the session's value, so an edit of the first document handled before the next +/// frame read the new document's default as `app.runtimeHighlight`. +#[test] +fn opening_another_document_keeps_the_highlight_the_one_in_front_reads() { + let script = |js: &str| format!(r#""#); + let xdp = format!( + r##" + + + +"##, + look = script("xfa.form.form.seen.rawValue = String(app.runtimeHighlight);"), + ); + let mut h = open(pdfcraft_xfa::fixtures::shell(&xdp)); + let app = h.state_mut(); + // The document in front has Highlight fields on; the remembered setting is off. + app.view_defaults.highlight_fields = false; + app.views[0].highlight_fields = true; + h.run_steps(2); + let app = h.state_mut(); + let first = app.views[0].id; + app.open_bytes("next.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(1))).unwrap(); + // Before the next frame, the first document's script reads its own setting. + app.session.apply(first, pdfcraft_engine::Edit::XfaEvent { som: "form[0].look[0]".into(), activity: "click".into() }).unwrap(); + let seen = app.session.get(first).unwrap().form.iter().find(|f| f.name == "seen").map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(seen, vec!["true".to_string()], "the first document's script reads its own Highlight fields"); +} + +/// A form that turns field highlighting on (`app.runtimeHighlight = true` in a `layout:ready` +/// script) gets it, and so does the next document, as with the Highlight fields button. +#[test] +fn a_form_that_turns_field_highlighting_on_gets_it() { + let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::layout_ready_template())); + h.run_steps(4); + assert!(h.state().views[0].highlight_fields, "the form's fields are highlighted"); + h.state_mut().open_bytes("next.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(1))).unwrap(); + h.run_steps(4); + assert!(h.state().views[1].highlight_fields, "the next document opens highlighted"); +} diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index d2a67fccf8..4b28c494fc 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -201,6 +201,30 @@ fn assemble(objs: Vec>) -> Vec { out } +/// A form with `layout:ready` scripts: the root subform reads the page count and turns field +/// highlighting on (as forms do with `app.runtimeHighlight`), `stamp` takes a value, `seen` records the highlight its script +/// reads after the root's, and `pageNo` numbers its page (left to the layout). `note` has a +/// `form:ready` script, a different event. +pub fn layout_ready_template() -> String { + r##" + + + +"## + .to_string() +} + /// A hidden subform `details` a script may show, holding a named field and three unnamed /// objects: a caption, an old caption the form keeps hidden, and a hidden subform. Unnamed /// objects have no SOM name of their own, so showing `details` must leave the hidden ones hidden. diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 919b053606..b9216a40bf 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -99,7 +99,7 @@ pub use data::{ }; pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout}; pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; -pub use parse::{measure, parse}; +pub use parse::{measure, numbers_pages, parse}; pub use pdf::{CLICK_KEY, DATE_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; pub use picture::typed_date; pub use script::{ diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 7f8a080be4..ac8639f98f 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -579,6 +579,9 @@ fn parse_events(n: XmlNode) -> Vec + + + + + + + +"##; + let (tpl, _) = parse(xdp).unwrap(); + assert_eq!(tpl.page_roles, vec![("no".to_string(), PageRole::Number)]); +} + +/// A `layout:ready` script is an activity of its own (`layoutReady`), told apart from `form:ready` +/// (`ready`), in the template and in the live form's events. +#[test] +fn a_layout_ready_event_is_told_apart_from_form_ready() { + let (tpl, _) = parse(&crate::fixtures::layout_ready_template()).unwrap(); + assert_eq!(tpl.root.scripts.iter().map(|s| s.activity.as_str()).collect::>(), ["layoutReady"]); + let Node::Subform(page) = &tpl.root.children[0] else { panic!() }; + let activity = |name: &str| match page.children.iter().find(|n| n.common().name.as_deref() == Some(name)) { + Some(Node::Field(f)) => f.scripts.iter().map(|s| s.activity.clone()).collect::>(), + _ => panic!("no field {name}"), + }; + assert_eq!(activity("stamp"), ["layoutReady"]); + assert_eq!(activity("note"), ["ready"], "form:ready keeps its name"); + // The live form's events carry the same names. + let live = form_tree(&tpl, None, &Overrides::default()); + assert!(live.events.iter().any(|e| e.som == "form[0]" && e.activity == "layoutReady"), "{:?}", live.events); +} + +/// A script numbers its own field's page when it gives that field a layout page call's result +/// (`page`, `absPage`, `pageCount`): read from its code, not a comment or a string, by whole +/// names, over several lines, and in bounded time for a hostile script. Page numbering is what +/// the layout does itself. +#[test] +fn a_script_that_gives_its_field_a_page_call_numbers_it() { + let js = |s: &str| crate::numbers_pages(s, false); + let formcalc = |s: &str| crate::numbers_pages(s, true); + assert!(js("this.rawValue = xfa.layout.page(this);")); + assert!(js("this.rawValue = xfa.layout.pageCount();")); + assert!(!js("this.rawValue = \"laid out\";")); + // Only the code counts: a mention in a comment or a string is not a page number. + assert!(!js("// xfa.layout.pageCount() was here\nthis.rawValue = \"ready\";")); + assert!(!js("/* xfa.layout.page(this) */ this.rawValue = 'see pageCount';")); + assert!(!js("this.rawValue = \"it's \\\"pageCount\\\"\";")); + assert!(js("var s = 'pageCount'; this.rawValue = xfa.layout.pageCount();"), "a string before the call"); + assert!(formcalc("$ = xfa.layout.pageCount()")); + assert!(!formcalc("; xfa.layout.pageCount()\n$ = \"pageCount \"\" here\"")); + assert!(formcalc("$ = \"a;b\" ; not a comment's end\n$ = xfa.layout.page($)"), "a ; inside a string isn't a comment"); + // Only a script that gives its own field the page call's result numbers that field. + assert!(!js("other.rawValue = xfa.layout.pageCount(); this.rawValue = 'x';"), "it numbers another field"); + assert!(!js("var n = xfa.layout.pageCount(); this.rawValue = 'pages: ' + n;"), "it only reads the count"); + assert!(!js("if (this.rawValue == xfa.layout.page(this)) { }"), "a comparison, not an assignment"); + assert!(formcalc("$.rawValue = xfa.layout.page($)")); + // The calls are matched as whole names, spaces allowed before the parenthesis. + assert!(!js("var pageCounter = 1; this.rawValue = pageCounter;")); + assert!(!js("this.rawValue = mylayout.page(this);")); + assert!(js("this.rawValue = xfa.layout.page (this);")); + assert!(js("this.rawValue = xfa . layout . absPage(this);")); + // An assignment over several lines is read whole; the next statement is not part of it. + assert!(js("this.rawValue =\n xfa.layout.page(this);")); + assert!(js("this.rawValue = 'Page ' +\n xfa.layout.page(this)")); + assert!(!js("this.rawValue = 'x'\nother.rawValue = xfa.layout.page(this)"), "the next line is another statement"); + assert!(js("if (true) { this.rawValue = xfa.layout.page(this) }")); + assert!(!js("that.this.rawValue = xfa.layout.page(this);"), "not the script's own field"); + assert!(formcalc("$ =\n xfa.layout.page($)")); + assert!(!formcalc("$record.x = xfa.layout.page($)"), "not the field"); + // A hostile script (a bracket left open, then a name repeated many times) is read in bounded + // time: each expression stops at its length cap, and only so many places are looked at. + let hostile = "$ = (\n".repeat(200_000); + let started = std::time::Instant::now(); + assert!(!formcalc(&hostile)); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed()); +} + /// `typed_date` rewrites a date typed by another of a field's edit-picture patterns in the first /// one, and leaves alone a date the first pattern reads, text no pattern reads, a field with one /// pattern and a field that isn't a date. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 44e56fbf01..cf77e8caa3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4614,8 +4614,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/it/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/it/view.rs::field_highlighting_is_on_by_default_and_remembered", "crates/ui-egui/tests/it/view.rs::stored_field_highlight_choice_wins_over_the_default", "crates/ui-egui/tests/it/view.rs::highlight_fields_leaves_read_only_fields_alone", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/view.rs::round_check_boxes_get_a_round_highlight"] -notes = "only fields that can be filled are marked (a read-only field is left as drawn, so a form's locked boxes don't look fillable); offered on secured forms too; on by default for new settings (a stored choice is kept) and remembered for the next document and session; toggled from the form bar and from Preferences > Appearance, where it sets the default for documents opened afterwards; required fields get a red border (the tint and border round for radio buttons and for check boxes laid out for a round XFA check button); the tint is drawn over the field, where Acrobat paints the highlight colour behind it" +evidence = ["crates/ui-egui/tests/it/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/it/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/it/view.rs::field_highlighting_is_on_by_default_and_remembered", "crates/ui-egui/tests/it/view.rs::stored_field_highlight_choice_wins_over_the_default", "crates/ui-egui/tests/it/view.rs::highlight_fields_leaves_read_only_fields_alone", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/xfa.rs::a_form_that_turns_field_highlighting_on_gets_it", "crates/engine/src/tests.rs::a_forms_layout_ready_scripts_run_once_it_is_laid_out", "crates/ui-egui/tests/it/view.rs::round_check_boxes_get_a_round_highlight"] +notes = "a form's scripts read and set it as app.runtimeHighlight (XFA forms usually in layout:ready, which runs once on opening), and a setting they make carries over as the button's does; only fields that can be filled are marked (a read-only field is left as drawn, so a form's locked boxes don't look fillable); offered on secured forms too; on by default for new settings (a stored choice is kept) and remembered for the next document and session; toggled from the form bar and from Preferences > Appearance, where it sets the default for documents opened afterwards; required fields get a red border (the tint and border round for radio buttons and for check boxes laid out for a round XFA check button); the tint is drawn over the field, where Acrobat paints the highlight colour behind it" [[feature]] id = "form.field-appearance-display"