mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 10:04:03 +00:00
Merge pull request #6716 from storytold/merge/xfa2
Land XFA layout, scripting and form fixes (#3589, #4336, #4359, #4517, #4537, #4608, #4662, #4664, #4784, #4791, #5010, #5122, #5124, #5153, #5225, #5368, #5369, #5370, #5393)
This commit is contained in:
@@ -9,7 +9,7 @@ use std::collections::BTreeSet;
|
||||
use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic};
|
||||
|
||||
mod documents;
|
||||
mod form_tools;
|
||||
pub(crate) mod form_tools;
|
||||
mod ocr;
|
||||
|
||||
fn status_id(s: Status) -> &'static str {
|
||||
|
||||
@@ -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() }),
|
||||
}
|
||||
@@ -19,7 +20,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
|
||||
/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none),
|
||||
/// icon and buttons.
|
||||
fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
|
||||
pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
|
||||
json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
|
||||
}
|
||||
|
||||
@@ -47,6 +48,8 @@ impl Automation {
|
||||
Ok(json!({ "scripts": list }))
|
||||
}
|
||||
|
||||
/// `js_set_document_script`: set the document-level script `name` to `script`, or remove it
|
||||
/// when `script` is left out, as one undoable edit; returns the document's scripts.
|
||||
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
|
||||
let id = self.doc(a)?.id;
|
||||
@@ -54,6 +57,8 @@ impl Automation {
|
||||
Ok(with_warnings(self.js_document_scripts(a)?, warnings))
|
||||
}
|
||||
|
||||
/// `form_set_script`: set field `field`'s JavaScript for `event` to `script`, or remove it when
|
||||
/// `script` is left out, as one undoable edit; returns the console output and errors.
|
||||
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetFieldScript {
|
||||
name: a.str("field")?.into(),
|
||||
@@ -65,6 +70,37 @@ impl Automation {
|
||||
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
|
||||
}
|
||||
|
||||
/// `form_focus`: field `field` (its 1-based `widget`) gains (`enter`) or loses (`exit`)
|
||||
/// focus, running its XFA scripts for that event; returns whether the form changed and what
|
||||
/// the scripts showed or asked for.
|
||||
pub(crate) fn form_focus(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::js::FieldFocus;
|
||||
let focus = match a.str("event")? {
|
||||
"enter" => FieldFocus::Enter,
|
||||
"exit" => FieldFocus::Exit,
|
||||
other => return Err(ToolError::InvalidArgs(format!("event must be enter or exit, not {other:?}"))),
|
||||
};
|
||||
let name = a.str("field")?;
|
||||
let doc = self.doc(a)?;
|
||||
let field = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?;
|
||||
// A field with no widget (one only scripts or calculations use) has nothing to focus.
|
||||
let widgets = field.widgets.len();
|
||||
let widget = a.opt_int("widget")?.unwrap_or(1);
|
||||
let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets {
|
||||
0 => ToolError::InvalidArgs(format!("{name:?} has no widget to focus")),
|
||||
n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")),
|
||||
})?;
|
||||
let id = doc.id;
|
||||
let changed = self.session.field_focus(id, name, widget, focus).map_err(failed)?;
|
||||
let out = self.session.take_js_output(id);
|
||||
let reqs: Vec<Value> = out.requests.iter().map(request_json).collect();
|
||||
Ok(
|
||||
json!({ "changed": changed, "alerts": out.alerts.iter().map(alert_json).collect::<Vec<_>>(), "console": out.console, "requests": reqs, "errors": out.errors }),
|
||||
)
|
||||
}
|
||||
|
||||
/// `form_merge_data`: merge the form data files at `paths` into one CSV at `path`, a row per
|
||||
/// file; returns the path and the row and column counts.
|
||||
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
|
||||
let mut files = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
|
||||
@@ -709,6 +709,7 @@ impl Automation {
|
||||
}
|
||||
"form_fields" => self.form_fields(&a)?,
|
||||
"form_fill" => self.form_fill(&a)?,
|
||||
"form_focus" => self.form_focus(&a)?,
|
||||
"form_set_image" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
|
||||
@@ -208,6 +208,24 @@ fn kind_name(k: FormFieldKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A JSON number as the radio option it names: its integer spelling when it is a whole number
|
||||
/// (`1`, `1.0` and `1e0` are all `1`; `-0` is `0`), otherwise JSON's own spelling (`1.5`), which
|
||||
/// names only an option exported as exactly that text. A whole number at or past 2^53, where a
|
||||
/// float no longer holds every integer, keeps JSON's spelling too (`1e300`), so it names no option
|
||||
/// by accident.
|
||||
fn number_option(n: &serde_json::Number) -> String {
|
||||
// Integers as parsed (u64, i64) print exactly.
|
||||
if n.is_u64() || n.is_i64() {
|
||||
return n.to_string();
|
||||
}
|
||||
const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53
|
||||
match n.as_f64() {
|
||||
// Finite, whole and below 2^53 in magnitude, so the cast is exact and in range.
|
||||
Some(x) if x.is_finite() && x.fract() == 0.0 && x.abs() < EXACT => (x as i64).to_string(),
|
||||
_ => n.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A JSON value → the value for this field (booleans for check boxes, arrays for lists…).
|
||||
fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
|
||||
let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name));
|
||||
@@ -224,7 +242,24 @@ fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
|
||||
FieldValue::Widget(i)
|
||||
}
|
||||
(FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None),
|
||||
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())),
|
||||
// An option as `form_fields` lists it (an export value) names its button's on state. The
|
||||
// export values are matched first and the on-state names after them
|
||||
// (`Field::state_for_export`): with `/Opt [(1) (2)]` and states `/0` `/1`, `1` is the first
|
||||
// button, not the second one's state. A value matching neither is refused when written,
|
||||
// with the options listed.
|
||||
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(f.state_for_export(s).unwrap_or(s).to_owned())),
|
||||
// A number is the same option written without quotes (`1` for the export value `1`).
|
||||
// Below 2^53 it is compared as a number with the export values first, so `1.0` names the button
|
||||
// exported as `1.0` (`/Opt [(1.0) (2.0)]`, states `/0 /1`) and not the one whose state is
|
||||
// `/1`; then as text, as a string would be.
|
||||
(FormFieldKind::Radio, Value::Number(n)) => {
|
||||
if let Some(state) = n.as_f64().and_then(|x| f.state_for_number(x)) {
|
||||
FieldValue::Radio(Some(state.to_owned()))
|
||||
} else {
|
||||
let s = number_option(n);
|
||||
FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned)))
|
||||
}
|
||||
}
|
||||
(FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice(
|
||||
a.iter().map(|x| x.as_str().map(str::to_owned).ok_or_else(|| wrong("list values must be strings"))).collect::<Result<_>>()?,
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! document, and manage trusted certificates. Rectangles are points from the top-left of the
|
||||
//! displayed page.
|
||||
|
||||
use pdfcraft_engine::js::SignEvent;
|
||||
use pdfcraft_engine::sign::{self, Certificate, DigitalId, Modification, Name, PrivateKey};
|
||||
use pdfcraft_engine::{SignOptions, SignatureInfo, SignatureStatus};
|
||||
use serde_json::{Value, json};
|
||||
@@ -175,6 +176,8 @@ impl Automation {
|
||||
Ok(json!({ "path": path.to_string_lossy(), "certificate": cert_json(&id.certificate) }))
|
||||
}
|
||||
|
||||
/// The digital ID `id` names: a .p12/.pfx file opened with `password`, or an OS key store
|
||||
/// identity (`keychain:` on macOS, `windows:` on Windows).
|
||||
fn open_id(&self, a: &Args) -> Result<DigitalId> {
|
||||
// A macOS Keychain identity: "keychain:<fingerprint>" or "keychain:<common name>".
|
||||
if let Some(r) = a.str("id")?.strip_prefix("keychain:") {
|
||||
@@ -197,6 +200,8 @@ impl Automation {
|
||||
})
|
||||
}
|
||||
|
||||
/// `sign_document`: sign the document with a digital ID into `out`, visibly (in a field or a
|
||||
/// rectangle) or not, running a dynamic form's signing scripts around it.
|
||||
pub(crate) fn sign_document(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.open_id(a)?;
|
||||
let doc = self.doc(a)?;
|
||||
@@ -246,30 +251,94 @@ impl Automation {
|
||||
Some(v) => labels_arg(v)?,
|
||||
};
|
||||
let out = self.resolve(a.str("out")?, true)?;
|
||||
let opts = SignOptions {
|
||||
field,
|
||||
page,
|
||||
rect,
|
||||
reason: a.opt_str("reason")?.map(str::to_string),
|
||||
location: a.opt_str("location")?.map(str::to_string),
|
||||
contact: a.opt_str("contact")?.map(str::to_string),
|
||||
certify,
|
||||
appearance,
|
||||
appearance_labels,
|
||||
graphic,
|
||||
..SignOptions::default()
|
||||
// A dynamic form's signing scripts run only when asked: what preSign changes is signed,
|
||||
// and the caller hasn't seen it. What they changed is listed.
|
||||
let run_scripts = a.opt_bool("run_form_scripts")?.unwrap_or(false);
|
||||
// Every argument is read before the preSign scripts change anything, so a bad one is
|
||||
// refused with the document as it was.
|
||||
let reason = a.opt_str("reason")?.map(str::to_string);
|
||||
let location = a.opt_str("location")?.map(str::to_string);
|
||||
let contact = a.opt_str("contact")?.map(str::to_string);
|
||||
let signing = field.clone();
|
||||
let scripted = signing.as_deref().is_some_and(|f| {
|
||||
self.session.sign_event_scripted(doc_id, f, SignEvent::Before) || self.session.sign_event_scripted(doc_id, f, SignEvent::After)
|
||||
});
|
||||
// Each field's value and whether it is read-only, to tell what the preSign scripts changed.
|
||||
let values = |me: &Automation| -> std::collections::BTreeMap<String, (Vec<String>, bool)> {
|
||||
me.session.get(doc_id).map(|d| d.form.iter().map(|f| (f.name.clone(), (f.value.clone(), f.read_only()))).collect()).unwrap_or_default()
|
||||
};
|
||||
let was = values(self);
|
||||
let before = match &signing {
|
||||
Some(f) if run_scripts => self.session.run_sign_event(doc_id, f, SignEvent::Before).map_err(failed)?,
|
||||
_ => false,
|
||||
};
|
||||
let changed: Vec<Value> = if before {
|
||||
values(self)
|
||||
.into_iter()
|
||||
.filter(|(name, now)| was.get(name) != Some(now))
|
||||
.map(|(name, (value, read_only))| {
|
||||
let (old, old_ro) = was.get(&name).cloned().unwrap_or_default();
|
||||
json!({ "field": name, "before": old, "after": value, "read_only_before": old_ro, "read_only": read_only })
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let opts =
|
||||
SignOptions { field, page, rect, reason, location, contact, certify, appearance, appearance_labels, graphic, ..SignOptions::default() };
|
||||
let signed = self.session.sign(doc_id, &id, opts).map_err(|e| match e {
|
||||
// A graphic the signer refuses is the caller's to fix.
|
||||
pdfcraft_engine::EditError::Sign(m) if m.starts_with("the signature graphic") => bad(m),
|
||||
e => failed(e),
|
||||
})?;
|
||||
write_atomic(&out, &signed)?;
|
||||
});
|
||||
let signed = match signed {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// Not signed: what the preSign scripts did is taken back.
|
||||
if before {
|
||||
let _ = self.session.undo(doc_id);
|
||||
let _ = self.session.take_js_output(doc_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if let Err(e) = write_atomic(&out, &signed) {
|
||||
// Not saved, so not signed: what the preSign scripts did is taken back too.
|
||||
if before {
|
||||
let _ = self.session.undo(doc_id);
|
||||
let _ = self.session.take_js_output(doc_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
let path = out.to_string_lossy().into_owned();
|
||||
self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?;
|
||||
// Then its postSign scripts; what they change is a change after signing, not saved yet.
|
||||
// The file is signed and saved by now: a postSign step that fails is reported, not an error.
|
||||
let (after, after_error) = match &signing {
|
||||
Some(f) if run_scripts => match self.session.run_sign_event(doc_id, f, SignEvent::After) {
|
||||
Ok(ran) => (ran, None),
|
||||
Err(e) => (true, Some(format!("the postSign step couldn't finish: {e}"))),
|
||||
},
|
||||
_ => (false, None),
|
||||
};
|
||||
let mut scripts = self.session.take_js_output(doc_id);
|
||||
scripts.errors.extend(after_error);
|
||||
let doc = self.doc(a)?;
|
||||
let newest = doc.signatures.iter().filter(|s| s.signed).max_by_key(|s| s.revision).cloned();
|
||||
Ok(json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }))
|
||||
let mut result = json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? });
|
||||
if before || after {
|
||||
result["form_scripts"] = json!({
|
||||
"before_signing": before,
|
||||
"after_signing": after,
|
||||
"changed": changed,
|
||||
"alerts": scripts.alerts.iter().map(crate::a11y::form_tools::alert_json).collect::<Vec<_>>(),
|
||||
"errors": scripts.errors,
|
||||
"unsaved_changes": self.doc(a)?.dirty,
|
||||
});
|
||||
} else if scripted {
|
||||
result["form_scripts"] = json!({ "skipped": true, "why": "the field has signing scripts; pass run_form_scripts: true to run them" });
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// `{"image": path}` (PNG or JPEG), `{"text": name}` (typed in the script font) or
|
||||
|
||||
@@ -514,9 +514,23 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"form_fill",
|
||||
"Fill in form fields",
|
||||
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes (or one of a check box's options, to check that widget and clear the others), an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
|
||||
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes (or one of a check box's options, to check that widget and clear the others), an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. A radio option is an export value as form_fields lists it in options; where one button's on-state name equals another button's export value, the export value wins, and an on-state name that no export value matches still selects its button. A radio option may also be a JSON number: a whole number names the option spelled as that integer (1, 1.0 and 1e0 all name \"1\"), any other number only an option spelled exactly as JSON prints it (1.5). A value that names no option is refused with the options listed. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
|
||||
)
|
||||
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
|
||||
t(
|
||||
"form_focus",
|
||||
"Move the focus into or out of a field",
|
||||
"A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run (other forms have none, and nothing happens). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"field": { "type": "string", "description": "Field name (from form_fields)." },
|
||||
"event": { "type": "string", "enum": ["enter", "exit"] },
|
||||
"widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." },
|
||||
}),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "fields": { "type": "array", "items": { "type": "string" } } }), &["doc"])),
|
||||
@@ -959,7 +973,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("sign.digital")
|
||||
.with(schema(json!({}), &[])),
|
||||
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
|
||||
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys). On a dynamic (XFA) form, run_form_scripts: true runs the field's own signing scripts: preSign just before signing, so what it changes is signed too (form_scripts.changed lists each field it changed, with the values before and after), and postSign after, left unsaved. Without it they don't run, and form_scripts.skipped says the field has them.")
|
||||
.cmd("sign.digital")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -1010,6 +1024,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"additionalProperties": false,
|
||||
},
|
||||
"out": { "type": "string", "description": "Where to save the signed document." },
|
||||
"run_form_scripts": { "type": "boolean", "description": "Run a dynamic form's preSign and postSign scripts for the field (default false)." },
|
||||
}),
|
||||
&["doc", "id", "out"],
|
||||
)),
|
||||
|
||||
@@ -3340,6 +3340,166 @@ fn form_fill_warns_when_a_value_does_not_fit() {
|
||||
assert!(r.get("warnings").is_none(), "{r}");
|
||||
}
|
||||
|
||||
/// `form_fill` takes a radio option as `form_fields` lists it: the export value. With `/Opt [(1)
|
||||
/// (2) (3)]` and on states named by position (`/0`, `/1`, `/2`; ISO 32000-2 Table 230), `1` is the
|
||||
/// first button. It used to be read as the state `/1` and turn the second one on.
|
||||
#[test]
|
||||
fn form_fill_picks_a_radio_option_by_its_export_value() {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>",
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>",
|
||||
"<< /FT /Btn /T (title) /Ff 49152 /Opt [(1) (2) (3)] /V /Off /Kids [6 0 R 7 0 R 8 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 9 0 R /Off 9 0 R >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 9 0 R /Off 9 0 R >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [70 250 82 262] /P 3 0 R /AP << /N << /2 9 0 R /Off 9 0 R >> >> >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream",
|
||||
];
|
||||
let dir = workdir("radio-export");
|
||||
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["options"], json!(["1", "2", "3"]));
|
||||
for pick in ["1", "2", "3"] {
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": pick } }));
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], pick);
|
||||
// The button drawn on is the one picked.
|
||||
let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone();
|
||||
let on: Vec<usize> = f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect();
|
||||
assert_eq!(on, [pick.parse::<usize>().unwrap() - 1], "picked {pick}");
|
||||
}
|
||||
// A number is the same option: `1` is the first button, not the state `/1`.
|
||||
for pick in [1, 2, 3] {
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": pick } }));
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], pick.to_string(), "picked {pick}");
|
||||
}
|
||||
// An on-state name the options do not list still selects its button.
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": "0" } }));
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "1");
|
||||
}
|
||||
|
||||
/// The ambiguous pair: the second button's on state is named `1` and the first button exports
|
||||
/// `1`. The export value wins, however the number is written in JSON (`1`, `1.0`, `1e0`, `0.1e1`);
|
||||
/// `-0` is `0`, the first button's state. A string is matched as written, so `"1.0"` names no
|
||||
/// option, and neither does a number that is not whole (`1.5`) or past 2^53 (`1e300`): each is
|
||||
/// refused with the options listed and the selection kept.
|
||||
#[test]
|
||||
fn form_fill_reads_a_radio_number_by_its_integer_spelling() {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>",
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>",
|
||||
"<< /FT /Btn /T (title) /Ff 49152 /Opt [(1) (2) (3)] /V /Off /Kids [6 0 R 7 0 R 8 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 9 0 R /Off 9 0 R >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 9 0 R /Off 9 0 R >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [70 250 82 262] /P 3 0 R /AP << /N << /2 9 0 R /Off 9 0 R >> >> >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream",
|
||||
];
|
||||
let dir = workdir("radio-number-spelling");
|
||||
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
// The values as JSON text, so each number keeps the spelling a client sends.
|
||||
let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"title": {lit}}}}}"#)).unwrap() };
|
||||
let on = |a: &Automation| -> Vec<usize> {
|
||||
let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone();
|
||||
f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect()
|
||||
};
|
||||
for (lit, button) in [("2", 1), ("1", 0), ("3.0", 2), ("1.0", 0), ("2e0", 1), ("1e0", 0), ("0.1e1", 0), ("-0", 0), ("\"1\"", 0), ("2.000", 1)] {
|
||||
// From another button, so a fill that does nothing would show.
|
||||
ok(&mut a, "form_fill", fill(if button == 2 { "1" } else { "3" }));
|
||||
ok(&mut a, "form_fill", fill(lit));
|
||||
assert_eq!(on(&a), [button], "{lit} selects button {}", button + 1);
|
||||
}
|
||||
ok(&mut a, "form_fill", fill("2"));
|
||||
for lit in ["\"1.0\"", "1.5", "-1", "1e300", "18446744073709551615", "9007199254740993.0"] {
|
||||
let e = err(&mut a, "form_fill", fill(lit)).to_string();
|
||||
assert!(e.contains("has no option") && e.contains("options: 1, 2, 3"), "{lit}: {e}");
|
||||
assert_eq!(on(&a), [1], "{lit} keeps the selection");
|
||||
}
|
||||
}
|
||||
|
||||
/// Export values written as decimals (`/Opt [(1.0) (2.0)]`, states `/0 /1`): a number is compared
|
||||
/// with them as a number first, so `1`, `1.0` and `1e0` all pick the first button and `2.0` the
|
||||
/// second, where `1.0` had become `"1"`, matched no export value and picked the button whose state
|
||||
/// is `/1`. A string is still matched as written, and a number no export value equals is refused.
|
||||
#[test]
|
||||
fn form_fill_matches_a_radio_number_to_decimal_export_values() {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>",
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>",
|
||||
"<< /FT /Btn /T (rate) /Ff 49152 /Opt [(1.0) (2.0)] /V /Off /Kids [6 0 R 7 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 8 0 R /Off 8 0 R >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 8 0 R /Off 8 0 R >> >> >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>
|
||||
stream
|
||||
|
||||
endstream",
|
||||
];
|
||||
let dir = workdir("radio-decimal-exports");
|
||||
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"rate": {lit}}}}}"#)).unwrap() };
|
||||
let on = |a: &Automation| -> Vec<usize> {
|
||||
let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone();
|
||||
f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect()
|
||||
};
|
||||
for (lit, button) in [("1.0", 0), ("2.0", 1), ("1", 0), ("2", 1), ("1e0", 0), ("\"1.0\"", 0), ("\"2.0\"", 1)] {
|
||||
// From the other button, so a fill that does nothing would show.
|
||||
ok(&mut a, "form_fill", fill(if button == 0 { "2.0" } else { "1.0" }));
|
||||
ok(&mut a, "form_fill", fill(lit));
|
||||
assert_eq!(on(&a), [button], "{lit} selects button {}", button + 1);
|
||||
}
|
||||
let e = err(&mut a, "form_fill", fill("3")).to_string();
|
||||
assert!(e.contains("has no option"), "{e}");
|
||||
}
|
||||
|
||||
/// Past 2^53 a float no longer holds every integer, so `9007199254740992` and `9007199254740993`
|
||||
/// are the same `f64`. A number there is matched only by its exact text, never compared as a
|
||||
/// number with the export values: with `/Opt [(9007199254740992) (2)]` the number
|
||||
/// `9007199254740993` names no option, nor does `9007199254740992` with
|
||||
/// `/Opt [(9007199254740993) (2)]`. Each is refused and the selection kept, while the number
|
||||
/// spelled as its export value still picks that button.
|
||||
#[test]
|
||||
fn form_fill_matches_a_radio_number_past_2_53_only_by_its_text() {
|
||||
for (export, other) in [("9007199254740992", "9007199254740993"), ("9007199254740993", "9007199254740992")] {
|
||||
let field = format!("<< /FT /Btn /T (big) /Ff 49152 /Opt [({export}) (2)] /V /Off /Kids [6 0 R 7 0 R] >>");
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>",
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>",
|
||||
field.as_str(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 8 0 R /Off 8 0 R >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 8 0 R /Off 8 0 R >> >> >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>
|
||||
stream
|
||||
|
||||
endstream",
|
||||
];
|
||||
let dir = workdir(&format!("radio-past-2-53-{export}"));
|
||||
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"big": {lit}}}}}"#)).unwrap() };
|
||||
let on = |a: &Automation| -> Vec<usize> {
|
||||
let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone();
|
||||
f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect()
|
||||
};
|
||||
ok(&mut a, "form_fill", fill(export));
|
||||
assert_eq!(on(&a), [0], "{export} selects the button exported as {export}");
|
||||
ok(&mut a, "form_fill", fill("2"));
|
||||
let e = err(&mut a, "form_fill", fill(other)).to_string();
|
||||
assert!(e.contains("has no option"), "{other} with /Opt [({export}) (2)]: {e}");
|
||||
assert_eq!(on(&a), [1], "{other} keeps the selection");
|
||||
}
|
||||
}
|
||||
|
||||
/// `form_fill` with {"widget": n} turns on that radio button alone when others share its on
|
||||
/// state (ISO 32000-2 Table 229, RadiosInUnison clear), and `form_fields` says which is on.
|
||||
#[test]
|
||||
@@ -7643,6 +7803,7 @@ fn object_has_name(obj: &pdfcraft_cos::Object, name: &[u8]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Polygons, polylines and other drawing comments are added and listed through the tools.
|
||||
#[test]
|
||||
fn drawing_comments_through_tools() {
|
||||
let dir = workdir("drawing");
|
||||
@@ -7749,6 +7910,8 @@ fn lines_and_corners_are_held_level_or_plumb_through_the_tool() {
|
||||
assert!(matches!(refused, Err(ToolError::InvalidArgs(_))), "ortho is for lines and corners: {refused:?}");
|
||||
}
|
||||
|
||||
/// A visible signature drawn from an image, typed text or strokes through sign_document; bad
|
||||
/// graphics are refused before anything is written.
|
||||
#[test]
|
||||
fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
|
||||
let dir = workdir("signing-graphic");
|
||||
@@ -7818,6 +7981,111 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
|
||||
assert!(inked(60, 110), "the strokes are drawn");
|
||||
}
|
||||
|
||||
/// Signing a dynamic form's signature box through sign_document runs its XFA signing scripts only
|
||||
/// when asked (run_form_scripts): preSign first, so what it changes is signed, listed field by field,
|
||||
/// and postSign after, left as unsaved changes; the result carries their messages. Not asked, they
|
||||
/// don't run and the result says the box has them; a box without them reports nothing.
|
||||
#[test]
|
||||
fn signing_an_xfa_box_runs_its_signing_scripts_through_tools() {
|
||||
let dir = workdir("xfa-sign-events");
|
||||
std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::signing_events_template())).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let name = |n: &str| {
|
||||
fields["fields"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|f| f["name"].as_str())
|
||||
.find(|f| *f == n || f.ends_with(&format!(".{n}[0]")))
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
// Not asked: the scripts don't run, and the result says the box has them.
|
||||
let r = ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "unasked.pdf" }));
|
||||
assert_eq!(r["form_scripts"]["skipped"], json!(true), "{r}");
|
||||
let unasked = ok(&mut a, "doc_open", json!({ "path": "unasked.pdf" }))["doc"].as_u64().unwrap();
|
||||
let unasked = ok(&mut a, "form_fields", json!({ "doc": unasked }));
|
||||
assert!(
|
||||
unasked["fields"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()),
|
||||
"{unasked}"
|
||||
);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
// A bad argument is refused before the preSign scripts run.
|
||||
let bad = a.call(
|
||||
"sign_document",
|
||||
&json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "bad.pdf", "run_form_scripts": true, "reason": 5 }),
|
||||
);
|
||||
assert!(matches!(bad, Err(ToolError::InvalidArgs(_))), "{bad:?}");
|
||||
let now = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
assert!(
|
||||
now["fields"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()),
|
||||
"{now}"
|
||||
);
|
||||
// A signed file that can't be written (`out` is a folder): not signed, and what preSign did
|
||||
// is taken back.
|
||||
std::fs::create_dir_all(dir.join("taken")).unwrap();
|
||||
let failed = a.call(
|
||||
"sign_document",
|
||||
&json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "taken", "run_form_scripts": true }),
|
||||
);
|
||||
assert!(failed.is_err(), "{failed:?}");
|
||||
let now = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
assert!(
|
||||
now["fields"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()),
|
||||
"{now}"
|
||||
);
|
||||
let r = ok(
|
||||
&mut a,
|
||||
"sign_document",
|
||||
json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "signed.pdf", "run_form_scripts": true }),
|
||||
);
|
||||
let scripts = &r["form_scripts"];
|
||||
let changed = scripts["changed"].as_array().unwrap();
|
||||
assert_eq!(changed.len(), 1, "only what preSign changed: {r}");
|
||||
assert_eq!((&changed[0]["after"], &changed[0]["read_only"]), (&json!(["signed"]), &json!(true)), "{r}");
|
||||
assert_eq!((scripts["before_signing"].as_bool(), scripts["after_signing"].as_bool()), (Some(true), Some(true)), "{r}");
|
||||
assert_eq!(scripts["alerts"], json!([{ "message": "Signing notice", "title": null, "icon": "error", "buttons": "ok" }]), "{r}");
|
||||
assert_eq!(scripts["unsaved_changes"], json!(true), "the postSign change isn't in the signed file: {r}");
|
||||
// The signed file holds what the preSign script did, not what the postSign one did.
|
||||
let signed = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap();
|
||||
let values = ok(&mut a, "form_fields", json!({ "doc": signed }));
|
||||
let value = |n: &str| {
|
||||
values["fields"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|f| f["name"].as_str().is_some_and(|f| f == n || f.ends_with(&format!(".{n}[0]"))))
|
||||
.map(|f| f["value"].clone())
|
||||
};
|
||||
assert_eq!(value("when"), Some(json!("signed")), "{values}");
|
||||
assert_eq!(value("after"), Some(json!(null)), "{values}");
|
||||
let list = ok(&mut a, "sign_list", json!({ "doc": signed }));
|
||||
assert!(list["signatures"].as_array().unwrap().iter().any(|s| s["signed"] == true && s["status"] == "unknown"), "intact: {list}");
|
||||
// A box without signing scripts: nothing reported.
|
||||
let r = ok(
|
||||
&mut a,
|
||||
"sign_document",
|
||||
json!({ "doc": signed, "id": "ada.p12", "password": "secret12", "field": name("plain"), "out": "both.pdf", "run_form_scripts": true }),
|
||||
);
|
||||
assert!(r.get("form_scripts").is_none(), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Signing a laid-out XFA form's signature box through the tools signs the field named by its
|
||||
/// SOM path, which is how another viewer, laying the form out again, finds the signature; the
|
||||
/// signed file reopens with the signature intact and on that field.
|
||||
@@ -7840,6 +8108,8 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Digital IDs are created and opened, documents signed with them and their signatures validated,
|
||||
/// all through the tools.
|
||||
#[test]
|
||||
fn named_layer_verdicts_are_reported_and_rendered_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Lexer, ObjRef, Object, SaveOptions, Stream};
|
||||
@@ -10216,6 +10486,8 @@ fn growable_xfa_text_paginates_after_fill_save_and_print() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A print button repeated on every page of an XFA form runs its own XFA click script (a print
|
||||
/// request) through js_run, from each copy, before and after saving and reopening.
|
||||
#[test]
|
||||
fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
let dir = workdir("xfa-print-buttons");
|
||||
@@ -10243,6 +10515,93 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
check(&mut a, doc);
|
||||
}
|
||||
|
||||
/// form_focus runs a field's XFA enter and exit scripts on every focus change; scripts that
|
||||
/// change nothing leave no edit, a radio button's own scripts run for its widget, and unknown
|
||||
/// fields, widgets and events are refused.
|
||||
#[test]
|
||||
fn xfa_focus_scripts_run_through_form_focus() {
|
||||
let dir = workdir("xfa-focus");
|
||||
std::fs::write(dir.join("focus.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "focus.pdf" }))["doc"].as_u64().unwrap();
|
||||
let value = |a: &mut Automation, n: &str| {
|
||||
let f = ok(a, "form_fields", json!({ "doc": doc }));
|
||||
f["fields"].as_array().unwrap().iter().find(|f| f["name"] == n).map(|f| f["value"].clone()).unwrap()
|
||||
};
|
||||
for _ in 0..3 {
|
||||
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": "probe", "event": "enter" }))["changed"], true);
|
||||
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": "probe", "event": "exit" }))["changed"], true);
|
||||
}
|
||||
assert_eq!((value(&mut a, "enters"), value(&mut a, "exits")), (json!("3"), json!("3")));
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(value(&mut a, "exits"), "2", "each run is one undo step");
|
||||
// A script that only shows a message changes nothing: no edit (the document stays saved and
|
||||
// what was undone can still be redone), and the message comes back.
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" }));
|
||||
let quiet = ok(&mut a, "form_focus", json!({ "doc": doc, "field": "quiet", "event": "exit" }));
|
||||
assert_eq!(
|
||||
(quiet["changed"].clone(), quiet["alerts"].clone()),
|
||||
(json!(false), json!([{ "message": "left quiet", "title": null, "icon": "error", "buttons": "ok" }])),
|
||||
"{quiet}"
|
||||
);
|
||||
let state = &ok(&mut a, "doc_info", json!({ "doc": doc }))["document"];
|
||||
assert_eq!((state["dirty"].clone(), state["redo"].clone()), (json!(false), json!("Run form script")), "{state}");
|
||||
// The second radio button has an enter script of its own.
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let group = fields["fields"].as_array().unwrap().iter().find(|f| f["type"] == "radio").unwrap()["name"].clone();
|
||||
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "event": "enter" }))["changed"], false);
|
||||
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "event": "enter", "widget": 2 }))["changed"], true);
|
||||
assert_eq!(value(&mut a, "picks"), "1");
|
||||
for bad in [
|
||||
json!({ "doc": doc, "field": "nowhere", "event": "enter" }),
|
||||
json!({ "doc": doc, "field": group, "event": "enter", "widget": 3 }),
|
||||
json!({ "doc": doc, "field": "probe", "event": "enter", "widget": 0 }),
|
||||
json!({ "doc": doc, "field": "probe", "event": "click" }),
|
||||
] {
|
||||
assert!(a.call("form_focus", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// form_focus refuses a field with no widget, which nothing can focus, instead of reporting a
|
||||
/// focus change that ran nothing.
|
||||
#[test]
|
||||
fn form_focus_refuses_a_field_with_no_widget() {
|
||||
let dir = workdir("focus-no-widget");
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [60 60 200 80] /P 3 0 R >>",
|
||||
"<< /FT /Tx /T (total) /V (3) >>",
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join("total.pdf"), bytes).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "total.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == "total"), "the field without a widget is listed: {fields}");
|
||||
for args in [json!({ "doc": doc, "field": "total", "event": "enter" }), json!({ "doc": doc, "field": "total", "event": "enter", "widget": 1 })] {
|
||||
let e = err(&mut a, "form_focus", args.clone()).to_string();
|
||||
assert!(e.contains("\"total\" has no widget to focus"), "{args}: {e}");
|
||||
}
|
||||
assert_eq!(
|
||||
ok(&mut a, "form_focus", json!({ "doc": doc, "field": "name", "event": "enter" }))["changed"],
|
||||
false,
|
||||
"a field with a widget still takes it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A dynamic XFA form's scripts run on open, on fills and for buttons through the tools, with undo,
|
||||
/// and `js_run` reports their messages.
|
||||
#[test]
|
||||
|
||||
@@ -59,6 +59,10 @@ pub struct Document {
|
||||
pub(crate) xfa_template: Option<Arc<pdfcraft_xfa::model::Template>>,
|
||||
/// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]).
|
||||
pub(crate) xfa_scripts_off: bool,
|
||||
/// SOM paths of the XFA fields whose exit scripts ran with a value change ([`xfa::on_changes`])
|
||||
/// since they last gained focus: their exit on losing focus has run already
|
||||
/// ([`Session::field_focus`]).
|
||||
pub(crate) xfa_exited: std::collections::HashSet<String>,
|
||||
/// A content-only refresh keeps `info` from before the edit; an edit may have added or
|
||||
/// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]).
|
||||
pub(crate) fonts_stale: bool,
|
||||
@@ -419,10 +423,16 @@ impl Document {
|
||||
self.undo_label().map(Label::as_str)
|
||||
}
|
||||
|
||||
/// What Redo would redo, in English, when there is something to redo.
|
||||
pub fn can_redo(&self) -> Option<&str> {
|
||||
self.redo_label().map(Label::as_str)
|
||||
}
|
||||
|
||||
/// How many steps Undo can take back: a step still at this depth is still the last one.
|
||||
pub fn undo_depth(&self) -> usize {
|
||||
self.editor.as_ref().map_or(0, |e| e.undo.len())
|
||||
}
|
||||
|
||||
/// What Undo would undo, in parts, for an interface that says it in another language.
|
||||
pub fn undo_label(&self) -> Option<&Label> {
|
||||
self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l)
|
||||
|
||||
@@ -221,12 +221,16 @@ pub(crate) struct EditCtx {
|
||||
pub(crate) xfa_datasets: Option<pdfcraft_cos::ObjRef>,
|
||||
/// A form script ran too long and was abandoned: the document's scripts go off.
|
||||
pub(crate) xfa_ran_away: bool,
|
||||
/// An XFA enter or exit event whose scripts changed nothing: no edit to keep.
|
||||
pub(crate) xfa_unchanged: bool,
|
||||
/// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]).
|
||||
pub(crate) xfa_time_scale: u32,
|
||||
/// The edit took the XFA out of the form: what went with it, for the document's warnings.
|
||||
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),
|
||||
@@ -246,6 +250,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 {
|
||||
@@ -262,9 +283,11 @@ impl EditCtx {
|
||||
xfa_out: Default::default(),
|
||||
xfa_datasets: None,
|
||||
xfa_ran_away: false,
|
||||
xfa_unchanged: false,
|
||||
xfa_time_scale: 1,
|
||||
xfa_removed: Vec::new(),
|
||||
pages: 0,
|
||||
highlight: false,
|
||||
date: now.map(pdfcraft_cos::pdf_date),
|
||||
today: (1970, 1, 1),
|
||||
seed,
|
||||
@@ -675,12 +698,26 @@ 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 ran = xfa::on_event(doc, &tpl, som, activity, &mut run);
|
||||
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 = if matches!(activity.as_str(), "enter" | "exit") {
|
||||
xfa::on_focus(doc, &tpl, som, activity, &mut run).map(|changes| changes.is_empty())
|
||||
} else {
|
||||
xfa::on_event(doc, &tpl, som, activity, &mut run).map(|()| false)
|
||||
};
|
||||
cx.xfa_datasets = run.datasets;
|
||||
cx.xfa_ran_away |= run.ran_away;
|
||||
ran.map_err(EditError::Invalid)?;
|
||||
// The next edit of a batch reads the highlight this one's scripts set.
|
||||
let on = run.highlight;
|
||||
cx.set_highlight(on);
|
||||
cx.xfa_unchanged = ran.map_err(EditError::Invalid)?;
|
||||
}
|
||||
Edit::SetFieldImage { name, image } => {
|
||||
let (img, _) = embed_image(doc, name, image)?;
|
||||
@@ -999,6 +1036,11 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
doc.remove_encryption();
|
||||
}
|
||||
Edit::Batch { edits, .. } => {
|
||||
// A form-script event runs on its own: in a batch, its scripts and those the batch's
|
||||
// value edits run afterwards (change, exit) would interleave, an exit running twice.
|
||||
if edits.iter().any(|e| matches!(e, Edit::XfaEvent { .. })) {
|
||||
return Err(EditError::Invalid("a form script event can't be part of a batch of edits".into()));
|
||||
}
|
||||
for e in edits {
|
||||
run_edit(doc, e, cx)?;
|
||||
}
|
||||
|
||||
+140
-5
@@ -11,6 +11,15 @@ pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Pan
|
||||
|
||||
use crate::{DocId, Edit, EditError, Session};
|
||||
|
||||
/// A field gaining or losing focus ([`Session::field_focus`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FieldFocus {
|
||||
/// The field gained focus: its `enter` scripts run.
|
||||
Enter,
|
||||
/// The field lost focus: its `exit` scripts run.
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// What scripts produced for the viewer since it last looked.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct JsOutput {
|
||||
@@ -255,11 +264,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 +286,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 +344,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;
|
||||
}
|
||||
@@ -355,21 +383,124 @@ impl crate::Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// When a signature field's XFA signing scripts run ([`Session::run_sign_event`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SignEvent {
|
||||
/// Just before the field is signed (`preSign`): what it changes is signed too.
|
||||
Before,
|
||||
/// Once the field is signed (`postSign`).
|
||||
After,
|
||||
}
|
||||
|
||||
impl SignEvent {
|
||||
/// The template's name for the event.
|
||||
pub fn activity(self) -> &'static str {
|
||||
match self {
|
||||
SignEvent::Before => "preSign",
|
||||
SignEvent::After => "postSign",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript (on by default).
|
||||
pub fn set_javascript(&mut self, on: bool) {
|
||||
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
|
||||
}
|
||||
|
||||
/// Widget `widget` of field `name` in document `id` gained or lost focus: on a laid-out XFA form,
|
||||
/// run the field's `enter` or `exit` scripts, as one undoable edit when they change something
|
||||
/// (and none when they don't). An exit that the field's new value already ran (the value's
|
||||
/// change runs it) isn't run a second time. Returns whether the document changed; what the
|
||||
/// scripts showed or asked for is in [`Session::take_js_output`].
|
||||
pub fn field_focus(&mut self, id: DocId, name: &str, widget: usize, focus: FieldFocus) -> Result<bool, EditError> {
|
||||
if self.js_off {
|
||||
return Ok(false);
|
||||
}
|
||||
let doc = self.doc_mut(id)?;
|
||||
let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else { return Ok(false) };
|
||||
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) };
|
||||
let Some(som) = doc.form.iter().find(|f| f.name == name).and_then(|f| crate::xfa::widget_som(cos, f, widget)) else { return Ok(false) };
|
||||
let activity = match focus {
|
||||
FieldFocus::Enter => "enter",
|
||||
FieldFocus::Exit => "exit",
|
||||
};
|
||||
if !crate::xfa::has_event(cos, &tpl, &som, activity) {
|
||||
doc.xfa_exited.remove(&som);
|
||||
return Ok(false);
|
||||
}
|
||||
// Gaining focus starts afresh; losing it runs the exit unless the change just did.
|
||||
if doc.xfa_exited.remove(&som) && focus == FieldFocus::Exit {
|
||||
return Ok(false);
|
||||
}
|
||||
let before = doc.generation;
|
||||
self.apply(id, Edit::XfaEvent { som, activity: activity.into() })?;
|
||||
Ok(self.get(id).is_some_and(|d| d.generation != before))
|
||||
}
|
||||
|
||||
/// Run signature field `field`'s XFA signing scripts for `event` in document `id`: `preSign`
|
||||
/// before it is signed, `postSign` after, as one undoable step, as a form's signing events run
|
||||
/// (DA 31's dates the signature and locks the date). `Ok(false)`, with nothing changed, when
|
||||
/// there are none: not a laid-out XFA form, JavaScript off, no such field, or no such script.
|
||||
/// What the scripts said or asked for is in [`Session::take_js_output`].
|
||||
pub fn run_sign_event(&mut self, id: DocId, field: &str, event: SignEvent) -> Result<bool, EditError> {
|
||||
if self.get(id).is_none() {
|
||||
return Err(EditError::NoDocument);
|
||||
}
|
||||
let Some(som) = self.sign_event_som(id, field, event) else { return Ok(false) };
|
||||
self.apply(id, Edit::XfaEvent { som, activity: event.activity().into() })?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Whether signature field `field` of document `id` has XFA signing scripts for `event` that
|
||||
/// [`Session::run_sign_event`] would run, without running them.
|
||||
pub fn sign_event_scripted(&self, id: DocId, field: &str, event: SignEvent) -> bool {
|
||||
self.sign_event_som(id, field, event).is_some()
|
||||
}
|
||||
|
||||
/// The SOM path of signature field `field` when it has scripts for `event` to run.
|
||||
fn sign_event_som(&self, id: DocId, field: &str, event: SignEvent) -> Option<String> {
|
||||
if self.js_off {
|
||||
return None;
|
||||
}
|
||||
let doc = self.get(id)?;
|
||||
let (tpl, editor) = (doc.xfa_template.as_ref()?, doc.editor.as_ref()?);
|
||||
let f = doc.form.iter().find(|f| f.name == field)?;
|
||||
crate::xfa::scripted_som(&editor.cos, tpl, f, event.activity())
|
||||
}
|
||||
|
||||
/// 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 +527,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 +545,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);
|
||||
@@ -371,6 +384,7 @@ impl Session {
|
||||
xfa,
|
||||
xfa_template: None,
|
||||
xfa_scripts_off: false,
|
||||
xfa_exited: Default::default(),
|
||||
fonts_stale: false,
|
||||
xfa_warnings: Vec::new(),
|
||||
edit_notices: Vec::new(),
|
||||
|
||||
@@ -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,13 +87,20 @@ 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();
|
||||
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
|
||||
guard(|| run_edit(&mut next, &edit, &mut cx))
|
||||
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
|
||||
// A field's enter or exit scripts that changed nothing leave no edit behind (no undo step,
|
||||
// nothing unsaved); what they showed or asked for still goes out. Only an event on its own:
|
||||
// a batch keeps the other edits it holds.
|
||||
if cx.xfa_unchanged && matches!(edit, Edit::XfaEvent { .. }) {
|
||||
take_xfa_output(doc, cx.xfa_out, cx.xfa_ran_away);
|
||||
return Ok(None);
|
||||
}
|
||||
// Redaction removed what its own interpreter places under the marks. The marks were drawn
|
||||
// over the page as the renderer shows it, so the renderer has the last word.
|
||||
if let Edit::ApplyRedactions { pages } = &edit {
|
||||
@@ -130,7 +139,8 @@ impl Session {
|
||||
};
|
||||
// An XFA form's scripts answer the change: exit and validate scripts of the field, then
|
||||
// every calculation (and a reset recalculates). They read the data, so it is brought up
|
||||
// to date first.
|
||||
// to date first. The fields whose exit scripts ran are noted once the edit is kept.
|
||||
let mut exited: Vec<String> = Vec::new();
|
||||
if let Some(tpl) = cx.xfa.clone() {
|
||||
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) {
|
||||
let datasets = &mut cx.xfa_datasets;
|
||||
@@ -139,6 +149,8 @@ impl Session {
|
||||
.map_err(EditError::Write)?;
|
||||
cx.xfa_out.errors.extend(notes);
|
||||
}
|
||||
/// The fields edit `e` (and the edits it batches) gives new values, and whether it
|
||||
/// resets the form.
|
||||
fn collect(e: &Edit, changed: &mut Vec<String>, reset: &mut bool) {
|
||||
match e {
|
||||
Edit::SetFieldValue { name, .. } => changed.push(name.clone()),
|
||||
@@ -151,19 +163,24 @@ 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,
|
||||
// only those).
|
||||
let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run))
|
||||
let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run, &mut exited))
|
||||
.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);
|
||||
}
|
||||
@@ -230,6 +247,10 @@ impl Session {
|
||||
doc.dirty = true;
|
||||
doc.generation += 1;
|
||||
doc.edit_notices = notices;
|
||||
// Only this edit's exits are skipped on the next focus loss: one an earlier change ran is
|
||||
// no reason to skip a later one.
|
||||
doc.xfa_exited.clear();
|
||||
doc.xfa_exited.extend(exited);
|
||||
if !cx.xfa_removed.is_empty() {
|
||||
let removed: Vec<String> = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect();
|
||||
doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX));
|
||||
@@ -242,15 +263,11 @@ impl Session {
|
||||
if let Some(js) = cx.js {
|
||||
doc.js_output.append(js.output);
|
||||
}
|
||||
doc.js_output.append(cx.xfa_out);
|
||||
if cx.xfa_ran_away {
|
||||
doc.xfa_template = None;
|
||||
doc.xfa_scripts_off = true;
|
||||
doc.js_output.errors.push(XFA_SCRIPTS_OFF.into());
|
||||
}
|
||||
take_xfa_output(doc, cx.xfa_out, cx.xfa_ran_away);
|
||||
Ok(cx.prepared)
|
||||
}
|
||||
|
||||
/// Undo the last edit of document `id`, keeping it for [`Session::redo`]. Returns its label.
|
||||
pub fn undo(&mut self, id: DocId) -> Result<Label, EditError> {
|
||||
let doc = self.doc_mut(id)?;
|
||||
let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?;
|
||||
@@ -276,10 +293,13 @@ impl Session {
|
||||
}
|
||||
doc.edit_notices.clear();
|
||||
Self::sync_dirty(doc);
|
||||
// The change whose exit ran is undone (or redone without its scripts): skip no exit.
|
||||
doc.xfa_exited.clear();
|
||||
doc.generation += 1;
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
/// Redo the last edit of document `id` that [`Session::undo`] took back. Returns its label.
|
||||
pub fn redo(&mut self, id: DocId) -> Result<Label, EditError> {
|
||||
let doc = self.doc_mut(id)?;
|
||||
let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?;
|
||||
@@ -305,6 +325,8 @@ impl Session {
|
||||
}
|
||||
doc.edit_notices.clear();
|
||||
Self::sync_dirty(doc);
|
||||
// The change whose exit ran is undone (or redone without its scripts): skip no exit.
|
||||
doc.xfa_exited.clear();
|
||||
doc.generation += 1;
|
||||
Ok(label)
|
||||
}
|
||||
@@ -574,6 +596,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand on what an edit's XFA scripts showed or asked for (`out`), and when one of them ran away,
|
||||
/// turn the form's scripts off and say so.
|
||||
fn take_xfa_output(doc: &mut Document, out: js::JsOutput, ran_away: bool) {
|
||||
doc.js_output.append(out);
|
||||
if ran_away {
|
||||
doc.xfa_template = None;
|
||||
doc.xfa_scripts_off = true;
|
||||
doc.js_output.errors.push(XFA_SCRIPTS_OFF.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// What `edit`, taking `prev` to `next`, wants said: never silent (AGENTS.md §4).
|
||||
fn edit_notices(edit: &Edit, prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec<EditNotice> {
|
||||
// What it drew with `?` (a value or comment text its font can't draw, stored as typed).
|
||||
|
||||
@@ -145,6 +145,8 @@ impl Session {
|
||||
editor.redo.clear();
|
||||
Self::adopt_keys(doc);
|
||||
Self::sync_dirty(doc);
|
||||
// The changes whose exits ran are gone with the rest: skip no exit.
|
||||
doc.xfa_exited.clear();
|
||||
doc.generation += 1;
|
||||
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
|
||||
}
|
||||
|
||||
+1048
-6
File diff suppressed because it is too large
Load Diff
+625
-43
@@ -5,12 +5,12 @@
|
||||
//! 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};
|
||||
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaProp, XfaProps};
|
||||
use pdfcraft_js::xfa_session::{Script, XfaSession};
|
||||
use pdfcraft_xfa::model::Template;
|
||||
use pdfcraft_xfa::model::{Prop, Props, Template};
|
||||
use pdfcraft_xfa::{FormNode, LiveForm, NodeKind, ScriptEvent};
|
||||
|
||||
use crate::js::JsOutput;
|
||||
@@ -128,14 +128,102 @@ 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`]).
|
||||
pub time_scale: u32,
|
||||
}
|
||||
|
||||
/// The live form node `n`, with its descendants, as the script engine's snapshot of it.
|
||||
fn to_js(n: &FormNode) -> XfaNode {
|
||||
/// The script engine's copies of a form's property trees, by the address of the tree each was
|
||||
/// made from: made the first time a script of the form needs one, then shared by every later
|
||||
/// script and event of that form. They are kept in the form's template
|
||||
/// ([`pdfcraft_xfa::model::TemplateCache`]), so they go with it when the form is closed, and
|
||||
/// the lock is the form's own. Each entry holds a weak reference to its tree, which keeps the
|
||||
/// address from being reused while the entry exists; entries whose tree is gone (a tree of a
|
||||
/// copy of the template, such as one with a script's overrides applied) are dropped each time
|
||||
/// the map is used.
|
||||
type PropsCopies = HashMap<usize, (std::sync::Weak<Props>, Arc<XfaProps>)>;
|
||||
type PropsCache = std::sync::Mutex<PropsCopies>;
|
||||
|
||||
/// The form's map of copies, with the entries of trees that are gone dropped; `None` only when
|
||||
/// the template's cache holds something else. A poisoned lock only means a panic elsewhere
|
||||
/// while it was held: the map is still a valid memo.
|
||||
fn props_copies(tpl: &Template) -> Option<std::sync::MutexGuard<'_, PropsCopies>> {
|
||||
let mut copies = tpl.cache.get_or_init(PropsCache::default)?.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
copies.retain(|_, (tree, _)| tree.strong_count() > 0);
|
||||
Some(copies)
|
||||
}
|
||||
|
||||
/// The copy made of `p`, if there is one.
|
||||
fn copy_of(copies: &PropsCopies, p: &Arc<Props>) -> Option<Arc<XfaProps>> {
|
||||
let (tree, copy) = copies.get(&(Arc::as_ptr(p) as usize))?;
|
||||
(std::sync::Weak::as_ptr(tree) == Arc::as_ptr(p) && tree.strong_count() > 0).then(|| Arc::clone(copy))
|
||||
}
|
||||
|
||||
/// The script engine's copy of the template's property tree `p`.
|
||||
fn convert_props(p: &Props) -> XfaProps {
|
||||
fn prop(p: &Prop, depth: usize) -> XfaProp {
|
||||
XfaProp {
|
||||
tag: p.tag.clone(),
|
||||
attrs: p.attrs.clone(),
|
||||
// Shared, not copied: a validation's script can be long.
|
||||
text: p.text.clone(),
|
||||
// The parser keeps 8 levels; this bounds the walk whatever builds the tree.
|
||||
children: if depth > 64 { Vec::new() } else { p.children.iter().map(|c| prop(c, depth + 1)).collect() },
|
||||
}
|
||||
}
|
||||
XfaProps { attrs: p.attrs.clone(), elements: p.elements.iter().map(|e| prop(e, 0)).collect() }
|
||||
}
|
||||
|
||||
/// The live form node `n` of the form whose template is `tpl`, with its descendants, as the
|
||||
/// script engine's snapshot of it; the property trees are the form's shared copies
|
||||
/// ([`PropsCopies`]), not copied again. The map is locked only to look copies up and to add the
|
||||
/// ones made (outside the lock) for trees met for the first time.
|
||||
fn to_js(n: &FormNode, tpl: &Template) -> XfaNode {
|
||||
fn trees<'a>(n: &'a FormNode, out: &mut HashMap<usize, &'a Arc<Props>>, depth: usize) {
|
||||
out.entry(Arc::as_ptr(&n.props) as usize).or_insert(&n.props);
|
||||
if depth <= 256 {
|
||||
n.children.iter().for_each(|c| trees(c, out, depth + 1));
|
||||
}
|
||||
}
|
||||
let mut needed = HashMap::new();
|
||||
trees(n, &mut needed, 0);
|
||||
let mut found: HashMap<usize, Arc<XfaProps>> = HashMap::with_capacity(needed.len());
|
||||
let missing: Vec<&Arc<Props>> = {
|
||||
let copies = props_copies(tpl);
|
||||
needed
|
||||
.iter()
|
||||
.filter_map(|(&key, p)| match copies.as_ref().and_then(|c| copy_of(c, p)) {
|
||||
Some(copy) => {
|
||||
found.insert(key, copy);
|
||||
None
|
||||
}
|
||||
None => Some(*p),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if !missing.is_empty() {
|
||||
let made: Vec<(&Arc<Props>, Arc<XfaProps>)> = missing.into_iter().map(|p| (p, Arc::new(convert_props(p)))).collect();
|
||||
let mut copies = props_copies(tpl);
|
||||
for (p, copy) in made {
|
||||
let key = Arc::as_ptr(p) as usize;
|
||||
// Another thread may have made one meanwhile: keep the first.
|
||||
let copy = match copies.as_mut() {
|
||||
Some(c) => copy_of(c, p).unwrap_or_else(|| {
|
||||
c.insert(key, (Arc::downgrade(p), Arc::clone(©)));
|
||||
copy
|
||||
}),
|
||||
None => copy,
|
||||
};
|
||||
found.insert(key, copy);
|
||||
}
|
||||
}
|
||||
to_js_with(n, &found, 0)
|
||||
}
|
||||
|
||||
fn to_js_with(n: &FormNode, copies: &HashMap<usize, Arc<XfaProps>>, depth: usize) -> XfaNode {
|
||||
XfaNode {
|
||||
name: n.name.clone(),
|
||||
som: n.som.clone(),
|
||||
@@ -154,11 +242,62 @@ fn to_js(n: &FormNode) -> XfaNode {
|
||||
occur_min: n.occur_min,
|
||||
occur_max: n.occur_max,
|
||||
index: n.index,
|
||||
children: n.children.iter().map(to_js).collect(),
|
||||
children: if depth > 256 { Vec::new() } else { n.children.iter().map(|c| to_js_with(c, copies, depth + 1)).collect() },
|
||||
props: copies.get(&(Arc::as_ptr(&n.props) as usize)).cloned().unwrap_or_else(|| Arc::new(convert_props(&n.props))),
|
||||
formatted: n.display_picture.as_deref().and_then(|p| pdfcraft_xfa::formatted_value(p, &n.value)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of a run of `resetData` calls, applied together: each name once, in the order
|
||||
/// first given. A call without names resets the whole form, which takes in every other.
|
||||
#[derive(Default)]
|
||||
struct ResetRun {
|
||||
names: Vec<String>,
|
||||
/// `names` as a set, so adding a name costs the same however many there are (a script
|
||||
/// may pass hundreds of thousands).
|
||||
seen: HashSet<String>,
|
||||
whole: bool,
|
||||
}
|
||||
|
||||
impl ResetRun {
|
||||
fn add(&mut self, names: &[String]) {
|
||||
if self.whole {
|
||||
return;
|
||||
}
|
||||
if names.is_empty() {
|
||||
self.whole = true;
|
||||
self.names.clear();
|
||||
self.seen.clear();
|
||||
return;
|
||||
}
|
||||
for n in names {
|
||||
if self.seen.insert(n.clone()) {
|
||||
self.names.push(n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The names to reset; none for the whole form.
|
||||
fn names(&self) -> &[String] {
|
||||
if self.whole { &[] } else { &self.names }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -169,6 +308,10 @@ fn find_container<'a>(n: &'a FormNode, som: &str, depth: usize) -> Option<&'a Fo
|
||||
n.children.iter().find_map(|c| find_container(c, som, depth + 1))
|
||||
}
|
||||
|
||||
fn has_node(n: &FormNode, som: &str, depth: usize) -> bool {
|
||||
depth <= 64 && (n.som == som || n.children.iter().any(|c| has_node(c, som, depth + 1)))
|
||||
}
|
||||
|
||||
fn count_instances(root: &FormNode, parent_som: &str, name: &str) -> usize {
|
||||
let parent = if parent_som.is_empty() { Some(root) } else { find_container(root, parent_som, 0) };
|
||||
parent.map_or(0, |p| p.children.iter().filter(|c| c.name == name).count())
|
||||
@@ -198,15 +341,52 @@ fn plain_som(som: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Does `name` from a script (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a container
|
||||
/// such as `table`) name this SOM or one of its ancestors?
|
||||
fn som_matches(som: &str, name: &str) -> bool {
|
||||
let plain = plain_som(som);
|
||||
let n = plain_som(name.trim());
|
||||
let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n);
|
||||
let want: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect();
|
||||
let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect();
|
||||
!want.is_empty() && have.windows(want.len()).any(|w| w == want.as_slice())
|
||||
/// The names a script passed to a reset (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a
|
||||
/// container such as `table`), indexed, so matching an object against them costs the same
|
||||
/// however many names there are.
|
||||
struct ResetTargets {
|
||||
/// Each name's steps without indexes, joined by `.`.
|
||||
wants: HashSet<String>,
|
||||
/// The most steps of a name.
|
||||
longest: usize,
|
||||
}
|
||||
|
||||
impl ResetTargets {
|
||||
fn new(names: &[String]) -> Self {
|
||||
let mut wants = HashSet::new();
|
||||
let mut longest = 0;
|
||||
for name in names {
|
||||
let n = plain_som(name.trim());
|
||||
let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n);
|
||||
let steps: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect();
|
||||
if !steps.is_empty() {
|
||||
longest = longest.max(steps.len());
|
||||
wants.insert(steps.join("."));
|
||||
}
|
||||
}
|
||||
ResetTargets { wants, longest }
|
||||
}
|
||||
|
||||
/// Does a name name this SOM or one of its ancestors: is it a run of the SOM's steps? The
|
||||
/// runs are looked up, at most [`Self::longest`] steps each, so the work depends on the
|
||||
/// SOM's depth and not on how many names there are.
|
||||
fn matches(&self, som: &str) -> bool {
|
||||
let plain = plain_som(som);
|
||||
let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect();
|
||||
for start in 0..have.len() {
|
||||
let mut run = String::new();
|
||||
for (k, step) in have.iter().skip(start).take(self.longest).enumerate() {
|
||||
if k > 0 {
|
||||
run.push('.');
|
||||
}
|
||||
run.push_str(step);
|
||||
if self.wants.contains(&run) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A generated field as the runner tracks it: kind, value and on state.
|
||||
@@ -256,6 +436,11 @@ struct Runner<'a, 'b> {
|
||||
skipped: usize,
|
||||
relayouts: usize,
|
||||
relayouts_skipped: bool,
|
||||
/// Lay the form out once, when the event ends, rather than after every script that changed
|
||||
/// its structure (opening: see [`on_open`]).
|
||||
defer_relayout: bool,
|
||||
/// A deferred layout is owed.
|
||||
relayout_owed: bool,
|
||||
dropped_output: usize,
|
||||
truncated_warned: bool,
|
||||
data: Option<pdfcraft_xfa::DataNode>,
|
||||
@@ -270,17 +455,25 @@ struct Runner<'a, 'b> {
|
||||
fields: Option<HashMap<String, FieldInfo>>,
|
||||
/// The live form as last built; dropped when a script changed the form.
|
||||
cached: Option<LiveForm>,
|
||||
/// `cached` as the scripts get it, made once and sent to each script until the live form
|
||||
/// changes in any way (a value too): the script engine reuses what it built from it.
|
||||
js_root: Option<Arc<XfaNode>>,
|
||||
/// The script engine for this event: one thread for all its scripts, each run in a fresh
|
||||
/// context on the form as it is then (`cached`, kept up to date in place for values and
|
||||
/// 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);
|
||||
@@ -294,6 +487,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
skipped: 0,
|
||||
relayouts: 0,
|
||||
relayouts_skipped: false,
|
||||
defer_relayout: false,
|
||||
relayout_owed: false,
|
||||
dropped_output: 0,
|
||||
truncated_warned: false,
|
||||
data,
|
||||
@@ -305,8 +500,10 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
by_path: None,
|
||||
fields: None,
|
||||
cached: None,
|
||||
js_root: None,
|
||||
session: None,
|
||||
changes: Changes::default(),
|
||||
numbered: HashSet::new(),
|
||||
embedded_changed: false,
|
||||
}
|
||||
}
|
||||
@@ -315,11 +512,12 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
/// script.
|
||||
fn invalidate(&mut self) {
|
||||
self.cached = None;
|
||||
self.js_root = None;
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
@@ -331,6 +529,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
let op = pdfcraft_xfa::DataOp::Value { path, text };
|
||||
self.data.get_or_insert_with(Default::default).apply(&op);
|
||||
self.pending.push(op);
|
||||
// The value changes in the live form below: the scripts' copy is made again.
|
||||
self.js_root = None;
|
||||
let refreshed = match self.cached.as_mut() {
|
||||
Some(live) => pdfcraft_xfa::refresh_value(&mut live.root, self.tpl, self.data.as_ref(), som).is_some(),
|
||||
None => true,
|
||||
@@ -342,7 +542,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
|
||||
fn live(&mut self) -> &LiveForm {
|
||||
if self.cached.is_none() {
|
||||
let live = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
|
||||
let uses = pdfcraft_xfa::page_area_uses(self.doc, self.tpl);
|
||||
let live = pdfcraft_xfa::form_tree_paged(self.tpl, self.data.as_ref(), &self.ov, &uses);
|
||||
if live.truncated && !self.truncated_warned {
|
||||
self.truncated_warned = true;
|
||||
self.error(format!(
|
||||
@@ -545,11 +746,37 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
fn reset(&mut self, names: &[String]) -> Result<(), String> {
|
||||
self.flush()?;
|
||||
let by_som = self.by_som().clone();
|
||||
let listed: Option<Vec<String>> = if names.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect())
|
||||
let targets = (!names.is_empty()).then(|| ResetTargets::new(names));
|
||||
let wanted = |som: &str| targets.as_ref().is_none_or(|t| t.matches(som));
|
||||
let listed: Option<Vec<String>> = targets.as_ref().map(|_| by_som.iter().filter(|(som, _)| wanted(som)).map(|(_, f)| f.clone()).collect());
|
||||
// Fields and exclusion groups of the live form with no widget yet (a row an opening
|
||||
// script added, before the form is laid out again): their data goes back to the
|
||||
// template's default too, or the value would stay and be laid out and saved.
|
||||
let unshown: Vec<String> = {
|
||||
let live = self.live();
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<&pdfcraft_xfa::FormNode> = vec![&live.root];
|
||||
while let Some(n) = stack.pop() {
|
||||
if matches!(n.kind, Some(pdfcraft_xfa::NodeKind::Field | pdfcraft_xfa::NodeKind::ExclGroup))
|
||||
&& !by_som.contains_key(&n.som)
|
||||
&& wanted(&n.som)
|
||||
{
|
||||
out.push(n.som.clone());
|
||||
}
|
||||
stack.extend(n.children.iter());
|
||||
}
|
||||
out
|
||||
};
|
||||
for som in unshown {
|
||||
// A floating field rich text embeds goes back to its default with every row that
|
||||
// holds it (below).
|
||||
if pdfcraft_xfa::field_is_embedded(self.tpl, &som) {
|
||||
continue;
|
||||
}
|
||||
let Some(text) = pdfcraft_xfa::reset_default(self.tpl, &som) else { continue };
|
||||
let path = pdfcraft_xfa::data_path(self.tpl, &som);
|
||||
self.reset_data(path, text);
|
||||
}
|
||||
// Names no widget answers to may still name a floating field (below).
|
||||
let widgets = !listed.as_ref().is_some_and(Vec::is_empty);
|
||||
if widgets {
|
||||
@@ -557,6 +784,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
self.fields = None;
|
||||
let infos = self.fields().clone();
|
||||
let listed: Option<HashSet<&String>> = listed.as_ref().map(|l| l.iter().collect());
|
||||
for (som, name) in by_som.iter().filter(|_| widgets) {
|
||||
if listed.as_ref().is_some_and(|l| !l.contains(name)) {
|
||||
continue;
|
||||
@@ -566,17 +794,12 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
continue;
|
||||
}
|
||||
let path = pdfcraft_xfa::data_path(self.tpl, som);
|
||||
let text = data_text(info);
|
||||
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
|
||||
self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path);
|
||||
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
|
||||
self.changes.values += 1;
|
||||
}
|
||||
self.reset_data(path, data_text(info));
|
||||
}
|
||||
// A floating field without a widget (a hidden one, as forms place them) goes back to
|
||||
// its default too, and the text that embeds it shows that.
|
||||
for (som, default) in pdfcraft_xfa::embedded_fields(self.tpl) {
|
||||
if by_som.contains_key(&som) || (!names.is_empty() && !names.iter().any(|n| som_matches(&som, n))) {
|
||||
if by_som.contains_key(&som) || !wanted(&som) {
|
||||
continue;
|
||||
}
|
||||
// Every row that holds it, not only the first.
|
||||
@@ -594,6 +817,16 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put a reset's `text` in the data at `path`, unless it already holds it: a multi-select
|
||||
/// list's picks (`value` elements) are compared as its lines (the write replaces them).
|
||||
fn reset_data(&mut self, path: pdfcraft_xfa::DataPath, text: String) {
|
||||
if self.data.as_ref().and_then(|d| d.value_at(&path)).unwrap_or_default() != text {
|
||||
self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path);
|
||||
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
|
||||
self.changes.values += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply what a script of `ev` did. Returns whether the form has to be laid out again.
|
||||
fn apply_effects(&mut self, ev: &ScriptEvent, effects: &[XfaEffect]) -> Result<bool, String> {
|
||||
let mut relayout = false;
|
||||
@@ -612,7 +845,9 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for e in effects {
|
||||
// Resets in a row, applied together when the run of them ends.
|
||||
let mut pending_reset: Option<ResetRun> = None;
|
||||
for (at, e) in effects.iter().enumerate() {
|
||||
// Only a click script may print, save, open links or move the focus (not the
|
||||
// calculations a click sets off, nor anything run on open or on a change).
|
||||
if let Some(what) = click_only(e)
|
||||
@@ -624,7 +859,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) {
|
||||
@@ -661,7 +898,15 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
relayout = true;
|
||||
}
|
||||
XfaEffect::MessageBox(m) => self.alert(m.clone()),
|
||||
XfaEffect::ResetData(names) => self.reset(names)?,
|
||||
XfaEffect::ResetData(names) => {
|
||||
// A script resetting several subforms in a row (`resetData` once for each)
|
||||
// resets them together: the same fields end up at their defaults.
|
||||
pending_reset.get_or_insert_with(ResetRun::default).add(names);
|
||||
let next_is_reset = effects.get(at.saturating_add(1)).is_some_and(|e| matches!(e, XfaEffect::ResetData(_)));
|
||||
if !next_is_reset && let Some(run) = pending_reset.take() {
|
||||
self.reset(run.names())?;
|
||||
}
|
||||
}
|
||||
XfaEffect::Print => self.run.out.requests.push(Request::Print),
|
||||
XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs),
|
||||
XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())),
|
||||
@@ -672,6 +917,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,
|
||||
}
|
||||
@@ -686,11 +938,25 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
self.skipped += 1;
|
||||
return Ok(None);
|
||||
}
|
||||
// An event listed before a script relaid the form out can name an object that is gone
|
||||
// (a page the form no longer fills, a row removed): it isn't run, rather than run with
|
||||
// `this` at some other object.
|
||||
if !has_node(&self.live().root, &ev.som, 0) {
|
||||
return Ok(None);
|
||||
}
|
||||
self.runs += 1;
|
||||
#[cfg(test)]
|
||||
self.check_live();
|
||||
let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() };
|
||||
let root = to_js(&self.live().root);
|
||||
let tpl = self.tpl;
|
||||
let root = match &self.js_root {
|
||||
Some(r) => Arc::clone(r),
|
||||
None => {
|
||||
let r = Arc::new(to_js(&self.live().root, tpl));
|
||||
self.js_root = Some(Arc::clone(&r));
|
||||
r
|
||||
}
|
||||
};
|
||||
let script = Script {
|
||||
source: ev.script.clone(),
|
||||
formcalc: ev.formcalc,
|
||||
@@ -717,7 +983,11 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
self.error(format!("{} ({}): {e}", ev.som, ev.activity));
|
||||
}
|
||||
if self.apply_effects(ev, &o.effects)? {
|
||||
self.relayout()?;
|
||||
if self.defer_relayout {
|
||||
self.relayout_owed = true;
|
||||
} else {
|
||||
self.relayout()?;
|
||||
}
|
||||
}
|
||||
Ok(Some(o))
|
||||
}
|
||||
@@ -747,9 +1017,10 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
fn finish(mut self) -> Result<Changes, String> {
|
||||
#[cfg(test)]
|
||||
self.check_live();
|
||||
if self.relayouts_skipped || self.embedded_changed {
|
||||
if self.relayouts_skipped || self.relayout_owed || self.embedded_changed {
|
||||
self.relayouts = 0;
|
||||
self.relayouts_skipped = false;
|
||||
self.relayout_owed = false;
|
||||
self.relayout()?;
|
||||
}
|
||||
self.flush()?;
|
||||
@@ -780,7 +1051,8 @@ impl Runner<'_, '_> {
|
||||
#[cfg(test)]
|
||||
fn check_live(&mut self) {
|
||||
let Some(live) = self.cached.as_ref() else { return };
|
||||
let fresh = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
|
||||
let uses = pdfcraft_xfa::page_area_uses(self.doc, self.tpl);
|
||||
let fresh = pdfcraft_xfa::form_tree_paged(self.tpl, self.data.as_ref(), &self.ov, &uses);
|
||||
let (mut have, mut want) = (Vec::new(), Vec::new());
|
||||
collect_fields(&live.root, &mut have, 0);
|
||||
collect_fields(&fresh.root, &mut want, 0);
|
||||
@@ -908,13 +1180,40 @@ 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);
|
||||
// The scripts read the live form (template, data and overrides), not the pages, so the
|
||||
// pages are laid out once, after the last of them. Laying a large form out after every
|
||||
// script that hides a subform spent the time limit on the first few, and the form was kept
|
||||
// half initialized: a page its initialize scripts hide stayed open to typing until the
|
||||
// next opening hid it and reset its data.
|
||||
r.defer_relayout = true;
|
||||
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()
|
||||
}
|
||||
@@ -922,16 +1221,34 @@ pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mu
|
||||
/// After fields `names` took new values: each one's change, exit and validate scripts, then
|
||||
/// the calculations once (or only those, after a reset). A validate script that answers false,
|
||||
/// or fails, shows its message; the value stays, as Acrobat marks the field invalid rather
|
||||
/// than reverting it.
|
||||
pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names: &[String], run: &mut XfaRun) -> Result<(), String> {
|
||||
/// than reverting it. The SOM paths whose exit scripts ran go into `exited`.
|
||||
pub(crate) fn on_changes(
|
||||
doc: &mut pdfcraft_cos::Document,
|
||||
tpl: &Template,
|
||||
names: &[String],
|
||||
run: &mut XfaRun,
|
||||
exited: &mut Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
|
||||
for name in names {
|
||||
let som = r.by_som().iter().find(|(_, f)| *f == name).map(|(s, _)| s.clone());
|
||||
let Some(som) = som else { continue };
|
||||
let value = r.fields().get(name).map(|f| f.value.join("\n")).unwrap_or_default();
|
||||
for ev in r.events_of(&som, "change").into_iter().chain(r.events_of(&som, "exit")) {
|
||||
for ev in r.events_of(&som, "change") {
|
||||
r.run(&ev, &value)?;
|
||||
}
|
||||
// Noted only when an exit script ran, whether it succeeded or failed: it has run for this
|
||||
// focus loss, and a failed script keeps what it changed before its error, so running it
|
||||
// again would repeat that and the error. One the event's budget skipped, or one never
|
||||
// reached because an earlier script stopped the run, is still to run when the field
|
||||
// loses focus.
|
||||
let mut ran_exit = false;
|
||||
for ev in r.events_of(&som, "exit") {
|
||||
ran_exit |= r.run(&ev, &value)?.is_some();
|
||||
}
|
||||
if ran_exit {
|
||||
exited.push(som.clone());
|
||||
}
|
||||
for ev in r.events_of(&som, "validate") {
|
||||
let Some(o) = r.run(&ev, &value)? else { continue };
|
||||
if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() {
|
||||
@@ -992,14 +1309,62 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s
|
||||
r.finish().map(|_| ())
|
||||
}
|
||||
|
||||
/// A field gaining (`enter`) or losing (`exit`) focus: its scripts for that activity, then, when
|
||||
/// they changed something, the calculations. Returns what changed; nothing (and no error) when
|
||||
/// the field has no such script, so a focus change with nothing to run leaves the form alone.
|
||||
pub(crate) fn on_focus(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<Changes, String> {
|
||||
let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS);
|
||||
let events = r.events_of(som, activity);
|
||||
if events.is_empty() {
|
||||
return Ok(Changes::default());
|
||||
}
|
||||
// The field's value, as the script's event sees it (`xfa.event.prevText`, `newText`).
|
||||
let value = r.by_som().get(som).cloned().and_then(|name| r.fields().get(&name).map(|f| f.value.join("\n"))).unwrap_or_default();
|
||||
for ev in events {
|
||||
r.run(&ev, &value)?;
|
||||
}
|
||||
if !r.changes.is_empty() {
|
||||
// The calculations that follow get a budget of their own.
|
||||
r.clock = Clock::start(OPEN_BUDGET_MS.saturating_mul(u64::from(r.run.time_scale)));
|
||||
r.calculates()?;
|
||||
}
|
||||
r.finish()
|
||||
}
|
||||
|
||||
/// The SOM path of widget `widget` of field `field`: each radio button of a group has its own,
|
||||
/// and a field's copies on several pages share one. `None` when the field has no widget
|
||||
/// `widget`: the field's own path is only for a widget that records none, so a widget index
|
||||
/// out of range never reaches the field (for a radio field, the group).
|
||||
pub(crate) fn widget_som(doc: &pdfcraft_cos::Document, field: &pdfcraft_forms::Field, widget: usize) -> Option<String> {
|
||||
let som_of = |obj: pdfcraft_cos::ObjRef| {
|
||||
let object = doc.get(obj);
|
||||
object.as_dict()?.get(pdfcraft_xfa::SOM_KEY).map(|s| doc.resolve(s))?.as_string().map(|s| s.to_text())
|
||||
};
|
||||
let w = field.widgets.get(widget)?;
|
||||
som_of(w.obj).or_else(|| som_of(field.obj))
|
||||
}
|
||||
|
||||
/// Does the live form give SOM path `som` an `activity` script?
|
||||
pub(crate) fn has_event(doc: &pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str) -> bool {
|
||||
// Page-master copies have a SOM path of their own on each page (`master[1]…`).
|
||||
let uses = pdfcraft_xfa::page_area_uses(doc, tpl);
|
||||
let live = pdfcraft_xfa::form_tree_paged(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc), &uses);
|
||||
live.events.iter().any(|e| e.som == som && e.activity == activity)
|
||||
}
|
||||
|
||||
/// The SOM path of a generated field whose template has a click script.
|
||||
pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field) -> Option<String> {
|
||||
// Page-master copies have distinct PDF field names but share a SOM path. Reversing
|
||||
// fields_by_som would lose every copy except the last: use the clicked field itself.
|
||||
scripted_som(doc, tpl, field, "click")
|
||||
}
|
||||
|
||||
/// The SOM path of a generated field whose template has an `activity` script.
|
||||
pub(crate) fn scripted_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field, activity: &str) -> Option<String> {
|
||||
// The field's own SOM path: each page-master copy has one of its own (`master[1]…`).
|
||||
let object = doc.get(field.obj);
|
||||
let som = object.as_dict()?.get(pdfcraft_xfa::SOM_KEY).map(|s| doc.resolve(s))?.as_string()?.to_text();
|
||||
let live = pdfcraft_xfa::form_tree(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc));
|
||||
live.events.iter().any(|e| e.som == som && e.activity == "click").then_some(som)
|
||||
let uses = pdfcraft_xfa::page_area_uses(doc, tpl);
|
||||
let live = pdfcraft_xfa::form_tree_paged(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc), &uses);
|
||||
live.events.iter().any(|e| e.som == som && e.activity == activity).then_some(som)
|
||||
}
|
||||
|
||||
/// The parsed template of a laid-out XFA form, kept for the document's life, when it has
|
||||
@@ -1027,20 +1392,237 @@ pub(crate) fn mark_script_buttons(doc: &pdfcraft_cos::Document, fields: &mut [pd
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A run of resets keeps each name once, in the order first given, and a reset of the whole
|
||||
/// form takes in the rest, before or after it.
|
||||
#[test]
|
||||
fn resets_in_a_row_keep_each_name_once_and_a_whole_form_reset_wins() {
|
||||
let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::<Vec<_>>();
|
||||
let mut run = ResetRun::default();
|
||||
run.add(&v(&["a", "b", "a"]));
|
||||
run.add(&v(&["c", "b"]));
|
||||
assert_eq!(run.names(), v(&["a", "b", "c"]).as_slice());
|
||||
run.add(&[]);
|
||||
assert!(run.names().is_empty());
|
||||
run.add(&v(&["d"]));
|
||||
assert!(run.names().is_empty(), "still the whole form");
|
||||
let mut first = ResetRun::default();
|
||||
first.add(&[]);
|
||||
first.add(&v(&["a"]));
|
||||
assert!(first.names().is_empty());
|
||||
}
|
||||
|
||||
/// Adding a name to a run costs the same however many it holds: two runs of 60,000
|
||||
/// distinct names merge at once (comparing each name with every kept one is 3.6 billion
|
||||
/// comparisons).
|
||||
#[test]
|
||||
fn a_long_run_of_reset_names_merges_in_linear_time() {
|
||||
let a: Vec<String> = (0..60_000).map(|i| format!("form1[0].a{i}")).collect();
|
||||
let b: Vec<String> = (0..60_000).map(|i| format!("form1[0].b{i}")).collect();
|
||||
let started = std::time::Instant::now();
|
||||
let mut run = ResetRun::default();
|
||||
run.add(&a);
|
||||
run.add(&b);
|
||||
assert_eq!(run.names().len(), 120_000);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// Matching objects against a reset's names costs the same however many names there are:
|
||||
/// 5,000 objects against 50,000 names that miss them all is 250 million comparisons when
|
||||
/// each object is compared with every name.
|
||||
#[test]
|
||||
fn matching_many_objects_against_many_reset_names_is_bounded() {
|
||||
let names: Vec<String> = (0..50_000).map(|i| format!("form1.page1.other{i}")).collect();
|
||||
let started = std::time::Instant::now();
|
||||
let targets = ResetTargets::new(&names);
|
||||
let hits = (0..5_000).filter(|i| targets.matches(&format!("form1[0].page1[0].table[0].row[{i}].item[0]"))).count();
|
||||
assert_eq!(hits, 0);
|
||||
assert!(targets.matches("form1[0].page1[0].other49999[0]"));
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_names_match_indexed_som_paths() {
|
||||
let som_matches = |som: &str, name: &str| ResetTargets::new(&[name.to_string()]).matches(som);
|
||||
assert!(som_matches("form[0].page1[0].qty[0]", "qty"));
|
||||
assert!(som_matches("form[0].page1[0].qty[0]", "page1.qty"));
|
||||
assert!(som_matches("form[0].page1[0].qty[0]", "xfa.form.form.page1.qty"));
|
||||
assert!(som_matches("form[0].page1[0].qty[0]", "$form.form.page1.qty"));
|
||||
assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table"));
|
||||
assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table.row[0].amount"));
|
||||
assert!(!som_matches("form[0].page1[0].qty[0]", "qt"));
|
||||
assert!(!som_matches("form[0].page1[0].price[0]", "qty"));
|
||||
assert!(!som_matches("form[0].page1[0].qty[0]", "page1.page1.qty"));
|
||||
assert!(!som_matches("form[0].page1[0].qty[0]", " . "));
|
||||
assert!(!ResetTargets::new(&[]).matches("form[0].qty[0]"));
|
||||
let many = ResetTargets::new(&["price".into(), "page1.qty".into()]);
|
||||
assert!(many.matches("form[0].page1[0].qty[0]") && many.matches("form[0].page2[0].price[0]"));
|
||||
assert_eq!(
|
||||
split_instance(&Template::default(), "form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)),
|
||||
Some((2, "form[0].table[0]".into(), "row".into(), 2))
|
||||
);
|
||||
}
|
||||
|
||||
/// The scripts' snapshot shares the template's property trees: the next script's, and the
|
||||
/// next event's (a live form built again, from a copy of the template as overrides make
|
||||
/// one), point at the trees the first converted rather than converting them again.
|
||||
#[test]
|
||||
fn snapshots_share_property_trees_across_runs_and_events() {
|
||||
let fields: String = (0..2_000)
|
||||
.map(|i| format!(r#"<field name="f{i}"><ui><choiceList/></ui><border><edge/><fill><color value="1,2,3"/></fill></border></field>"#))
|
||||
.collect();
|
||||
let xml = format!(
|
||||
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">{fields}</subform></template>"#
|
||||
);
|
||||
let (tpl, _) = pdfcraft_xfa::parse(&xml).unwrap();
|
||||
let live = pdfcraft_xfa::form_tree(&tpl, None, &Default::default());
|
||||
let a = to_js(&live.root, &tpl);
|
||||
let b = to_js(&live.root, &tpl);
|
||||
// A tree built again (with a script's overrides) is still the form's: same copies.
|
||||
let copy = pdfcraft_xfa::apply_overrides(&tpl, &Default::default());
|
||||
let c = to_js(&pdfcraft_xfa::form_tree(©, None, &Default::default()).root, &tpl);
|
||||
assert_eq!(a.children.len(), 2_000);
|
||||
for ((x, y), z) in a.children.iter().zip(&b.children).zip(&c.children) {
|
||||
assert!(Arc::ptr_eq(&x.props, &y.props) && Arc::ptr_eq(&x.props, &z.props));
|
||||
}
|
||||
assert_eq!(a.children[0].props.elements[1].children[1].children[0].attrs, [("value".to_string(), "1,2,3".to_string())]);
|
||||
}
|
||||
|
||||
/// A form's copies of its property trees are its own: kept for its later snapshots, and
|
||||
/// dropped with its template when the form is closed, whatever other forms do.
|
||||
#[test]
|
||||
fn a_closed_forms_property_copies_go_with_its_template() {
|
||||
let xml = |name: &str| {
|
||||
format!(
|
||||
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb"><field name="{name}"><border><edge/></border></field></subform></template>"#
|
||||
)
|
||||
};
|
||||
let (closed, _) = pdfcraft_xfa::parse(&xml("a")).unwrap();
|
||||
let (open, _) = pdfcraft_xfa::parse(&xml("b")).unwrap();
|
||||
let live = pdfcraft_xfa::form_tree(&closed, None, &Default::default());
|
||||
let snapshot = to_js(&live.root, &closed);
|
||||
let copy = Arc::downgrade(&snapshot.children[0].props);
|
||||
drop(snapshot);
|
||||
assert!(copy.upgrade().is_some(), "kept for the form's next snapshot");
|
||||
// Another form's snapshot neither keeps nor needs this form's copies.
|
||||
let other = to_js(&pdfcraft_xfa::form_tree(&open, None, &Default::default()).root, &open);
|
||||
assert!(!Arc::ptr_eq(&other.children[0].props, ©.upgrade().unwrap_or_default()));
|
||||
drop(live);
|
||||
drop(closed);
|
||||
assert!(copy.upgrade().is_none(), "closing the form drops its copies at once");
|
||||
}
|
||||
|
||||
/// A live form node whose property tree is told apart by an attribute.
|
||||
fn marked(mark: &str) -> FormNode {
|
||||
FormNode { props: Arc::new(Props { attrs: vec![("mark".into(), mark.into())], elements: Vec::new() }), ..Default::default() }
|
||||
}
|
||||
|
||||
/// The copy made of a tree is the one every later snapshot of the form gets, and the map
|
||||
/// holds one entry for it.
|
||||
#[test]
|
||||
fn later_snapshots_of_a_form_get_the_same_copy() {
|
||||
let tpl = Template::default();
|
||||
let node = marked("a");
|
||||
let first = to_js(&node, &tpl);
|
||||
let second = to_js(&node, &tpl);
|
||||
assert!(Arc::ptr_eq(&first.props, &second.props));
|
||||
let copies = props_copies(&tpl).expect("the engine's map");
|
||||
assert_eq!(copies.len(), 1);
|
||||
assert!(copy_of(&copies, &node.props).is_some_and(|c| Arc::ptr_eq(&c, &first.props)));
|
||||
}
|
||||
|
||||
/// The map is keyed by a tree's address, so a tree freed and another made at the same
|
||||
/// address must never get the first one's copy (the ABA case). An entry's weak reference
|
||||
/// keeps the freed tree's allocation, so its address is not reused while the entry exists;
|
||||
/// the next use of the map drops the entry, and only then can a new tree take the address,
|
||||
/// with no entry left to find. Each new tree below is dropped at the end of its turn, so the
|
||||
/// allocator is free to put the next one where it was.
|
||||
#[test]
|
||||
fn a_freed_trees_copy_never_goes_to_a_tree_at_its_address() {
|
||||
let tpl = Template::default();
|
||||
let old = marked("old");
|
||||
let addr = Arc::as_ptr(&old.props) as usize;
|
||||
drop(to_js(&old, &tpl));
|
||||
drop(old);
|
||||
let made: Vec<Arc<Props>> = (0..4_096).map(|_| Arc::new(Props::default())).collect();
|
||||
assert!(made.iter().all(|p| Arc::as_ptr(p) as usize != addr), "the address is held while the entry exists");
|
||||
drop(made);
|
||||
assert!(props_copies(&tpl).is_some_and(|c| c.is_empty()), "the next use drops the entry");
|
||||
for i in 0..4_096 {
|
||||
let node = marked(&format!("new {i}"));
|
||||
assert_eq!(to_js(&node, &tpl).props.attrs, node.props.attrs);
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry under a tree's address that was made from another tree is not that tree's copy:
|
||||
/// a lookup passes it by, and the snapshot's own lookup replaces it with the tree's copy.
|
||||
#[test]
|
||||
fn an_entry_made_from_another_tree_is_not_returned() {
|
||||
let tpl = Template::default();
|
||||
let node = marked("this");
|
||||
let other = Arc::new(Props { attrs: vec![("mark".into(), "other".into())], elements: Vec::new() });
|
||||
let gone = Arc::new(Props { attrs: vec![("mark".into(), "gone".into())], elements: Vec::new() });
|
||||
let key = Arc::as_ptr(&node.props) as usize;
|
||||
let mut copies = PropsCopies::new();
|
||||
// Its tree is alive but elsewhere.
|
||||
copies.insert(key, (Arc::downgrade(&other), Arc::new(convert_props(&other))));
|
||||
assert!(copy_of(&copies, &node.props).is_none());
|
||||
// Its tree is gone.
|
||||
copies.insert(key, (Arc::downgrade(&gone), Arc::new(convert_props(&gone))));
|
||||
drop(gone);
|
||||
assert!(copy_of(&copies, &node.props).is_none());
|
||||
// Through a snapshot: the stray entry is passed by and replaced.
|
||||
props_copies(&tpl).expect("the engine's map").insert(key, (Arc::downgrade(&other), Arc::new(convert_props(&other))));
|
||||
assert_eq!(to_js(&node, &tpl).props.attrs, node.props.attrs);
|
||||
assert!(props_copies(&tpl).and_then(|c| copy_of(&c, &node.props)).is_some_and(|c| c.attrs == node.props.attrs));
|
||||
}
|
||||
|
||||
/// Trees of other templates (the template read again) that a snapshot met leave the map once
|
||||
/// they are gone: however many come and go, it holds the form's own trees and no more.
|
||||
#[test]
|
||||
fn the_map_does_not_grow_with_trees_that_are_gone() {
|
||||
let xml = r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb"><field name="a"><border><edge/></border></field><field name="b"/></subform></template>"#;
|
||||
let (tpl, _) = pdfcraft_xfa::parse(xml).unwrap();
|
||||
let live = pdfcraft_xfa::form_tree(&tpl, None, &Default::default());
|
||||
drop(to_js(&live.root, &tpl));
|
||||
let own = props_copies(&tpl).map_or(0, |c| c.len());
|
||||
assert!(own >= 3, "{own}");
|
||||
for _ in 0..500 {
|
||||
let (again, _) = pdfcraft_xfa::parse(xml).unwrap();
|
||||
drop(to_js(&pdfcraft_xfa::form_tree(&again, None, &Default::default()).root, &tpl));
|
||||
}
|
||||
assert_eq!(props_copies(&tpl).map_or(0, |c| c.len()), own);
|
||||
}
|
||||
|
||||
/// A field's click script reads its own and other objects' properties through a laid-out
|
||||
/// dynamic form: template values, the spec's defaults, and a write read back, which changes
|
||||
/// nothing on the form.
|
||||
#[test]
|
||||
fn a_click_script_reads_field_properties() {
|
||||
let template = pdfcraft_xfa::fixtures::scripted_template()
|
||||
.replace(
|
||||
r#"<field name="hello" w="1in" h="0.3in"><ui><button/></ui>"#,
|
||||
r#"<field name="hello" w="1in" h="0.3in"><ui><button/></ui><border><fill><color value="200,200,255"/></fill></border>"#,
|
||||
)
|
||||
.replace(
|
||||
r#"xfa.host.messageBox("Hello " + qty.rawValue);"#,
|
||||
r#"qty.validate.nullTest = "error"; xfa.host.messageBox([this.ui.oneOfChild.className, this.border.fill.color.value, this.border.presence, qty.ui.oneOfChild.className, qty.validate.message.text.value, qty.validate.nullTest, table.row.occur.max, more.ui.oneOfChild.className, this.isPropertySpecified("border"), qty.isPropertySpecified("border")].join("|"));"#,
|
||||
);
|
||||
let mut s = crate::Session::new();
|
||||
let id = s.open("props.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
|
||||
let _ = s.take_js_output(id);
|
||||
let fields =
|
||||
|s: &crate::Session| s.get(id).unwrap().form.iter().map(|f| (f.name.clone(), f.value.clone(), f.read_only())).collect::<Vec<_>>();
|
||||
let before = fields(&s);
|
||||
let out = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(
|
||||
out.alerts,
|
||||
[pdfcraft_js::Message::plain("button|200,200,255|visible|numericEdit|Quantity must be 100 or less|error|-1|checkButton|true|false")]
|
||||
);
|
||||
assert!(s.take_js_output(id).errors.is_empty());
|
||||
assert_eq!(fields(&s), before, "a property write changes nothing yet");
|
||||
}
|
||||
|
||||
fn field(obj: pdfcraft_cos::ObjRef, kind: pdfcraft_forms::FieldKind, value: &[&str]) -> pdfcraft_forms::Field {
|
||||
pdfcraft_forms::Field {
|
||||
name: "f".into(),
|
||||
|
||||
@@ -120,6 +120,18 @@ pub(crate) fn decode_data_file(bytes: &[u8]) -> Option<Result<String, String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The on state a radio group's XFA data `item` selects: the button saving writes `item` for
|
||||
/// (`/PCOn`, else its `/Opt` entry, else its state; `pdfcraft_xfa::radio_state_for_item`), so a
|
||||
/// saved selection reopens on the same button; else `item` read as an export value, then as an
|
||||
/// on-state name ([`pdfcraft_forms::Field::state_for_export`]), as data from elsewhere is; else
|
||||
/// `item` itself.
|
||||
fn radio_button(doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, datum: Option<&pdfcraft_xfa::FieldDatum>, item: &str) -> String {
|
||||
datum
|
||||
.and_then(|d| pdfcraft_xfa::radio_state_for_item(doc, d, item))
|
||||
.or_else(|| f.state_for_export(item).map(str::to_owned))
|
||||
.unwrap_or_else(|| item.to_owned())
|
||||
}
|
||||
|
||||
/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed,
|
||||
/// buttons that did nothing but run scripts, and fields whose value differs from the form's
|
||||
/// data (data PdfCraft could not read into them, now lost).
|
||||
@@ -133,7 +145,16 @@ pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> R
|
||||
match value {
|
||||
pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(),
|
||||
pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on,
|
||||
pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(),
|
||||
// The field holds an on-state name; the data names a button by its item. They agree
|
||||
// when the data holds what saving writes (`/PCOn`, the `/Opt` entry or the state
|
||||
// name), or names the same button as opening reads it ([`radio_button`]).
|
||||
pdfcraft_xfa::FieldData::Radio(sel) => {
|
||||
let state = f.value.first().map(String::as_str);
|
||||
let datum = data.iter().find(|d| d.name == *name);
|
||||
let same_button = sel.as_deref().map(|s| radio_button(doc, f, datum, s)).as_deref() == state;
|
||||
let written = datum.map(|d| pdfcraft_xfa::radio_dataset_value(doc, d, &f.value.first().cloned()));
|
||||
!same_button && written.as_deref() != Some(sel.as_deref().unwrap_or_default())
|
||||
}
|
||||
pdfcraft_xfa::FieldData::None => false,
|
||||
}
|
||||
})
|
||||
@@ -217,12 +238,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);
|
||||
}
|
||||
@@ -283,7 +314,13 @@ pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Resu
|
||||
}
|
||||
pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)),
|
||||
pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)),
|
||||
pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)),
|
||||
// The data holds the item; the field takes the on state of the button saving writes it
|
||||
// for ([`radio_button`]), which with `/Opt` may be a position (`1` is the state `/0`).
|
||||
pdfcraft_xfa::FieldData::Radio(sel) => {
|
||||
let datum = data.iter().find(|d| d.name == name);
|
||||
let state = sel.map(|v| radio_button(doc, f, datum, &v));
|
||||
(f.value.first() != state.as_ref()).then_some(FieldValue::Radio(state))
|
||||
}
|
||||
pdfcraft_xfa::FieldData::None => None,
|
||||
};
|
||||
if let Some(v) = new {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,3 +12,4 @@ mod ocr_edit;
|
||||
mod office_export_images;
|
||||
mod private_image_resize;
|
||||
mod private_text_glyphs;
|
||||
mod xfa_repeat;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! A dynamic XFA form whose button adds rows past fifty lays every row out and adds the pages
|
||||
//! they need (XFA 3.3, "The occur element", p. 786: `max="-1"` sets no upper boundary).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_engine::Session;
|
||||
|
||||
/// A list whose `item` row (20pt, 36 to a page) repeats without bound, `data` rows in its
|
||||
/// datasets, and a button that adds `add` rows in one click.
|
||||
fn form(add: usize, data_rows: usize) -> Vec<u8> {
|
||||
let data: String = (1..=data_rows).map(|i| format!("<item><qty>row {i}</qty></item>")).collect();
|
||||
let datasets = if data_rows == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
r#"<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><page><list>{data}</list></page></form></xfa:data></xfa:datasets>"#
|
||||
)
|
||||
};
|
||||
let xdp = format!(
|
||||
r#"<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"><occur max="-1"/><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
<subform name="page" layout="tb">
|
||||
<field name="more" w="100pt" h="20pt"><ui><button/></ui><caption><value><text>Add rows</text></value></caption>
|
||||
<event activity="click"><script contentType="application/x-javascript">for (var i = 0; i < {add}; i++) list._item.addInstance(1);</script></event></field>
|
||||
<subform name="list" layout="tb">
|
||||
<subform name="item" layout="tb"><occur min="1" max="-1"/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
</subform>
|
||||
</subform>
|
||||
</subform></template>
|
||||
{datasets}
|
||||
</xdp:xdp>"#
|
||||
);
|
||||
pdfcraft_xfa::fixtures::shell(&xdp)
|
||||
}
|
||||
|
||||
fn rows(s: &Session, id: pdfcraft_engine::DocId) -> usize {
|
||||
s.get(id).map_or(0, |d| d.form.iter().filter(|f| f.name.starts_with("qty")).count())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_adding_rows_past_fifty_lays_them_all_out() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("rows.pdf", None, Arc::new(form(59, 0)), None).expect("opens");
|
||||
assert_eq!(rows(&s, id), 1);
|
||||
assert_eq!(s.get(id).map(|d| d.info.pages.len()), Some(1));
|
||||
let o = s.run_javascript(id, "", Some("more")).expect("the click runs");
|
||||
assert_eq!(o.error, None);
|
||||
// The button row plus 60 rows of 20pt: 61 rows at 36 a page is two pages.
|
||||
assert_eq!(rows(&s, id), 60);
|
||||
assert_eq!(s.get(id).map(|d| d.info.pages.len()), Some(2));
|
||||
let out = s.take_js_output(id);
|
||||
assert!(!out.console.iter().any(|l| l.contains("only the first")), "no row is cut: {:?}", out.console);
|
||||
}
|
||||
|
||||
/// A table (`layout="table"`) placed at its own position in a positioned subform, its `item` row
|
||||
/// repeating with `occur max` `max`, and five rows in its data (XFA 3.3, "Tables", p. 327).
|
||||
fn positioned_table(max: &str) -> Vec<u8> {
|
||||
let data: String = (1..=5).map(|i| format!("<item><qty>row {i}</qty></item>")).collect();
|
||||
let xdp = format!(
|
||||
r#"<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="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
<subform name="page" w="540pt" h="700pt">
|
||||
<subform name="table" layout="table" columnWidths="200pt" x="36pt" y="72pt">
|
||||
<subform name="item" layout="row"><occur min="1" max="{max}"/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
</subform>
|
||||
</subform>
|
||||
</subform></template>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><page><table>{data}</table></page></form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>"#
|
||||
);
|
||||
pdfcraft_xfa::fixtures::shell(&xdp)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_in_a_positioned_subform_gets_a_row_per_data_row_up_to_its_max() {
|
||||
let mut s = Session::new();
|
||||
for (max, want) in [("-1", 5), ("3", 3)] {
|
||||
let id = s.open("table.pdf", None, Arc::new(positioned_table(max)), None).expect("opens");
|
||||
assert_eq!(rows(&s, id), want, "occur max {max}");
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,37 @@ impl Field {
|
||||
})
|
||||
}
|
||||
|
||||
/// The on-state name that selects `value` read as an export value: form data, or one of the
|
||||
/// options [`Field::export_of`] lists. A field with one `/Opt` entry per widget matches those
|
||||
/// entries first and its on-state names only after them: the names may be positions (`/0`,
|
||||
/// `/1`; ISO 32000-2 Table 230), so the export value `1` is the first widget, not the second
|
||||
/// one's state `/1`. The order is (1) an `/Opt` export value, (2) an on-state name: when one
|
||||
/// button's on state is named `1` and another button exports `1`, `1` selects the one that
|
||||
/// exports it. Elsewhere this is [`Field::state_for`].
|
||||
pub fn state_for_export(&self, value: &str) -> Option<&str> {
|
||||
let by_export = self.button_exports().and_then(|exports| {
|
||||
let i = exports.iter().position(|(e, _)| e == value)?;
|
||||
self.widgets.get(i)?.on_state.as_deref()
|
||||
});
|
||||
by_export.or_else(|| self.state_for(value))
|
||||
}
|
||||
|
||||
/// The on-state name of the button whose export value is the number `value` (`/Opt [(1.0)
|
||||
/// (2.0)]`: `1` and `1.0` both name the first button), compared as numbers, so the spelling a
|
||||
/// caller writes a number in doesn't matter. `None` when no export value is that number, or
|
||||
/// the field has no `/Opt`, and for a number at or past 2^53 in magnitude: there a float no
|
||||
/// longer holds every integer (`9007199254740992` and `9007199254740993` are the same `f64`),
|
||||
/// so such a number is left to match an export value only by its text.
|
||||
pub fn state_for_number(&self, value: f64) -> Option<&str> {
|
||||
const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53
|
||||
if !value.is_finite() || value.abs() >= EXACT {
|
||||
return None;
|
||||
}
|
||||
let exports = self.button_exports()?;
|
||||
let i = exports.iter().position(|(e, _)| e.trim().parse::<f64>().is_ok_and(|x| x == value))?;
|
||||
self.widgets.get(i)?.on_state.as_deref()
|
||||
}
|
||||
|
||||
/// The export value for an on-state name such as the field's value; the name itself when the
|
||||
/// field has no `/Opt`.
|
||||
pub fn export_for_state<'a>(&'a self, state: &'a str) -> &'a str {
|
||||
|
||||
@@ -497,6 +497,31 @@ fn opt_export_values_select_button_states() {
|
||||
assert!(err.to_string().contains("options: Post, Pick-up"), "{err}");
|
||||
}
|
||||
|
||||
/// Export values that are themselves numbers (`/Opt [(1) (2)]`, as static XFA forms write their
|
||||
/// items) name the buttons in `/Opt` order when read as export values, though `1` is also the
|
||||
/// second button's on-state name (`/0`, `/1` by position; ISO 32000-2 Table 230).
|
||||
#[test]
|
||||
fn numeric_opt_export_values_name_their_own_buttons() {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
"<< /FT /Btn /T (lang) /Ff 49152 /Opt [(1) (2)] /V /Off /Kids [6 0 R 7 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [100 700 115 715] /P 3 0 R /AP << /N << /0 8 0 R /Off 8 0 R >> >> >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [200 700 215 715] /P 3 0 R /AP << /N << /1 8 0 R /Off 8 0 R >> >> >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
];
|
||||
let doc = document(&objs);
|
||||
let all = fields(&doc);
|
||||
let lang = field(&all, "lang");
|
||||
assert_eq!((lang.state_for_export("1"), lang.state_for_export("2")), (Some("0"), Some("1")));
|
||||
// A state name no export value matches still names its button; anything else names none.
|
||||
assert_eq!((lang.state_for_export("0"), lang.state_for_export("3")), (Some("0"), None));
|
||||
// On-state names keep their meaning where the caller has one (a click sends the state).
|
||||
assert_eq!(lang.state_for("1"), Some("1"));
|
||||
}
|
||||
|
||||
/// What fits a text field is measured as its appearance draws it: in the field's own font (its
|
||||
/// `/DR` font, Helvetica where that can't show the text), inside the border and padding, at its
|
||||
/// size, wrapped for multi-line, at the smallest automatic size, as asterisks for a password,
|
||||
@@ -6547,6 +6572,26 @@ fn a_read_only_button_is_not_turned_on_by_number() {
|
||||
assert_eq!(on_widgets(&doc, "r").0, [false, false, true]);
|
||||
}
|
||||
|
||||
/// A reset of named fields costs the same per field however many are named: resetting 60,000
|
||||
/// fields by name is not 3.6 billion name comparisons.
|
||||
#[test]
|
||||
fn resetting_many_named_fields_is_not_quadratic() {
|
||||
const N: usize = 60_000;
|
||||
let kids: String = (0..N).map(|i| format!("{} 0 R ", i + 4)).collect();
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [] /Count 0 >>".into(),
|
||||
format!("<< /Fields [{kids}] >>"),
|
||||
];
|
||||
objs.extend((0..N).map(|i| format!("<< /FT /Tx /T (f{i}) /V (a) /DV (a) >>")));
|
||||
let mut doc = document(&objs);
|
||||
let names: Vec<String> = (0..N).map(|i| format!("f{i}")).collect();
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(reset(&mut doc, Some(&names)).unwrap(), 0);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
|
||||
assert!(matches!(reset(&mut doc, Some(&["f0".to_string(), "nope".to_string()])), Err(FormError::NoSuchField(n)) if n == "nope"));
|
||||
}
|
||||
|
||||
/// Two check box fields of two kids each: `pair` with the on states `A` and `B` (and `/DV /B`),
|
||||
/// `same` with `Yes` twice.
|
||||
fn check_box_kids() -> Document {
|
||||
|
||||
@@ -825,13 +825,16 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result<usize, Form
|
||||
if all.is_empty() {
|
||||
return Err(FormError::NoForm);
|
||||
}
|
||||
// Names looked up through sets, so a long list of names costs no more per field.
|
||||
let have: std::collections::HashSet<&str> = names.map(|_| all.iter().map(|f| f.name.as_str()).collect()).unwrap_or_default();
|
||||
if let Some(n) = names
|
||||
&& let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n))
|
||||
&& let Some(missing) = n.iter().find(|n| !have.contains(n.as_str()))
|
||||
{
|
||||
return Err(FormError::NoSuchField(missing.clone()));
|
||||
}
|
||||
let named: Option<std::collections::HashSet<&str>> = names.map(|n| n.iter().map(String::as_str).collect());
|
||||
let mut changed = 0;
|
||||
for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) {
|
||||
for f in all.iter().filter(|f| named.as_ref().is_none_or(|n| n.contains(f.name.as_str()))) {
|
||||
// A default the reading didn't decode (its budget spent) is unknown, not empty: clearing
|
||||
// the field to nothing would lose the value it should go back to. A value it didn't decode
|
||||
// isn't known to be the default, so it is reset.
|
||||
|
||||
@@ -109,7 +109,7 @@ type R<T> = Result<T, String>;
|
||||
/// Run a FormCalc `script` for `event` on the form `root`, as [`crate::xfa::run_xfa`] runs a
|
||||
/// JavaScript one.
|
||||
pub fn run_formcalc(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome {
|
||||
run_formcalc_at(script, event, doc, root.clone(), limits, None)
|
||||
run_formcalc_at(script, event, doc, std::sync::Arc::new(root.clone()), false, limits, None)
|
||||
}
|
||||
|
||||
/// [`run_formcalc`] on an owned form, giving up at `deadline` (the clock is read every
|
||||
@@ -118,7 +118,8 @@ pub(crate) fn run_formcalc_at(
|
||||
script: &str,
|
||||
event: &XfaEvent,
|
||||
doc: &XfaDoc,
|
||||
root: XfaNode,
|
||||
root: std::sync::Arc<XfaNode>,
|
||||
reuse: bool,
|
||||
limits: Limits,
|
||||
deadline: Option<std::time::Duration>,
|
||||
) -> XfaOutcome {
|
||||
@@ -133,7 +134,7 @@ pub(crate) fn run_formcalc_at(
|
||||
Ok(b) => b,
|
||||
Err(e) => return XfaOutcome { error: Some(e), ..Default::default() },
|
||||
};
|
||||
let mut host = XHost::new(root, &event.target, doc);
|
||||
let mut host = XHost::new(root, reuse, &event.target, doc);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = deadline;
|
||||
let mut interp = Interp {
|
||||
@@ -177,7 +178,7 @@ pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: X
|
||||
// FormCalc has no engine to start: its time (and its own deadline) begins at once.
|
||||
run_within("pdfcraft-formcalc", timeout, move |started| {
|
||||
started();
|
||||
run_formcalc_at(&script, &event, &doc, root, limits, Some(timeout))
|
||||
run_formcalc_at(&script, &event, &doc, std::sync::Arc::new(root), false, limits, Some(timeout))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -313,16 +313,16 @@ impl Interp<'_> {
|
||||
let Some(n) = self.node(i) else { return Ok(Val::Null) };
|
||||
Ok(match name {
|
||||
"formattedValue" => match &n.formatted {
|
||||
Some(f) => Val::Str(f.clone()),
|
||||
Some(f) => Val::Str(f.to_string()),
|
||||
None => self.node_value(i),
|
||||
},
|
||||
"rawValue" | "value" => self.node_value(i),
|
||||
"name" => Val::Str(n.name.clone()),
|
||||
"somExpression" => Val::Str(n.som.clone()),
|
||||
"name" => Val::Str(n.name.to_string()),
|
||||
"somExpression" => Val::Str(n.som.to_string()),
|
||||
"className" => Val::Str(n.kind.class_name().into()),
|
||||
"index" | "instanceIndex" => Val::Num(n.index as f64),
|
||||
"presence" => Val::Str(n.presence.clone()),
|
||||
"access" => Val::Str(n.access.clone()),
|
||||
"presence" => Val::Str(n.presence.to_string()),
|
||||
"access" => Val::Str(n.access.to_string()),
|
||||
"isNull" => Val::Num(if n.value.is_empty() { 1.0 } else { 0.0 }),
|
||||
"mandatory" => Val::Str("disabled".into()),
|
||||
"parent" => n.parent.map_or(Val::Null, Val::Node),
|
||||
@@ -332,7 +332,7 @@ impl Interp<'_> {
|
||||
None => Val::Node(i),
|
||||
},
|
||||
"instanceManager" => match n.parent {
|
||||
Some(p) => Val::Im(p, n.name.clone()),
|
||||
Some(p) => Val::Im(p, n.name.to_string()),
|
||||
None => Val::Null,
|
||||
},
|
||||
"border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate"
|
||||
|
||||
@@ -277,11 +277,11 @@ impl Interp<'_> {
|
||||
Some(e) => {
|
||||
let raw = self.eval(e)?;
|
||||
match self.nodes_of(&raw).first() {
|
||||
Some(i) => self.node(*i).map(|n| n.som.clone()).unwrap_or_default(),
|
||||
Some(i) => self.node(*i).map(|n| n.som.to_string()).unwrap_or_default(),
|
||||
None => {
|
||||
let s = self.to_str(&raw);
|
||||
let found = resolve_som(self.h, self.h.current, &s);
|
||||
found.first().and_then(|i| self.node(*i)).map(|n| n.som.clone()).unwrap_or(s)
|
||||
found.first().and_then(|i| self.node(*i)).map(|n| n.som.to_string()).unwrap_or(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,7 +404,7 @@ impl Interp<'_> {
|
||||
match target {
|
||||
Val::Node(i) => {
|
||||
let Some(n) = self.node(i) else { return Ok(()) };
|
||||
let som = n.som.clone();
|
||||
let som = n.som.to_string();
|
||||
match prop {
|
||||
"rawValue" | "value" | "formattedValue" => {
|
||||
let text = self.to_str(&v);
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
+113
-6
@@ -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())
|
||||
}
|
||||
|
||||
@@ -1627,7 +1632,8 @@ $host.gotoURL("https://example.org")"#,
|
||||
"for i = 1 upto 100000 do Eval(\"1\") endfor",
|
||||
&ev,
|
||||
&doc,
|
||||
form(),
|
||||
std::sync::Arc::new(form()),
|
||||
false,
|
||||
Limits { loop_iterations: u64::MAX, recursion: 32 },
|
||||
Some(std::time::Duration::ZERO),
|
||||
);
|
||||
@@ -1745,12 +1751,53 @@ mod session {
|
||||
source: source.into(),
|
||||
formcalc,
|
||||
event: XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() },
|
||||
root: form(),
|
||||
root: std::sync::Arc::new(form()),
|
||||
limits: Limits::default(),
|
||||
timeout: std::time::Duration::from_secs(3),
|
||||
}
|
||||
}
|
||||
|
||||
/// The scripts of an event given the same form start from one flattened copy of it: the
|
||||
/// second host shares the first one's text, and each host changes only its own copy.
|
||||
#[test]
|
||||
fn hosts_given_the_same_form_reuse_its_flattened_copy_and_change_only_their_own() {
|
||||
let root = std::sync::Arc::new(form());
|
||||
let doc = XfaDoc::default();
|
||||
let mut first = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc);
|
||||
let second = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc);
|
||||
let shared = std::sync::Arc::clone(&first.nodes.get(1).expect("form1").som);
|
||||
assert!(std::sync::Arc::ptr_eq(&shared, &second.nodes.get(1).expect("form1").som), "the second host starts from the first one's copy");
|
||||
let qty = first.current;
|
||||
let before = second.nodes.get(qty).map(|n| n.value.clone());
|
||||
first.set_state(qty, XfaEffect::SetPresence { som: "form1[0].page1[0].qty[0]".into(), presence: "hidden".into() });
|
||||
if let Some(n) = first.nodes.get_mut(qty) {
|
||||
n.value = "99".into();
|
||||
}
|
||||
let third = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc);
|
||||
for h in [&second, &third] {
|
||||
assert_eq!(h.nodes.get(qty).map(|n| n.value.clone()), before);
|
||||
assert_eq!(h.nodes.get(qty).map(|n| n.presence.to_string()).as_deref(), Some("visible"));
|
||||
}
|
||||
// Another form (a new `Arc`, even with the same content) is flattened again.
|
||||
let other = crate::xfa::XHost::new(std::sync::Arc::new(form()), true, "form1[0].page1[0].qty[0]", &doc);
|
||||
assert!(!std::sync::Arc::ptr_eq(&other.nodes.get(1).expect("form1").som, &shared));
|
||||
}
|
||||
|
||||
/// One session given the same form for two scripts: what the first changed on its copy is
|
||||
/// not in the second's.
|
||||
#[test]
|
||||
fn a_form_sent_again_does_not_carry_the_last_scripts_changes() {
|
||||
let mut s = XfaSession::new(XfaDoc::default());
|
||||
let root = std::sync::Arc::new(form());
|
||||
let mut a = script("qty.rawValue = 10; details.presence = 'visible'; qty.rawValue", false, "form1[0].page1[0].total[0]", "calculate");
|
||||
a.root = std::sync::Arc::clone(&root);
|
||||
assert_eq!(s.run(a).result.as_deref(), Some("10"));
|
||||
let mut b = script("qty.rawValue + ':' + details.presence", false, "form1[0].page1[0].total[0]", "calculate");
|
||||
b.root = std::sync::Arc::clone(&root);
|
||||
let o = s.run(b);
|
||||
assert_eq!(o.result.as_deref(), Some("4:hidden"), "{o:?}");
|
||||
}
|
||||
|
||||
/// JavaScript and FormCalc scripts run in turn in one session, each on the form the caller
|
||||
/// sends: nothing an earlier script changed or defined leaks into the next, a failing script
|
||||
/// doesn't end the session, and hundreds of scripts run on its one thread.
|
||||
@@ -1768,7 +1815,7 @@ mod session {
|
||||
let o = s.run(script("qty * price", true, "form1[0].page1[0].total[0]", "calculate"));
|
||||
assert_eq!(o.result.as_deref(), Some("10"), "{o:?}");
|
||||
let mut with = script("qty * price", true, "form1[0].page1[0].total[0]", "calculate");
|
||||
with.root.children[0].children[0].value = "10".into();
|
||||
std::sync::Arc::make_mut(&mut with.root).children[0].children[0].value = "10".into();
|
||||
assert_eq!(s.run(with).result.as_deref(), Some("25"));
|
||||
// Nor do variables, globals, prototype changes or kept objects.
|
||||
s.run(script("leak = 5; String.prototype.trim = function () { return 'P' }; kept = qty; 1", false, "form1[0].page1[0].qty[0]", "calculate"));
|
||||
@@ -1841,6 +1888,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 +1924,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};
|
||||
|
||||
@@ -22,12 +22,16 @@ mod host;
|
||||
mod instances;
|
||||
mod model;
|
||||
mod nodes;
|
||||
mod props;
|
||||
mod run;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) use host::forget_base;
|
||||
pub(crate) use host::{HNode, XHost};
|
||||
pub use model::{MAX_ALERTS, MAX_CONSOLE, MAX_EFFECTS, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome};
|
||||
pub(crate) use model::{MAX_LINE, MAX_PAYLOAD, clip, clip_result, reset_names};
|
||||
pub(crate) use nodes::{child_named, descendants_named, instances, resolve_som};
|
||||
pub use props::{XfaProp, XfaProps};
|
||||
#[cfg(test)]
|
||||
pub(crate) use run::run_within_limits;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -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> {
|
||||
@@ -69,13 +88,13 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsVal
|
||||
let som = match target.as_object() {
|
||||
Some(_) if node_index(&target, ctx).is_ok() => {
|
||||
let i = node_index(&target, ctx)?;
|
||||
host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default()
|
||||
host(ctx)?.borrow().nodes.get(i).map(|n| n.som.to_string()).unwrap_or_default()
|
||||
}
|
||||
_ => {
|
||||
let expr = text(&target, ctx)?;
|
||||
let h = host(ctx)?.borrow();
|
||||
let found = resolve_som(&h, h.current, &expr);
|
||||
found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr)
|
||||
found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.to_string()).unwrap_or(expr)
|
||||
}
|
||||
};
|
||||
effect(ctx, XfaEffect::SetFocus(som))?;
|
||||
@@ -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)
|
||||
@@ -334,7 +356,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
|
||||
let h = host(ctx)?.borrow();
|
||||
h.nodes
|
||||
.get(scope)
|
||||
.map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect())
|
||||
.map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.to_string(), k.repeatable && !k.gone))).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for (c, name, repeatable) in kids {
|
||||
|
||||
+111
-23
@@ -2,27 +2,81 @@ use super::model::{
|
||||
MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value,
|
||||
effect_key, payload,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{Context, HashMap, JsResult, Message, RefCell, error};
|
||||
|
||||
/// One node of the flattened form, shared by the JavaScript and FormCalc engines.
|
||||
/// One node of the flattened form, shared by the JavaScript and FormCalc engines. Its text is
|
||||
/// shared (`Arc<str>`), so the copy each script starts from costs little: the scripts of one
|
||||
/// event start from the same flattened form until it changes (see [`XHost::new`]).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct HNode {
|
||||
pub(crate) name: String,
|
||||
pub(crate) som: String,
|
||||
pub(crate) name: Arc<str>,
|
||||
pub(crate) som: Arc<str>,
|
||||
pub(crate) kind: XfaKind,
|
||||
pub(crate) value: String,
|
||||
pub(crate) numeric: bool,
|
||||
pub(crate) presence: String,
|
||||
pub(crate) access: String,
|
||||
pub(crate) presence: Arc<str>,
|
||||
pub(crate) access: Arc<str>,
|
||||
pub(crate) repeatable: bool,
|
||||
pub(crate) occur_min: usize,
|
||||
pub(crate) occur_max: Option<usize>,
|
||||
pub(crate) index: usize,
|
||||
pub(crate) parent: Option<usize>,
|
||||
pub(crate) children: Vec<usize>,
|
||||
pub(crate) children: Arc<[usize]>,
|
||||
/// Removed by `removeInstance`: skipped everywhere.
|
||||
pub(crate) gone: bool,
|
||||
/// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows.
|
||||
pub(crate) formatted: Option<String>,
|
||||
pub(crate) formatted: Option<Arc<str>>,
|
||||
/// See [`XfaNode::props`]; instances a script adds share their first instance's.
|
||||
pub(crate) props: std::sync::Arc<super::XfaProps>,
|
||||
/// A choice list's entries as a script's list methods leave them, one list per `<items>`;
|
||||
/// `None` while they are as the template lists them. The hook for PdfCraft's choice-list
|
||||
/// methods (`addItem`, `clearItems`; the Series 1 work, not on main yet), which will set it so
|
||||
/// that the `items` property reads (see [`super::props::list_entries`]) agree with them.
|
||||
/// Nothing sets it yet, so reads follow the template.
|
||||
pub(crate) items: Option<Vec<super::props::ItemList>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The form the last script on this thread started from, flattened: the next script of the
|
||||
/// event starts from a copy of it while the engine sends the same form.
|
||||
static BASE: std::cell::RefCell<Option<(Arc<XfaNode>, Vec<HNode>)>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Let go of the form the scripts on this thread started from.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn forget_base() {
|
||||
BASE.with(|b| b.borrow_mut().take());
|
||||
}
|
||||
|
||||
/// `root` flattened under a synthetic `form` node (index 0).
|
||||
fn flattened(root: &XfaNode) -> Vec<HNode> {
|
||||
let mut nodes = vec![HNode {
|
||||
name: "form".into(),
|
||||
som: "".into(),
|
||||
kind: XfaKind::Form,
|
||||
value: String::new(),
|
||||
numeric: false,
|
||||
presence: "visible".into(),
|
||||
access: "open".into(),
|
||||
repeatable: false,
|
||||
occur_min: 0,
|
||||
occur_max: None,
|
||||
index: 0,
|
||||
parent: None,
|
||||
children: Arc::from([]),
|
||||
gone: false,
|
||||
formatted: None,
|
||||
props: Arc::default(),
|
||||
items: None,
|
||||
}];
|
||||
if let Some(ci) = flatten(root, Some(0), &mut nodes, 1)
|
||||
&& let Some(f) = nodes.get_mut(0)
|
||||
{
|
||||
f.children = Arc::from([ci]);
|
||||
}
|
||||
nodes
|
||||
}
|
||||
|
||||
/// The form as a script sees and changes it, with the effects it records.
|
||||
@@ -48,16 +102,38 @@ pub(crate) struct XHost {
|
||||
/// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`].
|
||||
kept: usize,
|
||||
dropped_bytes: usize,
|
||||
/// What the script wrote to property objects, by (node, property path): read back later in
|
||||
/// the run, applied nowhere yet. Charged against [`MAX_PAYLOAD`].
|
||||
pub(crate) prop_writes: super::props::PropWrites,
|
||||
/// Attributes written to a property past [`super::props::MAX_WRITTEN_ATTRS`], left out.
|
||||
pub(super) dropped_prop_writes: usize,
|
||||
/// Bytes of edges and corners copied when a script reached sides a border doesn't list,
|
||||
/// against [`super::props::MAX_COPIED`].
|
||||
pub(super) copied: usize,
|
||||
}
|
||||
|
||||
impl XHost {
|
||||
/// The host for a script on `root` (the template's root subform), with `this` at the node
|
||||
/// named by `target`. `xfa.form` is a synthetic root whose child is the root subform.
|
||||
pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost {
|
||||
let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() };
|
||||
let mut nodes = Vec::new();
|
||||
flatten(&form, None, &mut nodes, 0);
|
||||
let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1)));
|
||||
/// With `reuse`, the flattened form is kept on this thread for the next script given the
|
||||
/// same `root` (the scripts of one event, [`crate::xfa_session`]).
|
||||
pub(crate) fn new(root: Arc<XfaNode>, reuse: bool, target: &str, doc: &XfaDoc) -> XHost {
|
||||
let nodes = if !reuse {
|
||||
flattened(&root)
|
||||
} else {
|
||||
BASE.with(|b| {
|
||||
let mut b = b.borrow_mut();
|
||||
match b.as_ref() {
|
||||
Some((r, nodes)) if Arc::ptr_eq(r, &root) => nodes.clone(),
|
||||
_ => {
|
||||
let nodes = flattened(&root);
|
||||
*b = Some((root, nodes.clone()));
|
||||
nodes
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
let current = nodes.iter().position(|n| &*n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1)));
|
||||
XHost {
|
||||
nodes,
|
||||
effects: Vec::new(),
|
||||
@@ -74,11 +150,14 @@ impl XHost {
|
||||
dropped_console: 0,
|
||||
kept: 0,
|
||||
dropped_bytes: 0,
|
||||
prop_writes: super::props::PropWrites::new(),
|
||||
dropped_prop_writes: 0,
|
||||
copied: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not.
|
||||
fn room(&mut self, bytes: usize) -> bool {
|
||||
pub(super) fn room(&mut self, bytes: usize) -> bool {
|
||||
match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) {
|
||||
Some(k) => {
|
||||
self.kept = k;
|
||||
@@ -122,8 +201,8 @@ impl XHost {
|
||||
}
|
||||
if let Some(n) = self.nodes.get_mut(i) {
|
||||
match &e {
|
||||
XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(),
|
||||
XfaEffect::SetAccess { access, .. } => n.access = access.clone(),
|
||||
XfaEffect::SetPresence { presence, .. } => n.presence = presence.as_str().into(),
|
||||
XfaEffect::SetAccess { access, .. } => n.access = access.as_str().into(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +288,13 @@ impl XHost {
|
||||
if self.clipped_values > 0 {
|
||||
out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values));
|
||||
}
|
||||
if self.dropped_prop_writes > 0 {
|
||||
out.push(format!(
|
||||
"the script wrote more than {} attributes to one property; {} more were left out",
|
||||
super::props::MAX_WRITTEN_ATTRS,
|
||||
self.dropped_prop_writes
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -220,7 +306,7 @@ impl XHost {
|
||||
if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) {
|
||||
return false;
|
||||
}
|
||||
let som = n.som.clone();
|
||||
let som = n.som.to_string();
|
||||
// Cut here, not only in the effect: later reads in the script see what is saved, and
|
||||
// the snapshot stays bounded however often a long value is written.
|
||||
let (text, cut) = clip_value(text);
|
||||
@@ -257,21 +343,23 @@ fn flatten(n: &XfaNode, parent: Option<usize>, out: &mut Vec<HNode>, depth: usiz
|
||||
}
|
||||
let idx = out.len();
|
||||
out.push(HNode {
|
||||
name: n.name.clone(),
|
||||
som: n.som.clone(),
|
||||
name: n.name.as_str().into(),
|
||||
som: n.som.as_str().into(),
|
||||
kind: n.kind.unwrap_or(XfaKind::Subform),
|
||||
value: n.value.clone(),
|
||||
numeric: n.numeric,
|
||||
presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() },
|
||||
access: if n.access.is_empty() { "open".into() } else { n.access.clone() },
|
||||
presence: if n.presence.is_empty() { "visible".into() } else { n.presence.as_str().into() },
|
||||
access: if n.access.is_empty() { "open".into() } else { n.access.as_str().into() },
|
||||
repeatable: n.repeatable,
|
||||
occur_min: n.occur_min,
|
||||
occur_max: n.occur_max,
|
||||
index: n.index,
|
||||
parent,
|
||||
children: Vec::new(),
|
||||
children: Arc::from([]),
|
||||
gone: false,
|
||||
formatted: n.formatted.clone(),
|
||||
formatted: n.formatted.as_deref().map(Arc::from),
|
||||
props: Arc::clone(&n.props),
|
||||
items: None,
|
||||
});
|
||||
let mut kids = Vec::new();
|
||||
for c in &n.children {
|
||||
@@ -280,7 +368,7 @@ fn flatten(n: &XfaNode, parent: Option<usize>, out: &mut Vec<HNode>, depth: usiz
|
||||
}
|
||||
}
|
||||
if let Some(h) = out.get_mut(idx) {
|
||||
h.children = kids;
|
||||
h.children = kids.into();
|
||||
}
|
||||
Some(idx)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::host::{HNode, XHost, host};
|
||||
use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect};
|
||||
use super::nodes::{instances, node_object};
|
||||
@@ -112,14 +114,14 @@ fn renumber(h: &mut XHost, idx: usize, depth: usize) {
|
||||
}
|
||||
let (parent_som, name, index, kids) = {
|
||||
let Some(n) = h.nodes.get(idx) else { return };
|
||||
let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default();
|
||||
let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.to_string()).unwrap_or_default();
|
||||
(parent_som, n.name.clone(), n.index, n.children.clone())
|
||||
};
|
||||
let som = child_som(parent_som, &name, index);
|
||||
if let Some(n) = h.nodes.get_mut(idx) {
|
||||
n.som = som;
|
||||
n.som = som.into();
|
||||
}
|
||||
for k in kids {
|
||||
for k in kids.iter().copied() {
|
||||
renumber(h, k, depth + 1);
|
||||
}
|
||||
}
|
||||
@@ -145,7 +147,7 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
|
||||
(
|
||||
HNode {
|
||||
name: n.name.clone(),
|
||||
som: String::new(),
|
||||
som: "".into(),
|
||||
kind: n.kind,
|
||||
value: String::new(),
|
||||
numeric: n.numeric,
|
||||
@@ -156,10 +158,13 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
|
||||
occur_max: n.occur_max,
|
||||
index: n.index,
|
||||
parent,
|
||||
children: Vec::new(),
|
||||
children: Arc::from([]),
|
||||
gone: false,
|
||||
// The copy holds no value, so nothing is shown in its picture either.
|
||||
formatted: None,
|
||||
props: std::sync::Arc::clone(&n.props),
|
||||
// A new instance lists what the template lists.
|
||||
items: None,
|
||||
},
|
||||
n.children.clone(),
|
||||
)
|
||||
@@ -167,7 +172,7 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
|
||||
let idx = h.nodes.len();
|
||||
h.nodes.push(template);
|
||||
let mut copied = Vec::new();
|
||||
for k in kids {
|
||||
for k in kids.iter().copied() {
|
||||
// Nested repeating subforms copy their first instance only.
|
||||
let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0);
|
||||
if skip {
|
||||
@@ -178,7 +183,7 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
|
||||
}
|
||||
}
|
||||
if let Some(n) = h.nodes.get_mut(idx) {
|
||||
n.children = copied;
|
||||
n.children = copied.into();
|
||||
}
|
||||
Some(idx)
|
||||
}
|
||||
@@ -206,8 +211,8 @@ impl XHost {
|
||||
return None;
|
||||
}
|
||||
// The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy.
|
||||
let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default();
|
||||
let name = h.nodes.get(first).map(|n| n.name.to_string()).unwrap_or_default();
|
||||
let parent_som = h.nodes.get(parent).map(|p| p.som.to_string()).unwrap_or_default();
|
||||
let som = child_som(parent_som, &name, live.len());
|
||||
if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) {
|
||||
return None;
|
||||
@@ -219,8 +224,10 @@ impl XHost {
|
||||
}
|
||||
// Keep instances together: right after the last one.
|
||||
if let Some(p) = h.nodes.get_mut(parent) {
|
||||
let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1);
|
||||
p.children.insert(pos, new);
|
||||
let mut kids = p.children.to_vec();
|
||||
let pos = kids.iter().position(|&c| c == last).map_or(kids.len(), |i| i + 1);
|
||||
kids.insert(pos, new);
|
||||
p.children = kids.into();
|
||||
}
|
||||
renumber(h, new, 0);
|
||||
h.added += 1;
|
||||
@@ -260,7 +267,7 @@ impl XHost {
|
||||
if live.len() <= min {
|
||||
return;
|
||||
}
|
||||
let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default();
|
||||
let som = h.nodes.get(gone).map(|n| n.som.to_string()).unwrap_or_default();
|
||||
if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ pub struct XfaNode {
|
||||
/// Fields: the value shown in the field's display picture, what `formattedValue` reads;
|
||||
/// `None` when the picture doesn't change it (the raw value is read then).
|
||||
pub formatted: Option<String>,
|
||||
/// The template's attributes and property elements of the object (border, ui, validate, …),
|
||||
/// shared: snapshots taken for each script point at the same tree.
|
||||
pub props: std::sync::Arc<super::XfaProps>,
|
||||
}
|
||||
|
||||
/// The event a script runs for.
|
||||
@@ -70,6 +73,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 +115,8 @@ pub enum XfaEffect {
|
||||
Recalculate,
|
||||
/// `xfa.form.remerge()`, `xfa.layout.relayout()`.
|
||||
Relayout,
|
||||
/// `app.runtimeHighlight = on`.
|
||||
Highlight(bool),
|
||||
}
|
||||
|
||||
/// What running a script did.
|
||||
@@ -213,7 +220,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+94
-24
@@ -2,8 +2,9 @@ use super::globals::{recalculate, relayout};
|
||||
use super::host::{HNode, XHost, host};
|
||||
use super::instances::{instance_manager, list_object};
|
||||
use super::model::{MAX_DEPTH, XfaEffect};
|
||||
use super::props;
|
||||
use super::{
|
||||
Attribute, Context, JsObject, JsProxy, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, as_number, error, function,
|
||||
Attribute, Context, JsObject, JsProxy, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, as_number, clip, error, function,
|
||||
js_string, s, text,
|
||||
};
|
||||
|
||||
@@ -70,14 +71,14 @@ fn value_js(h: &HNode) -> JsValue {
|
||||
/// `(idx, name)` of the child of `parent` named `name`, first instance.
|
||||
pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option<usize> {
|
||||
let p = h.nodes.get(parent)?;
|
||||
p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name))
|
||||
p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && &*n.name == name))
|
||||
}
|
||||
|
||||
/// Every live instance named `name` under `parent`, in order.
|
||||
pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec<usize> {
|
||||
h.nodes
|
||||
.get(parent)
|
||||
.map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect())
|
||||
.map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && &*n.name == name)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -179,11 +180,11 @@ pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize
|
||||
return out;
|
||||
}
|
||||
let Some(n) = h.nodes.get(from) else { return out };
|
||||
for &c in &n.children {
|
||||
for &c in n.children.iter() {
|
||||
if let Some(k) = h.nodes.get(c)
|
||||
&& !k.gone
|
||||
{
|
||||
if k.name == name {
|
||||
if &*k.name == name {
|
||||
out.push(c);
|
||||
} else {
|
||||
out.extend(descendants_named(h, c, name, depth + 1));
|
||||
@@ -208,10 +209,16 @@ fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValu
|
||||
(n.name.clone(), n.som.clone(), n.kind, n.value.clone(), n.presence.clone(), n.access.clone(), n.index, n.parent, n.children.clone())
|
||||
};
|
||||
let (name, som, kind, value, presence, access, index, parent, children) = snapshot;
|
||||
// A child named like one of the members read from the properties comes first, as for the
|
||||
// properties themselves (XFA 3.3 p. 99).
|
||||
let shadowing = if props::NODE_MEMBERS.contains(&key.as_str()) { child_named(&host(ctx)?.borrow(), idx, &key) } else { None };
|
||||
if let Some(c) = shadowing {
|
||||
return node_object(ctx, c);
|
||||
}
|
||||
Ok(match key.as_str() {
|
||||
IDX => JsValue::from(idx as f64),
|
||||
"rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js),
|
||||
"formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)),
|
||||
"formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.as_deref().map(str::to_string)).unwrap_or(value)),
|
||||
"name" => s(&name),
|
||||
"somExpression" => s(&som),
|
||||
"className" => s(kind.class_name()),
|
||||
@@ -219,7 +226,15 @@ fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValu
|
||||
"presence" => s(&presence),
|
||||
"access" => s(&access),
|
||||
"isNull" => JsValue::from(value.is_empty()),
|
||||
"mandatory" => s("disabled"),
|
||||
"mandatory" => s(&props::node_mandatory(&host(ctx)?.borrow(), idx)),
|
||||
"fillColor" | "fontColor" | "borderColor" => {
|
||||
let path = props::shorthand_color(&key).unwrap_or_default();
|
||||
s(&props::node_color(&host(ctx)?.borrow(), idx, path))
|
||||
}
|
||||
"validationMessage" | "formatMessage" | "mandatoryMessage" => {
|
||||
let test = props::message_test(&key).unwrap_or_default();
|
||||
s(&props::node_message(&host(ctx)?.borrow(), idx, test))
|
||||
}
|
||||
"parent" => match parent {
|
||||
Some(p) => node_object(ctx, p)?,
|
||||
None => JsValue::null(),
|
||||
@@ -246,32 +261,33 @@ fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValu
|
||||
"resolveNodes" => resolver(ctx, idx, true)?,
|
||||
"recalculate" => function(ctx, NativeFunction::from_fn_ptr(recalculate)).into(),
|
||||
"remerge" => function(ctx, NativeFunction::from_fn_ptr(relayout)).into(),
|
||||
"getAttribute"
|
||||
| "setAttribute"
|
||||
| "execEvent"
|
||||
| "execInitialize"
|
||||
| "execCalculate"
|
||||
| "execValidate"
|
||||
| "getElement"
|
||||
| "setElement"
|
||||
| "isPropertySpecified"
|
||||
| "loadXML"
|
||||
| "saveXML"
|
||||
| "clone" => function(ctx, NativeFunction::from_fn_ptr(noop)).into(),
|
||||
"getAttribute" => node_method(ctx, idx, props::get_attribute),
|
||||
"getElement" => node_method(ctx, idx, props::get_element),
|
||||
"isPropertySpecified" => node_method(ctx, idx, props::is_property_specified),
|
||||
"setAttribute" | "execEvent" | "execInitialize" | "execCalculate" | "execValidate" | "setElement" | "loadXML" | "saveXML" | "clone" => {
|
||||
function(ctx, NativeFunction::from_fn_ptr(noop)).into()
|
||||
}
|
||||
"toString" | "valueOf" | "toJSON" => function(ctx, NativeFunction::from_fn_ptr(node_to_string)).into(),
|
||||
"length" => JsValue::undefined(),
|
||||
"border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" | "format"
|
||||
| "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" | "oneOfChild" | "locale"
|
||||
| "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" | "calculate" | "traversal" | "extras"
|
||||
| "desc" => sink(ctx)?,
|
||||
other => {
|
||||
if let Some(child) = other.strip_prefix('_') {
|
||||
let exists = child_named(&host(ctx)?.borrow(), idx, child).is_some();
|
||||
return if exists { instance_manager(ctx, idx, child) } else { Ok(JsValue::undefined()) };
|
||||
}
|
||||
// `#border`: the property, whatever the children are called.
|
||||
if let Some(prop) = other.strip_prefix('#').filter(|p| props::NODE_PROPS.contains(p)) {
|
||||
return props::prop_object(ctx, idx, &props::node_prop_path(prop));
|
||||
}
|
||||
let found = child_named(&host(ctx)?.borrow(), idx, other);
|
||||
match found {
|
||||
Some(c) => node_object(ctx, c)?,
|
||||
// A child of the name comes first, for every name below: "XFA SOM resolves the
|
||||
// name clash in favor of the explicit naming" (XFA 3.3 p. 99).
|
||||
// Property objects (p. 78): read with the spec's defaults, written for this run
|
||||
// only.
|
||||
None if props::NODE_PROPS.contains(&other) => props::prop_object(ctx, idx, &props::node_prop_path(other))?,
|
||||
// Members not modelled yet take any read or write without complaint.
|
||||
None if SINK_MEMBERS.contains(&other) => sink(ctx)?,
|
||||
None => JsValue::undefined(),
|
||||
}
|
||||
}
|
||||
@@ -289,7 +305,7 @@ fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValu
|
||||
let value = if value.is_null() || value.is_undefined() { String::new() } else { text(&value, ctx)? };
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::from(true)) };
|
||||
let som = n.som.clone();
|
||||
let som = n.som.to_string();
|
||||
match key.as_str() {
|
||||
"rawValue" | "value" | "formattedValue" => {
|
||||
// Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded.
|
||||
@@ -309,6 +325,12 @@ fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValu
|
||||
};
|
||||
h.set_state(idx, XfaEffect::SetAccess { som, access: a });
|
||||
}
|
||||
// A child of the same name is what a read of the member gives (see `node_get`), so a
|
||||
// write names that child too, and doesn't reach the property; writes to a child by name
|
||||
// are not modelled, as for every other child name.
|
||||
k if props::NODE_MEMBERS.contains(&k) && child_named(&h, idx, k).is_some() => {}
|
||||
"mandatory" | "validationMessage" | "formatMessage" | "mandatoryMessage" => h.set_validation(idx, &key, clip(value)),
|
||||
"fillColor" | "fontColor" | "borderColor" => h.set_color(idx, &key, clip(value)),
|
||||
_ => {}
|
||||
}
|
||||
Ok(JsValue::from(true))
|
||||
@@ -319,6 +341,37 @@ fn node_to_string(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<
|
||||
Ok(s(&host(ctx)?.borrow().nodes.get(idx).map(|n| n.value.clone()).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Node members not modelled yet: a read gives an object that takes any read or write
|
||||
/// ([`sink`]) unless a child has the name.
|
||||
const SINK_MEMBERS: &[&str] = &[
|
||||
"h",
|
||||
"w",
|
||||
"x",
|
||||
"y",
|
||||
"minH",
|
||||
"maxH",
|
||||
"minW",
|
||||
"maxW",
|
||||
"layout",
|
||||
"model",
|
||||
"oneOfChild",
|
||||
"locale",
|
||||
"relevant",
|
||||
"colSpan",
|
||||
"dataNode",
|
||||
"defaultValue",
|
||||
"editValue",
|
||||
"selectedIndex",
|
||||
"event",
|
||||
"traversal",
|
||||
];
|
||||
|
||||
/// The node method `f` bound to node `idx`.
|
||||
fn node_method(ctx: &mut Context, idx: usize, f: fn(&JsValue, &[JsValue], &JsObject, &mut Context) -> JsResult<JsValue>) -> JsValue {
|
||||
let r = props::node_ref(ctx, idx);
|
||||
function(ctx, NativeFunction::from_copy_closure_with_captures(f, r)).into()
|
||||
}
|
||||
|
||||
pub(super) fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
@@ -334,6 +387,23 @@ pub(super) fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult<J
|
||||
let many = key.get(js_string!("many"), ctx)?.to_boolean();
|
||||
let expr = text(&arg(args, 0), ctx)?;
|
||||
let found = resolve_som(&host(ctx)?.borrow(), from, &expr);
|
||||
// An expression that runs into an object's properties (`ui.#textEdit`, `border.edge[1]`),
|
||||
// for some of the objects it names at least: a child named as the property comes first
|
||||
// for each object on its own, in place ((child, "")).
|
||||
let props = props::resolve_into_props(&host(ctx)?.borrow(), from, &expr);
|
||||
if let Some((node, path)) = props.first().cloned() {
|
||||
{
|
||||
// Edges and corners reached past those a border lists come into being.
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
let reached = if many { props.as_slice() } else { props.get(..1).unwrap_or_default() };
|
||||
reached.iter().filter(|(_, p)| !p.is_empty()).for_each(|(n, p)| h.instantiate(*n, p));
|
||||
}
|
||||
return match (many, path.is_empty()) {
|
||||
(true, _) => props::list_object(ctx, props),
|
||||
(false, true) => node_object(ctx, node),
|
||||
(false, false) => props::prop_object(ctx, node, &path),
|
||||
};
|
||||
}
|
||||
if many {
|
||||
return list_object(ctx, &found);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,995 @@
|
||||
//! Property objects as scripts see them: the spec's defaults, the template's values,
|
||||
//! `getElement`, `oneOfChild`, `isPropertySpecified` and writes read back in the same run.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::super::{XfaDoc, XfaEvent, XfaKind, XfaNode, XfaOutcome, run_xfa};
|
||||
use super::{XfaProp, XfaProps};
|
||||
use crate::Limits;
|
||||
|
||||
fn p(tag: &str, attrs: &[(&str, &str)], children: Vec<XfaProp>) -> XfaProp {
|
||||
XfaProp { tag: tag.into(), attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), text: None, children }
|
||||
}
|
||||
|
||||
fn t(tag: &str, attrs: &[(&str, &str)], text: &str) -> XfaProp {
|
||||
XfaProp { text: Some(text.into()), ..p(tag, attrs, Vec::new()) }
|
||||
}
|
||||
|
||||
fn node(name: &str, kind: XfaKind, attrs: &[(&str, &str)], elements: Vec<XfaProp>) -> XfaNode {
|
||||
XfaNode {
|
||||
name: name.into(),
|
||||
som: format!("form1[0].{name}[0]"),
|
||||
kind: Some(kind),
|
||||
presence: "visible".into(),
|
||||
props: Arc::new(XfaProps { attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), elements }),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// form1 > { styled (a field with most properties written), plain (a field with none), label (a
|
||||
/// draw with a text edit), rows (a repeating subform) }
|
||||
fn form() -> XfaNode {
|
||||
let edge = |rgb: &str| p("edge", &[("thickness", "2pt")], vec![p("color", &[("value", rgb)], vec![])]);
|
||||
let styled = node(
|
||||
"styled",
|
||||
XfaKind::Field,
|
||||
&[("name", "styled"), ("w", "2in")],
|
||||
vec![
|
||||
p(
|
||||
"border",
|
||||
&[("presence", "hidden")],
|
||||
vec![
|
||||
edge("255,0,0"),
|
||||
edge("0,0,255"),
|
||||
p("fill", &[], vec![p("color", &[("value", "1,2,3")], vec![]), p("linear", &[("type", "toTop")], vec![])]),
|
||||
],
|
||||
),
|
||||
p("ui", &[], vec![p("choiceList", &[("open", "always")], vec![p("border", &[], vec![edge("9,9,9")])])]),
|
||||
p(
|
||||
"validate",
|
||||
&[("nullTest", "error")],
|
||||
vec![
|
||||
p("message", &[], vec![t("text", &[("name", "nullTest")], "Required"), t("text", &[("name", "scriptTest")], "Too big")]),
|
||||
t("script", &[("contentType", "application/x-javascript")], "this.rawValue > 0"),
|
||||
],
|
||||
),
|
||||
p("font", &[("typeface", "Arial")], vec![]),
|
||||
p("assist", &[], vec![t("toolTip", &[], "Pick one")]),
|
||||
p("format", &[], vec![t("picture", &[], "num{z9}")]),
|
||||
p("value", &[], vec![p("decimal", &[], vec![])]),
|
||||
p("desc", &[], vec![t("text", &[("name", "vMandatory")], "1"), t("text", &[("name", "title")], "Title")]),
|
||||
p(
|
||||
"extras",
|
||||
&[],
|
||||
vec![
|
||||
t("text", &[("name", "note")], "N"),
|
||||
p("extras", &[("name", "arrayGroup")], vec![t("text", &[("name", "groupName")], "A")]),
|
||||
p("extras", &[("name", "arrayGroup")], vec![t("text", &[("name", "groupName")], "B")]),
|
||||
],
|
||||
),
|
||||
p("bind", &[("ref", "$.a")], vec![]),
|
||||
],
|
||||
);
|
||||
let plain = node("plain", XfaKind::Field, &[("name", "plain")], vec![]);
|
||||
let label = node("label", XfaKind::Draw, &[], vec![p("ui", &[], vec![p("textEdit", &[], vec![])])]);
|
||||
let rows = XfaNode {
|
||||
repeatable: true,
|
||||
occur_max: None,
|
||||
..node(
|
||||
"rows",
|
||||
XfaKind::Subform,
|
||||
&[],
|
||||
vec![p("occur", &[("min", "0"), ("max", "-1")], vec![]), p("breakBefore", &[("targetType", "pageArea")], vec![])],
|
||||
)
|
||||
};
|
||||
XfaNode {
|
||||
name: "form1".into(),
|
||||
som: "form1[0]".into(),
|
||||
kind: Some(XfaKind::Subform),
|
||||
children: vec![styled, plain, label, rows],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn go_on(root: &XfaNode, script: &str, target: &str) -> XfaOutcome {
|
||||
let ev = XfaEvent { activity: "click".into(), target: format!("form1[0].{target}[0]"), ..Default::default() };
|
||||
run_xfa(script, &ev, &XfaDoc::default(), root, Limits::default())
|
||||
}
|
||||
|
||||
/// The script's result, failing the test on a script error.
|
||||
fn eval(script: &str, target: &str) -> String {
|
||||
let o = go_on(&form(), script, target);
|
||||
assert_eq!(o.error, None, "{script}: {o:?}");
|
||||
o.result.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Each property the template leaves out reads as the default XFA 3.3 ch. 17 gives it, and is
|
||||
/// not specified.
|
||||
#[test]
|
||||
fn unspecified_properties_read_as_the_spec_defaults() {
|
||||
for (expr, want) in [
|
||||
("this.border.presence", "hidden"),
|
||||
("this.border.edge.presence", "visible"),
|
||||
("this.border.edge.color.value", "0,0,0"),
|
||||
("this.border.getElement('corner', 0).presence", "visible"),
|
||||
("this.border.corner.color.value", "0,0,0"),
|
||||
("this.border.fill.presence", "visible"),
|
||||
("this.border.fill.color.value", "255,255,255"),
|
||||
("this.border.fill.oneOfChild.className", "solid"),
|
||||
("this.font.fill.color.value", "0,0,0"),
|
||||
("this.font.typeface", "Courier"),
|
||||
("this.font.weight", "normal"),
|
||||
("this.para.hAlign", "left"),
|
||||
("this.para.vAlign", "top"),
|
||||
("this.ui.oneOfChild.className", "textEdit"),
|
||||
("this.validate.nullTest", "disabled"),
|
||||
("this.validate.formatTest", "warning"),
|
||||
("this.validate.scriptTest", "error"),
|
||||
("this.occur.min", "1"),
|
||||
("this.occur.max", "1"),
|
||||
("this.occur.initial", "1"),
|
||||
("this.assist.toolTip.value", ""),
|
||||
("this.format.picture.value", ""),
|
||||
("String(this.isPropertySpecified('border'))", "false"),
|
||||
("String(this.border.isPropertySpecified('presence'))", "false"),
|
||||
("String(this.validate.isPropertySpecified('nullTest'))", "false"),
|
||||
// Attributes without a default here read as an empty value that never throws.
|
||||
("this.border.edge.thickness", "0.5pt"),
|
||||
("String(this.para.radixOffset)", ""),
|
||||
("String(this.para.radixOffset == '0in')", "false"),
|
||||
("String(this.para.radixOffset.isPropertySpecified('x'))", "false"),
|
||||
("String(this.breakBefore.targetType) + this.font.posture.value", ""),
|
||||
] {
|
||||
assert_eq!(eval(expr, "plain"), want, "{expr}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the template writes is what scripts read, and is specified.
|
||||
#[test]
|
||||
fn specified_properties_read_as_the_template_writes_them() {
|
||||
for (expr, want) in [
|
||||
("this.border.presence", "hidden"),
|
||||
("this.border.edge.color.value", "255,0,0"),
|
||||
("this.border.edge.thickness", "2pt"),
|
||||
("this.border.fill.color.value", "1,2,3"),
|
||||
("this.border.fill.oneOfChild.className", "linear"),
|
||||
("this.border.fill.oneOfChild.type", "toTop"),
|
||||
("this.ui.oneOfChild.className", "choiceList"),
|
||||
("this.ui.oneOfChild.open", "always"),
|
||||
("this.validate.nullTest", "error"),
|
||||
("this.validate.message.text.value", "Required"),
|
||||
("this.validate.message.getElement('text', 1).value", "Too big"),
|
||||
("this.validate.message.nodes.length", "2"),
|
||||
("this.validate.message.nodes.item(1).name", "scriptTest"),
|
||||
("this.validate.message.nodes.namedItem('text').value", "Required"),
|
||||
("this.font.typeface", "Arial"),
|
||||
("this.assist.toolTip.value", "Pick one"),
|
||||
("this.format.picture.value", "num{z9}"),
|
||||
("String(this.format.picture)", "num{z9}"),
|
||||
("this.getElement('value', 0).oneOfChild.className", "decimal"),
|
||||
("String(this.isPropertySpecified('border'))", "true"),
|
||||
("String(this.isPropertySpecified('caption'))", "false"),
|
||||
("String(this.isPropertySpecified('w'))", "true"),
|
||||
("String(this.border.isPropertySpecified('presence'))", "true"),
|
||||
("String(this.border.isPropertySpecified('break'))", "false"),
|
||||
("String(this.border.isPropertySpecified('fill'))", "true"),
|
||||
("String(this.border.isPropertySpecified('corner'))", "false"),
|
||||
("this.getAttribute('w')", "2in"),
|
||||
("this.getAttribute('name')", "styled"),
|
||||
("this.getAttribute('presence')", "visible"),
|
||||
("this.getAttribute('nothing')", ""),
|
||||
("this.border.getAttribute('presence')", "hidden"),
|
||||
("this.border.parent.className", "field"),
|
||||
("this.border.edge.parent.className", "border"),
|
||||
] {
|
||||
assert_eq!(eval(expr, "styled"), want, "{expr}");
|
||||
}
|
||||
assert_eq!(eval("this.occur.min + ',' + this.occur.max", "rows"), "0,-1");
|
||||
assert_eq!(eval("this.breakBefore.targetType", "rows"), "pageArea");
|
||||
}
|
||||
|
||||
/// `getElement(name, index)` reaches each occurrence the template lists, up to what the spec
|
||||
/// allows (four edges), and nothing beyond. An edge or corner past those listed reads as the
|
||||
/// one drawn on that side (as readers draw two edges 0, 1, 0, 1, three 0, 1, 2, 1 and one on
|
||||
/// every side), comes into being as a copy of it when reached, and a write then changes only
|
||||
/// it; with none listed, each is a default one (edge p. 687, corner p. 663).
|
||||
#[test]
|
||||
fn get_element_reaches_occurrences_by_index() {
|
||||
assert_eq!(eval("this.border.getElement('edge', 0).color.value", "styled"), "255,0,0");
|
||||
assert_eq!(eval("this.border.getElement('edge', 1).color.value", "styled"), "0,0,255");
|
||||
assert_eq!(eval("this.border.getElement('edge').color.value", "styled"), "255,0,0");
|
||||
let edge3 = "var e = this.border.getElement('edge', 3); [e.color.value, e.thickness, e.presence, e.stroke, e.cap].join('|')";
|
||||
assert_eq!(eval(edge3, "styled"), "0,0,255|2pt|visible|solid|square");
|
||||
assert_eq!(eval("this.border.resolveNode('edge[2]').color.value", "styled"), "255,0,0");
|
||||
assert_eq!(eval("this.border.getElement('edge', 3).somExpression", "styled"), "form1[0].styled[0].border[0].edge[3]");
|
||||
let corner2 = "var c = this.border.getElement('corner', 2); \
|
||||
[c.color.value, c.thickness, c.presence, c.stroke, c.radius, c.join, c.inverted].join('|')";
|
||||
assert_eq!(eval(corner2, "styled"), "0,0,0|0.5pt|visible|solid|0in|square|0");
|
||||
assert_eq!(eval("this.border.getElement('edge', 3).color.value", "plain"), "0,0,0");
|
||||
// A write to an edge past those listed changes only it.
|
||||
let all = "[0, 1, 2, 3].map(function (i) { return this.border.getElement('edge', i).color.value; }, this).join('|')";
|
||||
assert_eq!(eval(&format!("this.border.getElement('edge', 3).color.value = '9,9,9'; {all}"), "styled"), "255,0,0|0,0,255|255,0,0|9,9,9");
|
||||
assert_eq!(eval("this.border.getElement('edge', 2).thickness = '3pt'; this.border.getElement('edge', 0).thickness", "styled"), "2pt");
|
||||
// Reached, it is a copy: a later write to the edge it was drawn from leaves it.
|
||||
let copy = "var e = this.border.getElement('edge', 3); this.border.getElement('edge', 1).color.value = '5,5,5'; e.color.value";
|
||||
assert_eq!(eval(copy, "styled"), "0,0,255");
|
||||
// With no edge listed, each index is a default edge of its own.
|
||||
assert_eq!(eval("this.border.getElement('edge', 2).color.value = '1,1,1'; this.border.edge.color.value", "plain"), "0,0,0");
|
||||
// One edge listed is drawn on every side; three reuse the second for the fourth.
|
||||
let edges = |colors: &[&str]| {
|
||||
let listed = colors.iter().map(|c| p("edge", &[], vec![p("color", &[("value", c)], vec![])])).collect();
|
||||
let f = node("e", XfaKind::Field, &[], vec![p("border", &[], listed)]);
|
||||
let mut root = form();
|
||||
root.children.push(f);
|
||||
let o = go_on(&root, all, "e");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
o.result.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(edges(&["1,1,1"]), "1,1,1|1,1,1|1,1,1|1,1,1");
|
||||
assert_eq!(edges(&["1,1,1", "2,2,2", "3,3,3"]), "1,1,1|2,2,2|3,3,3|2,2,2");
|
||||
for gone in ["this.border.getElement('edge', 4)", "this.border.getElement('edge', -1)", "this.border.getElement('nothing', 0)"] {
|
||||
assert_eq!(eval(&format!("String({gone})"), "styled"), "null", "{gone}");
|
||||
}
|
||||
assert_eq!(eval("this.getElement('border', 0).presence", "styled"), "hidden");
|
||||
}
|
||||
|
||||
/// Each widget is the `ui`'s one-of child, by its class name.
|
||||
#[test]
|
||||
fn one_of_child_names_each_widget() {
|
||||
for widget in [
|
||||
"barcode",
|
||||
"button",
|
||||
"checkButton",
|
||||
"choiceList",
|
||||
"dateTimeEdit",
|
||||
"defaultUi",
|
||||
"imageEdit",
|
||||
"numericEdit",
|
||||
"passwordEdit",
|
||||
"signature",
|
||||
"textEdit",
|
||||
] {
|
||||
let f = node("w", XfaKind::Field, &[], vec![p("ui", &[], vec![p("picture", &[], vec![]), p(widget, &[], vec![])])]);
|
||||
let root = XfaNode { name: "form1".into(), som: "form1[0]".into(), kind: Some(XfaKind::Subform), children: vec![f], ..Default::default() };
|
||||
let o = go_on(&root, &format!("this.ui.oneOfChild.className == '{widget}'"), "w");
|
||||
assert_eq!((o.error.as_deref(), o.result.as_deref()), (None, Some("true")), "{widget}");
|
||||
}
|
||||
assert_eq!(eval("this.ui.oneOfChild.className == 'choiceList'", "styled"), "true");
|
||||
assert_eq!(eval("this.ui.oneOfChild.className == 'choiceList'", "plain"), "false");
|
||||
}
|
||||
|
||||
/// script-errors C04a: the forms' reads through widgets, edges and validations compare as
|
||||
/// strings and throw nothing, whether the template writes the property or not.
|
||||
#[test]
|
||||
fn widget_border_and_validate_reads_throw_no_type_error() {
|
||||
for target in ["styled", "plain", "label"] {
|
||||
let script = "var c = this.ui.oneOfChild.border.getElement('edge', 0).color.value; \
|
||||
var n = this.validate.nullTest == 'error'; \
|
||||
var w = this.ui.oneOfChild.className == 'choiceList'; \
|
||||
c + '|' + n + '|' + w";
|
||||
let o = go_on(&form(), script, target);
|
||||
assert_eq!(o.error, None, "{target}: {o:?}");
|
||||
let want = match target {
|
||||
"styled" => "9,9,9|true|true",
|
||||
_ => "0,0,0|false|false",
|
||||
};
|
||||
assert_eq!(o.result.as_deref(), Some(want), "{target}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A script's writes are read back later in the same run, make the property specified, and
|
||||
/// change nothing on the form (no effect comes back).
|
||||
#[test]
|
||||
fn writes_are_read_back_in_the_run_and_have_no_effect() {
|
||||
let script = "this.border.fill.color.value = '255,0,0'; \
|
||||
this.validate.nullTest = 'error'; \
|
||||
this.assist.toolTip.value = 'Now'; \
|
||||
this.font.setAttribute('bold', 'weight'); \
|
||||
[this.border.fill.color.value, this.validate.nullTest, this.assist.toolTip.value, this.font.weight, \
|
||||
this.isPropertySpecified('border'), this.border.isPropertySpecified('fill'), this.validate.isPropertySpecified('nullTest'), \
|
||||
this.font.getAttribute('weight')].join('|')";
|
||||
let o = go_on(&form(), script, "plain");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.result.as_deref(), Some("255,0,0|error|Now|bold|true|true|true|bold"));
|
||||
assert!(o.effects.is_empty(), "{o:?}");
|
||||
// Another object is untouched, and a fresh run starts from the template again.
|
||||
assert_eq!(eval("this.border.fill.color.value", "label"), "255,255,255");
|
||||
assert_eq!(eval("this.validate.nullTest", "plain"), "disabled");
|
||||
}
|
||||
|
||||
/// form1 > { label (a field captioned "Name:"), rich (a field whose caption is rich text),
|
||||
/// list (a choice list with shown and saved items), box (a check button with on and off values) }
|
||||
fn caption_form() -> XfaNode {
|
||||
let caption =
|
||||
|content: XfaProp| p("caption", &[("reserve", "1in")], vec![p("value", &[], vec![content]), p("font", &[("weight", "bold")], vec![])]);
|
||||
let items = |attrs: &[(&str, &str)], tag: &str, entries: &[&str]| p("items", attrs, entries.iter().map(|e| t(tag, &[], e)).collect());
|
||||
let fields = vec![
|
||||
node("label", XfaKind::Field, &[], vec![caption(t("text", &[], "Name:"))]),
|
||||
node("rich", XfaKind::Field, &[], vec![caption(t("exData", &[("contentType", "text/html")], "Date of birth:"))]),
|
||||
node("list", XfaKind::Field, &[], vec![items(&[], "text", &["A", "B", "C"]), items(&[("save", "1")], "text", &["1", "2", "3"])]),
|
||||
node("box", XfaKind::Field, &[], vec![items(&[], "integer", &["1", "0"])]),
|
||||
node("bare", XfaKind::Field, &[], vec![]),
|
||||
];
|
||||
XfaNode { name: "form1".into(), som: "form1[0]".into(), kind: Some(XfaKind::Subform), children: fields, ..Default::default() }
|
||||
}
|
||||
|
||||
fn eval_on(root: &XfaNode, script: &str, target: &str) -> String {
|
||||
let o = go_on(root, script, target);
|
||||
assert_eq!(o.error, None, "{script}: {o:?}");
|
||||
o.result.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A caption is a property object (XFA 3.3 p. 643): its value element holds the label, as text
|
||||
/// or rich text, read by name, by class, through `resolveNode` and `oneOfChild`; the content it
|
||||
/// doesn't hold reads as null (one of them, p. 598); its rich text reads as its words.
|
||||
#[test]
|
||||
fn a_caption_reads_its_label_as_text_or_rich_text() {
|
||||
let root = caption_form();
|
||||
for (expr, want) in [
|
||||
("this.caption.value.text.value", "Name:"),
|
||||
("this.caption.value['#text'].value", "Name:"),
|
||||
("this.caption.value.resolveNode('#text').value", "Name:"),
|
||||
("this.caption.value.oneOfChild.className", "text"),
|
||||
("xfa.resolveNode('xfa.form.form1.label.caption.value.oneOfChild').value", "Name:"),
|
||||
("String(this.caption.value.exData)", "null"),
|
||||
("String(this.caption.value.resolveNode('#exData'))", "null"),
|
||||
("this.caption.value.text.value.substr(0, 4) + this.caption.value.text.value.indexOf(':')", "Name4"),
|
||||
(
|
||||
"this.caption.reserve + '|' + this.caption.placement + '|' + this.caption.presence + '|' + this.caption.font.weight",
|
||||
"1in|left|visible|bold",
|
||||
),
|
||||
("this.caption.className + '|' + this.caption.value.className", "caption|value"),
|
||||
] {
|
||||
assert_eq!(eval_on(&root, expr, "label"), want, "{expr}");
|
||||
}
|
||||
for (expr, want) in [
|
||||
("this.caption.value.exData.value", "Date of birth:"),
|
||||
("this.caption.value['#exData'].value.length", "14"),
|
||||
("this.caption.value.resolveNode('#exData').value.indexOf(':')", "13"),
|
||||
("this.caption.value.resolveNode('exData').value.replace(':', '* :')", "Date of birth* :"),
|
||||
("this.caption.value.oneOfChild.className", "exData"),
|
||||
("String(this.caption.value.text)", "null"),
|
||||
("(this.caption.value.exData) ? this.caption.value.exData.value : this.caption.value.text.value", "Date of birth:"),
|
||||
] {
|
||||
assert_eq!(eval_on(&root, expr, "rich"), want, "{expr}");
|
||||
}
|
||||
// Its methods to load and save rich text are not modelled yet: calling them changes nothing
|
||||
// and throws nothing.
|
||||
let o = go_on(&root, "this.caption.value.exData.loadXML('<body/>', false, true); typeof this.caption.value.exData.saveXML('pretty')", "rich");
|
||||
assert_eq!((o.error, o.result.as_deref(), o.effects.len()), (None, Some("string"), 0));
|
||||
// A caption the template doesn't give reads its defaults; a write reads back in the run.
|
||||
assert_eq!(eval_on(&root, "this.caption.placement + '|' + this.caption.value.oneOfChild.className", "bare"), "left|text");
|
||||
assert_eq!(eval_on(&root, "this.caption.value.text.value = 'Surname:'; this.caption.value.text.value", "label"), "Surname:");
|
||||
}
|
||||
|
||||
/// A field's items are a property object (XFA 3.3 p. 758) whose entries `nodes` lists, each
|
||||
/// reading as the list or check button shows it; a write reads back in the run.
|
||||
#[test]
|
||||
fn items_list_their_entries_as_the_list_shows_them() {
|
||||
let root = caption_form();
|
||||
for (expr, want) in [
|
||||
("this.items.nodes.length", "3"),
|
||||
("this.items.nodes.item(1).value", "B"),
|
||||
("this.items.nodes.item(2).className", "text"),
|
||||
("String(this.items.nodes.item.value)", "undefined"),
|
||||
("this.getElement('items', 1).save + '|' + this.getElement('items', 1).nodes.item(0).value", "1|1"),
|
||||
// The first `<items>` is the shown list; the second is reached by index.
|
||||
("typeof this.items.save + ':' + this.items.save", "string:0"),
|
||||
("this.resolveNode('items[1]').nodes.item(1).value", "2"),
|
||||
("this.resolveNodes('items[*]').length", "2"),
|
||||
] {
|
||||
assert_eq!(eval_on(&root, expr, "list"), want, "{expr}");
|
||||
}
|
||||
assert_eq!(eval_on(&root, "this.items.nodes.item(0).value + '|' + this.items.nodes.item(1).value", "box"), "1|0");
|
||||
let script = "if (this.items.nodes.item(0).value == '1') this.items.nodes.item(0).value = 'On'; this.items.nodes.item(0).value";
|
||||
assert_eq!(eval_on(&root, script, "box"), "On");
|
||||
// A list without `<items>`: an empty items object, no error.
|
||||
assert_eq!(eval_on(&root, "this.items.nodes.length + '|' + this.resolveNodes('items').length", "bare"), "0|1");
|
||||
}
|
||||
|
||||
/// The items reads come from the list state (`HNode::items`) once one is set: the hook for
|
||||
/// PdfCraft's choice-list methods (`addItem`, `clearItems`; the Series 1 work, not on main yet).
|
||||
/// Nothing sets it in the product yet, so this test sets it directly.
|
||||
#[test]
|
||||
fn items_read_the_list_state_once_one_is_set() {
|
||||
use super::{ItemList, content, list_entries, occurrences};
|
||||
let doc = XfaDoc::default();
|
||||
let mut h = super::super::XHost::new(Arc::new(caption_form()), false, "form1[0].list[0]", &doc);
|
||||
let list = h.current;
|
||||
assert_eq!(list_entries(&h, list, 0).len(), 3, "as the template lists them");
|
||||
let entries = vec![("text".to_string(), "One".to_string()), ("text".to_string(), "Two".to_string())];
|
||||
if let Some(n) = h.nodes.get_mut(list) {
|
||||
n.items = Some(vec![ItemList { entries }]);
|
||||
}
|
||||
assert_eq!(list_entries(&h, list, 0).len(), 2);
|
||||
assert_eq!(content(&h, list, "items#0/text#1"), "Two");
|
||||
assert_eq!(occurrences(&h, list, "", "items"), 1);
|
||||
// A list that had no `<items>` and got one entry.
|
||||
let mut h = super::super::XHost::new(Arc::new(caption_form()), false, "form1[0].bare[0]", &doc);
|
||||
let bare = h.current;
|
||||
assert_eq!(list_entries(&h, bare, 0).len(), 0);
|
||||
if let Some(n) = h.nodes.get_mut(bare) {
|
||||
n.items = Some(vec![ItemList { entries: vec![("text".to_string(), "X".to_string())] }]);
|
||||
}
|
||||
assert_eq!((list_entries(&h, bare, 0).len(), content(&h, bare, "items#0/text#0")), (1, "X".to_string()));
|
||||
}
|
||||
|
||||
/// A script can't grow the run past its limits: paths deeper than [`super::MAX_PATH`] take
|
||||
/// anything (a template nested deeper than the parser keeps), and one property keeps at most
|
||||
/// [`super::MAX_WRITTEN_ATTRS`] written attributes, with a note.
|
||||
#[test]
|
||||
fn deep_paths_and_many_writes_stay_bounded() {
|
||||
let mut deep = p("x", &[], Vec::new());
|
||||
for _ in 0..20 {
|
||||
deep = p("x", &[], vec![deep]);
|
||||
}
|
||||
let f = node("deep", XfaKind::Field, &[], vec![deep]);
|
||||
let root = XfaNode { name: "form1".into(), som: "form1[0]".into(), kind: Some(XfaKind::Subform), children: vec![f], ..Default::default() };
|
||||
let script = "var o = this.getElement('x', 0); var tags = []; for (var i = 0; i < 12; i++) { tags.push(String(o.className)); o.y = 1; o = o.x; } tags.join(',')";
|
||||
let o = go_on(&root, script, "deep");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.result.as_deref(), Some("x,x,x,x,x,x,x,x,,,,"));
|
||||
let script = "for (var i = 0; i < 1000; i++) { this.font.setAttribute('v' + i, 'a' + i); } this.font.getAttribute('a63') + '|' + this.font.getAttribute('a64')";
|
||||
let o = go_on(&form(), script, "plain");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.result.as_deref(), Some("v63|"));
|
||||
assert!(o.notes.iter().any(|n| n.contains("936 more")), "{o:?}");
|
||||
}
|
||||
|
||||
/// The other reads forms make: children by name (`desc.vMandatory`, a message's `scriptTest`
|
||||
/// text), properties by class (`ui.#textEdit`), `resolveNode` from a property, `somExpression`,
|
||||
/// `nodes.namedItem` by name, and a node's `mandatory` and validation messages, with their
|
||||
/// defaults where the template has none.
|
||||
#[test]
|
||||
fn named_children_class_names_and_validation_members_read_as_forms_use_them() {
|
||||
for (expr, want) in [
|
||||
("this.desc.vMandatory.value", "1"),
|
||||
("this.desc.nodes.namedItem('title').value", "Title"),
|
||||
// A name written in the run is the one `namedItem` finds.
|
||||
(
|
||||
"this.desc.nodes.namedItem('title').setAttribute('heading', 'name'), String(this.desc.nodes.namedItem('title')) + '|' + this.desc.nodes.namedItem('heading').value",
|
||||
"null|Title",
|
||||
),
|
||||
("this.extras.nodes.namedItem('note').value", "N"),
|
||||
("this.extras.note.value", "N"),
|
||||
("this.bind.ref", "$.a"),
|
||||
("this.validate.message.scriptTest.value", "Too big"),
|
||||
("this.validate.message.nullTest.value", "Required"),
|
||||
("this.ui['#choiceList'].open", "always"),
|
||||
("this.ui.resolveNode('#choiceList').className", "choiceList"),
|
||||
("this.ui.resolveNode('#choiceList.border.edge').color.value", "9,9,9"),
|
||||
("this.border.resolveNodes('edge[*]').length", "2"),
|
||||
("String(this.ui.resolveNode('#textEdit'))", "null"),
|
||||
("this.ui.nodes.item(0).className", "choiceList"),
|
||||
("this.border.edge.somExpression", "form1[0].styled[0].border[0].edge[0]"),
|
||||
("this.mandatory", "error"),
|
||||
("this.validationMessage", "Too big"),
|
||||
("this.mandatoryMessage", "Required"),
|
||||
("this.formatMessage", ""),
|
||||
("this.validate.script.value", "this.rawValue > 0"),
|
||||
// SOM expressions from a node that run into its properties.
|
||||
("this.resolveNode('ui.#choiceList').className", "choiceList"),
|
||||
("this.resolveNode('border.edge[1]').color.value", "0,0,255"),
|
||||
("xfa.resolveNode('xfa.form.form1.styled.border.edge[0].color').value", "255,0,0"),
|
||||
("xfa.form.form1.resolveNode('styled.ui.#choiceList.border.edge').color.value", "9,9,9"),
|
||||
("this.resolveNodes('border.edge[*]').length", "2"),
|
||||
("String(this.resolveNode('nothing.edge'))", "null"),
|
||||
("String(this.resolveNode('ui.#textEdit'))", "null"),
|
||||
("this.resolveNodes('extras.arrayGroup[*].groupName').length", "2"),
|
||||
("this.resolveNodes('extras.arrayGroup[*].groupName').item(1).value", "B"),
|
||||
("this.resolveNode('ui.*.border.edge').color.value", "9,9,9"),
|
||||
("this.fillColor + '|' + this.borderColor + '|' + this.fontColor", "1,2,3|255,0,0|0,0,0"),
|
||||
("this.fillColor = '255,0,0'; this.fillColor + '|' + this.border.fill.color.value", "255,0,0|255,0,0"),
|
||||
("this.desc.rawValue", "1"),
|
||||
("this.validate.message.text.rawValue", "Required"),
|
||||
] {
|
||||
assert_eq!(eval(expr, "styled"), want, "{expr}");
|
||||
}
|
||||
for (expr, want) in [
|
||||
("this.mandatory", "disabled"),
|
||||
("this.validationMessage", ""),
|
||||
("this.validationMessage.split('x').length", "1"),
|
||||
("this.validate.script.value", ""),
|
||||
("this.desc.rawValue", ""),
|
||||
("this.fillColor + '|' + this.borderColor + '|' + this.fontColor", "255,255,255|0,0,0|0,0,0"),
|
||||
("this.margin.leftInset", "0in"),
|
||||
("this.break.after", "auto"),
|
||||
("this.ui.textEdit.comb.numberOfCells", "0"),
|
||||
("this.ui['#textEdit'].border.fill.color.value", "255,255,255"),
|
||||
("this.bind.ref", ""),
|
||||
("String(this.desc.vMandatory.value)", ""),
|
||||
("this.border.fill.oneOfChild.color.value", ""),
|
||||
] {
|
||||
assert_eq!(eval(expr, "plain"), want, "{expr}");
|
||||
}
|
||||
// A gradient without a type runs to the right (XFA 3.3 p. 770).
|
||||
let f = node("g", XfaKind::Field, &[], vec![p("border", &[], vec![p("fill", &[], vec![p("linear", &[], vec![])])])]);
|
||||
let root = XfaNode { name: "form1".into(), som: "form1[0]".into(), kind: Some(XfaKind::Subform), children: vec![f], ..Default::default() };
|
||||
let o = go_on(&root, "this.border.fill.oneOfChild.type + '|' + this.border.fill.oneOfChild.color.value", "g");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("toRight|0,0,0")));
|
||||
// Writes are read back in the run, and only a known nullTest is taken.
|
||||
let o = go_on(
|
||||
&form(),
|
||||
"this.mandatory = 'error'; this.validationMessage = 'Bad'; var a = this.mandatory + this.validate.nullTest + this.validationMessage; this.mandatory = 'nonsense'; a + this.mandatory",
|
||||
"plain",
|
||||
);
|
||||
assert_eq!((o.error, o.result.as_deref(), o.effects.len()), (None, Some("errorerrorBaderror"), 0));
|
||||
}
|
||||
|
||||
/// A validation message set through the node is the message's text of that name, read back
|
||||
/// both ways; `rawValue` set on a property reads back where it is read from.
|
||||
#[test]
|
||||
fn messages_and_raw_values_written_read_back_from_their_property() {
|
||||
for (target, want) in [("plain", "Bad|Bad|Soon"), ("styled", "Bad|Bad|Soon")] {
|
||||
let script = "this.validationMessage = 'Bad'; this.mandatoryMessage = 'Soon'; \
|
||||
[this.validationMessage, this.validate.message.scriptTest.value, this.validate.message.nullTest.value].join('|')";
|
||||
let o = go_on(&form(), script, target);
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some(want)), "{target}");
|
||||
}
|
||||
assert_eq!(eval("this.validate.message.scriptTest.value = 'Mine'; this.validationMessage", "styled"), "Mine");
|
||||
for (expr, want) in [
|
||||
("this.desc.rawValue = 'Z'; this.desc.rawValue + this.desc.vMandatory.value", "ZZ"),
|
||||
("this.border.fill.color.rawValue = '5,5,5'; this.border.fill.color.value + '|' + this.border.fill.color.rawValue", "5,5,5|5,5,5"),
|
||||
("this.assist.toolTip.rawValue = 'T'; this.assist.toolTip.value", "T"),
|
||||
] {
|
||||
assert_eq!(eval(expr, "styled"), want, "{expr}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A child named like a property comes first, for every property name, as XFA SOM resolves
|
||||
/// name clashes in favour of the explicitly named object; `#name` reaches the property (XFA
|
||||
/// 3.3 p. 99).
|
||||
#[test]
|
||||
fn a_child_named_like_a_property_comes_first() {
|
||||
let mut parent = node("box", XfaKind::Subform, &[], vec![p("border", &[("presence", "hidden")], vec![]), p("occur", &[("min", "2")], vec![])]);
|
||||
parent.children = ["format", "desc", "border", "occur", "mandatory"]
|
||||
.iter()
|
||||
.map(|n| XfaNode { som: format!("form1[0].box[0].{n}[0]"), ..node(n, XfaKind::Field, &[], vec![]) })
|
||||
.collect();
|
||||
let root = XfaNode { name: "form1".into(), som: "form1[0]".into(), kind: Some(XfaKind::Subform), children: vec![parent], ..Default::default() };
|
||||
let script = "[this.format.className, this.desc.className, this.border.className, this.occur.className, this.mandatory.className, \
|
||||
this['#format'].className, this['#border'].presence, this['#occur'].min].join('|')";
|
||||
let o = go_on(&root, script, "box");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("field|field|field|field|field|format|hidden|2")));
|
||||
}
|
||||
|
||||
/// `resolveNodes` that runs through every instance into a property gives each instance's.
|
||||
#[test]
|
||||
fn resolve_nodes_through_instances_gives_each_instances_property() {
|
||||
let row = |i: usize, rgb: &str| XfaNode {
|
||||
som: format!("form1[0].row[{i}]"),
|
||||
index: i,
|
||||
repeatable: true,
|
||||
..node("row", XfaKind::Subform, &[], vec![p("border", &[], vec![p("fill", &[], vec![p("color", &[("value", rgb)], vec![])])])])
|
||||
};
|
||||
let root = XfaNode {
|
||||
name: "form1".into(),
|
||||
som: "form1[0]".into(),
|
||||
kind: Some(XfaKind::Subform),
|
||||
children: vec![row(0, "1,1,1"), row(1, "2,2,2")],
|
||||
..Default::default()
|
||||
};
|
||||
let script = "var l = xfa.resolveNodes('xfa.form.form1.row[*].border.fill.color'); l.length + '|' + l.item(0).value + '|' + l.item(1).value";
|
||||
let o = go_on(&root, script, "row");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("2|1,1,1|2,2,2")));
|
||||
}
|
||||
|
||||
/// A child named as a property comes first for each instance on its own: with a field `border`
|
||||
/// in the first row and only the border property in the second, `row[*].border` lists the
|
||||
/// field and then the second row's border.
|
||||
#[test]
|
||||
fn resolve_nodes_takes_a_child_over_a_property_instance_by_instance() {
|
||||
let row = |i: usize, children: Vec<XfaNode>| XfaNode {
|
||||
som: format!("form1[0].row[{i}]"),
|
||||
index: i,
|
||||
repeatable: true,
|
||||
children,
|
||||
..node("row", XfaKind::Subform, &[], vec![p("border", &[("presence", "hidden")], vec![])])
|
||||
};
|
||||
let field = XfaNode { som: "form1[0].row[0].border[0]".into(), ..node("border", XfaKind::Field, &[], vec![]) };
|
||||
let root = XfaNode {
|
||||
name: "form1".into(),
|
||||
som: "form1[0]".into(),
|
||||
kind: Some(XfaKind::Subform),
|
||||
children: vec![row(0, vec![field]), row(1, Vec::new())],
|
||||
..Default::default()
|
||||
};
|
||||
let script = "var l = xfa.resolveNodes('xfa.form.form1.row[*].border'); \
|
||||
[l.length, l.item(0).className, l.item(1).className, l.item(1).presence, l.namedItem('border').className].join('|')";
|
||||
let o = go_on(&root, script, "row");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("2|field|border|hidden|field")));
|
||||
}
|
||||
|
||||
/// A child named as a property shadows that property for the rest of the expression too
|
||||
/// (`row.border.edge[0]` with a field `border`): the rest is resolved from the child, and when the
|
||||
/// child has no such property the expression names nothing; it does not fall back to the parent's
|
||||
/// property. `#border` names the property by class (XFA 3.3 p. 96), so it still reaches it.
|
||||
#[test]
|
||||
fn a_child_named_as_a_property_shadows_it_for_the_rest_of_the_expression() {
|
||||
let edges = |presence: &str| vec![p("edge", &[("presence", presence)], vec![])];
|
||||
let field =
|
||||
XfaNode { som: "form1[0].row[0].border[0]".into(), ..node("border", XfaKind::Field, &[], vec![p("border", &[], edges("invisible"))]) };
|
||||
let row = XfaNode {
|
||||
som: "form1[0].row[0]".into(),
|
||||
children: vec![field],
|
||||
..node("row", XfaKind::Subform, &[], vec![p("border", &[], edges("hidden"))])
|
||||
};
|
||||
let root = XfaNode { name: "form1".into(), som: "form1[0]".into(), kind: Some(XfaKind::Subform), children: vec![row], ..Default::default() };
|
||||
let script = "var r = function (s) { var o = xfa.resolveNode('xfa.form.form1.row.' + s); return o === null ? 'null' : o.presence; }; \
|
||||
[r('border.border.edge[0]'), r('border.edge[0]'), r('#border.edge[0]')].join('|')";
|
||||
let o = go_on(&root, script, "row");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("invisible|null|hidden")));
|
||||
}
|
||||
|
||||
/// `keep` reads as the template writes it, else with its defaults (XFA 3.3 pp. 762-763):
|
||||
/// `intact` is `contentArea` for a draw and a positioned or row subform, `none` for a field and
|
||||
/// a flowing subform; `next` and `previous` are `none`. A write reads back in the run.
|
||||
#[test]
|
||||
fn keep_reads_its_context_dependent_defaults_and_writes_read_back() {
|
||||
let kept = node("kept", XfaKind::Field, &[], vec![p("keep", &[("intact", "pageArea"), ("next", "contentArea")], vec![])]);
|
||||
let flowing = node("flowing", XfaKind::Subform, &[("layout", "tb")], vec![]);
|
||||
let row = node("arow", XfaKind::Subform, &[("layout", "row")], vec![]);
|
||||
let mut root = form();
|
||||
root.children.extend([kept, flowing, row]);
|
||||
let read = |target: &str, script: &str| {
|
||||
let o = go_on(&root, script, target);
|
||||
assert_eq!(o.error, None, "{target}: {o:?}");
|
||||
o.result.unwrap_or_default()
|
||||
};
|
||||
let all = "[this.keep.intact, this.keep.next, this.keep.previous, this.keep.isPropertySpecified('intact')].join('|')";
|
||||
for (target, want) in [
|
||||
("plain", "none|none|none|false"),
|
||||
("label", "contentArea|none|none|false"),
|
||||
("rows", "contentArea|none|none|false"),
|
||||
("flowing", "none|none|none|false"),
|
||||
("arow", "contentArea|none|none|false"),
|
||||
("kept", "pageArea|contentArea|none|true"),
|
||||
] {
|
||||
assert_eq!(read(target, all), want, "{target}");
|
||||
}
|
||||
assert_eq!(
|
||||
read("plain", "this.keep.intact = 'contentArea'; this.keep.intact + '|' + this.keep.isPropertySpecified('intact')"),
|
||||
"contentArea|true"
|
||||
);
|
||||
}
|
||||
|
||||
/// A single unnamed message text is every validation's message (XFA 3.3 p. 782), and setting
|
||||
/// a test's message overwrites it, as readers do; with several texts and none named for the
|
||||
/// test, a text of that name is added. A test's text that doesn't exist can't be read: readers
|
||||
/// raise an error, which a script can catch.
|
||||
#[test]
|
||||
fn a_single_unnamed_message_text_serves_every_validation() {
|
||||
let f = node("one", XfaKind::Field, &[], vec![p("validate", &[], vec![p("message", &[], vec![t("text", &[], "Fix it")])])]);
|
||||
let two = node(
|
||||
"two",
|
||||
XfaKind::Field,
|
||||
&[],
|
||||
vec![p("validate", &[], vec![p("message", &[], vec![t("text", &[("name", "nullTest")], "N"), t("text", &[], "U")])])],
|
||||
);
|
||||
let mut root = form();
|
||||
root.children.extend([f, two]);
|
||||
let o = go_on(&root, "[this.validationMessage, this.formatMessage, this.mandatoryMessage].join('|')", "one");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("Fix it|Fix it|Fix it")));
|
||||
let script = "this.validationMessage = 'New'; \
|
||||
[this.validationMessage, this.formatMessage, this.mandatoryMessage, this.validate.message.nodes.length, this.validate.message.text.value].join('|')";
|
||||
let o = go_on(&root, script, "one");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("New|New|New|1|New")));
|
||||
let o = go_on(&root, "try { this.validate.message.scriptTest.value } catch (e) { 'caught ' + (e instanceof TypeError) }", "one");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("caught true")));
|
||||
let o = go_on(
|
||||
&root,
|
||||
"this.validationMessage = 'S'; [this.validationMessage, this.mandatoryMessage, this.validate.message.scriptTest.value].join('|')",
|
||||
"two",
|
||||
);
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("S|N|S")));
|
||||
// A name a script gives the text counts: it no longer serves every test.
|
||||
let o = go_on(&root, "this.validate.message.text.name = 'scriptTest'; this.validationMessage + '|' + this.mandatoryMessage", "one");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("Fix it|")));
|
||||
}
|
||||
|
||||
/// A write to a key that names a child sets that child's value, as a read gets the child.
|
||||
#[test]
|
||||
fn a_write_to_a_named_child_sets_its_value() {
|
||||
let f = node("named", XfaKind::Field, &[], vec![p("extras", &[("id", "attr")], vec![t("text", &[("name", "id")], "X")])]);
|
||||
let mut root = form();
|
||||
root.children.push(f);
|
||||
let o = go_on(&root, "this.extras.id = 'Y'; this.extras.id.value + '|' + this.extras.getAttribute('id')", "named");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("Y|attr")));
|
||||
}
|
||||
|
||||
/// A property's child of a name comes before an attribute or a property of that name, as for
|
||||
/// nodes (XFA 3.3 p. 99), and a node's child named like a member not modelled yet comes before
|
||||
/// the member.
|
||||
#[test]
|
||||
fn named_children_come_before_attributes_and_unmodelled_members() {
|
||||
let f = node(
|
||||
"named",
|
||||
XfaKind::Field,
|
||||
&[],
|
||||
vec![
|
||||
p("extras", &[("id", "attr")], vec![t("text", &[("name", "id")], "X")]),
|
||||
p("validate", &[], vec![p("message", &[], vec![t("text", &[("name", "scriptTest")], "A"), t("text", &[("name", "text")], "B")])]),
|
||||
],
|
||||
);
|
||||
let mut parent = node("box", XfaKind::Subform, &[], vec![]);
|
||||
parent.children = ["items", "event", "locale"]
|
||||
.iter()
|
||||
.map(|n| XfaNode { som: format!("form1[0].box[0].{n}[0]"), ..node(n, XfaKind::Field, &[], vec![]) })
|
||||
.collect();
|
||||
let mut root = form();
|
||||
root.children.extend([f, parent]);
|
||||
let o = go_on(&root, "this.extras.id.value + '|' + this.extras.getAttribute('id') + '|' + this.validate.message.text.value", "named");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("X|attr|B")));
|
||||
let o = go_on(&root, "[this.items.className, this.event.className, this.locale.className].join('|')", "box");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("field|field|field")));
|
||||
// Without such a child the name reaches the property: a field with no `<items>` reads an
|
||||
// items object with no entries.
|
||||
let o = go_on(&root, "this.items.className + '|' + this.items.nodes.length", "plain");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("items|0")));
|
||||
}
|
||||
|
||||
/// `borderColor` set colours all four edges, the ones not listed made first as copies of the
|
||||
/// edge drawn there. Read, it is edge 0's.
|
||||
#[test]
|
||||
fn border_color_colours_all_four_edges() {
|
||||
let all = "[0, 1, 2, 3].map(function (i) { return this.border.getElement('edge', i).color.value; }, this).concat([this.borderColor]).join('|')";
|
||||
for target in ["styled", "plain"] {
|
||||
assert_eq!(eval(&format!("this.borderColor = '7,7,7'; {all}"), target), "7,7,7|7,7,7|7,7,7|7,7,7|7,7,7", "{target}");
|
||||
}
|
||||
// The edges made are copies of those drawn there: only their colour changed.
|
||||
assert_eq!(eval("this.borderColor = '7,7,7'; this.border.getElement('edge', 3).thickness", "styled"), "2pt");
|
||||
}
|
||||
|
||||
/// A corner past those a border lists reads as the corner drawn there: one serves all four,
|
||||
/// two alternate, three reuse the second for the fourth, as for edges.
|
||||
#[test]
|
||||
fn corners_not_listed_read_as_the_corner_drawn_there() {
|
||||
let corners = |listed: &[&str]| {
|
||||
let kids = listed.iter().map(|r| p("corner", &[("radius", r)], vec![])).collect();
|
||||
let f = node("c", XfaKind::Field, &[], vec![p("border", &[], kids)]);
|
||||
let mut root = form();
|
||||
root.children.push(f);
|
||||
let o = go_on(&root, "[0, 1, 2, 3].map(function (i) { return this.border.getElement('corner', i).radius; }, this).join('|')", "c");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
o.result.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(corners(&["1pt"]), "1pt|1pt|1pt|1pt");
|
||||
assert_eq!(corners(&["1pt", "2pt"]), "1pt|2pt|1pt|2pt");
|
||||
assert_eq!(corners(&["1pt", "2pt", "3pt"]), "1pt|2pt|3pt|2pt");
|
||||
}
|
||||
|
||||
/// A border's presence reads `hidden` when the object has no `<border>` element, as readers
|
||||
/// read it, and `visible` when it has one that doesn't say (XFA 3.3 p. 629).
|
||||
#[test]
|
||||
fn border_presence_reads_hidden_without_a_border_element() {
|
||||
let f = node("bordered", XfaKind::Field, &[], vec![p("border", &[], vec![])]);
|
||||
let mut root = form();
|
||||
root.children.push(f);
|
||||
for (target, want) in [("plain", "hidden"), ("label", "hidden"), ("bordered", "visible"), ("styled", "hidden")] {
|
||||
let o = go_on(&root, "this.border.presence", target);
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some(want)), "{target}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A border has four sides (edges and corners occur at most four times, XFA 3.3 p. 629): an
|
||||
/// index past them is no element, from a script or from inside the host, and nothing is made
|
||||
/// for it.
|
||||
#[test]
|
||||
fn edges_and_corners_past_the_fourth_are_none() {
|
||||
for script in [
|
||||
"String(this.border.getElement('edge', 7))",
|
||||
"String(this.border.resolveNode('edge[7]'))",
|
||||
"String(this.border.resolveNode('corner[5]'))",
|
||||
"String(xfa.resolveNode('xfa.form.form1.styled.border.edge[4]'))",
|
||||
] {
|
||||
assert_eq!(eval(script, "styled"), "null", "{script}");
|
||||
}
|
||||
let doc = XfaDoc::default();
|
||||
let mut h = super::super::XHost::new(Arc::new(form()), false, "form1[0].styled[0]", &doc);
|
||||
let styled = h.current;
|
||||
h.instantiate(styled, "border#0/edge#7");
|
||||
h.instantiate(styled, "border#0/corner#5/color#0");
|
||||
h.instantiate(styled, "border#0/edge#3");
|
||||
let made: Vec<String> = h.prop_writes.get(&styled).map(|m| m.keys().cloned().collect()).unwrap_or_default();
|
||||
assert!(made.iter().all(|p| !p.contains("edge#7") && !p.contains("corner#5")), "{made:?}");
|
||||
assert!(made.iter().any(|p| p.starts_with("border#0/edge#3")), "a side within the four is made: {made:?}");
|
||||
}
|
||||
|
||||
/// An edge not listed mirrors the drawn one with what a script made on it, not only what the
|
||||
/// template lists: a colour set on the one listed edge shows on all four sides.
|
||||
#[test]
|
||||
fn edges_not_listed_mirror_what_a_script_made_on_the_drawn_one() {
|
||||
let f = node("one", XfaKind::Field, &[], vec![p("border", &[], vec![p("edge", &[], vec![])])]);
|
||||
let mut root = form();
|
||||
root.children.push(f);
|
||||
let script = "this.border.getElement('edge', 0).color.value = '255,0,0'; \
|
||||
[0, 1, 2, 3].map(function (i) { return this.border.getElement('edge', i).color.value; }, this).join('|')";
|
||||
let o = go_on(&root, script, "one");
|
||||
assert_eq!((o.error, o.result.as_deref()), (None, Some("255,0,0|255,0,0|255,0,0|255,0,0")));
|
||||
}
|
||||
|
||||
/// A calculation is a property object (XFA 3.3 pp. 641-642): `override` reads `disabled` where
|
||||
/// the template has no `<calculate>` and `error` where it has one that doesn't say, else as
|
||||
/// written; its script and message read; a write reads back in the run.
|
||||
#[test]
|
||||
fn a_calculation_reads_its_override_script_and_message() {
|
||||
let mut root = form();
|
||||
root.children.push(node(
|
||||
"calc",
|
||||
XfaKind::Field,
|
||||
&[],
|
||||
vec![p(
|
||||
"calculate",
|
||||
&[("override", "disabled")],
|
||||
vec![p("message", &[], vec![t("text", &[], "Computed")]), t("script", &[("contentType", "application/x-javascript")], "a + b")],
|
||||
)],
|
||||
));
|
||||
root.children.push(node("sum", XfaKind::Field, &[], vec![p("calculate", &[], vec![t("script", &[], "1")])]));
|
||||
for (target, expr, want) in [
|
||||
("calc", "this.calculate.override", "disabled"),
|
||||
("calc", "this.calculate.script.value + '|' + this.calculate.message.text.value", "a + b|Computed"),
|
||||
("calc", "this.calculate.className + '|' + this.calculate.isPropertySpecified('override')", "calculate|true"),
|
||||
("calc", "this.calculate.override = 'warning'; this.calculate.override", "warning"),
|
||||
("sum", "this.calculate.override", "error"),
|
||||
("plain", "this.calculate.override + '|' + this.calculate.isPropertySpecified('override')", "disabled|false"),
|
||||
("plain", "this['#calculate'].className + '|' + this.getElement('calculate').override", "calculate|disabled"),
|
||||
] {
|
||||
assert_eq!(eval_on(&root, expr, target), want, "{target}: {expr}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A fill type the fill doesn't have (a pattern on a solid fill) can't be read: the read
|
||||
/// throws an error a script catches, and the fill keeps its type; the type it has reads.
|
||||
#[test]
|
||||
fn a_fill_type_the_fill_does_not_have_throws_and_leaves_the_fill_as_it_was() {
|
||||
let mut root = form();
|
||||
root.children.push(node(
|
||||
"hatched",
|
||||
XfaKind::Field,
|
||||
&[],
|
||||
vec![p("border", &[], vec![p("fill", &[], vec![p("pattern", &[("type", "crossHatch")], vec![])])])],
|
||||
));
|
||||
root.children.push(node(
|
||||
"solid",
|
||||
XfaKind::Field,
|
||||
&[],
|
||||
vec![p("border", &[], vec![p("fill", &[], vec![p("color", &[("value", "1,2,3")], vec![])])])],
|
||||
));
|
||||
for (target, expr) in
|
||||
[("solid", "this.border.fill.pattern.type = 'crossHatch'"), ("plain", "this.border.fill.pattern"), ("styled", "this.border.fill.radial")]
|
||||
{
|
||||
let o = go_on(&root, expr, target);
|
||||
let e = o.error.unwrap_or_default();
|
||||
assert!(e.contains("GeneralError") && e.contains("operation failed"), "{target}: {expr}: {e}");
|
||||
}
|
||||
let caught = "var r; try { this.border.fill.pattern.type = 'crossHatch'; r = 'no error'; } catch (e) { r = e.name; } \
|
||||
r + '|' + this.border.fill.oneOfChild.className + '|' + this.border.fill.color.value";
|
||||
assert_eq!(eval_on(&root, caught, "solid"), "GeneralError|solid|1,2,3");
|
||||
assert_eq!(eval_on(&root, "this.border.fill.pattern.type = 'horizontal'; this.border.fill.pattern.type", "hatched"), "horizontal");
|
||||
assert_eq!(eval_on(&root, "this.border.fill.oneOfChild.className + '|' + this.border.fill.linear.type", "styled"), "linear|toTop");
|
||||
}
|
||||
|
||||
/// A border that lists more than four edges or corners still gives scripts four of each, as
|
||||
/// the layout draws the first four: the fifth and later are out of reach by index, by
|
||||
/// `resolveNodes`, and in `nodes`.
|
||||
#[test]
|
||||
fn a_border_listing_more_than_four_edges_gives_scripts_four() {
|
||||
let six = |tag: &str| (0..6).map(|i| p(tag, &[("thickness", &format!("{i}pt"))], vec![])).collect::<Vec<_>>();
|
||||
let mut kids = six("edge");
|
||||
kids.extend(six("corner"));
|
||||
let mut root = form();
|
||||
root.children.push(node("six", XfaKind::Field, &[], vec![p("border", &[], kids)]));
|
||||
for (expr, want) in [
|
||||
("String(this.border.getElement('edge', 5))", "null"),
|
||||
("String(this.border.getElement('edge', 4))", "null"),
|
||||
("this.border.getElement('edge', 3).thickness", "3pt"),
|
||||
("this.border.resolveNodes('edge[*]').length", "4"),
|
||||
("this.border.resolveNodes('corner[*]').length", "4"),
|
||||
("String(this.border.resolveNode('edge[5]'))", "null"),
|
||||
("String(xfa.resolveNode('xfa.form.form1.six.border.edge[5]'))", "null"),
|
||||
("this.border.nodes.length", "8"),
|
||||
] {
|
||||
assert_eq!(eval_on(&root, expr, "six"), want, "{expr}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Copying the edges a script reaches past those a border lists is charged to its own budget,
|
||||
/// not to the run's text budget: reaching thousands of sides leaves room for the script's
|
||||
/// writes, and the sides past the copy budget still read as the side drawn there.
|
||||
#[test]
|
||||
fn reaching_unlisted_edges_leaves_the_write_budget_to_writes() {
|
||||
let wide = "x".repeat(40 << 10);
|
||||
let rows: Vec<XfaNode> = (0..1_000)
|
||||
.map(|i| XfaNode {
|
||||
som: format!("form1[0].row[{i}]"),
|
||||
index: i,
|
||||
repeatable: true,
|
||||
..node("row", XfaKind::Field, &[], vec![p("border", &[], vec![p("edge", &[("thickness", "2pt"), ("note", wide.as_str())], vec![])])])
|
||||
})
|
||||
.collect();
|
||||
let mut root = form();
|
||||
root.children.extend(rows);
|
||||
let script = "var l = xfa.resolveNodes('xfa.form.form1.row[*].border.edge[3]'); \
|
||||
var t = l.item(999).thickness; \
|
||||
xfa.resolveNode('xfa.form.form1.styled').rawValue = 'kept'; l.length + '|' + t";
|
||||
let o = go_on(&root, script, "styled");
|
||||
assert_eq!((o.error.as_deref(), o.result.as_deref()), (None, Some("1000|2pt")), "{:?}", o.notes);
|
||||
assert!(o.effects.iter().any(|e| matches!(e, crate::xfa::XfaEffect::SetValue { value, .. } if value == "kept")), "{:?}", o.notes);
|
||||
assert!(o.notes.iter().all(|n| !n.contains("MiB")), "{:?}", o.notes);
|
||||
}
|
||||
|
||||
/// `name in fill` is false for a fill type the fill doesn't have, whose read throws, and true
|
||||
/// for what a read answers.
|
||||
#[test]
|
||||
fn in_says_which_fill_types_can_be_read() {
|
||||
let mut root = form();
|
||||
root.children.push(node(
|
||||
"solid",
|
||||
XfaKind::Field,
|
||||
&[],
|
||||
vec![p("border", &[], vec![p("fill", &[], vec![p("color", &[("value", "1,2,3")], vec![])])])],
|
||||
));
|
||||
let expr = "['pattern', 'linear', '#radial', 'color', 'solid', 'presence', 'oneOfChild'].map(function (k) { return k in this.border.fill; }, this).join('|')";
|
||||
assert_eq!(eval_on(&root, expr, "solid"), "false|false|false|true|true|true|true");
|
||||
assert_eq!(eval_on(&root, "('linear' in this.border.fill) + '|' + ('pattern' in this.border.fill)", "styled"), "true|false");
|
||||
}
|
||||
|
||||
/// `value.#text` on a draw stays unresolved: the property objects don't carry a draw's content,
|
||||
/// so it reads as null rather than as an empty text until a node's value is its value element.
|
||||
/// On a field its value element's content type is reached, as before.
|
||||
#[test]
|
||||
fn a_draws_value_text_stays_unresolved() {
|
||||
let mut root = form();
|
||||
root.children.push(node("words", XfaKind::Draw, &[], vec![p("value", &[], vec![p("text", &[], vec![])])]));
|
||||
assert_eq!(eval_on(&root, "String(this.resolveNode('value.#text'))", "words"), "null");
|
||||
assert_eq!(eval_on(&root, "this.resolveNode('value.#decimal').className", "styled"), "decimal");
|
||||
}
|
||||
|
||||
/// A node with a child named like a member read from its properties reads that child, and a
|
||||
/// write to the member doesn't reach the property either.
|
||||
#[test]
|
||||
fn a_member_shadowed_by_a_child_is_not_written_to_the_property() {
|
||||
let mut shadow =
|
||||
node("shadow", XfaKind::Subform, &[], vec![p("border", &[], vec![p("fill", &[], vec![p("color", &[("value", "4,4,4")], vec![])])])]);
|
||||
shadow.children = ["fillColor", "mandatory"]
|
||||
.iter()
|
||||
.map(|n| XfaNode { som: format!("form1[0].shadow[0].{n}[0]"), ..node(n, XfaKind::Field, &[], vec![]) })
|
||||
.collect();
|
||||
let mut root = form();
|
||||
root.children.push(shadow);
|
||||
let script = "this.fillColor = '9,9,9'; this.mandatory = 'error'; \
|
||||
[this.fillColor.className, this.border.fill.color.value, this.validate.nullTest].join('|')";
|
||||
assert_eq!(eval_on(&root, script, "shadow"), "field|4,4,4|disabled");
|
||||
}
|
||||
|
||||
/// A SOM expression into the properties of many instances gives at most
|
||||
/// [`super::MAX_RESOLVED`] of them.
|
||||
#[test]
|
||||
fn resolving_into_many_instances_stops_at_the_limit() {
|
||||
let rows: Vec<XfaNode> = (0..5_000)
|
||||
.map(|i| XfaNode {
|
||||
som: format!("form1[0].row[{i}]"),
|
||||
index: i,
|
||||
repeatable: true,
|
||||
..node("row", XfaKind::Subform, &[], vec![p("border", &[], vec![])])
|
||||
})
|
||||
.collect();
|
||||
let mut root = form();
|
||||
root.children.extend(rows);
|
||||
assert_eq!(eval_on(&root, "xfa.resolveNodes('xfa.form.form1.row[*].border').length", "styled"), super::MAX_RESOLVED.to_string());
|
||||
}
|
||||
|
||||
/// An `assist`'s `speak` reads its text and attributes.
|
||||
#[test]
|
||||
fn assist_speak_reads_its_text_and_attributes() {
|
||||
let mut root = form();
|
||||
root.children.push(node(
|
||||
"spoken",
|
||||
XfaKind::Field,
|
||||
&[],
|
||||
vec![p("assist", &[], vec![t("speak", &[("priority", "toolTip"), ("disable", "1")], "Say this")])],
|
||||
));
|
||||
let expr = "[this.assist.speak.value, this.assist.speak.priority, this.assist.speak.disable, this.assist.speak.className].join('|')";
|
||||
assert_eq!(eval_on(&root, expr, "spoken"), "Say this|toolTip|1|speak");
|
||||
assert_eq!(eval_on(&root, "this.assist.speak.value + '|' + this.assist.isPropertySpecified('speak')", "plain"), "|false");
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::globals::install;
|
||||
use super::host::{Shared, XHost};
|
||||
use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result};
|
||||
@@ -15,7 +17,7 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfcraft-xfa-js".into())
|
||||
.stack_size(crate::SCRIPT_STACK)
|
||||
.spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {}));
|
||||
.spawn_scoped(scope, || run_here(script, event, doc, Arc::new(root.clone()), false, limits, &|| {}));
|
||||
match spawned {
|
||||
Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())),
|
||||
Err(e) => failed(format!("the script engine could not start: {e}")),
|
||||
@@ -23,7 +25,7 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim
|
||||
})
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
run_here(script, event, doc, root.clone(), limits, &|| {})
|
||||
run_here(script, event, doc, Arc::new(root.clone()), false, limits, &|| {})
|
||||
}
|
||||
|
||||
/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready
|
||||
@@ -36,7 +38,7 @@ pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNod
|
||||
return XfaOutcome { error: Some(why), ..Default::default() };
|
||||
}
|
||||
let (script, event, doc) = (script.to_string(), event.clone(), doc.clone());
|
||||
run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started))
|
||||
run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, Arc::new(root), false, limits, started))
|
||||
}
|
||||
|
||||
/// Longest the script engine may take to be ready for a script (its thread and context, and
|
||||
@@ -125,7 +127,15 @@ pub(crate) fn warm_up() {
|
||||
}
|
||||
|
||||
/// `started` is called once the engine is ready: a time limit on the script runs from then.
|
||||
pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome {
|
||||
pub(crate) fn run_here(
|
||||
script: &str,
|
||||
event: &XfaEvent,
|
||||
doc: &XfaDoc,
|
||||
root: Arc<XfaNode>,
|
||||
reuse: bool,
|
||||
limits: Limits,
|
||||
started: &dyn Fn(),
|
||||
) -> XfaOutcome {
|
||||
warm_up();
|
||||
let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() {
|
||||
Ok(ctx) => ctx,
|
||||
@@ -133,7 +143,7 @@ pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNo
|
||||
};
|
||||
ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations);
|
||||
ctx.runtime_limits_mut().set_recursion_limit(limits.recursion);
|
||||
let host_state = XHost::new(root, &event.target, doc);
|
||||
let host_state = XHost::new(root, reuse, &event.target, doc);
|
||||
let current = host_state.current;
|
||||
ctx.insert_data(RefCell::new(host_state));
|
||||
let mut out = XfaOutcome::default();
|
||||
|
||||
@@ -36,8 +36,10 @@ pub struct Script {
|
||||
pub source: String,
|
||||
pub formcalc: bool,
|
||||
pub event: XfaEvent,
|
||||
/// The form as the caller holds it now.
|
||||
pub root: XfaNode,
|
||||
/// The form as the caller holds it now. Send the same `Arc` while the form is unchanged:
|
||||
/// the next script then starts from the copy the last one was given, without flattening the
|
||||
/// form again.
|
||||
pub root: std::sync::Arc<XfaNode>,
|
||||
pub limits: Limits,
|
||||
pub timeout: std::time::Duration,
|
||||
}
|
||||
@@ -58,13 +60,13 @@ fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome {
|
||||
if s.formcalc {
|
||||
// FormCalc has no engine to start: its time (and its own deadline) begins at once.
|
||||
started();
|
||||
return crate::formcalc::run_formcalc_at(&s.source, &s.event, doc, s.root, s.limits, Some(s.timeout));
|
||||
return crate::formcalc::run_formcalc_at(&s.source, &s.event, doc, s.root, true, s.limits, Some(s.timeout));
|
||||
}
|
||||
if let Some(why) = crate::refuse(&s.source) {
|
||||
started();
|
||||
return XfaOutcome { error: Some(why), ..Default::default() };
|
||||
}
|
||||
crate::xfa::run_here(&s.source, &s.event, doc, s.root, s.limits, started)
|
||||
crate::xfa::run_here(&s.source, &s.event, doc, s.root, true, s.limits, started)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -236,6 +238,14 @@ mod native {
|
||||
run_script(script, &self.doc, &|| {})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for XfaSession {
|
||||
/// The scripts ran on this thread: the form they started from is let go of with the
|
||||
/// event (on other targets it goes with the session's thread).
|
||||
fn drop(&mut self) {
|
||||
crate::xfa::forget_base();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use native::XfaSession;
|
||||
|
||||
@@ -205,6 +205,7 @@ impl PdfCraftApp {
|
||||
rotate_draft: RotateDraft::default(),
|
||||
summary_sort: Default::default(),
|
||||
sign_draft: None,
|
||||
presign: None,
|
||||
digital_ids: Vec::new(),
|
||||
sig_expanded: Vec::new(),
|
||||
a11y_options: a11y_ui::A11yOptions::default(),
|
||||
@@ -709,6 +710,7 @@ impl PdfCraftApp {
|
||||
self.process_file_requests();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.process_picked();
|
||||
self.settle_presign();
|
||||
// Last, after everything that can open or answer the "save changes?" question.
|
||||
self.attend_save_prompt(ctx);
|
||||
self.sync_native_menu(ctx);
|
||||
@@ -741,6 +743,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)
|
||||
}
|
||||
|
||||
@@ -508,6 +508,9 @@ impl PdfCraftApp {
|
||||
let (other_applied, stay) = self.apply_other_pane_edit(i);
|
||||
let modal = self.modal_open();
|
||||
let applied = self.apply_queued_edit(i) && other_applied;
|
||||
// After the queued edit: a value typed into the field the focus left has been taken (and
|
||||
// its change has run the field's exit) before the focus scripts run.
|
||||
self.run_focus_scripts(i);
|
||||
if !applied {
|
||||
// Nor may a shortcut pressed with it (⌘S right after Enter) run behind the refusal.
|
||||
self.deferred_commands.clear();
|
||||
@@ -744,9 +747,11 @@ impl PdfCraftApp {
|
||||
|
||||
/// Make the active document say what its form shows (#166): apply an edit still queued on
|
||||
/// its tab, then commit the text in a field's open editor, which otherwise commits only on
|
||||
/// Enter, Tab or clicking away (the editor closes, as clicking away would). Run before
|
||||
/// anything reads the document: Save, Print, Export, Optimize, Sign, Extract, Split, Copy
|
||||
/// pages and button scripts.
|
||||
/// Enter, Tab or clicking away (the editor closes, as clicking away would), then run the
|
||||
/// XFA enter and exit scripts of a focus move whose scripts haven't run yet (closing the
|
||||
/// editor is one; so is a move made in the same frame), so the document has what they
|
||||
/// change too. Run before anything reads the document: Save, Print, Export, Optimize, Sign,
|
||||
/// Extract, Split, Copy pages and button scripts.
|
||||
///
|
||||
/// Returns false when the field rejects the value (a validation script or format; the user
|
||||
/// is told why). The editor stays open with the text, and the caller must stop: nothing is
|
||||
@@ -756,14 +761,21 @@ impl PdfCraftApp {
|
||||
if !self.apply_queued_edit(i) {
|
||||
return false;
|
||||
}
|
||||
let Some(draft) = self.views.get(i).and_then(|v| v.forms.focus.clone()) else { return true };
|
||||
let Some(draft) = self.views.get(i).and_then(|v| v.forms.focus.clone()) else {
|
||||
self.run_focus_scripts(i);
|
||||
return true;
|
||||
};
|
||||
let Some(edit) = self.session.get(id).and_then(|d| crate::forms_ui::draft_edit(&draft, &d.form)) else {
|
||||
return true; // nothing typed that the document doesn't already have
|
||||
// Nothing typed that the document doesn't already have.
|
||||
self.run_focus_scripts(i);
|
||||
return true;
|
||||
};
|
||||
if let Some(view) = self.views.get_mut(i) {
|
||||
view.forms.focus = None;
|
||||
}
|
||||
if self.apply_edit(edit) {
|
||||
// The editor closed: the field's exit runs now, unless its change just ran it.
|
||||
self.run_focus_scripts(i);
|
||||
return true;
|
||||
}
|
||||
if let Some(view) = self.views.get_mut(i) {
|
||||
@@ -774,6 +786,51 @@ impl PdfCraftApp {
|
||||
false
|
||||
}
|
||||
|
||||
/// XFA enter and exit scripts for where the focus went on tab `i` since it was last looked at,
|
||||
/// however it moved (a click, Tab or Shift+Tab, Escape, a click on the page): the
|
||||
/// field it left runs its exit, then the field it reached runs its enter. The engine skips an
|
||||
/// exit the field's committed change already ran. A tab that closes runs nothing: its
|
||||
/// document is going away, and a script changing it then would only ask to save it. A script
|
||||
/// that changes the field being edited (an enter clearing a placeholder) shows in its editor
|
||||
/// ([`crate::forms_ui::refresh_draft`]), so leaving the field doesn't write the old value back.
|
||||
fn run_focus_scripts(&mut self, i: usize) {
|
||||
let Some(view) = self.views.get_mut(i) else { return };
|
||||
let now = view.forms.focus.as_ref().map(|f| (f.name.clone(), f.widget));
|
||||
if view.forms.focused == now {
|
||||
return;
|
||||
}
|
||||
let left = std::mem::replace(&mut view.forms.focused, now.clone());
|
||||
let id = view.id;
|
||||
let edited = view.forms.focus.clone();
|
||||
use pdfcraft_engine::js::FieldFocus;
|
||||
for ((name, widget), focus) in [left.map(|l| (l, FieldFocus::Exit)), now.map(|n| (n, FieldFocus::Enter))].into_iter().flatten() {
|
||||
// The edited field's value before this script, to tell its untouched draft from typing:
|
||||
// the exit of the field left may have changed it already, and the draft with it.
|
||||
let before: Option<(String, Vec<String>)> = edited.as_ref().and_then(|e| {
|
||||
crate::forms_ui::focused_field(e, &self.session.get(id)?.form).map(|f| (crate::forms_ui::draft_text(f), f.value.clone()))
|
||||
});
|
||||
match self.session.field_focus(id, &name, widget, focus) {
|
||||
Ok(changed) => {
|
||||
if changed
|
||||
&& let Some(info) = self.session.get(id).map(|d| d.info.clone())
|
||||
&& let Some(view) = self.views.get_mut(i)
|
||||
{
|
||||
view.document_changed(&info);
|
||||
if let Some(before) = &before
|
||||
&& let Some(draft) = view.forms.focus.as_mut()
|
||||
&& let Some(f) = self.session.get(id).and_then(|d| crate::forms_ui::focused_field(draft, &d.form))
|
||||
{
|
||||
crate::forms_ui::refresh_draft(draft, (&before.0, &before.1), (crate::forms_ui::draft_text(f), &f.value));
|
||||
}
|
||||
}
|
||||
let out = self.session.take_js_output(id);
|
||||
self.handle_js(id, out);
|
||||
}
|
||||
Err(e) => self.notify_error(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the edit queued on tab `i` (the active one). Returns false only when it was a
|
||||
/// field's typing and the field refused it (#166): the editor reopens with the text, and the
|
||||
/// user has been told why, so the typing isn't lost and the caller can stop.
|
||||
@@ -1406,6 +1463,49 @@ mod revert_draft_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Revert drops the focus without running the exit of the field that had it: the document
|
||||
/// is the saved one again, and nothing changes it after.
|
||||
#[test]
|
||||
fn revert_runs_no_exit_for_the_field_that_had_the_focus() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("focus.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())).unwrap();
|
||||
let id = app.views[0].id;
|
||||
let count = |app: &PdfCraftApp, name: &str| {
|
||||
app.session.get(id).unwrap().form.iter().find(|f| f.name == name).and_then(|f| f.value.first().cloned()).unwrap_or_default()
|
||||
};
|
||||
app.views[0].forms.focus = Some(Focus { name: "probe".into(), ..focus("") });
|
||||
assert!(app.process_pending_edits());
|
||||
assert_eq!(count(&app, "enters"), "1", "reaching probe ran its enter");
|
||||
app.revert_active();
|
||||
assert_eq!(count(&app, "enters"), "0");
|
||||
assert!(app.process_pending_edits());
|
||||
assert_eq!(count(&app, "exits"), "0", "no exit ran on the reverted document");
|
||||
assert!(!app.session.get(id).unwrap().dirty);
|
||||
}
|
||||
|
||||
/// A focus move whose scripts the frame hasn't run yet (Save pressed as the focus reaches a
|
||||
/// field) runs them before anything reads the document: `hint`'s enter clears its "Type here".
|
||||
#[test]
|
||||
fn committing_the_form_runs_the_focus_scripts_of_a_move_not_yet_run() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("focus.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())).unwrap();
|
||||
let hint = |app: &PdfCraftApp| {
|
||||
app.session
|
||||
.get(app.views[0].id)
|
||||
.unwrap()
|
||||
.form
|
||||
.iter()
|
||||
.find(|f| f.name == "hint")
|
||||
.and_then(|f| f.value.first().cloned())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(hint(&app), "Type here");
|
||||
// As the editor opens on it: its text and value.
|
||||
app.views[0].forms.focus = Some(Focus { name: "hint".into(), text: "Type here".into(), picked: vec!["Type here".into()], ..focus("") });
|
||||
assert!(app.commit_form_typing());
|
||||
assert_eq!(hint(&app), "", "hint's enter ran before the document is read");
|
||||
assert_eq!(app.views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some(""), "and its editor shows it");
|
||||
}
|
||||
#[test]
|
||||
fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() {
|
||||
for queued in [false, true] {
|
||||
|
||||
@@ -91,6 +91,9 @@ impl PdfCraftApp {
|
||||
// Revert discards this source's uncommitted typing as well as model edits.
|
||||
// Otherwise a later Save could write a rejected draft back into the file.
|
||||
view.forms.focus = None;
|
||||
// The focus goes with them, running no exit: a script changing the document
|
||||
// just reverted would only make it unsaved again.
|
||||
view.forms.focused = None;
|
||||
view.forms.committed = None;
|
||||
view.forms.unbaked.clear();
|
||||
view.pending_edit = None;
|
||||
|
||||
@@ -544,6 +544,9 @@ pub struct PdfCraftApp {
|
||||
pub summary_sort: pdfcraft_engine::SummarySort,
|
||||
/// The signing dialogs' state.
|
||||
pub sign_draft: Option<SignDraft>,
|
||||
/// A dynamic form's preSign step, run when signing a field began: the document and its undo
|
||||
/// depth after the step. Taken back if signing ends without a signature.
|
||||
pub(crate) presign: Option<(DocId, usize)>,
|
||||
/// Digital ID files the user has created or added.
|
||||
pub digital_ids: Vec<DigitalIdEntry>,
|
||||
/// Signatures panel: expanded entries (field names).
|
||||
|
||||
@@ -372,6 +372,7 @@ pub struct UnlockPrompt {
|
||||
}
|
||||
|
||||
impl UnlockPrompt {
|
||||
/// Ask for document `doc`'s permissions password, then run command `then`, if any.
|
||||
pub(crate) fn new(doc: &pdfcraft_engine::Document, then: Option<&'static str>) -> Self {
|
||||
Self { doc: doc.id, name: doc.name.clone(), input: String::new(), error: None, then }
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@ pub struct FormView {
|
||||
/// The widget holding egui's keyboard focus for the focused check box or radio button, and
|
||||
/// whether it takes the arrows (radio buttons). The page keys still work around it.
|
||||
pub(crate) keys: Option<(egui::Id, bool)>,
|
||||
/// The field and widget that had focus when it was last looked at: when the focus moves, the
|
||||
/// one it left runs its XFA exit scripts and the one it reached its enter scripts
|
||||
/// (`PdfCraftApp::run_focus_scripts`).
|
||||
pub(crate) focused: Option<(String, usize)>,
|
||||
/// The list box row clicked last, without Shift: (its field name, the row). A Shift-click picks
|
||||
/// the rows from it to the one clicked; the field's value can't say which row came last when
|
||||
/// rows share an export value, and `/I` lists them in ascending order.
|
||||
@@ -220,6 +224,17 @@ fn open_focus(f: &FormField, widget: usize) -> Focus {
|
||||
}
|
||||
}
|
||||
|
||||
/// After a script changed the field being edited from `before` to `after`, each given as its
|
||||
/// editor text ([`draft_text`]: a combo box's shown option) and its value: the editor shows
|
||||
/// `after`, as [`open_focus`] would, unless the person has typed there already (the draft no
|
||||
/// longer holds `before`), whose typing then stays theirs to commit.
|
||||
pub(crate) fn refresh_draft(focus: &mut Focus, before: (&str, &[String]), after: (String, &[String])) {
|
||||
if focus.picked.as_slice() == before.1 && focus.text == before.0 {
|
||||
focus.text = after.0;
|
||||
focus.picked = after.1.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
/// Select radio button `wi`, if it isn't selected already. The button itself, not its on state:
|
||||
/// buttons sharing a state are exclusive unless the group has RadiosInUnison (ISO 32000-2
|
||||
/// Table 229).
|
||||
@@ -1382,9 +1397,52 @@ pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_SCREEN_FONT, bounded_line_width};
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
/// A draft as the editor opens it on a field holding `value`.
|
||||
fn draft(value: &[&str]) -> Focus {
|
||||
Focus {
|
||||
name: "hint".into(),
|
||||
obj: None,
|
||||
widget: 0,
|
||||
text: value.first().map(|s| s.to_string()).unwrap_or_default(),
|
||||
picked: value.iter().map(|s| s.to_string()).collect(),
|
||||
request_focus: true,
|
||||
select_all: false,
|
||||
press: None,
|
||||
calendar: None,
|
||||
calendar_rect: None,
|
||||
calendar_text: None,
|
||||
list: ChoiceList::default(),
|
||||
had_focus: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An untouched draft takes the value a script gave the field; typing is kept.
|
||||
#[test]
|
||||
fn a_draft_follows_a_scripts_change_until_something_is_typed() {
|
||||
let before = vec!["Type here".to_string()];
|
||||
let mut untouched = draft(&["Type here"]);
|
||||
refresh_draft(&mut untouched, ("Type here", &before), (String::new(), &[]));
|
||||
assert_eq!((untouched.text.as_str(), untouched.picked.len()), ("", 0));
|
||||
let mut typed = draft(&["Type here"]);
|
||||
typed.text = "Ann".into();
|
||||
refresh_draft(&mut typed, ("Type here", &before), (String::new(), &[]));
|
||||
assert_eq!(typed.text, "Ann", "the person's typing stays");
|
||||
// A list's choices count as typing too.
|
||||
let mut picked = draft(&["a"]);
|
||||
picked.picked.push("b".into());
|
||||
refresh_draft(&mut picked, ("a", &["a".to_string()]), ("c".into(), &["c".to_string()]));
|
||||
assert_eq!(picked.picked, ["a", "b"]);
|
||||
// A combo box's editor shows the option's shown text, not its export value: that is
|
||||
// what an untouched draft holds, and what it takes.
|
||||
let mut combo = draft(&["a"]);
|
||||
combo.text = "Apple".into();
|
||||
refresh_draft(&mut combo, ("Apple", &["a".to_string()]), ("Banana".into(), &["b".to_string()]));
|
||||
assert_eq!((combo.text.as_str(), combo.picked.as_slice()), ("Banana", ["b".to_string()].as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_is_never_laid_out_above_the_screen_font_bound() {
|
||||
let largest = Cell::new(0.0f32);
|
||||
|
||||
@@ -289,10 +289,11 @@ impl PdfCraftApp {
|
||||
self.write_files(&[(name, std::sync::Arc::new(certificate_pem(c).into_bytes()))], "Export certificate");
|
||||
}
|
||||
|
||||
/// Start signing: the rectangle (or field) is known; show Sign with a Digital ID.
|
||||
/// Start signing: the rectangle (or field) is known; show Sign with a Digital ID, after a
|
||||
/// dynamic form's preSign scripts for the field.
|
||||
pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option<String>, certify: Option<u8>) {
|
||||
self.refresh_os_key_store_ids();
|
||||
let mut draft = SignDraft::new(page, rect, field, certify, &self.digital_ids);
|
||||
let mut draft = SignDraft::new(page, rect, field.clone(), certify, &self.digital_ids);
|
||||
draft.graphic = match &self.sign_graphic {
|
||||
SignGraphic::FillSign if self.signature.is_none() => SignGraphic::Name,
|
||||
g => g.clone(),
|
||||
@@ -302,6 +303,55 @@ impl PdfCraftApp {
|
||||
}
|
||||
self.sign_draft = Some(draft);
|
||||
self.dialog = Some(crate::Dialog::Sign);
|
||||
// A dynamic form's preSign scripts run now, as signing the field begins, so what they
|
||||
// change and say shows before the signer confirms (DA 31's date the signature and lock the
|
||||
// date). Signing ended without a signature takes them back ([`Self::settle_presign`]).
|
||||
if let Some(f) = &field
|
||||
&& let Some((_, doc)) = self.active_ids()
|
||||
{
|
||||
self.run_presign(doc, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run signature field `field`'s preSign scripts in document `doc` and keep the step pending.
|
||||
fn run_presign(&mut self, doc: pdfcraft_engine::DocId, field: &str) {
|
||||
match self.session.run_sign_event(doc, field, pdfcraft_engine::js::SignEvent::Before) {
|
||||
Ok(true) => {
|
||||
self.presign = self.session.get(doc).map(|d| (doc, d.undo_depth()));
|
||||
self.refresh_view_of(doc);
|
||||
let out = self.session.take_js_output(doc);
|
||||
self.handle_js(doc, out);
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => self.notify_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take back a pending preSign step once signing has ended without a signature: the Sign
|
||||
/// dialog closed and no save picker is open for it. Only a step still the last one is taken
|
||||
/// back. Called every frame.
|
||||
pub(crate) fn settle_presign(&mut self) {
|
||||
let Some((doc, depth)) = self.presign else { return };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.pickers.pending() {
|
||||
return;
|
||||
}
|
||||
if self.dialog == Some(crate::Dialog::Sign) {
|
||||
return;
|
||||
}
|
||||
self.presign = None;
|
||||
if self.session.get(doc).is_some_and(|d| d.undo_depth() == depth) && self.session.undo(doc).is_ok() {
|
||||
self.refresh_view_of(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show document `doc`'s changed form in its view.
|
||||
fn refresh_view_of(&mut self, doc: pdfcraft_engine::DocId) {
|
||||
if let Some(i) = self.views.iter().position(|v| v.id == doc)
|
||||
&& let Some(info) = self.session.get(doc).map(|d| d.info.clone())
|
||||
{
|
||||
self.views[i].document_changed(&info);
|
||||
}
|
||||
}
|
||||
|
||||
/// List OS key store signing identities (after the file-based IDs).
|
||||
@@ -476,12 +526,31 @@ impl PdfCraftApp {
|
||||
if !app.commit_typing_in(doc_id) {
|
||||
return Err(String::new());
|
||||
}
|
||||
let signed = app.session.sign(doc_id, &id, opts).map_err(|e| e.to_string())?;
|
||||
crate::editing::write_atomically(&path.to_string_lossy(), signed.as_slice()).map_err(|e| format!("Could not save: {e}"))?;
|
||||
// The form's preSign step (run when signing began) is signed with it; if signing fails
|
||||
// the step stays pending, and is taken back if signing ends without a signature.
|
||||
let field = opts.field.clone();
|
||||
let pending = app.presign.take();
|
||||
let signed = match app.session.sign(doc_id, &id, opts) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
app.presign = pending;
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
if let Err(e) = crate::editing::write_atomically(&path.to_string_lossy(), signed.as_slice()) {
|
||||
// Not saved, so not signed: the preSign step stays pending as well.
|
||||
app.presign = pending;
|
||||
return Err(format!("Could not save: {e}"));
|
||||
}
|
||||
// Our own write is not a change on disk to reload (#431).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
app.watch.known_now(doc_id, &path.to_string_lossy());
|
||||
app.session.mark_signed(doc_id, signed, Some(path.to_string_lossy().into_owned())).map_err(|e| e.to_string())?;
|
||||
// Then its postSign scripts; what they change is a change after signing, to save.
|
||||
// What they say shows once signing is done, after the notice that it was saved.
|
||||
// The file is signed and saved by now: a postSign step that fails is said, not a failure.
|
||||
let after = field.as_deref().and_then(|f| app.session.run_sign_event(doc_id, f, pdfcraft_engine::js::SignEvent::After).err());
|
||||
let said = app.session.take_js_output(doc_id);
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::macos_association::repair(&path);
|
||||
app.sign_graphic = choice;
|
||||
@@ -490,6 +559,10 @@ impl PdfCraftApp {
|
||||
}
|
||||
app.right = Some(crate::RightPanel::Signatures);
|
||||
app.notify_fmt("Signed and saved to {path}", &[("path", &path.display().to_string())]);
|
||||
app.handle_js(doc_id, said);
|
||||
if let Some(e) = after {
|
||||
app.notify_fmt("Signed and saved, but the form's postSign script couldn't finish: {e}", &[("e", &e.to_string())]);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
match self.save_override.clone() {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -417,6 +417,7 @@ const WITH_FIELD: &[u8] = b"%PDF-1.7
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// Clicking an empty signature field opens Sign for that field, and signing fills it.
|
||||
#[test]
|
||||
fn clicking_an_empty_signature_field_signs_it() {
|
||||
let dir = dir().join("field");
|
||||
@@ -501,6 +502,92 @@ fn a_signature_badge_says_its_verdict_with_any_tool() {
|
||||
assert_eq!(hover(&mut h, stamp, p + egui::vec2(1.0, 0.0)), (egui::CursorIcon::Crosshair, true), "so does a stamp, beside its crosshair");
|
||||
}
|
||||
|
||||
/// Signing a dynamic form's signature box in the app runs its XFA preSign script as signing begins,
|
||||
/// so its notice and the value it sets show before the signer confirms, and that value is in the
|
||||
/// signed file; its postSign script runs once signed, its change left to save.
|
||||
#[test]
|
||||
fn signing_an_xfa_box_runs_its_signing_scripts_in_the_app() {
|
||||
let dir = dir().join("xfa-events");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut h = harness(dir.clone());
|
||||
let bytes = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::signing_events_template());
|
||||
h.state_mut().open_bytes("events.pdf", None, bytes).unwrap();
|
||||
h.state_mut().active = Some(1);
|
||||
crate::support::settle(&mut h);
|
||||
let named = |h: &Harness<'static, PdfCraftApp>, n: &str| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[1].id).unwrap().form.iter().find(|f| f.name == n || f.name.ends_with(&format!(".{n}[0]"))).cloned().unwrap()
|
||||
};
|
||||
let p = {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[1].id).unwrap();
|
||||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[1], &doc.info, &named(&h, "sign"), 0).expect("on screen").center()
|
||||
};
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.drop_at(p);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(Dialog::Sign));
|
||||
assert!(h.query_by_label_contains("Signing notice").is_some(), "the preSign script's notice shows before signing");
|
||||
assert_eq!(named(&h, "when").value, ["signed"], "and what it set, under the dialog");
|
||||
let entry = {
|
||||
let id = pdfcraft_engine::sign::pkcs12::open(
|
||||
&std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(),
|
||||
"test",
|
||||
)
|
||||
.unwrap();
|
||||
h.state_mut().add_digital_id(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12"), &id.certificate)
|
||||
};
|
||||
{
|
||||
let d = h.state_mut().sign_draft.as_mut().unwrap();
|
||||
d.selected = Some(entry);
|
||||
d.step = SignStep::SignAs;
|
||||
d.password = "test".into();
|
||||
}
|
||||
h.run_steps(2);
|
||||
support::outside_menus(&h, "Sign").click();
|
||||
h.run_steps(6);
|
||||
// The signed file holds the preSign script's value; the postSign one waits to be saved.
|
||||
let signed = std::fs::read(dir.join("contract_signed.pdf")).unwrap();
|
||||
let mut s = pdfcraft_engine::Session::new();
|
||||
let id = s.open("signed.pdf", None, std::sync::Arc::new(signed), None).unwrap();
|
||||
let value =
|
||||
|n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n || f.name.ends_with(&format!(".{n}[0]"))).map(|f| f.value.clone()).unwrap();
|
||||
assert_eq!(value("when"), ["signed"]);
|
||||
assert!(value("after").is_empty());
|
||||
assert_eq!(named(&h, "after").value, ["done"]);
|
||||
let st = h.state();
|
||||
assert!(st.session.get(st.views[1].id).unwrap().dirty, "the postSign change is not saved yet");
|
||||
}
|
||||
|
||||
/// Signing a dynamic form's box and then cancelling takes back what its preSign script changed.
|
||||
#[test]
|
||||
fn cancelling_signing_takes_back_the_presign_scripts_changes() {
|
||||
let dir = dir().join("xfa-events-cancel");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut h = harness(dir.clone());
|
||||
let bytes = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::signing_events_template());
|
||||
h.state_mut().open_bytes("events.pdf", None, bytes).unwrap();
|
||||
h.state_mut().active = Some(1);
|
||||
crate::support::settle(&mut h);
|
||||
let named = |h: &Harness<'static, PdfCraftApp>, n: &str| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[1].id).unwrap().form.iter().find(|f| f.name == n || f.name.ends_with(&format!(".{n}[0]"))).cloned().unwrap()
|
||||
};
|
||||
let sign = named(&h, "sign").name;
|
||||
h.state_mut().start_signing(0, None, Some(sign), None);
|
||||
h.run_steps(2);
|
||||
assert_eq!(named(&h, "when").value, ["signed"]);
|
||||
h.state_mut().dialog = None;
|
||||
h.state_mut().sign_draft = None;
|
||||
h.run_steps(3);
|
||||
assert!(named(&h, "when").value.is_empty(), "taken back");
|
||||
assert!(!named(&h, "when").read_only(), "and unlocked again");
|
||||
}
|
||||
|
||||
/// A digital signature can show the Fill & Sign signature or an image where the name would be.
|
||||
#[test]
|
||||
fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() {
|
||||
use pdfcraft_ui_egui::SignGraphic;
|
||||
|
||||
@@ -127,6 +127,8 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// Clicking an XFA button in the app runs its click script: one adds a row, another shows its
|
||||
/// message as a notice.
|
||||
#[test]
|
||||
fn xfa_buttons_run_their_scripts_in_the_app() {
|
||||
let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
@@ -143,6 +145,87 @@ fn xfa_buttons_run_their_scripts_in_the_app() {
|
||||
assert!(h.query_by_label_contains("Hello 2").is_some(), "script console: {:?}", h.state().js_console.log);
|
||||
}
|
||||
|
||||
/// Every way the focus moves (a click, Tab, Shift+Tab, Escape) runs the field it left's exit
|
||||
/// script and the field it reached's enter script, changed or not; typing a change and tabbing
|
||||
/// out runs the exit once, not twice. A script's message shows as a notice.
|
||||
#[test]
|
||||
fn xfa_enter_and_exit_scripts_run_whenever_the_focus_moves() {
|
||||
use egui::{Key, Modifiers};
|
||||
let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template()));
|
||||
crate::support::settle(&mut h);
|
||||
let counts = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
let v = |n: &str| doc.form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()).unwrap_or_default();
|
||||
(v("enters"), v("exits"))
|
||||
};
|
||||
let focus = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].forms.focus.as_ref().map(|f| f.name.clone());
|
||||
let press = |h: &mut Harness<'static, PdfCraftApp>, modifiers, key| {
|
||||
h.key_press_modifiers(modifiers, key);
|
||||
h.run_steps(4);
|
||||
};
|
||||
click_field(&mut h, "probe");
|
||||
assert_eq!(counts(&h), ("1".into(), "0".into()), "a click in runs the enter");
|
||||
for _ in 0..2 {
|
||||
press(&mut h, Modifiers::NONE, Key::Tab);
|
||||
assert_eq!(focus(&h).as_deref(), Some("quiet"));
|
||||
press(&mut h, Modifiers::SHIFT, Key::Tab);
|
||||
assert_eq!(focus(&h).as_deref(), Some("probe"));
|
||||
}
|
||||
press(&mut h, Modifiers::NONE, Key::Escape);
|
||||
assert_eq!(focus(&h), None);
|
||||
assert_eq!(counts(&h), ("3".into(), "3".into()), "three unchanged visits, three exits; console: {:?}", h.state().js_console.log);
|
||||
// A change typed in: tabbing out runs the exit once.
|
||||
click_field(&mut h, "probe");
|
||||
h.event(egui::Event::Text("typed".into()));
|
||||
h.run_steps(2);
|
||||
press(&mut h, Modifiers::NONE, Key::Tab);
|
||||
assert_eq!(counts(&h), ("4".into(), "4".into()));
|
||||
// Leaving `quiet` shows its message.
|
||||
assert_eq!(focus(&h).as_deref(), Some("quiet"));
|
||||
press(&mut h, Modifiers::NONE, Key::Escape);
|
||||
assert!(h.query_by_label_contains("left quiet").is_some(), "console: {:?}", h.state().js_console.log);
|
||||
}
|
||||
|
||||
/// An enter script that changes the field it enters (clearing a "Type here") shows in the editor,
|
||||
/// so leaving the field keeps the script's value rather than writing the old one back; typing
|
||||
/// after it is kept as usual. When the exit of the field left changed it first (`relay` puts the
|
||||
/// "Type here" back), the editor follows both scripts and ends on the enter script's value.
|
||||
#[test]
|
||||
fn an_enter_script_that_changes_its_field_shows_in_the_editor() {
|
||||
use egui::{Key, Modifiers};
|
||||
let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template()));
|
||||
crate::support::settle(&mut h);
|
||||
let value = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "hint").and_then(|f| f.value.first().cloned()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(value(&h), "Type here");
|
||||
click_field(&mut h, "hint");
|
||||
assert_eq!(value(&h), "", "the enter script cleared it");
|
||||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).as_deref(), Some(""), "and the editor shows that");
|
||||
h.key_press_modifiers(Modifiers::NONE, Key::Tab);
|
||||
h.run_steps(4);
|
||||
assert_eq!(value(&h), "", "leaving it doesn't write \"Type here\" back");
|
||||
click_field(&mut h, "hint");
|
||||
h.event(egui::Event::Text("Ann".into()));
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(Modifiers::NONE, Key::Tab);
|
||||
h.run_steps(4);
|
||||
assert_eq!(value(&h), "Ann");
|
||||
// From `relay` back to `hint`: relay's exit puts "Type here" back over "Ann", then hint's enter
|
||||
// clears it. The editor follows both, so it holds the last value, and Tab keeps it.
|
||||
click_field(&mut h, "relay");
|
||||
click_field(&mut h, "hint");
|
||||
assert_eq!(value(&h), "", "relay's exit, then hint's enter");
|
||||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).as_deref(), Some(""), "the editor holds the enter script's value");
|
||||
h.key_press_modifiers(Modifiers::NONE, Key::Tab);
|
||||
h.run_steps(4);
|
||||
assert_eq!(value(&h), "");
|
||||
}
|
||||
|
||||
/// A message an initialize script shows on opening appears at once, and a print request from it
|
||||
/// is refused (only a click may print).
|
||||
#[test]
|
||||
fn messages_from_scripts_run_on_open_show_right_away() {
|
||||
let tpl = pdfcraft_xfa::fixtures::scripted_template()
|
||||
@@ -210,6 +293,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 +313,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");
|
||||
}
|
||||
|
||||
+47
-7
@@ -25,8 +25,25 @@ that warning and keeps the existing layout and accepted widget values.
|
||||
margins, borders (edge order rules), captions with reserves, check-button items, pictures,
|
||||
rich text (`exData` XHTML: paragraphs, `br`, bold/italic/underline/size spans, embedded fields),
|
||||
text-field limits (`text/maxChars` and `exData/maxLength`, carried into AcroForm `/MaxLen`),
|
||||
inline JPEG, PNG and GIF images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow
|
||||
required fields and exclusion groups (`<validate nullTest="error">`), inline JPEG, PNG and GIF images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow
|
||||
leaders, `columnWidths` and `colSpan`.
|
||||
- **Prototypes** (`proto`, XFA 3.3 chapter 6, "Prototypes"): before parsing, every `use` and
|
||||
`usehref` reference into the same template is resolved and the template is written again
|
||||
with it merged in, so Designer style sheets (`usehref=".#som($template.#subform.styles.X)"`)
|
||||
give their fields and draws size, anchor, borders, fills, fonts and captions. The element's
|
||||
own attributes, content and children win; `id` and `name` are not inherited; children match
|
||||
by class and position, a matched prototype child being the prototype of the element's own;
|
||||
a `ui` widget or `value` kind and rich text are taken from the nearest layer that has one.
|
||||
References are `#id`, `#som(…)` (or `.#…`) and, for `use`, a SOM expression. Loops are cut
|
||||
where they close, chains merge at most 32 layers, nesting stops at 256 elements, a SOM
|
||||
expression longer than 1,024 bytes names nothing, SOM look-ups share a budget of 2 million
|
||||
elements, each element's reference is resolved once however many elements reach it,
|
||||
merging looks at no more than 64 million children, attributes and namespaces in all
|
||||
(prototype children that write nothing, such as `proto`, count too), and the written
|
||||
template may grow to 4 times its size (at least 1 MB, at most 64 MB). Past the nesting,
|
||||
look-up, merge or growth budget expanding fails as a whole: no partly written template is
|
||||
used, the template is read as it is written (unresolved), with one warning saying which
|
||||
budget. A cut loop or chain is a warning too.
|
||||
- **Layout** (`layout`): positioned, `tb`, `lr-tb`/`rl-tb`, `table` and `row` layouts; a
|
||||
container without a width is as wide as its content; flow across content areas and pages with
|
||||
page masters chosen by occurrence; a table's header row repeats after a break; presence
|
||||
@@ -41,7 +58,11 @@ that warning and keeps the existing layout and accepted widget values.
|
||||
a field inside an exclusion group is not shown yet;
|
||||
a draw placed by position turns about its anchor (`rotate`: degrees counter-clockwise, a multiple
|
||||
of 90), as one item a page break doesn't cut, and its container grows to the box it stands in. A
|
||||
field, a subform or a draw in a flow doesn't turn.
|
||||
field, a subform or a draw in a flow doesn't turn. A
|
||||
caption without a font of its own is drawn in its field's font, and one naming only
|
||||
some font properties takes the rest from it (XFA 3.3, "Formatting Text That Appears as Fixed
|
||||
or Variable Content", p. 43); a field caption's paragraph
|
||||
is still the one around the field.
|
||||
- **PDF** (`pdf::write_form`): pages with content streams in the standard 14 fonts (Arial and
|
||||
friends → Helvetica, Courier New → Courier, serif faces → Times; nothing is embedded), JPEG
|
||||
XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate`
|
||||
@@ -50,14 +71,18 @@ that warning and keeps the existing layout and accepted widget values.
|
||||
scripts, and signature fields, each named by its SOM path (`form1[0].page1[0].sign[0]`, as
|
||||
fields under one for each step above it) because other viewers lay a dynamic form out again
|
||||
and find a signature by that name. A box whose path, or a part of it, the file already gives
|
||||
another field keeps its own name, and a new layout keeps a signature a generated box holds. Appearance streams are left empty for the forms layer to
|
||||
another field keeps its own name, and a new layout keeps a signature a generated box holds. A field or exclusion group that must be filled in gets the `Required` flag (a radio
|
||||
group on its group field), which viewers outline when they highlight fields. Appearance streams are left empty for the forms layer to
|
||||
generate. Each field keeps its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out
|
||||
twice. The XFA packets stay, so Adobe's viewers keep rendering the form from them.
|
||||
|
||||
- **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data
|
||||
node at its data path: its SOM path without areas, which are named in SOM expressions but are
|
||||
not data scopes (`data_path`, as Adobe's viewers bind) (dates in ISO form, check and radio states by their on values), and a
|
||||
repeating subform or row gets as many instances as the data has. `write_datasets` merges the
|
||||
repeating subform or table row gets as many instances as the data has (in flowed and positioned
|
||||
containers alike), up to its `occur max` and at
|
||||
most 1 000 (`MAX_INSTANCES`, a work bound; the rows past it are left out with a layout
|
||||
warning). `write_datasets` merges the
|
||||
AcroForm fields' values (by `/PCSom`, or by the Designer field names of a static form) into
|
||||
the existing `xfa:data`: only the bound nodes' text changes and missing nodes are added, so
|
||||
unbound data, other namespaces, attributes and comments stay byte for byte. Values it can't
|
||||
@@ -79,7 +104,8 @@ be measured exponentially often in their nesting. A measurement budget backs thi
|
||||
the datasets packet per event), and `rerender`, which lays the form out again from template,
|
||||
data and overrides. The scripts themselves run in the `js` crate's XFA object model; the
|
||||
engine runs initialize and calculate on open, change, exit, validate and calculate after a
|
||||
field changes, and click for buttons. Every generated copy of a page-master button resolves
|
||||
field changes, click for buttons, and preSign and postSign around signing a signature box
|
||||
(preSign first, so what it changes is signed too). Every generated copy of a page-master button resolves
|
||||
its own PDF field to the shared XFA click script, so clicking an early copy works as well
|
||||
as the last, including through `js_run` and after save/reopen.
|
||||
While this XFA layout is active, the engine does not
|
||||
@@ -120,8 +146,17 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
|
||||
navigation and SOM resolution, `rawValue`, `presence`, `access`, instance managers,
|
||||
`xfa.host` messages, reset, print, focus and URLs, `xfa.layout` page and sheet numbers,
|
||||
`xfa.event`);
|
||||
not `xfa.template`, data descriptions, `xfa.connectionSet`, `border`/`font`/`ui` properties
|
||||
(reads give a sink that accepts writes), `execEvent`, or the change event per keystroke.
|
||||
property objects (`border`, `font`, `para`, `margin`, `ui` and its `oneOfChild`, `validate`,
|
||||
`calculate`, `occur`, `break`, `breakBefore`, `breakAfter`, `assist`, `format`, `bind`,
|
||||
`desc`, `extras`, `keep`, `caption` and a field's `items`), read as the template writes them
|
||||
(the parser keeps each object's property elements, shared by every instance and every
|
||||
script's snapshot) with the spec's defaults for what forms read, `getElement`,
|
||||
`isPropertySpecified`, `getAttribute`, and a node's `mandatory`, validation messages and
|
||||
colour shorthands; writes to them are read back in the same script but change nothing yet.
|
||||
Not `xfa.template`, data descriptions, `xfa.connectionSet`, the choice-list methods
|
||||
(`addItem`, `clearItems`, `boundItem`; `selectedIndex` reads give a sink that accepts
|
||||
writes), a node's `value` as its value element, `traversal` and `overflow`, property reads
|
||||
in FormCalc, `execEvent`, or the change event per keystroke.
|
||||
Validate failures show their message and keep the value, as Acrobat does for scripts.
|
||||
A hidden instance of a repeating subform hides every instance (overrides apply to the template).
|
||||
A repeating subform shows what its data holds once the data holds any instance, else its
|
||||
@@ -170,6 +205,11 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
|
||||
file's data is held as a tree and written with the packet's own namespace prefix, never as
|
||||
the file's markup, and every rewrite of the packet is parsed back before it is written; the
|
||||
engine then lays a dynamic form out again from it and runs its calculations.
|
||||
- **Prototypes in other documents** (`usehref="file.xdp#…"`) are not followed: that would
|
||||
mean reading files or the network on opening a form. The reference is left out with a
|
||||
warning. SOM references cover names, `#class` and `[n]`, not `[*]`, `..` or relative forms;
|
||||
traversal declarations are merged like other children, not knitted together as "Resolving
|
||||
Prototypes with Traversals" describes.
|
||||
- Missing: `keep` constraints, `subformSet` relations, `rl-tb` is mirrored `lr-tb`, font
|
||||
metrics are the approximate Helvetica ones, and line heights are 1.15 × size.
|
||||
- Acrobat cannot be an oracle here (clean-room rules): layout follows the specification, and
|
||||
|
||||
+18
-4
@@ -35,8 +35,8 @@ pub(crate) use import::MAX_IMPORT_BYTES;
|
||||
pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image};
|
||||
pub use passwords::{clear_password_values, has_password_values};
|
||||
pub use path::{data_path, parse_datasets, som_to_path};
|
||||
pub(crate) use path::{fields_with_ids, template_field};
|
||||
pub use values::{FieldData, FieldDatum, is_on, read_values, read_values_with};
|
||||
pub(crate) use path::{fields_with_ids, template_field, template_object};
|
||||
pub use values::{FieldData, FieldDatum, is_on, name_text, radio_dataset_value, radio_state_for_item, read_values, read_values_with};
|
||||
#[cfg(test)]
|
||||
pub(crate) use write::ENSURED;
|
||||
pub use write::{
|
||||
@@ -112,11 +112,25 @@ impl DataNode {
|
||||
/// elements (one way a multi-select list's picks are stored), those on separate lines.
|
||||
pub fn value_at(&self, path: &[(String, usize)]) -> Option<String> {
|
||||
let n = self.get(path)?;
|
||||
if n.children.is_empty() || !n.children.iter().all(|c| c.name == "value") {
|
||||
if !n.holds_picks() {
|
||||
return Some(n.text.clone());
|
||||
}
|
||||
Some(n.children.iter().map(|c| c.text.trim()).filter(|t| !t.is_empty()).take(1000).collect::<Vec<_>>().join("\n"))
|
||||
Some(picks_of(n.children.iter().map(|c| c.text.as_str())).collect::<Vec<_>>().join("\n"))
|
||||
}
|
||||
|
||||
/// Whether this node holds a multi-select list's picks: children, all `value` elements.
|
||||
pub(crate) fn holds_picks(&self) -> bool {
|
||||
!self.children.is_empty() && self.children.iter().all(|c| c.name == "value")
|
||||
}
|
||||
}
|
||||
|
||||
/// Most picks a multi-select value is read or written with.
|
||||
pub(crate) const MAX_PICKS: usize = 1000;
|
||||
|
||||
/// The picks among `texts` (`value` elements, or the lines of a value): non-empty, trimmed, at
|
||||
/// most [`MAX_PICKS`].
|
||||
pub(crate) fn picks_of<'a>(texts: impl Iterator<Item = &'a str>) -> impl Iterator<Item = &'a str> {
|
||||
texts.map(str::trim).filter(|t| !t.is_empty()).take(MAX_PICKS)
|
||||
}
|
||||
|
||||
/// One change to the data, for [`write_data_ops`].
|
||||
|
||||
@@ -52,7 +52,11 @@ pub fn data_path(tpl: &Template, som: &str) -> DataPath {
|
||||
// The template children the next segment is looked up in; `None` until the root matched,
|
||||
// and again once a segment is not a container the template has.
|
||||
let mut level: Option<&[Node]> = None;
|
||||
for (i, (name, index)) in som_to_path(som).into_iter().enumerate() {
|
||||
let segments = som_to_path(som);
|
||||
let page_area_index = page_area_index(tpl, &segments);
|
||||
for (i, (name, index)) in segments.into_iter().enumerate() {
|
||||
// A page area's SOM index is the page made from it; see [`page_area_index`].
|
||||
let index = if i == 0 { page_area_index.unwrap_or(index) } else { index };
|
||||
let found = match level {
|
||||
None if i == 0 && tpl.root.common.name.as_deref() == Some(name.as_str()) => Some(Container::Children(&tpl.root.children)),
|
||||
None => None,
|
||||
@@ -77,6 +81,36 @@ pub fn data_path(tpl: &Template, som: &str) -> DataPath {
|
||||
out
|
||||
}
|
||||
|
||||
/// For a SOM path that starts at a page area (`Area[n].…`), the index its first segment takes in
|
||||
/// the data path, `None` for any other path. Each page made from a page area has its own SOM
|
||||
/// index, so the master content repeated on each page (a page number, an office-use box) binds
|
||||
/// apart on every page. A field whose bind shares one data node (`match="global"`, or an
|
||||
/// explicit data reference) binds as instance 0 on each page instead, so it shows the same value
|
||||
/// on all of them (XFA 3.3, "Page Background": a page-background field binds only through an
|
||||
/// explicit data reference).
|
||||
fn page_area_index(tpl: &Template, segments: &[(String, usize)]) -> Option<usize> {
|
||||
let ((first, instance), rest) = segments.split_first()?;
|
||||
if tpl.root.common.name.as_deref() == Some(first.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let areas = tpl.root.page_set.as_ref()?;
|
||||
let area = areas.areas.iter().find(|a| a.name.as_deref() == Some(first.as_str()))?;
|
||||
// The object the rest of the path names, inside the page area's own content.
|
||||
let mut nodes: &[Node] = &area.items;
|
||||
let mut target = None;
|
||||
for (name, index) in rest {
|
||||
let found = named_at(nodes, name, *index, 0)?;
|
||||
target = Some(found);
|
||||
nodes = match found {
|
||||
Node::Subform(sf) => &sf.children,
|
||||
Node::Area(a) => &a.children,
|
||||
_ => &[],
|
||||
};
|
||||
}
|
||||
let shared = matches!(target, Some(Node::Field(f)) if f.data_shared);
|
||||
Some(if shared { 0 } else { *instance })
|
||||
}
|
||||
|
||||
enum Container<'a> {
|
||||
Children(&'a [Node]),
|
||||
/// Contents bind under the area's parent.
|
||||
@@ -133,6 +167,16 @@ fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Op
|
||||
/// as the form counts it ([`named_at`]). `None` when the template has no such field, or it is
|
||||
/// a button of an exclusion group (whose buttons take their value from the group).
|
||||
pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> {
|
||||
match template_object(tpl, som)? {
|
||||
Node::Field(f) => Some(f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The template object (field, exclusion group, subform...) the object at a SOM path is an
|
||||
/// instance of, found as [`template_field`] finds a field. `None` for the root and for a button
|
||||
/// of an exclusion group.
|
||||
pub(crate) fn template_object<'a>(tpl: &'a Template, som: &str) -> Option<&'a Node> {
|
||||
let path = som_to_path(som);
|
||||
let ((root, _), rest) = path.split_first()?;
|
||||
if tpl.root.common.name.as_deref() != Some(root.as_str()) {
|
||||
@@ -147,10 +191,7 @@ pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Fie
|
||||
_ => return None,
|
||||
};
|
||||
}
|
||||
match named_at(children, last, *last_index, 0)? {
|
||||
Node::Field(f) => Some(f),
|
||||
_ => None,
|
||||
}
|
||||
named_at(children, last, *last_index, 0)
|
||||
}
|
||||
|
||||
/// Every field with an `id`, with the SOM path the layout gives it: the root subform's
|
||||
|
||||
@@ -8,7 +8,8 @@ pub enum FieldData {
|
||||
Text(String),
|
||||
/// Checked or not.
|
||||
Check(bool),
|
||||
/// The selected button's on value, if any.
|
||||
/// The selected button, if any: written from its on-state name (the data gets its item, see
|
||||
/// `radio_dataset_value`), read as the data's item, which is its export value.
|
||||
Radio(Option<String>),
|
||||
/// Nothing to write (buttons, signatures).
|
||||
None,
|
||||
@@ -33,28 +34,90 @@ fn som_of(doc: &Document, f: &FieldDatum) -> String {
|
||||
doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone())
|
||||
}
|
||||
|
||||
/// Each button of a radio group, in `/Kids` order: its (`/PCOn` item, on-state name, `/Opt`
|
||||
/// entry). A group without `/Kids` is its own one widget.
|
||||
fn radio_widgets(doc: &Document, f: &FieldDatum) -> Vec<(Option<String>, Option<String>, Option<String>)> {
|
||||
let Some(group) = doc.get(f.obj).as_dict().cloned() else { return Vec::new() };
|
||||
let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]);
|
||||
// One export value per widget, as the field's `/Opt` lists them (an entry may be an
|
||||
// `[export display]` pair, as a choice field's are).
|
||||
let exports: Option<Vec<Option<String>>> = group
|
||||
.get(b"Opt")
|
||||
.map(|o| doc.resolve(o))
|
||||
.and_then(|o| o.as_array().cloned())
|
||||
.map(|opt| {
|
||||
opt.iter()
|
||||
.map(|o| match &*doc.resolve(o) {
|
||||
Object::Array(pair) => pair.first().and_then(|e| doc.resolve(e).as_string().map(|s| s.to_text())),
|
||||
other => other.as_string().map(|s| s.to_text()),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.filter(|opt: &Vec<Option<String>>| opt.len() == kids.len());
|
||||
kids.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, kid)| {
|
||||
let widget = doc.resolve(kid).as_dict().cloned()?;
|
||||
let original = text_of(doc, &widget, b"PCOn");
|
||||
let state = widget
|
||||
.get(b"AP")
|
||||
.map(|ap| doc.resolve(ap))
|
||||
.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned()))
|
||||
.and_then(|n| doc.resolve(&n).as_dict().cloned())
|
||||
.and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off"));
|
||||
let export = exports.as_ref().and_then(|e| e.get(i).cloned().flatten());
|
||||
Some((original, state, export))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An on-state name's bytes in the spelling a radio group's AcroForm value has, so the two can
|
||||
/// be compared: UTF-8 names read as themselves, other bytes (Shift-JIS states) and `#` are
|
||||
/// written `#XX`, as in PDF name syntax. This is `pdfcraft_forms::name_text`, which this crate
|
||||
/// can't depend on; the value saving gets is read through it, and the state opening returns is
|
||||
/// written back through its inverse, `pdfcraft_forms::name_bytes`.
|
||||
/// The engine checks that the two spell names alike (`xfa_and_forms_spell_names_alike`).
|
||||
pub fn name_text(bytes: &[u8]) -> String {
|
||||
let escape = |b: u8| format!("#{b:02X}");
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.replace('#', "#23"),
|
||||
Err(_) => bytes.iter().map(|&b| if (0x21..=0x7e).contains(&b) && b != b'#' { char::from(b).to_string() } else { escape(b) }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The datasets text for a radio group. The AcroForm value is an appearance-state name
|
||||
/// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item.
|
||||
fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option<String>) -> String {
|
||||
/// Without it, a group with one `/Opt` entry per widget exports that entry: its state names may
|
||||
/// be positions (`/0`, `/1`; ISO 32000-2 Table 230), and the data holds the item (`1`, `2`).
|
||||
/// `sel` is the selected on-state name; with none selected the text is empty.
|
||||
/// [`radio_state_for_item`] reads it back.
|
||||
pub fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option<String>) -> String {
|
||||
let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else {
|
||||
return String::new();
|
||||
};
|
||||
let Some(group) = doc.get(f.obj).as_dict().cloned() else { return selected.to_string() };
|
||||
let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]);
|
||||
for kid in kids {
|
||||
let Some(widget) = doc.resolve(&kid).as_dict().cloned() else { continue };
|
||||
let original = text_of(doc, &widget, b"PCOn");
|
||||
let state = widget
|
||||
.get(b"AP")
|
||||
.map(|ap| doc.resolve(ap))
|
||||
.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned()))
|
||||
.and_then(|n| doc.resolve(&n).as_dict().cloned())
|
||||
.and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off"));
|
||||
if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) {
|
||||
return original.unwrap_or_else(|| selected.to_string());
|
||||
}
|
||||
let widgets = radio_widgets(doc, f);
|
||||
// `sel` is an on-state name, so every widget's state is compared before any `/PCOn`: with
|
||||
// states `/0` `/1` and items `1` `2`, the state `1` is the second widget, not the first
|
||||
// one's item.
|
||||
let by_state = widgets.iter().find(|(_, state, _)| state.as_deref() == Some(selected));
|
||||
let by_item = || widgets.iter().find(|(original, _, _)| original.as_deref() == Some(selected));
|
||||
match by_state.or_else(by_item) {
|
||||
Some((original, _, export)) => original.clone().or_else(|| export.clone()).unwrap_or_else(|| selected.to_string()),
|
||||
None => selected.to_string(),
|
||||
}
|
||||
selected.to_string()
|
||||
}
|
||||
|
||||
/// The on-state name of the radio button whose item is `item` in the datasets, as
|
||||
/// [`radio_dataset_value`] writes it: the first button whose `/PCOn`, else its `/Opt` entry,
|
||||
/// else its on-state name, equals `item`. So a saved selection reopens on the same button even
|
||||
/// when one button's `/PCOn` equals another's `/Opt` entry. `None` when no button writes `item`
|
||||
/// (data from elsewhere); the caller then reads it as an export value or state name.
|
||||
pub fn radio_state_for_item(doc: &Document, f: &FieldDatum, item: &str) -> Option<String> {
|
||||
radio_widgets(doc, f).into_iter().find_map(|(original, state, export)| {
|
||||
let state = state?;
|
||||
let written = original.or(export).unwrap_or_else(|| state.clone());
|
||||
(written == item).then_some(state)
|
||||
})
|
||||
}
|
||||
|
||||
/// The on and off item values of a check box (`/PCItems`), or the widget's on state.
|
||||
|
||||
@@ -144,6 +144,43 @@ pub fn static_shell(data: &str) -> Vec<u8> {
|
||||
assemble(objs)
|
||||
}
|
||||
|
||||
/// A static XFA form with one exclusion group `form1[0].page1[0].lang[0]` (items `1` English,
|
||||
/// `2` French) bound to the data by name, laid out as an AcroForm radio group whose `/Opt` holds
|
||||
/// the items and whose on states are the buttons' positions `/0` and `/1` (ISO 32000-2 Table 230),
|
||||
/// as public static forms write it. Its datasets hold `data` (the children of `xfa:data`).
|
||||
pub fn static_radio_shell(data: &str) -> Vec<u8> {
|
||||
radio_shell(data, None)
|
||||
}
|
||||
|
||||
/// [`static_radio_shell`] whose buttons also carry `/PCOn`, the item PdfCraft writes to the data
|
||||
/// for the selected button ahead of its `/Opt` entry.
|
||||
pub fn static_radio_shell_with_pcon(data: &str, pcon: [&str; 2]) -> Vec<u8> {
|
||||
radio_shell(data, Some(pcon))
|
||||
}
|
||||
|
||||
fn radio_shell(data: &str, pcon: Option<[&str; 2]>) -> Vec<u8> {
|
||||
let pc = |i: usize| pcon.and_then(|p| p.get(i).map(|v| format!(" /PCOn ({v})"))).unwrap_or_default();
|
||||
let xdp = format!(
|
||||
"<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"form1\"><subform name=\"page1\"><exclGroup name=\"lang\"><field name=\"en\"><ui><checkButton shape=\"round\"/></ui><items><integer>1</integer></items></field><field name=\"fr\"><ui><checkButton shape=\"round\"/></ui><items><integer>2</integer></items></field></exclGroup></subform></subform></template><xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data>{data}</xfa:data></xfa:datasets></xdp:xdp>"
|
||||
);
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /XFA 5 0 R /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Annots [9 0 R 10 0 R] >>".to_vec(),
|
||||
b"<< /Length 0 >>\nstream\n\nendstream".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(),
|
||||
b"<< /T (form1[0]) /Kids [7 0 R] >>".to_vec(),
|
||||
b"<< /T (page1[0]) /Parent 6 0 R /Kids [8 0 R] >>".to_vec(),
|
||||
b"<< /FT /Btn /Ff 49152 /T (lang[0]) /Parent 7 0 R /Opt [(1) (2)] /V /Off /Kids [9 0 R 10 0 R] >>".to_vec(),
|
||||
format!("<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [72 700 84 712] /P 3 0 R /AS /Off /MK << /BC [0 0 0] >>{} /AP << /N << /0 11 0 R /Off 11 0 R >> >> >>", pc(0))
|
||||
.into_bytes(),
|
||||
format!("<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [144 700 156 712] /P 3 0 R /AS /Off /MK << /BC [0 0 0] >>{} /AP << /N << /1 11 0 R /Off 11 0 R >> >> >>", pc(1))
|
||||
.into_bytes(),
|
||||
b"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream".to_vec(),
|
||||
];
|
||||
assemble(objs)
|
||||
}
|
||||
|
||||
/// A static XFA form of `n` text fields `form1[0].page1[0].f<i>[0]` on one page, whose values
|
||||
/// (`v<i>`) are only in its datasets, as when another viewer filled it in. The first field is
|
||||
/// read-only.
|
||||
@@ -201,6 +238,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.
|
||||
@@ -584,6 +645,30 @@ pub fn date_pictures_template() -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Two signature boxes: `sign`, whose preSign script shows a notice, fills `when` and locks it, and
|
||||
/// whose postSign script fills `after`; and `plain`, with no signing scripts.
|
||||
pub fn signing_events_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">
|
||||
<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="page1" layout="tb" w="7.5in">
|
||||
<field name="when" w="2in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
<field name="after" w="2in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
<field name="sign" w="3in" h="1in"><ui><signature/></ui>
|
||||
<event activity="preSign"><script contentType="application/x-javascript">xfa.host.messageBox("Signing notice"); when.rawValue = "signed"; when.access = "readOnly";</script></event>
|
||||
<event activity="postSign"><script contentType="application/x-javascript">after.rawValue = "done";</script></event>
|
||||
</field>
|
||||
<field name="plain" w="3in" h="1in"><ui><signature/></ui></field>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A form of `sections` sections of `rows` rows, each row with a calculated total (half in
|
||||
/// JavaScript, half in FormCalc) and a section sum over its rows: 2 × sections × rows + sections
|
||||
/// calculate scripts, every one reading values the earlier ones settled.
|
||||
@@ -758,3 +843,87 @@ if (this.rawValue == "1") {
|
||||
"##
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A form whose text field `probe` counts its enters in `enters` and its exits in `exits`, whose
|
||||
/// text field `quiet` only shows a message when it is left, and whose radio button `size.large`
|
||||
/// counts its enters in `picks` and its exits in `drops` (each button of a radio group is a field
|
||||
/// with scripts of its own), whose text field `hint` clears its "Type here" when it gains focus, and
|
||||
/// whose text field `relay` puts that "Type here" back in `hint` when it loses focus.
|
||||
pub fn focus_template() -> String {
|
||||
let js = "application/x-javascript";
|
||||
let count = |name: &str| format!(r#"<script contentType="{js}">{name}.rawValue = Number({name}.rawValue) + 1;</script>"#);
|
||||
let counter = |name: &str| {
|
||||
format!(r#"<field name="{name}" w="2in" h="0.3in" access="protected"><ui><textEdit/></ui><value><integer>0</integer></value></field>"#)
|
||||
};
|
||||
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">
|
||||
<field name="probe" w="2in" h="0.3in"><ui><textEdit/></ui><event activity="enter">{}</event><event activity="exit">{}</event></field>
|
||||
<field name="quiet" w="2in" h="0.3in"><ui><textEdit/></ui><event activity="exit"><script contentType="{js}">xfa.host.messageBox("left quiet");</script></event></field>
|
||||
<exclGroup name="size" layout="lr-tb">
|
||||
<field name="small" w="0.5in" h="0.25in"><ui><checkButton/></ui><items><text>S</text></items></field>
|
||||
<field name="large" w="0.5in" h="0.25in"><ui><checkButton/></ui><items><text>L</text></items><event activity="enter">{}</event><event activity="exit">{}</event></field>
|
||||
</exclGroup>
|
||||
{}{}{}{}
|
||||
<field name="hint" w="2in" h="0.3in"><ui><textEdit/></ui><value><text>Type here</text></value><event activity="enter"><script contentType="{js}">if (this.rawValue == "Type here") this.rawValue = "";</script></event></field>
|
||||
<field name="relay" w="2in" h="0.3in"><ui><textEdit/></ui><event activity="exit"><script contentType="{js}">hint.rawValue = "Type here";</script></event></field>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##,
|
||||
count("enters"),
|
||||
count("exits"),
|
||||
count("picks"),
|
||||
count("drops"),
|
||||
counter("exits"),
|
||||
counter("enters"),
|
||||
counter("picks"),
|
||||
counter("drops")
|
||||
)
|
||||
}
|
||||
|
||||
/// A form whose two draws link their pictures instead of embedding them: `.\logo.png`, which the
|
||||
/// PDF keeps in its `XFAImages` name tree (a [`tiny_png`], under a `Kids` node, as producers
|
||||
/// split the tree), and `.\missing.png`, which it doesn't (XFA 3.3 pp. 257–258).
|
||||
pub fn linked_image_shell() -> Vec<u8> {
|
||||
linked_image_shell_with(b"<< /Kids [7 0 R] >>", br"<< /Limits [(.\\logo.png) (.\\logo.png)] /Names [(.\\logo.png) 8 0 R] >>")
|
||||
}
|
||||
|
||||
/// [`linked_image_shell`] with its own name tree: `root` is object 6 (the tree the catalog names),
|
||||
/// `kid` object 7, and object 8 the PNG.
|
||||
pub fn linked_image_shell_with(root: &[u8], kid: &[u8]) -> Vec<u8> {
|
||||
linked_image_shell_holding(root, kid, TINY_PNG)
|
||||
}
|
||||
|
||||
/// [`linked_image_shell_with`], object 8 a stream holding `picture` (a [`tiny_png`] there).
|
||||
pub fn linked_image_shell_holding(root: &[u8], kid: &[u8], picture: &[u8]) -> Vec<u8> {
|
||||
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>
|
||||
<subform name="page1" layout="tb">
|
||||
<draw name="logo" w="1in" h="1in"><value><image contentType="image/png" href=".\logo.png"/></value></draw>
|
||||
<draw name="gone" w="1in" h="1in"><value><image contentType="image/png" href=".\missing.png"/></value></draw>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##;
|
||||
let mut png = format!("<< /Length {} >>\nstream\n", picture.len()).into_bytes();
|
||||
png.extend_from_slice(picture);
|
||||
png.extend_from_slice(b"\nendstream");
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> /Names << /XFAImages 6 0 R >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_vec(),
|
||||
b"<< /Length 44 >>\nstream\nBT /F1 12 Tf 72 700 Td (Please wait...) Tj ET\nendstream".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(),
|
||||
root.to_vec(),
|
||||
kid.to_vec(),
|
||||
png,
|
||||
];
|
||||
assemble(objs)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::XfaError;
|
||||
use crate::data::{DataNode, DataPath, data_date_letters, data_path, data_to_iso, date_letters, is_on, iso_to_pattern};
|
||||
use crate::model::{
|
||||
Access, Area, Border, Common, Draw, ExclGroup, Field, Font, HAlign, Layout, Margin, Node, Occur, PageArea, PageRole, Para, Placement, Presence,
|
||||
Rect, Script, SomIndices, Stroke, Subform, Template, Ui, VAlign, Value,
|
||||
Rect, Script, SomIndices, Stroke, Subform, Template, Ui, VAlign, Value, Views,
|
||||
};
|
||||
use crate::text::{self, Block, Face, Span};
|
||||
|
||||
@@ -28,9 +28,9 @@ mod items;
|
||||
mod measure;
|
||||
mod pages;
|
||||
|
||||
use common::collect_values;
|
||||
pub(crate) use common::date_pattern;
|
||||
pub use common::formatted_value;
|
||||
use common::{collect_names, collect_values};
|
||||
pub(crate) use items::UNDERLINE_DROP;
|
||||
pub use items::{Action, BorderShape, Form, Item, Page, Rounded, Turned, Widget, WidgetBorder, WidgetKind};
|
||||
|
||||
@@ -52,8 +52,17 @@ const MAX_MEASURES: usize = 2_000_000;
|
||||
const MAX_DRAW_CHARS: usize = 256 << 20;
|
||||
#[cfg(test)]
|
||||
const MAX_DRAW_CHARS: usize = 1 << 20;
|
||||
/// Most initial instances of one repeating subform.
|
||||
const MAX_INSTANCES: usize = 50;
|
||||
/// Most instances of one repeating subform laid out (a row the data adds, or the template's
|
||||
/// initial count). `occur max="-1"` sets no upper boundary (XFA 3.3, "The occur element",
|
||||
/// p. 786), so this only bounds the work of a hostile or runaway form: a long list or table
|
||||
/// is laid out whole up to here, and the rows past it are reported ([`Layouter::warn_truncated`]).
|
||||
/// It matches the largest `occur` count the parser reads and the most rows one script run may
|
||||
/// add (`MAX_ADDED` in pdfcraft-js). Laying out and listing the fields grows faster than the
|
||||
/// row count (one text field a row: 1,000 rows open in about 3 s in a debug build, 2,000 in
|
||||
/// about 13 s), so a much larger bound would let one list stall the app. The layout budgets
|
||||
/// ([`MAX_MEASURES`], [`MAX_ITEMS`], [`MAX_PAGES`]) still bound the form as a whole, however
|
||||
/// its repeats nest.
|
||||
pub(crate) const MAX_INSTANCES: usize = 1_000;
|
||||
/// Largest picture taken from the data (decoded bytes), as for template pictures.
|
||||
const MAX_DATA_IMAGE: usize = 32 << 20;
|
||||
/// Most picture bytes one layout keeps, all copies together. Each laid-out picture holds its
|
||||
@@ -90,8 +99,9 @@ impl Ctx {
|
||||
}
|
||||
}
|
||||
|
||||
/// (address of a subform or area in the template, available width bits, depth).
|
||||
type MeasureKey = (usize, u64, usize);
|
||||
/// (address of a subform or area in the template, available width bits, depth, SOM path of the
|
||||
/// instance measured when data drives the layout).
|
||||
type MeasureKey = (usize, u64, usize, String);
|
||||
|
||||
/// A cached size, with the inherited text defaults it was measured with.
|
||||
struct Measured {
|
||||
@@ -158,6 +168,11 @@ struct Layouter<'t> {
|
||||
pages: Vec<Page>,
|
||||
cur: Option<Cursor>,
|
||||
names: HashMap<String, usize>,
|
||||
/// Every PDF field name handed out so far.
|
||||
taken: HashSet<String>,
|
||||
/// The names the template's fields, groups and groups' members take as written
|
||||
/// ([`common::collect_names`]), which a made-up `_2`, `_3` name must not take.
|
||||
template_names: HashSet<String>,
|
||||
/// Field values by `id`, for embedded fields in rich text.
|
||||
values: HashMap<String, common::EmbedSource>,
|
||||
warnings: Vec<String>,
|
||||
@@ -180,9 +195,16 @@ struct Layouter<'t> {
|
||||
splitting: Option<Splitting>,
|
||||
/// The items `draw_field` drew for the border of the field drawn last: (first, one past the last).
|
||||
field_border: Option<(usize, usize)>,
|
||||
/// Where what is being drawn shows, as the objects it is inside say ([`Common::views`]).
|
||||
views: Views,
|
||||
/// The flowed subforms being laid out, outermost first: a break between their children
|
||||
/// closes their parts on one content area and opens new ones on the next.
|
||||
frames: Vec<Frame>,
|
||||
/// What scripts set on page-master content, applied to each page's copy by its own SOM path.
|
||||
overrides: Option<&'t crate::script::Overrides>,
|
||||
/// The pages' copies of master content that overrides changed, kept for the whole layout so
|
||||
/// their addresses stay unique (the measurement cache keys on them).
|
||||
master_copies: Vec<Rc<Vec<Node>>>,
|
||||
}
|
||||
|
||||
/// How many instances of a repeating subform to lay out: what the data holds once it holds
|
||||
@@ -195,6 +217,17 @@ pub fn instance_count(occur: &Occur, from_data: usize) -> usize {
|
||||
|
||||
/// Lay a template out, with the values and repeat counts of `data` (the `xfa:data` element).
|
||||
pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result<Form, XfaError> {
|
||||
layout_inner(tpl, data, None)
|
||||
}
|
||||
|
||||
/// [`layout`], with what scripts set (`ov`) applied to page-master content page by page: a
|
||||
/// field hidden on the second page made from a page area is hidden there only. The rest of the
|
||||
/// template takes `ov` before this ([`crate::script::apply_overrides`]).
|
||||
pub fn layout_with_overrides(tpl: &Template, data: Option<&DataNode>, ov: &crate::script::Overrides) -> Result<Form, XfaError> {
|
||||
layout_inner(tpl, data, Some(ov))
|
||||
}
|
||||
|
||||
fn layout_inner(tpl: &Template, data: Option<&DataNode>, overrides: Option<&crate::script::Overrides>) -> Result<Form, XfaError> {
|
||||
let areas = match &tpl.root.page_set {
|
||||
Some(ps) if !ps.areas.is_empty() => ps.areas.clone(),
|
||||
_ => vec![PageArea {
|
||||
@@ -210,6 +243,8 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result<Form, XfaError>
|
||||
let values = collect_values(tpl, data);
|
||||
// One copy of the root for the whole layout: measurements are cached by node address.
|
||||
let root_node = Node::Subform(Box::new(tpl.root.clone()));
|
||||
let mut template_names = HashSet::new();
|
||||
collect_names(std::slice::from_ref(&root_node), &mut template_names, 0);
|
||||
let mut l = Layouter {
|
||||
tpl,
|
||||
data,
|
||||
@@ -218,6 +253,8 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result<Form, XfaError>
|
||||
pages: Vec::new(),
|
||||
cur: None,
|
||||
names: HashMap::new(),
|
||||
taken: HashSet::new(),
|
||||
template_names,
|
||||
values,
|
||||
warnings: Vec::new(),
|
||||
warned: HashSet::new(),
|
||||
@@ -230,7 +267,10 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result<Form, XfaError>
|
||||
leaves: Vec::new(),
|
||||
splitting: None,
|
||||
field_border: None,
|
||||
views: Views::ALL,
|
||||
frames: Vec::new(),
|
||||
overrides,
|
||||
master_copies: Vec::new(),
|
||||
};
|
||||
let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() };
|
||||
l.new_page()?;
|
||||
@@ -255,6 +295,7 @@ fn fill_page_count(items: &mut [Item], count: &str) {
|
||||
match it {
|
||||
Item::Text(s) if s.text.contains(PAGE_COUNT_MARK) => s.text = s.text.replace(PAGE_COUNT_MARK, count),
|
||||
Item::Turned(t) => fill_page_count(&mut t.items, count),
|
||||
Item::Only { item, .. } => fill_page_count(std::slice::from_mut(item.as_mut()), count),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::items::{Action, BorderShape, Item, Rounded, Widget, WidgetBorder};
|
||||
use super::{DataNode, DataPath, HashMap, Layout, Node, Rect, Script, Template, Ui, data_path, date_letters, iso_to_pattern};
|
||||
use super::{DataNode, DataPath, HashMap, HashSet, Layout, MAX_DEPTH, Node, Rect, Script, Template, Ui, data_path, date_letters, iso_to_pattern};
|
||||
use crate::data::{data_date_letters, data_to_iso, fields_with_ids};
|
||||
|
||||
/// The value of each field with an `id`, for the rich text that embeds it (floating fields,
|
||||
@@ -43,6 +43,47 @@ impl EmbedSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// The names the template's fields, exclusion groups and the groups' member fields take as
|
||||
/// written ([`clean_name`]): a name the layout makes up for a repeated field (`Cell4` →
|
||||
/// `Cell4_2`) must not be one of these, or two fields would share it. (A group's radio buttons
|
||||
/// are the group's PDF field; a member that isn't one is a PDF field with its own name.)
|
||||
pub(super) fn collect_names(nodes: &[Node], out: &mut HashSet<String>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in nodes {
|
||||
match n {
|
||||
Node::Field(f) => {
|
||||
if let Some(name) = &f.common.name {
|
||||
out.insert(clean_name(name));
|
||||
}
|
||||
}
|
||||
Node::ExclGroup(g) => {
|
||||
if let Some(name) = &g.common.name {
|
||||
out.insert(clean_name(name));
|
||||
}
|
||||
// Its members are drawn as fields with their own names (`draw_field`), so a
|
||||
// repeat drawn before the group must not take one.
|
||||
for f in &g.fields {
|
||||
if let Some(name) = &f.common.name {
|
||||
out.insert(clean_name(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Subform(s) => {
|
||||
collect_names(&s.children, out, depth + 1);
|
||||
if let Some(ps) = &s.page_set {
|
||||
for a in &ps.areas {
|
||||
collect_names(&a.items, out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Area(a) => collect_names(&a.children, out, depth + 1),
|
||||
Node::Draw(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` with the instance numbers `scope` gives the same names, segment by segment from the
|
||||
/// root, up to where the two part: the instance of a repeating subform the text is laid out in.
|
||||
pub(super) fn instance_path(path: &DataPath, scope: &DataPath) -> DataPath {
|
||||
@@ -88,12 +129,19 @@ pub(super) fn solid_extent(item: &Item) -> Option<(f64, f64)> {
|
||||
Item::Widget(w) => Some((w.rect.y, w.rect.bottom())),
|
||||
Item::Turned(t) => Some(t.extent),
|
||||
Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None,
|
||||
Item::Only { item, .. } => solid_extent(item),
|
||||
}
|
||||
}
|
||||
|
||||
/// `item` as the part above `cut` and the part below: solid items go whole to the side their
|
||||
/// top is on, fills and lines crossing the cut are cut in two.
|
||||
pub(super) fn divide(item: Item, cut: f64) -> (Option<Item>, Option<Item>) {
|
||||
if let Item::Only { views, item } = item {
|
||||
// Each part shows where the whole did.
|
||||
let wrap = |i: Item| Item::Only { views, item: Box::new(i) };
|
||||
let (a, b) = divide(*item, cut);
|
||||
return (a.map(wrap), b.map(wrap));
|
||||
}
|
||||
if let Some((top, _)) = solid_extent(&item) {
|
||||
return if top < cut - 0.01 { (Some(item), None) } else { (None, Some(item)) };
|
||||
}
|
||||
@@ -198,6 +246,9 @@ pub(super) fn widget_box(rect: Rect, border: &WidgetBorder) -> Vec<Item> {
|
||||
|
||||
/// `item` moved by (`dx`, `dy`).
|
||||
pub(super) fn moved(mut item: Item, dx: f64, dy: f64) -> Item {
|
||||
if let Item::Only { views, item: inner } = item {
|
||||
return Item::Only { views, item: Box::new(moved(*inner, dx, dy)) };
|
||||
}
|
||||
match &mut item {
|
||||
Item::Fill { rect, .. } | Item::Image { rect, .. } => {
|
||||
rect.x += dx;
|
||||
@@ -224,6 +275,8 @@ pub(super) fn moved(mut item: Item, dx: f64, dy: f64) -> Item {
|
||||
t.extent = (t.extent.0 + dy, t.extent.1 + dy);
|
||||
t.items = std::mem::take(&mut t.items).into_iter().map(|i| moved(i, dx, dy)).collect();
|
||||
}
|
||||
// Taken apart above.
|
||||
Item::Only { .. } => {}
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
+130
-27
@@ -1,17 +1,32 @@
|
||||
use super::common::clean_name;
|
||||
use super::items::{Item, Rounded, Turned};
|
||||
use super::{
|
||||
Arc, Border, Ctx, Draw, ExclGroup, Face, Font, Layout, Layouter, MAX_DEPTH, MAX_IMAGE_BYTES, Node, Para, Presence, Rect, SomIndices, Stroke,
|
||||
Arc, Border, Ctx, Draw, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_IMAGE_BYTES, Node, Para, Rect, SomIndices, Stroke,
|
||||
Subform, Value, XfaError, data_path, text,
|
||||
};
|
||||
|
||||
impl Layouter<'_> {
|
||||
// ───────────────────────────────────────────────────────────────────────── drawing
|
||||
|
||||
/// Draw table row `row` at `rect` in columns `cols`, shown where it and what it is inside say
|
||||
/// ([`Common::views`]).
|
||||
pub(super) fn draw_row(&mut self, row: &Subform, cols: &[f64], rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Ok(());
|
||||
}
|
||||
self.warn_other_views(&row.common);
|
||||
let views = self.views.and(row.common.views());
|
||||
if !views.any() {
|
||||
return Ok(());
|
||||
}
|
||||
let outer = std::mem::replace(&mut self.views, views);
|
||||
let drawn = self.draw_row_cells(row, cols, rect, ctx, depth, sib);
|
||||
self.views = outer;
|
||||
drawn
|
||||
}
|
||||
|
||||
/// [`Self::draw_row`]'s border and cells, once where the row shows is set.
|
||||
pub(super) fn draw_row_cells(&mut self, row: &Subform, cols: &[f64], rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> {
|
||||
if let Some(b) = &row.common.border {
|
||||
self.draw_border(b, rect)?;
|
||||
}
|
||||
@@ -33,7 +48,7 @@ impl Layouter<'_> {
|
||||
if !c.presence.occupies() {
|
||||
return Ok(());
|
||||
}
|
||||
let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1);
|
||||
let (w, h) = self.size_at(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1, sib);
|
||||
let (x, y) = c.top_left(w, h);
|
||||
let rect = Rect::new(inner.x + x, inner.y + y, w, h);
|
||||
let start = self.item_count();
|
||||
@@ -58,14 +73,27 @@ impl Layouter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw `node` at `rect`, shown where it and what it is inside say ([`Common::views`]): an
|
||||
/// invisible object that `relevant` prints is drawn for print only, and one shown nowhere
|
||||
/// draws nothing.
|
||||
pub(super) fn draw_node(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Ok(());
|
||||
}
|
||||
let c = node.common();
|
||||
if c.presence == Presence::Invisible {
|
||||
self.warn_other_views(c);
|
||||
let views = self.views.and(c.views());
|
||||
if !views.any() {
|
||||
return Ok(());
|
||||
}
|
||||
let outer = std::mem::replace(&mut self.views, views);
|
||||
let drawn = self.draw_node_in(node, rect, ctx, depth, sib);
|
||||
self.views = outer;
|
||||
drawn
|
||||
}
|
||||
|
||||
/// [`Self::draw_node`] once where the node shows is set.
|
||||
pub(super) fn draw_node_in(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> {
|
||||
match node {
|
||||
Node::Draw(d) => {
|
||||
let start = self.item_count();
|
||||
@@ -115,23 +143,53 @@ impl Layouter<'_> {
|
||||
if !k.common().presence.occupies() {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1);
|
||||
// A repeating subform here (a table in an area of a positioned page,
|
||||
// say) gets its instances, as in a flowed one, one under another.
|
||||
for instance in 0..self.instances(k, &ctx.som) {
|
||||
self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sibs.at(i) + instance)?;
|
||||
// say) gets its instances, as in a flowed one, one under another,
|
||||
// each as tall as its own content, and the rows past them are
|
||||
// reported as there.
|
||||
let instances = self.laid_out_instances(k, &ctx.som);
|
||||
for instance in 0..instances {
|
||||
let sib = sibs.at(i).saturating_add(instance);
|
||||
let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, sib);
|
||||
self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sib)?;
|
||||
y += h;
|
||||
}
|
||||
}
|
||||
}
|
||||
Layout::LrTb | Layout::RlTb => {
|
||||
// A repeating subform here (rows of fields under a heading) gets its
|
||||
// instances, as in the other layouts.
|
||||
// instances, as in the other layouts, and the rows past them are reported.
|
||||
for k in s.children.iter().filter(|k| k.common().presence.occupies()) {
|
||||
self.laid_out_instances(k, &ctx.som);
|
||||
}
|
||||
let (placed, _) = self.pack_instances(&s.children, inner.w, &ctx, depth, &ctx.som);
|
||||
for (i, instance, r) in placed {
|
||||
if let Some(k) = s.children.get(i) {
|
||||
// The sibling indices of each `subformSet` the steps go into, found once per
|
||||
// set (known by where its children are); the subform's own are `sibs`.
|
||||
let mut set_sibs: std::collections::HashMap<usize, SomIndices> = std::collections::HashMap::new();
|
||||
for (at, r) in placed {
|
||||
// Down the steps: a member of a `subformSet` takes the set's SOM path.
|
||||
let (mut nodes, mut node_ctx, mut node_depth) = (s.children.as_slice(), ctx.clone(), depth + 1);
|
||||
let mut target = None;
|
||||
for (step, &(i, instance)) in at.iter().enumerate() {
|
||||
let Some(k) = nodes.get(i) else { break };
|
||||
let index = if step == 0 {
|
||||
sibs.at(i)
|
||||
} else {
|
||||
set_sibs.entry(nodes.as_ptr() as usize).or_insert_with(|| SomIndices::of(nodes)).at(i)
|
||||
};
|
||||
let sib = index + instance;
|
||||
match k {
|
||||
Node::Subform(set) if set.set && step + 1 < at.len() => {
|
||||
node_ctx = node_ctx.with_som(&set.common, sib);
|
||||
nodes = &set.children;
|
||||
node_depth += 1;
|
||||
}
|
||||
_ => target = Some((k, sib)),
|
||||
}
|
||||
}
|
||||
if let Some((k, sib)) = target {
|
||||
let x = if s.layout == Layout::RlTb { inner.right() - r.x - r.w } else { inner.x + r.x };
|
||||
self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &ctx, depth + 1, sibs.at(i) + instance)?;
|
||||
self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &node_ctx, node_depth, sib)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,11 +204,16 @@ impl Layouter<'_> {
|
||||
if let Node::Subform(r) = k
|
||||
&& r.layout == Layout::Row
|
||||
{
|
||||
let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1);
|
||||
self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib)?;
|
||||
y += h;
|
||||
// A repeating row (a table in a positioned subform, say) gets its
|
||||
// instances one under another, as in a flowed table (XFA 3.3,
|
||||
// "Tables", p. 327), and the rows past them are reported.
|
||||
for instance in 0..self.laid_out_instances(k, &ctx.som) {
|
||||
let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1, csib.saturating_add(instance));
|
||||
self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib + instance)?;
|
||||
y += h;
|
||||
}
|
||||
} else {
|
||||
let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1);
|
||||
let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, csib);
|
||||
self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, csib)?;
|
||||
y += h;
|
||||
}
|
||||
@@ -276,6 +339,8 @@ impl Layouter<'_> {
|
||||
self.push_block(block)
|
||||
}
|
||||
|
||||
/// Draw a `draw` element (static text, an image, a line, a rectangle or an arc) in `rect`,
|
||||
/// inside its border and margin.
|
||||
fn draw_draw(&mut self, d: &Draw, rect: Rect, ctx: &Ctx) -> Result<(), XfaError> {
|
||||
if let Some(b) = &d.common.border {
|
||||
self.draw_border(b, rect)?;
|
||||
@@ -284,7 +349,7 @@ impl Layouter<'_> {
|
||||
let inner = rect.inset(&d.common.margin);
|
||||
match &d.value {
|
||||
Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para, &ctx.som),
|
||||
Value::Image { content_type, data } => self.draw_image(data, content_type, inner),
|
||||
Value::Image { content_type, data, .. } => self.draw_image(data, content_type, inner),
|
||||
Value::Line { slope_up, edge } => {
|
||||
if !edge.visible() {
|
||||
return Ok(());
|
||||
@@ -301,6 +366,10 @@ impl Layouter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw exclusion group `g` at `rect`: its border, then each of its buttons where its layout
|
||||
/// puts it, as one radio group, the one its data selects turned on. A group that must be
|
||||
/// answered makes each button required. `sib` is the group's index among same-named
|
||||
/// siblings, for its SOM path.
|
||||
fn draw_group(&mut self, g: &ExclGroup, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> {
|
||||
if let Some(b) = &g.common.border {
|
||||
self.draw_border(b, rect)?;
|
||||
@@ -310,8 +379,10 @@ impl Layouter<'_> {
|
||||
let selected: Option<String> =
|
||||
self.data.and_then(|d| d.text_at(&data_path(self.tpl, &ctx.som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
let inner = rect.inset(&g.common.margin);
|
||||
let group = self.unique_name(g.common.name.as_deref().unwrap_or("group"));
|
||||
let group = self.unique_name(g.common.name.as_deref().unwrap_or("group"), g.common.name.is_none());
|
||||
let nodes: Vec<Node> = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
|
||||
// Each field is measured under the SOM index it is drawn at.
|
||||
let sibs = SomIndices::of(&nodes);
|
||||
let placed: Vec<(usize, Rect)> = match g.layout {
|
||||
Layout::Tb => {
|
||||
let mut y = 0.0;
|
||||
@@ -320,7 +391,7 @@ impl Layouter<'_> {
|
||||
if !k.common().presence.occupies() {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1);
|
||||
let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, sibs.at(i));
|
||||
out.push((i, Rect::new(0.0, y, w, h)));
|
||||
y += h;
|
||||
}
|
||||
@@ -332,20 +403,38 @@ impl Layouter<'_> {
|
||||
.filter(|(_, k)| k.common().presence.occupies())
|
||||
.map(|(i, k)| {
|
||||
let c = k.common();
|
||||
let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1);
|
||||
let (w, h) = self.size_at(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1, sibs.at(i));
|
||||
let (x, y) = c.top_left(w, h);
|
||||
(i, Rect::new(x, y, w, h))
|
||||
})
|
||||
.collect(),
|
||||
_ => self.pack(&nodes, inner.w, &ctx, depth).0,
|
||||
};
|
||||
let sibs = SomIndices::of(&nodes);
|
||||
for (i, r) in placed {
|
||||
if let Some(f) = g.fields.get(i) {
|
||||
// Each button shows where it and the group say ([`Common::views`]), as any field
|
||||
// drawn through [`Self::draw_node`] does.
|
||||
self.warn_other_views(&f.common);
|
||||
let views = self.views.and(f.common.views());
|
||||
if !views.any() {
|
||||
continue;
|
||||
}
|
||||
let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| (i + 1).to_string());
|
||||
let tip = f.tooltip.clone().or_else(|| g.tooltip.clone());
|
||||
let chosen = selected.as_deref() == Some(on.as_str());
|
||||
self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i))?;
|
||||
// A group that must be answered makes each of its buttons required, as the group
|
||||
// field they make is.
|
||||
let required;
|
||||
let f = if g.required && !f.required {
|
||||
required = Field { required: true, ..f.clone() };
|
||||
&required
|
||||
} else {
|
||||
f
|
||||
};
|
||||
let outer = std::mem::replace(&mut self.views, views);
|
||||
let drawn = self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i));
|
||||
self.views = outer;
|
||||
drawn?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -367,12 +456,26 @@ impl Layouter<'_> {
|
||||
self.push(Item::Image { rect: fitted, data: Arc::new(data.to_vec()), content_type: content_type.to_string() })
|
||||
}
|
||||
|
||||
/// A field name made from `base` that no earlier field of the form has: the first keeps it,
|
||||
/// later ones get `_2`, `_3`, ….
|
||||
pub(super) fn unique_name(&mut self, base: &str) -> String {
|
||||
/// A field name made from `base` that no other field of the form has: the first keeps it,
|
||||
/// later ones get `_2`, `_3`, …, skipping a name another field already has or that the
|
||||
/// template gives another field (a template field named `Cell4_2` keeps it, and the second
|
||||
/// `Cell4` becomes `Cell4_3`). A `fallback` base, the name an unnamed field or group gets
|
||||
/// (`field`, `group`), is made up too: it doesn't take a name the template gives another
|
||||
/// field either, and a template field named `field` still gets it.
|
||||
pub(super) fn unique_name(&mut self, base: &str, fallback: bool) -> String {
|
||||
let base = clean_name(base);
|
||||
let n = self.names.entry(base.clone()).or_insert(0);
|
||||
*n += 1;
|
||||
if *n == 1 { base } else { format!("{base}_{n}") }
|
||||
// The template's own name, while no other field has it.
|
||||
if !fallback && self.taken.insert(base.clone()) {
|
||||
return base;
|
||||
}
|
||||
loop {
|
||||
let n = self.names.entry(base.clone()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
let candidate = if *n == 1 { base.clone() } else { format!("{base}_{n}") };
|
||||
let reserved = (fallback || *n > 1) && self.template_names.contains(&candidate);
|
||||
if !reserved && self.taken.insert(candidate.clone()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::common::{button_action, date_pattern};
|
||||
use super::items::{BorderShape, Item, Widget, WidgetBorder, WidgetKind};
|
||||
use super::{
|
||||
Access, Border, Ctx, Engine, Face, Field, HAlign, Layouter, MAX_DATA_IMAGE, Margin, Para, Placement, Presence, Rect, Ui, VAlign, Value, XfaError,
|
||||
data_date_letters, data_path, data_to_iso, is_on, iso_to_pattern, text,
|
||||
Access, Border, Ctx, Engine, Face, Field, HAlign, Layouter, MAX_DATA_IMAGE, Margin, Para, Placement, Presence, Rect, Ui, VAlign, Value, Views,
|
||||
XfaError, data_date_letters, data_path, data_to_iso, is_on, iso_to_pattern, text,
|
||||
};
|
||||
|
||||
impl Layouter<'_> {
|
||||
@@ -27,7 +27,10 @@ impl Layouter<'_> {
|
||||
let fctx = ctx.child(&f.common);
|
||||
let inner = rect.inset(&f.common.margin);
|
||||
let (mut ui, mut cap_rect) = (inner, None);
|
||||
if let Some((placement, reserve)) = self.caption_reserve(f, inner.w, ctx) {
|
||||
// A caption without a font of its own is drawn in the field's (XFA 3.3, "Formatting Text
|
||||
// That Appears as Fixed or Variable Content", p. 43), in the paragraph around the field,
|
||||
// as it is measured.
|
||||
if let Some((placement, reserve)) = self.caption_reserve(f, inner.w, &fctx, &ctx.para) {
|
||||
let reserve = auto_check_reserve(f, placement, inner.w, Some(inner.h)).unwrap_or(reserve);
|
||||
let r = reserve.max(0.0);
|
||||
match placement {
|
||||
@@ -52,7 +55,7 @@ impl Layouter<'_> {
|
||||
if let (Some(cr), Some(cap)) = (cap_rect, &f.caption)
|
||||
&& cap.presence == Presence::Visible
|
||||
{
|
||||
let font = ctx.font.apply(&cap.font);
|
||||
let font = fctx.font.apply(&cap.font);
|
||||
let para = ctx.para.apply(&cap.para);
|
||||
self.draw_value_text(&cap.value, cr, &font, ¶, &ctx.som)?;
|
||||
}
|
||||
@@ -97,7 +100,7 @@ impl Layouter<'_> {
|
||||
let box_ = ui.inset(&f.ui_margin);
|
||||
match (&from_data, &f.value) {
|
||||
(Some(d), _) => self.draw_image(d, "", box_)?,
|
||||
(None, Value::Image { content_type, data }) => self.draw_image(data, content_type, box_)?,
|
||||
(None, Value::Image { content_type, data, .. }) => self.draw_image(data, content_type, box_)?,
|
||||
_ => {}
|
||||
}
|
||||
// Then the field itself, which clicking gives a new picture.
|
||||
@@ -147,14 +150,14 @@ impl Layouter<'_> {
|
||||
}
|
||||
let face = match (&f.ui, &f.caption) {
|
||||
// Buttons show their caption in the caption's font.
|
||||
(Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)).unscaled(),
|
||||
(Ui::Button, Some(cap)) => Face::of(&fctx.font.apply(&cap.font)).unscaled(),
|
||||
_ => Face::of(&fctx.font).unscaled(),
|
||||
};
|
||||
let h_align = match (&f.ui, &f.caption) {
|
||||
(Ui::Button, Some(cap)) => ctx.para.apply(&cap.para).h_align,
|
||||
_ => fctx.para.h_align,
|
||||
};
|
||||
let name = self.unique_name(f.common.name.as_deref().unwrap_or("field"));
|
||||
let name = self.unique_name(f.common.name.as_deref().unwrap_or("field"), f.common.name.is_none());
|
||||
let som = som_path();
|
||||
let read_only = matches!(f.access, Access::ReadOnly | Access::Protected);
|
||||
// The data's value wins over the template's default.
|
||||
@@ -265,6 +268,9 @@ impl Layouter<'_> {
|
||||
action,
|
||||
items,
|
||||
no_scroll: f.no_scroll,
|
||||
// `push` sets where it shows.
|
||||
views: Views::ALL,
|
||||
required: f.required,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
+119
-57
@@ -1,6 +1,9 @@
|
||||
use super::common::{WidgetRest, clamp_opt, divide, is_row, moved, solid_extent, split_line, split_widget, splittable, widget_box};
|
||||
use super::items::Item;
|
||||
use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Node, Rect, SomIndices, Splitting, Subform, XfaError, data_path, instance_count};
|
||||
use super::{
|
||||
Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_INSTANCES, MAX_MEASURES, Node, Rect, SomIndices, Splitting, Subform, XfaError, data_path,
|
||||
instance_count,
|
||||
};
|
||||
|
||||
impl Layouter<'_> {
|
||||
/// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits,
|
||||
@@ -18,7 +21,12 @@ impl Layouter<'_> {
|
||||
// A field a page break may split starts here, and what doesn't fit goes on in the next
|
||||
// content area (XFA 3.3 pp. 291–292), rather than the whole field moving there.
|
||||
let splits = may_split && matches!(node, Node::Field(f) if f.splits_at_page_break());
|
||||
if !splits && y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 {
|
||||
// A growable flowing subform (`lr-tb`/`rl-tb`, no `h`) is split where its objects allow
|
||||
// ("splittable: in margins and where consensus exists among contained objects", XFA 3.3
|
||||
// p. 1520), so it starts here and the rest goes on in the next content area, as one
|
||||
// taller than a content area does. Anything else that fits one moves there whole.
|
||||
let flows_on = matches!(node, Node::Subform(s) if matches!(s.layout, Layout::LrTb | Layout::RlTb) && s.common.h.is_none());
|
||||
if !splits && !flows_on && y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 {
|
||||
self.make_room(h, 0.0)?;
|
||||
}
|
||||
let (rect, y, _) = self.cursor();
|
||||
@@ -27,7 +35,7 @@ impl Layouter<'_> {
|
||||
// doesn't fit here can turn out so. The item count goes back with the items taken off the
|
||||
// page; the measurement budget doesn't, as that work was done. (A text field draws no
|
||||
// picture, so the picture budget is untouched.)
|
||||
let undo = (splits && y + h > rect.bottom() + 0.01).then(|| (self.fields, self.names.clone(), self.items));
|
||||
let undo = (splits && y + h > rect.bottom() + 0.01).then(|| (self.fields, self.names.clone(), self.taken.clone(), self.items));
|
||||
self.leaves.clear();
|
||||
self.field_border = None;
|
||||
self.draw_node(node, Rect::new(x, y, w, h), ctx, depth, sib)?;
|
||||
@@ -50,7 +58,7 @@ impl Layouter<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(som) = (if splits { self.splittable_widget_since(first, rect.bottom() - self.frames_bottom()) } else { None }) else {
|
||||
if let Some((fields, names, items)) = undo {
|
||||
if let Some((fields, names, taken, items)) = undo {
|
||||
// Not split after all (a value, or no room for a line of it here): undone, and
|
||||
// placed as before, moving to the next content area whole.
|
||||
if let Some(p) = self.pages.last_mut() {
|
||||
@@ -58,11 +66,12 @@ impl Layouter<'_> {
|
||||
}
|
||||
self.fields = fields;
|
||||
self.names = names;
|
||||
self.taken = taken;
|
||||
self.items = items;
|
||||
self.leaves.clear();
|
||||
return self.place_block(node, x, (w, h), ctx, depth, sib, false);
|
||||
}
|
||||
return self.split_block(first, y, drawn);
|
||||
return self.split_block(first, y, drawn, flows_on);
|
||||
};
|
||||
// The field breaks into pieces, each with the field's border all round (a border's
|
||||
// default `break="close"`, XFA 3.3 pp. 629–630): the border drawn round the whole field
|
||||
@@ -81,7 +90,7 @@ impl Layouter<'_> {
|
||||
_ => None,
|
||||
};
|
||||
self.splitting = Some(Splitting { som, rect: Rect::new(x, y, w, h), pieces: Vec::new() });
|
||||
let split = self.split_block(first, y, drawn);
|
||||
let split = self.split_block(first, y, drawn, false);
|
||||
let pieces = self.splitting.take().map(|s| s.pieces).unwrap_or_default();
|
||||
split?;
|
||||
if let Some(b) = border {
|
||||
@@ -120,8 +129,11 @@ impl Layouter<'_> {
|
||||
/// Spread the items the last page holds from index `first` on (a block drawn from `top` to
|
||||
/// `end`, past the bottom of its content area) over this and the following content areas.
|
||||
/// Breaks fall between text, pictures and fields, never through one that fits in an area;
|
||||
/// fills and lines are cut at the break.
|
||||
fn split_block(&mut self, first: usize, top: f64, end: f64) -> Result<(), XfaError> {
|
||||
/// fills and lines are cut at the break. A flowing subform (`flows_on`: one [`Self::flow_block`]
|
||||
/// splits where it would otherwise move whole) breaks the flowed subforms round it there too
|
||||
/// ([`Self::break_frames`]), so they keep their border; anything else just moves on, and
|
||||
/// those subforms are drawn without theirs ([`Self::finish_frame`]).
|
||||
fn split_block(&mut self, first: usize, top: f64, end: f64, flows_on: bool) -> Result<(), XfaError> {
|
||||
let items: Vec<Item> = match self.pages.last_mut() {
|
||||
Some(p) if first <= p.items.len() => p.items.split_off(first),
|
||||
_ => return Ok(()),
|
||||
@@ -178,32 +190,13 @@ impl Layouter<'_> {
|
||||
f.lost = true;
|
||||
}
|
||||
}
|
||||
let mut cut = limit;
|
||||
if rect.h < 1.0 {
|
||||
let cut = if rect.h < 1.0 {
|
||||
// No room to split into: leave the rest here.
|
||||
cut = f64::INFINITY;
|
||||
f64::INFINITY
|
||||
} else {
|
||||
// The lowest break at or above the bottom that crosses no solid item: going
|
||||
// through them from the lowest top up, a break moved to an item's top can't
|
||||
// cross one already passed.
|
||||
let mut solid: Vec<(f64, f64)> = rest
|
||||
.iter()
|
||||
.filter(|(item, _)| !loose(item))
|
||||
.filter_map(|(item, kept)| kept.or_else(|| solid_extent(item)))
|
||||
.filter(|e| e.0 < limit - 0.01)
|
||||
.collect();
|
||||
solid.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
for (t, b) in solid {
|
||||
if t < cut - 0.01 && b > cut + 0.01 {
|
||||
cut = t;
|
||||
}
|
||||
}
|
||||
if cut <= seg_top + 0.01 {
|
||||
// An item taller than what is left. Start it in a fresh area if this one
|
||||
// already holds something; otherwise cut through it.
|
||||
cut = if first_segment && placed && seg_top > rect.y + 0.01 { seg_top } else { limit };
|
||||
}
|
||||
}
|
||||
let solid = rest.iter().filter(|(item, _)| !loose(item)).filter_map(|(item, kept)| kept.or_else(|| solid_extent(item)));
|
||||
lowest_break(solid, limit, seg_top, first_segment && placed && seg_top > rect.y + 0.01)
|
||||
};
|
||||
let mut here = Vec::new();
|
||||
let mut below = Vec::new();
|
||||
// The rest of the split widget's box: a piece down to this break, closed on every
|
||||
@@ -278,15 +271,21 @@ impl Layouter<'_> {
|
||||
rest = below;
|
||||
seg_top = cut;
|
||||
first_segment = false;
|
||||
let (rect, y) = if field.is_some() {
|
||||
// A field breaks the flowed subforms round it as a field moving on whole does:
|
||||
// each closes its part at the break and opens a new one, after its top margin,
|
||||
// in the next content area.
|
||||
let (rect, y) = if field.is_some() || flows_on {
|
||||
// A field breaks the flowed subforms round it as a field moving on whole does, and
|
||||
// so does a flowing subform: each closes its part at the break, after its bottom
|
||||
// margin, and opens a new one, after its top margin, in the next content area.
|
||||
let area_h = self.cursor().0.h;
|
||||
if let Some(c) = &mut self.cur {
|
||||
c.y = (cut - shift).min(c.rect.bottom()).max(c.y);
|
||||
c.placed = true;
|
||||
if field.is_some() {
|
||||
c.placed = true;
|
||||
}
|
||||
}
|
||||
self.break_frames(0.0, false)?;
|
||||
// A flowing subform's rest that fits an area has the top margins give way to it,
|
||||
// as `make_room` does.
|
||||
let left = (end - cut).max(0.0);
|
||||
self.break_frames(if flows_on && left <= area_h + 0.01 { left } else { 0.0 }, false)?;
|
||||
let (rect, y, _) = self.cursor();
|
||||
(rect, y)
|
||||
} else {
|
||||
@@ -305,8 +304,20 @@ impl Layouter<'_> {
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────── flow
|
||||
|
||||
/// Flow `node` (a subform; anything else is ignored) into the content areas.
|
||||
/// Flow subform `node` (each instance the data asks for; anything else is ignored) into the
|
||||
/// content areas, shown where it and what it is inside say ([`Common::views`]); hidden from
|
||||
/// both, it still takes its room.
|
||||
pub(super) fn flow_subform(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> {
|
||||
self.warn_other_views(node.common());
|
||||
let views = self.views.and(node.common().views());
|
||||
let outer = std::mem::replace(&mut self.views, views);
|
||||
let flowed = self.flow_subform_in(node, avail, ctx, depth, sib);
|
||||
self.views = outer;
|
||||
flowed
|
||||
}
|
||||
|
||||
/// [`Self::flow_subform`] once where the subform shows is set.
|
||||
pub(super) fn flow_subform_in(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -316,8 +327,7 @@ impl Layouter<'_> {
|
||||
self.break_frames(0.0, true)?;
|
||||
}
|
||||
// Data adds instances of a repeating subform (rows added in another viewer).
|
||||
let instances = self.instances(node, &ctx.som);
|
||||
self.warn_truncated(node, &ctx.som, instances);
|
||||
let instances = self.laid_out_instances(node, &ctx.som);
|
||||
let ctx_parent = ctx;
|
||||
for i in 0..instances {
|
||||
// Every instance spends budget, so nested repeats that place nothing still stop.
|
||||
@@ -329,7 +339,7 @@ impl Layouter<'_> {
|
||||
let w = clamp_opt(sf.common.w.unwrap_or(w), sf.common.min_w, sf.common.max_w).min(w.max(1.0));
|
||||
if !matches!(sf.layout, Layout::Tb | Layout::Table) || sf.common.h.is_some() {
|
||||
// Not splittable: one block.
|
||||
let (_, h) = self.size_of(node, w, ctx_parent, depth);
|
||||
let (_, h) = self.size_at(node, w, ctx_parent, depth, sib + i);
|
||||
self.flow_block(node, x, (w, h), ctx_parent, depth, sib + i)?;
|
||||
continue;
|
||||
}
|
||||
@@ -360,8 +370,7 @@ impl Layouter<'_> {
|
||||
self.flow_subform(child, Some((inner_x, inner_w)), &ctx, depth + 1, csib)?;
|
||||
continue;
|
||||
}
|
||||
let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1);
|
||||
self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?;
|
||||
self.flow_instances(child, inner_x, inner_w, &ctx, depth + 1, csib)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,8 +402,8 @@ impl Layouter<'_> {
|
||||
Some(Node::Subform(l)) => Some((li, l.as_ref())),
|
||||
_ => None,
|
||||
});
|
||||
let lead_h = lead.map_or(0.0, |(_, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1));
|
||||
let sibs = SomIndices::of(&sf.children);
|
||||
let lead_h = lead.map_or(0.0, |(li, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1, sibs.at(li)));
|
||||
for (ci, child) in sf.children.iter().enumerate() {
|
||||
if !child.common().presence.occupies() {
|
||||
continue;
|
||||
@@ -404,10 +413,9 @@ impl Layouter<'_> {
|
||||
&& row.layout == Layout::Row
|
||||
{
|
||||
// Rows added as data (in another viewer) repeat the row.
|
||||
let instances = self.instances(child, &ctx.som);
|
||||
self.warn_truncated(child, &ctx.som, instances);
|
||||
let instances = self.laid_out_instances(child, &ctx.som);
|
||||
for inst in 0..instances {
|
||||
let h = self.row_height(row, &cols, inner_w, ctx, depth + 1);
|
||||
let h = self.row_height(row, &cols, inner_w, ctx, depth + 1, csib.saturating_add(inst));
|
||||
let led = lead.filter(|(li, _)| *li != ci);
|
||||
if self.make_room(h, led.map_or(0.0, |_| lead_h))?
|
||||
&& let Some((li, l)) = led
|
||||
@@ -428,8 +436,23 @@ impl Layouter<'_> {
|
||||
self.flow_subform(child, Some((x, inner_w)), ctx, depth + 1, csib)?;
|
||||
continue;
|
||||
}
|
||||
let (cw, ch) = self.size_of(child, inner_w, ctx, depth + 1);
|
||||
self.flow_block(child, x, (cw, ch), ctx, depth + 1, csib)?;
|
||||
self.flow_instances(child, x, inner_w, ctx, depth + 1, csib)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flow `node`, which isn't split across content areas, as one block per instance (a
|
||||
/// repeating positioned subform gets one for each row the data or its `occur` gives it, XFA
|
||||
/// 3.3, "Repeating Elements using Occurrence Limits", p. 263), each as tall as its own
|
||||
/// content; the data rows past them are reported.
|
||||
fn flow_instances(&mut self, node: &Node, x: f64, inner_w: f64, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> {
|
||||
for instance in 0..self.laid_out_instances(node, &ctx.som) {
|
||||
// Every instance spends budget, so nested repeats that place nothing still stop.
|
||||
self.measures.set(self.measures.get().saturating_add(1));
|
||||
self.check_measures()?;
|
||||
let sib = sib.saturating_add(instance);
|
||||
let size = self.size_at(node, inner_w, ctx, depth, sib);
|
||||
self.flow_block(node, x, size, ctx, depth, sib)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -450,19 +473,58 @@ impl Layouter<'_> {
|
||||
instance_count(&sf.occur, from_data)
|
||||
}
|
||||
|
||||
/// [`Self::instances`] for laying `node` out: the count every layout that repeats a subform
|
||||
/// draws (flowed or positioned, top to bottom, wrapped or a table's rows), with the data rows
|
||||
/// past it reported ([`Self::warn_truncated`]).
|
||||
pub(super) fn laid_out_instances(&mut self, node: &Node, parent_som: &str) -> usize {
|
||||
let instances = self.instances(node, parent_som);
|
||||
self.warn_truncated(node, parent_som, instances);
|
||||
instances
|
||||
}
|
||||
|
||||
/// The data holds more instances of `node` than `laid_out`: say so, since calculations
|
||||
/// then see only those.
|
||||
fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) {
|
||||
/// then see only those, and say why. (The template's own counts can't ask for more: the
|
||||
/// parser reads `initial` and `max` up to [`MAX_INSTANCES`].)
|
||||
pub(super) fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) {
|
||||
let Node::Subform(sf) = node else { return };
|
||||
let from_data = match (&sf.common.name, self.data) {
|
||||
(Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name),
|
||||
_ => 0,
|
||||
};
|
||||
if from_data > laid_out {
|
||||
self.warn_once(format!(
|
||||
"{}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those)",
|
||||
sf.common.name.as_deref().unwrap_or("a subform")
|
||||
));
|
||||
if from_data <= laid_out {
|
||||
return;
|
||||
}
|
||||
let name = sf.common.name.as_deref().unwrap_or("a subform");
|
||||
// The template's own `occur max` is the form's rule: past it, the extra data is ignored
|
||||
// (XFA 3.3, p. 336). Only the layout's own limit needs naming as a limit.
|
||||
let why = if sf.occur.max.is_some_and(|m| m <= laid_out) {
|
||||
String::new()
|
||||
} else {
|
||||
format!("; {MAX_INSTANCES} is the most one repeating subform lays out")
|
||||
};
|
||||
self.warn_once(format!("{name}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those){why}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest break at or above `limit` that crosses none of the `solid` items' extents: going
|
||||
/// through them from the lowest top up, a break moved to an item's top can't cross one already
|
||||
/// passed. When that leaves nothing below `seg_top` (an item taller than what is left), the
|
||||
/// item starts in a fresh area if this one already holds something (`fresh`); otherwise the
|
||||
/// break cuts through it at `limit`.
|
||||
fn lowest_break(solid: impl Iterator<Item = (f64, f64)>, limit: f64, seg_top: f64, fresh: bool) -> f64 {
|
||||
let mut solid: Vec<(f64, f64)> = solid.filter(|e| e.0 < limit - 0.01).collect();
|
||||
solid.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
let mut cut = limit;
|
||||
for (t, b) in solid {
|
||||
if t < cut - 0.01 && b > cut + 0.01 {
|
||||
cut = t;
|
||||
}
|
||||
}
|
||||
if cut > seg_top + 0.01 {
|
||||
cut
|
||||
} else if fresh {
|
||||
seg_top
|
||||
} else {
|
||||
limit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Arc, Face, HAlign, Rect, Span};
|
||||
use super::{Arc, Face, HAlign, Rect, Span, Views};
|
||||
|
||||
/// How far below its baseline an underline is drawn, in the text's size: an underline is the
|
||||
/// level line pushed just before its span, this far below it (the PDF writer tells it apart from
|
||||
@@ -27,6 +27,12 @@ pub enum Item {
|
||||
content_type: String,
|
||||
},
|
||||
Widget(Box<Widget>),
|
||||
/// An item drawn only on screen or only in print (XFA `relevant`, `presence`): written in an
|
||||
/// optional content group whose usage says so.
|
||||
Only {
|
||||
views: Views,
|
||||
item: Box<Item>,
|
||||
},
|
||||
/// A draw turned about its anchor.
|
||||
Turned(Box<Turned>),
|
||||
}
|
||||
@@ -144,6 +150,10 @@ pub struct Widget {
|
||||
pub items: Vec<String>,
|
||||
/// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`).
|
||||
pub no_scroll: bool,
|
||||
/// Where the field is shown: on screen, in print, or both (its annotation flags).
|
||||
pub views: Views,
|
||||
/// The field must be filled in (`Required`); a radio button, when its group must be.
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -151,6 +161,8 @@ pub struct Page {
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub items: Vec<Item>,
|
||||
/// The name of the page area the page was made from (`None` for an unnamed one).
|
||||
pub area: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
use super::common::clamp_opt;
|
||||
use super::field::auto_check_reserve;
|
||||
use super::{
|
||||
Access, Area, Ctx, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Measured, Node, Para, Placement, Rect, Subform, Ui,
|
||||
Value, data_path, text,
|
||||
Access, Area, Ctx, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Measured, Node, Para, Placement, Rect, SomIndices,
|
||||
Subform, Ui, Value, data_path, text,
|
||||
};
|
||||
|
||||
/// Where [`Layouter::pack_instances`] puts one object: the (child index, instance) steps to it, and
|
||||
/// its place in the packed container's box.
|
||||
pub(super) type Packed = (Vec<(usize, usize)>, Rect);
|
||||
|
||||
impl Layouter<'_> {
|
||||
/// The width and height `node` takes in `avail_w`: its own size, else what its content needs
|
||||
/// (containers are measured once per width and depth, and measuring stops at its caps).
|
||||
pub(super) fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) {
|
||||
/// The width and height instance `index` of `node` under `ctx` takes in `avail_w`: its own
|
||||
/// size, else what its content needs (containers are measured once per width, depth and
|
||||
/// instance, and measuring stops at its caps). Instances differ when the data gives each its
|
||||
/// own rows (XFA 3.3, "Tables", p. 327; Chapter 9, p. 336), so with data each is measured
|
||||
/// under its own SOM path.
|
||||
pub(super) fn size_at(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize, index: usize) -> (f64, f64) {
|
||||
let c = node.common();
|
||||
let spent = self.measures.get().saturating_add(1);
|
||||
self.measures.set(spent);
|
||||
if depth > MAX_DEPTH || spent > MAX_MEASURES {
|
||||
return (c.w.unwrap_or(avail_w), c.h.unwrap_or(0.0));
|
||||
}
|
||||
let own = ctx.with_som(c, index);
|
||||
// Only containers recurse; their addresses are stable for the whole layout (the
|
||||
// template is borrowed, the root and page areas are held by the layouter).
|
||||
// template is borrowed, the root and page areas are held by the layouter). Without data
|
||||
// every instance is alike, so one measurement serves them all.
|
||||
let som = || if self.data.is_some() { own.som.clone() } else { String::new() };
|
||||
let key = match node {
|
||||
Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth)),
|
||||
Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth)),
|
||||
Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth, som())),
|
||||
Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth, som())),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(key) = key
|
||||
if let Some(key) = &key
|
||||
&& let Ok(cache) = self.measured.try_borrow()
|
||||
&& let Some(m) = cache.get(&key)
|
||||
&& let Some(m) = cache.get(key)
|
||||
&& m.font == ctx.font
|
||||
&& m.para == ctx.para
|
||||
{
|
||||
return m.size;
|
||||
}
|
||||
let size = self.measure(node, avail_w, ctx, depth);
|
||||
// A field's caption is measured in the paragraph around the field, as it is drawn.
|
||||
let size = self.measure(node, avail_w, &own, &ctx.para, depth);
|
||||
if let Some(key) = key
|
||||
&& let Ok(mut cache) = self.measured.try_borrow_mut()
|
||||
{
|
||||
@@ -39,15 +50,14 @@ impl Layouter<'_> {
|
||||
size
|
||||
}
|
||||
|
||||
/// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_of`] caches it).
|
||||
/// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_at`] caches it).
|
||||
/// Without a width, a container, a text draw or a button is as wide as its content (at most
|
||||
/// `avail_w`) and anything else takes `avail_w`; without a height, it is as tall as its content.
|
||||
fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) {
|
||||
/// `ctx` holds the SOM path of the instance measured: its repeating children count their
|
||||
/// instances under it, and a growing text field finds its value at it. `outer_para` is the
|
||||
/// paragraph around the node, which a field's caption takes.
|
||||
fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, outer_para: &Para, depth: usize) -> (f64, f64) {
|
||||
let c = node.common();
|
||||
// The node's own SOM: its repeating children count their instances under it, and a
|
||||
// growing text field finds its value at it. Measurements are cached per template node,
|
||||
// so instance 0 stands for all of them.
|
||||
let ctx = ctx.with_som(c, 0);
|
||||
let som = ctx.som.clone();
|
||||
// A container without a width is as wide as its content (a radio group beside its
|
||||
// question, say), and so is a text label or a button (as wide as its caption), within
|
||||
@@ -58,64 +68,84 @@ impl Layouter<'_> {
|
||||
None => match node {
|
||||
Node::Subform(_) | Node::ExclGroup(_) | Node::Area(_) => {
|
||||
let inner = (avail_w - c.margin.horizontal()).max(1.0);
|
||||
(self.content_width(node, inner, &ctx, depth) + c.margin.horizontal()).min(avail_w)
|
||||
(self.content_width(node, inner, ctx, depth) + c.margin.horizontal()).min(avail_w)
|
||||
}
|
||||
Node::Draw(d) => self.natural_width(&d.value, &ctx.font, &ctx.para).map_or(avail_w, |t| (t + c.margin.horizontal()).min(avail_w)),
|
||||
Node::Field(f) if f.ui == Ui::Button => f
|
||||
.caption
|
||||
.as_ref()
|
||||
.and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para)))
|
||||
.and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &outer_para.apply(&cap.para)))
|
||||
.map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)),
|
||||
Node::Field(f) if f.ui == Ui::CheckButton => (self.check_width(f, &ctx) + c.margin.horizontal()).min(avail_w),
|
||||
Node::Field(f) if f.ui == Ui::CheckButton => (self.check_width(f, ctx) + c.margin.horizontal()).min(avail_w),
|
||||
Node::Field(_) => avail_w,
|
||||
},
|
||||
};
|
||||
let w = clamp_opt(w, c.min_w, c.max_w);
|
||||
let h = match c.h {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
let inner_w = (w - c.margin.horizontal()).max(1.0);
|
||||
let content = match node {
|
||||
Node::Draw(d) => self.value_height(&d.value, inner_w, &ctx),
|
||||
Node::Field(f) => self.field_height(f, inner_w, &ctx),
|
||||
Node::ExclGroup(g) => self.group_height(g, inner_w, &ctx, depth),
|
||||
Node::Area(a) => self.positioned_height(&a.children, inner_w, &ctx, depth),
|
||||
Node::Subform(s) => match s.layout {
|
||||
Layout::Positioned => self.positioned_height(&s.children, inner_w, &ctx, depth),
|
||||
// Each instance of a repeating child takes its height again.
|
||||
Layout::Tb => s
|
||||
.children
|
||||
.iter()
|
||||
.filter(|k| k.common().presence.occupies())
|
||||
.map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1 * self.instances(k, &som) as f64)
|
||||
.sum(),
|
||||
Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, &ctx, depth, &som).1,
|
||||
Layout::Table => {
|
||||
let cols = self.columns(s, inner_w);
|
||||
s.children
|
||||
.iter()
|
||||
.filter(|k| k.common().presence.occupies())
|
||||
.map(|k| match k {
|
||||
Node::Subform(r) if r.layout == Layout::Row => self.row_height(r, &cols, inner_w, &ctx, depth + 1),
|
||||
_ => self.size_of(k, inner_w, &ctx, depth + 1).1,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
Layout::Row => self.row_height(s, &self.columns(s, inner_w), inner_w, &ctx, depth + 1),
|
||||
},
|
||||
};
|
||||
content + c.margin.vertical()
|
||||
}
|
||||
None => self.content_height(node, (w - c.margin.horizontal()).max(1.0), ctx, &som, outer_para, depth) + c.margin.vertical(),
|
||||
};
|
||||
// A flowed subform is as tall as what it holds: other viewers do not stretch one to
|
||||
// the `minH` a designer left on it.
|
||||
// A subform without a height is as tall as what it holds, flowed or positioned. XFA 3.3
|
||||
// p. 847 makes `minH` its minimum; Adobe Acrobat doesn't stretch one to it (IRCC's
|
||||
// IMM 5257: section 10 at `minH="58.274mm"`), and this follows Acrobat. A row, in a table
|
||||
// or not, keeps its `minH`: [`Self::cells`] has already raised its height to it.
|
||||
let min_h = match node {
|
||||
Node::Subform(s) if c.h.is_none() && s.layout != Layout::Positioned => None,
|
||||
Node::Subform(_) if c.h.is_none() => None,
|
||||
_ => c.min_h,
|
||||
};
|
||||
(w, clamp_opt(h, min_h, c.max_h))
|
||||
}
|
||||
|
||||
/// The height `k` takes with all its instances under the container at `som`, one under
|
||||
/// another, the first at SOM index `sib` among its siblings: each instance takes its own.
|
||||
fn instances_height(&self, k: &Node, inner_w: f64, ctx: &Ctx, depth: usize, som: &str, sib: usize) -> f64 {
|
||||
(0..self.instances(k, som)).map(|i| self.size_at(k, inner_w, ctx, depth, sib.saturating_add(i)).1).sum()
|
||||
}
|
||||
|
||||
/// The height of `node`'s content within `inner_w` (inside its margins), whatever height it
|
||||
/// gives itself. `ctx` is the node's own context ([`Ctx::with_som`]), `som` its SOM path and
|
||||
/// `outer_para` the paragraph around it, which a field's caption takes.
|
||||
fn content_height(&self, node: &Node, inner_w: f64, ctx: &Ctx, som: &str, outer_para: &Para, depth: usize) -> f64 {
|
||||
match node {
|
||||
Node::Draw(d) => self.value_height(&d.value, inner_w, ctx),
|
||||
Node::Field(f) => self.field_height(f, inner_w, ctx, outer_para),
|
||||
Node::ExclGroup(g) => self.group_height(g, inner_w, ctx, depth),
|
||||
Node::Area(a) => self.positioned_height(&a.children, inner_w, ctx, depth),
|
||||
Node::Subform(s) => match s.layout {
|
||||
Layout::Positioned => self.positioned_height(&s.children, inner_w, ctx, depth),
|
||||
// Each instance of a repeating child takes its own height.
|
||||
Layout::Tb => {
|
||||
let sibs = SomIndices::of(&s.children);
|
||||
s.children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, k)| k.common().presence.occupies())
|
||||
.map(|(i, k)| self.instances_height(k, inner_w, ctx, depth + 1, som, sibs.at(i)))
|
||||
.sum()
|
||||
}
|
||||
Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, ctx, depth, som).1,
|
||||
Layout::Table => {
|
||||
let cols = self.columns(s, inner_w);
|
||||
let sibs = SomIndices::of(&s.children);
|
||||
s.children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, k)| k.common().presence.occupies())
|
||||
.map(|(i, k)| match k {
|
||||
// Each instance of a repeating row takes its own height, as drawn.
|
||||
Node::Subform(r) if r.layout == Layout::Row => (0..self.instances(k, som))
|
||||
.map(|n| self.row_height(r, &cols, inner_w, ctx, depth + 1, sibs.at(i).saturating_add(n)))
|
||||
.sum(),
|
||||
_ => self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i)).1,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
// `ctx` is already this row instance's own.
|
||||
Layout::Row => self.cells(s, &self.columns(s, inner_w), inner_w, ctx, depth + 1).1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The natural width of a container's content within `avail_w`.
|
||||
fn content_width(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> f64 {
|
||||
let group: Vec<Node>;
|
||||
@@ -134,20 +164,24 @@ impl Layouter<'_> {
|
||||
Node::Area(a) => (&a.children, Layout::Positioned),
|
||||
_ => return avail_w,
|
||||
};
|
||||
let visible = children.iter().filter(|k| k.common().presence.occupies());
|
||||
// Each child under the SOM index it is drawn at.
|
||||
let sibs = SomIndices::of(children);
|
||||
let visible = children.iter().enumerate().filter(|(_, k)| k.common().presence.occupies());
|
||||
match layout {
|
||||
Layout::LrTb | Layout::RlTb | Layout::Row => {
|
||||
let sum: f64 = visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).sum();
|
||||
let sum: f64 = visible.map(|(i, k)| self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i)).0).sum();
|
||||
if sum <= avail_w + 0.05 { sum } else { avail_w }
|
||||
}
|
||||
Layout::Positioned => visible
|
||||
.map(|k| {
|
||||
let (w, h) = self.size_of(k, avail_w, ctx, depth + 1);
|
||||
.map(|(i, k)| {
|
||||
let (w, h) = self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i));
|
||||
k.standing_box(w, h).right()
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
.min(avail_w),
|
||||
Layout::Tb | Layout::Table => visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).fold(0.0, f64::max).min(avail_w),
|
||||
Layout::Tb | Layout::Table => {
|
||||
visible.map(|(i, k)| self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i)).0).fold(0.0, f64::max).min(avail_w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,8 +227,12 @@ impl Layouter<'_> {
|
||||
}
|
||||
|
||||
/// Where field `f`'s caption goes and how much room it takes from the field's box, or `None`
|
||||
/// when it has no caption that takes room (a button's is drawn inside its box).
|
||||
pub(super) fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> Option<(Placement, f64)> {
|
||||
/// when it has no caption that takes room (a button's is drawn inside its box). `ctx` is the
|
||||
/// field's own and `outer_para` the paragraph around the field: the caption is measured in
|
||||
/// the field's font (XFA 3.3, "Formatting Text That Appears as Fixed or Variable Content",
|
||||
/// p. 43) and the outer paragraph, each with the caption's own properties on top, as it is
|
||||
/// drawn.
|
||||
pub(super) fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx, outer_para: &Para) -> Option<(Placement, f64)> {
|
||||
let cap = f.caption.as_ref()?;
|
||||
if !cap.presence.occupies() || f.ui == Ui::Button {
|
||||
return None;
|
||||
@@ -208,7 +246,7 @@ impl Layouter<'_> {
|
||||
}
|
||||
let placement = if cap.placement == Placement::Inline { Placement::Left } else { cap.placement };
|
||||
let font = ctx.font.apply(&cap.font);
|
||||
let para = ctx.para.apply(&cap.para);
|
||||
let para = outer_para.apply(&cap.para);
|
||||
let reserve = cap.reserve.unwrap_or_else(|| match placement {
|
||||
Placement::Top | Placement::Bottom => {
|
||||
let rich = match &cap.value {
|
||||
@@ -225,9 +263,13 @@ impl Layouter<'_> {
|
||||
/// The height of field `f`'s content within `inner_w` (inside its margins): its widget (a check
|
||||
/// box, a button's caption, or one line of text or three when multi-line, more for a text field
|
||||
/// that grows) plus a caption above or below it, or the taller of the two for one beside it.
|
||||
fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 {
|
||||
/// `ctx` is the field's own and `outer_para` the paragraph around it, which its caption takes.
|
||||
fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx, outer_para: &Para) -> f64 {
|
||||
let font = ctx.font.apply(&f.common.font);
|
||||
let lines = if f.multiline { 3.0 } else { 1.0 };
|
||||
// An empty multi-line field is three lines tall, unless it gives a minimum height (`minH`,
|
||||
// XFA 3.3 p. 731): then that is where it starts, as a growable table cell does, and one
|
||||
// line below it; its text, when there is more, still grows it.
|
||||
let lines = if f.multiline && f.common.min_h.is_none() { 3.0 } else { 1.0 };
|
||||
let ui = match f.ui {
|
||||
Ui::CheckButton => f.check_size.unwrap_or(10.0).max(text::line_height(font.size)),
|
||||
// A button shows its caption: as tall as the caption's text in its own font.
|
||||
@@ -238,13 +280,13 @@ impl Layouter<'_> {
|
||||
other => text::plain(&other.plain().unwrap_or_default()),
|
||||
};
|
||||
let w = (inner_w - f.ui_margin.horizontal()).max(1.0);
|
||||
text::measure_height(&rich, w, &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| self.embed(id, max))
|
||||
text::measure_height(&rich, w, &outer_para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| self.embed(id, max))
|
||||
});
|
||||
cap + f.ui_margin.vertical()
|
||||
}
|
||||
_ => (text::line_height(font.size) * lines).max(self.grown_text_height(f, inner_w, ctx)) + 4.0 + f.ui_margin.vertical(),
|
||||
_ => (text::line_height(font.size) * lines).max(self.grown_text_height(f, inner_w, ctx, outer_para)) + 4.0 + f.ui_margin.vertical(),
|
||||
};
|
||||
match self.caption_reserve(f, inner_w, ctx) {
|
||||
match self.caption_reserve(f, inner_w, ctx, outer_para) {
|
||||
Some((Placement::Top | Placement::Bottom, r)) => ui + r,
|
||||
Some((placement, r)) => {
|
||||
// A check box's caption without a reserve is drawn in all the box leaves; any
|
||||
@@ -259,7 +301,7 @@ impl Layouter<'_> {
|
||||
Value::Rich(r) => r.clone(),
|
||||
other => text::plain(&other.plain().unwrap_or_default()),
|
||||
};
|
||||
text::measure_height(&rich, w.max(1.0), &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| {
|
||||
text::measure_height(&rich, w.max(1.0), &outer_para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| {
|
||||
self.embed(id, max)
|
||||
})
|
||||
});
|
||||
@@ -279,17 +321,20 @@ impl Layouter<'_> {
|
||||
|
||||
/// The height the text of a multi-line text field takes (its value in the data, else the
|
||||
/// template's), for a field without a fixed height, which grows to hold its text. `ctx`
|
||||
/// is the field's own (its SOM ends in the field). Zero for anything else.
|
||||
fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 {
|
||||
if !f.grows_with_text() || f.common.name.is_none() {
|
||||
/// is the field's own (its SOM ends in the field) and `outer_para` the paragraph around it.
|
||||
/// Zero for anything else. A nameless field takes no part in data binding (XFA 3.3 p. 194),
|
||||
/// and its `ctx` ends in its parent: its text is the template's.
|
||||
fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx, outer_para: &Para) -> f64 {
|
||||
if !f.grows_with_text() {
|
||||
return 0.0;
|
||||
}
|
||||
let value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain());
|
||||
let bound = f.common.name.as_ref().and(self.data);
|
||||
let value = bound.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain());
|
||||
let Some(value) = value.filter(|t| !t.trim().is_empty()) else { return 0.0 };
|
||||
// Beside a caption the text has what the caption leaves; a widget's text has 2pt of
|
||||
// padding on each side, a non-interactive field's (drawn as text) none, so it wraps
|
||||
// where it is drawn.
|
||||
let beside = match self.caption_reserve(f, inner_w, ctx) {
|
||||
let beside = match self.caption_reserve(f, inner_w, ctx, outer_para) {
|
||||
Some((Placement::Top | Placement::Bottom, _)) | None => 0.0,
|
||||
Some((_, r)) => r,
|
||||
};
|
||||
@@ -304,73 +349,134 @@ impl Layouter<'_> {
|
||||
fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 {
|
||||
let nodes: Vec<Node> = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
|
||||
match g.layout {
|
||||
Layout::Tb => nodes.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, ctx, depth + 1).1).sum(),
|
||||
Layout::Tb => {
|
||||
let sibs = SomIndices::of(&nodes);
|
||||
nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, k)| k.common().presence.occupies())
|
||||
.map(|(i, k)| self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i)).1)
|
||||
.sum()
|
||||
}
|
||||
Layout::Positioned => self.positioned_height(&nodes, inner_w, ctx, depth),
|
||||
_ => self.packed_height(&nodes, inner_w, ctx, depth),
|
||||
}
|
||||
}
|
||||
|
||||
fn positioned_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 {
|
||||
let sibs = SomIndices::of(children);
|
||||
children
|
||||
.iter()
|
||||
.filter(|k| k.common().presence.occupies())
|
||||
.map(|k| {
|
||||
.enumerate()
|
||||
.filter(|(_, k)| k.common().presence.occupies())
|
||||
.map(|(i, k)| {
|
||||
let c = k.common();
|
||||
let (w, h) = self.size_of(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1);
|
||||
let (w, h) = self.size_at(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1, sibs.at(i));
|
||||
k.standing_box(w, h).bottom()
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Left-to-right packing: rows of children, wrapped at the available width, as (child,
|
||||
/// instance, place). A repeating subform is packed once per instance it has under the
|
||||
/// container at `som`.
|
||||
pub(super) fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec<(usize, usize, Rect)>, f64) {
|
||||
/// Left-to-right packing: rows of children, wrapped at the available width, as (steps, place).
|
||||
/// The steps are (child index, instance) from the packed container down to the object: one
|
||||
/// step for a child, more for a member of a `subformSet`, which is transparent to layout (XFA
|
||||
/// 3.3 p. 357), so its members go on among the container's other children. A repeating
|
||||
/// subform is packed once per instance it has under the container at `som`.
|
||||
pub(super) fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec<Packed>, f64) {
|
||||
let mut out = Vec::new();
|
||||
let (mut x, mut y, mut line_h) = (0.0, 0.0, 0.0);
|
||||
let mut line = (0.0, 0.0, 0.0);
|
||||
self.pack_into(children, inner_w, ctx, depth, som, &[], &mut line, &mut out);
|
||||
(out, line.1 + line.2)
|
||||
}
|
||||
|
||||
/// [`Self::pack_instances`] from `line` (x, y, the line's height so far), the steps to
|
||||
/// `children` being `path`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pack_into(
|
||||
&self,
|
||||
children: &[Node],
|
||||
inner_w: f64,
|
||||
ctx: &Ctx,
|
||||
depth: usize,
|
||||
som: &str,
|
||||
path: &[(usize, usize)],
|
||||
line: &mut (f64, f64, f64),
|
||||
out: &mut Vec<Packed>,
|
||||
) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let sibs = SomIndices::of(children);
|
||||
for (i, k) in children.iter().enumerate() {
|
||||
if !k.common().presence.occupies() {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = self.size_of(k, inner_w, ctx, depth + 1);
|
||||
for instance in 0..self.instances(k, som) {
|
||||
if x > 0.0 && x + w > inner_w + 0.05 {
|
||||
y += line_h;
|
||||
x = 0.0;
|
||||
line_h = 0.0;
|
||||
if let Node::Subform(s) = k
|
||||
&& s.set
|
||||
{
|
||||
for instance in 0..self.instances(k, som) {
|
||||
let set_ctx = ctx.with_som(&s.common, sibs.at(i).saturating_add(instance));
|
||||
let at: Vec<(usize, usize)> = path.iter().copied().chain([(i, instance)]).collect();
|
||||
self.pack_into(&s.children, inner_w, &set_ctx, depth + 1, &set_ctx.som, &at, line, out);
|
||||
}
|
||||
out.push((i, instance, Rect::new(x, y, w, h)));
|
||||
x += w;
|
||||
line_h = line_h.max(h);
|
||||
continue;
|
||||
}
|
||||
for instance in 0..self.instances(k, som) {
|
||||
let (w, h) = self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i).saturating_add(instance));
|
||||
let (x, y, line_h) = line;
|
||||
if *x > 0.0 && *x + w > inner_w + 0.05 {
|
||||
*y += *line_h;
|
||||
*x = 0.0;
|
||||
*line_h = 0.0;
|
||||
}
|
||||
out.push((path.iter().copied().chain([(i, instance)]).collect(), Rect::new(*x, *y, w, h)));
|
||||
*x += w;
|
||||
*line_h = line_h.max(h);
|
||||
}
|
||||
}
|
||||
(out, y + line_h)
|
||||
}
|
||||
|
||||
/// [`Self::pack_instances`] for children that don't repeat (the fields of a group).
|
||||
pub(super) fn pack(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) {
|
||||
let (placed, h) = self.pack_instances(children, inner_w, ctx, depth, "");
|
||||
(placed.into_iter().map(|(i, _, r)| (i, r)).collect(), h)
|
||||
(placed.into_iter().filter_map(|(at, r)| at.first().map(|&(i, _)| (i, r))).collect(), h)
|
||||
}
|
||||
|
||||
fn packed_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 {
|
||||
self.pack(children, inner_w, ctx, depth).1
|
||||
}
|
||||
|
||||
/// Cell rectangles of a row (relative to the row's inner box) and the row height.
|
||||
/// Cell rectangles of a row (relative to the row's inner box) and the row height. Each cell
|
||||
/// is measured under its own SOM index among its same-named siblings (`qty[1]`, not
|
||||
/// `qty[0]`), the index [`Self::draw_row`] draws it at, so a cell that grows with its value
|
||||
/// takes its own value's height.
|
||||
pub(super) fn cells(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) {
|
||||
let ctx = ctx.child(&row.common);
|
||||
let mut out = Vec::new();
|
||||
let mut col = 0usize;
|
||||
let mut x = 0.0;
|
||||
let mut h: f64 = 0.0;
|
||||
let sibs = SomIndices::of(&row.children);
|
||||
for (i, k) in row.children.iter().enumerate() {
|
||||
if !k.common().presence.occupies() {
|
||||
continue;
|
||||
}
|
||||
let span = k.common().col_span.max(1);
|
||||
let w = if col < cols.len() { cols.iter().skip(col).take(span).sum::<f64>() } else { k.common().w.unwrap_or((inner_w - x).max(1.0)) };
|
||||
let (_, ch) = self.size_of(k, w, &ctx, depth + 1);
|
||||
let (_, ch) = self.size_at(k, w, &ctx, depth + 1, sibs.at(i));
|
||||
// A flowed subform in a cell is as tall as its content even when it gives a height:
|
||||
// other viewers (Adobe Acrobat, PDF-XChange Editor) lay a section of a table row out
|
||||
// that way, though a height makes a container fixed (XFA 3.3 p. 276).
|
||||
let ch = match k {
|
||||
Node::Subform(s) if s.common.h.is_some() && matches!(s.layout, Layout::Tb | Layout::LrTb | Layout::RlTb | Layout::Table) => {
|
||||
let own = ctx.with_som(&s.common, sibs.at(i));
|
||||
// At the width it is drawn at: the column's, not the cell's own.
|
||||
let inner = (w - s.common.margin.horizontal()).max(1.0);
|
||||
let content = self.content_height(k, inner, &own, &own.som, &ctx.para, depth + 1);
|
||||
clamp_opt(content + s.common.margin.vertical(), None, s.common.max_h)
|
||||
}
|
||||
_ => ch,
|
||||
};
|
||||
out.push((i, Rect::new(x, 0.0, w, ch)));
|
||||
h = h.max(ch);
|
||||
x += w;
|
||||
@@ -383,7 +489,10 @@ impl Layouter<'_> {
|
||||
(out, clamp_opt(h, row.common.min_h, row.common.max_h))
|
||||
}
|
||||
|
||||
pub(super) fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 {
|
||||
self.cells(row, cols, inner_w, ctx, depth).1
|
||||
/// The height of instance `sib` of `row` in the table at `ctx`, measured as [`Self::draw_row`]
|
||||
/// draws it: under the instance's own SOM path, so a cell that grows with its text takes
|
||||
/// that instance's value (XFA 3.3, "Tables", p. 327; "Growable Containers", p. 275).
|
||||
pub(super) fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize, sib: usize) -> f64 {
|
||||
self.cells(row, cols, inner_w, &ctx.with_som(&row.common, sib), depth).1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
use super::EMBED_COPIED;
|
||||
use super::items::{Item, Page, UNDERLINE_DROP};
|
||||
use super::{
|
||||
Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_DRAW_CHARS, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para,
|
||||
Rc, Rect, SomIndices, XfaError, data_path, text,
|
||||
Block, Border, Common, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_DRAW_CHARS, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, Node, PAGE_COUNT_MARK,
|
||||
PageRole, Para, Rc, Rect, SomIndices, Views, XfaError, data_path, text,
|
||||
};
|
||||
|
||||
impl Layouter<'_> {
|
||||
@@ -14,6 +14,14 @@ impl Layouter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Say once when `c`'s `relevant` names a view other than print, which is drawn as if it
|
||||
/// didn't: for drawn objects, flowed subforms and table rows alike.
|
||||
pub(super) fn warn_other_views(&mut self, c: &Common) {
|
||||
if let Some(other) = &c.relevant_other {
|
||||
self.warn_once(format!("the form limits some objects to views other than print (relevant \"{other}\"); they are drawn as if it didn't"));
|
||||
}
|
||||
}
|
||||
|
||||
fn page_no(&self) -> usize {
|
||||
self.pages.len()
|
||||
}
|
||||
@@ -65,12 +73,25 @@ impl Layouter<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put `item` on the last page, shown where what it is inside is shown ([`Self::views`]): a
|
||||
/// field's widget carries that itself, anything else drawn only on screen or only in print
|
||||
/// goes in an [`Item::Only`], and what shows nowhere isn't put at all.
|
||||
pub(super) fn push(&mut self, item: Item) -> Result<(), XfaError> {
|
||||
self.check_measures()?;
|
||||
self.items += 1;
|
||||
if self.items > MAX_ITEMS {
|
||||
return Err(XfaError::TooLarge("the form has too many objects to lay out".into()));
|
||||
}
|
||||
let views = self.views;
|
||||
let item = match item {
|
||||
Item::Widget(mut w) => {
|
||||
w.views = views;
|
||||
Item::Widget(w)
|
||||
}
|
||||
other if views == Views::ALL => other,
|
||||
_ if !views.any() => return Ok(()),
|
||||
other => Item::Only { views, item: Box::new(other) },
|
||||
};
|
||||
if let Some(p) = self.pages.last_mut() {
|
||||
p.items.push(item);
|
||||
}
|
||||
@@ -123,17 +144,35 @@ impl Layouter<'_> {
|
||||
if let Some(u) = self.uses.get_mut(idx) {
|
||||
*u += 1;
|
||||
}
|
||||
self.pages.push(Page { width: area.width, height: area.height, items: Vec::new() });
|
||||
self.pages.push(Page { width: area.width, height: area.height, items: Vec::new(), area: area.name.clone() });
|
||||
let content = area.content.first().copied().unwrap_or(Rect::new(0.0, 0.0, area.width, area.height));
|
||||
self.cur = Some(Cursor { area: idx, content: 0, rect: content, y: content.y, placed: false });
|
||||
// Master page content: positioned draws and fields.
|
||||
let ctx = Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_default() };
|
||||
// Master page content: positioned draws and fields. The page area's own SOM index counts
|
||||
// the pages made from it, so a field repeated on each page (a page number, an office-use
|
||||
// box) has a path of its own per page rather than every page sharing `[0]`.
|
||||
let instance = self.uses.get(idx).map_or(0, |u| u.saturating_sub(1));
|
||||
let ctx =
|
||||
Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[{instance}]")).unwrap_or_default() };
|
||||
let page_rect = Rect::new(0.0, 0.0, area.width, area.height);
|
||||
let sibs = SomIndices::of(&area.items);
|
||||
for (i, item) in area.items.iter().enumerate() {
|
||||
self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i))?;
|
||||
}
|
||||
Ok(())
|
||||
// What scripts set on this page's master content (a button hidden on this page only) goes
|
||||
// on a copy of it for this page; other pages keep the template's.
|
||||
let prefix = format!("{}.", ctx.som);
|
||||
let copy =
|
||||
self.overrides.filter(|ov| !ctx.som.is_empty() && ov.presence.keys().chain(ov.access.keys()).any(|k| k.starts_with(&prefix))).map(|ov| {
|
||||
let mut copy = area.items.clone();
|
||||
crate::script::apply_overrides_to(&mut copy, &ctx.som, ov, 1);
|
||||
let copy = Rc::new(copy);
|
||||
self.master_copies.push(Rc::clone(©));
|
||||
copy
|
||||
});
|
||||
let items: &[Node] = copy.as_deref().map_or(&area.items, Vec::as_slice);
|
||||
let sibs = SomIndices::of(items);
|
||||
// The master page's own objects: shown where they say, whatever the body was drawing
|
||||
// when it needed this page.
|
||||
let body = std::mem::replace(&mut self.views, Views::ALL);
|
||||
let drawn = items.iter().enumerate().try_for_each(|(i, item)| self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i)));
|
||||
self.views = body;
|
||||
drawn
|
||||
}
|
||||
|
||||
/// Move to the next content area, or the next page.
|
||||
|
||||
+19
-9
@@ -11,11 +11,13 @@ pub mod data;
|
||||
pub mod fixtures;
|
||||
pub mod image;
|
||||
pub mod layout;
|
||||
pub mod linked_images;
|
||||
pub mod model;
|
||||
pub mod packets;
|
||||
pub mod parse;
|
||||
pub mod pdf;
|
||||
pub mod picture;
|
||||
mod proto;
|
||||
pub mod script;
|
||||
pub mod text;
|
||||
|
||||
@@ -94,18 +96,18 @@ pub(crate) fn xml_nesting_depth(text: &str) -> usize {
|
||||
pub use data::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image};
|
||||
pub use data::{
|
||||
DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, clear_password_values, data_path,
|
||||
has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path,
|
||||
write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with,
|
||||
has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, radio_dataset_value, radio_state_for_item, read_values, read_values_with,
|
||||
remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with,
|
||||
};
|
||||
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 pdf::{CLICK_KEY, DATE_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
|
||||
pub use parse::{measure, numbers_pages, parse};
|
||||
pub use pdf::{AREA_KEY, 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::{
|
||||
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, embedded_fields,
|
||||
field_grows_with_text, field_is_embedded, fields_by_som, form_tree, has_scripts, overrides, path_is_embedded, refresh_value, rerender,
|
||||
set_overrides, template_of,
|
||||
field_grows_with_text, field_is_embedded, fields_by_som, form_tree, form_tree_paged, has_focus_scripts, has_scripts, overrides, page_area_uses,
|
||||
path_is_embedded, refresh_value, rerender, reset_default, set_overrides, template_of,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -158,10 +160,12 @@ pub fn render_into(doc: &mut Document) -> Result<Report, XfaError> {
|
||||
if packets.xdp.trim().is_empty() {
|
||||
return Err(XfaError::Malformed("the XFA packets hold no template".into()));
|
||||
}
|
||||
let (tpl, parse_warnings) = parse(&packets.xdp)?;
|
||||
let (mut tpl, mut parse_warnings) = parse(&packets.xdp)?;
|
||||
parse_warnings.extend(linked_images::resolve(&mut tpl, doc));
|
||||
let data = parse_datasets(&packets.xdp);
|
||||
let laid = apply_overrides(&tpl, &overrides(doc));
|
||||
let mut form = layout(&laid, data.as_ref())?;
|
||||
let ov = overrides(doc);
|
||||
let laid = apply_overrides(&tpl, &ov);
|
||||
let mut form = layout::layout_with_overrides(&laid, data.as_ref(), &ov)?;
|
||||
form.warnings.splice(0..0, parse_warnings);
|
||||
if form.pages.is_empty() {
|
||||
return Err(XfaError::Malformed("the template laid out to no pages".into()));
|
||||
@@ -176,5 +180,11 @@ pub fn render_into(doc: &mut Document) -> Result<Report, XfaError> {
|
||||
Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod repeat_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod table_repeat_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Pictures a template links instead of embedding (`<image href="…">`). Since XFA 2.6 a form
|
||||
//! keeps them in its PDF: each image file is a stream holding the file's bytes, named in the
|
||||
//! `XFAImages` name tree of the catalog's `Names` dictionary by the URI the template writes (XFA
|
||||
//! 3.3 pp. 257–258, "Storing Images Inside PDF"). Only that tree is looked in: as the spec warns,
|
||||
//! a form that isn't trusted must not have its links followed outside the file.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object};
|
||||
|
||||
use crate::model::{Field, Node, Subform, Template, Value};
|
||||
|
||||
/// Most nodes of the name tree visited, and most names kept from it.
|
||||
const MAX_TREE_NODES: usize = 4096;
|
||||
const MAX_NAMES: usize = 4096;
|
||||
/// Most name-and-value pairs looked at, a name repeated counted each time (only a new name is
|
||||
/// kept, so [`MAX_NAMES`] alone doesn't end a `Names` array that repeats one).
|
||||
const MAX_PAIRS: usize = 4 * MAX_NAMES;
|
||||
/// Longest name kept, in bytes: a template's link is a short path or URI, and a longer name
|
||||
/// could only be matched by an equally long link.
|
||||
const MAX_NAME_LEN: usize = 4096;
|
||||
/// Deepest the name tree is followed.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
/// Largest picture taken from the file, as for a picture written into the template. Small in
|
||||
/// tests, so a test can pass it.
|
||||
#[cfg(not(test))]
|
||||
const MAX_IMAGE: usize = 32 << 20;
|
||||
#[cfg(test)]
|
||||
const MAX_IMAGE: usize = 512;
|
||||
/// Most bytes of pictures taken from the file in all, each place a picture is used counted (a
|
||||
/// picture is held once per place). Small in tests, so a test can reach it.
|
||||
#[cfg(not(test))]
|
||||
const MAX_TOTAL: usize = 128 << 20;
|
||||
#[cfg(test)]
|
||||
const MAX_TOTAL: usize = 1 << 10;
|
||||
/// Deepest the template is walked.
|
||||
const MAX_TEMPLATE_DEPTH: usize = 256;
|
||||
|
||||
/// `f` of `o`, a reference followed. A direct object is lent as it is, not copied: a tree's
|
||||
/// arrays can be as long as the file allows.
|
||||
fn with_resolved<R>(doc: &Document, o: &Object, f: impl FnOnce(&Object) -> R) -> R {
|
||||
match o {
|
||||
Object::Ref(r) => f(&doc.get(*r)),
|
||||
other => f(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `XFAImages` name tree: each name, as text, and the object it names.
|
||||
fn names(doc: &Document) -> HashMap<String, Object> {
|
||||
let mut walk = Walk { doc, out: HashMap::new(), seen: HashSet::new(), visited: 0, pairs: 0 };
|
||||
let Some(root) = doc.root() else { return walk.out };
|
||||
let catalog = doc.get(root);
|
||||
if let Some(names) = catalog.as_dict().and_then(|c| c.get(b"Names")) {
|
||||
with_resolved(doc, names, |n| {
|
||||
if let Some(tree) = n.as_dict().and_then(|d| d.get(b"XFAImages")) {
|
||||
walk.node(tree, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
walk.out
|
||||
}
|
||||
|
||||
/// A walk of the name tree, borrowing each node from its resolved owner: nothing of the tree
|
||||
/// is copied but the names kept, and only the branch being walked is held.
|
||||
struct Walk<'a> {
|
||||
doc: &'a Document,
|
||||
out: HashMap<String, Object>,
|
||||
seen: HashSet<ObjRef>,
|
||||
visited: usize,
|
||||
/// Pairs looked at so far, repeats included.
|
||||
pairs: usize,
|
||||
}
|
||||
|
||||
impl Walk<'_> {
|
||||
fn full(&self) -> bool {
|
||||
self.visited >= MAX_TREE_NODES || self.out.len() >= MAX_NAMES || self.pairs >= MAX_PAIRS
|
||||
}
|
||||
|
||||
fn node(&mut self, node: &Object, depth: usize) {
|
||||
// Too deep: this branch is left, the others are still walked.
|
||||
if self.full() || depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
self.visited += 1;
|
||||
if let Some(r) = node.as_ref()
|
||||
&& !self.seen.insert(r)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let doc = self.doc;
|
||||
with_resolved(doc, node, |node| {
|
||||
let Some(d) = node.as_dict() else { return };
|
||||
if let Some(pairs) = d.get(b"Names") {
|
||||
with_resolved(doc, pairs, |pairs| self.pairs(pairs));
|
||||
}
|
||||
if let Some(kids) = d.get(b"Kids") {
|
||||
with_resolved(doc, kids, |kids| {
|
||||
for kid in kids.as_array().map(Vec::as_slice).unwrap_or_default() {
|
||||
if self.full() {
|
||||
break;
|
||||
}
|
||||
self.node(kid, depth + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn pairs(&mut self, pairs: &Object) {
|
||||
let doc = self.doc;
|
||||
for pair in pairs.as_array().map(Vec::as_slice).unwrap_or_default().chunks(2) {
|
||||
// The names and pairs limits only: the node limit ends the walk at the next node, not
|
||||
// the names of the node it has just come to.
|
||||
if self.out.len() >= MAX_NAMES || self.pairs >= MAX_PAIRS {
|
||||
break;
|
||||
}
|
||||
self.pairs += 1;
|
||||
// An over-long name is left before it is turned into text.
|
||||
if let [key, value] = pair
|
||||
&& let Some(key) = with_resolved(doc, key, |k| k.as_string().filter(|s| s.bytes.len() <= MAX_NAME_LEN).map(|s| s.to_text()))
|
||||
{
|
||||
self.out.entry(key).or_insert_with(|| value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pictures found so far by their link, read from the file once each.
|
||||
struct Found<'a> {
|
||||
doc: &'a Document,
|
||||
names: HashMap<String, Object>,
|
||||
read: HashMap<String, Option<Vec<u8>>>,
|
||||
/// Bytes of the pictures in `read`, and of the copies put in places: each at most
|
||||
/// [`MAX_TOTAL`], however many names the tree holds.
|
||||
kept: usize,
|
||||
total: usize,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl Found<'_> {
|
||||
/// A copy of the picture linked as `href` for one more place, when the file holds it and the
|
||||
/// pictures kept and the copies so far leave room for it under [`MAX_TOTAL`].
|
||||
fn get(&mut self, href: &str) -> Option<Vec<u8>> {
|
||||
if !self.read.contains_key(href) {
|
||||
// Past the total, a picture not read yet is neither read nor kept.
|
||||
let data = if self.kept >= MAX_TOTAL { None } else { self.load(href) };
|
||||
let data = match data {
|
||||
Some(d) if self.kept.saturating_add(d.len()) > MAX_TOTAL => {
|
||||
self.over_total();
|
||||
None
|
||||
}
|
||||
Some(d) => {
|
||||
self.kept = self.kept.saturating_add(d.len());
|
||||
Some(d)
|
||||
}
|
||||
None => {
|
||||
if self.kept >= MAX_TOTAL && self.names.contains_key(href) {
|
||||
self.over_total();
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
self.read.insert(href.to_string(), data);
|
||||
}
|
||||
let len = self.read.get(href).and_then(Option::as_ref).map(Vec::len)?;
|
||||
if self.total.saturating_add(len) > MAX_TOTAL {
|
||||
self.over_total();
|
||||
return None;
|
||||
}
|
||||
self.total = self.total.saturating_add(len);
|
||||
self.read.get(href).cloned().flatten()
|
||||
}
|
||||
|
||||
fn over_total(&mut self) {
|
||||
self.warnings.push("the pictures the form keeps in the file take too much memory in all; the rest are left out".into());
|
||||
}
|
||||
|
||||
/// Bytes of the pictures read so far and kept for reuse.
|
||||
#[cfg(test)]
|
||||
fn kept(&self) -> usize {
|
||||
self.read.values().flatten().map(Vec::len).sum()
|
||||
}
|
||||
|
||||
fn load(&mut self, href: &str) -> Option<Vec<u8>> {
|
||||
let object = self.names.get(href)?;
|
||||
let object = self.doc.resolve(object);
|
||||
let Object::Stream(s) = &*object else { return None };
|
||||
let data = match s.decoded_within(MAX_IMAGE) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
self.warnings.push(format!("a picture the form keeps in the file ({href}) could not be read; it is left out"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(data)
|
||||
}
|
||||
|
||||
fn value(&mut self, v: &mut Value) {
|
||||
if let Value::Image { data, href: Some(href), .. } = v
|
||||
&& data.is_empty()
|
||||
&& let Some(found) = self.get(href)
|
||||
{
|
||||
*data = found;
|
||||
}
|
||||
}
|
||||
|
||||
fn field(&mut self, f: &mut Field) {
|
||||
self.value(&mut f.value);
|
||||
if let Some(c) = f.caption.as_mut() {
|
||||
self.value(&mut c.value);
|
||||
}
|
||||
}
|
||||
|
||||
fn nodes(&mut self, nodes: &mut [Node], depth: usize) {
|
||||
if depth > MAX_TEMPLATE_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in nodes {
|
||||
match n {
|
||||
Node::Subform(s) => self.subform(s, depth + 1),
|
||||
Node::Area(a) => self.nodes(&mut a.children, depth + 1),
|
||||
Node::Field(f) => self.field(f),
|
||||
Node::Draw(d) => self.value(&mut d.value),
|
||||
Node::ExclGroup(g) => g.fields.iter_mut().for_each(|f| self.field(f)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subform(&mut self, s: &mut Subform, depth: usize) {
|
||||
if let Some(set) = s.page_set.as_mut() {
|
||||
for area in &mut set.areas {
|
||||
self.nodes(&mut area.items, depth + 1);
|
||||
}
|
||||
}
|
||||
self.nodes(&mut s.children, depth);
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the pictures `tpl` links to (and doesn't embed) the data `doc` keeps for them. A link
|
||||
/// the file holds nothing for stays empty. What couldn't be read is returned as warnings.
|
||||
pub fn resolve(tpl: &mut Template, doc: &Document) -> Vec<String> {
|
||||
resolve_found(tpl, doc).map(|f| f.warnings).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn resolve_found<'a>(tpl: &mut Template, doc: &'a Document) -> Option<Found<'a>> {
|
||||
let names = names(doc);
|
||||
if names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut found = Found { doc, names, read: HashMap::new(), kept: 0, total: 0, warnings: Vec::new() };
|
||||
found.subform(&mut tpl.root, 0);
|
||||
found.warnings.dedup();
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// [`resolve`], and the bytes of pictures it held for reuse at the end.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn resolve_keeping(tpl: &mut Template, doc: &Document) -> (Vec<String>, usize) {
|
||||
resolve_found(tpl, doc).map_or((Vec::new(), 0), |f| (f.warnings.clone(), f.kept()))
|
||||
}
|
||||
@@ -326,6 +326,37 @@ impl Para {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an object is drawn: on screen, when the form is printed, or both (see
|
||||
/// [`Common::views`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Views {
|
||||
pub screen: bool,
|
||||
pub print: bool,
|
||||
}
|
||||
|
||||
impl Views {
|
||||
/// On screen and in print: what an object with nothing to say is.
|
||||
pub const ALL: Views = Views { screen: true, print: true };
|
||||
|
||||
/// Where an object inside a container drawn in `self` is drawn, when it alone would be drawn
|
||||
/// in `other`: only where both are.
|
||||
pub fn and(self, other: Views) -> Views {
|
||||
Views { screen: self.screen && other.screen, print: self.print && other.print }
|
||||
}
|
||||
|
||||
/// Drawn somewhere.
|
||||
pub fn any(self) -> bool {
|
||||
self.screen || self.print
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Views {
|
||||
/// On screen and in print.
|
||||
fn default() -> Self {
|
||||
Views::ALL
|
||||
}
|
||||
}
|
||||
|
||||
/// What every container and leaf shares: name, box, presence, margins, border, text defaults.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Common {
|
||||
@@ -344,12 +375,43 @@ pub struct Common {
|
||||
pub min_h: Option<f64>,
|
||||
pub max_h: Option<f64>,
|
||||
pub presence: Presence,
|
||||
/// What `relevant` says about printing: `Some(true)` for `+print` (printed even when its
|
||||
/// presence hides it on screen), `Some(false)` for `-print` (never printed), `None` when it
|
||||
/// says nothing (printed as it is shown).
|
||||
pub relevant: Option<bool>,
|
||||
/// The first `relevant` token about a view other than print, which is left aside.
|
||||
pub relevant_other: Option<String>,
|
||||
pub margin: Margin,
|
||||
pub border: Option<Border>,
|
||||
pub font: FontSpec,
|
||||
pub para: ParaSpec,
|
||||
/// Table cells: how many columns this cell spans.
|
||||
pub col_span: usize,
|
||||
/// The object's own attributes and its property elements as the template writes them, for
|
||||
/// scripts to read (see [`Props`]). Shared: the live form's instances of the object and
|
||||
/// copies of the template point at the same tree.
|
||||
pub props: std::sync::Arc<Props>,
|
||||
}
|
||||
|
||||
/// What scripts read of an object's properties (XFA 3.3 ch. 3, "Properties vs. Children",
|
||||
/// p. 78): the object's own attributes and the property elements the template gives it (border,
|
||||
/// font, para, margin, ui, validate, occur, break, assist, format, value, bind, desc, extras, keep),
|
||||
/// as written. Properties the template leaves out are not here: the scripting host supplies
|
||||
/// their defaults.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Props {
|
||||
pub attrs: Vec<(String, String)>,
|
||||
pub elements: Vec<Prop>,
|
||||
}
|
||||
|
||||
/// One property element: its tag, attributes, text (for elements that hold text, such as
|
||||
/// `picture` or `toolTip`) and its own property elements.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Prop {
|
||||
pub tag: String,
|
||||
pub attrs: Vec<(String, String)>,
|
||||
pub text: Option<std::sync::Arc<str>>,
|
||||
pub children: Vec<Prop>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -362,6 +424,14 @@ pub enum Node {
|
||||
}
|
||||
|
||||
impl Common {
|
||||
/// Where the object itself is drawn, before what its containers say: on screen when it is
|
||||
/// visible, and in print when `relevant` says so (`+print` even when hidden or invisible,
|
||||
/// `-print` never), else when it is visible.
|
||||
pub fn views(&self) -> Views {
|
||||
let screen = self.presence == Presence::Visible;
|
||||
Views { screen, print: self.relevant.unwrap_or(screen) }
|
||||
}
|
||||
|
||||
/// The top-left corner of a positioned box of `w` by `h`, from `x`, `y` and the anchor.
|
||||
pub fn top_left(&self, w: f64, h: f64) -> (f64, f64) {
|
||||
(self.x.unwrap_or(0.0) - w * self.anchor.h, self.y.unwrap_or(0.0) - h * self.anchor.v)
|
||||
@@ -451,6 +521,9 @@ pub struct Subform {
|
||||
/// `<breakBefore targetType="pageArea">`: start on a new page.
|
||||
pub break_before_page: bool,
|
||||
pub break_after_page: bool,
|
||||
/// Parsed from a `subformSet`: transparent to layout (XFA 3.3 p. 357), so in a flowing
|
||||
/// container its members are laid out among their parent's other children.
|
||||
pub set: bool,
|
||||
/// Tables: the width of each column.
|
||||
pub column_widths: Vec<f64>,
|
||||
/// `<overflow leader="…">`: the named child (a header row) is repeated after a page break.
|
||||
@@ -472,6 +545,8 @@ pub struct ExclGroup {
|
||||
pub fields: Vec<Field>,
|
||||
pub tooltip: Option<String>,
|
||||
pub scripts: Vec<Script>,
|
||||
/// One of the buttons must be chosen (`<validate nullTest="error">` on the group).
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
@@ -532,6 +607,8 @@ pub struct Field {
|
||||
/// The field doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for one
|
||||
/// line, `vScrollPolicy="off"` for several): it takes no more text than it shows.
|
||||
pub no_scroll: bool,
|
||||
/// The field must be filled in (`<validate nullTest="error">`).
|
||||
pub required: bool,
|
||||
pub caption: Option<Caption>,
|
||||
pub value: Value,
|
||||
/// Check buttons: the on value then the off value; choice lists: the items as shown.
|
||||
@@ -562,6 +639,10 @@ pub struct Field {
|
||||
/// The display picture clause (`<format><picture>`), e.g. `date{YYYYMMDD}`: how the value
|
||||
/// shows when not being edited, and what scripts read as `formattedValue`.
|
||||
pub display_picture: Option<String>,
|
||||
/// Its bind shares one data node among every field that names it: `match="global"`, or an
|
||||
/// explicit data reference (`ref`). On a page area, such a field shows the same value on each
|
||||
/// page made from it (XFA 3.3, "Page Background").
|
||||
pub data_shared: bool,
|
||||
/// `<keep intact="contentArea">` or `"pageArea"`: a page break doesn't split the field (XFA 3.3
|
||||
/// p. 292). A field's default is `none`.
|
||||
pub keep_intact: bool,
|
||||
@@ -631,6 +712,9 @@ pub enum Value {
|
||||
Image {
|
||||
content_type: String,
|
||||
data: Vec<u8>,
|
||||
/// Where the template links the picture from (`href`), when it does: its data is then
|
||||
/// found in the PDF ([`crate::linked_images`]).
|
||||
href: Option<String>,
|
||||
},
|
||||
/// A diagonal line through the box: `\` (top-left to bottom-right) or `/`.
|
||||
Line {
|
||||
@@ -776,6 +860,41 @@ pub struct Template {
|
||||
/// The data paths of those fields, by name alone (any instance of a repeating subform), as
|
||||
/// a script writes them: a write there changes text shown elsewhere.
|
||||
pub embedded_paths: std::collections::BTreeSet<Vec<String>>,
|
||||
/// What a user of the template keeps for as long as it lives (see [`TemplateCache`]).
|
||||
pub cache: TemplateCache,
|
||||
}
|
||||
|
||||
/// Room for one value a user of a template keeps alongside it for as long as the template
|
||||
/// lives, and drops with it (the engine keeps the script engine's copies of the property trees
|
||||
/// here, so they go when the form is closed). Not part of the template: a clone starts empty,
|
||||
/// and two caches always compare equal.
|
||||
#[derive(Default)]
|
||||
pub struct TemplateCache(std::sync::OnceLock<Box<dyn std::any::Any + Send + Sync>>);
|
||||
|
||||
impl TemplateCache {
|
||||
/// The value kept here, made by `make` the first time; `None` when a value of another type
|
||||
/// was kept first.
|
||||
pub fn get_or_init<T: std::any::Any + Send + Sync>(&self, make: impl FnOnce() -> T) -> Option<&T> {
|
||||
self.0.get_or_init(|| Box::new(make())).downcast_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for TemplateCache {
|
||||
fn clone(&self) -> Self {
|
||||
TemplateCache::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for TemplateCache {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TemplateCache {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("TemplateCache")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -6,10 +6,28 @@ use pdfcraft_cos::{Document, Object};
|
||||
use crate::XfaError;
|
||||
|
||||
/// Most decoded packet bytes read in total.
|
||||
const MAX_XDP: usize = 64 << 20;
|
||||
pub(crate) const MAX_XDP: usize = 64 << 20;
|
||||
/// Most bytes all filter stages of all packets may decode together.
|
||||
const MAX_DECODE_WORK: usize = 4 * MAX_XDP;
|
||||
|
||||
/// Most bytes of a template [`crate::parse`] takes, and of the text [`read_packets`] returns. A
|
||||
/// UTF-16 packet within [`MAX_XDP`] grows by at most half going over to UTF-8 (a two-byte unit
|
||||
/// becomes at most three bytes; a four-byte pair stays four), so it fits. Other text can grow
|
||||
/// more: each byte of a packet that isn't valid UTF-8 may become a three-byte replacement
|
||||
/// character, and the packets of an array are joined by newlines. `read_packets` holds what it
|
||||
/// assembles to this limit too, so a template the reader returns always parses.
|
||||
pub(crate) const MAX_TEMPLATE_TEXT: usize = MAX_XDP / 2 * 3;
|
||||
|
||||
/// `Err` once the XDP text assembled so far, `len` bytes, is past [`MAX_TEMPLATE_TEXT`].
|
||||
fn check_text(len: usize) -> Result<(), XfaError> {
|
||||
if len > MAX_TEMPLATE_TEXT {
|
||||
return Err(XfaError::TooLarge(format!(
|
||||
"the XFA packets come to {len} bytes of text, past the {MAX_TEMPLATE_TEXT} bytes the reader accepts"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What was found in the XFA entry.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Packets {
|
||||
@@ -146,6 +164,8 @@ pub fn read_packets(doc: &Document) -> Result<Option<Packets>, XfaError> {
|
||||
if let Some(o) = stream
|
||||
&& let Some(text) = read(o)?
|
||||
{
|
||||
// The text and its newline, checked before either is kept.
|
||||
check_text(xdp.len().saturating_add(text.len()).saturating_add(1))?;
|
||||
if name.as_deref() == Some("template") {
|
||||
template = Some(text.clone());
|
||||
}
|
||||
@@ -160,6 +180,7 @@ pub fn read_packets(doc: &Document) -> Result<Option<Packets>, XfaError> {
|
||||
}
|
||||
Object::Stream(_) => {
|
||||
xdp = read(&xfa)?.unwrap_or_default();
|
||||
check_text(xdp.len())?;
|
||||
}
|
||||
// Not a stream or an array: an XFA entry all the same, with nothing to lay out.
|
||||
_ => {}
|
||||
|
||||
+525
-64
@@ -7,8 +7,8 @@ use roxmltree::Node as XmlNode;
|
||||
use crate::XfaError;
|
||||
use crate::model::{
|
||||
Access, Anchor, Area, Border, Caption, ChoiceList, Color, Common, Corner, Draw, Edge, ExclGroup, Field, FontSpec, HAlign, Layout, Margin, Node,
|
||||
Occur, PageArea, PageRole, PageSet, ParaSpec, Paragraph, Placement, Presence, Rect, RichText, Run, Script, Stroke, Subform, Template, Ui, VAlign,
|
||||
Value,
|
||||
Occur, PageArea, PageRole, PageSet, ParaSpec, Paragraph, Placement, Presence, Prop, Props, Rect, RichText, Run, Script, Stroke, Subform,
|
||||
Template, Ui, VAlign, Value,
|
||||
};
|
||||
|
||||
/// Deepest element nesting followed (templates are shallow; hostile ones are not).
|
||||
@@ -140,26 +140,36 @@ fn parse_fill(n: Option<XmlNode>) -> Option<Color> {
|
||||
Some(parse_color(child(n, "color")).unwrap_or(Color::WHITE))
|
||||
}
|
||||
|
||||
/// `<border>`: edges in XFA order. One edge applies to all four; two: top/bottom then
|
||||
/// left/right; three: top, left/right, bottom; four: top, right, bottom, left.
|
||||
/// `<border>`: edges top, right, bottom, left (XFA 3.3 p. 38), the sides not listed drawn as
|
||||
/// readers draw them ([`drawn_side`]), not as p. 38's text says.
|
||||
fn parse_border(n: Option<XmlNode>) -> Option<Border> {
|
||||
let n = n?;
|
||||
let edges: Vec<Edge> = children(n, "edge").map(parse_edge).collect();
|
||||
let d = Edge::default();
|
||||
let e = |i: usize| edges.get(i).copied().unwrap_or(d);
|
||||
let four = match edges.len() {
|
||||
0 => [d; 4],
|
||||
1 => [e(0); 4],
|
||||
2 => [e(0), e(1), e(0), e(1)],
|
||||
3 => [e(0), e(1), e(2), e(1)],
|
||||
_ => [e(0), e(1), e(2), e(3)],
|
||||
};
|
||||
let edges: Vec<Edge> = children(n, "edge").take(4).map(parse_edge).collect();
|
||||
let four = std::array::from_fn(|i| drawn_side(edges.len(), i).and_then(|k| edges.get(k).copied()).unwrap_or_default());
|
||||
Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, corners: parse_corners(n), fill: parse_fill(child(n, "fill")) })
|
||||
}
|
||||
|
||||
/// Which of the `listed` edges (or corners) of a border is drawn on side `index`: edges go
|
||||
/// top, right, bottom, left and corners top-left, top-right, bottom-right, bottom-left (XFA 3.3
|
||||
/// p. 38). For a border that lists fewer than four, p. 38 says the last one listed is reused
|
||||
/// for the rest; readers draw a side not listed from the edge or corner on the same side
|
||||
/// instead, and so does this: one serves every side, two alternate (0, 1, 0, 1), three reuse
|
||||
/// the second for the fourth (0, 1, 2, 1). Edges already followed the readers; corners now do
|
||||
/// too. The scripting host's reads mirror the same pattern so a script reads what is drawn.
|
||||
/// `None` when none is listed.
|
||||
pub(crate) fn drawn_side(listed: usize, index: usize) -> Option<usize> {
|
||||
match listed {
|
||||
0 => None,
|
||||
_ if index < listed => Some(index),
|
||||
1 => Some(0),
|
||||
2 => Some(index % 2),
|
||||
_ => Some(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `<corner>` children of a border or rectangle, top-left, top-right, bottom-right then
|
||||
/// bottom-left: one applies to all four, and a corner not given is the last one given. Without
|
||||
/// any, the corners are square.
|
||||
/// bottom-left, the ones not listed drawn as [`drawn_side`] picks, as edges are. Without any,
|
||||
/// the corners are square.
|
||||
fn parse_corners(n: XmlNode) -> [Corner; 4] {
|
||||
let given: Vec<Corner> = children(n, "corner")
|
||||
.take(4)
|
||||
@@ -169,8 +179,7 @@ fn parse_corners(n: XmlNode) -> [Corner; 4] {
|
||||
inverted: c.attribute("inverted") == Some("1"),
|
||||
})
|
||||
.collect();
|
||||
let Some(last) = given.last().copied() else { return [Corner::default(); 4] };
|
||||
std::array::from_fn(|i| given.get(i).copied().unwrap_or(last))
|
||||
std::array::from_fn(|i| drawn_side(given.len(), i).and_then(|k| given.get(k).copied()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `<font>`: typeface, size, weight, posture, underline, fill color and horizontal scale.
|
||||
@@ -202,6 +211,25 @@ fn parse_para(n: Option<XmlNode>) -> ParaSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// `relevant`: a space-separated list of views, each `+name` (or `name`) or `-name`. What it says
|
||||
/// about printing (`Some(true)` for `+print`, `Some(false)` for `-print`; the last one counts), and
|
||||
/// the first token about any other view, which is left aside.
|
||||
fn parse_relevant(s: Option<&str>) -> (Option<bool>, Option<String>) {
|
||||
let (mut print, mut other) = (None, None);
|
||||
for token in s.unwrap_or_default().split_whitespace().take(64) {
|
||||
match token {
|
||||
"+print" | "print" => print = Some(true),
|
||||
"-print" => print = Some(false),
|
||||
_ => {
|
||||
if other.is_none() {
|
||||
other = Some(token.chars().take(64).collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(print, other)
|
||||
}
|
||||
|
||||
/// `rotate` as quarter turns (XFA 3.3: degrees counter-clockwise, a multiple of 90, `deg` optional):
|
||||
/// anything else leaves the object as it stands.
|
||||
fn quarter_turns(rotate: Option<&str>) -> u8 {
|
||||
@@ -212,8 +240,11 @@ fn quarter_turns(rotate: Option<&str>) -> u8 {
|
||||
turns.filter(|t| t.fract() == 0.0).map_or(0, |t| t as u8 % 4)
|
||||
}
|
||||
|
||||
fn parse_common(n: XmlNode) -> Common {
|
||||
/// What element `n` shares with every container and leaf ([`Common`]).
|
||||
fn parse_common(n: XmlNode, warnings: &mut Vec<String>) -> Common {
|
||||
let (relevant, relevant_other) = parse_relevant(n.attribute("relevant"));
|
||||
Common {
|
||||
props: std::sync::Arc::new(parse_props(n, warnings)),
|
||||
name: n.attribute("name").map(str::to_string).filter(|s| !s.is_empty()),
|
||||
id: n.attribute("id").map(str::to_string),
|
||||
x: attr_measure(n, "x"),
|
||||
@@ -227,6 +258,8 @@ fn parse_common(n: XmlNode) -> Common {
|
||||
min_h: attr_measure(n, "minH"),
|
||||
max_h: attr_measure(n, "maxH"),
|
||||
presence: Presence::parse(n.attribute("presence")),
|
||||
relevant,
|
||||
relevant_other,
|
||||
margin: parse_margin(child(n, "margin")),
|
||||
border: parse_border(child(n, "border")),
|
||||
font: parse_font(child(n, "font")),
|
||||
@@ -235,6 +268,247 @@ fn parse_common(n: XmlNode) -> Common {
|
||||
}
|
||||
}
|
||||
|
||||
/// The property elements of an object that scripts read through the object model (XFA 3.3
|
||||
/// ch. 17: border p. 629, font p. 741, para p. 800, margin p. 776, ui p. 877, validate p. 880,
|
||||
/// occur p. 786, break p. 632, breakBefore p. 637, breakAfter p. 635, assist p. 609, format
|
||||
/// p. 745, value p. 882, bind p. 623, desc p. 674, extras p. 724, keep p. 762, caption p. 643,
|
||||
/// items p. 758, calculate p. 641).
|
||||
const PROP_TAGS: &[&str] = &[
|
||||
"border",
|
||||
"font",
|
||||
"para",
|
||||
"margin",
|
||||
"ui",
|
||||
"validate",
|
||||
"occur",
|
||||
"break",
|
||||
"breakBefore",
|
||||
"breakAfter",
|
||||
"assist",
|
||||
"format",
|
||||
"value",
|
||||
"bind",
|
||||
"desc",
|
||||
"extras",
|
||||
"keep",
|
||||
"caption",
|
||||
"items",
|
||||
"calculate",
|
||||
];
|
||||
/// Most property elements kept for one object, all levels together. A field written by a form
|
||||
/// designer holds about 40 (four edges and corners with their colours, a fill, a font, a widget
|
||||
/// with its own border); past this the rest are left out and a warning says so.
|
||||
pub const MAX_OBJECT_PROPS: usize = 256;
|
||||
/// Most bytes of tags, attribute names, values and text kept for one object's properties: a
|
||||
/// designer's field holds well under 2 KiB.
|
||||
pub const MAX_OBJECT_PROP_BYTES: usize = 16 << 10;
|
||||
/// Deepest property element kept below an object (`ui > textEdit > border > edge > color` is 5).
|
||||
const MAX_PROP_DEPTH: usize = 8;
|
||||
/// Most attributes kept on one element.
|
||||
const MAX_PROP_ATTRS: usize = 64;
|
||||
/// Longest validation or calculation script kept for scripts to read, in bytes: the script
|
||||
/// engine's own limit on a script (`pdfcraft_js`'s `MAX_SCRIPT_BYTES`), past which it would not run anyway.
|
||||
const MAX_VALIDATE_SCRIPT_BYTES: usize = 256 * 1024;
|
||||
/// The warning when an object's properties passed a limit.
|
||||
pub const PROPS_CUT_WARNING: &str = "an object has more property elements, attributes or text than scripts are given; the rest are left out";
|
||||
|
||||
/// What is left of one object's property budget.
|
||||
struct PropBudget {
|
||||
elements: usize,
|
||||
bytes: usize,
|
||||
cut: bool,
|
||||
}
|
||||
|
||||
impl PropBudget {
|
||||
/// `s` when the bytes are left for it (charged), else `None` and the cut noted.
|
||||
fn text(&mut self, s: &str) -> Option<String> {
|
||||
match self.bytes.checked_sub(s.len()) {
|
||||
Some(left) => {
|
||||
self.bytes = left;
|
||||
Some(s.to_string())
|
||||
}
|
||||
None => {
|
||||
self.cut = true;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The attributes of `n`, to [`MAX_PROP_ATTRS`] and while the bytes last.
|
||||
fn attrs(&mut self, n: XmlNode) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for a in n.attributes() {
|
||||
if out.len() >= MAX_PROP_ATTRS {
|
||||
self.cut = true;
|
||||
break;
|
||||
}
|
||||
let Some(name) = self.text(a.name()) else { break };
|
||||
let Some(value) = self.text(a.value()) else { break };
|
||||
out.push((name, value));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Most `<items>` of one object kept as lists ([`items_prop`]): a field has two at most, the
|
||||
/// shown list and the saved values (XFA 3.3 p. 726, `<items/> [0..2]`), the two the field's own
|
||||
/// lists are read from. Forms do carry more (one public form has a third, empty, saved list);
|
||||
/// any further one is kept as an ordinary property, every entry charged to the object's budget.
|
||||
const MAX_ITEM_LISTS: usize = 2;
|
||||
|
||||
/// An `<items>` element (XFA 3.3 p. 758) as scripts read it: its attributes and its entries,
|
||||
/// each with its tag and text, as the choice list or check button shows them ([`parse_items`]:
|
||||
/// the same [`MAX_ITEMS`] and the same text), so a script reads what the list holds. The
|
||||
/// element and its attributes are charged to the object's budget like any property; its
|
||||
/// entries are not (a list of a few hundred countries would otherwise crowd out every other
|
||||
/// property), so they are bounded by the list's own limits instead: [`MAX_ITEMS`] entries of
|
||||
/// at most [`MAX_TEXT`] each, in at most [`MAX_ITEM_LISTS`] lists, no more than the field's
|
||||
/// own lists hold. An entry's attributes are not kept: scripts read an entry's value.
|
||||
fn items_prop(n: XmlNode, b: &mut PropBudget) -> Option<Prop> {
|
||||
if b.elements == 0 {
|
||||
b.cut = true;
|
||||
return None;
|
||||
}
|
||||
b.elements -= 1;
|
||||
let tag = b.text(n.tag_name().name())?;
|
||||
let attrs = b.attrs(n);
|
||||
let children = n
|
||||
.children()
|
||||
.filter(|c| c.is_element())
|
||||
.take(MAX_ITEMS)
|
||||
.map(|c| Prop { tag: c.tag_name().name().to_string(), attrs: Vec::new(), text: Some(std::sync::Arc::from(text_of(c))), children: Vec::new() })
|
||||
.collect();
|
||||
Some(Prop { tag, attrs, text: None, children })
|
||||
}
|
||||
|
||||
/// The property elements of `n` scripts read: its own attributes and the [`PROP_TAGS`]
|
||||
/// elements with what they hold, within [`MAX_OBJECT_PROPS`] elements,
|
||||
/// [`MAX_OBJECT_PROP_BYTES`] of text, [`MAX_PROP_DEPTH`] levels and [`MAX_PROP_ATTRS`]
|
||||
/// attributes an element; past them the rest is left out and [`PROPS_CUT_WARNING`] is added
|
||||
/// once. Only elements in the object's namespace are properties (XFA 3.3 p. 598); a `script` is
|
||||
/// read only in a `validate` or a `calculate`, and only its first (forms read its text, which
|
||||
/// is not charged to the budget but kept within [`MAX_VALIDATE_SCRIPT_BYTES`]), the first
|
||||
/// [`MAX_ITEM_LISTS`] `items` as lists (see [`items_prop`]), `extras` only as the object's own, the XHTML
|
||||
/// of `exData` not at all, and of `value` only its content element's tag and attributes (the
|
||||
/// data is the object's value, read elsewhere).
|
||||
fn parse_props(n: XmlNode, warnings: &mut Vec<String>) -> Props {
|
||||
/// The property elements under `n`, whose tag is `within` (empty for the object itself):
|
||||
/// `extras` in the object and inside one (forms keep named values there), `script` in a
|
||||
/// `validate` or a `calculate` (XFA 3.3 p. 880, p. 641, p. 827).
|
||||
fn elements<'a, 'd>(n: XmlNode<'a, 'd>, within: &str) -> impl Iterator<Item = XmlNode<'a, 'd>> + use<'a, 'd> {
|
||||
let (extras, script) = (within.is_empty() || within == "extras", matches!(within, "validate" | "calculate"));
|
||||
let ns = n.tag_name().namespace();
|
||||
n.children().filter(move |c| {
|
||||
c.is_element()
|
||||
&& c.tag_name().namespace() == ns
|
||||
&& match c.tag_name().name() {
|
||||
"script" => script,
|
||||
"extras" => extras,
|
||||
_ => true,
|
||||
}
|
||||
})
|
||||
}
|
||||
/// `n` as a property, `depth` below the object; `bare`: its tag and attributes only.
|
||||
fn prop(n: XmlNode, depth: usize, bare: bool, b: &mut PropBudget) -> Option<Prop> {
|
||||
if depth > MAX_PROP_DEPTH || b.elements == 0 {
|
||||
b.cut = true;
|
||||
return None;
|
||||
}
|
||||
b.elements -= 1;
|
||||
let tag = b.text(n.tag_name().name())?;
|
||||
let mut p = Prop { attrs: b.attrs(n), tag, text: None, children: Vec::new() };
|
||||
if bare {
|
||||
return Some(p);
|
||||
}
|
||||
if p.tag == "exData" {
|
||||
// Rich text: its words, as scripts read a caption's rich text (`exData.value`), not
|
||||
// its XHTML elements.
|
||||
let t = text_of(n);
|
||||
let t = t.trim();
|
||||
if !t.is_empty() {
|
||||
p.text = b.text(t).map(std::sync::Arc::from);
|
||||
}
|
||||
return Some(p);
|
||||
}
|
||||
if p.tag == "script" {
|
||||
// A validation's or calculation's script: its text is not charged to the budget, so a long script
|
||||
// leaves the other properties their room, but it is kept only within the script
|
||||
// engine's own limit on a script, past which it would not run anyway.
|
||||
let t = text_of(n);
|
||||
let t = t.trim();
|
||||
if t.len() > MAX_VALIDATE_SCRIPT_BYTES {
|
||||
b.cut = true;
|
||||
} else if !t.is_empty() {
|
||||
p.text = Some(std::sync::Arc::from(t));
|
||||
}
|
||||
return Some(p);
|
||||
}
|
||||
let mut kids = elements(n, p.tag.as_str()).peekable();
|
||||
if kids.peek().is_none() {
|
||||
// The element's own text only: not that of children left out (an `extras`, a
|
||||
// `script`), and no more than the budget holds.
|
||||
let mut own = String::new();
|
||||
for t in n.children().filter(|c| c.is_text()).filter_map(|c| c.text()) {
|
||||
if own.len().saturating_add(t.len()) > b.bytes {
|
||||
b.cut = true;
|
||||
own.clear();
|
||||
break;
|
||||
}
|
||||
own.push_str(t);
|
||||
}
|
||||
let t = own.trim();
|
||||
if !t.is_empty() {
|
||||
p.text = b.text(t).map(std::sync::Arc::from);
|
||||
}
|
||||
return Some(p);
|
||||
}
|
||||
// The object's own value holds its data, read elsewhere: of it only the content
|
||||
// element's tag. A caption's value is its label, and is read whole.
|
||||
let content_only = p.tag == "value" && depth == 1;
|
||||
// A validation or a calculation has one script (XFA 3.3 p. 880, p. 641: [0..1]); any
|
||||
// more are not read.
|
||||
let mut scripts = 0usize;
|
||||
for k in kids {
|
||||
if k.tag_name().name() == "script" {
|
||||
scripts = scripts.saturating_add(1);
|
||||
if scripts > 1 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match prop(k, depth + 1, content_only, b) {
|
||||
Some(c) => p.children.push(c),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
let mut b = PropBudget { elements: MAX_OBJECT_PROPS, bytes: MAX_OBJECT_PROP_BYTES, cut: false };
|
||||
let attrs = b.attrs(n);
|
||||
let mut out = Vec::new();
|
||||
// `desc` and `extras` last: they can hold the most (forms keep data there), so a cut falls
|
||||
// on them before the appearance and validation properties (their order means nothing,
|
||||
// XFA 3.3 p. 78).
|
||||
let bulky = |c: &XmlNode| matches!(c.tag_name().name(), "desc" | "extras");
|
||||
let first = elements(n, "").filter(|c| PROP_TAGS.contains(&c.tag_name().name()) && !bulky(c));
|
||||
let mut lists = 0usize;
|
||||
for c in first.chain(elements(n, "").filter(bulky)) {
|
||||
let p = if c.tag_name().name() == "items" && lists < MAX_ITEM_LISTS {
|
||||
lists += 1;
|
||||
items_prop(c, &mut b)
|
||||
} else {
|
||||
prop(c, 1, false, &mut b)
|
||||
};
|
||||
match p {
|
||||
Some(p) => out.push(p),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if b.cut && !warnings.iter().any(|w| w == PROPS_CUT_WARNING) {
|
||||
warnings.push(PROPS_CUT_WARNING.into());
|
||||
}
|
||||
Props { attrs, elements: out }
|
||||
}
|
||||
|
||||
/// Rich text: XHTML `body > p > span…`. `br` starts a new paragraph; `xfa:embed` spans become
|
||||
/// embedded fields; the text of an `<a href>` keeps where it links to.
|
||||
fn parse_rich(body: XmlNode) -> RichText {
|
||||
@@ -416,7 +690,8 @@ fn parse_value(n: Option<XmlNode>, warnings: &mut Vec<String>) -> Value {
|
||||
warnings.push("an image is larger than 32 MB; it is left out".into());
|
||||
return Value::Empty;
|
||||
}
|
||||
return Value::Image { content_type: c.attribute("contentType").unwrap_or("").to_string(), data };
|
||||
let href = c.attribute("href").map(str::trim).filter(|h| !h.is_empty()).map(str::to_string);
|
||||
return Value::Image { content_type: c.attribute("contentType").unwrap_or("").to_string(), data, href };
|
||||
}
|
||||
"line" => {
|
||||
return Value::Line { slope_up: c.attribute("slope") == Some("/"), edge: child(c, "edge").map(parse_edge).unwrap_or_default() };
|
||||
@@ -535,7 +810,7 @@ fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty());
|
||||
Field {
|
||||
common: parse_common(n),
|
||||
common: parse_common(n, warnings),
|
||||
ui: kind,
|
||||
ui_border: ui_el.and_then(|e| parse_border(child(e, "border"))),
|
||||
ui_margin: parse_margin(ui_el.and_then(|e| child(e, "margin"))),
|
||||
@@ -558,16 +833,25 @@ fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
|
||||
},
|
||||
picture: ui.and_then(|u| child(u, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
|
||||
data_picture: child(n, "bind").and_then(|b| child(b, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
|
||||
data_shared: child(n, "bind")
|
||||
.is_some_and(|b| b.attribute("match").is_some_and(|m| m.trim() == "global") || b.attribute("ref").is_some_and(|r| !r.trim().is_empty())),
|
||||
scripts,
|
||||
calculate: child(n, "calculate").and_then(|c| child(c, "script")).map(|s| parse_script(s, "calculate")),
|
||||
validate: validate_el.and_then(|v| child(v, "script")).map(|s| parse_script(s, "validate")),
|
||||
validate_message,
|
||||
required: must_be_filled(validate_el),
|
||||
numeric,
|
||||
display_picture: child(n, "format").and_then(|f| child(f, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
|
||||
keep_intact: child(n, "keep").and_then(|k| k.attribute("intact")).is_some_and(|v| v == "contentArea" || v == "pageArea"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `<validate>` element makes its object one that must be filled in: `nullTest="error"`
|
||||
/// (`warning` only recommends it, and `disabled`, the default, leaves it optional).
|
||||
fn must_be_filled(validate: Option<XmlNode>) -> bool {
|
||||
validate.is_some_and(|v| v.attribute("nullTest").is_some_and(|t| t.trim() == "error"))
|
||||
}
|
||||
|
||||
fn parse_script(s: XmlNode, activity: &str) -> Script {
|
||||
let ct = s.attribute("contentType").unwrap_or("");
|
||||
Script { activity: activity.to_string(), text: text_of(s).trim().to_string(), formcalc: !ct.to_ascii_lowercase().contains("javascript") }
|
||||
@@ -579,6 +863,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));
|
||||
}
|
||||
}
|
||||
@@ -630,7 +917,7 @@ fn parse_page_set(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> PageS
|
||||
if content.is_empty() {
|
||||
content.push(Rect::new(0.0, 0.0, width, height));
|
||||
}
|
||||
let items = pa.children().filter(|c| c.is_element()).filter_map(|c| parse_node(c, warnings, depth + 1)).collect();
|
||||
let items = pa.children().filter(|c| c.is_element()).filter_map(|c| parse_node(c, warnings, depth + 1, Layout::Positioned)).collect();
|
||||
// A page area with no `<occur>` has no limit and no minimum (a subform's default of one
|
||||
// would exhaust it, and demand a page for every master).
|
||||
let occur = child(pa, "occur").map(|o| parse_occur(Some(o)));
|
||||
@@ -646,15 +933,19 @@ fn parse_page_set(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> PageS
|
||||
PageSet { areas }
|
||||
}
|
||||
|
||||
fn parse_subform(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Subform {
|
||||
let common = parse_common(n);
|
||||
/// A `subform` element: its own attributes, its page set if it has one, and the nodes it holds,
|
||||
/// `depth` levels down (for the nesting limit). `set_layout` is the layout a `subformSet` takes
|
||||
/// on from its container, in place of its own.
|
||||
fn parse_subform(n: XmlNode, warnings: &mut Vec<String>, depth: usize, set_layout: Option<Layout>) -> Subform {
|
||||
let common = parse_common(n, warnings);
|
||||
let layout = set_layout.unwrap_or_else(|| Layout::parse(n.attribute("layout")));
|
||||
let mut children_out = Vec::new();
|
||||
let mut page_set = None;
|
||||
for c in n.children().filter(|c| c.is_element()) {
|
||||
match c.tag_name().name() {
|
||||
"pageSet" => page_set = Some(parse_page_set(c, warnings, depth + 1)),
|
||||
_ => {
|
||||
if let Some(node) = parse_node(c, warnings, depth + 1) {
|
||||
if let Some(node) = parse_node(c, warnings, depth + 1, layout) {
|
||||
children_out.push(node);
|
||||
}
|
||||
}
|
||||
@@ -667,7 +958,7 @@ fn parse_subform(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Subfor
|
||||
let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_some_and(|t| t == "pageArea" || t == "contentArea"));
|
||||
Subform {
|
||||
common,
|
||||
layout: Layout::parse(n.attribute("layout")),
|
||||
layout,
|
||||
children: children_out,
|
||||
scripts: parse_events(n),
|
||||
page_set,
|
||||
@@ -675,63 +966,79 @@ fn parse_subform(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Subfor
|
||||
break_before_page: break_to_page("breakBefore")
|
||||
|| child(n, "break").is_some_and(|b| b.attribute("before").is_some_and(|v| v == "pageArea" || v == "contentArea")),
|
||||
break_after_page: break_to_page("breakAfter"),
|
||||
set: false,
|
||||
column_widths,
|
||||
overflow_leader: child(n, "overflow").and_then(|o| o.attribute("leader")).map(str::to_string).filter(|s| !s.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_node(c: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Option<Node> {
|
||||
/// The template object element `c` stands for (a subform, area, field, draw or exclusion group,
|
||||
/// with what it holds), or `None` for anything else and past [`MAX_DEPTH`] levels. What can't be
|
||||
/// read faithfully is noted in `warnings`. `parent`: the layout of the container `c` is in, which
|
||||
/// a `subformSet` takes on.
|
||||
fn parse_node(c: XmlNode, warnings: &mut Vec<String>, depth: usize, parent: Layout) -> Option<Node> {
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
Some(match c.tag_name().name() {
|
||||
"subform" => Node::Subform(Box::new(parse_subform(c, warnings, depth))),
|
||||
"subform" => Node::Subform(Box::new(parse_subform(c, warnings, depth, None))),
|
||||
// A set of related subforms is transparent to layout (XFA 3.3 p. 357): its members are laid out
|
||||
// as its container lays out its own children, at their own positions in a positioned one. In a
|
||||
// table or a row they go one under another.
|
||||
"subformSet" => {
|
||||
// A set of alternative subforms: lay out its members in order.
|
||||
let mut sf = parse_subform(c, warnings, depth);
|
||||
sf.layout = Layout::Tb;
|
||||
Node::Subform(Box::new(sf))
|
||||
let layout = if matches!(parent, Layout::Table | Layout::Row) { Layout::Tb } else { parent };
|
||||
let mut set = parse_subform(c, warnings, depth, Some(layout));
|
||||
set.set = true;
|
||||
Node::Subform(Box::new(set))
|
||||
}
|
||||
"area" => Node::Area(Box::new(Area {
|
||||
common: parse_common(c),
|
||||
children: c.children().filter(|k| k.is_element()).filter_map(|k| parse_node(k, warnings, depth + 1)).collect(),
|
||||
common: parse_common(c, warnings),
|
||||
children: c.children().filter(|k| k.is_element()).filter_map(|k| parse_node(k, warnings, depth + 1, Layout::Positioned)).collect(),
|
||||
})),
|
||||
"field" => Node::Field(Box::new(parse_field(c, warnings))),
|
||||
"draw" => Node::Draw(Box::new(Draw { common: parse_common(c), value: parse_value(child(c, "value"), warnings) })),
|
||||
"draw" => Node::Draw(Box::new(Draw { common: parse_common(c, warnings), value: parse_value(child(c, "value"), warnings) })),
|
||||
"exclGroup" => Node::ExclGroup(Box::new(ExclGroup {
|
||||
common: parse_common(c),
|
||||
common: parse_common(c, warnings),
|
||||
layout: Layout::parse(c.attribute("layout")),
|
||||
fields: children(c, "field").map(|f| parse_field(f, warnings)).collect(),
|
||||
tooltip: child(c, "assist").and_then(|a| child(a, "toolTip")).map(text_of).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
scripts: parse_events(c),
|
||||
required: must_be_filled(child(c, "validate")),
|
||||
})),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 +1076,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;
|
||||
@@ -796,6 +1232,13 @@ fn collect_page_roles(nodes: &[Node], out: &mut Vec<(String, PageRole)>, depth:
|
||||
|
||||
/// Parse a template packet. Returns the template and the warnings met on the way.
|
||||
pub fn parse(xml: &str) -> Result<(Template, Vec<String>), XfaError> {
|
||||
// No bigger than a packet the reader accepts can come to as text ([`crate::read_packets`]), so
|
||||
// what parsing keeps of it (item lists are held by the field and its properties) stays within
|
||||
// a few times that, whoever calls it.
|
||||
let limit = crate::packets::MAX_TEMPLATE_TEXT;
|
||||
if xml.len() > limit {
|
||||
return Err(XfaError::TooLarge(format!("the template is {} bytes, past the {limit} bytes the reader accepts", xml.len())));
|
||||
}
|
||||
let depth = crate::xml_nesting_depth(xml);
|
||||
if depth > crate::MAX_XML_DEPTH {
|
||||
return Err(XfaError::Malformed(format!(
|
||||
@@ -803,8 +1246,8 @@ pub fn parse(xml: &str) -> Result<(Template, Vec<String>), XfaError> {
|
||||
crate::MAX_XML_DEPTH
|
||||
)));
|
||||
}
|
||||
let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES, ..Default::default() };
|
||||
let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| XfaError::Malformed(format!("template XML: {e}")))?;
|
||||
let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES, ..Default::default() };
|
||||
let doc = roxmltree::Document::parse_with_options(xml, opts()).map_err(|e| XfaError::Malformed(format!("template XML: {e}")))?;
|
||||
let root = doc.root_element();
|
||||
// The XDP's config packet has a <template> element too: the one we want is in the
|
||||
// xfa-template namespace, or at least has a subform in it.
|
||||
@@ -820,9 +1263,23 @@ pub fn parse(xml: &str) -> Result<(Template, Vec<String>), XfaError> {
|
||||
} else {
|
||||
root.children().find(is_template).ok_or_else(|| XfaError::Malformed("no <template> element".into()))?
|
||||
};
|
||||
let mut warnings = Vec::new();
|
||||
// Prototype references (`use`, `usehref`) are resolved first: the parser reads the template
|
||||
// written again with them merged in. The saved state below still comes from `doc`.
|
||||
let (expanded, mut warnings) = crate::proto::expand(template);
|
||||
let expanded_doc;
|
||||
let template = match expanded.as_deref().map(|x| roxmltree::Document::parse_with_options(x, opts())) {
|
||||
Some(Ok(d)) => {
|
||||
expanded_doc = d;
|
||||
expanded_doc.root_element()
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
warnings.push(format!("the template with its prototypes resolved could not be read again ({e}); it is read without them"));
|
||||
template
|
||||
}
|
||||
None => template,
|
||||
};
|
||||
let sf = children(template, "subform").next().ok_or_else(|| XfaError::Malformed("the template has no root subform".into()))?;
|
||||
let mut root_sf = parse_subform(sf, &mut warnings, 0);
|
||||
let mut root_sf = parse_subform(sf, &mut warnings, 0, None);
|
||||
if root_sf.page_set.is_none() {
|
||||
// A page set may sit beside the root subform.
|
||||
root_sf.page_set = child(template, "pageSet").map(|ps| parse_page_set(ps, &mut warnings, 0));
|
||||
@@ -842,7 +1299,7 @@ pub fn parse(xml: &str) -> Result<(Template, Vec<String>), XfaError> {
|
||||
collect_page_roles(&root_node, &mut page_roles, 0);
|
||||
let mut embedded = std::collections::BTreeSet::new();
|
||||
collect_embedded(&root_node, &mut embedded, 0);
|
||||
let mut tpl = Template { root: root_sf, page_roles, embedded, embedded_paths: Default::default() };
|
||||
let mut tpl = Template { root: root_sf, page_roles, embedded, embedded_paths: Default::default(), cache: Default::default() };
|
||||
let paths = crate::data::fields_with_ids(&tpl)
|
||||
.into_iter()
|
||||
.filter(|(id, _, _)| tpl.embedded.contains(*id))
|
||||
@@ -925,3 +1382,7 @@ fn apply_saved_state(nodes: &mut [Node], saved: XmlNode, depth: usize) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "props_tests.rs"]
|
||||
mod props_tests;
|
||||
|
||||
+503
-42
@@ -22,6 +22,7 @@ use common::{
|
||||
};
|
||||
pub(crate) use strip::USAGE_RIGHTS_REMOVED;
|
||||
use strip::drop_usage_rights;
|
||||
use strip::earlier_groups;
|
||||
pub use strip::{Stripped, XfaShape, existing_layout, strip, xfa_shape};
|
||||
|
||||
/// Private key on generated fields: the template's SOM path, so data can be put back.
|
||||
@@ -41,6 +42,11 @@ pub const DATE_KEY: &[u8] = b"PCXfaDate";
|
||||
/// fields they hold.
|
||||
pub const NODE_KEY: &[u8] = b"PCXfaNode";
|
||||
|
||||
/// Private key on each page laying the form out writes: the name of the page area the page was
|
||||
/// made from. The n-th page with a name is that area's `[n-1]` instance, which scripts address
|
||||
/// even when no field on the page is left to show it (see `page_area_uses`).
|
||||
pub const AREA_KEY: &[u8] = b"PCXfaArea";
|
||||
|
||||
pub struct Written {
|
||||
pub pages: usize,
|
||||
pub fields: usize,
|
||||
@@ -53,6 +59,15 @@ pub struct Written {
|
||||
/// layer reads this key).
|
||||
pub const IMAGE_FIELD_KEY: &[u8] = b"PCXfaImage";
|
||||
|
||||
/// On an optional content group PdfCraft made for what a form shows only on screen (`/Screen`)
|
||||
/// or only in print (`/Print`): a later layout of the form finds and reuses it.
|
||||
pub const RELEVANT_KEY: &[u8] = b"PCXfaRelevant";
|
||||
|
||||
/// The optional content groups of [`RELEVANT_KEY`], by index: what the key says, the group's
|
||||
/// resource name on the pages, its `/Name`, and its view and print states.
|
||||
const GROUPS: [(&str, &str, &str, &str, &str); 2] =
|
||||
[("Screen", "PCXfaScreen", "Form: screen only", "ON", "OFF"), ("Print", "PCXfaPrint", "Form: print only", "OFF", "ON")];
|
||||
|
||||
/// On a check box or radio button generated for a round XFA check button (`shape="round"`): its
|
||||
/// background and border are drawn as a circle, not the widget's rectangle (the forms layer
|
||||
/// reads this key).
|
||||
@@ -80,6 +95,11 @@ struct Emitter<'a> {
|
||||
warnings: Vec<String>,
|
||||
/// The signatures the file holds, by the SOM path of the field signed.
|
||||
signed: HashMap<String, Signed>,
|
||||
/// The screen-only and print-only optional content groups ([`GROUPS`]): an earlier layout's,
|
||||
/// or made when first needed.
|
||||
groups: [Option<ObjRef>; 2],
|
||||
/// Which of [`Self::groups`] this layout drew in.
|
||||
used: [bool; 2],
|
||||
/// The full names of the fields the file came with, which a generated name must not repeat.
|
||||
taken: HashSet<String>,
|
||||
/// The fields above the signature fields named by their SOM paths, by the path they name:
|
||||
@@ -95,10 +115,12 @@ struct Signed {
|
||||
appearance: Option<Object>,
|
||||
}
|
||||
|
||||
/// Most fields looked at for signatures.
|
||||
/// Most fields looked at for signatures, counted as they are queued.
|
||||
const MAX_FIELDS_SEARCHED: usize = 20_000;
|
||||
/// Deepest field nesting followed.
|
||||
const MAX_FIELD_DEPTH: usize = 32;
|
||||
/// Most `/Fields` and `/Kids` entries read while looking, repeats included.
|
||||
const MAX_KIDS_READ: usize = 1 << 20;
|
||||
|
||||
/// The fields the document came with: the full name of each, and the signed signature fields by
|
||||
/// SOM path. Signing applications name a dynamic form's fields by their SOM path
|
||||
@@ -110,22 +132,18 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap<String, Signed>,
|
||||
let mut names = HashSet::new();
|
||||
let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return (out, names) };
|
||||
let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return (out, names) };
|
||||
let mut walk = FieldQueue::default();
|
||||
// The accumulated name is shared by the children it is queued for, not copied into each.
|
||||
let mut stack: Vec<(Object, std::rc::Rc<str>, bool, usize)> =
|
||||
fields.as_array().into_iter().flatten().map(|f| (f.clone(), "".into(), false, 0)).collect();
|
||||
let mut seen = 0usize;
|
||||
while let Some((field, prefix, parent_is_sig, depth)) = stack.pop() {
|
||||
seen += 1;
|
||||
if seen > MAX_FIELDS_SEARCHED {
|
||||
break;
|
||||
}
|
||||
let field = doc.resolve(&field);
|
||||
let top: std::rc::Rc<str> = "".into();
|
||||
walk.queue(fields.as_array().map(Vec::as_slice).unwrap_or_default(), &top, false, 0);
|
||||
while let Some((field, prefix, parent_is_sig, depth)) = walk.stack.pop() {
|
||||
let field = doc.get(field);
|
||||
let Some(d) = field.as_dict() else { continue };
|
||||
if d.contains(NODE_KEY) {
|
||||
// Generated to name a signature field: only what it holds is looked at.
|
||||
if depth < MAX_FIELD_DEPTH {
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
stack.extend(kids.into_iter().map(|k| (k, "".into(), false, depth + 1)));
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k));
|
||||
walk.queue(kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), &top, false, depth + 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -151,30 +169,134 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap<String, Signed>,
|
||||
Some(ft) => ft == b"Sig",
|
||||
None => parent_is_sig,
|
||||
};
|
||||
let kids: Vec<Object> = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k));
|
||||
let kids = kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
if is_sig && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) {
|
||||
// The appearance is on the field when it is its own widget, or on its one widget.
|
||||
let appearance = d.get(b"AP").cloned().or_else(|| kids.iter().find_map(|k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned())));
|
||||
let appearance = d.get(b"AP").cloned().or_else(|| walk.find_map(kids, |k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned())));
|
||||
out.entry(name.to_string()).or_insert(Signed { value: value.clone(), appearance });
|
||||
}
|
||||
if depth < MAX_FIELD_DEPTH {
|
||||
stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1)));
|
||||
walk.queue(kids, &name, is_sig, depth + 1);
|
||||
}
|
||||
}
|
||||
(out, names)
|
||||
}
|
||||
|
||||
/// Required (`Ff` bit 2), which viewers outline when they highlight a form's fields, for widget
|
||||
/// `w` when it must be filled in. A radio button's flags are its group's (the group takes it,
|
||||
/// [`Emitter::radio_group`]), and a push button keeps no value (ISO 32000-2 §12.7.5.2.2), so
|
||||
/// there is nothing it could be required to hold.
|
||||
fn required_flag(w: &Widget) -> i64 {
|
||||
if w.required && !matches!(w.kind, WidgetKind::Radio { .. } | WidgetKind::Button { .. }) { 1 << 1 } else { 0 }
|
||||
}
|
||||
|
||||
/// The fields [`existing_fields`] still has to look at: each with its parent's full name,
|
||||
/// whether it inherits a signature type, and its depth.
|
||||
#[derive(Default)]
|
||||
struct FieldQueue {
|
||||
stack: Vec<(ObjRef, std::rc::Rc<str>, bool, usize)>,
|
||||
/// The shallowest depth each object number was queued at: [`Document::get`] finds a field
|
||||
/// by number alone, so `5 0 R` and `5 1 R` are the same field.
|
||||
seen: HashMap<u32, usize>,
|
||||
/// Entries pushed onto `stack`, a field queued again at a shallower depth included.
|
||||
pushed: usize,
|
||||
/// `/Fields` and `/Kids` entries read while queuing.
|
||||
read: usize,
|
||||
/// `/Kids` entries read while searching for a signed field's appearance.
|
||||
searched: usize,
|
||||
}
|
||||
|
||||
impl FieldQueue {
|
||||
/// The first of `kids` for which `f` gives a value. At most [`MAX_KIDS_READ`] entries are
|
||||
/// looked at in all: signed fields sharing one long `/Kids` would otherwise each search all
|
||||
/// of it. The searches have a budget of their own, apart from the one for queuing, so a
|
||||
/// search can never leave a field's kids unqueued. Past the limit nothing more is looked at.
|
||||
fn find_map<T>(&mut self, kids: &[Object], mut f: impl FnMut(&Object) -> Option<T>) -> Option<T> {
|
||||
for k in kids {
|
||||
self.searched += 1;
|
||||
if self.searched > MAX_KIDS_READ {
|
||||
return None;
|
||||
}
|
||||
if let Some(v) = f(k) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Queue the fields `kids` lists, each the first time it is listed. Fields and kids are
|
||||
/// indirect references (ISO 32000-2 §12.7.3.1); anything else is skipped. A field listed
|
||||
/// again, by this list or another, under any generation, takes no room, unless it is now
|
||||
/// shallower than where it was queued: first reached at [`MAX_FIELD_DEPTH`], its kids would
|
||||
/// otherwise never be read. So a field is queued at most once per depth, and at most
|
||||
/// [`MAX_FIELDS_SEARCHED`] entries are ever queued: a field listing one kid many times, or
|
||||
/// fields sharing one long `/Kids`, would otherwise queue it all on every visit. Reading the
|
||||
/// repeats costs too, so at most [`MAX_KIDS_READ`] entries are read in all.
|
||||
fn queue(&mut self, kids: &[Object], prefix: &std::rc::Rc<str>, is_sig: bool, depth: usize) {
|
||||
for k in kids {
|
||||
self.read += 1;
|
||||
if self.pushed >= MAX_FIELDS_SEARCHED || self.read > MAX_KIDS_READ {
|
||||
return;
|
||||
}
|
||||
let Some(r) = k.as_ref() else { continue };
|
||||
if self.seen.get(&r.num).is_none_or(|&queued| depth < queued) {
|
||||
self.seen.insert(r.num, depth);
|
||||
self.pushed += 1;
|
||||
self.stack.push((r, std::rc::Rc::clone(prefix), is_sig, depth));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Emitter<'_> {
|
||||
fn da(face: &Face) -> Vec<u8> {
|
||||
format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes()
|
||||
}
|
||||
|
||||
fn populate_widget_kind(&mut self, w: &Widget, d: &mut Dict, mk: &mut Dict) -> (i64, bool, Option<String>) {
|
||||
/// The field of radio group `group`, which button `w` is a kid of: the one an earlier button
|
||||
/// made (required too when `w` must be chosen), or a new field of the form, with `w`'s text
|
||||
/// look and the group's SOM path (the button's without its last step).
|
||||
fn radio_group(&mut self, w: &Widget, group: &str) -> Result<ObjRef, XfaError> {
|
||||
if let Some((p, _)) = self.radio_groups.get(group) {
|
||||
let p = *p;
|
||||
// A later button that must be chosen makes the group required too.
|
||||
if w.required {
|
||||
self.doc.update_dict(p, |g| {
|
||||
let ff = g.int(b"Ff").unwrap_or(0);
|
||||
g.set(b"Ff".to_vec(), Object::Int(ff | (1 << 1)));
|
||||
})?;
|
||||
}
|
||||
return Ok(p);
|
||||
}
|
||||
let mut g = Dict::new();
|
||||
g.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
let required = if w.required { 1 << 1 } else { 0 };
|
||||
g.set(b"Ff".to_vec(), Object::Int((1 << 15) | (1 << 14) | required));
|
||||
g.set(b"T".to_vec(), Object::String(PdfString::text(group)));
|
||||
g.set(b"V".to_vec(), Object::name("Off"));
|
||||
g.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face))));
|
||||
g.set(b"Kids".to_vec(), Object::Array(Vec::new()));
|
||||
let group_som = w.som.rsplit_once('.').map(|(g, _)| g.to_string()).unwrap_or_else(|| w.som.clone());
|
||||
g.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&group_som)));
|
||||
let p = self.doc.add(Object::Dict(g));
|
||||
self.radio_groups.insert(group.to_string(), (p, Vec::new()));
|
||||
self.fields.push(p);
|
||||
self.field_count += 1;
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Fill in widget `w`'s field by its kind (field type, flags, value, appearance states, items,
|
||||
/// action) in `d` and `mk`. Returns its field flags, whether it is a top-level field (not a kid
|
||||
/// of its radio group's field, nor a signature field under the fields generated for its SOM
|
||||
/// path), and for such a signature field the SOM path of the generated field above it.
|
||||
fn populate_widget_kind(&mut self, w: &Widget, d: &mut Dict, mk: &mut Dict) -> Result<(i64, bool, Option<String>), XfaError> {
|
||||
let r = w.rect;
|
||||
let mut ff: i64 = 0;
|
||||
if w.read_only {
|
||||
ff |= 1;
|
||||
}
|
||||
ff |= required_flag(w);
|
||||
let mut top_level = true;
|
||||
// The SOM path of the generated field above this one, for a signature field named by its
|
||||
// SOM path.
|
||||
@@ -319,26 +441,7 @@ impl Emitter<'_> {
|
||||
}
|
||||
WidgetKind::Radio { group, on, .. } => {
|
||||
top_level = false;
|
||||
let parent = match self.radio_groups.get(group) {
|
||||
Some((p, _)) => *p,
|
||||
None => {
|
||||
let mut g = Dict::new();
|
||||
g.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
g.set(b"Ff".to_vec(), Object::Int((1 << 15) | (1 << 14)));
|
||||
g.set(b"T".to_vec(), Object::String(PdfString::text(group)));
|
||||
g.set(b"V".to_vec(), Object::name("Off"));
|
||||
g.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face))));
|
||||
g.set(b"Kids".to_vec(), Object::Array(Vec::new()));
|
||||
// The group's SOM path: the button's without its last step.
|
||||
let group_som = w.som.rsplit_once('.').map(|(g, _)| g.to_string()).unwrap_or_else(|| w.som.clone());
|
||||
g.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&group_som)));
|
||||
let p = self.doc.add(Object::Dict(g));
|
||||
self.radio_groups.insert(group.clone(), (p, Vec::new()));
|
||||
self.fields.push(p);
|
||||
self.field_count += 1;
|
||||
p
|
||||
}
|
||||
};
|
||||
let parent = self.radio_group(w, group)?;
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
// Appearance states are PDF names, so a space becomes an underscore. The datasets
|
||||
// still hold the template's text (`/PCOn`); writing the name back would drop the selection.
|
||||
@@ -364,18 +467,27 @@ impl Emitter<'_> {
|
||||
write_button_action(w.action.as_ref(), d);
|
||||
}
|
||||
}
|
||||
(ff, top_level, under)
|
||||
Ok((ff, top_level, under))
|
||||
}
|
||||
|
||||
/// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with
|
||||
/// its border, background and kind, and return the annotation's object.
|
||||
/// its border, background and kind, its flags saying whether it shows on screen and in print,
|
||||
/// and return the annotation's object.
|
||||
fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result<Option<ObjRef>, XfaError> {
|
||||
let r = w.rect;
|
||||
let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y);
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
d.set(b"F".to_vec(), Object::Int(4));
|
||||
// Print (4) when it prints, NoView (32) when it isn't shown on screen, Hidden (2) when
|
||||
// it is shown nowhere.
|
||||
let flags = match (w.views.screen, w.views.print) {
|
||||
(true, true) => 4,
|
||||
(true, false) => 0,
|
||||
(false, true) => 4 | 32,
|
||||
(false, false) => 2,
|
||||
};
|
||||
d.set(b"F".to_vec(), Object::Int(flags));
|
||||
d.set(b"P".to_vec(), Object::Ref(page_ref));
|
||||
d.set(b"Rect".to_vec(), Object::Array([x0, y0, x1, y1].iter().map(|v| Object::Real(*v)).collect()));
|
||||
d.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face))));
|
||||
@@ -399,7 +511,7 @@ impl Emitter<'_> {
|
||||
if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) {
|
||||
d.set(ROUND_KEY.to_vec(), Object::Bool(true));
|
||||
}
|
||||
let (ff, top_level, under) = self.populate_widget_kind(w, &mut d, &mut mk);
|
||||
let (ff, top_level, under) = self.populate_widget_kind(w, &mut d, &mut mk)?;
|
||||
if !mk.is_empty() {
|
||||
d.set(b"MK".to_vec(), Object::Dict(mk));
|
||||
}
|
||||
@@ -563,6 +675,37 @@ impl Emitter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional content group `g` of [`GROUPS`] (0 screen only, 1 print only), marked as drawn in:
|
||||
/// an earlier layout's, or a new one whose `/Usage` says where it shows.
|
||||
fn group(&mut self, g: usize) -> ObjRef {
|
||||
if let Some(u) = self.used.get_mut(g) {
|
||||
*u = true;
|
||||
}
|
||||
if let Some(r) = self.groups.get(g).copied().flatten() {
|
||||
return r;
|
||||
}
|
||||
let (key, _, name, view, print) = GROUPS.get(g).copied().unwrap_or(GROUPS[0]);
|
||||
let state = |event: &str, key: &str, value: &str| {
|
||||
let mut s = Dict::new();
|
||||
s.set(key.as_bytes().to_vec(), Object::name(value));
|
||||
(event.as_bytes().to_vec(), Object::Dict(s))
|
||||
};
|
||||
let mut usage = Dict::new();
|
||||
for (k, v) in [state("View", "ViewState", view), state("Print", "PrintState", print)] {
|
||||
usage.set(k, v);
|
||||
}
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("OCG"));
|
||||
d.set(b"Name".to_vec(), Object::String(PdfString::text(name)));
|
||||
d.set(b"Usage".to_vec(), Object::Dict(usage));
|
||||
d.set(RELEVANT_KEY.to_vec(), Object::name(key));
|
||||
let r = self.doc.add(Object::Dict(d));
|
||||
if let Some(slot) = self.groups.get_mut(g) {
|
||||
*slot = Some(r);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Write one laid-out page under `pages_ref`: its fills, lines, text and pictures in a
|
||||
/// content stream, the fonts and images it uses as resources, and its widgets and the links
|
||||
/// in its text as annotations. Returns the page object.
|
||||
@@ -573,6 +716,9 @@ impl Emitter<'_> {
|
||||
let mut xobjects = Dict::new();
|
||||
let mut annots = Vec::new();
|
||||
let h = page.height;
|
||||
// The group whose marked content is open, and the groups the page uses.
|
||||
let mut open: Option<usize> = None;
|
||||
let mut properties = Dict::new();
|
||||
// The link the text before ended in, which a next piece of it on the line widens.
|
||||
let mut last_link: Option<LinkPiece> = None;
|
||||
let steps = steps(&page.items);
|
||||
@@ -580,7 +726,12 @@ impl Emitter<'_> {
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let item = match step {
|
||||
Step::Item(item) => *item,
|
||||
// A turned draw's `q`/`Q` and the marked content nest, never cross: an open group
|
||||
// is closed first, and opened again inside or after as its items need.
|
||||
Step::Turn(t) => {
|
||||
if open.take().is_some() {
|
||||
content.extend_from_slice(b"EMC\n");
|
||||
}
|
||||
let matrix = turn_matrix(t, h);
|
||||
turn = Some(matrix);
|
||||
last_link = None;
|
||||
@@ -588,17 +739,37 @@ impl Emitter<'_> {
|
||||
continue;
|
||||
}
|
||||
Step::EndTurn => {
|
||||
if open.take().is_some() {
|
||||
content.extend_from_slice(b"EMC\n");
|
||||
}
|
||||
turn = None;
|
||||
last_link = None;
|
||||
content.extend_from_slice(b"Q\n");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (group, item) = match item {
|
||||
Item::Only { views, item } => (Some(if views.screen { 0 } else { 1 }), item.as_ref()),
|
||||
other => (None, other),
|
||||
};
|
||||
// A field's widget is an annotation, not page content: the marked content runs on.
|
||||
if !matches!(item, Item::Widget(_)) && group != open {
|
||||
if open.is_some() {
|
||||
content.extend_from_slice(b"EMC\n");
|
||||
}
|
||||
if let Some((g, (_, resource, ..))) = group.and_then(|g| GROUPS.get(g).map(|k| (g, k))) {
|
||||
properties.set(resource.as_bytes().to_vec(), Object::Ref(self.group(g)));
|
||||
content.extend(format!("/OC /{resource} BDC\n").bytes());
|
||||
}
|
||||
open = group;
|
||||
// A link's pieces join only within one group: what is in another shows elsewhere.
|
||||
last_link = None;
|
||||
}
|
||||
// A link's pieces join only across text and the underline drawn just before it: a fill,
|
||||
// picture, field or any other line (a rule) between them ends the link there.
|
||||
let keeps_link = match item {
|
||||
Item::Text(_) => true,
|
||||
Item::Line { from, to, .. } => matches!(steps.get(i + 1), Some(Step::Item(Item::Text(s)))
|
||||
Item::Line { from, to, .. } => matches!(steps.get(i + 1).and_then(Step::item).map(bare), Some(Item::Text(s))
|
||||
if (from.1 - to.1).abs() < 0.01
|
||||
&& (from.0 - s.x).abs() < 0.01
|
||||
&& (from.1 - (s.baseline + s.face.size * crate::layout::UNDERLINE_DROP)).abs() < 0.01),
|
||||
@@ -654,6 +825,12 @@ impl Emitter<'_> {
|
||||
content.extend(literal(&win_ansi(&s.text)));
|
||||
content.extend_from_slice(format!(" Tj{close}\n").as_bytes());
|
||||
if let Some(r) = self.text_link(s, h, page_ref, &mut last_link, turn)? {
|
||||
// A link works only where its text shows: one in print-only text is
|
||||
// NoView (32), so it can't be clicked on screen, and one in screen-only
|
||||
// text doesn't print, as a field's widget there.
|
||||
if let Some(g) = group {
|
||||
self.doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(if g == 0 { 0 } else { 4 | 32 })))?;
|
||||
}
|
||||
annots.push(Object::Ref(r));
|
||||
}
|
||||
}
|
||||
@@ -670,8 +847,13 @@ impl Emitter<'_> {
|
||||
annots.push(Object::Ref(r));
|
||||
}
|
||||
}
|
||||
// Taken apart above: an item is never wrapped twice.
|
||||
Item::Only { .. } => {}
|
||||
}
|
||||
}
|
||||
if open.is_some() {
|
||||
content.extend_from_slice(b"EMC\n");
|
||||
}
|
||||
let mut font_dict = Dict::new();
|
||||
for (name, base) in fonts {
|
||||
font_dict.set(name.as_bytes().to_vec(), standard_font(base));
|
||||
@@ -681,6 +863,9 @@ impl Emitter<'_> {
|
||||
if !xobjects.is_empty() {
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
}
|
||||
if !properties.is_empty() {
|
||||
res.set(b"Properties".to_vec(), Object::Dict(properties));
|
||||
}
|
||||
let mut cd = Dict::new();
|
||||
cd.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
let contents = self.doc.add(Object::Stream(Stream::flate(Dict::new(), &content)));
|
||||
@@ -691,6 +876,9 @@ impl Emitter<'_> {
|
||||
pd.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, page.width, page.height].iter().map(|v| Object::Real(*v)).collect()));
|
||||
pd.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
pd.set(b"Contents".to_vec(), Object::Ref(contents));
|
||||
if let Some(area) = &page.area {
|
||||
pd.set(AREA_KEY.to_vec(), Object::String(PdfString::text(area)));
|
||||
}
|
||||
if !annots.is_empty() {
|
||||
pd.set(b"Annots".to_vec(), Object::Array(annots));
|
||||
}
|
||||
@@ -806,6 +994,14 @@ fn write_button_action(action: Option<&Action>, d: &mut Dict) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `item`, or what it holds when it is shown only on screen or only in print ([`Item::Only`]).
|
||||
fn bare(item: &Item) -> &Item {
|
||||
match item {
|
||||
Item::Only { item, .. } => item,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a page's content is written from: its items, with a turned draw opened out.
|
||||
enum Step<'a> {
|
||||
Item(&'a Item),
|
||||
@@ -813,6 +1009,16 @@ enum Step<'a> {
|
||||
EndTurn,
|
||||
}
|
||||
|
||||
impl<'a> Step<'a> {
|
||||
/// The item, when the step is one.
|
||||
fn item(&self) -> Option<&'a Item> {
|
||||
match self {
|
||||
Step::Item(item) => Some(item),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn steps(items: &[Item]) -> Vec<Step<'_>> {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
@@ -933,6 +1139,7 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError>
|
||||
// Pictures embedded by earlier passes, from the pages this pass replaces.
|
||||
let images = earlier_images(doc, pages_ref);
|
||||
let (signed, taken) = existing_fields(doc, &catalog);
|
||||
let groups = earlier_groups(doc, &catalog);
|
||||
let mut em = Emitter {
|
||||
doc,
|
||||
images,
|
||||
@@ -942,6 +1149,8 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError>
|
||||
field_count: 0,
|
||||
warnings: Vec::new(),
|
||||
signed,
|
||||
groups,
|
||||
used: [false; 2],
|
||||
taken,
|
||||
nodes: HashMap::new(),
|
||||
};
|
||||
@@ -971,7 +1180,11 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError>
|
||||
for (node, kids) in nodes {
|
||||
em.doc.update_dict(node, |d| d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())))?;
|
||||
}
|
||||
let Emitter { doc, fields, field_count, warnings, .. } = em;
|
||||
let Emitter { doc, fields, field_count, warnings, groups, used, .. } = em;
|
||||
let drawn: Vec<ObjRef> = groups.iter().zip(used).filter(|(_, u)| *u).filter_map(|(g, _)| *g).collect();
|
||||
if !drawn.is_empty() {
|
||||
catalog_changed |= register_groups(doc, &mut catalog, &groups, used)?;
|
||||
}
|
||||
let count = kids.len();
|
||||
doc.update_dict(pages_ref, |d| {
|
||||
d.set(b"Type".to_vec(), Object::name("Pages"));
|
||||
@@ -1034,6 +1247,101 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError>
|
||||
Ok(Written { pages: count, fields: field_count, warnings, usage_rights })
|
||||
}
|
||||
|
||||
/// Add the screen-only and print-only `groups` this layout drew in (`used`) to the document's
|
||||
/// optional content properties, keeping its own layers as they are: each goes in `/OCGs`; in the
|
||||
/// default configuration the print-only one starts off (the screen-only one starts on when the
|
||||
/// base state is off); and both join `/AS` usage applications for the View and Print events (an
|
||||
/// existing one for that event and category, or a new one), so a viewer shows each where it
|
||||
/// belongs. Nothing already there is added again. Returns whether `catalog` itself changed (a
|
||||
/// direct `/OCProperties`); an indirect one is updated where it is.
|
||||
fn register_groups(doc: &mut Document, catalog: &mut Dict, groups: &[Option<ObjRef>; 2], used: [bool; 2]) -> Result<bool, XfaError> {
|
||||
let mine: Vec<(usize, ObjRef)> =
|
||||
groups.iter().zip(used).enumerate().filter(|(_, (_, u))| *u).filter_map(|(i, (g, _))| g.map(|r| (i, r))).collect();
|
||||
let indirect = catalog.get(b"OCProperties").and_then(Object::as_ref);
|
||||
let mut props = catalog.get(b"OCProperties").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()).unwrap_or_default();
|
||||
// A list in `dict` under `key` (direct or not), with `add` appended where missing.
|
||||
let add_refs = |doc: &mut Document, dict: &mut Dict, key: &[u8], add: &[ObjRef]| -> Result<(), XfaError> {
|
||||
let at = dict.get(key).and_then(Object::as_ref);
|
||||
let mut list = dict.get(key).map(|l| doc.resolve(l)).and_then(|l| l.as_array().cloned()).unwrap_or_default();
|
||||
for r in add {
|
||||
if !list.iter().any(|o| o.as_ref() == Some(*r)) {
|
||||
list.push(Object::Ref(*r));
|
||||
}
|
||||
}
|
||||
match at {
|
||||
Some(a) => doc.set(a, Object::Array(list)),
|
||||
None => dict.set(key.to_vec(), Object::Array(list)),
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let all: Vec<ObjRef> = mine.iter().map(|(_, r)| *r).collect();
|
||||
add_refs(doc, &mut props, b"OCGs", &all)?;
|
||||
let d_at = props.get(b"D").and_then(Object::as_ref);
|
||||
let mut d = props.get(b"D").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let base_off = d.name(b"BaseState") == Some(&b"OFF"[..]);
|
||||
for (g, r) in &mine {
|
||||
match (*g, base_off) {
|
||||
(1, false) => add_refs(doc, &mut d, b"OFF", &[*r])?,
|
||||
(0, true) => add_refs(doc, &mut d, b"ON", &[*r])?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The usage applications: one per event, with its own category.
|
||||
let as_at = d.get(b"AS").and_then(Object::as_ref);
|
||||
let mut apps = d.get(b"AS").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).unwrap_or_default();
|
||||
for event in ["View", "Print"] {
|
||||
let matches = |doc: &Document, o: &Object| {
|
||||
doc.resolve(o).as_dict().is_some_and(|a| {
|
||||
a.name(b"Event") == Some(event.as_bytes())
|
||||
&& a.get(b"Category")
|
||||
.map(|c| doc.resolve(c))
|
||||
.and_then(|c| c.as_array().cloned())
|
||||
.is_some_and(|c| c.len() == 1 && c.first().and_then(Object::as_name).is_some_and(|n| n == event.as_bytes()))
|
||||
})
|
||||
};
|
||||
match apps.iter().position(|o| matches(doc, o)) {
|
||||
Some(i) => {
|
||||
let entry = apps.get(i).cloned().unwrap_or(Object::Null);
|
||||
let mut app = doc.resolve(&entry).as_dict().cloned().unwrap_or_default();
|
||||
add_refs(doc, &mut app, b"OCGs", &all)?;
|
||||
match entry.as_ref() {
|
||||
Some(r) => doc.set(r, Object::Dict(app)),
|
||||
None => {
|
||||
if let Some(slot) = apps.get_mut(i) {
|
||||
*slot = Object::Dict(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut app = Dict::new();
|
||||
app.set(b"Event".to_vec(), Object::name(event));
|
||||
app.set(b"OCGs".to_vec(), Object::Array(all.iter().map(|r| Object::Ref(*r)).collect()));
|
||||
app.set(b"Category".to_vec(), Object::Array(vec![Object::name(event)]));
|
||||
apps.push(Object::Dict(app));
|
||||
}
|
||||
}
|
||||
}
|
||||
match as_at {
|
||||
Some(a) => doc.set(a, Object::Array(apps)),
|
||||
None => d.set(b"AS".to_vec(), Object::Array(apps)),
|
||||
}
|
||||
match d_at {
|
||||
Some(a) => doc.set(a, Object::Dict(d)),
|
||||
None => props.set(b"D".to_vec(), Object::Dict(d)),
|
||||
}
|
||||
match indirect {
|
||||
Some(r) => {
|
||||
doc.set(r, Object::Dict(props));
|
||||
Ok(false)
|
||||
}
|
||||
None => {
|
||||
catalog.set(b"OCProperties".to_vec(), Object::Dict(props));
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rectangle of a widget in PDF user space (for tests).
|
||||
pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] {
|
||||
[r.x, page_height - r.bottom(), r.right(), page_height - r.y]
|
||||
@@ -1043,6 +1351,157 @@ pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The fields `existing_fields` finds under an AcroForm listing `fields`.
|
||||
fn names_found(doc: &Document, fields: Vec<Object>) -> Vec<String> {
|
||||
let (signed, names) = existing(doc, fields);
|
||||
assert!(signed.is_empty());
|
||||
let mut names: Vec<String> = names.into_iter().collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
fn field(name: &str, kids: Object) -> Object {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"T".to_vec(), Object::String(PdfString::literal(name)));
|
||||
d.set(b"Kids".to_vec(), kids);
|
||||
Object::Dict(d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_listed_many_times_queue_a_bounded_number_of_kids() {
|
||||
// A field lists itself 5,000 times: queuing every kid on each of 20,000 visits would
|
||||
// hold a hundred million entries. It is read once.
|
||||
let mut doc = Document::new_empty();
|
||||
let r = doc.add(Object::Null);
|
||||
doc.set(r, field("a", Object::Array(vec![Object::Ref(r); 5_000])));
|
||||
assert_eq!(names_found(&doc, vec![Object::Ref(r)]), ["a"]);
|
||||
|
||||
// 5,000 fields share one /Kids array listing each of them 200 times. Read once each,
|
||||
// the fields down one branch still queue the whole array at every level: 32 million
|
||||
// entries, unless the queue itself is bounded.
|
||||
let mut doc = Document::new_empty();
|
||||
let kids = doc.add(Object::Null);
|
||||
let fields: Vec<Object> = (0..5_000).map(|i| Object::Ref(doc.add(field(&format!("f{i}"), Object::Ref(kids))))).collect();
|
||||
doc.set(kids, Object::Array(fields.iter().cycle().take(1_000_000).cloned().collect()));
|
||||
let names = names_found(&doc, fields[..1].to_vec());
|
||||
assert!(names.contains(&"f0".to_string()) && names.len() <= MAX_FIELDS_SEARCHED, "{} names", names.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_listed_many_times_leaves_room_for_the_fields_under_it() {
|
||||
// The AcroForm lists one field 19,999 times. The copies take no room, so its kids are
|
||||
// still queued, and the signed one among them is found.
|
||||
let mut doc = Document::new_empty();
|
||||
let parent = signed_parent(&mut doc);
|
||||
let (signed, names) = existing(&doc, vec![Object::Ref(parent); MAX_FIELDS_SEARCHED - 1]);
|
||||
assert!(signed.contains_key("c.sig"), "{:?}", signed.keys().collect::<Vec<_>>());
|
||||
assert!(names.contains("c.plain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_listed_under_many_generations_is_queued_once() {
|
||||
// `Document::get` finds a field by object number alone, so `5 0 R` and `5 1 R` are one
|
||||
// field. Listed 19,999 times, each under another generation, it is still queued once,
|
||||
// and the signed field under it is found.
|
||||
let mut doc = Document::new_empty();
|
||||
let parent = signed_parent(&mut doc);
|
||||
let fields = (0..MAX_FIELDS_SEARCHED - 1).map(|g| Object::Ref(ObjRef::new(parent.num, u16::try_from(g).unwrap()))).collect();
|
||||
let (signed, names) = existing(&doc, fields);
|
||||
assert!(signed.contains_key("c.sig"), "{:?}", signed.keys().collect::<Vec<_>>());
|
||||
assert!(names.contains("c.plain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_fields_sharing_one_long_kids_array_search_it_within_the_read_limit() {
|
||||
// 1,000 signed fields share one `/Kids` array of 100,000 nulls and a widget with an
|
||||
// appearance at its end. Each searched the whole array for that appearance: with a
|
||||
// million nulls, a billion lookups (39 s). The search counts against `MAX_KIDS_READ`, so
|
||||
// the fields read once it is spent keep their value but get no appearance.
|
||||
let mut doc = Document::new_empty();
|
||||
let mut widget = Dict::new();
|
||||
widget.set(b"AP".to_vec(), Object::Dict(Dict::new()));
|
||||
let mut shared = vec![Object::Null; 100_000];
|
||||
shared.push(Object::Ref(doc.add(Object::Dict(widget))));
|
||||
let kids = doc.add(Object::Array(shared));
|
||||
let fields = (0..1_000)
|
||||
.map(|i| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"T".to_vec(), Object::String(PdfString::literal(format!("s{i}").as_str())));
|
||||
d.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
|
||||
d.set(b"V".to_vec(), Object::Dict(Dict::new()));
|
||||
d.set(b"Kids".to_vec(), Object::Ref(kids));
|
||||
Object::Ref(doc.add(Object::Dict(d)))
|
||||
})
|
||||
.collect();
|
||||
let (signed, _) = existing(&doc, fields);
|
||||
assert_eq!(signed.len(), 1_000);
|
||||
let with_appearance = signed.values().filter(|s| s.appearance.is_some()).count();
|
||||
assert!((1..=10).contains(&with_appearance), "{with_appearance} appearances found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_appearance_search_leaves_the_kids_of_a_signed_field_queued() {
|
||||
// A signed field with no appearance of its own lists a signed kid and then
|
||||
// `MAX_KIDS_READ` nulls. Searching them all for an appearance must not spend what
|
||||
// queuing its kids has to read, or the signed kid is never found.
|
||||
let mut doc = Document::new_empty();
|
||||
let mut kid = Dict::new();
|
||||
kid.set(b"T".to_vec(), Object::String(PdfString::literal("c")));
|
||||
kid.set(b"V".to_vec(), Object::Dict(Dict::new()));
|
||||
let mut kids = vec![Object::Ref(doc.add(Object::Dict(kid)))];
|
||||
kids.extend(std::iter::repeat_n(Object::Null, MAX_KIDS_READ));
|
||||
let mut parent = Dict::new();
|
||||
parent.set(b"T".to_vec(), Object::String(PdfString::literal("p")));
|
||||
parent.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
|
||||
parent.set(b"V".to_vec(), Object::Dict(Dict::new()));
|
||||
parent.set(b"Kids".to_vec(), Object::Array(kids));
|
||||
let parent = doc.add(Object::Dict(parent));
|
||||
let (signed, _) = existing(&doc, vec![Object::Ref(parent)]);
|
||||
assert!(signed.contains_key("p.c"), "{:?}", signed.keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_reached_first_at_the_depth_limit_is_read_again_when_reached_shallower() {
|
||||
// Field `s` is listed at the bottom of a chain `MAX_FIELD_DEPTH` deep, which is walked
|
||||
// first, and again under `b`. Reached first where its kids are not read, it is read
|
||||
// again under `b`, and the signed field under it is found.
|
||||
let mut doc = Document::new_empty();
|
||||
let mut sig = Dict::new();
|
||||
sig.set(b"T".to_vec(), Object::String(PdfString::literal("sig")));
|
||||
sig.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
|
||||
sig.set(b"V".to_vec(), Object::Dict(Dict::new()));
|
||||
let sig = doc.add(Object::Dict(sig));
|
||||
let shared = doc.add(field("s", Object::Array(vec![Object::Ref(sig)])));
|
||||
let mut deep = shared;
|
||||
for i in (0..MAX_FIELD_DEPTH).rev() {
|
||||
deep = doc.add(field(&format!("d{i}"), Object::Array(vec![Object::Ref(deep)])));
|
||||
}
|
||||
let b = doc.add(field("b", Object::Array(vec![Object::Ref(shared)])));
|
||||
let (signed, names) = existing(&doc, vec![Object::Ref(b), Object::Ref(deep)]);
|
||||
assert!(signed.contains_key("b.s.sig"), "{:?}", signed.keys().collect::<Vec<_>>());
|
||||
assert!(names.contains("b.s"));
|
||||
}
|
||||
|
||||
/// Field `c` with two kids: `plain`, and `sig`, a signed signature field.
|
||||
fn signed_parent(doc: &mut Document) -> ObjRef {
|
||||
let plain = doc.add(field("plain", Object::Array(Vec::new())));
|
||||
let mut sig = Dict::new();
|
||||
sig.set(b"T".to_vec(), Object::String(PdfString::literal("sig")));
|
||||
sig.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
|
||||
sig.set(b"V".to_vec(), Object::Dict(Dict::new()));
|
||||
let sig = doc.add(Object::Dict(sig));
|
||||
doc.add(field("c", Object::Array(vec![Object::Ref(plain), Object::Ref(sig)])))
|
||||
}
|
||||
|
||||
/// What `existing_fields` finds under an AcroForm listing `fields`.
|
||||
fn existing(doc: &Document, fields: Vec<Object>) -> (HashMap<String, Signed>, HashSet<String>) {
|
||||
let mut acro = Dict::new();
|
||||
acro.set(b"Fields".to_vec(), Object::Array(fields));
|
||||
let mut catalog = Dict::new();
|
||||
catalog.set(b"AcroForm".to_vec(), Object::Dict(acro));
|
||||
existing_fields(doc, &catalog)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hex32_rejects_non_hex_digits() {
|
||||
for prefix in ["+f", "-f", " f"] {
|
||||
@@ -1067,6 +1526,8 @@ mod tests {
|
||||
field_count: 0,
|
||||
warnings: Vec::new(),
|
||||
signed: Default::default(),
|
||||
groups: [None; 2],
|
||||
used: [false; 2],
|
||||
taken: Default::default(),
|
||||
nodes: HashMap::new(),
|
||||
};
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
use super::{CLICK_KEY, DATE_KEY, Dict, Document, IMAGE_FIELD_KEY, LAYOUT_KEY, NODE_KEY, ObjRef, Object, SOM_KEY, XfaError};
|
||||
use super::{CLICK_KEY, DATE_KEY, Dict, Document, GROUPS, IMAGE_FIELD_KEY, LAYOUT_KEY, NODE_KEY, ObjRef, Object, RELEVANT_KEY, SOM_KEY, XfaError};
|
||||
|
||||
/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]).
|
||||
pub(crate) const USAGE_RIGHTS_REMOVED: &str =
|
||||
"the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked";
|
||||
|
||||
/// The screen-only and print-only groups an earlier layout of the form made ([`RELEVANT_KEY`]),
|
||||
/// found among the document's optional content groups.
|
||||
pub(super) fn earlier_groups(doc: &Document, catalog: &Dict) -> [Option<ObjRef>; 2] {
|
||||
let mut out = [None; 2];
|
||||
let ocgs = catalog
|
||||
.get(b"OCProperties")
|
||||
.map(|p| doc.resolve(p))
|
||||
.and_then(|p| p.as_dict().and_then(|p| p.get(b"OCGs").map(|o| doc.resolve(o))))
|
||||
.and_then(|o| o.as_array().cloned())
|
||||
.unwrap_or_default();
|
||||
for r in ocgs.iter().filter_map(Object::as_ref).take(MAX_GROUPS_SEARCHED) {
|
||||
let key = doc.get(r).as_dict().and_then(|d| d.name(RELEVANT_KEY).map(<[u8]>::to_vec));
|
||||
if let Some(g) = key.and_then(|k| GROUPS.iter().position(|(name, ..)| name.as_bytes() == k.as_slice()))
|
||||
&& let Some(slot) = out.get_mut(g)
|
||||
&& slot.is_none()
|
||||
{
|
||||
*slot = Some(r);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Most optional content groups looked at for an earlier layout's.
|
||||
const MAX_GROUPS_SEARCHED: usize = 10_000;
|
||||
|
||||
/// What [`strip`] took out, for the user to be told (the packets held more than the fields).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Stripped {
|
||||
@@ -193,6 +218,12 @@ pub fn strip(doc: &mut Document) -> Result<Stripped, XfaError> {
|
||||
None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)),
|
||||
}
|
||||
catalog.remove(b"NeedsRendering");
|
||||
// The screen-only and print-only layers stay, as the pages use them; only their key goes.
|
||||
for r in earlier_groups(doc, &catalog).into_iter().flatten() {
|
||||
doc.update_dict(r, |d| {
|
||||
d.remove(RELEVANT_KEY);
|
||||
})?;
|
||||
}
|
||||
out.usage_rights = drop_usage_rights(doc, &mut catalog);
|
||||
doc.set(root, Object::Dict(catalog));
|
||||
// The packet streams are referenced from nowhere else: freed, so an incremental save
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! The property elements the parser keeps for scripts (XFA 3.3 ch. 3, "Properties vs.
|
||||
//! Children"), their limits, and the live form sharing them rather than copying.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::model::{Node, Prop};
|
||||
use crate::parse::{MAX_OBJECT_PROP_BYTES, MAX_OBJECT_PROPS, PROPS_CUT_WARNING};
|
||||
use crate::{Overrides, form_tree, parse};
|
||||
|
||||
fn template(body: &str) -> String {
|
||||
format!(
|
||||
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb"><pageSet><pageArea name="p"><contentArea w="8in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>{body}</subform></template>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn first_field(xml: &str) -> (crate::model::Field, Vec<String>) {
|
||||
let (t, warnings) = parse(xml).expect("parses");
|
||||
let f = t.root.children.iter().find_map(|n| if let Node::Field(f) = n { Some((**f).clone()) } else { None }).expect("a field");
|
||||
(f, warnings)
|
||||
}
|
||||
|
||||
fn count(p: &Prop) -> usize {
|
||||
1 + p.children.iter().map(count).sum::<usize>()
|
||||
}
|
||||
|
||||
fn depth(p: &Prop) -> usize {
|
||||
1 + p.children.iter().map(depth).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fields_property_elements_are_kept_for_scripts() {
|
||||
let xml = template(
|
||||
r#"<field name="f" w="2in" access="readOnly"><ui><choiceList open="always"><border><edge><color value="9,9,9"/></edge></border></choiceList><extras><x/></extras></ui>
|
||||
<border presence="hidden"><edge thickness="2pt"><color value="255,0,0"/></edge><fill><color value="1,2,3"/><linear type="toTop"/></fill></border>
|
||||
<caption><value><text>Label</text></value></caption>
|
||||
<validate nullTest="error"><message><text name="nullTest">Required</text></message><script>true</script></validate>
|
||||
<assist><toolTip>Tip</toolTip></assist><format><picture>num{z9}</picture></format>
|
||||
<value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Hi</p></body></exData></value>
|
||||
<keep intact="contentArea"/><event activity="click"><script>1</script></event></field>"#,
|
||||
);
|
||||
let (f, warnings) = first_field(&xml);
|
||||
assert!(warnings.iter().all(|w| w != PROPS_CUT_WARNING), "{warnings:?}");
|
||||
let mut props = (*f.common.props).clone();
|
||||
assert!(props.attrs.contains(&("w".into(), "2in".into())) && props.attrs.contains(&("access".into(), "readOnly".into())), "{props:?}");
|
||||
let tags: Vec<&str> = props.elements.iter().map(|p| p.tag.as_str()).collect();
|
||||
// Not events. The caption is read whole (its label's text), the object's own value only
|
||||
// as its content element's tag.
|
||||
assert_eq!(tags, ["ui", "border", "caption", "validate", "assist", "format", "value", "keep"]);
|
||||
let caption = props.elements.remove(2);
|
||||
assert_eq!(caption.children[0].children[0].text.as_deref(), Some("Label"));
|
||||
let ui = &props.elements[0];
|
||||
assert_eq!(ui.children.len(), 1, "extras left out: {ui:?}");
|
||||
assert_eq!(ui.children[0].tag, "choiceList");
|
||||
assert_eq!(ui.children[0].children[0].children[0].children[0].attrs, [("value".to_string(), "9,9,9".to_string())]);
|
||||
let validate = &props.elements[2];
|
||||
assert_eq!(validate.children[0].children[0].text.as_deref(), Some("Required"));
|
||||
// A validation's script is kept (forms check whether there is one); event scripts are not.
|
||||
assert_eq!((validate.children[1].tag.as_str(), validate.children[1].text.as_deref()), ("script", Some("true")));
|
||||
assert!(props.elements.iter().all(|p| p.tag != "event"));
|
||||
assert_eq!(props.elements[3].children[0].text.as_deref(), Some("Tip"));
|
||||
assert_eq!(props.elements[4].children[0].text.as_deref(), Some("num{z9}"));
|
||||
// The value's content element, not its data.
|
||||
let value = &props.elements[5];
|
||||
assert_eq!((value.children.len(), value.children[0].tag.as_str(), value.children[0].children.len()), (1, "exData", 0));
|
||||
assert_eq!(value.children[0].text, None);
|
||||
}
|
||||
|
||||
/// A hostile template: thousands of edges, deep nesting, a huge attribute and many attributes.
|
||||
/// What scripts are given stays within the per-object limits, and a warning says so once.
|
||||
#[test]
|
||||
fn hostile_property_trees_are_cut_to_the_limits() {
|
||||
let edges = "<edge><color value=\"1,1,1\"/></edge>".repeat(5_000);
|
||||
let deep = format!("{}{}", "<fill>".repeat(200), "</fill>".repeat(200));
|
||||
let big = "x".repeat(200_000);
|
||||
let attrs: String = (0..500).map(|i| format!(" a{i}=\"{i}\"")).collect();
|
||||
// Many lists of many entries: only two lists are kept (a field has two at most), each to the
|
||||
// list's own entry limit, and the lists count against the budget like any property.
|
||||
let entries = "<text>e</text>".repeat(1_200);
|
||||
let lists = format!("<items>{entries}</items>").repeat(40);
|
||||
for body in [
|
||||
format!("<field name=\"f\"><border>{edges}</border></field>"),
|
||||
format!("<field name=\"f\"><border>{deep}</border></field>"),
|
||||
format!("<field name=\"f\"><font typeface=\"{big}\"/></field>"),
|
||||
format!("<field name=\"f\"><font{attrs}/></field>"),
|
||||
format!("<field name=\"f\" {}><font/></field>", attrs.trim()),
|
||||
format!("<field name=\"f\">{lists}</field>"),
|
||||
] {
|
||||
let (f, warnings) = first_field(&template(&format!("{body}{body}")));
|
||||
assert_eq!(warnings.iter().filter(|w| *w == PROPS_CUT_WARNING).count(), 1, "once: {warnings:?}");
|
||||
let props = &f.common.props;
|
||||
// The first two lists' entries are the lists' own (up to 1,000 each), not property
|
||||
// elements; any further list, entries and all, counts against the budget.
|
||||
let mut lists = 0;
|
||||
let elements: usize = props
|
||||
.elements
|
||||
.iter()
|
||||
.map(|p| match p.tag.as_str() {
|
||||
"items" if lists < 2 => {
|
||||
lists += 1;
|
||||
assert!(p.children.len() <= 1_000, "entries past the list's limit");
|
||||
1
|
||||
}
|
||||
_ => count(p),
|
||||
})
|
||||
.sum();
|
||||
assert!(elements <= MAX_OBJECT_PROPS, "{elements}");
|
||||
assert!(props.elements.iter().map(depth).max().unwrap_or(0) <= 9, "deep");
|
||||
let bytes = |p: &Prop| -> usize { p.attrs.iter().map(|(k, v)| k.len() + v.len()).sum::<usize>() + p.tag.len() };
|
||||
fn all<'a>(p: &'a Prop, out: &mut Vec<&'a Prop>) {
|
||||
out.push(p);
|
||||
p.children.iter().for_each(|c| all(c, out));
|
||||
}
|
||||
let mut flat = Vec::new();
|
||||
props.elements.iter().for_each(|p| all(p, &mut flat));
|
||||
let total: usize = flat.iter().map(|p| bytes(p)).sum::<usize>() + props.attrs.iter().map(|(k, v)| k.len() + v.len()).sum::<usize>();
|
||||
assert!(total <= MAX_OBJECT_PROP_BYTES, "{total}");
|
||||
assert!(flat.iter().all(|p| p.attrs.len() <= 64) && props.attrs.len() <= 64);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every instance of a repeating subform, and every live form built, points at the template's
|
||||
/// property tree: nothing is copied per instance or per build.
|
||||
#[test]
|
||||
fn the_live_form_shares_the_templates_property_trees() {
|
||||
let xml = template(
|
||||
r#"<subform name="row" layout="tb"><occur min="3" max="-1" initial="3"/><field name="f"><border><edge/></border></field></subform>"#,
|
||||
);
|
||||
let (t, _) = parse(&xml).expect("parses");
|
||||
let a = form_tree(&t, None, &Overrides::default());
|
||||
let b = form_tree(&t, None, &Overrides::default());
|
||||
let rows: Vec<_> = a.root.children.iter().filter(|c| c.name == "row").collect();
|
||||
assert_eq!(rows.len(), 3);
|
||||
let Some(Node::Subform(row)) = t.root.children.first() else { panic!("a row subform") };
|
||||
for r in &rows {
|
||||
assert!(Arc::ptr_eq(&r.props, &row.common.props));
|
||||
assert!(Arc::ptr_eq(&r.children[0].props, &rows[0].children[0].props));
|
||||
}
|
||||
assert_eq!(rows[0].props.elements[0].tag, "occur");
|
||||
assert!(Arc::ptr_eq(&a.root.children[0].props, &b.root.children[0].props));
|
||||
}
|
||||
|
||||
/// A property element whose children are all left out (an `extras`, a `script`) has only its
|
||||
/// own text, not theirs, and nothing is reported cut.
|
||||
#[test]
|
||||
fn a_property_keeps_its_own_text_not_that_of_children_left_out() {
|
||||
let big = "x".repeat(20_000);
|
||||
let xml = template(&format!(
|
||||
r#"<field name="f"><font typeface="Arial"><extras><text>{big}</text></extras></font><assist><toolTip>Tip<extras><text>hidden</text></extras></toolTip></assist><breakBefore targetType="pageArea"><script>xfa.host.messageBox("{big}")</script></breakBefore><validate><script>{big}</script></validate><border><edge thickness="2pt"/></border></field>"#
|
||||
));
|
||||
let (f, warnings) = first_field(&xml);
|
||||
assert!(warnings.iter().all(|w| w != PROPS_CUT_WARNING), "{warnings:?}");
|
||||
let tags: Vec<(&str, Option<&str>)> = f.common.props.elements.iter().map(|p| (p.tag.as_str(), p.text.as_deref())).collect();
|
||||
assert_eq!(tags, [("font", None), ("assist", None), ("breakBefore", None), ("validate", None), ("border", None)]);
|
||||
assert_eq!(f.common.props.elements[1].children[0].text.as_deref(), Some("Tip"));
|
||||
// A validation's script keeps its text, outside the budget: the border after it is kept.
|
||||
assert_eq!(f.common.props.elements[3].children[0].text.as_ref().map(|t| t.len()), Some(20_000));
|
||||
assert_eq!(f.common.props.elements[4].children[0].attrs, [("thickness".to_string(), "2pt".to_string())]);
|
||||
}
|
||||
|
||||
/// A validation keeps its first script only (it has one, XFA 3.3 p. 880), and that script's
|
||||
/// text only within the script engine's limit on a script.
|
||||
#[test]
|
||||
fn a_validation_keeps_its_first_script_within_the_script_limit() {
|
||||
let xml = template(r#"<field name="f"><validate><script>first</script><script>second</script></validate></field>"#);
|
||||
let (f, _) = first_field(&xml);
|
||||
let scripts: Vec<Option<&str>> = f.common.props.elements[0].children.iter().map(|s| s.text.as_deref()).collect();
|
||||
assert_eq!(scripts, [Some("first")]);
|
||||
let huge = "x".repeat(300 * 1024);
|
||||
let (f, warnings) =
|
||||
first_field(&template(&format!(r#"<field name="f"><validate><script>{huge}</script></validate><font typeface="Arial"/></field>"#)));
|
||||
assert_eq!(f.common.props.elements[0].children[0].text, None);
|
||||
assert_eq!(f.common.props.elements[1].attrs, [("typeface".to_string(), "Arial".to_string())]);
|
||||
assert!(warnings.iter().any(|w| w == PROPS_CUT_WARNING), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A field with a third `<items>` (a public form carries an empty saved list twice) keeps it
|
||||
/// too, as an ordinary property, with no warning.
|
||||
#[test]
|
||||
fn a_third_items_list_is_kept_as_a_property() {
|
||||
let xml =
|
||||
template(r#"<field name="f"><items/><items save="1" presence="hidden"/><items save="1" presence="hidden"><text>0</text></items></field>"#);
|
||||
let (f, warnings) = first_field(&xml);
|
||||
assert!(warnings.iter().all(|w| w != PROPS_CUT_WARNING), "{warnings:?}");
|
||||
let lists: Vec<usize> = f.common.props.elements.iter().filter(|p| p.tag == "items").map(|p| p.children.len()).collect();
|
||||
assert_eq!(lists, [0, 0, 1]);
|
||||
}
|
||||
|
||||
/// A calculation is kept with its override, message and first script (XFA 3.3 p. 641: one
|
||||
/// script), as a validation is; the field still calculates with that script.
|
||||
#[test]
|
||||
fn a_calculation_is_kept_with_its_override_message_and_script() {
|
||||
let xml = template(
|
||||
r#"<field name="f"><calculate override="warning"><message><text>Computed</text></message><script>a + b</script><script>c</script></calculate></field>"#,
|
||||
);
|
||||
let (f, _) = first_field(&xml);
|
||||
let calc = f.common.props.elements.first().expect("the calculation");
|
||||
assert_eq!((calc.tag.as_str(), calc.attrs.as_slice()), ("calculate", [("override".to_string(), "warning".to_string())].as_slice()));
|
||||
let kids: Vec<(&str, Option<&str>)> = calc.children.iter().map(|k| (k.tag.as_str(), k.text.as_deref())).collect();
|
||||
assert_eq!(kids, [("message", None), ("script", Some("a + b"))]);
|
||||
assert_eq!(calc.children[0].children[0].text.as_deref(), Some("Computed"));
|
||||
assert_eq!(f.calculate.as_ref().map(|s| s.text.as_str()), Some("a + b"));
|
||||
}
|
||||
|
||||
/// A caption's rich text is kept as its words, and a field's items are kept whole, each entry
|
||||
/// with its text as the list shows it, outside the object's property budget (a long list of
|
||||
/// countries must not crowd out the other properties).
|
||||
#[test]
|
||||
fn a_caption_s_rich_text_and_a_list_s_items_are_kept() {
|
||||
let entries: String = (0..400).map(|i| format!("<text>Country {i}</text>")).collect();
|
||||
let xml = template(&format!(
|
||||
r#"<field name="f"><caption><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Date of <span style="font-weight:bold">birth</span>:</p></body></exData></value></caption><items>{entries}</items><items save="1"><integer>1</integer></items><border><edge/></border></field>"#
|
||||
));
|
||||
let (f, warnings) = first_field(&xml);
|
||||
assert!(warnings.iter().all(|w| w != PROPS_CUT_WARNING), "{warnings:?}");
|
||||
let tags: Vec<&str> = f.common.props.elements.iter().map(|p| p.tag.as_str()).collect();
|
||||
assert_eq!(tags, ["caption", "items", "items", "border"]);
|
||||
let exdata = &f.common.props.elements[0].children[0].children[0];
|
||||
assert_eq!((exdata.tag.as_str(), exdata.text.as_deref()), ("exData", Some("Date of birth:")));
|
||||
let shown = &f.common.props.elements[1];
|
||||
assert_eq!((shown.children.len(), shown.children[399].text.as_deref()), (400, Some("Country 399")));
|
||||
assert_eq!(f.common.props.elements[2].attrs, [("save".to_string(), "1".to_string())]);
|
||||
assert_eq!(f.items.len(), 400, "the list shows the same entries");
|
||||
}
|
||||
|
||||
/// A template handed to `parse` directly is held to what a document's packets may come to as text:
|
||||
/// one byte past it is refused before any of it is parsed (the error giving its size in bytes), and
|
||||
/// a template of exactly that size, as a UTF-16 packet the reader accepted can become, is parsed.
|
||||
#[test]
|
||||
fn a_template_past_the_packet_limit_is_refused_before_parsing() {
|
||||
let limit = crate::packets::MAX_TEMPLATE_TEXT;
|
||||
assert!(limit > crate::packets::MAX_XDP, "room for a UTF-16 packet grown into UTF-8");
|
||||
let head = r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form"><field name="f"><items><text>"#;
|
||||
let tail = "</text></items></field></subform></template>";
|
||||
let xml = format!("{head}{}{tail}", "a".repeat(limit + 1 - head.len() - tail.len()));
|
||||
assert_eq!(xml.len(), limit + 1);
|
||||
match crate::parse(&xml) {
|
||||
Err(crate::XfaError::TooLarge(m)) => assert!(m.contains(&format!("{} bytes", limit + 1)), "{m}"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let at = format!("{head}{}{tail}", "a".repeat(limit - head.len() - tail.len()));
|
||||
assert!(crate::parse(&at).is_ok(), "a template at the limit is parsed");
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
//! Prototypes (XFA 3.3, chapter 6, "Prototypes"): an element with `use` or `usehref` inherits
|
||||
//! the attributes, data content and children of the element it names, and overrides what it
|
||||
//! declares itself. Designer's style sheets work this way: a field names its style, and its
|
||||
//! size, border, font and caption placement are the style's.
|
||||
//!
|
||||
//! [`expand`] writes the template again with every reference resolved, so the parser reads
|
||||
//! plain elements. Prototypes are resolved once, when the template is loaded, as the
|
||||
//! specification says ("Prototypes", the note before "Defining Prototypes").
|
||||
//!
|
||||
//! Rules ("Overriding Prototype Properties"): every attribute but `id`, `name`, `use` and
|
||||
//! `usehref` is inherited unless the element has its own; the prototype's data content is
|
||||
//! inherited unless the element has non-empty content of its own; children are matched by
|
||||
//! class and then by position among that class, and a matched prototype child is in turn the
|
||||
//! prototype of the element's child. A `ui` or `value` holds one alternative (one widget, one
|
||||
//! kind of content), so the nearest layer's choice is kept and only that one is merged; rich
|
||||
//! text (`exData`) is content and is taken whole from the nearest layer that has any.
|
||||
//!
|
||||
//! Only references into the same template are followed: `usehref` with no document (or the
|
||||
//! legacy `.`), as `#id` or `#som(expression)`, and `use` as `#id` or a SOM expression. A
|
||||
//! reference to another document, to nothing, or back into itself is left out, with a warning.
|
||||
//! The SOM subset is the one Designer writes: `$template.#subform.styles.Heading[1]`, each
|
||||
//! step a name or a `#class` with an optional index, looking through `proto` elements and
|
||||
//! nameless subforms.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use roxmltree::{Node, NodeId};
|
||||
|
||||
/// Deepest element nesting written. A prototype may contain a reference to an element that
|
||||
/// contains it (Example 6.8): such loops are cut, and this bounds what remains.
|
||||
const MAX_DEPTH: usize = 256;
|
||||
/// Most layers (an element and the prototypes it inherits from) merged into one element.
|
||||
const MAX_LAYERS: usize = 32;
|
||||
/// Most bytes written: at least `MIN_OUTPUT`, at most `GROWTH` times the template and never
|
||||
/// more than `MAX_OUTPUT`. Each reference repeats its prototype, so a hostile template could
|
||||
/// otherwise grow without bound.
|
||||
const MAX_OUTPUT: usize = 64 << 20;
|
||||
const MIN_OUTPUT: usize = 1 << 20;
|
||||
const GROWTH: usize = 4;
|
||||
/// Longest SOM expression evaluated, in bytes. Designer writes some tens of bytes
|
||||
/// (`$template.#subform.designer__stylesheet.Heading[1]`); a longer one names nothing.
|
||||
const MAX_SOM: usize = 1_024;
|
||||
/// Most elements looked at to resolve all SOM references together.
|
||||
const MAX_SEARCH: usize = 2_000_000;
|
||||
/// Most children, attributes and namespaces looked at to merge all the layers together. Each
|
||||
/// reference looks through its prototype's children again, also those that write nothing
|
||||
/// (`proto`, and the `ui` and `value` alternatives not chosen), so the output budget alone does
|
||||
/// not bound this. Real forms cost one unit for every 5.5 to 36 bytes written (the largest
|
||||
/// measured, a 14 MB template, costs 2.5 million), so writing as much as the 64 MB output
|
||||
/// budget allows costs some 12 million: this keeps a margin of five over that, and stops a
|
||||
/// hostile template after about a second.
|
||||
const MAX_WORK: usize = 64_000_000;
|
||||
/// The namespace the `xml` prefix is always bound to.
|
||||
const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
|
||||
/// The template written again with its prototype references resolved (`None` when it has
|
||||
/// none, or when resolving them would pass a budget: the template is then read as it is), and
|
||||
/// the warnings for what was left out.
|
||||
pub(crate) fn expand(template: Node) -> (Option<String>, Vec<String>) {
|
||||
let referencing = |n: &Node| {
|
||||
n.is_element() && (n.attribute("use").is_some_and(|u| !u.trim().is_empty()) || n.attribute("usehref").is_some_and(|u| !u.trim().is_empty()))
|
||||
};
|
||||
if !template.descendants().any(|n| referencing(&n)) {
|
||||
return (None, Vec::new());
|
||||
}
|
||||
let limit = template.range().len().saturating_mul(GROWTH).clamp(MIN_OUTPUT, MAX_OUTPUT);
|
||||
let mut w = Writer {
|
||||
template,
|
||||
out: String::new(),
|
||||
limit,
|
||||
failed: None,
|
||||
ids: None,
|
||||
refs: HashMap::new(),
|
||||
soms: HashMap::new(),
|
||||
search: MAX_SEARCH,
|
||||
work: MAX_WORK,
|
||||
active: HashMap::new(),
|
||||
missing: false,
|
||||
external: false,
|
||||
looped: false,
|
||||
layers_cut: false,
|
||||
};
|
||||
w.element(&[template], &Scope::new(), 0);
|
||||
let mut warnings = Vec::new();
|
||||
if w.external {
|
||||
warnings.push("a prototype reference (usehref) points to another document; it is not followed".into());
|
||||
}
|
||||
if w.missing {
|
||||
warnings.push("a prototype reference (use or usehref) names nothing of its kind in the template; the element is read without it".into());
|
||||
}
|
||||
if w.looped {
|
||||
warnings.push("a prototype reference leads back to itself; the loop is cut there".into());
|
||||
}
|
||||
if w.layers_cut {
|
||||
warnings.push(format!("a chain of prototypes is longer than {MAX_LAYERS}; the farthest are left out"));
|
||||
}
|
||||
match w.failed {
|
||||
Some(why) => {
|
||||
warnings.push(format!("the template's prototypes {why}; it is read without resolving them"));
|
||||
(None, warnings)
|
||||
}
|
||||
None => (Some(w.out), warnings),
|
||||
}
|
||||
}
|
||||
|
||||
/// Namespace bindings in scope where an element is written: URI by prefix (`None` for the
|
||||
/// default namespace).
|
||||
type Scope<'a> = HashMap<Option<&'a str>, &'a str>;
|
||||
|
||||
struct Writer<'a, 'd> {
|
||||
template: Node<'a, 'd>,
|
||||
out: String,
|
||||
limit: usize,
|
||||
/// Why expanding stopped, for the warning.
|
||||
failed: Option<String>,
|
||||
/// The template's elements by `id`, built on the first `#id` reference.
|
||||
ids: Option<HashMap<&'a str, Node<'a, 'd>>>,
|
||||
/// The prototype each element names, once resolved: a prototype shared by many elements
|
||||
/// is reached again from each of them, and its reference (of any length) is read once.
|
||||
refs: HashMap<NodeId, Option<Node<'a, 'd>>>,
|
||||
/// SOM references already resolved, by their text.
|
||||
soms: HashMap<&'a str, Option<Node<'a, 'd>>>,
|
||||
/// Elements left to look at for SOM references.
|
||||
search: usize,
|
||||
/// Children, attributes and namespaces left to look at while merging layers.
|
||||
work: usize,
|
||||
/// The elements being written around the current one: inheriting from one is a loop.
|
||||
active: HashMap<NodeId, u32>,
|
||||
missing: bool,
|
||||
external: bool,
|
||||
looped: bool,
|
||||
layers_cut: bool,
|
||||
}
|
||||
|
||||
fn escape(s: &str, attribute: bool, out: &mut String) {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' if attribute => out.push_str("""),
|
||||
'\n' if attribute => out.push_str(" "),
|
||||
'\r' if attribute => out.push_str(" "),
|
||||
'\t' if attribute => out.push_str("	"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Containers a SOM step looks through: they have no name of their own.
|
||||
fn transparent(n: Node) -> bool {
|
||||
n.tag_name().name() == "proto" || n.tag_name().name() == "subform" && n.attribute("name").is_none_or(str::is_empty)
|
||||
}
|
||||
|
||||
/// Does `n` have content of its own: non-blank text, or (`elements`) any child element?
|
||||
fn has_content(n: &Node, elements: bool) -> bool {
|
||||
n.children().any(|c| elements && c.is_element() || c.is_text() && c.text().is_some_and(|t| !t.trim().is_empty()))
|
||||
}
|
||||
|
||||
/// In a `ui` or a `value`, is `c` one of the alternatives of which only one is meant?
|
||||
fn alternative(tag: &str, c: &Node) -> bool {
|
||||
c.is_element() && (tag == "value" || tag == "ui" && !matches!(c.tag_name().name(), "picture" | "extras"))
|
||||
}
|
||||
|
||||
impl<'a, 'd> Writer<'a, 'd> {
|
||||
/// The element `n` names as its prototype. `usehref` wins over `use` when both are set.
|
||||
fn reference(&mut self, n: Node<'a, 'd>) -> Option<Node<'a, 'd>> {
|
||||
if let Some(found) = self.refs.get(&n.id()) {
|
||||
return *found;
|
||||
}
|
||||
let found = self.find_reference(n);
|
||||
// A look-up a budget cut short is not kept: expanding has failed.
|
||||
if self.failed.is_none() {
|
||||
self.refs.insert(n.id(), found);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn find_reference(&mut self, n: Node<'a, 'd>) -> Option<Node<'a, 'd>> {
|
||||
let set = |name: &str| n.attribute(name).map(str::trim).filter(|v| !v.is_empty());
|
||||
let found = if let Some(href) = set("usehref") {
|
||||
match href.split_once('#') {
|
||||
Some((doc, fragment)) if matches!(doc.trim(), "" | ".") => {
|
||||
let fragment = fragment.trim();
|
||||
match fragment.strip_prefix("som(").and_then(|s| s.strip_suffix(')')) {
|
||||
Some(expr) => self.som(expr.trim()),
|
||||
None => self.by_id(fragment),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.external = true;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let r = set("use")?;
|
||||
match r.strip_prefix('#') {
|
||||
Some(id) => self.by_id(id.trim()),
|
||||
// A SOM expression; the specification's own Example 6.5 writes a bare id.
|
||||
None => self.som(r).or_else(|| self.by_id(r)),
|
||||
}
|
||||
};
|
||||
match found {
|
||||
// A budget ran out while looking: the template is read unresolved, with that warning.
|
||||
_ if self.failed.is_some() => None,
|
||||
Some(t) if t == n => {
|
||||
self.looped = true;
|
||||
None
|
||||
}
|
||||
Some(t) if t.tag_name().name() == n.tag_name().name() => Some(t),
|
||||
_ => {
|
||||
self.missing = true;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn by_id(&mut self, id: &str) -> Option<Node<'a, 'd>> {
|
||||
let template = self.template;
|
||||
let ids = self.ids.get_or_insert_with(|| {
|
||||
let mut ids = HashMap::new();
|
||||
for n in template.descendants().filter(Node::is_element) {
|
||||
if let Some(id) = n.attribute("id") {
|
||||
ids.entry(id).or_insert(n);
|
||||
}
|
||||
}
|
||||
ids
|
||||
});
|
||||
ids.get(id).copied()
|
||||
}
|
||||
|
||||
/// A SOM expression from the template down, such as `$template.#subform.styles.Heading[1]`.
|
||||
fn som(&mut self, expr: &'a str) -> Option<Node<'a, 'd>> {
|
||||
if expr.len() > MAX_SOM {
|
||||
return None;
|
||||
}
|
||||
if let Some(found) = self.soms.get(expr) {
|
||||
return *found;
|
||||
}
|
||||
let found = self.resolve(expr);
|
||||
// A look-up the budget cut short has not found nothing: it is not cached, and the
|
||||
// template is read unresolved (`step` set `failed`).
|
||||
if self.failed.is_none() {
|
||||
self.soms.insert(expr, found);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn resolve(&mut self, expr: &str) -> Option<Node<'a, 'd>> {
|
||||
let mut steps = expr.split('.').map(str::trim).peekable();
|
||||
while steps.peek().is_some_and(|s| matches!(*s, "$template" | "template" | "template[0]" | "xfa" | "$xfa" | "xfa[0]")) {
|
||||
steps.next();
|
||||
}
|
||||
let mut at = self.template;
|
||||
for step in steps {
|
||||
let (name, index) = match step.split_once('[') {
|
||||
Some((name, index)) => (name.trim(), index.strip_suffix(']')?.trim().parse::<usize>().ok()?),
|
||||
None => (step, 0),
|
||||
};
|
||||
if name.is_empty() || name == "#" {
|
||||
return None;
|
||||
}
|
||||
let mut matches = Vec::new();
|
||||
self.step(at, name, index, &mut matches, 0);
|
||||
at = *matches.get(index)?;
|
||||
}
|
||||
(at != self.template).then_some(at)
|
||||
}
|
||||
|
||||
/// The children of `at` called `name` (or of class `#name`), looking through nameless
|
||||
/// containers, until the one at `index` is found.
|
||||
fn step(&mut self, at: Node<'a, 'd>, name: &str, index: usize, out: &mut Vec<Node<'a, 'd>>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for c in at.children().filter(Node::is_element) {
|
||||
if out.len() > index || self.failed.is_some() {
|
||||
return;
|
||||
}
|
||||
if self.search == 0 {
|
||||
self.failed = Some(format!("need more than {MAX_SEARCH} elements looked at to resolve their SOM references"));
|
||||
return;
|
||||
}
|
||||
self.search -= 1;
|
||||
let hit = match name.strip_prefix('#') {
|
||||
Some(class) => c.tag_name().name() == class,
|
||||
None => c.attribute("name") == Some(name),
|
||||
};
|
||||
if hit {
|
||||
out.push(c);
|
||||
} else if transparent(c) {
|
||||
self.step(c, name, index, out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` and the prototypes it inherits from, nearest first.
|
||||
fn chain(&mut self, n: Node<'a, 'd>, layers: &mut Vec<Node<'a, 'd>>) {
|
||||
let mut seen = HashSet::from([n.id()]);
|
||||
layers.push(n);
|
||||
let mut at = n;
|
||||
while let Some(t) = self.reference(at) {
|
||||
if !seen.insert(t.id()) || self.active.contains_key(&t.id()) {
|
||||
self.looped = true;
|
||||
return;
|
||||
}
|
||||
if layers.len() >= MAX_LAYERS {
|
||||
self.layers_cut = true;
|
||||
return;
|
||||
}
|
||||
layers.push(t);
|
||||
at = t;
|
||||
}
|
||||
}
|
||||
|
||||
/// Take `n` from the work budget; past it, expanding stops.
|
||||
fn charge(&mut self, n: usize) -> bool {
|
||||
match self.work.checked_sub(n) {
|
||||
Some(left) => {
|
||||
self.work = left;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.work = 0;
|
||||
self.failed = Some(format!("need more than {MAX_WORK} children, attributes and namespaces looked at to merge them"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the namespace bindings `layers` have that differ from those in `scope` around their
|
||||
/// element, by prefix, and return the scope inside it: the nearest layer's binding of a prefix
|
||||
/// wins, and a prefix bound around the element to another URI is bound again here (an XHTML
|
||||
/// `body` inside a template whose default namespace is XFA's).
|
||||
fn namespaces(&mut self, layers: &[Node<'a, 'd>], scope: &Scope<'a>) -> Scope<'a> {
|
||||
let mut inner: Scope<'a> = scope.clone();
|
||||
let mut decided: HashSet<Option<&'a str>> = HashSet::new();
|
||||
for layer in layers {
|
||||
for ns in layer.namespaces() {
|
||||
let (name, uri) = (ns.name(), ns.uri());
|
||||
if name == Some("xml") || !decided.insert(name) || scope.get(&name) == Some(&uri) {
|
||||
continue;
|
||||
}
|
||||
match name {
|
||||
Some(name) => {
|
||||
self.out.push_str(" xmlns:");
|
||||
self.out.push_str(name);
|
||||
}
|
||||
None => self.out.push_str(" xmlns"),
|
||||
}
|
||||
self.out.push_str("=\"");
|
||||
escape(uri, true, &mut self.out);
|
||||
self.out.push('"');
|
||||
inner.insert(name, uri);
|
||||
}
|
||||
}
|
||||
inner
|
||||
}
|
||||
|
||||
/// Write the attributes of the element `layers` make, inside the namespace scope `inner`.
|
||||
fn attributes(&mut self, layers: &[Node<'a, 'd>], inner: &Scope<'a>) {
|
||||
// Attributes: the nearest layer that has one wins; `id` and `name` are not inherited.
|
||||
// Attributes are told apart by namespace URI and local name. One whose layer's prefix is
|
||||
// bound here to another URI (the nearest layer's binding won) is written with a fresh
|
||||
// prefix, declared on this element.
|
||||
let mut written: HashSet<(Option<&str>, &str)> = HashSet::new();
|
||||
let mut fresh: HashMap<&str, String> = HashMap::new();
|
||||
let mut next = 0usize;
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
for a in layer.attributes() {
|
||||
let inherited = i > 0 && matches!(a.name(), "id" | "name");
|
||||
let reference = a.namespace().is_none() && matches!(a.name(), "use" | "usehref");
|
||||
if inherited || reference || !written.insert((a.namespace(), a.name())) {
|
||||
continue;
|
||||
}
|
||||
let name = match a.namespace() {
|
||||
None => a.name().to_string(),
|
||||
Some(XML_NS) => format!("xml:{}", a.name()),
|
||||
Some(uri) => match layer.lookup_prefix(uri).filter(|p| !p.is_empty()) {
|
||||
Some(p) if inner.get(&Some(p)) == Some(&uri) => format!("{p}:{}", a.name()),
|
||||
_ => {
|
||||
let p = match fresh.get(uri) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
// `next` only grows, and skips the prefixes in scope here,
|
||||
// so it passes at most `inner.len()` of them.
|
||||
let p = loop {
|
||||
let p = format!("ns{next}");
|
||||
next = next.saturating_add(1);
|
||||
if !inner.contains_key(&Some(p.as_str())) {
|
||||
break p;
|
||||
}
|
||||
};
|
||||
self.out.push_str(" xmlns:");
|
||||
self.out.push_str(&p);
|
||||
self.out.push_str("=\"");
|
||||
escape(uri, true, &mut self.out);
|
||||
self.out.push('"');
|
||||
fresh.insert(uri, p.clone());
|
||||
p
|
||||
}
|
||||
};
|
||||
format!("{p}:{}", a.name())
|
||||
}
|
||||
},
|
||||
};
|
||||
self.out.push(' ');
|
||||
self.out.push_str(&name);
|
||||
self.out.push_str("=\"");
|
||||
escape(a.value(), true, &mut self.out);
|
||||
self.out.push('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the element that `stack` makes together: `stack[0]` over `stack[1]` over …, each
|
||||
/// an element of the same class. `scope` holds the namespaces already declared around it.
|
||||
fn element(&mut self, stack: &[Node<'a, 'd>], scope: &Scope<'a>, depth: usize) {
|
||||
if self.failed.is_some() {
|
||||
return;
|
||||
}
|
||||
if depth > MAX_DEPTH {
|
||||
self.failed = Some(format!("nest deeper than {MAX_DEPTH} elements"));
|
||||
return;
|
||||
}
|
||||
if self.out.len() > self.limit {
|
||||
self.failed = Some(format!("would make it larger than {} MB", self.limit >> 20));
|
||||
return;
|
||||
}
|
||||
// Every layer may itself inherit from a prototype.
|
||||
let mut layers = Vec::new();
|
||||
for l in stack {
|
||||
self.chain(*l, &mut layers);
|
||||
if layers.len() >= MAX_LAYERS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Charge what merging looks at before looking: each layer's children (counted no
|
||||
// further than the budget left), attributes and namespaces, and the scope around it.
|
||||
let mut cost = scope.len().saturating_add(layers.len());
|
||||
for l in &layers {
|
||||
let children = l.children().take(self.work.saturating_add(1)).count();
|
||||
cost = cost.saturating_add(children).saturating_add(l.attributes().len()).saturating_add(l.namespaces().len());
|
||||
}
|
||||
if self.failed.is_some() || !self.charge(cost) {
|
||||
return;
|
||||
}
|
||||
let Some(&top) = layers.first() else { return };
|
||||
for l in &layers {
|
||||
*self.active.entry(l.id()).or_insert(0) += 1;
|
||||
}
|
||||
let tag = top.tag_name().name();
|
||||
let prefix =
|
||||
|n: Node, ns: Option<&str>| ns.and_then(|ns| n.lookup_prefix(ns)).filter(|p| !p.is_empty()).map(|p| format!("{p}:")).unwrap_or_default();
|
||||
let qname = format!("{}{tag}", prefix(top, top.tag_name().namespace()));
|
||||
self.out.push('<');
|
||||
self.out.push_str(&qname);
|
||||
let mut inner = self.namespaces(&layers, scope);
|
||||
// An element in no namespace inside a default namespace undeclares it.
|
||||
if top.tag_name().namespace().is_none() && inner.get(&None).is_some_and(|u| !u.is_empty()) {
|
||||
self.out.push_str(" xmlns=\"\"");
|
||||
inner.insert(None, "");
|
||||
}
|
||||
self.attributes(&layers, &inner);
|
||||
self.out.push('>');
|
||||
// Rich text is taken whole from the nearest layer that has any.
|
||||
let cut = if tag == "exData" { layers.iter().position(|l| has_content(l, true)).map_or(layers.len(), |i| i + 1) } else { layers.len() };
|
||||
let merged = layers.get(..cut).unwrap_or(&layers);
|
||||
// Data content: the nearest layer's that has any (empty content overrides nothing).
|
||||
let text_layer = merged.iter().position(|l| has_content(l, false)).unwrap_or(0);
|
||||
let chosen = if matches!(tag, "ui" | "value") {
|
||||
merged.iter().find_map(|l| l.children().find(|c| alternative(tag, c)).map(|c| c.tag_name().name()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Prototypes take no part in the form; of the alternatives one is kept. A class left
|
||||
// out is left out whole, so it need not be counted either.
|
||||
let left_out = |c: &Node| c.tag_name().name() == "proto" || alternative(tag, c) && chosen != Some(c.tag_name().name());
|
||||
// The deeper layers' children by (class, position among that class).
|
||||
let index = |l: &Node<'a, 'd>| {
|
||||
let mut counts: HashMap<&str, usize> = HashMap::new();
|
||||
let mut by: HashMap<(&'a str, usize), Node<'a, 'd>> = HashMap::new();
|
||||
for c in l.children().filter(|c| c.is_element() && !left_out(c)) {
|
||||
let k = counts.entry(c.tag_name().name()).or_insert(0);
|
||||
by.insert((c.tag_name().name(), *k), c);
|
||||
*k += 1;
|
||||
}
|
||||
by
|
||||
};
|
||||
let below: Vec<_> = merged.iter().skip(1).map(index).collect();
|
||||
let mut done: HashSet<(&str, usize)> = HashSet::new();
|
||||
for (i, layer) in merged.iter().enumerate() {
|
||||
let mut counts: HashMap<&str, usize> = HashMap::new();
|
||||
for c in layer.children() {
|
||||
if c.is_text() && i == text_layer {
|
||||
escape(c.text().unwrap_or_default(), false, &mut self.out);
|
||||
}
|
||||
if !c.is_element() || left_out(&c) {
|
||||
continue;
|
||||
}
|
||||
let ctag = c.tag_name().name();
|
||||
let n = {
|
||||
let k = counts.entry(ctag).or_insert(0);
|
||||
*k += 1;
|
||||
*k - 1
|
||||
};
|
||||
if !done.insert((ctag, n)) {
|
||||
continue;
|
||||
}
|
||||
let stack: Vec<Node<'a, 'd>> = std::iter::once(c).chain(below.iter().skip(i).filter_map(|b| b.get(&(ctag, n)).copied())).collect();
|
||||
self.element(&stack, &inner, depth + 1);
|
||||
}
|
||||
}
|
||||
self.out.push_str("</");
|
||||
self.out.push_str(&qname);
|
||||
self.out.push('>');
|
||||
for l in &layers {
|
||||
if let Some(k) = self.active.get_mut(&l.id()) {
|
||||
*k = k.saturating_sub(1);
|
||||
if *k == 0 {
|
||||
self.active.remove(&l.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Prototypes (`use`, `usehref`): synthetic templates only.
|
||||
|
||||
use crate::layout::{BorderShape, Item, WidgetKind};
|
||||
use crate::model::{Node, Placement, Template, Ui, Value};
|
||||
use crate::{layout_xml, parse};
|
||||
|
||||
const MM: f64 = 72.0 / 25.4;
|
||||
|
||||
/// A form whose root subform holds `styles` in a Designer-style sheet (a named subform inside
|
||||
/// `proto`) and positions `body` on one page.
|
||||
fn form(styles: &str, body: &str) -> String {
|
||||
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="form1" layout="tb">
|
||||
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform name="body" w="190mm" h="250mm">{body}</subform>
|
||||
<proto><subform name="designer__stylesheet">{styles}</subform></proto>
|
||||
</subform></template></xdp:xdp>"##
|
||||
)
|
||||
}
|
||||
|
||||
fn close(a: Option<f64>, b: f64) -> bool {
|
||||
a.is_some_and(|a| (a - b).abs() < 0.01)
|
||||
}
|
||||
|
||||
/// The nodes of the body subform.
|
||||
fn nodes(t: &Template) -> &[Node] {
|
||||
let Some(Node::Subform(b)) = t.root.children.first() else { panic!("no body subform: {:?}", t.root.children) };
|
||||
&b.children
|
||||
}
|
||||
|
||||
fn field<'t>(t: &'t Template, name: &str) -> &'t crate::model::Field {
|
||||
nodes(t)
|
||||
.iter()
|
||||
.find_map(|n| match n {
|
||||
Node::Field(f) if f.common.name.as_deref() == Some(name) => Some(f.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no field {name}"))
|
||||
}
|
||||
|
||||
const STYLES: &str = r#"
|
||||
<draw name="Heading" w="190.5mm" h="6mm" anchorType="middleLeft"><border><edge presence="hidden"/><fill><color value="153,153,153"/></fill></border><font typeface="Arial" size="12pt" weight="bold"/></draw>
|
||||
<field name="Numeric" h="5mm"><ui><numericEdit><border><edge stroke="lowered"/></border></numericEdit></ui><font typeface="Arial" size="9pt"/></field>
|
||||
<field name="Checkbox" h="6mm" anchorType="middleCenter"><ui><checkButton size="3.5278mm"><border><edge/></border></checkButton></ui><caption placement="right" reserve="30mm"><font typeface="Arial" size="9pt"/></caption><font typeface="Arial" size="9pt"/><items><integer>1</integer><integer>0</integer></items></field>"#;
|
||||
|
||||
const BODY: &str = r##"
|
||||
<draw name="Section" x="0mm" y="10mm" usehref=".#som($template.#subform.designer__stylesheet.Heading)"><value><text>Section</text></value></draw>
|
||||
<field name="Count" x="0mm" y="30mm" w="30mm" usehref="#som($template.#subform.designer__stylesheet.Numeric)"><ui><numericEdit/></ui></field>
|
||||
<field name="Agree" x="20mm" y="50mm" w="40mm" usehref=".#som($template.#subform.designer__stylesheet.Checkbox[0])"><caption><value><text>Agree</text></value></caption></field>"##;
|
||||
|
||||
#[test]
|
||||
fn fields_and_draws_take_size_border_fill_caption_and_font_from_a_style_sheet() {
|
||||
let xml = form(STYLES, BODY);
|
||||
let (t, warnings) = parse(&xml).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let Some(Node::Draw(section)) = nodes(&t).first() else { panic!("{:?}", nodes(&t)) };
|
||||
let c = §ion.common;
|
||||
assert_eq!(c.name.as_deref(), Some("Section"));
|
||||
assert!(close(c.w, 190.5 * MM) && close(c.h, 6.0 * MM), "{c:?}");
|
||||
assert_eq!((c.anchor.h, c.anchor.v), (0.0, 0.5));
|
||||
assert_eq!(c.border.as_ref().and_then(|b| b.fill).map(|f| f.0), Some([0.6; 3]));
|
||||
assert_eq!((c.font.typeface.as_deref(), c.font.bold), (Some("Arial"), Some(true)));
|
||||
assert_eq!(section.value, Value::Text("Section".into()));
|
||||
|
||||
let count = field(&t, "Count");
|
||||
assert_eq!(count.ui, Ui::NumericEdit);
|
||||
assert!(close(count.common.w, 30.0 * MM) && close(count.common.h, 5.0 * MM));
|
||||
assert_eq!(count.ui_border.as_ref().map(|b| b.visible_edges()), Some([true; 4]));
|
||||
assert_eq!(count.common.font.typeface.as_deref(), Some("Arial"));
|
||||
|
||||
let agree = field(&t, "Agree");
|
||||
assert_eq!(agree.ui, Ui::CheckButton);
|
||||
assert!(close(agree.check_size, 3.5278 * MM) && close(agree.common.h, 6.0 * MM));
|
||||
assert_eq!((agree.common.anchor.h, agree.common.anchor.v), (0.5, 0.5));
|
||||
let cap = agree.caption.as_ref().expect("caption");
|
||||
assert_eq!((cap.placement, cap.value.clone()), (Placement::Right, Value::Text("Agree".into())));
|
||||
assert_eq!(cap.font.typeface.as_deref(), Some("Arial"));
|
||||
assert_eq!(agree.items, vec!["1".to_string(), "0".to_string()]);
|
||||
|
||||
// Laid out: the heading's grey bar, the numeric field's box and the check box left of its
|
||||
// caption. The style sheet itself lays nothing out.
|
||||
let laid = layout_xml(&xml).unwrap();
|
||||
let items = &laid.pages.first().expect("a page").items;
|
||||
assert!(items.iter().any(|i| matches!(i, Item::Fill { rect, color } if *color == [0.6; 3] && (rect.w - 190.5 * MM).abs() < 0.01)), "{items:?}");
|
||||
let widgets: Vec<_> = items.iter().filter_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }).collect();
|
||||
assert_eq!(widgets.len(), 2, "{widgets:?}");
|
||||
let count = widgets.iter().find(|w| w.name.contains("Count")).expect("Count");
|
||||
assert_eq!(count.border.shape, BorderShape::Full);
|
||||
let check = widgets.iter().find(|w| matches!(w.kind, WidgetKind::CheckBox { .. })).expect("check box");
|
||||
let caption = items.iter().find_map(|i| if let Item::Text(s) = i { (s.text == "Agree").then_some(s.x) } else { None }).expect("caption");
|
||||
assert!(check.rect.x + check.rect.w <= caption, "box {:?}, caption at {caption}", check.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_an_element_declares_overrides_what_it_inherits() {
|
||||
let styles = r#"<field id="P" name="Proto" w="50mm" h="10mm"><font typeface="Courier" size="14pt" weight="bold"/><border><edge thickness="2pt"/><edge thickness="1pt"/></border><value><text>proto</text></value></field>
|
||||
<field id="Q" w="20mm"/><field id="R"><ui><numericEdit/></ui><value><decimal/></value></field>
|
||||
<draw id="T"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Proto <b>bold</b></p><p>second</p></body></exData></value></draw>"#;
|
||||
let body = r##"<field name="f" use="#P" h="7mm"><font size="9pt"/><border><edge thickness="3pt"/></border><value><text>own</text></value></field>
|
||||
<field use="#P"/>
|
||||
<field name="g" use="#P" usehref="#Q"/>
|
||||
<field name="t" use="#R"><ui><textEdit/></ui><value><text>7</text></value></field>
|
||||
<draw name="rich" use="#T"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Own</p></body></exData></value></draw>"##;
|
||||
let (t, warnings) = parse(&form(styles, body)).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let f = field(&t, "f");
|
||||
assert!(close(f.common.h, 7.0 * MM) && close(f.common.w, 50.0 * MM), "{:?}", f.common);
|
||||
assert_eq!((f.common.font.typeface.as_deref(), f.common.font.size, f.common.font.bold), (Some("Courier"), Some(9.0), Some(true)));
|
||||
// Edges match by position: the first overridden, the second inherited (Example 6.12).
|
||||
let edges = f.common.border.as_ref().expect("border").edges.map(|e| e.thickness);
|
||||
assert_eq!(edges, [3.0, 1.0, 3.0, 1.0]);
|
||||
assert_eq!(f.value, Value::Text("own".into()));
|
||||
assert_eq!(f.common.id, None, "a prototype's id is its own");
|
||||
// Neither the name nor the id is inherited, but the content is.
|
||||
let Some(Node::Field(unnamed)) = nodes(&t).get(1) else { panic!() };
|
||||
assert_eq!((unnamed.common.name.as_deref(), unnamed.value.clone()), (None, Value::Text("proto".into())));
|
||||
// usehref wins over use.
|
||||
assert!(close(field(&t, "g").common.w, 20.0 * MM));
|
||||
// A ui holds one widget and a value one kind of content: the nearer one replaces the other.
|
||||
let t_field = field(&t, "t");
|
||||
assert_eq!((t_field.ui.clone(), t_field.numeric, t_field.value.clone()), (Ui::TextEdit, false, Value::Text("7".into())));
|
||||
// Rich text is content: the nearer layer's is taken whole.
|
||||
let Some(Node::Draw(rich)) = nodes(&t).get(4) else { panic!() };
|
||||
let Value::Rich(r) = &rich.value else { panic!("{:?}", rich.value) };
|
||||
assert_eq!(r.plain(), "Own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prototypes_inherit_from_prototypes() {
|
||||
// Example 6.5: a font prototype using another, the second by a bare id.
|
||||
let styles = r##"<font id="HELV" typeface="Helvetica" size="10pt"/>
|
||||
<font id="HELV-RED" use="HELV"><fill><color value="255,0,0"/></fill></font>
|
||||
<field id="A" w="40mm"/><field id="B" use="#A" h="8mm"/><field id="C" usehref="#B"><font use="#HELV-RED"/></field>"##;
|
||||
let body = r##"<field name="f" usehref=".#C"/>"##;
|
||||
let (t, warnings) = parse(&form(styles, body)).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let f = field(&t, "f");
|
||||
assert!(close(f.common.w, 40.0 * MM) && close(f.common.h, 8.0 * MM), "{:?}", f.common);
|
||||
assert_eq!((f.common.font.typeface.as_deref(), f.common.font.size), (Some("Helvetica"), Some(10.0)));
|
||||
assert_eq!(f.common.font.color.map(|c| c.0), Some([1.0, 0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prototype_loops_end_with_a_warning() {
|
||||
let styles = r##"<field id="A" use="#B" w="10mm"/><field id="B" use="#A" h="12mm"/>
|
||||
<subform name="Client" id="ClientSubform" w="50mm"><subform name="ClientName" use="#ClientSubform"/></subform>"##;
|
||||
let body = r##"<field name="self" id="S" use="#S" w="1in"/>
|
||||
<field name="cycle" use="#A"/>
|
||||
<subform name="client" use="#ClientSubform"/>"##;
|
||||
let (t, warnings) = parse(&form(styles, body)).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("leads back to itself")), "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w.contains("longer than")), "a loop is cut where it closes: {warnings:?}");
|
||||
assert!(close(field(&t, "self").common.w, 72.0));
|
||||
let cycle = field(&t, "cycle");
|
||||
assert!(close(cycle.common.w, 10.0 * MM) && close(cycle.common.h, 12.0 * MM), "{:?}", cycle.common);
|
||||
// Example 6.8: the inner subform does not take its parent in again.
|
||||
let Some(Node::Subform(client)) = nodes(&t).get(2) else { panic!() };
|
||||
assert!(close(client.common.w, 50.0 * MM));
|
||||
let Some(Node::Subform(inner)) = client.children.first() else { panic!("{:?}", client.children) };
|
||||
assert_eq!(inner.common.name.as_deref(), Some("ClientName"));
|
||||
assert!(inner.children.is_empty() && inner.common.w.is_none(), "{inner:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_to_nothing_or_another_document_is_left_as_it_is() {
|
||||
let styles = r#"<field id="F" w="99mm"/>"#;
|
||||
let body = r##"<field name="nowhere" w="1in" usehref=".#som($template.#subform.designer__stylesheet.Missing)"/>
|
||||
<field name="noid" w="1in" use="#nope"/>
|
||||
<field name="far" w="1in" usehref="https://example.com/styles.xdp#F"/>
|
||||
<field name="bad" w="1in" usehref="#som($template.#subform[*])"/>
|
||||
<draw name="other" w="1in" use="#F"/>"##;
|
||||
let (t, warnings) = parse(&form(styles, body)).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("names nothing")), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("another document")), "{warnings:?}");
|
||||
for name in ["nowhere", "noid", "far", "bad"] {
|
||||
assert!(close(field(&t, name).common.w, 72.0), "{name}");
|
||||
}
|
||||
let Some(Node::Draw(other)) = nodes(&t).get(4) else { panic!() };
|
||||
assert!(close(other.common.w, 72.0), "a prototype is of the referencing element's class");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expansion_past_its_budget_leaves_the_template_unresolved_with_a_warning() {
|
||||
// 200 references to a 20 kB prototype would write 4 MB from a template of a few kB.
|
||||
let styles = format!(r#"<draw id="Big" w="10mm"><value><text>{}</text></value></draw>"#, "x".repeat(20_000));
|
||||
let body: String = (0..200).map(|i| format!(r##"<draw name="d{i}" use="#Big"/>"##)).collect();
|
||||
let started = std::time::Instant::now();
|
||||
let (t, warnings) = parse(&form(&styles, &body)).unwrap();
|
||||
assert!(started.elapsed().as_secs() < 10);
|
||||
assert!(warnings.iter().any(|w| w.contains("larger than 1 MB")), "{warnings:?}");
|
||||
let Some(Node::Draw(d)) = nodes(&t).first() else { panic!() };
|
||||
assert_eq!((d.common.w, &d.value), (None, &Value::Empty), "read as written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_references_neither_panic_nor_run_away() {
|
||||
let refs = [
|
||||
"#som(",
|
||||
"#som()",
|
||||
"#som($template.[)",
|
||||
"#som($template.#subform[99999999999999999999999])",
|
||||
"#som(.)",
|
||||
"#som(#)",
|
||||
"#",
|
||||
".#",
|
||||
"#som($template.#subform.designer__stylesheet.ü[0].x[1])",
|
||||
"#som(xfa.template.#subform[0].#proto)",
|
||||
"#\u{feff}",
|
||||
];
|
||||
let body: String = refs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| format!(r#"<field name="f{i}" w="1in" usehref="{r}"/><field name="u{i}" w="1in" use="{r}"/>"#))
|
||||
.collect();
|
||||
let (t, _) = parse(&form("", &body)).unwrap();
|
||||
assert!(close(field(&t, "f0").common.w, 72.0));
|
||||
// Prototypes six levels deep, each holding ten references to the next: a million elements.
|
||||
let mut styles = String::from(r#"<subform id="P6"><field w="1mm"/></subform>"#);
|
||||
for level in (1..6).rev() {
|
||||
let next = level + 1;
|
||||
let refs: String = (0..10).map(|_| format!(r##"<subform use="#P{next}"/>"##)).collect();
|
||||
styles.push_str(&format!(r#"<subform id="P{level}">{refs}</subform>"#));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let (_, warnings) = parse(&form(&styles, r##"<subform name="big" use="#P1"/>"##)).unwrap();
|
||||
assert!(started.elapsed().as_secs() < 10);
|
||||
assert!(warnings.iter().any(|w| w.contains("larger than")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_som_search_past_its_budget_leaves_the_template_unresolved_with_one_warning() {
|
||||
// 300 references, each to a style 10,000 elements into the sheet: 3 million elements looked
|
||||
// at, past the 2 million the look-ups share. The first ones resolve before the budget runs
|
||||
// out, so the template is either resolved in full or not at all.
|
||||
let styles: String = (0..10_000).map(|i| format!(r#"<field name="s{i}" w="1mm"/>"#)).collect();
|
||||
let body: String =
|
||||
(0..300).map(|i| format!(r##"<field name="f{i}" usehref="#som($template.#subform.designer__stylesheet.s{})"/>"##, 9_999 - i)).collect();
|
||||
let (t, warnings) = parse(&form(&styles, &body)).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("SOM references") && w.contains("read without resolving")), "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w.contains("names nothing")), "an interrupted search is not a missing target: {warnings:?}");
|
||||
assert_eq!(field(&t, "f0").common.w, None, "read as written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prototype_children_that_write_nothing_still_count_against_a_budget() {
|
||||
// A prototype with 20,000 `proto` children, used by 20,000 fields: the children write
|
||||
// nothing, but merging would look at 400 million of them.
|
||||
let protos = "<proto/>".repeat(20_000);
|
||||
let styles = format!(r#"<field id="P" w="1mm">{protos}</field>"#);
|
||||
let body = r##"<field use="#P"/>"##.repeat(20_000).replacen("<field ", r#"<field name="first" "#, 1);
|
||||
let started = std::time::Instant::now();
|
||||
let (t, warnings) = parse(&form(&styles, &body)).unwrap();
|
||||
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
|
||||
assert!(warnings.iter().any(|w| w.contains("children, attributes and namespaces") && w.contains("read without resolving")), "{warnings:?}");
|
||||
assert_eq!(field(&t, "first").common.w, None, "read as written");
|
||||
}
|
||||
|
||||
/// The template of `xml` written again by `expand`, parsed.
|
||||
fn expanded(xml: &str) -> String {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
let template = doc.descendants().find(|n| n.tag_name().name() == "template").unwrap();
|
||||
let (out, warnings) = crate::proto::expand(template);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
out.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespaces_bound_again_inside_the_template_keep_their_own_uri() {
|
||||
const XHTML: &str = "http://www.w3.org/1999/xhtml";
|
||||
// Rich text in a prototype and in a plain element: XHTML's default namespace inside the
|
||||
// template's (XFA's), and a prefix bound to one URI around an element and another inside.
|
||||
let styles = r#"<draw id="Note" w="40mm"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>See <a href="https://example.com/">the guide</a></p></body></exData></value></draw>"#;
|
||||
let body = r##"<draw name="note" x="0mm" y="0mm" use="#Note"/>
|
||||
<draw name="plain" x="0mm" y="20mm" w="40mm" xmlns:q="urn:outer" q:a="1"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml" xmlns:q="urn:inner"><p q:b="2">Plain</p></body></exData></value></draw>
|
||||
<draw name="none" x="0mm" y="40mm" w="40mm"><extras><free xmlns=""/></extras></draw>"##;
|
||||
let xml = form(styles, body);
|
||||
let out = expanded(&xml);
|
||||
let doc = roxmltree::Document::parse(&out).unwrap();
|
||||
let bodies: Vec<_> = doc.descendants().filter(|n| n.tag_name().name() == "body").collect();
|
||||
assert_eq!(bodies.len(), 2, "{out}");
|
||||
for b in &bodies {
|
||||
assert_eq!(b.tag_name().namespace(), Some(XHTML), "{out}");
|
||||
assert!(b.descendants().filter(|n| n.is_element()).all(|n| n.tag_name().namespace() == Some(XHTML)), "{out}");
|
||||
}
|
||||
let p = doc.descendants().find(|n| n.attribute(("urn:inner", "b")).is_some());
|
||||
assert!(p.is_some(), "a prefix bound again keeps the inner URI: {out}");
|
||||
assert!(doc.descendants().any(|n| n.attribute(("urn:outer", "a")) == Some("1")), "{out}");
|
||||
let free = doc.descendants().find(|n| n.tag_name().name() == "free").unwrap();
|
||||
assert_eq!(free.tag_name().namespace().unwrap_or_default(), "", "an undeclared default namespace stays undeclared: {out}");
|
||||
// And the form still reads the rich text and its link.
|
||||
let (t, warnings) = parse(&xml).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let Some(Node::Draw(note)) = nodes(&t).first() else { panic!() };
|
||||
let Value::Rich(paras) = ¬e.value else { panic!("{:?}", note.value) };
|
||||
assert!(
|
||||
paras.paragraphs.iter().flat_map(|p| &p.runs).any(|r| r.text.contains("the guide") && r.link.as_deref() == Some("https://example.com/")),
|
||||
"{paras:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_attributes_keep_their_namespace_where_the_prefix_is_bound_again() {
|
||||
// The element binds `q` to one URI and its prototype to another: the element's binding
|
||||
// wins, so the prototype's `q:` attributes need another prefix to keep their own URI.
|
||||
let styles = r#"<field id="P" xmlns:q="urn:prototype" q:kind="k" q:own="2" xml:lang="en" w="1in"/>"#;
|
||||
let body = r##"<field name="f" xmlns:q="urn:instance" q:own="1" use="#P"/>"##;
|
||||
let out = expanded(&form(styles, body));
|
||||
let doc = roxmltree::Document::parse(&out).unwrap();
|
||||
let f = doc.descendants().find(|n| n.attribute("name") == Some("f")).unwrap();
|
||||
assert_eq!(f.attribute(("urn:instance", "own")), Some("1"), "{out}");
|
||||
assert_eq!(f.attribute(("urn:prototype", "own")), Some("2"), "{out}");
|
||||
assert_eq!(f.attribute(("urn:prototype", "kind")), Some("k"), "{out}");
|
||||
assert_eq!(f.attribute(("urn:instance", "kind")), None, "{out}");
|
||||
assert_eq!(f.attribute(("http://www.w3.org/XML/1998/namespace", "lang")), Some("en"), "{out}");
|
||||
assert_eq!(f.attribute("w"), Some("1in"), "{out}");
|
||||
}
|
||||
|
||||
/// `expand` on the template of `xml`, timed.
|
||||
fn expand_timed(xml: &str) -> (Option<String>, Vec<String>, std::time::Duration) {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
let template = doc.descendants().find(|n| n.tag_name().name() == "template").unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let (out, warnings) = crate::proto::expand(template);
|
||||
(out, warnings, started.elapsed())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billion_laughs_prototypes_fail_whole_within_the_output_cap() {
|
||||
// Ten levels of prototypes, each holding ten references to the level below: a template of
|
||||
// under 3 kB that would write 10^10 fields. Expanding fails as a whole, quickly, and
|
||||
// nothing of it is used (no truncated template): the form is read as written.
|
||||
let mut styles = String::from(r#"<subform id="L10"><field w="1mm"/></subform>"#);
|
||||
for level in (1..10).rev() {
|
||||
let refs = format!(r##"<subform use="#L{}"/>"##, level + 1).repeat(10);
|
||||
styles.push_str(&format!(r#"<subform id="L{level}">{refs}</subform>"#));
|
||||
}
|
||||
let xml = form(&styles, r##"<subform name="bomb" use="#L1"/><field name="plain" w="1in"/>"##);
|
||||
assert!(xml.len() < 3_000, "{}", xml.len());
|
||||
let (out, warnings, took) = expand_timed(&xml);
|
||||
assert!(took.as_secs() < 5, "{took:?}");
|
||||
assert!(out.is_none(), "a failed expansion writes nothing for the parser to read");
|
||||
assert!(warnings.iter().any(|w| w.contains("larger than 1 MB") && w.contains("read without resolving")), "{warnings:?}");
|
||||
let (t, _) = parse(&xml).unwrap();
|
||||
let Some(Node::Subform(bomb)) = nodes(&t).first() else { panic!() };
|
||||
assert!(bomb.children.is_empty(), "read as written: {bomb:?}");
|
||||
assert!(close(field(&t, "plain").common.w, 72.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_designer_style_sheet_on_a_long_form_resolves_in_full() {
|
||||
// 3,000 fields and draws on Designer's style sheet, each through `#som(…)`: well inside
|
||||
// every budget, so all of them take their style and nothing is warned about.
|
||||
let styles: String = (0..40).map(|i| format!(r#"<field name="S{i}" h="{}mm"><font typeface="Arial" size="9pt"/></field>"#, i + 1)).collect();
|
||||
let body: String = (0..3_000)
|
||||
.map(|i| format!(r##"<field name="f{i}" w="20mm" usehref=".#som($template.#subform.designer__stylesheet.S{})"/>"##, i % 40))
|
||||
.collect();
|
||||
let xml = form(&styles, &body);
|
||||
let (out, warnings, took) = expand_timed(&xml);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert!(out.is_some() && took.as_secs() < 5, "{took:?}");
|
||||
let (t, warnings) = parse(&xml).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
for i in [0usize, 39, 1_234, 2_999] {
|
||||
let f = field(&t, &format!("f{i}"));
|
||||
assert!(close(f.common.h, (i % 40 + 1) as f64 * MM), "f{i}: {:?}", f.common);
|
||||
assert_eq!(f.common.font.size, Some(9.0), "f{i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_reference_shared_by_many_elements_is_resolved_once() {
|
||||
// One prototype whose own reference is a 1 MB `use` id, used by 60,000 fields: the output
|
||||
// stays small, so only reading that reference again for every field (60 GB of text) could
|
||||
// make this slow. A reference is resolved once per element.
|
||||
let long = "x".repeat(1 << 20);
|
||||
let styles = format!(r#"<field id="P" w="1mm" use="{long}"/><field id="{long}" h="2mm"/>"#);
|
||||
let body = r##"<field use="#P"/>"##.repeat(60_000).replacen("<field ", r#"<field name="first" "#, 1);
|
||||
let xml = form(&styles, &body);
|
||||
let (out, warnings, took) = expand_timed(&xml);
|
||||
assert!(took.as_secs() < 3, "{took:?}");
|
||||
assert!(out.is_some(), "{warnings:?}");
|
||||
let (t, _) = parse(&xml).unwrap();
|
||||
let first = field(&t, "first");
|
||||
assert!(close(first.common.w, MM) && close(first.common.h, 2.0 * MM), "{:?}", first.common);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_som_expression_past_its_length_limit_is_not_followed() {
|
||||
// A SOM expression longer than any Designer writes is not evaluated: it is warned about as
|
||||
// a reference naming nothing, and the rest of the template resolves.
|
||||
let padded = format!("$template.{}#subform.designer__stylesheet.Q", " ".repeat(5_000));
|
||||
let styles = r#"<field name="Q" h="2mm"/>"#;
|
||||
let body = format!(
|
||||
r##"<field name="long" w="1in" usehref="#som({padded})"/><field name="short" w="1in" usehref="#som($template.#subform.designer__stylesheet.Q)"/>"##
|
||||
);
|
||||
let (t, warnings) = parse(&form(styles, &body)).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("names nothing")), "{warnings:?}");
|
||||
assert_eq!(field(&t, "long").common.h, None);
|
||||
assert!(close(field(&t, "short").common.h, 2.0 * MM));
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Repeating subforms with many instances: every row the data holds is laid out, up to the
|
||||
//! template's `occur max` and the layout's instance limit, past which the rows left out are
|
||||
//! reported (XFA 3.3, "The occur element", p. 786; Chapter 9, p. 336).
|
||||
|
||||
use super::*;
|
||||
use crate::layout::MAX_INSTANCES;
|
||||
use crate::model::Occur;
|
||||
|
||||
/// A form with a flowed list (`table` false) or a table (`table` true) whose `item` row
|
||||
/// repeats with `occur max`, and data holding `rows` rows, each 20pt tall: 36 to a page.
|
||||
fn rows_form(max: &str, rows: usize, table: bool) -> String {
|
||||
let (list, row) = if table { (r#"layout="table" columnWidths="200pt""#, r#"layout="row""#) } else { (r#"layout="tb""#, r#"layout="tb""#) };
|
||||
let data: String = (1..=rows).map(|i| format!("<item><qty>row {i}</qty></item>")).collect();
|
||||
format!(
|
||||
r#"<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"><occur max="-1"/><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
<subform name="list" {list}>
|
||||
<subform name="item" {row}><occur min="1" max="{max}"/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
</subform>
|
||||
</subform></template>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><list>{data}</list></form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn qty_widgets(pages: &[Page]) -> Vec<&Widget> {
|
||||
pages.iter().flat_map(|p| p.items.iter()).filter_map(|it| if let Item::Widget(w) = it { Some(w.as_ref()) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// The values of the laid-out `qty` fields, in order.
|
||||
fn values(form: &Form) -> Vec<String> {
|
||||
qty_widgets(&form.pages).iter().map(|w| w.value.clone().unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
fn expected(n: usize) -> Vec<String> {
|
||||
(1..=n).map(|i| format!("row {i}")).collect()
|
||||
}
|
||||
|
||||
fn cut_warning(form: &Form) -> Option<&String> {
|
||||
form.warnings.iter().find(|w| w.contains("rows; only the first"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_data_row_past_fifty_is_laid_out_and_paginated() {
|
||||
for table in [false, true] {
|
||||
for (rows, pages) in [(60, 2), (300, 9)] {
|
||||
let form = layout_xml(&rows_form("-1", rows, table)).unwrap();
|
||||
assert_eq!(values(&form), expected(rows), "table {table}, {rows} rows");
|
||||
assert_eq!(form.pages.len(), pages, "table {table}, {rows} rows at 36 a page");
|
||||
assert_eq!(cut_warning(&form), None, "nothing is cut: {:?}", form.warnings);
|
||||
// Rows stay inside the content area of their page.
|
||||
for w in qty_widgets(&form.pages) {
|
||||
assert!(w.rect.y >= 36.0 - 0.01 && w.rect.bottom() <= 756.0 + 0.01, "{} at {:?}", w.name, w.rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occur_max_still_stops_the_rows_and_says_so() {
|
||||
for table in [false, true] {
|
||||
let form = layout_xml(&rows_form("5", 60, table)).unwrap();
|
||||
assert_eq!(values(&form), expected(5), "table {table}");
|
||||
let w = cut_warning(&form).unwrap_or_else(|| panic!("the 55 rows past occur max are reported: {:?}", form.warnings));
|
||||
assert!(w.contains("holds 60 rows") && w.contains("first 5") && !w.contains("the most"), "occur max is the form's own rule: {w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_past_the_instance_limit_are_cut_with_a_warning() {
|
||||
for table in [false, true] {
|
||||
// Exactly at the limit: all laid out, nothing reported.
|
||||
let form = layout_xml(&rows_form("-1", MAX_INSTANCES, table)).unwrap();
|
||||
assert_eq!(values(&form).len(), MAX_INSTANCES, "table {table}");
|
||||
assert_eq!(cut_warning(&form), None, "{:?}", form.warnings);
|
||||
// One past it: the last row is left out, and the warning says so.
|
||||
let form = layout_xml(&rows_form("-1", MAX_INSTANCES + 1, table)).unwrap();
|
||||
assert_eq!(values(&form), expected(MAX_INSTANCES), "table {table}");
|
||||
let w = cut_warning(&form).unwrap_or_else(|| panic!("rows past the limit are reported: {:?}", form.warnings));
|
||||
assert!(w.contains(&format!("holds {} rows", MAX_INSTANCES + 1)) && w.contains(&format!("first {MAX_INSTANCES}")), "{w}");
|
||||
assert!(w.contains(&format!("{MAX_INSTANCES} is the most one repeating subform lays out")), "the limit is named: {w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_initial_count_lays_out_whole_up_to_the_limit() {
|
||||
// `initial` comes from the template, not the data: past fifty it lays out whole, and a
|
||||
// hostile count stops at the limit (the parser reads it up to there).
|
||||
let tpl = |initial: &str| {
|
||||
rows_form("-1", 0, false).replace(r#"<occur min="1" max="-1"/>"#, &format!(r#"<occur min="1" initial="{initial}" max="-1"/>"#))
|
||||
};
|
||||
let form = layout_xml(&tpl("300")).unwrap();
|
||||
assert_eq!(qty_widgets(&form.pages).len(), 300);
|
||||
assert_eq!(form.pages.len(), 9);
|
||||
let form = layout_xml(&tpl("1000000000")).unwrap();
|
||||
assert_eq!(qty_widgets(&form.pages).len(), MAX_INSTANCES);
|
||||
assert_eq!(crate::layout::instance_count(&Occur { initial: usize::MAX, ..Occur::default() }, 0), 1, "max defaults to 1");
|
||||
let unbounded = Occur { initial: usize::MAX, max: None, ..Occur::default() };
|
||||
assert_eq!(crate::layout::instance_count(&unbounded, 0), MAX_INSTANCES);
|
||||
assert_eq!(crate::layout::instance_count(&unbounded, usize::MAX), MAX_INSTANCES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_cut_in_a_positioned_container_are_reported_too() {
|
||||
// The list sits in a positioned subform, so it is drawn rather than flowed: its `tb`,
|
||||
// `lr-tb` and `rl-tb` repeats are cut at the same bounds and must say so as a flowed one does.
|
||||
for layout in ["tb", "lr-tb", "rl-tb"] {
|
||||
let xml = rows_form("5", 60, false)
|
||||
.replace(r#"<subform name="form" layout="tb">"#, r#"<subform name="form" layout="tb"><subform name="page" w="540pt" h="720pt">"#)
|
||||
.replace("</subform></template>", "</subform></subform></template>")
|
||||
.replace(r#"<subform name="list" layout="tb">"#, &format!(r#"<subform name="list" x="0pt" y="0pt" w="540pt" layout="{layout}">"#))
|
||||
.replace("<xfa:data><form><list>", "<xfa:data><form><page><list>")
|
||||
.replace("</list></form></xfa:data>", "</list></page></form></xfa:data>");
|
||||
let form = layout_xml(&xml).unwrap();
|
||||
assert_eq!(values(&form), expected(5), "{layout}");
|
||||
let w = cut_warning(&form).unwrap_or_else(|| panic!("{layout}: the rows past occur max are reported: {:?}", form.warnings));
|
||||
assert!(w.contains("holds 60 rows") && w.contains("first 5"), "{layout}: {w}");
|
||||
}
|
||||
}
|
||||
+233
-97
@@ -3,11 +3,12 @@
|
||||
//! survive undo and reopening), and laying the form out again after scripts changed it.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, Object, PdfString};
|
||||
|
||||
use crate::data::{DataNode, data_path, parse_datasets};
|
||||
use crate::model::{Access, Field, Node, Presence, Script, SomIndices, Template};
|
||||
use crate::model::{Access, Field, Node, Presence, Props, Script, SomIndices, Template};
|
||||
use crate::{Report, XfaError};
|
||||
|
||||
/// Private key on the AcroForm: presence scripts set, by SOM path.
|
||||
@@ -15,6 +16,9 @@ pub const OVERRIDES_KEY: &[u8] = b"PCXfaOverrides";
|
||||
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_EVENTS: usize = 20_000;
|
||||
/// Most `/Fields` and `/Kids` entries [`fields_by_som`] looks at in all: fields already read, and
|
||||
/// entries that are not references, included.
|
||||
const MAX_FIELD_STEPS: usize = 1 << 20;
|
||||
/// Most bytes of script the events hold together: each instance of a repeated subform holds its
|
||||
/// own copy, so a long script repeated many times stops here (as much as the XFA packets hold).
|
||||
pub(crate) const MAX_EVENT_SCRIPT_BYTES: usize = 64 << 20;
|
||||
@@ -49,6 +53,9 @@ pub struct FormNode {
|
||||
pub children: Vec<FormNode>,
|
||||
/// Fields: the display picture (see [`Field::display_picture`]), for `formattedValue`.
|
||||
pub display_picture: Option<String>,
|
||||
/// The object's attributes and property elements as the template gives them, for scripts to
|
||||
/// read: shared with the template and every other instance of the object, never copied.
|
||||
pub props: Arc<Props>,
|
||||
}
|
||||
|
||||
/// A script the template runs for an event of one node instance.
|
||||
@@ -269,6 +276,7 @@ impl Walker<'_> {
|
||||
index,
|
||||
children: Vec::new(),
|
||||
display_picture: f.display_picture.clone(),
|
||||
props: Arc::clone(&f.common.props),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +341,7 @@ impl Walker<'_> {
|
||||
index,
|
||||
children,
|
||||
display_picture: None,
|
||||
props: Arc::clone(&sf.common.props),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -349,6 +358,7 @@ impl Walker<'_> {
|
||||
occur_max: Some(1),
|
||||
index: sib,
|
||||
children,
|
||||
props: Arc::clone(&a.common.props),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
@@ -360,6 +370,7 @@ impl Walker<'_> {
|
||||
som: som.clone(),
|
||||
kind: Some(NodeKind::Draw),
|
||||
value: d.value.plain().unwrap_or_default(),
|
||||
props: Arc::clone(&d.common.props),
|
||||
presence: self.presence(d.common.name.as_deref(), &som, d.common.presence),
|
||||
access: "open".into(),
|
||||
occur_min: 1,
|
||||
@@ -393,6 +404,7 @@ impl Walker<'_> {
|
||||
occur_max: Some(1),
|
||||
index: sib,
|
||||
children,
|
||||
props: Arc::clone(&g.common.props),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
@@ -406,32 +418,48 @@ impl Walker<'_> {
|
||||
/// from the template and `ov`) and every scripted event, in document order, within
|
||||
/// [`MAX_FORM_NODES`] objects.
|
||||
pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> LiveForm {
|
||||
form_tree_paged(tpl, data, ov, &HashMap::new())
|
||||
}
|
||||
|
||||
/// [`form_tree`] for a laid-out form whose page areas made `uses` pages each, by name
|
||||
/// ([`page_area_uses`]): the master content of each of those pages is a node of its own
|
||||
/// (`master[0]`, `master[1]`, …), as the layout names its fields, so a button repeated on every
|
||||
/// page runs its click script as itself. A page area not in `uses` gets one node.
|
||||
pub fn form_tree_paged(tpl: &Template, data: Option<&DataNode>, ov: &Overrides, uses: &HashMap<String, usize>) -> LiveForm {
|
||||
let mut w = Walker { tpl, data, ov, events: Vec::new(), script_bytes: 0, nodes: 0, truncated: false };
|
||||
let root = Node::Subform(Box::new(tpl.root.clone()));
|
||||
let mut nodes = w.children(std::slice::from_ref(&root), "", 0);
|
||||
let mut root_node = nodes.pop().unwrap_or_default();
|
||||
// Page masters' fields (Print and Save buttons, page numbers) belong to the layout model
|
||||
// in XFA; they hang off the root here, under their page area's name, as the layouter
|
||||
// names them, so their click scripts can run.
|
||||
// in XFA; they hang off the root here, under their page area's name, one node per page made
|
||||
// from it, as the layouter names them, so their click scripts can run.
|
||||
if let Some(ps) = &tpl.root.page_set {
|
||||
for (i, area) in ps.areas.iter().enumerate() {
|
||||
let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]"));
|
||||
let children = w.children(&area.items, &som, 1);
|
||||
if children.is_empty() {
|
||||
continue;
|
||||
// A name two areas share stays one node: its pages can't be told apart by it.
|
||||
let unique = area.name.as_ref().is_some_and(|n| ps.areas.iter().filter(|a| a.name.as_ref() == Some(n)).count() == 1);
|
||||
let pages = match &area.name {
|
||||
Some(n) if unique => uses.get(n).copied().unwrap_or(1).clamp(1, crate::layout::MAX_PAGES),
|
||||
_ => 1,
|
||||
};
|
||||
for page in 0..pages {
|
||||
let som = area.name.clone().map(|n| format!("{n}[{page}]")).unwrap_or_else(|| format!("#pageArea[{i}]"));
|
||||
let children = w.children(&area.items, &som, 1);
|
||||
if children.is_empty() {
|
||||
break;
|
||||
}
|
||||
root_node.children.push(FormNode {
|
||||
name: area.name.clone().unwrap_or_else(|| "#pageArea".into()),
|
||||
som,
|
||||
kind: Some(NodeKind::Area),
|
||||
presence: "visible".into(),
|
||||
access: "open".into(),
|
||||
occur_min: 1,
|
||||
occur_max: Some(1),
|
||||
index: page,
|
||||
children,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
root_node.children.push(FormNode {
|
||||
name: area.name.clone().unwrap_or_else(|| "#pageArea".into()),
|
||||
som,
|
||||
kind: Some(NodeKind::Area),
|
||||
presence: "visible".into(),
|
||||
access: "open".into(),
|
||||
occur_min: 1,
|
||||
occur_max: Some(1),
|
||||
index: 0,
|
||||
children,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
LiveForm { root: root_node, events: w.events, truncated: w.truncated }
|
||||
@@ -440,12 +468,15 @@ pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> Liv
|
||||
/// Does the template declare any script (an event, calculation or validation)? Forms without
|
||||
/// one need no live form and run nothing.
|
||||
pub fn has_scripts(tpl: &Template) -> bool {
|
||||
/// Is any of `s` a non-blank script?
|
||||
fn any(s: &[Script]) -> bool {
|
||||
s.iter().any(|s| !s.text.trim().is_empty())
|
||||
}
|
||||
/// Has field `f` an event, calculate or validate script?
|
||||
fn field(f: &Field) -> bool {
|
||||
any(&f.scripts) || [&f.calculate, &f.validate].into_iter().flatten().any(|s| !s.text.trim().is_empty())
|
||||
}
|
||||
/// Has any object among `nodes` (and their containers, `depth` levels down) a script?
|
||||
fn walk(nodes: &[Node], depth: usize) -> bool {
|
||||
depth <= MAX_DEPTH
|
||||
&& nodes.iter().any(|n| match n {
|
||||
@@ -459,81 +490,105 @@ pub fn has_scripts(tpl: &Template) -> bool {
|
||||
any(&tpl.root.scripts) || walk(&tpl.root.children, 1) || tpl.root.page_set.as_ref().is_some_and(|ps| ps.areas.iter().any(|a| walk(&a.items, 1)))
|
||||
}
|
||||
|
||||
/// The template with `ov` applied to its objects (instance 0 of each; scripts that hide one
|
||||
/// instance of a repeating subform hide them all).
|
||||
pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
|
||||
/// Set the presence `ov` gives each of `nodes` (children of `parent_som`) and their descendants,
|
||||
/// down to [`MAX_DEPTH`], and the access it gives a field outside an exclusion group. An unnamed
|
||||
/// draw, area or subform shares its parent's SOM path, so it takes no override of its own.
|
||||
fn walk(nodes: &mut [Node], parent_som: &str, ov: &Overrides, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let sibs = SomIndices::of(nodes);
|
||||
for (i, n) in nodes.iter_mut().enumerate() {
|
||||
let sib = sibs.at(i);
|
||||
let name_default = match n {
|
||||
Node::Field(_) => Some("field"),
|
||||
Node::ExclGroup(_) => Some("group"),
|
||||
_ => None,
|
||||
};
|
||||
let name = n.common().name.as_deref().or(name_default);
|
||||
let som = child_som(parent_som, name, sib);
|
||||
// An unnamed draw, area or subform has no SOM name: `som` is its parent's, and so are
|
||||
// the overrides under it.
|
||||
let presence = name.and_then(|_| ov.presence.get(&som)).map(|p| Presence::parse(Some(p)));
|
||||
let access = name.and_then(|_| ov.access.get(&som)).cloned();
|
||||
match n {
|
||||
Node::Subform(s) => {
|
||||
if let Some(p) = presence {
|
||||
s.common.presence = p;
|
||||
}
|
||||
walk(&mut s.children, &som, ov, depth + 1);
|
||||
/// Does any field of the template (in the body, an exclusion group or a master page) have an
|
||||
/// `enter` or `exit` script? Forms without one have nothing to run when the focus moves.
|
||||
pub fn has_focus_scripts(tpl: &Template) -> bool {
|
||||
/// Has field `f` a non-blank enter or exit script?
|
||||
fn field(f: &Field) -> bool {
|
||||
f.scripts.iter().any(|s| matches!(s.activity.as_str(), "enter" | "exit") && !s.text.trim().is_empty())
|
||||
}
|
||||
/// Has any field among `nodes` (and their containers, `depth` levels down) one?
|
||||
fn walk(nodes: &[Node], depth: usize) -> bool {
|
||||
depth <= MAX_DEPTH
|
||||
&& nodes.iter().any(|n| match n {
|
||||
Node::Subform(sf) => walk(&sf.children, depth + 1),
|
||||
Node::Area(a) => walk(&a.children, depth + 1),
|
||||
Node::Field(f) => field(f),
|
||||
Node::ExclGroup(g) => g.fields.iter().any(field),
|
||||
Node::Draw(_) => false,
|
||||
})
|
||||
}
|
||||
walk(&tpl.root.children, 1) || tpl.root.page_set.as_ref().is_some_and(|ps| ps.areas.iter().any(|a| walk(&a.items, 1)))
|
||||
}
|
||||
|
||||
/// Set the presence `ov` gives each of `nodes` (children of `parent_som`) and their descendants,
|
||||
/// down to [`MAX_DEPTH`], and the access it gives a field outside an exclusion group. An unnamed
|
||||
/// draw, area or subform shares its parent's SOM path, so it takes no override of its own.
|
||||
pub(crate) fn apply_overrides_to(nodes: &mut [Node], parent_som: &str, ov: &Overrides, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let sibs = SomIndices::of(nodes);
|
||||
for (i, n) in nodes.iter_mut().enumerate() {
|
||||
let sib = sibs.at(i);
|
||||
let name_default = match n {
|
||||
Node::Field(_) => Some("field"),
|
||||
Node::ExclGroup(_) => Some("group"),
|
||||
_ => None,
|
||||
};
|
||||
let name = n.common().name.as_deref().or(name_default);
|
||||
let som = child_som(parent_som, name, sib);
|
||||
// An unnamed draw, area or subform has no SOM name: `som` is its parent's, and so are
|
||||
// the overrides under it.
|
||||
let presence = name.and_then(|_| ov.presence.get(&som)).map(|p| Presence::parse(Some(p)));
|
||||
let access = name.and_then(|_| ov.access.get(&som)).cloned();
|
||||
match n {
|
||||
Node::Subform(s) => {
|
||||
if let Some(p) = presence {
|
||||
s.common.presence = p;
|
||||
}
|
||||
Node::Area(a) => {
|
||||
if let Some(p) = presence {
|
||||
a.common.presence = p;
|
||||
}
|
||||
walk(&mut a.children, &som, ov, depth + 1);
|
||||
apply_overrides_to(&mut s.children, &som, ov, depth + 1);
|
||||
}
|
||||
Node::Area(a) => {
|
||||
if let Some(p) = presence {
|
||||
a.common.presence = p;
|
||||
}
|
||||
Node::Field(f) => {
|
||||
if let Some(p) = presence {
|
||||
f.common.presence = p;
|
||||
}
|
||||
if let Some(a) = access {
|
||||
f.access = match a.as_str() {
|
||||
"readOnly" => Access::ReadOnly,
|
||||
"protected" => Access::Protected,
|
||||
"nonInteractive" => Access::NonInteractive,
|
||||
_ => Access::Open,
|
||||
};
|
||||
}
|
||||
apply_overrides_to(&mut a.children, &som, ov, depth + 1);
|
||||
}
|
||||
Node::Field(f) => {
|
||||
if let Some(p) = presence {
|
||||
f.common.presence = p;
|
||||
}
|
||||
Node::Draw(d) => {
|
||||
if let Some(p) = presence {
|
||||
d.common.presence = p;
|
||||
}
|
||||
if let Some(a) = access {
|
||||
f.access = match a.as_str() {
|
||||
"readOnly" => Access::ReadOnly,
|
||||
"protected" => Access::Protected,
|
||||
"nonInteractive" => Access::NonInteractive,
|
||||
_ => Access::Open,
|
||||
};
|
||||
}
|
||||
Node::ExclGroup(g) => {
|
||||
if let Some(p) = presence {
|
||||
g.common.presence = p;
|
||||
}
|
||||
let fields: Vec<Node> = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
|
||||
let fsibs = SomIndices::of(&fields);
|
||||
for (fi, f) in g.fields.iter_mut().enumerate() {
|
||||
let fsom = child_som(&som, f.common.name.as_deref().or(Some("field")), fsibs.at(fi));
|
||||
if let Some(p) = ov.presence.get(&fsom) {
|
||||
f.common.presence = Presence::parse(Some(p));
|
||||
}
|
||||
}
|
||||
Node::Draw(d) => {
|
||||
if let Some(p) = presence {
|
||||
d.common.presence = p;
|
||||
}
|
||||
}
|
||||
Node::ExclGroup(g) => {
|
||||
if let Some(p) = presence {
|
||||
g.common.presence = p;
|
||||
}
|
||||
let fields: Vec<Node> = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
|
||||
let fsibs = SomIndices::of(&fields);
|
||||
for (fi, f) in g.fields.iter_mut().enumerate() {
|
||||
let fsom = child_som(&som, f.common.name.as_deref().or(Some("field")), fsibs.at(fi));
|
||||
if let Some(p) = ov.presence.get(&fsom) {
|
||||
f.common.presence = Presence::parse(Some(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The template with `ov` applied to its objects (instance 0 of each; scripts that hide one
|
||||
/// instance of a repeating subform hide them all). Page-master content is left as it is: a page
|
||||
/// area makes many pages, each with its own SOM path, so `ov` is applied to it page by page as
|
||||
/// the pages are laid out ([`crate::layout::layout_with_overrides`]).
|
||||
pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
|
||||
let mut t = tpl.clone();
|
||||
if !ov.is_empty() {
|
||||
let mut root = vec![Node::Subform(Box::new(t.root.clone()))];
|
||||
walk(&mut root, "", ov, 0);
|
||||
apply_overrides_to(&mut root, "", ov, 0);
|
||||
if let Some(Node::Subform(r)) = root.pop() {
|
||||
t.root = *r;
|
||||
}
|
||||
@@ -543,12 +598,35 @@ pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
|
||||
|
||||
/// The generated fields of the AcroForm by SOM path: `som → (field name, is a radio group)`.
|
||||
pub fn fields_by_som(doc: &Document) -> HashMap<String, String> {
|
||||
fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, out: &mut HashMap<String, String>, depth: usize) {
|
||||
if depth > MAX_DEPTH || out.len() > 100_000 {
|
||||
return;
|
||||
// Each field is read once: a field listed twice in `/Kids` (or among its own kids) would
|
||||
// otherwise be walked again for every path to it, doubling the work at each level. Fields
|
||||
// are told apart by object number, as `Document::get` finds them, so `5 0 R` and `5 1 R`
|
||||
// are one. A field is read again only when it is reached shallower than before: first reached
|
||||
// at `MAX_DEPTH`, its kids would otherwise never be read. `steps` counts every entry looked at, references or not, so fields sharing one
|
||||
// long `/Kids` array stay bounded too; `walk_all` returns false once it is spent, and every
|
||||
// loop then stops.
|
||||
struct Walk {
|
||||
out: HashMap<String, String>,
|
||||
/// The shallowest depth each object number was read at.
|
||||
seen: HashMap<u32, usize>,
|
||||
steps: usize,
|
||||
}
|
||||
fn walk_all(doc: &Document, kids: &[Object], prefix: &str, w: &mut Walk, depth: usize) -> bool {
|
||||
kids.iter().all(|k| {
|
||||
w.steps += 1;
|
||||
if w.steps > MAX_FIELD_STEPS || w.out.len() > 100_000 {
|
||||
return false;
|
||||
}
|
||||
k.as_ref().is_none_or(|r| walk(doc, r, prefix, w, depth))
|
||||
})
|
||||
}
|
||||
fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, w: &mut Walk, depth: usize) -> bool {
|
||||
if depth > MAX_DEPTH || w.seen.get(&r.num).is_some_and(|&read| read <= depth) {
|
||||
return true;
|
||||
}
|
||||
w.seen.insert(r.num, depth);
|
||||
let o = doc.get(r);
|
||||
let Some(d) = o.as_dict() else { return };
|
||||
let Some(d) = o.as_dict() else { return true };
|
||||
let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|s| s.to_text()));
|
||||
let name = match (&t, prefix.is_empty()) {
|
||||
(Some(t), true) => t.clone(),
|
||||
@@ -558,19 +636,75 @@ pub fn fields_by_som(doc: &Document) -> HashMap<String, String> {
|
||||
if let Some(som) = d.get(crate::pdf::SOM_KEY).map(|s| doc.resolve(s)).and_then(|s| s.as_string().map(|s| s.to_text()))
|
||||
&& t.is_some()
|
||||
{
|
||||
out.insert(som, name.clone());
|
||||
w.out.insert(som, name.clone());
|
||||
}
|
||||
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
|
||||
for k in kids.iter().filter_map(Object::as_ref) {
|
||||
walk(doc, k, &name, out, depth + 1);
|
||||
// Read in place: a copy of the array at every level would cost its length each time.
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k));
|
||||
walk_all(doc, kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), &name, w, depth + 1)
|
||||
}
|
||||
let Some((_, acro)) = acroform(doc) else { return HashMap::new() };
|
||||
let fields = acro.get(b"Fields").map(|f| doc.resolve(f));
|
||||
let mut w = Walk { out: HashMap::new(), seen: HashMap::new(), steps: 0 };
|
||||
walk_all(doc, fields.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), "", &mut w, 0);
|
||||
w.out
|
||||
}
|
||||
|
||||
/// What a reset gives the field or exclusion group at `som` in the data: its template default
|
||||
/// as saved (a choice list's item value, not its shown text, as [`Field::saved_value`] gives
|
||||
/// it), or nothing when it has none. A group's default is the on value of the first of its
|
||||
/// buttons whose own default is that on value. `None` for an object a reset restores no value
|
||||
/// of: not a field or group of the template, or a button, signature or picture field.
|
||||
pub fn reset_default(tpl: &Template, som: &str) -> Option<String> {
|
||||
match crate::data::template_object(tpl, som)? {
|
||||
Node::Field(f) => {
|
||||
if matches!(f.ui, crate::model::Ui::Button | crate::model::Ui::Signature | crate::model::Ui::ImageEdit) {
|
||||
return None;
|
||||
}
|
||||
Some(f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default())
|
||||
}
|
||||
Node::ExclGroup(g) => Some(
|
||||
g.fields
|
||||
.iter()
|
||||
.find_map(|f| {
|
||||
let on = f.items.first().map(|s| s.trim()).filter(|s| !s.is_empty())?;
|
||||
(f.value.plain()?.trim() == on).then(|| on.to_string())
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many pages the layout made from each named page area of `tpl`: the master content of the
|
||||
/// n-th page from area `master` is `master[n-1]`. Each page the layout wrote names its area
|
||||
/// ([`crate::AREA_KEY`]), so a page whose master fields are all hidden still counts; a file laid
|
||||
/// out before pages were named shows it by its generated fields. Areas neither shows are left out
|
||||
/// (one page's worth, to [`form_tree_paged`]).
|
||||
pub fn page_area_uses(doc: &Document, tpl: &Template) -> HashMap<String, usize> {
|
||||
let mut out: HashMap<String, usize> = HashMap::new();
|
||||
let Some(ps) = &tpl.root.page_set else { return out };
|
||||
let is_area = |name: &str| tpl.root.common.name.as_deref() != Some(name) && ps.areas.iter().any(|a| a.name.as_deref() == Some(name));
|
||||
// The flat page list laying the form out writes.
|
||||
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|c| c.get(b"Pages")).map(|p| doc.resolve(p)));
|
||||
let kids =
|
||||
pages.and_then(|p| p.as_dict().and_then(|d| d.get(b"Kids")).map(|k| doc.resolve(k))).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
for page in kids.iter().filter_map(Object::as_ref).take(crate::layout::MAX_PAGES) {
|
||||
let page = doc.get(page);
|
||||
let Some(name) = page.as_dict().and_then(|d| d.get(crate::AREA_KEY)).and_then(Object::as_string).map(PdfString::to_text) else { continue };
|
||||
if is_area(&name) {
|
||||
let pages = out.entry(name).or_insert(0);
|
||||
*pages = pages.saturating_add(1).min(crate::layout::MAX_PAGES);
|
||||
}
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
let Some((_, acro)) = acroform(doc) else { return out };
|
||||
let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
|
||||
for f in fields.iter().filter_map(Object::as_ref) {
|
||||
walk(doc, f, "", &mut out, 0);
|
||||
for som in fields_by_som(doc).keys() {
|
||||
let first = som.split('.').next().unwrap_or_default();
|
||||
let Some((name, rest)) = first.split_once('[') else { continue };
|
||||
let Some(index) = rest.strip_suffix(']').and_then(|i| i.parse::<usize>().ok()) else { continue };
|
||||
if !is_area(name) {
|
||||
continue;
|
||||
}
|
||||
let pages = out.entry(name.to_string()).or_insert(1);
|
||||
*pages = (*pages).max(index.saturating_add(1)).min(crate::layout::MAX_PAGES);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -582,7 +716,7 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result<Report, XfaError>
|
||||
let data = parse_datasets(&packets.xdp);
|
||||
let ov = overrides(doc);
|
||||
let laid = apply_overrides(tpl, &ov);
|
||||
let form = crate::layout(&laid, data.as_ref())?;
|
||||
let form = crate::layout::layout_with_overrides(&laid, data.as_ref(), &ov)?;
|
||||
if form.pages.is_empty() {
|
||||
return Err(XfaError::Malformed("the template laid out to no pages".into()));
|
||||
}
|
||||
@@ -656,7 +790,9 @@ pub fn template_of(doc: &Document) -> Result<Template, XfaError> {
|
||||
if packets.xdp.trim().is_empty() {
|
||||
return Err(XfaError::Malformed("the XFA packets hold no template".into()));
|
||||
}
|
||||
crate::parse(&packets.xdp).map(|(t, _)| t)
|
||||
let (mut tpl, _) = crate::parse(&packets.xdp)?;
|
||||
crate::linked_images::resolve(&mut tpl, doc);
|
||||
Ok(tpl)
|
||||
}
|
||||
|
||||
/// Whether the template field at `som` is multiline text without a fixed height.
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
//! A table (`layout="table"`, its rows `layout="row"`) placed in positioned content repeats a
|
||||
//! row subform once per instance, as a table in flowed content does: the template's `occur`
|
||||
//! and the data's rows decide how many (XFA 3.3, "Tables", p. 327; "Repeating Elements using
|
||||
//! Occurrence Limits", p. 263; Chapter 9, p. 336), and the data rows past `occur max` or the
|
||||
//! layout's own limit are reported, not dropped silently.
|
||||
|
||||
use super::*;
|
||||
use crate::layout::Item;
|
||||
use crate::model::Rect;
|
||||
|
||||
/// Where the table sits.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Place {
|
||||
/// At its own `x`/`y` in a positioned subform.
|
||||
Positioned,
|
||||
/// In a top-to-bottom subform at a position in a positioned subform, with a field after it.
|
||||
PositionedFlow,
|
||||
/// In the flowed body, with a field after it.
|
||||
Flowed,
|
||||
}
|
||||
|
||||
/// A form with a table whose header row (`head`, shown once) and footer row (`foot`) frame a
|
||||
/// repeating `item` row with `occur` attributes `occur`, and data holding `rows` items (and two
|
||||
/// header rows, which the template shows once). Each row is 20pt tall.
|
||||
fn table_form(place: Place, occur: &str, rows: usize) -> String {
|
||||
let items: String = (1..=rows).map(|i| format!("<item><qty>row {i}</qty></item>")).collect();
|
||||
let table = r#"<subform name="table" layout="table" columnWidths="200pt" POS><border><edge/></border>
|
||||
<subform name="head" layout="row"><field name="label" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
<subform name="item" layout="row"><occur OCCUR/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
<subform name="foot" layout="row"><field name="total" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
</subform>"#
|
||||
.replace("OCCUR", occur);
|
||||
let after = r#"<field name="after" w="200pt" h="20pt"><ui><textEdit/></ui></field>"#;
|
||||
let (page, data) = match place {
|
||||
Place::Positioned => {
|
||||
(format!(r#"<subform name="page1" w="540pt" h="700pt">{}</subform>"#, table.replace("POS", r#"x="36pt" y="72pt""#)), "page1")
|
||||
}
|
||||
Place::PositionedFlow => (
|
||||
format!(
|
||||
r#"<subform name="page1" w="540pt" h="700pt"><subform name="wrap" layout="tb" w="200pt" x="36pt" y="72pt">{}{after}</subform></subform>"#,
|
||||
table.replace("POS", "")
|
||||
),
|
||||
"page1><wrap",
|
||||
),
|
||||
Place::Flowed => (format!(r#"<subform name="page1" layout="tb" w="540pt">{}{after}</subform>"#, table.replace("POS", "")), "page1"),
|
||||
};
|
||||
let close: String = data.split("><").collect::<Vec<_>>().iter().rev().map(|n| format!("</{n}>")).collect();
|
||||
format!(
|
||||
r#"<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"><occur max="-1"/><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
{page}
|
||||
</subform></template>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><{data}><table><head><label>H</label></head><head><label>H2</label></head>{items}<foot><total>T</total></foot></table>{close}</form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A laid-out widget: (field name, SOM, value, rect).
|
||||
type Laid = (String, String, Option<String>, Rect);
|
||||
|
||||
/// The laid-out widgets, in order.
|
||||
fn laid_out(form: &Form) -> Vec<Laid> {
|
||||
form.pages
|
||||
.iter()
|
||||
.flat_map(|p| p.items.iter())
|
||||
.filter_map(|it| if let Item::Widget(w) = it { Some((w.name.clone(), w.som.clone(), w.value.clone(), w.rect)) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn named<'a>(all: &'a [Laid], field: &str) -> Vec<&'a Laid> {
|
||||
all.iter().filter(|w| w.1.ends_with(&format!(".{field}[0]"))).collect()
|
||||
}
|
||||
|
||||
fn qty_values(all: &[Laid]) -> Vec<String> {
|
||||
named(all, "qty").iter().map(|w| w.2.clone().unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
fn expected(n: usize) -> Vec<String> {
|
||||
(1..=n).map(|i| format!("row {i}")).collect()
|
||||
}
|
||||
|
||||
fn cut_warning(form: &Form) -> Option<&String> {
|
||||
form.warnings.iter().find(|w| w.contains("rows; only the first"))
|
||||
}
|
||||
|
||||
/// Header, the rows and footer one under another, 20pt apart, from the header's top.
|
||||
fn assert_stacked(all: &[Laid], rows: usize, what: &str) {
|
||||
let head = named(all, "label");
|
||||
assert_eq!(head.len(), 1, "{what}: the header row is shown once: {head:?}");
|
||||
let top = head[0].3.y;
|
||||
let qty = named(all, "qty");
|
||||
assert_eq!(qty.len(), rows, "{what}");
|
||||
for (i, w) in qty.iter().enumerate() {
|
||||
assert!((w.3.y - (top + 20.0 * (i as f64 + 1.0))).abs() < 0.01, "{what}: row {i} at {:?}, header at {top}", w.3);
|
||||
}
|
||||
let foot = named(all, "total");
|
||||
assert_eq!(foot.len(), 1, "{what}: the footer row is shown once");
|
||||
assert!((foot[0].3.y - (top + 20.0 * (rows as f64 + 1.0))).abs() < 0.01, "{what}: footer at {:?} after {rows} rows", foot[0].3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_in_positioned_content_gets_a_row_per_data_row() {
|
||||
for place in [Place::Positioned, Place::PositionedFlow] {
|
||||
let form = layout_xml(&table_form(place, r#"min="1" max="-1""#, 5)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
assert_eq!(qty_values(&all), expected(5), "{place:?}");
|
||||
let soms: Vec<&str> = named(&all, "qty").iter().map(|w| w.1.as_str()).collect();
|
||||
let prefix = if matches!(place, Place::Positioned) { "form[0].page1[0].table[0]" } else { "form[0].page1[0].wrap[0].table[0]" };
|
||||
let want: Vec<String> = (0..5).map(|i| format!("{prefix}.item[{i}].qty[0]")).collect();
|
||||
assert_eq!(soms, want, "{place:?}: each row with its own SOM index");
|
||||
assert_stacked(&all, 5, &format!("{place:?}"));
|
||||
assert_eq!(named(&all, "label")[0].2.as_deref(), Some("H"), "{place:?}: the header takes the first header data row");
|
||||
assert!(cut_warning(&form).is_none(), "{place:?}: {:?}", form.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_in_positioned_content_is_measured_with_its_rows() {
|
||||
// What follows the table in a top-to-bottom container comes after the footer, not after one
|
||||
// row: the table is measured with every instance.
|
||||
let form = layout_xml(&table_form(Place::PositionedFlow, r#"min="1" max="-1""#, 5)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
let foot = named(&all, "total")[0].3;
|
||||
let after = named(&all, "after")[0].3;
|
||||
assert!((after.y - foot.bottom()).abs() < 0.01, "after {after:?}, footer {foot:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occur_max_bounds_a_positioned_table_and_the_rows_cut_are_reported() {
|
||||
for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
|
||||
let form = layout_xml(&table_form(place, r#"min="1" max="3""#, 5)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
assert_eq!(qty_values(&all), expected(3), "{place:?}");
|
||||
assert_stacked(&all, 3, &format!("{place:?}"));
|
||||
let warning = cut_warning(&form).unwrap_or_else(|| panic!("{place:?}: {:?}", form.warnings));
|
||||
assert!(warning.contains("item") && warning.contains("5 rows") && warning.contains("first 3"), "{place:?}: {warning}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_data_rows_a_positioned_table_takes_its_initial_count() {
|
||||
let form = layout_xml(&table_form(Place::Positioned, r#"min="1" max="-1" initial="2""#, 0)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
assert_stacked(&all, 2, "initial 2");
|
||||
assert!(cut_warning(&form).is_none(), "{:?}", form.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_positioned_table_lays_out_the_rows_a_flowed_one_does() {
|
||||
// One rule for both: the same rows, values, spacing and warnings, wherever the table sits.
|
||||
for (occur, rows) in [(r#"min="1" max="-1""#, 5), (r#"min="2" max="4""#, 7), (r#"min="1" max="-1""#, 0), (r#"min="3""#, 1)] {
|
||||
let flowed = layout_xml(&table_form(Place::Flowed, occur, rows)).unwrap();
|
||||
let positioned = layout_xml(&table_form(Place::Positioned, occur, rows)).unwrap();
|
||||
let (f, p) = (laid_out(&flowed), laid_out(&positioned));
|
||||
assert_eq!(qty_values(&p), qty_values(&f), "{occur} with {rows} rows");
|
||||
let offsets = |all: &[Laid]| -> Vec<f64> {
|
||||
let top = named(all, "label").first().map_or(0.0, |w| w.3.y);
|
||||
all.iter().filter(|w| w.0 != "after").map(|w| w.3.y - top).collect()
|
||||
};
|
||||
assert_eq!(offsets(&p), offsets(&f), "{occur} with {rows} rows");
|
||||
assert_eq!(cut_warning(&positioned).is_some(), cut_warning(&flowed).is_some(), "{occur} with {rows} rows");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_occur_values_and_data_floods_lay_a_positioned_table_out_within_bounds() {
|
||||
for occur in [
|
||||
r#"min="-3" max="-7" initial="99999999999999999999""#,
|
||||
r#"min="abc" max="1e9" initial="-1""#,
|
||||
r#"min="999999" max="999999" initial="999999""#,
|
||||
r#"max="0""#,
|
||||
r#"min="0" max="0" initial="0""#,
|
||||
] {
|
||||
for rows in [0, 4] {
|
||||
let form = layout_xml(&table_form(Place::Positioned, occur, rows)).unwrap();
|
||||
let flowed = layout_xml(&table_form(Place::Flowed, occur, rows)).unwrap();
|
||||
assert_eq!(qty_values(&laid_out(&form)).len(), qty_values(&laid_out(&flowed)).len(), "{occur} with {rows} rows");
|
||||
}
|
||||
}
|
||||
// More data rows than the layout lays out: bounded, and said.
|
||||
let rows = 1_200;
|
||||
let form = layout_xml(&table_form(Place::Positioned, r#"min="1" max="-1""#, rows)).unwrap();
|
||||
let n = qty_values(&laid_out(&form)).len();
|
||||
assert!((1..rows).contains(&n), "{n} of {rows} rows");
|
||||
let warning = cut_warning(&form).unwrap_or_else(|| panic!("{:?}", form.warnings));
|
||||
assert!(warning.contains(&format!("{rows} rows")) && warning.contains(&format!("first {n}")), "{warning}");
|
||||
}
|
||||
|
||||
/// A repeating positioned `entry` subform, each instance holding a positioned table whose `item`
|
||||
/// rows repeat, with a field after the entries; the data gives entry `i` `rows[i]` rows.
|
||||
fn nested_form(place: Place, rows: &[usize]) -> String {
|
||||
let entries: String = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(e, n)| {
|
||||
let items: String = (1..=*n).map(|i| format!("<item><qty>{e}.{i}</qty></item>")).collect();
|
||||
format!("<entry><table>{items}</table></entry>")
|
||||
})
|
||||
.collect();
|
||||
let entry = r#"<subform name="entry" w="200pt"><occur min="1" max="-1"/>
|
||||
<subform name="table" layout="table" columnWidths="200pt" x="0pt" y="0pt">
|
||||
<subform name="item" layout="row"><occur min="1" max="-1"/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
</subform></subform>"#;
|
||||
let after = r#"<field name="after" w="200pt" h="20pt"><ui><textEdit/></ui></field>"#;
|
||||
let (page, open, close) = match place {
|
||||
Place::PositionedFlow => (
|
||||
format!(
|
||||
r#"<subform name="page1" w="540pt" h="700pt"><subform name="wrap" layout="tb" w="200pt" x="36pt" y="72pt">{entry}{after}</subform></subform>"#
|
||||
),
|
||||
"<page1><wrap>",
|
||||
"</wrap></page1>",
|
||||
),
|
||||
_ => (format!(r#"<subform name="page1" layout="tb" w="540pt">{entry}{after}</subform>"#), "<page1>", "</page1>"),
|
||||
};
|
||||
format!(
|
||||
r#"<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"><occur max="-1"/><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
{page}
|
||||
</subform></template>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form>{open}{entries}{close}</form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_instance_of_a_repeating_container_is_measured_with_its_own_rows() {
|
||||
// Instances of one repeating subform hold different numbers of rows in the data (XFA 3.3,
|
||||
// "Tables", p. 327; Chapter 9, p. 336): each takes the height of its own rows, so the rows
|
||||
// of one never run into the next instance or what follows.
|
||||
for place in [Place::Flowed, Place::PositionedFlow] {
|
||||
for rows in [[1, 5], [5, 1], [2, 3]] {
|
||||
let what = format!("{place:?} {rows:?}");
|
||||
let form = layout_xml(&nested_form(place, &rows)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
let qty = named(&all, "qty");
|
||||
let want: Vec<String> = rows.iter().enumerate().flat_map(|(e, n)| (1..=*n).map(move |i| format!("{e}.{i}"))).collect();
|
||||
assert_eq!(qty.iter().map(|w| w.2.clone().unwrap_or_default()).collect::<Vec<_>>(), want, "{what}");
|
||||
let top = qty[0].3.y;
|
||||
for (i, w) in qty.iter().enumerate() {
|
||||
assert!((w.3.y - (top + 20.0 * i as f64)).abs() < 0.01, "{what}: row {i} at {:?}, first at {top}", w.3);
|
||||
}
|
||||
let after = named(&all, "after")[0].3;
|
||||
let last = qty[qty.len() - 1].3;
|
||||
assert!((after.y - last.bottom()).abs() < 0.01, "{what}: after {after:?}, last row {last:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_row_instance_takes_the_height_of_its_own_growing_text() {
|
||||
// A repeating row whose cell grows with its text (a multi-line field without a height) is
|
||||
// as tall as its own instance's value (XFA 3.3, "Tables", p. 327; "Growable Containers",
|
||||
// p. 271): a long value makes its row taller and pushes the rows after it down, wherever
|
||||
// the table sits.
|
||||
let long = "a long value that wraps over several lines of the cell ".repeat(6);
|
||||
for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
|
||||
let xml = table_form(place, r#"min="1" max="-1""#, 3)
|
||||
.replace(
|
||||
r#"<field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field>"#,
|
||||
r#"<field name="qty" w="200pt" minH="20pt"><ui><textEdit multiLine="1"/></ui></field>"#,
|
||||
)
|
||||
.replace("<qty>row 2</qty>", &format!("<qty>{long}</qty>"));
|
||||
let form = layout_xml(&xml).unwrap();
|
||||
let all = laid_out(&form);
|
||||
let qty = named(&all, "qty");
|
||||
assert_eq!(qty.len(), 3, "{place:?}");
|
||||
let (short, tall) = (qty[0].3, qty[1].3);
|
||||
assert!(tall.h > short.h + 20.0, "{place:?}: the long row {tall:?} is no taller than a short one {short:?}");
|
||||
for pair in qty.windows(2) {
|
||||
let (a, b) = (pair[0].3, pair[1].3);
|
||||
assert!((b.y - a.bottom()).abs() < 0.01, "{place:?}: row at {b:?} after {a:?}");
|
||||
}
|
||||
let foot = named(&all, "total")[0].3;
|
||||
assert!((foot.y - qty[2].3.bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", qty[2].3);
|
||||
if let Some(after) = named(&all, "after").first() {
|
||||
assert!((after.3.y - foot.bottom()).abs() < 0.01, "{place:?}: after {:?}, footer {foot:?}", after.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A multi-line `qty` text field 100pt wide that grows with its text, with `access` and
|
||||
/// template value `value`.
|
||||
fn qty_cell(access: &str, value: &str) -> String {
|
||||
format!(
|
||||
r#"<field name="qty" w="100pt" minH="20pt" access="{access}"><ui><textEdit multiLine="1"/></ui><value><text>{value}</text></value></field>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// [`table_form`] with two same-named cells per `item` row, `qty[0]` and `qty[1]` (`first` and
|
||||
/// `second`), in two 100pt columns, and the data of row `i` holding `data[i]`.
|
||||
fn same_name_cells_form(place: Place, first: &str, second: &str, data: [&str; 3]) -> String {
|
||||
let mut xml = table_form(place, r#"min="1" max="-1""#, 3)
|
||||
.replace(r#"columnWidths="200pt""#, r#"columnWidths="100pt 100pt""#)
|
||||
.replace(r#"<field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field>"#, &format!("{first}{second}"));
|
||||
for (i, d) in data.iter().enumerate() {
|
||||
xml = xml.replace(&format!("<item><qty>row {}</qty></item>", i + 1), &format!("<item>{d}</item>"));
|
||||
}
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_is_as_tall_as_its_tallest_same_named_cell() {
|
||||
// Each cell of a row is measured under its own SOM index among its same-named siblings,
|
||||
// the index it is drawn and bound at (XFA 3.3, "Tables", p. 327; "Growable Containers",
|
||||
// p. 271; Chapter 9, p. 336): a short `qty[0]` beside a long `qty[1]` makes the row as tall
|
||||
// as the long one, and the rows and content after it start below it.
|
||||
let long = "a long value that wraps over several lines of the cell".repeat(6);
|
||||
let cell = qty_cell("open", "");
|
||||
for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
|
||||
let data = ["<qty>s1</qty><qty>t1</qty>", &format!("<qty>s2</qty><qty>{long}</qty>"), "<qty>s3</qty><qty>t3</qty>"];
|
||||
let form = layout_xml(&same_name_cells_form(place, &cell, &cell, data)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
let first = named(&all, "qty");
|
||||
let second: Vec<&Laid> = all.iter().filter(|w| w.1.ends_with(".qty[1]")).collect();
|
||||
assert_eq!(first.iter().map(|w| w.2.clone().unwrap_or_default()).collect::<Vec<_>>(), ["s1", "s2", "s3"], "{place:?}");
|
||||
assert_eq!(second.len(), 3, "{place:?}");
|
||||
assert_eq!(second[1].2.as_deref().map(str::trim), Some(long.trim()), "{place:?}");
|
||||
let tall = second[1].3;
|
||||
assert!(tall.h > 60.0, "{place:?}: the long cell {tall:?} did not grow");
|
||||
// Both cells of the row take its height, and the next row starts below it.
|
||||
assert!((first[1].3.h - tall.h).abs() < 0.01, "{place:?}: qty[0] {:?} beside qty[1] {tall:?}", first[1].3);
|
||||
for cells in [&first, &second] {
|
||||
for pair in cells.windows(2) {
|
||||
let (a, b) = (pair[0].3, pair[1].3);
|
||||
assert!((b.y - a.bottom()).abs() < 0.01, "{place:?}: row at {b:?} after {a:?}");
|
||||
}
|
||||
}
|
||||
let foot = named(&all, "total")[0].3;
|
||||
assert!((foot.y - second[2].3.bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", second[2].3);
|
||||
if let Some(after) = named(&all, "after").first() {
|
||||
assert!((after.3.y - foot.bottom()).abs() < 0.01, "{place:?}: after {:?}, footer {foot:?}", after.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_non_interactive_same_named_cell_stays_in_its_row() {
|
||||
// A non-interactive `qty[1]` is drawn as text, not as a widget; measured under its own SOM
|
||||
// index (where the data holds nothing, so its long template value is what shows), each row
|
||||
// holds its lines, and none run into the row after it or the footer.
|
||||
let long = "longword ".repeat(12);
|
||||
for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
|
||||
let data = ["<qty>s1</qty>", "<qty>s2</qty>", "<qty>s3</qty>"];
|
||||
let form = layout_xml(&same_name_cells_form(place, &qty_cell("open", ""), &qty_cell("nonInteractive", &long), data)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
let rows: Vec<Rect> = named(&all, "qty").iter().map(|w| w.3).collect();
|
||||
assert_eq!(rows.len(), 3, "{place:?}");
|
||||
let lines: Vec<f64> = form
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| p.items.iter())
|
||||
.filter_map(|it| if let Item::Text(s) = it { Some(s) } else { None })
|
||||
.filter(|s| s.text.contains("longword"))
|
||||
.map(|s| s.baseline)
|
||||
.collect();
|
||||
assert!(lines.len() >= 6, "{place:?}: {lines:?}");
|
||||
let per_row: Vec<usize> = rows.iter().map(|r| lines.iter().filter(|&&b| b > r.y && b <= r.bottom()).count()).collect();
|
||||
assert_eq!(per_row.iter().sum::<usize>(), lines.len(), "{place:?}: lines {lines:?} outside rows {rows:?}");
|
||||
assert!(per_row.iter().all(|&n| n == per_row[0] && n > 1), "{place:?}: lines per row {per_row:?}");
|
||||
let foot = named(&all, "total")[0].3;
|
||||
assert!((foot.y - rows[2].bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", rows[2]);
|
||||
}
|
||||
}
|
||||
+2073
-6
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user