rpdfium-doc 7676.6.2

Document-level features for rpdfium
Documentation
// Derived from PDFium's cpdf_aaction.h/cpp
// Original: Copyright 2014 The PDFium Authors
// Licensed under BSD-3-Clause / Apache-2.0
// See pdfium-upstream/LICENSE for the original license.

//! Additional actions dictionary (`/AA`) — event-triggered action mapping.
//!
//! The `/AA` dictionary attaches actions to events on documents, pages, and
//! form fields (ISO 32000-2 section 12.6.3). Event names include `E` (enter),
//! `X` (exit), `D` (mouse down), `U` (mouse up), `Fo` (focus), `Bl` (blur),
//! `PO` (page open), `PC` (page close), `K` (keystroke), `F` (format),
//! `V` (validate), `C` (calculate), `WS`/`DS`/`WP`/`DP` (will/did save/print).
//!
//! Corresponds to upstream `CPDF_AAction`.

use std::collections::HashMap;

use rpdfium_core::PdfSource;
use rpdfium_parser::{Object, ObjectStore};

use crate::action::{Action, parse_action};
use crate::error::{DocError, DocResult};

/// Typed event type for additional actions (ISO 32000-2 Table 197, 198, 199).
///
/// Each variant corresponds to a PDF dictionary key in the `/AA` dict.
/// Corresponds to `CPDF_AAction::AActionType` in PDFium.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AActionType {
    /// Cursor enters the annotation area (`/E`).
    CursorEnter,
    /// Cursor exits the annotation area (`/X`).
    CursorExit,
    /// Mouse button pressed inside annotation (`/D`).
    ButtonDown,
    /// Mouse button released inside annotation (`/U`).
    ButtonUp,
    /// Annotation receives input focus (`/Fo`).
    GetFocus,
    /// Annotation loses input focus (`/Bl`).
    LoseFocus,
    /// Page containing annotation is opened (`/PO`).
    PageOpen,
    /// Page containing annotation is closed (`/PC`).
    PageClose,
    /// Page becomes visible (`/PV`).
    PageVisible,
    /// Page becomes invisible (`/PI`).
    PageInvisible,
    /// Page-level: page opens (`/O`).
    OpenPage,
    /// Page-level: page closes (`/C`).
    ClosePage,
    /// Keystroke in form field (`/K`).
    KeyStroke,
    /// Format field value (`/F`).
    Format,
    /// Validate field value (`/V`).
    Validate,
    /// Recalculate field value (`/C`).
    Calculate,
    /// Will-close document (`/WC`).
    CloseDocument,
    /// Will-save document (`/WS`).
    SaveDocument,
    /// Did-save document (`/DS`).
    DocumentSaved,
    /// Will-print document (`/WP`).
    PrintDocument,
    /// Did-print document (`/DP`).
    DocumentPrinted,
    /// Document opened (artificial — no PDF key; handled at document level).
    DocumentOpen,
}

impl AActionType {
    /// Returns the PDF dictionary key string for this event type, if any.
    ///
    /// `DocumentOpen` is an artificial type with no PDF key; returns `None`.
    pub fn pdf_key(self) -> Option<&'static str> {
        match self {
            Self::CursorEnter => Some("E"),
            Self::CursorExit => Some("X"),
            Self::ButtonDown => Some("D"),
            Self::ButtonUp => Some("U"),
            Self::GetFocus => Some("Fo"),
            Self::LoseFocus => Some("Bl"),
            Self::PageOpen => Some("PO"),
            Self::PageClose => Some("PC"),
            Self::PageVisible => Some("PV"),
            Self::PageInvisible => Some("PI"),
            Self::OpenPage => Some("O"),
            Self::ClosePage => Some("C"),
            Self::KeyStroke => Some("K"),
            Self::Format => Some("F"),
            Self::Validate => Some("V"),
            Self::Calculate => Some("C"),
            Self::CloseDocument => Some("WC"),
            Self::SaveDocument => Some("WS"),
            Self::DocumentSaved => Some("DS"),
            Self::PrintDocument => Some("WP"),
            Self::DocumentPrinted => Some("DP"),
            Self::DocumentOpen => None,
        }
    }

    /// Returns `true` if this event type corresponds to user input.
    ///
    /// Corresponds to `CPDF_AAction::IsUserInput()` in PDFium.
    pub fn is_user_input(self) -> bool {
        matches!(self, Self::ButtonUp | Self::ButtonDown | Self::KeyStroke)
    }
}

