Merge #3589: Forms: app.runtimeHighlight, and XFA layout:ready scripts run on opening

# Conflicts:
#	crates/ui-egui/src/app.rs
#	crates/xfa/src/lib.rs
This commit is contained in:
bflatastic committed 2026-11-09 18:16:03 +07:00
commit 7976fff3c7
26 files changed
+844 -61

No files matched your search

+1
View File
@@ -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() }),
}
+32 -2
View File
@@ -226,6 +226,8 @@ pub(crate) struct EditCtx {
pub(crate) xfa_removed: Vec<String>,
/// 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<String>,
/// 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<i64>, 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 } => {
+51 -5
View File
@@ -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<bool> {
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<bool> {
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 });
+13
View File
@@ -18,6 +18,7 @@ pub struct OpenContext {
now: Option<i64>,
js_off: bool,
trust: Arc<TrustStore>,
highlight: bool,
captions: Arc<StatusCaptions>,
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);
+3
View File
@@ -45,6 +45,9 @@ pub struct Session {
pub(crate) date_format: Option<String>,
/// Preferences ▸ Date format ▸ Language, when not following the interface language.
pub(crate) date_language: Option<String>,
/// 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<u32>,
+8 -1
View File
@@ -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);
}
+150 -3
View File
@@ -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##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<field name="reads" w="2in" h="0.3in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script contentType="application/x-javascript">var n = xfa.layout.pageCount(); this.rawValue = "pages: " + n;</script></event></field>
</subform>
</template>
</xdp:xdp>
"##;
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#"<event activity="click"><script contentType="application/x-javascript">{js}</script></event>"#);
let xdp = format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<field name="on" w="1in" h="0.3in"><ui><button/></ui>{on}</field>
<field name="look" w="1in" h="0.3in"><ui><button/></ui>{look}</field>
<field name="seen" w="2in" h="0.3in"><ui><textEdit/></ui></field>
</subform>
</template>
</xdp:xdp>
"##,
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::<Vec<_>>().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.
+57 -4
View File
@@ -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<pdfcraft_cos::ObjRef>,
/// 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<String>, 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<XfaSession>,
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<String>,
/// 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<Changes, String> {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
let events: Vec<ScriptEvent> = 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<ScriptEvent> = 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<String> =
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()
}
+12 -2
View File
@@ -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);
}
+5 -5
View File
@@ -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);
+25
View File
@@ -90,6 +90,28 @@ fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
Ok(JsValue::undefined())
}
/// `app.runtimeHighlight`: whether the viewer highlights form fields.
fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
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<JsValue> {
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<JsValue> {
@@ -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)
+5 -3
View File
@@ -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
}
}
+4
View File
@@ -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),
}
+68 -3
View File
@@ -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<FieldState> {
let mut agree = FieldState::new("agree", FieldType::CheckBox, vec![]);
agree.options = vec![("Yes".into(), "Yes".into())];
@@ -15,10 +17,12 @@ fn fields() -> Vec<FieldState> {
]
}
/// 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};
+25 -3
View File
@@ -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<JsValue> {
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<JsValue> {
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<JsValue> {
@@ -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)
+11 -1
View File
@@ -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::<String>())),
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,
}
}
+3
View File
@@ -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.
+17 -3
View File
@@ -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<u64, String> {
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)
}
+11
View File
@@ -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 {
+18
View File
@@ -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<u8> {
attachment_pdf("invoice\u{202e}fdp.exe", b"hello notes")
+50
View File
@@ -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#"<event activity="click"><script contentType="application/x-javascript">{js}</script></event>"#);
let xdp = format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<field name="look" w="1in" h="0.3in"><ui><button/></ui>{look}</field>
<field name="seen" w="2in" h="0.3in"><ui><textEdit/></ui></field>
</subform>
</template>
</xdp:xdp>
"##,
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");
}
+24
View File
@@ -201,6 +201,30 @@ fn assemble(objs: Vec<Vec<u8>>) -> Vec<u8> {
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##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<event activity="ready" ref="$layout"><script contentType="application/x-javascript">var pages = xfa.layout.pageCount(); if (!app.runtimeHighlight) { app.runtimeHighlight = true; }</script></event>
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<subform name="page" layout="tb">
<field name="stamp" w="2in" h="0.3in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = "laid out";</script></event></field>
<field name="seen" w="2in" h="0.3in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = String(app.runtimeHighlight);</script></event></field>
<field name="pageNo" w="1in" h="0.3in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.page(this);</script></event></field>
<field name="note" w="2in" h="0.3in"><ui><textEdit/></ui><event activity="ready" ref="$form"><script contentType="application/x-javascript">this.rawValue = "form ready";</script></event></field>
</subform>
</subform>
</template>
</xdp:xdp>
"##
.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.
+1 -1
View File
@@ -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::{
+162 -23
View File
@@ -579,6 +579,9 @@ fn parse_events(n: XmlNode) -> Vec<Script> {
for ev in children(n, "event").take(64) {
if let Some(s) = child(ev, "script") {
let activity = ev.attribute("activity").unwrap_or("click");
// `ready` is two events, told apart by `ref`: layout:ready (`$layout`, once the form
// is laid out) and form:ready (`$form`).
let activity = if activity == "ready" && ev.attribute("ref").is_some_and(|r| r.trim() == "$layout") { "layoutReady" } else { activity };
scripts.push(parse_script(s, activity));
}
}
@@ -709,29 +712,36 @@ fn parse_node(c: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Option<No
})
}
/// Which page-number role a field with a layout:ready script plays, if any. On screen every
/// page is a sheet of its own, so the sheet count is the page count, and the sheet number a form
/// shows (`sheet(this) + 1`, the sheet counted from 0) is the page number.
fn page_role(f: &Field) -> Option<PageRole> {
// Any of its ready scripts: a field may have one that does something else first.
f.scripts.iter().filter(|s| s.activity == "ready" || s.activity == "layoutReady").find_map(|script| {
// FormCalc names methods case-insensitively; JavaScript does not.
let text: std::borrow::Cow<str> =
if script.formcalc { std::borrow::Cow::Owned(script.text.to_ascii_lowercase()) } else { std::borrow::Cow::Borrowed(&script.text) };
let has = |name: &str| if script.formcalc { text.contains(&name.to_ascii_lowercase()) } else { text.contains(name) };
// `this` in JavaScript, `$` in FormCalc.
let sheet_number = || {
let compact: String = text.chars().filter(|c| !c.is_whitespace()).collect();
compact.contains("layout.sheet(this)+1") || compact.contains("layout.sheet($)+1")
};
if has("pageCount") || has("sheetCount") {
Some(PageRole::Count)
} else if has("layout.page(") || has("layout.absPage(") || sheet_number() {
Some(PageRole::Number)
} else {
None
}
})
/// Whether a `layout:ready` script numbers its own field: assigns it (`this.rawValue = …`, and in
/// FormCalc `$ = …` or `$.rawValue = …`) an expression that calls `xfa.layout.page(…)`,
/// `absPage(…)` or `pageCount()`. Such a field is one the layout numbers itself, page by page.
/// Its code is read (JavaScript, or FormCalc when `formcalc`), not its comments and strings, and
/// the calls are matched as whole names, with or without spaces before the parenthesis.
pub fn numbers_pages(script: &str, formcalc: bool) -> bool {
own_page_role(script, formcalc).is_some()
}
/// The page-number role a `layout:ready` script gives its own field ([`numbers_pages`]): the page
/// count when what it assigns calls `pageCount()`, else the page number.
fn own_page_role(script: &str, formcalc: bool) -> Option<PageRole> {
let code = code_of(script, formcalc);
// FormCalc names methods case-insensitively; JavaScript does not.
let own: Vec<String> =
own_assignments(&code, formcalc).into_iter().map(|rhs| if formcalc { rhs.to_ascii_lowercase() } else { rhs.to_string() }).collect();
let name = |n: &str| if formcalc { n.to_ascii_lowercase() } else { n.to_string() };
// On screen every page is a sheet of its own, so the sheet count is the page count, and the
// sheet number a form shows (`sheet(this) + 1`, the sheet counted from 0) is the page number.
let sheet_number = |rhs: &str| {
let compact: String = rhs.chars().filter(|c| !c.is_whitespace()).collect();
compact.contains("layout.sheet(this)+1") || compact.contains("layout.sheet($)+1")
};
if own.iter().any(|rhs| calls_layout(rhs, &name("pageCount")) || calls_layout(rhs, &name("sheetCount"))) {
Some(PageRole::Count)
} else if own.iter().any(|rhs| calls_layout(rhs, &name("page")) || calls_layout(rhs, &name("absPage")) || sheet_number(rhs)) {
Some(PageRole::Number)
} else {
None
}
}
/// The ids rich text in `nodes` embeds (draws' values, fields' values and captions), down to
@@ -769,6 +779,135 @@ fn collect_embedded(nodes: &[Node], out: &mut std::collections::BTreeSet<String>
}
}
/// Most places a name is looked at as an assignment to the script's own field ([`own_assignments`]).
const MAX_ASSIGNMENTS: usize = 256;
/// Most bytes of an assignment's expression read ([`expression`]); a page-number expression is short.
const MAX_EXPRESSION: usize = 4096;
/// What the statements of `code` assign to the script's own field: the expression after `=` of
/// each `this.rawValue = …` (in FormCalc also `$.rawValue = …` and `$ = …`) that starts a
/// statement. The expression runs to a `;` (JavaScript) or to the end of a line that completes it:
/// brackets closed and not ending in an operator, so `this.rawValue =` with its value on the next
/// line, or a sum broken over lines, is read whole.
fn own_assignments(code: &str, formcalc: bool) -> Vec<&str> {
let targets: &[&str] = if formcalc { &["this.rawValue", "$.rawValue", "$"] } else { &["this.rawValue"] };
let ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$' || c == '.';
let mut out = Vec::new();
for t in targets {
// At most MAX_ASSIGNMENTS places looked at per name, each expression read at most
// MAX_EXPRESSION bytes: a hostile script can't make this scan quadratic.
for (i, _) in code.match_indices(t).take(MAX_ASSIGNMENTS) {
// A statement's start: nothing before it on its line but spaces, or a `;`, `{` or `}`.
let before = code.get(..i).unwrap_or_default().trim_end_matches([' ', '\t']);
if !(before.is_empty() || before.ends_with(['\n', '\r', ';', '{', '}'])) {
continue;
}
let Some(rest) = code.get(i + t.len()..) else { continue };
// `$` is the whole name (not `$record`, not `$.rawValue`, a target of its own).
if rest.chars().next().is_some_and(ident) {
continue;
}
let Some(rhs) = rest.trim_start().strip_prefix('=').filter(|r| !r.starts_with('=')) else { continue };
out.push(expression(rhs, formcalc));
}
}
out
}
/// The expression at the start of `rhs` (what follows an `=`): to a `;` outside brackets in
/// JavaScript, or to a line break once something is written, the brackets are closed and the
/// line doesn't end in an operator that continues it.
fn expression(rhs: &str, formcalc: bool) -> &str {
// Read no further than MAX_EXPRESSION bytes (cut at a character boundary).
let cap = (0..=MAX_EXPRESSION.min(rhs.len())).rev().find(|&n| rhs.is_char_boundary(n)).unwrap_or(0);
let rhs = rhs.get(..cap).unwrap_or_default();
let mut depth = 0i32;
let mut last: Option<char> = None;
for (j, c) in rhs.char_indices() {
match c {
'(' | '[' | '{' => depth = depth.saturating_add(1),
')' | ']' | '}' => depth = depth.saturating_sub(1),
';' if !formcalc && depth <= 0 => return rhs.get(..j).unwrap_or(rhs),
'\n' if depth <= 0 && last.is_some_and(|l| !"+-*/%=,(&|?:.<>!".contains(l)) => return rhs.get(..j).unwrap_or(rhs),
_ => {}
}
if !c.is_whitespace() {
last = Some(c);
}
}
rhs
}
/// Whether `code` calls `layout.<method>(…)`: the method's whole name after `layout.` (spaces
/// allowed around the dot), then `(` after any spaces.
fn calls_layout(code: &str, method: &str) -> bool {
let ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
code.match_indices(method).any(|(i, _)| {
let before = code.get(..i).unwrap_or_default().trim_end();
let after = code.get(i + method.len()..).unwrap_or_default();
!after.chars().next().is_some_and(ident)
&& after.trim_start().starts_with('(')
&& before
.strip_suffix('.')
.map(str::trim_end)
.and_then(|b| b.strip_suffix("layout"))
.is_some_and(|rest| !rest.chars().next_back().is_some_and(ident))
})
}
/// `script` with its comments blanked out and each string literal left as a bare `"`: `//` and
/// `/* */` comments and `'…'`, `"…"` and `` `…` `` strings (with `\` escapes) in JavaScript; `;`
/// and `//` comments and `"…"` strings (a doubled `""` inside) in FormCalc. Line breaks stay. An
/// unclosed comment or string runs to the end.
fn code_of(script: &str, formcalc: bool) -> String {
let mut out = String::with_capacity(script.len());
let mut chars = script.chars().peekable();
while let Some(c) = chars.next() {
let line_comment = (c == '/' && chars.peek() == Some(&'/')) || (formcalc && c == ';');
if line_comment {
for n in chars.by_ref() {
if n == '\n' {
out.push('\n');
break;
}
}
} else if !formcalc && c == '/' && chars.peek() == Some(&'*') {
chars.next();
let mut star = false;
for n in chars.by_ref() {
if star && n == '/' {
break;
}
star = n == '*';
}
out.push(' ');
} else if c == '"' || (!formcalc && (c == '\'' || c == '`')) {
while let Some(n) = chars.next() {
if !formcalc && n == '\\' {
chars.next();
} else if n == c {
if formcalc && chars.peek() == Some(&c) {
chars.next();
} else {
break;
}
}
}
// A mark where the string was: the expression it ends is still seen to end there.
out.push('"');
} else {
out.push(c);
}
}
out
}
/// Which page-number role a field with a layout:ready script plays, if any.
fn page_role(f: &Field) -> Option<PageRole> {
// Any of its layout:ready scripts, past a form:ready one.
f.scripts.iter().filter(|s| s.activity == "layoutReady").find_map(|s| own_page_role(&s.text, s.formcalc))
}
fn collect_page_roles(nodes: &[Node], out: &mut Vec<(String, PageRole)>, depth: usize) {
if depth > MAX_DEPTH {
return;
+86
View File
@@ -4206,6 +4206,92 @@ fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() {
assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6);
}
/// `ready` is two events: `ref="$layout"` is layout:ready (`layoutReady`), anything else stays
/// form:ready (`ready`), in the template and in the live form; page numbering is recognized.
/// A field's page-number role comes from its layout:ready scripts, also when a form:ready script
/// comes first; a field whose layout:ready script only reads the count gets none.
#[test]
fn page_roles_come_from_layout_ready_scripts() {
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<pageSet><pageArea name="p"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/>
<field name="no" id="no" w="1in" h="0.3in"><ui><textEdit/></ui>
<event activity="ready" ref="$form"><script contentType="application/x-javascript">this.access = "readOnly";</script></event>
<event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.page(this);</script></event>
</field>
<field name="reads" id="reads" w="1in" h="0.3in"><ui><textEdit/></ui>
<event activity="ready" ref="$layout"><script contentType="application/x-javascript">var n = xfa.layout.pageCount(); this.rawValue = "pages: " + n;</script></event>
</field>
</pageArea></pageSet>
<field name="body" w="1in" h="0.3in"><ui><textEdit/></ui></field>
</subform></template></xdp:xdp>"##;
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::<Vec<_>>(), ["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::<Vec<_>>(),
_ => 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.
+2 -2
View File
@@ -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"