Run set-layer-visibility actions from links and push buttons (#292)

* Layers: run set-layer-visibility actions, keeping radio-button groups

The engine can apply a set-layer-visibility action (SetOCGState,
ISO 32000-2 §12.6.4.13) to a document's layers: each ON, OFF or Toggle
in order, naming the layer by its optional content group, so a toggle
sees the state the changes before it left. With /PreserveRB (the
default), a layer turned on turns off the other layers of its
radio-button groups, now read from /OCProperties /D /RBGroups; turning
one off leaves them alone. The renderer is rebuilt once per action, not
once per layer.

Like the Layers panel, this changes the view, not the document.

Tests: engine set_layer_actions_toggle_in_order_and_keep_radio_groups
and set_layer_actions_change_what_renders (pixels before and after),
render radio_button_layer_groups_are_read.

* Links: run set-layer-visibility actions

Clicking a link whose action is SetOCGState showed "SetOCGState actions
run in the JavaScript engine (M6)" and did nothing. Links now read the
action (/State, also when indirect, and /PreserveRB) and clicking runs
it; the Layers panel follows. Hovering shows "Set layer visibility"
(translated in the ja, es, zh-hans and zh-hant catalogs), and doc_info
describes the action in its link list.

Tests: render links_read_set_layer_actions, automation
doc_info_describes_set_layer_links and ui-egui
links_set_layer_visibility.

* Forms: push buttons run set-layer-visibility actions

A push button whose mouse-up action is SetOCGState counted as having no
action, so clicking it did nothing. Buttons now read the action as
links do, and clicking runs it.

Tests: forms push_buttons_read_set_layer_actions and ui-egui
push_buttons_set_layer_visibility.
parity: edit.action-set-ocg-state is partial. The notes say what is
missing: creating the action, bookmarks and other triggers, /Next
chains, JavaScript ocg.state, and the Layers panel keeping radio-button
groups itself.

* French: Set layer visibility

The French catalog is coverage-checked since #291.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pauliusiv123-rgb 2026-10-08 18:27:10 +03:00 committed by GitHub
parent cd8c753527
commit 22752dc915
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 521 additions and 14 deletions

View File

@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0be99cfad8d84f76c8a46e2f8da8b2fd8d89d30cac34310198859c195eb5cea0"
sha256 = "e7b02a3dd6b53496a45ec3c20a03c0572fc2586825a15452ae2395038285ec55"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "37f3dc97e716eb9f9d115e0a6de47a3c9ec82897aace3fa0d0fb7b237224197b"
sha256 = "c5ef451d44d941f455c7202839cc1ed60f81ede9a6fdcc4a1a2c1ebc4a0923ba"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "db79f5eb4ac0072bb42186838d0a9e089652c7bd95f679fd2431c79c74725b74"
sha256 = "399a6a51a6da28a9323f7afaa5b7cb8c6f143f8a9082859fa4f77235784ebcdc"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2937b2b28e1947cb7f78bf3dff9d06c5d91ca29789980070a1b8518cf0cd1d59"
sha256 = "2f2b4b75f1914dc873584c4a2e6f94bd8be95c4146cf1d42eb57e67d646a672d"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "97777f7f3156f7ecb341dedfc612bf805e2dff8b85cc001d2f5b36a32d1d3b7a"
sha256 = "1151e22f87e8ad69e5ab4db0d818b23eb4a6655addc4f3ebb5cc04ec01c03b2c"
[[asset]]
path = "assets/icons/accessibility.svg"

View File

@ -1603,6 +1603,17 @@ fn info(d: &Document) -> Value {
"target": match &l.target {
pdfcraft_render::LinkTarget::Page(p) => json!({ "page": page1(*p) }),
pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }),
pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({
"layers": changes.iter().map(|(op, ocg)| json!({
"layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()),
"state": match op {
pdfcraft_render::LayerOp::On => "on",
pdfcraft_render::LayerOp::Off => "off",
pdfcraft_render::LayerOp::Toggle => "toggle",
},
})).collect::<Vec<_>>(),
"preserve_rb": preserve_rb,
}),
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
},
})).collect::<Vec<_>>(),

