//! pdfcraft-automation — agent control for PdfCraft (architecture §13). //! //! - **Layer:** L7. Headless: depends on the engine, never on a UI toolkit. //! - [`Automation`] is a tool set over an engine [`Session`]: open, inspect, render, extract and //! find text, edit pages and metadata, undo/redo, save, combine, extract and split. Every tool //! has a JSON Schema ([`tools`]) and takes and returns JSON, so the same table drives the MCP //! server ([`mcp`]), `pdfcraft-cli run` and (later) the UI control channel. //! - Pages are **1-based** in every tool, as people number them. Rectangles are in PDF points //! with the origin at the top-left of the displayed page. //! - An optional root directory confines every path a tool reads or writes. Separately, //! [`file_access::AutomationRoots`] (`pdfcraft-cli mcp --automation-read-root` and //! `--automation-write-root`) holds a read folder and a write folder, each left out meaning no //! access of its kind. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod a11y; mod batch; mod comments; mod content; mod conventions; pub mod file_access; mod forms; mod layers; mod links; #[cfg(feature = "mcp")] pub mod mcp; mod measure; mod printing; mod reading; mod redact; mod signing; mod text_cache; mod tools; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use pdfcraft_engine::{BlockKey, CombineSource, DocId, Document, Edit, Session, TextBlock, block_overlapping, commands}; use pdfcraft_platform::staging::{StagingName, staging_suffixes}; use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind}; use serde_json::{Value, json}; pub use pdfcraft_render::Pixels; pub use tools::{ToolDef, tools}; use file_access::foreign_share; use text_cache::TextCache; mod assemble; mod dispatch; mod documents; mod error; mod json; mod pages; mod paths; pub use dispatch::Content; pub use error::ToolError; use error::{Result, failed}; pub(crate) use json::destination; pub use json::view_rect; use json::{Args, info, summary}; use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content}; #[cfg(test)] use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel}; #[cfg(test)] pub(crate) use paths::MAX_INPUT; use paths::{child, lexical, unused, write_atomic}; pub(crate) use paths::{read_input, read_input_bytes}; #[cfg(test)] use paths::{write_atomic_synced, write_atomic_with}; /// A headless PdfCraft session driven by tool calls. pub struct Automation { session: Session, root: Option, /// The read and write folders of `--automation-read-root` / `--automation-write-root`. roots: Option>, /// Synchronous renderers, rebuilt when a document's working bytes change. renderers: HashMap>, PageRenderer)>, /// Extracted page text, under one memory budget across all open documents. texts: TextCache, /// The paragraphs of the last `text_paragraphs` listing of each page, so a paragraph number /// keeps naming that paragraph after edits regroup the page (#2516). listed: HashMap<(DocId, usize), Vec>, /// Documents that lost a listing to the cap. A page of one with no listing is refused rather /// than read as "a number is a position", which the page may no longer match. evicted: HashSet, } impl Default for Automation { fn default() -> Self { Self::new() } } impl Automation { pub fn new() -> Self { Self { session: Session::new(), root: None, roots: None, renderers: HashMap::new(), texts: TextCache::default(), listed: HashMap::new(), evicted: HashSet::new(), } } /// Confine every path the tools read or write to `root` (relative paths resolve inside it). pub fn with_root(mut self, root: impl Into) -> std::io::Result { let root = root.into().canonicalize()?; if !root.is_dir() { return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the root must be a folder")); } self.root = Some(root); Ok(self) } /// Confine the files the tools read to the read root and the files they write to the write /// root (`None`: no confinement). Applies on top of [`with_root`](Self::with_root) when both /// are set. pub fn with_automation_roots(mut self, roots: Option>) -> Self { self.roots = roots; self } /// Use a fixed clock for saves (deterministic output in tests). pub fn with_clock(mut self, clock: fn() -> i64) -> Self { self.session = std::mem::take(&mut self.session).with_clock(clock); self } /// Multiply the time XFA form scripts may take (tests). See [`Session::with_xfa_time_scale`]. pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { self.session = std::mem::take(&mut self.session).with_xfa_time_scale(factor); self } /// The clock and time zone of a host without its own. See [`Session::with_wall_clock`]. pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { self.session = std::mem::take(&mut self.session).with_wall_clock(clock, utc_offset); self } pub fn session(&self) -> &Session { &self.session } /// Save `bytes` that a caller writes on the session's behalf, such as `pdfcraft-cli run` /// saving a rendered page. With a root, it is confined like a tool's own output (a relative /// path resolves inside it). Written atomically either way (#2166), except to something /// that is not a regular file, such as `/dev/stdout` or a pipe, which is written as given. /// Returns where the file went. pub fn write_output(&self, path: &str, bytes: &[u8]) -> Result { let target = self.resolve(path, true)?; let special = self.root.is_none() && self.roots.is_none() && std::fs::metadata(&target).is_ok_and(|m| !m.is_file() && !m.is_dir()); if special { std::fs::write(&target, bytes).map_err(|e| failed(format!("{path}: {e}")))?; } else { write_atomic(&target, bytes)?; } Ok(target) } } #[cfg(test)] mod tests;