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