/// Additional actions associated with a document, page, or form field.
///
/// The `/AA` dictionary maps event names to action dictionaries.
/// Event names include: E, X, D, U, Fo, Bl, PO, PC, O, C, WS, DS, WP, DP, K, F, V.
#[derive(Debug, Clone)]
pub struct AdditionalActions {
    /// Map from event name to action.
    pub entries: HashMap<String, Action>,
}

impl AdditionalActions {
    /// Returns `true` if an action exists for the given event type.
    ///
    /// Corresponds to `CPDF_AAction::ActionExist()` in PDFium.
    pub fn action_exists(&self, event_type: AActionType) -> bool {
        event_type
            .pdf_key()
            .is_some_and(|key| self.entries.contains_key(key))
    }

    /// Returns the action for the given event type, if present.
    ///
    /// Corresponds to `CPDF_AAction::GetAction()` in PDFium.
    pub fn action(&self, event_type: AActionType) -> Option<&Action> {
        let key = event_type.pdf_key()?;
        self.entries.get(key)
    }

    /// Deprecated — use [`action()`](Self::action) — no public `FPDFAAction_GetAction` API.
    #[deprecated(
        note = "use `action()` — there is no public `FPDFAAction_GetAction` API (internal `CPDF_AAction::GetAction`)"
    )]
    #[inline]
    pub fn get_action(&self, event_type: AActionType) -> Option<&Action> {
        self.action(event_type)
    }
}

/// Parse additional actions from an `/AA` dictionary.
pub fn parse_additional_actions<S: PdfSource>(
    obj: &Object,
    store: &ObjectStore<S>,
) -> DocResult<AdditionalActions> {
    let resolved = store
        .deep_resolve(obj)
        .map_err(|e| DocError::Parser(e.to_string()))?;
    let dict = resolved.as_dict().ok_or(DocError::UnexpectedType)?;

    let mut entries = HashMap::new();
    for (key, value) in dict {
        let event_name = key.as_str().into_owned();
        if let Ok(action) = parse_action(value, store) {
            entries.insert(event_name, action);
        }
    }

    Ok(AdditionalActions { entries })
}

#[cfg(test)]
mod tests {
    use super::*;
    use rpdfium_core::Name;
    use rpdfium_parser::ObjectStore;
    use std::collections::HashMap;

    fn build_store() -> ObjectStore<Vec<u8>> {
        let pdf = build_minimal_pdf();
        ObjectStore::open(pdf, rpdfium_core::ParsingMode::Lenient).unwrap()
    }

