mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 19:48:14 -06:00
refactor(automation): split a11y.rs into a11y/
New modules: a11y/ocr, a11y/documents, a11y/form_tools (JS and form-action tools).
This commit is contained in:
@@ -8,6 +8,10 @@ use std::collections::BTreeSet;
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic};
|
||||
|
||||
mod documents;
|
||||
mod form_tools;
|
||||
mod ocr;
|
||||
|
||||
fn status_id(s: Status) -> &'static str {
|
||||
match s {
|
||||
Status::Passed => "passed",
|
||||
@@ -574,511 +578,6 @@ impl Automation {
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused
|
||||
/// unless the models read it) and `skip_text_pages`, each left at its default when absent.
|
||||
pub(crate) fn ocr_settings(&self, a: &Args) -> Result<pdfcraft_engine::ocr::OcrSettings> {
|
||||
let mut settings = pdfcraft_engine::ocr::OcrSettings::default();
|
||||
if let Some(d) = a.opt_num("dpi")? {
|
||||
settings.dpi = d.clamp(72.0, 600.0) as f32;
|
||||
}
|
||||
if let Some(l) = a.opt_str("language")? {
|
||||
if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) {
|
||||
return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}")));
|
||||
}
|
||||
settings.language = l.into();
|
||||
}
|
||||
if let Some(s) = a.opt_bool("skip_text_pages")? {
|
||||
settings.skip_text_pages = s;
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Recognizes text in each of `paths` and writes the result under the same file name in
|
||||
/// `folder`, reporting per file its output, word count and skipped pages, or its error. A
|
||||
/// path outside the root refuses the whole call before any work starts.
|
||||
pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result<Value> {
|
||||
let settings = self.ocr_settings(a)?;
|
||||
let folder = self.resolve(a.str("folder")?, true)?;
|
||||
// A path that leaves the root refuses the whole call before any work starts, like the
|
||||
// other tools (the write check passes missing files, which stay per-file errors below).
|
||||
let paths = a.strs("paths")?;
|
||||
for p in &paths {
|
||||
self.resolve_for(p, crate::file_access::Access::Read, true)?;
|
||||
}
|
||||
// Two inputs that would write the same output file overwrite each other while both report
|
||||
// success, so the whole batch is refused here: before the folder is created, anything is
|
||||
// read, or the OCR engines load.
|
||||
let targets: Vec<std::path::PathBuf> = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect();
|
||||
let shared = shared_file_names(&targets);
|
||||
if !shared.is_empty() {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.",
|
||||
shared.join(", ")
|
||||
)));
|
||||
}
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
|
||||
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let name = input_file_name(p);
|
||||
let result =
|
||||
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then(
|
||||
|bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true),
|
||||
);
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
let target = child(&folder, &name);
|
||||
write_atomic(&target, &r.bytes)?;
|
||||
let skipped: Vec<usize> = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect();
|
||||
json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped })
|
||||
}
|
||||
Err(e) => json!({ "path": p, "error": e }),
|
||||
});
|
||||
}
|
||||
Ok(json!({ "files": out }))
|
||||
}
|
||||
|
||||
/// Recognizes text on the open document's `pages` (every page when absent) and adds it,
|
||||
/// reporting the total word count and, per page, why it was skipped or what it found.
|
||||
pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result<Value> {
|
||||
let settings = self.ocr_settings(a)?;
|
||||
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
|
||||
let doc = self.doc(a)?;
|
||||
// Recognizing needs only permission to change the document; the words it found are
|
||||
// returned only when copying is allowed too.
|
||||
let (id, copy) = (doc.id, doc.allows_copying());
|
||||
let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?;
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|p| match &p.skipped {
|
||||
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
|
||||
None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
|
||||
None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }),
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::<usize>(), "pages": list }))
|
||||
}
|
||||
|
||||
/// Whether text recognition's models are installed, the directories searched for them (in
|
||||
/// order), and the languages it reads, each as a code and a name.
|
||||
pub(crate) fn ocr_status(&self) -> Result<Value> {
|
||||
use pdfcraft_engine::ocr;
|
||||
let dirs: Vec<String> = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect();
|
||||
let langs: Vec<Value> = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect();
|
||||
Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs }))
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name an input is written under: its own file name, or `document.pdf` when it has none.
|
||||
fn input_file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into())
|
||||
}
|
||||
|
||||
/// The output file names that two or more of `targets` share, each spelling listed once, in input
|
||||
/// order. Names are compared without regard to case on Windows and macOS and exactly elsewhere; on
|
||||
/// Windows a trailing dot or space is not part of the name (`scan.pdf.` is `scan.pdf`). Unicode
|
||||
/// normalization forms (NFC and NFD spellings of one name) are not compared, so such inputs are
|
||||
/// not caught. The folder is the same for every target, so only the names are compared.
|
||||
fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec<String> {
|
||||
let mut groups: std::collections::BTreeMap<String, (usize, Vec<String>)> = std::collections::BTreeMap::new();
|
||||
for target in targets {
|
||||
let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let key = if cfg!(windows) {
|
||||
name.trim_end_matches(['.', ' ']).to_lowercase()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
// Case only; NFC and NFD spellings of one name stay distinct here (a known limit).
|
||||
name.to_lowercase()
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new()));
|
||||
*count += 1;
|
||||
if !spellings.contains(&name) {
|
||||
spellings.push(name);
|
||||
}
|
||||
}
|
||||
groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect()
|
||||
}
|
||||
|
||||
/// A script's request to the viewer as the JSON that running a script reports; a side panel
|
||||
/// goes by its fixed id.
|
||||
fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
use pdfcraft_engine::js::Request as R;
|
||||
match r {
|
||||
R::Reset(n) => json!({ "reset": n }),
|
||||
R::Print => json!({ "print": true }),
|
||||
R::GoToPage(p) => json!({ "page": p + 1 }),
|
||||
R::LaunchUrl(u) => json!({ "url": u }),
|
||||
R::Submit(u) => json!({ "submit": u }),
|
||||
R::Focus(f) => json!({ "focus": f }),
|
||||
R::Beep => json!({ "beep": true }),
|
||||
R::SaveAs => json!({ "save_as": true }),
|
||||
R::ShowPanel(p) => json!({ "panel": p.id() }),
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
|
||||
let reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
|
||||
Ok(
|
||||
json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
|
||||
)
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
self.js_document_scripts(a)
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetFieldScript {
|
||||
name: a.str("field")?.into(),
|
||||
event: a.str("event")?.into(),
|
||||
script: a.opt_str("script")?.map(str::to_string),
|
||||
};
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
let out = self.session.take_js_output(id);
|
||||
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
|
||||
let mut files = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?));
|
||||
}
|
||||
let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?;
|
||||
let target = self.resolve(a.str("path")?, true)?;
|
||||
write_atomic(&target, csv.as_bytes())?;
|
||||
let columns = csv.lines().next().map_or(0, |h| h.split(',').count());
|
||||
Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?;
|
||||
let export = self.doc_to_copy(a)?.export_office_checked(format);
|
||||
write_atomic(&path, &export.bytes)?;
|
||||
// Text left out to keep a hostile page bounded is said, not silent (#3120).
|
||||
let truncated: Vec<usize> = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect();
|
||||
let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(),
|
||||
"truncated": !truncated.is_empty(), "truncated_pages": truncated });
|
||||
if !export.truncated_pages.is_empty() {
|
||||
use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
|
||||
out["warnings"] = json!([format!(
|
||||
"forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
|
||||
)]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn pdfa_level(&self, a: &Args) -> Result<pdfcraft_engine::pdfa::Level> {
|
||||
let l = a.opt_str("level")?.unwrap_or("2b");
|
||||
pdfcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)")))
|
||||
}
|
||||
|
||||
pub(crate) fn pdfa_verify(&self, a: &Args) -> Result<Value> {
|
||||
let level = self.pdfa_level(a)?;
|
||||
let doc = self.doc(a)?;
|
||||
let issues: Vec<Value> = doc
|
||||
.pdfa_verify(level)
|
||||
.iter()
|
||||
.map(|i| json!({ "clause": i.clause, "message": i.message, "page": i.page.map(|p| p + 1), "fixable": i.fixable }))
|
||||
.collect();
|
||||
let d = doc.standards();
|
||||
Ok(json!({
|
||||
"level": level.label(),
|
||||
"compliant": issues.is_empty(),
|
||||
"issues": issues,
|
||||
"declared": { "pdfa": d.pdfa.map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())), "pdfua": d.pdfua, "output_intents": d.output_intents },
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn print_readiness(&self, a: &Args) -> Result<Value> {
|
||||
let report = self.doc(a)?.print_readiness();
|
||||
let findings: Vec<Value> =
|
||||
report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect();
|
||||
let pages: Vec<Value> = report
|
||||
.pages
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"page": p.page,
|
||||
"media_mm": p.media_mm,
|
||||
"crop_mm": p.crop_mm,
|
||||
"trim_mm": p.trim_mm,
|
||||
"bleed_mm": p.bleed_mm,
|
||||
"trim_set": p.trim_set,
|
||||
"bleed_set": p.bleed_set,
|
||||
"bleed_margin_mm": p.bleed_margin_mm,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let images: Vec<Value> = report
|
||||
.images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
json!({
|
||||
"page": image.page,
|
||||
"name": image.name,
|
||||
"ppi": image.ppi,
|
||||
"pixels": image.pixels,
|
||||
"size_mm": image.size_mm,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let pdfa: Vec<Value> = report
|
||||
.pdfa_issues
|
||||
.iter()
|
||||
.map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) }))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"text": report.text(),
|
||||
"findings": findings,
|
||||
"pages": pages,
|
||||
"images": images,
|
||||
"output_intents": report.output_intents,
|
||||
"fonts_not_embedded": report.fonts_not_embedded,
|
||||
"pdfa_issues": pdfa,
|
||||
"resolution_unknown": report.resolution_unknown,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result<Value> {
|
||||
let level = self.pdfa_level(a)?;
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?;
|
||||
self.pdfa_verify(a)
|
||||
}
|
||||
|
||||
pub(crate) fn action_list(&self) -> Result<Value> {
|
||||
use pdfcraft_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 pdfcraft_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| crate::read_input_bytes(&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 = child(&folder, &name);
|
||||
let target = if target.exists() {
|
||||
let stem =
|
||||
std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
|
||||
unused(&folder, &stem)
|
||||
} else {
|
||||
target
|
||||
};
|
||||
if target.exists() {
|
||||
return Err(failed(format!("{}: no unused output name available", target.display())));
|
||||
}
|
||||
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<(pdfcraft_engine::DocId, pdfcraft_engine::DocId)> {
|
||||
let new = self.doc(a)?.id;
|
||||
let other = a.int("other")?;
|
||||
let old = u64::try_from(other)
|
||||
.ok()
|
||||
.and_then(|o| self.session.get(pdfcraft_engine::DocId(o)))
|
||||
.map(|d| d.id)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("no open document {other}")))?;
|
||||
Ok((old, new))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare(&self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::compare::Kind;
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let c = self.session.compare(old, new).map_err(failed)?;
|
||||
let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize;
|
||||
let r2 = |r: &[f64; 4]| r.map(|v| (v * 100.0).round() / 100.0);
|
||||
let side =
|
||||
|s: &pdfcraft_engine::compare::Side| json!({ "text": s.text, "page": s.page + 1, "rects": s.rects.iter().map(r2).collect::<Vec<_>>() });
|
||||
let list: Vec<Value> = c
|
||||
.changes
|
||||
.iter()
|
||||
.take(limit)
|
||||
.map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) }))
|
||||
.collect();
|
||||
let visual: Vec<Value> = if a.opt_bool("visual")?.unwrap_or(false) {
|
||||
self.session.compare_visual(old, new, 72.0).map_err(failed)?.iter().map(|(p, r)| json!({ "page": p + 1, "rect": r2(r) })).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(json!({
|
||||
"visual": visual,
|
||||
"identical": c.identical(),
|
||||
"replaced": c.count(Kind::Replaced),
|
||||
"inserted": c.count(Kind::Inserted),
|
||||
"deleted": c.count(Kind::Deleted),
|
||||
"old_words": c.old_words,
|
||||
"new_words": c.new_words,
|
||||
"changes": list,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare_report(&self, a: &Args) -> Result<Value> {
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let bytes = self.session.compare_report(old, new).map_err(failed)?;
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
write_atomic(&path, &bytes)?;
|
||||
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result<Value> {
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let n = self.session.mark_differences(old, new).map_err(failed)?;
|
||||
Ok(json!({ "comments": n }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result<Value> {
|
||||
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
|
||||
let id = self.doc(a)?.id;
|
||||
let found = self.session.detect_fields(id, &pages);
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
let (kind, comb) = match c.kind {
|
||||
pdfcraft_engine::detect::Kind::Text => ("text", None),
|
||||
pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None),
|
||||
pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)),
|
||||
};
|
||||
let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) });
|
||||
if let Some(cells) = comb {
|
||||
v["comb"] = json!(cells);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
|
||||
self.session.auto_detect_fields(id, &pages).map_err(failed)?;
|
||||
}
|
||||
Ok(json!({ "fields": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_actions(&self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::FieldAction as A;
|
||||
let list: Vec<Value> = self
|
||||
.doc(a)?
|
||||
.field_actions(a.str("field")?)
|
||||
.iter()
|
||||
.map(|(t, act)| {
|
||||
let mut v = match act {
|
||||
A::JavaScript(j) => json!({ "javascript": j }),
|
||||
A::Uri(u) => json!({ "url": u }),
|
||||
A::Reset(f) => json!({ "reset": f }),
|
||||
A::Named(n) => json!({ "menu": n }),
|
||||
A::GoTo(p) => json!({ "page": p + 1 }),
|
||||
A::ShowHide { fields, hide: true } => json!({ "hide": fields }),
|
||||
A::ShowHide { fields, hide: false } => json!({ "show": fields }),
|
||||
A::Submit(u) => json!({ "submit": u }),
|
||||
A::Other(s) => json!({ "other": s }),
|
||||
};
|
||||
v["trigger"] = json!(t.id());
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "field": a.str("field")?, "actions": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{FieldAction as A, FieldTrigger as T};
|
||||
let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?;
|
||||
let bad = |m: String| ToolError::InvalidArgs(m);
|
||||
let names = |v: &Value| -> Vec<String> {
|
||||
v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
};
|
||||
let mut acts = Vec::new();
|
||||
for it in items {
|
||||
let tid = it["trigger"].as_str().unwrap_or("");
|
||||
let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?;
|
||||
let act = if let Some(j) = it["javascript"].as_str() {
|
||||
A::JavaScript(j.into())
|
||||
} else if let Some(u) = it["url"].as_str() {
|
||||
A::Uri(u.into())
|
||||
} else if it.get("reset").is_some() {
|
||||
A::Reset(names(&it["reset"]))
|
||||
} else if let Some(m) = it["menu"].as_str() {
|
||||
A::Named(m.into())
|
||||
} else if let Some(p) = it["page"].as_u64() {
|
||||
A::GoTo((p.max(1) - 1) as usize)
|
||||
} else if it.get("show").is_some() {
|
||||
A::ShowHide { fields: names(&it["show"]), hide: false }
|
||||
} else if it.get("hide").is_some() {
|
||||
A::ShowHide { fields: names(&it["hide"]), hide: true }
|
||||
} else if let Some(u) = it["submit"].as_str() {
|
||||
A::Submit(u.into())
|
||||
} else {
|
||||
return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)")));
|
||||
};
|
||||
acts.push((t, act));
|
||||
}
|
||||
let name = a.str("field")?.to_string();
|
||||
let edit =
|
||||
pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) };
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
self.form_actions(a)
|
||||
}
|
||||
|
||||
pub(crate) fn js_enabled(&mut self, a: &Args) -> Result<Value> {
|
||||
if let Some(on) = a.opt_bool("enabled")? {
|
||||
self.session.set_javascript(on);
|
||||
}
|
||||
Ok(json!({ "enabled": self.session.javascript() }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod review_html_tests {
|
||||
use super::*;
|
||||
@@ -1098,21 +597,3 @@ mod review_html_tests {
|
||||
assert!(!html.contains("<script>"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_output_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_output_names_follow_the_default_filesystem() {
|
||||
let targets = |names: &[&str]| -> Vec<std::path::PathBuf> { names.iter().map(|n| std::path::Path::new("out").join(n)).collect() };
|
||||
// A spelling that repeats is one shared name, listed once.
|
||||
assert_eq!(shared_file_names(&targets(&["a.pdf", "b.pdf", "a.pdf"])), vec!["a.pdf".to_string()]);
|
||||
assert!(shared_file_names(&targets(&["a.pdf", "b.pdf"])).is_empty());
|
||||
// Case is ignored on Windows and macOS only; a trailing dot is ignored on Windows only.
|
||||
let case = shared_file_names(&targets(&["scan.pdf", "Scan.PDF"]));
|
||||
let dot = shared_file_names(&targets(&["scan.pdf", "scan.pdf."]));
|
||||
assert_eq!(case.len(), if cfg!(any(windows, target_os = "macos")) { 2 } else { 0 }, "{case:?}");
|
||||
assert_eq!(dot.len(), if cfg!(windows) { 2 } else { 0 }, "{dot:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
use super::*;
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?;
|
||||
let export = self.doc_to_copy(a)?.export_office_checked(format);
|
||||
write_atomic(&path, &export.bytes)?;
|
||||
// Text left out to keep a hostile page bounded is said, not silent (#3120).
|
||||
let truncated: Vec<usize> = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect();
|
||||
let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(),
|
||||
"truncated": !truncated.is_empty(), "truncated_pages": truncated });
|
||||
if !export.truncated_pages.is_empty() {
|
||||
use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
|
||||
out["warnings"] = json!([format!(
|
||||
"forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
|
||||
)]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn pdfa_level(&self, a: &Args) -> Result<pdfcraft_engine::pdfa::Level> {
|
||||
let l = a.opt_str("level")?.unwrap_or("2b");
|
||||
pdfcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)")))
|
||||
}
|
||||
|
||||
pub(crate) fn pdfa_verify(&self, a: &Args) -> Result<Value> {
|
||||
let level = self.pdfa_level(a)?;
|
||||
let doc = self.doc(a)?;
|
||||
let issues: Vec<Value> = doc
|
||||
.pdfa_verify(level)
|
||||
.iter()
|
||||
.map(|i| json!({ "clause": i.clause, "message": i.message, "page": i.page.map(|p| p + 1), "fixable": i.fixable }))
|
||||
.collect();
|
||||
let d = doc.standards();
|
||||
Ok(json!({
|
||||
"level": level.label(),
|
||||
"compliant": issues.is_empty(),
|
||||
"issues": issues,
|
||||
"declared": { "pdfa": d.pdfa.map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())), "pdfua": d.pdfua, "output_intents": d.output_intents },
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn print_readiness(&self, a: &Args) -> Result<Value> {
|
||||
let report = self.doc(a)?.print_readiness();
|
||||
let findings: Vec<Value> =
|
||||
report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect();
|
||||
let pages: Vec<Value> = report
|
||||
.pages
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"page": p.page,
|
||||
"media_mm": p.media_mm,
|
||||
"crop_mm": p.crop_mm,
|
||||
"trim_mm": p.trim_mm,
|
||||
"bleed_mm": p.bleed_mm,
|
||||
"trim_set": p.trim_set,
|
||||
"bleed_set": p.bleed_set,
|
||||
"bleed_margin_mm": p.bleed_margin_mm,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let images: Vec<Value> = report
|
||||
.images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
json!({
|
||||
"page": image.page,
|
||||
"name": image.name,
|
||||
"ppi": image.ppi,
|
||||
"pixels": image.pixels,
|
||||
"size_mm": image.size_mm,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let pdfa: Vec<Value> = report
|
||||
.pdfa_issues
|
||||
.iter()
|
||||
.map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) }))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"text": report.text(),
|
||||
"findings": findings,
|
||||
"pages": pages,
|
||||
"images": images,
|
||||
"output_intents": report.output_intents,
|
||||
"fonts_not_embedded": report.fonts_not_embedded,
|
||||
"pdfa_issues": pdfa,
|
||||
"resolution_unknown": report.resolution_unknown,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result<Value> {
|
||||
let level = self.pdfa_level(a)?;
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?;
|
||||
self.pdfa_verify(a)
|
||||
}
|
||||
|
||||
pub(crate) fn action_list(&self) -> Result<Value> {
|
||||
use pdfcraft_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 pdfcraft_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| crate::read_input_bytes(&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 = child(&folder, &name);
|
||||
let target = if target.exists() {
|
||||
let stem =
|
||||
std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
|
||||
unused(&folder, &stem)
|
||||
} else {
|
||||
target
|
||||
};
|
||||
if target.exists() {
|
||||
return Err(failed(format!("{}: no unused output name available", target.display())));
|
||||
}
|
||||
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<(pdfcraft_engine::DocId, pdfcraft_engine::DocId)> {
|
||||
let new = self.doc(a)?.id;
|
||||
let other = a.int("other")?;
|
||||
let old = u64::try_from(other)
|
||||
.ok()
|
||||
.and_then(|o| self.session.get(pdfcraft_engine::DocId(o)))
|
||||
.map(|d| d.id)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("no open document {other}")))?;
|
||||
Ok((old, new))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare(&self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::compare::Kind;
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let c = self.session.compare(old, new).map_err(failed)?;
|
||||
let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize;
|
||||
let r2 = |r: &[f64; 4]| r.map(|v| (v * 100.0).round() / 100.0);
|
||||
let side =
|
||||
|s: &pdfcraft_engine::compare::Side| json!({ "text": s.text, "page": s.page + 1, "rects": s.rects.iter().map(r2).collect::<Vec<_>>() });
|
||||
let list: Vec<Value> = c
|
||||
.changes
|
||||
.iter()
|
||||
.take(limit)
|
||||
.map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) }))
|
||||
.collect();
|
||||
let visual: Vec<Value> = if a.opt_bool("visual")?.unwrap_or(false) {
|
||||
self.session.compare_visual(old, new, 72.0).map_err(failed)?.iter().map(|(p, r)| json!({ "page": p + 1, "rect": r2(r) })).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(json!({
|
||||
"visual": visual,
|
||||
"identical": c.identical(),
|
||||
"replaced": c.count(Kind::Replaced),
|
||||
"inserted": c.count(Kind::Inserted),
|
||||
"deleted": c.count(Kind::Deleted),
|
||||
"old_words": c.old_words,
|
||||
"new_words": c.new_words,
|
||||
"changes": list,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare_report(&self, a: &Args) -> Result<Value> {
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let bytes = self.session.compare_report(old, new).map_err(failed)?;
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
write_atomic(&path, &bytes)?;
|
||||
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result<Value> {
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let n = self.session.mark_differences(old, new).map_err(failed)?;
|
||||
Ok(json!({ "comments": n }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::*;
|
||||
|
||||
/// A script's request to the viewer as the JSON that running a script reports; a side panel
|
||||
/// goes by its fixed id.
|
||||
fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
use pdfcraft_engine::js::Request as R;
|
||||
match r {
|
||||
R::Reset(n) => json!({ "reset": n }),
|
||||
R::Print => json!({ "print": true }),
|
||||
R::GoToPage(p) => json!({ "page": p + 1 }),
|
||||
R::LaunchUrl(u) => json!({ "url": u }),
|
||||
R::Submit(u) => json!({ "submit": u }),
|
||||
R::Focus(f) => json!({ "focus": f }),
|
||||
R::Beep => json!({ "beep": true }),
|
||||
R::SaveAs => json!({ "save_as": true }),
|
||||
R::ShowPanel(p) => json!({ "panel": p.id() }),
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
|
||||
let reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
|
||||
Ok(
|
||||
json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
|
||||
)
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
self.js_document_scripts(a)
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetFieldScript {
|
||||
name: a.str("field")?.into(),
|
||||
event: a.str("event")?.into(),
|
||||
script: a.opt_str("script")?.map(str::to_string),
|
||||
};
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
let out = self.session.take_js_output(id);
|
||||
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
|
||||
let mut files = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?));
|
||||
}
|
||||
let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?;
|
||||
let target = self.resolve(a.str("path")?, true)?;
|
||||
write_atomic(&target, csv.as_bytes())?;
|
||||
let columns = csv.lines().next().map_or(0, |h| h.split(',').count());
|
||||
Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result<Value> {
|
||||
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
|
||||
let id = self.doc(a)?.id;
|
||||
let found = self.session.detect_fields(id, &pages);
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
let (kind, comb) = match c.kind {
|
||||
pdfcraft_engine::detect::Kind::Text => ("text", None),
|
||||
pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None),
|
||||
pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)),
|
||||
};
|
||||
let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) });
|
||||
if let Some(cells) = comb {
|
||||
v["comb"] = json!(cells);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
|
||||
self.session.auto_detect_fields(id, &pages).map_err(failed)?;
|
||||
}
|
||||
Ok(json!({ "fields": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_actions(&self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::FieldAction as A;
|
||||
let list: Vec<Value> = self
|
||||
.doc(a)?
|
||||
.field_actions(a.str("field")?)
|
||||
.iter()
|
||||
.map(|(t, act)| {
|
||||
let mut v = match act {
|
||||
A::JavaScript(j) => json!({ "javascript": j }),
|
||||
A::Uri(u) => json!({ "url": u }),
|
||||
A::Reset(f) => json!({ "reset": f }),
|
||||
A::Named(n) => json!({ "menu": n }),
|
||||
A::GoTo(p) => json!({ "page": p + 1 }),
|
||||
A::ShowHide { fields, hide: true } => json!({ "hide": fields }),
|
||||
A::ShowHide { fields, hide: false } => json!({ "show": fields }),
|
||||
A::Submit(u) => json!({ "submit": u }),
|
||||
A::Other(s) => json!({ "other": s }),
|
||||
};
|
||||
v["trigger"] = json!(t.id());
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "field": a.str("field")?, "actions": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{FieldAction as A, FieldTrigger as T};
|
||||
let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?;
|
||||
let bad = |m: String| ToolError::InvalidArgs(m);
|
||||
let names = |v: &Value| -> Vec<String> {
|
||||
v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
};
|
||||
let mut acts = Vec::new();
|
||||
for it in items {
|
||||
let tid = it["trigger"].as_str().unwrap_or("");
|
||||
let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?;
|
||||
let act = if let Some(j) = it["javascript"].as_str() {
|
||||
A::JavaScript(j.into())
|
||||
} else if let Some(u) = it["url"].as_str() {
|
||||
A::Uri(u.into())
|
||||
} else if it.get("reset").is_some() {
|
||||
A::Reset(names(&it["reset"]))
|
||||
} else if let Some(m) = it["menu"].as_str() {
|
||||
A::Named(m.into())
|
||||
} else if let Some(p) = it["page"].as_u64() {
|
||||
A::GoTo((p.max(1) - 1) as usize)
|
||||
} else if it.get("show").is_some() {
|
||||
A::ShowHide { fields: names(&it["show"]), hide: false }
|
||||
} else if it.get("hide").is_some() {
|
||||
A::ShowHide { fields: names(&it["hide"]), hide: true }
|
||||
} else if let Some(u) = it["submit"].as_str() {
|
||||
A::Submit(u.into())
|
||||
} else {
|
||||
return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)")));
|
||||
};
|
||||
acts.push((t, act));
|
||||
}
|
||||
let name = a.str("field")?.to_string();
|
||||
let edit =
|
||||
pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) };
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
self.form_actions(a)
|
||||
}
|
||||
|
||||
pub(crate) fn js_enabled(&mut self, a: &Args) -> Result<Value> {
|
||||
if let Some(on) = a.opt_bool("enabled")? {
|
||||
self.session.set_javascript(on);
|
||||
}
|
||||
Ok(json!({ "enabled": self.session.javascript() }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use super::*;
|
||||
|
||||
impl Automation {
|
||||
/// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused
|
||||
/// unless the models read it) and `skip_text_pages`, each left at its default when absent.
|
||||
pub(crate) fn ocr_settings(&self, a: &Args) -> Result<pdfcraft_engine::ocr::OcrSettings> {
|
||||
let mut settings = pdfcraft_engine::ocr::OcrSettings::default();
|
||||
if let Some(d) = a.opt_num("dpi")? {
|
||||
settings.dpi = d.clamp(72.0, 600.0) as f32;
|
||||
}
|
||||
if let Some(l) = a.opt_str("language")? {
|
||||
if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) {
|
||||
return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}")));
|
||||
}
|
||||
settings.language = l.into();
|
||||
}
|
||||
if let Some(s) = a.opt_bool("skip_text_pages")? {
|
||||
settings.skip_text_pages = s;
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Recognizes text in each of `paths` and writes the result under the same file name in
|
||||
/// `folder`, reporting per file its output, word count and skipped pages, or its error. A
|
||||
/// path outside the root refuses the whole call before any work starts.
|
||||
pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result<Value> {
|
||||
let settings = self.ocr_settings(a)?;
|
||||
let folder = self.resolve(a.str("folder")?, true)?;
|
||||
// A path that leaves the root refuses the whole call before any work starts, like the
|
||||
// other tools (the write check passes missing files, which stay per-file errors below).
|
||||
let paths = a.strs("paths")?;
|
||||
for p in &paths {
|
||||
self.resolve_for(p, crate::file_access::Access::Read, true)?;
|
||||
}
|
||||
// Two inputs that would write the same output file overwrite each other while both report
|
||||
// success, so the whole batch is refused here: before the folder is created, anything is
|
||||
// read, or the OCR engines load.
|
||||
let targets: Vec<std::path::PathBuf> = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect();
|
||||
let shared = shared_file_names(&targets);
|
||||
if !shared.is_empty() {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.",
|
||||
shared.join(", ")
|
||||
)));
|
||||
}
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
|
||||
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let name = input_file_name(p);
|
||||
let result =
|
||||
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then(
|
||||
|bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true),
|
||||
);
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
let target = child(&folder, &name);
|
||||
write_atomic(&target, &r.bytes)?;
|
||||
let skipped: Vec<usize> = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect();
|
||||
json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped })
|
||||
}
|
||||
Err(e) => json!({ "path": p, "error": e }),
|
||||
});
|
||||
}
|
||||
Ok(json!({ "files": out }))
|
||||
}
|
||||
|
||||
/// Recognizes text on the open document's `pages` (every page when absent) and adds it,
|
||||
/// reporting the total word count and, per page, why it was skipped or what it found.
|
||||
pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result<Value> {
|
||||
let settings = self.ocr_settings(a)?;
|
||||
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
|
||||
let doc = self.doc(a)?;
|
||||
// Recognizing needs only permission to change the document; the words it found are
|
||||
// returned only when copying is allowed too.
|
||||
let (id, copy) = (doc.id, doc.allows_copying());
|
||||
let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?;
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|p| match &p.skipped {
|
||||
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
|
||||
None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
|
||||
None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }),
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::<usize>(), "pages": list }))
|
||||
}
|
||||
|
||||
/// Whether text recognition's models are installed, the directories searched for them (in
|
||||
/// order), and the languages it reads, each as a code and a name.
|
||||
pub(crate) fn ocr_status(&self) -> Result<Value> {
|
||||
use pdfcraft_engine::ocr;
|
||||
let dirs: Vec<String> = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect();
|
||||
let langs: Vec<Value> = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect();
|
||||
Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs }))
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name an input is written under: its own file name, or `document.pdf` when it has none.
|
||||
fn input_file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into())
|
||||
}
|
||||
|
||||
/// The output file names that two or more of `targets` share, each spelling listed once, in input
|
||||
/// order. Names are compared without regard to case on Windows and macOS and exactly elsewhere; on
|
||||
/// Windows a trailing dot or space is not part of the name (`scan.pdf.` is `scan.pdf`). Unicode
|
||||
/// normalization forms (NFC and NFD spellings of one name) are not compared, so such inputs are
|
||||
/// not caught. The folder is the same for every target, so only the names are compared.
|
||||
fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec<String> {
|
||||
let mut groups: std::collections::BTreeMap<String, (usize, Vec<String>)> = std::collections::BTreeMap::new();
|
||||
for target in targets {
|
||||
let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let key = if cfg!(windows) {
|
||||
name.trim_end_matches(['.', ' ']).to_lowercase()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
// Case only; NFC and NFD spellings of one name stay distinct here (a known limit).
|
||||
name.to_lowercase()
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new()));
|
||||
*count += 1;
|
||||
if !spellings.contains(&name) {
|
||||
spellings.push(name);
|
||||
}
|
||||
}
|
||||
groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_output_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_output_names_follow_the_default_filesystem() {
|
||||
let targets = |names: &[&str]| -> Vec<std::path::PathBuf> { names.iter().map(|n| std::path::Path::new("out").join(n)).collect() };
|
||||
// A spelling that repeats is one shared name, listed once.
|
||||
assert_eq!(shared_file_names(&targets(&["a.pdf", "b.pdf", "a.pdf"])), vec!["a.pdf".to_string()]);
|
||||
assert!(shared_file_names(&targets(&["a.pdf", "b.pdf"])).is_empty());
|
||||
// Case is ignored on Windows and macOS only; a trailing dot is ignored on Windows only.
|
||||
let case = shared_file_names(&targets(&["scan.pdf", "Scan.PDF"]));
|
||||
let dot = shared_file_names(&targets(&["scan.pdf", "scan.pdf."]));
|
||||
assert_eq!(case.len(), if cfg!(any(windows, target_os = "macos")) { 2 } else { 0 }, "{case:?}");
|
||||
assert_eq!(dot.len(), if cfg!(windows) { 2 } else { 0 }, "{dot:?}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user