mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-15 20:34:36 +00:00
* M14: translation catalogs, same system as PhotoCraft Move the interface translations to PhotoCraft's i18n system: one TSV catalog per language (crates/ui-egui/src/i18n/ja.tsv), the tl! macro, entries keyed by command id, context or plural forms, and catalog tests (format, duplicates, placeholders, command ids). The 49 existing Japanese strings move over unchanged, plus "Auto". The language preference now defaults to "auto" and follows the system language (English when it has no catalog); "en" and "ja" still persist and unknown values are rejected. Update docs/localization.md, ROADMAP.md and the misc.prefs-language parity entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * i18n: add Brazilian Portuguese (pt-br) UI translation Adds Language::PtBr (code pt-br, endonym Português (Brasil)) with serde rename so it persists as "pt-br", registers it in Language::ALL (Preferences ▸ Interface language and ui.set/ui.state) and translates the same 49 menu/command keys as the Japanese table. Extends the i18n tests (table parity, codes, persistence) and updates docs/localization.md. * M14: strict catalog validation, one-pass fmt, translation assets Fold in the infrastructure parts of #175: - `fmt` fills placeholders in one pass, so inserted values (a file name containing `{n}`) are never substituted again. - Catalog validation rejects unknown escapes, unknown `@` contexts, placeholder, ellipsis and plural-form mismatches and duplicates. The bundled catalogs must have no errors (tests); at run time a bad line is still only skipped and logged, so the rest of the catalog loads. - `.tsv` files count as assets in `cargo xtask assets`, with a `translation` kind that may never come from Adobe; ja.tsv gets its ATTRIBUTION.toml entry. - Control-channel test: switching the language keeps documents, their dirty state and command ids. Also make the drawing language thread-local, as in PhotoCraft, so parallel kittest harnesses can't change each other's language. Co-Authored-By: 才倾梦华V <185041488+CQMHV@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M14: Brazilian Portuguese as a TSV catalog Move #165's Portuguese table into crates/ui-egui/src/i18n/pt-br.tsv (same 49 strings, plus Auto) and register it in LANGUAGES with PhotoCraft's Portuguese plural rule (0 and 1 singular). Any Portuguese locale (pt_BR, pt_PT, pt) follows the Brazilian catalog, as in PhotoCraft. The catalog is an attributed asset (ATTRIBUTION.toml), and the docs and parity notes list the new code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Simon Leblanc <contact@leblanc-simon.eu> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: 才倾梦华V <185041488+CQMHV@users.noreply.github.com>
519 lines
26 KiB
Rust
519 lines
26 KiB
Rust
//! pdfcraft-cli — headless PdfCraft.
|
||
//!
|
||
//! ```text
|
||
//! pdfcraft-cli info <file.pdf> [--password PW] document summary as JSON
|
||
//! pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.pam
|
||
//! pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
|
||
//! pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
|
||
//! [--insert-blank 1] [--title T] [--author A] [--full]
|
||
//! pdfcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
|
||
//! pdfcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
|
||
//! pdfcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
|
||
//! pdfcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
|
||
//! pdfcraft-cli tools automation tools and their JSON Schemas
|
||
//! pdfcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
|
||
//! pdfcraft-cli run --script steps.json [--root DIR] [{"tool": "doc_open", "args": {…}}, …]
|
||
//! pdfcraft-cli mcp [--root DIR] MCP server on stdin/stdout (opt-in)
|
||
//! pdfcraft-cli ui --control FILE <method> [key=value …] [--out shot.png]
|
||
//! drive a running app started with --control FILE
|
||
//! ```
|
||
//!
|
||
//! `run` and `mcp` drive the same tool table (`pdfcraft-automation`). In `run`, values parse as
|
||
//! JSON when they can (`pages=[1,3]`, `degrees=90`) and are strings otherwise. A script runs its
|
||
//! steps in one session, so `doc_open` returns id 1, the next document id 2, and so on. A step's
|
||
//! `"out"` (like `--out`) saves its image there. With `--root DIR`, every path a tool or step
|
||
//! names, `"out"` included, must be inside DIR, and relative paths resolve inside it.
|
||
//!
|
||
//! The MCP server never starts on its own: it runs only when this command is launched (normally
|
||
//! by an agent configured to use it), talks only over stdio, and exits when stdin closes.
|
||
//!
|
||
//! `check` is the robustness harness: every file is opened, inspected and fully rendered in a
|
||
//! *separate child process* with a wall-clock timeout, so hangs, panics and aborts in any
|
||
//! dependency are observed and reported instead of taking the harness down.
|
||
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
|
||
use std::io::Write;
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Command, ExitCode, Stdio};
|
||
use std::sync::Arc;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, inspect};
|
||
|
||
fn main() -> ExitCode {
|
||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||
let result =
|
||
match args.first().map(String::as_str) {
|
||
Some("info") => info(&args[1..]),
|
||
Some("render") => render(&args[1..]),
|
||
Some("text") => text(&args[1..]),
|
||
Some("edit") => edit(&args[1..]),
|
||
Some("combine") => combine(&args[1..]),
|
||
Some("extract") => extract(&args[1..]),
|
||
Some("split") => split(&args[1..]),
|
||
Some("check") => check(&args[1..]),
|
||
Some("check-one") => check_one(&args[1..]),
|
||
Some("tools") => tools(),
|
||
Some("run") => run(&args[1..]),
|
||
Some("ui") => ui(&args[1..]),
|
||
#[cfg(feature = "mcp")]
|
||
Some("mcp") => mcp(&args[1..]),
|
||
Some("--version") => {
|
||
println!("pdfcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||
println!("Discord: {} (help and feedback)", pdfcraft_engine::links::DISCORD);
|
||
println!("Web: {}", pdfcraft_engine::links::APP_PAGE);
|
||
println!("Source: {}", pdfcraft_engine::links::GITHUB);
|
||
Ok(())
|
||
}
|
||
_ => Err("usage: pdfcraft-cli <info|render|text|edit|combine|extract|split|check|tools|run|mcp|ui> … (see source header for options)\nhelp and feedback: https://discord.gg/artcraft"
|
||
.into()),
|
||
};
|
||
match result {
|
||
Ok(()) => ExitCode::SUCCESS,
|
||
Err(e) => {
|
||
eprintln!("pdfcraft-cli: {e}");
|
||
ExitCode::FAILURE
|
||
}
|
||
}
|
||
}
|
||
|
||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
|
||
}
|
||
|
||
fn positional(args: &[String]) -> Vec<&str> {
|
||
let mut out = Vec::new();
|
||
let mut skip = false;
|
||
for a in args {
|
||
if skip {
|
||
skip = false;
|
||
continue;
|
||
}
|
||
if a.starts_with("--") {
|
||
skip = true;
|
||
continue;
|
||
}
|
||
out.push(a.as_str());
|
||
}
|
||
out
|
||
}
|
||
|
||
fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
|
||
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
|
||
}
|
||
|
||
fn info(args: &[String]) -> Result<(), String> {
|
||
let path = *positional(args).first().ok_or("info: missing file")?;
|
||
let info = inspect(read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||
let json = serde_json::json!({
|
||
"file": path,
|
||
"pdf_version": info.pdf_version,
|
||
"file_size": info.file_size,
|
||
"pages": info.pages.len(),
|
||
"first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
|
||
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
|
||
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
|
||
"xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
|
||
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
|
||
"links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
|
||
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),
|
||
"warnings": info.warnings,
|
||
});
|
||
println!("{}", serde_json::to_string_pretty(&json).unwrap_or_default());
|
||
Ok(())
|
||
}
|
||
|
||
fn text(args: &[String]) -> Result<(), String> {
|
||
let path = *positional(args).first().ok_or("text: missing file")?;
|
||
let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() });
|
||
let pages: Vec<usize> = match flag(args, "--page") {
|
||
Some(p) => vec![p.parse::<usize>().map_err(|_| "bad --page")?.saturating_sub(1)],
|
||
None => (0..r.page_count()).collect(),
|
||
};
|
||
let mut failed: Vec<usize> = Vec::new();
|
||
for (n, p) in pages.iter().enumerate() {
|
||
let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
|
||
if let Some(e) = out.error {
|
||
eprintln!("page {}: {e}", p + 1);
|
||
failed.push(p + 1);
|
||
continue;
|
||
}
|
||
if n > 0 {
|
||
println!("\u{c}");
|
||
}
|
||
println!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default());
|
||
}
|
||
if failed.is_empty() {
|
||
Ok(())
|
||
} else {
|
||
Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::<Vec<_>>().join(", ")))
|
||
}
|
||
}
|
||
|
||
/// 1-based page list ("1,3,5") → 0-based indices.
|
||
fn page_list(s: &str) -> Result<Vec<usize>, String> {
|
||
s.split(',').map(|p| p.trim().parse::<usize>().ok().filter(|n| *n > 0).map(|n| n - 1).ok_or(format!("bad page number {p:?}"))).collect()
|
||
}
|
||
|
||
/// Apply page and metadata edits through the engine and save (incrementally unless `--full`).
|
||
fn edit(args: &[String]) -> Result<(), String> {
|
||
use pdfcraft_engine::{Edit, Session};
|
||
let path = *positional(args).first().ok_or("edit: missing file")?;
|
||
let out = flag(args, "--out").ok_or("edit: missing --out")?;
|
||
let mut session = Session::new();
|
||
let id = session.open(path, Some(path.to_string()), read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||
let mut edits = Vec::new();
|
||
let mut i = 0;
|
||
while i < args.len() {
|
||
let value = args.get(i + 1).map(String::as_str).unwrap_or("");
|
||
match args[i].as_str() {
|
||
"--rotate" => {
|
||
let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
|
||
edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? });
|
||
}
|
||
"--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }),
|
||
"--move" => {
|
||
let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
|
||
let to: usize = to.parse().map_err(|_| "bad target")?;
|
||
edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.saturating_sub(1) });
|
||
}
|
||
"--insert-blank" => {
|
||
let at: usize = value.parse().map_err(|_| "bad position")?;
|
||
edits.push(Edit::InsertBlankPage { at: at.saturating_sub(1), width: 612.0, height: 792.0 });
|
||
}
|
||
"--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }),
|
||
"--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }),
|
||
_ => {}
|
||
}
|
||
i += 1;
|
||
}
|
||
for e in edits {
|
||
session.apply(id, e).map_err(|e| e.to_string())?;
|
||
}
|
||
let bytes = if args.iter().any(|a| a == "--full") { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
|
||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
|
||
}
|
||
|
||
fn file_stem(path: &str) -> String {
|
||
Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
|
||
}
|
||
|
||
fn combine(args: &[String]) -> Result<(), String> {
|
||
let out = flag(args, "--out").ok_or("combine: missing --out")?;
|
||
let inputs = positional(args);
|
||
if inputs.len() < 2 {
|
||
return Err("combine: give at least two input files".into());
|
||
}
|
||
let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::<Result<Vec<_>, String>>()?;
|
||
let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
|
||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
|
||
}
|
||
|
||
fn extract(args: &[String]) -> Result<(), String> {
|
||
let path = *positional(args).first().ok_or("extract: missing file")?;
|
||
let out = flag(args, "--out").ok_or("extract: missing --out")?;
|
||
let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
|
||
let mut session = pdfcraft_engine::Session::new();
|
||
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||
let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
|
||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
|
||
}
|
||
|
||
fn split(args: &[String]) -> Result<(), String> {
|
||
use pdfcraft_engine::SplitBy;
|
||
let path = *positional(args).first().ok_or("split: missing file")?;
|
||
let by = match (flag(args, "--every"), flag(args, "--before")) {
|
||
(Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?),
|
||
(None, Some(list)) => SplitBy::Before(page_list(list)?),
|
||
_ => return Err("split: give either --every N or --before PAGES".into()),
|
||
};
|
||
let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or("."));
|
||
let mut session = pdfcraft_engine::Session::new();
|
||
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||
let stem = file_stem(path);
|
||
for (a, b, bytes) in session.split(id, &by).map_err(|e| e.to_string())? {
|
||
let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
|
||
std::fs::write(&name, bytes.as_slice()).map_err(|e| format!("{}: {e}", name.display()))?;
|
||
println!("{}", name.display());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn render(args: &[String]) -> Result<(), String> {
|
||
let path = *positional(args).first().ok_or("render: missing file")?;
|
||
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
|
||
let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
|
||
let out = flag(args, "--out").ok_or("render: missing --out (.pam)")?;
|
||
let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() });
|
||
let p = r.render(RenderRequest { page: page.saturating_sub(1), kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
|
||
if let Some(e) = p.error {
|
||
return Err(e);
|
||
}
|
||
// PAM (netpbm RGB_ALPHA) keeps this tool dependency-free; convert with any image tool.
|
||
let mut f = std::fs::File::create(out).map_err(|e| e.to_string())?;
|
||
write!(f, "P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).map_err(|e| e.to_string())?;
|
||
f.write_all(&p.rgba).map_err(|e| e.to_string())?;
|
||
eprintln!("rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
|
||
Ok(())
|
||
}
|
||
|
||
/// Child-process body for `check`: prints one JSON line.
|
||
fn check_one(args: &[String]) -> Result<(), String> {
|
||
let path = *positional(args).first().ok_or("check-one: missing file")?;
|
||
let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?;
|
||
let start = Instant::now();
|
||
let bytes = read(path)?;
|
||
let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
|
||
Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0),
|
||
Ok(info) => {
|
||
let mut r = PageRenderer::new(bytes, RenderConfig::default());
|
||
let mut failed = Vec::new();
|
||
for i in 0..info.pages.len().min(500) {
|
||
let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
|
||
if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }).error {
|
||
failed.push(format!("p{} text: {e}", i + 1));
|
||
}
|
||
if let Some(e) = p.error {
|
||
failed.push(format!("p{}: {e}", i + 1));
|
||
}
|
||
}
|
||
let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() };
|
||
(status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len())
|
||
}
|
||
};
|
||
// `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
|
||
// so a panic in cos shows up as a crash of this process.
|
||
if args.iter().any(|a| a == "--edit") {
|
||
edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
|
||
}
|
||
let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
|
||
"ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::<String>() });
|
||
println!("{line}");
|
||
Ok(())
|
||
}
|
||
|
||
/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
|
||
/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
|
||
fn edit_round_trip(bytes: &[u8]) {
|
||
use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
|
||
let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
|
||
for num in doc.object_numbers().into_iter().take(20_000) {
|
||
if let Ok(o) = doc.try_get(num)
|
||
&& let Object::Stream(s) = &*o
|
||
{
|
||
let _ = s.decoded();
|
||
}
|
||
}
|
||
if let Some(root) = doc.root() {
|
||
let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true)));
|
||
}
|
||
let classic = SaveOptions { object_streams: false, ..SaveOptions::default() };
|
||
for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)]
|
||
.into_iter()
|
||
.flatten()
|
||
{
|
||
if let Ok(again) = Document::open(Arc::new(out)) {
|
||
let _ = again.root().map(|r| again.get(r));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn short(s: &str) -> String {
|
||
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::<Vec<_>>().join("-").to_lowercase()
|
||
}
|
||
|
||
fn collect(paths: &[&str]) -> Vec<PathBuf> {
|
||
let mut out = Vec::new();
|
||
let mut stack: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||
while let Some(p) = stack.pop() {
|
||
if p.is_dir() {
|
||
if let Ok(rd) = std::fs::read_dir(&p) {
|
||
stack.extend(rd.flatten().map(|e| e.path()));
|
||
}
|
||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||
out.push(p);
|
||
}
|
||
}
|
||
out.sort();
|
||
out
|
||
}
|
||
|
||
fn check(args: &[String]) -> Result<(), String> {
|
||
let files = collect(&positional(args));
|
||
if files.is_empty() {
|
||
return Err("check: no PDF files found".into());
|
||
}
|
||
let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?);
|
||
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
|
||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||
let mut results = Vec::new();
|
||
let mut counts: std::collections::BTreeMap<String, usize> = Default::default();
|
||
for (n, f) in files.iter().enumerate() {
|
||
let r = run_child(&exe, f, &dpi, timeout);
|
||
let status = r["status"].as_str().unwrap_or("?").to_string();
|
||
*counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1;
|
||
if status != "ok" {
|
||
eprintln!(
|
||
"[{}/{}] {status:<28} {} {}",
|
||
n + 1,
|
||
files.len(),
|
||
f.display(),
|
||
r["detail"].as_str().unwrap_or("").chars().take(160).collect::<String>()
|
||
);
|
||
}
|
||
results.push(r);
|
||
}
|
||
eprintln!("\nchecked {} files: {:?}", files.len(), counts);
|
||
if let Some(out) = flag(args, "--json") {
|
||
std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value {
|
||
let file_s = file.to_string_lossy().to_string();
|
||
let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
|
||
let mut child = match child {
|
||
Ok(c) => c,
|
||
Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
|
||
};
|
||
let start = Instant::now();
|
||
loop {
|
||
match child.try_wait() {
|
||
Ok(Some(status)) => {
|
||
let mut out = String::new();
|
||
if let Some(mut so) = child.stdout.take() {
|
||
use std::io::Read;
|
||
let _ = so.read_to_string(&mut out);
|
||
}
|
||
return match serde_json::from_str::<serde_json::Value>(out.trim()) {
|
||
Ok(v) => v,
|
||
Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }),
|
||
};
|
||
}
|
||
Ok(None) if start.elapsed() > timeout => {
|
||
let _ = child.kill();
|
||
let _ = child.wait();
|
||
return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) });
|
||
}
|
||
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
|
||
Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- automation --------------------------------------------------------------------------------
|
||
|
||
fn automation(args: &[String]) -> Result<pdfcraft_automation::Automation, String> {
|
||
let a = pdfcraft_automation::Automation::new();
|
||
match flag(args, "--root") {
|
||
Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")),
|
||
None => Ok(a),
|
||
}
|
||
}
|
||
|
||
fn tools() -> Result<(), String> {
|
||
let list: Vec<serde_json::Value> = pdfcraft_automation::tools()
|
||
.iter()
|
||
.map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema }))
|
||
.collect();
|
||
println!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
|
||
Ok(())
|
||
}
|
||
|
||
/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is
|
||
/// written like a tool's own output, so `--root` confines it too.
|
||
fn print_output(auto: &pdfcraft_automation::Automation, content: Vec<pdfcraft_automation::Content>, out: Option<&str>) -> Result<(), String> {
|
||
for c in content {
|
||
match c {
|
||
pdfcraft_automation::Content::Json(v) => println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()),
|
||
pdfcraft_automation::Content::Png { data, width, height } => match out {
|
||
Some(path) => {
|
||
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
|
||
println!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height }));
|
||
}
|
||
None => println!(
|
||
"{}",
|
||
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
|
||
),
|
||
},
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn run(args: &[String]) -> Result<(), String> {
|
||
let mut auto = automation(args)?;
|
||
if let Some(script) = flag(args, "--script") {
|
||
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
|
||
let steps: Vec<serde_json::Value> = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
|
||
for (i, step) in steps.iter().enumerate() {
|
||
let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
|
||
let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
|
||
print_output(&auto, content, step["out"].as_str()).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
|
||
}
|
||
return Ok(());
|
||
}
|
||
let tool = *positional(args).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?;
|
||
let mut obj = serde_json::Map::new();
|
||
for kv in positional(args).iter().skip(1) {
|
||
let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?;
|
||
let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()));
|
||
obj.insert(k.to_string(), value);
|
||
}
|
||
let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?;
|
||
print_output(&auto, content, flag(args, "--out"))
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
fn mcp(args: &[String]) -> Result<(), String> {
|
||
let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?);
|
||
eprintln!("pdfcraft-cli: MCP server on stdio (protocol {}); close stdin to stop", pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0]);
|
||
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| e.to_string())
|
||
}
|
||
|
||
// ---- UI control channel client -----------------------------------------------------------------
|
||
|
||
/// One request to a running app's control channel (`pdfcraft --control FILE`).
|
||
fn ui(args: &[String]) -> Result<(), String> {
|
||
use std::io::{BufRead, BufReader, Write as _};
|
||
let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?;
|
||
let info: serde_json::Value =
|
||
serde_json::from_str(&std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?).map_err(|e| format!("{file}: {e}"))?;
|
||
let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?;
|
||
let token = info["token"].as_str().ok_or(format!("{file}: no token"))?;
|
||
let pos = positional(args);
|
||
let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
|
||
let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") };
|
||
let mut params = serde_json::Map::new();
|
||
for kv in pos.iter().skip(1) {
|
||
let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?;
|
||
params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())));
|
||
}
|
||
let stream = std::net::TcpStream::connect(("127.0.0.1", port as u16))
|
||
.map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?;
|
||
stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?;
|
||
let mut write = stream.try_clone().map_err(|e| e.to_string())?;
|
||
let mut lines = BufReader::new(stream).lines();
|
||
let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result<serde_json::Value, String> {
|
||
writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?;
|
||
let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?;
|
||
let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
|
||
match reply.get("error") {
|
||
Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()),
|
||
None => Ok(reply["result"].clone()),
|
||
}
|
||
};
|
||
rpc(1, "auth", serde_json::json!({ "token": token }))?;
|
||
let mut result = rpc(2, &method, serde_json::Value::Object(params))?;
|
||
if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) {
|
||
use base64::Engine as _;
|
||
let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?;
|
||
std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?;
|
||
result["png_base64"] = serde_json::json!(format!("written to {out}"));
|
||
}
|
||
println!("{}", serde_json::to_string_pretty(&result).unwrap_or_default());
|
||
Ok(())
|
||
}
|