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 if matches!(
366 dict.get(b"S").ok(),
367 Some(lopdf::Object::Name(name)) if name == b"JavaScript"
368 ) {
369 return true;
370 }
371 match dict.get(b"Next").ok() {
376 Some(lopdf::Object::Dictionary(next)) => is_javascript_action_dict(next),
377 Some(lopdf::Object::Array(arr)) => arr.iter().any(|obj| {
378 if let lopdf::Object::Dictionary(d) = obj {
379 is_javascript_action_dict(d)
380 } else {
381 false
382 }
383 }),
384 _ => false,
385 }
386}
387
388fn remove_acroform_dict(doc: &mut lopdf::Document) {
389 if let Ok(catalog) = doc.catalog_mut() {
390 catalog.remove(b"AcroForm");
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::flags::FieldFlags;
398 use crate::tree::{FieldNode, FieldValue};
399 use lopdf::{Dictionary, Document, Object, ObjectId, Stream, StringFormat};
400
401 fn js_action() -> Object {
402 Object::Dictionary(dictionary! {
403 "S" => Object::Name(b"JavaScript".to_vec()),
404 "JS" => Object::String(b"app.alert('blocked')".to_vec(), StringFormat::Literal),
405 })
406 }
407
408 fn uri_action() -> Object {
409 Object::Dictionary(dictionary! {
410 "S" => Object::Name(b"URI".to_vec()),
411 "URI" => Object::String(b"https://example.com".to_vec(), StringFormat::Literal),
412 })
413 }
414
415 fn widget_dict(widget_extra: Dictionary) -> Dictionary {
416 let mut widget = dictionary! {
417 "Type" => Object::Name(b"Annot".to_vec()),
418 "Subtype" => Object::Name(b"Widget".to_vec()),
419 "Rect" => Object::Array(vec![
420 Object::Integer(100),
421 Object::Integer(700),
422 Object::Integer(220),
423 Object::Integer(730),
424 ]),
425 };
426 for (key, value) in widget_extra {
427 widget.set(key, value);
428 }
429 widget
430 }
431
432 fn make_doc_with_widget(widget_extra: Dictionary) -> (Document, ObjectId) {
433 let mut doc = Document::with_version("1.4");
434 let pages_id = doc.new_object_id();
435 let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, Vec::new())));
436 let widget_id = doc.new_object_id();
437 let page_id = doc.add_object(Object::Dictionary(dictionary! {
438 "Type" => Object::Name(b"Page".to_vec()),
439 "Parent" => Object::Reference(pages_id),
440 "MediaBox" => Object::Array(vec![
441 Object::Integer(0),
442 Object::Integer(0),
443 Object::Integer(612),
444 Object::Integer(792),
445 ]),
446 "Contents" => Object::Reference(content_id),
447 "Annots" => Object::Array(vec![Object::Reference(widget_id)]),
448 }));
449 doc.objects.insert(
450 pages_id,
451 Object::Dictionary(dictionary! {
452 "Type" => Object::Name(b"Pages".to_vec()),
453 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
454 "Count" => Object::Integer(1),
455 }),
456 );
457
458 doc.objects
459 .insert(widget_id, Object::Dictionary(widget_dict(widget_extra)));
460
461 let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
462 "Type" => Object::Name(b"Catalog".to_vec()),
463 "Pages" => Object::Reference(pages_id),
464 }));
465 doc.trailer.set("Root", Object::Reference(catalog_id));
466 (doc, widget_id)
467 }
468
469 fn make_doc_with_shared_indirect_mixed_aa() -> (Document, ObjectId, ObjectId, ObjectId) {
470 let mut doc = Document::with_version("1.4");
476 let pages_id = doc.new_object_id();
477 let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, Vec::new())));
478 let shared_aa_id = doc.add_object(Object::Dictionary(dictionary! {
479 "E" => js_action(),
480 "X" => uri_action(),
481 }));
482 let widget_a_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
483 "AA" => Object::Reference(shared_aa_id),
484 })));
485 let widget_b_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
486 "AA" => Object::Reference(shared_aa_id),
487 })));
488 let page_id = doc.add_object(Object::Dictionary(dictionary! {
489 "Type" => Object::Name(b"Page".to_vec()),
490 "Parent" => Object::Reference(pages_id),
491 "MediaBox" => Object::Array(vec![
492 Object::Integer(0),
493 Object::Integer(0),
494 Object::Integer(612),
495 Object::Integer(792),
496 ]),
497 "Contents" => Object::Reference(content_id),
498 "Annots" => Object::Array(vec![
499 Object::Reference(widget_a_id),
500 Object::Reference(widget_b_id),
501 ]),
502 }));
503 doc.objects.insert(
504 pages_id,
505 Object::Dictionary(dictionary! {
506 "Type" => Object::Name(b"Pages".to_vec()),
507 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
508 "Count" => Object::Integer(1),
509 }),
510 );
511 let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
512 "Type" => Object::Name(b"Catalog".to_vec()),
513 "Pages" => Object::Reference(pages_id),
514 }));
515 doc.trailer.set("Root", Object::Reference(catalog_id));
516 (doc, widget_a_id, widget_b_id, shared_aa_id)
517 }
518
519 fn make_doc_with_shared_indirect_aa() -> (Document, ObjectId, ObjectId, ObjectId) {
520 let mut doc = Document::with_version("1.4");
521 let pages_id = doc.new_object_id();
522 let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, Vec::new())));
523 let shared_aa_id = doc.add_object(Object::Dictionary(dictionary! {
524 "E" => js_action(),
525 }));
526 let widget_a_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
527 "AA" => Object::Reference(shared_aa_id),
528 })));
529 let widget_b_id = doc.add_object(Object::Dictionary(widget_dict(dictionary! {
530 "AA" => Object::Reference(shared_aa_id),
531 })));
532 let page_id = doc.add_object(Object::Dictionary(dictionary! {
533 "Type" => Object::Name(b"Page".to_vec()),
534 "Parent" => Object::Reference(pages_id),
535 "MediaBox" => Object::Array(vec![
536 Object::Integer(0),
537 Object::Integer(0),
538 Object::Integer(612),
539 Object::Integer(792),
540 ]),
541 "Contents" => Object::Reference(content_id),
542 "Annots" => Object::Array(vec![
543 Object::Reference(widget_a_id),
544 Object::Reference(widget_b_id),
545 ]),
546 }));
547 doc.objects.insert(
548 pages_id,
549 Object::Dictionary(dictionary! {
550 "Type" => Object::Name(b"Pages".to_vec()),
551 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
552 "Count" => Object::Integer(1),
553 }),
554 );
555 let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
556 "Type" => Object::Name(b"Catalog".to_vec()),
557 "Pages" => Object::Reference(pages_id),
558 }));
559 doc.trailer.set("Root", Object::Reference(catalog_id));
560 (doc, widget_a_id, widget_b_id, shared_aa_id)
561 }
562
563 fn field_tree_for_widget(widget_id: ObjectId) -> FieldTree {
564 field_tree_for_widgets(&[("name", widget_id)])
565 }
566
567 fn field_tree_for_widgets(widgets: &[(&str, ObjectId)]) -> FieldTree {
568 let mut tree = FieldTree::new();
569 tree.document_da = Some("/Helv 12 Tf 0 g".to_string());
570 for &(name, widget_id) in widgets {
571 tree.alloc(FieldNode {
572 partial_name: name.into(),
573 alternate_name: None,
574 mapping_name: None,
575 field_type: Some(FieldType::Text),
576 flags: FieldFlags::empty(),
577 value: Some(FieldValue::Text("Ada".into())),
578 default_value: None,
579 default_appearance: Some("/Helv 12 Tf 0 g".into()),
580 quadding: None,
581 max_len: None,
582 options: vec![],
583 top_index: None,
584 rect: Some([100.0, 700.0, 220.0, 730.0]),
585 appearance_state: None,
586 on_state: None,
587 page_index: Some(0),
588 parent: None,
589 children: vec![],
590 object_id: Some((widget_id.0 as i32, widget_id.1 as i32)),
591 has_actions: true,
592 mk: None,
593 border_style: None,
594 });
595 }
596 tree
597 }
598
599 fn widget_aa_reference(doc: &Document, widget_id: ObjectId) -> Option<ObjectId> {
600 doc.get_dictionary(widget_id)
601 .ok()?
602 .get(b"AA")
603 .ok()?
604 .as_reference()
605 .ok()
606 }
607
608 #[test]
609 fn flatten_config_default() {
610 let config = FlattenConfig::default();
611 assert!(config.field_names.is_empty());
612 assert!(config.remove_acroform);
613 }
614 #[test]
615 fn flatten_empty_tree() {
616 let tree = FieldTree::new();
617 let mut doc = lopdf::Document::new();
618 let result = flatten_form(&mut doc, &tree, &FlattenConfig::default());
619 assert_eq!(result.fields_flattened, 0);
620 }
621
622 #[test]
623 fn flatten_strips_widget_javascript_additional_actions() {
624 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
625 "AA" => Object::Dictionary(dictionary! {
626 "E" => js_action(),
627 }),
628 });
629 let tree = field_tree_for_widget(widget_id);
630
631 let result = flatten_form(
632 &mut doc,
633 &tree,
634 &FlattenConfig {
635 remove_acroform: false,
636 ..Default::default()
637 },
638 );
639
640 assert_eq!(result.fields_flattened, 1);
641 let widget = doc.get_dictionary(widget_id).expect("widget dict");
642 assert!(
643 widget.get(b"AA").is_err(),
644 "JS-only widget /AA must be removed after flatten"
645 );
646 }
647
648 #[test]
649 fn flatten_preserves_widget_without_additional_actions() {
650 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {});
651 let tree = field_tree_for_widget(widget_id);
652
653 let result = flatten_form(
654 &mut doc,
655 &tree,
656 &FlattenConfig {
657 remove_acroform: false,
658 ..Default::default()
659 },
660 );
661
662 assert_eq!(result.fields_flattened, 1);
663 let widget = doc.get_dictionary(widget_id).expect("widget dict");
664 assert!(widget.get(b"AA").is_err());
665 assert!(matches!(
666 widget.get(b"Subtype"),
667 Ok(Object::Name(name)) if name == b"Widget"
668 ));
669 }
670
671 #[test]
672 fn flatten_preserves_non_javascript_additional_actions_for_later_policy() {
673 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
674 "AA" => Object::Dictionary(dictionary! {
675 "E" => js_action(),
676 "X" => uri_action(),
677 }),
678 });
679 let tree = field_tree_for_widget(widget_id);
680
681 let result = flatten_form(
682 &mut doc,
683 &tree,
684 &FlattenConfig {
685 remove_acroform: false,
686 ..Default::default()
687 },
688 );
689
690 assert_eq!(result.fields_flattened, 1);
691 let widget = doc.get_dictionary(widget_id).expect("widget dict");
692 let aa = widget
693 .get(b"AA")
694 .expect("non-JS /AA entry should remain")
695 .as_dict()
696 .expect("AA dict");
697 assert!(aa.get(b"E").is_err(), "JS /AA entry must be stripped");
698 assert!(aa.get(b"X").is_ok(), "non-JS /AA entry is out of scope");
699 }
700
701 #[test]
702 fn flatten_strips_targeted_widget_aa_without_mutating_shared_indirect_dict() {
703 let (mut doc, widget_a_id, widget_b_id, shared_aa_id) = make_doc_with_shared_indirect_aa();
704 let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
705
706 let result = flatten_form(
707 &mut doc,
708 &tree,
709 &FlattenConfig {
710 field_names: vec!["a".into()],
711 remove_acroform: false,
712 ..Default::default()
713 },
714 );
715
716 assert_eq!(result.fields_flattened, 1);
717 let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
718 assert!(widget_a.get(b"AA").is_err(), "targeted widget AA stripped");
719 assert_eq!(
720 widget_aa_reference(&doc, widget_b_id),
721 Some(shared_aa_id),
722 "untargeted widget keeps its shared AA reference"
723 );
724 let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
725 assert!(
726 shared_aa.get(b"E").is_ok(),
727 "shared AA object must not be emptied in place"
728 );
729 }
730
731 #[test]
732 fn flatten_strips_each_targeted_widget_aa_when_shared_indirect_dict_is_reused() {
733 let (mut doc, widget_a_id, widget_b_id, shared_aa_id) = make_doc_with_shared_indirect_aa();
734 let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
735
736 let result = flatten_form(
737 &mut doc,
738 &tree,
739 &FlattenConfig {
740 field_names: vec!["a".into(), "b".into()],
741 remove_acroform: false,
742 ..Default::default()
743 },
744 );
745
746 assert_eq!(result.fields_flattened, 2);
747 let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
748 let widget_b = doc.get_dictionary(widget_b_id).expect("widget B dict");
749 assert!(widget_a.get(b"AA").is_err(), "widget A AA stripped");
750 assert!(widget_b.get(b"AA").is_err(), "widget B AA stripped");
751 let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
752 assert!(
753 shared_aa.get(b"E").is_ok(),
754 "shared AA object remains intact even when all users are targeted"
755 );
756 }
757
758 #[test]
759 fn flatten_preserves_non_js_entries_on_targeted_widget_with_shared_indirect_aa() {
760 let (mut doc, widget_a_id, widget_b_id, shared_aa_id) =
768 make_doc_with_shared_indirect_mixed_aa();
769 let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
770
771 let result = flatten_form(
772 &mut doc,
773 &tree,
774 &FlattenConfig {
775 field_names: vec!["a".into()],
776 remove_acroform: false,
777 ..Default::default()
778 },
779 );
780
781 assert_eq!(result.fields_flattened, 1);
782
783 let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
786 let aa_a = widget_a
787 .get(b"AA")
788 .expect("targeted widget keeps non-JS /AA entries")
789 .as_dict()
790 .expect("widget A /AA must be an inline sanitized dict");
791 assert!(
792 aa_a.get(b"E").is_err(),
793 "JS /AA entry must be stripped from targeted widget"
794 );
795 assert!(
796 aa_a.get(b"X").is_ok(),
797 "non-JS /AA entry must be preserved on targeted widget"
798 );
799
800 assert_eq!(
802 widget_aa_reference(&doc, widget_b_id),
803 Some(shared_aa_id),
804 "untargeted widget keeps its shared indirect /AA reference"
805 );
806
807 let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
809 assert!(
810 shared_aa.get(b"E").is_ok(),
811 "shared /AA dict must not be mutated in place (E key)"
812 );
813 assert!(
814 shared_aa.get(b"X").is_ok(),
815 "shared /AA dict must not be mutated in place (X key)"
816 );
817 }
818
819 #[test]
820 fn flatten_strips_js_hidden_in_next_action_chain() {
821 let chained_js = Object::Dictionary(dictionary! {
826 "S" => Object::Name(b"GoTo".to_vec()),
827 "D" => Object::String(b"page1".to_vec(), StringFormat::Literal),
828 "Next" => js_action(),
829 });
830 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
831 "AA" => Object::Dictionary(dictionary! {
832 "E" => chained_js,
833 }),
834 });
835 let tree = field_tree_for_widget(widget_id);
836
837 let result = flatten_form(
838 &mut doc,
839 &tree,
840 &FlattenConfig {
841 remove_acroform: false,
842 ..Default::default()
843 },
844 );
845
846 assert_eq!(result.fields_flattened, 1);
847 let widget = doc
850 .get_object(widget_id)
851 .expect("widget still in doc")
852 .as_dict()
853 .expect("widget is dict");
854 assert!(
855 widget.get(b"AA").is_err(),
856 "/AA must be stripped when /Next chain contains JavaScript"
857 );
858 assert!(widget.get(b"JS").is_err(), "widget must not retain /JS key");
859 }
860
861 #[test]
862 fn flatten_preserves_non_js_action_without_next_chain() {
863 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
865 "AA" => Object::Dictionary(dictionary! {
866 "E" => uri_action(),
867 }),
868 });
869 let tree = field_tree_for_widget(widget_id);
870
871 let result = flatten_form(
872 &mut doc,
873 &tree,
874 &FlattenConfig {
875 remove_acroform: false,
876 ..Default::default()
877 },
878 );
879
880 assert_eq!(result.fields_flattened, 1);
881 let widget = doc
884 .get_object(widget_id)
885 .expect("widget still in doc")
886 .as_dict()
887 .expect("widget is dict");
888 if let Ok(Object::Dictionary(aa_dict)) = widget.get(b"AA") {
893 if let Ok(Object::Dictionary(e_dict)) = aa_dict.get(b"E") {
894 if let Ok(Object::Name(name)) = e_dict.get(b"S") {
895 assert_ne!(name, b"JavaScript", "/E must not be JS");
896 }
897 }
898 }
899 }
900
901 #[test]
902 fn flatten_writes_static_field_appearance_after_widget_aa_strip() {
903 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
904 "AA" => Object::Dictionary(dictionary! {
905 "E" => js_action(),
906 }),
907 });
908 let tree = field_tree_for_widget(widget_id);
909
910 let result = flatten_form(
911 &mut doc,
912 &tree,
913 &FlattenConfig {
914 remove_acroform: false,
915 ..Default::default()
916 },
917 );
918
919 assert_eq!(result.fields_flattened, 1);
920 let page_id = doc.page_iter().next().expect("page");
921 let page = doc.get_dictionary(page_id).expect("page dict");
922 let content_id = page
923 .get(b"Contents")
924 .expect("contents")
925 .as_reference()
926 .expect("contents ref");
927 let stream = doc
928 .get_object(content_id)
929 .expect("content object")
930 .as_stream()
931 .expect("content stream");
932 assert!(
933 !stream.content.is_empty(),
934 "flatten should still render static field appearance"
935 );
936 }
937}