mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
fix(engine): coalesce script resetForm requests and bound console output per edit (#5975)
This commit is contained in:
1 parent
afaaaa6415
commit
ee6ae8eac4
1 file changed
+108
-13
+108
-13
@@ -26,29 +26,70 @@ pub struct JsOutput {
|
||||
pub doc_errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Console lines one output keeps, from every script an edit runs together (a form's
|
||||
/// calculations, say): each run's own cap ([`pdfcraft_js::xfa::MAX_CONSOLE`]) would otherwise
|
||||
/// be multiplied by the number of fields a document declares.
|
||||
pub const MAX_CONSOLE_LINES: usize = 10_000;
|
||||
/// Bytes of console text one output keeps, for the same reason.
|
||||
pub const MAX_CONSOLE_BYTES: usize = 1 << 20;
|
||||
/// Alerts one output keeps.
|
||||
pub const MAX_ALERTS: usize = 1_000;
|
||||
|
||||
impl JsOutput {
|
||||
/// Whether scripts produced nothing: no alerts, console lines, requests or errors.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty()
|
||||
}
|
||||
|
||||
/// Adds `lines` to the console within [`MAX_CONSOLE_LINES`] and [`MAX_CONSOLE_BYTES`]; what
|
||||
/// is past them is left out, which one closing line says.
|
||||
fn say(&mut self, lines: impl IntoIterator<Item = String>) {
|
||||
let note = format!("more console lines were left out: the scripts printed more than {MAX_CONSOLE_LINES} lines or {MAX_CONSOLE_BYTES} bytes");
|
||||
// Once the notice is written the console is closed: nothing may follow it, and a later
|
||||
// rejection must not add a second copy.
|
||||
if self.console.last() == Some(¬e) {
|
||||
return;
|
||||
}
|
||||
let mut bytes: usize = self.console.iter().map(String::len).sum();
|
||||
let mut dropped = false;
|
||||
for l in lines {
|
||||
if self.console.len() >= MAX_CONSOLE_LINES || bytes.saturating_add(l.len()) > MAX_CONSOLE_BYTES {
|
||||
dropped = true;
|
||||
break;
|
||||
}
|
||||
bytes += l.len();
|
||||
self.console.push(l);
|
||||
}
|
||||
if dropped && !self.console.iter().any(|l| l == ¬e) {
|
||||
self.console.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `alerts` within [`MAX_ALERTS`].
|
||||
fn alert(&mut self, alerts: impl IntoIterator<Item = Message>) {
|
||||
let room = MAX_ALERTS.saturating_sub(self.alerts.len());
|
||||
self.alerts.extend(alerts.into_iter().take(room));
|
||||
}
|
||||
|
||||
/// Adds what a field's own script produced: its alerts, console lines, errors and requests,
|
||||
/// except a request to show a side panel or open Save As, which is noted on the console
|
||||
/// and dropped.
|
||||
fn absorb(&mut self, o: &Outcome) {
|
||||
self.alerts.extend(o.alerts.iter().cloned());
|
||||
self.console.extend(o.console.iter().cloned());
|
||||
self.alert(o.alerts.iter().cloned());
|
||||
self.say(o.console.iter().cloned());
|
||||
// A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
|
||||
// button's may show or hide a side panel, or open Save As.
|
||||
// The warnings go through `say` like any other console line, so they count against the
|
||||
// line and byte caps instead of growing the console past them.
|
||||
let mut notes = Vec::new();
|
||||
for r in &o.requests {
|
||||
match r {
|
||||
Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()),
|
||||
Request::SaveAs => {
|
||||
self.console.push("A field script asked to save the document as a new file; only a button may open Save As".into())
|
||||
}
|
||||
Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()),
|
||||
Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()),
|
||||
r => self.requests.push(r.clone()),
|
||||
}
|
||||
}
|
||||
self.say(notes);
|
||||
self.errors.extend(o.error.iter().cloned());
|
||||
// The document scripts run before every field script, so a failing one would repeat
|
||||
// once per calculation; it is reported once.
|
||||
@@ -61,8 +102,8 @@ impl JsOutput {
|
||||
}
|
||||
|
||||
pub(crate) fn append(&mut self, other: JsOutput) {
|
||||
self.alerts.extend(other.alerts);
|
||||
self.console.extend(other.console);
|
||||
self.alert(other.alerts);
|
||||
self.say(other.console);
|
||||
self.requests.extend(other.requests);
|
||||
// As in `absorb`: a document script's error, already reported, is not repeated; a field
|
||||
// script's errors are each their own, even one worded like a document script's.
|
||||
@@ -330,11 +371,9 @@ impl Session {
|
||||
if !changes.is_empty() {
|
||||
edits.push(Edit::ApplyScriptChanges { changes });
|
||||
}
|
||||
for names in resets {
|
||||
let all: Vec<String> = fields.iter().map(|f| f.name.clone()).collect();
|
||||
let names = (!names.is_empty())
|
||||
.then(|| all.iter().filter(|n| names.iter().any(|x| *n == x || n.starts_with(&format!("{x}.")))).cloned().collect());
|
||||
edits.push(Edit::ResetForm { names });
|
||||
if !resets.is_empty() {
|
||||
let all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
|
||||
edits.push(Edit::ResetForm { names: reset_targets(&all, &resets) });
|
||||
}
|
||||
if !edits.is_empty() {
|
||||
self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?;
|
||||
@@ -343,6 +382,21 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields every `resetForm` call of one script asked for, as one reset: a reset is to the
|
||||
/// defaults, so resetting the union once does what each call in turn would, and recalculates
|
||||
/// once. `None` is the whole form (a call with no names). A field is named by a selector that
|
||||
/// is its name or the name of a field above it, looked up in a set of the selectors, so the
|
||||
/// work is the fields times their depth, not the fields times the selectors, which a script
|
||||
/// controls.
|
||||
fn reset_targets(fields: &[&str], requests: &[Vec<String>]) -> Option<Vec<String>> {
|
||||
if requests.iter().any(Vec::is_empty) {
|
||||
return None;
|
||||
}
|
||||
let wanted: std::collections::HashSet<&str> = requests.iter().flatten().map(String::as_str).collect();
|
||||
let matched = |name: &str| name.match_indices('.').map(|(i, _)| &name[..i]).chain(std::iter::once(name)).any(|p| wanted.contains(p));
|
||||
Some(fields.iter().filter(|n| matched(n)).map(|n| n.to_string()).collect())
|
||||
}
|
||||
|
||||
/// A page's words for form-field detection, in user space (underscore runs split from the
|
||||
/// text around them).
|
||||
fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec<pdfcraft_forms::detect::Word> {
|
||||
@@ -492,6 +546,47 @@ impl Session {
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
|
||||
/// Many `resetForm` calls with many selectors over a big form: one reset of the union, found
|
||||
/// by looking each field's name and parents up, not by comparing every field with every
|
||||
/// selector.
|
||||
#[test]
|
||||
fn reset_requests_are_coalesced_and_matched_through_an_index() {
|
||||
let fields: Vec<String> = (0..20_000).map(|i| format!("group{}.row{i}.cell", i % 100)).collect();
|
||||
let names: Vec<&str> = fields.iter().map(String::as_str).collect();
|
||||
let requests: Vec<Vec<String>> = (0..1_000).map(|k| (0..20).map(|j| format!("nothing{}", k * 20 + j)).collect()).collect();
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(reset_targets(&names, &requests), Some(Vec::new()), "no selector names a field");
|
||||
let mut requests = requests;
|
||||
requests.push(vec!["group7".into(), "group7.row7.cell".into(), "group8.row8".into()]);
|
||||
let hit = reset_targets(&names, &requests).unwrap();
|
||||
assert_eq!(hit.len(), 200 + 1, "group7 has 200 fields, one more by its exact name");
|
||||
assert!(hit.iter().all(|n| n.starts_with("group7.") || n == "group8.row8.cell"));
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
|
||||
requests.push(Vec::new());
|
||||
assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form");
|
||||
}
|
||||
|
||||
/// A form of many calculated fields, each printing its own cap of console lines, keeps a
|
||||
/// bounded console: past the budget, lines are left out and one closing line says so.
|
||||
#[test]
|
||||
fn console_output_of_one_edit_is_bounded() {
|
||||
let mut out = JsOutput::default();
|
||||
let line = "x".repeat(4_096);
|
||||
let run = Outcome { console: vec![line; 1_000], alerts: vec![Message::plain("hi"); 100], ..Default::default() };
|
||||
for _ in 0..1_000 {
|
||||
out.absorb(&run);
|
||||
}
|
||||
let bytes: usize = out.console.iter().map(String::len).sum();
|
||||
assert!(bytes <= MAX_CONSOLE_BYTES + 256, "{bytes} bytes kept");
|
||||
assert!(out.console.len() <= MAX_CONSOLE_LINES + 1, "{} lines kept", out.console.len());
|
||||
assert!(out.console.last().unwrap().starts_with("more console lines were left out"));
|
||||
assert_eq!(out.console.iter().filter(|l| l.starts_with("more console lines")).count(), 1, "said once");
|
||||
assert_eq!(out.alerts.len(), MAX_ALERTS);
|
||||
let mut merged = JsOutput::default();
|
||||
merged.append(out);
|
||||
assert!(merged.console.len() <= MAX_CONSOLE_LINES + 1);
|
||||
}
|
||||
|
||||
/// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with
|
||||
/// the word so far without reading the word again.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user