diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index cb367eb4..969a3093 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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" diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1deb8575..1ca2031a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -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::>(), + "preserve_rb": preserve_rb, + }), pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), }, })).collect::>(), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f3e5528e..bfa1ec1c 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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, + }) + ); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 11f73de7..c7ce0650 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -107,7 +107,7 @@ pub type SplitPart = (usize, usize, Arc>); 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 = 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>, Option); @@ -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 } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7019de53..0ddcedb6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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::>()) + }; + // 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); diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index f93d08de..00f59134 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -1094,6 +1094,13 @@ pub enum ButtonAction { fields: Vec, 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(); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 289a21b6..41ce2198 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -180,6 +180,9 @@ fn text_of(o: &Object) -> Option { } } +/// 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 { let pick = |dict: &Dict| -> Option { @@ -234,6 +237,29 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option { + // /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, }) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 4a531433..8cdc8d37 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -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, preserve_rb: Option| { + 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::*; diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index afb873bf..938525e6 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -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, pub links: Vec, pub layers: Vec, + /// Radio-button layer groups (`/OCProperties /D /RBGroups`): turning one layer of a group on + /// turns the others off. + pub layer_groups: Vec>, pub fonts: Vec, pub attachments: Vec, pub warnings: Vec, @@ -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> { + 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 = 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) { 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"); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eed877e8..503e6561 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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::{ diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2b38e76b..4c01ac70 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 }; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bcdc259a..1a63ad1c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 49f8994b..bc3d8a45 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b106176c..a6213664 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 新しいタブで開く diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9b12288b..b4ad3b29 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 在新选项卡中打开 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3f0717de..46d88a68 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 在新索引標籤中開啟 diff --git a/crates/ui-egui/tests/layer_actions.rs b/crates/ui-egui/tests/layer_actions.rs new file mode 100644 index 00000000..efa9a1e3 --- /dev/null +++ b/crates/ui-egui/tests/layer_actions.rs @@ -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 { + 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"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c1f8edd7..bbe839b6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"