View File

@ -2164,3 +2164,41 @@ fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" }));
assert_eq!(r["alerts"], json!(["Hello 4"]));
}
/// doc_info describes a link's set-layer-visibility action by layer name.
#[test]
fn doc_info_describes_set_layer_links() {
let dir = workdir("layer-links");
let objs = [
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << >> >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R] >>",
"<< /Type /OCG /Name (Red) >>",
"<< /Type /OCG /Name (Green) >>",
"<< /Type /Annot /Subtype /Link /Rect [10 10 90 30] /A << /S /SetOCGState /State [/Toggle 5 0 R /OFF 4 0 R 9 0 R] /PreserveRB false >> >>",
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
std::fs::write(dir.join("layers.pdf"), pdf).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "layers.pdf" }))["doc"].as_u64().unwrap();
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
// A group that isn't a layer has no name.
assert_eq!(
info["links"][0]["target"],
json!({
"layers": [{ "layer": "Green", "state": "toggle" }, { "layer": "Red", "state": "off" }, { "layer": null, "state": "off" }],
"preserve_rb": false,
})
);
}

View File

@ -107,7 +107,7 @@ pub type SplitPart = (usize, usize, Arc<Vec<u8>>);
use std::sync::Arc;
use pdfcraft_cos::{SaveOptions, write_full, write_incremental};
use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect};
use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, RenderConfig, RenderPool, inspect};
/// Stable identifier of an open document within a session.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -474,6 +474,38 @@ fn render_threads() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
}
/// Render `doc` with the layer visibility in its `info.layers`.
fn use_layer_choices(doc: &mut Document) {
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
doc.config.layers = Arc::new(overrides);
doc.renderer = RenderPool::new(doc.bytes.clone(), render_threads(), doc.config.clone());
}
/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the
/// state the changes before it left. Returns whether any layer ended up changed.
fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool {
let before: Vec<bool> = layers.iter().map(|l| l.visible).collect();
let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect();
for &(op, ocg) in changes {
let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue };
let on = match op {
LayerOp::On => true,
LayerOp::Off => false,
LayerOp::Toggle => !layer.visible,
};
layer.visible = on;
// Turning a layer off leaves the rest of its groups alone.
if on && preserve_rb {
for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) {
if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) {
l.visible = false;
}
}
}
}
layers.iter().zip(before).any(|(l, was)| l.visible != was)
}
/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all).
pub type CombineSource = (String, Arc<Vec<u8>>, Option<String>);
@ -2721,9 +2753,21 @@ impl Session {
return false;
}
l.visible = visible;
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
doc.config.layers = Arc::new(overrides);
doc.renderer = RenderPool::new(doc.bytes.clone(), render_threads(), doc.config.clone());
use_layer_choices(doc);
true
}
/// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each
/// layer by its optional content group; groups that aren't layers are skipped. With
/// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups.
/// Returns `true` if any layer changed; callers must drop cached rasters and text for the
/// document, as for [`Self::set_layer_visible`].
pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool {
let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false };
if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) {
return false;
}
use_layer_choices(doc);
true
}

View File

