rpdfium-doc 7676.6.2

Document-level features for rpdfium
Documentation
// Copyright 2019 The PDFium Authors (original C API: fpdf_javascript.h)
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

//! Document-level JavaScript actions (PDF `/Names`/`/JavaScript` name tree).
//!
//! Corresponds to `fpdf_javascript.h` in PDFium.

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

use crate::error::{DocError, DocResult};
use crate::name_tree::NameTree;

/// A document-level JavaScript action entry.
///
/// Corresponds to a single entry in the `/Names`/`/JavaScript` name tree.
/// The upstream handle type is `FPDF_JAVASCRIPT_ACTION`.
#[derive(Debug, Clone)]
pub struct JavaScriptAction {
    name: String,
    script: String,
}

impl JavaScriptAction {
    /// Returns the name of this JavaScript action.
    ///
    /// Corresponds to `FPDFJavaScriptAction_GetName`.
    pub fn name(&self) -> &str {
        &self.name
    }

    /// ADR-019 T2 alias for [`name()`](Self::name).
    ///
    /// Corresponds to `FPDFJavaScriptAction_GetName`.
    #[inline]
    pub fn javascript_action_get_name(&self) -> &str {
        self.name()
    }

    /// Deprecated — use [`javascript_action_get_name()`](Self::javascript_action_get_name).
    ///
    /// Corresponds to `FPDFJavaScriptAction_GetName`.
    #[deprecated(
        note = "use `javascript_action_get_name()` — matches upstream `FPDFJavaScriptAction_GetName`"
    )]
    #[inline]
    pub fn get_name(&self) -> &str {
        self.name()
    }

    /// Returns the JavaScript code of this action.
    ///
    /// Corresponds to `FPDFJavaScriptAction_GetScript`.
    pub fn script(&self) -> &str {
        &self.script
    }

    /// ADR-019 T2 alias for [`script()`](Self::script).
    ///
    /// Corresponds to `FPDFJavaScriptAction_GetScript`.
    #[inline]
    pub fn javascript_action_get_script(&self) -> &str {
        self.script()
    }

    /// Deprecated — use [`javascript_action_get_script()`](Self::javascript_action_get_script).
    ///
    /// Corresponds to `FPDFJavaScriptAction_GetScript`.
    #[deprecated(
        note = "use `javascript_action_get_script()` — matches upstream `FPDFJavaScriptAction_GetScript`"
    )]
    #[inline]
    pub fn get_script(&self) -> &str {
        self.script()
    }
}

/// Collect all JavaScript actions from a document catalog.
///
/// Reads the `/Names`/`/JavaScript` name tree.  Each entry value is an action
/// dictionary with `/S /JavaScript` and `/JS` (the script string).
/// Entries whose value cannot be parsed as a JavaScript action are silently
/// skipped.
///
/// Returns an empty `Vec` when the catalog contains no JavaScript name tree.
///
/// Corresponds to the combination of `FPDFDoc_GetJavaScriptActionCount` +
/// `FPDFDoc_GetJavaScriptAction` in `fpdf_javascript.h`.
pub fn collect_javascript_actions<S: PdfSource>(
    catalog: &Object,
    store: &ObjectStore<S>,
) -> DocResult<Vec<JavaScriptAction>> {
    let catalog_dict = catalog.as_dict().ok_or(DocError::UnexpectedType)?;

    let names_obj = match catalog_dict.get(&Name::names()) {
        Some(o) => o,
        None => return Ok(Vec::new()),
    };

    let names_resolved = store
        .deep_resolve(names_obj)
        .map_err(|e| DocError::Parser(e.to_string()))?;

    let names_dict = match names_resolved.as_dict() {
        Some(d) => d,
        None => return Ok(Vec::new()),
    };

    let js_obj = match names_dict.get(&Name::java_script()) {
        Some(o) => o,
        None => return Ok(Vec::new()),
    };

    let tree = NameTree::<Object>::parse(js_obj, store, |obj| Ok(obj.clone()))?;

    let mut actions = Vec::new();
    for (name, obj) in tree.entries() {
        let resolved = store
            .deep_resolve(obj)
            .map_err(|e| DocError::Parser(e.to_string()))?;
        if let Some(dict) = resolved.as_dict() {
            if let Some(script) = extract_js(dict, store) {
                actions.push(JavaScriptAction {
                    name: name.clone(),
                    script,
                });
            }
        }
    }

    Ok(actions)
}

