Files
pdfcraft/crates/automation/src/lib.rs
T
2026-10-27 05:40:59 +00:00

171 lines
6.3 KiB
Rust

//! 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<PathBuf>,
/// The read and write folders of `--automation-read-root` / `--automation-write-root`.
roots: Option<Arc<file_access::AutomationRoots>>,
/// Synchronous renderers, rebuilt when a document's working bytes change.
renderers: HashMap<DocId, (Arc<Vec<u8>>, 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<BlockKey>>,
/// 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<DocId>,
}
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<PathBuf>) -> std::io::Result<Self> {
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<Arc<file_access::AutomationRoots>>) -> 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<PathBuf> {
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;