Skip to main content

pdfluent_forms/
flatten.rs

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