M13: Action Wizard: built-in actions (Prepare for Distribution, Optimize Scanned Documents, page numbers, confidential, sanitize), custom actions with 12 step kinds, background runs over files; action_list, action_run tools

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 05:48:19 -04:00
parent a4bf717c06
commit 632a21bfcf
17 changed files with 797 additions and 34 deletions

View File

@ -259,6 +259,57 @@ impl Automation {
Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns }))
}
pub(crate) fn action_list(&self) -> Result<Value> {
use printcraft_engine::actions::{Step, builtin};
let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() });
let actions: Vec<Value> = builtin()
.iter()
.map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::<Vec<_>>() }))
.collect();
let steps: Vec<Value> = Step::all().iter().map(|s| json!({ "step": s.id(), "label": s.label(), "takes_arg": s.arg().is_some() })).collect();
Ok(json!({ "actions": actions, "steps": steps }))
}
pub(crate) fn action_run(&mut self, a: &Args) -> Result<Value> {
use printcraft_engine::actions::{Action, Step, builtin, run_on};
let action = if let Some(name) = a.opt_str("action")? {
builtin()
.into_iter()
.find(|x| x.name.eq_ignore_ascii_case(name))
.ok_or_else(|| ToolError::InvalidArgs(format!("no built-in action {name:?}")))?
} else {
let items = a.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("give action or steps".into()))?;
let steps = items
.iter()
.map(|it| {
let id = it["step"].as_str().unwrap_or("");
Step::from_id(id, it["arg"].as_str().unwrap_or("")).ok_or_else(|| ToolError::InvalidArgs(format!("unknown step {id:?}")))
})
.collect::<Result<Vec<_>>>()?;
Action { name: "Custom".into(), description: String::new(), steps, builtin: false }
};
let folder = self.resolve(a.str("folder")?, true)?;
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
let mut out = Vec::new();
for p in a.strs("paths")? {
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into());
let result = self
.resolve(p, false)
.map_err(|e| e.to_string())
.and_then(|src| std::fs::read(&src).map_err(|e| e.to_string()))
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {}));
out.push(match result {
Ok(r) => {
let target = folder.join(&name);
write_atomic(&target, &r.bytes)?;
json!({ "path": p, "output": target.to_string_lossy(), "log": r.log })
}
Err(e) => json!({ "path": p, "error": e }),
});
}
Ok(json!({ "action": action.name, "files": out }))
}
fn compare_ids(&self, a: &Args) -> Result<(printcraft_engine::DocId, printcraft_engine::DocId)> {
let new = self.doc(a)?.id;
let other = a.int("other")?;

View File

@ -512,6 +512,8 @@ impl Automation {
"form_actions" => self.form_actions(&a)?,
"form_detect_fields" => self.form_detect_fields(&a)?,
"doc_compare" => self.doc_compare(&a)?,
"action_list" => self.action_list()?,
"action_run" => self.action_run(&a)?,
"doc_compare_report" => self.doc_compare_report(&a)?,
"doc_compare_mark" => self.doc_compare_mark(&a)?,
"form_set_actions" => self.form_set_actions(&a)?,

View File

@ -759,6 +759,21 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).")
.ro()
.cmd("actions.wizard")
.with(schema(json!({}), &[])),
t("action_run", "Run an action", "Action Wizard: run a built-in action (action: its name) or a list of steps ([{step, arg}], ids from action_list) on each PDF in paths, writing the results into folder under the same names. Returns each file's step log or error.")
.cmd("actions.wizard")
.with(schema(
json!({
"action": { "type": "string" },
"steps": { "type": "array", "items": { "type": "object", "properties": { "step": { "type": "string" }, "arg": { "type": "string" } }, "required": ["step"] } },
"paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"folder": { "type": "string" },
}),
&["paths", "folder"],
)),
t("doc_compare", "Compare files", "Compare the text of two open documents: other is the older version, doc the newer. Returns counts and each change (replaced, inserted, deleted) with the old and new text, pages (1-based) and rectangles (points, origin bottom-left).")
.ro()
.cmd("doc.compare")

View File

@ -1453,3 +1453,20 @@ fn comparing_documents_through_tools() {
assert_eq!(ok(&mut a, "doc_compare_mark", json!({ "doc": v2, "other": v1 }))["comments"], 3);
assert!(a.call("doc_compare", &json!({ "doc": v2, "other": 999 })).is_err());
}
#[test]
fn actions_through_tools() {
let dir = workdir("actions");
let mut a = auto(&dir);
let list = ok(&mut a, "action_list", json!({}));
assert!(list["actions"].as_array().unwrap().iter().any(|x| x["name"] == "Prepare for Distribution"));
assert!(list["steps"].as_array().unwrap().iter().any(|x| x["step"] == "add_watermark" && x["takes_arg"] == true));
let r = ok(&mut a, "action_run", json!({ "action": "add page numbers", "paths": ["a.pdf", "b.pdf", "missing.pdf"], "folder": "out" }));
assert_eq!(r["files"][0]["log"][0], "Add footer");
assert!(r["files"][2]["error"].is_string());
let doc = ok(&mut a, "doc_open", json!({ "path": "out/b.pdf" }))["doc"].as_u64().unwrap();
assert!(page_text(&mut a, doc)[1].contains("Page 2 of 2"), "{:?}", page_text(&mut a, doc));
let r = ok(&mut a, "action_run", json!({ "steps": [{ "step": "set_title", "arg": "Hello" }], "paths": ["a.pdf"], "folder": "out2" }));
assert_eq!(r["files"][0]["log"][0], "Set document title");
assert!(a.call("action_run", &json!({ "steps": [{ "step": "fly" }], "paths": ["a.pdf"], "folder": "x" })).is_err());
}

View File

@ -0,0 +1,220 @@
//! Action Wizard: named sequences of steps run over files (Acrobat's guided actions), the
//! built-in actions, and running an action over many files (batch processing).
use std::sync::Arc;
use crate::{DocId, Edit, EditError, Session};
/// One step of an action.
#[derive(Clone, Debug, PartialEq)]
pub enum Step {
/// Scan & OCR ▸ Recognize text (pages without text).
RecognizeText,
/// Reduce file size.
ReduceFileSize,
/// Remove hidden information (every category).
RemoveHiddenInformation,
/// Sanitize document.
Sanitize,
FlattenComments,
FlattenFields,
/// Add a text watermark (diagonal, 50% opaque).
AddWatermark(String),
/// Add a footer with this centre text (tokens such as `<<Page 1 of n>>` work).
AddFooter(String),
/// Add a header with this centre text.
AddHeader(String),
/// Set the document title.
SetTitle(String),
/// Prepare a form: detect fields.
DetectFormFields,
/// Run a JavaScript (console context).
RunJavaScript(String),
}
impl Step {
/// Every step, with an empty argument where it takes one (for the step picker).
pub fn all() -> Vec<Step> {
vec![
Step::RecognizeText,
Step::ReduceFileSize,
Step::RemoveHiddenInformation,
Step::Sanitize,
Step::FlattenComments,
Step::FlattenFields,
Step::AddWatermark(String::new()),
Step::AddHeader(String::new()),
Step::AddFooter(String::new()),
Step::SetTitle(String::new()),
Step::DetectFormFields,
Step::RunJavaScript(String::new()),
]
}
/// A stable id (agents, saved actions).
pub fn id(&self) -> &'static str {
match self {
Step::RecognizeText => "recognize_text",
Step::ReduceFileSize => "reduce_file_size",
Step::RemoveHiddenInformation => "remove_hidden_information",
Step::Sanitize => "sanitize",
Step::FlattenComments => "flatten_comments",
Step::FlattenFields => "flatten_fields",
Step::AddWatermark(_) => "add_watermark",
Step::AddHeader(_) => "add_header",
Step::AddFooter(_) => "add_footer",
Step::SetTitle(_) => "set_title",
Step::DetectFormFields => "detect_form_fields",
Step::RunJavaScript(_) => "run_javascript",
}
}
pub fn label(&self) -> &'static str {
match self {
Step::RecognizeText => "Recognize text",
Step::ReduceFileSize => "Reduce file size",
Step::RemoveHiddenInformation => "Remove hidden information",
Step::Sanitize => "Sanitize document",
Step::FlattenComments => "Flatten comments",
Step::FlattenFields => "Flatten form fields",
Step::AddWatermark(_) => "Add watermark",
Step::AddHeader(_) => "Add header",
Step::AddFooter(_) => "Add footer",
Step::SetTitle(_) => "Set document title",
Step::DetectFormFields => "Detect form fields",
Step::RunJavaScript(_) => "Run a JavaScript",
}
}
/// The step's text argument, if it takes one.
pub fn arg(&self) -> Option<&str> {
match self {
Step::AddWatermark(s) | Step::AddHeader(s) | Step::AddFooter(s) | Step::SetTitle(s) | Step::RunJavaScript(s) => Some(s),
_ => None,
}
}
pub fn arg_mut(&mut self) -> Option<&mut String> {
match self {
Step::AddWatermark(s) | Step::AddHeader(s) | Step::AddFooter(s) | Step::SetTitle(s) | Step::RunJavaScript(s) => Some(s),
_ => None,
}
}
/// Build a step from its id and argument.
pub fn from_id(id: &str, arg: &str) -> Option<Step> {
let mut s = Step::all().into_iter().find(|s| s.id() == id)?;
if let Some(a) = s.arg_mut() {
*a = arg.to_string();
}
Some(s)
}
}
/// A named action.
#[derive(Clone, Debug, PartialEq)]
pub struct Action {
pub name: String,
pub description: String,
pub steps: Vec<Step>,
/// One of PrintCraft's own actions (can't be edited or deleted).
pub builtin: bool,
}
/// The built-in actions.
pub fn builtin() -> Vec<Action> {
let a = |name: &str, description: &str, steps: Vec<Step>| Action { name: name.into(), description: description.into(), steps, builtin: true };
vec![
a(
"Prepare for Distribution",
"Remove hidden information and flatten comments and form fields, so recipients see only the pages.",
vec![Step::RemoveHiddenInformation, Step::FlattenComments, Step::FlattenFields],
),
a("Optimize Scanned Documents", "Make scans searchable, then make them smaller.", vec![Step::RecognizeText, Step::ReduceFileSize]),
a("Add Page Numbers", "Number every page in the footer.", vec![Step::AddFooter("Page <<1>> of <<n>>".into())]),
a("Mark as Confidential", "A diagonal CONFIDENTIAL watermark on every page.", vec![Step::AddWatermark("CONFIDENTIAL".into())]),
a("Sanitize Documents", "Remove hidden information, metadata, scripts and embedded content, and rewrite the files.", vec![Step::Sanitize]),
]
}
/// What running an action on one file did.
#[derive(Clone, Debug, PartialEq)]
pub struct FileResult {
pub name: String,
pub bytes: Arc<Vec<u8>>,
/// One line per step.
pub log: Vec<String>,
}
fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec<String>) -> Result<Option<DocId>, EditError> {
let pages = s.get(id).map_or(0, |d| d.info.pages.len());
let all: Vec<usize> = (0..pages).collect();
let edit = match step {
Step::RecognizeText => {
let found = s.recognize_text(id, &[], crate::ocr::OcrSettings::default()).map_err(EditError::Invalid)?;
log.push(format!("Recognize text: {} words", found.iter().map(|p| p.words.len()).sum::<usize>()));
return Ok(None);
}
Step::ReduceFileSize => {
let (bytes, _) = s.optimized_bytes(id, &crate::optimize::Settings::default(), &[])?;
let before = s.get(id).map_or(0, |d| d.bytes.len());
log.push(format!("Reduce file size: {} → {} bytes", before, bytes.len()));
let name = s.get(id).map(|d| d.name.clone()).unwrap_or_default();
s.close(id);
let new = s.open(name, None, bytes, None).map_err(|e| EditError::Reopen(e.to_string()))?;
return Ok(Some(new));
}
Step::RemoveHiddenInformation => Edit::RemoveHidden { which: crate::HIDDEN.to_vec() },
Step::Sanitize => Edit::Sanitize,
Step::FlattenComments => Edit::Flatten { comments: true, fields: false },
Step::FlattenFields => Edit::Flatten { comments: false, fields: true },
Step::AddWatermark(t) => {
Edit::AddWatermark { pages: all, settings: crate::Watermark { text: t.clone(), ..Default::default() }, replace: false, file: None }
}
Step::AddHeader(t) | Step::AddFooter(t) => {
let mut settings = crate::HeaderFooter::default();
settings.text[if matches!(step, Step::AddHeader(_)) { 1 } else { 4 }] = t.clone();
Edit::AddHeaderFooter { pages: all, settings, replace: false }
}
Step::SetTitle(t) => Edit::SetInfo { key: "Title".into(), value: t.clone() },
Step::DetectFormFields => {
let n = s.auto_detect_fields(id, &[])?.len();
log.push(format!("Detect form fields: {n}"));
return Ok(None);
}
Step::RunJavaScript(js) => {
let o = s.run_javascript(id, js, None)?;
log.push(match o.error {
Some(e) => format!("Run a JavaScript: {e}"),
None => "Run a JavaScript".into(),
});
return Ok(None);
}
};
// Steps that find nothing to do (no comments to flatten, …) aren't failures.
match s.apply(id, edit) {
Ok(()) => log.push(step.label().to_string()),
Err(e) => log.push(format!("{}: {e}", step.label())),
}
Ok(None)
}
/// Run `action` on one file. `progress(step, steps)` is called before each step.
pub fn run_on(action: &Action, name: &str, bytes: Arc<Vec<u8>>, mut progress: impl FnMut(usize, usize)) -> Result<FileResult, String> {
let mut s = Session::new();
let mut id = s.open(name, None, bytes, None).map_err(|e| e.to_string())?;
if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) {
return Err(why);
}
let mut log = Vec::new();
for (i, step) in action.steps.iter().enumerate() {
progress(i, action.steps.len());
if let Some(new) = apply_step(&mut s, id, step, &mut log).map_err(|e| format!("{}: {e}", step.label()))? {
id = new;
}
}
let d = s.get(id).ok_or("the document was lost")?;
// A signed file keeps its signatures: incremental. Otherwise a clean full rewrite.
let bytes = if d.is_signed() { s.save_bytes(id) } else { s.save_full_bytes(id) }.map_err(|e| e.to_string())?;
Ok(FileResult { name: name.to_string(), bytes, log })
}

