Skip to main content

pdfluent_forms/
flatten.rs

1//! Form flattening: convert interactive fields to static content (B.8).
2
3use crate::appearance::generate_appearance;
4use crate::tree::{FieldId, FieldTree, FieldType};
5use lopdf::dictionary;
6
7/// Configuration for form flattening.
8#[derive(Debug, Clone)]
9pub struct FlattenConfig {
10    /// If set, only flatten fields with these fully qualified names. If empty, flatten all.
11    pub field_names: Vec<String>,
12    /// Remove the /AcroForm dictionary after flattening.
13    pub remove_acroform: bool,
14    /// PDF/A compliance mode.
15    pub pdfa: bool,
16}
17
18impl Default for FlattenConfig {
19    fn default() -> Self {
20        Self {
21            field_names: vec![],
22            remove_acroform: true,
23            pdfa: false,
24        }
25    }
26}
27
28/// Result of a flatten operation.
29#[derive(Debug)]
30pub struct FlattenResult {
31    /// Number of fields flattened.
32    pub fields_flattened: usize,
33    /// Fields that could not be flattened.
34    pub skipped: Vec<String>,
35}
36
37/// Flatten form fields into static content in a lopdf Document.
38pub fn flatten_form(
39    doc: &mut lopdf::Document,
40    tree: &FieldTree,
41    config: &FlattenConfig,
42) -> FlattenResult {
43    let mut result = FlattenResult {
44        fields_flattened: 0,
45        skipped: vec![],
46    };
47
48    // Pre-build a HashSet from config.field_names to avoid O(F×N) Vec::contains
49    // inside the filter — with large forms (many fields) and many target names
50    // the slice contains() is O(N) per field. (#perf)
51    let fields_to_flatten: Vec<FieldId> = if config.field_names.is_empty() {
52        tree.terminal_fields()
53    } else {
54        let name_set: std::collections::HashSet<&str> =
55            config.field_names.iter().map(String::as_str).collect();
56        tree.terminal_fields()
57            .into_iter()
58            .filter(|&id| name_set.contains(tree.fully_qualified_name(id).as_str()))
59            .collect()
60    };
61
62    for &field_id in &fields_to_flatten {
63        if tree.effective_field_type(field_id) == Some(FieldType::Signature) {
64            result.skipped.push(tree.fully_qualified_name(field_id));
65            continue;
66        }
67        let ap_data = match generate_appearance(tree, field_id) {
68            Some(data) => data,
69            None => {
70                result.skipped.push(tree.fully_qualified_name(field_id));
71                continue;
72            }
73        };
74        let node = tree.get(field_id);
75        let rect = match node.rect {
76            Some(r) => r,
77            None => {
78                result.skipped.push(tree.fully_qualified_name(field_id));
79                continue;
80            }
81        };
82        let page_idx = node.page_index.unwrap_or(0);
83        let bbox = vec![
84            lopdf::Object::Real(0.0),
85            lopdf::Object::Real(0.0),
86            lopdf::Object::Real(rect[2] - rect[0]),
87            lopdf::Object::Real(rect[3] - rect[1]),
88        ];
89        let xobj_dict = dictionary! {
90            "Type" => lopdf::Object::Name(b"XObject".to_vec()),
91            "Subtype" => lopdf::Object::Name(b"Form".to_vec()),
92            "BBox" => lopdf::Object::Array(bbox),
93            "Matrix" => lopdf::Object::Array(vec![
94                lopdf::Object::Integer(1), lopdf::Object::Integer(0),
95                lopdf::Object::Integer(0), lopdf::Object::Integer(1),
96                lopdf::Object::Integer(0), lopdf::Object::Integer(0),
97            ]),
98        };
99        let xobj_stream = lopdf::Stream::new(xobj_dict, ap_data);
100        let xobj_id = doc.add_object(lopdf::Object::Stream(xobj_stream));
101        let xobj_name = format!("Fm{}", xobj_id.0);
102
103        let page_ids: Vec<lopdf::ObjectId> = doc.page_iter().collect();
104        if let Some(&page_id) = page_ids.get(page_idx) {
105            let resources_id = get_or_create_page_resources(doc, page_id);
106            add_xobject_to_resources(doc, resources_id, &xobj_name, xobj_id);
107            let content_ops = format!(
108                "q {} 0 0 {} {} {} cm /{} Do Q\n",
109                rect[2] - rect[0],
110                rect[3] - rect[1],
111                rect[0],
112                rect[1],
113                xobj_name
114            );
115            append_to_page_content(doc, page_id, content_ops.as_bytes());
116            result.fields_flattened += 1;
117        } else {
118            result.skipped.push(tree.fully_qualified_name(field_id));
119        }
120    }
121
122    remove_widget_annotations(doc, tree, &fields_to_flatten);
123    if config.remove_acroform {
124        remove_acroform_dict(doc);
125    }
126    result
127}
128
129fn get_or_create_page_resources(
130    doc: &mut lopdf::Document,
131    page_id: lopdf::ObjectId,
132) -> lopdf::ObjectId {
133    if let Ok(lopdf::Object::Dictionary(d)) = doc.get_object(page_id) {
134        if let Ok(lopdf::Object::Reference(res_id)) = d.get(b"Resources") {
135            return *res_id;
136        }
137    }
138    let res_id = doc.add_object(dictionary! {});
139    if let Ok(lopdf::Object::Dictionary(ref mut page_dict)) = doc.get_object_mut(page_id) {
140        page_dict.set("Resources", lopdf::Object::Reference(res_id));
141    }
142    res_id
143}
144
145fn add_xobject_to_resources(
146    doc: &mut lopdf::Document,
147    resources_id: lopdf::ObjectId,
148    name: &str,
149    xobj_id: lopdf::ObjectId,
150) {
151    if let Ok(lopdf::Object::Dictionary(ref mut res_dict)) = doc.get_object_mut(resources_id) {
152        if let Ok(lopdf::Object::Dictionary(ref mut xobj_dict)) = res_dict.get_mut(b"XObject") {
153            xobj_dict.set(name, lopdf::Object::Reference(xobj_id));
154        } else {
155            let mut xobj_dict = lopdf::Dictionary::new();
156            xobj_dict.set(name, lopdf::Object::Reference(xobj_id));
157            res_dict.set("XObject", lopdf::Object::Dictionary(xobj_dict));
158        }
159    }
160}
161
162fn append_to_page_content(doc: &mut lopdf::Document, page_id: lopdf::ObjectId, data: &[u8]) {
163    let content_ref = doc.get_object(page_id).ok().and_then(|o| {
164        if let lopdf::Object::Dictionary(d) = o {
165            d.get(b"Contents").ok().cloned()
166        } else {
167            None
168        }
169    });
170    match content_ref {
171        Some(lopdf::Object::Reference(content_id)) => {
172            if let Ok(lopdf::Object::Stream(ref mut stream)) = doc.get_object_mut(content_id) {
173                stream.content.extend_from_slice(data);
174            }
175        }
176        Some(lopdf::Object::Array(arr)) => {
177            let new_stream = lopdf::Stream::new(dictionary! {}, data.to_vec());
178            let new_id = doc.add_object(lopdf::Object::Stream(new_stream));
179            let mut new_arr = arr;
180            new_arr.push(lopdf::Object::Reference(new_id));
181            if let Ok(lopdf::Object::Dictionary(ref mut pd)) = doc.get_object_mut(page_id) {
182                pd.set("Contents", lopdf::Object::Array(new_arr));
183            }
184        }
185        _ => {
186            let new_stream = lopdf::Stream::new(dictionary! {}, data.to_vec());
187            let new_id = doc.add_object(lopdf::Object::Stream(new_stream));
188            if let Ok(lopdf::Object::Dictionary(ref mut pd)) = doc.get_object_mut(page_id) {
189                pd.set("Contents", lopdf::Object::Reference(new_id));
190            }
191        }
192    }
193}
194
195fn remove_widget_annotations(doc: &mut lopdf::Document, tree: &FieldTree, flattened: &[FieldId]) {
196    let obj_ids_to_remove: Vec<lopdf::ObjectId> = flattened
197        .iter()
198        .filter_map(|&id| {
199            tree.get(id)
200                .object_id
201                .map(|(obj, gen)| (obj as u32, gen as u16))
202        })
203        .collect();
204    if obj_ids_to_remove.is_empty() {
205        return;
206    }
207
208    strip_widget_javascript_additional_actions(doc, &obj_ids_to_remove);
209
210    let page_ids: Vec<lopdf::ObjectId> = doc.page_iter().collect();
211    for page_id in page_ids {
212        let annots = doc.get_object(page_id).ok().and_then(|o| {
213            if let lopdf::Object::Dictionary(d) = o {
214                d.get(b"Annots").ok().cloned()
215            } else {
216                None
217            }
218        });
219        if let Some(lopdf::Object::Array(arr)) = annots {
220            let filtered: Vec<lopdf::Object> = arr
221                .into_iter()
222                .filter(|obj| {
223                    if let lopdf::Object::Reference(ref_id) = obj {
224                        !obj_ids_to_remove.contains(ref_id)
225                    } else {
226                        true
227                    }
228                })
229                .collect();
230            if let Ok(lopdf::Object::Dictionary(ref mut pd)) = doc.get_object_mut(page_id) {
231                if filtered.is_empty() {
232                    pd.remove(b"Annots");
233                } else {
234                    pd.set("Annots", lopdf::Object::Array(filtered));
235                }
236            }
237        }
238    }
239}
240
241fn strip_widget_javascript_additional_actions(
242    doc: &mut lopdf::Document,
243    widget_ids: &[lopdf::ObjectId],
244) -> usize {
245    // M8-SEC-02 policy source: JavaScript is inspectable, never executable,
246    // and JS-bearing widget /AA entries are STRIP_ON_FLATTEN on hardening paths.
247    let mut stripped = 0;
248    for &widget_id in widget_ids {
249        let aa_action = match doc.objects.get(&widget_id) {
250            Some(lopdf::Object::Dictionary(dict)) if is_widget_annotation(dict) => {
251                dict.get(b"AA").ok().cloned()
252            }
253            _ => None,
254        };
255
256        match aa_action {
257            Some(lopdf::Object::Dictionary(aa_dict)) => {
258                let js_keys = javascript_additional_action_keys(doc, &aa_dict);
259                if js_keys.is_empty() {
260                    continue;
261                }
262                let remove_aa = js_keys.len() == aa_dict.len();
263                if let Some(lopdf::Object::Dictionary(dict)) = doc.objects.get_mut(&widget_id) {
264                    if remove_aa {
265                        dict.remove(b"AA");
266                    } else if let Ok(lopdf::Object::Dictionary(aa)) = dict.get_mut(b"AA") {
267                        for key in &js_keys {
268                            aa.remove(key);
269                        }
270                    }
271                    stripped += js_keys.len();
272                }
273            }
274            Some(lopdf::Object::Reference(aa_id)) => {
275                // Compute JS keys + sanitized non-JS copy in a read-only pass.
276                // Mirror of the direct-Dictionary branch's selectivity, with
277                // clone-on-write to avoid in-place mutation of the shared
278                // indirect object (other widgets may still reference it).
279                let (js_keys, sanitized) = match doc.objects.get(&aa_id) {
280                    Some(lopdf::Object::Dictionary(aa_dict)) => {
281                        let js = javascript_additional_action_keys(doc, aa_dict);
282                        if js.is_empty() || js.len() == aa_dict.len() {
283                            // No JS, or all-JS — nothing to keep on the widget.
284                            (js, None)
285                        } else {
286                            // Mixed entries — build a widget-private dict
287                            // containing only the non-JS ones.
288                            let mut s = lopdf::Dictionary::new();
289                            for (key, val) in aa_dict.iter() {
290                                if !js.iter().any(|jk| jk == key) {
291                                    s.set(key.clone(), val.clone());
292                                }
293                            }
294                            (js, Some(s))
295                        }
296                    }
297                    _ => (Vec::new(), None),
298                };
299                if js_keys.is_empty() {
300                    continue;
301                }
302
303                if let Some(lopdf::Object::Dictionary(dict)) = doc.objects.get_mut(&widget_id) {
304                    match sanitized {
305                        None => {
306                            // All-JS (or unresolvable): drop /AA from this widget.
307                            dict.remove(b"AA");
308                        }
309                        Some(s) => {
310                            // Replace the indirect ref with a widget-private
311                            // sanitized inline dict. The shared indirect object
312                            // is left untouched; other widgets keep their /AA.
313                            dict.set("AA", lopdf::Object::Dictionary(s));
314                        }
315                    }
316                    stripped += js_keys.len();
317                }
318            }
319            _ => {}
320        }
321    }
322    stripped
323}
324
325fn javascript_additional_action_keys(
326    doc: &lopdf::Document,
327    aa_dict: &lopdf::Dictionary,
328) -> Vec<Vec<u8>> {
329    aa_dict
330        .iter()
331        .filter_map(|(key, action)| {
332            is_javascript_action_object(doc, action, 0).then_some(key.clone())
333        })
334        .collect()
335}
336
337fn is_widget_annotation(dict: &lopdf::Dictionary) -> bool {
338    matches!(
339        dict.get(b"Subtype").ok(),
340        Some(lopdf::Object::Name(name)) if name == b"Widget"
341    )
342}
343
344fn is_javascript_action_object(
345    doc: &lopdf::Document,
346    action: &lopdf::Object,
347    depth: usize,
348) -> bool {
349    if depth > 16 {
350        return false;
351    }
352
353    match action {
354        lopdf::Object::Dictionary(dict) => is_javascript_action_dict(dict),
355        lopdf::Object::Reference(id) => doc
356            .objects
357            .get(id)
358            .is_some_and(|object| is_javascript_action_object(doc, object, depth + 1)),
359        _ => false,
360    }
361}
362
363fn is_javascript_action_dict(dict: &lopdf::Dictionary) -> bool {
364    // Primary check: /S /JavaScript
365    if matches!(
366        dict.get(b"S").ok(),
367        Some(lopdf::Object::Name(name)) if name == b"JavaScript"
368    ) {
369        return true;
370    }
371    // /Next chained actions: a non-JS trigger can chain to a JS action via
372    // /Next, which would survive flattening if only the top-level /S is
373    // checked. We treat any action dict that chains to a JS action as JS
374    // (M8-SEC-02). The recursive walk is bounded by the caller's depth cap.
375    match dict.get(b"Next").ok() {
376        Some(lopdf::Object::Dictionary(next)) => is_javascript_action_dict(next),
377        Some(lopdf::Object::Array(arr)) => arr.iter().any(|obj| {
378            if let lopdf::Object::Dictionary(d) = obj {
379                is_javascript_action_dict(d)
380            } else {
381                false
382            }
383        }),
384        _ => false,
385    }
386}
387
388fn remove_acroform_dict(doc: &mut lopdf::Document) {
389    if let Ok(catalog) = doc.catalog_mut() {
390        catalog.remove(b"AcroForm");
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::flags::FieldFlags;
398    use crate::tree::{FieldNode, FieldValue};
399    use lopdf::{Dictionary, Document, Object, ObjectId, Stream, StringFormat};
400
401    fn js_action() -> Object {
402        Object::Dictionary(dictionary! {
403            "S" => Object::Name(b"JavaScript".to_vec()),
404            "JS" => Object::String(b"app.alert('blocked')".to_vec(), StringFormat::Literal),
405        })
406    }
407
408    fn uri_action() -> Object {
409        Object::Dictionary(dictionary! {
410            "S" => Object::Name(b"URI".to_vec()),
411            "URI" => Object::String(b"https://example.com".to_vec(), StringFormat::Literal),
412        })
413    }
414
415    fn widget_dict(widget_extra: Dictionary) -> Dictionary {
416        let mut widget = dictionary! {
417            "Type" => Object::Name(b"Annot".to_vec()),
418            "Subtype" => Object::Name(b"Widget".to_vec()),
419            "Rect" => Object::Array(vec![
420                Object::Integer(100),
421                Object::Integer(700),
422                Object::Integer(220),
423                Object::Integer(730),
424            ]),
425        };
426        for (key, value) in widget_extra {
427            widget.set(key, value);
428        }
429        widget
430    }
431
432    fn make_doc_with_widget(widget_extra: Dictionary) -> (Document, ObjectId) {
433        let mut doc = Document::with_version("1.4");
434        let pages_id = doc.new_object_id();
435        let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, Vec::new())));
436        let widget_id = doc.new_object_id();
437        let page_id = doc.add_object(Object::Dictionary(dictionary! {
438            "Type" => Object::Name(b"Page".to_vec()),
439            "Parent" => Object::Reference(pages_id),
440            "MediaBox" => Object::Array(vec![
441                Object::Integer(0),
442                Object::Integer(0),
443                Object::Integer(612),
444                Object::Integer(792),
445            ]),
446            "Contents" => Object::Reference(content_id),
447            "Annots" => Object::Array(vec![Object::Reference(widget_id)]),
448        }));
449        doc.objects.insert(
450            pages_id,
451            Object::Dictionary(dictionary! {
452                "Type" => Object::Name(b"Pages".to_vec()),
453                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
454                "Count" => Object::Integer(1),
455            }),
456        );
457
458        doc.objects
459            .insert(widget_id, Object::Dictionary(widget_dict(widget_extra)));
460
461        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
462            "Type" => Object::Name(b"Catalog".to_vec()),
463            "Pages" => Object::Reference(pages_id),
464        }));
465        doc.trailer.set("Root", Object::Reference(catalog_id));
466        (doc, widget_id)
467    }
468
469    fn make_doc_with_shared_indirect_mixed_aa() -> (Document, ObjectId, ObjectId, ObjectId) {
470        // Build a doc where two widgets share the same indirect /AA dict, and
471        // that dict carries one JS-bearing entry (/E) plus one non-JS entry
472        // (/X with a URI action). Used to exercise the clone-on-write path
473        // where the targeted widget should keep /X but lose /E, and the
474        // shared indirect dict must remain intact.
475        let mut doc = Document::with_version("1.4");
476        let pages_id = doc.new_object_id();
477        let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, Vec::new())));
478        let shared_aa_id = doc.add_object(Object::Dictionary(dictionary! {
479            "E" => js_action(),
480            "X" => uri_action(),
481        }));
482        let widget_a_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
483            "AA" => Object::Reference(shared_aa_id),
484        })));
485        let widget_b_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
486            "AA" => Object::Reference(shared_aa_id),
487        })));
488        let page_id = doc.add_object(Object::Dictionary(dictionary! {
489            "Type" => Object::Name(b"Page".to_vec()),
490            "Parent" => Object::Reference(pages_id),
491            "MediaBox" => Object::Array(vec![
492                Object::Integer(0),
493                Object::Integer(0),
494                Object::Integer(612),
495                Object::Integer(792),
496            ]),
497            "Contents" => Object::Reference(content_id),
498            "Annots" => Object::Array(vec![
499                Object::Reference(widget_a_id),
500                Object::Reference(widget_b_id),
501            ]),
502        }));
503        doc.objects.insert(
504            pages_id,
505            Object::Dictionary(dictionary! {
506                "Type" => Object::Name(b"Pages".to_vec()),
507                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
508                "Count" => Object::Integer(1),
509            }),
510        );
511        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
512            "Type" => Object::Name(b"Catalog".to_vec()),
513            "Pages" => Object::Reference(pages_id),
514        }));
515        doc.trailer.set("Root", Object::Reference(catalog_id));
516        (doc, widget_a_id, widget_b_id, shared_aa_id)
517    }
518
519    fn make_doc_with_shared_indirect_aa() -> (Document, ObjectId, ObjectId, ObjectId) {
520        let mut doc = Document::with_version("1.4");
521        let pages_id = doc.new_object_id();
522        let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, Vec::new())));
523        let shared_aa_id = doc.add_object(Object::Dictionary(dictionary! {
524            "E" => js_action(),
525        }));
526        let widget_a_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
527            "AA" => Object::Reference(shared_aa_id),
528        })));
529        let widget_b_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
530            "AA" => Object::Reference(shared_aa_id),
531        })));
532        let page_id = doc.add_object(Object::Dictionary(dictionary! {
533            "Type" => Object::Name(b"Page".to_vec()),
534            "Parent" => Object::Reference(pages_id),
535            "MediaBox" => Object::Array(vec![
536                Object::Integer(0),
537                Object::Integer(0),
538                Object::Integer(612),
539                Object::Integer(792),
540            ]),
541            "Contents" => Object::Reference(content_id),
542            "Annots" => Object::Array(vec![
543                Object::Reference(widget_a_id),
544                Object::Reference(widget_b_id),
545            ]),
546        }));
547        doc.objects.insert(
548            pages_id,
549            Object::Dictionary(dictionary! {
550                "Type" => Object::Name(b"Pages".to_vec()),
551                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
552                "Count" => Object::Integer(1),
553            }),
554        );
555        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
556            "Type" => Object::Name(b"Catalog".to_vec()),
557            "Pages" => Object::Reference(pages_id),
558        }));
559        doc.trailer.set("Root", Object::Reference(catalog_id));
560        (doc, widget_a_id, widget_b_id, shared_aa_id)
561    }
562
563    fn field_tree_for_widget(widget_id: ObjectId) -> FieldTree {
564        field_tree_for_widgets(&[("name", widget_id)])
565    }
566
567    fn field_tree_for_widgets(widgets: &[(&str, ObjectId)]) -> FieldTree {
568        let mut tree = FieldTree::new();
569        tree.document_da = Some("/Helv 12 Tf 0 g".to_string());
570        for &(name, widget_id) in widgets {
571            tree.alloc(FieldNode {
572                partial_name: name.into(),
573                alternate_name: None,
574                mapping_name: None,
575                field_type: Some(FieldType::Text),
576                flags: FieldFlags::empty(),
577                value: Some(FieldValue::Text("Ada".into())),
578                default_value: None,
579                default_appearance: Some("/Helv 12 Tf 0 g".into()),
580                quadding: None,
581                max_len: None,
582                options: vec![],
583                top_index: None,
584                rect: Some([100.0, 700.0, 220.0, 730.0]),
585                appearance_state: None,
586                page_index: Some(0),
587                parent: None,
588                children: vec![],
589                object_id: Some((widget_id.0 as i32, widget_id.1 as i32)),
590                has_actions: true,
591                mk: None,
592                border_style: None,
593            });
594        }
595        tree
596    }
597
598    fn widget_aa_reference(doc: &Document, widget_id: ObjectId) -> Option<ObjectId> {
599        doc.get_dictionary(widget_id)
600            .ok()?
601            .get(b"AA")
602            .ok()?
603            .as_reference()
604            .ok()
605    }
606
607    #[test]
608    fn flatten_config_default() {
609        let config = FlattenConfig::default();
610        assert!(config.field_names.is_empty());
611        assert!(config.remove_acroform);
612    }
613    #[test]
614    fn flatten_empty_tree() {
615        let tree = FieldTree::new();
616        let mut doc = lopdf::Document::new();
617        let result = flatten_form(&mut doc, &tree, &FlattenConfig::default());
618        assert_eq!(result.fields_flattened, 0);
619    }
620
621    #[test]
622    fn flatten_strips_widget_javascript_additional_actions() {
623        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
624            "AA" => Object::Dictionary(dictionary! {
625                "E" => js_action(),
626            }),
627        });
628        let tree = field_tree_for_widget(widget_id);
629
630        let result = flatten_form(
631            &mut doc,
632            &tree,
633            &FlattenConfig {
634                remove_acroform: false,
635                ..Default::default()
636            },
637        );
638
639        assert_eq!(result.fields_flattened, 1);
640        let widget = doc.get_dictionary(widget_id).expect("widget dict");
641        assert!(
642            widget.get(b"AA").is_err(),
643            "JS-only widget /AA must be removed after flatten"
644        );
645    }
646
647    #[test]
648    fn flatten_preserves_widget_without_additional_actions() {
649        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {});
650        let tree = field_tree_for_widget(widget_id);
651
652        let result = flatten_form(
653            &mut doc,
654            &tree,
655            &FlattenConfig {
656                remove_acroform: false,
657                ..Default::default()
658            },
659        );
660
661        assert_eq!(result.fields_flattened, 1);
662        let widget = doc.get_dictionary(widget_id).expect("widget dict");
663        assert!(widget.get(b"AA").is_err());
664        assert!(matches!(
665            widget.get(b"Subtype"),
666            Ok(Object::Name(name)) if name == b"Widget"
667        ));
668    }
669
670    #[test]
671    fn flatten_preserves_non_javascript_additional_actions_for_later_policy() {
672        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
673            "AA" => Object::Dictionary(dictionary! {
674                "E" => js_action(),
675                "X" => uri_action(),
676            }),
677        });
678        let tree = field_tree_for_widget(widget_id);
679
680        let result = flatten_form(
681            &mut doc,
682            &tree,
683            &FlattenConfig {
684                remove_acroform: false,
685                ..Default::default()
686            },
687        );
688
689        assert_eq!(result.fields_flattened, 1);
690        let widget = doc.get_dictionary(widget_id).expect("widget dict");
691        let aa = widget
692            .get(b"AA")
693            .expect("non-JS /AA entry should remain")
694            .as_dict()
695            .expect("AA dict");
696        assert!(aa.get(b"E").is_err(), "JS /AA entry must be stripped");
697        assert!(aa.get(b"X").is_ok(), "non-JS /AA entry is out of scope");
698    }
699
700    #[test]
701    fn flatten_strips_targeted_widget_aa_without_mutating_shared_indirect_dict() {
702        let (mut doc, widget_a_id, widget_b_id, shared_aa_id) = make_doc_with_shared_indirect_aa();
703        let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
704
705        let result = flatten_form(
706            &mut doc,
707            &tree,
708            &FlattenConfig {
709                field_names: vec!["a".into()],
710                remove_acroform: false,
711                ..Default::default()
712            },
713        );
714
715        assert_eq!(result.fields_flattened, 1);
716        let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
717        assert!(widget_a.get(b"AA").is_err(), "targeted widget AA stripped");
718        assert_eq!(
719            widget_aa_reference(&doc, widget_b_id),
720            Some(shared_aa_id),
721            "untargeted widget keeps its shared AA reference"
722        );
723        let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
724        assert!(
725            shared_aa.get(b"E").is_ok(),
726            "shared AA object must not be emptied in place"
727        );
728    }
729
730    #[test]
731    fn flatten_strips_each_targeted_widget_aa_when_shared_indirect_dict_is_reused() {
732        let (mut doc, widget_a_id, widget_b_id, shared_aa_id) = make_doc_with_shared_indirect_aa();
733        let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
734
735        let result = flatten_form(
736            &mut doc,
737            &tree,
738            &FlattenConfig {
739                field_names: vec!["a".into(), "b".into()],
740                remove_acroform: false,
741                ..Default::default()
742            },
743        );
744
745        assert_eq!(result.fields_flattened, 2);
746        let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
747        let widget_b = doc.get_dictionary(widget_b_id).expect("widget B dict");
748        assert!(widget_a.get(b"AA").is_err(), "widget A AA stripped");
749        assert!(widget_b.get(b"AA").is_err(), "widget B AA stripped");
750        let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
751        assert!(
752            shared_aa.get(b"E").is_ok(),
753            "shared AA object remains intact even when all users are targeted"
754        );
755    }
756
757    #[test]
758    fn flatten_preserves_non_js_entries_on_targeted_widget_with_shared_indirect_aa() {
759        // Codex P2 regression-guard (PR #1373 follow-up): when /AA is an
760        // indirect dict shared between widgets and contains a mix of JS and
761        // non-JS entries, flattening the targeted widget must:
762        //   - drop the JS entry (/E) from that widget's effective /AA
763        //   - keep the non-JS entry (/X URI) on the targeted widget
764        //   - leave the untargeted widget's reference to the shared dict intact
765        //   - never mutate the shared indirect dict in place
766        let (mut doc, widget_a_id, widget_b_id, shared_aa_id) =
767            make_doc_with_shared_indirect_mixed_aa();
768        let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
769
770        let result = flatten_form(
771            &mut doc,
772            &tree,
773            &FlattenConfig {
774                field_names: vec!["a".into()],
775                remove_acroform: false,
776                ..Default::default()
777            },
778        );
779
780        assert_eq!(result.fields_flattened, 1);
781
782        // Targeted widget A: /AA must now be an inline dict that contains /X
783        // but not /E (clone-on-write).
784        let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
785        let aa_a = widget_a
786            .get(b"AA")
787            .expect("targeted widget keeps non-JS /AA entries")
788            .as_dict()
789            .expect("widget A /AA must be an inline sanitized dict");
790        assert!(
791            aa_a.get(b"E").is_err(),
792            "JS /AA entry must be stripped from targeted widget"
793        );
794        assert!(
795            aa_a.get(b"X").is_ok(),
796            "non-JS /AA entry must be preserved on targeted widget"
797        );
798
799        // Untargeted widget B: still references the shared indirect /AA.
800        assert_eq!(
801            widget_aa_reference(&doc, widget_b_id),
802            Some(shared_aa_id),
803            "untargeted widget keeps its shared indirect /AA reference"
804        );
805
806        // Shared indirect /AA dict: untouched, both /E and /X intact.
807        let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
808        assert!(
809            shared_aa.get(b"E").is_ok(),
810            "shared /AA dict must not be mutated in place (E key)"
811        );
812        assert!(
813            shared_aa.get(b"X").is_ok(),
814            "shared /AA dict must not be mutated in place (X key)"
815        );
816    }
817
818    #[test]
819    fn flatten_strips_js_hidden_in_next_action_chain() {
820        // M8-SEC-02 regression: a non-JS top-level action that chains to JS
821        // via /Next must be treated as a JS action and stripped on flatten.
822        // Before the fix, only the top-level /S key was checked, so a chain
823        // like /GoTo → /Next /JavaScript would survive flattening.
824        let chained_js = Object::Dictionary(dictionary! {
825            "S" => Object::Name(b"GoTo".to_vec()),
826            "D" => Object::String(b"page1".to_vec(), StringFormat::Literal),
827            "Next" => js_action(),
828        });
829        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
830            "AA" => Object::Dictionary(dictionary! {
831                "E" => chained_js,
832            }),
833        });
834        let tree = field_tree_for_widget(widget_id);
835
836        let result = flatten_form(
837            &mut doc,
838            &tree,
839            &FlattenConfig {
840                remove_acroform: false,
841                ..Default::default()
842            },
843        );
844
845        assert_eq!(result.fields_flattened, 1);
846        // The widget should have no /AA left after flattening — the chained
847        // JS action must have been detected and stripped.
848        let widget = doc
849            .get_object(widget_id)
850            .expect("widget still in doc")
851            .as_dict()
852            .expect("widget is dict");
853        assert!(
854            widget.get(b"AA").is_err(),
855            "/AA must be stripped when /Next chain contains JavaScript"
856        );
857        assert!(
858            widget.get(b"JS").is_err(),
859            "widget must not retain /JS key"
860        );
861    }
862
863    #[test]
864    fn flatten_preserves_non_js_action_without_next_chain() {
865        // Ensure plain non-JS actions without /Next are still preserved.
866        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
867            "AA" => Object::Dictionary(dictionary! {
868                "E" => uri_action(),
869            }),
870        });
871        let tree = field_tree_for_widget(widget_id);
872
873        let result = flatten_form(
874            &mut doc,
875            &tree,
876            &FlattenConfig {
877                remove_acroform: false,
878                ..Default::default()
879            },
880        );
881
882        assert_eq!(result.fields_flattened, 1);
883        // URI actions are not JavaScript — /AA should be preserved (it's the
884        // viewer's job to decide whether to execute URI actions).
885        let widget = doc
886            .get_object(widget_id)
887            .expect("widget still in doc")
888            .as_dict()
889            .expect("widget is dict");
890        // The widget annotation was removed from the page Annots, but the
891        // object itself may or may not remain — the important thing is that
892        // no JS-stripping occurred on the URI action.
893        // We verify the URI AA entry on the widget object is untouched.
894        if let Ok(aa) = widget.get(b"AA") {
895            if let Object::Dictionary(aa_dict) = aa {
896                if let Ok(Object::Dictionary(e_dict)) = aa_dict.get(b"E") {
897                    if let Ok(Object::Name(name)) = e_dict.get(b"S") {
898                        assert_ne!(name, b"JavaScript", "/E must not be JS");
899                    }
900                }
901            }
902        }
903    }
904
905    #[test]
906    fn flatten_writes_static_field_appearance_after_widget_aa_strip() {
907        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
908            "AA" => Object::Dictionary(dictionary! {
909                "E" => js_action(),
910            }),
911        });
912        let tree = field_tree_for_widget(widget_id);
913
914        let result = flatten_form(
915            &mut doc,
916            &tree,
917            &FlattenConfig {
918                remove_acroform: false,
919                ..Default::default()
920            },
921        );
922
923        assert_eq!(result.fields_flattened, 1);
924        let page_id = doc.page_iter().next().expect("page");
925        let page = doc.get_dictionary(page_id).expect("page dict");
926        let content_id = page
927            .get(b"Contents")
928            .expect("contents")
929            .as_reference()
930            .expect("contents ref");
931        let stream = doc
932            .get_object(content_id)
933            .expect("content object")
934            .as_stream()
935            .expect("content stream");
936        assert!(
937            !stream.content.is_empty(),
938            "flatten should still render static field appearance"
939        );
940    }
941}