@ -158,6 +158,85 @@ fn layer_choices_survive_edits() {
assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
}
#[test]
fn set_layer_actions_toggle_in_order_and_keep_radio_groups() {
use pdfcraft_render::LayerOp::{Off, On, Toggle};
let layer = |n: u32, visible: bool| Layer { id: (n, 0), name: format!("L{n}"), visible };
let run = |changes: &[(LayerOp, u32)], preserve_rb: bool| {
// L1 and L2 are a radio-button group; L3 is on its own.
let mut layers = vec![layer(1, true), layer(2, false), layer(3, true)];
let changes: Vec<_> = changes.iter().map(|&(op, n)| (op, (n, 0))).collect();
let changed = apply_layer_state(&mut layers, &[vec![(1, 0), (2, 0)]], &changes, preserve_rb);
(changed, layers.iter().map(|l| l.visible).collect::<Vec<_>>())
};
// Turning a layer on turns off the rest of its group, unless the action says not to.
assert_eq!(run(&[(On, 2)], true), (true, vec![false, true, true]));
assert_eq!(run(&[(On, 2)], false), (true, vec![true, true, true]));
// A toggle that turns a layer on does the same.
assert_eq!(run(&[(Toggle, 2)], true), (true, vec![false, true, true]));
// Turning a layer off leaves its group alone.
assert_eq!(run(&[(Off, 1)], true), (true, vec![false, false, true]));
// Each toggle sees the state the changes before it left: twice is no change.
assert_eq!(run(&[(Toggle, 3), (Toggle, 3)], true), (false, vec![true, false, true]));
// Later changes win, and groups that aren't layers are skipped.
assert_eq!(run(&[(On, 1), (On, 2), (Off, 99)], true), (true, vec![false, true, true]));
// What counts is the end state: on and back off again is no change.
assert_eq!(run(&[(On, 2), (On, 1), (Off, 99)], true), (false, vec![true, false, true]));
}
#[test]
fn set_layer_actions_change_what_renders() {
// A red left half on layer Red (on) and a green right half on layer Green (off); the two
// are a radio-button group.
let content = "/OC /R BDC 1 0 0 rg 0 0 50 100 re f EMC /OC /G BDC 0 1 0 rg 50 0 50 100 re f EMC";
let objs = [
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] >> >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /R 5 0 R /G 6 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /OCG /Name (Red) >>".into(),
"<< /Type /OCG /Name (Green) >>".into(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap();
// The colours at the middle of the left and right halves, as the document draws now.
let halves = |s: &Session| {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let p = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() });
let at = |x: usize| {
let i = (50 * p.width as usize + x) * 4;
p.rgba[i..i + 3].to_vec()
};
(at(25), at(75))
};
let (red, green, white) = (vec![255, 0, 0], vec![0, 255, 0], vec![255, 255, 255]);
assert_eq!(halves(&s), (red.clone(), white.clone()));
// Showing Green hides Red, its radio-button partner.
assert!(s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true));
let visible: Vec<_> = s.get(id).unwrap().info.layers.iter().map(|l| (l.name.as_str(), l.visible)).collect();
assert_eq!(visible, [("Red", false), ("Green", true)]);
assert_eq!(halves(&s), (white.clone(), green));
// Running it again changes nothing.
assert!(!s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true));
// Turning Green off brings nothing back on.
assert!(s.set_layer_state(id, &[(LayerOp::Off, (6, 0))], true));
assert_eq!(halves(&s), (white.clone(), white));
assert!(!s.set_layer_state(DocId(999), &[(LayerOp::On, (5, 0))], true));
}
#[test]
fn edits_to_unknown_documents_are_rejected() {
let (mut s, _) = session_with(1);

View File

@ -1094,6 +1094,13 @@ pub enum ButtonAction {
fields: Vec<String>,
hide: bool,
},
/// A set-layer-visibility action (`SetOCGState`, §12.6.4.13): each change in order, naming
/// the layer by its optional content group (object number, generation). With `preserve_rb`,
/// a layer turned on turns off the other layers of its radio-button groups.
SetLayers {
changes: Vec<(LayerOp, (u32, u16))>,
preserve_rb: bool,
},
/// `app.alert("…")`.
Alert(String),
/// Submit the form to a URL (not sent: PdfCraft never posts form data on its own).
@ -1104,6 +1111,14 @@ pub enum ButtonAction {
Script(String),
}
/// What a set-layer-visibility action does to a layer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LayerOp {
On,
Off,
Toggle,
}
/// Recognise the common one-line button scripts.
pub fn button_script(js: &str) -> ButtonAction {
let t = js.trim();

View File

@ -180,6 +180,9 @@ fn text_of(o: &Object) -> Option<String> {
}
}
/// At most this many `/State` entries of a set-layer-visibility action are read.
const MAX_LAYER_STATE: usize = 1024;
/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget).
fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option<af::ButtonAction> {
let pick = |dict: &Dict| -> Option<Object> {
@ -234,6 +237,29 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option<af::But
let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false)));
af::ButtonAction::ShowHide { fields, hide }
}
b"SetOCGState" => {
// /State: ON, OFF or Toggle, each followed by the groups it applies to.
let mut changes = Vec::new();
if let Some(state) = a.get(b"State") {
let mut op = None;
for item in doc.resolve(state).as_array().into_iter().flatten().take(MAX_LAYER_STATE) {
match item {
Object::Name(n) => {
op = match n.as_slice() {
b"ON" => Some(af::LayerOp::On),
b"OFF" => Some(af::LayerOp::Off),
b"Toggle" => Some(af::LayerOp::Toggle),
_ => None,
}
}
Object::Ref(r) => changes.extend(op.map(|op| (op, (r.num, r.generation)))),
_ => {}
}
}
}
let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false)));
af::ButtonAction::SetLayers { changes, preserve_rb }
}
b"JavaScript" => af::button_script(&script(doc, &action)?),
_ => return None,
})

