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