mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-14 23:03:27 +00:00
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:
commit
7976fff3c7
26 files changed
+844
-61
No files matched your search
@@ -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() }),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user