use std::collections::HashSet;
use pdfrum_common::{DiagKind, Diagnostics, Limits, Severity};
use pdfrum_object::{Dict, Object, Resolve, decode_text};
use crate::names;
use crate::nav::dest::Dest;
use crate::nav::filespec::FileSpec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ActionKind {
#[default]
Unknown,
GoTo,
GoToR,
GoToE,
Launch,
Thread,
Uri,
Sound,
Movie,
Hide,
Named,
SubmitForm,
ResetForm,
ImportData,
JavaScript,
SetOcgState,
Rendition,
Trans,
GoTo3DView,
}
const KINDS: [(ActionKind, &[u8]); 18] = [
(ActionKind::GoTo, b"GoTo"),
(ActionKind::GoToR, b"GoToR"),
(ActionKind::GoToE, b"GoToE"),
(ActionKind::Launch, b"Launch"),
(ActionKind::Thread, b"Thread"),
(ActionKind::Uri, b"URI"),
(ActionKind::Sound, b"Sound"),
(ActionKind::Movie, b"Movie"),
(ActionKind::Hide, b"Hide"),
(ActionKind::Named, b"Named"),
(ActionKind::SubmitForm, b"SubmitForm"),
(ActionKind::ResetForm, b"ResetForm"),
(ActionKind::ImportData, b"ImportData"),
(ActionKind::JavaScript, b"JavaScript"),
(ActionKind::SetOcgState, b"SetOCGState"),
(ActionKind::Rendition, b"Rendition"),
(ActionKind::Trans, b"Trans"),
(ActionKind::GoTo3DView, b"GoTo3DView"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AActionType {
CursorEnter,
CursorExit,
ButtonDown,
ButtonUp,
GetFocus,
LoseFocus,
PageOpen,
PageClose,
PageVisible,
PageInvisible,
OpenPage,
ClosePage,
KeyStroke,
Format,
Validate,
Calculate,
CloseDocument,
SaveDocument,
DocumentSaved,
PrintDocument,
DocumentPrinted,
}
const AACTION_KEYS: [(AActionType, &[u8]); 21] = [
(AActionType::CursorEnter, b"E"),
(AActionType::CursorExit, b"X"),
(AActionType::ButtonDown, b"D"),
(AActionType::ButtonUp, b"U"),
(AActionType::GetFocus, b"Fo"),
(AActionType::LoseFocus, b"Bl"),
(AActionType::PageOpen, b"PO"),
(AActionType::PageClose, b"PC"),
(AActionType::PageVisible, b"PV"),
(AActionType::PageInvisible, b"PI"),
(AActionType::OpenPage, b"O"),
(AActionType::ClosePage, b"C"),
(AActionType::KeyStroke, b"K"),
(AActionType::Format, b"F"),
(AActionType::Validate, b"V"),
(AActionType::Calculate, b"C"),
(AActionType::CloseDocument, b"WC"),
(AActionType::SaveDocument, b"WS"),
(AActionType::DocumentSaved, b"DS"),
(AActionType::PrintDocument, b"WP"),
(AActionType::DocumentPrinted, b"DP"),
];
impl AActionType {
#[must_use]
pub fn key(self) -> &'static [u8] {
AACTION_KEYS
.iter()
.find(|(kind, _)| *kind == self)
.map_or(&b""[..], |(_, key)| key)
}
#[must_use]
pub fn is_user_input(self) -> bool {
matches!(
self,
AActionType::ButtonUp | AActionType::ButtonDown | AActionType::KeyStroke
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Action {
pub dict: Dict,
}
impl Action {
#[must_use]
pub fn new(dict: Dict) -> Action {
Action { dict }
}
#[must_use]
pub fn kind(&self) -> ActionKind {
if let Some(kind) = self.dict.name(names::TYPE)
&& kind != names::ANNOT_ACTION
{
return ActionKind::Unknown;
}
let Some(spelling) = self.dict.name(names::S) else {
return ActionKind::Unknown;
};
KINDS
.iter()
.find(|(_, name)| *name == spelling.as_bytes())
.map_or(ActionKind::Unknown, |(kind, _)| *kind)
}
#[must_use]
pub fn dest<R: Resolve>(
&self,
catalog: &Dict,
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) -> Dest {
if !matches!(
self.kind(),
ActionKind::GoTo | ActionKind::GoToR | ActionKind::GoToE
) {
return Dest::default();
}
let target = self.dict.get(names::D, r).map(|d| d.get().clone());
Dest::create(catalog, target.as_ref(), r, limits, diags)
}
#[must_use]
pub fn file_path<R: Resolve>(&self, r: &R) -> String {
let kind = self.kind();
if !matches!(
kind,
ActionKind::GoToR
| ActionKind::GoToE
| ActionKind::Launch
| ActionKind::SubmitForm
| ActionKind::ImportData
) {
return String::new();
}
if let Some(spec) = self.dict.get(names::F, r) {
return FileSpec::new(spec.get().clone()).file_name(r);
}
if kind != ActionKind::Launch {
return String::new();
}
self.dict
.dict(names::WIN, r)
.and_then(|win| win.byte_string(names::F, r))
.map(|bytes| bytes.iter().map(|b| char::from(*b)).collect())
.unwrap_or_default()
}
#[must_use]
pub fn uri<R: Resolve>(&self, catalog: &Dict, r: &R) -> Vec<u8> {
if self.kind() != ActionKind::Uri {
return Vec::new();
}
let uri = self.dict.byte_string(names::URI, r).unwrap_or_default();
let is_absolute = uri.iter().position(|b| *b == b':').is_some_and(|at| at > 0);
if is_absolute {
return uri;
}
let base = catalog
.dict(names::URI, r)
.and_then(|dict| dict.get(names::BASE, r).map(|b| b.get().clone()))
.filter(|value| matches!(value, Object::Str(_) | Object::Stream(_)))
.map(|value| value.to_byte_string())
.unwrap_or_default();
let mut joined = base;
joined.extend_from_slice(&uri);
joined
}
#[must_use]
pub fn hide_status(&self) -> bool {
self.dict.bool(names::H).unwrap_or(true)
}
#[must_use]
pub fn named_action<R: Resolve>(&self, r: &R) -> Vec<u8> {
self.dict.byte_string(names::N, r).unwrap_or_default()
}
#[must_use]
pub fn flags<R: Resolve>(&self, r: &R) -> i64 {
self.dict.int(names::FLAGS, r).unwrap_or(0)
}
#[must_use]
pub fn fields<R: Resolve>(&self, r: &R) -> Vec<Object> {
let is_hide = self.dict.byte_string(names::S, r).as_deref() == Some(b"Hide");
let key = if is_hide { names::T } else { names::FIELDS };
let Some(value) = self.dict.get(key, r).map(|v| v.get().clone()) else {
return Vec::new();
};
match value {
single @ (Object::Dict(_) | Object::Str(_)) => vec![single],
Object::Array(array) => (0..array.len())
.filter_map(|index| array.get(index, r).map(|v| v.get().clone()))
.filter(|value| !value.is_null())
.collect(),
_ => Vec::new(),
}
}
#[must_use]
pub fn javascript<R: Resolve>(&self, r: &R) -> Option<String> {
let value = self.dict.get(names::JS, r).map(|v| v.get().clone())?;
match value {
Object::Str(text) => Some(decode_text(&text.bytes).into_owned()),
Object::Stream(stream) => {
let mut diags = Diagnostics::default();
let decoded =
pdfrum_filters::decode_chain(&stream, 0, r, &Limits::default(), &mut diags);
Some(decode_text(&decoded.data).into_owned())
}
_ => None,
}
}
#[must_use]
pub fn next_count<R: Resolve>(&self, r: &R) -> usize {
if !self.dict.contains_key(names::NEXT) {
return 0;
}
match self.dict.get(names::NEXT, r).map(|n| n.get().clone()) {
Some(Object::Dict(_)) => 1,
Some(Object::Array(array)) => array.len(),
_ => 0,
}
}
#[must_use]
pub fn next<R: Resolve>(&self, index: usize, r: &R) -> Option<Action> {
if !self.dict.contains_key(names::NEXT) {
return None;
}
match self.dict.get(names::NEXT, r).map(|n| n.get().clone())? {
Object::Array(array) => Some(Action::new(array.dict_at(index, r).unwrap_or_default())),
Object::Dict(dict) if index == 0 => Some(Action::new(dict)),
_ => None,
}
}
#[must_use]
pub fn chain<R: Resolve>(
&self,
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) -> Vec<Action> {
let mut out = Vec::new();
let mut seen = HashSet::new();
collect_chain(self, 0, &mut seen, &mut out, r, limits, diags);
out
}
}
fn collect_chain<R: Resolve>(
action: &Action,
depth: u32,
seen: &mut HashSet<u32>,
out: &mut Vec<Action>,
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) {
if depth > limits.max_name_tree_depth {
diags.record(Severity::Suspicious, DiagKind::TreeDepthExceeded, None);
return;
}
for index in 0..action.next_count(r) {
let Some(next) = action.next(index, r) else {
continue;
};
if let Some(reference) = action.dict.reference(names::NEXT)
&& !seen.insert(reference.num)
{
diags.record(Severity::Recovered, DiagKind::NavigationCycle, None);
return;
}
out.push(next.clone());
collect_chain(&next, depth + 1, seen, out, r, limits, diags);
}
}
#[must_use]
pub fn additional_action<R: Resolve>(
aactions: &Dict,
trigger: AActionType,
r: &R,
) -> Option<Action> {
let key = pdfrum_object::Name::new(trigger.key().to_vec());
aactions.dict(&key, r).map(Action::new)
}
#[cfg(test)]
mod tests {
use super::{AActionType, Action, ActionKind};
use pdfrum_object::{Array, ByteSpan, Dict, Name, NoResolve, Object, PdfString, Stream};
#[test]
fn a_flate_encoded_script_stream_is_decoded_before_it_is_read() {
let source = b"this.getField('echo').value = 'hi!';";
let encoded = pdfrum_filters::encode_flate(source);
let dict = Dict::from_pairs([
(
Name::from("Filter"),
Object::Name(Name::from("FlateDecode")),
),
(
Name::from("Length"),
Object::Int(i64::try_from(encoded.len()).unwrap()),
),
]);
let stream = Stream::new(dict, ByteSpan::from(encoded));
let action = action(&[
("S", Object::Name(Name::from("JavaScript"))),
("JS", Object::Stream(Box::new(stream))),
]);
assert_eq!(
action.javascript(&NoResolve).as_deref(),
Some("this.getField('echo').value = 'hi!';")
);
}
fn action(pairs: &[(&str, Object)]) -> Action {
Action::new(Dict::from_pairs(
pairs
.iter()
.map(|(k, v)| (Name::from(*k), v.clone()))
.collect::<Vec<_>>(),
))
}
const SPELLINGS: [&str; 18] = [
"GoTo",
"GoToR",
"GoToE",
"Launch",
"Thread",
"URI",
"Sound",
"Movie",
"Hide",
"Named",
"SubmitForm",
"ResetForm",
"ImportData",
"JavaScript",
"SetOCGState",
"Rendition",
"Trans",
"GoTo3DView",
];
#[test]
fn every_spelling_resolves_with_and_without_an_explicit_type() {
for spelling in SPELLINGS {
let bare = action(&[("S", Object::Name(Name::from(spelling)))]);
assert_ne!(bare.kind(), ActionKind::Unknown, "{spelling}");
let typed = action(&[
("Type", Object::Name(Name::from("Action"))),
("S", Object::Name(Name::from(spelling))),
]);
assert_eq!(typed.kind(), bare.kind(), "{spelling}");
}
}
#[test]
fn a_wrong_type_or_a_string_subtype_makes_every_action_unknown() {
for spelling in SPELLINGS {
let wrong_type = action(&[
("Type", Object::Name(Name::from("Lights"))),
("S", Object::Name(Name::from(spelling))),
]);
assert_eq!(wrong_type.kind(), ActionKind::Unknown, "{spelling}");
let stringly = action(&[("S", Object::Str(PdfString::literal(spelling.as_bytes())))]);
assert_eq!(stringly.kind(), ActionKind::Unknown, "{spelling}");
}
}
#[test]
fn matching_is_case_sensitive() {
for spelling in ["Camera", "Javascript", "Unknown", "goto"] {
let a = action(&[("S", Object::Name(Name::from(spelling)))]);
assert_eq!(a.kind(), ActionKind::Unknown, "{spelling}");
}
}
#[test]
fn the_hide_flag_is_boolean_typed_so_an_integer_reads_as_absent() {
assert!(action(&[]).hide_status());
assert!(!action(&[("H", Object::Bool(false))]).hide_status());
assert!(action(&[("H", Object::Int(0))]).hide_status());
}
#[test]
fn a_uri_travels_through_as_raw_bytes() {
let a = action(&[
("S", Object::Name(Name::from("URI"))),
(
"URI",
Object::Str(PdfString::literal(b"https://example.com/\xA5x\xC7y")),
),
]);
assert_eq!(
a.uri(&Dict::new(), &NoResolve),
b"https://example.com/\xA5x\xC7y".to_vec()
);
}
#[test]
fn a_relative_uri_picks_up_the_catalogs_base() {
let catalog = Dict::from_pairs([(
Name::from("URI"),
Object::Dict(Dict::from_pairs([(
Name::from("Base"),
Object::Str(PdfString::literal(b"https://example.com/")),
)])),
)]);
let relative = action(&[
("S", Object::Name(Name::from("URI"))),
("URI", Object::Str(PdfString::literal(b"page.html"))),
]);
assert_eq!(
relative.uri(&catalog, &NoResolve),
b"https://example.com/page.html".to_vec()
);
let leading_colon = action(&[
("S", Object::Name(Name::from("URI"))),
("URI", Object::Str(PdfString::literal(b":odd"))),
]);
assert_eq!(
leading_colon.uri(&catalog, &NoResolve),
b"https://example.com/:odd".to_vec()
);
let absolute = action(&[
("S", Object::Name(Name::from("URI"))),
("URI", Object::Str(PdfString::literal(b"ftp://host/x"))),
]);
assert_eq!(absolute.uri(&catalog, &NoResolve), b"ftp://host/x".to_vec());
}
#[test]
fn the_field_list_reads_its_subtype_coercively() {
let hide = action(&[
("S", Object::Str(PdfString::literal(b"Hide"))),
("T", Object::Str(PdfString::literal(b"field"))),
]);
assert_eq!(hide.kind(), ActionKind::Unknown);
assert_eq!(hide.fields(&NoResolve).len(), 1);
}
#[test]
fn a_next_key_holding_nothing_usable_counts_zero() {
assert_eq!(action(&[]).next_count(&NoResolve), 0);
assert_eq!(action(&[("Next", Object::Null)]).next_count(&NoResolve), 0);
assert_eq!(
action(&[("Next", Object::Dict(Dict::new()))]).next_count(&NoResolve),
1
);
let two = action(&[(
"Next",
Object::Array(Array::of([Object::Dict(Dict::new()), Object::Int(4)])),
)]);
assert_eq!(two.next_count(&NoResolve), 2);
assert_eq!(
two.next(1, &NoResolve).map(|a| a.dict.is_empty()),
Some(true)
);
}
#[test]
fn the_additional_action_table_keeps_its_key_collision() {
assert_eq!(AActionType::ClosePage.key(), b"C");
assert_eq!(AActionType::Calculate.key(), b"C");
assert!(AActionType::ButtonUp.is_user_input());
assert!(!AActionType::Format.is_user_input());
}
}