View File

@ -422,13 +422,14 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
icon: "list-checks",
hue: PURPLE,
badge: None,
availability: Planned("M13"),
availability: Ready,
sections: &[ToolSection {
title: "Actions list",
items: &[
item("Action Wizard", "list-checks", "actions.wizard", Ready),
item("Make accessible", "accessibility", "actions.make_accessible", Planned("M13")),
item("Prepare for distribution", "send", "actions.distribution", Planned("M13")),
item("Optimize scanned documents", "scan-text", "actions.optimize_scans", Planned("M13")),
item("Prepare for distribution", "send", "actions.distribution", Ready),
item("Optimize scanned documents", "scan-text", "actions.optimize_scans", Ready),
item("Archive documents", "file-check", "actions.archive", Planned("M13")),
],
}],

View File

@ -201,6 +201,9 @@ pub const COMMANDS: &[CommandSpec] = &[
c("comment.summarize", "Summarize comments", None, None, HasComments, "file-text"),
c("form.import_data", "Import form data…", None, None, HasFields, "file-input"),
c("form.export_data", "Export form data…", None, None, HasFields, "file-output"),
c("actions.wizard", "Action Wizard…", None, None, Nothing, "list-checks"),
c("actions.distribution", "Prepare for distribution…", None, None, Nothing, "send"),
c("actions.optimize_scans", "Optimize scanned documents…", None, None, Nothing, "scan-text"),
c("doc.compare", "Compare files…", None, None, Document, "git-compare"),
c("form.detect", "Detect form fields", None, None, Modification, "scan"),
c("form.merge_data", "Merge data files into spreadsheet…", None, None, Nothing, "file-spreadsheet"),

View File

@ -10,6 +10,7 @@
//! appended revision — and the view is refreshed from it, so what you see is exactly what Save
//! will write. Saving rebases onto the written bytes, so the next save appends only new edits.
pub mod actions;
pub mod catalog;
pub mod commands;
pub mod compare;

View File

@ -1350,3 +1350,37 @@ fn comparing_two_versions_of_a_document() {
assert_eq!(marks.len(), 2);
assert!(marks[0].contents.as_deref().unwrap().starts_with("Replaced: \"Monday.\""));
}
#[test]
fn actions_run_their_steps_on_files() {
let s = Session::new();
let src = s.create_from_text("t", "Quarterly numbers").unwrap();
let built = actions::builtin();
let numbers = built.iter().find(|a| a.name == "Add Page Numbers").unwrap();
let r = actions::run_on(numbers, "q.pdf", src.clone(), |_, _| {}).unwrap();
let mut s2 = Session::new();
let id = s2.open("out.pdf", None, r.bytes, None).unwrap();
assert!(page_texts(&s2, id)[0].contains("Page 1 of 1"), "{:?}", page_texts(&s2, id));
assert_eq!(r.log, ["Add footer"]);
let custom = actions::Action {
name: "Mine".into(),
description: String::new(),
steps: vec![
actions::Step::from_id("set_title", "Q3").unwrap(),
actions::Step::AddWatermark("DRAFT".into()),
actions::Step::ReduceFileSize,
actions::Step::FlattenComments,
],
builtin: false,
};
let mut steps = Vec::new();
let r = actions::run_on(&custom, "q.pdf", src, |i, n| steps.push((i, n))).unwrap();
assert_eq!(steps, [(0, 4), (1, 4), (2, 4), (3, 4)]);
assert_eq!(r.log.len(), 4, "{:?}", r.log);
let id = s2.open("out2.pdf", None, r.bytes, None).unwrap();
let d = s2.get(id).unwrap();
assert_eq!(d.info.title.as_deref(), Some("Q3"));
assert!(page_texts(&s2, id)[0].contains("DRAFT"));
assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err());
}