View File

@ -746,6 +746,47 @@ fn push_buttons_read_hide_actions() {
assert_eq!(button(&mut doc, Object::Ref(looped), None), hide(&["loop"], true));
}
#[test]
fn push_buttons_read_set_layer_actions() {
use af::LayerOp::{Off, On, Toggle};
let mut doc = fixture();
let go = field(&fields(&doc), "go").obj;
let ocg = |n: u32| Object::Ref(ObjRef::new(n, 0));
let button = |doc: &mut Document, state: Option<Object>, preserve_rb: Option<Object>| {
let mut a = Dict::new();
a.set(b"S".to_vec(), Object::name("SetOCGState"));
if let Some(state) = state {
a.set(b"State".to_vec(), state);
}
if let Some(p) = preserve_rb {
a.set(b"PreserveRB".to_vec(), p);
}
doc.update_dict(go, |d| d.set(b"A".to_vec(), Object::Dict(a))).unwrap();
field(&fields(doc), "go").button.clone()
};
let layers = |changes: &[(af::LayerOp, u32)], preserve_rb: bool| {
Some(af::ButtonAction::SetLayers { changes: changes.iter().map(|&(op, n)| (op, (n, 0))).collect(), preserve_rb })
};
// Each name applies to the groups after it; /PreserveRB false is read.
let state = Object::Array(vec![Object::name("ON"), ocg(30), ocg(31), Object::name("OFF"), ocg(32), Object::name("Toggle"), ocg(30)]);
assert_eq!(button(&mut doc, Some(state), Some(Object::Bool(false))), layers(&[(On, 30), (On, 31), (Off, 32), (Toggle, 30)], false));
// An indirect /State; groups before the first name or after an unknown one, and entries that
// aren't groups, are skipped. /PreserveRB defaults to true, also when it isn't a boolean.
let state = doc.add(Object::Array(vec![
ocg(29),
Object::name("OFF"),
ocg(30),
Object::Int(7),
Object::name("Hide"),
ocg(31),
Object::name("Toggle"),
ocg(32),
]));
assert_eq!(button(&mut doc, Some(Object::Ref(state)), Some(Object::Int(0))), layers(&[(Off, 30), (Toggle, 32)], true));
// No /State: an action that changes nothing (the button still has an action).
assert_eq!(button(&mut doc, None, None), layers(&[], true));
}
#[test]
fn detection_finds_blanks_rules_boxes_and_names_them() {
use crate::detect::*;

View File

@ -21,6 +21,12 @@ use crate::OpenError;
/// xref streams are far below this.
const LOAD_STREAM_LIMIT: usize = 256 << 20;
/// At most this many `/State` entries of a set-layer-visibility action are read.
const MAX_LAYER_STATE: usize = 1024;
/// At most this many layers are read from all of `/RBGroups` together.
const MAX_LAYER_GROUP_ENTRIES: usize = 4096;
fn load_options(password: Option<&str>) -> LoadOptions {
LoadOptions { password: password.map(str::to_owned), max_decompressed_size: Some(LOAD_STREAM_LIMIT), ..LoadOptions::default() }
}
@ -46,6 +52,9 @@ pub struct DocInfo {
pub fields: Vec<Field>,
pub links: Vec<Link>,
pub layers: Vec<Layer>,
/// Radio-button layer groups (`/OCProperties /D /RBGroups`): turning one layer of a group on
/// turns the others off.
pub layer_groups: Vec<Vec<(u32, u16)>>,
pub fonts: Vec<FontInfo>,
pub attachments: Vec<Attachment>,
pub warnings: Vec<String>,
@ -136,9 +145,24 @@ pub struct Link {
pub enum LinkTarget {
Page(usize),
Uri(String),
/// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in
/// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned
/// on turns off the other layers of its radio-button groups.
SetLayers {
changes: Vec<(LayerOp, (u32, u16))>,
preserve_rb: bool,
},
Other(String),
}
/// What a set-layer-visibility action does to a layer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LayerOp {
On,
Off,
Toggle,
}
#[derive(Clone, Debug)]
pub struct OutlineItem {
pub title: String,
@ -367,6 +391,7 @@ impl<'a> Inspector<'a> {
info.xfa = Some(if needs_rendering || info.fields.is_empty() { Xfa::Dynamic } else { Xfa::Static });
}
self.layers(catalog, &mut info.layers);
info.layer_groups = self.layer_groups(catalog);
self.fonts(&mut info.fonts);
if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) {
if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) {
@ -650,6 +675,7 @@ impl<'a> Inspector<'a> {
Some("URI") => {
LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?)
}
Some("SetOCGState") => self.layer_state(a),
Some(other) => LinkTarget::Other(other.to_string()),
None => return None,
}
@ -657,6 +683,31 @@ impl<'a> Inspector<'a> {
Some(Link { page, rect, target })
}
/// A set-OCG-state action: `/State` is `ON`, `OFF` or `Toggle`, each followed by the groups
/// it applies to; `/PreserveRB` is true unless it is `false`.
fn layer_state(&self, a: &Dictionary) -> LinkTarget {
let mut changes = Vec::new();
if let Ok(Object::Array(items)) = a.get(b"State").map(|o| self.resolve(o)) {
let mut op = None;
for item in items.iter().take(MAX_LAYER_STATE) {
match item {
Object::Name(n) => {
op = match n.as_slice() {
b"ON" => Some(LayerOp::On),
b"OFF" => Some(LayerOp::Off),
b"Toggle" => Some(LayerOp::Toggle),
_ => None,
}
}
Object::Reference(id) => changes.extend(op.map(|op| (op, *id))),
_ => {}
}
}
}
let preserve_rb = !matches!(a.get(b"PreserveRB").map(|o| self.resolve(o)), Ok(Object::Boolean(false)));
LinkTarget::SetLayers { changes, preserve_rb }
}
// ── form fields ─────────────────────────────────────────────────────────────────────────
fn field(
@ -766,6 +817,32 @@ impl<'a> Inspector<'a> {
}
}
/// The default configuration's radio-button groups (`/RBGroups`) of two or more layers.
fn layer_groups(&self, catalog: &Dictionary) -> Vec<Vec<ObjectId>> {
let groups = catalog
.get(b"OCProperties")
.ok()
.and_then(|o| self.dict(o))
.and_then(|p| p.get(b"D").ok())
.and_then(|o| self.dict(o))
.and_then(|c| c.get(b"RBGroups").ok())
.and_then(|o| self.resolve(o).as_array().ok());
let mut left = MAX_LAYER_GROUP_ENTRIES;
let mut out = Vec::new();
for g in groups.into_iter().flatten() {
let Ok(members) = self.resolve(g).as_array() else { continue };
let group: Vec<ObjectId> = members.iter().filter_map(|x| x.as_reference().ok()).take(left).collect();
left = left.saturating_sub(group.len());
if group.len() > 1 {
out.push(group);
}
if left == 0 {
break;
}
}
out
}
fn fonts(&self, out: &mut Vec<FontInfo>) {
let mut seen = HashSet::new();
let mut pages: Vec<_> = self.page_index.iter().collect();
@ -989,6 +1066,46 @@ trailer << /Root 1 0 R >>
assert_eq!(info.fonts, vec![FontInfo { name: "Helvetica".into(), kind: "Type1".into(), embedded: false, subset: true, encoding: None }]);
}
const LAYERS: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R] [7 0 R] 8 0 R] >> >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [10 0 R 11 0 R] >> endobj
5 0 obj << /Type /OCG /Name (Red) >> endobj
6 0 obj << /Type /OCG /Name (Green) >> endobj
7 0 obj << /Type /OCG /Name (Blue) >> endobj
8 0 obj [6 0 R 7 0 R] endobj
9 0 obj [/OFF 5 0 R] endobj
10 0 obj << /Type /Annot /Subtype /Link /Rect [10 10 50 30] /A << /S /SetOCGState /State [7 0 R /ON 6 0 R /Bogus 5 0 R /Toggle 5 0 R 1 7 0 R] /PreserveRB false >> >> endobj
11 0 obj << /Type /Annot /Subtype /Link /Rect [60 10 100 30] /A << /S /SetOCGState /State 9 0 R >> >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn radio_button_layer_groups_are_read() {
let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens");
let layers: Vec<_> = info.layers.iter().map(|l| (l.id, l.name.as_str(), l.visible)).collect();
assert_eq!(layers, [((5, 0), "Red", true), ((6, 0), "Green", false), ((7, 0), "Blue", true)]);
// A group of one constrains nothing; an indirect group is read.
assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]);
}
#[test]
fn links_read_set_layer_actions() {
let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens");
let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect();
use LayerOp::{Off, On, Toggle};
assert_eq!(
targets,
[
// Groups before the first name or after an unknown one are skipped, and so is
// anything that isn't a group.
LinkTarget::SetLayers { changes: vec![(On, (6, 0)), (Toggle, (5, 0)), (Toggle, (7, 0))], preserve_rb: false },
// An indirect /State; /PreserveRB defaults to true.
LinkTarget::SetLayers { changes: vec![(Off, (5, 0))], preserve_rb: true },
]
);
}
#[test]
fn dates_are_prettified() {
assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45");

View File

@ -13,7 +13,7 @@ mod raster;
pub mod text;
pub use inspect::{
Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, Link, LinkTarget, OutlineItem, PageInfo, Xfa,
Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, Xfa,
attachment_data, inspect, pretty_date,
};
pub use raster::{

View File

@ -10,7 +10,7 @@ use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2};
use pdfcraft_engine::{DocId, Edit};
use pdfcraft_render::{DocInfo, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
use pdfcraft_render::{DocInfo, LayerOp, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
@ -1640,6 +1640,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))])
}
LinkTarget::Uri(u) => u.clone(),
LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(),
LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]),
};
hover_text = Some((p, label));
@ -1858,6 +1859,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
match clicked_link {
Some(LinkTarget::Page(p)) => view.go_to_page(p),
Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link),
Some(LinkTarget::SetLayers { changes, preserve_rb }) => set_layers(app, index, &changes, preserve_rb),
Some(LinkTarget::Other(s)) => app.notify_fmt("{s} actions run in the JavaScript engine (M6)", &[("s", &s)]),
None => {}
}
@ -2005,6 +2007,21 @@ fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action: pdfcraft_
Some(pdfcraft_engine::Edit::Batch { label: label.into(), edits: vec![pdfcraft_engine::Edit::ApplyScriptChanges { changes }] });
}
}
B::SetLayers { changes, preserve_rb } => {
use pdfcraft_engine::form_scripts::LayerOp as Op;
let changes: Vec<(LayerOp, (u32, u16))> = changes
.into_iter()
.map(|(op, ocg)| {
let op = match op {
Op::On => LayerOp::On,
Op::Off => LayerOp::Off,
Op::Toggle => LayerOp::Toggle,
};
(op, ocg)
})
.collect();
set_layers(app, index, &changes, preserve_rb);
}
B::Alert(m) => app.notify(m),
B::Submit(url) => app.notify_fmt(
"{name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries.",
@ -2018,6 +2035,15 @@ fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action: pdfcraft_
}
}
/// Run a set-layer-visibility action from a button or link. It changes what is shown, as the
/// Layers panel does (which follows), not the document.
fn set_layers(app: &mut PdfCraftApp, index: usize, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) {
let id = app.views[index].id;
if app.session.set_layer_state(id, changes, preserve_rb) {
app.views[index].invalidate_content();
}
}
/// Acrobat-style floating find bar at the top-right of the document area.
fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: &Tokens) {
let Some(find) = view.find.as_mut() else { return };

View File

@ -1239,6 +1239,7 @@
This document has no attachments. Este documento no tiene archivos adjuntos.
Hide layer Ocultar capa
Show layer Mostrar capa
Set layer visibility Definir visibilidad de capas
on page {label} en la página {label}
Save attachment… Guardar archivo adjunto…
Open in a new tab Abrir en una pestaña nueva

Can't render this file because it contains an unexpected character in line 6 and column 60.

View File

@ -1927,3 +1927,4 @@
% of all commits % de tous les commits
Lines +/− Lignes +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} fichiers binaires, {first} – {last}
Set layer visibility Définir la visibilité des calques

