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 page_index: Some(0),
587 parent: None,
588 children: vec![],
589 object_id: Some((widget_id.0 as i32, widget_id.1 as i32)),
590 has_actions: true,
591 mk: None,
592 border_style: None,
593 });
594 }
595 tree
596 }
597
598 fn widget_aa_reference(doc: &Document, widget_id: ObjectId) -> Option<ObjectId> {
599 doc.get_dictionary(widget_id)
600 .ok()?
601 .get(b"AA")
602 .ok()?
603 .as_reference()
604 .ok()
605 }
606
607 #[test]
608 fn flatten_config_default() {
609 let config = FlattenConfig::default();
610 assert!(config.field_names.is_empty());
611 assert!(config.remove_acroform);
612 }
613 #[test]
614 fn flatten_empty_tree() {
615 let tree = FieldTree::new();
616 let mut doc = lopdf::Document::new();
617 let result = flatten_form(&mut doc, &tree, &FlattenConfig::default());
618 assert_eq!(result.fields_flattened, 0);
619 }
620
621 #[test]
622 fn flatten_strips_widget_javascript_additional_actions() {
623 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
624 "AA" => Object::Dictionary(dictionary! {
625 "E" => js_action(),
626 }),
627 });
628 let tree = field_tree_for_widget(widget_id);
629
630 let result = flatten_form(
631 &mut doc,
632 &tree,
633 &FlattenConfig {
634 remove_acroform: false,
635 ..Default::default()
636 },
637 );
638
639 assert_eq!(result.fields_flattened, 1);
640 let widget = doc.get_dictionary(widget_id).expect("widget dict");
641 assert!(
642 widget.get(b"AA").is_err(),
643 "JS-only widget /AA must be removed after flatten"
644 );
645 }
646
647 #[test]
648 fn flatten_preserves_widget_without_additional_actions() {
649 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {});
650 let tree = field_tree_for_widget(widget_id);
651
652 let result = flatten_form(
653 &mut doc,
654 &tree,
655 &FlattenConfig {
656 remove_acroform: false,
657 ..Default::default()
658 },
659 );
660
661 assert_eq!(result.fields_flattened, 1);
662 let widget = doc.get_dictionary(widget_id).expect("widget dict");
663 assert!(widget.get(b"AA").is_err());
664 assert!(matches!(
665 widget.get(b"Subtype"),
666 Ok(Object::Name(name)) if name == b"Widget"
667 ));
668 }
669
670 #[test]
671 fn flatten_preserves_non_javascript_additional_actions_for_later_policy() {
672 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
673 "AA" => Object::Dictionary(dictionary! {
674 "E" => js_action(),
675 "X" => uri_action(),
676 }),
677 });
678 let tree = field_tree_for_widget(widget_id);
679
680 let result = flatten_form(
681 &mut doc,
682 &tree,
683 &FlattenConfig {
684 remove_acroform: false,
685 ..Default::default()
686 },
687 );
688
689 assert_eq!(result.fields_flattened, 1);
690 let widget = doc.get_dictionary(widget_id).expect("widget dict");
691 let aa = widget
692 .get(b"AA")
693 .expect("non-JS /AA entry should remain")
694 .as_dict()
695 .expect("AA dict");
696 assert!(aa.get(b"E").is_err(), "JS /AA entry must be stripped");
697 assert!(aa.get(b"X").is_ok(), "non-JS /AA entry is out of scope");
698 }
699
700 #[test]
701 fn flatten_strips_targeted_widget_aa_without_mutating_shared_indirect_dict() {
702 let (mut doc, widget_a_id, widget_b_id, shared_aa_id) = make_doc_with_shared_indirect_aa();
703 let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
704
705 let result = flatten_form(
706 &mut doc,
707 &tree,
708 &FlattenConfig {
709 field_names: vec!["a".into()],
710 remove_acroform: false,
711 ..Default::default()
712 },
713 );
714
715 assert_eq!(result.fields_flattened, 1);
716 let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
717 assert!(widget_a.get(b"AA").is_err(), "targeted widget AA stripped");
718 assert_eq!(
719 widget_aa_reference(&doc, widget_b_id),
720 Some(shared_aa_id),
721 "untargeted widget keeps its shared AA reference"
722 );
723 let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
724 assert!(
725 shared_aa.get(b"E").is_ok(),
726 "shared AA object must not be emptied in place"
727 );
728 }
729
730 #[test]
731 fn flatten_strips_each_targeted_widget_aa_when_shared_indirect_dict_is_reused() {
732 let (mut doc, widget_a_id, widget_b_id, shared_aa_id) = make_doc_with_shared_indirect_aa();
733 let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
734
735 let result = flatten_form(
736 &mut doc,
737 &tree,
738 &FlattenConfig {
739 field_names: vec!["a".into(), "b".into()],
740 remove_acroform: false,
741 ..Default::default()
742 },
743 );
744
745 assert_eq!(result.fields_flattened, 2);
746 let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
747 let widget_b = doc.get_dictionary(widget_b_id).expect("widget B dict");
748 assert!(widget_a.get(b"AA").is_err(), "widget A AA stripped");
749 assert!(widget_b.get(b"AA").is_err(), "widget B AA stripped");
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 remains intact even when all users are targeted"
754 );
755 }
756
757 #[test]
758 fn flatten_preserves_non_js_entries_on_targeted_widget_with_shared_indirect_aa() {
759 let (mut doc, widget_a_id, widget_b_id, shared_aa_id) =
767 make_doc_with_shared_indirect_mixed_aa();
768 let tree = field_tree_for_widgets(&[("a", widget_a_id), ("b", widget_b_id)]);
769
770 let result = flatten_form(
771 &mut doc,
772 &tree,
773 &FlattenConfig {
774 field_names: vec!["a".into()],
775 remove_acroform: false,
776 ..Default::default()
777 },
778 );
779
780 assert_eq!(result.fields_flattened, 1);
781
782 let widget_a = doc.get_dictionary(widget_a_id).expect("widget A dict");
785 let aa_a = widget_a
786 .get(b"AA")
787 .expect("targeted widget keeps non-JS /AA entries")
788 .as_dict()
789 .expect("widget A /AA must be an inline sanitized dict");
790 assert!(
791 aa_a.get(b"E").is_err(),
792 "JS /AA entry must be stripped from targeted widget"
793 );
794 assert!(
795 aa_a.get(b"X").is_ok(),
796 "non-JS /AA entry must be preserved on targeted widget"
797 );
798
799 assert_eq!(
801 widget_aa_reference(&doc, widget_b_id),
802 Some(shared_aa_id),
803 "untargeted widget keeps its shared indirect /AA reference"
804 );
805
806 let shared_aa = doc.get_dictionary(shared_aa_id).expect("shared AA dict");
808 assert!(
809 shared_aa.get(b"E").is_ok(),
810 "shared /AA dict must not be mutated in place (E key)"
811 );
812 assert!(
813 shared_aa.get(b"X").is_ok(),
814 "shared /AA dict must not be mutated in place (X key)"
815 );
816 }
817
818 #[test]
819 fn flatten_strips_js_hidden_in_next_action_chain() {
820 let chained_js = Object::Dictionary(dictionary! {
825 "S" => Object::Name(b"GoTo".to_vec()),
826 "D" => Object::String(b"page1".to_vec(), StringFormat::Literal),
827 "Next" => js_action(),
828 });
829 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
830 "AA" => Object::Dictionary(dictionary! {
831 "E" => chained_js,
832 }),
833 });
834 let tree = field_tree_for_widget(widget_id);
835
836 let result = flatten_form(
837 &mut doc,
838 &tree,
839 &FlattenConfig {
840 remove_acroform: false,
841 ..Default::default()
842 },
843 );
844
845 assert_eq!(result.fields_flattened, 1);
846 let widget = doc
849 .get_object(widget_id)
850 .expect("widget still in doc")
851 .as_dict()
852 .expect("widget is dict");
853 assert!(
854 widget.get(b"AA").is_err(),
855 "/AA must be stripped when /Next chain contains JavaScript"
856 );
857 assert!(
858 widget.get(b"JS").is_err(),
859 "widget must not retain /JS key"
860 );
861 }
862
863 #[test]
864 fn flatten_preserves_non_js_action_without_next_chain() {
865 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
867 "AA" => Object::Dictionary(dictionary! {
868 "E" => uri_action(),
869 }),
870 });
871 let tree = field_tree_for_widget(widget_id);
872
873 let result = flatten_form(
874 &mut doc,
875 &tree,
876 &FlattenConfig {
877 remove_acroform: false,
878 ..Default::default()
879 },
880 );
881
882 assert_eq!(result.fields_flattened, 1);
883 let widget = doc
886 .get_object(widget_id)
887 .expect("widget still in doc")
888 .as_dict()
889 .expect("widget is dict");
890 if let Ok(aa) = widget.get(b"AA") {
895 if let Object::Dictionary(aa_dict) = aa {
896 if let Ok(Object::Dictionary(e_dict)) = aa_dict.get(b"E") {
897 if let Ok(Object::Name(name)) = e_dict.get(b"S") {
898 assert_ne!(name, b"JavaScript", "/E must not be JS");
899 }
900 }
901 }
902 }
903 }
904
905 #[test]
906 fn flatten_writes_static_field_appearance_after_widget_aa_strip() {
907 let (mut doc, widget_id) = make_doc_with_widget(dictionary! {
908 "AA" => Object::Dictionary(dictionary! {
909 "E" => js_action(),
910 }),
911 });
912 let tree = field_tree_for_widget(widget_id);
913
914 let result = flatten_form(
915 &mut doc,
916 &tree,
917 &FlattenConfig {
918 remove_acroform: false,
919 ..Default::default()
920 },
921 );
922
923 assert_eq!(result.fields_flattened, 1);
924 let page_id = doc.page_iter().next().expect("page");
925 let page = doc.get_dictionary(page_id).expect("page dict");
926 let content_id = page
927 .get(b"Contents")
928 .expect("contents")
929 .as_reference()
930 .expect("contents ref");
931 let stream = doc
932 .get_object(content_id)
933 .expect("content object")
934 .as_stream()
935 .expect("content stream");
936 assert!(
937 !stream.content.is_empty(),
938 "flatten should still render static field appearance"
939 );
940 }
941}