View File

@ -0,0 +1,330 @@
//! Action Wizard: the actions (PrintCraft's and your own), running one over files in the
//! background, and creating or editing an action's steps. Your actions are remembered.
use std::sync::{Arc, Mutex};
use egui::{Align, Layout};
use printcraft_engine::actions::{Action, Step, builtin};
use crate::theme::{self, Tokens};
use crate::{PrintCraftApp, widgets};
/// The Action Wizard's state.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Wizard {
/// The selected action, by name.
pub selected: Option<String>,
/// The action being created or edited (and the name it had).
pub editing: Option<(Option<String>, Action)>,
/// The step the "Add" menu adds.
pub add: usize,
}
/// Progress of a run: files done, total, and the summary once finished.
#[derive(Default)]
pub struct RunProgress {
pub done: usize,
pub total: usize,
pub message: Option<String>,
}
impl PrintCraftApp {
/// Built-in actions, then the user's.
pub fn all_actions(&self) -> Vec<Action> {
let mut v = builtin();
v.extend(self.custom_actions.iter().cloned());
v
}
/// Run `action` on files, writing the results into a folder the user picks (the export
/// folder override in tests), in the background.
pub fn run_action_on(&mut self, action: Action, files: Vec<(String, Vec<u8>)>) {
if self.action_run.is_some() {
self.notify("An action is already running");
return;
}
#[cfg(not(target_arch = "wasm32"))]
let dir = match &self.export_dir_override {
Some(d) => Some(std::path::PathBuf::from(d)),
None => rfd::FileDialog::new().set_title("Choose a folder for the results").pick_folder(),
};
#[cfg(not(target_arch = "wasm32"))]
let Some(dir) = dir else { return };
let progress = Arc::new(Mutex::new(RunProgress { total: files.len(), ..Default::default() }));
let p = progress.clone();
let work = move || {
let (mut ok, mut failed) = (0, Vec::new());
for (i, (name, bytes)) in files.into_iter().enumerate() {
if let Ok(mut s) = p.lock() {
s.done = i;
}
let r = printcraft_engine::actions::run_on(&action, &name, Arc::new(bytes), |_, _| {}).and_then(|r| {
#[cfg(not(target_arch = "wasm32"))]
crate::editing::write_atomically(&dir.join(&name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())?;
#[cfg(target_arch = "wasm32")]
crate::editing::download(&name, &r.bytes)?;
Ok(())
});
match r {
Ok(()) => ok += 1,
Err(e) => failed.push(format!("{name}: {e}")),
}
}
let mut msg = format!("{}: {ok} file{} done", action.name, if ok == 1 { "" } else { "s" });
if !failed.is_empty() {
msg.push_str(&format!("; failed: {}", failed.join("; ")));
}
if let Ok(mut s) = p.lock() {
s.done = s.total;
s.message = Some(msg);
}
};
#[cfg(not(target_arch = "wasm32"))]
if self.run_inline {
work();
} else {
std::thread::Builder::new().name("printcraft-action".into()).spawn(work).ok();
}
#[cfg(target_arch = "wasm32")]
work();
self.action_run = Some(progress);
self.poll_action();
}
/// Show the running action's progress, and its summary when done.
pub(crate) fn poll_action(&mut self) {
let Some(p) = self.action_run.clone() else { return };
let state = p.lock().ok().map(|mut s| s.message.take().ok_or((s.done, s.total)));
match state {
Some(Ok(m)) => {
self.action_run = None;
self.notify(m);
}
Some(Err((done, total))) => {
let m = format!("Running action… file {} of {}", (done + 1).min(total.max(1)), total.max(1));
if self.toast.as_ref().is_none_or(|t| t.0 != m) {
self.notify(m);
}
if let Some(ctx) = &self.ctx {
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
}
None => self.action_run = None,
}
}
/// Start: ask for the files, then run the selected action on them.
fn start_selected_action(&mut self) {
let Some(action) = self.wizard.selected.as_ref().and_then(|n| self.all_actions().into_iter().find(|a| &a.name == n)) else { return };
#[cfg(not(target_arch = "wasm32"))]
{
let paths = match &self.action_files_override {
Some(p) => p.iter().map(std::path::PathBuf::from).collect(),
None => rfd::FileDialog::new()
.set_title(format!("Files for {}", action.name))
.add_filter("PDF", &["pdf"])
.pick_files()
.unwrap_or_default(),
};
let mut files = Vec::new();
for p in paths {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
match std::fs::read(&p) {
Ok(b) => files.push((name, b)),
Err(e) => return self.notify(format!("Couldn't read {name}: {e}")),
}
}
if !files.is_empty() {
self.run_action_on(action, files);
}
}
#[cfg(target_arch = "wasm32")]
{
// The open document stands in for a file picker.
if let Some(doc) = self.active_ids().and_then(|(_, id)| self.session.get(id)) {
let files = vec![(doc.name.clone(), doc.bytes.to_vec())];
self.run_action_on(action, files);
}
}
}
}
/// Saved actions as JSON (`[{name, description, steps: [[id, arg], …]}]`).
pub fn encode(actions: &[Action]) -> serde_json::Value {
serde_json::Value::Array(
actions
.iter()
.map(|a| {
let steps: Vec<serde_json::Value> = a.steps.iter().map(|s| serde_json::json!([s.id(), s.arg().unwrap_or("")])).collect();
serde_json::json!({ "name": a.name, "description": a.description, "steps": steps })
})
.collect(),
)
}
pub fn decode(v: &serde_json::Value) -> Vec<Action> {
v.as_array()
.map(|list| {
list.iter()
.filter_map(|a| {
let name = a["name"].as_str()?.to_string();
let steps = a["steps"].as_array()?.iter().filter_map(|s| Step::from_id(s[0].as_str()?, s[1].as_str().unwrap_or(""))).collect();
Some(Action { name, description: a["description"].as_str().unwrap_or("").to_string(), steps, builtin: false })
})
.collect()
})
.unwrap_or_default()
}
/// The Action Wizard dialog. Returns `true` to close.
pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool {
if app.wizard.editing.is_some() {
return edit_body(ui, app, t);
}
ui.label(egui::RichText::new("Action Wizard").font(theme::semibold(18.0)));
ui.add_space(8.0);
let actions = app.all_actions();
if app.wizard.selected.as_ref().is_none_or(|s| !actions.iter().any(|a| &a.name == s)) {
app.wizard.selected = actions.first().map(|a| a.name.clone());
}
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(220.0);
egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.set_width(208.0);
ui.set_min_height(260.0);
for a in &actions {
let on = app.wizard.selected.as_deref() == Some(a.name.as_str());
if ui.selectable_label(on, &a.name).clicked() {
app.wizard.selected = Some(a.name.clone());
}
}
});
});
ui.vertical(|ui| {
let Some(a) = actions.iter().find(|a| app.wizard.selected.as_deref() == Some(a.name.as_str())) else { return };
ui.label(egui::RichText::new(&a.name).font(theme::semibold(15.0)));
if !a.description.is_empty() {
ui.label(egui::RichText::new(&a.description).color(t.text_muted));
}
ui.add_space(6.0);
ui.label(egui::RichText::new("Steps").font(theme::semibold(13.0)));
for (i, s) in a.steps.iter().enumerate() {
let arg = s.arg().filter(|x| !x.is_empty()).map(|x| format!(": {x}")).unwrap_or_default();
ui.label(format!("{}. {}{arg}", i + 1, s.label()));
}
});
});
ui.add_space(10.0);
let selected_custom = app.wizard.selected.as_ref().and_then(|n| app.custom_actions.iter().position(|a| &a.name == n));
let mut close = false;
ui.horizontal(|ui| {
if ui.button("New Action…").clicked() {
app.wizard.editing = Some((None, Action { name: String::new(), description: String::new(), steps: Vec::new(), builtin: false }));
}
if let Some(i) = selected_custom {
if ui.button("Edit…").clicked() {
let a = app.custom_actions[i].clone();
app.wizard.editing = Some((Some(a.name.clone()), a));
}
if ui.button("Delete").clicked() {
app.custom_actions.remove(i);
app.wizard.selected = None;
}
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, "Start", true).clicked() {
close = true;
app.start_selected_action();
}
if widgets::pill_button(ui, "Close", false).clicked() {
close = true;
}
});
});
close
}
fn edit_body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool {
let Some((original, a)) = app.wizard.editing.as_mut() else { return false };
ui.label(egui::RichText::new(if original.is_some() { "Edit Action" } else { "New Action" }).font(theme::semibold(18.0)));
ui.add_space(8.0);
egui::Grid::new("action-edit").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
ui.label("Action name:");
ui.add(egui::TextEdit::singleline(&mut a.name).desired_width(320.0).id_salt("action-name"));
ui.end_row();
ui.label("Description:");
ui.add(egui::TextEdit::singleline(&mut a.description).desired_width(320.0).id_salt("action-description"));
ui.end_row();
});
ui.add_space(6.0);
ui.label(egui::RichText::new("Steps").font(theme::semibold(13.0)));
let mut remove = None;
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());
if a.steps.is_empty() {
ui.label(egui::RichText::new("Add the steps this action runs, in order.").color(t.text_muted));
}
for (i, s) in a.steps.iter_mut().enumerate() {
ui.push_id(i, |ui| {
ui.horizontal(|ui| {
ui.label(format!("{}. {}", i + 1, s.label()));
if let Some(arg) = s.arg_mut() {
ui.add(egui::TextEdit::singleline(arg).desired_width(220.0));
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button("Remove").clicked() {
remove = Some(i);
}
});
});
});
}
});
if let Some(i) = remove {
a.steps.remove(i);
}
let all = Step::all();
let add = &mut app.wizard.add;
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("action-add-step").selected_text(all[(*add).min(all.len() - 1)].label()).width(240.0).show_ui(ui, |ui| {
for (i, s) in all.iter().enumerate() {
ui.selectable_value(add, i, s.label());
}
});
if ui.button("Add Step").clicked() {
a.steps.push(all[(*add).min(all.len() - 1)].clone());
}
});
ui.add_space(10.0);
let name = a.name.trim().to_string();
let taken =
builtin().iter().any(|b| b.name == name) || app.custom_actions.iter().any(|c| c.name == name && original.as_deref() != Some(name.as_str()));
if taken {
ui.label(egui::RichText::new("An action with this name already exists.").small().color(t.text_muted));
}
let (mut done, mut cancel) = (false, false);
let ok = !name.is_empty() && !taken && !a.steps.is_empty();
ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "Save", true)).inner.clicked() {
done = true;
}
if widgets::pill_button(ui, "Cancel", false).clicked() {
cancel = true;
}
});
});
if cancel {
app.wizard.editing = None;
}
if done && let Some((original, mut a)) = app.wizard.editing.take() {
a.name = name.clone();
match original.and_then(|o| app.custom_actions.iter().position(|c| c.name == o)) {
Some(i) => app.custom_actions[i] = a,
None => app.custom_actions.push(a),
}
app.wizard.selected = Some(name);
}
false
}

