From e13d0a04ca878f1f07299fbe90704744612f774b Mon Sep 17 00:00:00 2001
From: FredApps <153173854+FredApps@users.noreply.github.com>
Date: Wed, 11 Nov 2026 10:40:17 +0100
Subject: [PATCH] feat(js): this.getOCGs lists the document's layers and
ocg.state turns one (#169)
Ported from #4751 by @pyatsysh. DocInfo carries the layers; a run reports
the layers its script turned in Outcome::layers, for the viewer to show or
hide as the Layers panel does (the document isn't changed).
Co-authored-by: Peter Yatsyshin
---
crates/js/src/globals.rs | 3 +-
crates/js/src/host.rs | 2 ++
crates/js/src/lib.rs | 2 ++
crates/js/src/model.rs | 5 ++++
crates/js/src/ocg.rs | 64 ++++++++++++++++++++++++++++++++++++++++
crates/js/src/run.rs | 3 ++
crates/js/src/tests.rs | 24 +++++++++++++++
7 files changed, 102 insertions(+), 1 deletion(-)
create mode 100644 crates/js/src/ocg.rs
diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs
index 5d690f3b19..941b70d1f3 100644
--- a/crates/js/src/globals.rs
+++ b/crates/js/src/globals.rs
@@ -484,7 +484,7 @@ fn document_info_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<
/// Install the host API once; live document properties continue reading each job's snapshot.
pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
- let globals: [(&str, usize, NativeFunction); 12] = [
+ let globals: [(&str, usize, NativeFunction); 13] = [
("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)),
@@ -497,6 +497,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)),
("dirty", 0, NativeFunction::from_fn_ptr(noop)),
("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)),
+ ("getOCGs", 1, NativeFunction::from_fn_ptr(crate::ocg::get_ocgs)),
];
for (name, len, f) in globals {
ctx.register_global_callable(JsString::from(name), len, f)?;
diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs
index 5966e63545..6134bc4afe 100644
--- a/crates/js/src/host.rs
+++ b/crates/js/src/host.rs
@@ -29,6 +29,8 @@ pub(crate) struct Host {
pub(crate) kept: usize,
/// Alerts, console lines and requests the limits left out.
pub(crate) dropped: usize,
+ /// Layers a script turned on or off (`ocg.state`), by their places in [`DocInfo::layers`].
+ pub(crate) layers_changed: BTreeSet,
}
impl Host {
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index 482e6c8bd0..59be3c7db4 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -73,6 +73,8 @@ mod geometry;
pub use geometry::{rotate_rect, unrotate_rect};
mod icons;
+mod ocg;
+pub use ocg::Layer;
mod timers;
pub use timers::{MAX_TIMERS, MIN_TIMER_MS, Timer};
diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs
index e94868d993..bd2f025786 100644
--- a/crates/js/src/model.rs
+++ b/crates/js/src/model.rs
@@ -181,6 +181,8 @@ pub struct DocInfo {
pub xfa: bool,
pub zoom: f64,
pub runtime_highlight: bool,
+ /// The document's layers (optional content groups), for `getOCGs`, in the viewer's order.
+ pub layers: Vec,
}
/// The icon a script's message shows: `app.alert`'s `nIcon` and `xfa.host.messageBox`'s
@@ -385,6 +387,9 @@ pub struct Outcome {
/// The timers of a document's retained realm as they stand after the run, in the order they
/// were set (`app.setInterval`, `app.setTimeOut`); none from a one-off run.
pub timers: Vec,
+ /// Layers the script turned on or off (`ocg.state`), as they are now: their places in
+ /// [`DocInfo::layers`], and whether each is on.
+ pub layers: Vec<(usize, bool)>,
}
/// Bounds that keep a script from hanging the viewer.
diff --git a/crates/js/src/ocg.rs b/crates/js/src/ocg.rs
new file mode 100644
index 0000000000..2f94afa2da
--- /dev/null
+++ b/crates/js/src/ocg.rs
@@ -0,0 +1,64 @@
+//! `this.getOCGs()`: the document's layers (optional content groups) as OCG objects with
+//! `name`, `state` and `initState` (#169, ported from #4751). Setting `state` turns a layer on
+//! or off as the Layers panel does: how the document is shown, not the document; the caller
+//! reads the layers a run turned in [`crate::Outcome::layers`].
+
+use crate::host::{arg, error, function, host, s};
+use crate::{Attribute, Context, JsArray, JsResult, JsValue, NativeFunction, ObjectInitializer, js_string};
+
+/// A layer as `getOCGs` shows it.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct Layer {
+ pub name: String,
+ /// Shown now (`ocg.state`).
+ pub on: bool,
+ /// Shown as the file opens (`ocg.initState`).
+ pub initial: bool,
+}
+
+/// `this.getOCGs(nPage)`: every layer of the document, whatever the page (which pages a layer
+/// is on isn't known here), or `null` when it has none.
+pub(crate) fn get_ocgs(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult {
+ let n = host(ctx)?.borrow().doc.layers.len();
+ if n == 0 {
+ return Ok(JsValue::null());
+ }
+ let items: Vec = (0..n).map(|i| ocg(ctx, i)).collect();
+ Ok(JsArray::from_iter(items, ctx).into())
+}
+
+fn layer(c: &Context, i: u32) -> JsResult {
+ host(c)?.borrow().doc.layers.get(i as usize).cloned().ok_or_else(|| error("the layer is no longer available"))
+}
+
+fn ocg(ctx: &mut Context, index: usize) -> JsValue {
+ fn name(_: &JsValue, _: &[JsValue], i: &u32, c: &mut Context) -> JsResult {
+ Ok(s(&layer(c, *i)?.name))
+ }
+ fn state(_: &JsValue, _: &[JsValue], i: &u32, c: &mut Context) -> JsResult {
+ Ok(JsValue::from(layer(c, *i)?.on))
+ }
+ fn initial(_: &JsValue, _: &[JsValue], i: &u32, c: &mut Context) -> JsResult {
+ Ok(JsValue::from(layer(c, *i)?.initial))
+ }
+ fn set_state(_: &JsValue, args: &[JsValue], i: &u32, c: &mut Context) -> JsResult {
+ let on = arg(args, 0).to_boolean();
+ let mut h = host(c)?.borrow_mut();
+ let l = h.doc.layers.get_mut(*i as usize).ok_or_else(|| error("the layer is no longer available"))?;
+ l.on = on;
+ h.layers_changed.insert(*i as usize);
+ Ok(JsValue::undefined())
+ }
+ let i = u32::try_from(index).unwrap_or(u32::MAX);
+ let name = function(ctx, NativeFunction::from_copy_closure_with_captures(name, i));
+ let state = function(ctx, NativeFunction::from_copy_closure_with_captures(state, i));
+ let set_state = function(ctx, NativeFunction::from_copy_closure_with_captures(set_state, i));
+ let initial = function(ctx, NativeFunction::from_copy_closure_with_captures(initial, i));
+ let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE;
+ ObjectInitializer::new(ctx)
+ .accessor(js_string!("name"), Some(name), None, rw)
+ .accessor(js_string!("state"), Some(state), Some(set_state), rw)
+ .accessor(js_string!("initState"), Some(initial), None, rw)
+ .build()
+ .into()
+}
diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs
index 3353fe4826..b446b2cd77 100644
--- a/crates/js/src/run.rs
+++ b/crates/js/src/run.rs
@@ -276,6 +276,7 @@ pub(crate) fn run_in_context(
reset_limit_exceeded: false,
kept: 0,
dropped: 0,
+ layers_changed: BTreeSet::new(),
}));
let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() };
let mut doc_errors: Vec<(usize, String)> = setup.skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect();
@@ -335,6 +336,8 @@ pub(crate) fn run_in_context(
out.alerts = std::mem::take(&mut h.alerts);
out.console = std::mem::take(&mut h.console);
out.requests = std::mem::take(&mut h.requests);
+ let layers = std::mem::take(&mut h.layers_changed);
+ out.layers = layers.into_iter().filter_map(|i| h.doc.layers.get(i).map(|l| (i, l.on))).collect();
if h.dropped > 0 {
out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped));
}
diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs
index c79e609967..a501f463ee 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -529,6 +529,7 @@ fn app_alert_titles_are_clipped_and_charged() {
reset_limit_exceeded: false,
kept: 0,
dropped: 0,
+ layers_changed: Default::default(),
};
h.alert(Message { title: Some("Order form".into()), ..Message::plain("Saved") });
assert_eq!(h.kept, "Saved".len() + "Order form".len());
@@ -2702,3 +2703,26 @@ fn af_simple_calculate_bounds_selector_work_before_expansion() {
run("AFSimple_Calculate('SUM',new Array(4096).fill('n'));", &Event::doc("Console"), &Default::default(), &fields, &[], Default::default());
assert!(out.error.as_deref().is_some_and(|e| e.contains("calculation field lookup exceeds")));
}
+
+/// `getOCGs` lists the document's layers; setting `state` turns one, which the run reports, and
+/// a document without layers gives `null` (#169, from #4751).
+#[test]
+fn get_ocgs_lists_layers_and_reports_the_ones_a_script_turns() {
+ let layers =
+ vec![crate::Layer { name: "Frame 1".into(), on: true, initial: true }, crate::Layer { name: "Frame 2".into(), on: false, initial: false }];
+ let layered = DocInfo { layers, ..doc() };
+ let out = run(
+ "var o = this.getOCGs(); var before = o.map(function (l) { return l.name + '=' + l.state + '/' + l.initState; }).join(','); \
+ o[0].state = false; o[1].state = true; before + ' ' + o[1].state;",
+ &Event::doc("Console"),
+ &layered,
+ &[],
+ &[],
+ Default::default(),
+ );
+ assert_eq!(out.error, None);
+ assert_eq!(out.result.as_deref(), Some("Frame 1=true/true,Frame 2=false/false true"));
+ assert_eq!(out.layers, [(0, false), (1, true)]);
+ let none = run("this.getOCGs() === null;", &Event::doc("Console"), &doc(), &[], &[], Default::default());
+ assert_eq!(none.result.as_deref(), Some("true"));
+}