use std::collections::HashSet;
use crate::error::PdfError;
use crate::objects::{Dict, Object, ObjectId};
use crate::reader::document::DocumentReader;
use crate::reader::outline::build_page_index_map;
#[derive(Debug, Clone)]
pub struct PdfAction {
pub trigger: ActionTrigger,
pub kind: ActionKind,
pub chain_depth: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ActionTrigger {
CatalogOpen,
Catalog {
event: String,
},
Page {
page_index: usize,
event: String,
},
Annotation {
page_index: usize,
subtype: String,
event: String,
},
FormField {
field_name: Option<String>,
event: String,
},
NamedJavaScript {
name: String,
},
}
#[derive(Debug, Clone)]
pub enum ActionKind {
GoTo {
page_index: Option<usize>,
raw_dest: Option<String>,
},
GoToR {
file: Option<String>,
new_window: Option<bool>,
raw_dest: Option<String>,
},
GoToE {
file: Option<String>,
raw_dest: Option<String>,
},
Launch {
file: Option<String>,
new_window: Option<bool>,
},
Thread,
Uri {
uri: String,
is_map: bool,
},
Sound,
Movie,
Hide {
hide: bool,
target: Option<String>,
},
Named {
name: String,
},
SubmitForm {
url: Option<String>,
flags: u32,
},
ResetForm {
flags: u32,
},
ImportData {
file: Option<String>,
},
JavaScript {
script: String,
},
SetOcgState {
on_count: usize,
off_count: usize,
toggle_count: usize,
},
Rendition,
Trans,
GoTo3DView,
Other {
kind: String,
},
}
pub fn actions(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfAction>, PdfError> {
let page_index_map = build_page_index_map(reader)?;
let mut pages_by_index: Vec<ObjectId> = Vec::with_capacity(page_index_map.len());
pages_by_index.resize(page_index_map.len(), ObjectId::new(0));
for (n, idx) in &page_index_map {
pages_by_index[*idx] = ObjectId::new(*n);
}
let mut out = Vec::new();
let mut visited: HashSet<ObjectId> = HashSet::new();
let root_id = reader.xref().root()?;
let catalog = reader.resolve(root_id)?;
if let Object::Dict(catalog) = &catalog {
if let Some(open) = catalog
.entries()
.iter()
.find(|(k, _)| k == "OpenAction")
.map(|(_, v)| v.clone())
{
let open = reader.deref(open)?;
if let Object::Dict(a) = open {
expand_action_chain(
reader,
&a,
ActionTrigger::CatalogOpen,
0,
&mut visited,
&mut out,
)?;
}
}
if let Some(aa) = catalog
.entries()
.iter()
.find(|(k, _)| k == "AA")
.map(|(_, v)| v.clone())
{
let aa = reader.deref(aa)?;
if let Object::Dict(aa) = aa {
for (event, val) in aa.entries() {
let val = reader.deref(val.clone())?;
if let Object::Dict(a) = val {
expand_action_chain(
reader,
&a,
ActionTrigger::Catalog {
event: event.clone(),
},
0,
&mut visited,
&mut out,
)?;
}
}
}
}
}
for (page_index, page_id) in pages_by_index.iter().enumerate() {
if page_id.number == 0 {
continue;
}
let page = match reader.resolve(*page_id)? {
Object::Dict(d) => d,
_ => continue,
};
let aa_obj = page
.entries()
.iter()
.find(|(k, _)| k == "AA")
.map(|(_, v)| v.clone());
if let Some(aa) = aa_obj {
let aa = reader.deref(aa)?;
if let Object::Dict(aa) = aa {
for (event, val) in aa.entries() {
let val = reader.deref(val.clone())?;
if let Object::Dict(a) = val {
expand_action_chain(
reader,
&a,
ActionTrigger::Page {
page_index,
event: event.clone(),
},
0,
&mut visited,
&mut out,
)?;
}
}
}
}
}
for (page_index, page_id) in pages_by_index.iter().enumerate() {
if page_id.number == 0 {
continue;
}
let page = match reader.resolve(*page_id)? {
Object::Dict(d) => d,
_ => continue,
};
let annots_obj = page
.entries()
.iter()
.find(|(k, _)| k == "Annots")
.map(|(_, v)| v.clone());
let Some(annots_obj) = annots_obj else {
continue;
};
let annots_obj = reader.deref(annots_obj)?;
let Object::Array(items) = annots_obj else {
continue;
};
for item in items {
let annot = match reader.deref(item)? {
Object::Dict(d) => d,
_ => continue,
};
let subtype = annot
.entries()
.iter()
.find(|(k, _)| k == "Subtype")
.and_then(|(_, v)| match v {
Object::Name(s) => Some(s.clone()),
_ => None,
})
.unwrap_or_default();
collect_a_and_aa(
reader,
&annot,
|event| ActionTrigger::Annotation {
page_index,
subtype: subtype.clone(),
event,
},
&mut visited,
&mut out,
)?;
}
}
if let Object::Dict(catalog) = &catalog {
let acro_obj = catalog
.entries()
.iter()
.find(|(k, _)| k == "AcroForm")
.map(|(_, v)| v.clone());
if let Some(acro_obj) = acro_obj {
let acro = reader.deref(acro_obj)?;
if let Object::Dict(acro) = acro {
let fields_obj = acro
.entries()
.iter()
.find(|(k, _)| k == "Fields")
.map(|(_, v)| v.clone());
if let Some(fields_obj) = fields_obj {
let fields = reader.deref(fields_obj)?;
if let Object::Array(items) = fields {
let mut depth = 0u32;
for field in items {
walk_form_field(reader, field, &mut out, &mut visited, &mut depth)?;
}
}
}
}
}
}
if let Object::Dict(catalog) = &catalog {
let names_obj = catalog
.entries()
.iter()
.find(|(k, _)| k == "Names")
.map(|(_, v)| v.clone());
if let Some(names_obj) = names_obj {
let names = reader.deref(names_obj)?;
if let Object::Dict(names) = names {
let js_obj = names
.entries()
.iter()
.find(|(k, _)| k == "JavaScript")
.map(|(_, v)| v.clone());
if let Some(js_obj) = js_obj {
let js = reader.deref(js_obj)?;
if let Object::Dict(root) = js {
let mut pairs: Vec<(String, Object)> = Vec::new();
walk_name_tree(reader, &root, &mut pairs, 0)?;
for (key, val) in pairs {
let val = reader.deref(val)?;
if let Object::Dict(a) = val {
expand_action_chain(
reader,
&a,
ActionTrigger::NamedJavaScript { name: key },
0,
&mut visited,
&mut out,
)?;
}
}
}
}
}
}
}
Ok(out)
}
fn walk_form_field(
reader: &mut DocumentReader<'_>,
field_obj: Object,
out: &mut Vec<PdfAction>,
visited: &mut HashSet<ObjectId>,
depth: &mut u32,
) -> Result<(), PdfError> {
if *depth > 32 {
return Ok(());
}
*depth += 1;
let dict = match reader.deref(field_obj)? {
Object::Dict(d) => d,
_ => {
*depth -= 1;
return Ok(());
}
};
let field_name = dict
.entries()
.iter()
.find(|(k, _)| k == "T")
.and_then(|(_, v)| decode_text_obj(v));
collect_a_and_aa(
reader,
&dict,
|event| ActionTrigger::FormField {
field_name: field_name.clone(),
event,
},
visited,
out,
)?;
if let Some(kids_obj) = dict
.entries()
.iter()
.find(|(k, _)| k == "Kids")
.map(|(_, v)| v.clone())
{
let kids = reader.deref(kids_obj)?;
if let Object::Array(items) = kids {
for kid in items {
walk_form_field(reader, kid, out, visited, depth)?;
}
}
}
*depth -= 1;
Ok(())
}
fn collect_a_and_aa<F>(
reader: &mut DocumentReader<'_>,
dict: &Dict,
make_trigger: F,
visited: &mut HashSet<ObjectId>,
out: &mut Vec<PdfAction>,
) -> Result<(), PdfError>
where
F: Fn(String) -> ActionTrigger,
{
if let Some(a) = dict
.entries()
.iter()
.find(|(k, _)| k == "A")
.map(|(_, v)| v.clone())
{
let a = reader.deref(a)?;
if let Object::Dict(a) = a {
expand_action_chain(reader, &a, make_trigger("A".into()), 0, visited, out)?;
}
}
if let Some(aa) = dict
.entries()
.iter()
.find(|(k, _)| k == "AA")
.map(|(_, v)| v.clone())
{
let aa = reader.deref(aa)?;
if let Object::Dict(aa) = aa {
for (event, val) in aa.entries() {
let val = reader.deref(val.clone())?;
if let Object::Dict(a) = val {
expand_action_chain(reader, &a, make_trigger(event.clone()), 0, visited, out)?;
}
}
}
}
Ok(())
}
fn expand_action_chain(
reader: &mut DocumentReader<'_>,
action: &Dict,
trigger: ActionTrigger,
chain_depth: u32,
visited: &mut HashSet<ObjectId>,
out: &mut Vec<PdfAction>,
) -> Result<(), PdfError> {
if chain_depth > 32 {
return Ok(());
}
let kind = decode_action_kind(reader, action)?;
out.push(PdfAction {
trigger: trigger.clone(),
kind,
chain_depth,
});
if let Some(next) = action
.entries()
.iter()
.find(|(k, _)| k == "Next")
.map(|(_, v)| v.clone())
{
process_next(reader, next, &trigger, chain_depth + 1, visited, out)?;
}
Ok(())
}
fn process_next(
reader: &mut DocumentReader<'_>,
next_obj: Object,
trigger: &ActionTrigger,
chain_depth: u32,
visited: &mut HashSet<ObjectId>,
out: &mut Vec<PdfAction>,
) -> Result<(), PdfError> {
match next_obj {
Object::Reference(id) => {
if !visited.insert(id) {
return Ok(()); }
let resolved = reader.resolve(id)?;
process_next(reader, resolved, trigger, chain_depth, visited, out)?;
}
Object::Array(items) => {
for it in items {
process_next(reader, it, trigger, chain_depth, visited, out)?;
}
}
Object::Dict(a) => {
expand_action_chain(reader, &a, trigger.clone(), chain_depth, visited, out)?;
}
_ => {}
}
Ok(())
}
fn decode_action_kind(
reader: &mut DocumentReader<'_>,
action: &Dict,
) -> Result<ActionKind, PdfError> {
let s = action
.entries()
.iter()
.find(|(k, _)| k == "S")
.and_then(|(_, v)| match v {
Object::Name(s) => Some(s.as_str()),
_ => None,
})
.unwrap_or("");
match s {
"GoTo" => {
let (page_index, raw_dest) = decode_dest_entry(reader, action, "D")?;
Ok(ActionKind::GoTo {
page_index,
raw_dest,
})
}
"GoToR" => {
let file = decode_filespec_entry(reader, action, "F")?;
let new_window = action
.entries()
.iter()
.find(|(k, _)| k == "NewWindow")
.and_then(|(_, v)| match v {
Object::Bool(b) => Some(*b),
_ => None,
});
let (_, raw_dest) = decode_dest_entry(reader, action, "D")?;
Ok(ActionKind::GoToR {
file,
new_window,
raw_dest,
})
}
"GoToE" => {
let file = decode_filespec_entry(reader, action, "F")?;
let (_, raw_dest) = decode_dest_entry(reader, action, "D")?;
Ok(ActionKind::GoToE { file, raw_dest })
}
"Launch" => {
let file = decode_filespec_entry(reader, action, "F")?;
let new_window = action
.entries()
.iter()
.find(|(k, _)| k == "NewWindow")
.and_then(|(_, v)| match v {
Object::Bool(b) => Some(*b),
_ => None,
});
Ok(ActionKind::Launch { file, new_window })
}
"Thread" => Ok(ActionKind::Thread),
"URI" => {
let uri = action
.entries()
.iter()
.find(|(k, _)| k == "URI")
.and_then(|(_, v)| match v {
Object::LiteralString(b) | Object::HexString(b) => {
Some(String::from_utf8_lossy(b).into_owned())
}
_ => None,
})
.unwrap_or_default();
let is_map = action
.entries()
.iter()
.find(|(k, _)| k == "IsMap")
.and_then(|(_, v)| match v {
Object::Bool(b) => Some(*b),
_ => None,
})
.unwrap_or(false);
Ok(ActionKind::Uri { uri, is_map })
}
"Sound" => Ok(ActionKind::Sound),
"Movie" => Ok(ActionKind::Movie),
"Hide" => {
let hide = action
.entries()
.iter()
.find(|(k, _)| k == "H")
.and_then(|(_, v)| match v {
Object::Bool(b) => Some(*b),
_ => None,
})
.unwrap_or(true);
let target = action
.entries()
.iter()
.find(|(k, _)| k == "T")
.and_then(|(_, v)| decode_t_target(v));
Ok(ActionKind::Hide { hide, target })
}
"Named" => {
let name = action
.entries()
.iter()
.find(|(k, _)| k == "N")
.and_then(|(_, v)| match v {
Object::Name(s) => Some(s.clone()),
_ => None,
})
.unwrap_or_default();
Ok(ActionKind::Named { name })
}
"SubmitForm" => {
let url = decode_filespec_entry(reader, action, "F")?;
let flags = action
.entries()
.iter()
.find(|(k, _)| k == "Flags")
.and_then(|(_, v)| match v {
Object::Integer(n) => Some(*n as u32),
_ => None,
})
.unwrap_or(0);
Ok(ActionKind::SubmitForm { url, flags })
}
"ResetForm" => {
let flags = action
.entries()
.iter()
.find(|(k, _)| k == "Flags")
.and_then(|(_, v)| match v {
Object::Integer(n) => Some(*n as u32),
_ => None,
})
.unwrap_or(0);
Ok(ActionKind::ResetForm { flags })
}
"ImportData" => {
let file = decode_filespec_entry(reader, action, "F")?;
Ok(ActionKind::ImportData { file })
}
"JavaScript" => {
let script = decode_js_entry(reader, action)?;
Ok(ActionKind::JavaScript { script })
}
"SetOCGState" => {
let (on_count, off_count, toggle_count) = decode_ocg_state(reader, action)?;
Ok(ActionKind::SetOcgState {
on_count,
off_count,
toggle_count,
})
}
"Rendition" => Ok(ActionKind::Rendition),
"Trans" => Ok(ActionKind::Trans),
"GoTo3DView" => Ok(ActionKind::GoTo3DView),
other => Ok(ActionKind::Other {
kind: other.to_owned(),
}),
}
}
fn decode_dest_entry(
reader: &mut DocumentReader<'_>,
action: &Dict,
key: &str,
) -> Result<(Option<usize>, Option<String>), PdfError> {
let Some(d) = action
.entries()
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
else {
return Ok((None, None));
};
let d = reader.deref(d)?;
Ok(match d {
Object::Name(s) => (None, Some(s)),
Object::LiteralString(b) | Object::HexString(b) => {
(None, Some(String::from_utf8_lossy(&b).into_owned()))
}
Object::Array(items) => {
let page_index = match items.first() {
Some(Object::Reference(id)) => {
let map = build_page_index_map(reader).ok();
map.and_then(|m| m.get(&id.number).copied())
}
_ => None,
};
let raw = format!("{items:?}");
(page_index, Some(raw))
}
_ => (None, None),
})
}
fn decode_filespec_entry(
reader: &mut DocumentReader<'_>,
action: &Dict,
key: &str,
) -> Result<Option<String>, PdfError> {
let Some(f) = action
.entries()
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
else {
return Ok(None);
};
let f = reader.deref(f)?;
Ok(match f {
Object::LiteralString(b) | Object::HexString(b) => {
Some(String::from_utf8_lossy(&b).into_owned())
}
Object::Name(s) => Some(s),
Object::Dict(d) => {
let pick = d
.entries()
.iter()
.find(|(k, _)| k == "UF")
.or_else(|| d.entries().iter().find(|(k, _)| k == "F"));
pick.and_then(|(_, v)| decode_text_obj(v))
}
_ => None,
})
}
fn decode_js_entry(reader: &mut DocumentReader<'_>, action: &Dict) -> Result<String, PdfError> {
let Some(js) = action
.entries()
.iter()
.find(|(k, _)| k == "JS")
.map(|(_, v)| v.clone())
else {
return Ok(String::new());
};
let js = reader.deref(js)?;
Ok(match js {
Object::LiteralString(b) | Object::HexString(b) => decode_js_bytes(&b),
Object::Stream(s) => {
let bytes = crate::reader::document::decode_stream(&s)?;
decode_js_bytes(&bytes)
}
_ => String::new(),
})
}
fn decode_js_bytes(b: &[u8]) -> String {
if b.len() >= 3 && &b[..3] == b"\xEF\xBB\xBF" {
return String::from_utf8_lossy(&b[3..]).into_owned();
}
if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
let utf16: Vec<u16> = b[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
return String::from_utf16_lossy(&utf16);
}
if b.len() >= 2 && b[0] == 0xFF && b[1] == 0xFE {
let utf16: Vec<u16> = b[2..]
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
return String::from_utf16_lossy(&utf16);
}
String::from_utf8_lossy(b).into_owned()
}
fn decode_t_target(v: &Object) -> Option<String> {
match v {
Object::Name(s) => Some(s.clone()),
Object::LiteralString(b) | Object::HexString(b) => {
Some(String::from_utf8_lossy(b).into_owned())
}
Object::Array(items) => Some(format!("{items:?}")),
_ => None,
}
}
fn decode_ocg_state(
reader: &mut DocumentReader<'_>,
action: &Dict,
) -> Result<(usize, usize, usize), PdfError> {
let Some(state) = action
.entries()
.iter()
.find(|(k, _)| k == "State")
.map(|(_, v)| v.clone())
else {
return Ok((0, 0, 0));
};
let state = reader.deref(state)?;
let Object::Array(items) = state else {
return Ok((0, 0, 0));
};
let mut on = 0usize;
let mut off = 0usize;
let mut tog = 0usize;
enum Mode {
On,
Off,
Toggle,
None,
}
let mut mode = Mode::None;
for it in items {
match it {
Object::Name(s) => {
mode = match s.as_str() {
"ON" => Mode::On,
"OFF" => Mode::Off,
"Toggle" => Mode::Toggle,
_ => Mode::None,
};
}
Object::Reference(_) => match mode {
Mode::On => on += 1,
Mode::Off => off += 1,
Mode::Toggle => tog += 1,
Mode::None => {}
},
_ => {}
}
}
Ok((on, off, tog))
}
fn walk_name_tree(
reader: &mut DocumentReader<'_>,
node: &Dict,
out: &mut Vec<(String, Object)>,
depth: usize,
) -> Result<(), PdfError> {
if depth > 32 || out.len() > 100_000 {
return Ok(());
}
if let Some(Object::Array(items)) = node
.entries()
.iter()
.find(|(k, _)| k == "Names")
.map(|(_, v)| v)
{
let mut iter = items.iter();
while let (Some(key_obj), Some(val_obj)) = (iter.next(), iter.next()) {
let Some(key) = decode_text_obj(key_obj) else {
continue;
};
out.push((key, val_obj.clone()));
}
return Ok(());
}
if let Some(kids_obj) = node
.entries()
.iter()
.find(|(k, _)| k == "Kids")
.map(|(_, v)| v.clone())
{
let kids = reader.deref(kids_obj)?;
if let Object::Array(items) = kids {
for kid in items {
let kid = reader.deref(kid)?;
if let Object::Dict(d) = kid {
walk_name_tree(reader, &d, out, depth + 1)?;
}
}
}
}
Ok(())
}
fn decode_text_obj(obj: &Object) -> Option<String> {
match obj {
Object::LiteralString(b) => Some(String::from_utf8_lossy(b).into_owned()),
Object::HexString(b) => {
if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
let utf16: Vec<u16> = b[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
Some(String::from_utf16_lossy(&utf16))
} else {
Some(String::from_utf8_lossy(b).into_owned())
}
}
Object::Name(s) => Some(s.clone()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_js_bytes_utf8_bom() {
let s = decode_js_bytes(b"\xEF\xBB\xBFapp.alert('hi')");
assert_eq!(s, "app.alert('hi')");
}
#[test]
fn decode_js_bytes_utf16be_bom() {
let mut b: Vec<u8> = vec![0xFE, 0xFF];
for ch in b"alert" {
b.push(0x00);
b.push(*ch);
}
assert_eq!(decode_js_bytes(&b), "alert");
}
#[test]
fn decode_js_bytes_utf16le_bom() {
let mut b: Vec<u8> = vec![0xFF, 0xFE];
for ch in b"alert" {
b.push(*ch);
b.push(0x00);
}
assert_eq!(decode_js_bytes(&b), "alert");
}
#[test]
fn decode_js_bytes_plain_ascii_passes_through() {
assert_eq!(decode_js_bytes(b"app.alert('hi')"), "app.alert('hi')");
}
#[test]
fn decode_t_target_array_falls_back_to_debug() {
let v = Object::Array(vec![Object::Name("BtnA".into())]);
let s = decode_t_target(&v).unwrap();
assert!(s.contains("BtnA"), "expected BtnA in {s:?}");
}
#[test]
fn decode_t_target_name_returns_name() {
let v = Object::Name("BtnA".into());
assert_eq!(decode_t_target(&v).as_deref(), Some("BtnA"));
}
}