mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
feat(js): expose document page boxes (#5501)
* feat(js): expose document page boxes * Normalize page boxes for getPageBox * Correct page box test expectations and formatting * Use local page helper in box test --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
1 parent
1fc9501cf3
commit
3df6be4eff
9 files changed
+131
-10
No files matched your search
@@ -9480,6 +9480,8 @@ fn javascript_through_tools() {
|
||||
])
|
||||
);
|
||||
assert_eq!(r["requests"][0]["page"], 2);
|
||||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "JSON.stringify(this.getPageBox())" }));
|
||||
assert_eq!(r["result"], "[0,300,200,0]");
|
||||
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");
|
||||
|
||||
@@ -274,12 +274,15 @@ impl JsRunner {
|
||||
/// highlights form fields (`app.runtimeHighlight`).
|
||||
pub fn new(cos: &pdfcraft_cos::Document, file_name: &str, highlight: bool) -> JsRunner {
|
||||
let xfa = pdfcraft_xfa::xfa_shape(cos).is_some();
|
||||
let pages = pdfcraft_model::pages(cos);
|
||||
let doc = pdfcraft_js::DocInfo {
|
||||
file_name: file_name.to_string(),
|
||||
num_pages: pdfcraft_model::pages(cos).len(),
|
||||
num_pages: pages.len(),
|
||||
page: 0,
|
||||
info: info(cos),
|
||||
highlight,
|
||||
page_boxes: pdfcraft_organize::page_boxes(cos).unwrap_or_default(),
|
||||
page_rotations: pages.iter().map(|page| page.rotation(cos)).collect(),
|
||||
xfa,
|
||||
};
|
||||
// Once XFA owns the form, the PDF container's scripts belong to its fallback view.
|
||||
|
||||
@@ -6753,6 +6753,25 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_get_page_box_reads_effective_boxes_without_editing_the_document() {
|
||||
let (mut s, id) = session_with(1);
|
||||
let before = s.get(id).expect("opened document").can_undo().map(str::to_owned);
|
||||
let out = s
|
||||
.run_javascript(id, "JSON.stringify([this.getPageBox(), this.getPageBox('Media', 0), this.getPageBox('Trim', 0)])", None)
|
||||
.expect("script runs");
|
||||
assert_eq!(out.error, None, "{out:?}");
|
||||
assert_eq!(out.result.as_deref(), Some("[[0,300,200,0],[0,300,200,0],[0,300,200,0]]"));
|
||||
assert_eq!(s.get(id).expect("document remains open").can_undo(), before.as_deref());
|
||||
|
||||
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("rotate page");
|
||||
let before = s.get(id).expect("rotated document").can_undo().map(str::to_owned);
|
||||
let out = s.run_javascript(id, "JSON.stringify(this.getPageBox('Crop', 0))", None).expect("script runs");
|
||||
assert_eq!(out.error, None, "{out:?}");
|
||||
assert_eq!(out.result.as_deref(), Some("[0,200,300,0]"));
|
||||
assert_eq!(s.get(id).expect("document remains open").can_undo(), before.as_deref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_field_scripts_cannot_print() {
|
||||
use pdfcraft_forms::{FieldEvent, Scripts};
|
||||
|
||||
@@ -21,7 +21,7 @@ fn names_lock() -> MutexGuard<'static, ()> {
|
||||
|
||||
/// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads.
|
||||
fn printd(fmt: &str, typed: &str) -> String {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
assert_eq!(o.error, None);
|
||||
@@ -75,7 +75,7 @@ fn form_dates_follow_the_date_language() {
|
||||
#[test]
|
||||
fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
|
||||
let _names = names_lock();
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 };
|
||||
let mut bad = Vec::new();
|
||||
for language in ["it", "es", "ro", "sq"] {
|
||||
@@ -111,7 +111,7 @@ fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
|
||||
/// letter that writes a name, through the form path and through `util.scand`. Called from the
|
||||
/// one test, which owns the process-wide names.
|
||||
fn every_date_language_reads_back_what_it_writes() {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let mut bad = Vec::new();
|
||||
for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() {
|
||||
use_form_date_language(l.code);
|
||||
@@ -319,7 +319,7 @@ fn an_english_run_that_prints_the_stored_value_is_drawn() {
|
||||
|
||||
/// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`.
|
||||
fn scanned(fmt: &str, text: &str) -> String {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
assert_eq!(o.error, None);
|
||||
|
||||
@@ -177,6 +177,55 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// Return a page box in Acrobat's rotated-user-space order: left, top, right, bottom.
|
||||
fn get_page_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let name = arg(args, 0);
|
||||
let name = if name.is_undefined() {
|
||||
"Crop".to_string()
|
||||
} else {
|
||||
name.as_string().ok_or_else(|| error("getPageBox box name must be a string"))?.to_std_string_escaped()
|
||||
};
|
||||
let which = match name.as_str() {
|
||||
"Media" | "MediaBox" => 0,
|
||||
"Crop" | "CropBox" => 1,
|
||||
"Bleed" | "BleedBox" => 2,
|
||||
"Trim" | "TrimBox" => 3,
|
||||
"Art" | "ArtBox" => 4,
|
||||
_ => return Err(error("getPageBox box name must be Media, Crop, Trim, Bleed, or Art")),
|
||||
};
|
||||
let page_value = arg(args, 1);
|
||||
let page = if page_value.is_undefined() {
|
||||
0
|
||||
} else {
|
||||
if !page_value.is_number() {
|
||||
return Err(error("getPageBox page must be a non-negative in-range integer"));
|
||||
}
|
||||
let page = page_value.to_number(ctx)?;
|
||||
if !page.is_finite() || page < 0.0 || page.fract() != 0.0 || page >= usize::MAX as f64 {
|
||||
return Err(error("getPageBox page must be a non-negative in-range integer"));
|
||||
}
|
||||
page as usize
|
||||
};
|
||||
let (rect, rotation, crop) = {
|
||||
let h = host(ctx)?.borrow();
|
||||
let boxes = h.doc.page_boxes.get(page).ok_or_else(|| error("getPageBox page is outside the document"))?;
|
||||
let rotation = h.doc.page_rotations.get(page).copied().unwrap_or(0);
|
||||
(boxes[which], rotation, boxes[1])
|
||||
};
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let [cx0, cy0, cx1, cy1] = crop;
|
||||
let rotated = match rotation {
|
||||
90 => [y0 - cy0, cx1 - x0, y1 - cy0, cx1 - x1],
|
||||
180 => [cx1 - x1, cy1 - y0, cx1 - x0, cy1 - y1],
|
||||
270 => [cy1 - y1, x1 - cx0, cy1 - y0, x0 - cx0],
|
||||
_ => [x0 - cx0, y1 - cy0, x1 - cx0, y0 - cy0],
|
||||
};
|
||||
if !rotated.iter().all(|value| value.is_finite()) {
|
||||
return Err(error("getPageBox returned non-finite coordinates"));
|
||||
}
|
||||
Ok(JsArray::from_iter(rotated.into_iter().map(JsValue::from), ctx).into())
|
||||
}
|
||||
|
||||
/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to
|
||||
/// type one.
|
||||
fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
@@ -233,7 +282,7 @@ const XFA_VERSION: f64 = 3.5;
|
||||
/// `numPages`, `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`,
|
||||
/// `display` and `color` objects.
|
||||
pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
let globals: [(&str, usize, NativeFunction); 10] = [
|
||||
let globals: [(&str, usize, NativeFunction); 11] = [
|
||||
("getField", 1, NativeFunction::from_fn_ptr(get_field)),
|
||||
("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)),
|
||||
("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)),
|
||||
@@ -241,6 +290,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)),
|
||||
("mailDoc", 1, NativeFunction::from_fn_ptr(noop)),
|
||||
("calculateNow", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("getPageBox", 2, NativeFunction::from_fn_ptr(get_page_box)),
|
||||
("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("dirty", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! - `event`: `value`, `change`, `willCommit`, `rc`, `name`, `type`, `target`, `targetName`.
|
||||
//! - the document (`this`, also the global object): `getField`, `numFields`,
|
||||
//! `getNthFieldName`, `numPages`, `pageNum`, `documentFileName`, `info`, `resetForm`,
|
||||
//! `calculateNow`, `print`, `submitForm`, `mailDoc`, `exportDataObject`.
|
||||
//! `calculateNow`, `getPageBox`, `print`, `submitForm`, `mailDoc`, `exportDataObject`.
|
||||
//! - Field objects: `name`, `type`, `value`, `valueAsString`, `defaultValue`, `readonly`,
|
||||
//! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`,
|
||||
//! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`,
|
||||
|
||||
@@ -114,6 +114,10 @@ pub struct DocInfo {
|
||||
pub page: usize,
|
||||
/// Document information (`this.info.title` etc.): lower-case key, value.
|
||||
pub info: Vec<(String, String)>,
|
||||
/// Effective page boxes in PDF user space, ordered media, crop, bleed, trim, art.
|
||||
pub page_boxes: Vec<[[f64; 4]; 5]>,
|
||||
/// Clockwise page rotations, normalized to 0, 90, 180 or 270 degrees.
|
||||
pub page_rotations: Vec<i64>,
|
||||
/// Whether the viewer highlights form fields (`app.runtimeHighlight`).
|
||||
pub highlight: bool,
|
||||
/// The document has an XFA form, so scripts see `xfa_installed`.
|
||||
|
||||
+44
-1
@@ -19,7 +19,7 @@ fn fields() -> Vec<FieldState> {
|
||||
|
||||
/// The document these tests' scripts run in.
|
||||
fn doc() -> DocInfo {
|
||||
DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], highlight: false, xfa: false }
|
||||
DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], ..Default::default() }
|
||||
}
|
||||
|
||||
/// Run AcroForm JavaScript `script` for `event` on these tests' document and fields.
|
||||
@@ -50,6 +50,49 @@ fn the_outcome_says_whether_the_script_assigned_event_value() {
|
||||
assert_eq!(o.result.as_deref(), Some("true"), "value stays an enumerable property of event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_page_box_returns_supported_boxes_in_rotated_user_space() {
|
||||
let mut document = doc();
|
||||
document.page_boxes = vec![
|
||||
[
|
||||
[10.0, 20.0, 210.0, 320.0],
|
||||
[20.0, 30.0, 200.0, 310.0],
|
||||
[25.0, 35.0, 195.0, 305.0],
|
||||
[30.0, 40.0, 190.0, 300.0],
|
||||
[35.0, 45.0, 185.0, 295.0]
|
||||
];
|
||||
3
|
||||
];
|
||||
document.page_rotations = vec![0, 90, 270];
|
||||
let out = run(
|
||||
"JSON.stringify([this.getPageBox(), this.getPageBox('Media', 1), this.getPageBox('Bleed', 0), this.getPageBox('Trim', 0), this.getPageBox('Art', 2)])",
|
||||
&Event::doc("Console"),
|
||||
&document,
|
||||
&[],
|
||||
&[],
|
||||
Limits::default(),
|
||||
);
|
||||
assert_eq!(out.error, None);
|
||||
assert_eq!(out.result.as_deref(), Some("[[0,280,180,0],[-10,190,290,-10],[5,275,175,5],[10,270,170,10],[15,165,265,15]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_page_box_rejects_unknown_boxes_and_invalid_pages() {
|
||||
for script in [
|
||||
"this.getPageBox('BBox')",
|
||||
"this.getPageBox(1)",
|
||||
"this.getPageBox('Crop', -1)",
|
||||
"this.getPageBox('Crop', 1.5)",
|
||||
"this.getPageBox('Crop', 3)",
|
||||
"this.getPageBox('Crop', null)",
|
||||
"this.getPageBox('Crop', true)",
|
||||
"this.getPageBox('Crop', '0')",
|
||||
] {
|
||||
let out = go(script, &Event::doc("Console"));
|
||||
assert!(out.error.is_some(), "{script}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_with_leading_zeros_stay_text() {
|
||||
let o = go("event.value = typeof getField('zip').value + ':' + typeof getField('qty').value;", &Event::field("Calculate", "total", ""));
|
||||
|
||||
@@ -5363,8 +5363,8 @@ milestone = "M6"
|
||||
status = "partial"
|
||||
commands = ["tools.js_console"]
|
||||
tools = ["js_run"]
|
||||
evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"]
|
||||
notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes, rect), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color, exportDataObject (from a button only: an attached PDF opens in a new tab, other files are offered to save, also as event.target in XFA scripts); xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes. field.rect reads and writes the first widget in PDF user space. no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)."
|
||||
evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes", "crates/engine/src/tests.rs::js_get_page_box_reads_effective_boxes_without_editing_the_document", "crates/js/src/tests.rs::get_page_box_returns_supported_boxes_in_rotated_user_space"]
|
||||
notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes, rect), this.getPageBox(cBox, nPage) for effective Media, Crop (default), Trim, Bleed and Art boxes in rotated user space, app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color, exportDataObject (from a button only: an attached PDF opens in a new tab, other files are offered to save, also as event.target in XFA scripts); xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes. field.rect reads and writes the first widget in PDF user space. no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)."
|
||||
|
||||
[[feature]]
|
||||
id = "form.js-console"
|
||||
|
||||
Reference in new issue
Block a user