/// Extract the `/JS` string from an action dictionary.
///
/// Returns `None` if the `/JS` key is absent or the value is not a string/stream.
fn extract_js<S: PdfSource>(
    dict: &std::collections::HashMap<Name, Object>,
    store: &ObjectStore<S>,
) -> Option<String> {
    let js_obj = dict.get(&Name::js())?;
    let resolved = store.deep_resolve(js_obj).ok()?;
    // Try string first
    if let Some(s) = resolved.as_string() {
        return Some(s.to_string_lossy());
    }
    // Stream-based /JS (PDF 1.5+)
    if resolved.as_stream_dict().is_some() {
        if let Ok(data) = store.decode_stream(resolved) {
            return Some(String::from_utf8_lossy(&data).into_owned());
        }
    }
    None
}

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

    fn build_store() -> ObjectStore<Vec<u8>> {
        let mut pdf = Vec::new();
        pdf.extend_from_slice(b"%PDF-1.4\n");
        let o1 = pdf.len();
        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
        let o2 = pdf.len();
        pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
        let xref = 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", o1).as_bytes());
        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", o2).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).as_bytes());
        ObjectStore::open(pdf, rpdfium_core::ParsingMode::Lenient).unwrap()
    }

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

    #[test]
    fn test_empty_catalog_returns_empty() {
        let store = build_store();
        let catalog = Object::Dictionary(HashMap::new());
        let actions = collect_javascript_actions(&catalog, &store).unwrap();
        assert!(actions.is_empty());
    }

    #[test]
    fn test_no_names_dict_returns_empty() {
        let store = build_store();
        let mut catalog_dict = HashMap::new();
        // No /Names key
        catalog_dict.insert(Name::from("Type"), Object::Name(Name::from("Catalog")));
        let catalog = Object::Dictionary(catalog_dict);
        let actions = collect_javascript_actions(&catalog, &store).unwrap();
        assert!(actions.is_empty());
    }

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

        // Build JS action dict: { /S /JavaScript, /JS "app.alert('hi');" }
        let mut action_dict = HashMap::new();
        action_dict.insert(Name::s(), Object::Name(Name::java_script()));
        action_dict.insert(Name::js(), str_obj("app.alert('hi');"));

        // Build Names leaf: { /Names [name, action] }
        let mut js_leaf = HashMap::new();
        js_leaf.insert(
            Name::names(),
            Object::Array(vec![str_obj("myAction"), Object::Dictionary(action_dict)]),
        );

        let mut names_dict = HashMap::new();
        names_dict.insert(Name::java_script(), Object::Dictionary(js_leaf));

        let mut catalog_dict = HashMap::new();
        catalog_dict.insert(Name::names(), Object::Dictionary(names_dict));

        let catalog = Object::Dictionary(catalog_dict);
        let actions = collect_javascript_actions(&catalog, &store).unwrap();
        assert_eq!(actions.len(), 1);
        assert_eq!(actions[0].name(), "myAction");
        assert_eq!(actions[0].script(), "app.alert('hi');");
    }

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

        let make_action = |script: &str| {
            let mut d = HashMap::new();
            d.insert(Name::s(), Object::Name(Name::java_script()));
            d.insert(Name::js(), str_obj(script));
            Object::Dictionary(d)
        };

        let mut js_leaf = HashMap::new();
        js_leaf.insert(
            Name::names(),
            Object::Array(vec![
                str_obj("action1"),
                make_action("alert(1);"),
                str_obj("action2"),
                make_action("alert(2);"),
            ]),
        );

        let mut names_dict = HashMap::new();
        names_dict.insert(Name::java_script(), Object::Dictionary(js_leaf));

        let mut catalog_dict = HashMap::new();
        catalog_dict.insert(Name::names(), Object::Dictionary(names_dict));

        let catalog = Object::Dictionary(catalog_dict);
        let actions = collect_javascript_actions(&catalog, &store).unwrap();
        assert_eq!(actions.len(), 2);
        assert_eq!(actions[0].script(), "alert(1);");
        assert_eq!(actions[1].script(), "alert(2);");
    }

    #[test]
    fn test_accessors_match() {
        let action = JavaScriptAction {
            name: "test".into(),
            script: "1+1;".into(),
        };
        assert_eq!(action.name(), action.javascript_action_get_name());
        assert_eq!(action.script(), action.javascript_action_get_script());
    }
}