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                on_state: None,
587                page_index: Some(0),
588                parent: None,
589                children: vec![],
590                object_id: Some((widget_id.0 as i32, widget_id.1 as i32)),
591                has_actions: true,
592                mk: None,
593                border_style: None,
594            });
595        }
596        tree
597    }
598
599    fn widget_aa_reference(doc: &Document, widget_id: ObjectId) -> Option<ObjectId> {
600        doc.get_dictionary(widget_id)
601            .ok()?
602            .get(b"AA")
603            .ok()?
604            .as_reference()
605            .ok()
606    }
607
608    #[test]
609    fn flatten_config_default() {
610        let config = FlattenConfig::default();
611        assert!(config.field_names.is_empty());
612        assert!(config.remove_acroform);
613    }
614    #[test]
615    fn flatten_empty_tree() {
616        let tree = FieldTree::new();
617        let mut doc = lopdf::Document::new();
618        let result = flatten_form(&mut doc, &tree, &FlattenConfig::default());
619        assert_eq!(result.fields_flattened, 0);
620    }
621
622    #[test]
623    fn flatten_strips_widget_javascript_additional_actions() {
624        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
625            "AA" => Object::Dictionary(dictionary! {
626                "E" => js_action(),
627            }),
628        });
629        let tree = field_tree_for_widget(widget_id);
630
631        let result = flatten_form(
632            &mut doc,
633            &tree,
634            &FlattenConfig {
635                remove_acroform: false,
636                ..Default::default()
637            },
638        );
639
640        assert_eq!(result.fields_flattened, 1);
641        let widget = doc.get_dictionary(widget_id).expect("widget dict");
642        assert!(
643            widget.get(b"AA").is_err(),
644            "JS-only widget /AA must be removed after flatten"
645        );
646    }
647
648    #[test]
649    fn flatten_preserves_widget_without_additional_actions() {
650        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {});
651        let tree = field_tree_for_widget(widget_id);
652
653        let result = flatten_form(
654            &mut doc,
655            &tree,
656            &FlattenConfig {
657                remove_acroform: false,
658                ..Default::default()
659            },
660        );
661
662        assert_eq!(result.fields_flattened, 1);
663        let widget = doc.get_dictionary(widget_id).expect("widget dict");
664        assert!(widget.get(b"AA").is_err());
665        assert!(matches!(
666            widget.get(b"Subtype"),
667            Ok(Object::Name(name)) if name == b"Widget"
668        ));
669    }
670
671    #[test]
672    fn flatten_preserves_non_javascript_additional_actions_for_later_policy() {
673        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
674            "AA" => Object::Dictionary(dictionary! {
675                "E" => js_action(),
676                "X" => uri_action(),
677            }),
678        });
679        let tree = field_tree_for_widget(widget_id);
680
681        let result = flatten_form(
682            &mut doc,
683            &tree,
684            &FlattenConfig {
685                remove_acroform: false,
686                ..Default::default()
687            },
688        );
689
690        assert_eq!(result.fields_flattened, 1);
691        let widget = doc.get_dictionary(widget_id).expect("widget dict");
692        let aa = widget
693            .get(b"AA")
694            .expect("non-JS /AA entry should remain")
695            .as_dict()
696            .expect("AA dict");
697        assert!(aa.get(b"E").is_err(), "JS /AA entry must be stripped");
698        assert!(aa.get(b"X").is_ok(), "non-JS /AA entry is out of scope");
699    }
700
701    #[test]
702    fn flatten_strips_targeted_widget_aa_without_mutating_shared_indirect_dict() {
703        let (mut doc, widget_a_id, widget_b_id, shared_aa_id) = make_doc_with_shared_indirect_aa();
704        let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
705
706        let result = flatten_form(
707            &mut doc,
708            &tree,
709            &FlattenConfig {
710                field_names: vec!["a".into()],
711                remove_acroform: false,
712                ..Default::default()
713            },
714        );
715
716        assert_eq!(result.fields_flattened, 1);
717        let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
718        assert!(widget_a.get(b"AA").is_err(), "targeted widget AA stripped");
719        assert_eq!(
720            widget_aa_reference(&doc, widget_b_id),
721            Some(shared_aa_id),
722            "untargeted widget keeps its shared AA reference"
723        );
724        let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
725        assert!(
726            shared_aa.get(b"E").is_ok(),
727            "shared AA object must not be emptied in place"
728        );
729    }
730
731    #[test]
732    fn flatten_strips_each_targeted_widget_aa_when_shared_indirect_dict_is_reused() {
733        let (mut doc, widget_a_id, widget_b_id, shared_aa_id) = make_doc_with_shared_indirect_aa();
734        let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
735
736        let result = flatten_form(
737            &mut doc,
738            &tree,
739            &FlattenConfig {
740                field_names: vec!["a".into(), "b".into()],
741                remove_acroform: false,
742                ..Default::default()
743            },
744        );
745
746        assert_eq!(result.fields_flattened, 2);
747        let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
748        let widget_b = doc.get_dictionary(widget_b_id).expect("widget B dict");
749        assert!(widget_a.get(b"AA").is_err(), "widget A AA stripped");
750        assert!(widget_b.get(b"AA").is_err(), "widget B AA stripped");
751        let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
752        assert!(
753            shared_aa.get(b"E").is_ok(),
754            "shared AA object remains intact even when all users are targeted"
755        );
756    }
757
758    #[test]
759    fn flatten_preserves_non_js_entries_on_targeted_widget_with_shared_indirect_aa() {
760        // Codex P2 regression-guard (PR #1373 follow-up): when /AA is an
761        // indirect dict shared between widgets and contains a mix of JS and
762        // non-JS entries, flattening the targeted widget must:
763        //   - drop the JS entry (/E) from that widget's effective /AA
764        //   - keep the non-JS entry (/X URI) on the targeted widget
765        //   - leave the untargeted widget's reference to the shared dict intact
766        //   - never mutate the shared indirect dict in place
767        let (mut doc, widget_a_id, widget_b_id, shared_aa_id) =
768            make_doc_with_shared_indirect_mixed_aa();
769        let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
770
771        let result = flatten_form(
772            &mut doc,
773            &tree,
774            &FlattenConfig {
775                field_names: vec!["a".into()],
776                remove_acroform: false,
777                ..Default::default()
778            },
779        );
780
781        assert_eq!(result.fields_flattened, 1);
782
783        // Targeted widget A: /AA must now be an inline dict that contains /X
784        // but not /E (clone-on-write).
785        let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
786        let aa_a = widget_a
787            .get(b"AA")
788            .expect("targeted widget keeps non-JS /AA entries")
789            .as_dict()
790            .expect("widget A /AA must be an inline sanitized dict");
791        assert!(
792            aa_a.get(b"E").is_err(),
793            "JS /AA entry must be stripped from targeted widget"
794        );
795        assert!(
796            aa_a.get(b"X").is_ok(),
797            "non-JS /AA entry must be preserved on targeted widget"
798        );
799
800        // Untargeted widget B: still references the shared indirect /AA.
801        assert_eq!(
802            widget_aa_reference(&doc, widget_b_id),
803            Some(shared_aa_id),
804            "untargeted widget keeps its shared indirect /AA reference"
805        );
806
807        // Shared indirect /AA dict: untouched, both /E and /X intact.
808        let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
809        assert!(
810            shared_aa.get(b"E").is_ok(),
811            "shared /AA dict must not be mutated in place (E key)"
812        );
813        assert!(
814            shared_aa.get(b"X").is_ok(),
815            "shared /AA dict must not be mutated in place (X key)"
816        );
817    }
818
819    #[test]
820    fn flatten_strips_js_hidden_in_next_action_chain() {
821        // M8-SEC-02 regression: a non-JS top-level action that chains to JS
822        // via /Next must be treated as a JS action and stripped on flatten.
823        // Before the fix, only the top-level /S key was checked, so a chain
824        // like /GoTo → /Next /JavaScript would survive flattening.
825        let chained_js = Object::Dictionary(dictionary! {
826            "S" => Object::Name(b"GoTo".to_vec()),
827            "D" => Object::String(b"page1".to_vec(), StringFormat::Literal),
828            "Next" => js_action(),
829        });
830        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
831            "AA" => Object::Dictionary(dictionary! {
832                "E" => chained_js,
833            }),
834        });
835        let tree = field_tree_for_widget(widget_id);
836
837        let result = flatten_form(
838            &mut doc,
839            &tree,
840            &FlattenConfig {
841                remove_acroform: false,
842                ..Default::default()
843            },
844        );
845
846        assert_eq!(result.fields_flattened, 1);
847        // The widget should have no /AA left after flattening — the chained
848        // JS action must have been detected and stripped.
849        let widget = doc
850            .get_object(widget_id)
851            .expect("widget still in doc")
852            .as_dict()
853            .expect("widget is dict");
854        assert!(
855            widget.get(b"AA").is_err(),
856            "/AA must be stripped when /Next chain contains JavaScript"
857        );
858        assert!(widget.get(b"JS").is_err(), "widget must not retain /JS key");
859    }
860
861    #[test]
862    fn flatten_preserves_non_js_action_without_next_chain() {
863        // Ensure plain non-JS actions without /Next are still preserved.
864        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
865            "AA" => Object::Dictionary(dictionary! {
866                "E" => uri_action(),
867            }),
868        });
869        let tree = field_tree_for_widget(widget_id);
870
871        let result = flatten_form(
872            &mut doc,
873            &tree,
874            &FlattenConfig {
875                remove_acroform: false,
876                ..Default::default()
877            },
878        );
879
880        assert_eq!(result.fields_flattened, 1);
881        // URI actions are not JavaScript — /AA should be preserved (it's the
882        // viewer's job to decide whether to execute URI actions).
883        let widget = doc
884            .get_object(widget_id)
885            .expect("widget still in doc")
886            .as_dict()
887            .expect("widget is dict");
888        // The widget annotation was removed from the page Annots, but the
889        // object itself may or may not remain — the important thing is that
890        // no JS-stripping occurred on the URI action.
891        // We verify the URI AA entry on the widget object is untouched.
892        if let Ok(Object::Dictionary(aa_dict)) = widget.get(b"AA") {
893            if let Ok(Object::Dictionary(e_dict)) = aa_dict.get(b"E") {
894                if let Ok(Object::Name(name)) = e_dict.get(b"S") {
895                    assert_ne!(name, b"JavaScript", "/E must not be JS");
896                }
897            }
898        }
899    }
900
901    #[test]
902    fn flatten_writes_static_field_appearance_after_widget_aa_strip() {
903        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
904            "AA" => Object::Dictionary(dictionary! {
905                "E" => js_action(),
906            }),
907        });
908        let tree = field_tree_for_widget(widget_id);
909
910        let result = flatten_form(
911            &mut doc,
912            &tree,
913            &FlattenConfig {
914                remove_acroform: false,
915                ..Default::default()
916            },
917        );
918
919        assert_eq!(result.fields_flattened, 1);
920        let page_id = doc.page_iter().next().expect("page");
921        let page = doc.get_dictionary(page_id).expect("page dict");
922        let content_id = page
923            .get(b"Contents")
924            .expect("contents")
925            .as_reference()
926            .expect("contents ref");
927        let stream = doc
928            .get_object(content_id)
929            .expect("content object")
930            .as_stream()
931            .expect("content stream");
932        assert!(
933            !stream.content.is_empty(),
934            "flatten should still render static field appearance"
935        );
936    }
937}