1536 lines
92 KiB
Rust
1536 lines
92 KiB
Rust
//! End-to-end tests of the automation tools and the MCP server, on synthetic PDFs.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
#[cfg(feature = "mcp")]
|
||
use pdfcraft_automation::mcp::McpServer;
|
||
use pdfcraft_automation::{Automation, Content, ToolError, tools};
|
||
use serde_json::{Value, json};
|
||
|
||
/// A PDF with `n` 200×300 pt pages reading "Page 1", "Page 2", …
|
||
fn fixture(n: usize) -> Vec<u8> {
|
||
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
|
||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
|
||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
|
||
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
|
||
for i in 0..n {
|
||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i).into_bytes());
|
||
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
|
||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
|
||
}
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offsets = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offsets.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||
out.extend_from_slice(o);
|
||
out.extend_from_slice(b"\nendobj\n");
|
||
}
|
||
let xref = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offsets {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
/// A fresh directory with `a.pdf` (3 pages) and `b.pdf` (2 pages).
|
||
fn workdir(test: &str) -> PathBuf {
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-{test}-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("a.pdf"), fixture(3)).unwrap();
|
||
std::fs::write(dir.join("b.pdf"), fixture(2)).unwrap();
|
||
dir
|
||
}
|
||
|
||
fn auto(dir: &Path) -> Automation {
|
||
Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000)
|
||
}
|
||
|
||
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||
match a.call(tool, &args) {
|
||
Ok(mut c) => match c.remove(0) {
|
||
Content::Json(v) => v,
|
||
other => panic!("{tool}: expected JSON, got {other:?}"),
|
||
},
|
||
Err(e) => panic!("{tool} {args}: {e}"),
|
||
}
|
||
}
|
||
|
||
fn page_text(a: &mut Automation, doc: u64) -> Vec<String> {
|
||
ok(a, "text_extract", json!({ "doc": doc }))["pages"].as_array().unwrap().iter().map(|p| p["text"].as_str().unwrap().trim().to_string()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn tool_table_is_well_formed() {
|
||
let all = tools();
|
||
let mut names = std::collections::HashSet::new();
|
||
for t in &all {
|
||
assert!(names.insert(t.name), "duplicate tool {}", t.name);
|
||
assert!(t.name.len() <= 64 && t.name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "bad MCP tool name {}", t.name);
|
||
assert_eq!(t.input_schema["type"], "object", "{}", t.name);
|
||
let props = t.input_schema["properties"].as_object().unwrap();
|
||
for r in t.input_schema["required"].as_array().unwrap() {
|
||
assert!(props.contains_key(r.as_str().unwrap()), "{}: required {r} is not a property", t.name);
|
||
}
|
||
assert!(!(t.read_only && t.destructive), "{} is both read-only and destructive", t.name);
|
||
if let Some(c) = t.command {
|
||
assert!(pdfcraft_engine::commands::command(c).is_some(), "{} names unregistered command {c}", t.name);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn open_inspect_render_and_find() {
|
||
let dir = workdir("inspect");
|
||
let mut a = auto(&dir);
|
||
let opened = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }));
|
||
assert_eq!(opened["pages"], 3);
|
||
assert_eq!(opened["editable"], true);
|
||
let doc = opened["doc"].as_u64().unwrap();
|
||
|
||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
assert_eq!(info["pages"].as_array().unwrap().len(), 3);
|
||
assert_eq!(info["pages"][0]["width"], 200.0);
|
||
assert_eq!(info["pages"][1]["label"], "2");
|
||
|
||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]);
|
||
|
||
let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "page 2" }));
|
||
assert_eq!(found["count"], 1);
|
||
assert_eq!(found["matches"][0]["page"], 2);
|
||
let rect = found["matches"][0]["rects"][0].as_array().unwrap();
|
||
assert!(rect[1].as_f64().unwrap() > 100.0 && rect[3].as_f64().unwrap() < 160.0, "rect is top-left based: {rect:?}");
|
||
|
||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||
let Content::Png { data, width, height } = &png[0] else { panic!("expected an image") };
|
||
assert_eq!((*width, *height), (200, 300));
|
||
assert_eq!(&data[..8], b"\x89PNG\r\n\x1a\n");
|
||
let decoder = png::Decoder::new(std::io::Cursor::new(data.as_slice()));
|
||
let info = decoder.read_info().unwrap().info().clone();
|
||
assert_eq!((info.width, info.height), (200, 300));
|
||
}
|
||
|
||
#[test]
|
||
fn edit_undo_redo_and_save_round_trip() {
|
||
let dir = workdir("edit");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
|
||
let s = ok(&mut a, "page_delete", json!({ "doc": doc, "pages": [2] }));
|
||
assert_eq!((s["pages"].as_u64(), s["dirty"].as_bool()), (Some(2), Some(true)));
|
||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 3"]);
|
||
|
||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]);
|
||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 3"]);
|
||
|
||
ok(&mut a, "page_insert_file", json!({ "doc": doc, "path": "b.pdf", "pages": [2], "at": 1 }));
|
||
ok(&mut a, "page_insert_blank", json!({ "doc": doc, "at": 4 }));
|
||
ok(&mut a, "page_move", json!({ "doc": doc, "pages": [3], "to": 1 }));
|
||
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [1], "degrees": 90 }));
|
||
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Automated" }));
|
||
assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1", ""]);
|
||
|
||
let saved = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/edited.pdf" }));
|
||
assert_eq!(saved["incremental"], false);
|
||
assert_eq!(saved["document"]["dirty"], false);
|
||
|
||
let mut b = auto(&dir);
|
||
let re = ok(&mut b, "doc_open", json!({ "path": "out/edited.pdf" }))["doc"].as_u64().unwrap();
|
||
let info = ok(&mut b, "doc_info", json!({ "doc": re }));
|
||
assert_eq!(info["title"], "Automated");
|
||
assert_eq!(info["pages"][0]["rotation"], 90);
|
||
assert_eq!(page_text(&mut b, re), ["Page 3", "Page 2", "Page 1", ""]);
|
||
|
||
// Saving in place appends an incremental update.
|
||
ok(&mut b, "doc_set_info", json!({ "doc": re, "key": "Author", "value": "Agent" }));
|
||
let before = std::fs::metadata(dir.join("out/edited.pdf")).unwrap().len();
|
||
let again = ok(&mut b, "doc_save", json!({ "doc": re }));
|
||
assert_eq!(again["incremental"], true);
|
||
let after = std::fs::read(dir.join("out/edited.pdf")).unwrap();
|
||
assert!(after.len() as u64 > before);
|
||
assert_eq!(after.windows(5).filter(|w| w == b"%%EOF").count(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn combine_extract_and_split() {
|
||
let dir = workdir("organize");
|
||
let mut a = auto(&dir);
|
||
let combined = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "b.pdf"], "out": "ab.pdf", "open": true }));
|
||
let doc = combined["document"]["doc"].as_u64().unwrap();
|
||
assert_eq!(combined["document"]["pages"], 5);
|
||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3", "Page 1", "Page 2"]);
|
||
// Chosen pages per file, in the order given.
|
||
let some = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "b.pdf"], "pages": ["3, 1", null], "open": true }));
|
||
assert_eq!(page_text(&mut a, some["document"]["doc"].as_u64().unwrap()), ["Page 3", "Page 1", "Page 1", "Page 2"]);
|
||
assert!(matches!(a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "pages": ["9", null] })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "pages": ["1"] })), Err(ToolError::InvalidArgs(_))));
|
||
|
||
let ex = ok(&mut a, "page_extract", json!({ "doc": doc, "pages": [2, 4] }));
|
||
let ex_doc = ex["document"]["doc"].as_u64().unwrap();
|
||
assert_eq!(page_text(&mut a, ex_doc), ["Page 2", "Page 1"]);
|
||
assert!(ex.get("path").is_none());
|
||
|
||
let split = ok(&mut a, "doc_split", json!({ "doc": doc, "every": 2, "out_dir": "parts" }));
|
||
let files = split["files"].as_array().unwrap();
|
||
assert_eq!(files.len(), 3);
|
||
assert_eq!((files[2]["first_page"].as_u64(), files[2]["last_page"].as_u64()), (Some(5), Some(5)));
|
||
for f in files {
|
||
assert!(Path::new(f["path"].as_str().unwrap()).starts_with(dir.canonicalize().unwrap()));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn errors_are_specific_and_safe() {
|
||
let dir = workdir("errors");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
|
||
let err = |a: &mut Automation, tool: &str, args: Value| a.call(tool, &args).unwrap_err();
|
||
assert!(matches!(err(&mut a, "nope", json!({})), ToolError::UnknownTool(_)));
|
||
assert!(matches!(err(&mut a, "page_delete", json!({ "doc": doc, "pages": [9] })), ToolError::InvalidArgs(m) if m.contains("3 pages")));
|
||
assert!(matches!(err(&mut a, "page_delete", json!({ "doc": doc, "pages": [0] })), ToolError::InvalidArgs(_)));
|
||
assert!(matches!(err(&mut a, "page_delete", json!({ "doc": doc, "page": [1] })), ToolError::InvalidArgs(m) if m.contains("unknown argument")));
|
||
assert!(matches!(err(&mut a, "page_rotate", json!({ "doc": doc, "pages": [1], "degrees": 45 })), ToolError::InvalidArgs(_)));
|
||
assert!(matches!(err(&mut a, "doc_info", json!({ "doc": 99 })), ToolError::Failed(_)));
|
||
assert!(matches!(err(&mut a, "edit_undo", json!({ "doc": doc })), ToolError::Failed(_)));
|
||
|
||
// Unsaved changes are never dropped silently.
|
||
ok(&mut a, "page_delete", json!({ "doc": doc, "pages": [1] }));
|
||
assert!(matches!(err(&mut a, "doc_close", json!({ "doc": doc })), ToolError::Failed(m) if m.contains("unsaved")));
|
||
ok(&mut a, "doc_close", json!({ "doc": doc, "discard_changes": true }));
|
||
assert_eq!(ok(&mut a, "doc_list", json!({}))["documents"], json!([]));
|
||
|
||
// The root confines reads and writes.
|
||
let outside = std::env::temp_dir().join("pdfcraft-automation-outside.pdf");
|
||
std::fs::write(&outside, fixture(1)).unwrap();
|
||
assert!(matches!(err(&mut a, "doc_open", json!({ "path": outside.to_str().unwrap() })), ToolError::Failed(m) if m.contains("outside")));
|
||
assert!(
|
||
matches!(err(&mut a, "doc_open", json!({ "path": "../pdfcraft-automation-outside.pdf" })), ToolError::Failed(m) if m.contains("outside"))
|
||
);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
assert!(a.call("doc_save", &json!({ "doc": doc, "path": "new/../../escape.pdf" })).is_err());
|
||
assert!(a.call("doc_save", &json!({ "doc": doc, "path": outside.to_str().unwrap() })).is_err());
|
||
let _ = std::fs::remove_file(outside);
|
||
}
|
||
|
||
#[test]
|
||
fn command_list_reports_enablement_and_tools() {
|
||
let dir = workdir("commands");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let list = ok(&mut a, "command_list", json!({ "doc": doc }));
|
||
let find = |id: &str| list["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone();
|
||
assert_eq!(find("page.rotate")["tool"], "page_rotate");
|
||
assert_eq!(find("page.rotate")["enabled"], true);
|
||
assert_eq!(find("edit.undo")["enabled"], false);
|
||
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [1], "degrees": 90 }));
|
||
let list = ok(&mut a, "command_list", json!({ "doc": doc }));
|
||
let undo = list["commands"].as_array().unwrap().iter().find(|c| c["id"] == "edit.undo").unwrap().clone();
|
||
assert_eq!(undo["enabled"], true);
|
||
assert_eq!(undo["label"], "Undo Rotate page");
|
||
}
|
||
|
||
// ---- MCP ---------------------------------------------------------------------------------------
|
||
|
||
#[cfg(feature = "mcp")]
|
||
fn rpc(server: &mut McpServer, id: u64, method: &str, params: Value) -> Value {
|
||
let line = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
|
||
serde_json::from_str(&server.handle_line(&line).expect("a reply")).unwrap()
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn mcp_session_over_stdio() {
|
||
let dir = workdir("mcp");
|
||
let path = dir.join("a.pdf");
|
||
let input = [
|
||
json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } } }),
|
||
json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }),
|
||
json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }),
|
||
json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "doc_open", "arguments": { "path": path } } }),
|
||
json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "page_render", "arguments": { "doc": 1, "page": 2, "dpi": 36 } } }),
|
||
]
|
||
.iter()
|
||
.map(Value::to_string)
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let mut out = Vec::new();
|
||
McpServer::new(Automation::new()).serve(input.as_bytes(), &mut out).unwrap();
|
||
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||
assert_eq!(replies.len(), 4, "the notification gets no reply");
|
||
|
||
assert_eq!(replies[0]["result"]["protocolVersion"], "2025-03-26");
|
||
assert_eq!(replies[0]["result"]["serverInfo"]["name"], "pdfcraft");
|
||
assert_eq!(replies[1]["result"]["tools"].as_array().unwrap().len(), tools().len());
|
||
assert_eq!(replies[2]["result"]["structuredContent"]["pages"], 3);
|
||
assert_eq!(replies[2]["result"]["isError"], false);
|
||
let img = &replies[3]["result"]["content"][0];
|
||
assert_eq!((img["type"].as_str(), img["mimeType"].as_str()), (Some("image"), Some("image/png")));
|
||
use base64::Engine as _;
|
||
let png = base64::engine::general_purpose::STANDARD.decode(img["data"].as_str().unwrap()).unwrap();
|
||
assert_eq!(&png[1..4], b"PNG");
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn mcp_errors() {
|
||
let mut s = McpServer::new(Automation::new());
|
||
assert_eq!(rpc(&mut s, 1, "initialize", json!({ "protocolVersion": "1999-01-01" }))["result"]["protocolVersion"], "2025-06-18");
|
||
assert_eq!(rpc(&mut s, 2, "ping", json!({}))["result"], json!({}));
|
||
assert_eq!(rpc(&mut s, 3, "prompts/list", json!({}))["error"]["code"], -32601);
|
||
assert_eq!(rpc(&mut s, 6, "resources/read", json!({ "uri": "pdfcraft://doc/9/info" }))["error"]["code"], -32602);
|
||
assert_eq!(rpc(&mut s, 4, "tools/call", json!({ "name": "nope" }))["error"]["code"], -32602);
|
||
let failed = rpc(&mut s, 5, "tools/call", json!({ "name": "doc_open", "arguments": { "path": "/definitely/not/here.pdf" } }));
|
||
assert_eq!(failed["result"]["isError"], true);
|
||
assert!(failed["result"]["content"][0]["text"].as_str().unwrap().contains("here.pdf"));
|
||
let bad: Value = serde_json::from_str(&s.handle_line("{not json").unwrap()).unwrap();
|
||
assert_eq!(bad["error"]["code"], -32700);
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn mcp_resources_expose_open_documents() {
|
||
let dir = workdir("mcp-resources");
|
||
let mut s = McpServer::new(auto(&dir));
|
||
assert!(rpc(&mut s, 1, "initialize", json!({}))["result"]["capabilities"]["resources"].is_object());
|
||
assert_eq!(rpc(&mut s, 2, "resources/list", json!({}))["result"]["resources"], json!([]));
|
||
assert_eq!(rpc(&mut s, 3, "resources/templates/list", json!({}))["result"]["resourceTemplates"].as_array().unwrap().len(), 4);
|
||
rpc(&mut s, 4, "tools/call", json!({ "name": "doc_open", "arguments": { "path": "a.pdf" } }));
|
||
let list = rpc(&mut s, 5, "resources/list", json!({}))["result"]["resources"].as_array().cloned().unwrap();
|
||
assert_eq!(list.len(), 2 + 3, "info, text and three page images");
|
||
assert_eq!(list[0]["uri"], "pdfcraft://doc/1/info");
|
||
let read = |s: &mut McpServer, uri: &str| rpc(s, 6, "resources/read", json!({ "uri": uri }))["result"]["contents"][0].clone();
|
||
let text = read(&mut s, "pdfcraft://doc/1/text");
|
||
assert_eq!(text["mimeType"], "text/plain");
|
||
assert!(text["text"].as_str().unwrap().contains("Page 2\nPage 2"), "{text}");
|
||
assert_eq!(read(&mut s, "pdfcraft://doc/1/page/3/text")["text"], "Page 3");
|
||
let info: Value = serde_json::from_str(read(&mut s, "pdfcraft://doc/1/info")["text"].as_str().unwrap()).unwrap();
|
||
assert_eq!(info["pages"].as_array().unwrap().len(), 3);
|
||
let img = read(&mut s, "pdfcraft://doc/1/page/1/image?dpi=36");
|
||
use base64::Engine as _;
|
||
let png = base64::engine::general_purpose::STANDARD.decode(img["blob"].as_str().unwrap()).unwrap();
|
||
assert_eq!(&png[1..4], b"PNG");
|
||
assert_eq!(rpc(&mut s, 7, "resources/read", json!({ "uri": "pdfcraft://doc/1/page/9/image" }))["error"]["code"], -32602);
|
||
}
|
||
|
||
#[test]
|
||
fn parallel_text_extraction_keeps_page_order_and_follows_edits() {
|
||
let dir = workdir("parallel");
|
||
std::fs::write(dir.join("long.pdf"), fixture(40)).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].as_u64().unwrap();
|
||
let expected: Vec<String> = (1..=40).map(|i| format!("Page {i}")).collect();
|
||
assert_eq!(page_text(&mut a, doc), expected);
|
||
assert_eq!(ok(&mut a, "text_find", json!({ "doc": doc, "query": "page 3" }))["count"], 11); // 3, 30–39
|
||
ok(&mut a, "page_delete", json!({ "doc": doc, "pages": [3] }));
|
||
assert_eq!(ok(&mut a, "text_find", json!({ "doc": doc, "query": "page 3" }))["count"], 10);
|
||
assert_eq!(page_text(&mut a, doc)[2], "Page 4");
|
||
}
|
||
|
||
#[test]
|
||
fn bookmarks_through_tools() {
|
||
let dir = workdir("bookmarks");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([]));
|
||
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Intro", "page": 1 }));
|
||
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Body", "page": 2 }));
|
||
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Detail", "page": 3, "parent": [2] }));
|
||
ok(&mut a, "bookmark_move", json!({ "doc": doc, "path": [1], "parent": [2], "position": 1 })); // Intro under Body
|
||
ok(&mut a, "bookmark_rename", json!({ "doc": doc, "path": [1, 2], "title": "Details" }));
|
||
ok(&mut a, "bookmark_set_page", json!({ "doc": doc, "path": [1, 1], "page": 3 }));
|
||
let list = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone();
|
||
assert_eq!(list[0]["title"], "Body");
|
||
assert_eq!(list[0]["children"][0]["title"], "Intro");
|
||
assert_eq!(list[0]["children"][0]["page"], 3);
|
||
assert_eq!(list[0]["children"][0]["path"], json!([1, 1]));
|
||
assert_eq!(list[0]["children"][1]["title"], "Details");
|
||
assert!(matches!(a.call("bookmark_delete", &json!({ "doc": doc, "path": [9] })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(a.call("bookmark_delete", &json!({ "doc": doc, "path": [0] })), Err(ToolError::InvalidArgs(_))));
|
||
ok(&mut a, "bookmark_delete", json!({ "doc": doc, "path": [1, 1] }));
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" }));
|
||
let mut b = auto(&dir);
|
||
let re = ok(&mut b, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap();
|
||
let list = ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"].clone();
|
||
assert_eq!((list[0]["title"].as_str(), list[0]["children"][0]["title"].as_str()), (Some("Body"), Some("Details")));
|
||
}
|
||
|
||
#[test]
|
||
fn numbering_pages_through_tools() {
|
||
let dir = workdir("labels");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "page_number", json!({ "doc": doc, "from": 1, "to": 1, "style": "upper-roman" }));
|
||
assert_eq!(r["labels"], json!(["I", "2", "3"]));
|
||
let r = ok(&mut a, "page_number", json!({ "doc": doc, "from": 2, "to": 3, "prefix": "B-", "start": 5 }));
|
||
assert_eq!(r["labels"], json!(["I", "B-5", "B-6"]));
|
||
assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 2, "to": 4 })), Err(ToolError::InvalidArgs(_))));
|
||
assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn comments_through_tools() {
|
||
let dir = workdir("comments");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
// "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page.
|
||
let hl = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" }));
|
||
assert_eq!(hl["comment"]["lines"], 1);
|
||
let id = hl["comment"]["id"].as_str().unwrap().to_string();
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" }));
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 }));
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 }));
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] }));
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] }));
|
||
ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" }));
|
||
ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" }));
|
||
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
assert_eq!(list["count"], 6, "{list}");
|
||
let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id.as_str()).unwrap().clone();
|
||
assert_eq!((c["type"].as_str(), c["author"].as_str(), c["contents"].as_str()), (Some("Highlight"), Some("Ada"), Some("check")));
|
||
assert_eq!(c["status"], "Accepted");
|
||
assert_eq!(c["replies"].as_array().unwrap().len(), 1);
|
||
assert_eq!(c["replies"][0]["contents"], "Looks right");
|
||
// The highlight covers the found text, reported in top-left-origin points.
|
||
let r: Vec<f64> = c["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||
assert!(r[0] >= 15.0 && r[0] <= 25.0 && r[1] > 120.0 && r[3] < 160.0, "{r:?}");
|
||
let rect = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Square").unwrap().clone();
|
||
assert_eq!(rect["color"], "#0078D6");
|
||
assert_eq!(rect["rect"], json!([10.0, 10.0, 60.0, 40.0]));
|
||
let note = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Text").unwrap().clone();
|
||
assert_eq!(note["rect"], json!([150.0, 20.0, 170.0, 40.0]), "note icon hangs from its top-left point");
|
||
|
||
// Edit by page + index; several changes are one undo step.
|
||
let (page, index) = (rect["page"].as_u64().unwrap(), rect["index"].as_u64().unwrap());
|
||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" }));
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||
let rect = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Square").unwrap().clone();
|
||
assert_eq!((rect["color"].as_str(), rect["contents"].as_str()), (Some("#FF0000"), Some("moved")));
|
||
assert_eq!(rect["rect"], json!([15.0, 15.0, 65.0, 45.0]));
|
||
let undo = ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||
assert_eq!(undo["undone"], "Edit comment");
|
||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||
|
||
// Editing a text box re-fits its rectangle to the new text: the wrap width and top edge
|
||
// stay, the height follows the wrapped lines.
|
||
let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
|
||
let long = "the quick brown fox jumps over the lazy dog ".repeat(3);
|
||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() }));
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||
let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
|
||
let r: Vec<f64> = tb["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||
assert_eq!((r[0], r[1], r[2] - r[0]), (10.0, 200.0, 180.0), "width and top edge stay: {r:?}");
|
||
assert!(r[3] - r[1] > 40.0, "the box grew to fit the wrapped lines: {r:?}");
|
||
|
||
// The rendered page shows the rectangle's border.
|
||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||
assert!(matches!(png[0], Content::Png { .. }));
|
||
|
||
ok(&mut a, "comment_delete", json!({ "doc": doc, "id": id }));
|
||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 5);
|
||
assert!(matches!(a.call("comment_delete", &json!({ "doc": doc, "id": id })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "highlight", "find": "nowhere" })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle" })), Err(ToolError::InvalidArgs(_))));
|
||
assert!(matches!(
|
||
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "note", "at": [1, 1], "color": "mauve" })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1 })), Err(ToolError::InvalidArgs(_))));
|
||
|
||
// Comments survive a save and reopen.
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" }));
|
||
let mut b = auto(&dir);
|
||
let re = ok(&mut b, "doc_open", json!({ "path": "commented.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5);
|
||
}
|
||
|
||
#[test]
|
||
fn protecting_through_tools() {
|
||
let dir = workdir("protect");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["security"]["protected"], false);
|
||
assert!(matches!(a.call("doc_protect", &json!({ "doc": doc })), Err(ToolError::Failed(_))), "a password is required");
|
||
let r = ok(
|
||
&mut a,
|
||
"doc_protect",
|
||
json!({ "doc": doc, "open_password": "open", "permissions_password": "boss", "printing": "low", "changes": "comment-fill-sign" }),
|
||
);
|
||
assert_eq!(r["security"]["pending"], true);
|
||
assert_eq!(
|
||
(r["security"]["printing"].as_str(), r["security"]["annotate"].as_bool(), r["security"]["copy"].as_bool()),
|
||
(Some("low"), Some(true), Some(false))
|
||
);
|
||
assert!(!r.to_string().contains("boss"), "passwords are never echoed");
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" }));
|
||
// Another session: the open password is required, and the restrictions hold.
|
||
let mut b = auto(&dir);
|
||
assert!(matches!(b.call("doc_open", &json!({ "path": "locked.pdf" })), Err(ToolError::Failed(_))));
|
||
let re = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap();
|
||
assert!(matches!(b.call("page_delete", &json!({ "doc": re, "pages": [1] })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(b.call("doc_unprotect", &json!({ "doc": re })), Err(ToolError::Failed(_))));
|
||
ok(&mut b, "comment_add", json!({ "doc": re, "page": 1, "type": "note", "at": [10, 10], "contents": "allowed" }));
|
||
// With the permissions password everything is possible, including removing security.
|
||
let owner = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "boss" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut b, "doc_unprotect", json!({ "doc": owner }))["security"]["protected"], false);
|
||
ok(&mut b, "doc_save", json!({ "doc": owner, "path": "open.pdf" }));
|
||
let mut c = auto(&dir);
|
||
ok(&mut c, "doc_open", json!({ "path": "open.pdf" }));
|
||
}
|
||
|
||
#[test]
|
||
fn forms_through_tools() {
|
||
let dir = workdir("forms");
|
||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../dist/demo/pdfcraft-showcase.pdf");
|
||
if !src.exists() {
|
||
eprintln!("skipped: run `cargo xtask demo-pdf` for the showcase form");
|
||
return;
|
||
}
|
||
std::fs::copy(&src, dir.join("show.pdf")).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "show.pdf" }))["doc"].as_u64().unwrap();
|
||
let list = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
let fields = list["fields"].as_array().unwrap().clone();
|
||
assert!(fields.len() >= 5, "{list}");
|
||
let pick = |t: &str| fields.iter().find(|f| f["type"] == t && f["read_only"] == false).cloned();
|
||
let text = pick("text").expect("a text field");
|
||
let mut values = serde_json::Map::new();
|
||
values.insert(text["name"].as_str().unwrap().into(), json!("Filled by an agent"));
|
||
if let Some(cb) = pick("checkbox") {
|
||
values.insert(cb["name"].as_str().unwrap().into(), json!(true));
|
||
}
|
||
if let Some(combo) = pick("combo") {
|
||
values.insert(combo["name"].as_str().unwrap().into(), combo["options"][1]["label"].clone());
|
||
}
|
||
let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": values }));
|
||
assert_eq!(r["undo"], "Fill in form");
|
||
let after = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
let get = |name: &str| after["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).unwrap()["value"].clone();
|
||
assert_eq!(get(text["name"].as_str().unwrap()), "Filled by an agent");
|
||
// The page shows it.
|
||
let page = text["page"].as_u64().unwrap();
|
||
let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "Filled by an agent" }));
|
||
assert_eq!(found["count"], 1, "rendered on page {page}");
|
||
assert!(matches!(a.call("form_fill", &json!({ "doc": doc, "values": { "no such field": "x" } })), Err(ToolError::Failed(_))));
|
||
ok(&mut a, "form_reset", json!({ "doc": doc }));
|
||
let reset = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
let v = reset["fields"].as_array().unwrap().iter().find(|f| f["name"] == text["name"]).unwrap()["value"].clone();
|
||
assert_ne!(v, "Filled by an agent");
|
||
}
|
||
|
||
#[test]
|
||
fn duplicating_and_cropping_through_tools() {
|
||
let dir = workdir("boxes");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "page_duplicate", json!({ "doc": doc, "pages": [2] }));
|
||
assert_eq!(r["pages"], 4);
|
||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 2", "Page 3"]);
|
||
// Crop page 1 by margins, page 2 to a rect drawn from the top-left.
|
||
let r = ok(&mut a, "page_set_box", json!({ "doc": doc, "pages": [1], "margins": [10, 20, 30, 40] }));
|
||
assert_eq!(r["page_sizes"][0], json!([160.0, 240.0]));
|
||
let r = ok(&mut a, "page_set_box", json!({ "doc": doc, "pages": [2], "rect": [0, 0, 100, 150] }));
|
||
assert_eq!(r["page_sizes"][1], json!([100.0, 150.0]));
|
||
assert_eq!(page_text(&mut a, doc)[1], "Page 2", "the text at y = 150 is still inside");
|
||
// Reset and errors.
|
||
let r = ok(&mut a, "page_set_box", json!({ "doc": doc, "pages": [1] }));
|
||
assert_eq!(r["page_sizes"][0], json!([200.0, 300.0]));
|
||
assert!(matches!(a.call("page_set_box", &json!({ "doc": doc, "margins": [150, 0, 150, 0] })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(a.call("page_set_box", &json!({ "doc": doc, "rect": [0, 0, 10, 10] })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn headers_watermarks_and_backgrounds_through_tools() {
|
||
let dir = workdir("marks");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "doc_header_footer", json!({ "doc": doc, "footer_center": "<<Page 1 of n>>", "header_right": "ACME" }));
|
||
let texts = page_text(&mut a, doc);
|
||
assert!(texts[2].contains("Page 3 of 3") && texts[0].contains("ACME"), "{texts:?}");
|
||
ok(&mut a, "doc_watermark", json!({ "doc": doc, "pages": [1], "text": "DRAFT", "opacity": 0.2 }));
|
||
assert!(page_text(&mut a, doc)[0].contains("DRAFT"));
|
||
ok(&mut a, "doc_background", json!({ "doc": doc, "color": "yellow" }));
|
||
ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "watermark" }));
|
||
assert!(!page_text(&mut a, doc)[0].contains("DRAFT"));
|
||
assert!(matches!(a.call("doc_remove_marks", &json!({ "doc": doc, "kind": "watermark" })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(a.call("doc_header_footer", &json!({ "doc": doc })), Err(ToolError::Failed(_))), "no text");
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" }));
|
||
}
|
||
|
||
#[test]
|
||
fn exporting_images_and_text_through_tools() {
|
||
let dir = workdir("export");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "out", "dpi": 72, "pages": [1, 3] }));
|
||
assert_eq!(r["count"], 2);
|
||
assert!(dir.join("out/a_page_3.png").exists());
|
||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "out", "dpi": 72, "pages": [2], "format": "jpeg", "quality": 70 }));
|
||
assert!(std::fs::read(dir.join("out/a_page_2.jpg")).unwrap().starts_with(&[0xFF, 0xD8]));
|
||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "out", "dpi": 72, "pages": [2], "format": "tiff" }));
|
||
let tif = std::fs::read(dir.join("out/a_page_2.tif")).unwrap();
|
||
assert!(tif.starts_with(b"II*\0") || tif.starts_with(b"MM\0*"));
|
||
assert!(matches!(a.call("doc_export_images", &json!({ "doc": doc, "folder": "out", "format": "webp" })), Err(ToolError::InvalidArgs(_))));
|
||
ok(&mut a, "doc_export_text", json!({ "doc": doc, "path": "a.txt" }));
|
||
assert_eq!(std::fs::read_to_string(dir.join("a.txt")).unwrap(), "Page 1\n\u{c}Page 2\n\u{c}Page 3\n");
|
||
assert!(a.call("doc_export_text", &json!({ "doc": doc, "path": "/etc/x.txt" })).is_err(), "confined to the root");
|
||
}
|
||
|
||
#[test]
|
||
fn accessibility_check_report_and_fixes_through_tools() {
|
||
let dir = workdir("a11y");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "accessibility_check", json!({ "doc": doc }));
|
||
assert_eq!(r["results"].as_array().unwrap().len(), 32);
|
||
let status =
|
||
|r: &Value, id: &str| r["results"].as_array().unwrap().iter().find(|x| x["rule"] == id).unwrap()["status"].as_str().unwrap().to_owned();
|
||
for id in ["tagged-pdf", "primary-language", "title", "tagged-content"] {
|
||
assert_eq!(status(&r, id), "failed", "{id}");
|
||
}
|
||
assert_eq!((status(&r, "color-contrast"), status(&r, "scripts")), ("skipped".into(), "manual".into()));
|
||
assert_eq!(status(&ok(&mut a, "accessibility_check", json!({ "doc": doc, "all": true })), "color-contrast"), "manual");
|
||
let docs = ok(&mut a, "accessibility_check", json!({ "doc": doc, "categories": ["document"] }));
|
||
assert_eq!(docs["skipped"], 25, "24 other rules and colour contrast");
|
||
// Fixes.
|
||
assert!(matches!(a.call("accessibility_fix", &json!({ "doc": doc, "rule": "primary-language" })), Err(ToolError::Failed(_))), "needs a language");
|
||
assert_eq!(ok(&mut a, "accessibility_fix", json!({ "doc": doc, "rule": "primary-language", "value": "en-GB" }))["status"], "passed");
|
||
assert_eq!(ok(&mut a, "accessibility_fix", json!({ "doc": doc, "rule": "title" }))["status"], "passed");
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["title"], "a");
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Set document title");
|
||
assert!(matches!(a.call("accessibility_fix", &json!({ "doc": doc, "rule": "tagged-pdf" })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(a.call("accessibility_check", &json!({ "doc": doc, "rules": ["nope"] })), Err(ToolError::InvalidArgs(_))));
|
||
// The report.
|
||
let r = ok(&mut a, "accessibility_report", json!({ "doc": doc, "path": "report.html" }));
|
||
assert_eq!(r["failed"].as_u64(), Some(3), "tagging, tagged content and (undone) title");
|
||
let html = std::fs::read_to_string(dir.join("report.html")).unwrap();
|
||
assert!(html.contains("Accessibility Report") && html.contains("a.pdf"));
|
||
// Figures: list, describe, mark decorative.
|
||
std::fs::write(
|
||
dir.join("figures.pdf"),
|
||
b"%PDF-1.7
|
||
1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj
|
||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 >> endobj
|
||
4 0 obj << /Length 74 >> stream
|
||
/Figure << /MCID 0 >> BDC 10 150 40 20 re f EMC /Figure << /MCID 1 >> BDC 100 100 50 30 re f EMC
|
||
endstream endobj
|
||
5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 9 0 R >> endobj
|
||
6 0 obj << /S /Document /P 5 0 R /K [7 0 R 8 0 R] >> endobj
|
||
7 0 obj << /S /Figure /P 6 0 R /Pg 3 0 R /K 0 >> endobj
|
||
8 0 obj << /S /Figure /P 6 0 R /Pg 3 0 R /K 1 >> endobj
|
||
9 0 obj << /Nums [0 [7 0 R 8 0 R]] >> endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF"
|
||
.as_slice(),
|
||
)
|
||
.unwrap();
|
||
let fd = ok(&mut a, "doc_open", json!({ "path": "figures.pdf" }))["doc"].as_u64().unwrap();
|
||
let figs = ok(&mut a, "accessibility_figures", json!({ "doc": fd }));
|
||
assert_eq!(figs["count"], 2);
|
||
assert_eq!(figs["figures"][0]["rect"], json!([10.0, 30.0, 50.0, 50.0]), "top-left-origin points");
|
||
let first = figs["figures"][0]["figure"].as_u64().unwrap();
|
||
let second = figs["figures"][1]["figure"].as_u64().unwrap();
|
||
let r = ok(&mut a, "accessibility_set_alt", json!({ "doc": fd, "figure": first, "alt": "A small bar" }));
|
||
assert_eq!(r["figures"][0]["alt"], "A small bar");
|
||
let r = ok(&mut a, "accessibility_set_alt", json!({ "doc": fd, "figure": second, "decorative": true }));
|
||
assert_eq!(r["count"], 1);
|
||
let check = ok(&mut a, "accessibility_check", json!({ "doc": fd, "rules": ["figures-alt-text", "tagged-content"] }));
|
||
assert_eq!(check["failed"], 0, "{check}");
|
||
assert!(matches!(a.call("accessibility_set_alt", &json!({ "doc": fd, "figure": 6, "alt": "x" })), Err(ToolError::Failed(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn editing_existing_text_through_tools() {
|
||
let dir = workdir("edit-text");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 2 }));
|
||
assert_eq!((lines["count"].as_u64(), lines["lines"][0]["text"].as_str()), (Some(1), Some("Page 2")));
|
||
let r = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 2, "line": 1, "text": "Section two" }));
|
||
assert_eq!(r["line"]["text"], "Section two");
|
||
assert_eq!(page_text(&mut a, doc)[1], "Section two");
|
||
assert!(matches!(a.call("text_edit", &json!({ "doc": doc, "page": 2, "line": 9, "text": "x" })), Err(ToolError::InvalidArgs(_))));
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text");
|
||
let paras = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 3 }));
|
||
assert_eq!(paras["paragraphs"][0]["text"], "Page 3");
|
||
let r = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 3, "paragraph": 1, "text": "Part three" }));
|
||
assert_eq!(r["paragraph"]["text"], "Part three");
|
||
assert!(matches!(a.call("text_edit", &json!({ "doc": doc, "page": 3, "paragraph": 4, "text": "x" })), Err(ToolError::InvalidArgs(_))));
|
||
// Formatting only: font, size, colour, alignment.
|
||
ok(
|
||
&mut a,
|
||
"text_edit",
|
||
json!({ "doc": doc, "page": 3, "paragraph": 1, "font": "times", "bold": true, "size": 20, "color": "#cc0000", "align": "center" }),
|
||
);
|
||
let p = &ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 3 }))["paragraphs"][0];
|
||
assert_eq!((p["text"].as_str(), p["font"].as_str(), p["size"].as_f64()), (Some("Part three"), Some("Times-Bold"), Some(20.0)));
|
||
// Moved 15 pt right and 10 pt down (dy is up; the listing's rect is measured from the top).
|
||
let rect = |p: &serde_json::Value| -> Vec<f64> { p["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect() };
|
||
let before = rect(p);
|
||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 3, "paragraph": 1, "dx": 15, "dy": -10 }));
|
||
let p = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 3 }))["paragraphs"][0].clone();
|
||
let after = rect(&p);
|
||
assert!((after[0] - before[0] - 15.0).abs() < 0.5 && (after[1] - before[1] - 10.0).abs() < 0.5, "{before:?} → {after:?}");
|
||
assert_eq!(p["text"], "Part three");
|
||
// A narrow width rewraps "Part three" onto two lines.
|
||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 3, "paragraph": 1, "width": 50 }));
|
||
let p = &ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 3 }))["paragraphs"][0];
|
||
assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}");
|
||
}
|
||
|
||
#[test]
|
||
fn editing_page_images_through_tools() {
|
||
let dir = workdir("page-images");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1, 2] }));
|
||
let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap();
|
||
let list = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }));
|
||
assert_eq!(list["count"], 1);
|
||
let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "move", "rect": [5, 5, 25, 35] }));
|
||
assert_eq!(r["undo"], "Move image");
|
||
let moved = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone();
|
||
assert_eq!(moved, json!([5.0, 5.0, 25.0, 35.0]));
|
||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" }));
|
||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" }));
|
||
let saved = ok(&mut a, "image_save", json!({ "doc": made, "page": 1, "image": 1, "path": "out/picture" }));
|
||
assert_eq!(saved["format"], "png");
|
||
assert!(std::fs::read(dir.join("out/picture.png")).unwrap().starts_with(b"\x89PNG"));
|
||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "replace", "path": "src/a_page_2.png" }));
|
||
assert_eq!(ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["count"], 1);
|
||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "delete" }));
|
||
assert_eq!(ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["count"], 0);
|
||
assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "delete" })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn auditing_space_through_tools() {
|
||
let dir = workdir("audit");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "doc_audit_space", json!({ "doc": doc }));
|
||
let rows = r["categories"].as_array().unwrap();
|
||
let total: f64 = rows.iter().map(|x| x["percent"].as_f64().unwrap()).sum();
|
||
assert!((total - 100.0).abs() < 0.1, "{r}");
|
||
let content = rows.iter().find(|x| x["category"] == "Content Streams").unwrap();
|
||
assert!(content["bytes"].as_u64().unwrap() > 0, "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn exporting_all_images_through_tools() {
|
||
let dir = workdir("export-all-images");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": "none" }))["count"], 0, "text only");
|
||
// A PDF made from two page renders (PNG, JPEG) holds two images.
|
||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 36, "pages": [1] }));
|
||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 36, "pages": [2], "format": "jpeg" }));
|
||
let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png", "src/a_page_2.jpg"] }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "doc_export_all_images", json!({ "doc": made, "folder": "imgs" }));
|
||
assert_eq!(r["count"], 2, "{r}");
|
||
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect();
|
||
assert!(files[0].ends_with("_Page_1_Image_0001.png") && files[1].ends_with("_Page_2_Image_0002.jpg"), "{files:?}");
|
||
assert_eq!(std::fs::read(files[1]).unwrap(), std::fs::read(dir.join("src/a_page_2.jpg")).unwrap(), "JPEG unchanged");
|
||
assert!(std::fs::read(files[0]).unwrap().starts_with(b"\x89PNG"));
|
||
assert_eq!(ok(&mut a, "doc_export_all_images", json!({ "doc": made, "folder": "imgs2", "pages": [2] }))["count"], 1);
|
||
assert_eq!(ok(&mut a, "doc_export_all_images", json!({ "doc": made, "folder": "imgs3", "min_size": 10000 }))["count"], 0);
|
||
}
|
||
|
||
#[test]
|
||
fn fill_and_sign_through_tools() {
|
||
let dir = workdir("fill");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": "Ada Lovelace" }));
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "check", "at": [20, 80] }));
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 100] }));
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 150], "text": "Ada Lovelace" }));
|
||
assert!(matches!(
|
||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "initials", "at": [20, 190], "text": " " })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
let texts = page_text(&mut a, doc);
|
||
assert!(texts[0].contains("Ada Lovelace") && texts[0].contains("11/14/2023"), "{texts:?}");
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
assert_eq!(list["count"], 4);
|
||
assert!(matches!(a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [1, 1] })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn creating_and_reducing_through_tools() {
|
||
let dir = workdir("create");
|
||
std::fs::write(dir.join("notes.txt"), "Meeting notes\nAction items").unwrap();
|
||
let mut a = auto(&dir);
|
||
let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "pages": 2 }));
|
||
assert_eq!((blank["pages"].as_u64(), blank["dirty"].as_bool()), (Some(2), Some(true)));
|
||
let t = ok(&mut a, "doc_create", json!({ "from": "text", "path": "notes.txt" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(page_text(&mut a, t), ["Meeting notes\nAction items"]);
|
||
ok(&mut a, "doc_save", json!({ "doc": t, "path": "notes.pdf" }));
|
||
let r = ok(&mut a, "doc_reduce", json!({ "doc": t, "path": "notes-small.pdf" }));
|
||
assert!(r["bytes_after"].as_u64().unwrap() > 0 && dir.join("notes-small.pdf").exists());
|
||
assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn flattening_through_tools() {
|
||
let dir = workdir("flatten");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Approved" }));
|
||
ok(&mut a, "doc_flatten", json!({ "doc": doc }));
|
||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0);
|
||
assert!(page_text(&mut a, doc)[0].contains("Approved"), "the text box is now page text");
|
||
assert!(matches!(a.call("doc_flatten", &json!({ "doc": doc, "comments": false, "fields": false })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn replacing_pages_through_tools() {
|
||
let dir = workdir("replace");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "page_replace", json!({ "doc": doc, "pages": [2, 3], "path": "b.pdf", "from_pages": [2, 1] }));
|
||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 1"]);
|
||
assert!(
|
||
matches!(a.call("page_replace", &json!({ "doc": doc, "pages": [1, 2, 3], "path": "b.pdf" })), Err(ToolError::Failed(_))),
|
||
"b.pdf has only 2 pages"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn preparing_a_form_through_tools() {
|
||
let dir = workdir("prepare");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let add = |a: &mut Automation, args: Value| ok(a, "form_add_field", args)["field"].as_str().unwrap().to_owned();
|
||
assert_eq!(add(&mut a, json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42] })), "Text1");
|
||
assert_eq!(add(&mut a, json!({ "doc": doc, "page": 1, "type": "combo", "rect": [20, 60, 180, 82], "options": ["Red", "Green"] })), "Dropdown1");
|
||
assert_eq!(add(&mut a, json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" })), "size");
|
||
assert_eq!(add(&mut a, json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "size", "export": "L" })), "size");
|
||
let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "Text1", "name": "full name", "required": true, "tooltip": "Your name" }));
|
||
assert_eq!(r["field"], "full name");
|
||
ok(&mut a, "form_delete_field", json!({ "doc": doc, "field": "Dropdown1" }));
|
||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "full name", "rect": [30, 20, 190, 42] }));
|
||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
let f = fields["fields"].as_array().unwrap();
|
||
assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::<Vec<_>>(), ["full name", "size"]);
|
||
assert_eq!((f[0]["required"].as_bool(), f[0]["tooltip"].as_str()), (Some(true), Some("Your name")));
|
||
assert_eq!(f[0]["rect"], json!([30.0, 20.0, 190.0, 42.0]), "moved; the rect round-trips in view coordinates");
|
||
assert_eq!(f[1]["options"], json!(["S", "L"]));
|
||
// #94: the mark a check box or radio button shows.
|
||
assert_eq!(f[1]["check_style"], "circle", "radio buttons default to a circle");
|
||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "size", "check_style": "star" }));
|
||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][1]["check_style"], "star");
|
||
assert!(matches!(a.call("form_set_props", &json!({ "doc": doc, "field": "size", "check_style": "heart" })), Err(ToolError::InvalidArgs(_))));
|
||
assert!(matches!(a.call("form_set_props", &json!({ "doc": doc, "field": "full name", "check_style": "star" })), Err(ToolError::Failed(_))));
|
||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "full name": "Ada", "size": "L" } }));
|
||
assert!(page_text(&mut a, doc)[0].contains("Ada"));
|
||
// Options tab: alignment, default, flags (comb needs a limit).
|
||
assert!(matches!(a.call("form_set_props", &json!({ "doc": doc, "field": "full name", "flags": { "comb": true } })), Err(ToolError::Failed(_))));
|
||
ok(
|
||
&mut a,
|
||
"form_set_props",
|
||
json!({ "doc": doc, "field": "full name", "align": "center", "default": "Anon", "max_length": 8, "flags": { "comb": true, "spell_check": false } }),
|
||
);
|
||
assert!(matches!(
|
||
a.call("form_set_props", &json!({ "doc": doc, "field": "full name", "flags": { "sparkles": true } })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
assert!(matches!(
|
||
a.call("form_add_field", &json!({ "doc": doc, "page": 1, "type": "slider", "rect": [0, 0, 9, 9] })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
assert!(matches!(a.call("form_delete_field", &json!({ "doc": doc, "field": "nope" })), Err(ToolError::Failed(_))));
|
||
// An image field shows the picture it is given.
|
||
assert_eq!(add(&mut a, json!({ "doc": doc, "page": 2, "type": "image", "rect": [20, 20, 120, 120] })), "Image1");
|
||
let png = |a: &mut Automation| match a.call("page_render", &json!({ "doc": doc, "page": 2, "dpi": 36 })).unwrap().remove(0) {
|
||
Content::Png { data, .. } => data,
|
||
other => panic!("{other:?}"),
|
||
};
|
||
let before = png(&mut a);
|
||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "pics", "dpi": 18, "pages": [1] }));
|
||
ok(&mut a, "form_set_image", json!({ "doc": doc, "field": "Image1", "path": "pics/a_page_1.png" }));
|
||
let after = png(&mut a);
|
||
assert_ne!(before, after, "the picture is drawn");
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Set the image of Image1");
|
||
assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn redacting_through_tools() {
|
||
let dir = workdir("redact");
|
||
std::fs::write(dir.join("memo.txt"), "Call 555-123-4567 today\nSSN 123-45-6789 is private\nPublic line").unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "phone" }));
|
||
assert_eq!((r["marked"].as_u64(), r["marks_pending"].as_u64()), (Some(1), Some(1)));
|
||
ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "ssn", "overlay": "SSN" }));
|
||
ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "private" }));
|
||
assert_eq!(page_text(&mut a, doc)[0].matches("555-123-4567").count(), 1, "marks alone remove nothing");
|
||
let r = ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||
assert_eq!((r["applied"].as_u64(), r["marks_pending"].as_u64()), (Some(3), Some(0)));
|
||
let text = page_text(&mut a, doc)[0].clone();
|
||
assert!(!text.contains("555") && !text.contains("6789") && !text.contains("private"), "{text}");
|
||
assert!(text.contains("Call") && text.contains("today") && text.contains("Public line"), "{text}");
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "memo.pdf" }));
|
||
let bytes = std::fs::read(dir.join("memo.pdf")).unwrap();
|
||
assert!(!bytes.windows(4).any(|w| w == b"4567"), "the saved file has no trace of the number");
|
||
assert!(matches!(a.call("redact_apply", &json!({ "doc": doc })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc, "find": "nowhere to be found" })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn removing_hidden_information_through_tools() {
|
||
let dir = workdir("hidden");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "internal note" }));
|
||
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Secret plan" }));
|
||
let info = ok(&mut a, "doc_hidden_info", json!({ "doc": doc }));
|
||
let count = |v: &Value, c: &str| v["categories"].as_array().unwrap().iter().find(|x| x["category"] == c).unwrap()["count"].as_u64().unwrap();
|
||
assert!(count(&info, "comments") >= 1 && count(&info, "metadata") >= 1, "{info}");
|
||
let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc, "categories": ["comments"] }));
|
||
assert_eq!(r["undo"], "Remove hidden information");
|
||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0);
|
||
assert!(count(&ok(&mut a, "doc_hidden_info", json!({ "doc": doc })), "metadata") >= 1, "only comments went");
|
||
let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc }));
|
||
assert_eq!(r["undo"], "Sanitize document");
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "clean.pdf" }));
|
||
let bytes = std::fs::read(dir.join("clean.pdf")).unwrap();
|
||
assert!(!bytes.windows(11).any(|w| w == b"Secret plan"), "the old revision is gone");
|
||
assert!(matches!(a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["nonsense"] })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn printing_through_tools() {
|
||
let dir = workdir("print");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
assert!(ok(&mut a, "printers", json!({}))["printers"].is_array());
|
||
let n = ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["pages"].as_u64().unwrap();
|
||
let r = ok(&mut a, "doc_print", json!({ "doc": doc, "layout": "multiple", "per_sheet": 4, "path": "sheets.pdf" }));
|
||
assert_eq!(r["sheets"].as_u64(), Some(n.div_ceil(4)));
|
||
let printed = ok(&mut a, "doc_open", json!({ "path": "sheets.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": printed }))["document"]["pages"].as_u64(), Some(n.div_ceil(4)));
|
||
let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "layout": "poster", "scale": 400, "path": "poster.pdf" }));
|
||
assert!(r["sheets"].as_u64().unwrap() > 1);
|
||
assert!(matches!(a.call("doc_print", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_))));
|
||
assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn adding_content_through_tools() {
|
||
let dir = workdir("content");
|
||
let mut png = Vec::new();
|
||
{
|
||
let mut enc = png::Encoder::new(&mut png, 40, 20);
|
||
enc.set_color(png::ColorType::Rgb);
|
||
let mut w = enc.write_header().unwrap();
|
||
w.write_image_data(&[200u8; 2400]).unwrap();
|
||
}
|
||
std::fs::write(dir.join("logo.png"), png).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "CONFIDENTIAL", "at": [20, 20], "size": 18, "bold": true, "color": "red" }));
|
||
let r = ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "logo.png", "rect": [100, 200, 180, 240] }));
|
||
assert_eq!(r["rect"], json!([100.0, 200.0, 180.0, 240.0]));
|
||
assert!(page_text(&mut a, doc)[0].contains("CONFIDENTIAL"));
|
||
let list = ok(&mut a, "content_list", json!({ "doc": doc }));
|
||
assert_eq!(list["count"], 2);
|
||
assert_eq!((list["items"][0]["type"].as_str(), list["items"][0]["bold"].as_bool()), (Some("text"), Some(true)));
|
||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "text": "DRAFT", "font": "times" }));
|
||
let text = page_text(&mut a, doc)[0].clone();
|
||
assert!(text.contains("DRAFT") && !text.contains("CONFIDENTIAL"), "{text}");
|
||
// Image tools: rotate, flip, crop, replace.
|
||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true, "crop": [0.1, 0, 0.1, 0] }));
|
||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "image": "logo.png" }));
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], true);
|
||
assert!(matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "rotate": 45 })), Err(ToolError::InvalidArgs(_))));
|
||
assert!(
|
||
matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 1, "rotate": 90 })), Err(ToolError::InvalidArgs(_))),
|
||
"text doesn't rotate"
|
||
);
|
||
ok(&mut a, "content_delete", json!({ "doc": doc, "page": 1, "index": 2 }));
|
||
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1);
|
||
assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn form_formats_and_calculations_through_tools() {
|
||
let dir = workdir("formcalc");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
for (name, y) in [("Price", 20), ("Qty", 60), ("Total", 100)] {
|
||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
|
||
}
|
||
ok(
|
||
&mut a,
|
||
"form_set_props",
|
||
json!({ "doc": doc, "field": "Price", "format": { "type": "number", "decimals": 2, "currency": "$" }, "validate": { "min": 0 } }),
|
||
);
|
||
ok(
|
||
&mut a,
|
||
"form_set_props",
|
||
json!({ "doc": doc, "field": "Total", "format": { "type": "number", "decimals": 2, "currency": "$" }, "calculate": { "notation": "Price * Qty" } }),
|
||
);
|
||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Price": "19.99", "Qty": "3" } }));
|
||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone();
|
||
assert_eq!((total["value"].as_str(), total["display"].as_str()), (Some("59.97"), Some("$59.97")), "{total}");
|
||
assert_eq!(total["calculate"]["notation"], "Price * Qty");
|
||
assert!(page_text(&mut a, doc)[0].contains("$59.97"));
|
||
let err = a.call("form_fill", &json!({ "doc": doc, "values": { "Price": "-5" } })).unwrap_err();
|
||
assert!(err.to_string().contains("greater than or equal to 0"), "{err}");
|
||
assert!(matches!(
|
||
a.call("form_set_props", &json!({ "doc": doc, "field": "Qty", "format": { "type": "roman" } })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn tab_order_and_field_appearance_through_tools() {
|
||
let dir = workdir("taborder");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
// Added out of reading order: B (top right), A (top left), C (below).
|
||
for (name, x, y) in [("B", 110, 20), ("A", 10, 20), ("C", 10, 60)] {
|
||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [x, y, x + 80, y + 20], "name": name }));
|
||
}
|
||
let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "row" }));
|
||
assert_eq!(r["tab_order"], json!(["A", "B", "C"]));
|
||
let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "column" }));
|
||
assert_eq!(r["tab_order"], json!(["A", "C", "B"]));
|
||
let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "field": "B", "move": "earlier" }));
|
||
assert_eq!(r["tab_order"], json!(["A", "B", "C"]), "ordered manually");
|
||
let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "column" }));
|
||
assert_eq!(r["tab_order"], json!(["A", "C", "B"]));
|
||
ok(
|
||
&mut a,
|
||
"form_set_props",
|
||
json!({ "doc": doc, "field": "A", "appearance": { "border": "red", "fill": "none", "style": "underline", "font": "courier" } }),
|
||
);
|
||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "A": "typed" } }));
|
||
assert!(page_text(&mut a, doc)[0].contains("typed"));
|
||
assert!(matches!(
|
||
a.call("form_set_props", &json!({ "doc": doc, "field": "A", "appearance": { "style": "wavy" } })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn comments_and_form_data_travel_as_xfdf_fdf_and_text() {
|
||
let dir = workdir("xfdf");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [30, 30], "contents": "Please review", "author": "Ada" }));
|
||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "City" }));
|
||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "City": "Paris" } }));
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "reviewed.pdf" }));
|
||
ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "all.xfdf" }));
|
||
ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.txt", "what": "fields" }));
|
||
assert_eq!(std::fs::read_to_string(dir.join("data.txt")).unwrap(), "City\nParis\n");
|
||
assert!(matches!(a.call("doc_export_data", &json!({ "doc": doc, "path": "c.csv", "what": "comments" })), Err(ToolError::InvalidArgs(_))));
|
||
// A copy without the comment and with the field empty takes both back.
|
||
ok(&mut a, "comment_delete", json!({ "doc": doc, "page": 2, "index": 1 }));
|
||
ok(&mut a, "form_reset", json!({ "doc": doc }));
|
||
let r = ok(&mut a, "doc_import_data", json!({ "doc": doc, "path": "all.xfdf" }));
|
||
assert_eq!(r["filled_fields"], 1);
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
assert_eq!(list["comments"][0]["contents"], "Please review", "{list}");
|
||
assert_eq!(r["undo"], "Import all.xfdf");
|
||
}
|
||
|
||
#[test]
|
||
fn stamps_through_tools() {
|
||
let dir = workdir("stamps");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "stamp", "stamp": "approved", "at": [100, 100] }));
|
||
ok(
|
||
&mut a,
|
||
"comment_add",
|
||
json!({ "doc": doc, "page": 1, "type": "stamp", "stamp": "reviewed", "dynamic": true, "at": [100, 200], "author": "Ada" }),
|
||
);
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "stamp", "stamp": "sign here", "at": [100, 250] }));
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
assert_eq!(list["count"], 3, "{list}");
|
||
let text = page_text(&mut a, doc)[0].clone();
|
||
assert!(text.contains("APPROVED") && text.contains("REVIEWED") && text.contains("By Ada at") && text.contains("SIGN HERE"), "{text}");
|
||
assert!(matches!(
|
||
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "stamp", "stamp": "nonsense", "at": [1, 1] })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
// A custom stamp from another PDF's page.
|
||
ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 2, "path": "b.pdf", "file_page": 2, "at": [100, 150], "name": "Logo" }));
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
assert_eq!(list["count"], 4, "{list}");
|
||
// Its "Page 2" is drawn over the page's own.
|
||
assert_eq!(page_text(&mut a, doc)[1].matches('2').count(), 2);
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add stamp");
|
||
assert!(matches!(a.call("stamp_custom", &json!({ "doc": doc, "page": 1, "path": "nope.png", "at": [1, 1] })), Err(ToolError::Failed(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn organizing_with_filters_bookmark_splits_and_extract_options() {
|
||
let dir = workdir("organize2");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
// Rotate the even page numbers only (a.pdf has 3 pages: page 2 is the only even one).
|
||
let r = ok(&mut a, "page_rotate", json!({ "doc": doc, "degrees": 90, "subset": "even" }));
|
||
assert_eq!(r["rotated"], 1);
|
||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
assert_eq!(info["pages"][1]["rotation"], 90);
|
||
assert_eq!(info["pages"][0]["rotation"], 0);
|
||
// Split at top-level bookmarks: parts named after them.
|
||
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Start", "page": 1 }));
|
||
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "End/Part", "page": 3 }));
|
||
let s = ok(&mut a, "doc_split", json!({ "doc": doc, "bookmarks": true, "out_dir": "parts" }));
|
||
let files: Vec<String> = s["files"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|f| std::path::Path::new(f["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned())
|
||
.collect();
|
||
assert_eq!(files, ["a-Start.pdf", "a-End_Part.pdf"]);
|
||
let s = ok(&mut a, "doc_split", json!({ "doc": doc, "max_mb": 0.0001, "out_dir": "sized" }));
|
||
assert_eq!(s["files"].as_array().unwrap().len(), 3, "tiny limit: a page per file");
|
||
// Extract as separate files and delete them from the original.
|
||
let e = ok(&mut a, "page_extract", json!({ "doc": doc, "pages": [1, 2], "separate": true, "out_dir": "pages", "delete": true }));
|
||
assert_eq!(e["files"].as_array().unwrap().len(), 2);
|
||
assert_eq!(e["original"]["pages"], 1);
|
||
assert!(matches!(a.call("page_extract", &json!({ "doc": doc, "pages": [1], "separate": true })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn links_through_tools() {
|
||
let dir = workdir("links");
|
||
std::fs::write(dir.join("notes.txt"), "Docs at https://example.org/docs and www.rust-lang.org").unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "notes.txt" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "links_from_urls", json!({ "doc": doc }));
|
||
assert_eq!(r["created"], json!(["https://example.org/docs", "http://www.rust-lang.org"]));
|
||
ok(&mut a, "link_add", json!({ "doc": doc, "page": 1, "rect": [72, 300, 200, 320], "to_page": 1, "visible": true, "color": "red" }));
|
||
let list = ok(&mut a, "link_list", json!({ "doc": doc }));
|
||
assert_eq!(list["count"], 3);
|
||
let added = list["links"].as_array().unwrap().iter().find(|l| l["to_page"] == 1).unwrap().clone();
|
||
assert_eq!(added["rect"], json!([72.0, 300.0, 200.0, 320.0]));
|
||
ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": added["index"], "url": "https://pdfcraft.dev" }));
|
||
let list = ok(&mut a, "link_list", json!({ "doc": doc }));
|
||
assert!(list["links"].as_array().unwrap().iter().any(|l| l["url"] == "https://pdfcraft.dev"));
|
||
ok(&mut a, "link_delete", json!({ "doc": doc, "page": 1, "index": added["index"] }));
|
||
let r = ok(&mut a, "links_remove", json!({ "doc": doc }));
|
||
assert_eq!(r["removed"], 2);
|
||
assert_eq!(ok(&mut a, "link_list", json!({ "doc": doc }))["count"], 0);
|
||
assert!(matches!(a.call("link_add", &json!({ "doc": doc, "page": 1, "rect": [0, 0, 50, 20] })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
|
||
let dir = workdir("comment-polish");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let c = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "Sticky", "author": "Ada" }));
|
||
let id = c["comment"]["id"].as_str().unwrap().to_string();
|
||
ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted" }));
|
||
ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id }));
|
||
ok(&mut a, "comment_lock", json!({ "doc": doc, "id": id }));
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
assert_eq!(list["count"], 1, "{list}");
|
||
let c = &list["comments"][0];
|
||
assert_eq!((c["status"].as_str(), c["marked"].as_bool(), c["locked"].as_bool()), (Some("Accepted"), Some(true), Some(true)));
|
||
assert!(matches!(a.call("comment_delete", &json!({ "doc": doc, "id": id })), Err(ToolError::Failed(_))), "locked");
|
||
ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "marked": false }));
|
||
ok(&mut a, "comment_lock", json!({ "doc": doc, "id": id, "locked": false }));
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
assert_eq!((list["comments"][0]["marked"].as_bool(), list["comments"][0]["locked"].as_bool()), (Some(false), Some(false)));
|
||
|
||
assert_eq!(ok(&mut a, "comments_hide", json!({ "doc": doc }))["hidden"], true);
|
||
ok(&mut a, "comments_hide", json!({ "doc": doc, "hidden": false }));
|
||
|
||
let r = ok(&mut a, "comments_summarize", json!({ "doc": doc, "sort": "author", "out": "summary.pdf" }));
|
||
assert!(r["bytes"].as_u64().unwrap() > 500);
|
||
let text = ok(&mut a, "doc_open", json!({ "path": "summary.pdf" }))["doc"].as_u64().unwrap();
|
||
let found = ok(&mut a, "text_find", json!({ "doc": text, "query": "Sticky" }));
|
||
assert!(found["count"].as_u64().unwrap() >= 1, "{found}");
|
||
assert!(matches!(a.call("comments_summarize", &json!({ "doc": doc, "sort": "colour" })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn drawing_comments_through_tools() {
|
||
let dir = workdir("drawing");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
for (ty, extra) in [
|
||
("polygon", json!({ "points": [[20, 20], [80, 20], [50, 70]] })),
|
||
("cloud", json!({ "points": [[100, 20], [180, 20], [180, 80], [100, 80]], "color": "red" })),
|
||
("polyline", json!({ "points": [[20, 120], [60, 100], [100, 120]] })),
|
||
("callout", json!({ "rect": [110, 200, 190, 240], "to": [40, 160], "contents": "Look" })),
|
||
("caret", json!({ "at": [60, 150], "contents": "insert this" })),
|
||
] {
|
||
let mut args = json!({ "doc": doc, "page": 1, "type": ty });
|
||
args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||
ok(&mut a, "comment_add", args);
|
||
}
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
let types: Vec<&str> = list["comments"].as_array().unwrap().iter().map(|c| c["type"].as_str().unwrap()).collect();
|
||
for t in ["Polygon", "PolyLine", "FreeText", "Caret"] {
|
||
assert!(types.contains(&t), "{types:?}");
|
||
}
|
||
assert_eq!(types.iter().filter(|t| **t == "Polygon").count(), 2);
|
||
let callout = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap();
|
||
let r: Vec<f64> = callout["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||
assert!(r[0] < 40.0 && r[3] > 159.0, "the rect holds the leader line: {r:?}");
|
||
assert!(matches!(
|
||
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "polygon", "points": [[1, 1]] })),
|
||
Err(ToolError::Failed(_) | ToolError::InvalidArgs(_))
|
||
));
|
||
// Replace Text: a strikeout over the found text and a grouped caret.
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2", "contents": "second page" }));
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 2 }));
|
||
let strike = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "StrikeOut").unwrap().clone();
|
||
assert_eq!(strike["replies"][0]["contents"], "second page", "the caret threads under the strikeout");
|
||
assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2" })), Err(ToolError::InvalidArgs(_))));
|
||
// A file attached as a comment.
|
||
std::fs::write(dir.join("notes.txt"), b"remember").unwrap();
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "attachment", "path": "notes.txt", "at": [250, 20], "icon": "Paperclip" }));
|
||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
assert!(info.to_string().contains("notes.txt"), "{info}");
|
||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||
assert!(matches!(png[0], Content::Png { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn digital_ids_signing_and_validation_through_tools() {
|
||
let dir = workdir("signing");
|
||
let mut a = auto(&dir);
|
||
let id = ok(
|
||
&mut a,
|
||
"sign_id_create",
|
||
json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret1", "path": "ada.p12" }),
|
||
);
|
||
assert_eq!(id["certificate"]["subject"], "C=GB, O=Analytical Engines, CN=Ada Lovelace, E=ada@example.com");
|
||
assert_eq!(id["certificate"]["self_signed"], true);
|
||
assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_))));
|
||
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0);
|
||
// A Keychain identity that doesn't exist (macOS) or Keychains at all (elsewhere).
|
||
assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "keychain:No Such Signer", "out": "k.pdf" })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(
|
||
a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "wrong!", "out": "signed.pdf" })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
let r = ok(
|
||
&mut a,
|
||
"sign_document",
|
||
json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }),
|
||
);
|
||
assert_eq!(r["signature"]["status"], "unknown");
|
||
assert_eq!(r["signature"]["signer"], "Ada Lovelace");
|
||
assert_eq!(r["signature"]["page"], 2);
|
||
let rect: Vec<f64> = r["signature"]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||
assert!((rect[0] - 20.0).abs() < 0.5 && (rect[3] - 250.0).abs() < 0.5, "{rect:?}");
|
||
assert!(dir.join("signed.pdf").exists());
|
||
|
||
// Trust the ID: valid; comment afterwards: still valid, change allowed.
|
||
let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret1" }));
|
||
assert_eq!(t["trusted"].as_array().unwrap().len(), 1);
|
||
let list = ok(&mut a, "sign_list", json!({ "doc": doc }));
|
||
assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid")));
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" }));
|
||
let s = &ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0];
|
||
assert_eq!((s["status"].as_str(), s["modification"].as_str()), (Some("valid"), Some("allowed")));
|
||
// A full rewrite is refused for a signed document.
|
||
assert!(matches!(a.call("doc_save", &json!({ "doc": doc, "path": "copy.pdf", "full": true })), Err(ToolError::Failed(_))));
|
||
ok(&mut a, "doc_save", json!({ "doc": doc }));
|
||
// Three revisions: the original, the signature, the comment; the middle one is signed.
|
||
let revs = ok(&mut a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap();
|
||
assert_eq!(revs.len(), 3, "{revs:?}");
|
||
assert!(revs[0]["signed_by"].as_array().unwrap().is_empty() && revs[1]["signed_by"].as_array().unwrap().len() == 1, "{revs:?}");
|
||
let old = ok(&mut a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment");
|
||
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": old }))["count"], 1);
|
||
assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_))));
|
||
assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0);
|
||
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown");
|
||
}
|
||
|
||
#[test]
|
||
fn optimizing_through_tools() {
|
||
let dir = workdir("optimize");
|
||
// A 2400 × 1600 photo-like JPEG at 600 dpi: a 4 × 2.67 inch page.
|
||
let (w, h) = (2400u32, 1600u32);
|
||
let px: Vec<u8> = (0..w * h)
|
||
.flat_map(|i| {
|
||
let (x, y) = (i % w, i / w);
|
||
[(x * 255 / w) as u8 ^ ((x * y) & 15) as u8, (y * 255 / h) as u8, ((x + y) & 255) as u8]
|
||
})
|
||
.collect();
|
||
let mut jpeg = Vec::new();
|
||
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 95);
|
||
enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(600));
|
||
enc.encode(&px, w, h, image::ExtendedColorType::Rgb8).unwrap();
|
||
std::fs::write(dir.join("photo.jpg"), &jpeg).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["photo.jpg"] }))["doc"].as_u64().unwrap();
|
||
let r = ok(
|
||
&mut a,
|
||
"doc_optimize",
|
||
json!({ "doc": doc, "path": "small.pdf", "color": { "ppi": 100, "above_ppi": 150, "compression": "jpeg", "quality": 45 }, "discard": ["metadata"] }),
|
||
);
|
||
assert_eq!((r["images"].as_u64(), r["images_resampled"].as_u64()), (Some(1), Some(1)), "{r}");
|
||
assert!(r["bytes_after"].as_u64().unwrap() * 5 < r["bytes_before"].as_u64().unwrap(), "{r}");
|
||
assert_eq!(r["discarded"][0]["category"], "metadata");
|
||
// The result opens and shows one page of the same size.
|
||
let small = ok(&mut a, "doc_open", json!({ "path": "small.pdf" }));
|
||
assert_eq!(small["pages"], 1);
|
||
let reduced = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" }));
|
||
assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap());
|
||
assert!(matches!(
|
||
a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "color": { "compression": "gif" } })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn initial_view_through_tools() {
|
||
let dir = workdir("initial-view");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "doc_initial_view", json!({ "doc": doc }));
|
||
assert_eq!((r["initial_view"]["layout"].as_str(), r["initial_view"]["page"].as_u64()), (Some("Default"), Some(1)));
|
||
let r = ok(
|
||
&mut a,
|
||
"doc_initial_view",
|
||
json!({ "doc": doc, "navigation": "pages", "layout": "two_up", "magnification": 150, "page": 2, "language": "en-GB", "binding": "right", "display_title": true }),
|
||
);
|
||
let v = &r["initial_view"];
|
||
assert_eq!((v["navigation"].as_str(), v["magnification"].as_str(), v["page"].as_u64()), (Some("Pages"), Some("Percent(150.0)"), Some(2)));
|
||
assert_eq!((v["language"].as_str(), v["binding"].as_str()), (Some("en-GB"), Some("right")));
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change initial view");
|
||
assert!(matches!(a.call("doc_initial_view", &json!({ "doc": doc, "layout": "spiral" })), Err(ToolError::InvalidArgs(_))));
|
||
assert!(matches!(a.call("doc_initial_view", &json!({ "doc": doc, "page": 9 })), Err(ToolError::Failed(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn ocr_tools_make_a_scan_searchable() {
|
||
let dir = workdir("ocr");
|
||
let mut a = auto(&dir);
|
||
let status = ok(&mut a, "ocr_status", json!({}));
|
||
assert_eq!(status["languages"][0]["code"], "en");
|
||
if status["available"] != true {
|
||
eprintln!("skipped: OCR models not installed");
|
||
return;
|
||
}
|
||
let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Searchable scans with recognised words" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] }));
|
||
let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path();
|
||
let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "ocr_recognize", json!({ "doc": scan }));
|
||
assert!(r["pages"][0]["text"].as_str().unwrap().to_lowercase().contains("searchable"), "{r}");
|
||
assert!(r["words"].as_u64().unwrap() >= 4);
|
||
let again = ok(&mut a, "ocr_recognize", json!({ "doc": scan, "pages": [1] }));
|
||
assert!(again["pages"][0]["skipped"].is_string(), "{again}");
|
||
assert!(matches!(a.call("ocr_recognize", &json!({ "doc": scan, "language": "xx" })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn ocr_recognize_files_writes_searchable_copies() {
|
||
let dir = workdir("ocr-files");
|
||
let mut a = auto(&dir);
|
||
if ok(&mut a, "ocr_status", json!({}))["available"] != true {
|
||
eprintln!("skipped: OCR models not installed");
|
||
return;
|
||
}
|
||
let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Batch recognition works" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] }));
|
||
let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path();
|
||
let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "doc_save", json!({ "doc": scan, "path": "in/scan.pdf" }));
|
||
let r = ok(&mut a, "ocr_recognize_files", json!({ "paths": ["in/scan.pdf", "missing.pdf"], "folder": "out" }));
|
||
assert!(r["files"][0]["words"].as_u64().unwrap() >= 3, "{r}");
|
||
assert!(r["files"][1]["error"].is_string(), "{r}");
|
||
let out = ok(&mut a, "doc_open", json!({ "path": "out/scan.pdf" }))["doc"].as_u64().unwrap();
|
||
let found = ok(&mut a, "text_find", json!({ "doc": out, "query": "recognition" }));
|
||
assert!(found.to_string().contains("\"page\""), "{found}");
|
||
}
|
||
|
||
#[test]
|
||
fn javascript_through_tools() {
|
||
let dir = workdir("js");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
for (name, y) in [("Price", 20), ("Qty", 60), ("Total", 100)] {
|
||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
|
||
}
|
||
ok(
|
||
&mut a,
|
||
"js_set_document_script",
|
||
json!({ "doc": doc, "name": "lib", "script": "function money(v) { return util.printf('EUR %,2.2f', v); }" }),
|
||
);
|
||
assert_eq!(ok(&mut a, "js_document_scripts", json!({ "doc": doc }))["scripts"][0]["name"], "lib");
|
||
ok(
|
||
&mut a,
|
||
"form_set_script",
|
||
json!({ "doc": doc, "field": "Total", "event": "calculate", "script": "event.value = getField('Price').value * getField('Qty').value;" }),
|
||
);
|
||
ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "Total", "event": "format", "script": "event.value = money(event.value);" }));
|
||
ok(
|
||
&mut a,
|
||
"form_set_script",
|
||
json!({ "doc": doc, "field": "Qty", "event": "validate", "script": "if (event.value < 1) { app.alert('Order at least one'); event.rc = false; }" }),
|
||
);
|
||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Price": "1250", "Qty": "2" } }));
|
||
assert!(page_text(&mut a, doc)[0].contains("EUR 2.500,00"), "{:?}", page_text(&mut a, doc));
|
||
let err = a.call("form_fill", &json!({ "doc": doc, "values": { "Qty": "0" } })).unwrap_err();
|
||
assert!(err.to_string().contains("Order at least one"), "{err}");
|
||
|
||
let r = ok(
|
||
&mut a,
|
||
"js_run",
|
||
json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok');" }),
|
||
);
|
||
assert_eq!(r["console"][0], "2500");
|
||
assert_eq!(r["alerts"][0], "ok");
|
||
assert_eq!(r["requests"][0]["page"], 2);
|
||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone();
|
||
assert_eq!(total["value"], "3750");
|
||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "nope()" }));
|
||
assert!(r["error"].as_str().unwrap().contains("nope"));
|
||
assert_eq!(ok(&mut a, "js_enabled", json!({ "enabled": false }))["enabled"], false);
|
||
assert!(a.call("js_run", &json!({ "doc": doc, "script": "1" })).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn merging_form_data_into_a_spreadsheet() {
|
||
let dir = workdir("merge-data");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "City" }));
|
||
for (city, file) in [("Paris", "one.xfdf"), ("Oslo", "two.fdf")] {
|
||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "City": city } }));
|
||
ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": file, "what": "fields" }));
|
||
}
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "filled.pdf" }));
|
||
let r = ok(&mut a, "form_merge_data", json!({ "paths": ["one.xfdf", "two.fdf", "filled.pdf"], "path": "report.csv" }));
|
||
assert_eq!(r["rows"], 3);
|
||
assert_eq!(std::fs::read_to_string(dir.join("report.csv")).unwrap(), "City\nParis\nOslo\nOslo\n");
|
||
}
|
||
|
||
#[test]
|
||
fn field_actions_through_tools() {
|
||
let dir = workdir("field-actions");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 20, 120, 42], "name": "Go" }));
|
||
let r = ok(
|
||
&mut a,
|
||
"form_set_actions",
|
||
json!({ "doc": doc, "field": "Go", "actions": [
|
||
{ "trigger": "mouse_up", "javascript": "this.pageNum = 2;" },
|
||
{ "trigger": "mouse_enter", "hide": ["Go"] },
|
||
{ "trigger": "on_focus", "page": 3 }
|
||
] }),
|
||
);
|
||
assert_eq!(r["actions"].as_array().unwrap().len(), 3, "{r}");
|
||
assert_eq!(r["actions"][0], json!({ "trigger": "mouse_up", "javascript": "this.pageNum = 2;" }));
|
||
assert_eq!(r["actions"][1], json!({ "trigger": "mouse_enter", "hide": ["Go"] }));
|
||
assert_eq!(r["actions"][2], json!({ "trigger": "on_focus", "page": 3 }));
|
||
let run = ok(&mut a, "js_run", json!({ "doc": doc, "script": "this.pageNum = 2;", "field": "Go" }));
|
||
assert_eq!(run["requests"][0]["page"], 3);
|
||
assert!(a.call("form_set_actions", &json!({ "doc": doc, "field": "Go", "actions": [{ "trigger": "wave" }] })).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn detecting_form_fields_through_tools() {
|
||
let dir = workdir("detect-fields");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Name: ____________________\n\nCity: ____________________" }))["doc"]
|
||
.as_u64()
|
||
.unwrap();
|
||
let r = ok(&mut a, "form_detect_fields", json!({ "doc": doc, "add": false }));
|
||
assert_eq!(r["fields"].as_array().unwrap().len(), 2, "{r}");
|
||
assert_eq!(r["fields"][1]["name"], "City");
|
||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(), 0);
|
||
ok(&mut a, "form_detect_fields", json!({ "doc": doc }));
|
||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "City": "Lisbon" } }));
|
||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}");
|
||
}
|
||
|
||
#[test]
|
||
fn comparing_documents_through_tools() {
|
||
let dir = workdir("compare");
|
||
let mut a = auto(&dir);
|
||
let v1 = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Rent is 900 per month. Pets are not allowed." }))["doc"].as_u64().unwrap();
|
||
let v2 = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Rent is 950 per month. Pets are allowed. Parking included." }))["doc"]
|
||
.as_u64()
|
||
.unwrap();
|
||
let r = ok(&mut a, "doc_compare", json!({ "doc": v2, "other": v1, "visual": true }));
|
||
assert!(!r["visual"].as_array().unwrap().is_empty(), "{r}");
|
||
assert_eq!((r["replaced"].as_u64(), r["inserted"].as_u64(), r["deleted"].as_u64()), (Some(1), Some(1), Some(1)), "{r}");
|
||
assert_eq!(r["changes"][0]["old"]["text"], "900");
|
||
assert_eq!(r["changes"][0]["new"]["text"], "950");
|
||
assert_eq!(r["changes"][0]["new"]["page"], 1);
|
||
ok(&mut a, "doc_compare_report", json!({ "doc": v2, "other": v1, "path": "report.pdf" }));
|
||
assert!(std::fs::read(dir.join("report.pdf")).unwrap().starts_with(b"%PDF"));
|
||
assert_eq!(ok(&mut a, "doc_compare_mark", json!({ "doc": v2, "other": v1 }))["comments"], 3);
|
||
assert!(a.call("doc_compare", &json!({ "doc": v2, "other": 999 })).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn actions_through_tools() {
|
||
let dir = workdir("actions");
|
||
let mut a = auto(&dir);
|
||
let list = ok(&mut a, "action_list", json!({}));
|
||
assert!(list["actions"].as_array().unwrap().iter().any(|x| x["name"] == "Prepare for Distribution"));
|
||
assert!(list["steps"].as_array().unwrap().iter().any(|x| x["step"] == "add_watermark" && x["takes_arg"] == true));
|
||
let r = ok(&mut a, "action_run", json!({ "action": "add page numbers", "paths": ["a.pdf", "b.pdf", "missing.pdf"], "folder": "out" }));
|
||
assert_eq!(r["files"][0]["log"][0], "Add footer");
|
||
assert!(r["files"][2]["error"].is_string());
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "out/b.pdf" }))["doc"].as_u64().unwrap();
|
||
assert!(page_text(&mut a, doc)[1].contains("Page 2 of 2"), "{:?}", page_text(&mut a, doc));
|
||
let r = ok(&mut a, "action_run", json!({ "steps": [{ "step": "set_title", "arg": "Hello" }], "paths": ["a.pdf"], "folder": "out2" }));
|
||
assert_eq!(r["files"][0]["log"][0], "Set document title");
|
||
assert!(a.call("action_run", &json!({ "steps": [{ "step": "fly" }], "paths": ["a.pdf"], "folder": "x" })).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn pdfa_through_tools() {
|
||
let dir = workdir("pdfa");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Archive me" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "pdfa_verify", json!({ "doc": doc }));
|
||
assert_eq!(r["compliant"], false);
|
||
assert!(r["issues"].as_array().unwrap().iter().any(|i| i["clause"] == "6.6.2.1"), "{r}");
|
||
let r = ok(&mut a, "pdfa_convert", json!({ "doc": doc, "level": "3b" }));
|
||
assert_eq!(r["declared"]["pdfa"], "PDF/A-3b", "{r}");
|
||
assert!(r["issues"].as_array().unwrap().iter().all(|i| i["fixable"] == false), "{r}");
|
||
assert!(a.call("pdfa_verify", &json!({ "doc": doc, "level": "9z" })).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn exporting_to_word_html_and_rtf() {
|
||
let dir = workdir("office");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
for ext in ["docx", "html", "rtf"] {
|
||
let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("a.{ext}") }));
|
||
assert_eq!(r["format"], ext);
|
||
}
|
||
let html = std::fs::read_to_string(dir.join("a.html")).unwrap();
|
||
assert!(html.contains("Page 1") && html.contains("Page 3") && html.matches("<hr>").count() == 2, "{html}");
|
||
assert!(std::fs::read(dir.join("a.docx")).unwrap().starts_with(b"PK"));
|
||
assert!(std::fs::read_to_string(dir.join("a.rtf")).unwrap().contains("Page 2"));
|
||
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err());
|
||
}
|