From 3df6be4effef8d4dc6b080a53590f9d5414cf911 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:37:10 -0600 Subject: [PATCH] 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> --- crates/automation/tests/it/automation.rs | 2 + crates/engine/src/js.rs | 5 ++- crates/engine/src/tests.rs | 19 +++++++++ crates/engine/tests/form_date_language.rs | 8 ++-- crates/js/src/globals.rs | 52 ++++++++++++++++++++++- crates/js/src/lib.rs | 2 +- crates/js/src/model.rs | 4 ++ crates/js/src/tests.rs | 45 +++++++++++++++++++- parity/acrobat-features.toml | 4 +- 9 files changed, 131 insertions(+), 10 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 632f692e9f..095cfde63e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -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"); diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 1d46f97786..c3f94b4a19 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -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. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 15e868bd61..3c6ccb26b3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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}; diff --git a/crates/engine/tests/form_date_language.rs b/crates/engine/tests/form_date_language.rs index 6a70406cfe..55a701b6a9 100644 --- a/crates/engine/tests/form_date_language.rs +++ b/crates/engine/tests/form_date_language.rs @@ -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); diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 3550257b9e..5562c7ad4f 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -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 { + 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 { @@ -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)), diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 7948c06120..b04813cdc4 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -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`, diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 86a6945846..4419f6b77b 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -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, /// Whether the viewer highlights form fields (`app.runtimeHighlight`). pub highlight: bool, /// The document has an XFA form, so scripts see `xfa_installed`. diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index c726f78af9..4dd00c69e4 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -19,7 +19,7 @@ fn fields() -> Vec { /// 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", "")); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c337eee59d..6f95898c04 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"