mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 17:01:23 +00:00
* AcroForm: integrate persistent timesheet script workflows * AcroForm: address review caching, worker stacks and live metadata * M6: address second review of AcroForm script workflows * M6: handle top-level ASI boundaries and verify cyclic calculation results * M6: bound script workers and isolate pending completions across edits * M6: make form resets visible before subsequent script reads * M6: include calculated dependents in reset comparison baselines * M6: bound reset writeback work before native completion * M6: charge expanded reset selections and field name bytes * M6: bound string conversion bytes and charge reset widgets * M6: preserve unread defaults and bound prompt text conversion * M6: bound reset calls early and preserve calculated field identity * AcroForm: address grouped calculations, field identity and widget ownership * AcroForm: retire field instances and address native review edge cases * AcroForm: route comparison Save As and Print to the source document * M6: dispatch text widget mouse-up scripts * M6: honor fit bounds for form button icons * AcroForm: bound calculation work and preserve reset and save integrity * Harden AcroForm script requests and worker recovery * Keep form callbacks and focus consistent across edits * AcroForm: preserve reset ordering and bound edit callbacks * Fix reset appearance ordering and XFA focus after row removal * Keep XFA focus indexes and stopped reset appearances consistent * Document form script execution and reset helpers * Keep script workers bounded and reject partial callback edits * Keep form events available after a document script reaches VM limits * Keep main's first-value Simple calculation and restore the calculateNow helper test AFSimple_Calculate (JavaScript) and the native Simple calculation count each field's first value again, as #5304 landed them; summing every selection of a multiple-selection list is a separate behaviour change. Restores the document-helper calculateNow test and the AFSimple_Calculate doc comment, and adds native and JavaScript multiple-selection cases. * Patch the callback field snapshot in place instead of rebuilding it Each field callback rebuilt all field states (3,129 on the benchmark form), and the retained realm held the previous snapshot, so stamping instance tokens copied it again. The runner now keeps its last snapshot and patches only the values and flags that changed; ScriptSession::stamp_fields stamps it before dispatch, the worker copies only when a token changes, and the realm drops the job's snapshot when the job ends (field identity is checked against the cached keys instead). * An oversized job script is that script's error, not a refused edit Realm::run refused a script too long or too deep to run as an execution refusal, which made the engine reject the whole edit (setting a 300 KiB calculation script failed). As on main it is now a plain script error that keeps the value; the realm is not touched. * Read a leading #! line as a comment in the chain scan; follow main's refusal and conditional limits in tests The nesting guard from #5079 now checks brackets, prefixes and conditionals; the document worker's preflight still bounds arrow-function and right-associative chains and skips a leading #! line as the guard does. A refused script is an error, not a refused edit, so the preflight test now checks the edit goes through with the error reported. --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
164 lines
9.2 KiB
Rust
164 lines
9.2 KiB
Rust
//! Editing a button's visible label through the CLI/MCP tool table (#2892).
|
|
|
|
use pdfcraft_automation::{Automation, Content};
|
|
use pdfcraft_engine::{DocId, Edit, NewField, Session};
|
|
use serde_json::{Value, json};
|
|
|
|
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
|
|
match a.call(name, &args).unwrap().remove(0) {
|
|
Content::Json(value) => value,
|
|
other => panic!("expected JSON: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
/// Changing an existing caption regenerates the saved appearance and preserves form values.
|
|
fn button_caption_can_be_changed_after_creation() {
|
|
let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let mut session = Session::new();
|
|
let bytes = session.create_blank(300.0, 400.0, 1).unwrap();
|
|
let id = session.open("buttons.pdf", None, bytes, None).unwrap();
|
|
session
|
|
.apply(
|
|
id,
|
|
Edit::AddField {
|
|
page: 0,
|
|
rect: [40.0, 300.0, 200.0, 330.0],
|
|
kind: NewField::Button { caption: String::new() },
|
|
name: Some("clear_button".into()),
|
|
},
|
|
)
|
|
.unwrap();
|
|
std::fs::write(dir.join("buttons.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap();
|
|
let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000);
|
|
let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap();
|
|
let before = a.session().save_bytes(DocId(id)).unwrap();
|
|
let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
|
|
let result = tool(
|
|
&mut a,
|
|
"js_run",
|
|
json!({ "doc": id, "field": "clear_button", "script": "getField('clear_button').value='ignored'; getField('clear_button').buttonSetCaption('Clear Form'); getField('clear_button').buttonGetCaption();" }),
|
|
);
|
|
assert_eq!(result["result"], "Clear Form");
|
|
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
|
|
assert_eq!(fields["fields"][0]["caption"], "Clear Form");
|
|
assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}]));
|
|
assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Run JavaScript"));
|
|
let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
|
|
assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance");
|
|
tool(&mut a, "edit_undo", json!({"doc": id}));
|
|
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
|
|
tool(&mut a, "edit_redo", json!({"doc": id}));
|
|
tool(&mut a, "doc_save", json!({"doc": id, "path": "labelled.pdf"}));
|
|
let saved = std::sync::Arc::new(std::fs::read(dir.join("labelled.pdf")).unwrap());
|
|
let doc = pdfcraft_cos::Document::open(saved).unwrap();
|
|
let root = doc.get(doc.root().unwrap());
|
|
let form = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
|
|
let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap());
|
|
let button = doc.resolve(&fields.as_array().unwrap()[0]);
|
|
let mk = doc.resolve(button.as_dict().unwrap().get(b"MK").unwrap());
|
|
assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form");
|
|
assert_eq!(doc.resolve(button.as_dict().unwrap().get(b"T").unwrap()).as_string().unwrap().to_text(), "clear_button");
|
|
let reopened = tool(&mut a, "doc_open", json!({"path": "labelled.pdf"}))["doc"].as_u64().unwrap();
|
|
let fields = tool(&mut a, "form_fields", json!({"doc": reopened}));
|
|
assert_eq!(fields["fields"][0]["caption"], "Clear Form");
|
|
let (_, _, pixels) = a.render_pixels(reopened, 1, 1.0).unwrap();
|
|
assert_eq!(pixels, labelled_pixels);
|
|
let out = tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "提交"}));
|
|
assert!(out["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).any(|w| w.contains("U+63D0")));
|
|
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
|
|
assert_eq!(fields["fields"][0]["caption"], "提交", "unsupported characters are retained and reported");
|
|
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
|
|
for invalid in [
|
|
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 0}),
|
|
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": -1}),
|
|
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 2}),
|
|
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": i64::MAX}),
|
|
json!({"doc": id, "field": "clear_button", "caption": 42}),
|
|
json!({"doc": id, "field": "clear_button", "widget": 1, "tooltip": "bad"}),
|
|
json!({"doc": id, "fields": ["clear_button"], "caption": "bad"}),
|
|
] {
|
|
let before = a.session().save_bytes(DocId(id)).unwrap();
|
|
assert!(a.call("form_set_props", &invalid).is_err(), "{invalid}");
|
|
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before, "{invalid}");
|
|
}
|
|
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": true}));
|
|
let before = a.session().save_bytes(DocId(id)).unwrap();
|
|
assert!(a.call("form_set_props", &json!({"doc": id, "field": "clear_button", "caption": "bad"})).is_err());
|
|
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
|
|
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": false}));
|
|
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": ""}));
|
|
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
|
|
assert_eq!(fields["fields"][0]["caption"], "");
|
|
let (_, _, pixels) = a.render_pixels(id, 1, 1.0).unwrap();
|
|
assert_eq!(pixels, empty_pixels);
|
|
std::fs::remove_dir_all(dir).unwrap();
|
|
}
|
|
|
|
/// An unindexed setter reaches both widgets even when the first caption already matches.
|
|
#[test]
|
|
fn javascript_caption_updates_all_widgets_with_undo_and_save() {
|
|
use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full};
|
|
|
|
let dir = std::env::temp_dir().join(format!("pdfcraft-js-multi-caption-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let mut session = Session::new();
|
|
let bytes = session.create_blank(300.0, 400.0, 1).unwrap();
|
|
let id = session.open("buttons.pdf", None, bytes, None).unwrap();
|
|
for (name, caption, y) in [("first", "First", 300.0), ("second", "Second", 240.0)] {
|
|
session
|
|
.apply(
|
|
id,
|
|
Edit::AddField {
|
|
page: 0,
|
|
rect: [40.0, y, 200.0, y + 30.0],
|
|
kind: NewField::Button { caption: caption.into() },
|
|
name: Some(name.into()),
|
|
},
|
|
)
|
|
.unwrap();
|
|
}
|
|
let mut doc = Document::open(session.save_bytes(id).unwrap()).unwrap();
|
|
let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap();
|
|
let form = doc.get(af);
|
|
let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap());
|
|
let widgets: Vec<_> = fields.as_array().unwrap().iter().map(|w| w.as_ref().unwrap()).collect();
|
|
let mut parent = Dict::new();
|
|
parent.set(b"FT".to_vec(), Object::name("Btn"));
|
|
parent.set(b"Ff".to_vec(), Object::Int(1 << 16));
|
|
parent.set(b"T".to_vec(), PdfString::text("go"));
|
|
parent.set(b"Kids".to_vec(), Object::Array(widgets.iter().copied().map(Object::Ref).collect()));
|
|
let parent = doc.add(parent);
|
|
for w in widgets {
|
|
doc.update_dict(w, |d| {
|
|
for key in [b"FT".as_slice(), b"Ff", b"T"] {
|
|
d.remove(key);
|
|
}
|
|
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
|
})
|
|
.unwrap();
|
|
}
|
|
doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap();
|
|
std::fs::write(dir.join("buttons.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
|
let mut a = Automation::new().with_root(&dir).unwrap();
|
|
let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap();
|
|
let (_, _, before) = a.render_pixels(id, 1, 1.0).unwrap();
|
|
let result = tool(&mut a, "js_run", json!({"doc": id, "script": "getField('go').buttonSetCaption('First'); getField('go').buttonGetCaption();"}));
|
|
assert_eq!(result["error"], Value::Null);
|
|
assert_eq!(result["result"], "First");
|
|
let expected = json!([{"widget": 1, "caption": "First"}, {"widget": 2, "caption": "First"}]);
|
|
assert_eq!(tool(&mut a, "form_fields", json!({"doc": id}))["fields"][0]["captions"], expected);
|
|
let (_, _, after) = a.render_pixels(id, 1, 1.0).unwrap();
|
|
assert_ne!(before, after, "the second widget must visibly change");
|
|
tool(&mut a, "edit_undo", json!({"doc": id}));
|
|
assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, before);
|
|
tool(&mut a, "edit_redo", json!({"doc": id}));
|
|
assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, after);
|
|
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();
|
|
assert_eq!(tool(&mut a, "form_fields", json!({"doc": reopened}))["fields"][0]["captions"], expected);
|
|
assert_eq!(a.render_pixels(reopened, 1, 1.0).unwrap().2, after);
|
|
std::fs::remove_dir_all(dir).unwrap();
|
|
}
|