Skip to main content

rpdfium_doc/
javascript.rs

1// Copyright 2019 The PDFium Authors (original C API: fpdf_javascript.h)
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5//! Document-level JavaScript actions (PDF `/Names`/`/JavaScript` name tree).
6//!
7//! Corresponds to `fpdf_javascript.h` in PDFium.
8
9use rpdfium_core::{Name, PdfSource};
10use rpdfium_parser::{Object, ObjectStore};
11
12use crate::error::{DocError, DocResult};
13use crate::name_tree::NameTree;
14
15/// A document-level JavaScript action entry.
16///
17/// Corresponds to a single entry in the `/Names`/`/JavaScript` name tree.
18/// The upstream handle type is `FPDF_JAVASCRIPT_ACTION`.
19#[derive(Debug, Clone)]
20pub struct JavaScriptAction {
21    name: String,
22    script: String,
23}
24
25impl JavaScriptAction {
26    /// Returns the name of this JavaScript action.
27    ///
28    /// Corresponds to `FPDFJavaScriptAction_GetName`.
29    pub fn name(&self) -> &str {
30        &self.name
31    }
32
33    /// ADR-019 T2 alias for [`name()`](Self::name).
34    ///
35    /// Corresponds to `FPDFJavaScriptAction_GetName`.
36    #[inline]
37    pub fn javascript_action_get_name(&self) -> &str {
38        self.name()
39    }
40
41    /// Deprecated — use [`javascript_action_get_name()`](Self::javascript_action_get_name).
42    ///
43    /// Corresponds to `FPDFJavaScriptAction_GetName`.
44    #[deprecated(
45        note = "use `javascript_action_get_name()` — matches upstream `FPDFJavaScriptAction_GetName`"
46    )]
47    #[inline]
48    pub fn get_name(&self) -> &str {
49        self.name()
50    }
51
52    /// Returns the JavaScript code of this action.
53    ///
54    /// Corresponds to `FPDFJavaScriptAction_GetScript`.
55    pub fn script(&self) -> &str {
56        &self.script
57    }
58
59    /// ADR-019 T2 alias for [`script()`](Self::script).
60    ///
61    /// Corresponds to `FPDFJavaScriptAction_GetScript`.
62    #[inline]
63    pub fn javascript_action_get_script(&self) -> &str {
64        self.script()
65    }
66
67    /// Deprecated — use [`javascript_action_get_script()`](Self::javascript_action_get_script).
68    ///
69    /// Corresponds to `FPDFJavaScriptAction_GetScript`.
70    #[deprecated(
71        note = "use `javascript_action_get_script()` — matches upstream `FPDFJavaScriptAction_GetScript`"
72    )]
73    #[inline]
74    pub fn get_script(&self) -> &str {
75        self.script()
76    }
77
78    /// Close / release a JavaScript action handle.
79    ///
80    /// # Not Supported
81    ///
82    /// rpdfium uses RAII: `JavaScriptAction` is freed when dropped.
83    /// Explicit close is not needed and not supported (ADR-002: RAII lifecycle).
84    ///
85    /// Corresponds to `FPDFDoc_CloseJavaScriptAction()`.
86    pub fn close(self) -> crate::error::DocResult<()> {
87        Err(crate::error::DocError::NotSupported(
88            "close: JavaScriptAction is RAII-managed; explicit close not supported (ADR-002)"
89                .into(),
90        ))
91    }
92}
93
94/// Collect all JavaScript actions from a document catalog.
95///
96/// Reads the `/Names`/`/JavaScript` name tree.  Each entry value is an action
97/// dictionary with `/S /JavaScript` and `/JS` (the script string).
98/// Entries whose value cannot be parsed as a JavaScript action are silently
99/// skipped.
100///
101/// Returns an empty `Vec` when the catalog contains no JavaScript name tree.
102///
103/// Corresponds to the combination of `FPDFDoc_GetJavaScriptActionCount` +
104/// `FPDFDoc_GetJavaScriptAction` in `fpdf_javascript.h`.
105pub fn collect_javascript_actions<S: PdfSource>(
106    catalog: &Object,
107    store: &ObjectStore<S>,
108) -> DocResult<Vec<JavaScriptAction>> {
109    let catalog_dict = catalog.as_dict().ok_or(DocError::UnexpectedType)?;
110
111    let names_obj = match catalog_dict.get(&Name::names()) {
112        Some(o) => o,
113        None => return Ok(Vec::new()),
114    };
115
116    let names_resolved = store
117        .deep_resolve(names_obj)
118        .map_err(|e| DocError::Parser(e.to_string()))?;
119
120    let names_dict = match names_resolved.as_dict() {
121        Some(d) => d,
122        None => return Ok(Vec::new()),
123    };
124
125    let js_obj = match names_dict.get(&Name::java_script()) {
126        Some(o) => o,
127        None => return Ok(Vec::new()),
128    };
129
130    let tree = NameTree::<Object>::parse(js_obj, store, |obj| Ok(obj.clone()))?;
131
132    let mut actions = Vec::new();
133    for (name, obj) in tree.entries() {
134        let resolved = store
135            .deep_resolve(obj)
136            .map_err(|e| DocError::Parser(e.to_string()))?;
137        if let Some(dict) = resolved.as_dict() {
138            if let Some(script) = extract_js(dict, store) {
139                actions.push(JavaScriptAction {
140                    name: name.clone(),
141                    script,
142                });
143            }
144        }
145    }
146
147    Ok(actions)
148}
149
150/// Extract the `/JS` string from an action dictionary.
151///
152/// Returns `None` if the `/JS` key is absent or the value is not a string/stream.
153fn extract_js<S: PdfSource>(
154    dict: &std::collections::HashMap<Name, Object>,
155    store: &ObjectStore<S>,
156) -> Option<String> {
157    let js_obj = dict.get(&Name::js())?;
158    let resolved = store.deep_resolve(js_obj).ok()?;
159    // Try string first
160    if let Some(s) = resolved.as_string() {
161        return Some(s.to_string_lossy());
162    }
163    // Stream-based /JS (PDF 1.5+)
164    if resolved.as_stream_dict().is_some() {
165        if let Ok(data) = store.decode_stream(resolved) {
166            return Some(String::from_utf8_lossy(&data).into_owned());
167        }
168    }
169    None
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use rpdfium_core::PdfString;
176    use rpdfium_parser::ObjectStore;
177    use std::collections::HashMap;
178
179    fn build_store() -> ObjectStore<Vec<u8>> {
180        let mut pdf = Vec::new();
181        pdf.extend_from_slice(b"%PDF-1.4\n");
182        let o1 = pdf.len();
183        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
184        let o2 = pdf.len();
185        pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
186        let xref = pdf.len();
187        pdf.extend_from_slice(b"xref\n0 3\n");
188        pdf.extend_from_slice(b"0000000000 65535 f \r\n");
189        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", o1).as_bytes());
190        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", o2).as_bytes());
191        pdf.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
192        pdf.extend_from_slice(format!("startxref\n{}\n%%EOF", xref).as_bytes());
193        ObjectStore::open(pdf, rpdfium_core::ParsingMode::Lenient).unwrap()
194    }
195
196    fn str_obj(s: &str) -> Object {
197        Object::String(PdfString::from_bytes(s.as_bytes().to_vec()))
198    }
199
200    #[test]
201    fn test_empty_catalog_returns_empty() {
202        let store = build_store();
203        let catalog = Object::Dictionary(HashMap::new());
204        let actions = collect_javascript_actions(&catalog, &store).unwrap();
205        assert!(actions.is_empty());
206    }
207
208    #[test]
209    fn test_no_names_dict_returns_empty() {
210        let store = build_store();
211        let mut catalog_dict = HashMap::new();
212        // No /Names key
213        catalog_dict.insert(Name::from("Type"), Object::Name(Name::from("Catalog")));
214        let catalog = Object::Dictionary(catalog_dict);
215        let actions = collect_javascript_actions(&catalog, &store).unwrap();
216        assert!(actions.is_empty());
217    }
218
219    #[test]
220    fn test_single_javascript_action() {
221        let store = build_store();
222
223        // Build JS action dict: { /S /JavaScript, /JS "app.alert('hi');" }
224        let mut action_dict = HashMap::new();
225        action_dict.insert(Name::s(), Object::Name(Name::java_script()));
226        action_dict.insert(Name::js(), str_obj("app.alert('hi');"));
227
228        // Build Names leaf: { /Names [name, action] }
229        let mut js_leaf = HashMap::new();
230        js_leaf.insert(
231            Name::names(),
232            Object::Array(vec![str_obj("myAction"), Object::Dictionary(action_dict)]),
233        );
234
235        let mut names_dict = HashMap::new();
236        names_dict.insert(Name::java_script(), Object::Dictionary(js_leaf));
237
238        let mut catalog_dict = HashMap::new();
239        catalog_dict.insert(Name::names(), Object::Dictionary(names_dict));
240
241        let catalog = Object::Dictionary(catalog_dict);
242        let actions = collect_javascript_actions(&catalog, &store).unwrap();
243        assert_eq!(actions.len(), 1);
244        assert_eq!(actions[0].name(), "myAction");
245        assert_eq!(actions[0].script(), "app.alert('hi');");
246    }
247
248    #[test]
249    fn test_multiple_javascript_actions() {
250        let store = build_store();
251
252        let make_action = |script: &str| {
253            let mut d = HashMap::new();
254            d.insert(Name::s(), Object::Name(Name::java_script()));
255            d.insert(Name::js(), str_obj(script));
256            Object::Dictionary(d)
257        };
258
259        let mut js_leaf = HashMap::new();
260        js_leaf.insert(
261            Name::names(),
262            Object::Array(vec![
263                str_obj("action1"),
264                make_action("alert(1);"),
265                str_obj("action2"),
266                make_action("alert(2);"),
267            ]),
268        );
269
270        let mut names_dict = HashMap::new();
271        names_dict.insert(Name::java_script(), Object::Dictionary(js_leaf));
272
273        let mut catalog_dict = HashMap::new();
274        catalog_dict.insert(Name::names(), Object::Dictionary(names_dict));
275
276        let catalog = Object::Dictionary(catalog_dict);
277        let actions = collect_javascript_actions(&catalog, &store).unwrap();
278        assert_eq!(actions.len(), 2);
279        assert_eq!(actions[0].script(), "alert(1);");
280        assert_eq!(actions[1].script(), "alert(2);");
281    }
282
283    #[test]
284    fn test_accessors_match() {
285        let action = JavaScriptAction {
286            name: "test".into(),
287            script: "1+1;".into(),
288        };
289        assert_eq!(action.name(), action.javascript_action_get_name());
290        assert_eq!(action.script(), action.javascript_action_get_script());
291    }
292
293    #[test]
294    fn test_close_returns_not_supported() {
295        let action = JavaScriptAction {
296            name: "x".into(),
297            script: "1;".into(),
298        };
299        assert!(action.close().is_err());
300    }
301}