mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 11:17:10 -04:00
A font the document does not embed was replaced by whatever the caller happened to pick, and nothing told the user. `doc_info` said only `embedded: false`, which is why "my Chinese PDF shows nothing" has been a mystery rather than a sentence. `crates/fonts/src/fallback.rs` applies one chain, in a documented order, and returns what it chose with a reason: 1. embedded -> nothing substituted; 2. /BaseFont names a standard-14 face or a known alias (exact metrics); 3. craft-fonts holds that exact family; 4. /CIDSystemInfo /Ordering names a script we have a face for; 5. the ordering names a script we have no licensed face for -- report none, so nothing is drawn rather than the wrong glyphs; 6. otherwise the nearest of the fourteen by descriptor flags. It delegates to the existing per-script selectors instead of restating their order, so each keeps one source of truth. It answers the dictionary question only; "which face has a glyph for this character?" depends on the string being drawn and stays where the drawing happens. `doc_info` gains `substitute` and `substitution` per font. Both null means the document's own glyphs are shown; a null `substitute` with a reason means no face was available at all. That is Acrobat's Actual Font, minus the interface, which `parity/` now records as still missing. Two findings on the way: `Std14::from_base_font` matches loosely, so "SomeHouseSerif" resolved to Times by its name containing "serif". The face is a fair guess but reporting it as "names one of the standard 14" would have been untrue of a font nobody has heard of. `Std14::recognise` now says whether a name matched an alias or only a family hint, and the two report differently. `from_base_font` is unchanged. The generated table moved to `crates/fonts/src/std14_metrics.rs`, data alone, and the ATTRIBUTION entry and AGENTS.md 1.1 line follow it. F1 put the hash on a file holding both the table and the code reading it, so this commit's ordinary edits tripped the gate -- and a hash that churns on unrelated changes gets re-attested reflexively, which is the protection gone. Verified both directions after the split: editing std14.rs passes the gate, changing one width in std14_metrics.rs fails it. Every new test was watched failing first: removing the script step breaks the two unit tests and the doc_info integration test. Co-Authored-By: Claude <[email protected]>
5682 lines
331 KiB
Rust
5682 lines
331 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
|
||
}
|
||
|
||
fn remote_link_pdf(file: &str, destination: Option<&str>) -> Vec<u8> {
|
||
// canonicalize() uses a Windows device prefix; a PDF file specification uses the ordinary drive path.
|
||
#[cfg(windows)]
|
||
let file = file.strip_prefix(r"\\?\").filter(|path| path.as_bytes().get(1) == Some(&b':')).unwrap_or(file);
|
||
let file = file.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)");
|
||
let destination = destination.map_or_else(String::new, |d| format!("/D {d}"));
|
||
let objects: Vec<Vec<u8>> = vec![
|
||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".to_vec(),
|
||
format!("<< /Type /Annot /Subtype /Link /Rect [10 10 80 30] /A << /S /GoToR /F ({file}) {destination} >> >>").into_bytes(),
|
||
];
|
||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||
let mut offsets = Vec::new();
|
||
for (i, object) in objects.iter().enumerate() {
|
||
offsets.push(bytes.len());
|
||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||
bytes.extend_from_slice(object);
|
||
bytes.extend_from_slice(b"\nendobj\n");
|
||
}
|
||
let xref = bytes.len();
|
||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||
for offset in offsets {
|
||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||
}
|
||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||
bytes
|
||
}
|
||
|
||
fn protected_fixture(password: &str) -> Vec<u8> {
|
||
use pdfcraft_engine::{Edit, Protection, Session};
|
||
let mut session = Session::new();
|
||
let id = session.open("target.pdf", None, std::sync::Arc::new(fixture(1)), None).unwrap();
|
||
session.apply(id, Edit::Protect(Protection { open_password: Some(password.into()), ..Default::default() })).unwrap();
|
||
session.save_bytes(id).unwrap().as_ref().clone()
|
||
}
|
||
|
||
/// A fresh directory with `a.pdf` (3 pages) and `b.pdf` (2 pages).
|
||
#[track_caller]
|
||
fn workdir(test: &str) -> PathBuf {
|
||
let caller = std::panic::Location::caller();
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-{test}-{}-{}-{}", std::process::id(), caller.line(), caller.column(),));
|
||
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
|
||
}
|
||
|
||
#[test]
|
||
fn workdir_call_sites_do_not_delete_each_other() {
|
||
let first = workdir("same-tag");
|
||
let sentinel = first.join("sentinel");
|
||
std::fs::write(&sentinel, b"first call site").unwrap();
|
||
|
||
let second = workdir("same-tag");
|
||
let paths_differ = first != second;
|
||
let sentinel_bytes = std::fs::read(&sentinel).ok();
|
||
let first_a = std::fs::read(first.join("a.pdf")).ok();
|
||
let first_b = std::fs::read(first.join("b.pdf")).ok();
|
||
let second_a = std::fs::read(second.join("a.pdf")).ok();
|
||
let second_b = std::fs::read(second.join("b.pdf")).ok();
|
||
|
||
let _ = std::fs::remove_dir_all(&first);
|
||
if paths_differ {
|
||
let _ = std::fs::remove_dir_all(&second);
|
||
}
|
||
|
||
assert!(paths_differ, "distinct call sites shared {first:?}");
|
||
assert_eq!(sentinel_bytes.as_deref(), Some(&b"first call site"[..]));
|
||
assert_eq!(first_a, Some(fixture(3)));
|
||
assert_eq!(first_b, Some(fixture(2)));
|
||
assert_eq!(second_a, Some(fixture(3)));
|
||
assert_eq!(second_b, Some(fixture(2)));
|
||
}
|
||
|
||
fn auto(dir: &Path) -> Automation {
|
||
Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000)
|
||
}
|
||
|
||
#[test]
|
||
fn autotag_preview_proposes_text_without_changing_the_pdf() {
|
||
let dir = workdir("autotag-preview");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let before = std::fs::read(dir.join("a.pdf")).unwrap();
|
||
|
||
let preview = ok(&mut a, "accessibility_autotag_preview", json!({ "doc": doc, "page": 1 }));
|
||
assert_eq!(preview["page"], 1);
|
||
assert_eq!(preview["candidates"][0]["type"], "P");
|
||
assert_eq!(preview["candidates"][0]["text"], "Page 1");
|
||
assert_eq!(preview["candidates"][0]["needs_review"], true);
|
||
assert_eq!(preview["unclassified_images"], 0);
|
||
assert_eq!(preview["unclassified_text_blocks"], 0);
|
||
assert_eq!(preview["existing_structure"], false);
|
||
assert_eq!(std::fs::read(dir.join("a.pdf")).unwrap(), before);
|
||
assert!(matches!(
|
||
a.call("accessibility_autotag_preview", &json!({ "doc": doc, "page": 4 })),
|
||
Err(ToolError::InvalidArgs(_) | ToolError::Failed(_))
|
||
));
|
||
|
||
let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 300, "height": 400 }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "page_add_text", json!({ "doc": blank, "page": 1, "text": "Annual report", "at": [20, 25], "size": 24 }));
|
||
ok(&mut a, "page_add_text", json!({ "doc": blank, "page": 1, "text": "First paragraph", "at": [20, 100], "size": 12 }));
|
||
ok(&mut a, "page_add_text", json!({ "doc": blank, "page": 1, "text": "Second paragraph", "at": [20, 140], "size": 12 }));
|
||
let preview = ok(&mut a, "accessibility_autotag_preview", json!({ "doc": blank, "page": 1 }));
|
||
assert!(preview["candidates"].as_array().unwrap().iter().any(|c| c["text"] == "Annual report" && c["type"] == "H1"));
|
||
assert!(preview["candidates"].as_array().unwrap().iter().any(|c| c["text"] == "First paragraph" && c["type"] == "P"));
|
||
}
|
||
|
||
#[test]
|
||
fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() {
|
||
let dir = workdir("autotag-apply");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
|
||
let before = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["tagged-pdf", "tagged-content"] }));
|
||
assert_eq!(before["failed"], 2);
|
||
let rendered_before = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||
|
||
let applied = ok(&mut a, "accessibility_autotag_apply", json!({ "doc": doc }));
|
||
assert_eq!(applied["count"], 3, "Document plus one paragraph per page");
|
||
assert_eq!(applied["tags"][1]["type"], "P");
|
||
let after = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["tagged-pdf", "tagged-content"] }));
|
||
assert_eq!(after["passed"], 2);
|
||
assert_eq!(a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(), rendered_before);
|
||
assert!(matches!(a.call("accessibility_autotag_apply", &json!({ "doc": doc })), Err(ToolError::Failed(_))));
|
||
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "tagged.pdf" }));
|
||
let saved = std::fs::read(dir.join("tagged.pdf")).unwrap();
|
||
let reopened = pdfcraft_cos::Document::open(std::sync::Arc::new(saved)).unwrap();
|
||
let pages = pdfcraft_model::pages(&reopened);
|
||
assert_eq!(pages.len(), 2);
|
||
assert!(pages.iter().all(|page| page.dict.contains(b"StructParents")));
|
||
assert!(reopened.get(reopened.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot"));
|
||
}
|
||
|
||
/// A one-page PDF whose form holds one text field named `name`.
|
||
fn form_pdf(name: &str) -> Vec<u8> {
|
||
let objs: Vec<String> = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(),
|
||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"),
|
||
];
|
||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||
let mut offsets = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offsets.push(bytes.len());
|
||
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let xref = bytes.len();
|
||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offsets {
|
||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
bytes
|
||
}
|
||
|
||
#[test]
|
||
fn accessibility_check_locates_field_description_on_page() {
|
||
let dir = workdir("accessibility-field-location");
|
||
std::fs::write(dir.join("field.pdf"), form_pdf("name")).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "field.pdf" }))["doc"].as_u64().unwrap();
|
||
let report = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["field-descriptions"] }));
|
||
let rule = report["results"].as_array().unwrap().iter().find(|r| r["rule"] == "field-descriptions").unwrap();
|
||
assert_eq!(rule["status"], "failed");
|
||
assert_eq!(rule["findings"][0]["page"], 1);
|
||
assert_eq!(rule["findings"][0]["bbox_user_space"], json!([60.0, 60.0, 200.0, 80.0]));
|
||
}
|
||
|
||
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 err(a: &mut Automation, tool: &str, args: Value) -> ToolError {
|
||
a.call(tool, &args).unwrap_err()
|
||
}
|
||
|
||
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 explicit_font_and_disjoint_ranges_save_reopen_through_tools() {
|
||
let dir = workdir("font-ranges");
|
||
std::fs::write(dir.join("inter.ttf"), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
|
||
let mut a = auto(&dir);
|
||
std::fs::write(dir.join("broken.otf"), b"not a font").unwrap();
|
||
let found = ok(&mut a, "text_font_files", json!({}));
|
||
assert_eq!(found["fonts"].as_array().unwrap().len(), 1);
|
||
assert_eq!(found["fonts"][0]["font"], "Inter-Regular");
|
||
assert!(a.call("text_font_files", &json!({"directories":["../outside"]})).is_err());
|
||
let id = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
|
||
let font = ok(&mut a, "text_font_import", json!({"doc":id,"page":1,"path":"inter.ttf"}));
|
||
let resource = font["resource"].as_str().unwrap();
|
||
assert_eq!(font["font"], "Inter-Regular");
|
||
assert_eq!(page_text(&mut a, id), ["Page 1", "Page 2", "Page 3"]);
|
||
let draft = ok(
|
||
&mut a,
|
||
"text_edit_layout",
|
||
json!({"doc":id,"page":1,"paragraph":1,"text":"Aé ΩЖ","font_resource":resource,"ranges":[{"range":[0,3],"size":16,"color":"#cc0000"},{"range":[4,8],"size":20,"color":"#003399"}]}),
|
||
);
|
||
assert!(draft["draft_rect"].is_array());
|
||
assert_eq!(page_text(&mut a, id), ["Page 1", "Page 2", "Page 3"]);
|
||
ok(
|
||
&mut a,
|
||
"text_edit",
|
||
json!({"doc":id,"page":1,"paragraph":1,"text":"Aé ΩЖ","font_resource":resource,"ranges":[{"range":[0,3],"size":16,"color":"#cc0000"},{"range":[4,8],"size":20,"color":"#003399"}]}),
|
||
);
|
||
let lines = ok(&mut a, "text_lines", json!({"doc":id,"page":1}));
|
||
assert!(
|
||
lines["lines"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.any(|l| l["text"].as_str().is_some_and(|s| s.trim_end() == "Aé") && l["size"].as_f64() == Some(16.0)),
|
||
"{lines}"
|
||
);
|
||
let rendered = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
|
||
ok(&mut a, "doc_save", json!({"doc":id,"path":"edited.pdf"}));
|
||
let reopened = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap();
|
||
let saved = ok(&mut a, "text_lines", json!({"doc":reopened,"page":1}));
|
||
assert_eq!(lines, saved);
|
||
assert_eq!(rendered, a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap());
|
||
let before = saved.clone();
|
||
assert!(a.call("text_edit", &json!({"doc":reopened,"page":1,"paragraph":1,"text":"é test","ranges":[{"range":[1,2],"size":18}]})).is_err());
|
||
assert_eq!(ok(&mut a, "text_lines", json!({"doc":reopened,"page":1})), before);
|
||
assert!(a.call("text_font_import", &json!({"doc":id,"page":1,"path":"../outside.ttf"})).is_err());
|
||
std::fs::remove_dir_all(dir).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen() {
|
||
if pdfcraft_fonts::document_arabic_font().is_none() {
|
||
eprintln!("set CRAFT_FONTS_DIR to exercise added Arabic text");
|
||
return;
|
||
}
|
||
let dir = workdir("arabic-copy-order");
|
||
let mut a = auto(&dir);
|
||
let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap();
|
||
let original = "سَلَام 123 (PDF) - اختبار الكتابة العربية";
|
||
ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":original,"at":[40,50],"width":500,"size":16,"align":"right"}));
|
||
assert_eq!(page_text(&mut a, id), [original]);
|
||
assert_eq!(ok(&mut a, "text_lines", json!({"doc":id,"page":1}))["lines"][0]["text"], original);
|
||
let found = ok(&mut a, "text_find", json!({"doc":id,"query":original}));
|
||
assert_eq!(found["matches"].as_array().unwrap().len(), 1);
|
||
let retyped = "مُرَاجَعَة PDF رقم ١٢٣";
|
||
ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"text":retyped}));
|
||
ok(&mut a, "doc_save", json!({"doc":id,"path":"retyped.pdf"}));
|
||
let re = ok(&mut a, "doc_open", json!({"path":"retyped.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(page_text(&mut a, re), [retyped]);
|
||
assert_eq!(ok(&mut a, "text_lines", json!({"doc":re,"page":1}))["lines"][0]["text"], retyped);
|
||
std::fs::remove_dir_all(dir).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn arabic_actual_text_stays_logical_through_editor_tools_and_save_reopen() {
|
||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||
use std::sync::Arc;
|
||
|
||
let dir = workdir("arabic-actual-text");
|
||
let logical = "سَلَام 123 (PDF)";
|
||
let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect();
|
||
let content = format!("/Span <</ActualText <FEFF{hex}>>> BDC BT /F1 24 Tf 20 150 Td (abcd) Tj (efgh) Tj ET EMC");
|
||
let mut doc = Document::open(Arc::new(fixture(1))).unwrap();
|
||
doc.set(ObjRef::new(5, 0), Object::Stream(Stream::flate(Dict::new(), content.as_bytes())));
|
||
std::fs::write(dir.join("arabic.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
||
|
||
let mut a = auto(&dir);
|
||
let id = ok(&mut a, "doc_open", json!({"path":"arabic.pdf"}))["doc"].as_u64().unwrap();
|
||
let lines = ok(&mut a, "text_lines", json!({"doc":id,"page":1}));
|
||
assert_eq!(lines["lines"][0]["text"], logical);
|
||
let render = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
|
||
ok(&mut a, "doc_save", json!({"doc":id,"path":"saved.pdf"}));
|
||
let re = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "text_lines", json!({"doc":re,"page":1})), lines);
|
||
assert_eq!(a.call("page_render", &json!({"doc":re,"page":1,"dpi":72})).unwrap(), render);
|
||
ok(&mut a, "text_edit", json!({"doc":re,"page":1,"paragraph":1,"text":"Replacement"}));
|
||
ok(&mut a, "doc_save", json!({"doc":re,"path":"edited.pdf"}));
|
||
let edited = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "text_lines", json!({"doc":edited,"page":1}))["lines"][0]["text"], "Replacement");
|
||
assert_eq!(page_text(&mut a, edited), ["Replacement"]);
|
||
std::fs::remove_dir_all(dir).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn arabic_ligature_actual_text_is_one_editable_word_through_save_reopen() {
|
||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||
use std::sync::Arc;
|
||
|
||
let dir = workdir("arabic-ligature-actual-text");
|
||
let mut doc = Document::open(Arc::new(fixture(1))).unwrap();
|
||
// Synthetic glyph codes: A س, B م, X lam-alef. No installed Arabic font is needed.
|
||
let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
|
||
1 begincodespacerange <00> <FF> endcodespacerange 3 beginbfchar \
|
||
<41> <0633> <42> <0645> <58> <FEFB> \
|
||
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
|
||
let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
|
||
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"ToUnicode".to_vec(), Object::Ref(unicode))).unwrap();
|
||
let content = "BT /F1 24 Tf 20 150 Td (B) Tj /Span <</ActualText <FEFF06440627>>> BDC (X) Tj EMC (A) Tj ET";
|
||
doc.set(ObjRef::new(5, 0), Object::Stream(Stream::flate(Dict::new(), content.as_bytes())));
|
||
std::fs::write(dir.join("arabic.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
||
|
||
let mut a = auto(&dir);
|
||
let id = ok(&mut a, "doc_open", json!({"path":"arabic.pdf"}))["doc"].as_u64().unwrap();
|
||
let lines = ok(&mut a, "text_lines", json!({"doc":id,"page":1}));
|
||
assert_eq!(lines["lines"].as_array().unwrap().len(), 1);
|
||
assert_eq!(lines["lines"][0]["text"], "سلام");
|
||
assert_eq!(page_text(&mut a, id), ["سلام"]);
|
||
assert_eq!(ok(&mut a, "text_find", json!({"doc":id,"query":"سلام"}))["matches"].as_array().unwrap().len(), 1);
|
||
let render = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
|
||
ok(&mut a, "doc_save", json!({"doc":id,"path":"saved.pdf"}));
|
||
let re = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "text_lines", json!({"doc":re,"page":1})), lines);
|
||
assert_eq!(a.call("page_render", &json!({"doc":re,"page":1,"dpi":72})).unwrap(), render);
|
||
ok(&mut a, "text_edit", json!({"doc":re,"page":1,"paragraph":1,"text":"Replacement"}));
|
||
ok(&mut a, "doc_save", json!({"doc":re,"path":"edited.pdf"}));
|
||
let edited = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(page_text(&mut a, edited), ["Replacement"]);
|
||
std::fs::remove_dir_all(dir).unwrap();
|
||
}
|
||
|
||
/// Each of these writes to its required `path` and replaces an existing file there, so the MCP
|
||
/// annotations must not tell clients the call is read-only or harmless (#130).
|
||
#[test]
|
||
fn file_writing_tools_are_not_read_only() {
|
||
for name in ["doc_export_data", "accessibility_report", "image_save"] {
|
||
let t = tools().into_iter().find(|t| t.name == name).unwrap();
|
||
assert!(!t.read_only, "{name} writes a file but advertises read-only");
|
||
assert!(t.destructive, "{name} overwrites its path but advertises non-destructive");
|
||
}
|
||
}
|
||
|
||
#[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");
|
||
|
||
// The fixture names /Helvetica without embedding it, so an agent can see both that a face is
|
||
// standing in and why that face. `substitution` is null only when nothing stands in.
|
||
let font = &info["fonts"][0];
|
||
assert_eq!((&font["name"], &font["embedded"]), (&json!("Helvetica"), &json!(false)));
|
||
assert_eq!(font["substitute"], json!("Helvetica"));
|
||
assert!(font["substitution"].as_str().is_some_and(|w| w.contains("standard 14")), "{font}");
|
||
|
||
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 text_editor_background_is_read_only_and_removes_source_glyphs() {
|
||
let dir = workdir("text-preview");
|
||
let mut a = auto(&dir);
|
||
std::fs::write(dir.join("text-preview.pdf"), fixture(2)).unwrap();
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "text-preview.pdf" }))["doc"].as_u64().unwrap();
|
||
let args = json!({ "doc": doc, "page": 1, "dpi": 72 });
|
||
let before = a.call("page_render", &args).unwrap();
|
||
let state = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
let preview = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72, "hide_text_block": 1 })).unwrap();
|
||
assert_ne!(preview, before);
|
||
let Content::Png { data, .. } = &preview[0] else { panic!("expected PNG") };
|
||
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
|
||
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
|
||
let info = reader.next_frame(&mut pixels).unwrap();
|
||
assert!(pixels[..info.buffer_size()].iter().all(|b| *b == 255), "no source glyphs remain on the white page");
|
||
assert_eq!(a.call("page_render", &args).unwrap(), before);
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc })), state);
|
||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2"]);
|
||
for block in [-1, 0, 2, i64::MAX] {
|
||
assert!(a.call("page_render", &json!({ "doc": doc, "page": 1, "hide_text_block": block })).is_err());
|
||
}
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// Issue #1329: an in-place save says it was incremental only when it kept the file it was
|
||
/// saving over: protecting or sanitizing rewrites the file, and the result says so.
|
||
#[test]
|
||
fn an_in_place_save_reports_a_required_rewrite_as_not_incremental() {
|
||
let dir = workdir("save-incremental-flag");
|
||
let original = std::fs::read(dir.join("a.pdf")).unwrap();
|
||
let cases = [
|
||
("page_rotate", json!({ "pages": [1], "degrees": 90 }), true),
|
||
("doc_protect", json!({ "open_password": "openme" }), false),
|
||
("doc_remove_hidden", json!({}), false),
|
||
];
|
||
for (tool, args, incremental) in cases {
|
||
std::fs::write(dir.join("a.pdf"), &original).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let mut args = args;
|
||
args["doc"] = json!(doc);
|
||
ok(&mut a, tool, args);
|
||
let saved = ok(&mut a, "doc_save", json!({ "doc": doc }));
|
||
let kept = std::fs::read(dir.join("a.pdf")).unwrap().starts_with(&original);
|
||
assert_eq!((saved["incremental"].as_bool(), kept), (Some(incremental), incremental), "{tool}");
|
||
}
|
||
}
|
||
|
||
/// organize.field-name-conflicts: combining files whose forms share a field name keeps the
|
||
/// fields separate by renaming, says so in the result, and can be told to merge instead; any
|
||
/// other policy is an argument error.
|
||
#[test]
|
||
fn combine_renames_colliding_field_names() {
|
||
let dir = workdir("combine-field-conflicts");
|
||
std::fs::write(dir.join("form-a.pdf"), form_pdf("Text1")).unwrap();
|
||
std::fs::write(dir.join("form-b.pdf"), form_pdf("Text1")).unwrap();
|
||
let mut a = auto(&dir);
|
||
let combined = ok(&mut a, "doc_combine", json!({ "paths": ["form-a.pdf", "form-b.pdf"], "open": true }));
|
||
assert_eq!(combined["renamed_fields"], json!([["Text1", "Text1-1"]]), "{combined}");
|
||
let merged = ok(&mut a, "doc_combine", json!({ "paths": ["form-a.pdf", "form-b.pdf"], "field_conflicts": "merge" }));
|
||
assert!(merged.get("renamed_fields").is_none(), "merge renames nothing: {merged}");
|
||
let bad = a.call("doc_combine", &json!({ "paths": ["form-a.pdf", "form-b.pdf"], "field_conflicts": "bogus" }));
|
||
assert!(matches!(bad, Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
/// #569: images and text files combine with PDFs, each made into a PDF first.
|
||
#[test]
|
||
fn combine_takes_images_and_text_files() {
|
||
let dir = workdir("combine-images");
|
||
let mut png = Vec::new();
|
||
{
|
||
let mut encoder = png::Encoder::new(&mut png, 4, 2);
|
||
encoder.set_color(png::ColorType::Rgb);
|
||
encoder.set_depth(png::BitDepth::Eight);
|
||
encoder.write_header().unwrap().write_image_data(&[90; 24]).unwrap();
|
||
}
|
||
std::fs::write(dir.join("scan.png"), png).unwrap();
|
||
std::fs::write(dir.join("notes.txt"), "Some notes").unwrap();
|
||
std::fs::write(dir.join("broken.png"), b"\x89PNG\r\n\x1a\nbroken").unwrap();
|
||
std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap();
|
||
let mut a = auto(&dir);
|
||
let combined = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "scan.png", "notes.txt"], "pages": [null, null, "1"], "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", "", "Some notes"]);
|
||
let marks = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone();
|
||
let titles: Vec<&str> = marks.as_array().unwrap().iter().map(|b| b["title"].as_str().unwrap()).collect();
|
||
assert_eq!(titles, ["a", "scan", "notes"]);
|
||
// A file that can't be made into a PDF stops the combine, and says which.
|
||
for bad in ["broken.png", "report.docx"] {
|
||
let refused = a.call("doc_combine", &json!({ "paths": ["a.pdf", bad] }));
|
||
assert!(matches!(&refused, Err(ToolError::Failed(e)) if e.contains(bad)), "{bad}: {refused:?}");
|
||
}
|
||
}
|
||
|
||
#[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(_))));
|
||
|
||
// A file split around another, as the Combine files grid makes it: copied once, one bookmark.
|
||
let split = ok(
|
||
&mut a,
|
||
"doc_combine",
|
||
json!({ "paths": ["a.pdf", "b.pdf", "a.pdf"], "pages": ["1", null, "3, 2"], "groups": [4, null, 4], "open": true }),
|
||
);
|
||
let split_doc = split["document"]["doc"].as_u64().unwrap();
|
||
assert_eq!(page_text(&mut a, split_doc), ["Page 1", "Page 1", "Page 2", "Page 3", "Page 2"]);
|
||
let marks = ok(&mut a, "bookmark_list", json!({ "doc": split_doc }))["bookmarks"].clone();
|
||
let marks: Vec<_> = marks.as_array().unwrap().iter().map(|b| (b["title"].as_str().unwrap().to_string(), b["page"].as_u64().unwrap())).collect();
|
||
assert_eq!(marks, [("a".to_string(), 1), ("b".to_string(), 2)]);
|
||
// Without groups the same paths are separate files, each with its own bookmark.
|
||
let apart = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "b.pdf", "a.pdf"], "pages": ["1", null, "3, 2"], "open": true }));
|
||
let apart = ok(&mut a, "bookmark_list", json!({ "doc": apart["document"]["doc"].as_u64().unwrap() }))["bookmarks"].clone();
|
||
assert_eq!(apart.as_array().unwrap().len(), 3);
|
||
// A group names one file; groups must be in step with paths and whole numbers.
|
||
let not_same = a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "groups": [1, 1] }));
|
||
assert!(matches!(not_same, Err(ToolError::InvalidArgs(e)) if e.contains("group 1")));
|
||
for groups in [json!([1]), json!([1, -1]), json!([1, 1.5]), json!(["1", null]), json!(7)] {
|
||
let bad = a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "groups": groups }));
|
||
assert!(matches!(bad, Err(ToolError::InvalidArgs(_))), "{groups}");
|
||
}
|
||
// The largest group number still leaves a key for a file of its own, or says why not.
|
||
let max = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "b.pdf"], "groups": [u64::MAX - 1, null], "open": true }));
|
||
assert_eq!(max["document"]["pages"], 5);
|
||
let full = a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "groups": [u64::MAX, null] }));
|
||
assert!(matches!(full, 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);
|
||
}
|
||
|
||
/// `root/` (with `inside.pdf`) next to `outside/` (with the file `secret.pdf` and the folder
|
||
/// `sub`), all in a fresh temporary directory. Returns (base, canonical root).
|
||
fn sandbox(test: &str) -> (PathBuf, PathBuf) {
|
||
let base = std::env::temp_dir().join(format!("pdfcraft-automation-{test}-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
std::fs::create_dir_all(base.join("root")).unwrap();
|
||
std::fs::create_dir_all(base.join("outside/sub")).unwrap();
|
||
std::fs::write(base.join("root/inside.pdf"), fixture(1)).unwrap();
|
||
std::fs::write(base.join("outside/secret.pdf"), fixture(1)).unwrap();
|
||
let root = base.join("root").canonicalize().unwrap();
|
||
(base, root)
|
||
}
|
||
|
||
/// A link `root/<name>` to the directory `target`: a symlink on Unix, a junction on Windows
|
||
/// (which needs no privilege).
|
||
fn link_dir(root: &Path, name: &str, target: &Path) {
|
||
#[cfg(unix)]
|
||
std::os::unix::fs::symlink(target, root.join(name)).unwrap();
|
||
#[cfg(windows)]
|
||
{
|
||
// Rebuilt from components so every separator is `\` (cmd reads `/x` as a switch).
|
||
let (link, target): (PathBuf, PathBuf) = (root.join(name).components().collect(), target.components().collect());
|
||
let status = std::process::Command::new("cmd").arg("/C").arg("mklink").arg("/J").arg(link).arg(target).output().unwrap();
|
||
assert!(status.status.success(), "mklink /J failed: {}", String::from_utf8_lossy(&status.stderr));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn root_refusals_do_not_reveal_what_exists_outside() {
|
||
// Regression test for #136: every path outside the root gets the same refusal, whether it
|
||
// exists, is a file or a folder, or passes through a missing folder.
|
||
let (base, root) = sandbox("root-oracle");
|
||
let mut a = auto(&root);
|
||
let refusal = |p: &str| ToolError::Failed(format!("{p} is outside the allowed directory {}", root.display()));
|
||
let abs = |rel: &str| base.join(rel).to_str().unwrap().to_owned();
|
||
|
||
#[cfg_attr(not(windows), allow(unused_mut))]
|
||
let mut reads = vec![
|
||
"../outside/secret.pdf".to_owned(),
|
||
"../outside/nope.pdf".into(),
|
||
"../outside/secret.pdf/x".into(),
|
||
"../outside/sub".into(),
|
||
"../outside/sub/x".into(),
|
||
"../nowhere/at/all.pdf".into(),
|
||
"missing/../../outside/secret.pdf".into(),
|
||
"missing/../../outside/nope.pdf".into(),
|
||
"../../../../../../../../../../../../../../../../../../../../../../../../x.pdf".into(),
|
||
abs("outside/secret.pdf"),
|
||
abs("outside/nope.pdf"),
|
||
abs("outside/sub/x"),
|
||
abs("nowhere/x.pdf"),
|
||
abs("nowhere/../outside/nope.pdf"),
|
||
abs("outside/../outside/secret.pdf"),
|
||
abs("root/../outside/secret.pdf"),
|
||
];
|
||
#[cfg(windows)]
|
||
{
|
||
reads.extend([
|
||
r"..\outside\secret.pdf".to_owned(),
|
||
r"..\outside/nope.pdf".into(),
|
||
"../outside/secret.pdf.".into(),
|
||
"../outside/secret.pdf ".into(),
|
||
// Another network share or device namespace is refused by name, without contacting
|
||
// it (`.invalid` never resolves, so a regression fails instead of reaching a host).
|
||
r"\\pdfcraft-test.invalid\share\secret.pdf".into(),
|
||
"//pdfcraft-test.invalid/share/secret.pdf".into(),
|
||
r"\\?\UNC\pdfcraft-test.invalid\share\secret.pdf".into(),
|
||
r"\\.\pipe\pdfcraft-test".into(),
|
||
r"\\?\GLOBALROOT\Device\Null".into(),
|
||
]);
|
||
let other = base.join("outside/secret.pdf").canonicalize().unwrap();
|
||
reads.push(other.to_str().unwrap().to_owned()); // the verbatim \\?\C:\… form
|
||
if let Some(drive) = (b'D'..=b'Z').rev().map(|d| format!("{}:\\", d as char)).find(|d| !Path::new(d).exists()) {
|
||
reads.push(format!("{drive}secret.pdf")); // a drive that doesn't exist
|
||
}
|
||
}
|
||
for p in &reads {
|
||
assert_eq!(a.call("doc_open", &json!({ "path": p })).unwrap_err(), refusal(p), "reading {p}");
|
||
}
|
||
|
||
let writes = [
|
||
"../outside/sub/../y.pdf".to_owned(),
|
||
"../outside/nosub/../y.pdf".into(),
|
||
"../outside/y.pdf".into(),
|
||
"../outside/nosub/y.pdf".into(),
|
||
"../outside/secret.pdf".into(),
|
||
"../outside/secret.pdf/y.pdf".into(),
|
||
"new/../../escape.pdf".into(),
|
||
abs("outside/nosub/deeper/y.pdf"),
|
||
];
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "inside.pdf" }))["doc"].as_u64().unwrap();
|
||
for p in &writes {
|
||
assert_eq!(a.call("doc_save", &json!({ "doc": doc, "path": p })).unwrap_err(), refusal(p), "writing {p}");
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
// In a verbatim path `/` is not a separator, so `x/../..` can't climb out of it either.
|
||
for tail in [r"\x/../../outside/v.pdf", r"\x/../../outside/secret.pdf"] {
|
||
let p = format!("{}{tail}", root.display());
|
||
if let Ok(c) = a.call("doc_save", &json!({ "doc": doc, "path": p })) {
|
||
let Content::Json(v) = &c[0] else { panic!("{p}: expected JSON") };
|
||
assert!(Path::new(v["path"].as_str().unwrap()).starts_with(&root), "{p} wrote {v}");
|
||
}
|
||
// (What lands outside the root, if anything, is checked below.)
|
||
}
|
||
}
|
||
|
||
// A link inside the root that leads out of it is refused the same way, below it too.
|
||
link_dir(&base.join("root"), "link", &base.join("outside"));
|
||
for p in ["link/secret.pdf", "link/nope.pdf", "link/sub/x", "link"] {
|
||
assert_eq!(a.call("doc_open", &json!({ "path": p })).unwrap_err(), refusal(p), "reading {p}");
|
||
}
|
||
for p in ["link/new.pdf", "link/nosub/new.pdf", "link/secret.pdf"] {
|
||
assert_eq!(a.call("doc_save", &json!({ "doc": doc, "path": p })).unwrap_err(), refusal(p), "writing {p}");
|
||
}
|
||
// So is a link whose target is gone: whether a link's target exists stays hidden too.
|
||
std::fs::create_dir_all(base.join("outside/gone")).unwrap();
|
||
link_dir(&base.join("root"), "broken", &base.join("outside/gone"));
|
||
std::fs::remove_dir(base.join("outside/gone")).unwrap();
|
||
for p in ["broken", "broken/x.pdf", "broken/x/y.pdf"] {
|
||
assert_eq!(a.call("doc_open", &json!({ "path": p })).unwrap_err(), refusal(p), "reading {p}");
|
||
assert_eq!(a.call("doc_save", &json!({ "doc": doc, "path": p })).unwrap_err(), refusal(p), "writing {p}");
|
||
}
|
||
|
||
// Nothing was written outside the root, and the outside files are untouched.
|
||
let mut left: Vec<String> =
|
||
std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||
left.sort();
|
||
assert_eq!(left, ["secret.pdf", "sub"]);
|
||
assert_eq!(std::fs::read(base.join("outside/secret.pdf")).unwrap(), fixture(1));
|
||
assert!(!base.join("escape.pdf").exists() && !base.join("y.pdf").exists());
|
||
|
||
// Inside the root nothing changes: missing files say so, and existing ones open and save,
|
||
// however the path is spelled.
|
||
let missing = a.call("doc_open", &json!({ "path": "missing-inside.pdf" })).unwrap_err();
|
||
assert!(matches!(&missing, ToolError::Failed(m) if m.starts_with("missing-inside.pdf: ") && !m.contains("outside")), "{missing:?}");
|
||
let not_dir = a.call("doc_open", &json!({ "path": "inside.pdf/x" })).unwrap_err();
|
||
assert!(matches!(¬_dir, ToolError::Failed(m) if !m.contains("outside")), "{not_dir:?}");
|
||
#[cfg_attr(not(windows), allow(unused_mut))]
|
||
let mut opens = vec![
|
||
"inside.pdf".to_owned(),
|
||
"./inside.pdf".into(),
|
||
"sub/../inside.pdf".into(),
|
||
"../root/inside.pdf".into(),
|
||
"../outside/../root/inside.pdf".into(),
|
||
"../nowhere/../root/inside.pdf".into(),
|
||
abs("root/inside.pdf"),
|
||
abs("outside/../root/inside.pdf"),
|
||
abs("nowhere/../root/inside.pdf"),
|
||
root.join("inside.pdf").to_str().unwrap().to_owned(),
|
||
];
|
||
#[cfg(windows)]
|
||
{
|
||
let plain = abs("root/inside.pdf");
|
||
opens.extend([plain.to_lowercase(), plain.to_uppercase(), r"..\root\inside.pdf".into()]);
|
||
}
|
||
for p in &opens {
|
||
ok(&mut a, "doc_open", json!({ "path": p }));
|
||
}
|
||
for p in ["new.pdf", "fresh/dir/new.pdf", "fresh/../also-new.pdf"] {
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": p }));
|
||
}
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": abs("root/abs-new.pdf") }));
|
||
for f in ["new.pdf", "fresh/dir/new.pdf", "also-new.pdf", "abs-new.pdf"] {
|
||
assert!(root.join(f).is_file(), "{f} was written inside the root");
|
||
}
|
||
|
||
// Without a root, paths are used as given.
|
||
let mut free = Automation::new();
|
||
ok(&mut free, "doc_open", json!({ "path": abs("outside/secret.pdf") }));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
#[test]
|
||
fn writing_to_a_folder_touches_nothing_beside_it() {
|
||
// "." names the root itself. Saving there used to stage its temporary file next to the
|
||
// root, outside it, overwriting and then deleting any file of that name. Staging names are
|
||
// random now and a failed rename removes the staging file, so the "is a folder" refusal is
|
||
// what this checks; the listings and the file at the old staging name are canaries.
|
||
let (base, root) = sandbox("root-itself");
|
||
let mut a = auto(&root);
|
||
let beside = base.join(".root.pdfcraft-tmp");
|
||
std::fs::write(&beside, "SENTINEL").unwrap();
|
||
std::fs::create_dir_all(root.join("folder")).unwrap();
|
||
let listing = |dir: &Path| {
|
||
let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||
names.sort();
|
||
names
|
||
};
|
||
let (base_before, folder_before) = (listing(&base), listing(&root.join("folder")));
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "inside.pdf" }))["doc"].as_u64().unwrap();
|
||
for p in [".", "", "folder", "folder/"] {
|
||
let e = a.call("doc_save", &json!({ "doc": doc, "path": p })).unwrap_err();
|
||
assert!(matches!(&e, ToolError::Failed(m) if m.contains("is a folder")), "{p:?}: {e:?}");
|
||
}
|
||
let png = vec![1, 2, 3];
|
||
assert!(a.write_output(".", &png).is_err());
|
||
assert_eq!(std::fs::read_to_string(&beside).unwrap(), "SENTINEL");
|
||
assert_eq!(listing(&base), base_before, "nothing was left beside the root");
|
||
assert_eq!(listing(&root.join("folder")), folder_before, "nothing was left in the folder");
|
||
// `image_save` adds an extension when the path has none, which turned "." into `root.png`
|
||
// beside the root.
|
||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18 }));
|
||
let pic = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/inside_page_1.png"] }))["doc"].as_u64().unwrap();
|
||
for p in [".", "", "folder", "src/.."] {
|
||
let e = a.call("image_save", &json!({ "doc": pic, "page": 1, "image": 1, "path": p })).unwrap_err();
|
||
assert!(matches!(&e, ToolError::Failed(m) if m.contains("is a folder")), "{p:?}: {e:?}");
|
||
}
|
||
assert!(!base.join("root.png").exists() && !root.join("folder.png").exists());
|
||
ok(&mut a, "image_save", json!({ "doc": pic, "page": 1, "image": 1, "path": "folder/picture" }));
|
||
assert!(root.join("folder/picture.png").is_file());
|
||
// Folder outputs may still name the root.
|
||
ok(&mut a, "doc_split", json!({ "doc": doc, "every": 1, "out_dir": "." }));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
#[test]
|
||
fn file_names_from_documents_stay_in_the_output_folder() {
|
||
// Folder outputs name their files after the document. A document name with separators,
|
||
// `..` or (on Windows) a drive letter used to take those files out of the folder, and out
|
||
// of the root.
|
||
let (base, root) = sandbox("doc-names");
|
||
let mut a = auto(&root);
|
||
let names = ["../../escape", "../../escape.pdf", "x/../../../escape.pdf", "/tmp/escape.pdf", r"x\C:escape.pdf", "C:escape.pdf", "..", "."];
|
||
for (i, name) in names.iter().enumerate() {
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "pages": 2, "name": name }))["doc"].as_u64().unwrap();
|
||
let out = format!("out{i}");
|
||
let mut files: Vec<String> = Vec::new();
|
||
let r = ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": out, "dpi": 10 }));
|
||
files.extend(r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_owned()));
|
||
let r = ok(&mut a, "page_extract", json!({ "doc": doc, "pages": [1], "separate": true, "out_dir": out }));
|
||
files.extend(r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_owned()));
|
||
let r = ok(&mut a, "doc_split", json!({ "doc": doc, "every": 1, "out_dir": out }));
|
||
files.extend(r["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap().to_owned()));
|
||
assert_eq!(files.len(), 5, "{name:?}: {files:?}");
|
||
for f in &files {
|
||
let f = Path::new(f);
|
||
assert_eq!(f.parent(), Some(root.join(&out).as_path()), "{name:?} wrote {}", f.display());
|
||
assert!(f.is_file(), "{name:?}: {} exists", f.display());
|
||
}
|
||
}
|
||
// The same for the images a page uses, with a document that has one.
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "inside.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18 }));
|
||
let pic =
|
||
ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/inside_page_1.png"], "name": "../../escape" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "doc_export_all_images", json!({ "doc": pic, "folder": "all" }));
|
||
let files = r["files"].as_array().unwrap();
|
||
assert_eq!(files.len(), 1, "{r}");
|
||
for f in files {
|
||
assert_eq!(Path::new(f["path"].as_str().unwrap()).parent(), Some(root.join("all").as_path()), "{r}");
|
||
}
|
||
let mut left: Vec<String> = std::fs::read_dir(&base).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||
left.sort();
|
||
assert_eq!(left, ["outside", "root"]);
|
||
|
||
// The root itself must be a folder.
|
||
assert!(Automation::new().with_root(root.join("inside.pdf")).is_err());
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
#[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"].as_array().unwrap().len(), 2);
|
||
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 + 2, "info, text, three page images and two session resources");
|
||
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);
|
||
}
|
||
|
||
/// A server in compact mode with `dir` as its root.
|
||
#[cfg(feature = "mcp")]
|
||
fn compact_server(dir: &Path) -> McpServer {
|
||
McpServer::new(auto(dir)).with_compact(true)
|
||
}
|
||
|
||
/// Run a tool through the compact server's `tool_call`.
|
||
#[cfg(feature = "mcp")]
|
||
fn via_tool_call(s: &mut McpServer, id: u64, tool: &str, arguments: Value) -> Value {
|
||
rpc(s, id, "tools/call", json!({ "name": "tool_call", "arguments": { "name": tool, "arguments": arguments } }))
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
fn tool_names(reply: &Value) -> Vec<String> {
|
||
reply["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect()
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn mcp_default_lists_every_tool_and_has_no_meta_tools() {
|
||
let mut s = McpServer::new(Automation::new());
|
||
let names = tool_names(&rpc(&mut s, 1, "tools/list", json!({})));
|
||
assert_eq!(names.len(), tools().len());
|
||
assert!(!names.iter().any(|n| n == "tool_search" || n == "tool_call"));
|
||
assert!(!rpc(&mut s, 2, "initialize", json!({}))["result"]["instructions"].as_str().unwrap().contains("tool_search"));
|
||
// Without --compact the meta tools do not exist.
|
||
assert_eq!(rpc(&mut s, 3, "tools/call", json!({ "name": "tool_search" }))["error"]["code"], -32602);
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn mcp_compact_lists_the_core_tools_and_two_meta_tools() {
|
||
let mut s = McpServer::new(Automation::new()).with_compact(true);
|
||
let list = rpc(&mut s, 1, "tools/list", json!({}));
|
||
let mut expected: Vec<String> = pdfcraft_automation::mcp::COMPACT_CORE_TOOLS.iter().map(|n| n.to_string()).collect();
|
||
expected.extend(["tool_search".to_string(), "tool_call".to_string()]);
|
||
assert_eq!(tool_names(&list), expected);
|
||
let all: Vec<&str> = tools().iter().map(|t| t.name).collect();
|
||
for core in pdfcraft_automation::mcp::COMPACT_CORE_TOOLS {
|
||
assert!(all.contains(core), "core tool {core} is not in the tool table");
|
||
}
|
||
// Core tools are the real definitions, and the list is far smaller than the full one.
|
||
let full = rpc(&mut McpServer::new(Automation::new()), 2, "tools/list", json!({}));
|
||
let core_open = list["result"]["tools"][0].clone();
|
||
assert_eq!(Some(&core_open), full["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "doc_open"));
|
||
assert!(list.to_string().len() * 5 < full.to_string().len(), "compact {} vs full {}", list.to_string().len(), full.to_string().len());
|
||
for meta in &list["result"]["tools"].as_array().unwrap()[pdfcraft_automation::mcp::COMPACT_CORE_TOOLS.len()..] {
|
||
assert_eq!(meta["inputSchema"]["type"], "object");
|
||
}
|
||
let instructions = rpc(&mut s, 3, "initialize", json!({}))["result"]["instructions"].as_str().unwrap().to_string();
|
||
assert!(instructions.contains("tool_search") && instructions.contains("tool_call"), "{instructions}");
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn mcp_compact_tool_search_filters_by_query_and_category() {
|
||
let mut s = McpServer::new(Automation::new()).with_compact(true);
|
||
let mut search = |args: Value| rpc(&mut s, 1, "tools/call", json!({ "name": "tool_search", "arguments": args }));
|
||
|
||
let everything = search(json!({}));
|
||
assert_eq!(everything["result"]["isError"], false);
|
||
assert_eq!(everything["result"]["structuredContent"]["count"], tools().len());
|
||
let first = &everything["result"]["structuredContent"]["tools"][0];
|
||
assert!(first["name"].is_string() && first["description"].is_string() && first["read_only"].is_boolean(), "{first}");
|
||
|
||
let rotate = search(json!({ "query": "ROTATE" }))["result"]["structuredContent"].clone();
|
||
let names: Vec<&str> = rotate["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||
assert!(names.contains(&"page_rotate"), "{names:?}");
|
||
assert!(names.len() < tools().len());
|
||
|
||
let docs = search(json!({ "category": "doc" }))["result"]["structuredContent"].clone();
|
||
let expected = tools().iter().filter(|t| t.name.starts_with("doc_")).count();
|
||
assert!(expected > 0);
|
||
assert_eq!(docs["count"], expected);
|
||
assert!(docs["tools"].as_array().unwrap().iter().all(|t| t["name"].as_str().unwrap().starts_with("doc_") && t["category"] == "doc"));
|
||
|
||
let both = search(json!({ "category": "page", "query": "rotate" }))["result"]["structuredContent"].clone();
|
||
assert!(both["tools"].as_array().unwrap().iter().all(|t| t["name"].as_str().unwrap().starts_with("page_")));
|
||
assert!(both["tools"].as_array().unwrap().iter().any(|t| t["name"] == "page_rotate"));
|
||
|
||
// No match is an empty result that lists the categories, not an error.
|
||
let none = search(json!({ "category": "nonsense" }));
|
||
assert_eq!(none["result"]["isError"], false);
|
||
assert_eq!(none["result"]["structuredContent"]["count"], 0);
|
||
assert!(none["result"]["structuredContent"]["categories"].as_array().unwrap().iter().any(|c| c == "page"));
|
||
|
||
// One tool in full.
|
||
let one = search(json!({ "name": "page_rotate" }))["result"]["structuredContent"]["tool"].clone();
|
||
assert_eq!(one["name"], "page_rotate");
|
||
assert_eq!(one["input_schema"]["type"], "object");
|
||
let def = tools().into_iter().find(|t| t.name == "page_rotate").unwrap();
|
||
assert_eq!(one["input_schema"], def.input_schema);
|
||
assert_eq!(one["description"], def.description);
|
||
|
||
// Bad arguments are tool errors.
|
||
for bad in [json!({ "name": "no_such_tool" }), json!({ "query": 3 }), json!({ "category": ["doc"] }), json!("doc")] {
|
||
let r = search(bad.clone());
|
||
assert_eq!(r["result"]["isError"], true, "{bad}");
|
||
assert!(r["result"]["content"][0]["text"].as_str().is_some_and(|t| !t.is_empty()));
|
||
}
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn mcp_compact_tool_call_dispatches_to_any_tool() {
|
||
let dir = workdir("mcp-compact");
|
||
let mut s = compact_server(&dir);
|
||
let opened = via_tool_call(&mut s, 1, "doc_open", json!({ "path": "a.pdf" }));
|
||
assert_eq!(opened["result"]["isError"], false);
|
||
assert_eq!(opened["result"]["structuredContent"]["pages"], 3);
|
||
|
||
// A tool outside the core list, through the meta tool.
|
||
let deleted = via_tool_call(&mut s, 2, "page_delete", json!({ "doc": 1, "pages": [3] }));
|
||
assert_eq!(deleted["result"]["isError"], false, "{deleted}");
|
||
assert_eq!(deleted["result"]["structuredContent"]["pages"], 2);
|
||
|
||
// The same tool called by name directly still works, and sees the same session.
|
||
let listed = rpc(&mut s, 3, "tools/call", json!({ "name": "doc_list", "arguments": {} }));
|
||
assert_eq!(listed["result"]["isError"], false);
|
||
assert_eq!(listed["result"]["structuredContent"]["documents"][0]["pages"], 2);
|
||
|
||
// Images come back as images.
|
||
let png = via_tool_call(&mut s, 4, "page_render", json!({ "doc": 1, "page": 1, "dpi": 36 }));
|
||
assert_eq!(png["result"]["content"][0]["type"], "image");
|
||
|
||
// `arguments` may be left out for tools that need none.
|
||
assert_eq!(via_tool_call(&mut s, 5, "doc_list", Value::Null)["result"]["isError"], false);
|
||
let no_args = rpc(&mut s, 6, "tools/call", json!({ "name": "tool_call", "arguments": { "name": "doc_list" } }));
|
||
assert_eq!(no_args["result"]["isError"], false);
|
||
|
||
// The result equals what the direct call returns.
|
||
let direct = rpc(&mut s, 7, "tools/call", json!({ "name": "doc_info", "arguments": { "doc": 1 } }));
|
||
let wrapped = via_tool_call(&mut s, 8, "doc_info", json!({ "doc": 1 }));
|
||
assert_eq!(direct["result"], wrapped["result"]);
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn mcp_compact_tool_call_reports_errors_without_panicking() {
|
||
let dir = workdir("mcp-compact-errors");
|
||
let mut s = compact_server(&dir);
|
||
let text = |r: &Value| r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
|
||
|
||
let unknown = via_tool_call(&mut s, 1, "no_such_tool", json!({}));
|
||
assert_eq!(unknown["result"]["isError"], true);
|
||
assert!(text(&unknown).contains("no_such_tool") && text(&unknown).contains("tool_search"), "{}", text(&unknown));
|
||
|
||
// The tool's own validation errors pass through.
|
||
let missing = via_tool_call(&mut s, 2, "doc_open", json!({}));
|
||
assert_eq!(missing["result"]["isError"], true);
|
||
let outside = via_tool_call(&mut s, 3, "doc_open", json!({ "path": "/definitely/not/here.pdf" }));
|
||
assert_eq!(outside["result"]["isError"], true);
|
||
let wrong_type = via_tool_call(&mut s, 4, "page_delete", json!({ "doc": "one", "pages": "all" }));
|
||
assert_eq!(wrong_type["result"]["isError"], true);
|
||
|
||
// Malformed meta-tool arguments.
|
||
for args in [
|
||
json!({}),
|
||
json!({ "name": 7 }),
|
||
json!({ "name": "doc_list", "arguments": [1] }),
|
||
json!("doc_list"),
|
||
Value::Null,
|
||
json!({ "name": "tool_call" }),
|
||
json!({ "name": "tool_search" }),
|
||
] {
|
||
let r = rpc(&mut s, 5, "tools/call", json!({ "name": "tool_call", "arguments": args }));
|
||
assert_eq!(r["result"]["isError"], true, "{args}");
|
||
assert!(!text(&r).is_empty());
|
||
}
|
||
|
||
// Direct calls keep the usual protocol error for an unknown tool, and the session survives all of it.
|
||
assert_eq!(rpc(&mut s, 6, "tools/call", json!({ "name": "nope" }))["error"]["code"], -32602);
|
||
assert_eq!(via_tool_call(&mut s, 7, "doc_open", json!({ "path": "a.pdf" }))["result"]["isError"], false);
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn mcp_compact_tool_search_summarizes_the_first_sentence() {
|
||
let mut s = McpServer::new(Automation::new()).with_compact(true);
|
||
let mut summary = |tool: &str| {
|
||
let found = rpc(
|
||
&mut s,
|
||
1,
|
||
"tools/call",
|
||
json!({ "name": "tool_search", "arguments": { "query": tool, "category": tool.split('_').next().unwrap() } }),
|
||
);
|
||
found["result"]["structuredContent"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == tool).unwrap()["description"]
|
||
.as_str()
|
||
.unwrap()
|
||
.to_string()
|
||
};
|
||
// Abbreviations such as "e.g." do not end the summary.
|
||
assert_eq!(summary("page_number"), "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix).");
|
||
assert_eq!(
|
||
summary("accessibility_fix"),
|
||
"Apply the checker's automatic fix for a rule: primary-language (value: the language, e.g. en-US), title (value: the title; default the current title or file name; also shows it in the title bar) or tab-order (every page tabs in structure order)."
|
||
);
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn mcp_compact_meta_tools_reject_unknown_arguments() {
|
||
let dir = workdir("mcp-compact-keys");
|
||
let mut s = compact_server(&dir);
|
||
let list = rpc(&mut s, 1, "tools/list", json!({}));
|
||
let metas = &list["result"]["tools"].as_array().unwrap()[pdfcraft_automation::mcp::COMPACT_CORE_TOOLS.len()..];
|
||
assert_eq!(metas.len(), 2);
|
||
for meta in metas {
|
||
assert_eq!(meta["inputSchema"]["additionalProperties"], false, "{}", meta["name"]);
|
||
}
|
||
let text = |r: &Value| r["error"]["message"].as_str().or_else(|| r["result"]["content"][0]["text"].as_str()).unwrap_or_default().to_string();
|
||
|
||
// A misspelled filter is an error, not an unfiltered listing.
|
||
let typo = rpc(&mut s, 2, "tools/call", json!({ "name": "tool_search", "arguments": { "qurey": "rotate" } }));
|
||
assert_eq!(typo["error"]["code"], -32602, "{typo}");
|
||
assert!(text(&typo).contains("\"qurey\"") && text(&typo).contains("query"), "{}", text(&typo));
|
||
let extra = rpc(&mut s, 3, "tools/call", json!({ "name": "tool_search", "arguments": { "query": "rotate", "extra": 1 } }));
|
||
assert_eq!(extra["error"]["code"], -32602, "{extra}");
|
||
assert!(text(&extra).contains("\"extra\""), "{}", text(&extra));
|
||
|
||
// tool_call rejects extra outer keys before running anything.
|
||
let misspelled =
|
||
rpc(&mut s, 4, "tools/call", json!({ "name": "tool_call", "arguments": { "name": "doc_open", "argumentz": { "path": "a.pdf" } } }));
|
||
assert_eq!(misspelled["error"]["code"], -32602, "{misspelled}");
|
||
assert!(text(&misspelled).contains("\"argumentz\""), "{}", text(&misspelled));
|
||
let extra = rpc(&mut s, 5, "tools/call", json!({ "name": "tool_call", "arguments": { "name": "doc_list", "unexpected": "value" } }));
|
||
assert_eq!(extra["error"]["code"], -32602, "{extra}");
|
||
assert!(text(&extra).contains("\"unexpected\""), "{}", text(&extra));
|
||
assert_eq!(rpc(&mut s, 6, "tools/call", json!({ "name": "doc_list", "arguments": {} }))["result"]["structuredContent"]["documents"], json!([]));
|
||
|
||
// Keys inside "arguments" are left to the target tool, which rejects its unknown ones itself.
|
||
let inner = via_tool_call(&mut s, 7, "doc_list", json!({ "unexpected": "value" }));
|
||
assert_eq!(inner["result"]["isError"], true, "{inner}");
|
||
assert!(text(&inner).contains("doc_list: unknown argument \"unexpected\""), "{}", text(&inner));
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn mcp_compact_tool_call_keeps_the_root_confinement() {
|
||
let root = workdir("mcp-compact-root");
|
||
let outside_dir = workdir("mcp-compact-outside");
|
||
let outside = outside_dir.join("a.pdf").to_string_lossy().into_owned();
|
||
let text = |r: &Value| r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
|
||
|
||
// Direct call and tool_call both refuse a real PDF outside the root, with the same words.
|
||
let mut confined = compact_server(&root);
|
||
let direct = rpc(&mut confined, 1, "tools/call", json!({ "name": "doc_open", "arguments": { "path": outside } }));
|
||
assert_eq!(direct["result"]["isError"], true, "{direct}");
|
||
assert!(text(&direct).contains("is outside the allowed directory"), "{}", text(&direct));
|
||
let wrapped = via_tool_call(&mut confined, 2, "doc_open", json!({ "path": outside }));
|
||
assert_eq!(wrapped["result"]["isError"], true, "{wrapped}");
|
||
assert_eq!(text(&wrapped), text(&direct));
|
||
// Nothing was opened.
|
||
let listed = rpc(&mut confined, 3, "tools/call", json!({ "name": "doc_list", "arguments": {} }));
|
||
assert_eq!(listed["result"]["structuredContent"]["documents"], json!([]));
|
||
|
||
// The same file opens through tool_call on a server without a root, so the refusal came from the root.
|
||
let mut open = McpServer::new(Automation::new()).with_compact(true);
|
||
let opened = via_tool_call(&mut open, 4, "doc_open", json!({ "path": outside }));
|
||
assert_eq!(opened["result"]["isError"], false, "{opened}");
|
||
assert_eq!(opened["result"]["structuredContent"]["pages"], 3);
|
||
|
||
// And a file inside the root still opens through the confined server.
|
||
assert_eq!(via_tool_call(&mut confined, 5, "doc_open", json!({ "path": "a.pdf" }))["result"]["isError"], false);
|
||
}
|
||
|
||
#[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")));
|
||
}
|
||
|
||
/// A flat outline of `n` top-level bookmarks, `Item 0` to `Item n-1`, on one page.
|
||
fn flat_outline_pdf(n: usize) -> Vec<u8> {
|
||
let mut objs: Vec<String> = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(),
|
||
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
|
||
format!("<< /Type /Outlines /First 5 0 R /Last {} 0 R /Count {n} >>", 4 + n),
|
||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||
];
|
||
for k in 0..n {
|
||
let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) };
|
||
let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) };
|
||
objs.push(format!("<< /Title (Item {k}) /Parent 3 0 R{prev}{next} /Dest [4 0 R /Fit] >>"));
|
||
}
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offsets = Vec::new();
|
||
for (i, body) in objs.iter().enumerate() {
|
||
offsets.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
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
|
||
}
|
||
|
||
#[test]
|
||
fn bookmark_list_pages_reach_bookmarks_past_the_hundred_thousandth() {
|
||
let dir = workdir("bookmarks-large");
|
||
std::fs::write(dir.join("large.pdf"), flat_outline_pdf(100_005)).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "large.pdf" }))["doc"].as_u64().unwrap();
|
||
// The default call is the first page of 100, with the offset of the next one.
|
||
let first = ok(&mut a, "bookmark_list", json!({ "doc": doc }));
|
||
assert_eq!((first["bookmarks"].as_array().unwrap().len(), first["next"].clone()), (100, json!(100)));
|
||
assert_eq!((first["bookmarks"][0]["title"].clone(), first["bookmarks"][0]["path"].clone()), (json!("Item 0"), json!([1])));
|
||
let late = ok(&mut a, "bookmark_list", json!({ "doc": doc, "offset": 100_000, "limit": 100 }));
|
||
let titles: Vec<&str> = late["bookmarks"].as_array().unwrap().iter().filter_map(|b| b["title"].as_str()).collect();
|
||
assert_eq!((titles.first().copied(), titles.last().copied(), late["next"].clone()), (Some("Item 100000"), Some("Item 100004"), json!(null)));
|
||
assert!(matches!(a.call("bookmark_list", &json!({ "doc": doc, "limit": 1001 })), Err(ToolError::InvalidArgs(_))));
|
||
assert!(matches!(a.call("bookmark_list", &json!({ "doc": doc, "offset": -1 })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn bookmarks_from_structure_through_tools() {
|
||
let dir = workdir("bookmarks-structure");
|
||
std::fs::write(
|
||
dir.join("tagged.pdf"),
|
||
b"%PDF-1.7
|
||
1 0 obj << /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >> endobj
|
||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >> endobj
|
||
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||
5 0 obj << /Type /StructTreeRoot /K 6 0 R /RoleMap << /Heading /H1 >> >> endobj
|
||
6 0 obj << /S /Document /K [7 0 R << /S /Sect /K 8 0 R >>] >> endobj
|
||
7 0 obj << /S /Heading /Pg 3 0 R /ActualText (Report) >> endobj
|
||
8 0 obj << /S /H2 /Pg 4 0 R /Alt (Findings) >> endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF"
|
||
.as_slice(),
|
||
)
|
||
.unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "tagged.pdf" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "bookmark_from_structure", json!({ "doc": doc }));
|
||
let top = &r["bookmarks"][0];
|
||
assert_eq!(
|
||
(top["title"].as_str(), top["children"][0]["title"].as_str(), top["children"][0]["page"].as_u64()),
|
||
(Some("Untitled"), Some("Report"), Some(1))
|
||
);
|
||
assert_eq!(top["children"][0]["children"][0]["title"], "Findings");
|
||
assert_eq!(top["children"][0]["children"][0]["path"], json!([1, 1, 1]));
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "New bookmarks from structure");
|
||
// An untagged document: an error that says why.
|
||
let plain = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
assert!(matches!(a.call("bookmark_from_structure", &json!({ "doc": plain })), Err(ToolError::Failed(m)) if m.contains("no tagged headings")));
|
||
}
|
||
|
||
#[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 }));
|
||
// Rectangles, ovals and polygons take a fill (#686); "none" removes it. Lines have none.
|
||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" }));
|
||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" }));
|
||
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "fill": "mauve" })), Err(ToolError::InvalidArgs(_))));
|
||
let arrow = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone();
|
||
assert!(matches!(
|
||
a.call("comment_edit", &json!({ "doc": doc, "page": arrow["page"], "index": arrow["index"], "fill": "red" })),
|
||
Err(ToolError::Failed(_))
|
||
));
|
||
|
||
// 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);
|
||
}
|
||
|
||
/// #806: a reply to another reply stays in the listing, at any depth.
|
||
#[test]
|
||
fn nested_replies_stay_in_the_listing() {
|
||
let dir = workdir("nested-replies");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let root = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "ROOT_NOTE", "author": "A" }));
|
||
let root_id = root["comment"]["id"].as_str().unwrap().to_string();
|
||
ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "DIRECT_REPLY", "author": "B" }));
|
||
let listed = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
let root_comment = listed["comments"].as_array().unwrap().iter().find(|c| c["contents"] == "ROOT_NOTE").unwrap().clone();
|
||
let direct_id =
|
||
root_comment["replies"].as_array().unwrap().iter().find(|r| r["contents"] == "DIRECT_REPLY").unwrap()["id"].as_str().unwrap().to_string();
|
||
ok(&mut a, "comment_reply", json!({ "doc": doc, "id": direct_id, "text": "NESTED_REPLY", "author": "C" }));
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [120, 120], "contents": "CONTROL_NOTE", "author": "D" }));
|
||
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
assert_eq!(list["count"], 2, "the two notes stay roots: {list}");
|
||
let root = list["comments"].as_array().unwrap().iter().find(|c| c["contents"] == "ROOT_NOTE").unwrap().clone();
|
||
let replies: Vec<&str> = root["replies"].as_array().unwrap().iter().filter_map(|r| r["contents"].as_str()).collect();
|
||
assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}");
|
||
}
|
||
|
||
#[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" }));
|
||
// Copying wasn't allowed either (#568): no text or images come out, though search works.
|
||
for (tool, args) in [
|
||
("text_extract", json!({ "doc": re })),
|
||
("text_paragraphs", json!({ "doc": re, "page": 1 })),
|
||
("doc_export_text", json!({ "doc": re, "path": "out.txt" })),
|
||
("doc_export_images", json!({ "doc": re, "folder": "pages" })),
|
||
("doc_export_all_images", json!({ "doc": re, "folder": "images" })),
|
||
("doc_export_office", json!({ "doc": re, "path": "out.docx" })),
|
||
] {
|
||
match b.call(tool, &args) {
|
||
Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{tool}: {m}"),
|
||
other => panic!("{tool} is refused: {other:?}"),
|
||
}
|
||
}
|
||
assert!(!dir.join("out.txt").exists() && !dir.join("pages").exists() && !dir.join("out.docx").exists());
|
||
assert_eq!(ok(&mut b, "text_find", json!({ "doc": re, "query": "Page" }))["count"], 3);
|
||
// 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!(ok(&mut b, "text_extract", json!({ "doc": owner }))["pages"][0]["text"].as_str().is_some_and(|t| t.contains("Page")));
|
||
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" }));
|
||
}
|
||
|
||
/// A form secured with "fill-sign" changes and no open password (how secured forms are usually
|
||
/// distributed): its existing signature field can be signed without the permissions password,
|
||
/// the update keeps the encryption, and the signature validates after reopening. A new field
|
||
/// needs the permissions password.
|
||
#[test]
|
||
fn signing_an_encrypted_form_through_tools() {
|
||
let dir = workdir("sign-encrypted");
|
||
let mut a = auto(&dir);
|
||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" }));
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let field = ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "signature", "rect": [20, 200, 180, 250] }))["field"]
|
||
.as_str()
|
||
.unwrap()
|
||
.to_owned();
|
||
ok(&mut a, "doc_protect", json!({ "doc": doc, "permissions_password": "boss", "changes": "fill-sign" }));
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "form.pdf" }));
|
||
let mut b = auto(&dir);
|
||
let form = ok(&mut b, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut b, "doc_info", json!({ "doc": form }))["security"]["protected"], true);
|
||
match b.call(
|
||
"sign_document",
|
||
&json!({ "doc": form, "id": "ada.p12", "password": "secret1", "page": 1, "rect": [200, 200, 380, 250], "out": "new.pdf" }),
|
||
) {
|
||
Err(ToolError::Failed(m)) => assert!(m.contains("adding new signature fields"), "{m}"),
|
||
other => panic!("a new field needs the permissions password: {other:?}"),
|
||
}
|
||
let r = ok(&mut b, "sign_document", json!({ "doc": form, "id": "ada.p12", "password": "secret1", "field": field, "out": "signed.pdf" }));
|
||
assert_eq!((r["signature"]["signer"].as_str(), r["signature"]["status"].as_str()), (Some("Ada Lovelace"), Some("unknown")));
|
||
let original = std::fs::read(dir.join("form.pdf")).unwrap();
|
||
let signed = std::fs::read(dir.join("signed.pdf")).unwrap();
|
||
assert!(signed.starts_with(&original), "an incremental update");
|
||
let mut c = auto(&dir);
|
||
let re = ok(&mut c, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut c, "doc_info", json!({ "doc": re }))["security"]["protected"], true, "still encrypted");
|
||
ok(&mut c, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret1" }));
|
||
let list = ok(&mut c, "sign_list", json!({ "doc": re }));
|
||
assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid")), "{list}");
|
||
}
|
||
|
||
#[test]
|
||
fn combine_opens_protected_files_with_their_passwords() {
|
||
let dir = workdir("combine-passwords");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
// Opens with "open"; only "boss" may assemble pages.
|
||
ok(&mut a, "doc_protect", json!({ "doc": doc, "open_password": "open", "permissions_password": "boss", "changes": "none" }));
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" }));
|
||
let combine = |a: &mut Automation, passwords: Value| {
|
||
a.call("doc_combine", &json!({ "paths": ["locked.pdf", "b.pdf"], "passwords": passwords, "open": true }))
|
||
};
|
||
let err = |r: Result<_, ToolError>| match r {
|
||
Err(ToolError::Failed(m)) => m,
|
||
Err(other) => panic!("expected a failure, got {other:?}"),
|
||
Ok(_) => panic!("expected a failure"),
|
||
};
|
||
assert!(err(combine(&mut a, Value::Null)).contains("password-protected"));
|
||
assert!(err(combine(&mut a, json!(["wrong", null]))).contains("password is wrong"));
|
||
assert!(err(combine(&mut a, json!(["open", null]))).contains("don't allow copying pages"));
|
||
assert!(matches!(combine(&mut a, json!(["boss"])), Err(ToolError::InvalidArgs(_))), "one per path");
|
||
let done = ok(&mut a, "doc_combine", json!({ "paths": ["locked.pdf", "b.pdf"], "passwords": ["boss", null], "open": true }));
|
||
assert!(!done.to_string().contains("boss"), "passwords are never echoed");
|
||
let out = done["document"]["doc"].as_u64().unwrap();
|
||
assert_eq!(page_text(&mut a, out), ["Page 1", "Page 2", "Page 3", "Page 1", "Page 2"]);
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": out }))["security"]["protected"], false, "the result is not encrypted");
|
||
}
|
||
|
||
/// Restrictions exist only behind a permissions password: open_password alone encrypts and
|
||
/// restricts nothing, and asking for a restriction without one is refused rather than ignored (#134).
|
||
#[test]
|
||
fn protecting_with_an_open_password_alone_restricts_nothing() {
|
||
let dir = workdir("protect-open-only");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
for (key, value) in [("copy", json!(false)), ("changes", json!("none")), ("printing", json!("none")), ("accessibility", json!(false))] {
|
||
match a.call("doc_protect", &json!({ "doc": doc, "open_password": "openme", key: value })) {
|
||
Err(ToolError::InvalidArgs(m)) => assert!(m.contains(key) && m.contains("permissions_password"), "{key}: {m}"),
|
||
other => panic!("{key} without permissions_password: {other:?}"),
|
||
}
|
||
}
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["security"]["protected"], false, "a refused call changes nothing");
|
||
let r = ok(&mut a, "doc_protect", json!({ "doc": doc, "open_password": "openme" }));
|
||
assert_eq!(
|
||
(r["security"]["protected"].as_bool(), r["security"]["copy"].as_bool(), r["security"]["modify"].as_bool()),
|
||
(Some(true), Some(true), Some(true))
|
||
);
|
||
assert_eq!(r["security"]["printing"], "high");
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "open-only.pdf" }));
|
||
let mut b = auto(&dir);
|
||
let re = ok(&mut b, "doc_open", json!({ "path": "open-only.pdf", "password": "openme" }))["doc"].as_u64().unwrap();
|
||
let s = ok(&mut b, "doc_info", json!({ "doc": re }))["security"].clone();
|
||
assert_eq!(
|
||
(s["protected"].as_bool(), s["copy"].as_bool(), s["modify"].as_bool(), s["printing"].as_str()),
|
||
(Some(true), Some(true), Some(true), Some("high"))
|
||
);
|
||
ok(&mut b, "page_delete", json!({ "doc": re, "pages": [1] }));
|
||
// The schema says so too.
|
||
let def = tools().into_iter().find(|t| t.name == "doc_protect").unwrap();
|
||
assert!(def.description.contains("permissions_password"), "{}", def.description);
|
||
for key in ["printing", "changes", "copy", "accessibility"] {
|
||
let desc = def.input_schema["properties"][key]["description"].as_str().unwrap();
|
||
assert!(desc.contains("permissions_password"), "{key}: {desc}");
|
||
}
|
||
}
|
||
|
||
/// AES-256 keys on the SASLprep form of a password, which drops soft hyphens: a password made
|
||
/// only of them is no password at all (as the permissions password, it would give everyone owner
|
||
/// rights), so it is refused rather than written.
|
||
#[test]
|
||
fn protecting_with_a_password_encryption_ignores_is_refused() {
|
||
let dir = workdir("protect-ignored");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let answers: Vec<String> = ["open_password", "permissions_password"]
|
||
.into_iter()
|
||
.map(|key| match a.call("doc_protect", &json!({ "doc": doc, key: "\u{AD}" })) {
|
||
Err(ToolError::Failed(m)) => m,
|
||
other => format!("{key}: {other:?}"),
|
||
})
|
||
.collect();
|
||
assert!(answers.iter().all(|m| m.contains("soft hyphens")), "{answers:?}");
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["security"]["protected"], false, "a refused call changes nothing");
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
/// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and
|
||
/// resets through the tools, and the leniency shows up as a repair note.
|
||
#[test]
|
||
fn orphan_form_fields_through_tools() {
|
||
/// One page with two text fields that appear only as page widgets: the AcroForm lists none.
|
||
fn orphan_form() -> Vec<u8> {
|
||
let objs: Vec<&str> = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", // 2
|
||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", // 3
|
||
"<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", // 4
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /Rect [10 200 90 220] /P 3 0 R >>", // 5
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 150 90 170] /P 3 0 R >>", // 6
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", // 7
|
||
];
|
||
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{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
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
|
||
}
|
||
let dir = workdir("orphan-forms");
|
||
std::fs::write(dir.join("orphan.pdf"), orphan_form()).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "orphan.pdf" }))["doc"].as_u64().unwrap();
|
||
let list = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
assert_eq!(list["count"], 2, "{list}");
|
||
let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "alpha": "Filled by an agent" } }));
|
||
assert_eq!(r["undo"], "Fill in alpha");
|
||
let after = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
let alpha = after["fields"].as_array().unwrap().iter().find(|f| f["name"] == "alpha").unwrap();
|
||
assert_eq!(alpha["value"], "Filled by an agent");
|
||
// The page shows it.
|
||
assert_eq!(ok(&mut a, "text_find", json!({ "doc": doc, "query": "Filled by an agent" }))["count"], 1);
|
||
// The leniency is not silent: the repair note names the adopted fields.
|
||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
assert!(info["repairs"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").contains("page annotations")), "{}", info["repairs"]);
|
||
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"] == "alpha").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 watermarks_move_from_the_page_centre_through_tools() {
|
||
let dir = workdir("wm-offset");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
// The top-left corner of "DRAFT" on page 1, in points (y grows downwards).
|
||
// Saved and reopened, so the search reads the file as another program would.
|
||
let mut saved = 0;
|
||
let mut corner = |a: &mut Automation| -> (f64, f64) {
|
||
saved += 1;
|
||
let path = format!("moved{saved}.pdf");
|
||
ok(a, "doc_save", json!({ "doc": doc, "path": path }));
|
||
let copy = ok(a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
|
||
let found = ok(a, "text_find", json!({ "doc": copy, "query": "DRAFT" }));
|
||
let rect =
|
||
found["matches"][0]["rects"][0].as_array().unwrap_or_else(|| panic!("DRAFT is on the page: {found}; text: {:?}", page_text(a, copy)));
|
||
(rect[0].as_f64().unwrap(), rect[1].as_f64().unwrap())
|
||
};
|
||
// Both sit above the fixture's "Page 1" (at the page centre), or their letters would mix.
|
||
ok(&mut a, "doc_watermark", json!({ "doc": doc, "pages": [1], "text": "DRAFT", "rotation": 0, "font_size": 20, "offset_y": 60 }));
|
||
let centred = corner(&mut a);
|
||
ok(
|
||
&mut a,
|
||
"doc_watermark",
|
||
json!({ "doc": doc, "pages": [1], "text": "DRAFT", "rotation": 0, "font_size": 20, "offset_x": 30, "offset_y": 100, "replace": true }),
|
||
);
|
||
let moved = corner(&mut a);
|
||
assert!((moved.0 - centred.0 - 30.0).abs() < 1.0, "right: {centred:?} → {moved:?}");
|
||
assert!((centred.1 - moved.1 - 40.0).abs() < 1.0, "up is towards the top: {centred:?} → {moved:?}");
|
||
// Huge offsets are clamped, and a value that isn't a number is refused.
|
||
ok(&mut a, "doc_watermark", json!({ "doc": doc, "pages": [1], "text": "DRAFT", "offset_y": 1e300, "replace": true }));
|
||
assert!(matches!(a.call("doc_watermark", &json!({ "doc": doc, "text": "X", "offset_x": "left" })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[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"));
|
||
ok(&mut a, "accessibility_report", json!({ "doc": doc, "path": "manual-report.html", "rules": ["scripts"] }));
|
||
let manual_html = std::fs::read_to_string(dir.join("manual-report.html")).unwrap();
|
||
assert!(manual_html.contains("<p>Needs manual check: 1</p>"));
|
||
assert!(!manual_html.contains("no problems"));
|
||
// 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 tags = ok(&mut a, "accessibility_tags", json!({ "doc": fd }));
|
||
assert_eq!(tags["count"], 3);
|
||
assert_eq!(tags["tags"][1]["object"], 7);
|
||
assert_eq!(tags["tags"][1]["type"], "Figure");
|
||
assert_eq!(tags["tags"][1]["parent"], json!({ "object": 6, "generation": 0 }));
|
||
assert_eq!(tags["tags"][1]["depth"], 1);
|
||
let changed = ok(
|
||
&mut a,
|
||
"accessibility_set_tag_property",
|
||
json!({
|
||
"doc": fd, "object": 7, "generation": 0, "property": "title", "value": "First shape"
|
||
}),
|
||
);
|
||
assert_eq!(changed["tags"][1]["title"], "First shape");
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Change tag properties");
|
||
assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": fd }))["tags"][1]["title"], Value::Null);
|
||
ok(&mut a, "accessibility_set_tag_property", json!({ "doc": fd, "object": 8, "generation": 0, "property": "title", "value": "Second shape" }));
|
||
let moved = ok(
|
||
&mut a,
|
||
"accessibility_move_tag",
|
||
json!({ "doc": fd, "object": 8, "generation": 0, "anchor": 7, "anchor_generation": 0, "placement": "before" }),
|
||
);
|
||
assert_eq!((moved["tags"][1]["object"].as_u64(), moved["tags"][2]["object"].as_u64()), (Some(8), Some(7)));
|
||
ok(&mut a, "doc_save", json!({ "doc": fd, "path": "reordered.pdf" }));
|
||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("reordered.pdf")).unwrap())).unwrap();
|
||
assert_eq!(pdfcraft_engine::a11y::tags(&saved).into_iter().map(|tag| tag.title).collect::<Vec<_>>(), [None, Some("Second shape".into()), None]);
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Move object");
|
||
assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": fd }))["tags"][1]["object"], 7);
|
||
assert!(matches!(
|
||
a.call(
|
||
"accessibility_move_tag",
|
||
&json!({ "doc": fd, "object": 8, "generation": 0, "anchor": 7, "anchor_generation": 0, "placement": "sideways" })
|
||
),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
assert!(matches!(
|
||
a.call(
|
||
"accessibility_set_tag_property",
|
||
&json!({
|
||
"doc": fd, "object": 7, "generation": 1, "property": "title", "value": "Wrong generation"
|
||
})
|
||
),
|
||
Err(ToolError::Failed(_))
|
||
));
|
||
let findings = ok(&mut a, "accessibility_check", json!({ "doc": fd, "rules": ["figures-alt-text"] }));
|
||
let figures_rule = findings["results"].as_array().unwrap().iter().find(|x| x["rule"] == "figures-alt-text").unwrap();
|
||
assert_eq!(figures_rule["findings"][0]["object"], 7);
|
||
assert_eq!(figures_rule["findings"][0]["object_generation"], 0);
|
||
assert_eq!(figures_rule["findings"][0]["bbox_user_space"], json!([10.0, 150.0, 50.0, 170.0]));
|
||
assert_eq!(figures_rule["findings"][1]["object"], 8);
|
||
assert_eq!(figures_rule["findings"][1]["object_generation"], 0);
|
||
assert_eq!(figures_rule["findings"][1]["bbox_user_space"], json!([100.0, 100.0, 150.0, 130.0]));
|
||
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 accessibility_review_record_compares_two_versions_without_changing_them() {
|
||
let dir = workdir("a11y-review-record");
|
||
let source = std::fs::read(dir.join("a.pdf")).unwrap();
|
||
let mut a = auto(&dir);
|
||
let before = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let after = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "accessibility_fix", json!({ "doc": after, "rule": "primary-language", "value": "en-GB" }));
|
||
|
||
let output = ok(&mut a, "accessibility_review_record", json!({ "before": before, "after": after, "path": "review.json" }));
|
||
let file: Value = serde_json::from_slice(&std::fs::read(dir.join("review.json")).unwrap()).unwrap();
|
||
assert_eq!(output["record"], file);
|
||
assert_eq!(file["schema"], 1);
|
||
assert_eq!(file["before"]["sha256"].as_str().unwrap().len(), 64);
|
||
assert_eq!(file["after"]["sha256"].as_str().unwrap().len(), 64);
|
||
assert_ne!(file["before"]["sha256"], file["after"]["sha256"]);
|
||
assert_eq!(file["changed"], json!(["primary-language"]));
|
||
let language = file["rules"].as_array().unwrap().iter().find(|r| r["rule"] == "primary-language").unwrap();
|
||
assert_eq!(language["before"]["status"], "failed");
|
||
assert_eq!(language["after"]["status"], "passed");
|
||
assert!(file["review_required"].as_array().unwrap().iter().any(|r| r == "tagged-pdf"));
|
||
let html_output = ok(&mut a, "accessibility_review_html", json!({ "before": before, "after": after, "path": "review.html" }));
|
||
assert_eq!(html_output["record"], file);
|
||
let html = std::fs::read_to_string(dir.join("review.html")).unwrap();
|
||
assert!(html.contains("<html lang=\"en\">") && html.contains("Accessibility checker outcomes"));
|
||
assert!(html.contains("Primary language") && html.contains("<td>Changed</td>"));
|
||
assert!(html.contains("Still requiring review") && html.contains("Tagged PDF"));
|
||
assert!(html.contains(file["before"]["sha256"].as_str().unwrap()));
|
||
assert!(html.contains("validate the final PDF independently"));
|
||
assert_eq!(std::fs::read(dir.join("a.pdf")).unwrap(), source);
|
||
assert!(matches!(
|
||
a.call("accessibility_review_record", &json!({ "before": before, "after": before, "path": "same.json" })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
assert!(!dir.join("same.json").exists());
|
||
assert!(matches!(
|
||
a.call("accessibility_review_html", &json!({ "before": before, "after": before, "path": "same.html" })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
assert!(!dir.join("same.html").exists());
|
||
assert!(a.call("accessibility_review_record", &json!({ "before": before, "after": after, "path": "../escape.json" })).is_err());
|
||
assert!(a.call("accessibility_review_html", &json!({ "before": before, "after": after, "path": "../escape.html" })).is_err());
|
||
assert!(!dir.parent().unwrap().join("escape.html").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn accessibility_review_sidecar_tracks_human_decisions_for_exact_pdf_revision() {
|
||
let dir = workdir("a11y-review-sidecar");
|
||
let source = std::fs::read(dir.join("a.pdf")).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let decisions = json!([
|
||
{ "rule": "logical-reading-order", "decision": "needs_work", "page": 1, "note": "Check heading order" },
|
||
{ "rule": "figures-alt-text", "decision": "accepted", "page": 2 }
|
||
]);
|
||
let saved = ok(&mut a, "accessibility_review_save", json!({ "doc": doc, "path": "review.json", "decisions": decisions }));
|
||
assert_eq!(saved["review"]["schema"], 1);
|
||
assert_eq!(saved["review"]["sha256"].as_str().unwrap().len(), 64);
|
||
let original_sidecar = std::fs::read(dir.join("review.json")).unwrap();
|
||
assert_eq!(ok(&mut a, "accessibility_review_load", json!({ "doc": doc, "path": "review.json" }))["review"], saved["review"]);
|
||
let mut reopened = auto(&dir);
|
||
let reopened_doc = ok(&mut reopened, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut reopened, "accessibility_review_load", json!({ "doc": reopened_doc, "path": "review.json" }))["review"], saved["review"]);
|
||
assert!(matches!(
|
||
reopened.call("accessibility_review_save", &json!({ "doc": reopened_doc, "path": "a.pdf", "decisions": [], "replace": true })),
|
||
Err(ToolError::Failed(_))
|
||
));
|
||
assert_eq!(std::fs::read(dir.join("a.pdf")).unwrap(), source);
|
||
|
||
assert!(matches!(
|
||
a.call(
|
||
"accessibility_review_save",
|
||
&json!({ "doc": doc, "path": "review.json", "decisions": [
|
||
{ "rule": "title", "decision": "accepted" }, { "rule": "title", "decision": "skipped" }
|
||
] })
|
||
),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
assert_eq!(std::fs::read(dir.join("review.json")).unwrap(), original_sidecar);
|
||
ok(&mut a, "accessibility_fix", json!({ "doc": doc, "rule": "primary-language", "value": "en-GB" }));
|
||
assert!(matches!(a.call("accessibility_review_load", &json!({ "doc": doc, "path": "review.json" })), Err(ToolError::Failed(_))));
|
||
assert!(matches!(a.call("accessibility_review_save", &json!({ "doc": doc, "path": "review.json", "decisions": [] })), Err(ToolError::Failed(_))));
|
||
assert_eq!(std::fs::read(dir.join("review.json")).unwrap(), original_sidecar);
|
||
let replacement = ok(&mut a, "accessibility_review_save", json!({ "doc": doc, "path": "review.json", "decisions": decisions, "replace": true }));
|
||
assert_ne!(replacement["review"]["sha256"], saved["review"]["sha256"]);
|
||
assert_eq!(ok(&mut a, "accessibility_review_load", json!({ "doc": doc, "path": "review.json" }))["review"], replacement["review"]);
|
||
assert_eq!(std::fs::read(dir.join("a.pdf")).unwrap(), source);
|
||
assert!(a.call("accessibility_review_save", &json!({ "doc": doc, "path": "../escape.json", "decisions": [] })).is_err());
|
||
assert!(!dir.parent().unwrap().join("escape.json").exists());
|
||
|
||
// Malformed or oversized files are not sidecars: loading fails cleanly and
|
||
// saving leaves them alone, without reading more than the size cap.
|
||
let oversized = [b"{\"schema\": 1, \"pad\": \"".as_slice(), &vec![b'x'; (1 << 20) + 16], b"\"}"].concat();
|
||
for (name, bytes) in [("malformed.json", b"{ not json".to_vec()), ("binary.json", vec![0xff, 0x00, 0xfe]), ("oversized.json", oversized)] {
|
||
std::fs::write(dir.join(name), &bytes).unwrap();
|
||
assert!(matches!(a.call("accessibility_review_load", &json!({ "doc": doc, "path": name })), Err(ToolError::Failed(_))), "{name}");
|
||
assert!(
|
||
matches!(
|
||
a.call("accessibility_review_save", &json!({ "doc": doc, "path": name, "decisions": [], "replace": true })),
|
||
Err(ToolError::Failed(_))
|
||
),
|
||
"{name}"
|
||
);
|
||
assert_eq!(std::fs::read(dir.join(name)).unwrap(), bytes, "{name}");
|
||
}
|
||
}
|
||
|
||
#[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}");
|
||
}
|
||
|
||
/// One page whose content is one stream in three pieces (#155): a `TJ` array ends the middle
|
||
/// piece and its operator starts the last one.
|
||
fn split_streams_pdf() -> Vec<u8> {
|
||
let piece = |s: &str| format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len());
|
||
let objs: Vec<String> = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents [4 0 R 5 0 R 6 0 R] /Resources << /Font << /F1 7 0 R >> >> >>".into(),
|
||
piece("/P << /MCID 0"),
|
||
piece(">> BDC BT /F1 12 Tf 72 700 Td (Target) Tj 0 -20 Td [(After) -20 (wards)]"),
|
||
piece("TJ ET EMC"),
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||
];
|
||
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{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
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
|
||
}
|
||
|
||
#[test]
|
||
fn a_line_split_across_content_streams_is_listed_and_edited() {
|
||
let dir = workdir("split-streams");
|
||
std::fs::write(dir.join("split.pdf"), split_streams_pdf()).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "split.pdf" }))["doc"].as_u64().unwrap();
|
||
let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 }));
|
||
let texts: Vec<&str> = lines["lines"].as_array().unwrap().iter().filter_map(|l| l["text"].as_str()).collect();
|
||
assert_eq!(texts, ["Target", "Afterwards"], "{lines}");
|
||
let r = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "line": 2, "text": "Later" }));
|
||
assert_eq!(r["line"]["text"], "Later");
|
||
assert_eq!(page_text(&mut a, doc)[0].split_whitespace().collect::<Vec<_>>(), ["Target", "Later"]);
|
||
}
|
||
|
||
#[test]
|
||
fn paragraph_bold_without_font_keeps_the_source_family() {
|
||
let dir = workdir("paragraph-bold");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 2, "paragraph": 1, "font": "times" }));
|
||
let bold = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 2, "paragraph": 1, "bold": true }));
|
||
assert_eq!(bold["paragraph"]["font"], "Times-Bold");
|
||
let regular = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 2, "paragraph": 1, "bold": false }));
|
||
assert_eq!(regular["paragraph"]["font"], "Times-Roman");
|
||
assert_eq!(regular["paragraph"]["text"], "Page 2");
|
||
assert!(matches!(a.call("text_edit", &json!({ "doc": doc, "page": 2, "paragraph": 1, "bold": "yes" })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[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 enhance_camera_image_through_tools() {
|
||
let dir = workdir("enhance-camera");
|
||
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": 72, "pages": [1] }));
|
||
let photo = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap();
|
||
// A 200 × 300 pt page at one pixel per point; the corners, in any order, keep a 160 × 240 part.
|
||
let r = ok(&mut a, "page_enhance_camera", json!({ "doc": photo, "page": 1, "corners": [180, 270, 20, 30, 20, 270, 180, 30] }));
|
||
assert_eq!(r["undo"], "Enhance camera image");
|
||
assert_eq!(r["pages"], 1);
|
||
let images = ok(&mut a, "page_images", json!({ "doc": photo, "page": 1 }));
|
||
assert_eq!(images["count"], 1);
|
||
assert_eq!(images["images"][0]["pixels"], json!([160, 240]));
|
||
assert_eq!(images["images"][0]["rect"], json!([0.0, 0.0, 160.0, 240.0]));
|
||
for bad in [json!([0, 0, 10, 10, 20, 20, 30, 30]), json!([0, 0, 10]), json!("corners")] {
|
||
let e = a.call("page_enhance_camera", &json!({ "doc": photo, "page": 1, "corners": bad }));
|
||
assert!(matches!(e, Err(ToolError::InvalidArgs(_) | ToolError::Failed(_))), "{bad}: {e:?}");
|
||
}
|
||
// Without corners on a page with no photo, the edges can't be found: an error that says so.
|
||
let e = a.call("page_enhance_camera", &json!({ "doc": photo, "page": 1 }));
|
||
assert!(matches!(&e, Err(ToolError::Failed(m)) if m.contains("corners")), "{e:?}");
|
||
assert!(matches!(
|
||
a.call("page_enhance_camera", &json!({ "doc": photo, "page": 9, "corners": [0, 0, 9, 0, 9, 9, 0, 9] })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn enhance_camera_image_finds_the_edges_by_itself() {
|
||
let dir = workdir("enhance-camera-auto");
|
||
// A 600 × 450 photo: a white sheet, tilted, on a dark table.
|
||
let sheet = [[90.0, 60.0], [500.0, 30.0], [525.0, 405.0], [60.0, 375.0]];
|
||
let inside = |x: f64, y: f64| {
|
||
(0..4).all(|i| {
|
||
let (a, b): ([f64; 2], [f64; 2]) = (sheet[i], sheet[(i + 1) % 4]);
|
||
(b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]) >= 0.0
|
||
})
|
||
};
|
||
let img = image::RgbImage::from_fn(600, 450, |x, y| {
|
||
if inside(x as f64 + 0.5, y as f64 + 0.5) { image::Rgb([240, 238, 232]) } else { image::Rgb([70, 60, 50]) }
|
||
});
|
||
img.save(dir.join("photo.jpg")).unwrap();
|
||
let mut a = auto(&dir);
|
||
let photo = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["photo.jpg"] }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "page_enhance_camera", json!({ "doc": photo, "page": 1 }));
|
||
assert_eq!(r["detected"], true);
|
||
assert_eq!(r["undo"], "Enhance camera image");
|
||
// A clean-up level outside 0–100 is refused before anything changes.
|
||
let e = a.call("page_enhance_camera", &json!({ "doc": photo, "page": 1, "corners": [0, 0, 100, 0, 100, 100, 0, 100], "level": 101 }));
|
||
assert!(matches!(&e, Err(ToolError::InvalidArgs(m)) if m.contains("level")), "{e:?}");
|
||
ok(&mut a, "page_enhance_camera", json!({ "doc": photo, "page": 1, "corners": [0, 0, 100, 0, 100, 100, 0, 100], "level": 80 }));
|
||
// One point per pixel: the corners it used are the sheet's.
|
||
let used: Vec<f64> = r["corners"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||
for (k, p) in sheet.iter().enumerate() {
|
||
let (x, y) = (used[2 * k], used[2 * k + 1]);
|
||
assert!((x - p[0]).hypot(y - p[1]) < 4.0, "{used:?} vs {sheet:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn editing_form_artwork_through_tools() {
|
||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
|
||
let dir = workdir("form-artwork");
|
||
let mut cos = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||
let page = pdfcraft_model::pages(&cos)[0].clone();
|
||
let mut form = Dict::new();
|
||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||
form.set(b"BBox".to_vec(), Object::Array([0, 0, 80, 40].map(Object::Int).to_vec()));
|
||
let artwork = cos.add(Object::Stream(Stream::flate(form, b"0.2 0.5 0.9 rg 0 0 80 40 re f")));
|
||
let contents = cos.add(Object::Stream(Stream::flate(Dict::new(), b"q 1 0 0 1 20 240 cm /Figure Do Q")));
|
||
cos.update_dict(page.obj, |d| {
|
||
d.set(b"Contents".to_vec(), Object::Ref(contents));
|
||
let mut xo = Dict::new();
|
||
xo.set(b"Figure".to_vec(), Object::Ref(artwork));
|
||
let mut resources = Dict::new();
|
||
resources.set(b"XObject".to_vec(), Object::Dict(xo));
|
||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||
})
|
||
.unwrap();
|
||
std::fs::write(dir.join("figure.pdf"), write_incremental(&cos, &SaveOptions::default()).unwrap()).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({"path": "figure.pdf"}))["doc"].as_u64().unwrap();
|
||
let before = ok(&mut a, "page_images", json!({"doc": doc, "page": 1}))["images"][0].clone();
|
||
assert_eq!(before["kind"], "form");
|
||
assert_eq!(before["pixels"], json!([0, 0]));
|
||
assert_eq!(before["rect"], json!([20.0, 20.0, 100.0, 60.0]));
|
||
let rendered_before = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap();
|
||
assert!(a.call("image_save", &json!({"doc": doc, "page": 1, "image": 1, "path": "out/figure"})).is_err());
|
||
ok(&mut a, "image_edit", json!({"doc": doc, "page": 1, "image": 1, "action": "move", "rect": [40, 50, 120, 90]}));
|
||
assert_eq!(ok(&mut a, "page_images", json!({"doc": doc, "page": 1}))["images"][0]["rect"], json!([40.0, 50.0, 120.0, 90.0]));
|
||
let rendered_moved = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap();
|
||
assert_ne!(rendered_before, rendered_moved, "the rendered artwork actually moves");
|
||
ok(&mut a, "doc_save", json!({"doc": doc, "path": "out/moved.pdf"}));
|
||
let re = ok(&mut a, "doc_open", json!({"path": "out/moved.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "page_images", json!({"doc": re, "page": 1}))["images"][0]["rect"], json!([40.0, 50.0, 120.0, 90.0]));
|
||
ok(&mut a, "image_edit", json!({"doc": re, "page": 1, "image": 1, "action": "delete"}));
|
||
assert_eq!(ok(&mut a, "page_images", json!({"doc": re, "page": 1}))["count"], 0);
|
||
ok(&mut a, "edit_undo", json!({"doc": re}));
|
||
assert_eq!(ok(&mut a, "page_images", json!({"doc": re, "page": 1}))["count"], 1);
|
||
ok(&mut a, "edit_undo", json!({"doc": doc}));
|
||
assert_eq!(ok(&mut a, "page_images", json!({"doc": doc, "page": 1}))["images"][0], before);
|
||
assert_eq!(a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap(), rendered_before);
|
||
}
|
||
|
||
#[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:?}");
|
||
// Preferences ▸ Date format, and a one-off pattern.
|
||
let f = ok(&mut a, "fill_sign_date_format", json!({}));
|
||
assert_eq!((f["format"].as_str(), f["today"].as_str()), (Some("m/d/yyyy"), Some("11/14/2023")), "{f}");
|
||
assert_eq!(ok(&mut a, "fill_sign_date_format", json!({ "format": "yyyy.mm.dd." }))["today"], "2023.11.14.");
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 220] }));
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 240], "format": "d \\de mmmm" }));
|
||
let texts = page_text(&mut a, doc);
|
||
assert!(texts[0].contains("2023.11.14.") && texts[0].contains("14 de November"), "{texts:?}");
|
||
assert!(matches!(a.call("fill_sign_date_format", &json!({ "format": "HH:MM" })), Err(ToolError::InvalidArgs(_))));
|
||
assert_eq!(ok(&mut a, "fill_sign_date_format", json!({}))["format"], "yyyy.mm.dd.", "a rejected format keeps the previous one");
|
||
// Month and weekday names in a chosen language, for the preference or one date.
|
||
let f = ok(&mut a, "fill_sign_date_format", json!({ "format": "d. mmmm yyyy", "language": "cs" }));
|
||
assert_eq!((f["language"].as_str(), f["today"].as_str()), (Some("cs"), Some("14. listopadu 2023")), "{f}");
|
||
assert_eq!(f["languages"].as_array().unwrap().len(), pdfcraft_engine::dates::DATE_LANGUAGES.len(), "every date language");
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 280], "format": "d \\de mmmm", "language": "es" }));
|
||
assert!(page_text(&mut a, doc)[0].contains("14 de noviembre"));
|
||
assert!(matches!(a.call("fill_sign_date_format", &json!({ "format": "yyy" })), Err(ToolError::InvalidArgs(_))));
|
||
// A date the PDF's text font can't hold is refused, not saved as "?".
|
||
match a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 300], "format": "dddd", "language": "ja" })) {
|
||
Err(ToolError::InvalidArgs(e)) => assert!(e.contains("火曜日") && e.contains("can't be written into the PDF yet"), "{e}"),
|
||
other => panic!("expected a refusal, got {other:?}"),
|
||
}
|
||
let f = ok(&mut a, "fill_sign_date_format", json!({ "format": "dddd", "language": "ja" }));
|
||
assert_eq!((f["today"].as_str(), f["unwritable"].as_str()), (Some("火曜日"), Some("火曜日")));
|
||
ok(&mut a, "fill_sign_date_format", json!({ "format": "d. mmmm yyyy", "language": "cs" }));
|
||
assert!(matches!(a.call("fill_sign_date_format", &json!({ "format": "dd", "language": "xx" })), Err(ToolError::InvalidArgs(_))));
|
||
assert!(matches!(
|
||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 300], "language": "xx" })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
let f = ok(&mut a, "fill_sign_date_format", json!({ "language": "auto" }));
|
||
assert_eq!((f["format"].as_str(), f["language"].as_str(), f["today"].as_str()), (Some("d. mmmm yyyy"), Some("auto"), Some("14. November 2023")));
|
||
assert!(matches!(
|
||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 260], "format": "Year" })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
assert_eq!(list["count"], 7);
|
||
assert!(matches!(a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [1, 1] })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn fill_sign_text_font_size_is_saved_and_validated() {
|
||
let dir = workdir("fill-sign-text-size");
|
||
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": "Big", "font_size": 24 }));
|
||
let comments = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
let rect = comments["comments"][0]["rect"].as_array().unwrap();
|
||
let height = rect[3].as_f64().unwrap() - rect[1].as_f64().unwrap();
|
||
assert!(height > 30.0, "24 pt text has a taller annotation: {comments}");
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "sized.pdf" }));
|
||
let saved = std::fs::read(dir.join("sized.pdf")).unwrap();
|
||
let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(saved)).unwrap();
|
||
let page = pdfcraft_model::pages(&pdf).remove(0);
|
||
let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap());
|
||
let annotation = pdf.resolve(annotations.as_array().unwrap().first().unwrap());
|
||
let da = annotation.as_dict().unwrap().get(b"DA").unwrap().as_string().unwrap();
|
||
assert!(da.bytes.windows(b"/Helv 24 Tf".len()).any(|w| w == b"/Helv 24 Tf"), "saved annotation uses the requested font size");
|
||
for size in [0, 3, 301] {
|
||
assert!(matches!(
|
||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 80], "text": "No", "font_size": size })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
}
|
||
assert!(matches!(
|
||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 80], "text": "Ada", "font_size": 24 })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn image_signatures_through_tools_preserve_transparency_and_survive_save() {
|
||
let dir = workdir("image-signatures");
|
||
let mut png = Vec::new();
|
||
{
|
||
let mut encoder = png::Encoder::new(&mut png, 120, 40);
|
||
encoder.set_color(png::ColorType::Rgba);
|
||
encoder.set_depth(png::BitDepth::Eight);
|
||
let rgba: Vec<u8> = (0..40)
|
||
.flat_map(|y| (0..120).flat_map(move |x| if (40..80).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [0, 0, 0, 0] }))
|
||
.collect();
|
||
encoder.write_header().unwrap().write_image_data(&rgba).unwrap();
|
||
}
|
||
std::fs::write(dir.join("signature.png"), png).unwrap();
|
||
std::fs::write(dir.join("broken.png"), b"broken").unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300 }))["doc"].as_u64().unwrap();
|
||
for (kind, y) in [("signature", 60), ("initials", 120)] {
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": kind, "at": [20, y], "path": "signature.png" }));
|
||
}
|
||
let render = |a: &mut Automation, doc| {
|
||
let output = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||
let Content::Png { data, .. } = &output[0] else { panic!() };
|
||
image::load_from_memory(data).unwrap().to_rgba8()
|
||
};
|
||
let before = render(&mut a, doc);
|
||
assert_eq!(before.get_pixel(25, 60).0, [255, 255, 255, 255], "transparent margin exposes the page");
|
||
assert_eq!(before.get_pixel(65, 60).0, [0, 0, 0, 255], "signature ink is embedded");
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add initials");
|
||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 1);
|
||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||
// The same resize edit the selection handles use must keep the imported appearance.
|
||
for rect in [json!([20, 40, 20, 88]), json!([20, 40, 100000000, 88])] {
|
||
assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "rect": rect })).is_err());
|
||
}
|
||
ok(&mut a, "comment_lock", json!({ "doc": doc, "page": 1, "index": 1 }));
|
||
assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "rect": [20, 40, 164, 88] })).is_err());
|
||
ok(&mut a, "comment_lock", json!({ "doc": doc, "page": 1, "index": 1, "locked": false }));
|
||
assert_eq!(render(&mut a, doc), before, "invalid and locked resizes leave the image intact");
|
||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 1, "rect": [20, 40, 164, 88] }));
|
||
let resized = render(&mut a, doc);
|
||
assert_eq!(resized.get_pixel(110, 64).0, [0, 0, 0, 255], "resizing scales the original ink");
|
||
assert_eq!(resized.get_pixel(25, 64).0, [255, 255, 255, 255], "resizing retains alpha");
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Resize comment");
|
||
assert_eq!(render(&mut a, doc), before);
|
||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||
assert_eq!(render(&mut a, doc), resized);
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "signed.pdf" }));
|
||
let reopened = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": reopened }))["count"], 2);
|
||
assert_eq!(render(&mut a, reopened), resized, "resized appearances retain alpha and geometry after save");
|
||
for args in [
|
||
json!({ "type": "signature", "path": "broken.png" }),
|
||
json!({ "type": "signature", "path": "signature.png", "text": "Ada" }),
|
||
json!({ "type": "check", "path": "signature.png" }),
|
||
json!({ "type": "signature", "path": "../outside.png" }),
|
||
] {
|
||
let mut args = args;
|
||
args["doc"] = json!(doc);
|
||
args["page"] = json!(1);
|
||
args["at"] = json!([20, 80]);
|
||
assert!(a.call("fill_sign_add", &args).is_err(), "{args}");
|
||
}
|
||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 2, "errors leave the PDF intact");
|
||
}
|
||
|
||
#[test]
|
||
fn scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments() {
|
||
let dir = workdir("scanned-signatures");
|
||
// A grey, unevenly lit sheet with a pen stroke for the signature, and a blank one.
|
||
let photo = |ink: bool| {
|
||
let mut png = Vec::new();
|
||
let mut encoder = png::Encoder::new(&mut png, 400, 300);
|
||
encoder.set_color(png::ColorType::Rgb);
|
||
encoder.set_depth(png::BitDepth::Eight);
|
||
let rgb: Vec<u8> = (0..300_u32)
|
||
.flat_map(|y| {
|
||
(0..400_u32).flat_map(move |x| {
|
||
let paper = (215 - x / 20) as u8;
|
||
if ink && (100..300).contains(&x) && (144..148).contains(&y) { [20, 20, 25] } else { [paper, paper, paper] }
|
||
})
|
||
})
|
||
.collect();
|
||
encoder.write_header().unwrap().write_image_data(&rgb).unwrap();
|
||
png
|
||
};
|
||
std::fs::write(dir.join("photo.png"), photo(true)).unwrap();
|
||
std::fs::write(dir.join("blank.png"), photo(false)).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300 }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 60], "path": "photo.png", "scan": true }));
|
||
let output = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||
let Content::Png { data, .. } = &output[0] else { panic!() };
|
||
let page = image::load_from_memory(data).unwrap().to_rgba8();
|
||
let dark = (20..170).filter(|&x| page.get_pixel(x, 60).0[0] < 80).count();
|
||
assert!(dark > 100, "the stroke is placed as ink across the signature width ({dark} px)");
|
||
for (x, y) in [(22, 50), (100, 50), (100, 70)] {
|
||
assert_eq!(page.get_pixel(x, y).0, [255, 255, 255, 255], "the grey paper is gone at {x},{y}");
|
||
}
|
||
ok(
|
||
&mut a,
|
||
"fill_sign_add",
|
||
json!({ "doc": doc, "page": 1, "type": "initials", "at": [20, 150], "path": "photo.png", "scan": true, "sensitivity": 0 }),
|
||
);
|
||
for args in [
|
||
json!({ "type": "signature", "path": "blank.png", "scan": true }),
|
||
json!({ "type": "signature", "path": "photo.png", "scan": true, "sensitivity": 101 }),
|
||
json!({ "type": "signature", "path": "photo.png", "scan": true, "sensitivity": -1 }),
|
||
json!({ "type": "signature", "path": "photo.png", "sensitivity": 50 }),
|
||
json!({ "type": "signature", "text": "Ada", "scan": true }),
|
||
json!({ "type": "signature", "path": "photo.png", "scan": "yes" }),
|
||
] {
|
||
let mut args = args;
|
||
args["doc"] = json!(doc);
|
||
args["page"] = json!(1);
|
||
args["at"] = json!([20, 80]);
|
||
let error = a.call("fill_sign_add", &args).unwrap_err();
|
||
assert!(matches!(error, ToolError::InvalidArgs(_)), "{args}: {error:?}");
|
||
}
|
||
let Err(ToolError::InvalidArgs(no_ink)) =
|
||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 80], "path": "blank.png", "scan": true }))
|
||
else {
|
||
panic!()
|
||
};
|
||
assert!(no_ink.contains("No signature was found"), "{no_ink}");
|
||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 2, "errors leave the PDF intact");
|
||
}
|
||
|
||
#[test]
|
||
fn image_signature_preview_layers_are_read_only_and_survive_encrypted_save() {
|
||
let dir = workdir("signature-preview");
|
||
let mut png = Vec::new();
|
||
{
|
||
let mut encoder = png::Encoder::new(&mut png, 120, 40);
|
||
encoder.set_color(png::ColorType::Rgba);
|
||
encoder.set_depth(png::BitDepth::Eight);
|
||
let rgba: Vec<u8> = (0..40)
|
||
.flat_map(|y| (0..120).flat_map(move |x| if (40..80).contains(&x) && (10..30).contains(&y) { [20, 40, 60, 128] } else { [0, 0, 0, 0] }))
|
||
.collect();
|
||
encoder.write_header().unwrap().write_image_data(&rgba).unwrap();
|
||
}
|
||
std::fs::write(dir.join("signature.png"), png).unwrap();
|
||
std::fs::write(dir.join("form.pdf"), fixture(1)).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [1], "degrees": 90 }));
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "initials", "at": [20, 40], "path": "signature.png" }));
|
||
let background = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||
let Content::Png { data: expected, .. } = &background[0] else { panic!() };
|
||
let expected = image::load_from_memory(expected).unwrap().to_rgba8();
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [120, 100], "path": "signature.png" }));
|
||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 2, "opacity": 0.5 }));
|
||
ok(&mut a, "doc_protect", json!({ "doc": doc, "open_password": "preview-test" }));
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "signed.pdf" }));
|
||
let reopened = ok(&mut a, "doc_open", json!({ "path": "signed.pdf", "password": "preview-test" }))["doc"].as_u64().unwrap();
|
||
for doc in [doc, reopened] {
|
||
let before = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
let d = a.session().docs().iter().find(|d| d.id.0 == doc).unwrap();
|
||
let generation = d.edit_generation();
|
||
let bytes = d.bytes.clone();
|
||
let output = a.call("comment_image_preview", &json!({ "doc": doc, "page": 1, "index": 2, "dpi": 72 })).unwrap();
|
||
let [Content::Json(meta), Content::Png { data: background, .. }] = output.as_slice() else { panic!("labeled background layer") };
|
||
let image = a.call("comment_image_preview", &json!({ "doc": doc, "page": 1, "index": 2, "layer": "image" })).unwrap();
|
||
let [Content::Json(image_meta), Content::Png { data: signature, width, height }] = image.as_slice() else { panic!("labeled image layer") };
|
||
assert_eq!(meta["layer"], "background");
|
||
assert_eq!(image_meta["layer"], "image");
|
||
assert_eq!(meta["rotation"], 90);
|
||
assert_eq!(meta["image_rotation"], 0, "placed upright as displayed on the turned page");
|
||
assert_eq!(meta["opacity"], 0.5);
|
||
assert_eq!((*width, *height), (120, 40));
|
||
assert_eq!(image::load_from_memory(background).unwrap().to_rgba8(), expected, "page text and the other signature remain");
|
||
let signature = image::load_from_memory(signature).unwrap().to_rgba8();
|
||
assert_eq!(signature.get_pixel(5, 20).0[3], 0);
|
||
assert_eq!(signature.get_pixel(60, 20).0, [20, 40, 60, 128], "embedded alpha and colour survive reopen");
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc })), before);
|
||
let d = a.session().docs().iter().find(|d| d.id.0 == doc).unwrap();
|
||
assert_eq!(d.edit_generation(), generation);
|
||
assert!(std::sync::Arc::ptr_eq(&bytes, &d.bytes));
|
||
assert!(d.image_signature_preview(0, 1).unwrap().unwrap().render_background(f32::NAN).is_err());
|
||
assert!(d.image_signature_preview(0, 999).is_err());
|
||
}
|
||
assert!(tools().iter().find(|t| t.name == "comment_image_preview").unwrap().read_only);
|
||
for dpi in [0, 601] {
|
||
assert!(matches!(
|
||
a.call("comment_image_preview", &json!({ "doc": reopened, "page": 1, "index": 2, "dpi": dpi })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
}
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": reopened, "page": 1, "type": "check", "at": [10, 10] }));
|
||
assert!(a.call("comment_image_preview", &json!({ "doc": reopened, "page": 1, "index": 3 })).is_err());
|
||
}
|
||
|
||
#[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" }));
|
||
// A new text PDF is already compact: Reduce writes nothing rather than a copy no smaller.
|
||
let r = ok(&mut a, "doc_reduce", json!({ "doc": t, "path": "notes-small.pdf" }));
|
||
assert!(r["bytes_after"].as_u64().unwrap() >= r["bytes_before"].as_u64().unwrap(), "{r}");
|
||
assert_eq!(r["written"], false);
|
||
assert!(!dir.join("notes-small.pdf").exists());
|
||
assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn creating_from_text_refuses_what_the_font_cannot_draw() {
|
||
let dir = workdir("create-undrawable");
|
||
std::fs::write(dir.join("kr.txt"), "ABC 제안서 시험").unwrap();
|
||
let mut a = auto(&dir);
|
||
let refused = |r: Result<Vec<Content>, ToolError>| match r {
|
||
Err(ToolError::InvalidArgs(m) | ToolError::Failed(m)) => assert!(m.contains("U+C81C"), "{m}"),
|
||
other => panic!("expected a refusal, got {other:?}"),
|
||
};
|
||
assert!(matches!(a.call("doc_create", &json!({ "from": "text", "text": "ABC 제안서 시험" })), Err(ToolError::InvalidArgs(_))));
|
||
refused(a.call("doc_create", &json!({ "from": "text", "text": "ABC 제안서 시험" })));
|
||
refused(a.call("doc_create", &json!({ "from": "text", "path": "kr.txt" })));
|
||
// WinAnsi characters, line breaks and tabs are still fine.
|
||
let d = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Price: 5 € at café\r\n\tnext\u{c}page two" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(page_text(&mut a, d).len(), 2);
|
||
// #959: page_add_text refuses it too.
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank" }))["doc"].as_u64().unwrap();
|
||
refused(a.call("page_add_text", &json!({ "doc": doc, "page": 1, "text": "ABC 제안서 시험", "at": [20, 20] })));
|
||
}
|
||
|
||
#[test]
|
||
fn creating_from_multiple_files_through_tools() {
|
||
let dir = workdir("create-multiple");
|
||
let mut png = Vec::new();
|
||
{
|
||
let mut enc = png::Encoder::new(&mut png, 4, 2);
|
||
enc.set_color(png::ColorType::Rgb);
|
||
enc.set_depth(png::BitDepth::Eight);
|
||
enc.write_header().unwrap().write_image_data(&[100; 4 * 2 * 3]).unwrap();
|
||
}
|
||
std::fs::write(dir.join("scan.png"), png).unwrap();
|
||
std::fs::write(dir.join("notes.txt"), "hello").unwrap();
|
||
std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap();
|
||
let mut a = auto(&dir);
|
||
|
||
// Combine: every file converted, in the order given, with a bookmark per file.
|
||
let made = ok(
|
||
&mut a,
|
||
"doc_create_multiple",
|
||
json!({ "paths": ["notes.txt", "a.pdf", "scan.png"], "pages": [null, "3", null], "out": "all.pdf", "open": true }),
|
||
);
|
||
let doc = made["document"]["doc"].as_u64().unwrap();
|
||
assert_eq!(page_text(&mut a, doc), ["hello", "Page 3", ""]);
|
||
let titles: Vec<String> = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|b| b["title"].as_str().unwrap().to_string())
|
||
.collect();
|
||
assert_eq!(titles, ["notes", "a", "scan"]);
|
||
assert!(dir.join("all.pdf").is_file());
|
||
// A file that can't be converted fails the whole combine, naming it.
|
||
let err = a.call("doc_create_multiple", &json!({ "paths": ["a.pdf", "report.docx"] })).unwrap_err();
|
||
assert!(matches!(&err, ToolError::Failed(m) if m.contains("report.docx") && m.contains("can't be converted")), "{err}");
|
||
assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": [] })), Err(ToolError::InvalidArgs(_))));
|
||
assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "zip" })), Err(ToolError::InvalidArgs(_))));
|
||
assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "separate" })), Err(ToolError::InvalidArgs(_))));
|
||
|
||
// Separate: one PDF per file; PDFs are skipped, a bad file doesn't stop the rest, and an
|
||
// existing file is never overwritten.
|
||
std::fs::create_dir_all(dir.join("out")).unwrap();
|
||
std::fs::write(dir.join("out/notes.pdf"), b"mine").unwrap();
|
||
let args = json!({ "paths": ["notes.txt", "report.docx", "a.pdf", "scan.png", "missing.txt"], "mode": "separate", "out_dir": "out" });
|
||
let made = ok(&mut a, "doc_create_multiple", args);
|
||
let files = made["files"].as_array().unwrap();
|
||
assert!(files[0]["output"].as_str().unwrap().ends_with("notes (2).pdf"), "{files:?}");
|
||
assert!(files[1]["error"].as_str().unwrap().contains("can't be converted"));
|
||
assert_eq!(files[2]["skipped"], "already a PDF");
|
||
assert!(files[3]["output"].as_str().unwrap().ends_with("scan.pdf"));
|
||
assert!(files[4]["error"].is_string());
|
||
assert_eq!(std::fs::read(dir.join("out/notes.pdf")).unwrap(), b"mine");
|
||
let reopened = ok(&mut a, "doc_open", json!({ "path": "out/notes (2).pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(page_text(&mut a, reopened), ["hello"]);
|
||
assert!(!dir.join("out/a.pdf").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn create_multiple_has_no_file_count_limit() {
|
||
let dir = workdir("create-many");
|
||
let mut a = auto(&dir);
|
||
// The same 3-page PDF listed 1,001 times: every listed file is a source, however many there are.
|
||
let paths = vec!["a.pdf"; 1001];
|
||
let made = ok(&mut a, "doc_create_multiple", json!({ "paths": paths, "out": "many.pdf", "open": true }));
|
||
assert_eq!(made["document"]["pages"], 3 * 1001, "{made}");
|
||
assert!(dir.join("many.pdf").is_file());
|
||
}
|
||
|
||
#[test]
|
||
fn creating_images_with_dpi_through_tools() {
|
||
let dir = workdir("image-dpi");
|
||
let mut png = Vec::new();
|
||
{
|
||
let mut enc = png::Encoder::new(&mut png, 300, 150);
|
||
enc.set_color(png::ColorType::Rgb);
|
||
enc.set_depth(png::BitDepth::Eight);
|
||
enc.set_pixel_dims(Some(png::PixelDimensions { xppu: 11811, yppu: 5906, unit: png::Unit::Meter }));
|
||
enc.write_header().unwrap().write_image_data(&vec![100; 300 * 150 * 3]).unwrap();
|
||
}
|
||
std::fs::write(dir.join("scan.png"), png).unwrap();
|
||
let mut a = auto(&dir);
|
||
for (dpi, width, height) in [(None, 72.0, 72.0), (Some(72.0), 300.0, 150.0), (Some(300.0), 72.0, 36.0)] {
|
||
let mut args = json!({ "from": "images", "paths": ["scan.png"] });
|
||
if let Some(dpi) = dpi {
|
||
args["dpi"] = json!(dpi);
|
||
}
|
||
let doc = ok(&mut a, "doc_create", args)["doc"].as_u64().unwrap();
|
||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
assert!((info["pages"][0]["width"].as_f64().unwrap() - width).abs() < 0.02);
|
||
assert!((info["pages"][0]["height"].as_f64().unwrap() - height).abs() < 0.02);
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "made.pdf" }));
|
||
let reopened = ok(&mut a, "doc_open", json!({ "path": "made.pdf" }))["doc"].as_u64().unwrap();
|
||
let render = a.call("page_render", &json!({ "doc": reopened, "page": 1, "dpi": 72 })).unwrap();
|
||
let Content::Png { width: w, height: h, .. } = &render[0] else { panic!("expected PNG") };
|
||
assert!((*w as f64 - width).abs() <= 1.0 && (*h as f64 - height).abs() <= 1.0);
|
||
}
|
||
for dpi in [0.0, -72.0, 1201.0] {
|
||
assert!(a.call("doc_create", &json!({ "from": "images", "paths": ["scan.png"], "dpi": dpi })).is_err());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments() {
|
||
let dir = workdir("fill-sign-flatten");
|
||
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": [40.0, 80.0], "text": "Hello" }));
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "highlight", "find": "Page", "contents": "keep" }));
|
||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20.0, 120.0, 180.0, 142.0] }));
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "kept.pdf" }));
|
||
let kept = ok(&mut a, "doc_open", json!({ "path": "kept.pdf" }))["doc"].as_u64().unwrap();
|
||
let kept_comments = ok(&mut a, "comment_list", json!({ "doc": kept }))["comments"].as_array().unwrap().clone();
|
||
assert!(kept_comments.iter().any(|c| c["contents"] == "Hello"), "without the flag the typewriter stays: {kept_comments:?}");
|
||
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "flat.pdf", "flatten_fill_sign": true }));
|
||
let flat = ok(&mut a, "doc_open", json!({ "path": "flat.pdf" }))["doc"].as_u64().unwrap();
|
||
let comments = ok(&mut a, "comment_list", json!({ "doc": flat }))["comments"].as_array().unwrap().clone();
|
||
assert!(comments.iter().all(|c| c["contents"] != "Hello"), "the typewriter was baked in: {comments:?}");
|
||
assert!(comments.iter().any(|c| c["contents"] == "keep"), "the highlight stays: {comments:?}");
|
||
let fields = ok(&mut a, "form_fields", json!({ "doc": flat }))["fields"].as_array().unwrap().clone();
|
||
assert_eq!(fields.len(), 1, "the form field stays");
|
||
}
|
||
|
||
#[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 rotating_a_field_through_tools() {
|
||
let dir = workdir("rotate-field");
|
||
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" }));
|
||
let rect_of = |a: &mut Automation| {
|
||
let f = &ok(a, "form_fields", json!({ "doc": doc }))["fields"][0];
|
||
(f["rotation"].as_i64().unwrap(), f["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>())
|
||
};
|
||
let (rot, before) = rect_of(&mut a);
|
||
assert_eq!(rot, 0);
|
||
assert!(matches!(a.call("form_set_props", &json!({ "doc": doc, "field": "City", "rotation": 45 })), Err(ToolError::InvalidArgs(_))));
|
||
assert_eq!(rect_of(&mut a).1, before, "a rejected rotation changes nothing");
|
||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "City", "rotation": 90 }));
|
||
let (rot, turned) = rect_of(&mut a);
|
||
assert_eq!(rot, 90);
|
||
let (bw, bh) = (before[2] - before[0], before[3] - before[1]);
|
||
let (tw, th) = (turned[2] - turned[0], turned[3] - turned[1]);
|
||
assert!((tw - bh).abs() < 0.2 && (th - bw).abs() < 0.2, "swapped {before:?} -> {turned:?}");
|
||
let center = |r: &[f64]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||
let (bc, tc) = (center(&before), center(&turned));
|
||
assert!((bc.0 - tc.0).abs() < 0.2 && (bc.1 - tc.1).abs() < 0.2, "center moved {bc:?} -> {tc:?}");
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change field properties");
|
||
let (rot, back) = rect_of(&mut a);
|
||
assert_eq!((rot, back), (0, before));
|
||
}
|
||
|
||
#[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 redacting_with_codes_through_tools() {
|
||
let dir = workdir("redact-codes");
|
||
std::fs::write(dir.join("memo.txt"), "Informant Jane Roe met the agent\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 bad = |a: &mut Automation, args: Value| matches!(a.call("redact_mark", &args), Err(ToolError::InvalidArgs(_)));
|
||
assert!(bad(&mut a, json!({ "doc": doc, "find": "Jane Roe", "code_set": "gdpr", "codes": ["(b)(6)"] })));
|
||
assert!(bad(&mut a, json!({ "doc": doc, "find": "Jane Roe", "code_set": "foia", "codes": ["(k)(1)"] })));
|
||
assert!(bad(&mut a, json!({ "doc": doc, "find": "Jane Roe", "code_set": "foia", "codes": [] })));
|
||
assert!(bad(&mut a, json!({ "doc": doc, "find": "Jane Roe", "codes": ["(b)(6)"] })));
|
||
assert!(bad(&mut a, json!({ "doc": doc, "find": "Jane Roe", "overlay": "X", "code_set": "foia", "codes": ["(b)(6)"] })));
|
||
let Err(ToolError::InvalidArgs(msg)) =
|
||
a.call("redact_mark", &json!({ "doc": doc, "find": "Jane Roe", "code_set": "foia", "codes": ["(b)(10)"] }))
|
||
else {
|
||
panic!("an unknown code is rejected")
|
||
};
|
||
assert!(msg.contains("(b)(7)(C)"), "the error lists the allowed codes: {msg}");
|
||
assert_eq!(
|
||
ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "Jane Roe", "code_set": "foia", "codes": ["(b)(7)(C)", "(b)(6)"] }))["marked"],
|
||
1
|
||
);
|
||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||
let text = page_text(&mut a, doc)[0].clone();
|
||
assert!(!text.contains("Jane") && text.contains("(b)(6), (b)(7)(C)"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn redacting_word_lists_through_tools() {
|
||
let dir = workdir("redact-words");
|
||
std::fs::write(dir.join("memo.txt"), "Call Ada today\nAda 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, "words": ["Ada", " private ", "absent", "Ada"] }));
|
||
assert_eq!(r["marked"].as_u64(), Some(3), "{r}");
|
||
assert_eq!(r["matched_words"], json!([{ "word": "Ada", "marked": 2 }, { "word": "private", "marked": 1 }, { "word": "absent", "marked": 0 }]));
|
||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Mark for redaction");
|
||
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "words": ["Ada", "private"] }))["marks_pending"].as_u64(), Some(3));
|
||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||
let text = page_text(&mut a, doc)[0].clone();
|
||
assert!(!text.contains("Ada") && !text.contains("private"), "{text}");
|
||
assert!(text.contains("Call") && text.contains("Public line"), "{text}");
|
||
for bad in [json!({ "doc": doc, "words": [] }), json!({ "doc": doc, "words": [" "] }), json!({ "doc": doc, "words": ["x"], "find": "x" })] {
|
||
assert!(matches!(a.call("redact_mark", &bad), Err(ToolError::InvalidArgs(_))), "{bad}");
|
||
}
|
||
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc, "words": ["absent", "nowhere"] })), Err(ToolError::Failed(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn redaction_search_respects_case_and_word_boundaries() {
|
||
let dir = workdir("redact-find-options");
|
||
std::fs::write(dir.join("memo.txt"), "Ada ada Adaline\nAda beta betamax").unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap();
|
||
let mark = |a: &mut Automation, args: Value| ok(a, "redact_mark", args)["marked"].as_u64().unwrap();
|
||
assert_eq!(mark(&mut a, json!({ "doc": doc, "find": "Ada", "case_sensitive": true, "whole_words": true })), 2);
|
||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||
assert_eq!(mark(&mut a, json!({ "doc": doc, "find": "Ada", "whole_words": true })), 3);
|
||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||
let result = ok(&mut a, "redact_mark", json!({ "doc": doc, "words": ["Ada", "beta"], "case_sensitive": true, "whole_words": true }));
|
||
assert_eq!(result["matched_words"], json!([{ "word": "Ada", "marked": 2 }, { "word": "beta", "marked": 1 }]));
|
||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc, "pattern": "email", "whole_words": true })), Err(ToolError::InvalidArgs(_))));
|
||
assert_eq!(mark(&mut a, json!({ "doc": doc, "find": "Ada", "case_sensitive": true, "whole_words": true })), 2);
|
||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||
let text = &page_text(&mut a, doc)[0];
|
||
assert!(text.contains("ada") && text.contains("Adaline") && text.contains("betamax"), "{text}");
|
||
}
|
||
|
||
#[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(_))));
|
||
}
|
||
|
||
/// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle.
|
||
fn faux_redacted_fixture() -> Vec<u8> {
|
||
let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f";
|
||
let objs: Vec<Vec<u8>> = vec![
|
||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(),
|
||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string().into_bytes(),
|
||
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
|
||
}
|
||
|
||
#[test]
|
||
fn auditing_faux_redactions_through_tools() {
|
||
let dir = workdir("faux-redaction-audit");
|
||
std::fs::write(dir.join("redacted.pdf"), faux_redacted_fixture()).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "redacted.pdf" }))["doc"].as_u64().unwrap();
|
||
let r = ok(&mut a, "doc_audit_redactions", json!({ "doc": doc }));
|
||
assert_eq!(r["count"], 1, "{r}");
|
||
let f = &r["findings"][0];
|
||
assert_eq!(f["page"], 1);
|
||
assert_eq!(f["kind"], "covered-text");
|
||
assert!(f["covered_text"].as_str().unwrap().contains("TOP SECRET"), "{f}");
|
||
// A clean document audits clean.
|
||
let clean = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "doc_audit_redactions", json!({ "doc": clean }))["count"], 0);
|
||
}
|
||
|
||
#[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 none = ok(&mut a, "printer_options", json!({ "printer": "No_Such_Queue_PdfCraft" }));
|
||
assert_eq!((none["count"].as_u64(), none["options"].as_array().map(Vec::len)), (Some(0), Some(0)));
|
||
assert!(matches!(a.call("printer_options", &json!({})), Err(ToolError::InvalidArgs(_))));
|
||
assert!(matches!(
|
||
a.call("doc_print", &json!({ "doc": doc, "printer": "default", "options": ["InputSlot"] })),
|
||
Err(ToolError::InvalidArgs(m)) if m.contains("printer_options")
|
||
));
|
||
assert!(matches!(
|
||
a.call("doc_print", &json!({ "doc": doc, "printer": "default", "options": { "InputSlot": 2 } })),
|
||
Err(ToolError::InvalidArgs(m)) if m.contains("options.InputSlot")
|
||
));
|
||
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(_))));
|
||
}
|
||
|
||
/// Text styling on an added image is refused (#1331), as image transforms are on text, and the
|
||
/// refusal leaves the content, the dirty flag and the undo and redo steps as they were.
|
||
#[test]
|
||
fn text_styling_on_an_image_is_refused_without_an_edit() {
|
||
let dir = workdir("content-style");
|
||
let mut png = Vec::new();
|
||
{
|
||
let mut enc = png::Encoder::new(&mut png, 1, 1);
|
||
enc.set_color(png::ColorType::Rgb);
|
||
enc.write_header().unwrap().write_image_data(&[80, 160, 200]).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": "sample", "at": [20, 20] }));
|
||
ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "logo.png", "rect": [10, 100, 40, 130] }));
|
||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90 }));
|
||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" }));
|
||
let state = |a: &Automation| {
|
||
let d = a.session().docs().iter().find(|d| d.id.0 == doc).unwrap();
|
||
(format!("{:?}", d.added), d.dirty, d.edit_generation(), d.can_undo().map(str::to_owned), d.can_redo().map(str::to_owned))
|
||
};
|
||
let before = state(&a);
|
||
assert!(!before.1 && before.4.is_some(), "a clean document with a step to redo: {before:?}");
|
||
for (key, value) in [
|
||
("font", json!("times")),
|
||
("size", json!(24)),
|
||
("bold", json!(true)),
|
||
("italic", json!(true)),
|
||
("color", json!("red")),
|
||
("align", json!("center")),
|
||
] {
|
||
for rect in [None, Some(json!([20, 100, 50, 130]))] {
|
||
let mut args = json!({ "doc": doc, "page": 1, "index": 2 });
|
||
args[key] = value.clone();
|
||
if let Some(r) = rect {
|
||
args["rect"] = r;
|
||
}
|
||
assert!(matches!(a.call("content_update", &args), Err(ToolError::InvalidArgs(_))), "{args}");
|
||
assert_eq!(state(&a), before, "{args}");
|
||
}
|
||
}
|
||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "font": "times", "size": 24, "align": "center" }));
|
||
assert!(state(&a).1, "text still takes its styling");
|
||
}
|
||
|
||
#[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 natural_image_stamps_through_tools_on_rotated_pages() {
|
||
for degrees in [0, 90, 180, 270] {
|
||
let dir = workdir(&format!("natural-stamps-{degrees}"));
|
||
let mut bytes = Vec::new();
|
||
{
|
||
let mut encoder = png::Encoder::new(&mut bytes, 80, 40);
|
||
encoder.set_color(png::ColorType::Rgb);
|
||
encoder.set_depth(png::BitDepth::Eight);
|
||
let pixels: Vec<u8> = (0..40)
|
||
.flat_map(|y| {
|
||
(0..80).flat_map(move |x| {
|
||
[[240, 20, 20], [20, 180, 20], [20, 20, 240], [230, 180, 20]][usize::from(y >= 20) * 2 + usize::from(x >= 40)]
|
||
})
|
||
})
|
||
.collect();
|
||
encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
|
||
}
|
||
std::fs::write(dir.join("quadrants.png"), bytes).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_rotate", json!({"doc":doc,"pages":[1],"degrees":degrees}));
|
||
ok(&mut a, "stamp_custom", json!({"doc":doc,"page":1,"path":"quadrants.png","at":[100,100]}));
|
||
let rendered = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap();
|
||
let Content::Png { data, .. } = &rendered[0] else { panic!("expected PNG") };
|
||
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
|
||
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
|
||
let info = reader.next_frame(&mut pixels).unwrap();
|
||
assert_eq!(info.color_type, png::ColorType::Rgba);
|
||
for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([
|
||
[240, 20, 20, 255],
|
||
[20, 180, 20, 255],
|
||
[20, 20, 240, 255],
|
||
[230, 180, 20, 255],
|
||
]) {
|
||
let offset = ((y * info.width + x) * 4) as usize;
|
||
assert_eq!(&pixels[offset..offset + 4], &colour, "page rotation {degrees}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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 bookmark_split_with_equal_titles_keeps_every_part() {
|
||
let dir = workdir("organize_dup_titles");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Same", "page": 1 }));
|
||
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Same", "page": 2 }));
|
||
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "same", "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-Same.pdf", "a-Same-2.pdf", "a-same-3.pdf"]);
|
||
for f in &files {
|
||
assert!(dir.join("parts").join(f).exists(), "{f} missing");
|
||
}
|
||
}
|
||
|
||
#[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, "pages": [1] }));
|
||
assert_eq!(r["removed"], 2);
|
||
assert_eq!(ok(&mut a, "link_list", json!({ "doc": doc }))["count"], 0);
|
||
// Add one more link and test links_remove across all pages
|
||
ok(&mut a, "link_add", json!({ "doc": doc, "page": 1, "rect": [72, 300, 200, 320], "url": "https://example.org" }));
|
||
let r2 = ok(&mut a, "links_remove", json!({ "doc": doc }));
|
||
assert_eq!(r2["removed"], 1);
|
||
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 local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
|
||
let (_base, root) = sandbox("gotor-not-a-file");
|
||
std::fs::create_dir_all(root.join("folder.pdf")).unwrap();
|
||
std::fs::write(root.join("notes.txt"), fixture(1)).unwrap();
|
||
std::fs::write(root.join("dir-source.pdf"), remote_link_pdf("folder.pdf", None)).unwrap();
|
||
std::fs::write(root.join("txt-source.pdf"), remote_link_pdf("notes.txt", None)).unwrap();
|
||
// Only Unix adds a case (a FIFO), so elsewhere the list is never changed.
|
||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||
let mut cases = vec![("dir-source.pdf", "not a regular file"), ("txt-source.pdf", "not a .pdf file")];
|
||
#[cfg(unix)]
|
||
{
|
||
// A FIFO inside the root would block a plain read forever.
|
||
if std::process::Command::new("mkfifo").arg(root.join("pipe.pdf")).status().is_ok_and(|s| s.success()) {
|
||
std::fs::write(root.join("fifo-source.pdf"), remote_link_pdf("pipe.pdf", None)).unwrap();
|
||
cases.push(("fifo-source.pdf", "not a regular file"));
|
||
}
|
||
}
|
||
let mut a = auto(&root);
|
||
for (source_path, reason) in cases {
|
||
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||
let documents_before = a.session().docs().len();
|
||
let e = err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||
assert!(matches!(&e, ToolError::Failed(m) if m.contains(reason)), "{source_path}: {e:?}");
|
||
assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened: {source_path}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn local_gotor_link_open_applies_destinations_and_honors_root() {
|
||
let (base, root) = sandbox("gotor-open");
|
||
std::fs::create_dir_all(root.join("sub")).unwrap();
|
||
std::fs::write(root.join("target.pdf"), fixture(3)).unwrap();
|
||
std::fs::write(root.join("a.pdf"), fixture(1)).unwrap();
|
||
std::fs::write(root.join("sub/source.pdf"), remote_link_pdf("../target.pdf", Some("[2 /FitH 180]"))).unwrap();
|
||
std::fs::write(root.join("sub/escape.pdf"), remote_link_pdf("../../outside/secret.pdf", Some("[0 /Fit]"))).unwrap();
|
||
std::fs::write(root.join("named.pdf"), remote_link_pdf("target.pdf", Some("(chapter)"))).unwrap();
|
||
std::fs::write(root.join("range.pdf"), remote_link_pdf("target.pdf", Some("[99 /Fit]"))).unwrap();
|
||
std::fs::write(root.join("url.pdf"), remote_link_pdf("https://example.org/target.pdf", Some("[0 /Fit]"))).unwrap();
|
||
std::fs::write(root.join("missing-source.pdf"), remote_link_pdf("does-not-exist.pdf", Some("[0 /Fit]"))).unwrap();
|
||
std::fs::write(root.join("not-pdf.pdf"), b"not a PDF").unwrap();
|
||
std::fs::write(root.join("non-pdf-source.pdf"), remote_link_pdf("not-pdf.pdf", Some("[0 /Fit]"))).unwrap();
|
||
std::fs::write(root.join("encrypted.pdf"), protected_fixture("secret")).unwrap();
|
||
std::fs::write(root.join("password-source.pdf"), remote_link_pdf("encrypted.pdf", Some("[0 /Fit]"))).unwrap();
|
||
std::fs::write(root.join("absolute.pdf"), remote_link_pdf(root.join("target.pdf").to_str().unwrap(), Some("[1 /Fit]"))).unwrap();
|
||
link_dir(&root, "outside-link", &base.join("outside"));
|
||
std::fs::write(root.join("symlink.pdf"), remote_link_pdf("outside-link/secret.pdf", Some("[0 /Fit]"))).unwrap();
|
||
|
||
let mut a = auto(&root);
|
||
let source = ok(&mut a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap();
|
||
let listed = ok(&mut a, "link_list", json!({ "doc": source }));
|
||
assert_eq!(listed["links"][0]["other"], "GoToR");
|
||
let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||
assert_eq!(opened["path"], "target.pdf");
|
||
assert_eq!(opened["destination_applied"], true);
|
||
assert_eq!(opened["destination"]["page"], 3);
|
||
assert_eq!(opened["destination"]["view"]["kind"], "fit_h");
|
||
assert_eq!(opened["document"]["pages"], 3);
|
||
let target_id = opened["document"]["doc"].as_u64().unwrap();
|
||
assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3);
|
||
|
||
let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap();
|
||
let before = a.session().docs().len();
|
||
for args in [json!({ "doc": absolute, "page": 1, "index": 1 }), json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": false })] {
|
||
assert!(matches!(err(&mut a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute")));
|
||
assert_eq!(a.session().docs().len(), before);
|
||
}
|
||
let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true }));
|
||
assert_eq!(opened["path"], "target.pdf");
|
||
assert_eq!(opened["destination"]["page"], 2);
|
||
|
||
for (source_path, warning) in [("named.pdf", "named destination"), ("range.pdf", "out of range")] {
|
||
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||
let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||
assert_eq!(opened["destination_applied"], false);
|
||
assert!(opened["warning"].as_str().unwrap().contains(warning), "{opened}");
|
||
}
|
||
|
||
let password_source = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
|
||
let documents_before = a.session().docs().len();
|
||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_)));
|
||
assert_eq!(a.session().docs().len(), documents_before);
|
||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_)));
|
||
assert_eq!(a.session().docs().len(), documents_before);
|
||
let opened = ok(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" }));
|
||
assert_eq!(opened["document"]["encrypted"], true);
|
||
|
||
for source_path in ["url.pdf", "missing-source.pdf", "non-pdf-source.pdf"] {
|
||
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||
let documents_before = a.session().docs().len();
|
||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_)));
|
||
assert_eq!(a.session().docs().len(), documents_before, "a rejected target is not opened: {source_path}");
|
||
}
|
||
|
||
for source_path in ["sub/escape.pdf", "symlink.pdf"] {
|
||
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||
let documents_before = a.session().docs().len();
|
||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside")));
|
||
assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened");
|
||
}
|
||
|
||
let missing_source = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" }));
|
||
assert!(
|
||
matches!(err(&mut a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR"))
|
||
);
|
||
}
|
||
|
||
/// doc_info reports annotation and link rectangles in the tools' displayed-page convention,
|
||
/// the same values comment_list and link_list give, so they can be fed back to link_edit (#129).
|
||
#[test]
|
||
fn doc_info_rects_are_displayed_page_coordinates() {
|
||
let dir = workdir("info-rects");
|
||
let mut a = auto(&dir);
|
||
// Page 2 is rotated so an unrotated-only y flip cannot pass.
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 612, "height": 792, "pages": 2 }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [2], "degrees": 90 }));
|
||
for page in 1..=2 {
|
||
ok(
|
||
&mut a,
|
||
"comment_add",
|
||
json!({ "doc": doc, "page": page, "type": "note", "at": [72, 72], "author": "Example", "contents": "Fixture note" }),
|
||
);
|
||
ok(&mut a, "link_add", json!({ "doc": doc, "page": page, "rect": [72, 100, 200, 120], "url": "https://example.com" }));
|
||
}
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "fixture.pdf" }));
|
||
let re = ok(&mut a, "doc_open", json!({ "path": "fixture.pdf" }))["doc"].as_u64().unwrap();
|
||
let info = ok(&mut a, "doc_info", json!({ "doc": re }));
|
||
let comments = ok(&mut a, "comment_list", json!({ "doc": re }));
|
||
let links = ok(&mut a, "link_list", json!({ "doc": re }));
|
||
let close = |a: &Value, b: &Value| {
|
||
let (a, b) = (a.as_array().unwrap(), b.as_array().unwrap());
|
||
a.len() == 4 && a.iter().zip(b).all(|(x, y)| (x.as_f64().unwrap() - y.as_f64().unwrap()).abs() < 0.01)
|
||
};
|
||
for page in 1..=2 {
|
||
let page = json!(page);
|
||
let find = |items: &Value| items.as_array().unwrap().iter().find(|x| x["page"] == page).unwrap()["rect"].clone();
|
||
let (info_note, note) = (find(&info["annotations"]), find(&comments["comments"]));
|
||
assert!(close(&info_note, ¬e), "page {page}: doc_info note {info_note} vs comment_list {note}");
|
||
if page == 1 {
|
||
assert!(close(¬e, &json!([72.0, 72.0, 92.0, 92.0])), "note {note}");
|
||
}
|
||
let (info_link, link) = (find(&info["links"]), find(&links["links"]));
|
||
assert!(close(&info_link, &link), "page {page}: doc_info link {info_link} vs link_list {link}");
|
||
assert!(close(&link, &json!([72.0, 100.0, 200.0, 120.0])), "page {page}: link {link}");
|
||
}
|
||
// Reusing the doc_info rectangle in a geometry-taking edit leaves the link where it is.
|
||
let rotated = links["links"].as_array().unwrap().iter().find(|l| l["page"] == 2).unwrap().clone();
|
||
let from_info = info["links"].as_array().unwrap().iter().find(|l| l["page"] == 2).unwrap()["rect"].clone();
|
||
ok(&mut a, "link_edit", json!({ "doc": re, "page": 2, "index": rotated["index"], "rect": from_info }));
|
||
let after = ok(&mut a, "link_list", json!({ "doc": re }));
|
||
let moved = after["links"].as_array().unwrap().iter().find(|l| l["page"] == 2).unwrap()["rect"].clone();
|
||
assert!(close(&moved, &rotated["rect"]), "link moved: {moved} vs {}", rotated["rect"]);
|
||
}
|
||
|
||
/// `comment_add` places a note's or an attachment's icon with its displayed top-left corner at
|
||
/// `at`, on rotated pages too. The engine anchors the icon at the user-space top-left of its
|
||
/// `/Rect`, which after `/Rotate` is another corner of the square as displayed; converting the
|
||
/// point alone put the icon one icon-width off.
|
||
#[test]
|
||
fn note_icons_anchor_at_the_requested_corner_on_rotated_pages() {
|
||
let dir = workdir("note-anchor");
|
||
std::fs::write(dir.join("note.txt"), b"attached").unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 612, "height": 792, "pages": 4 }))["doc"].as_u64().unwrap();
|
||
for (page, degrees) in [(2, 90), (3, 180), (4, 270)] {
|
||
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees }));
|
||
}
|
||
for page in 1..=4 {
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": page, "type": "note", "at": [72, 72], "contents": "Fixture note" }));
|
||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": page, "type": "attachment", "at": [200, 300], "path": "note.txt" }));
|
||
}
|
||
let comments = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||
let comments = comments["comments"].as_array().unwrap();
|
||
assert_eq!(comments.len(), 8);
|
||
for c in comments {
|
||
let want = if c["type"] == "Text" { [72.0, 72.0, 92.0, 92.0] } else { [200.0, 300.0, 220.0, 320.0] };
|
||
let rect: Vec<f64> = c["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||
assert!(rect.iter().zip(want).all(|(x, y)| (x - y).abs() < 0.01), "page {} {}: rect {rect:?}, want {want:?}", c["page"], c["type"]);
|
||
}
|
||
}
|
||
|
||
/// An image signature is drawn upright as displayed, and anchored at the displayed point asked
|
||
/// for, on every `/Rotate`: its picture is placed in user space, which the page turns, so the
|
||
/// appearance is counter-rotated and its box is found in user space.
|
||
#[test]
|
||
fn image_signatures_stay_upright_on_rotated_pages() {
|
||
let dir = workdir("image-upright");
|
||
// 80 x 40 px, one colour per quadrant: red and green above, blue and yellow below.
|
||
let quadrants = image::RgbaImage::from_fn(80, 40, |x, y| match (x < 40, y < 20) {
|
||
(true, true) => image::Rgba([255, 0, 0, 255]),
|
||
(false, true) => image::Rgba([0, 255, 0, 255]),
|
||
(true, false) => image::Rgba([0, 0, 255, 255]),
|
||
(false, false) => image::Rgba([255, 255, 0, 255]),
|
||
});
|
||
quadrants.save(dir.join("quadrants.png")).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap();
|
||
for (page, degrees) in [(2, 90), (3, 180), (4, 270)] {
|
||
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees }));
|
||
}
|
||
for page in 1..=4 {
|
||
// 64 x 32 pt from x = 60 as displayed, centred on y = 140.
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "signature", "at": [60, 140], "path": "quadrants.png" }));
|
||
}
|
||
let (white, red, green, blue, yellow) = ([255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]);
|
||
let check = |a: &mut Automation, doc: u64| {
|
||
for page in 1..=4 {
|
||
let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
|
||
let Content::Png { data, .. } = &output[0] else { panic!() };
|
||
let pixels = image::load_from_memory(data).unwrap().to_rgba8();
|
||
for (x, y, want, what) in [
|
||
(76, 132, red, "signature top-left"),
|
||
(108, 132, green, "signature top-right"),
|
||
(76, 148, blue, "signature bottom-left"),
|
||
(108, 148, yellow, "signature bottom-right"),
|
||
(56, 140, white, "left of the signature"),
|
||
(128, 140, white, "right of the signature"),
|
||
(92, 120, white, "above the signature"),
|
||
(92, 160, white, "below the signature"),
|
||
] {
|
||
let got = pixels.get_pixel(x, y).0;
|
||
assert!(got[..3].iter().zip(want).all(|(g, w)| g.abs_diff(w) < 12), "page {page}, {what} at ({x}, {y}): {got:?}, want {want:?}");
|
||
}
|
||
}
|
||
};
|
||
check(&mut a, doc);
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" }));
|
||
let reopened = ok(&mut a, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap();
|
||
check(&mut a, reopened);
|
||
}
|
||
|
||
/// Issue #299: a typed signature and initials read across, as displayed, on every `/Rotate`,
|
||
/// anchored at the displayed point, and look the same as on an unturned page.
|
||
#[test]
|
||
fn typed_signatures_stay_upright_on_rotated_pages() {
|
||
let dir = workdir("typed-upright");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap();
|
||
for (page, degrees) in [(2, 90), (3, 180), (4, 270)] {
|
||
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees }));
|
||
}
|
||
for page in 1..=4 {
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "signature", "at": [30, 60], "text": "Ada Lovelace" }));
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "initials", "at": [30, 140], "text": "AL" }));
|
||
}
|
||
let check = |a: &mut Automation, doc: u64| {
|
||
let mut upright = Vec::new();
|
||
for page in 1..=4 {
|
||
let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
|
||
let Content::Png { data, .. } = &output[0] else { panic!() };
|
||
let pixels = image::load_from_memory(data).unwrap().to_rgba8();
|
||
assert_eq!(pixels.dimensions(), if page % 2 == 1 { (200, 300) } else { (300, 200) }, "page {page}");
|
||
let dark: Vec<bool> = pixels.pixels().map(|p| p.0[..3].iter().all(|v| *v < 128)).collect();
|
||
for y in [60, 140] {
|
||
let mut b = [u32::MAX, u32::MAX, 0, 0];
|
||
for (x, py, p) in pixels.enumerate_pixels() {
|
||
if py.abs_diff(y) < 40 && p.0[..3].iter().all(|v| *v < 128) {
|
||
b = [b[0].min(x), b[1].min(py), b[2].max(x), b[3].max(py)];
|
||
}
|
||
}
|
||
assert!(b[0] <= b[2], "page {page}: no ink near y = {y}");
|
||
assert!(b[2] - b[0] > b[3] - b[1], "page {page}: the name reads across: {b:?}");
|
||
assert!(b[0].abs_diff(30) <= 2 && b[1] < y && b[3] > y, "page {page}: anchored at (30, {y}): {b:?}");
|
||
}
|
||
if page == 1 {
|
||
upright = dark;
|
||
continue;
|
||
}
|
||
// Same pixels as the unturned page where both pages are (pages 2 and 4 are wider).
|
||
let width = pixels.width();
|
||
let (mut differ, mut ink) = (0, 0);
|
||
for y in 0..200 {
|
||
for x in 0..200 {
|
||
let (want, got) = (upright[(y * 200 + x) as usize], dark[(y * width + x) as usize]);
|
||
ink += usize::from(want);
|
||
differ += usize::from(want != got);
|
||
}
|
||
}
|
||
assert!(differ * 10 < ink, "page {page}: {differ} of {ink} ink pixels differ from the unturned page");
|
||
}
|
||
};
|
||
check(&mut a, doc);
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" }));
|
||
let reopened = ok(&mut a, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap();
|
||
check(&mut a, reopened);
|
||
}
|
||
|
||
#[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(_))));
|
||
}
|
||
|
||
/// Text the standard fonts can't draw is never written as `?` silently, through the tools. A form
|
||
/// value, a button caption, a text box and Fill & Sign text are kept as typed (as Acrobat keeps
|
||
/// them) and the result's `warnings` names the character; a form also asks other viewers to
|
||
/// redraw it. Where the `?` would be permanent the tool refuses: a typed signature, a redaction's
|
||
/// overlay text, a new document from text, and flattening what PdfCraft drew with `?`. A comment
|
||
/// summary that quotes such text writes it as [U+XXXX].
|
||
#[test]
|
||
fn text_the_standard_fonts_cannot_draw_is_never_silent_through_tools() {
|
||
let dir = workdir("undrawable");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let refused = |a: &mut Automation, tool: &str, args: Value, ch: &str| match a.call(tool, &args) {
|
||
Err(ToolError::Failed(e) | ToolError::InvalidArgs(e)) => assert!(e.contains(ch), "{tool}: {e}"),
|
||
other => panic!("{tool}: expected a refusal, got {other:?}"),
|
||
};
|
||
let warned = |out: &Value, parts: &[&str]| {
|
||
let w = out["warnings"].as_array().unwrap_or_else(|| panic!("no warnings: {out}"));
|
||
assert!(w.iter().filter_map(Value::as_str).any(|m| parts.iter().all(|p| m.contains(p))), "{parts:?} not in {w:?}");
|
||
};
|
||
// Forms: a value is kept and the form asks other viewers to redraw it; a button caption too.
|
||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "who" }));
|
||
let out = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "who": "Ada" } }));
|
||
assert!(out.get("warnings").is_none(), "{out}");
|
||
let out = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "who": "张三" } }));
|
||
warned(&out, &["form field \"who\"", "\"张\" (U+5F20)", "other PDF viewers redraw the field"]);
|
||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
assert_eq!(fields["fields"][0]["value"], "张三", "{fields}");
|
||
let out = ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 60, 120, 82], "caption": "提交" }));
|
||
warned(&out, &["\"提\" (U+63D0)"]);
|
||
// Comments and Fill & Sign text: kept, and said.
|
||
let out = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "欢迎" }));
|
||
warned(&out, &["a comment on page 1", "\"欢\" (U+6B22)", "every viewer"]);
|
||
let out = ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 300], "text": "签名" }));
|
||
warned(&out, &["U+7B7E"]);
|
||
// A sticky note draws an icon, not its text: nothing to say.
|
||
let out = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "签名 OK", "author": "Ada" }));
|
||
assert!(out.get("warnings").is_none(), "{out}");
|
||
// Permanent `?`: refused.
|
||
refused(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 340], "text": "张三" }), "pass `path`");
|
||
// Cyrillic comes from the script font's Inter fallback (#963), so it isn't refused.
|
||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 380], "text": "Саша" }));
|
||
refused(&mut a, "redact_mark", json!({ "doc": doc, "page": 1, "rect": [10, 400, 190, 420], "overlay": "机密" }), "\"机\" (U+673A)");
|
||
refused(&mut a, "doc_flatten", json!({ "doc": doc }), "nothing was flattened");
|
||
refused(&mut a, "doc_create", json!({ "from": "text", "text": "Notes\n第二行" }), "(line 2)");
|
||
// A summary quotes the note: escaped, not refused, not `?`.
|
||
ok(&mut a, "comments_summarize", json!({ "doc": doc, "out": "summary.pdf" }));
|
||
let summary = ok(&mut a, "doc_open", json!({ "path": "summary.pdf" }))["doc"].as_u64().unwrap();
|
||
let text = page_text(&mut a, summary).join("\n");
|
||
assert!(text.contains("[U+7B7E][U+540D] OK") && text.contains("[U+XXXX]"), "{text}");
|
||
assert!(!text.contains("?? OK"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn line_endings_through_the_tool() {
|
||
let dir = workdir("endings");
|
||
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": "line", "from": [20, 40], "to": [120, 80], "endings": ["None", "Diamond"] }));
|
||
ok(
|
||
&mut a,
|
||
"comment_add",
|
||
json!({ "doc": doc, "page": 1, "type": "polyline", "points": [[20, 120], [60, 100], [100, 140]], "endings": ["Circle", "Slash"] }),
|
||
);
|
||
ok(
|
||
&mut a,
|
||
"comment_add",
|
||
json!({ "doc": doc, "page": 1, "type": "callout", "rect": [110, 200, 190, 240], "to": [40, 160], "contents": "Look", "endings": ["Square"] }),
|
||
);
|
||
assert!(matches!(
|
||
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 80], "endings": ["Sparkle", "None"] })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
assert!(matches!(
|
||
a.call(
|
||
"comment_add",
|
||
&json!({ "doc": doc, "page": 1, "type": "callout", "rect": [10, 10, 80, 40], "to": [90, 80], "endings": ["None", "None"] })
|
||
),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
assert!(matches!(
|
||
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 40, 40], "endings": ["Circle"] })),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "ended.pdf" }));
|
||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("ended.pdf")).unwrap())).unwrap();
|
||
for name in [b"Diamond".as_slice(), b"Circle", b"Slash", b"Square"] {
|
||
assert!(
|
||
saved.object_numbers().iter().any(|n| saved.try_get(*n).ok().is_some_and(|o| object_has_name(&o, name))),
|
||
"missing /{}",
|
||
String::from_utf8_lossy(name)
|
||
);
|
||
}
|
||
let drawn: Vec<String> = saved
|
||
.object_numbers()
|
||
.into_iter()
|
||
.filter_map(|n| {
|
||
let obj = saved.try_get(n).ok()?;
|
||
let pdfcraft_cos::Object::Stream(s) = &*obj else { return None };
|
||
if s.dict.name(b"Subtype") != Some(b"Form") {
|
||
return None;
|
||
}
|
||
s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned())
|
||
})
|
||
.collect();
|
||
assert!(drawn.iter().any(|s| s.contains("h B")), "a filled ending was drawn: {drawn:?}");
|
||
assert!(drawn.iter().any(|s| s.contains(" l S")), "an open ending was drawn: {drawn:?}");
|
||
}
|
||
|
||
fn object_has_name(obj: &pdfcraft_cos::Object, name: &[u8]) -> bool {
|
||
match obj {
|
||
pdfcraft_cos::Object::Name(n) => n.as_slice() == name,
|
||
pdfcraft_cos::Object::Array(items) => items.iter().any(|o| object_has_name(o, name)),
|
||
pdfcraft_cos::Object::Dict(d) => d.iter().any(|(_, o)| object_has_name(o, name)),
|
||
pdfcraft_cos::Object::Stream(s) => s.dict.iter().any(|(_, o)| object_has_name(o, name)),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
#[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 signing_with_an_image_typed_or_drawn_graphic_through_tools() {
|
||
let dir = workdir("signing-graphic");
|
||
// A solid red 120 x 40 picture.
|
||
let mut png = Vec::new();
|
||
{
|
||
let mut encoder = png::Encoder::new(&mut png, 120, 40);
|
||
encoder.set_color(png::ColorType::Rgba);
|
||
encoder.set_depth(png::BitDepth::Eight);
|
||
let rgba: Vec<u8> = (0..120 * 40).flat_map(|_| [220, 0, 0, 255]).collect();
|
||
encoder.write_header().unwrap().write_image_data(&rgba).unwrap();
|
||
}
|
||
std::fs::write(dir.join("logo.png"), png).unwrap();
|
||
std::fs::write(dir.join("broken.png"), b"broken").unwrap();
|
||
let mut a = auto(&dir);
|
||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" }));
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let sign = |a: &mut Automation, rect: [f64; 4], graphic: Value, out: &str| {
|
||
a.call(
|
||
"sign_document",
|
||
&json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 1, "rect": rect, "graphic": graphic, "out": out }),
|
||
)
|
||
};
|
||
for (graphic, why) in [
|
||
(json!({ "image": "logo.png", "text": "Ada" }), "two kinds"),
|
||
(json!({}), "no kind"),
|
||
(json!({ "picture": "logo.png" }), "unknown key"),
|
||
(json!("logo.png"), "not an object"),
|
||
(json!({ "image": "broken.png" }), "not an image"),
|
||
(json!({ "text": " " }), "nothing to draw"),
|
||
(json!({ "text": "张三" }), "a name the script font and its fallback can't draw"),
|
||
(json!({ "strokes": [[[0, 0], ["x", 1]]] }), "malformed strokes"),
|
||
(json!({ "strokes": [] }), "empty strokes"),
|
||
] {
|
||
let r = sign(&mut a, [20.0, 200.0, 180.0, 250.0], graphic, "bad.pdf");
|
||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{why}: {r:?}");
|
||
}
|
||
// A missing file fails as it does for every tool that reads one.
|
||
assert!(matches!(sign(&mut a, [20.0, 200.0, 180.0, 250.0], json!({ "image": "missing.png" }), "bad.pdf"), Err(ToolError::Failed(_))));
|
||
// An invisible signature shows nothing, so a graphic for one is a mistake.
|
||
let r = a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "graphic": { "text": "Ada" }, "out": "bad.pdf" }));
|
||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{r:?}");
|
||
assert!(!dir.join("bad.pdf").exists());
|
||
|
||
// Image, typed and drawn graphics, each a signature of its own, all intact.
|
||
sign(&mut a, [20.0, 200.0, 180.0, 250.0], json!({ "image": "logo.png" }), "s1.pdf").unwrap();
|
||
sign(&mut a, [20.0, 130.0, 180.0, 180.0], json!({ "text": "Ada Lovelace" }), "s2.pdf").unwrap();
|
||
sign(&mut a, [20.0, 60.0, 180.0, 110.0], json!({ "strokes": [[[0, 0], [0.3, 0.2], [0.6, 0], [1, 0.2]]] }), "s3.pdf").unwrap();
|
||
let list = ok(&mut a, "sign_list", json!({ "doc": doc }));
|
||
assert_eq!(list["count"], 3);
|
||
for s in list["signatures"].as_array().unwrap() {
|
||
assert_eq!((s["status"].as_str(), s["visible"].as_bool()), (Some("unknown"), Some(true)), "{s}");
|
||
}
|
||
// At 72 dpi a pixel is a point. Each graphic is in the left half of its box (x 23–97), the
|
||
// details on the right.
|
||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||
let Content::Png { data, width, .. } = &png[0] else { panic!("expected an image") };
|
||
let mut reader = png::Decoder::new(std::io::Cursor::new(data.as_slice())).read_info().unwrap();
|
||
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
|
||
let frame = reader.next_frame(&mut buf).unwrap();
|
||
let channels = frame.line_size / *width as usize;
|
||
let px = |x: usize, y: usize| buf[y * frame.line_size + x * channels..][..3].to_vec();
|
||
assert_eq!(px(60, 225), [220, 0, 0], "the image fills the left half's middle");
|
||
assert_ne!(px(150, 225), [220, 0, 0], "and not the details");
|
||
let inked = |y0: usize, y1: usize| (y0..y1).any(|y| (23..97).any(|x| px(x, y).iter().all(|c| *c < 100)));
|
||
assert!(inked(130, 180), "the typed name is drawn");
|
||
assert!(inked(60, 110), "the strokes are drawn");
|
||
}
|
||
|
||
#[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": "[email protected]", "country": "GB", "key": "p256", "password": "secret1", "path": "ada.p12" }),
|
||
);
|
||
assert_eq!(id["certificate"]["subject"], "C=GB, O=Analytical Engines, CN=Ada Lovelace, [email protected]");
|
||
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": "windows:No Such Signer", "out": "w.pdf" })), Err(ToolError::Failed(_))));
|
||
let store = ok(&mut a, "sign_windows_ids", json!({}));
|
||
let ids = store["ids"].as_array().unwrap();
|
||
assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64);
|
||
assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:")));
|
||
// Store certificates that can't sign are explained, not dropped (issue #179).
|
||
let unusable = store["unusable"].as_array().unwrap();
|
||
assert!(unusable.iter().all(|u| u["subject"].is_string() && !u["reason"].as_str().unwrap().is_empty() && u["no_private_key"].is_boolean()));
|
||
#[cfg(not(windows))]
|
||
assert!(ids.is_empty() && unusable.is_empty());
|
||
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_eq!(reduced["written"], true);
|
||
assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].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(_))));
|
||
}
|
||
|
||
/// #1028: `skip_text_pages: false` reads a picture on a page that has its own text, and the
|
||
/// page's own words are left out (counted) instead of being added a second time.
|
||
#[test]
|
||
fn ocr_recognize_reads_pictures_on_pages_with_text() {
|
||
let dir = workdir("ocr-hybrid");
|
||
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": "Pictures of words become searchable" }))["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 doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Invoice reference 4711", "at": [40, 500], "size": 24 }));
|
||
|
||
let skipped = ok(&mut a, "ocr_recognize", json!({ "doc": doc }));
|
||
assert!(skipped["pages"][0]["skipped"].is_string(), "{skipped}");
|
||
let r = ok(&mut a, "ocr_recognize", json!({ "doc": doc, "skip_text_pages": false }));
|
||
let page = &r["pages"][0];
|
||
let added = page["text"].as_str().unwrap().to_lowercase();
|
||
assert!(added.contains("searchable") && !added.contains("invoice"), "{r}");
|
||
assert!(page["already_on_page"].as_u64().unwrap() >= 2, "{r}");
|
||
let found = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q }))["count"].as_u64().unwrap();
|
||
assert_eq!((found(&mut a, "invoice"), found(&mut a, "searchable")), (1, 1));
|
||
assert!(matches!(a.call("ocr_recognize", &json!({ "doc": doc, "skip_text_pages": "no" })), Err(ToolError::InvalidArgs(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn ocr_recognize_files_refuses_paths_outside_the_root() {
|
||
let dir = workdir("ocr-files-root");
|
||
let mut a = auto(&dir);
|
||
// Refused before anything else, with or without the OCR models installed.
|
||
for path in ["../outside.pdf", "sub/../../outside.pdf", "/outside.pdf"] {
|
||
let r = a.call("ocr_recognize_files", &json!({ "paths": ["a.pdf", path], "folder": "out" }));
|
||
assert!(matches!(&r, Err(e) if e.to_string().contains("outside the allowed directory")), "{path}: {r:?}");
|
||
}
|
||
assert!(!dir.join("out").exists(), "nothing was started");
|
||
}
|
||
|
||
#[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 action_run_keeps_outputs_with_duplicate_input_basenames() {
|
||
let dir = workdir("action-duplicate-basenames");
|
||
std::fs::create_dir_all(dir.join("left")).unwrap();
|
||
std::fs::create_dir_all(dir.join("right")).unwrap();
|
||
std::fs::write(dir.join("left/report.pdf"), fixture(1)).unwrap();
|
||
std::fs::write(dir.join("right/report.pdf"), fixture(2)).unwrap();
|
||
let mut a = auto(&dir);
|
||
let args = json!({
|
||
"steps": [{ "step": "set_title", "arg": "Processed" }],
|
||
"paths": ["left/report.pdf", "right/report.pdf"],
|
||
"folder": "results"
|
||
});
|
||
let result = ok(&mut a, "action_run", args.clone());
|
||
let files = result["files"].as_array().unwrap();
|
||
assert_eq!(files.len(), 2);
|
||
let first_path = files[0]["output"].as_str().unwrap();
|
||
let second_path = files[1]["output"].as_str().unwrap();
|
||
assert!(first_path.ends_with("report.pdf"), "{first_path}");
|
||
assert!(second_path.ends_with("report (2).pdf"), "{second_path}");
|
||
let first_bytes = std::fs::read(first_path).unwrap();
|
||
assert_eq!(ok(&mut a, "doc_open", json!({ "path": first_path }))["pages"], 1);
|
||
assert_eq!(ok(&mut a, "doc_open", json!({ "path": second_path }))["pages"], 2);
|
||
|
||
let repeated = ok(&mut a, "action_run", args);
|
||
assert!(repeated["files"][0]["output"].as_str().unwrap().ends_with("report (3).pdf"));
|
||
assert!(repeated["files"][1]["output"].as_str().unwrap().ends_with("report (4).pdf"));
|
||
assert_eq!(std::fs::read(first_path).unwrap(), first_bytes, "an earlier output must not be replaced");
|
||
}
|
||
|
||
#[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 print_readiness_through_tools() {
|
||
let dir = workdir("print-readiness");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Proof me" }))["doc"].as_u64().unwrap();
|
||
let before = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
let r = ok(&mut a, "print_readiness", json!({ "doc": doc }));
|
||
assert!(r["text"].as_str().unwrap().contains("verification only"), "{r}");
|
||
assert_eq!(r["pages"].as_array().unwrap().len(), 1, "{r}");
|
||
assert_eq!(r["pages"][0]["trim_set"], false, "{r}");
|
||
assert_eq!(r["resolution_unknown"], 0, "{r}");
|
||
let findings = r["findings"].as_array().unwrap();
|
||
// A page without a TrimBox is one informational note for the document, not a bleed warning.
|
||
assert!(!findings.iter().any(|f| f["id"] == "page-bleed"), "{r}");
|
||
let trim: Vec<_> = findings.iter().filter(|f| f["id"] == "page-trim").collect();
|
||
assert_eq!(trim.len(), 1, "{r}");
|
||
assert_eq!(trim[0]["severity"], "info");
|
||
assert!(trim[0]["page"].is_null(), "{r}");
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc })), before, "verification doesn't change the document");
|
||
assert!(a.call("print_readiness", &json!({ "doc": 999 })).is_err());
|
||
assert!(pdfcraft_automation::tools().iter().any(|t| t.name == "print_readiness" && t.read_only));
|
||
}
|
||
|
||
#[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", "pptx", "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(dir.join("a.pptx")).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());
|
||
}
|
||
|
||
/// #773: a diagonal watermark must not enter the body flow or move invoice fields.
|
||
#[test]
|
||
fn exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow() {
|
||
use pdfcraft_cos::{Dict, Document, ObjRef, SaveOptions, Stream, write_full};
|
||
let dir = workdir("invoice-word");
|
||
let mut pdf = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||
let content = b"q BT /F1 60 Tf 0.8 g 0.70710678 0.70710678 -0.70710678 0.70710678 30 80 Tm (DRAFT) Tj ET Q \
|
||
BT /F1 11 Tf 20 250 Td (Description) Tj 110 0 Td (Amount) Tj \
|
||
-110 -20 Td (Design) Tj 110 0 Td (100.00) Tj \
|
||
-110 -20 Td (Review) Tj 110 0 Td (50.00) Tj ET \
|
||
BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET";
|
||
pdf.set(ObjRef::new(5, 0), Stream { dict: Dict::new(), raw: content.to_vec().into() });
|
||
std::fs::write(dir.join("invoice.pdf"), write_full(&pdf, &SaveOptions::default()).unwrap()).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "invoice.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "invoice.docx" }));
|
||
// Inspect the actual package emitted by the tool, not an intermediate page model.
|
||
let zip = std::fs::read(dir.join("invoice.docx")).unwrap();
|
||
let mut at = 0;
|
||
let mut xml = String::new();
|
||
while zip[at..].starts_with(b"PK\x03\x04") {
|
||
let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize;
|
||
let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize;
|
||
let data = at + 30 + len;
|
||
if &zip[at + 30..data] == b"word/document.xml" {
|
||
std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap();
|
||
break;
|
||
}
|
||
at = data + size;
|
||
}
|
||
assert!(xml.contains("rotation:315.000"), "diagonal text survives extraction: {xml}");
|
||
assert!(xml.contains("z-index:-2"), "the large watermark stays behind the invoice");
|
||
assert!(!xml.contains("<wp:inline>") && !xml.contains("<w:tbl>"));
|
||
assert!(!xml.contains("w:val=\"single\""));
|
||
assert_eq!(xml.matches(">DRAFT</w:t>").count(), 1);
|
||
assert_eq!(xml.matches(">Total due: 150.00</w:t>").count(), 1);
|
||
assert_eq!(xml.matches("<w:sectPr>").count(), 1);
|
||
assert!(!xml.contains("w:type=\"page\""));
|
||
assert!(xml.contains("margin-left:20.000pt"), "invoice fields retain their left edge");
|
||
let rendered = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||
assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() {
|
||
let dir = workdir("xfa-many");
|
||
let (sections, rows) = (10, 20);
|
||
std::fs::write(dir.join("many.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))).unwrap();
|
||
let mut a = auto(&dir);
|
||
let started = std::time::Instant::now();
|
||
let opened = ok(&mut a, "doc_open", json!({ "path": "many.pdf" }));
|
||
let open_took = started.elapsed();
|
||
let doc = opened["doc"].as_u64().unwrap();
|
||
let warnings = opened["warnings"].to_string();
|
||
assert!(!warnings.contains("were skipped") && !warnings.contains("stopped"), "{warnings}");
|
||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
let totals: Vec<&serde_json::Value> =
|
||
fields["fields"].as_array().unwrap().iter().filter(|f| f["name"].as_str().is_some_and(|n| n.starts_with("total"))).collect();
|
||
assert_eq!(totals.len(), sections * rows);
|
||
assert!(totals.iter().all(|f| !f["value"].as_str().unwrap_or("").is_empty()), "every total calculated");
|
||
let started = std::time::Instant::now();
|
||
let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "100" } }));
|
||
let change_took = started.elapsed();
|
||
assert!(!r.to_string().contains("were skipped"), "{r}");
|
||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
let total = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap();
|
||
assert_eq!(total["value"], "200");
|
||
eprintln!("{} calculate scripts: open {open_took:?}, recalculation after a change {change_took:?}", sections * rows + sections);
|
||
}
|
||
|
||
#[test]
|
||
fn xfa_data_files_export_and_import_through_tools() {
|
||
let dir = workdir("xfa-data");
|
||
let data = "<form><page1><country>JP</country></page1></form>";
|
||
std::fs::write(dir.join("fields.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xml", "what": "fields" }));
|
||
ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xdp", "what": "fields" }));
|
||
let xml = std::fs::read_to_string(dir.join("data.xml")).unwrap();
|
||
let xdp = std::fs::read_to_string(dir.join("data.xdp")).unwrap();
|
||
assert!(xml.contains("<form") && xml.contains("<country>JP</country>"), "{xml}");
|
||
assert!(xdp.contains("<xdp:xdp") && xdp.contains("<country>JP</country>"), "{xdp}");
|
||
// Edit the exported data and import it back.
|
||
std::fs::write(dir.join("edited.xdp"), xdp.replace("<country>JP</country>", "<country>CA</country>")).unwrap();
|
||
let r = ok(&mut a, "doc_import_data", json!({ "doc": doc, "path": "edited.xdp" }));
|
||
assert!(r["filled_fields"].as_u64().unwrap() >= 1);
|
||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
let country = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "country").unwrap();
|
||
assert_eq!(country["value"], json!("CA"));
|
||
// A picture for the image field, through form_set_image, lands in the data.
|
||
std::fs::write(dir.join("pic.gif"), pdfcraft_xfa::fixtures::TINY_GIF).unwrap();
|
||
ok(&mut a, "form_set_image", json!({ "doc": doc, "field": "photo", "path": "pic.gif" }));
|
||
ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "after.xml", "what": "fields" }));
|
||
let after = std::fs::read_to_string(dir.join("after.xml")).unwrap();
|
||
assert!(after.contains("<photo xfa:contentType=\"image/gif\">R0lGODlh"), "{after}");
|
||
// XDP is for XFA forms only.
|
||
std::fs::write(dir.join("plain.pdf"), pdfcraft_xfa::fixtures::static_shell("<form1><page1><name>Ada</name></page1></form1>")).unwrap();
|
||
let plain = ok(&mut a, "doc_open", json!({ "path": "plain.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "doc_export_data", json!({ "doc": plain, "path": "static.xml", "what": "fields" }));
|
||
assert!(std::fs::read_to_string(dir.join("static.xml")).unwrap().contains("<name>Ada</name>"), "a static XFA form exports its data too");
|
||
}
|
||
|
||
#[test]
|
||
fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() {
|
||
let dir = workdir("xfa");
|
||
std::fs::write(dir.join("xfa.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(2))).unwrap();
|
||
let mut a = auto(&dir);
|
||
let opened = ok(&mut a, "doc_open", json!({ "path": "xfa.pdf" }));
|
||
assert_eq!(opened["pages"], 2, "laid out from the template, not the placeholder page");
|
||
let doc = opened["doc"].as_u64().unwrap();
|
||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
assert_eq!(info["xfa"], "dynamic");
|
||
assert_eq!(info["xfa_layout"]["pages"], 2);
|
||
assert_eq!(info["xfa_layout"]["fields"], 11);
|
||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||
let family = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "familyName").expect("familyName");
|
||
assert_eq!((family["type"].as_str(), family["tooltip"].as_str(), family["page"].as_u64()), (Some("text"), Some("Your family name"), Some(1)));
|
||
let answer = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "answer").expect("radio group");
|
||
assert_eq!(answer["options"], json!(["Y", "N"]));
|
||
// The page renders with the widgets' own appearances: the check box's border is drawn.
|
||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 36 })).unwrap();
|
||
let Content::Png { data, .. } = &png[0] else { panic!("expected an image") };
|
||
let decoder = png::Decoder::new(std::io::Cursor::new(data.as_slice()));
|
||
let mut reader = decoder.read_info().unwrap();
|
||
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
|
||
reader.next_frame(&mut buf).unwrap();
|
||
assert!(buf.iter().filter(|b| **b < 128).count() > 200, "the page is not blank");
|
||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "familyName": "Singh", "agree": true, "answer": "N", "born": "2001-02-03" } }));
|
||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
|
||
ok(&mut a, "doc_close", json!({ "doc": doc }));
|
||
let reopened = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }));
|
||
assert_eq!(reopened["pages"], 2, "not laid out twice");
|
||
let doc2 = reopened["doc"].as_u64().unwrap();
|
||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc2 }));
|
||
let by = |n: &str| fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == n).unwrap()["value"].clone();
|
||
assert_eq!((by("familyName"), by("agree"), by("answer"), by("born")), (json!("Singh"), json!(true), json!("N"), json!("2001-02-03")));
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc2 }))["xfa_layout"]["pages"], 2);
|
||
}
|
||
|
||
#[test]
|
||
fn cut_stack_printing_through_tools() {
|
||
let dir = workdir("cut-stack");
|
||
std::fs::write(dir.join("numbered.pdf"), fixture(10)).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({"path": "numbered.pdf"}))["doc"].as_u64().unwrap();
|
||
let before = page_text(&mut a, doc);
|
||
for reverse in [false, true] {
|
||
let r = ok(
|
||
&mut a,
|
||
"doc_print",
|
||
json!({
|
||
"doc": doc, "layout": "multiple", "order": "cut-stack", "per_sheet": 4,
|
||
"orientation": "portrait", "auto_rotate": false, "reverse": reverse, "path": "cut.pdf"
|
||
}),
|
||
);
|
||
assert_eq!(r["sheets"], 3);
|
||
let printed = ok(&mut a, "doc_open", json!({"path": "cut.pdf"}))["doc"].as_u64().unwrap();
|
||
let expected =
|
||
if reverse { vec![vec![10, 7, 4, 1], vec![9, 6, 3], vec![8, 5, 2]] } else { vec![vec![1, 4, 7, 10], vec![2, 5, 8], vec![3, 6, 9]] };
|
||
for (text, expected) in page_text(&mut a, printed).iter().zip(&expected) {
|
||
let actual: Vec<usize> = text.split_whitespace().filter_map(|t| t.parse().ok()).collect();
|
||
assert_eq!(&actual, expected, "saved PDF must contain the imposed order: {text}");
|
||
}
|
||
ok(&mut a, "doc_close", json!({"doc": printed}));
|
||
}
|
||
let r = ok(
|
||
&mut a,
|
||
"doc_print",
|
||
json!({
|
||
"doc": doc, "pages": "2-10", "subset": "odd", "reverse": true,
|
||
"layout": "multiple", "order": "cut-stack", "per_sheet": 2, "auto_rotate": false,
|
||
"orientation": "landscape", "path": "range.pdf"
|
||
}),
|
||
);
|
||
assert_eq!(r["pages"], 5);
|
||
let printed = ok(&mut a, "doc_open", json!({"path": "range.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(page_text(&mut a, printed), ["Page 10\nPage 4", "Page 8\nPage 2", "Page 6"]);
|
||
assert_eq!(page_text(&mut a, doc), before);
|
||
assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], false);
|
||
for duplex in ["long-edge", "short-edge"] {
|
||
assert!(matches!(
|
||
a.call(
|
||
"doc_print",
|
||
&json!({
|
||
"doc": doc, "layout": "multiple", "order": "cut-stack", "duplex": duplex, "path": "refused.pdf"
|
||
})
|
||
),
|
||
Err(ToolError::InvalidArgs(_))
|
||
));
|
||
}
|
||
assert!(matches!(a.call("doc_print", &json!({"doc": doc, "order": "cut-stack", "path": "refused.pdf"})), Err(ToolError::InvalidArgs(_))));
|
||
assert!(!dir.join("refused.pdf").exists());
|
||
assert!(a.call("doc_print", &json!({"doc": doc, "layout": "multiple", "order": "cut-stack", "path": "../escaped.pdf"})).is_err());
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn measurements_calibrate_draw_save_reopen_and_export() {
|
||
let dir = workdir("measurements");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
|
||
let scale = ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"points":[[10,10],[70,10]],"distance":6,"unit":"m","precision":3}));
|
||
assert!((scale["scale"]["x"].as_f64().unwrap() - 0.1).abs() < 1e-12);
|
||
for (tool, points) in [
|
||
("measure_distance", json!([[10, 20], [70, 100]])),
|
||
("measure_perimeter", json!([[10, 20], [70, 20], [70, 100]])),
|
||
("measure_area", json!([[10, 20], [70, 20], [70, 100], [10, 100]])),
|
||
] {
|
||
ok(&mut a, tool, json!({"doc":doc,"page":1,"points":points,"label":"Room, \"A\"","author":"Tester"}));
|
||
}
|
||
let all = ok(&mut a, "measure_list", json!({"doc":doc}));
|
||
assert_eq!(all["count"], 3);
|
||
assert_eq!(all["unsupported"], json!([]));
|
||
assert_eq!(all["truncated"], false);
|
||
for (m, value) in all["measurements"].as_array().unwrap().iter().zip([10.0, 14.0, 48.0]) {
|
||
assert!((m["reading"]["value"].as_f64().unwrap() - value).abs() < 1e-6);
|
||
assert_eq!(m["page"], 1);
|
||
assert_eq!(m["label"], "Room, \"A\"");
|
||
}
|
||
let preview = ok(&mut a, "measure_info", json!({"doc":doc,"page":1,"type":"area","points":[[10,20],[70,20],[70,100],[10,100]]}));
|
||
assert!((preview["reading"]["value"].as_f64().unwrap() - 48.0).abs() < 1e-6);
|
||
let rendered = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap();
|
||
assert!(matches!(rendered.first(), Some(Content::Png { .. })));
|
||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||
assert_eq!(ok(&mut a, "measure_list", json!({"doc":doc}))["count"], 2);
|
||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"measured.pdf"}));
|
||
let reopened = ok(&mut a, "doc_open", json!({"path":"measured.pdf"}))["doc"].as_u64().unwrap();
|
||
let after = ok(&mut a, "measure_list", json!({"doc":reopened}));
|
||
assert_eq!(after["measurements"], all["measurements"]);
|
||
let exported = ok(&mut a, "measure_export", json!({"doc":reopened,"out":"measurements.csv"}));
|
||
assert_eq!((exported["count"].as_u64(), exported["unsupported"].as_u64()), (Some(3), Some(0)));
|
||
let csv = std::fs::read_to_string(dir.join("measurements.csv")).unwrap();
|
||
assert!(csv.contains("area,48,\"m^2\",\"Room, \"\"A\"\"\""), "{csv}");
|
||
assert!(a.call("measure_export", &json!({"doc":doc,"out":"../outside.csv"})).is_err());
|
||
// A new viewport changes future readings, without recalibrating saved measurements.
|
||
ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"units_per_point":1,"rect":[0,0,50,50],"unit":"cm"}));
|
||
assert_eq!(ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"at":[20,20]}))["scale"]["unit"], "cm");
|
||
assert_eq!(ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"at":[80,80]}))["scale"]["unit"], "m");
|
||
assert_eq!(ok(&mut a, "measure_list", json!({"doc":doc}))["measurements"], all["measurements"]);
|
||
// Rotate the page, then measure in its displayed coordinates.
|
||
ok(&mut a, "page_rotate", json!({"doc":doc,"pages":[1],"degrees":90}));
|
||
ok(&mut a, "measure_distance", json!({"doc":doc,"page":1,"points":[[100,100],[180,160]]}));
|
||
let all = ok(&mut a, "measure_list", json!({"doc":doc}));
|
||
let last = all["measurements"].as_array().unwrap().last().unwrap();
|
||
assert!((last["reading"]["value"].as_f64().unwrap() - 10.0).abs() < 1e-6);
|
||
assert_eq!(last["points"], json!([[100.0, 100.0], [180.0, 160.0]]));
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn measurements_bad_arguments_leave_document_and_history_unchanged() {
|
||
let dir = workdir("measurement-errors");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
|
||
let before = ok(&mut a, "doc_list", json!({}));
|
||
for (tool, args) in [
|
||
("measure_distance", json!({"doc":doc,"page":1,"points":[[0,0]]})),
|
||
("measure_distance", json!({"doc":doc,"page":1,"points":[[0,0],[0,0]]})),
|
||
("measure_area", json!({"doc":doc,"page":1,"points":[[0,0],[20,20],[0,20],[20,0]]})),
|
||
("measure_scale", json!({"doc":doc,"page":1,"points":[[0,0],[0,0]],"distance":10})),
|
||
("measure_snap", json!({"doc":doc,"page":1,"at":[1e100,0]})),
|
||
("measure_scale", json!({"doc":doc,"page":1,"units_per_point":1,"precision":8})),
|
||
] {
|
||
assert!(a.call(tool, &args).is_err(), "{tool} {args}");
|
||
assert_eq!(ok(&mut a, "doc_list", json!({})), before);
|
||
}
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn measurement_snap_tool_covers_all_targets() {
|
||
let dir = workdir("measurement-snap");
|
||
let mut a = auto(&dir);
|
||
let source = String::from_utf8(fixture(1)).unwrap();
|
||
let old = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
|
||
let drawing = "10 20 m 110 20 l S 60 0 m 60 80 l S";
|
||
assert!(drawing.len() <= old.len());
|
||
let source = source.replace(old, &format!("{drawing:<width$}", width = old.len()));
|
||
std::fs::write(dir.join("drawing.pdf"), source).unwrap();
|
||
let doc = ok(&mut a, "doc_open", json!({"path":"drawing.pdf"}))["doc"].as_u64().unwrap();
|
||
for (at, kind, point, midpoints) in [
|
||
([11, 280], "endpoint", [10, 280], true),
|
||
([60, 259], "midpoint", [60, 260], true),
|
||
([59, 279], "intersection", [60, 280], false),
|
||
([32, 278], "path", [32, 280], true),
|
||
] {
|
||
let snap = ok(&mut a, "measure_snap", json!({"doc":doc,"page":1,"at":at,"tolerance":3,"midpoints":midpoints}));
|
||
assert_eq!(snap["snap"]["kind"], kind);
|
||
assert_eq!(snap["snap"]["point"], json!(point.map(f64::from)));
|
||
assert_eq!(snap["truncated"], false);
|
||
}
|
||
assert!(ok(&mut a, "measure_snap", json!({"doc":doc,"page":1,"at":[180,180]}))["snap"].is_null());
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
|
||
let dir = workdir("xfa-scripts");
|
||
std::fs::write(dir.join("scripted.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "scripted.pdf" }))["doc"].as_u64().unwrap();
|
||
let field = |a: &mut Automation, n: &str| {
|
||
let f = ok(a, "form_fields", json!({ "doc": doc }));
|
||
f["fields"].as_array().unwrap().iter().find(|f| f["name"] == n).cloned()
|
||
};
|
||
// Opening ran the initialize and calculate scripts.
|
||
assert_eq!(field(&mut a, "qty").unwrap()["value"], "2");
|
||
assert_eq!(field(&mut a, "total").unwrap()["value"], "10");
|
||
// Filling recalculates; a bad value shows its message (the tool reports alerts in js output? no: it is applied, the value stays).
|
||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "4" } }));
|
||
assert_eq!(field(&mut a, "total").unwrap()["value"], "20");
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa_scripts_off"], false);
|
||
// A button's XFA click script runs through js_run, like any button.
|
||
let before = ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa_layout"]["fields"].as_u64().unwrap();
|
||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "addRow" }));
|
||
assert!(r["error"].is_null(), "{r}");
|
||
assert!(field(&mut a, "amount_2").is_some());
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa_layout"]["fields"].as_u64().unwrap(), before + 2);
|
||
// Undo takes the row away again.
|
||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||
assert!(field(&mut a, "amount_2").is_none());
|
||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" }));
|
||
assert_eq!(r["alerts"], json!(["Hello 4"]));
|
||
}
|
||
|
||
#[test]
|
||
fn doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off() {
|
||
let dir = workdir("xfa-scripts-off");
|
||
// An initialize script whose loops are in nested calls runs until it is abandoned.
|
||
let tpl = pdfcraft_xfa::fixtures::scripted_template().replace(
|
||
"if (qty.rawValue === null) qty.rawValue = 2;",
|
||
"function f() { for (var j = 0; j < 99999; j++) {} } for (var k = 0; k < 5000; k++) f();",
|
||
);
|
||
std::fs::write(dir.join("runaway.pdf"), pdfcraft_xfa::fixtures::shell(&tpl)).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "runaway.pdf" }))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa_scripts_off"], true);
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
/// doc_info describes a link's set-layer-visibility action by layer name.
|
||
#[test]
|
||
fn doc_info_describes_set_layer_links() {
|
||
let dir = workdir("layer-links");
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R] >>",
|
||
"<< /Type /OCG /Name (Red) >>",
|
||
"<< /Type /OCG /Name (Green) >>",
|
||
"<< /Type /Annot /Subtype /Link /Rect [10 10 90 30] /A << /S /SetOCGState /State [/Toggle 5 0 R /OFF 4 0 R 9 0 R] /PreserveRB false >> >>",
|
||
];
|
||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||
let mut offsets = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offsets.push(pdf.len());
|
||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let xref = pdf.len();
|
||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offsets {
|
||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
std::fs::write(dir.join("layers.pdf"), pdf).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "layers.pdf" }))["doc"].as_u64().unwrap();
|
||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
// A group that isn't a layer has no name.
|
||
assert_eq!(
|
||
info["links"][0]["target"],
|
||
json!({
|
||
"layers": [{ "layer": "Green", "state": "toggle" }, { "layer": "Red", "state": "off" }, { "layer": null, "state": "off" }],
|
||
"preserve_rb": false,
|
||
})
|
||
);
|
||
}
|
||
|
||
mod close_argument_tests {
|
||
use super::*;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
|
||
// Create exclusively and remove only this test's directory, never a pre-existing one.
|
||
struct CloseDir(PathBuf);
|
||
|
||
impl CloseDir {
|
||
fn new() -> Self {
|
||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||
let parent = std::env::temp_dir();
|
||
for _ in 0..128 {
|
||
let serial = NEXT.fetch_add(1, Ordering::Relaxed);
|
||
let path = parent.join(format!("pdfcraft-close-{}-{serial}", std::process::id()));
|
||
match std::fs::create_dir(&path) {
|
||
Ok(()) => {
|
||
let dir = Self(path);
|
||
std::fs::write(dir.0.join("a.pdf"), fixture(3)).unwrap();
|
||
std::fs::write(dir.0.join("b.pdf"), fixture(2)).unwrap();
|
||
return dir;
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||
Err(error) => panic!("creating close test directory: {error}"),
|
||
}
|
||
}
|
||
panic!("no unused close test directory after 128 attempts");
|
||
}
|
||
}
|
||
|
||
impl Drop for CloseDir {
|
||
fn drop(&mut self) {
|
||
let _ = std::fs::remove_dir_all(&self.0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn close_rejects_invalid_discard_types_without_changing_clean_or_dirty_documents() {
|
||
let dir = CloseDir::new();
|
||
let source = std::fs::read(dir.0.join("a.pdf")).unwrap();
|
||
let other_source = std::fs::read(dir.0.join("b.pdf")).unwrap();
|
||
let mut a = auto(&dir.0);
|
||
let other = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
|
||
for dirty in [false, true] {
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
if dirty {
|
||
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Unsaved title" }));
|
||
}
|
||
let list = ok(&mut a, "doc_list", json!({}));
|
||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
let text = ok(&mut a, "text_extract", json!({ "doc": doc }));
|
||
let bytes = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone();
|
||
for invalid in [json!("false"), json!(0), json!([]), json!({})] {
|
||
let error = a.call("doc_close", &json!({ "doc": doc, "discard_changes": invalid })).unwrap_err();
|
||
assert!(matches!(error, ToolError::InvalidArgs(ref message) if message == "discard_changes must be true or false"));
|
||
assert_eq!(ok(&mut a, "doc_list", json!({})), list, "all document identities, order, paths and history stay unchanged");
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc })), info);
|
||
assert_eq!(ok(&mut a, "text_extract", json!({ "doc": doc })), text);
|
||
let current = a.session().get(pdfcraft_engine::DocId(doc)).unwrap();
|
||
assert_eq!(current.bytes, bytes);
|
||
assert_eq!(current.dirty, dirty);
|
||
assert_eq!(std::fs::read(dir.0.join("a.pdf")).unwrap(), source);
|
||
assert_eq!(std::fs::read(dir.0.join("b.pdf")).unwrap(), other_source);
|
||
}
|
||
assert_eq!(ok(&mut a, "doc_close", json!({ "doc": doc, "discard_changes": true }))["closed"], doc);
|
||
assert!(a.session().get(pdfcraft_engine::DocId(doc)).is_none());
|
||
assert!(a.session().get(pdfcraft_engine::DocId(other)).is_some());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn close_keeps_default_and_boolean_discard_controls() {
|
||
let dir = CloseDir::new();
|
||
let source = std::fs::read(dir.0.join("a.pdf")).unwrap();
|
||
let mut a = auto(&dir.0);
|
||
let other = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
|
||
for discard in [None, Some(Value::Null), Some(json!(false)), Some(json!(true))] {
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let mut args = json!({ "doc": doc });
|
||
if let Some(discard) = discard {
|
||
args["discard_changes"] = discard;
|
||
}
|
||
assert_eq!(ok(&mut a, "doc_close", args)["closed"], doc);
|
||
assert!(a.session().get(pdfcraft_engine::DocId(doc)).is_none());
|
||
assert!(a.session().get(pdfcraft_engine::DocId(other)).is_some());
|
||
}
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Unsaved title" }));
|
||
let before = ok(&mut a, "doc_list", json!({}));
|
||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
let bytes = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone();
|
||
for args in [json!({ "doc": doc }), json!({ "doc": doc, "discard_changes": null }), json!({ "doc": doc, "discard_changes": false })] {
|
||
let error = a.call("doc_close", &args).unwrap_err();
|
||
assert!(matches!(error, ToolError::Failed(ref message) if message.contains("unsaved changes")));
|
||
assert_eq!(ok(&mut a, "doc_list", json!({})), before);
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc })), info);
|
||
assert_eq!(a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes, bytes);
|
||
}
|
||
assert_eq!(ok(&mut a, "doc_close", json!({ "doc": doc, "discard_changes": true }))["closed"], doc);
|
||
assert!(a.session().get(pdfcraft_engine::DocId(doc)).is_none());
|
||
assert!(a.session().get(pdfcraft_engine::DocId(other)).is_some());
|
||
assert_eq!(std::fs::read(dir.0.join("a.pdf")).unwrap(), source);
|
||
}
|
||
}
|
||
|
||
mod combine_argument_tests {
|
||
use super::*;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
|
||
// Own only a newly created directory; the bounded runner provides project-local TMPDIR.
|
||
struct CombineDir(PathBuf);
|
||
|
||
impl CombineDir {
|
||
fn new() -> Self {
|
||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||
let parent = std::env::temp_dir();
|
||
for _ in 0..128 {
|
||
let serial = NEXT.fetch_add(1, Ordering::Relaxed);
|
||
let path = parent.join(format!("pdfcraft-combine-{}-{serial}", std::process::id()));
|
||
match std::fs::create_dir(&path) {
|
||
Ok(()) => {
|
||
let dir = Self(path);
|
||
std::fs::write(dir.0.join("a.pdf"), fixture(3)).unwrap();
|
||
std::fs::write(dir.0.join("b.pdf"), fixture(2)).unwrap();
|
||
return dir;
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||
Err(error) => panic!("creating combine test directory: {error}"),
|
||
}
|
||
}
|
||
panic!("no unused combine test directory after 128 attempts");
|
||
}
|
||
}
|
||
|
||
impl Drop for CombineDir {
|
||
fn drop(&mut self) {
|
||
let _ = std::fs::remove_dir_all(&self.0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_combine_selectors_preserve_outputs_inputs_and_open_documents() {
|
||
let dir = CombineDir::new();
|
||
let first = std::fs::read(dir.0.join("a.pdf")).unwrap();
|
||
let second = std::fs::read(dir.0.join("b.pdf")).unwrap();
|
||
let sentinel = b"existing destination";
|
||
std::fs::write(dir.0.join("combined.pdf"), sentinel).unwrap();
|
||
let mut a = auto(&dir.0);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Unsaved title" }));
|
||
ok(&mut a, "doc_open", json!({ "path": "b.pdf" }));
|
||
let list = ok(&mut a, "doc_list", json!({}));
|
||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||
let text = page_text(&mut a, doc);
|
||
let bytes = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone();
|
||
for invalid in [json!(1), json!(true), json!({ "page": 1 }), json!(["1"])] {
|
||
for (index, pages) in [json!([invalid, null]), json!([null, invalid])].into_iter().enumerate() {
|
||
for out in ["combined.pdf", "not-created.pdf"] {
|
||
let error = a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "pages": pages, "out": out, "open": true })).unwrap_err();
|
||
assert!(
|
||
matches!(error, ToolError::InvalidArgs(ref message) if message == &format!("pages[{index}] must be a range string or null"))
|
||
);
|
||
assert_eq!(std::fs::read(dir.0.join("combined.pdf")).unwrap(), sentinel);
|
||
assert!(!dir.0.join("not-created.pdf").exists());
|
||
assert_eq!(ok(&mut a, "doc_list", json!({})), list);
|
||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc })), info);
|
||
assert_eq!(page_text(&mut a, doc), text);
|
||
assert_eq!(a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes, bytes);
|
||
assert_eq!(std::fs::read(dir.0.join("a.pdf")).unwrap(), first);
|
||
assert_eq!(std::fs::read(dir.0.join("b.pdf")).unwrap(), second);
|
||
}
|
||
}
|
||
}
|
||
let error = a
|
||
.call("doc_combine", &json!({ "paths": ["missing-a.pdf", "missing-b.pdf"], "pages": [null, false], "out": "not-created.pdf" }))
|
||
.unwrap_err();
|
||
assert!(matches!(error, ToolError::InvalidArgs(ref message) if message == "pages[1] must be a range string or null"));
|
||
assert_eq!(ok(&mut a, "doc_list", json!({})), list);
|
||
assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 3, "no output or staging files are created");
|
||
}
|
||
|
||
#[test]
|
||
fn valid_combine_selectors_keep_all_pages_and_selected_order_after_reopen() {
|
||
let dir = CombineDir::new();
|
||
let first = std::fs::read(dir.0.join("a.pdf")).unwrap();
|
||
let second = std::fs::read(dir.0.join("b.pdf")).unwrap();
|
||
let mut a = auto(&dir.0);
|
||
let all = vec!["Page 1", "Page 2", "Page 3", "Page 1", "Page 2"];
|
||
for (pages, expected) in [
|
||
(None, all.clone()),
|
||
(Some(Value::Null), all.clone()),
|
||
(Some(json!([null, null])), all.clone()),
|
||
(Some(json!(["", null])), all),
|
||
(Some(json!(["3, 1", null])), vec!["Page 3", "Page 1", "Page 1", "Page 2"]),
|
||
(Some(json!([null, "2"])), vec!["Page 1", "Page 2", "Page 3", "Page 2"]),
|
||
] {
|
||
let mut args = json!({ "paths": ["a.pdf", "b.pdf"], "out": "combined.pdf", "open": false });
|
||
if let Some(pages) = pages {
|
||
args["pages"] = pages;
|
||
}
|
||
let result = ok(&mut a, "doc_combine", args);
|
||
assert!(result["bytes"].as_u64().unwrap() > 0);
|
||
assert!(result.get("document").is_none());
|
||
assert!(a.session().docs().is_empty());
|
||
let mut fresh = auto(&dir.0);
|
||
let opened = ok(&mut fresh, "doc_open", json!({ "path": "combined.pdf" }));
|
||
assert_eq!(opened["pages"].as_u64().unwrap(), expected.len() as u64);
|
||
assert_eq!(page_text(&mut fresh, opened["doc"].as_u64().unwrap()), expected);
|
||
assert_eq!(std::fs::read(dir.0.join("a.pdf")).unwrap(), first);
|
||
assert_eq!(std::fs::read(dir.0.join("b.pdf")).unwrap(), second);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn conventions_core_tools_preserve_root_and_batch_errors() {
|
||
let dir = workdir("conventions-core");
|
||
let mut s = McpServer::new(auto(&dir));
|
||
let run = |s: &mut McpServer, name: &str, args: Value| rpc(s, 1, "tools/call", json!({"name":name,"arguments":args}));
|
||
assert_eq!(run(&mut s, "doc_inspect", json!({}))["result"]["structuredContent"]["documents"], json!([]));
|
||
let opened = run(&mut s, "command_run", json!({"id":"file.open","params":{"path":"a.pdf"}}));
|
||
assert_eq!(opened["result"]["isError"], false, "{opened}");
|
||
let doc = opened["result"]["structuredContent"]["doc"].as_u64().unwrap();
|
||
let filtered = run(&mut s, "command_list", json!({"doc":doc,"filter":"rotate","enabled_only":true}));
|
||
let commands = filtered["result"]["structuredContent"]["commands"].as_array().unwrap();
|
||
assert!(!commands.is_empty());
|
||
assert!(commands.iter().all(|c| c["enabled"] == true));
|
||
assert!(commands.iter().find(|c| c["id"] == "page.rotate").unwrap()["params"].is_object());
|
||
for stop in [true, false] {
|
||
let r = run(
|
||
&mut s,
|
||
"command_batch",
|
||
json!({"steps":[{"id":"page.rotate","params":{"doc":doc,"degrees":90}},{"id":"no.such.command"},{"id":"page.rotate","params":{"doc":doc,"degrees":90}}],"stop_on_error":stop}),
|
||
);
|
||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||
assert_eq!(r["result"]["structuredContent"]["completed"], if stop { 1 } else { 2 });
|
||
assert_eq!(r["result"]["structuredContent"]["failed"], 1);
|
||
}
|
||
let r = run(&mut s, "command_run", json!({"id":"file.save","params":{"doc":doc,"path":"../escaped.pdf"}}));
|
||
assert_eq!(r["result"]["isError"], true);
|
||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the allowed directory"));
|
||
let r = run(&mut s, "command_run", json!({"id":"page.rotate","params":{"doc":doc,"degrees":90,"typo":1}}));
|
||
assert_eq!(r["result"]["isError"], false);
|
||
assert!(r["result"]["structuredContent"]["warnings"][0].as_str().unwrap().contains("typo"));
|
||
let before = run(&mut s, "doc_info", json!({"doc":doc}));
|
||
let r = run(&mut s, "render_preview", json!({"doc":doc,"page":1,"max_side":64}));
|
||
assert_eq!(r["result"]["isError"], false, "{r}");
|
||
use base64::Engine as _;
|
||
let png = base64::engine::general_purpose::STANDARD.decode(r["result"]["content"][0]["data"].as_str().unwrap()).unwrap();
|
||
let image = image::load_from_memory(&png).unwrap();
|
||
assert!(image.width().max(image.height()) <= 64);
|
||
assert_eq!(before["result"], run(&mut s, "doc_info", json!({"doc":doc}))["result"]);
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn conventions_annotations_and_strict_keys_in_both_modes() {
|
||
for compact in [false, true] {
|
||
let mut s = McpServer::new(Automation::new()).with_compact(compact);
|
||
let r = rpc(&mut s, 1, "tools/list", json!({}));
|
||
let tools = r["result"]["tools"].as_array().unwrap();
|
||
for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview"] {
|
||
assert!(tools.iter().any(|t| t["name"] == name), "missing {name}");
|
||
}
|
||
for tool in tools {
|
||
for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
|
||
assert!(tool["annotations"][hint].is_boolean(), "{}: {hint}", tool["name"]);
|
||
}
|
||
let r = rpc(&mut s, 2, "tools/call", json!({"name":tool["name"],"arguments":{"bogus_arg":1}}));
|
||
assert_eq!(r["error"]["code"], -32602, "{}", tool["name"]);
|
||
let message = r["error"]["message"].as_str().unwrap();
|
||
assert!(message.contains("bogus_arg") && message.contains("expected:"));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
#[test]
|
||
fn conventions_resources() {
|
||
let dir = workdir("conventions-resources");
|
||
let mut s = McpServer::new(auto(&dir));
|
||
let read = |s: &mut McpServer, uri: &str| {
|
||
let r = rpc(s, 1, "resources/read", json!({"uri":uri}));
|
||
serde_json::from_str::<Value>(r["result"]["contents"][0]["text"].as_str().expect("JSON resource")).unwrap()
|
||
};
|
||
assert_eq!(read(&mut s, "pdfcraft://document"), json!({"documents":[]}));
|
||
rpc(&mut s, 2, "tools/call", json!({"name":"doc_open","arguments":{"path":"a.pdf"}}));
|
||
let doc = rpc(&mut s, 3, "tools/call", json!({"name":"doc_inspect","arguments":{}}));
|
||
assert_eq!(read(&mut s, "pdfcraft://document"), doc["result"]["structuredContent"]);
|
||
let commands = rpc(&mut s, 4, "tools/call", json!({"name":"command_list","arguments":{}}));
|
||
assert_eq!(read(&mut s, "pdfcraft://commands"), commands["result"]["structuredContent"]);
|
||
// Responses keep the shape every supported protocol revision expects: no cache hints.
|
||
for (method, params) in [("tools/list", json!({})), ("resources/list", json!({})), ("resources/read", json!({"uri":"pdfcraft://document"}))] {
|
||
assert!(rpc(&mut s, 5, method, params)["result"].get("resultType").is_none());
|
||
}
|
||
assert_eq!(rpc(&mut s, 7, "initialize", json!({"protocolVersion":"2026-07-28"}))["result"]["protocolVersion"], "2025-06-18");
|
||
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":999}}"#).is_none());
|
||
assert_eq!(
|
||
rpc(&mut s, 9, "tools/call", json!({"name":"doc_inspect","arguments":{},"_meta":{"progressToken":"quick"}}))["result"]["isError"],
|
||
false
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn command_batch_refuses_more_than_a_thousand_steps() {
|
||
let dir = workdir("batch-cap");
|
||
let mut a = auto(&dir);
|
||
let steps: Vec<Value> = (0..1001).map(|_| json!({"id":"no.such.command"})).collect();
|
||
let err = a.call("command_batch", &json!({"steps": steps})).unwrap_err();
|
||
assert!(err.to_string().contains("at most 1000 steps"), "{err}");
|
||
}
|
||
|
||
#[test]
|
||
fn thai_text_through_tools_saves_reopens_extracts_and_renders() {
|
||
let dir = workdir("thai-content");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap();
|
||
let text = "สวัสดี น้ำ ผู้ใช้ ปู่ ๑๒๓ Hello 123";
|
||
let args = json!({"doc": doc, "page": 1, "text": text, "at": [10, 20], "size": 9});
|
||
if pdfcraft_fonts::document_thai_font().is_none() {
|
||
assert!(a.call("page_add_text", &args).unwrap_err().to_string().contains("Thai"));
|
||
assert_eq!(ok(&mut a, "content_list", json!({"doc": doc}))["count"], 0);
|
||
return;
|
||
}
|
||
ok(&mut a, "page_add_text", args);
|
||
assert!(page_text(&mut a, doc)[0].contains(text), "{:?}", page_text(&mut a, doc));
|
||
ok(&mut a, "doc_save", json!({"doc": doc, "path": "thai.pdf"}));
|
||
let reopened = ok(&mut a, "doc_open", json!({"path": "thai.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "content_list", json!({"doc": reopened}))["items"][0]["text"], text);
|
||
assert!(page_text(&mut a, reopened)[0].contains(text));
|
||
let rendered = a.call("page_render", &json!({"doc": reopened, "page": 1, "dpi": 144})).unwrap();
|
||
assert!(rendered.iter().any(|c| matches!(c, Content::Png { data, width, height } if !data.is_empty() && *width > 0 && *height > 0)));
|
||
ok(&mut a, "content_update", json!({"doc": reopened, "page": 1, "index": 1, "text": "แก้ไข น้ำ"}));
|
||
assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ"));
|
||
}
|
||
|
||
#[test]
|
||
fn mixed_objects_move_through_tools_with_generation_and_one_undo() {
|
||
let dir = workdir("mixed-object-move");
|
||
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":"Added label","rect":[20,20,130,45]}));
|
||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"before.pdf"}));
|
||
let inventory = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
|
||
assert_eq!(inventory["count"], 2);
|
||
let objects: Vec<_> = inventory["objects"].as_array().unwrap().iter().map(|o| json!({"kind":o["kind"],"index":o["index"]})).collect();
|
||
let moved = ok(&mut a, "object_move", json!({"doc":doc,"page":1,"generation":inventory["generation"],"objects":objects,"offset":[18,27]}));
|
||
assert_eq!(moved["moved"], 2);
|
||
let after = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
|
||
for (old, new) in inventory["objects"].as_array().unwrap().iter().zip(after["objects"].as_array().unwrap()) {
|
||
for i in 0..4 {
|
||
assert!((new["rect"][i].as_f64().unwrap() - old["rect"][i].as_f64().unwrap() - if i % 2 == 0 { 18.0 } else { 27.0 }).abs() < 0.02);
|
||
}
|
||
assert_eq!(old["text"], new["text"]);
|
||
}
|
||
assert!(a.call("object_move", &json!({"doc":doc,"page":1,"generation":inventory["generation"],"objects":objects,"offset":[1,1]})).is_err());
|
||
let stable = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
|
||
assert_eq!(stable, after, "a stale generation cannot edit");
|
||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1}))["objects"], inventory["objects"]);
|
||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"moved.pdf"}));
|
||
let reopened = ok(&mut a, "doc_open", json!({"path":"moved.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "object_list", json!({"doc":reopened,"page":1}))["objects"], after["objects"]);
|
||
assert!(page_text(&mut a, reopened)[0].contains("Added label"));
|
||
}
|
||
|
||
#[test]
|
||
fn object_move_rejects_invalid_nested_references_without_any_change() {
|
||
let dir = workdir("object-move-refusals");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
|
||
let before = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
|
||
for objects in [
|
||
json!([]),
|
||
json!([{"kind":"text","index":0}]),
|
||
json!([{"kind":"path","index":1}]),
|
||
json!([{"kind":"text","index":1,"extra":true}]),
|
||
json!([{"kind":"text","index":1.5}]),
|
||
json!([{"kind":"text","index":1},{"kind":"text","index":1}]),
|
||
json!([{"kind":"text","index":1},{"kind":"image","index":99}]),
|
||
json!(vec![json!({"kind":"text","index":1}); 1001]),
|
||
] {
|
||
assert!(a.call("object_move", &json!({"doc":doc,"page":1,"objects":objects,"offset":[10,20]})).is_err());
|
||
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1})), before);
|
||
}
|
||
for offset in [json!([0, 0]), json!([1]), json!([1, 2, 3]), json!(["1", 2]), json!([1e30, 1])] {
|
||
assert!(a.call("object_move", &json!({"doc":doc,"page":1,"objects":[{"kind":"text","index":1}],"offset":offset})).is_err());
|
||
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1})), before);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_deleted_image_is_not_kept_by_reduce_or_optimize() {
|
||
let dir = workdir("deleted-image");
|
||
let (w, h) = (600u32, 400u32);
|
||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 255 / w) as u8 ^ (i & 7) as u8, (i / w * 255 / h) as u8, 128]).collect();
|
||
let mut photo = Vec::new();
|
||
{
|
||
let mut enc = png::Encoder::new(&mut photo, w, h);
|
||
enc.set_color(png::ColorType::Rgb);
|
||
enc.set_depth(png::BitDepth::Eight);
|
||
enc.write_header().unwrap().write_image_data(&px).unwrap();
|
||
}
|
||
std::fs::write(dir.join("photo.png"), photo).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["photo.png"] }))["doc"].as_u64().unwrap();
|
||
ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "delete" }));
|
||
assert_eq!(ok(&mut a, "page_images", json!({ "doc": doc, "page": 1 }))["count"], 0);
|
||
// The page is blank, so the copies are tiny: the picture's data is not carried along.
|
||
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "optimized.pdf" }));
|
||
assert_eq!(r["unused_xobjects"], 1, "{r}");
|
||
assert!(r["bytes_after"].as_u64().unwrap() < 4_000, "{r}");
|
||
let r = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" }));
|
||
assert!(r["bytes_after"].as_u64().unwrap() < 4_000, "{r}");
|
||
let reopened = ok(&mut a, "doc_open", json!({ "path": "reduced.pdf" }));
|
||
assert_eq!(reopened["pages"], 1);
|
||
}
|
||
|
||
/// A two-page PDF with the same three-row table ("Name Qty / Apple 12 / Pear 7"); the second
|
||
/// page is turned with /Rotate 90.
|
||
fn table_pdf() -> Vec<u8> {
|
||
let body = "BT /F1 12 Tf 20 250 Td (Name) Tj 130 0 Td (Qty) Tj -130 -20 Td (Apple) Tj 130 0 Td (12) Tj -130 -20 Td (Pear) Tj 130 0 Td (7) Tj ET";
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 300] >>".to_string(),
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
|
||
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
|
||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
|
||
];
|
||
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{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
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
|
||
}
|
||
|
||
/// Issue #740: `text_extract` with `rect` takes what Column select takes, in the displayed-page
|
||
/// coordinates `text_find` reports, on upright and turned pages alike.
|
||
#[test]
|
||
fn text_extract_with_a_rect_takes_one_column() {
|
||
let dir = workdir("column");
|
||
std::fs::write(dir.join("table.pdf"), table_pdf()).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
|
||
let reading = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }));
|
||
assert_eq!(reading["pages"][0]["text"], "Name\nApple\nPear\nQty\n12\n7", "reading order is column by column");
|
||
|
||
for page in [1u64, 2] {
|
||
// The box around "Qty" and "7", as text_find reports them on this page.
|
||
let rect_of = |a: &mut Automation, q: &str| -> [f64; 4] {
|
||
let found = ok(a, "text_find", json!({ "doc": doc, "query": q }));
|
||
let m = found["matches"].as_array().unwrap().iter().find(|m| m["page"] == page).unwrap().clone();
|
||
let r: Vec<f64> = m["rects"][0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||
[r[0], r[1], r[2], r[3]]
|
||
};
|
||
let (qty, seven) = (rect_of(&mut a, "Qty"), rect_of(&mut a, "7"));
|
||
let rect = [qty[0].min(seven[0]) - 2.0, qty[1].min(seven[1]) - 2.0, qty[2].max(seven[2]) + 2.0, qty[3].max(seven[3]) + 2.0];
|
||
let column = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [page], "rect": rect }));
|
||
assert_eq!(column["pages"][0]["text"], "Qty\n12\n7", "page {page}, rect {rect:?}");
|
||
}
|
||
|
||
// The whole table, row by row with tab-separated cells (upright page).
|
||
let all = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [0, 0, 300, 300] }));
|
||
assert_eq!(all["pages"][0]["text"], "Name\tQty\nApple\t12\nPear\t7");
|
||
// Nothing inside: empty text, not an error.
|
||
let none = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [0, 0, 5, 5] }));
|
||
assert_eq!(none["pages"][0]["text"], "");
|
||
// A rect that is not four numbers is refused.
|
||
for bad in [json!([1, 2, 3]), json!("0 0 1 1"), json!([0, 0, "a", 1])] {
|
||
let e = a.call("text_extract", &json!({ "doc": doc, "rect": bad })).unwrap_err();
|
||
assert!(matches!(e, ToolError::InvalidArgs(_)), "{bad}: {e}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools() {
|
||
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
|
||
let dir = workdir("mixed-region");
|
||
let mut cos = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||
let page = pdfcraft_model::pages(&cos)[0].clone();
|
||
let mut font = Dict::new();
|
||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold"));
|
||
let bold = cos.add(Object::Dict(font));
|
||
let mut resources = cos.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
|
||
let mut fonts = cos.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone();
|
||
fonts.set(b"F2".to_vec(), Object::Ref(bold));
|
||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||
let stream = cos.add(Object::Stream(Stream::flate(Dict::new(),
|
||
b"BT /F2 1 Tf 18 0 0 18 20 240 Tm (Heading) Tj ET BT /F1 12 Tf 0 g 20 217 Td (Body ) Tj /F2 12 Tf 0 0.2 0.5 rg (bold) Tj /F1 12 Tf 0 g ( text) Tj ET")));
|
||
cos.update_dict(page.obj, |d| {
|
||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||
d.set(b"Contents".to_vec(), Object::Ref(stream));
|
||
})
|
||
.unwrap();
|
||
std::fs::write(dir.join("mixed.pdf"), write_incremental(&cos, &Default::default()).unwrap()).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({"path":"mixed.pdf"}))["doc"].as_u64().unwrap();
|
||
let before = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1}));
|
||
assert_eq!(before["count"], 1);
|
||
assert_eq!(before["paragraphs"][0]["editor_text"], "Heading\nBody bold text");
|
||
assert_eq!(before["paragraphs"][0]["rows"].as_array().unwrap().len(), 2);
|
||
assert_eq!(before["paragraphs"][0]["lines"].as_array().unwrap().len(), 4);
|
||
let unchanged = ok(&mut a, "text_edit_layout", json!({"doc":doc,"page":1,"paragraph":1}));
|
||
assert_eq!(unchanged["overflow"], false);
|
||
assert_eq!(unchanged["source_rect"], unchanged["draft_rect"]);
|
||
let growing = ok(&mut a, "text_edit_layout", json!({"doc":doc,"page":1,"paragraph":1,"text":"Heading\nBody bold text\nExtra row\nAnother row"}));
|
||
assert_eq!(growing["overflow"], true);
|
||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})), before, "measuring does not apply a draft");
|
||
let normal = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap();
|
||
let preview = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":1})).unwrap();
|
||
assert_ne!(normal, preview);
|
||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})), before, "preview is read-only");
|
||
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":"Heading\nText bold text"}));
|
||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"out/mixed-edited.pdf"}));
|
||
let re = ok(&mut a, "doc_open", json!({"path":"out/mixed-edited.pdf"}))["doc"].as_u64().unwrap();
|
||
let after = ok(&mut a, "text_paragraphs", json!({"doc":re,"page":1}));
|
||
assert_eq!(after["count"], 1);
|
||
assert_eq!(after["paragraphs"][0]["editor_text"], "Heading\nText bold text");
|
||
let saved = Document::open(std::sync::Arc::new(std::fs::read(dir.join("out/mixed-edited.pdf")).unwrap())).unwrap();
|
||
let mut session = pdfcraft_engine::Session::new();
|
||
let id = session.open("saved.pdf", None, std::sync::Arc::new(write_incremental(&saved, &Default::default()).unwrap()), None).unwrap();
|
||
let runs = session.get(id).unwrap().text_lines(0);
|
||
let preserved = runs.iter().find(|l| l.text == "bold").unwrap();
|
||
assert!(preserved.bold);
|
||
assert_eq!(preserved.color, [0.0, 0.2, 0.5]);
|
||
ok(&mut a, "text_edit", json!({"doc":re,"page":1,"paragraph":1,"text":""}));
|
||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":re,"page":1}))["count"], 0);
|
||
ok(&mut a, "edit_undo", json!({"doc":re}));
|
||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":re,"page":1})), after);
|
||
}
|
||
|
||
#[test]
|
||
fn clipped_image_bounds_and_text_edit_save_reopen_through_tools() {
|
||
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
|
||
let dir = workdir("clipped-artwork");
|
||
let mut cos = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||
let page = pdfcraft_model::pages(&cos)[0].clone();
|
||
let mut image = Dict::new();
|
||
for (key, value) in [
|
||
("Type", Object::name("XObject")),
|
||
("Subtype", Object::name("Image")),
|
||
("Width", Object::Int(1)),
|
||
("Height", Object::Int(1)),
|
||
("BitsPerComponent", Object::Int(8)),
|
||
("ColorSpace", Object::name("DeviceRGB")),
|
||
] {
|
||
image.set(key.as_bytes().to_vec(), value);
|
||
}
|
||
let image = cos.add(Object::Stream(Stream::flate(image, &[220, 235, 245])));
|
||
let mut resources = cos.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
|
||
let mut xo = Dict::new();
|
||
xo.set(b"Im0".to_vec(), Object::Ref(image));
|
||
resources.set(b"XObject".to_vec(), Object::Dict(xo));
|
||
let contents = cos.add(Object::Stream(Stream::flate(
|
||
Dict::new(),
|
||
b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 195 Td (Shared caption) Tj ET Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET",
|
||
)));
|
||
cos.update_dict(page.obj, |d| {
|
||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||
d.set(b"Contents".to_vec(), Object::Ref(contents));
|
||
})
|
||
.unwrap();
|
||
std::fs::write(dir.join("clipped.pdf"), write_incremental(&cos, &Default::default()).unwrap()).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({"path":"clipped.pdf"}))["doc"].as_u64().unwrap();
|
||
let pixels = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap();
|
||
let images = ok(&mut a, "page_images", json!({"doc":doc,"page":1}));
|
||
assert_eq!(ok(&mut a, "image_hit", json!({"doc":doc,"page":1,"point":[50,60]}))["images"], json!([1]));
|
||
assert_eq!(ok(&mut a, "image_hit", json!({"doc":doc,"page":1,"point":[20,60]}))["images"], json!([]));
|
||
assert_eq!(images["images"][0]["visible_rect"], json!([30.0, 50.0, 160.0, 130.0]));
|
||
assert_eq!(images["images"][0]["rect"], json!([-300.0, -200.0, 500.0, 700.0]), "raw placement remains available for transforms");
|
||
let objects = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
|
||
let object = objects["objects"].as_array().unwrap().iter().find(|o| o["kind"] == "image").unwrap();
|
||
assert_eq!(object["rect"], images["images"][0]["visible_rect"]);
|
||
assert_eq!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(), pixels, "inventory does not change rendering");
|
||
let labels = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1}));
|
||
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"move","rect":[-432.0,-300.0,688.0,960.0]}));
|
||
let resized = ok(&mut a, "page_images", json!({"doc":doc,"page":1}));
|
||
assert_eq!(
|
||
resized["images"][0]["visible_rect"],
|
||
json!([30.0, 50.0, 160.0, 130.0]),
|
||
"a clip shared with the caption stays where it is (#1049 moves only an image's own clip)"
|
||
);
|
||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})), labels);
|
||
// The one-colour image still fills the fixed shared clip, so the page looks the same.
|
||
let resized_pixels = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap();
|
||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"out/resized.pdf"}));
|
||
let resized_re = ok(&mut a, "doc_open", json!({"path":"out/resized.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "page_images", json!({"doc":resized_re,"page":1})), resized);
|
||
assert_eq!(a.call("page_render", &json!({"doc":resized_re,"page":1,"dpi":72})).unwrap(), resized_pixels);
|
||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||
assert_eq!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(), pixels);
|
||
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":2,"text":"Edited caption"}));
|
||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"out/edited.pdf"}));
|
||
let re = ok(&mut a, "doc_open", json!({"path":"out/edited.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":re,"page":1}))["paragraphs"][1]["editor_text"], "Edited caption");
|
||
assert_eq!(ok(&mut a, "page_images", json!({"doc":re,"page":1})), images, "image placement and crop survive text editing");
|
||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||
assert_eq!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(), pixels);
|
||
}
|
||
|
||
/// Independent inch/mm dimensions, checked in the saved file through the tool path.
|
||
#[test]
|
||
fn architectural_and_iso_papers_through_tools() {
|
||
let dir = workdir("plotter-papers");
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let mm = 72.0 / 25.4;
|
||
for (name, w, h) in [
|
||
("ARCH A", 9.0 * 72.0, 12.0 * 72.0),
|
||
("arch b", 12.0 * 72.0, 18.0 * 72.0),
|
||
("ARCH C", 18.0 * 72.0, 24.0 * 72.0),
|
||
("ARCH D", 24.0 * 72.0, 36.0 * 72.0),
|
||
("ARCH E1", 30.0 * 72.0, 42.0 * 72.0),
|
||
("ARCH E (36 x 48 in)", 36.0 * 72.0, 48.0 * 72.0),
|
||
("ANSI C", 17.0 * 72.0, 22.0 * 72.0),
|
||
("ANSI D", 22.0 * 72.0, 34.0 * 72.0),
|
||
("ANSI E", 34.0 * 72.0, 44.0 * 72.0),
|
||
("A2", 420.0 * mm, 594.0 * mm),
|
||
("A1", 594.0 * mm, 841.0 * mm),
|
||
("A0", 841.0 * mm, 1189.0 * mm),
|
||
("Letter", 612.0, 792.0),
|
||
("US Legal", 612.0, 1008.0),
|
||
("Tabloid", 792.0, 1224.0),
|
||
] {
|
||
for orientation in ["portrait", "landscape"] {
|
||
let out = ok(
|
||
&mut a,
|
||
"doc_print",
|
||
json!({"doc": doc, "pages": "1", "paper": name,
|
||
"layout": "actual", "orientation": orientation, "path": "plot.pdf"}),
|
||
);
|
||
assert_eq!(out["sheets"], 1);
|
||
let bytes = std::fs::read(dir.join("plot.pdf")).unwrap();
|
||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||
let pages = pdfcraft_model::pages(&saved);
|
||
assert_eq!(pages.len(), 1);
|
||
let (ew, eh) = if orientation == "landscape" { (h, w) } else { (w, h) };
|
||
let (aw, ah) = pages[0].display_size(&saved);
|
||
assert!((aw - ew).abs() < 0.02 && (ah - eh).abs() < 0.02, "{name} {orientation}: {aw} x {ah}");
|
||
}
|
||
}
|
||
let before = std::fs::read(dir.join("plot.pdf")).unwrap();
|
||
let bad = a.call("doc_print", &json!({"doc": doc, "paper": "ARCH Z", "path": "plot.pdf"}));
|
||
assert!(matches!(bad, Err(ToolError::InvalidArgs(m)) if m.contains("ARCH E1") && m.contains("A0")));
|
||
assert_eq!(std::fs::read(dir.join("plot.pdf")).unwrap(), before);
|
||
assert!(a.call("doc_print", &json!({"doc": doc, "paper": "ARCH D", "path": "../escape.pdf"})).is_err());
|
||
}
|
||
|
||
/// A 2-page 200×300 pt PDF in the `fixture()` style for the reading-colours test: bold black
|
||
/// "Page N" text on a white page; page 2 also draws a 4×4 uncompressed `/DeviceRGB` image,
|
||
/// scaled to 40×40 pt at 120, 200.
|
||
fn reading_colors_fixture() -> Vec<u8> {
|
||
let body1 = "BT /F1 48 Tf 20 150 Td (Page 1) Tj ET";
|
||
let body2 = "BT /F1 48 Tf 20 150 Td (Page 2) Tj ET\nq 40 0 0 40 120 200 cm /Im1 Do Q";
|
||
// 16 opaque pure-red pixels; images are never remapped by the reading-colours setting.
|
||
let image: Vec<u8> = (0..16).flat_map(|_| [0xff_u8, 0x00, 0x00]).collect();
|
||
let mut objs: Vec<Vec<u8>> = vec![
|
||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 300] >>".to_vec(),
|
||
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >>".to_vec(),
|
||
b"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> /XObject << /Im1 8 0 R >> >> >>".to_vec(),
|
||
format!("<< /Length {} >>\nstream\n{body1}\nendstream", body1.len()).into_bytes(),
|
||
format!("<< /Length {} >>\nstream\n{body2}\nendstream", body2.len()).into_bytes(),
|
||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".to_vec(),
|
||
];
|
||
let mut image_obj = format!(
|
||
"<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >>\nstream\n",
|
||
image.len()
|
||
)
|
||
.into_bytes();
|
||
image_obj.extend_from_slice(&image);
|
||
image_obj.extend_from_slice(b"\nendstream");
|
||
objs.push(image_obj);
|
||
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 whose `a.pdf` is `reading_colors_fixture()`.
|
||
fn reading_colors_workdir() -> PathBuf {
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-reading-colors-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("a.pdf"), reading_colors_fixture()).unwrap();
|
||
dir
|
||
}
|
||
|
||
/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels.
|
||
fn png_pixels(content: &Content) -> Vec<u8> {
|
||
let Content::Png { data, width, height } = content else { panic!("expected a PNG") };
|
||
assert_eq!((*width, *height), (200, 300));
|
||
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
|
||
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
|
||
let info = reader.next_frame(&mut pixels).unwrap();
|
||
assert_eq!((info.width, info.height), (200, 300));
|
||
pixels
|
||
}
|
||
|
||
/// The RGBA pixel at (`x`, `y`), top-left origin, of a 200-wide image.
|
||
fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] {
|
||
let at = ((y * 200 + x) * 4) as usize;
|
||
pixels[at..at + 4].try_into().unwrap()
|
||
}
|
||
|
||
/// The `view_reading_colors` tool changes what `page_render` draws — text and background remap,
|
||
/// images never do — without touching the file, and its errors name what's wrong (#897).
|
||
#[test]
|
||
fn reading_colors_through_tools() {
|
||
let dir = reading_colors_workdir();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||
let render = |a: &mut Automation, page: u64| png_pixels(&a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap()[0]);
|
||
|
||
// Off: black text on a white page.
|
||
let page1_off = render(&mut a, 1);
|
||
let page2_off = render(&mut a, 2);
|
||
assert_eq!(pixel(&page1_off, 10, 290), [255, 255, 255, 255]);
|
||
|
||
let echo = ok(&mut a, "view_reading_colors", json!({ "doc": doc, "mode": "white-on-black" }));
|
||
assert_eq!(echo, json!({ "mode": "white-on-black", "text": "#ffffff", "background": "#000000", "only_black": false, "line_art": true }));
|
||
|
||
// The page clears to the setting's background, and the text paints with its colour.
|
||
let page1_on = render(&mut a, 1);
|
||
assert_eq!(pixel(&page1_on, 10, 290), [0, 0, 0, 255], "background clears to black");
|
||
// The brightest pixel inside the text band is a stroke interior: a 48 pt bold stem covers it.
|
||
let (tx, ty) = (20..172u32).flat_map(|x| (116..160u32).map(move |y| (x, y))).max_by_key(|&(x, y)| u32::from(pixel(&page1_on, x, y)[0])).unwrap();
|
||
let on = pixel(&page1_on, tx, ty);
|
||
assert!(on[0] >= 250, "a text-interior pixel renders white: {on:?}");
|
||
let off = pixel(&page1_off, tx, ty);
|
||
assert!(off[0] <= 60, "the same pixel is black ink without the setting: {off:?}");
|
||
|
||
// Images are never remapped: page 2's picture draws byte-identically in both modes.
|
||
let page2_on = render(&mut a, 2);
|
||
fn image_row(px: &[u8], y: u32) -> &[u8] {
|
||
let at = ((y * 200 + 122) * 4) as usize;
|
||
&px[at..at + 36 * 4]
|
||
}
|
||
assert_eq!(pixel(&page2_on, 140, 80), [255, 0, 0, 255], "the image keeps its colour");
|
||
for y in 62..98u32 {
|
||
assert_eq!(image_row(&page2_on, y), image_row(&page2_off, y), "image row {y}");
|
||
}
|
||
|
||
// Errors name the problem and the allowed values.
|
||
let missing = a.call("view_reading_colors", &json!({ "doc": doc, "mode": "custom", "text": "#00ff00" }));
|
||
assert!(matches!(&missing, Err(ToolError::InvalidArgs(m)) if m.contains("background")), "{missing:?}");
|
||
let unknown = a.call("view_reading_colors", &json!({ "doc": doc, "mode": "nope" }));
|
||
assert!(matches!(&unknown, Err(ToolError::InvalidArgs(m)) if m.contains("off") && m.contains("custom")), "{unknown:?}");
|
||
let no_mode = a.call("view_reading_colors", &json!({ "doc": doc }));
|
||
assert!(matches!(&no_mode, Err(ToolError::InvalidArgs(m)) if m.contains("mode")), "{no_mode:?}");
|
||
|
||
// Back off: the render matches the original one again.
|
||
assert_eq!(
|
||
ok(&mut a, "view_reading_colors", json!({ "doc": doc, "mode": "off" })),
|
||
json!({ "mode": "off", "text": null, "background": null, "only_black": false, "line_art": false })
|
||
);
|
||
assert_eq!(render(&mut a, 1), page1_off);
|
||
}
|
||
|
||
#[test]
|
||
fn private_crops_with_nested_state_resize_save_reopen_through_tools() {
|
||
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
|
||
let dir = workdir("private-crop-state");
|
||
let mut cos = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||
let page = pdfcraft_model::pages(&cos)[0].clone();
|
||
let mut image = Dict::new();
|
||
for (key, value) in [
|
||
("Type", Object::name("XObject")),
|
||
("Subtype", Object::name("Image")),
|
||
("Width", Object::Int(1)),
|
||
("Height", Object::Int(1)),
|
||
("BitsPerComponent", Object::Int(8)),
|
||
("ColorSpace", Object::name("DeviceRGB")),
|
||
] {
|
||
image.set(key.as_bytes().to_vec(), value);
|
||
}
|
||
let image = cos.add(Object::Stream(Stream::flate(image, &[220, 235, 245])));
|
||
let mut resources = cos.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
|
||
let mut xo = Dict::new();
|
||
xo.set(b"Im0".to_vec(), Object::Ref(image));
|
||
resources.set(b"XObject".to_vec(), Object::Dict(xo));
|
||
let parts: [&[u8]; 3] =
|
||
[b"q 30 170 130 80 re W*", b"n 0.5 g q 800 0 0 900 -300 -400 cm /Im0 Do", b"Q Q BT /F1 12 Tf 30 130 Td (Neighbour caption) Tj ET"];
|
||
let refs = parts.iter().map(|b| Object::Ref(cos.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect();
|
||
cos.update_dict(page.obj, |d| {
|
||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||
d.set(b"Contents".to_vec(), Object::Array(refs));
|
||
})
|
||
.unwrap();
|
||
std::fs::write(dir.join("crop.pdf"), write_incremental(&cos, &Default::default()).unwrap()).unwrap();
|
||
let mut a = auto(&dir);
|
||
let doc = ok(&mut a, "doc_open", json!({"path":"crop.pdf"}))["doc"].as_u64().unwrap();
|
||
let original = ok(&mut a, "page_images", json!({"doc":doc,"page":1}));
|
||
let labels = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1}));
|
||
let pixels = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap();
|
||
for factor in [1.2, 0.7] {
|
||
let rect = [30.0 + (-300.0 - 30.0) * factor, 50.0 + (-200.0 - 50.0) * factor, 30.0 + (500.0 - 30.0) * factor, 50.0 + (700.0 - 50.0) * factor];
|
||
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"move","rect":rect}));
|
||
let images = ok(&mut a, "page_images", json!({"doc":doc,"page":1}));
|
||
let expected = [30.0, 50.0, 30.0 + 130.0 * factor, 50.0 + 80.0 * factor];
|
||
for (actual, expected) in images["images"][0]["visible_rect"].as_array().unwrap().iter().zip(expected) {
|
||
assert!((actual.as_f64().unwrap() - expected).abs() < 0.01);
|
||
}
|
||
assert_eq!(images["images"][0]["pixels"], original["images"][0]["pixels"]);
|
||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})), labels);
|
||
let resized = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap();
|
||
assert_ne!(resized, pixels, "the actual crop changes size");
|
||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"out/resized.pdf"}));
|
||
let re = ok(&mut a, "doc_open", json!({"path":"out/resized.pdf"}))["doc"].as_u64().unwrap();
|
||
assert_eq!(ok(&mut a, "page_images", json!({"doc":re,"page":1})), images);
|
||
assert_eq!(a.call("page_render", &json!({"doc":re,"page":1,"dpi":72})).unwrap(), resized);
|
||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||
assert_eq!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(), pixels);
|
||
assert_eq!(ok(&mut a, "page_images", json!({"doc":doc,"page":1})), original);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn absolute_gotor_consent_does_not_bypass_validation_or_passwords() {
|
||
let (base, root) = sandbox("gotor-absolute-consent");
|
||
std::fs::create_dir_all(root.join("folder.pdf")).unwrap();
|
||
std::fs::write(root.join("notes.txt"), fixture(1)).unwrap();
|
||
std::fs::write(root.join("encrypted.pdf"), protected_fixture("secret")).unwrap();
|
||
link_dir(&root, "outside-link", &base.join("outside"));
|
||
let mut a = auto(&root);
|
||
for (name, target, reason) in [
|
||
("missing", root.join("missing.pdf"), "missing.pdf"),
|
||
("folder", root.join("folder.pdf"), "not a regular file"),
|
||
("text", root.join("notes.txt"), "not a .pdf file"),
|
||
("outside", base.join("outside/secret.pdf"), "outside"),
|
||
("symlink", root.join("outside-link/secret.pdf"), "outside"),
|
||
] {
|
||
let path = format!("{name}-source.pdf");
|
||
std::fs::write(root.join(&path), remote_link_pdf(target.to_str().unwrap(), Some("[0 /Fit]"))).unwrap();
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
|
||
let before = a.session().docs().len();
|
||
// The absence of a target or a root escape must not be probed before consent.
|
||
assert!(
|
||
matches!(err(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("allow_absolute"))
|
||
);
|
||
assert!(
|
||
matches!(err(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1, "allow_absolute": true })), ToolError::Failed(m) if m.contains(reason)),
|
||
"{name}"
|
||
);
|
||
assert_eq!(a.session().docs().len(), before);
|
||
}
|
||
std::fs::write(root.join("password-source.pdf"), remote_link_pdf(root.join("encrypted.pdf").to_str().unwrap(), Some("[0 /Fit]"))).unwrap();
|
||
let doc = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
|
||
let before = a.session().docs().len();
|
||
for password in ["", "wrong"] {
|
||
assert!(matches!(
|
||
err(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1, "allow_absolute": true, "password": password })),
|
||
ToolError::Failed(_)
|
||
));
|
||
assert_eq!(a.session().docs().len(), before);
|
||
}
|
||
let opened = ok(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1, "allow_absolute": true, "password": "secret" }));
|
||
assert_eq!(opened["document"]["encrypted"], true);
|
||
assert_eq!(opened["destination_applied"], true);
|
||
}
|