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