From a891dfebb2bcf0c6abf3b3c68122b7cd104d3116 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:13:58 +0000 Subject: [PATCH] refactor(automation): split a11y.rs into a11y/ New modules: a11y/ocr, a11y/documents, a11y/form_tools (JS and form-action tools). --- crates/automation/src/a11y.rs | 527 +---------------------- crates/automation/src/a11y/documents.rs | 219 ++++++++++ crates/automation/src/a11y/form_tools.rs | 163 +++++++ crates/automation/src/a11y/ocr.rs | 146 +++++++ 4 files changed, 532 insertions(+), 523 deletions(-) create mode 100644 crates/automation/src/a11y/documents.rs create mode 100644 crates/automation/src/a11y/form_tools.rs create mode 100644 crates/automation/src/a11y/ocr.rs diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 20243dcda..1f0c9cb8a 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -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 { - 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 { - 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 = 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 = 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 { - 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 = 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::(), "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 { - use pdfcraft_engine::ocr; - let dirs: Vec = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect(); - let langs: Vec = 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 { - let mut groups: std::collections::BTreeMap)> = 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 { - 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 = 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 { - let list: Vec = 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 { - 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 { - 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 { - 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 { - 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 = 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 { - 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 { - let level = self.pdfa_level(a)?; - let doc = self.doc(a)?; - let issues: Vec = 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 { - let report = self.doc(a)?.print_readiness(); - let findings: Vec = - report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect(); - let pages: Vec = 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 = 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 = 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 { - 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 { - use pdfcraft_engine::actions::{Step, builtin}; - let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() }); - let actions: Vec = builtin() - .iter() - .map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::>() })) - .collect(); - let steps: Vec = 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 { - 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::>>()?; - 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 { - 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::>() }); - let list: Vec = 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 = 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 { - 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 { - 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 { - 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 = 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 { - use pdfcraft_engine::FieldAction as A; - let list: Vec = 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 { - 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 { - 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 { - 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("