    fn build_minimal_pdf() -> Vec<u8> {
        let mut pdf = Vec::new();
        pdf.extend_from_slice(b"%PDF-1.4\n");
        let obj1_offset = pdf.len();
        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
        let obj2_offset = pdf.len();
        pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
        let xref_offset = pdf.len();
        pdf.extend_from_slice(b"xref\n0 3\n");
        pdf.extend_from_slice(b"0000000000 65535 f \r\n");
        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj1_offset).as_bytes());
        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj2_offset).as_bytes());
        pdf.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
        pdf.extend_from_slice(format!("startxref\n{}\n%%EOF", xref_offset).as_bytes());
        pdf
    }

    fn str_obj(s: &str) -> Object {
        Object::String(rpdfium_core::PdfString::from_bytes(s.as_bytes().to_vec()))
    }

    #[test]
    fn test_aaction_type_pdf_key() {
        assert_eq!(AActionType::CursorEnter.pdf_key(), Some("E"));
        assert_eq!(AActionType::CursorExit.pdf_key(), Some("X"));
        assert_eq!(AActionType::ButtonDown.pdf_key(), Some("D"));
        assert_eq!(AActionType::ButtonUp.pdf_key(), Some("U"));
        assert_eq!(AActionType::GetFocus.pdf_key(), Some("Fo"));
        assert_eq!(AActionType::LoseFocus.pdf_key(), Some("Bl"));
        assert_eq!(AActionType::PageOpen.pdf_key(), Some("PO"));
        assert_eq!(AActionType::PageClose.pdf_key(), Some("PC"));
        assert_eq!(AActionType::KeyStroke.pdf_key(), Some("K"));
        assert_eq!(AActionType::Format.pdf_key(), Some("F"));
        assert_eq!(AActionType::Validate.pdf_key(), Some("V"));
        assert_eq!(AActionType::CloseDocument.pdf_key(), Some("WC"));
        assert_eq!(AActionType::SaveDocument.pdf_key(), Some("WS"));
        assert_eq!(AActionType::DocumentSaved.pdf_key(), Some("DS"));
        assert_eq!(AActionType::PrintDocument.pdf_key(), Some("WP"));
        assert_eq!(AActionType::DocumentPrinted.pdf_key(), Some("DP"));
        // DocumentOpen is artificial — no PDF key
        assert_eq!(AActionType::DocumentOpen.pdf_key(), None);
    }

    #[test]
    fn test_aaction_type_is_user_input() {
        assert!(AActionType::ButtonUp.is_user_input());
        assert!(AActionType::ButtonDown.is_user_input());
        assert!(AActionType::KeyStroke.is_user_input());
        assert!(!AActionType::CursorEnter.is_user_input());
        assert!(!AActionType::GetFocus.is_user_input());
        assert!(!AActionType::SaveDocument.is_user_input());
    }

    #[test]
    fn test_additional_actions_get_action() {
        let store = build_store();
        let mut e_action = HashMap::new();
        e_action.insert(Name::s(), Object::Name(Name::from("Named")));
        e_action.insert(Name::n(), Object::Name(Name::from("NextPage")));
        let mut aa_dict = HashMap::new();
        aa_dict.insert(Name::from("E"), Object::Dictionary(e_action));
        let obj = Object::Dictionary(aa_dict);
        let aa = parse_additional_actions(&obj, &store).unwrap();

        assert!(aa.action_exists(AActionType::CursorEnter));
        assert!(!aa.action_exists(AActionType::ButtonDown));
        assert!(aa.action(AActionType::CursorEnter).is_some());
        assert!(aa.action(AActionType::ButtonDown).is_none());
        // DocumentOpen has no PDF key → never exists
        assert!(!aa.action_exists(AActionType::DocumentOpen));
        assert!(aa.action(AActionType::DocumentOpen).is_none());
    }

    #[test]
    fn test_parse_additional_actions_multiple_events() {
        let store = build_store();

        let mut e_action = HashMap::new();
        e_action.insert(Name::s(), Object::Name(Name::from("Named")));
        e_action.insert(Name::n(), Object::Name(Name::from("NextPage")));

        let mut fo_action = HashMap::new();
        fo_action.insert(Name::s(), Object::Name(Name::from("URI")));
        fo_action.insert(Name::uri(), str_obj("https://example.com"));

        let mut aa_dict = HashMap::new();
        aa_dict.insert(Name::from("E"), Object::Dictionary(e_action));
        aa_dict.insert(Name::from("Fo"), Object::Dictionary(fo_action));

        let obj = Object::Dictionary(aa_dict);
        let aa = parse_additional_actions(&obj, &store).unwrap();
        assert_eq!(aa.entries.len(), 2);
        assert!(aa.entries.contains_key("E"));
        assert!(aa.entries.contains_key("Fo"));
        match &aa.entries["E"] {
            Action::Named(n) => assert_eq!(n, "NextPage"),
            _ => panic!("expected Named action"),
        }
    }

    #[test]
    fn test_parse_additional_actions_empty() {
        let store = build_store();
        let aa_dict: HashMap<Name, Object> = HashMap::new();
        let obj = Object::Dictionary(aa_dict);
        let aa = parse_additional_actions(&obj, &store).unwrap();
        assert!(aa.entries.is_empty());
    }

    #[test]
    fn test_parse_additional_actions_skips_invalid() {
        let store = build_store();
        let mut aa_dict = HashMap::new();
        // An invalid action (missing /S)
        let bad = HashMap::new();
        aa_dict.insert(Name::from("K"), Object::Dictionary(bad));

        let mut good = HashMap::new();
        good.insert(Name::s(), Object::Name(Name::from("Sound")));
        aa_dict.insert(Name::from("V"), Object::Dictionary(good));

        let obj = Object::Dictionary(aa_dict);
        let aa = parse_additional_actions(&obj, &store).unwrap();
        // Only the valid action should be present
        assert_eq!(aa.entries.len(), 1);
        assert!(aa.entries.contains_key("V"));
    }
}