1use std::collections::HashSet;
29
30use crate::diagnostics::{LocationHint, ParsePhase, Severity, WarningSink};
31use crate::metadata::pdf_string_to_rust_pub;
32use crate::objects::{PdfDict, PdfObj};
33use crate::resolver::Resolver;
34
35const MAX_FIELD_DEPTH: u32 = 32;
39
40const MAX_FORM_FIELDS: usize = 100_000;
42
43#[derive(Debug, Clone, Default)]
45pub struct FormCatalog {
46 pub fields: Vec<FormField>,
48 pub need_appearances: bool,
51 pub sig_flags: SigFlags,
53 pub calculation_order: Vec<String>,
56 pub default_appearance: Option<String>,
59 pub quadding: u8,
61 pub has_xfa: bool,
65}
66
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69pub struct SigFlags {
70 pub signatures_exist: bool,
72 pub append_only: bool,
75}
76
77impl SigFlags {
78 fn from_bits(bits: i64) -> Self {
79 Self {
80 signatures_exist: bits & 0x01 != 0,
81 append_only: bits & 0x02 != 0,
82 }
83 }
84}
85
86#[derive(Debug, Clone)]
88pub struct FormField {
89 pub name: String,
92 pub partial_name: String,
94 pub alternate_name: Option<String>,
96 pub mapping_name: Option<String>,
98 pub flags: FieldFlags,
100 pub kind: FieldKind,
103 pub value: FieldValue,
105 pub default_value: FieldValue,
107 pub widget_obj_nums: Vec<u32>,
113 pub children: Vec<FormField>,
116 pub has_additional_actions: bool,
119}
120
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
123pub struct FieldFlags {
124 pub read_only: bool,
126 pub required: bool,
128 pub no_export: bool,
130}
131
132impl FieldFlags {
133 fn from_bits(bits: i64) -> Self {
134 Self {
135 read_only: bits & 0x0000_0001 != 0,
136 required: bits & 0x0000_0002 != 0,
137 no_export: bits & 0x0000_0004 != 0,
138 }
139 }
140}
141
142#[derive(Debug, Clone)]
144#[non_exhaustive]
145pub enum FieldKind {
146 Button(ButtonField),
148 Text(TextField),
150 Choice(ChoiceField),
152 Signature(SignatureField),
154 Container,
156 Other { ft: String },
159}
160
161#[derive(Debug, Clone, Default)]
163pub struct ButtonField {
164 pub button_type: ButtonType,
165 pub options: Vec<String>,
168 pub no_toggle_to_off: bool,
170 pub is_radio: bool,
172 pub is_pushbutton: bool,
174 pub radios_in_unison: bool,
176}
177
178#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
180#[non_exhaustive]
181pub enum ButtonType {
182 #[default]
184 Checkbox,
185 Radio,
186 Pushbutton,
187}
188
189#[derive(Debug, Clone, Default)]
191pub struct TextField {
192 pub max_length: Option<u32>,
194 pub multiline: bool,
196 pub password: bool,
198 pub file_select: bool,
200 pub do_not_spell_check: bool,
202 pub do_not_scroll: bool,
204 pub comb: bool,
206 pub rich_text: bool,
208 pub default_appearance: Option<String>,
210 pub quadding: Option<u8>,
212 pub rich_value: Option<String>,
214}
215
216#[derive(Debug, Clone, Default)]
218pub struct ChoiceField {
219 pub options: Vec<ChoiceOption>,
221 pub top_index: u32,
223 pub selected_indices: Vec<u32>,
225 pub combo: bool,
227 pub edit: bool,
229 pub sort: bool,
231 pub multi_select: bool,
233 pub do_not_spell_check: bool,
235 pub commit_on_sel_change: bool,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct ChoiceOption {
242 pub export: String,
245 pub display: String,
247}
248
249#[derive(Debug, Clone, Default)]
251pub struct SignatureField {
252 pub has_lock: bool,
254 pub has_seed_value: bool,
256}
257
258#[derive(Debug, Clone, Default, PartialEq)]
262#[non_exhaustive]
263pub enum FieldValue {
264 #[default]
266 None,
267 Text(String),
269 Name(String),
271 Array(Vec<String>),
273 Bool(bool),
275 Integer(i64),
277}
278
279impl FieldValue {
280 fn from_pdf_object(obj: &PdfObj) -> FieldValue {
281 match obj {
282 PdfObj::Null => FieldValue::None,
283 PdfObj::Bool(b) => FieldValue::Bool(*b),
284 PdfObj::Int(n) => FieldValue::Integer(*n),
285 PdfObj::Real(r) => FieldValue::Integer(*r as i64),
286 PdfObj::Name(n) => FieldValue::Name(String::from_utf8_lossy(n).into_owned()),
287 PdfObj::Str(s) => FieldValue::Text(crate::metadata::decode_pdf_text_string_pub(s)),
288 PdfObj::Array(arr) => {
289 let strings: Vec<String> = arr
290 .iter()
291 .filter_map(|o| match o {
292 PdfObj::Str(s) => Some(crate::metadata::decode_pdf_text_string_pub(s)),
293 PdfObj::Name(n) => Some(String::from_utf8_lossy(n).into_owned()),
294 _ => None,
295 })
296 .collect();
297 if strings.is_empty() {
298 FieldValue::None
299 } else {
300 FieldValue::Array(strings)
301 }
302 }
303 _ => FieldValue::None,
304 }
305 }
306}
307
308pub fn parse_acroform(resolver: &Resolver, sink: &WarningSink) -> Option<FormCatalog> {
317 let catalog = catalog_dict(resolver)?;
318 let acroform_obj = catalog.get(b"AcroForm")?;
319 let acroform = resolver.deref(acroform_obj).ok()?;
320 let dict = acroform.as_dict()?;
321
322 let mut form = FormCatalog {
323 need_appearances: dict
324 .get(b"NeedAppearances")
325 .and_then(as_bool)
326 .unwrap_or(false),
327 sig_flags: SigFlags::from_bits(dict.get_int(b"SigFlags").unwrap_or(0)),
328 default_appearance: dict.get(b"DA").and_then(pdf_string_to_rust_pub),
329 quadding: dict.get_int(b"Q").unwrap_or(0).clamp(0, 2) as u8,
330 has_xfa: dict.get(b"XFA").is_some(),
331 ..Default::default()
332 };
333
334 if let Some(co_arr) = dict.get_array(b"CO") {
335 form.calculation_order = co_arr
336 .iter()
337 .filter_map(|o| match o {
338 PdfObj::Str(s) => Some(crate::metadata::decode_pdf_text_string_pub(s)),
339 _ => None,
340 })
341 .collect();
342 }
343
344 if let Some(fields_arr) = dict.get_array(b"Fields") {
345 let mut visited = HashSet::new();
346 let mut total_fields = 0usize;
347 let mut roots = Vec::with_capacity(fields_arr.len());
348 for field_obj in fields_arr {
349 if let Some(field) = walk_field(
350 resolver,
351 field_obj,
352 "",
353 &FieldDefaults::from_form(&form),
354 &mut visited,
355 &mut total_fields,
356 0,
357 sink,
358 ) {
359 roots.push(field);
360 }
361 }
362 form.fields = roots;
363 }
364
365 Some(form)
366}
367
368#[derive(Clone, Default)]
373struct FieldDefaults {
374 da: Option<String>,
375 q: u8,
376}
377
378impl FieldDefaults {
379 fn from_form(form: &FormCatalog) -> Self {
380 Self {
381 da: form.default_appearance.clone(),
382 q: form.quadding,
383 }
384 }
385
386 fn merge(&self, dict: &PdfDict) -> Self {
387 Self {
388 da: dict
389 .get(b"DA")
390 .and_then(pdf_string_to_rust_pub)
391 .or_else(|| self.da.clone()),
392 q: dict
393 .get_int(b"Q")
394 .map(|n| n.clamp(0, 2) as u8)
395 .unwrap_or(self.q),
396 }
397 }
398}
399
400#[allow(clippy::too_many_arguments)] fn walk_field(
402 resolver: &Resolver,
403 field_obj: &PdfObj,
404 parent_qualified_name: &str,
405 parent_defaults: &FieldDefaults,
406 visited: &mut HashSet<u32>,
407 total_fields: &mut usize,
408 depth: u32,
409 sink: &WarningSink,
410) -> Option<FormField> {
411 if depth >= MAX_FIELD_DEPTH {
412 sink.record(
413 ParsePhase::Form,
414 Some(LocationHint::FieldName(parent_qualified_name.to_string())),
415 Severity::Error,
416 format!(
417 "form-field depth limit {MAX_FIELD_DEPTH} reached; \
418 deeper sub-fields dropped"
419 ),
420 );
421 return None;
422 }
423 if *total_fields >= MAX_FORM_FIELDS {
424 sink.record(
425 ParsePhase::Form,
426 None,
427 Severity::Error,
428 format!(
429 "form-field count limit {MAX_FORM_FIELDS} reached; \
430 remaining fields dropped"
431 ),
432 );
433 return None;
434 }
435 let obj_num = field_obj.as_ref().map(|(n, _)| n);
436 if let Some(n) = obj_num
437 && !visited.insert(n)
438 {
439 sink.record(
440 ParsePhase::Form,
441 Some(LocationHint::Object {
442 obj_num: n,
443 gen_num: 0,
444 }),
445 Severity::Warning,
446 "form-field cycle detected; sub-tree truncated",
447 );
448 return None;
449 }
450
451 let resolved = resolver.deref(field_obj).ok()?;
452 let dict = resolved.as_dict()?;
453 *total_fields += 1;
454
455 let partial_name = dict
456 .get(b"T")
457 .and_then(pdf_string_to_rust_pub)
458 .unwrap_or_default();
459 let qualified_name = if parent_qualified_name.is_empty() {
460 partial_name.clone()
461 } else if partial_name.is_empty() {
462 parent_qualified_name.to_string()
463 } else {
464 format!("{parent_qualified_name}.{partial_name}")
465 };
466
467 let alternate_name = dict.get(b"TU").and_then(pdf_string_to_rust_pub);
468 let mapping_name = dict.get(b"TM").and_then(pdf_string_to_rust_pub);
469 let ff_bits = dict.get_int(b"Ff").unwrap_or(0);
470 let flags = FieldFlags::from_bits(ff_bits);
471 let has_additional_actions = dict.get(b"AA").is_some();
472
473 let value = dict
474 .get(b"V")
475 .map(FieldValue::from_pdf_object)
476 .unwrap_or_default();
477 let default_value = dict
478 .get(b"DV")
479 .map(FieldValue::from_pdf_object)
480 .unwrap_or_default();
481
482 let merged_defaults = parent_defaults.merge(dict);
483
484 let mut widget_obj_nums = Vec::new();
485 let mut children = Vec::new();
486
487 let ft = dict.get_name(b"FT").map(|n| n.to_vec());
488 let kind = match ft.as_deref() {
489 Some(b"Btn") => FieldKind::Button(parse_button(dict, ff_bits)),
490 Some(b"Tx") => FieldKind::Text(parse_text(dict, ff_bits, &merged_defaults)),
491 Some(b"Ch") => FieldKind::Choice(parse_choice(dict, ff_bits)),
492 Some(b"Sig") => FieldKind::Signature(SignatureField {
493 has_lock: dict.get(b"Lock").is_some(),
494 has_seed_value: dict.get(b"SV").is_some(),
495 }),
496 Some(other) => FieldKind::Other {
497 ft: String::from_utf8_lossy(other).into_owned(),
498 },
499 None => FieldKind::Container,
500 };
501
502 if dict.get_name(b"Subtype") == Some(b"Widget")
505 && let Some(n) = obj_num
506 {
507 widget_obj_nums.push(n);
508 }
509
510 if let Some(kids_arr) = dict.get_array(b"Kids") {
514 for kid_obj in kids_arr {
515 let Ok(kid_resolved) = resolver.deref(kid_obj) else {
516 continue;
517 };
518 let Some(kid_dict) = kid_resolved.as_dict() else {
519 continue;
520 };
521 let kid_obj_num = kid_obj.as_ref().map(|(n, _)| n);
522 let kid_is_widget = kid_dict.get_name(b"Subtype") == Some(b"Widget");
523 let kid_has_field_keys = kid_dict.get(b"T").is_some() || kid_dict.get(b"FT").is_some();
524
525 if kid_is_widget && !kid_has_field_keys {
526 if let Some(n) = kid_obj_num {
527 widget_obj_nums.push(n);
528 }
529 continue;
530 }
531
532 if let Some(child) = walk_field(
534 resolver,
535 kid_obj,
536 &qualified_name,
537 &merged_defaults,
538 visited,
539 total_fields,
540 depth + 1,
541 sink,
542 ) {
543 children.push(child);
544 }
545 }
546 }
547
548 Some(FormField {
549 name: qualified_name,
550 partial_name,
551 alternate_name,
552 mapping_name,
553 flags,
554 kind,
555 value,
556 default_value,
557 widget_obj_nums,
558 children,
559 has_additional_actions,
560 })
561}
562
563fn parse_button(dict: &PdfDict, ff: i64) -> ButtonField {
564 let no_toggle_to_off = ff & (1 << 14) != 0;
565 let is_radio = ff & (1 << 15) != 0;
566 let is_pushbutton = ff & (1 << 16) != 0;
567 let radios_in_unison = ff & (1 << 25) != 0;
568
569 let button_type = if is_pushbutton {
570 ButtonType::Pushbutton
571 } else if is_radio {
572 ButtonType::Radio
573 } else {
574 ButtonType::Checkbox
575 };
576
577 let options = dict
578 .get_array(b"Opt")
579 .map(|arr| {
580 arr.iter()
581 .filter_map(|o| match o {
582 PdfObj::Str(s) => Some(crate::metadata::decode_pdf_text_string_pub(s)),
583 PdfObj::Name(n) => Some(String::from_utf8_lossy(n).into_owned()),
584 _ => None,
585 })
586 .collect()
587 })
588 .unwrap_or_default();
589
590 ButtonField {
591 button_type,
592 options,
593 no_toggle_to_off,
594 is_radio,
595 is_pushbutton,
596 radios_in_unison,
597 }
598}
599
600fn parse_text(dict: &PdfDict, ff: i64, defaults: &FieldDefaults) -> TextField {
601 TextField {
602 max_length: dict.get_int(b"MaxLen").and_then(|n| u32::try_from(n).ok()),
603 multiline: ff & (1 << 12) != 0,
604 password: ff & (1 << 13) != 0,
605 file_select: ff & (1 << 20) != 0,
606 do_not_spell_check: ff & (1 << 22) != 0,
607 do_not_scroll: ff & (1 << 23) != 0,
608 comb: ff & (1 << 24) != 0,
609 rich_text: ff & (1 << 25) != 0,
610 default_appearance: dict
611 .get(b"DA")
612 .and_then(pdf_string_to_rust_pub)
613 .or_else(|| defaults.da.clone()),
614 quadding: dict.get_int(b"Q").map(|n| n.clamp(0, 2) as u8),
615 rich_value: dict.get(b"RV").and_then(pdf_string_to_rust_pub),
616 }
617}
618
619fn parse_choice(dict: &PdfDict, ff: i64) -> ChoiceField {
620 let options = dict
621 .get_array(b"Opt")
622 .map(|arr| {
623 arr.iter()
624 .filter_map(|o| match o {
625 PdfObj::Str(s) => {
626 let v = crate::metadata::decode_pdf_text_string_pub(s);
627 Some(ChoiceOption {
628 export: v.clone(),
629 display: v,
630 })
631 }
632 PdfObj::Name(n) => {
633 let v = String::from_utf8_lossy(n).into_owned();
634 Some(ChoiceOption {
635 export: v.clone(),
636 display: v,
637 })
638 }
639 PdfObj::Array(pair) if pair.len() == 2 => {
640 let export = match &pair[0] {
641 PdfObj::Str(s) => crate::metadata::decode_pdf_text_string_pub(s),
642 PdfObj::Name(n) => String::from_utf8_lossy(n).into_owned(),
643 _ => return None,
644 };
645 let display = match &pair[1] {
646 PdfObj::Str(s) => crate::metadata::decode_pdf_text_string_pub(s),
647 PdfObj::Name(n) => String::from_utf8_lossy(n).into_owned(),
648 _ => return None,
649 };
650 Some(ChoiceOption { export, display })
651 }
652 _ => None,
653 })
654 .collect()
655 })
656 .unwrap_or_default();
657
658 let selected_indices = dict
659 .get_array(b"I")
660 .map(|arr| {
661 arr.iter()
662 .filter_map(|o| o.as_int().and_then(|n| u32::try_from(n).ok()))
663 .collect()
664 })
665 .unwrap_or_default();
666
667 ChoiceField {
668 options,
669 top_index: dict
670 .get_int(b"TI")
671 .and_then(|n| u32::try_from(n).ok())
672 .unwrap_or(0),
673 selected_indices,
674 combo: ff & (1 << 17) != 0,
675 edit: ff & (1 << 18) != 0,
676 sort: ff & (1 << 19) != 0,
677 multi_select: ff & (1 << 21) != 0,
678 do_not_spell_check: ff & (1 << 22) != 0,
679 commit_on_sel_change: ff & (1 << 26) != 0,
680 }
681}
682
683fn as_bool(obj: &PdfObj) -> Option<bool> {
684 match obj {
685 PdfObj::Bool(b) => Some(*b),
686 _ => None,
687 }
688}
689
690fn catalog_dict(resolver: &Resolver) -> Option<PdfDict> {
691 if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
692 && let Ok(obj) = resolver.resolve(num, gen_num)
693 && let Some(dict) = obj.as_dict()
694 {
695 return Some(dict.clone());
696 }
697 crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 #[test]
705 fn field_flags_decode() {
706 let f = FieldFlags::from_bits(0x07);
707 assert!(f.read_only && f.required && f.no_export);
708 let f = FieldFlags::from_bits(0x02);
709 assert!(!f.read_only && f.required && !f.no_export);
710 }
711
712 #[test]
713 fn sig_flags_decode() {
714 let s = SigFlags::from_bits(0x03);
715 assert!(s.signatures_exist && s.append_only);
716 let s = SigFlags::from_bits(0x01);
717 assert!(s.signatures_exist && !s.append_only);
718 }
719
720 #[test]
721 fn button_type_classification() {
722 let b = parse_button(&PdfDict::new(), (1 << 15) | (1 << 16));
724 assert_eq!(b.button_type, ButtonType::Pushbutton);
725 let b = parse_button(&PdfDict::new(), 1 << 15);
727 assert_eq!(b.button_type, ButtonType::Radio);
728 let b = parse_button(&PdfDict::new(), 0);
730 assert_eq!(b.button_type, ButtonType::Checkbox);
731 }
732
733 #[test]
734 fn text_field_flags() {
735 let t = parse_text(
736 &PdfDict::new(),
737 (1 << 12) | (1 << 13),
738 &FieldDefaults::default(),
739 );
740 assert!(t.multiline && t.password);
741 assert!(!t.comb);
742 }
743
744 #[test]
745 fn choice_options_pair_form() {
746 let mut dict = PdfDict::new();
747 dict.insert(
748 b"Opt".to_vec(),
749 PdfObj::Array(vec![
750 PdfObj::Str(b"R".to_vec()),
751 PdfObj::Array(vec![
752 PdfObj::Str(b"G".to_vec()),
753 PdfObj::Str(b"Green".to_vec()),
754 ]),
755 PdfObj::Str(b"B".to_vec()),
756 ]),
757 );
758 let ch = parse_choice(&dict, 0);
759 assert_eq!(ch.options.len(), 3);
760 assert_eq!(ch.options[0].export, "R");
761 assert_eq!(ch.options[0].display, "R");
762 assert_eq!(ch.options[1].export, "G");
763 assert_eq!(ch.options[1].display, "Green");
764 assert_eq!(ch.options[2].export, "B");
765 }
766
767 #[test]
768 fn field_value_from_pdf() {
769 assert_eq!(FieldValue::from_pdf_object(&PdfObj::Null), FieldValue::None);
770 assert_eq!(
771 FieldValue::from_pdf_object(&PdfObj::Str(b"Scott".to_vec())),
772 FieldValue::Text("Scott".to_string())
773 );
774 assert_eq!(
775 FieldValue::from_pdf_object(&PdfObj::Name(b"Yes".to_vec())),
776 FieldValue::Name("Yes".to_string())
777 );
778 assert_eq!(
779 FieldValue::from_pdf_object(&PdfObj::Array(vec![
780 PdfObj::Str(b"a".to_vec()),
781 PdfObj::Str(b"b".to_vec()),
782 ])),
783 FieldValue::Array(vec!["a".to_string(), "b".to_string()])
784 );
785 }
786}