mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 16:36:13 -06:00
341 lines
15 KiB
Rust
341 lines
15 KiB
Rust
//! Acrobat JavaScript in the shell: what scripts ask for (alerts, printing, navigation, links),
|
|
//! the JavaScript console (⌘J), Document JavaScripts, and Preferences ▸ JavaScript.
|
|
|
|
use egui::{Align, Layout};
|
|
use pdfcraft_engine::js::{JsOutput, Message, MessageIcon, Panel, Request};
|
|
use pdfcraft_engine::{DocId, Edit};
|
|
|
|
use crate::theme::{self, Tokens};
|
|
use crate::{PdfCraftApp, widgets};
|
|
|
|
/// The JavaScript console: the input and the output so far.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct JsConsole {
|
|
pub input: String,
|
|
pub log: Vec<String>,
|
|
}
|
|
|
|
/// Document JavaScripts: the script being edited.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct DocJsDraft {
|
|
pub name: String,
|
|
pub script: String,
|
|
}
|
|
|
|
/// Most form script messages that wait for the toast; past it, more are left out and counted, and
|
|
/// the toast says how many once the rest have been shown. One run shows at most this many (the
|
|
/// script engine's own limit), so only runs in quick succession reach it.
|
|
const MAX_WAITING_MESSAGES: usize = 100;
|
|
|
|
/// The icon a script message's toast shows for `icon`.
|
|
pub(crate) fn message_icon(icon: MessageIcon) -> &'static str {
|
|
match icon {
|
|
MessageIcon::Error => "circle-x",
|
|
MessageIcon::Warning => "triangle-alert",
|
|
MessageIcon::Question => "circle-help",
|
|
MessageIcon::Status => "info",
|
|
}
|
|
}
|
|
|
|
/// What the user is told when a form's scripts were turned off: in a toast as it happens, then
|
|
/// on the notice bar for as long as the document is open.
|
|
pub(crate) fn scripts_off_notice() -> String {
|
|
tl!("This form's scripts are turned off because one ran too long. Reopen the document to run them again.").to_string()
|
|
}
|
|
|
|
impl PdfCraftApp {
|
|
/// Act on what scripts produced in document `id`: alerts are shown, console output goes to
|
|
/// the console, print and page requests are carried out, links wait for the user's permission
|
|
/// (form submissions are reported, never sent).
|
|
pub fn handle_js(&mut self, id: DocId, out: JsOutput) {
|
|
let view = self.views.iter().position(|v| v.id == id);
|
|
// A script that ran away turned the form's scripts off: calculations and buttons stop
|
|
// working, and the error is only in the console. Say so once, and bring the notice bar
|
|
// (which keeps saying so) back if it was closed. A message of the form's own, below,
|
|
// takes the toast's place.
|
|
if let Some(v) = view.and_then(|i| self.views.get_mut(i))
|
|
&& !v.scripts_off_noted
|
|
&& self.session.get(id).is_some_and(|d| d.xfa_scripts_off())
|
|
{
|
|
v.scripts_off_noted = true;
|
|
v.notice_dismissed = false;
|
|
self.notify(scripts_off_notice());
|
|
}
|
|
if out.is_empty() {
|
|
return;
|
|
}
|
|
self.js_console.log.extend(out.console.iter().cloned());
|
|
for e in &out.errors {
|
|
self.js_console.log.push(format!("Error: {e}"));
|
|
}
|
|
for r in out.requests {
|
|
match r {
|
|
Request::Print => self.open_print(),
|
|
Request::GoToPage(p) => {
|
|
if let Some(i) = view {
|
|
self.views[i].go_to_page(p);
|
|
}
|
|
}
|
|
Request::LaunchUrl(u) => self.request_document_url(&u, crate::LinkOrigin::Script),
|
|
Request::Submit(u) => self.notify_fmt(
|
|
"The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries.",
|
|
&[("u", &u)],
|
|
),
|
|
Request::SaveAs => self.run_command("file.save_as"),
|
|
Request::ShowPanel(p) => {
|
|
let panel = match p {
|
|
Panel::Bookmarks => crate::RightPanel::Bookmarks,
|
|
Panel::Pages => crate::RightPanel::Pages,
|
|
Panel::Attachments => crate::RightPanel::Attachments,
|
|
Panel::Signatures => crate::RightPanel::Signatures,
|
|
Panel::Layers => crate::RightPanel::Layers,
|
|
};
|
|
// Show/Hide: a panel already showing closes.
|
|
self.right = (self.right != Some(panel)).then_some(panel);
|
|
}
|
|
Request::Focus(_) | Request::Beep | Request::Reset(_) => {}
|
|
}
|
|
}
|
|
self.show_script_messages(out.alerts);
|
|
}
|
|
|
|
/// Show form script messages `messages` in order, each in the toast with its title and icon.
|
|
/// The first takes the toast at once when it is free; otherwise it waits, like the rest, for
|
|
/// what the toast shows (another message, or a notice such as the run's own submit request)
|
|
/// to go ([`Self::next_script_message`]).
|
|
pub fn show_script_messages(&mut self, messages: Vec<Message>) {
|
|
let room = MAX_WAITING_MESSAGES.saturating_sub(self.script_messages.len());
|
|
let left_out = u64::try_from(messages.len().saturating_sub(room)).unwrap_or(u64::MAX);
|
|
self.script_messages_dropped = self.script_messages_dropped.saturating_add(left_out);
|
|
self.script_messages.extend(messages.into_iter().take(room));
|
|
if self.toast.is_none() {
|
|
self.next_script_message();
|
|
}
|
|
}
|
|
|
|
/// Put the next waiting script message in the toast, or, once none waits, a notice of how many
|
|
/// the queue had no room for. False when there is neither.
|
|
pub fn next_script_message(&mut self) -> bool {
|
|
let Some(m) = self.script_messages.pop_front() else {
|
|
// Every message the queue held has been shown: say how many it had no room for.
|
|
if self.script_messages_dropped == 0 {
|
|
return false;
|
|
}
|
|
let n = std::mem::take(&mut self.script_messages_dropped);
|
|
self.notify(crate::i18n::trn(
|
|
crate::i18n::current(),
|
|
n,
|
|
"{n} more message from the form's scripts was left out.",
|
|
"{n} more messages from the form's scripts were left out.",
|
|
));
|
|
// Kept with the toast until it has been shown its time (the toast's own expiry).
|
|
self.toast_dropped = n;
|
|
return true;
|
|
};
|
|
self.toast = Some((m.text.clone(), 0.0));
|
|
self.toast_script = Some(m);
|
|
true
|
|
}
|
|
|
|
/// Run a push button's JavaScript (its Mouse Up action).
|
|
pub fn run_button_script(&mut self, id: DocId, field: &str, script: &str) {
|
|
// A script reads the fields: include what's still being typed in one (#166).
|
|
if !self.commit_form_typing() {
|
|
return;
|
|
}
|
|
match self.session.run_javascript(id, script, Some(field)) {
|
|
Ok(o) => {
|
|
if let Some(i) = self.views.iter().position(|v| v.id == id)
|
|
&& let Some(info) = self.session.get(id).map(|d| d.info.clone())
|
|
{
|
|
self.views[i].document_changed(&info);
|
|
}
|
|
let out = JsOutput {
|
|
alerts: o.alerts,
|
|
console: o.console,
|
|
requests: o.requests,
|
|
errors: o.error.into_iter().collect(),
|
|
doc_errors: Vec::new(),
|
|
};
|
|
self.handle_js(id, out);
|
|
}
|
|
Err(e) => self.notify_fmt("{field}: {e}", &[("field", field), ("e", &e.to_string())]),
|
|
}
|
|
}
|
|
|
|
/// Prepare a form ▸ detect fields from the page's blanks, lines and boxes.
|
|
pub fn detect_fields(&mut self) {
|
|
let Some((i, id)) = self.active_ids() else { return };
|
|
match self.session.auto_detect_fields(id, &[]) {
|
|
Ok(names) if names.is_empty() => self.notify_tr("No form fields were detected"),
|
|
Ok(names) => {
|
|
if let Some(info) = self.session.get(id).map(|d| d.info.clone()) {
|
|
self.views[i].document_changed(&info);
|
|
}
|
|
if names.len() == 1 {
|
|
self.notify_tr("Detected 1 form field");
|
|
} else {
|
|
self.notify_fmt("Detected {n} form fields", &[("n", &names.len().to_string())]);
|
|
}
|
|
}
|
|
Err(e) => self.notify_error(e),
|
|
}
|
|
}
|
|
|
|
/// The console's Run: evaluate the input in the active document.
|
|
pub fn run_console(&mut self) {
|
|
let Some((i, id)) = self.active_ids() else { return };
|
|
let script = self.js_console.input.clone();
|
|
if script.trim().is_empty() {
|
|
return;
|
|
}
|
|
self.js_console.log.push(format!("> {}", script.trim()));
|
|
match self.session.run_javascript(id, &script, None) {
|
|
Ok(o) => {
|
|
if let Some(info) = self.session.get(id).map(|d| d.info.clone()) {
|
|
self.views[i].document_changed(&info);
|
|
}
|
|
let (error, result) = (o.error.clone(), o.result.clone());
|
|
let out = JsOutput { alerts: o.alerts, console: o.console, requests: o.requests, errors: Vec::new(), doc_errors: Vec::new() };
|
|
self.handle_js(id, out);
|
|
match error {
|
|
Some(e) => self.js_console.log.push(format!("Error: {e}")),
|
|
None => self.js_console.log.extend(result),
|
|
}
|
|
}
|
|
Err(e) => self.js_console.log.push(format!("Error: {e}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn buttons(ui: &mut egui::Ui, primary: &str, others: &[&str]) -> Option<String> {
|
|
let mut clicked = None;
|
|
ui.horizontal(|ui| {
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
// Stable ids: the console's output above changes how many widgets come first.
|
|
// Labels are translated for display; the returned id stays English.
|
|
if ui.push_id(primary, |ui| widgets::pill_button(ui, tl!(primary), true)).inner.clicked() {
|
|
clicked = Some(primary.to_string());
|
|
}
|
|
for o in others {
|
|
if ui.push_id(o, |ui| widgets::pill_button(ui, tl!(o), false)).inner.clicked() {
|
|
clicked = Some(o.to_string());
|
|
}
|
|
}
|
|
})
|
|
});
|
|
clicked
|
|
}
|
|
|
|
/// The JavaScript console. Returns `true` to close.
|
|
pub(crate) fn console_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
|
|
ui.label(egui::RichText::new(tl!("JavaScript Console")).font(theme::semibold(18.0)));
|
|
ui.add_space(6.0);
|
|
if !app.session.javascript() {
|
|
ui.label(egui::RichText::new(tl!("JavaScript is turned off (Preferences ▸ JavaScript).")).small().color(t.text_muted));
|
|
}
|
|
egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
|
|
ui.set_width(ui.available_width());
|
|
egui::ScrollArea::vertical().max_height(220.0).stick_to_bottom(true).show(ui, |ui| {
|
|
ui.set_width(ui.available_width());
|
|
ui.set_min_height(160.0);
|
|
if app.js_console.log.is_empty() {
|
|
ui.label(egui::RichText::new(tl!("Output appears here.")).color(t.text_muted));
|
|
}
|
|
for line in &app.js_console.log {
|
|
ui.label(egui::RichText::new(line).monospace());
|
|
}
|
|
});
|
|
});
|
|
ui.add_space(6.0);
|
|
let input = ui.add(
|
|
crate::widgets::block(&mut app.js_console.input)
|
|
.code_editor()
|
|
.desired_rows(4)
|
|
.desired_width(f32::INFINITY)
|
|
.hint_text(tl!("JavaScript, e.g. getField(\"total\").value"))
|
|
.id_salt("js-console-input"),
|
|
);
|
|
let run_key = input.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command);
|
|
ui.add_space(8.0);
|
|
match buttons(ui, "Run", &["Close", "Clear"]).as_deref() {
|
|
Some("Run") => app.run_console(),
|
|
Some("Clear") => app.js_console.log.clear(),
|
|
Some("Close") => return true,
|
|
_ if run_key => app.run_console(),
|
|
_ => {}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Document JavaScripts: list, edit, add and delete. Returns `true` to close.
|
|
pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
|
|
ui.label(egui::RichText::new(tl!("Document JavaScripts")).font(theme::semibold(18.0)));
|
|
ui.add_space(6.0);
|
|
let scripts = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.document_scripts()).unwrap_or_default();
|
|
let mut edit: Option<Edit> = None;
|
|
ui.horizontal(|ui| {
|
|
ui.label(tl!("Script Name:"));
|
|
ui.add(crate::widgets::line(&mut app.doc_js.name).desired_width(240.0).id_salt("doc-js-name"));
|
|
});
|
|
ui.add_space(4.0);
|
|
egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
|
|
ui.set_width(ui.available_width());
|
|
egui::ScrollArea::vertical().max_height(120.0).id_salt("doc-js-list").show(ui, |ui| {
|
|
ui.set_width(ui.available_width());
|
|
if scripts.is_empty() {
|
|
ui.label(egui::RichText::new(tl!("This document has no document-level scripts.")).color(t.text_muted));
|
|
}
|
|
for (name, js) in &scripts {
|
|
if ui.selectable_label(app.doc_js.name == *name, name).clicked() {
|
|
app.doc_js = DocJsDraft { name: name.clone(), script: js.clone() };
|
|
}
|
|
}
|
|
});
|
|
});
|
|
ui.add_space(6.0);
|
|
ui.add(crate::widgets::block(&mut app.doc_js.script).code_editor().desired_rows(8).desired_width(f32::INFINITY).id_salt("doc-js-script"));
|
|
ui.add_space(8.0);
|
|
let name = app.doc_js.name.trim().to_string();
|
|
let close = match buttons(ui, "Save", &["Close", "Delete"]).as_deref() {
|
|
Some("Save") if !name.is_empty() => {
|
|
edit = Some(Edit::SetDocumentScript { name, script: Some(app.doc_js.script.clone()) });
|
|
false
|
|
}
|
|
Some("Delete") if scripts.iter().any(|(n, _)| *n == name) => {
|
|
edit = Some(Edit::SetDocumentScript { name, script: None });
|
|
app.doc_js = DocJsDraft::default();
|
|
false
|
|
}
|
|
Some("Close") => true,
|
|
_ => false,
|
|
};
|
|
if let Some(e) = edit {
|
|
app.apply_edit(e);
|
|
}
|
|
close
|
|
}
|
|
|
|
#[expect(unused_imports, reason = "re-exported for tests and backward compatibility")]
|
|
pub(crate) use crate::dialogs::preferences::{
|
|
PREFERENCES_BUTTON_ROW, PREFERENCES_MIN_SCROLL, date_language_picker_name, preferences_body, preferences_buttons, preferences_header,
|
|
};
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn date_language_names_fall_back_when_the_native_name_cannot_be_drawn() {
|
|
let date_language = |code: &str| pdfcraft_engine::dates::DATE_LANGUAGES.iter().find(|l| l.code == code).expect("date language");
|
|
let hindi = date_language("hi");
|
|
assert_eq!(date_language_picker_name(hindi, |_| true), hindi.name, "the native name is kept when it can be drawn");
|
|
assert_eq!(date_language_picker_name(hindi, |_| false), "Hindi", "no Devanagari face: show the English name");
|
|
let chinese = date_language("zh-hans");
|
|
assert_eq!(date_language_picker_name(chinese, |_| false), "Simplified Chinese");
|
|
for language in &pdfcraft_engine::dates::DATE_LANGUAGES {
|
|
assert!(!date_language_picker_name(language, |_| false).is_empty(), "{}", language.code);
|
|
}
|
|
}
|
|
}
|