Files
pdfcraft/crates/automation/tests/form_bulk.rs
bfa65a9bfc M8: redaction code sets (U.S. FOIA, Privacy Act) as overlay text (#741)
* M8: redaction code sets (U.S. FOIA, Privacy Act) as overlay text

Redaction properties offers "Custom text" or "Redaction code" under "Use overlay
text". Picking the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and
ticking codes makes new marks carry them as overlay text, in the set's order.
redact_mark takes code_set and codes and rejects unknown sets or codes with the
allowed list.

Also merges a duplicated evidence/notes pair in form.field-list that made
parity/acrobat-features.toml fail to parse on main.

* M8: say why the default redaction code set can't fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: pin the clock in form_bulk (saves a second apart differed in /ModDate on Windows CI)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:27:26 -04:00

154 lines
8.1 KiB
Rust

//! End-to-end bulk Field Properties through the same tools used by CLI and MCP.
use pdfcraft_automation::{Automation, Content, ToolError};
use pdfcraft_engine::{DocId, Edit, FieldFont, FieldProps, FieldValue, NewField, Session, field_flags};
use serde_json::{Value, json};
fn call(a: &mut Automation, args: Value) -> Value {
match a.call("form_set_props", &args).unwrap().remove(0) {
Content::Json(v) => v,
other => panic!("expected JSON: {other:?}"),
}
}
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
match a.call(name, &args).unwrap().remove(0) {
Content::Json(v) => v,
other => panic!("expected JSON: {other:?}"),
}
}
fn setup(test: &str) -> (Automation, u64, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("pdfcraft-form-bulk-{test}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::new();
let bytes = s.create_blank(300.0, 400.0, 2).unwrap();
let id = s.open("form.pdf", None, bytes, None).unwrap();
for (name, page, x) in [("alpha", 0, 30.0), ("beta", 1, 40.0)] {
s.apply(id, Edit::AddField { page, rect: [x, 300.0, x + 180.0, 324.0], kind: NewField::Text { multiline: false }, name: Some(name.into()) })
.unwrap();
s.apply(id, Edit::SetFieldValue { name: name.into(), value: FieldValue::Text(name.into()) }).unwrap();
let mut look = s.get(id).unwrap().field_look(name).unwrap();
look.fill = Some(if name == "alpha" { [1.0, 0.9, 0.8] } else { [0.8, 0.9, 1.0] });
look.font = if name == "alpha" { FieldFont::Courier } else { FieldFont::Times };
s.apply(
id,
Edit::SetFieldProps {
name: name.into(),
props: Box::new(FieldProps { look: Some(look), tooltip: Some(name.into()), ..Default::default() }),
},
)
.unwrap();
}
std::fs::write(dir.join("form.pdf"), s.save_bytes(id).unwrap().as_slice()).unwrap();
// Saves stamp /ModDate from the clock: pin it, or two saves a second apart differ.
let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000);
let id = tool(&mut a, "doc_open", json!({"path": "form.pdf"}))["doc"].as_u64().unwrap();
(a, id, dir)
}
fn bytes(a: &Automation, id: u64) -> Vec<u8> {
a.session().save_bytes(DocId(id)).unwrap().as_ref().clone()
}
#[test]
fn bulk_properties_are_one_undo_step_preserve_other_looks_and_survive_save() {
let (mut a, id, dir) = setup("roundtrip");
let before = bytes(&a, id);
let original =
[a.session().get(DocId(id)).unwrap().field_look("alpha").unwrap(), a.session().get(DocId(id)).unwrap().field_look("beta").unwrap()];
let out = call(&mut a, json!({"doc": id, "fields": ["alpha", "beta"], "required": true, "appearance": {"width": 4, "border": "#FF0000"}}));
assert_eq!(out["fields"], json!(["alpha", "beta"]));
let doc = a.session().get(DocId(id)).unwrap();
assert_eq!(doc.can_undo(), Some("Change field properties"));
for (name, look) in [("alpha", original[0]), ("beta", original[1])] {
let changed = doc.field_look(name).unwrap();
assert_eq!(changed.width, 4.0);
assert_eq!(changed.border, Some([1.0, 0.0, 0.0]));
assert_eq!((changed.fill, changed.font, changed.text), (look.fill, look.font, look.text));
let f = doc.form.iter().find(|f| f.name == name).unwrap();
assert!(f.has(field_flags::REQUIRED));
assert_eq!(f.value, [name]);
assert_eq!(f.tooltip.as_deref(), Some(name));
}
tool(&mut a, "edit_undo", json!({"doc": id}));
assert_eq!(bytes(&a, id), before);
tool(&mut a, "edit_redo", json!({"doc": id}));
tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"}));
let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();
let doc = a.session().get(DocId(reopened)).unwrap();
assert_eq!(doc.field_look("beta").unwrap().font, original[1].font);
assert_eq!(doc.field_look("beta").unwrap().width, 4.0);
assert_eq!(doc.form.iter().find(|f| f.name == "beta").unwrap().value, ["beta"]);
let image = a.call("page_render", &json!({"doc": reopened, "page": 2, "dpi": 72})).unwrap();
assert!(image.iter().any(|c| matches!(c, Content::Png { width: 300, height: 400, .. })));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn bulk_property_arguments_are_strict_and_fail_without_any_edit() {
let (mut a, id, dir) = setup("invalid");
let before = bytes(&a, id);
let cases = [
json!({"doc": id, "required": true}),
json!({"doc": id, "field": "alpha", "fields": ["beta"], "required": true}),
json!({"doc": id, "fields": [], "required": true}),
json!({"doc": id, "fields": ["alpha", "alpha"], "required": true}),
json!({"doc": id, "fields": ["alpha", 7], "required": true}),
json!({"doc": id, "fields": ["alpha", "beta"], "name": "same"}),
json!({"doc": id, "fields": ["alpha", "beta"], "rotation": 90}),
json!({"doc": id, "fields": ["alpha", "beta"], "rect": [0, 0, 20, 20]}),
json!({"doc": id, "fields": ["alpha", "beta"]}),
json!({"doc": id, "fields": ["alpha", "beta"], "required": "yes"}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"style": "invalid"}}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"width": -1}}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"width": "thick"}}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"style": 3}}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"font": true}}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"unknown": true}}),
json!({"doc": id, "fields": ["alpha", "beta"], "requred": true}),
json!({"doc": id, "fields": vec!["alpha"; 1001], "required": true}),
];
for args in cases {
assert!(matches!(a.call("form_set_props", &args), Err(ToolError::InvalidArgs(_))), "{args}");
assert_eq!(bytes(&a, id), before, "{args}");
}
assert!(matches!(a.call("form_set_props", &json!({"doc": id, "fields": ["alpha", "missing"], "required": true})), Err(ToolError::Failed(_))));
assert_eq!(bytes(&a, id), before);
assert!(a.session().get(DocId(id)).unwrap().can_undo().is_none());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_locked_last_field_rolls_back_earlier_changes_and_unlocks_with_the_batch() {
let (mut a, id, dir) = setup("locked");
call(&mut a, json!({"doc": id, "field": "beta", "locked": true}));
let before = bytes(&a, id);
assert!(matches!(
a.call("form_set_props", &json!({"doc": id, "fields": ["alpha", "beta"], "tooltip": "Shared help"})),
Err(ToolError::Failed(_))
));
assert_eq!(bytes(&a, id), before);
assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties"));
call(&mut a, json!({"doc": id, "fields": ["alpha", "beta"], "locked": false, "tooltip": "Shared help"}));
assert!(a.session().get(DocId(id)).unwrap().form.iter().all(|f| !f.locked() && f.tooltip.as_deref() == Some("Shared help")));
tool(&mut a, "edit_undo", json!({"doc": id}));
assert_eq!(bytes(&a, id), before);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() {
let (mut a, id, dir) = setup("options");
let before = bytes(&a, id);
assert!(matches!(a.call("form_set_props", &json!({"doc": id, "fields": ["alpha", "beta"], "flags": {"comb": true}})), Err(ToolError::Failed(_))));
assert_eq!(bytes(&a, id), before);
call(&mut a, json!({"doc": id, "fields": ["alpha", "beta"], "max_length": 20, "align": "center", "flags": {"comb": true, "spell_check": false}}));
let doc = a.session().get(DocId(id)).unwrap();
assert!(doc.form.iter().all(|f| f.max_len == Some(20) && f.quadding == 1 && f.has(field_flags::COMB) && f.has(field_flags::DO_NOT_SPELL_CHECK)));
assert_eq!(doc.form.iter().map(|f| f.value[0].as_str()).collect::<Vec<_>>(), ["alpha", "beta"]);
tool(&mut a, "edit_undo", json!({"doc": id}));
assert_eq!(bytes(&a, id), before);
std::fs::remove_dir_all(dir).unwrap();
}