use crate::annotation_types::{AnnotationFlags, AnnotationSubtype, WidgetFieldType};
use crate::document::PdfDocument;
use crate::error::Result;
use crate::object::Object;
#[derive(Debug, Clone)]
pub struct Annotation {
pub annotation_type: String,
pub subtype: Option<String>,
pub subtype_enum: AnnotationSubtype,
pub contents: Option<String>,
pub rect: Option<[f64; 4]>,
pub author: Option<String>,
pub creation_date: Option<String>,
pub modification_date: Option<String>,
pub subject: Option<String>,
pub destination: Option<LinkDestination>,
pub action: Option<LinkAction>,
pub quad_points: Option<Vec<[f64; 8]>>,
pub color: Option<Vec<f64>>,
pub opacity: Option<f64>,
pub flags: AnnotationFlags,
pub border: Option<[f64; 3]>,
pub interior_color: Option<Vec<f64>>,
pub field_type: Option<WidgetFieldType>,
pub field_name: Option<String>,
pub field_value: Option<String>,
pub default_value: Option<String>,
pub field_flags: Option<u32>,
pub options: Option<Vec<String>>,
pub appearance_state: Option<String>,
pub raw_dict: Option<std::collections::HashMap<String, crate::object::Object>>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum LinkDestination {
Named(String),
Explicit {
page: u32,
fit_type: String,
params: Vec<f32>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum LinkAction {
Uri(String),
GoTo(LinkDestination),
GoToRemote {
file: String,
destination: Option<LinkDestination>,
},
Other {
action_type: String,
},
}
impl PdfDocument {
pub fn get_annotations(&self, page_index: usize) -> Result<Vec<Annotation>> {
let page_ref = self.get_page_ref(page_index)?;
let page_obj = self.load_object(page_ref)?;
let annots = match page_obj.as_dict() {
Some(dict) => match dict.get("Annots") {
Some(Object::Array(arr)) => arr.clone(),
Some(Object::Reference(annot_ref)) => match self.load_object(*annot_ref)? {
Object::Array(arr) => arr,
_ => return Ok(Vec::new()),
},
_ => return Ok(Vec::new()),
},
None => return Ok(Vec::new()),
};
let mut result = Vec::new();
for annot_obj in annots {
let annot_ref = match annot_obj {
Object::Reference(r) => r,
_ => continue,
};
if let Ok(annotation) = self.parse_annotation(annot_ref) {
result.push(annotation);
}
}
Ok(result)
}
fn parse_annotation(&self, annot_ref: crate::object::ObjectRef) -> Result<Annotation> {
let annot_obj = self.load_object(annot_ref)?;
let dict = annot_obj.as_dict().ok_or_else(|| {
crate::error::Error::InvalidPdf("Annotation is not a dictionary".to_string())
})?;
let annotation_type = dict
.get("Type")
.and_then(|t| t.as_name())
.unwrap_or("Unknown")
.to_string();
let subtype = dict
.get("Subtype")
.and_then(|s| s.as_name())
.map(|s| s.to_string());
let subtype_enum = subtype
.as_deref()
.map(AnnotationSubtype::from_pdf_name)
.unwrap_or(AnnotationSubtype::Unknown);
let contents = dict.get("Contents").and_then(|c| match c {
Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
_ => None,
});
let rect = dict.get("Rect").and_then(|r| match r {
Object::Array(arr) if arr.len() == 4 => {
let mut rect_arr = [0.0; 4];
for (i, obj) in arr.iter().enumerate() {
rect_arr[i] = match obj {
Object::Integer(n) => *n as f64,
Object::Real(f) => *f,
_ => 0.0,
};
}
Some(rect_arr)
},
_ => None,
});
let author = dict.get("T").and_then(|t| match t {
Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
_ => None,
});
let creation_date = dict.get("CreationDate").and_then(|d| match d {
Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
_ => None,
});
let modification_date = dict.get("M").and_then(|d| match d {
Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
_ => None,
});
let subject = dict.get("Subj").and_then(|s| match s {
Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
_ => None,
});
let flags = dict
.get("F")
.and_then(|f| match f {
Object::Integer(n) => Some(AnnotationFlags::new(*n as u32)),
_ => None,
})
.unwrap_or_default();
let color = Self::parse_number_array(dict.get("C"));
let opacity = dict.get("CA").and_then(|o| match o {
Object::Real(f) => Some(*f),
Object::Integer(n) => Some(*n as f64),
_ => None,
});
let border = dict.get("Border").and_then(|b| match b {
Object::Array(arr) if arr.len() >= 3 => {
let mut border_arr = [0.0; 3];
for (i, obj) in arr.iter().take(3).enumerate() {
border_arr[i] = match obj {
Object::Integer(n) => *n as f64,
Object::Real(f) => *f,
_ => 0.0,
};
}
Some(border_arr)
},
_ => None,
});
let interior_color = Self::parse_number_array(dict.get("IC"));
let quad_points = if subtype_enum.is_text_markup() {
Self::parse_quad_points(dict.get("QuadPoints"))
} else {
None
};
let (destination, action) = if subtype_enum == AnnotationSubtype::Link {
let dest = dict
.get("Dest")
.and_then(|d| self.parse_destination(d).ok());
let act = dict.get("A").and_then(|a| self.parse_action(a).ok());
(dest, act)
} else {
(None, None)
};
let (
field_type,
field_name,
field_value,
default_value,
field_flags,
options,
appearance_state,
) = if subtype_enum == AnnotationSubtype::Widget {
self.parse_widget_fields(dict)
} else {
(None, None, None, None, None, None, None)
};
Ok(Annotation {
annotation_type,
subtype,
subtype_enum,
contents,
rect,
author,
creation_date,
modification_date,
subject,
destination,
action,
quad_points,
color,
opacity,
flags,
border,
interior_color,
field_type,
field_name,
field_value,
default_value,
field_flags,
options,
appearance_state,
raw_dict: Some(dict.clone()),
})
}
fn parse_widget_fields(
&self,
dict: &std::collections::HashMap<String, Object>,
) -> (
Option<WidgetFieldType>,
Option<String>,
Option<String>,
Option<String>,
Option<u32>,
Option<Vec<String>>,
Option<String>,
) {
let mut ft = dict
.get("FT")
.and_then(|f| f.as_name())
.map(|s| s.to_string());
let mut field_flags = dict.get("Ff").and_then(|f| match f {
Object::Integer(n) => Some(*n as u32),
_ => None,
});
let mut field_value = Self::parse_string_value(dict.get("V"));
let mut default_value = Self::parse_string_value(dict.get("DV"));
if ft.is_none() || field_flags.is_none() || field_value.is_none() || default_value.is_none()
{
let mut parent_ref = dict.get("Parent").and_then(|p| {
if let Object::Reference(r) = p {
Some(*r)
} else {
None
}
});
let mut depth = 0;
while let Some(pref) = parent_ref {
if depth >= 10 {
break;
}
depth += 1;
match self.load_object(pref) {
Ok(parent_obj) => {
if let Some(parent_dict) = parent_obj.as_dict() {
if ft.is_none() {
ft = parent_dict
.get("FT")
.and_then(|f| f.as_name())
.map(|s| s.to_string());
}
if field_flags.is_none() {
field_flags = parent_dict.get("Ff").and_then(|f| match f {
Object::Integer(n) => Some(*n as u32),
_ => None,
});
}
if field_value.is_none() {
field_value = Self::parse_string_value(parent_dict.get("V"));
}
if default_value.is_none() {
default_value = Self::parse_string_value(parent_dict.get("DV"));
}
parent_ref = parent_dict.get("Parent").and_then(|p| {
if let Object::Reference(r) = p {
Some(*r)
} else {
None
}
});
} else {
break;
}
},
_ => {
break;
},
}
}
}
let field_name = dict.get("T").and_then(|t| match t {
Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
_ => None,
});
let appearance_state = dict
.get("AS")
.and_then(|a| a.as_name())
.map(|s| s.to_string());
let options = Self::parse_options_array(dict.get("Opt"));
let field_type = match ft.as_deref() {
Some("Tx") => Some(WidgetFieldType::Text),
Some("Btn") => {
let ff = field_flags.unwrap_or(0);
if ff & 0x10000 != 0 {
Some(WidgetFieldType::Radio {
selected: appearance_state.clone(),
})
} else if ff & 0x8000 != 0 {
Some(WidgetFieldType::Button)
} else {
let checked = appearance_state
.as_deref()
.map(|s| s != "Off" && !s.is_empty())
.unwrap_or(false);
Some(WidgetFieldType::Checkbox { checked })
}
},
Some("Ch") => Some(WidgetFieldType::Choice {
options: options.clone().unwrap_or_default(),
selected: field_value.clone(),
}),
Some("Sig") => Some(WidgetFieldType::Signature),
_ => None,
};
(
field_type,
field_name,
field_value,
default_value,
field_flags,
options,
appearance_state,
)
}
fn parse_string_value(obj: Option<&Object>) -> Option<String> {
match obj {
Some(Object::String(s)) => Some(String::from_utf8_lossy(s).to_string()),
Some(Object::Name(n)) => Some(n.clone()),
Some(Object::Integer(i)) => Some(i.to_string()),
Some(Object::Real(f)) => Some(f.to_string()),
_ => None,
}
}
fn parse_options_array(obj: Option<&Object>) -> Option<Vec<String>> {
match obj {
Some(Object::Array(arr)) if !arr.is_empty() => {
let opts: Vec<String> = arr
.iter()
.filter_map(|o| match o {
Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
Object::Name(n) => Some(n.clone()),
Object::Array(inner) if !inner.is_empty() => {
inner.first().and_then(|first| match first {
Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
_ => None,
})
},
_ => None,
})
.collect();
if opts.is_empty() { None } else { Some(opts) }
},
_ => None,
}
}
fn parse_number_array(obj: Option<&Object>) -> Option<Vec<f64>> {
match obj {
Some(Object::Array(arr)) if !arr.is_empty() => {
let nums: Vec<f64> = arr
.iter()
.filter_map(|o| match o {
Object::Integer(n) => Some(*n as f64),
Object::Real(f) => Some(*f),
_ => None,
})
.collect();
if nums.is_empty() { None } else { Some(nums) }
},
_ => None,
}
}
fn parse_quad_points(obj: Option<&Object>) -> Option<Vec<[f64; 8]>> {
match obj {
Some(Object::Array(arr)) if arr.len() >= 8 => {
let nums: Vec<f64> = arr
.iter()
.filter_map(|o| match o {
Object::Integer(n) => Some(*n as f64),
Object::Real(f) => Some(*f),
_ => None,
})
.collect();
let quads: Vec<[f64; 8]> = nums
.as_chunks::<8>()
.0
.iter()
.map(|chunk| {
let mut quad = [0.0; 8];
quad.copy_from_slice(chunk);
quad
})
.collect();
if quads.is_empty() { None } else { Some(quads) }
},
_ => None,
}
}
fn parse_destination(&self, dest_obj: &Object) -> Result<LinkDestination> {
match dest_obj {
Object::String(s) => Ok(LinkDestination::Named(String::from_utf8_lossy(s).to_string())),
Object::Name(n) => Ok(LinkDestination::Named(n.clone())),
Object::Array(arr) if !arr.is_empty() => {
let page = match &arr[0] {
Object::Integer(n) => *n as u32,
Object::Reference(r) => {
self.page_index_of_ref(*r)? as u32
},
_ => 0,
};
let fit_type = if arr.len() > 1 {
arr[1].as_name().unwrap_or("Fit").to_string()
} else {
"Fit".to_string()
};
let params: Vec<f32> = arr
.iter()
.skip(2)
.filter_map(|obj| match obj {
Object::Integer(i) => Some(*i as f32),
Object::Real(r) => Some(*r as f32),
_ => None,
})
.collect();
Ok(LinkDestination::Explicit {
page,
fit_type,
params,
})
},
Object::Reference(r) => {
let dest_loaded = self.load_object(*r)?;
self.parse_destination(&dest_loaded)
},
_ => Err(crate::error::Error::InvalidPdf("Invalid destination format".to_string())),
}
}
fn parse_action(&self, action_obj: &Object) -> Result<LinkAction> {
let action = if let Object::Reference(r) = action_obj {
self.load_object(*r)?
} else {
action_obj.clone()
};
let dict = action.as_dict().ok_or_else(|| {
crate::error::Error::InvalidPdf("Action is not a dictionary".to_string())
})?;
let action_type = dict.get("S").and_then(|s| s.as_name()).ok_or_else(|| {
crate::error::Error::InvalidPdf("Action missing /S field".to_string())
})?;
match action_type {
"URI" => {
let uri = dict
.get("URI")
.and_then(|u| match u {
Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
_ => None,
})
.ok_or_else(|| {
crate::error::Error::InvalidPdf("URI action missing /URI field".to_string())
})?;
Ok(LinkAction::Uri(uri))
},
"GoTo" => {
let dest_obj = dict.get("D").ok_or_else(|| {
crate::error::Error::InvalidPdf("GoTo action missing /D field".to_string())
})?;
match self.parse_destination(dest_obj) {
Ok(destination) => Ok(LinkAction::GoTo(destination)),
Err(e) => {
tracing::warn!(error = %e, "GoTo destination unresolvable");
Ok(LinkAction::Other {
action_type: "GoTo".to_string(),
})
},
}
},
"GoToR" => {
let file = dict
.get("F")
.and_then(|f| match f {
Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
Object::Dictionary(d) => d.get("F").and_then(|f| match f {
Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
_ => None,
}),
_ => None,
})
.ok_or_else(|| {
crate::error::Error::InvalidPdf("GoToR action missing /F field".to_string())
})?;
let destination = dict.get("D").and_then(|d| self.parse_destination(d).ok());
Ok(LinkAction::GoToRemote { file, destination })
},
other => Ok(LinkAction::Other {
action_type: other.to_string(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_annotation_creation() {
let annot = Annotation {
annotation_type: "Annot".to_string(),
subtype: Some("Text".to_string()),
subtype_enum: AnnotationSubtype::Text,
contents: Some("This is a comment".to_string()),
rect: Some([100.0, 200.0, 150.0, 250.0]),
author: Some("John Doe".to_string()),
creation_date: Some("D:20231030120000".to_string()),
modification_date: None,
subject: Some("Review".to_string()),
destination: None,
action: None,
quad_points: None,
color: None,
opacity: None,
flags: AnnotationFlags::empty(),
border: None,
interior_color: None,
field_type: None,
field_name: None,
field_value: None,
default_value: None,
field_flags: None,
options: None,
appearance_state: None,
raw_dict: None,
};
assert_eq!(annot.annotation_type, "Annot");
assert_eq!(annot.subtype, Some("Text".to_string()));
assert_eq!(annot.subtype_enum, AnnotationSubtype::Text);
assert_eq!(annot.contents, Some("This is a comment".to_string()));
assert!(annot.rect.is_some());
}
#[test]
fn test_highlight_annotation() {
let annot = Annotation {
annotation_type: "Annot".to_string(),
subtype: Some("Highlight".to_string()),
subtype_enum: AnnotationSubtype::Highlight,
contents: Some("Highlighted text".to_string()),
rect: Some([100.0, 700.0, 200.0, 720.0]),
author: Some("Reviewer".to_string()),
creation_date: None,
modification_date: None,
subject: None,
destination: None,
action: None,
quad_points: Some(vec![[100.0, 700.0, 200.0, 700.0, 200.0, 720.0, 100.0, 720.0]]),
color: Some(vec![1.0, 1.0, 0.0]),
opacity: Some(0.5),
flags: AnnotationFlags::printable(),
border: None,
interior_color: None,
field_type: None,
field_name: None,
field_value: None,
default_value: None,
field_flags: None,
options: None,
appearance_state: None,
raw_dict: None,
};
assert!(annot.subtype_enum.is_text_markup());
assert!(annot.quad_points.is_some());
assert_eq!(annot.quad_points.as_ref().unwrap().len(), 1);
assert_eq!(annot.color, Some(vec![1.0, 1.0, 0.0]));
assert_eq!(annot.opacity, Some(0.5));
assert!(annot.flags.is_printable());
}
#[test]
fn test_parse_number_array() {
use crate::object::Object;
let arr = vec![Object::Real(1.0), Object::Real(0.5), Object::Real(0.0)];
let result = PdfDocument::parse_number_array(Some(&Object::Array(arr)));
assert_eq!(result, Some(vec![1.0, 0.5, 0.0]));
let arr2 = vec![Object::Integer(1), Object::Real(0.5)];
let result2 = PdfDocument::parse_number_array(Some(&Object::Array(arr2)));
assert_eq!(result2, Some(vec![1.0, 0.5]));
let result3 = PdfDocument::parse_number_array(None);
assert!(result3.is_none());
}
#[test]
fn test_parse_quad_points() {
use crate::object::Object;
let arr: Vec<Object> = vec![
Object::Real(100.0),
Object::Real(700.0),
Object::Real(200.0),
Object::Real(700.0),
Object::Real(200.0),
Object::Real(720.0),
Object::Real(100.0),
Object::Real(720.0),
];
let result = PdfDocument::parse_quad_points(Some(&Object::Array(arr)));
assert!(result.is_some());
let quads = result.unwrap();
assert_eq!(quads.len(), 1);
assert_eq!(quads[0][0], 100.0);
assert_eq!(quads[0][6], 100.0);
}
#[test]
fn test_widget_text_field_annotation() {
let annot = Annotation {
annotation_type: "Annot".to_string(),
subtype: Some("Widget".to_string()),
subtype_enum: AnnotationSubtype::Widget,
contents: None,
rect: Some([100.0, 700.0, 300.0, 720.0]),
author: None,
creation_date: None,
modification_date: None,
subject: None,
destination: None,
action: None,
quad_points: None,
color: None,
opacity: None,
flags: AnnotationFlags::empty(),
border: None,
interior_color: None,
field_type: Some(WidgetFieldType::Text),
field_name: Some("FirstName".to_string()),
field_value: Some("John".to_string()),
default_value: None,
field_flags: None,
options: None,
appearance_state: None,
raw_dict: None,
};
assert_eq!(annot.subtype_enum, AnnotationSubtype::Widget);
assert_eq!(annot.field_type, Some(WidgetFieldType::Text));
assert_eq!(annot.field_name, Some("FirstName".to_string()));
assert_eq!(annot.field_value, Some("John".to_string()));
}
#[test]
fn test_widget_checkbox_annotation() {
let annot = Annotation {
annotation_type: "Annot".to_string(),
subtype: Some("Widget".to_string()),
subtype_enum: AnnotationSubtype::Widget,
contents: None,
rect: Some([100.0, 600.0, 120.0, 620.0]),
author: None,
creation_date: None,
modification_date: None,
subject: None,
destination: None,
action: None,
quad_points: None,
color: None,
opacity: None,
flags: AnnotationFlags::empty(),
border: None,
interior_color: None,
field_type: Some(WidgetFieldType::Checkbox { checked: true }),
field_name: Some("AcceptTerms".to_string()),
field_value: Some("Yes".to_string()),
default_value: None,
field_flags: None,
options: None,
appearance_state: Some("Yes".to_string()),
raw_dict: None,
};
assert_eq!(annot.subtype_enum, AnnotationSubtype::Widget);
match &annot.field_type {
Some(WidgetFieldType::Checkbox { checked }) => assert!(*checked),
_ => panic!("Expected Checkbox field type"),
}
assert_eq!(annot.appearance_state, Some("Yes".to_string()));
}
#[test]
fn test_widget_choice_annotation() {
let annot = Annotation {
annotation_type: "Annot".to_string(),
subtype: Some("Widget".to_string()),
subtype_enum: AnnotationSubtype::Widget,
contents: None,
rect: Some([100.0, 500.0, 250.0, 520.0]),
author: None,
creation_date: None,
modification_date: None,
subject: None,
destination: None,
action: None,
quad_points: None,
color: None,
opacity: None,
flags: AnnotationFlags::empty(),
border: None,
interior_color: None,
field_type: Some(WidgetFieldType::Choice {
options: vec![
"Option A".to_string(),
"Option B".to_string(),
"Option C".to_string(),
],
selected: Some("Option B".to_string()),
}),
field_name: Some("Selection".to_string()),
field_value: Some("Option B".to_string()),
default_value: Some("Option A".to_string()),
field_flags: None,
options: Some(vec![
"Option A".to_string(),
"Option B".to_string(),
"Option C".to_string(),
]),
appearance_state: None,
raw_dict: None,
};
assert_eq!(annot.subtype_enum, AnnotationSubtype::Widget);
match &annot.field_type {
Some(WidgetFieldType::Choice { options, selected }) => {
assert_eq!(options.len(), 3);
assert_eq!(selected, &Some("Option B".to_string()));
},
_ => panic!("Expected Choice field type"),
}
assert_eq!(annot.options.as_ref().unwrap().len(), 3);
}
#[test]
fn test_widget_field_type_default() {
assert_eq!(WidgetFieldType::default(), WidgetFieldType::Text);
}
#[test]
fn test_parse_string_value() {
assert_eq!(
PdfDocument::parse_string_value(Some(&Object::String(b"Hello".to_vec()))),
Some("Hello".to_string())
);
assert_eq!(
PdfDocument::parse_string_value(Some(&Object::Name("MyName".to_string()))),
Some("MyName".to_string())
);
assert_eq!(
PdfDocument::parse_string_value(Some(&Object::Integer(42))),
Some("42".to_string())
);
assert_eq!(PdfDocument::parse_string_value(None), None);
}
#[test]
fn test_parse_options_array() {
let arr = vec![
Object::String(b"Option 1".to_vec()),
Object::String(b"Option 2".to_vec()),
];
let result = PdfDocument::parse_options_array(Some(&Object::Array(arr)));
assert!(result.is_some());
let opts = result.unwrap();
assert_eq!(opts.len(), 2);
assert_eq!(opts[0], "Option 1");
assert_eq!(opts[1], "Option 2");
let empty: Vec<Object> = vec![];
assert!(PdfDocument::parse_options_array(Some(&Object::Array(empty))).is_none());
assert!(PdfDocument::parse_options_array(None).is_none());
}
}