View File

@ -374,6 +374,15 @@ impl PrintCraftApp {
}
"form.detect" => self.detect_fields(),
"doc.compare" => self.dialog = Some(Dialog::CompareFiles),
"actions.wizard" | "actions.distribution" | "actions.optimize_scans" => {
self.wizard.editing = None;
match id {
"actions.distribution" => self.wizard.selected = Some("Prepare for Distribution".into()),
"actions.optimize_scans" => self.wizard.selected = Some("Optimize Scanned Documents".into()),
_ => {}
}
self.dialog = Some(Dialog::ActionWizard);
}
"form.field.properties" => {
if let Some((name, w)) = active.and_then(|i| self.views[i].prepare.selected.clone()) {
self.open_field_props(&name, w);

View File

@ -820,6 +820,11 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
close = go || cancel;
return;
}
Dialog::ActionWizard => {
ui.set_width(620.0);
close = crate::actions_ui::body(ui, app, &t);
return;
}
Dialog::CompareFiles => {
let (go, cancel) = crate::compare_ui::body(ui, app, &t);
compare_now = go;

View File

@ -6,6 +6,7 @@
//! `printcraft-engine`.
mod a11y_ui;
mod actions_ui;
pub mod canvas;
mod chrome;
mod combine_ui;
@ -199,6 +200,8 @@ pub enum Dialog {
Preferences,
/// Compare files: choose the older version.
CompareFiles,
/// Action Wizard.
ActionWizard,
/// Combine files: the files, their order and pages.
Combine,
/// Custom stamps ▸ Create.
@ -313,7 +316,14 @@ pub struct PrintCraftApp {
pub ocr_draft: ocr_ui::OcrDraft,
pub ocr_run: Option<ocr_ui::OcrRun>,
pub ocr_batch: Option<std::sync::Arc<std::sync::Mutex<ocr_ui::BatchProgress>>>,
pub ocr_sync: bool,
/// Background jobs (OCR, actions) run inline instead (tests).
pub run_inline: bool,
/// Action Wizard: the user's actions, the dialog state, the running action and (tests) the
/// files to use instead of a picker.
pub custom_actions: Vec<printcraft_engine::actions::Action>,
pub wizard: actions_ui::Wizard,
pub action_run: Option<std::sync::Arc<std::sync::Mutex<actions_ui::RunProgress>>>,
pub action_files_override: Option<Vec<String>>,
/// Compare files: the chosen older document and the last result.
pub compare_old: Option<DocId>,
pub compare: Option<compare_ui::CompareState>,
@ -458,7 +468,11 @@ impl PrintCraftApp {
ocr_draft: ocr_ui::OcrDraft::default(),
ocr_run: None,
ocr_batch: None,
ocr_sync: false,
run_inline: false,
custom_actions: Vec::new(),
wizard: Default::default(),
action_run: None,
action_files_override: None,
compare_old: None,
compare: None,
js_console: Default::default(),
@ -801,6 +815,7 @@ impl PrintCraftApp {
"trusted": trusted,
"custom_stamps": stamps_ui::encode(&self.custom_stamps),
"javascript": self.session.javascript(),
"actions": actions_ui::encode(&self.custom_actions),
})
.to_string()
}
@ -832,6 +847,7 @@ impl PrintCraftApp {
self.digital_ids = ids;
}
self.custom_stamps = stamps_ui::decode(&v["custom_stamps"]);
self.custom_actions = actions_ui::decode(&v["actions"]);
if let Some(on) = v["javascript"].as_bool() {
self.session.set_javascript(on);
}
@ -910,6 +926,7 @@ impl PrintCraftApp {
"document-js" => Some(Dialog::DocumentJs),
"preferences" => Some(Dialog::Preferences),
"compare-files" => Some(Dialog::CompareFiles),
"action-wizard" => Some(Dialog::ActionWizard),
"signature" => Some(Dialog::Signature),
"optimize" => Some(Dialog::Optimize),
"sign" | "certify" => {
@ -1092,6 +1109,7 @@ impl eframe::App for PrintCraftApp {
self.process_pending_edits();
self.poll_export();
self.poll_ocr();
self.poll_action();
self.process_file_requests();
// Pull finished renders into textures for every open document.
for view in &mut self.views {

View File

@ -170,7 +170,7 @@ impl PrintCraftApp {
}
};
#[cfg(not(target_arch = "wasm32"))]
if self.ocr_sync {
if self.run_inline {
work();
} else {
std::thread::Builder::new().name("printcraft-ocr".into()).spawn(work).ok();
@ -235,7 +235,7 @@ impl PrintCraftApp {
}
};
#[cfg(not(target_arch = "wasm32"))]
if self.ocr_sync {
if self.run_inline {
work();
} else {
std::thread::Builder::new().name("printcraft-ocr-files".into()).spawn(work).ok();

View File

@ -0,0 +1,57 @@
//! Action Wizard in the real shell (egui_kittest): run a built-in action on files, create an
//! action, and remember it.
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use printcraft_engine::Session;
use printcraft_engine::actions::Step;
use printcraft_ui_egui::PrintCraftApp;
#[test]
fn run_and_create_actions() {
let dir = std::env::temp_dir().join(format!("printcraft-actions-ui-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("out")).unwrap();
let src = Session::new().create_from_text("t", "Draft report").unwrap();
std::fs::write(dir.join("r.pdf"), &*src).unwrap();
let d = dir.clone();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PrintCraftApp::new();
app.run_inline = true;
app.export_dir_override = Some(d.join("out").to_string_lossy().into_owned());
app.action_files_override = Some(vec![d.join("r.pdf").to_string_lossy().into_owned()]);
app
});
h.run_steps(2);
assert!(h.state_mut().execute("actions.distribution"));
h.run_steps(2);
h.get_by_label("Action Wizard");
h.get_by_label("1. Remove hidden information");
h.get_by_label("Start").click();
h.run_steps(3);
assert_eq!(h.state().toast.clone().unwrap().0, "Prepare for Distribution: 1 file done");
assert!(std::fs::read(dir.join("out/r.pdf")).unwrap().starts_with(b"%PDF"));
// A new action of our own.
assert!(h.state_mut().execute("actions.wizard"));
h.run_steps(2);
h.get_by_label("New Action…").click();
h.run_steps(2);
{
let (_, a) = h.state_mut().wizard.editing.as_mut().unwrap();
a.name = "Stamp drafts".into();
a.steps.push(Step::AddWatermark("DRAFT".into()));
}
h.run_steps(3);
h.get_by_label("Save").click();
h.run_steps(3);
assert_eq!(h.state().custom_actions.len(), 1);
h.get_by_label("1. Add watermark: DRAFT");
let saved = h.state().persist();
let mut fresh = PrintCraftApp::new();
fresh.restore(&saved);
assert_eq!(fresh.custom_actions, h.state().custom_actions);
h.get_by_label("Start").click();
h.run_steps(3);
assert_eq!(h.state().toast.clone().unwrap().0, "Stamp drafts: 1 file done");
}

View File

@ -20,7 +20,7 @@ fn recognize_text_dialog_adds_searchable_text() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("scan.pdf", None, scan()).unwrap();
app.ocr_sync = true;
app.run_inline = true;
app
});
h.run_steps(4);
@ -52,7 +52,7 @@ fn recognize_text_in_multiple_files_writes_searchable_copies() {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = PrintCraftApp::new();
app.ocr_sync = true;
app.run_inline = true;
app.export_dir_override = Some(dir.to_string_lossy().into_owned());
app.use_files(printcraft_ui_egui::FilePurpose::Ocr, vec![("one.pdf".into(), scan()), ("two.pdf".into(), scan())]);
assert!(app.ocr_batch.is_none(), "finished");

View File

@ -9059,11 +9059,11 @@ title = "Run guided actions on files"
area = "N"
tier = "P1"
milestone = "M13"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["actions.wizard"]
tools = ["action_list", "action_run"]
evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"]
notes = "Pick an action and files; results go to a chosen folder, in the background with progress."
[[feature]]
id = "misc.action-wizard-create"
@ -9071,11 +9071,11 @@ title = "Create and edit actions"
area = "N"
tier = "P1"
milestone = "M13"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["actions.wizard"]
tools = ["action_run"]
evidence = ["crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"]
notes = "New/Edit/Delete actions with 12 step kinds (OCR, reduce size, remove hidden information, sanitize, flatten, watermark, header, footer, title, detect fields, JavaScript); remembered between runs."
[[feature]]
id = "misc.action-wizard-manage"
@ -9095,11 +9095,11 @@ title = "Built-in action: prepare for distribution"
area = "N"
tier = "P1"
milestone = "M13"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["actions.distribution"]
tools = ["action_run"]
evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"]
notes = "Remove hidden information, flatten comments and fields."
[[feature]]
id = "misc.action-optimize-scans"
@ -9107,11 +9107,11 @@ title = "Built-in action: optimize scanned documents"
area = "N"
tier = "P1"
milestone = "M13"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["actions.optimize_scans"]
tools = ["action_run"]
evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools"]
notes = "Recognize text, then reduce file size."
[[feature]]
id = "misc.action-archive"
@ -9143,11 +9143,11 @@ title = "Batch processing over files and folders"
area = "N"
tier = "P1"
milestone = "M13"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["actions.wizard", "ocr.recognize_batch"]
tools = ["action_run", "ocr_recognize_files"]
evidence = ["crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"]
notes = "Actions and OCR run over chosen files; whole folders (with subfolders) not yet."
[[feature]]
id = "misc.action-prompt-steps"