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    matches!(
365        dict.get(b"S").ok(),
366        Some(lopdf::Object::Name(name)) if name == b"JavaScript"
367    )
368}
369
370fn remove_acroform_dict(doc: &mut lopdf::Document) {
371    if let Ok(catalog) = doc.catalog_mut() {
372        catalog.remove(b"AcroForm");
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::flags::FieldFlags;
380    use crate::tree::{FieldNode, FieldValue};
381    use lopdf::{Dictionary, Document, Object, ObjectId, Stream, StringFormat};
382
383    fn js_action() -> Object {
384        Object::Dictionary(dictionary! {
385            "S" => Object::Name(b"JavaScript".to_vec()),
386            "JS" => Object::String(b"app.alert('blocked')".to_vec(), StringFormat::Literal),
387        })
388    }
389
390    fn uri_action() -> Object {
391        Object::Dictionary(dictionary! {
392            "S" => Object::Name(b"URI".to_vec()),
393            "URI" => Object::String(b"https://example.com".to_vec(), StringFormat::Literal),
394        })
395    }
396
397    fn widget_dict(widget_extra: Dictionary) -> Dictionary {
398        let mut widget = dictionary! {
399            "Type" => Object::Name(b"Annot".to_vec()),
400            "Subtype" => Object::Name(b"Widget".to_vec()),
401            "Rect" => Object::Array(vec![
402                Object::Integer(100),
403                Object::Integer(700),
404                Object::Integer(220),
405                Object::Integer(730),
406            ]),
407        };
408        for (key, value) in widget_extra {
409            widget.set(key, value);
410        }
411        widget
412    }
413
414    fn make_doc_with_widget(widget_extra: Dictionary) -> (Document, ObjectId) {
415        let mut doc = Document::with_version("1.4");
416        let pages_id = doc.new_object_id();
417        let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, Vec::new())));
418        let widget_id = doc.new_object_id();
419        let page_id = doc.add_object(Object::Dictionary(dictionary! {
420            "Type" => Object::Name(b"Page".to_vec()),
421            "Parent" => Object::Reference(pages_id),
422            "MediaBox" => Object::Array(vec![
423                Object::Integer(0),
424                Object::Integer(0),
425                Object::Integer(612),
426                Object::Integer(792),
427            ]),
428            "Contents" => Object::Reference(content_id),
429            "Annots" => Object::Array(vec![Object::Reference(widget_id)]),
430        }));
431        doc.objects.insert(
432            pages_id,
433            Object::Dictionary(dictionary! {
434                "Type" => Object::Name(b"Pages".to_vec()),
435                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
436                "Count" => Object::Integer(1),
437            }),
438        );
439
440        doc.objects
441            .insert(widget_id, Object::Dictionary(widget_dict(widget_extra)));
442
443        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
444            "Type" => Object::Name(b"Catalog".to_vec()),
445            "Pages" => Object::Reference(pages_id),
446        }));
447        doc.trailer.set("Root", Object::Reference(catalog_id));
448        (doc, widget_id)
449    }
450
451    fn make_doc_with_shared_indirect_mixed_aa() -> (Document, ObjectId, ObjectId, ObjectId) {
452        // Build a doc where two widgets share the same indirect /AA dict, and
453        // that dict carries one JS-bearing entry (/E) plus one non-JS entry
454        // (/X with a URI action). Used to exercise the clone-on-write path
455        // where the targeted widget should keep /X but lose /E, and the
456        // shared indirect dict must remain intact.
457        let mut doc = Document::with_version("1.4");
458        let pages_id = doc.new_object_id();
459        let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, Vec::new())));
460        let shared_aa_id = doc.add_object(Object::Dictionary(dictionary! {
461            "E" => js_action(),
462            "X" => uri_action(),
463        }));
464        let widget_a_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
465            "AA" => Object::Reference(shared_aa_id),
466        })));
467        let widget_b_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
468            "AA" => Object::Reference(shared_aa_id),
469        })));
470        let page_id = doc.add_object(Object::Dictionary(dictionary! {
471            "Type" => Object::Name(b"Page".to_vec()),
472            "Parent" => Object::Reference(pages_id),
473            "MediaBox" => Object::Array(vec![
474                Object::Integer(0),
475                Object::Integer(0),
476                Object::Integer(612),
477                Object::Integer(792),
478            ]),
479            "Contents" => Object::Reference(content_id),
480            "Annots" => Object::Array(vec![
481                Object::Reference(widget_a_id),
482                Object::Reference(widget_b_id),
483            ]),
484        }));
485        doc.objects.insert(
486            pages_id,
487            Object::Dictionary(dictionary! {
488                "Type" => Object::Name(b"Pages".to_vec()),
489                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
490                "Count" => Object::Integer(1),
491            }),
492        );
493        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
494            "Type" => Object::Name(b"Catalog".to_vec()),
495            "Pages" => Object::Reference(pages_id),
496        }));
497        doc.trailer.set("Root", Object::Reference(catalog_id));
498        (doc, widget_a_id, widget_b_id, shared_aa_id)
499    }
500
501    fn make_doc_with_shared_indirect_aa() -> (Document, ObjectId, ObjectId, ObjectId) {
502        let mut doc = Document::with_version("1.4");
503        let pages_id = doc.new_object_id();
504        let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, Vec::new())));
505        let shared_aa_id = doc.add_object(Object::Dictionary(dictionary! {
506            "E" => js_action(),
507        }));
508        let widget_a_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
509            "AA" => Object::Reference(shared_aa_id),
510        })));
511        let widget_b_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
512            "AA" => Object::Reference(shared_aa_id),
513        })));
514        let page_id = doc.add_object(Object::Dictionary(dictionary! {
515            "Type" => Object::Name(b"Page".to_vec()),
516            "Parent" => Object::Reference(pages_id),
517            "MediaBox" => Object::Array(vec![
518                Object::Integer(0),
519                Object::Integer(0),
520                Object::Integer(612),
521                Object::Integer(792),
522            ]),
523            "Contents" => Object::Reference(content_id),
524            "Annots" => Object::Array(vec![
525                Object::Reference(widget_a_id),
526                Object::Reference(widget_b_id),
527            ]),
528        }));
529        doc.objects.insert(
530            pages_id,
531            Object::Dictionary(dictionary! {
532                "Type" => Object::Name(b"Pages".to_vec()),
533                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
534                "Count" => Object::Integer(1),
535            }),
536        );
537        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
538            "Type" => Object::Name(b"Catalog".to_vec()),
539            "Pages" => Object::Reference(pages_id),
540        }));
541        doc.trailer.set("Root", Object::Reference(catalog_id));
542        (doc, widget_a_id, widget_b_id, shared_aa_id)
543    }
544
545    fn field_tree_for_widget(widget_id: ObjectId) -> FieldTree {
546        field_tree_for_widgets(&[("name", widget_id)])
547    }
548
549    fn field_tree_for_widgets(widgets: &[(&str, ObjectId)]) -> FieldTree {
550        let mut tree = FieldTree::new();
551        tree.document_da = Some("/Helv 12 Tf 0 g".to_string());
552        for &(name, widget_id) in widgets {
553            tree.alloc(FieldNode {
554                partial_name: name.into(),
555                alternate_name: None,
556                mapping_name: None,
557                field_type: Some(FieldType::Text),
558                flags: FieldFlags::empty(),
559                value: Some(FieldValue::Text("Ada".into())),
560                default_value: None,
561                default_appearance: Some("/Helv 12 Tf 0 g".into()),
562                quadding: None,
563                max_len: None,
564                options: vec![],
565                top_index: None,
566                rect: Some([100.0, 700.0, 220.0, 730.0]),
567                appearance_state: None,
568                page_index: Some(0),
569                parent: None,
570                children: vec![],
571                object_id: Some((widget_id.0 as i32, widget_id.1 as i32)),
572                has_actions: true,
573                mk: None,
574                border_style: None,
575            });
576        }
577        tree
578    }
579
580    fn widget_aa_reference(doc: &Document, widget_id: ObjectId) -> Option<ObjectId> {
581        doc.get_dictionary(widget_id)
582            .ok()?
583            .get(b"AA")
584            .ok()?
585            .as_reference()
586            .ok()
587    }
588
589    #[test]
590    fn flatten_config_default() {
591        let config = FlattenConfig::default();
592        assert!(config.field_names.is_empty());
593        assert!(config.remove_acroform);
594    }
595    #[test]
596    fn flatten_empty_tree() {
597        let tree = FieldTree::new();
598        let mut doc = lopdf::Document::new();
599        let result = flatten_form(&mut doc, &tree, &FlattenConfig::default());
600        assert_eq!(result.fields_flattened, 0);
601    }
602
603    #[test]
604    fn flatten_strips_widget_javascript_additional_actions() {
605        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
606            "AA" => Object::Dictionary(dictionary! {
607                "E" => js_action(),
608            }),
609        });
610        let tree = field_tree_for_widget(widget_id);
611
612        let result = flatten_form(
613            &mut doc,
614            &tree,
615            &FlattenConfig {
616                remove_acroform: false,
617                ..Default::default()
618            },
619        );
620
621        assert_eq!(result.fields_flattened, 1);
622        let widget = doc.get_dictionary(widget_id).expect("widget dict");
623        assert!(
624            widget.get(b"AA").is_err(),
625            "JS-only widget /AA must be removed after flatten"
626        );
627    }
628
629    #[test]
630    fn flatten_preserves_widget_without_additional_actions() {
631        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {});
632        let tree = field_tree_for_widget(widget_id);
633
634        let result = flatten_form(
635            &mut doc,
636            &tree,
637            &FlattenConfig {
638                remove_acroform: false,
639                ..Default::default()
640            },
641        );
642
643        assert_eq!(result.fields_flattened, 1);
644        let widget = doc.get_dictionary(widget_id).expect("widget dict");
645        assert!(widget.get(b"AA").is_err());
646        assert!(matches!(
647            widget.get(b"Subtype"),
648            Ok(Object::Name(name)) if name == b"Widget"
649        ));
650    }
651
652    #[test]
653    fn flatten_preserves_non_javascript_additional_actions_for_later_policy() {
654        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
655            "AA" => Object::Dictionary(dictionary! {
656                "E" => js_action(),
657                "X" => uri_action(),
658            }),
659        });
660        let tree = field_tree_for_widget(widget_id);
661
662        let result = flatten_form(
663            &mut doc,
664            &tree,
665            &FlattenConfig {
666                remove_acroform: false,
667                ..Default::default()
668            },
669        );
670
671        assert_eq!(result.fields_flattened, 1);
672        let widget = doc.get_dictionary(widget_id).expect("widget dict");
673        let aa = widget
674            .get(b"AA")
675            .expect("non-JS /AA entry should remain")
676            .as_dict()
677            .expect("AA dict");
678        assert!(aa.get(b"E").is_err(), "JS /AA entry must be stripped");
679        assert!(aa.get(b"X").is_ok(), "non-JS /AA entry is out of scope");
680    }
681
682    #[test]
683    fn flatten_strips_targeted_widget_aa_without_mutating_shared_indirect_dict() {
684        let (mut doc, widget_a_id, widget_b_id, shared_aa_id) = make_doc_with_shared_indirect_aa();
685        let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
686
687        let result = flatten_form(
688            &mut doc,
689            &tree,
690            &FlattenConfig {
691                field_names: vec!["a".into()],
692                remove_acroform: false,
693                ..Default::default()
694            },
695        );
696
697        assert_eq!(result.fields_flattened, 1);
698        let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
699        assert!(widget_a.get(b"AA").is_err(), "targeted widget AA stripped");
700        assert_eq!(
701            widget_aa_reference(&doc, widget_b_id),
702            Some(shared_aa_id),
703            "untargeted widget keeps its shared AA reference"
704        );
705        let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
706        assert!(
707            shared_aa.get(b"E").is_ok(),
708            "shared AA object must not be emptied in place"
709        );
710    }
711
712    #[test]
713    fn flatten_strips_each_targeted_widget_aa_when_shared_indirect_dict_is_reused() {
714        let (mut doc, widget_a_id, widget_b_id, shared_aa_id) = make_doc_with_shared_indirect_aa();
715        let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
716
717        let result = flatten_form(
718            &mut doc,
719            &tree,
720            &FlattenConfig {
721                field_names: vec!["a".into(), "b".into()],
722                remove_acroform: false,
723                ..Default::default()
724            },
725        );
726
727        assert_eq!(result.fields_flattened, 2);
728        let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
729        let widget_b = doc.get_dictionary(widget_b_id).expect("widget B dict");
730        assert!(widget_a.get(b"AA").is_err(), "widget A AA stripped");
731        assert!(widget_b.get(b"AA").is_err(), "widget B AA stripped");
732        let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
733        assert!(
734            shared_aa.get(b"E").is_ok(),
735            "shared AA object remains intact even when all users are targeted"
736        );
737    }
738
739    #[test]
740    fn flatten_preserves_non_js_entries_on_targeted_widget_with_shared_indirect_aa() {
741        // Codex P2 regression-guard (PR #1373 follow-up): when /AA is an
742        // indirect dict shared between widgets and contains a mix of JS and
743        // non-JS entries, flattening the targeted widget must:
744        //   - drop the JS entry (/E) from that widget's effective /AA
745        //   - keep the non-JS entry (/X URI) on the targeted widget
746        //   - leave the untargeted widget's reference to the shared dict intact
747        //   - never mutate the shared indirect dict in place
748        let (mut doc, widget_a_id, widget_b_id, shared_aa_id) =
749            make_doc_with_shared_indirect_mixed_aa();
750        let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
751
752        let result = flatten_form(
753            &mut doc,
754            &tree,
755            &FlattenConfig {
756                field_names: vec!["a".into()],
757                remove_acroform: false,
758                ..Default::default()
759            },
760        );
761
762        assert_eq!(result.fields_flattened, 1);
763
764        // Targeted widget A: /AA must now be an inline dict that contains /X
765        // but not /E (clone-on-write).
766        let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
767        let aa_a = widget_a
768            .get(b"AA")
769            .expect("targeted widget keeps non-JS /AA entries")
770            .as_dict()
771            .expect("widget A /AA must be an inline sanitized dict");
772        assert!(
773            aa_a.get(b"E").is_err(),
774            "JS /AA entry must be stripped from targeted widget"
775        );
776        assert!(
777            aa_a.get(b"X").is_ok(),
778            "non-JS /AA entry must be preserved on targeted widget"
779        );
780
781        // Untargeted widget B: still references the shared indirect /AA.
782        assert_eq!(
783            widget_aa_reference(&doc, widget_b_id),
784            Some(shared_aa_id),
785            "untargeted widget keeps its shared indirect /AA reference"
786        );
787
788        // Shared indirect /AA dict: untouched, both /E and /X intact.
789        let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
790        assert!(
791            shared_aa.get(b"E").is_ok(),
792            "shared /AA dict must not be mutated in place (E key)"
793        );
794        assert!(
795            shared_aa.get(b"X").is_ok(),
796            "shared /AA dict must not be mutated in place (X key)"
797        );
798    }
799
800    #[test]
801    fn flatten_writes_static_field_appearance_after_widget_aa_strip() {
802        let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
803            "AA" => Object::Dictionary(dictionary! {
804                "E" => js_action(),
805            }),
806        });
807        let tree = field_tree_for_widget(widget_id);
808
809        let result = flatten_form(
810            &mut doc,
811            &tree,
812            &FlattenConfig {
813                remove_acroform: false,
814                ..Default::default()
815            },
816        );
817
818        assert_eq!(result.fields_flattened, 1);
819        let page_id = doc.page_iter().next().expect("page");
820        let page = doc.get_dictionary(page_id).expect("page dict");
821        let content_id = page
822            .get(b"Contents")
823            .expect("contents")
824            .as_reference()
825            .expect("contents ref");
826        let stream = doc
827            .get_object(content_id)
828            .expect("content object")
829            .as_stream()
830            .expect("content stream");
831        assert!(
832            !stream.content.is_empty(),
833            "flatten should still render static field appearance"
834        );
835    }
836}