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:
parent
cd8c753527
commit
22752dc915
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<_>>(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue