mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 01:41:18 +00:00
Merge pull request #6985 from FredApps/port-4751
feat: LaTeX animate playback — page actions, timers, getOCGs, view-only overrides (port of #4751)
This commit is contained in:
42 files changed
+1913
-62
No files matched your search
@@ -82,6 +82,60 @@ impl Automation {
|
||||
))
|
||||
}
|
||||
|
||||
/// `js_page`: read the document at a page, running page actions as the viewer does (#169).
|
||||
pub(crate) fn js_page(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let page = self.page(a)?;
|
||||
let clock = self.session.script_clock(id).unwrap_or(0.0);
|
||||
let run = self.session.show_page(id, page, clock);
|
||||
// What the actions showed or hid is in the document's view settings.
|
||||
self.renderers.remove(&id);
|
||||
Ok(self.script_run_json(id, &run))
|
||||
}
|
||||
|
||||
/// `js_advance`: move the script clock on, running the timers that fall due on the way, in
|
||||
/// the order they do.
|
||||
pub(crate) fn js_advance(&mut self, a: &Args) -> Result<Value> {
|
||||
/// Most timer runs one call makes (a 10 ms interval for a minute).
|
||||
const MAX_RUNS: usize = 6000;
|
||||
let id = self.doc(a)?.id;
|
||||
let ms = a.opt_num("ms")?.ok_or_else(|| ToolError::InvalidArgs("ms is required".into()))?;
|
||||
if !(0.0..=3_600_000.0).contains(&ms) {
|
||||
return Err(ToolError::InvalidArgs("ms must be between 0 and 3600000".into()));
|
||||
}
|
||||
let start = self.session.script_clock(id).unwrap_or(0.0);
|
||||
let end = start + ms;
|
||||
let mut total = pdfcraft_engine::ScriptRun::default();
|
||||
while total.ran < MAX_RUNS {
|
||||
let Some(at) = self.session.next_timer(id).filter(|at| *at <= end) else { break };
|
||||
let run = self.session.run_timers_at_most(id, at.max(start), MAX_RUNS - total.ran);
|
||||
if run.ran == 0 {
|
||||
break;
|
||||
}
|
||||
merge_runs(&mut total, run);
|
||||
}
|
||||
let behind = self.session.next_timer(id).is_some_and(|at| at <= end);
|
||||
// The clock ends where it was sent; a timer the run cap left behind counts on from there,
|
||||
// and runs at the next call, not past the cap.
|
||||
let last = self.session.run_timers_at_most(id, end, MAX_RUNS - total.ran);
|
||||
merge_runs(&mut total, last);
|
||||
self.renderers.remove(&id);
|
||||
let mut out = self.script_run_json(id, &total);
|
||||
out["ran"] = json!(total.ran);
|
||||
out["stopped_early"] = json!(behind);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn script_run_json(&mut self, id: pdfcraft_engine::DocId, run: &pdfcraft_engine::ScriptRun) -> Value {
|
||||
let reqs: Vec<Value> = run.output.requests.iter().map(request_json).collect();
|
||||
let alerts: Vec<Value> = run.output.alerts.iter().map(alert_json).collect();
|
||||
let pages: Vec<usize> = run.pages.iter().map(|p| p + 1).collect();
|
||||
let clock = self.session.script_clock(id).unwrap_or(0.0);
|
||||
let next = self.session.next_timer(id).map(|at| (at - clock).max(0.0));
|
||||
json!({ "alerts": alerts, "console": run.output.console, "requests": reqs, "errors": run.output.errors,
|
||||
"pages": pages, "edited": run.edited, "layers": run.layers, "clock_ms": clock, "next_timer_in_ms": next })
|
||||
}
|
||||
|
||||
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result<Value> {
|
||||
let list: Vec<Value> = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect();
|
||||
Ok(json!({ "scripts": list }))
|
||||
@@ -285,3 +339,16 @@ impl Automation {
|
||||
Ok(json!({ "enabled": self.session.javascript() }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds what `run` did to `total`.
|
||||
fn merge_runs(total: &mut pdfcraft_engine::ScriptRun, run: pdfcraft_engine::ScriptRun) {
|
||||
total.ran += run.ran;
|
||||
total.edited |= run.edited;
|
||||
total.layers |= run.layers;
|
||||
total.output.append(run.output);
|
||||
for p in run.pages {
|
||||
if !total.pages.contains(&p) {
|
||||
total.pages.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -837,6 +837,8 @@ impl Automation {
|
||||
"ocr_recognize" => self.ocr_recognize(&a)?,
|
||||
"js_run" => self.js_run(&a)?,
|
||||
"js_event" => self.js_event(&a)?,
|
||||
"js_page" => self.js_page(&a)?,
|
||||
"js_advance" => self.js_advance(&a)?,
|
||||
"js_document_scripts" => self.js_document_scripts(&a)?,
|
||||
"js_set_document_script" => self.js_set_document_script(&a)?,
|
||||
"js_enabled" => self.js_enabled(&a)?,
|
||||
|
||||
@@ -1443,6 +1443,10 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("js_page", "Run page actions", "Read the document at `page`, as the viewer does when that page becomes the one being read: the actions the page it was at runs as it closes, then this page's open actions (its own, and its annotations' page-open and page-visible actions, which start an animation made with the LaTeX animate package), in the document's JavaScript context, which buttons, the console and timers share. Fields the actions show or hide change only how the document is shown, and page_render draws them so; values they change are applied as one undoable step. Returns the actions' alerts, console output, requests and errors, the pages showing a field they showed or hid, and when the next timer falls due on the script clock (js_advance moves it).")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
t("js_advance", "Advance the script clock", "Move the document's script clock on by `ms` milliseconds and run the timers its scripts set (app.setInterval, app.setTimeOut) as they fall due on the way, in order, as the viewer runs them over time: an animation shows its next frames. Fields timers show or hide change only how the document is shown, and page_render draws them so. A timer's alerts and requests go to its console output instead. Returns how many timers ran, their console output and errors, the pages showing a field they showed or hid, the clock, and when the next timer falls due.")
|
||||
.with(schema(json!({ "doc": doc(), "ms": { "type": "number", "minimum": 0, "maximum": 3600000 } }), &["doc", "ms"])),
|
||||
t("js_document_scripts", "Document JavaScripts", "List the document-level JavaScripts (name and source), which define functions field scripts use.")
|
||||
.ro()
|
||||
.cmd("tools.document_js")
|
||||
|
||||
@@ -18,6 +18,7 @@ mod model_3d;
|
||||
mod model_render;
|
||||
mod office;
|
||||
mod optimize_content_boundaries;
|
||||
mod page_actions;
|
||||
mod portfolio_info;
|
||||
mod redact_after_stray_q_form;
|
||||
mod redact_text_after_q;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Page actions and timers through the tool table (#169): `js_page` reads a page as the viewer
|
||||
//! does, `js_advance` moves the script clock on, and `page_render` draws what they show.
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
|
||||
match a.call(name, &args).unwrap().remove(0) {
|
||||
Content::Json(value) => value,
|
||||
other => panic!("expected JSON: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two pages; on the first, two frames over one another (red showing, green hidden) and a
|
||||
/// page-open action flipping them every 100 ms, with a page-close action stopping it.
|
||||
fn animation_pdf() -> Vec<u8> {
|
||||
let fill = |rgb: &str| {
|
||||
let body = format!("{rgb} rg 0 0 100 100 re f");
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{body}\nendstream", body.len())
|
||||
};
|
||||
let open = "var on = 0; function flip() { getField('f' + on).display = display.hidden; on = 1 - on; getField('f' + on).display = display.visible; } \
|
||||
var t = app.setInterval('flip()', 100);";
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 10 0 R] /Count 2 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] /AA << /O << /S /JavaScript /JS 8 0 R >> /C << /S /JavaScript /JS 9 0 R >> >> >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (f0) /F 4 /Rect [0 0 200 300] /P 3 0 R /AP << /N 6 0 R >> >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (f1) /F 6 /Rect [0 0 200 300] /P 3 0 R /AP << /N 7 0 R >> >>".into(),
|
||||
fill("1 0 0"),
|
||||
fill("0 1 0"),
|
||||
format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()),
|
||||
"<< /Length 20 >>\nstream\napp.clearInterval(t)\nendstream".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_page_and_js_advance_run_an_animation_that_page_render_draws() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-page-actions-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("anim.pdf"), animation_pdf()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = tool(&mut a, "doc_open", json!({ "path": "anim.pdf" }))["doc"].as_u64().unwrap();
|
||||
let centre = |a: &mut Automation| {
|
||||
let (w, h, px) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
let i = ((h / 2 * w + w / 2) * 4) as usize;
|
||||
px[i..i + 3].to_vec()
|
||||
};
|
||||
assert_eq!(centre(&mut a), [255, 0, 0]);
|
||||
let out = tool(&mut a, "js_page", json!({ "doc": id, "page": 1 }));
|
||||
assert_eq!((out["errors"].clone(), out["next_timer_in_ms"].clone()), (json!([]), json!(100.0)), "{out}");
|
||||
let out = tool(&mut a, "js_advance", json!({ "doc": id, "ms": 150 }));
|
||||
assert_eq!((out["ran"].clone(), out["pages"].clone(), out["edited"].clone()), (json!(1), json!([1]), json!(false)), "{out}");
|
||||
assert_eq!(out["next_timer_in_ms"], json!(50.0));
|
||||
assert_eq!(centre(&mut a), [0, 255, 0], "page_render draws the frame the timer showed");
|
||||
let out = tool(&mut a, "js_advance", json!({ "doc": id, "ms": 1000 }));
|
||||
assert_eq!(out["ran"], json!(10), "{out}");
|
||||
assert_eq!(centre(&mut a), [0, 255, 0]);
|
||||
assert!(a.call("js_advance", &json!({ "doc": id, "ms": 3_600_001 })).is_err());
|
||||
let out = tool(&mut a, "js_page", json!({ "doc": id, "page": 2 }));
|
||||
assert_eq!(out["next_timer_in_ms"], Value::Null, "the page's close action cleared the interval: {out}");
|
||||
let doc = a.session().get(pdfcraft_engine::DocId(id)).unwrap();
|
||||
assert!(!doc.dirty && doc.undo_depth() == 0, "the frames turned in the view only");
|
||||
}
|
||||
|
||||
/// A page whose open action starts a 10 ms interval.
|
||||
const INTERVAL: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /AA << /O << /S /JavaScript /JS (var n = 0; app.setInterval\\('n++', 10\\);) >> >> >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// `js_advance` runs at most 6,000 timers, even with one still due where the clock ends: that
|
||||
/// one runs at the next call.
|
||||
#[test]
|
||||
fn js_advance_runs_no_more_timers_than_its_cap() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-js-advance-cap-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("interval.pdf"), INTERVAL).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = tool(&mut a, "doc_open", json!({ "path": "interval.pdf" }))["doc"].as_u64().unwrap();
|
||||
tool(&mut a, "js_page", json!({ "doc": id, "page": 1 }));
|
||||
let out = tool(&mut a, "js_advance", json!({ "doc": id, "ms": 60_010 }));
|
||||
assert_eq!((out["ran"].clone(), out["stopped_early"].clone()), (json!(6000), json!(true)), "{out:?}");
|
||||
let out = tool(&mut a, "js_advance", json!({ "doc": id, "ms": 0 }));
|
||||
assert_eq!(out["ran"], json!(1), "{out:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -55,6 +55,8 @@ pub struct Document {
|
||||
pub(crate) js_runtime: js::Runtime,
|
||||
pub(crate) js_zoom: f64,
|
||||
pub(crate) js_page: usize,
|
||||
/// How its page actions and timers show it while it is read (see [`Session::show_page`](crate::Session::show_page)).
|
||||
pub(crate) live: crate::live_scripts::LiveView,
|
||||
/// Dynamic XFA forms: what laying the template out produced (pages and fields are
|
||||
/// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves).
|
||||
pub xfa: Option<XfaLayout>,
|
||||
|
||||
+25
-6
@@ -154,7 +154,7 @@ impl JsOutput {
|
||||
/// Adds what a field's own script produced: its alerts, console lines, errors and requests,
|
||||
/// except a request to print, show a side panel, open Save As, or open or save an attached
|
||||
/// file, which is noted on the console and dropped.
|
||||
fn absorb(&mut self, o: &Outcome) {
|
||||
pub(crate) fn absorb(&mut self, o: &Outcome) {
|
||||
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
|
||||
@@ -187,7 +187,8 @@ impl JsOutput {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn append(&mut self, other: JsOutput) {
|
||||
/// Adds what other scripts produced, telling a document script's error once.
|
||||
pub fn append(&mut self, other: JsOutput) {
|
||||
self.alert(other.alerts);
|
||||
self.say(other.console);
|
||||
self.requests.extend(other.requests);
|
||||
@@ -535,6 +536,7 @@ impl JsRunner {
|
||||
let mut current = Self::new(cos, &self.doc.file_name, self.doc.runtime_highlight);
|
||||
current.doc.zoom = self.doc.zoom;
|
||||
current.doc.page = self.doc.page;
|
||||
current.doc.layers = std::mem::take(&mut self.doc.layers);
|
||||
self.doc = current.doc;
|
||||
self.doc_scripts = current.doc_scripts;
|
||||
self.calculations = None;
|
||||
@@ -564,6 +566,7 @@ impl JsRunner {
|
||||
runtime_highlight: highlight,
|
||||
icon_epoch: 0,
|
||||
icon_versions: Default::default(),
|
||||
layers: Vec::new(),
|
||||
};
|
||||
// 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
|
||||
@@ -593,7 +596,7 @@ impl JsRunner {
|
||||
}
|
||||
|
||||
/// Index calculation sources by PDF object identity, retaining their matching snapshot positions.
|
||||
fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(usize, String)> {
|
||||
pub(crate) fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(usize, String)> {
|
||||
#[cfg(test)]
|
||||
SNAPSHOT_WORK.with(|c| {
|
||||
let (g, p) = c.get();
|
||||
@@ -664,6 +667,7 @@ impl Scripts for JsRunner {
|
||||
fields: states,
|
||||
doc_scripts: self.doc_scripts.clone(),
|
||||
calculations,
|
||||
timer: None,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let remaining = self.callback_time_left();
|
||||
@@ -752,6 +756,7 @@ impl Scripts for JsRunner {
|
||||
fields: states,
|
||||
doc_scripts: self.doc_scripts.clone(),
|
||||
calculations: calculations.clone(),
|
||||
timer: None,
|
||||
};
|
||||
let remaining = self.callback_time_left();
|
||||
let outcome = if remaining.is_zero() {
|
||||
@@ -1190,8 +1195,9 @@ impl Session {
|
||||
let mut runner = JsRunner::new(&cos, &doc.name, self.field_highlight);
|
||||
runner.doc.zoom = doc.js_zoom;
|
||||
runner.doc.page = doc.js_page;
|
||||
runner.doc.layers = crate::live_scripts::js_layers(&doc.info);
|
||||
let fields = pdfcraft_forms::fields(&cos);
|
||||
let states = std::sync::Arc::new(fields.iter().map(|f| field_state(&cos, f, &runner.doc)).collect::<Vec<_>>());
|
||||
let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, &cos, f, &runner.doc)).collect::<Vec<_>>());
|
||||
let event = match target {
|
||||
Some(t) => pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field(event_name.unwrap_or("Mouse Up"), t, "") },
|
||||
None => pdfcraft_js::Event::doc(event_name.unwrap_or("Console")),
|
||||
@@ -1206,6 +1212,7 @@ impl Session {
|
||||
fields: states.clone(),
|
||||
doc_scripts: runner.doc_scripts,
|
||||
calculations,
|
||||
timer: None,
|
||||
},
|
||||
answers,
|
||||
);
|
||||
@@ -1218,7 +1225,13 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Convert the worker snapshot delta and reset requests into permission-checked atomic document edits.
|
||||
fn apply_script_outcome(&mut self, id: DocId, states: &[pdfcraft_js::FieldState], mut o: Outcome, opening: bool) -> Result<Outcome, EditError> {
|
||||
pub(crate) fn apply_script_outcome(
|
||||
&mut self,
|
||||
id: DocId,
|
||||
states: &[pdfcraft_js::FieldState],
|
||||
mut o: Outcome,
|
||||
opening: bool,
|
||||
) -> Result<Outcome, EditError> {
|
||||
if o.execution_refused {
|
||||
o.changed.clear();
|
||||
o.requests.clear();
|
||||
@@ -1281,12 +1294,15 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let changes = changes(&baseline, &o.changed, &o.formatted);
|
||||
let written: Vec<String> = changes.iter().filter(|c| c.display.is_some() || c.icon.is_some()).map(|c| c.name.clone()).collect();
|
||||
if !changes.is_empty() {
|
||||
edits.push(Edit::ApplyScriptChanges { changes });
|
||||
}
|
||||
if !edits.is_empty() {
|
||||
self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?;
|
||||
}
|
||||
// The timers the script left, the layers it turned, and how fields show (#169).
|
||||
self.after_script(id, &o, &written);
|
||||
Ok(o)
|
||||
}
|
||||
}
|
||||
@@ -1660,9 +1676,10 @@ impl Session {
|
||||
let mut runner = JsRunner::new(cos, &doc.name, self.field_highlight);
|
||||
runner.doc.zoom = doc.js_zoom;
|
||||
runner.doc.page = doc.js_page;
|
||||
runner.doc.layers = crate::live_scripts::js_layers(&doc.info);
|
||||
let fields = pdfcraft_forms::fields(cos);
|
||||
validate_script_target(target, target_id, &fields)?;
|
||||
let states = std::sync::Arc::new(fields.iter().map(|f| field_state(cos, f, &runner.doc)).collect::<Vec<_>>());
|
||||
let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, cos, f, &runner.doc)).collect::<Vec<_>>());
|
||||
let job = pdfcraft_js::session::Job {
|
||||
script: script.into(),
|
||||
event: target.map_or_else(
|
||||
@@ -1673,6 +1690,7 @@ impl Session {
|
||||
fields: states.clone(),
|
||||
doc_scripts: runner.doc_scripts,
|
||||
calculations: calculation_scripts(cos, &fields),
|
||||
timer: None,
|
||||
};
|
||||
let receiver =
|
||||
doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.start(job).map_err(EditError::Invalid)?;
|
||||
@@ -2008,6 +2026,7 @@ mod widget_budget_review_tests {
|
||||
rotation: 0,
|
||||
caption: None,
|
||||
mouse_up_script: None,
|
||||
mouse_down: false,
|
||||
list: None,
|
||||
round: false,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{Layer, LayerOp, RenderPool};
|
||||
|
||||
use crate::{Document, display_config, render_threads};
|
||||
use crate::{Document, render_threads};
|
||||
|
||||
/// Most usage application dictionaries (`/AS` entries) that [`print_layer_states`] reads, and
|
||||
/// most category names and groups it reads in all of them together (entries can share one
|
||||
@@ -132,7 +132,7 @@ pub(crate) fn use_layer_choices(doc: &mut Document) {
|
||||
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
|
||||
doc.config.layers = Arc::new(overrides);
|
||||
doc.config.print_layers = print_preview_layers(doc.editor.as_ref().map(|e| &e.cos), &doc.info.layers);
|
||||
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
|
||||
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), crate::live_scripts::screen_config(doc));
|
||||
}
|
||||
|
||||
/// The layer overrides a print preview draws with (`RenderConfig::print_layers`): the layers as
|
||||
|
||||
@@ -37,6 +37,7 @@ mod history;
|
||||
pub mod js;
|
||||
mod layers;
|
||||
pub mod links;
|
||||
mod live_scripts;
|
||||
pub mod model3d;
|
||||
mod object_clip;
|
||||
pub mod ocr;
|
||||
@@ -74,6 +75,7 @@ use std::sync::Arc;
|
||||
pub use links::{
|
||||
MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
|
||||
};
|
||||
pub use live_scripts::{MAX_TIMER_RUNS, ScriptRun};
|
||||
pub use pdfcraft_edit::strip_subset_tag;
|
||||
pub use rich_copy::RichCopy;
|
||||
pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! A document's page actions and timers while it is read (#169): page open/close actions run
|
||||
//! as the reader moves between pages, `app.setInterval` / `app.setTimeOut` timers fire on the
|
||||
//! viewer's clock, and what they show or hide (an animation's frames) changes only the view.
|
||||
//! Everything runs in the document's script realm (#5699), shared with its buttons.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use pdfcraft_js::{Outcome, Request};
|
||||
|
||||
use crate::js::{JsOutput, Runtime, calculation_scripts, field_state};
|
||||
use crate::{DocId, Document, EditError, Session};
|
||||
|
||||
/// Most timers one call to [`Session::run_timers`] runs: a document asking for more at once
|
||||
/// falls behind instead of holding up the viewer.
|
||||
pub const MAX_TIMER_RUNS: usize = 16;
|
||||
|
||||
/// A timer of a document's scripts, on the caller's clock.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Due {
|
||||
id: u32,
|
||||
period_ms: u32,
|
||||
repeat: bool,
|
||||
/// When it falls due, in milliseconds.
|
||||
at: f64,
|
||||
}
|
||||
|
||||
/// How a document's page actions and timers show it while it is read. Nothing here is part of
|
||||
/// the document: saving, printing and exporting never see it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LiveView {
|
||||
/// The page whose open actions ran last.
|
||||
page: Option<usize>,
|
||||
timers: Vec<Due>,
|
||||
/// The realm the timers were set in: a fresh realm (an edit's reset, Revert, a full
|
||||
/// save) starts without them.
|
||||
timers_of: Option<std::sync::Weak<std::sync::Mutex<pdfcraft_js::session::ScriptSession>>>,
|
||||
/// `display` page actions and timers gave fields, by name, over the document's own flags.
|
||||
shown: BTreeMap<String, i32>,
|
||||
/// Icons page actions and timers gave push buttons (`buttonSetIcon`), by name: the icon's
|
||||
/// form object (`None`: no icon), over the button's own.
|
||||
icons: BTreeMap<String, Option<u64>>,
|
||||
/// The caller's clock when it last looked, in milliseconds: a timer a button or the
|
||||
/// console sets counts from here.
|
||||
clock: f64,
|
||||
}
|
||||
|
||||
impl LiveView {
|
||||
/// The timers, unless the realm that set them was replaced since.
|
||||
fn timers(&mut self, runtime: &Runtime) -> &mut Vec<Due> {
|
||||
if self.timers_of.as_ref().and_then(std::sync::Weak::upgrade).is_none_or(|r| !std::sync::Arc::ptr_eq(&r, runtime)) {
|
||||
self.timers.clear();
|
||||
self.timers_of = None;
|
||||
}
|
||||
&mut self.timers
|
||||
}
|
||||
|
||||
/// Keep the timers realm `runtime` reported after a run: new ones count from the clock,
|
||||
/// cleared ones go.
|
||||
fn schedule(&mut self, runtime: &Runtime, reported: &[pdfcraft_js::Timer]) {
|
||||
let clock = self.clock;
|
||||
let timers = self.timers(runtime);
|
||||
timers.retain(|d| reported.iter().any(|t| t.id == d.id));
|
||||
for t in reported {
|
||||
if !timers.iter().any(|d| d.id == t.id) {
|
||||
timers.push(Due { id: t.id, period_ms: t.period_ms, repeat: t.repeat, at: clock + f64::from(t.period_ms) });
|
||||
}
|
||||
}
|
||||
self.timers_of = (!self.timers.is_empty()).then(|| std::sync::Arc::downgrade(runtime));
|
||||
}
|
||||
}
|
||||
|
||||
/// What running a document's page actions or timers did, for the viewer.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ScriptRun {
|
||||
/// Alerts, console output, requests and errors, as from any script.
|
||||
pub output: JsOutput,
|
||||
/// Pages showing a field the scripts showed, hid or gave another icon: draw them again.
|
||||
pub pages: Vec<usize>,
|
||||
/// The scripts changed the document (a field's value, or a reset): read it again.
|
||||
pub edited: bool,
|
||||
/// How many timers ran.
|
||||
pub ran: usize,
|
||||
/// Layers were turned on or off (`ocg.state`): draw every page again.
|
||||
pub layers: bool,
|
||||
}
|
||||
|
||||
impl ScriptRun {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.output.is_empty() && self.pages.is_empty() && !self.edited && !self.layers
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's layers as scripts see them (`getOCGs`), in the viewer's order.
|
||||
pub(crate) fn js_layers(info: &pdfcraft_render::DocInfo) -> Vec<pdfcraft_js::Layer> {
|
||||
info.layers.iter().map(|l| pdfcraft_js::Layer { name: l.name.clone(), on: l.visible, initial: l.initial }).collect()
|
||||
}
|
||||
|
||||
/// A field as scripts see it: as the document has it, but shown as page actions and timers
|
||||
/// left it (its `display` and push button icon), so a button stepping an animation steps from
|
||||
/// the frame on screen.
|
||||
pub(crate) fn live_state(
|
||||
doc: &Document,
|
||||
cos: &pdfcraft_cos::Document,
|
||||
f: &pdfcraft_forms::Field,
|
||||
info: &pdfcraft_js::DocInfo,
|
||||
) -> pdfcraft_js::FieldState {
|
||||
let mut s = field_state(cos, f, info);
|
||||
if let Some(d) = doc.live.shown.get(&f.name) {
|
||||
s.display = *d;
|
||||
}
|
||||
if let Some(icon) = doc.live.icons.get(&f.name) {
|
||||
s.icon = *icon;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Whether a field with this `display` shows on screen.
|
||||
fn on_screen(display: i32) -> bool {
|
||||
display == pdfcraft_js::DISPLAY_VISIBLE || display == pdfcraft_js::DISPLAY_NO_PRINT
|
||||
}
|
||||
|
||||
/// A widget's object as the renderer names it, when its number fits.
|
||||
fn object(w: &pdfcraft_forms::Widget) -> Option<(i32, i32)> {
|
||||
Some((i32::try_from(w.obj.num).ok()?, i32::from(w.obj.generation)))
|
||||
}
|
||||
|
||||
/// The viewer's render configuration: the document's, with installed fonts, and the fields as
|
||||
/// page actions and timers show them. Exports and printing use `doc.config`, without them.
|
||||
pub(crate) fn screen_config(doc: &Document) -> pdfcraft_render::RenderConfig {
|
||||
with_live_view(doc, crate::display_config(&doc.config))
|
||||
}
|
||||
|
||||
/// `config` with the fields as page actions and timers show them.
|
||||
pub(crate) fn with_live_view(doc: &Document, config: pdfcraft_render::RenderConfig) -> pdfcraft_render::RenderConfig {
|
||||
let live = &doc.live;
|
||||
let annotations: Vec<(i32, i32, bool)> = doc
|
||||
.form
|
||||
.iter()
|
||||
.filter_map(|f| live.shown.get(&f.name).map(|d| (f, on_screen(*d))))
|
||||
.flat_map(|(f, on)| f.widgets.iter().filter_map(move |w| object(w).map(|(n, g)| (n, g, on))))
|
||||
.collect();
|
||||
let appearances: Vec<(i32, i32, i32, i32)> = doc
|
||||
.form
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let id = live.icons.get(&f.name)?;
|
||||
// No icon: drawn with an object that isn't a form, so nothing (an empty button).
|
||||
let (num, generation) = id.map_or((0, 0), |id| ((id >> 16) as i32, (id & 0xffff) as i32));
|
||||
Some(f.widgets.iter().filter_map(move |w| object(w).map(|(n, g)| (n, g, num, generation))))
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
pdfcraft_render::RenderConfig { annotations: std::sync::Arc::new(annotations), appearances: std::sync::Arc::new(appearances), ..config }
|
||||
}
|
||||
|
||||
/// Draw the viewer with how fields show now.
|
||||
fn use_live_view(doc: &mut Document) {
|
||||
doc.renderer = pdfcraft_render::RenderPool::new(doc.display.clone(), crate::render_threads(), screen_config(doc));
|
||||
}
|
||||
|
||||
/// Turn layers on or off as a script set them (`ocg.state`), as the Layers panel does: how the
|
||||
/// document is shown, not the document. Returns whether any changed.
|
||||
pub(crate) fn set_layers(doc: &mut Document, layers: &[(usize, bool)]) -> bool {
|
||||
let mut changed = false;
|
||||
for &(i, on) in layers {
|
||||
if let Some(l) = doc.info.layers.get_mut(i)
|
||||
&& l.visible != on
|
||||
{
|
||||
l.visible = on;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
crate::layers::use_layer_choices(doc);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Push buttons given another icon, by name: the icon's form object (`None`: no icon).
|
||||
type IconChanges = Vec<(String, Option<u64>)>;
|
||||
|
||||
/// The `display` and icon changes in `o`, against the fields as they were (`states`), taken out
|
||||
/// of it: shown in the view instead of written into the document.
|
||||
fn take_view_changes(states: &[pdfcraft_js::FieldState], o: &mut Outcome) -> (Vec<(String, i32)>, IconChanges) {
|
||||
let mut shown = Vec::new();
|
||||
let mut icons = Vec::new();
|
||||
for a in &mut o.changed {
|
||||
let b = match a.object_id {
|
||||
Some(id) => states.iter().find(|s| s.object_id == Some(id)),
|
||||
None => states.iter().find(|s| s.name == a.name),
|
||||
};
|
||||
let Some(b) = b else { continue };
|
||||
if a.display != b.display {
|
||||
shown.push((a.name.clone(), a.display));
|
||||
a.display = b.display;
|
||||
}
|
||||
if a.icon_written && a.icon != b.icon {
|
||||
icons.push((a.name.clone(), a.icon));
|
||||
}
|
||||
a.icon = b.icon;
|
||||
a.icon_written = false;
|
||||
}
|
||||
(shown, icons)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// After any script of document `id` ran (a button, the console, a page action, a timer):
|
||||
/// keep the timers its realm has, turn layers as it asked, and let a `display` written
|
||||
/// into the document win over how page actions and timers showed that field.
|
||||
pub(crate) fn after_script(&mut self, id: DocId, o: &Outcome, written: &[String]) {
|
||||
let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return };
|
||||
let runtime = doc.js_runtime.clone();
|
||||
doc.live.schedule(&runtime, &o.timers);
|
||||
let before = doc.live.shown.len();
|
||||
let icons = doc.live.icons.len();
|
||||
doc.live.shown.retain(|name, _| !written.contains(name));
|
||||
doc.live.icons.retain(|name, _| !written.contains(name));
|
||||
if doc.live.shown.len() != before || doc.live.icons.len() != icons {
|
||||
use_live_view(doc);
|
||||
}
|
||||
set_layers(doc, &o.layers);
|
||||
}
|
||||
|
||||
/// Run `script` (or timer `timer`) of document `id` in its realm, as a page action or timer
|
||||
/// runs: with the fields as the view shows them. Returns the fields as the script saw them.
|
||||
fn run_live(
|
||||
&mut self,
|
||||
id: DocId,
|
||||
script: &str,
|
||||
event: pdfcraft_js::Event,
|
||||
timer: Option<u32>,
|
||||
) -> Result<(std::sync::Arc<Vec<pdfcraft_js::FieldState>>, Outcome), EditError> {
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
|
||||
let mut runner = crate::js::JsRunner::new(&cos, &doc.name, self.field_highlight);
|
||||
runner.doc.zoom = doc.js_zoom;
|
||||
runner.doc.page = doc.live.page.unwrap_or(doc.js_page);
|
||||
runner.doc.layers = js_layers(&doc.info);
|
||||
let fields = pdfcraft_forms::fields(&cos);
|
||||
let states: Vec<_> = fields.iter().map(|f| live_state(doc, &cos, f, &runner.doc)).collect();
|
||||
let states = std::sync::Arc::new(states);
|
||||
let job = pdfcraft_js::session::Job {
|
||||
script: script.into(),
|
||||
event,
|
||||
doc: runner.doc,
|
||||
fields: states.clone(),
|
||||
doc_scripts: runner.doc_scripts,
|
||||
calculations: calculation_scripts(&cos, &fields),
|
||||
timer,
|
||||
};
|
||||
let o = doc.js_runtime.lock().map_err(|_| EditError::Invalid("document script runtime is unavailable".into()))?.run(job, &[]);
|
||||
Ok((states, o))
|
||||
}
|
||||
|
||||
/// Run one page action or timer of document `id` and apply what it did. Fields it showed,
|
||||
/// hid or gave another icon change only how the document is shown; a value it changed, or
|
||||
/// a reset, changes the document (one undoable step). A timer runs unasked and again and
|
||||
/// again, so what it asks of the viewer (alerts, printing, navigation, links) goes to the
|
||||
/// console instead.
|
||||
fn live_step(&mut self, id: DocId, script: &str, event: pdfcraft_js::Event, timer: Option<u32>, run: &mut ScriptRun) {
|
||||
let (states, mut o) = match self.run_live(id, script, event, timer) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return run.output.errors.push(e.to_string()),
|
||||
};
|
||||
let discard = o.execution_refused && !o.realm_untouched;
|
||||
let (shown, icons) = take_view_changes(&states, &mut o);
|
||||
if timer.is_some() {
|
||||
for a in std::mem::take(&mut o.alerts) {
|
||||
o.console.push(format!("A timer's alert: {}", a.text));
|
||||
}
|
||||
for r in std::mem::take(&mut o.requests) {
|
||||
if !matches!(r, Request::Reset(_)) {
|
||||
o.console.push(format!("A timer asked for {r:?}; timers may only change fields"));
|
||||
} else {
|
||||
o.requests.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
let refused = o.execution_refused;
|
||||
let generation = self.get(id).map(Document::edit_generation);
|
||||
let layers = |s: &Session| s.get(id).map(|d| d.info.layers.iter().map(|l| l.visible).collect::<Vec<_>>());
|
||||
let layers_before = layers(self);
|
||||
match self.apply_script_outcome(id, &states, o, false) {
|
||||
Ok(o) => {
|
||||
run.output.absorb(&o);
|
||||
// Only now: a script whose edits are refused shows nothing it did either.
|
||||
if !refused {
|
||||
self.show_view_changes(id, shown, icons, run);
|
||||
}
|
||||
}
|
||||
Err(e) => run.output.errors.push(e.to_string()),
|
||||
}
|
||||
run.edited |= self.get(id).map(Document::edit_generation) != generation;
|
||||
run.layers |= layers(self) != layers_before;
|
||||
if discard {
|
||||
self.invalidate_javascript(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the fields a page action or timer of document `id` showed or hid (`shown`) or gave
|
||||
/// another icon (`icons`), and add the pages they are on to `run`.
|
||||
fn show_view_changes(&mut self, id: DocId, shown: Vec<(String, i32)>, icons: IconChanges, run: &mut ScriptRun) {
|
||||
if shown.is_empty() && icons.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return };
|
||||
for name in shown.iter().map(|(n, _)| n).chain(icons.iter().map(|(n, _)| n)) {
|
||||
for p in doc.form.iter().filter(|f| f.name == *name).flat_map(|f| f.widgets.iter().filter_map(|w| w.page)) {
|
||||
if !run.pages.contains(&p) {
|
||||
run.pages.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.live.shown.extend(shown);
|
||||
doc.live.icons.extend(icons);
|
||||
use_live_view(doc);
|
||||
}
|
||||
|
||||
/// Document `id` is read at page `page` (0-based), at `now_ms` on the caller's clock (on
|
||||
/// which its timers fall due). When the page changed, the close actions of the page it was
|
||||
/// at run, then `page`'s open actions (the page's own `/AA /O`, then each annotation's
|
||||
/// page-open and page-visible actions, such as an animation's widget). Cheap when nothing
|
||||
/// changed; call it as often as the page can change. With JavaScript off nothing runs.
|
||||
pub fn show_page(&mut self, id: DocId, page: usize, now_ms: f64) -> ScriptRun {
|
||||
let mut run = ScriptRun::default();
|
||||
let js_off = self.js_off;
|
||||
let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return run };
|
||||
doc.live.clock = now_ms;
|
||||
if js_off || doc.live.page == Some(page) || doc.xfa_template.is_some() {
|
||||
return run;
|
||||
}
|
||||
let before = doc.live.page.replace(page);
|
||||
let Some(cos) = doc.editor.as_ref().map(|e| e.cos.clone()) else { return run };
|
||||
use pdfcraft_forms::PageEvent;
|
||||
let mut scripts: Vec<(&str, pdfcraft_forms::PageScript)> = Vec::new();
|
||||
if let Some(p) = before {
|
||||
scripts.extend(pdfcraft_forms::page_scripts(&cos, p, PageEvent::Close).into_iter().map(|s| ("Close", s)));
|
||||
}
|
||||
scripts.extend(pdfcraft_forms::page_scripts(&cos, page, PageEvent::Open).into_iter().map(|s| ("Open", s)));
|
||||
for (name, s) in scripts {
|
||||
let event = pdfcraft_js::Event { kind: "Page".into(), target: s.field, ..pdfcraft_js::Event::doc(name) };
|
||||
self.live_step(id, &s.script, event, None, &mut run);
|
||||
}
|
||||
run
|
||||
}
|
||||
|
||||
/// When document `id`'s next timer falls due, on the clock [`Session::show_page`] and
|
||||
/// [`Session::run_timers`] are given; `None` without timers (or with JavaScript off).
|
||||
pub fn next_timer(&mut self, id: DocId) -> Option<f64> {
|
||||
if self.js_off {
|
||||
return None;
|
||||
}
|
||||
let doc = self.docs.iter_mut().find(|d| d.id == id)?;
|
||||
let runtime = doc.js_runtime.clone();
|
||||
doc.live.timers(&runtime).iter().map(|d| d.at).min_by(f64::total_cmp)
|
||||
}
|
||||
|
||||
/// Run document `id`'s timers that are due at `now_ms`, earliest first, at most
|
||||
/// [`MAX_TIMER_RUNS`] of them. An interval that fell behind runs once and then counts its
|
||||
/// period from now, rather than catching up run by run.
|
||||
pub fn run_timers(&mut self, id: DocId, now_ms: f64) -> ScriptRun {
|
||||
self.run_timers_at_most(id, now_ms, MAX_TIMER_RUNS)
|
||||
}
|
||||
|
||||
/// [`Session::run_timers`], running at most `max` timers (and never more than
|
||||
/// [`MAX_TIMER_RUNS`]). With `max` 0 it only moves the clock on to `now_ms`: the timers still
|
||||
/// due run at the next call.
|
||||
pub fn run_timers_at_most(&mut self, id: DocId, now_ms: f64, max: usize) -> ScriptRun {
|
||||
let mut run = ScriptRun::default();
|
||||
if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
doc.live.clock = now_ms;
|
||||
}
|
||||
for _ in 0..max.min(MAX_TIMER_RUNS) {
|
||||
if self.js_off {
|
||||
break;
|
||||
}
|
||||
let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { break };
|
||||
doc.live.clock = now_ms;
|
||||
let runtime = doc.js_runtime.clone();
|
||||
let timers = doc.live.timers(&runtime);
|
||||
let Some(i) = timers.iter().enumerate().filter(|(_, d)| d.at <= now_ms).min_by(|a, b| a.1.at.total_cmp(&b.1.at)).map(|(i, _)| i) else {
|
||||
break;
|
||||
};
|
||||
let due = timers[i];
|
||||
if due.repeat {
|
||||
let next = due.at + f64::from(due.period_ms);
|
||||
timers[i].at = if next <= now_ms { now_ms + f64::from(due.period_ms) } else { next };
|
||||
} else {
|
||||
timers.remove(i);
|
||||
}
|
||||
run.ran += 1;
|
||||
let event = pdfcraft_js::Event { kind: "App".into(), ..pdfcraft_js::Event::doc("Timer") };
|
||||
self.live_step(id, "", event, Some(due.id), &mut run);
|
||||
}
|
||||
run
|
||||
}
|
||||
|
||||
/// The clock of document `id`'s live scripts: the time it was last given (milliseconds).
|
||||
pub fn script_clock(&self, id: DocId) -> Option<f64> {
|
||||
Some(self.get(id)?.live.clock)
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Whether the document's page actions and timers show field `name` on screen (`Some`), over
|
||||
/// its widgets' own Hidden and NoView flags; `None` when they left it as the document has it.
|
||||
/// For the viewer's hit test: a frame they showed takes presses, and one they hid doesn't.
|
||||
pub fn shown_by_scripts(&self, name: &str) -> Option<bool> {
|
||||
self.live.shown.get(name).map(|d| on_screen(*d))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "live_scripts_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,303 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::screen_config;
|
||||
use crate::{DocId, Session};
|
||||
|
||||
/// A PDF of `objs` (object 1 the catalog), with its cross-reference table.
|
||||
fn pdf(objs: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A form XObject filling 100 × 100 with colour `rgb`.
|
||||
fn fill(rgb: &str) -> String {
|
||||
let body = format!("{rgb} rg 0 0 100 100 re f");
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{body}\nendstream", body.len())
|
||||
}
|
||||
|
||||
/// A push button widget on page 3 0 R at [50 100 150 200].
|
||||
fn widget(name: &str, flags: u32, extra: &str) -> String {
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T ({name}) /F {flags} /Rect [50 100 150 200] /P 3 0 R {extra} >>")
|
||||
}
|
||||
|
||||
/// Two pages; on the first, an animation as the LaTeX `animate` package builds one: three
|
||||
/// frames as push buttons over one another (red showing, green and blue hidden), and a widget
|
||||
/// whose page-open action defines the player and starts an interval and whose page-close
|
||||
/// action stops it.
|
||||
fn animation_pdf() -> Vec<u8> {
|
||||
let open = "if (typeof on == 'undefined') { var on = 0; var t = null; } \
|
||||
function show(i) { getField('f' + on).display = display.hidden; on = i; getField('f' + on).display = display.visible; } \
|
||||
function step() { show((on + 1) % 3); } t = app.setInterval('step()', 100);";
|
||||
pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 7 0 R] >> >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R 12 0 R] /Count 2 /MediaBox [0 0 200 300] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".into(), // 3
|
||||
widget("f0", 4, "/AP << /N 8 0 R >>"), // 4
|
||||
widget("f1", 6, "/AP << /N 9 0 R >>"), // 5
|
||||
widget("f2", 6, "/AP << /N 10 0 R >>"), // 6
|
||||
widget("anim", 4, "/AA << /PO << /S /JavaScript /JS 11 0 R >> /PC << /S /JavaScript /JS (app.clearInterval\\(t\\);) >> >>"), // 7
|
||||
fill("1 0 0"), // 8
|
||||
fill("0 1 0"), // 9
|
||||
fill("0 0 1"), // 10
|
||||
format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()), // 11
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(), // 12
|
||||
])
|
||||
}
|
||||
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const GREEN: [u8; 4] = [0, 255, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
|
||||
/// RGBA at PDF point (x, y) on `page` as the viewer draws it (1 px/pt, page height 300).
|
||||
fn shown(s: &Session, id: DocId, page: usize, x: u32, y: u32) -> [u8; 4] {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.display.clone(), screen_config(doc));
|
||||
let out = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() });
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
let i = (((300 - y) * out.width + x) * 4) as usize;
|
||||
out.rgba[i..i + 4].try_into().unwrap()
|
||||
}
|
||||
|
||||
/// RGBA at (x, y) on `page` as it prints or exports: the document itself.
|
||||
fn printed(s: &Session, id: DocId, page: usize, x: u32, y: u32) -> [u8; 4] {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
|
||||
let out = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() });
|
||||
let i = (((300 - y) * out.width + x) * 4) as usize;
|
||||
out.rgba[i..i + 4].try_into().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_actions_start_timers_that_turn_an_animations_frames_without_changing_the_document() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("animation.pdf", None, Arc::new(animation_pdf()), None).unwrap();
|
||||
let bytes = s.get(id).unwrap().bytes.clone();
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), RED);
|
||||
assert_eq!(s.next_timer(id), None);
|
||||
let run = s.show_page(id, 0, 1000.0);
|
||||
assert!(run.output.errors.is_empty(), "{run:?}");
|
||||
assert_eq!(s.next_timer(id), Some(1100.0));
|
||||
assert!(s.show_page(id, 0, 1010.0).is_empty(), "the same page again runs nothing");
|
||||
assert!(s.run_timers(id, 1050.0).is_empty(), "not due yet");
|
||||
let run = s.run_timers(id, 1100.0);
|
||||
assert_eq!((run.pages.as_slice(), run.edited, run.ran), ([0].as_slice(), false, 1), "{run:?}");
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), GREEN);
|
||||
assert_eq!(printed(&s, id, 0, 100, 150), RED, "printing and export draw the document");
|
||||
assert_eq!(s.next_timer(id), Some(1200.0));
|
||||
let doc = s.get(id).unwrap();
|
||||
assert!(Arc::ptr_eq(&doc.bytes, &bytes) && !doc.dirty && doc.undo_depth() == 0, "the frames turn in the view, not in the document");
|
||||
// An interval that fell behind runs once and counts on from now.
|
||||
assert_eq!(s.run_timers(id, 1450.0).ran, 1);
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), BLUE);
|
||||
assert_eq!(s.next_timer(id), Some(1550.0));
|
||||
// A button runs in the same realm: the page action's functions and state are there.
|
||||
let o = s.run_javascript(id, "step();", Some("f0")).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
let o = s.run_javascript(id, "on", None).unwrap();
|
||||
assert_eq!(o.result.as_deref(), Some("0"));
|
||||
// Leaving the page runs its close action, which clears the interval.
|
||||
s.show_page(id, 1, 1500.0);
|
||||
assert_eq!(s.next_timer(id), None);
|
||||
// With JavaScript off, page actions don't run.
|
||||
s.set_javascript(false);
|
||||
assert!(s.show_page(id, 0, 1600.0).is_empty());
|
||||
assert_eq!(s.next_timer(id), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_that_shows_a_frame_changes_the_document_and_the_views_frame_gives_way() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("animation.pdf", None, Arc::new(animation_pdf()), None).unwrap();
|
||||
s.show_page(id, 0, 0.0);
|
||||
s.run_timers(id, 100.0);
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), GREEN);
|
||||
s.run_javascript(id, "step();", Some("f0")).unwrap();
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), BLUE);
|
||||
assert!(s.get(id).unwrap().dirty, "a button's change is the document's");
|
||||
assert_eq!(printed(&s, id, 0, 100, 150), BLUE);
|
||||
s.run_timers(id, 200.0);
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), RED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timer_runs_unasked_so_its_alerts_and_requests_go_to_the_console() {
|
||||
let open = "app.setTimeOut('app.alert(\"hi\"); this.print(); app.launchURL(\"https://example.com\");', 50);";
|
||||
let mut s = Session::new();
|
||||
let id = s.open("t.pdf", None, Arc::new(timer_pdf(open)), None).unwrap();
|
||||
assert!(s.show_page(id, 0, 0.0).output.errors.is_empty());
|
||||
let run = s.run_timers(id, 50.0);
|
||||
assert_eq!(run.ran, 1);
|
||||
assert!(run.output.alerts.is_empty() && run.output.requests.is_empty(), "{run:?}");
|
||||
assert!(run.output.console.iter().any(|l| l.contains("hi")), "{run:?}");
|
||||
assert_eq!(s.next_timer(id), None, "a time-out runs once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_realm_has_no_timers_and_a_timer_writing_a_value_is_one_undoable_edit() {
|
||||
let open = "app.setInterval('getField(\"f0\").value = \"x\";', 10);";
|
||||
let mut s = Session::new();
|
||||
let id = s.open("t.pdf", None, Arc::new(timer_pdf(open)), None).unwrap();
|
||||
s.show_page(id, 0, 0.0);
|
||||
let run = s.run_timers(id, 10.0);
|
||||
assert!(run.edited && run.output.errors.is_empty(), "{run:?}");
|
||||
assert_eq!(s.get(id).unwrap().undo_depth(), 1);
|
||||
assert!(s.next_timer(id).is_some());
|
||||
s.invalidate_javascript(id);
|
||||
assert_eq!(s.next_timer(id), None, "the timers went with the realm");
|
||||
}
|
||||
|
||||
/// One page with a text field f0 whose page-open action is `open`.
|
||||
fn timer_pdf(open: &str) -> Vec<u8> {
|
||||
pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] /AA << /O 5 0 R >> >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (f0) /Rect [50 100 150 200] /P 3 0 R >>".into(),
|
||||
"<< /S /JavaScript /JS 6 0 R >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()),
|
||||
])
|
||||
}
|
||||
|
||||
/// An animation as `animate` builds one by default (method=icon): hidden frames are buttons
|
||||
/// holding the icons, and the player gives the one showing button a frame's icon.
|
||||
#[test]
|
||||
fn a_timer_giving_a_button_another_icon_draws_that_icon_in_its_place() {
|
||||
let open = "if (typeof on == 'undefined') { var on = 0; var fr = [getField('f0').buttonGetIcon(), getField('f1').buttonGetIcon()]; } \
|
||||
app.setInterval('on = 1 - on; getField(\"anim\").buttonSetIcon(fr[on]);', 100);";
|
||||
let out = pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>".into(),
|
||||
widget("anim", 4, "/AP << /N 7 0 R >> /MK << /I 7 0 R >> /AA << /PO << /S /JavaScript /JS 9 0 R >> >>"),
|
||||
widget("f0", 6, "/AP << /N 7 0 R >> /MK << /I 7 0 R >>"),
|
||||
widget("f1", 6, "/AP << /N 8 0 R >> /MK << /I 8 0 R >>"),
|
||||
fill("1 0 0"),
|
||||
fill("0 1 0"),
|
||||
format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()),
|
||||
]);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("icons.pdf", None, Arc::new(out), None).unwrap();
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), RED);
|
||||
assert!(s.show_page(id, 0, 0.0).output.errors.is_empty());
|
||||
let run = s.run_timers(id, 100.0);
|
||||
assert_eq!((run.pages.as_slice(), run.output.errors.len()), ([0].as_slice(), 0), "{run:?}");
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), GREEN);
|
||||
assert_eq!(printed(&s, id, 0, 100, 150), RED);
|
||||
s.run_timers(id, 200.0);
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), RED, "its own icon again");
|
||||
assert!(!s.get(id).unwrap().dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_ocgs_turns_a_layer_in_the_view() {
|
||||
let open = "this.getOCGs()[0].state = false;";
|
||||
let out = pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /Order [5 0 R] >> >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> /AA << /O << /S /JavaScript /JS 6 0 R >> >> >>"
|
||||
.into(),
|
||||
"<< /Length 41 >>\nstream\n/OC /L1 BDC 1 0 0 rg 0 0 200 300 re f EMC\nendstream".into(),
|
||||
"<< /Type /OCG /Name (Red) >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()),
|
||||
]);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("layers.pdf", None, Arc::new(out), None).unwrap();
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), RED);
|
||||
let run = s.show_page(id, 0, 0.0);
|
||||
assert!(run.layers && run.output.errors.is_empty(), "{run:?}");
|
||||
assert!(!s.get(id).unwrap().info.layers[0].visible);
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), [255, 255, 255, 255]);
|
||||
assert!(!s.get(id).unwrap().dirty);
|
||||
}
|
||||
|
||||
/// A page action whose document edit is refused (here: past the reset limit) shows nothing it
|
||||
/// did either. The frame it showed stays hidden.
|
||||
#[test]
|
||||
fn a_page_action_whose_edit_is_refused_shows_nothing() {
|
||||
let open = "getField('f1').display = display.visible; for (var i = 0; i < 70; i++) this.resetForm();";
|
||||
let out = pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] /AA << /O << /S /JavaScript /JS 8 0 R >> >> >>".into(),
|
||||
widget("f0", 4, "/AP << /N 6 0 R >>"),
|
||||
widget("f1", 6, "/AP << /N 7 0 R >>"),
|
||||
fill("1 0 0"),
|
||||
fill("0 1 0"),
|
||||
format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()),
|
||||
]);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("refused.pdf", None, Arc::new(out), None).unwrap();
|
||||
let run = s.show_page(id, 0, 0.0);
|
||||
assert!(!run.output.errors.is_empty(), "the edit was refused: {run:?}");
|
||||
assert!(run.pages.is_empty(), "{run:?}");
|
||||
assert_eq!(shown(&s, id, 0, 100, 150), RED, "the frame it showed stays hidden");
|
||||
}
|
||||
|
||||
/// A field's calculation reads the document's layers (`getOCGs`), as page actions and buttons do.
|
||||
#[test]
|
||||
fn field_calculations_read_the_documents_layers() {
|
||||
let calc = "var l = this.getOCGs(); event.value = l ? String(l.length) : 'none';";
|
||||
let out = pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /CO [5 0 R] >> /OCProperties << /OCGs [6 0 R] /D << /Order [6 0 R] >> >> >>"
|
||||
.into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (a) /Rect [10 10 90 30] /P 3 0 R >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (n) /Rect [10 40 90 60] /P 3 0 R /AA << /C << /S /JavaScript /JS 7 0 R >> >> >>".into(),
|
||||
"<< /Type /OCG /Name (Red) >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{calc}\nendstream", calc.len()),
|
||||
]);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("calc.pdf", None, Arc::new(out), None).unwrap();
|
||||
s.apply(id, crate::Edit::SetFieldValue { name: "a".into(), value: crate::FieldValue::Text("1".into()) }).unwrap();
|
||||
let n = s.get(id).unwrap().form.iter().find(|f| f.name == "n").unwrap().value.clone();
|
||||
assert_eq!(n, ["1"], "the calculation saw the document's one layer");
|
||||
}
|
||||
|
||||
/// The viewer's hit test asks which fields the scripts show (`shown_by_scripts`): a frame they
|
||||
/// showed, and one they hid.
|
||||
#[test]
|
||||
fn the_view_says_which_fields_the_scripts_show() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("animation.pdf", None, Arc::new(animation_pdf()), None).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().shown_by_scripts("f1"), None);
|
||||
s.show_page(id, 0, 0.0);
|
||||
s.run_timers(id, 100.0);
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!((doc.shown_by_scripts("f0"), doc.shown_by_scripts("f1"), doc.shown_by_scripts("anim")), (Some(false), Some(true), None));
|
||||
}
|
||||
|
||||
/// With a cap of none, timers due only move the clock on; they run at the next call.
|
||||
#[test]
|
||||
fn a_cap_of_no_timer_runs_only_moves_the_clock() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("animation.pdf", None, Arc::new(animation_pdf()), None).unwrap();
|
||||
s.show_page(id, 0, 0.0);
|
||||
let run = s.run_timers_at_most(id, 100.0, 0);
|
||||
assert_eq!(run.ran, 0);
|
||||
assert_eq!((s.script_clock(id), s.next_timer(id)), (Some(100.0), Some(100.0)));
|
||||
assert_eq!(s.run_timers_at_most(id, 100.0, 1).ran, 1);
|
||||
}
|
||||
|
||||
/// A script refused before it runs (too long) leaves the page's timers running.
|
||||
#[test]
|
||||
fn a_script_refused_before_it_runs_keeps_the_pages_timers() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("t.pdf", None, Arc::new(timer_pdf("app.setInterval('1', 10);")), None).unwrap();
|
||||
s.show_page(id, 0, 0.0);
|
||||
assert_eq!(s.next_timer(id), Some(10.0));
|
||||
let refused = s.run_javascript(id, &"1;".repeat(256 * 1024), None).expect("runs");
|
||||
assert!(refused.error.is_some(), "the script is refused");
|
||||
assert_eq!(s.next_timer(id), Some(10.0), "and the interval still runs");
|
||||
}
|
||||
@@ -385,6 +385,7 @@ impl Session {
|
||||
js_runtime: Default::default(),
|
||||
js_zoom: 100.0,
|
||||
js_page: 0,
|
||||
live: Default::default(),
|
||||
xfa,
|
||||
xfa_template: None,
|
||||
xfa_scripts_off: false,
|
||||
|
||||
@@ -181,6 +181,7 @@ impl Session {
|
||||
runner.runtime = doc.js_runtime.clone();
|
||||
runner.doc.zoom = doc.js_zoom;
|
||||
runner.doc.page = doc.js_page;
|
||||
runner.doc.layers = crate::live_scripts::js_layers(&doc.info);
|
||||
cx.js = Some(runner);
|
||||
}
|
||||
cx.renamed_fields.clear();
|
||||
@@ -204,13 +205,16 @@ impl Session {
|
||||
// Fail closed.
|
||||
let before: std::collections::HashSet<_> = pdfcraft_model::pages(&editor.cos).iter().map(|p| p.obj).collect();
|
||||
let after: std::collections::HashSet<_> = pdfcraft_model::pages(&next).iter().map(|p| p.obj).collect();
|
||||
// Either refusal also discards what the batch's field scripts left in the realm.
|
||||
if cx.redaction_marks.iter().any(|(o, _)| !after.contains(o)) && after.iter().any(|o| !before.contains(o)) {
|
||||
discard_edit_callbacks(&cx);
|
||||
return Err(EditError::Invalid(
|
||||
"a batch that deletes a page with a redaction mark and adds pages can't be verified; the document was not changed".into(),
|
||||
));
|
||||
}
|
||||
guard(|| shown_under_redactions(&next, &cx.redaction_marks, &cx.redaction_overlays, doc.password.as_deref()))
|
||||
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
|
||||
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))
|
||||
.inspect_err(|_| discard_edit_callbacks(&cx))?;
|
||||
}
|
||||
// Renamed fields are said, not slipped in: a repair-log note (with the caveat that
|
||||
// scripts were left alone) and a count in the undo step's label.
|
||||
|
||||
@@ -336,9 +336,11 @@ impl Session {
|
||||
}
|
||||
|
||||
/// How the document's pages are drawn for viewing: its password and view settings (layer
|
||||
/// visibility, hidden comments, reading colours). `None` when no such document is open.
|
||||
/// visibility, hidden comments, reading colours), and the fields as its page actions and
|
||||
/// timers show them (#169). `None` when no such document is open.
|
||||
pub fn render_config(&self, id: DocId) -> Option<RenderConfig> {
|
||||
Some(self.get(id)?.config.clone())
|
||||
let doc = self.get(id)?;
|
||||
Some(crate::live_scripts::with_live_view(doc, doc.config.clone()))
|
||||
}
|
||||
|
||||
/// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment
|
||||
|
||||
@@ -14398,6 +14398,31 @@ fn a_budget_refusal_discards_pdf_edits_and_callback_globals() {
|
||||
assert_eq!(s.run_javascript(id, "typeof fromRejectedEdit;", None).unwrap().result.as_deref(), Some("undefined"));
|
||||
}
|
||||
|
||||
/// A batch whose field scripts ran and that a redaction check then refuses leaves the script realm
|
||||
/// as it was, like the document: here the check that a deleted marked page can't be tied to the
|
||||
/// pages the batch added.
|
||||
#[test]
|
||||
fn a_batch_a_redaction_check_refuses_discards_callback_globals() {
|
||||
let (mut s, id) = reset_order_session("");
|
||||
let script = Some("var fromRefusedBatch = 1;".to_string());
|
||||
s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "keystroke".into(), script }).unwrap();
|
||||
let shape = Shape::Redact { quads: vec![pdfcraft_annot::rect_quad([10.0, 10.0, 60.0, 30.0])], overlay: String::new(), look: Default::default() };
|
||||
let mark = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() };
|
||||
s.apply(id, Edit::AddAnnotation(mark)).unwrap();
|
||||
s.set_javascript(true);
|
||||
let before = s.save_bytes(id).unwrap();
|
||||
let edits = vec![
|
||||
Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("10".into()) },
|
||||
Edit::ApplyRedactions { pages: None },
|
||||
Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 },
|
||||
Edit::DeletePages { pages: vec![0] },
|
||||
];
|
||||
let refused = s.apply(id, Edit::Batch { label: "x".into(), edits });
|
||||
assert!(matches!(&refused, Err(EditError::Invalid(m)) if m.contains("can't be verified")), "{refused:?}");
|
||||
assert_eq!(s.save_bytes(id).unwrap(), before);
|
||||
assert_eq!(s.run_javascript(id, "typeof fromRefusedBatch;", None).unwrap().result.as_deref(), Some("undefined"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn timeout_after_undo_releases_the_stale_script_runtime() {
|
||||
|
||||
@@ -173,6 +173,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
rotation: 0,
|
||||
caption: None,
|
||||
mouse_up_script: None,
|
||||
mouse_down: false,
|
||||
list: None,
|
||||
round: true,
|
||||
};
|
||||
|
||||
@@ -31,8 +31,8 @@ pub use author::{
|
||||
};
|
||||
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
|
||||
pub use scripting::{
|
||||
DocumentTrigger, FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_action_scripts, document_scripts,
|
||||
document_scripts_named, set_document_script, set_field_script,
|
||||
DocumentTrigger, FieldChange, FieldEvent, NoScripts, PageEvent, PageScript, ScriptResult, Scripts, apply_script_changes, document_action_scripts,
|
||||
document_scripts, document_scripts_named, page_scripts, set_document_script, set_field_script,
|
||||
};
|
||||
|
||||
mod appearances;
|
||||
@@ -46,7 +46,7 @@ pub use read::selected_options;
|
||||
pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted};
|
||||
#[cfg(test)]
|
||||
use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads};
|
||||
use read::{first_by_export, script, script_budget};
|
||||
use read::{first_by_export, qualified_name, script, script_budget};
|
||||
pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders};
|
||||
#[cfg(test)]
|
||||
use values::has_password_flag;
|
||||
@@ -140,6 +140,9 @@ pub struct Widget {
|
||||
pub caption: Option<String>,
|
||||
/// Text widget JavaScript to run on a mouse-up click.
|
||||
pub mouse_up_script: Option<String>,
|
||||
/// The widget has a mouse-down action (`/AA /D`): a press runs it (the LaTeX `animate`
|
||||
/// package's controls act on mouse down), so a push button with only that still takes a press.
|
||||
pub mouse_down: bool,
|
||||
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
|
||||
pub list: Option<appearance::ListRows>,
|
||||
/// Drawn round: a radio button, or a check box laid out for a round XFA check button
|
||||
|
||||
@@ -85,7 +85,7 @@ pub(crate) fn action_fields(doc: &Document, o: &Object) -> Vec<String> {
|
||||
|
||||
/// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to,
|
||||
/// from the partial names (`/T`) up its `/Parent` chain.
|
||||
fn qualified_name(doc: &Document, r: ObjRef) -> Option<String> {
|
||||
pub(crate) fn qualified_name(doc: &Document, r: ObjRef) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut cur = Some(r);
|
||||
@@ -733,6 +733,7 @@ fn walk(
|
||||
} else {
|
||||
None
|
||||
},
|
||||
mouse_down: wd.get(b"AA").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_dict).is_some_and(|aa| aa.contains(b"D")),
|
||||
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
|
||||
round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)),
|
||||
})
|
||||
|
||||
@@ -626,6 +626,73 @@ fn in_array(doc: &Document, o: &Object, f: impl FnOnce(&[Object])) {
|
||||
}
|
||||
}
|
||||
|
||||
/// When a page's actions run: as it becomes the page being read, or stops being it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PageEvent {
|
||||
Open,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// A script a page runs as it opens or closes, with the field whose widget carries it (`None`:
|
||||
/// the page's own action, or an annotation that isn't a field's).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PageScript {
|
||||
pub field: Option<String>,
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
/// Most annotations of one page whose actions are read.
|
||||
const MAX_PAGE_ANNOTATIONS: usize = 10_000;
|
||||
|
||||
/// The JavaScript page `page` (0-based) runs for `event`, in the order it runs: the page's own
|
||||
/// action (`/AA /O` or `/C`), then each annotation's, in the page's `/Annots` order (`/AA /PO`
|
||||
/// then `/PV` as the page opens, `/PC` then `/PI` as it closes). Other kinds of action, and
|
||||
/// actions chained with `/Next`, are left out.
|
||||
pub fn page_scripts(doc: &Document, page: usize, event: PageEvent) -> Vec<PageScript> {
|
||||
let Some(page_ref) = crate::page_refs(doc).get(page).copied() else { return Vec::new() };
|
||||
let page_obj = doc.get(page_ref);
|
||||
let Some(pd) = page_obj.as_dict() else { return Vec::new() };
|
||||
let (own, keys): (&[u8], [&[u8]; 2]) = match event {
|
||||
PageEvent::Open => (b"O", [b"PO", b"PV"]),
|
||||
PageEvent::Close => (b"C", [b"PC", b"PI"]),
|
||||
};
|
||||
let additional = |d: &pdfcraft_cos::Dict| d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned());
|
||||
let mut budget = crate::script_budget();
|
||||
let mut out = Vec::new();
|
||||
if let Some(action) = additional(pd).and_then(|aa| aa.get(own).cloned())
|
||||
&& let Some(script) = javascript(doc, &action, &mut budget)
|
||||
{
|
||||
out.push(PageScript { field: None, script });
|
||||
}
|
||||
let annots = pd.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
for item in annots.iter().take(MAX_PAGE_ANNOTATIONS) {
|
||||
let annot = doc.resolve(item);
|
||||
let Some(ad) = annot.as_dict() else { continue };
|
||||
let Some(aa) = additional(ad) else { continue };
|
||||
let field = match item {
|
||||
Object::Ref(r) if ad.name(b"Subtype") == Some(b"Widget".as_slice()) => crate::qualified_name(doc, *r),
|
||||
_ => None,
|
||||
};
|
||||
for k in keys {
|
||||
if let Some(action) = aa.get(k)
|
||||
&& let Some(script) = javascript(doc, action, &mut budget)
|
||||
{
|
||||
out.push(PageScript { field: field.clone(), script });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The script of a JavaScript action (`/S /JavaScript`); `None` for any other action.
|
||||
fn javascript(doc: &Document, action: &Object, budget: &mut pdfcraft_cos::DecodeBudget) -> Option<String> {
|
||||
let a = doc.resolve(action);
|
||||
if a.as_dict()?.name(b"S")? != b"JavaScript" {
|
||||
return None;
|
||||
}
|
||||
crate::script(doc, action, budget)
|
||||
}
|
||||
|
||||
/// Add (or replace) a document-level JavaScript named `name`.
|
||||
pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) -> Result<(), FormError> {
|
||||
let root = doc.root().ok_or(FormError::NoForm)?;
|
||||
|
||||
@@ -7496,3 +7496,31 @@ fn simple_calculation_counts_the_first_value_of_a_multiple_selection_list() {
|
||||
recalculate(&mut doc).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "total").value, ["4"]);
|
||||
}
|
||||
|
||||
/// A page with open and close actions of its own (one script a stream), an animation's widget
|
||||
/// with page actions (one of them not a script), buttons acting on mouse down, and a screen
|
||||
/// annotation with a page-open action.
|
||||
fn page_actions_fixture() -> Document {
|
||||
document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] >> >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /AA << /O << /S /JavaScript /JS (page_open\\(\\);) >> /C << /S /JavaScript /JS 7 0 R >> >> /Annots [4 0 R 5 0 R 6 0 R 8 0 R] >>".into(), // 3
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (anim) /Rect [0 0 100 100] /P 3 0 R /AA << /PO << /S /JavaScript /JS (start\\(\\);) >> /PV << /S /JavaScript /JS (start\\(\\);) >> /PC << /S /JavaScript /JS (stop\\(\\);) >> /PI << /S /URI /URI (https://example.com) >> >> >>".into(), // 4
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (step) /Rect [0 0 10 10] /P 3 0 R /AA << /D << /S /JavaScript /JS (step\\(\\);) >> >> >>".into(), // 5
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (both) /Rect [20 0 30 10] /P 3 0 R /AA << /D << /S /JavaScript /JS (down\\(\\);) >> /U << /S /JavaScript /JS (up\\(\\);) >> >> >>".into(), // 6
|
||||
"<< /Length 7 >>\nstream\nclose()\nendstream".into(), // 7
|
||||
"<< /Type /Annot /Subtype /Screen /Rect [0 0 1 1] /AA << /PO << /S /JavaScript /JS (screen\\(\\);) >> >> >>".into(), // 8
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_scripts_are_the_pages_own_then_each_annotations_in_order() {
|
||||
let doc = page_actions_fixture();
|
||||
let open = page_scripts(&doc, 0, PageEvent::Open);
|
||||
let got: Vec<(Option<&str>, &str)> = open.iter().map(|s| (s.field.as_deref(), s.script.as_str())).collect();
|
||||
assert_eq!(got, [(None, "page_open();"), (Some("anim"), "start();"), (Some("anim"), "start();"), (None, "screen();")]);
|
||||
let close = page_scripts(&doc, 0, PageEvent::Close);
|
||||
let got: Vec<(Option<&str>, &str)> = close.iter().map(|s| (s.field.as_deref(), s.script.trim())).collect();
|
||||
assert_eq!(got, [(None, "close()"), (Some("anim"), "stop();")], "a URI action is not a script");
|
||||
assert!(page_scripts(&doc, 1, PageEvent::Open).is_empty(), "no such page");
|
||||
}
|
||||
@@ -416,11 +416,6 @@ fn js_number(v: f64) -> String {
|
||||
if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") }
|
||||
}
|
||||
|
||||
fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
// Timers never fire in the sandbox; scripts get an object they can pass to clear*.
|
||||
Ok(ObjectInitializer::new(ctx).build().into())
|
||||
}
|
||||
|
||||
/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as
|
||||
/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components.
|
||||
fn color_object(ctx: &mut Context) -> JsObject {
|
||||
@@ -489,7 +484,7 @@ fn document_info_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<
|
||||
|
||||
/// Install the host API once; live document properties continue reading each job's snapshot.
|
||||
pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
let globals: [(&str, usize, NativeFunction); 12] = [
|
||||
let globals: [(&str, usize, NativeFunction); 13] = [
|
||||
("getField", 1, NativeFunction::from_fn_ptr(get_field)),
|
||||
("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)),
|
||||
("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)),
|
||||
@@ -502,6 +497,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("dirty", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)),
|
||||
("getOCGs", 1, NativeFunction::from_fn_ptr(crate::ocg::get_ocgs)),
|
||||
];
|
||||
for (name, len, f) in globals {
|
||||
ctx.register_global_callable(JsString::from(name), len, f)?;
|
||||
@@ -556,10 +552,10 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
.function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(crate::interaction::popup_menu), js_string!("popUpMenuEx"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(crate::timers::set_time_out), js_string!("setTimeOut"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(crate::timers::set_interval), js_string!("setInterval"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(crate::timers::clear), js_string!("clearTimeOut"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(crate::timers::clear), js_string!("clearInterval"), 1)
|
||||
.property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY)
|
||||
.property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY)
|
||||
.property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY)
|
||||
|
||||
@@ -33,6 +33,8 @@ pub(crate) struct Host {
|
||||
pub(crate) retained: usize,
|
||||
/// Alerts, console lines and requests the limits left out.
|
||||
pub(crate) dropped: usize,
|
||||
/// Layers a script turned on or off (`ocg.state`), by their places in [`DocInfo::layers`].
|
||||
pub(crate) layers_changed: BTreeSet<usize>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
|
||||
@@ -74,6 +74,10 @@ mod geometry;
|
||||
pub use geometry::{rotate_rect, unrotate_rect};
|
||||
|
||||
mod icons;
|
||||
mod ocg;
|
||||
pub use ocg::Layer;
|
||||
mod timers;
|
||||
pub use timers::{MAX_TIMERS, MIN_TIMER_MS, Timer};
|
||||
|
||||
pub mod interaction;
|
||||
pub mod session;
|
||||
|
||||
@@ -187,6 +187,8 @@ pub struct DocInfo {
|
||||
pub xfa: bool,
|
||||
pub zoom: f64,
|
||||
pub runtime_highlight: bool,
|
||||
/// The document's layers (optional content groups), for `getOCGs`, in the viewer's order.
|
||||
pub layers: Vec<crate::Layer>,
|
||||
}
|
||||
|
||||
/// The icon a script's message shows: `app.alert`'s `nIcon` and `xfa.host.messageBox`'s
|
||||
@@ -388,6 +390,12 @@ pub struct Outcome {
|
||||
pub doc_errors: Vec<String>,
|
||||
/// The script's completion value (what the console prints), unless undefined.
|
||||
pub result: Option<String>,
|
||||
/// The timers of a document's retained realm as they stand after the run, in the order they
|
||||
/// were set (`app.setInterval`, `app.setTimeOut`); none from a one-off run.
|
||||
pub timers: Vec<crate::Timer>,
|
||||
/// Layers the script turned on or off (`ocg.state`), as they are now: their places in
|
||||
/// [`DocInfo::layers`], and whether each is on.
|
||||
pub layers: Vec<(usize, bool)>,
|
||||
}
|
||||
|
||||
/// Bounds that keep a script from hanging the viewer.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! `this.getOCGs()`: the document's layers (optional content groups) as OCG objects with
|
||||
//! `name`, `state` and `initState` (#169, ported from #4751). Setting `state` turns a layer on
|
||||
//! or off as the Layers panel does: how the document is shown, not the document; the caller
|
||||
//! reads the layers a run turned in [`crate::Outcome::layers`].
|
||||
|
||||
use crate::host::{arg, error, function, host, s};
|
||||
use crate::{Attribute, Context, JsArray, JsResult, JsValue, NativeFunction, ObjectInitializer, js_string};
|
||||
|
||||
/// A layer as `getOCGs` shows it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Layer {
|
||||
pub name: String,
|
||||
/// Shown now (`ocg.state`).
|
||||
pub on: bool,
|
||||
/// Shown as the file opens (`ocg.initState`).
|
||||
pub initial: bool,
|
||||
}
|
||||
|
||||
/// `this.getOCGs(nPage)`: every layer of the document, whatever the page (which pages a layer
|
||||
/// is on isn't known here), or `null` when it has none.
|
||||
pub(crate) fn get_ocgs(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let n = host(ctx)?.borrow().doc.layers.len();
|
||||
if n == 0 {
|
||||
return Ok(JsValue::null());
|
||||
}
|
||||
let items: Vec<JsValue> = (0..n).map(|i| ocg(ctx, i)).collect();
|
||||
Ok(JsArray::from_iter(items, ctx).into())
|
||||
}
|
||||
|
||||
fn layer(c: &Context, i: u32) -> JsResult<Layer> {
|
||||
host(c)?.borrow().doc.layers.get(i as usize).cloned().ok_or_else(|| error("the layer is no longer available"))
|
||||
}
|
||||
|
||||
fn ocg(ctx: &mut Context, index: usize) -> JsValue {
|
||||
fn name(_: &JsValue, _: &[JsValue], i: &u32, c: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(s(&layer(c, *i)?.name))
|
||||
}
|
||||
fn state(_: &JsValue, _: &[JsValue], i: &u32, c: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(layer(c, *i)?.on))
|
||||
}
|
||||
fn initial(_: &JsValue, _: &[JsValue], i: &u32, c: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(layer(c, *i)?.initial))
|
||||
}
|
||||
fn set_state(_: &JsValue, args: &[JsValue], i: &u32, c: &mut Context) -> JsResult<JsValue> {
|
||||
let on = arg(args, 0).to_boolean();
|
||||
let mut h = host(c)?.borrow_mut();
|
||||
let l = h.doc.layers.get_mut(*i as usize).ok_or_else(|| error("the layer is no longer available"))?;
|
||||
l.on = on;
|
||||
h.layers_changed.insert(*i as usize);
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
let i = u32::try_from(index).unwrap_or(u32::MAX);
|
||||
let name = function(ctx, NativeFunction::from_copy_closure_with_captures(name, i));
|
||||
let state = function(ctx, NativeFunction::from_copy_closure_with_captures(state, i));
|
||||
let set_state = function(ctx, NativeFunction::from_copy_closure_with_captures(set_state, i));
|
||||
let initial = function(ctx, NativeFunction::from_copy_closure_with_captures(initial, i));
|
||||
let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE;
|
||||
ObjectInitializer::new(ctx)
|
||||
.accessor(js_string!("name"), Some(name), None, rw)
|
||||
.accessor(js_string!("state"), Some(state), Some(set_state), rw)
|
||||
.accessor(js_string!("initState"), Some(initial), None, rw)
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
+14
-2
@@ -1,7 +1,9 @@
|
||||
use super::globals::{event_object, install};
|
||||
use super::host::{Host, Shared, host, text};
|
||||
use super::model::{DocInfo, Event, FieldState, Limits, Outcome};
|
||||
use super::{Arc, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Realm, RefCell, Source, js_string};
|
||||
use super::{
|
||||
Arc, BTreeSet, Context, HostHooks, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Realm, RefCell, Source, js_string,
|
||||
};
|
||||
|
||||
/// Longest script, in bytes.
|
||||
pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024;
|
||||
@@ -148,6 +150,7 @@ pub(crate) fn run_shared_here(
|
||||
reset_calls: 0,
|
||||
calculation_evaluations: 0,
|
||||
calculation_limit_exceeded: false,
|
||||
call: None,
|
||||
},
|
||||
limits,
|
||||
)
|
||||
@@ -200,6 +203,8 @@ pub(crate) struct ScriptSetup<'a> {
|
||||
pub(crate) reset_calls: usize,
|
||||
pub(crate) calculation_evaluations: usize,
|
||||
pub(crate) calculation_limit_exceeded: bool,
|
||||
/// A timer's function to call in place of the script (#169).
|
||||
pub(crate) call: Option<JsObject>,
|
||||
}
|
||||
|
||||
/// Create a restricted temporary Boa context and convert initialization failures into script outcomes.
|
||||
@@ -272,6 +277,7 @@ pub(crate) fn run_in_context(
|
||||
kept: 0,
|
||||
retained: 0,
|
||||
dropped: 0,
|
||||
layers_changed: BTreeSet::new(),
|
||||
}));
|
||||
let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() };
|
||||
let mut doc_errors: Vec<(usize, String)> = setup.skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect();
|
||||
@@ -297,7 +303,10 @@ pub(crate) fn run_in_context(
|
||||
doc_errors.push((i, doc_error(i, &e)));
|
||||
}
|
||||
}
|
||||
let r = ctx.eval(Source::from_bytes(script))?;
|
||||
let r = match &setup.call {
|
||||
Some(f) => f.call(&JsValue::undefined(), &[], ctx)?,
|
||||
None => ctx.eval(Source::from_bytes(script))?,
|
||||
};
|
||||
if !r.is_undefined() {
|
||||
out.result = Some(text(&r, ctx)?);
|
||||
}
|
||||
@@ -328,11 +337,14 @@ pub(crate) fn run_in_context(
|
||||
out.alerts = std::mem::take(&mut h.alerts);
|
||||
out.console = std::mem::take(&mut h.console);
|
||||
out.requests = std::mem::take(&mut h.requests);
|
||||
let layers = std::mem::take(&mut h.layers_changed);
|
||||
out.layers = layers.into_iter().filter_map(|i| h.doc.layers.get(i).map(|l| (i, l.on))).collect();
|
||||
if h.dropped > 0 {
|
||||
out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped));
|
||||
}
|
||||
ctx.insert_data(RefCell::new(h));
|
||||
}
|
||||
out.timers = crate::timers::report(ctx);
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
+119
-3
@@ -16,6 +16,10 @@ pub struct Job {
|
||||
pub doc_scripts: Vec<String>,
|
||||
/// Target positions in this job's field snapshot and their ordered script sources.
|
||||
pub calculations: Vec<(usize, String)>,
|
||||
/// Fire this timer of the realm (`app.setInterval`, `app.setTimeOut`) in place of `script`
|
||||
/// (#169). A timer the realm no longer has runs nothing; nor does any timer of a realm that
|
||||
/// has not started, or whose document scripts changed (its timers went with it).
|
||||
pub timer: Option<u32>,
|
||||
}
|
||||
/// Live field instances are independent of restorable PDF object-number allocation.
|
||||
#[derive(Default)]
|
||||
@@ -129,8 +133,12 @@ impl Realm {
|
||||
}
|
||||
fn run_job(&mut self, job: Job) -> Outcome {
|
||||
let failed = |why: String| Outcome { rc: true, execution_refused: true, realm_untouched: true, error: Some(why), ..Default::default() };
|
||||
if let Some(id) = job.timer {
|
||||
return self.fire(job, id);
|
||||
}
|
||||
// A script too long or too deeply nested to run is that script's error, as on `main`: the
|
||||
// value stays and the edit's other scripts still run. The realm is not touched.
|
||||
// value stays and the edit's other scripts still run. The realm is not touched, so its
|
||||
// timers are as they were: they are reported, as after any run, or the viewer drops them.
|
||||
if let Some(why) = crate::refuse(&job.script) {
|
||||
return Outcome {
|
||||
rc: true,
|
||||
@@ -138,6 +146,7 @@ impl Realm {
|
||||
value: job.event.value.clone(),
|
||||
change: job.event.change.clone(),
|
||||
error: Some(why),
|
||||
timers: self.context.as_ref().map(crate::timers::report).unwrap_or_default(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
@@ -147,7 +156,10 @@ impl Realm {
|
||||
let initialize = self.context.is_none() || self.scripts != job.doc_scripts;
|
||||
if initialize {
|
||||
self.context = match crate::Context::builder().host_hooks(std::rc::Rc::new(crate::run::NoCodeFromStrings)).build() {
|
||||
Ok(c) => Some(c),
|
||||
Ok(mut c) => {
|
||||
crate::timers::enable(&mut c);
|
||||
Some(c)
|
||||
}
|
||||
Err(e) => return failed(e.to_string()),
|
||||
};
|
||||
self.scripts = job.doc_scripts.clone();
|
||||
@@ -186,6 +198,7 @@ impl Realm {
|
||||
reset_calls: 0,
|
||||
calculation_evaluations: 0,
|
||||
calculation_limit_exceeded: false,
|
||||
call: None,
|
||||
},
|
||||
Limits::default(),
|
||||
);
|
||||
@@ -212,6 +225,7 @@ impl Realm {
|
||||
reset_calls,
|
||||
calculation_evaluations,
|
||||
calculation_limit_exceeded: initial.calculation_limit_exceeded,
|
||||
call: None,
|
||||
},
|
||||
Limits::default(),
|
||||
);
|
||||
@@ -251,6 +265,7 @@ impl Realm {
|
||||
reset_calls: 0,
|
||||
calculation_evaluations: 0,
|
||||
calculation_limit_exceeded: false,
|
||||
call: None,
|
||||
},
|
||||
Limits::default(),
|
||||
)
|
||||
@@ -258,6 +273,43 @@ impl Realm {
|
||||
}
|
||||
}
|
||||
|
||||
impl Realm {
|
||||
/// Run timer `id` that fell due, with the job's form and document: its expression, or the
|
||||
/// function it was given, in the realm that set it (#169).
|
||||
fn fire(&mut self, job: Job, id: u32) -> Outcome {
|
||||
let idle = |timers: Vec<crate::Timer>| Outcome { rc: true, realm_untouched: true, timers, ..Default::default() };
|
||||
let Some(ctx) = self.context.as_mut().filter(|_| self.scripts == job.doc_scripts) else { return idle(Vec::new()) };
|
||||
let Some(code) = crate::timers::due(ctx, id) else { return idle(crate::timers::report(ctx)) };
|
||||
let (script, call) = match code {
|
||||
crate::timers::Code::Source(source) => {
|
||||
if let Some(why) = crate::refuse(&source) {
|
||||
return Outcome { rc: true, realm_untouched: true, error: Some(why), timers: crate::timers::report(ctx), ..Default::default() };
|
||||
}
|
||||
(source, None)
|
||||
}
|
||||
crate::timers::Code::Function(f) => (String::new(), Some(f)),
|
||||
};
|
||||
crate::run::run_in_context(
|
||||
ctx,
|
||||
&script,
|
||||
&job.event,
|
||||
&job.doc,
|
||||
job.fields,
|
||||
crate::run::ScriptSetup {
|
||||
doc_scripts: &[],
|
||||
skipped: &[],
|
||||
calculations: &job.calculations,
|
||||
initialize: false,
|
||||
reset_calls: 0,
|
||||
calculation_evaluations: 0,
|
||||
calculation_limit_exceeded: false,
|
||||
call,
|
||||
},
|
||||
Limits::default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static LIVE_WORKERS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -577,8 +629,68 @@ mod tests {
|
||||
fields: Arc::new(vec![FieldState::new("go", crate::FieldType::Button, vec!["old".into()])]),
|
||||
doc_scripts: vec!["var buffer='', init=1, opened=event.name;var held=getField('go');function digit(v){buffer+=v;return buffer;}".into()],
|
||||
calculations: Vec::new(),
|
||||
timer: None,
|
||||
}
|
||||
}
|
||||
/// A job firing timer `id` of the realm, as the viewer runs one that fell due (#169).
|
||||
fn fire(id: u32) -> Job {
|
||||
Job { script: String::new(), event: Event { kind: "App".into(), ..Event::doc("Timer") }, timer: Some(id), ..job("") }
|
||||
}
|
||||
#[test]
|
||||
/// Timers a script sets stay in the document's realm and fire there: an interval until it is
|
||||
/// cleared, a time-out once; the field writes a timer makes are reported like any script's.
|
||||
fn timers_fire_in_the_document_realm_until_cleared() {
|
||||
let mut s = ScriptSession::default();
|
||||
let set = s.run(
|
||||
job("var n = 0; var t = app.setInterval(function () { n++; getField('go').value = 'frame ' + n; }, 40); app.setTimeOut('n += 100', 0);"),
|
||||
&[],
|
||||
);
|
||||
assert_eq!(set.error, None);
|
||||
assert_eq!(
|
||||
set.timers,
|
||||
[crate::Timer { id: 1, period_ms: 40, repeat: true }, crate::Timer { id: 2, period_ms: crate::MIN_TIMER_MS, repeat: false }]
|
||||
);
|
||||
let first = s.run(fire(1), &[]);
|
||||
assert_eq!(first.error, None);
|
||||
assert_eq!(first.changed.iter().map(|f| f.value.clone()).collect::<Vec<_>>(), [vec!["frame 1".to_string()]]);
|
||||
let once = s.run(fire(2), &[]);
|
||||
assert_eq!(once.timers.len(), 1, "a time-out runs once");
|
||||
assert!(s.run(fire(2), &[]).realm_untouched, "and is gone after");
|
||||
s.run(fire(1), &[]);
|
||||
assert_eq!(s.run(job("n;"), &[]).result.as_deref(), Some("102"));
|
||||
let cleared = s.run(job("app.clearInterval(t); app.clearInterval({}); n;"), &[]);
|
||||
assert!(cleared.timers.is_empty());
|
||||
assert!(s.run(fire(1), &[]).changed.is_empty(), "a cleared timer runs nothing");
|
||||
}
|
||||
#[test]
|
||||
/// A document keeps at most 64 timers, a timer runs at most every 10 ms, a one-off run keeps
|
||||
/// none, and changed document scripts start a realm without the old timers.
|
||||
fn timers_are_bounded_and_belong_to_one_realm() {
|
||||
let mut s = ScriptSession::default();
|
||||
let many = s.run(
|
||||
job("var made = 0; try { for (var i = 0; i < 70; i++) { app.setInterval('1', -5); made++; } } catch (e) { made + ': ' + e.message; }"),
|
||||
&[],
|
||||
);
|
||||
assert_eq!(many.result.as_deref(), Some("64: a document's scripts can keep at most 64 timers"));
|
||||
assert!(many.timers.iter().all(|t| t.period_ms == crate::MIN_TIMER_MS));
|
||||
let one_off = crate::run("app.setInterval('1', 50); 'set';", &Event::doc("Console"), &DocInfo::default(), &[], &[], Limits::default());
|
||||
assert_eq!(one_off.result.as_deref(), Some("set"));
|
||||
assert!(one_off.timers.is_empty());
|
||||
let changed = Job { doc_scripts: vec!["var other = 1;".into()], ..fire(1) };
|
||||
let gone = s.run(changed, &[]);
|
||||
assert!(gone.timers.is_empty() && gone.changed.is_empty(), "{gone:?}");
|
||||
}
|
||||
#[test]
|
||||
/// A script refused before it runs (too long) leaves the realm as it was and reports the
|
||||
/// timers it still has: the viewer keeps only the timers a run reports.
|
||||
fn a_script_refused_before_it_runs_reports_the_realms_timers() {
|
||||
let mut s = ScriptSession::default();
|
||||
let set = s.run(job("app.setInterval('1', 40);"), &[]);
|
||||
assert_eq!(set.timers.len(), 1);
|
||||
let refused = s.run(job(&"1;".repeat(crate::MAX_SCRIPT_BYTES)), &[]);
|
||||
assert!(refused.realm_untouched && !refused.execution_refused && refused.error.is_some(), "{:?}", refused.error);
|
||||
assert_eq!(refused.timers, set.timers);
|
||||
}
|
||||
#[test]
|
||||
/// Retained Field identity, custom properties and keypad buffers persist locally while separate documents stay isolated.
|
||||
fn document_realm_keeps_keypad_state_and_isolates_documents() {
|
||||
@@ -980,6 +1092,7 @@ mod reset_budget_tests {
|
||||
fields: Arc::new(vec![]),
|
||||
doc_scripts: vec!["for(var i=0;i<64;i++)resetForm();".into()],
|
||||
calculations: vec![],
|
||||
timer: None,
|
||||
};
|
||||
let out = runtime.run(job.clone(), &[]);
|
||||
assert!(out.reset_limit_exceeded);
|
||||
@@ -1006,6 +1119,7 @@ mod event_identity_tests {
|
||||
fields: Arc::new(vec![field]),
|
||||
doc_scripts: vec![],
|
||||
calculations: vec![],
|
||||
timer: None,
|
||||
};
|
||||
let out = ScriptSession::default().run(job, &[]);
|
||||
assert_eq!(out.error, None);
|
||||
@@ -1027,7 +1141,7 @@ mod cache_capacity_tests {
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let job=Job{script:"for(var i=0;i<4100;i++)getField('f'+i);var held=getField('f4099');held.custom=42;held===getField('f4099') && getField('f4099').custom===42;".into(),event:Event::doc("Console"),doc:Default::default(),fields,doc_scripts:vec![],calculations:vec![]};
|
||||
let job=Job{script:"for(var i=0;i<4100;i++)getField('f'+i);var held=getField('f4099');held.custom=42;held===getField('f4099') && getField('f4099').custom===42;".into(),event:Event::doc("Console"),doc:Default::default(),fields,doc_scripts:vec![],calculations:vec![],timer:None};
|
||||
let mut session = ScriptSession::default();
|
||||
let out = session.run(job.clone(), &[]);
|
||||
assert_eq!(out.error, None);
|
||||
@@ -1051,6 +1165,7 @@ mod cache_lifecycle_tests {
|
||||
fields: Arc::new(vec![field.clone()]),
|
||||
doc_scripts: vec![],
|
||||
calculations: vec![],
|
||||
timer: None,
|
||||
};
|
||||
let mut realm = Realm::new();
|
||||
assert_eq!(realm.run(job.clone()).error, None);
|
||||
@@ -1088,6 +1203,7 @@ mod review13_tests {
|
||||
fields: Arc::new(fields),
|
||||
doc_scripts: vec![],
|
||||
calculations: vec![],
|
||||
timer: None,
|
||||
}
|
||||
}
|
||||
/// A retired accessor must not resolve a replacement even if no job observed the deletion.
|
||||
|
||||
@@ -541,6 +541,7 @@ fn app_alert_titles_are_clipped_and_charged() {
|
||||
kept: 0,
|
||||
retained: 0,
|
||||
dropped: 0,
|
||||
layers_changed: Default::default(),
|
||||
};
|
||||
h.alert(Message { title: Some("Order form".into()), ..Message::plain("Saved") });
|
||||
assert_eq!(h.kept, "Saved".len() + "Order form".len());
|
||||
@@ -2749,6 +2750,29 @@ fn af_simple_calculate_bounds_selector_work_before_expansion() {
|
||||
assert!(out.error.as_deref().is_some_and(|e| e.contains("calculation field lookup exceeds")));
|
||||
}
|
||||
|
||||
/// `getOCGs` lists the document's layers; setting `state` turns one, which the run reports, and
|
||||
/// a document without layers gives `null` (#169, from #4751).
|
||||
#[test]
|
||||
fn get_ocgs_lists_layers_and_reports_the_ones_a_script_turns() {
|
||||
let layers =
|
||||
vec![crate::Layer { name: "Frame 1".into(), on: true, initial: true }, crate::Layer { name: "Frame 2".into(), on: false, initial: false }];
|
||||
let layered = DocInfo { layers, ..doc() };
|
||||
let out = run(
|
||||
"var o = this.getOCGs(); var before = o.map(function (l) { return l.name + '=' + l.state + '/' + l.initState; }).join(','); \
|
||||
o[0].state = false; o[1].state = true; before + ' ' + o[1].state;",
|
||||
&Event::doc("Console"),
|
||||
&layered,
|
||||
&[],
|
||||
&[],
|
||||
Default::default(),
|
||||
);
|
||||
assert_eq!(out.error, None);
|
||||
assert_eq!(out.result.as_deref(), Some("Frame 1=true/true,Frame 2=false/false true"));
|
||||
assert_eq!(out.layers, [(0, false), (1, true)]);
|
||||
let none = run("this.getOCGs() === null;", &Event::doc("Console"), &doc(), &[], &[], Default::default());
|
||||
assert_eq!(none.result.as_deref(), Some("true"));
|
||||
}
|
||||
|
||||
/// `toSource` writes plain state as a JavaScript literal of the same value (#269, from #344).
|
||||
/// Scripts here can't `eval` it back (strings never compile), so the exact text is checked.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! `app.setInterval`, `app.setTimeOut`, `app.clearInterval` and `app.clearTimeOut` in a
|
||||
//! document's retained realm (#169, ported from #4751). The realm keeps the timers its scripts
|
||||
//! set and reports them in every [`crate::Outcome`]; the caller owns the clock and fires one
|
||||
//! by running a [`crate::session::Job`] with its `timer` when it falls due, in the same realm. A
|
||||
//! one-off run (a fresh context) keeps none: there the functions return an object and nothing
|
||||
//! ever fires, as before.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::host::{arg, error, text};
|
||||
use crate::{Context, JsObject, JsResult, JsValue, MAX_SCRIPT_BYTES, ObjectInitializer};
|
||||
|
||||
/// Most timers one document's scripts keep at once; setting another is an error.
|
||||
pub const MAX_TIMERS: usize = 64;
|
||||
/// The shortest period, in milliseconds: a timer asked to run sooner or more often runs at this.
|
||||
pub const MIN_TIMER_MS: u32 = 10;
|
||||
|
||||
/// A timer a script set, as the realm reports it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Timer {
|
||||
/// The realm's name for it, never reused while the realm lives.
|
||||
pub id: u32,
|
||||
/// Milliseconds between runs (`setInterval`) or before the one run (`setTimeOut`), at least
|
||||
/// [`MIN_TIMER_MS`].
|
||||
pub period_ms: u32,
|
||||
/// `setInterval`: it runs until it is cleared. `setTimeOut`: once.
|
||||
pub repeat: bool,
|
||||
}
|
||||
|
||||
/// What a timer runs: its expression, or a function the script gave instead.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum Code {
|
||||
Source(String),
|
||||
Function(JsObject),
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
/// The object `set*` returned, which `clear*` is given back.
|
||||
handle: JsObject,
|
||||
code: Code,
|
||||
period_ms: u32,
|
||||
repeat: bool,
|
||||
}
|
||||
|
||||
/// A realm's timers by id, in the order they were set (ids only grow).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Timers {
|
||||
last: u32,
|
||||
table: BTreeMap<u32, Entry>,
|
||||
}
|
||||
|
||||
type Shared = RefCell<Timers>;
|
||||
|
||||
/// Let the timers of a retained realm run: the functions keep what they are given from now on.
|
||||
pub(crate) fn enable(ctx: &mut Context) {
|
||||
if ctx.get_data::<Shared>().is_none() {
|
||||
ctx.insert_data(RefCell::new(Timers::default()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The realm's timers as they stand, in the order they were set; none in a one-off run.
|
||||
pub(crate) fn report(ctx: &Context) -> Vec<Timer> {
|
||||
ctx.get_data::<Shared>()
|
||||
.map(|t| t.borrow().table.iter().map(|(id, e)| Timer { id: *id, period_ms: e.period_ms, repeat: e.repeat }).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What timer `id` runs now that it fell due; a `setTimeOut` timer is gone after this. `None`
|
||||
/// when it was cleared (or the realm has no timers).
|
||||
pub(crate) fn due(ctx: &Context, id: u32) -> Option<Code> {
|
||||
let mut timers = ctx.get_data::<Shared>()?.borrow_mut();
|
||||
let entry = timers.table.get(&id)?;
|
||||
let code = entry.code.clone();
|
||||
if !entry.repeat {
|
||||
timers.table.remove(&id);
|
||||
}
|
||||
Some(code)
|
||||
}
|
||||
|
||||
/// `app.setInterval(cExpr, nMilliseconds)`: run the expression (or a function) every period
|
||||
/// until it is cleared.
|
||||
pub(crate) fn set_interval(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
set(args, true, ctx)
|
||||
}
|
||||
|
||||
/// `app.setTimeOut(cExpr, nMilliseconds)`: run the expression (or a function) once, after the
|
||||
/// period.
|
||||
pub(crate) fn set_time_out(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
set(args, false, ctx)
|
||||
}
|
||||
|
||||
fn set(args: &[JsValue], repeat: bool, ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let handle = ObjectInitializer::new(ctx).build();
|
||||
if ctx.get_data::<Shared>().is_none() {
|
||||
return Ok(handle.into());
|
||||
}
|
||||
let what = arg(args, 0);
|
||||
let code = match what.as_callable() {
|
||||
Some(f) => Code::Function(f.clone()),
|
||||
None => {
|
||||
let source = text(&what, ctx)?;
|
||||
if source.len() > MAX_SCRIPT_BYTES {
|
||||
return Err(error(format!("the timer's script is too long (the limit is {} KiB)", MAX_SCRIPT_BYTES / 1024)));
|
||||
}
|
||||
Code::Source(source)
|
||||
}
|
||||
};
|
||||
let ms = arg(args, 1).to_number(ctx)?;
|
||||
// A missing, negative or NaN period runs as soon as allowed; the float cast saturates.
|
||||
let period_ms = if ms.is_nan() { MIN_TIMER_MS } else { (ms.min(f64::from(u32::MAX)) as u32).max(MIN_TIMER_MS) };
|
||||
let Some(timers) = ctx.get_data::<Shared>() else { return Ok(handle.into()) };
|
||||
let mut timers = timers.borrow_mut();
|
||||
if timers.table.len() >= MAX_TIMERS {
|
||||
return Err(error(format!("a document's scripts can keep at most {MAX_TIMERS} timers")));
|
||||
}
|
||||
let id = timers.last.checked_add(1).ok_or_else(|| error("no more timers can be set"))?;
|
||||
timers.last = id;
|
||||
timers.table.insert(id, Entry { handle: handle.clone(), code, period_ms, repeat });
|
||||
Ok(handle.into())
|
||||
}
|
||||
|
||||
/// `app.clearInterval(oInterval)` and `app.clearTimeOut(oTime)`: forget the timer the object
|
||||
/// stands for. Anything else, or a timer already gone, is ignored.
|
||||
pub(crate) fn clear(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
if let Some(o) = arg(args, 0).as_object()
|
||||
&& let Some(timers) = ctx.get_data::<Shared>()
|
||||
{
|
||||
timers.borrow_mut().table.retain(|_, e| !JsObject::equals(&e.handle, &o));
|
||||
}
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
@@ -68,6 +68,15 @@ pub struct RenderConfig {
|
||||
pub layers: Arc<Vec<(i32, i32, bool)>>,
|
||||
/// View ▸ Hide all comments: markup annotations aren't drawn (fields and links still are).
|
||||
pub hide_comments: bool,
|
||||
/// Annotations shown or hidden for viewing in place of their Hidden and NoView flags (object
|
||||
/// number, generation, shown): fields a document's page actions and timers showed or hid
|
||||
/// (an animation's frames). Display only; the document never changes.
|
||||
pub annotations: Arc<Vec<(i32, i32, bool)>>,
|
||||
/// Annotations drawn with a form XObject as their normal appearance (object number and
|
||||
/// generation of the annotation, then of the form): push buttons a document's page actions
|
||||
/// and timers gave another icon (`buttonSetIcon`, an animation's frames). Display only; the
|
||||
/// document never changes.
|
||||
pub appearances: Arc<Vec<(i32, i32, i32, i32)>>,
|
||||
/// Refuse a whole-page raster that would be reduced by the renderer's hard size caps.
|
||||
/// Tiled requests remain available for pages that exceed those caps. Both modes refuse
|
||||
/// a raster whose rounded integer dimensions exceed the final pixel-allocation budget.
|
||||
@@ -118,6 +127,8 @@ impl RenderConfig {
|
||||
ocg_overrides: self.layers.clone(),
|
||||
view_auto_state: !self.for_print,
|
||||
hide_comments: self.hide_comments,
|
||||
annotation_overrides: self.annotations.clone(),
|
||||
appearance_overrides: self.appearances.clone(),
|
||||
font_resolver: Arc::new(move |query| {
|
||||
japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query))
|
||||
}),
|
||||
@@ -377,7 +388,8 @@ fn render_page<'a>(
|
||||
) -> Result<Output, (String, bool)> {
|
||||
// Print previews share the viewer pool, but must use the output font policy and the layers
|
||||
// as they print (`print_layers`). Keep the caller's cancellation/warning hooks and annotation
|
||||
// settings. A fresh cache is essential: hayro keys cached fonts by PDF dictionary, not by the
|
||||
// settings, except what a document's scripts show while it is read (`annotations`,
|
||||
// `appearances`), which never prints. A fresh cache is essential: hayro keys cached fonts by PDF dictionary, not by the
|
||||
// font resolver, so the viewer cache could supply an installed face even after changing the
|
||||
// resolver (and a cached missing font from print preview could blank the viewer on switching
|
||||
// back); nothing drawn with the viewer's layers is reused either.
|
||||
@@ -385,10 +397,13 @@ fn render_page<'a>(
|
||||
let print_cache;
|
||||
let print = req.tag & PRINT_TAG != 0;
|
||||
let print_layers = config.print_layers.as_ref().filter(|_| print);
|
||||
let (settings, cache) = if (config.system_fonts && print) || print_layers.is_some() {
|
||||
let live = !settings.annotation_overrides.is_empty() || !settings.appearance_overrides.is_empty();
|
||||
let (settings, cache) = if print && (config.system_fonts || print_layers.is_some() || live) {
|
||||
print_settings = InterpreterSettings {
|
||||
font_resolver: RenderConfig { system_fonts: false, ..config.clone() }.settings().font_resolver,
|
||||
ocg_overrides: print_layers.cloned().unwrap_or_else(|| settings.ocg_overrides.clone()),
|
||||
annotation_overrides: Arc::default(),
|
||||
appearance_overrides: Arc::default(),
|
||||
..settings.clone()
|
||||
};
|
||||
print_cache = RenderCache::new();
|
||||
|
||||
@@ -2741,6 +2741,58 @@ trailer << /Root 1 0 R >>
|
||||
assert_eq!(px(&hidden, 20, 80), vec![255, 0, 0, 255], "fields stay");
|
||||
}
|
||||
|
||||
/// The viewer's overrides (patches 105 and 106) show a hidden widget, hide a shown one and draw
|
||||
/// one with another form, by object; an entry of `/Annots` that isn't a dictionary is skipped.
|
||||
#[test]
|
||||
fn annotation_and_appearance_overrides_change_only_the_view() {
|
||||
let pdf = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Annots [42 5 0 R 6 0 R] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (a) /F 2 /Rect [0 0 50 50] /AP << /N 7 0 R >> >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (b) /Rect [50 0 100 50] /AP << /N 8 0 R >> >> endobj
|
||||
7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 50 50] /Length 24 >> stream
|
||||
1 0 0 rg 0 0 50 50 re f
|
||||
endstream endobj
|
||||
8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 50 50] /Length 24 >> stream
|
||||
0 0 1 rg 0 0 50 50 re f
|
||||
endstream endobj
|
||||
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 24 >> stream
|
||||
0 1 0 rg 0 0 10 10 re f
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let render = |config: RenderConfig| {
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), config);
|
||||
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })
|
||||
};
|
||||
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
|
||||
let (white, red, blue, green) = (vec![255, 255, 255, 255], vec![255, 0, 0, 255], vec![0, 0, 255, 255], vec![0, 255, 0, 255]);
|
||||
let plain = render(RenderConfig::default());
|
||||
assert_eq!((px(&plain, 25, 25), px(&plain, 75, 25)), (white.clone(), blue), "the hidden widget isn't drawn");
|
||||
let flipped = render(RenderConfig { annotations: Arc::new(vec![(5, 0, true), (6, 0, false)]), ..RenderConfig::default() });
|
||||
assert_eq!((px(&flipped, 25, 25), px(&flipped, 75, 25)), (red, white.clone()), "shown and hidden by object");
|
||||
let face = render(RenderConfig { appearances: Arc::new(vec![(6, 0, 9, 0), (5, 0, 99, 0)]), ..RenderConfig::default() });
|
||||
assert_eq!((px(&face, 25, 25), px(&face, 75, 25)), (white.clone(), green), "drawn with the other form, scaled to its own rectangle");
|
||||
let cleared = render(RenderConfig { appearances: Arc::new(vec![(6, 0, 0, 0)]), ..RenderConfig::default() });
|
||||
assert_eq!(px(&cleared, 75, 25), white, "an override naming no form draws nothing (a cleared icon), not the button's own face");
|
||||
// A print preview draws the page as it prints, without what the scripts show.
|
||||
let config = RenderConfig {
|
||||
annotations: Arc::new(vec![(5, 0, true), (6, 0, false)]),
|
||||
appearances: Arc::new(vec![(6, 0, 9, 0)]),
|
||||
..RenderConfig::default()
|
||||
};
|
||||
let preview = PageRenderer::new(Arc::new(pdf.to_vec()), config).render(RenderRequest {
|
||||
page: 0,
|
||||
kind: RequestKind::Pixels,
|
||||
tile: None,
|
||||
scale: 1.0,
|
||||
tag: PRINT_TAG,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!((px(&preview, 25, 25), px(&preview, 75, 25)), (vec![255, 255, 255, 255], vec![0, 0, 255, 255]), "the print preview");
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 167: Invisible hides an annotation of a type that isn't standard and
|
||||
/// nothing else; §12.5.6.14: a popup has no appearance of its own, so its `/AP` isn't painted.
|
||||
#[test]
|
||||
|
||||
@@ -722,6 +722,7 @@ impl PdfCraftApp {
|
||||
let out = self.session.take_js_output(id);
|
||||
self.handle_js(id, out);
|
||||
}
|
||||
self.script_tick(ctx);
|
||||
if let Some(mut control) = self.control.take() {
|
||||
control.tick(ctx, self);
|
||||
self.control = Some(control);
|
||||
|
||||
@@ -101,6 +101,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
|
||||
tool,
|
||||
pcx: &pcx,
|
||||
form,
|
||||
doc,
|
||||
can_fill,
|
||||
can_modify,
|
||||
editing_content,
|
||||
@@ -486,6 +487,8 @@ struct PageHits<'a> {
|
||||
tool: QuickTool,
|
||||
pcx: &'a comments::PageCx<'a>,
|
||||
form: &'a [pdfcraft_engine::FormField],
|
||||
/// Its document: what its scripts show while it is read (`Document::shown_by_scripts`).
|
||||
doc: &'a pdfcraft_engine::Document,
|
||||
can_fill: bool,
|
||||
can_modify: bool,
|
||||
/// Edit a PDF is open: added text and pictures can be picked up.
|
||||
@@ -576,6 +579,7 @@ impl PageHits<'_> {
|
||||
tool,
|
||||
pcx,
|
||||
form,
|
||||
doc,
|
||||
can_fill,
|
||||
can_modify,
|
||||
editing_content,
|
||||
@@ -611,7 +615,7 @@ impl PageHits<'_> {
|
||||
let fields = if preparing {
|
||||
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
|
||||
} else if tool == QuickTool::Select {
|
||||
crate::forms_ui::hit(xf, info, page, form, tabs, can_fill, p)
|
||||
crate::forms_ui::hit(xf, info, page, doc, tabs, can_fill, p)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1068,7 +1072,9 @@ fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInf
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
for field in form.iter() {
|
||||
// A push button holds no value, so it is left as drawn (the frames and controls of a LaTeX
|
||||
// `animate` animation are push buttons).
|
||||
for field in form.iter().filter(|f| f.kind != pdfcraft_engine::FormFieldKind::PushButton) {
|
||||
let required = field.has(pdfcraft_engine::field_flags::REQUIRED);
|
||||
for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) {
|
||||
let rect = widget.rect;
|
||||
|
||||
@@ -58,6 +58,7 @@ fn field(name: &str, page: usize, rect: [f64; 4]) -> FormField {
|
||||
tooltip: None,
|
||||
widgets: vec![FormWidget {
|
||||
mouse_up_script: None,
|
||||
mouse_down: false,
|
||||
obj: pdfcraft_cos::ObjRef::new(4, 0),
|
||||
page: Some(page),
|
||||
rect,
|
||||
|
||||
@@ -421,9 +421,17 @@ fn toggle_edit(f: &FormField, wi: usize) -> Option<Edit> {
|
||||
Some(Edit::SetFieldValue { name: f.name.clone(), value })
|
||||
}
|
||||
|
||||
/// The first shown widget of `f` on `page` under the pointer at `p`, with its index.
|
||||
fn widget_at<'a>(xf: &PageXform, info: &DocInfo, page: usize, f: &'a FormField, p: egui::Pos2) -> Option<(usize, &'a FormWidget)> {
|
||||
f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p))
|
||||
/// The first shown widget of `f` on `page` under the pointer at `p`, with its index. `shown`:
|
||||
/// whether the document's scripts show `f` while it is read, over its widgets' own flags.
|
||||
fn widget_at<'a>(
|
||||
xf: &PageXform,
|
||||
info: &DocInfo,
|
||||
page: usize,
|
||||
f: &'a FormField,
|
||||
shown: Option<bool>,
|
||||
p: egui::Pos2,
|
||||
) -> Option<(usize, &'a FormWidget)> {
|
||||
f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && shown.unwrap_or(!w.hidden) && widget_rect(xf, info, page, w.rect).contains(p))
|
||||
}
|
||||
|
||||
/// The field whose sign tab is under `p`, of `tabs` in the order they are painted: where tabs
|
||||
@@ -432,11 +440,13 @@ pub(crate) fn tab_under<'a>(tabs: &[(&'a str, SignTab)], p: egui::Pos2) -> Optio
|
||||
tabs.iter().rev().find(|(_, tab)| tab.rect.contains(p)).map(|(name, _)| *name)
|
||||
}
|
||||
|
||||
/// Whether `w`, a widget of `f`, takes a press: fillable, a push button with an action, or an
|
||||
/// Whether `w`, a widget of `f`, takes a press: fillable, a push button with an action (a click
|
||||
/// or mouse-down one), or an
|
||||
/// empty signature field (signed by clicking it, Use a certificate). A radio button of a group can
|
||||
/// be read-only on its own.
|
||||
fn usable(f: &FormField, w: &FormWidget, allowed: bool) -> bool {
|
||||
let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some();
|
||||
// A button acting only on mouse down (`/AA /D`) takes the press too, which runs it (#4751).
|
||||
let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && (f.button.is_some() || w.mouse_down);
|
||||
allowed && !w.read_only && (fillable(f) || pressable || f.kind == FormFieldKind::Signature)
|
||||
}
|
||||
|
||||
@@ -444,16 +454,26 @@ fn usable(f: &FormField, w: &FormWidget, allowed: bool) -> bool {
|
||||
/// press. It is the first widget under the pointer in the form's order, except on a sign tab
|
||||
/// (`tabs`, with the signature field each one signs), which is drawn over every field and so is
|
||||
/// on its own field. Over the page's text too: a check box drawn with a glyph, or set on printed
|
||||
/// text, is a button, not text to select.
|
||||
pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField], tabs: &[(&str, SignTab)], allowed: bool, p: Pos2) -> Option<Hit> {
|
||||
/// text, is a button, not text to select. The fields are `doc`'s, shown as its page actions and
|
||||
/// timers show them ([`pdfcraft_engine::Document::shown_by_scripts`]).
|
||||
pub(crate) fn hit(
|
||||
xf: &PageXform,
|
||||
info: &DocInfo,
|
||||
page: usize,
|
||||
doc: &pdfcraft_engine::Document,
|
||||
tabs: &[(&str, SignTab)],
|
||||
allowed: bool,
|
||||
p: Pos2,
|
||||
) -> Option<Hit> {
|
||||
if !xf.rect.contains(p) {
|
||||
return None;
|
||||
}
|
||||
let under = |only: Option<&str>| {
|
||||
form.iter()
|
||||
doc.form
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| only.is_none_or(|name| f.name == name && f.kind == FormFieldKind::Signature))
|
||||
.find_map(|(fi, f)| widget_at(xf, info, page, f, p).map(|(wi, w)| (fi, f, wi, w)))
|
||||
.find_map(|(fi, f)| widget_at(xf, info, page, f, doc.shown_by_scripts(&f.name), p).map(|(wi, w)| (fi, f, wi, w)))
|
||||
};
|
||||
// A tab lies inside its field's widget, so that widget is under the pointer too.
|
||||
let (field, f, widget, w) = tab_under(tabs, p).and_then(|name| under(Some(name))).or_else(|| under(None))?;
|
||||
@@ -551,6 +571,8 @@ pub(crate) fn page_input(
|
||||
_ if f.read_only() || w.read_only => view.forms.notice = Some(FormNotice::ReadOnly(f.name.clone())),
|
||||
FormFieldKind::PushButton => match &f.button {
|
||||
Some(a) => view.forms.button = Some((f.name.clone(), pdfcraft_engine::js::field_object_id(f.obj), a.clone())),
|
||||
// Its press already ran its mouse-down action.
|
||||
None if w.mouse_down => {}
|
||||
None => view.forms.notice = Some(FormNotice::NoAction(f.name.clone())),
|
||||
},
|
||||
FormFieldKind::Text if let Some(script) = &w.mouse_up_script => {
|
||||
|
||||
@@ -200,6 +200,62 @@ impl PdfCraftApp {
|
||||
true
|
||||
}
|
||||
|
||||
/// Each frame: the live scripts of the document being read (#169). Its page's open actions
|
||||
/// run when the page changes (after the close actions of the page it was at), and its timers
|
||||
/// when they fall due, such as an animation's frames (LaTeX `animate`). Only the active tab
|
||||
/// runs: an animation in another tab waits until its tab shows again.
|
||||
pub(crate) fn script_tick(&mut self, ctx: &egui::Context) {
|
||||
// Nothing acts on the document under a dialog or prompt (`modal_open`): page actions and
|
||||
// timers wait, and an interval that fell behind runs once when it closes.
|
||||
if self.modal_open() {
|
||||
return;
|
||||
}
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
let now_ms = ctx.input(|input| input.time) * 1000.0;
|
||||
let page = self.views[i].current;
|
||||
// The page's actions, then its timers, if what the actions asked for left the viewer as it
|
||||
// was: a page they went to runs its close and open actions (next frame) before any timer,
|
||||
// and a dialog they opened holds the timers too.
|
||||
for timers in [false, true] {
|
||||
if timers && (self.modal_open() || self.active_ids() != Some((i, id)) || self.views[i].current != page) {
|
||||
ctx.request_repaint();
|
||||
break;
|
||||
}
|
||||
// The field being edited as it was, so a script changing it shows in its editor while
|
||||
// a draft the user already typed into is kept (as `run_field_triggers` does).
|
||||
let before: Option<(String, Vec<String>)> = self.views[i].forms.focus.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()))
|
||||
});
|
||||
let run = if timers { self.session.run_timers(id, now_ms) } else { self.session.show_page(id, page, now_ms) };
|
||||
if run.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if run.edited {
|
||||
if let Some(info) = self.session.get(id).map(|d| d.info.clone()) {
|
||||
self.views[i].document_changed(&info);
|
||||
}
|
||||
if let Some(before) = &before
|
||||
&& let Some(draft) = self.views[i].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));
|
||||
}
|
||||
} else if run.layers {
|
||||
self.views[i].invalidate_content();
|
||||
} else {
|
||||
for p in &run.pages {
|
||||
self.views[i].page_changed(*p);
|
||||
}
|
||||
}
|
||||
self.handle_js(id, run.output);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if let Some(at) = self.session.next_timer(id) {
|
||||
let wait = ((at - now_ms) / 1000.0).clamp(0.0, 3600.0);
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run document `id`'s catalog JavaScript for `event` (Will Save before a save, Will Print
|
||||
/// before printing) and show what it said. What it changed is part of what is saved or
|
||||
/// printed. A failing script is reported and the save or print still goes ahead.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! A form field widget's pointer scripts in the app (#269, ported from #344): the pointer
|
||||
//! moving onto and off a widget runs its Mouse Enter and Mouse Exit JavaScript, and a click on a
|
||||
//! check box runs its Mouse Up JavaScript after the click stored the new value (#247).
|
||||
//! check box runs its Mouse Up JavaScript after the click stored the new value (#247). A push
|
||||
//! button with only a mouse-down action runs it when pressed (#4751).
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
@@ -9,15 +10,16 @@ use pdfcraft_ui_egui::PdfCraftApp;
|
||||
/// `strike` (a check box) shows or hides `note` from its Mouse Up script by its own new value.
|
||||
fn form() -> Vec<u8> {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>",
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 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 5 0 R 6 0 R 8 0 R] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 8 0 R 10 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 350 280 370] /P 3 0 R /F 4 >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 250 280 290] /P 3 0 R /F 4 /AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += 'in;';) >> /X << /S /JavaScript /JS (getField\\('log'\\).value += 'out;';) >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (strike) /V /Off /AS /Off /Rect [20 300 50 330] /P 3 0 R /F 4 /MK << /CA (4) >> /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> /A << /S /JavaScript /JS (getField\\('note'\\).display = event.target.value == 'Off' ? display.hidden : display.visible; getField\\('log'\\).value += 'up:' + event.target.value + ';';) >> >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /V (struck) /Rect [60 300 280 330] /P 3 0 R /F 6 >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 0 >>\nstream\n\nendstream",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (step) /Rect [20 232 120 248] /P 3 0 R /F 4 /MK << /CA (Step) >> /AA << /D << /S /JavaScript /JS (getField\\('log'\\).value += event.name + ';';) >> >> >>",
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
@@ -102,3 +104,19 @@ fn a_check_box_mouse_up_script_sees_the_value_its_click_stored() {
|
||||
crate::support::wait_until(&mut h, "unchecked and its Mouse Up ran", |app| field(app, "log").value.join(",") == "up:Yes;up:Off;");
|
||||
assert!(field(h.state(), "note").widgets[0].hidden, "hidden again");
|
||||
}
|
||||
|
||||
/// A push button with only a mouse-down action (`/AA /D`, as the LaTeX `animate` package's
|
||||
/// controls have) takes the press and runs it as Mouse Down, once per click (#4751).
|
||||
#[test]
|
||||
fn a_push_button_with_only_a_mouse_down_action_runs_it_on_press() {
|
||||
let mut h = harness();
|
||||
let step = centre(&h, "step");
|
||||
h.hover_at(step);
|
||||
h.run_steps(1);
|
||||
h.drag_at(step);
|
||||
h.run_steps(1);
|
||||
h.drop_at(step);
|
||||
crate::support::wait_until(&mut h, "Mouse Down ran", |app| field(app, "log").value.join(",") == "Mouse Down;");
|
||||
h.run_steps(4);
|
||||
assert_eq!(log(&h), "Mouse Down;", "the click runs nothing more");
|
||||
}
|
||||
@@ -1880,6 +1880,205 @@ fn highlight_fields_leaves_a_groups_read_only_button_alone() {
|
||||
assert_eq!(tinted, 1, "only the button that can be chosen is tinted");
|
||||
}
|
||||
|
||||
/// A push button and a text field, with fields highlighted.
|
||||
const BUTTON_AND_TEXT: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (frame) /Rect [50 200 250 340] /P 3 0 R >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 130] /P 3 0 R >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// A push button holds no value, so Highlight fields leaves it as drawn (an `animate`
|
||||
/// animation's frames are push buttons); the text field beside it is tinted.
|
||||
#[test]
|
||||
fn highlight_fields_leaves_push_buttons_alone() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("button.pdf", None, BUTTON_AND_TEXT.to_vec()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "none").unwrap();
|
||||
app.set_option("fields", "on").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
let tint = egui::Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48);
|
||||
let tinted = h.output().shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == tint)).count();
|
||||
assert_eq!(tinted, 1, "only the text field is tinted");
|
||||
}
|
||||
|
||||
/// A page whose open action starts an interval writing a counter into the console.
|
||||
const PAGE_TIMER: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /AA << /O << /S /JavaScript /JS (var n = 0; app.setInterval\\('n++', 10\\);) >> >> >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// The viewer runs the open action of the page being read, and its timers as they fall due.
|
||||
#[test]
|
||||
fn the_viewer_runs_the_pages_open_action_and_its_timers() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("timer.pdf", None, PAGE_TIMER.to_vec()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(2);
|
||||
let id = h.state().views[0].id;
|
||||
assert!(h.state_mut().session.next_timer(id).is_some(), "the page's open action set the interval");
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
h.run_steps(4);
|
||||
let n = h.state_mut().session.run_javascript(id, "n", None).expect("runs").result;
|
||||
assert!(n.as_deref().and_then(|n| n.parse::<u32>().ok()).is_some_and(|n| n >= 1), "the interval ran: {n:?}");
|
||||
}
|
||||
|
||||
/// Under a dialog nothing acts on the document: the page's open action waits until it closes.
|
||||
#[test]
|
||||
fn page_actions_and_timers_wait_under_a_dialog() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("timer.pdf", None, PAGE_TIMER.to_vec()).expect("opens");
|
||||
app.dialog = Some(pdfcraft_ui_egui::Dialog::Shortcuts);
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
let id = h.state().views[0].id;
|
||||
assert!(h.state_mut().session.next_timer(id).is_none(), "the open action waits under the dialog");
|
||||
h.state_mut().dialog = None;
|
||||
h.run_steps(2);
|
||||
assert!(h.state_mut().session.next_timer(id).is_some(), "and runs once it closes");
|
||||
}
|
||||
|
||||
/// Three pages: the first's open action starts an interval counting `n`; the second's open
|
||||
/// action goes on to the third, and its close action stops the interval.
|
||||
const PAGE_HOP: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /AA << /O << /S /JavaScript /JS (var n = 0; var t = app.setInterval\\('n++', 10\\);) >> >> >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /AA << /O << /S /JavaScript /JS (this.pageNum = 2;) >> /C << /S /JavaScript /JS (app.clearInterval\\(t\\);) >> >> >> endobj
|
||||
5 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// A page whose open action goes to another page runs no timer before its close action: the
|
||||
/// interval that close action stops doesn't run in between.
|
||||
#[test]
|
||||
fn timers_wait_for_the_page_a_page_action_goes_to() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("hop.pdf", None, PAGE_HOP.to_vec()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(2);
|
||||
let id = h.state().views[0].id;
|
||||
assert!(h.state_mut().session.next_timer(id).is_some(), "the first page's open action set the interval");
|
||||
let count = |h: &mut Harness<'static, PdfCraftApp>| h.state_mut().session.run_javascript(id, "n", None).expect("runs").result;
|
||||
let before = count(&mut h);
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
h.state_mut().views[0].go_to_page(1);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().views[0].current, 2, "the second page's open action went on");
|
||||
assert_eq!(h.state_mut().session.next_timer(id), None, "and its close action stopped the interval");
|
||||
assert_eq!(count(&mut h), before, "which didn't run in between");
|
||||
}
|
||||
|
||||
/// A text field, with a default appearance for its editor.
|
||||
const FIELD_TIMER: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (clock) /Rect [50 100 250 130] /P 3 0 R /F 4 >> endobj
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// Press field `name`'s first widget, wherever the document has it.
|
||||
fn press_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
|
||||
let p = {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
let f = doc.form.iter().find(|f| f.name == name).unwrap();
|
||||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 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);
|
||||
}
|
||||
|
||||
/// A time-out writing the field being edited shows in its editor: leaving the field then keeps the
|
||||
/// timer's value instead of writing the old one back.
|
||||
#[test]
|
||||
fn a_timer_writing_the_focused_field_shows_in_its_editor() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("clock.pdf", None, FIELD_TIMER.to_vec()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("zoom", "150").unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..30 {
|
||||
h.run_steps(2);
|
||||
}
|
||||
press_field(&mut h, "clock");
|
||||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| (f.name.as_str(), f.text.as_str())), Some(("clock", "")));
|
||||
// Set only now, with the field being edited: the harness's clock runs ahead of the test's.
|
||||
let id = h.state().views[0].id;
|
||||
let set = h.state_mut().session.run_javascript(id, "app.setTimeOut('getField(\"clock\").value = \"tick\";', 50);", None).expect("runs");
|
||||
assert_eq!(set.error, None);
|
||||
let value = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "clock").unwrap().value.clone()
|
||||
};
|
||||
for _ in 0..600 {
|
||||
if value(&h) == ["tick"] {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert_eq!(value(&h), ["tick"], "the time-out wrote the field");
|
||||
let focus = h.state().views[0].forms.focus.as_ref().map(|f| (f.name.clone(), f.text.clone()));
|
||||
assert_eq!(focus, Some(("clock".into(), "tick".into())), "the editor shows the time-out's value");
|
||||
}
|
||||
|
||||
/// Two push buttons: "shown" hidden in the document and "gone" not; the page's open action shows
|
||||
/// the first and hides the second. Each one's mouse-up action says it was pressed.
|
||||
const SHOWN_BY_SCRIPT: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] /AA << /O << /S /JavaScript /JS (var pressed = ''; getField\\('shown'\\).display = display.visible; getField\\('gone'\\).display = display.hidden;) >> >> >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (shown) /F 6 /Rect [50 200 250 240] /P 3 0 R /AA << /U << /S /JavaScript /JS (pressed += 'shown';) >> >> >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (gone) /F 4 /Rect [50 100 250 140] /P 3 0 R /AA << /U << /S /JavaScript /JS (pressed += 'gone';) >> >> >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// A button the page's open action showed takes presses, and one it hid takes none, whatever
|
||||
/// their flags in the document say.
|
||||
#[test]
|
||||
fn buttons_take_presses_as_the_scripts_show_them() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("frames.pdf", None, SHOWN_BY_SCRIPT.to_vec()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(2);
|
||||
press_field(&mut h, "shown");
|
||||
press_field(&mut h, "gone");
|
||||
let id = h.state().views[0].id;
|
||||
let pressed = h.state_mut().session.run_javascript(id, "pressed", None).expect("runs").result;
|
||||
assert_eq!(pressed.as_deref(), Some("shown"));
|
||||
}
|
||||
|
||||
/// A radio group's read-only button can't be chosen by a click, while the group's other button
|
||||
/// can.
|
||||
#[test]
|
||||
|
||||
@@ -83,8 +83,38 @@ host path cancels unanswered popups and response dialogs; no web-interaction par
|
||||
This work does not add timer execution, PDF annotations/layers to the JS object model, or the
|
||||
remaining document action triggers.
|
||||
|
||||
## Page actions, timers and layers
|
||||
|
||||
When the page being read changes (the active tab's current page), the close actions of the
|
||||
page it was at run, then the new page's open actions: the page's own `/AA /O` and `/C`, and
|
||||
each annotation's `/PO`, `/PV` (opening) and `/PC`, `/PI` (closing), in `/Annots` order. They
|
||||
run in the document realm, so the functions and variables they define are there for buttons,
|
||||
the console and timers. This is how LaTeX `animate` animations start and stop.
|
||||
|
||||
`app.setInterval`, `app.setTimeOut`, `app.clearInterval` and `app.clearTimeOut` keep up to 64
|
||||
timers per realm, each at least 10 ms apart. The viewer fires those that are due each frame (at
|
||||
most 16 per call; an interval that fell behind runs once and then counts from now). A timer
|
||||
runs unasked, so its alerts and requests go to the console. A fresh realm (after an edit
|
||||
resets it, Revert, or a full save) has no timers.
|
||||
|
||||
Fields that page actions and timers show, hide or give another icon (`display`,
|
||||
`buttonSetIcon`) change only how the document is shown, through view-only render overrides
|
||||
(hayro-interpret patches 105 and 106). They aren't saved, printed or exported, and they don't
|
||||
mark the document as changed. Buttons and the console see the fields as shown, and a `display`
|
||||
or icon a button writes into the document takes over from the view's. A value or a reset from
|
||||
a page action or timer is one undoable edit.
|
||||
|
||||
`this.getOCGs()` lists the document's layers (`name`, `state`, `initState`); setting
|
||||
`ocg.state` turns a layer as the Layers panel does.
|
||||
|
||||
## Automation
|
||||
|
||||
`js_page` reads the document at a page and runs its page actions as the viewer does, and
|
||||
`js_advance` moves the script clock on by `ms` (up to one hour) and runs the timers that fall
|
||||
due on the way (at most 6,000 runs per call). Both return the scripts' output, the pages to
|
||||
draw again, and when the next timer is due; `page_render` draws the fields as the scripts show
|
||||
them.
|
||||
|
||||
The existing `js_run` accepts `answers`, an optional array of up to 32 strings or null values:
|
||||
|
||||
```json
|
||||
|
||||
@@ -3295,11 +3295,11 @@ title = "Page open/close actions"
|
||||
area = "D"
|
||||
tier = "P2"
|
||||
milestone = "M6"
|
||||
status = "planned"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
tools = ["js_page"]
|
||||
evidence = ["crates/forms/src/tests.rs::page_scripts_are_the_pages_own_then_each_annotations_in_order", "crates/engine/src/live_scripts_tests.rs::page_actions_start_timers_that_turn_an_animations_frames_without_changing_the_document", "crates/automation/tests/it/page_actions.rs::js_page_and_js_advance_run_an_animation_that_page_render_draws"]
|
||||
notes = "JavaScript page actions run in the document's script realm when the page being read changes (the active tab's current page): the page's own open and close actions (/AA /O, /C) and its annotations' page-open, page-visible, page-close and page-invisible actions (/PO, /PV, /PC, /PI), which start and stop LaTeX animate animations. Fields they show, hide or give another icon change only how the document is shown (not saved, printed or exported); values they change are one undoable edit. Not yet: non-JavaScript page actions, actions chained with /Next, and the page-visible/invisible distinction for pages scrolled into view without becoming current."
|
||||
|
||||
[[feature]]
|
||||
id = "organize.page-templates"
|
||||
@@ -5362,9 +5362,9 @@ tier = "P0"
|
||||
milestone = "M6"
|
||||
status = "partial"
|
||||
commands = ["tools.js_console"]
|
||||
tools = ["js_run", "js_event"]
|
||||
evidence = ["crates/js/src/tests.rs::to_source_writes_plain_state_as_a_literal", "crates/js/src/tests.rs::to_source_refuses_cycles_functions_and_oversized_state", "crates/js/src/tests.rs::set_items_and_set_action_change_the_field", "crates/engine/src/document_actions_tests.rs::set_items_and_set_action_are_written_to_the_document", "crates/engine/src/document_actions_tests.rs::pointer_and_focus_triggers_run_the_widget_scripts", "crates/engine/src/document_actions_tests.rs::only_a_click_trigger_may_print", "crates/engine/src/document_actions_tests.rs::a_check_box_mouse_up_reads_the_value_its_click_stored", "crates/ui-egui/tests/it/field_triggers.rs::the_pointer_entering_and_leaving_a_widget_runs_its_scripts", "crates/ui-egui/tests/it/field_triggers.rs::a_check_box_mouse_up_script_sees_the_value_its_click_stored", "crates/automation/tests/it/timesheet_compat.rs::timesheet_keypad_geometry_icons_calculation_and_persistence", "crates/automation/tests/it/timesheet_compat.rs::popup_and_manual_response_resume_the_same_script_and_validate_choices", "crates/js/src/session.rs::document_realm_keeps_keypad_state_and_isolates_documents", "crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"]
|
||||
notes = "Document-scoped native Boa realms shared by button, console and field events; synchronous calculateNow in CO order; normal captions/icons; rect and appearance/tooltip writeback; effective page boxes; native explicit-button popup/response/alert continuation and viewer requests, including opening or saving attachments from form buttons. Headless js_run has deterministic ordered answers. A widget's Mouse Enter, Mouse Exit, Mouse Down, Focus and Blur JavaScript run in the app and through js_event (and form_focus for focus), and a check box's or radio button's Mouse Up runs after its click stored the new value (#247); field.setItems, field.setAction and a bounded Object.prototype.toSource (plain data only) are written back to the document (#269, ported from #344). Partial: web prompt UI, timers, annotation/layer APIs, non-normal button faces, widget-indexed Field access, and Will Close / Did Save / Did Print are not implemented. See docs/acroform-script-workflows.md for lifecycle, limits and validation scope."
|
||||
tools = ["js_run", "js_event", "js_advance"]
|
||||
evidence = ["crates/js/src/tests.rs::to_source_writes_plain_state_as_a_literal", "crates/js/src/tests.rs::to_source_refuses_cycles_functions_and_oversized_state", "crates/js/src/tests.rs::set_items_and_set_action_change_the_field", "crates/engine/src/document_actions_tests.rs::set_items_and_set_action_are_written_to_the_document", "crates/engine/src/document_actions_tests.rs::pointer_and_focus_triggers_run_the_widget_scripts", "crates/engine/src/document_actions_tests.rs::only_a_click_trigger_may_print", "crates/engine/src/document_actions_tests.rs::a_check_box_mouse_up_reads_the_value_its_click_stored", "crates/ui-egui/tests/it/field_triggers.rs::the_pointer_entering_and_leaving_a_widget_runs_its_scripts", "crates/ui-egui/tests/it/field_triggers.rs::a_check_box_mouse_up_script_sees_the_value_its_click_stored", "crates/automation/tests/it/timesheet_compat.rs::timesheet_keypad_geometry_icons_calculation_and_persistence", "crates/automation/tests/it/timesheet_compat.rs::popup_and_manual_response_resume_the_same_script_and_validate_choices", "crates/js/src/session.rs::document_realm_keeps_keypad_state_and_isolates_documents", "crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes", "crates/js/src/session.rs::timers_fire_in_the_document_realm_until_cleared", "crates/engine/src/live_scripts_tests.rs::a_timer_giving_a_button_another_icon_draws_that_icon_in_its_place", "crates/engine/src/live_scripts_tests.rs::get_ocgs_turns_a_layer_in_the_view"]
|
||||
notes = "Document-scoped native Boa realms shared by button, console and field events; synchronous calculateNow in CO order; normal captions/icons; rect and appearance/tooltip writeback; effective page boxes; native explicit-button popup/response/alert continuation and viewer requests, including opening or saving attachments from form buttons. Headless js_run has deterministic ordered answers. A widget's Mouse Enter, Mouse Exit, Mouse Down, Focus and Blur JavaScript run in the app and through js_event (and form_focus for focus), and a check box's or radio button's Mouse Up runs after its click stored the new value (#247); field.setItems, field.setAction and a bounded Object.prototype.toSource (plain data only) are written back to the document (#269, ported from #344). Partial: web prompt UI, non-normal button faces, widget-indexed Field access, and Will Close / Did Save / Did Print are not implemented; annotation scripting covers only view-only display and appearance changes. See docs/acroform-script-workflows.md for lifecycle, limits and validation scope. app.setInterval/setTimeOut/clearInterval/clearTimeOut (at most 64 timers, 10 ms minimum) fire in the document realm on the viewer clock (js_advance headless); timer alerts and requests go to the console. this.getOCGs() with ocg.name/state/initState turns layers as the Layers panel does."
|
||||
|
||||
[[feature]]
|
||||
id = "form.js-console"
|
||||
|
||||
Vendored
+1
-1
@@ -55,7 +55,7 @@ Other resource categories retain their existing parent-chain lookup.
|
||||
|
||||
| Crate | Version | Licence | Patches | Test |
|
||||
|---|---|---|---|---|
|
||||
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
|
||||
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
|
||||
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option<ColorReplacement>` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroLine truncated
|
||||
| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most as many symbol pixels per symbol dictionary as the largest page bitmap holds, 2^28 (a fuzzed dictionary decoded for minutes; each symbol now pays for its pixels out of the stream's decoding work and is held in the retained bitmaps, and at the earlier 2^26 the soft mask of a scanned drawing, one dictionary of 7,559 symbols, was refused: #6722), and a refusal by one of the decoder's limits is told apart from damaged data (`DecodeError::is_limit`, `OverflowError::BitmapLimit`); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and Line truncated
|
||||
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365); (7) object parsing builds at most 4,194,304 objects (every array element and dictionary value at any depth, `parser::MAX_OBJECT_NODES`, the same cap as `pdfcraft_cos`) per top-level object, an object stream reads at most 1,000,000 index entries (cos's `/N` clamp), and one load builds at most 8,388,608 objects from all of its object streams together, shared across rayon workers (a decoded stream of `0 0 0 ...` inside the 256 MiB decompression limit built ~120M objects, 10+ GB, and aborted). A lookup parses only the member it wants, so it isn't skipped behind a large member, and a load parses only the members it would add: none its cross-reference table places in another stream (stale copies) and none already loaded as an ordinary object or from an earlier stream. Members the table doesn't list at all are still parsed (a rebuilt table lists no compressed objects) and share the budget, so they can spend it before listed members of the same stream; a load expands its object streams one at a time in object-number order, a stream's members in parallel only when the budget holds all of them and otherwise in index order, so a spent budget skips the same members on every load (#5083) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog`; (7) `inspect::tests::{object_stream_members_over_the_node_cap_are_skipped, object_stream_index_entries_past_the_clamp_are_ignored, object_streams_of_one_load_share_their_node_budget, object_streams_loaded_in_parallel_share_their_node_budget, a_malformed_object_stream_member_refunds_its_reservation, a_tight_budget_builds_object_stream_members_in_index_order, a_lookup_in_an_object_stream_parses_only_the_member_it_wants}` (pdfcraft-render; `NodeBudget` and `ObjectStream::new_within` are public so these can use a small budget) |
|
||||
|
||||
+52
-8
@@ -117,6 +117,16 @@ pub struct InterpreterSettings {
|
||||
/// `ocg_overrides`. On by default: that is the state for viewing. Off for output made for
|
||||
/// printing, which the View event is not.
|
||||
pub view_auto_state: bool,
|
||||
/// PdfCraft patch (105): viewer overrides for annotations (object number, generation, shown),
|
||||
/// applied in place of their Hidden and NoView flags: what a document's scripts show and
|
||||
/// hide while it is read (frames of an animation), without changing the document.
|
||||
pub annotation_overrides: Arc<Vec<(i32, i32, bool)>>,
|
||||
/// PdfCraft patch (106): annotations the viewer draws with a form XObject as their normal
|
||||
/// appearance (object number and generation of the annotation, then of the form), in their
|
||||
/// own `/Rect`: a push button a document's script gave another icon (`buttonSetIcon`) while
|
||||
/// it is read, without changing the document. An override naming an object that isn't a form
|
||||
/// draws nothing (the script cleared the icon).
|
||||
pub appearance_overrides: Arc<Vec<(i32, i32, i32, i32)>>,
|
||||
/// PdfCraft patch: once this is `true`, interpretation stops before the next content
|
||||
/// operator. What was drawn is then incomplete, so only set it when the output will be
|
||||
/// thrown away (a viewer's render whose result nobody can receive any more).
|
||||
@@ -151,6 +161,8 @@ impl Default for InterpreterSettings {
|
||||
hide_comments: false,
|
||||
ocg_overrides: Arc::new(Vec::new()),
|
||||
view_auto_state: true,
|
||||
annotation_overrides: Arc::new(Vec::new()),
|
||||
appearance_overrides: Arc::new(Vec::new()),
|
||||
cancelled: None,
|
||||
preview: false,
|
||||
jbig2_work: hayro_syntax::MAX_PAGE_JBIG2_WORK,
|
||||
@@ -222,8 +234,17 @@ pub fn interpret_page<'a>(
|
||||
// annotations, each group in `/Annots` order, as other readers draw them: a filled
|
||||
// comment listed after a check box covered the box. ISO 32000-2 doesn't order the two.
|
||||
// Two passes over the array, so nothing is held for every entry of a long one.
|
||||
// PdfCraft patch (105): each annotation with its object, so the viewer can show or hide
|
||||
// it by its object (`annotation_overrides`). An entry that isn't a dictionary is
|
||||
// skipped; it no longer ends the list.
|
||||
let xref = context.xref;
|
||||
for widgets in [false, true] {
|
||||
for annot in annot_arr.iter::<Dict<'_>>() {
|
||||
let annots = annot_arr.raw_iter().filter_map(|item| match item {
|
||||
hayro_syntax::object::MaybeRef::Ref(r) => xref.get::<Dict<'_>>(r.into()).map(|d| (d, Some(r))),
|
||||
hayro_syntax::object::MaybeRef::NotRef(Object::Dict(d)) => Some((d, None)),
|
||||
hayro_syntax::object::MaybeRef::NotRef(_) => None,
|
||||
});
|
||||
for (annot, obj) in annots {
|
||||
if (annot.get::<hayro_syntax::object::Name<'_>>(SUBTYPE).as_deref() == Some(b"Widget")) != widgets {
|
||||
continue;
|
||||
}
|
||||
@@ -231,7 +252,16 @@ pub fn interpret_page<'a>(
|
||||
|
||||
// Annotation should be hidden (Hidden = bit 2, NoView = bit 6).
|
||||
// PdfCraft patch: NoView was not honoured upstream.
|
||||
if flags & (2 | 32) != 0 {
|
||||
// PdfCraft patch (105): the viewer's override, when it has one, decides instead.
|
||||
let overridden = obj.and_then(|r| {
|
||||
context
|
||||
.settings
|
||||
.annotation_overrides
|
||||
.iter()
|
||||
.find(|(num, generation, _)| *num == r.obj_number && *generation == r.gen_number)
|
||||
.map(|(_, _, shown)| !*shown)
|
||||
});
|
||||
if overridden.unwrap_or(flags & (2 | 32) != 0) {
|
||||
continue;
|
||||
}
|
||||
// PdfCraft patch (56): Invisible (bit 1) hides an annotation of a type that isn't one
|
||||
@@ -265,13 +295,27 @@ pub fn interpret_page<'a>(
|
||||
|
||||
// PdfCraft patch: `/N` may be a stream or a dictionary of appearance states selected
|
||||
// by `/AS` (checkboxes, radio buttons, …). Upstream only handled the stream form.
|
||||
let normal = annot.get::<Dict<'_>>(AP).and_then(|ap| {
|
||||
ap.get::<Stream<'_>>(N).or_else(|| {
|
||||
let states = ap.get::<Dict<'_>>(N)?;
|
||||
let state = annot.get::<hayro_syntax::object::Name<'_>>(AS)?;
|
||||
states.get::<Stream<'_>>(state.as_ref())
|
||||
})
|
||||
// PdfCraft patch (106): the viewer may draw it with another form instead
|
||||
// (`appearance_overrides`).
|
||||
// An override naming an object that isn't a form draws nothing: a script cleared the
|
||||
// button's icon.
|
||||
let face = obj.and_then(|r| {
|
||||
context
|
||||
.settings
|
||||
.appearance_overrides
|
||||
.iter()
|
||||
.find(|(num, generation, _, _)| *num == r.obj_number && *generation == r.gen_number)
|
||||
});
|
||||
let normal = match face {
|
||||
Some(&(_, _, num, generation)) => xref.get::<Stream<'_>>(hayro_syntax::object::ObjectIdentifier::new(num, generation)),
|
||||
None => annot.get::<Dict<'_>>(AP).and_then(|ap| {
|
||||
ap.get::<Stream<'_>>(N).or_else(|| {
|
||||
let states = ap.get::<Dict<'_>>(N)?;
|
||||
let state = annot.get::<hayro_syntax::object::Name<'_>>(AS)?;
|
||||
states.get::<Stream<'_>>(state.as_ref())
|
||||
})
|
||||
}),
|
||||
};
|
||||
if let Some(apx) = normal.and_then(|o| FormXObject::new(&o)) {
|
||||
let Some(rect) = annot.get::<Rect>(RECT) else {
|
||||
continue;
|
||||
|
||||
Reference in new issue
Block a user