Can't render this file because it contains an unexpected character in line 6 and column 39.

View File

@ -1245,6 +1245,7 @@
This document has no attachments. この文書に添付ファイルはありません。
Hide layer レイヤーを非表示
Show layer レイヤーを表示
Set layer visibility レイヤーの表示を設定
on page {label} ページ {label}
Save attachment… 添付ファイルを保存…
Open in a new tab 新しいタブで開く

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1145,6 +1145,7 @@
This document has no attachments. 本文档没有附件。
Hide layer 隐藏图层
Show layer 显示图层
Set layer visibility 设置图层可见性
on page {label} 第 {label} 页
Save attachment… 保存附件…
Open in a new tab 在新选项卡中打开

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -1144,6 +1144,7 @@
This document has no attachments. 本文件沒有附件。
Hide layer 隱藏圖層
Show layer 顯示圖層
Set layer visibility 設定圖層可見性
on page {label} 第 {label} 頁
Save attachment… 儲存附件…
Open in a new tab 在新索引標籤中開啟

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -0,0 +1,105 @@
//! Set-layer-visibility actions (`SetOCGState`) on links and push buttons show and hide layers,
//! as the Layers panel does.
use egui::Pos2;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::PdfCraftApp;
/// One page with layers Red (on) and Green (off), a radio-button group, each drawing a band;
/// a "show-red" push button turns Red on and a link toggles Green.
fn fixture() -> Vec<u8> {
let content = "/OC /R BDC 1 0 0 rg 20 300 260 40 re f EMC /OC /G BDC 0 1 0 rg 20 240 260 40 re f EMC";
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] >> >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Contents 4 0 R /Resources << /Properties << /R 5 0 R /G 6 0 R >> >> /Annots [7 0 R 8 0 R] >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /OCG /Name (Red) >>".into(),
"<< /Type /OCG /Name (Green) >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (show-red) /Rect [20 20 120 50] /P 3 0 R /A << /S /SetOCGState /State [/ON 5 0 R] >> >>".into(),
"<< /Type /Annot /Subtype /Link /Rect [180 20 280 50] /Border [0 0 0] /A << /S /SetOCGState /State [/Toggle 6 0 R] >> >>".into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("layers.pdf", None, fixture()).unwrap();
app.set_option("zoom", "150").unwrap();
setup(&mut app);
app
});
h.run_steps(6);
h
}
/// The screen position of a point given in PDF user space (y up) on the 300 × 400 page.
fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
let r = h.state().views[0].page_screen_rect(0).unwrap();
let k = r.width() / 300.0;
r.min + egui::vec2(x * k, (400.0 - y) * k)
}
fn click(h: &mut Harness<'static, PdfCraftApp>, p: Pos2) {
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
}
/// Whether Red and Green are shown.
fn shown(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, bool)> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.layers.iter().map(|l| (l.name.clone(), l.visible)).collect()
}
fn layers(red: bool, green: bool) -> Vec<(String, bool)> {
vec![("Red".into(), red), ("Green".into(), green)]
}
#[test]
fn links_set_layer_visibility() {
let mut h = harness(|_| {});
assert_eq!(shown(&h), layers(true, false));
let link = at(&h, 230.0, 35.0);
h.hover_at(link);
h.run_steps(2);
h.get_by_label("Set layer visibility");
// Turning Green on turns Red, its radio-button partner, off.
click(&mut h, link);
assert_eq!(shown(&h), layers(false, true));
// Toggling Green off turns nothing back on.
click(&mut h, link);
assert_eq!(shown(&h), layers(false, false));
let s = h.state();
assert!(!s.session.get(s.views[0].id).unwrap().dirty, "showing and hiding layers doesn't change the document");
}
#[test]
fn push_buttons_set_layer_visibility() {
// Both on, as the Layers panel can leave them.
let mut h = harness(|app| app.set_option("layer", "Green=on").unwrap());
assert_eq!(shown(&h), layers(true, true));
// Turning Red on (it already is) turns Green, its radio-button partner, off.
let button = at(&h, 70.0, 35.0);
click(&mut h, button);
assert_eq!(shown(&h), layers(true, false));
let s = h.state();
assert!(!s.session.get(s.views[0].id).unwrap().dirty, "showing and hiding layers doesn't change the document");
}

View File

@ -2235,11 +2235,11 @@ title = "Set-layer-visibility action"
area = "C"
tier = "P2"
milestone = "M12"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
evidence = ["crates/ui-egui/tests/layer_actions.rs::links_set_layer_visibility", "crates/ui-egui/tests/layer_actions.rs::push_buttons_set_layer_visibility", "crates/engine/src/tests.rs::set_layer_actions_change_what_renders", "crates/engine/src/tests.rs::set_layer_actions_toggle_in_order_and_keep_radio_groups", "crates/render/src/inspect.rs::links_read_set_layer_actions", "crates/forms/src/tests.rs::push_buttons_read_set_layer_actions"]
notes = "Links and push buttons run it: ON, OFF and Toggle in order, by optional content group, and with /PreserveRB (the default) a layer turned on turns off the rest of its /RBGroups group. Like the Layers panel it changes the view, not the document (no undo step, not saved). Not yet: creating the action (Link Properties, a field's Actions), bookmarks and other triggers, /Next chains, JavaScript ocg.state, and the Layers panel keeping radio-button groups itself."
[[feature]]
id = "edit.action-show-hide-field"