Files
pdfcraft/crates/ui-egui/src/workflows/js_ui.rs
T

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);
}
}
}