1use crate::appearance::generate_appearance;
4use crate::tree::{FieldId, FieldTree, FieldType};
5use lopdf::dictionary;
6
7#[derive(Debug, Clone)]
9pub struct FlattenConfig {
10 pub field_names: Vec<String>,
12 pub remove_acroform: bool,
14 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#[derive(Debug)]
30pub struct FlattenResult {
31 pub fields_flattened: usize,
33 pub skipped: Vec<String>,
35}
36
37pub 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 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 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 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 (js, None)
285 } else {
286 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 dict.remove(b"AA");
308 }
309 Some(s) => {
310 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 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 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 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 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 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}