use std::collections::HashMap;
use crate::error::PdfError;
use crate::objects::{Dict, Object, ObjectId};
use crate::reader::document::DocumentReader;
use crate::reader::link::PdfLinkTarget;
use crate::reader::outline::build_page_index_map;
#[derive(Debug, Clone)]
pub struct PdfAnnotation {
pub source_page_index: usize,
pub rect: [f32; 4],
pub contents: Option<String>,
pub name: Option<String>,
pub modified: Option<String>,
pub flags: u32,
pub colour: Option<Vec<f32>>,
pub border: Option<Vec<f32>>,
pub appearance: Option<AnnotationAppearance>,
pub appearance_state: Option<String>,
pub kind: AnnotationKind,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AnnotationAppearance {
pub has_normal: bool,
pub has_rollover: bool,
pub has_down: bool,
pub states: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum AnnotationKind {
Text {
open: bool,
icon: String,
state: Option<String>,
state_model: Option<String>,
},
FreeText {
default_appearance: Option<String>,
quadding: u8,
rich_content: Option<String>,
intent: Option<String>,
},
Stamp {
icon: String,
},
TextMarkup {
variant: TextMarkupVariant,
quad_points: Vec<f32>,
},
Geometry {
is_square: bool,
interior_colour: Option<Vec<f32>>,
rect_diffs: Option<[f32; 4]>,
},
Link { target: Option<PdfLinkTarget> },
Widget {
field_type: Option<String>,
field_name: Option<String>,
value: Option<String>,
},
Line {
l: [f32; 4],
line_endings: Option<[String; 2]>,
interior_colour: Option<Vec<f32>>,
leader_line: Option<f32>,
leader_line_extension: Option<f32>,
leader_line_offset: Option<f32>,
cap: bool,
intent: Option<String>,
},
PolygonOrPolyLine {
is_polygon: bool,
vertices: Vec<f32>,
line_endings: Option<[String; 2]>,
interior_colour: Option<Vec<f32>>,
intent: Option<String>,
},
Ink {
ink_list: Vec<Vec<f32>>,
},
Caret {
rect_diffs: Option<[f32; 4]>,
symbol: String,
},
Popup {
parent: Option<ObjectId>,
open: bool,
},
Watermark {
fixed_print: Option<FixedPrint>,
},
Redact {
quad_points: Option<Vec<f32>>,
interior_colour: Option<[f32; 3]>,
overlay_form: Option<ObjectId>,
overlay_text: Option<String>,
repeat: bool,
default_appearance: Option<String>,
quadding: u8,
},
FileAttachment {
icon: String,
file_name: Option<String>,
filespec: Option<ObjectId>,
},
Sound {
sound: Option<ObjectId>,
icon: String,
},
Movie {
title: Option<String>,
movie: Option<ObjectId>,
activation: MovieActivation,
},
Screen {
title: Option<String>,
appearance_chars: Option<ObjectId>,
action: Option<ObjectId>,
additional_actions: Option<ObjectId>,
},
PrinterMark {
mark_name: Option<String>,
},
TrapNet {
last_modified: Option<String>,
version: Option<Vec<ObjectId>>,
annot_states: Option<Vec<Option<String>>>,
font_fauxing: Option<Vec<ObjectId>>,
},
ThreeD {
artwork: Option<ObjectId>,
view: Option<ThreeDViewSelector>,
activation: ThreeDActivation,
interactive: bool,
view_box: Option<[f32; 4]>,
},
Other { subtype: String },
}
#[derive(Debug, Clone, PartialEq)]
pub enum ThreeDViewSelector {
View(ObjectId),
Index(i64),
Name(String),
Symbolic(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct ThreeDActivation {
pub activation_when: Option<String>,
pub artwork_state_on_activation: Option<String>,
pub deactivation_when: Option<String>,
pub artwork_state_on_deactivation: Option<String>,
pub toolbar: Option<bool>,
pub navigation_panel: Option<bool>,
}
impl Default for ThreeDActivation {
fn default() -> Self {
Self {
activation_when: None,
artwork_state_on_activation: None,
deactivation_when: None,
artwork_state_on_deactivation: None,
toolbar: None,
navigation_panel: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum MovieActivation {
Play,
Dont,
Custom(ObjectId),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextMarkupVariant {
Highlight,
Underline,
Squiggly,
StrikeOut,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FixedPrint {
pub matrix: [f32; 6],
pub h: f32,
pub v: f32,
}
impl Default for FixedPrint {
fn default() -> Self {
Self {
matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
h: 0.0,
v: 0.0,
}
}
}
pub fn annotations(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfAnnotation>, PdfError> {
let page_index_map = build_page_index_map(reader)?;
let mut pages_by_index: Vec<ObjectId> = Vec::with_capacity(page_index_map.len());
pages_by_index.resize(page_index_map.len(), ObjectId::new(0));
for (n, idx) in &page_index_map {
pages_by_index[*idx] = ObjectId::new(*n);
}
let mut out = Vec::new();
for (idx, page_id) in pages_by_index.iter().enumerate() {
if page_id.number == 0 {
continue;
}
let page = match reader.resolve(*page_id)? {
Object::Dict(d) => d,
_ => continue,
};
let annots_obj = page
.entries()
.iter()
.find(|(k, _)| k == "Annots")
.map(|(_, v)| v.clone());
let Some(annots_obj) = annots_obj else {
continue;
};
let annots_obj = reader.deref(annots_obj)?;
let Object::Array(items) = annots_obj else {
continue;
};
for item in items {
let annot = match reader.deref(item)? {
Object::Dict(d) => d,
_ => continue,
};
if let Some(parsed) = decode_annotation(reader, &annot, idx, &page_index_map)? {
out.push(parsed);
}
}
}
Ok(out)
}
fn decode_annotation(
reader: &mut DocumentReader<'_>,
annot: &Dict,
page_index: usize,
page_index_map: &HashMap<u32, usize>,
) -> Result<Option<PdfAnnotation>, PdfError> {
let rect = match find_entry(annot, "Rect") {
Some(Object::Array(items)) if items.len() == 4 => {
let mut out = [0f32; 4];
for (i, it) in items.iter().enumerate() {
out[i] = match it {
Object::Real(f) => *f as f32,
Object::Integer(n) => *n as f32,
_ => return Ok(None),
};
}
out
}
_ => return Ok(None),
};
let subtype = match find_entry(annot, "Subtype") {
Some(Object::Name(s)) => s.clone(),
_ => return Ok(None),
};
let contents = decode_text_string(find_entry(annot, "Contents"));
let name = decode_text_string(find_entry(annot, "NM"));
let modified = decode_text_string(find_entry(annot, "M"));
let flags = match find_entry(annot, "F") {
Some(Object::Integer(n)) => *n as u32,
_ => 0,
};
let colour = decode_real_array(find_entry(annot, "C"));
let border = decode_real_array(find_entry(annot, "Border"));
let appearance = decode_appearance_summary(reader, annot)?;
let appearance_state = match find_entry(annot, "AS") {
Some(Object::Name(s)) => Some(s.clone()),
_ => None,
};
let kind = match subtype.as_str() {
"Text" => AnnotationKind::Text {
open: matches!(find_entry(annot, "Open"), Some(Object::Bool(true))),
icon: match find_entry(annot, "Name") {
Some(Object::Name(s)) => s.clone(),
_ => "Note".into(),
},
state: decode_text_string(find_entry(annot, "State")),
state_model: decode_text_string(find_entry(annot, "StateModel")),
},
"FreeText" => AnnotationKind::FreeText {
default_appearance: decode_text_string(find_entry(annot, "DA")),
quadding: match find_entry(annot, "Q") {
Some(Object::Integer(n)) => (*n).clamp(0, 2) as u8,
_ => 0,
},
rich_content: decode_text_string(find_entry(annot, "RC")),
intent: match find_entry(annot, "IT") {
Some(Object::Name(s)) => Some(s.clone()),
_ => None,
},
},
"Stamp" => AnnotationKind::Stamp {
icon: match find_entry(annot, "Name") {
Some(Object::Name(s)) => s.clone(),
_ => "Draft".into(),
},
},
"Highlight" => AnnotationKind::TextMarkup {
variant: TextMarkupVariant::Highlight,
quad_points: decode_real_array(find_entry(annot, "QuadPoints")).unwrap_or_default(),
},
"Underline" => AnnotationKind::TextMarkup {
variant: TextMarkupVariant::Underline,
quad_points: decode_real_array(find_entry(annot, "QuadPoints")).unwrap_or_default(),
},
"Squiggly" => AnnotationKind::TextMarkup {
variant: TextMarkupVariant::Squiggly,
quad_points: decode_real_array(find_entry(annot, "QuadPoints")).unwrap_or_default(),
},
"StrikeOut" => AnnotationKind::TextMarkup {
variant: TextMarkupVariant::StrikeOut,
quad_points: decode_real_array(find_entry(annot, "QuadPoints")).unwrap_or_default(),
},
"Square" | "Circle" => AnnotationKind::Geometry {
is_square: subtype == "Square",
interior_colour: decode_real_array(find_entry(annot, "IC")),
rect_diffs: decode_rect_diffs(find_entry(annot, "RD")),
},
"Link" => AnnotationKind::Link {
target: decode_link_target(reader, annot, page_index_map)?,
},
"Widget" => AnnotationKind::Widget {
field_type: match find_entry(annot, "FT") {
Some(Object::Name(s)) => Some(s.clone()),
_ => None,
},
field_name: decode_text_string(find_entry(annot, "T")),
value: decode_field_value(find_entry(annot, "V")),
},
"Line" => {
let l = decode_rect_diffs(find_entry(annot, "L")).unwrap_or([0.0; 4]);
AnnotationKind::Line {
l,
line_endings: decode_two_name_array(find_entry(annot, "LE")),
interior_colour: decode_real_array(find_entry(annot, "IC")),
leader_line: decode_real(find_entry(annot, "LL")),
leader_line_extension: decode_real(find_entry(annot, "LLE")),
leader_line_offset: decode_real(find_entry(annot, "LLO")),
cap: matches!(find_entry(annot, "Cap"), Some(Object::Bool(true))),
intent: match find_entry(annot, "IT") {
Some(Object::Name(s)) => Some(s.clone()),
_ => None,
},
}
}
"Polygon" | "PolyLine" => AnnotationKind::PolygonOrPolyLine {
is_polygon: subtype == "Polygon",
vertices: decode_real_array(find_entry(annot, "Vertices")).unwrap_or_default(),
line_endings: decode_two_name_array(find_entry(annot, "LE")),
interior_colour: decode_real_array(find_entry(annot, "IC")),
intent: match find_entry(annot, "IT") {
Some(Object::Name(s)) => Some(s.clone()),
_ => None,
},
},
"Ink" => AnnotationKind::Ink {
ink_list: decode_ink_list(find_entry(annot, "InkList")),
},
"Caret" => AnnotationKind::Caret {
rect_diffs: decode_rect_diffs(find_entry(annot, "RD")),
symbol: match find_entry(annot, "Sy") {
Some(Object::Name(s)) => s.clone(),
_ => "None".to_string(),
},
},
"Popup" => AnnotationKind::Popup {
parent: match find_entry(annot, "Parent") {
Some(Object::Reference(id)) => Some(*id),
_ => None,
},
open: matches!(find_entry(annot, "Open"), Some(Object::Bool(true))),
},
"Watermark" => {
let fixed_print = match find_entry(annot, "FixedPrint").cloned() {
Some(o) => {
let resolved = reader.deref(o)?;
decode_fixed_print(&resolved)
}
None => None,
};
AnnotationKind::Watermark { fixed_print }
}
"Redact" => {
let quad_points = decode_real_array(find_entry(annot, "QuadPoints"));
let interior_colour = decode_real_array(find_entry(annot, "IC")).and_then(|v| {
if v.len() == 3 {
Some([v[0], v[1], v[2]])
} else {
None
}
});
let overlay_form = match find_entry(annot, "RO") {
Some(Object::Reference(id)) => Some(*id),
_ => None,
};
let overlay_text = decode_text_string(find_entry(annot, "OverlayText"));
let repeat = matches!(find_entry(annot, "Repeat"), Some(Object::Bool(true)));
let default_appearance = decode_text_string(find_entry(annot, "DA"));
let quadding = match find_entry(annot, "Q") {
Some(Object::Integer(n)) => (*n).clamp(0, 2) as u8,
_ => 0,
};
AnnotationKind::Redact {
quad_points,
interior_colour,
overlay_form,
overlay_text,
repeat,
default_appearance,
quadding,
}
}
"FileAttachment" => {
let (filespec_id, filespec_dict) = match find_entry(annot, "FS") {
Some(Object::Reference(id)) => {
let resolved = reader.resolve(*id)?;
let dict = match resolved {
Object::Dict(d) => Some(d),
_ => None,
};
(Some(*id), dict)
}
Some(Object::Dict(d)) => (None, Some(d.clone())),
_ => (None, None),
};
let file_name = filespec_dict.as_ref().and_then(decode_filespec_name);
AnnotationKind::FileAttachment {
icon: match find_entry(annot, "Name") {
Some(Object::Name(s)) => s.clone(),
_ => "PushPin".to_string(),
},
file_name,
filespec: filespec_id,
}
}
"Sound" => AnnotationKind::Sound {
sound: match find_entry(annot, "Sound") {
Some(Object::Reference(id)) => Some(*id),
_ => None,
},
icon: match find_entry(annot, "Name") {
Some(Object::Name(s)) => s.clone(),
_ => "Speaker".to_string(),
},
},
"Movie" => {
let activation = match find_entry(annot, "A") {
Some(Object::Bool(true)) => MovieActivation::Play,
Some(Object::Bool(false)) => MovieActivation::Dont,
Some(Object::Reference(id)) => MovieActivation::Custom(*id),
None => MovieActivation::Play,
_ => MovieActivation::Play,
};
AnnotationKind::Movie {
title: decode_text_string(find_entry(annot, "T")),
movie: match find_entry(annot, "Movie") {
Some(Object::Reference(id)) => Some(*id),
_ => None,
},
activation,
}
}
"Screen" => AnnotationKind::Screen {
title: decode_text_string(find_entry(annot, "T")),
appearance_chars: match find_entry(annot, "MK") {
Some(Object::Reference(id)) => Some(*id),
_ => None,
},
action: match find_entry(annot, "A") {
Some(Object::Reference(id)) => Some(*id),
_ => None,
},
additional_actions: match find_entry(annot, "AA") {
Some(Object::Reference(id)) => Some(*id),
_ => None,
},
},
"3D" => {
let artwork = match find_entry(annot, "3DD") {
Some(Object::Reference(id)) => Some(*id),
_ => None,
};
let view = decode_three_d_view_selector(find_entry(annot, "3DV"));
let activation = match find_entry(annot, "3DA").cloned() {
Some(o) => {
let resolved = reader.deref(o)?;
decode_three_d_activation(&resolved).unwrap_or_default()
}
None => ThreeDActivation::default(),
};
let interactive = match find_entry(annot, "3DI") {
Some(Object::Bool(b)) => *b,
_ => true,
};
let view_box = match find_entry(annot, "3DB") {
Some(Object::Array(items)) if items.len() == 4 => {
let mut out = [0f32; 4];
let mut ok = true;
for (i, it) in items.iter().enumerate() {
match it {
Object::Real(f) => out[i] = *f as f32,
Object::Integer(n) => out[i] = *n as f32,
_ => {
ok = false;
break;
}
}
}
if ok {
Some(out)
} else {
None
}
}
_ => None,
};
AnnotationKind::ThreeD {
artwork,
view,
activation,
interactive,
view_box,
}
}
"PrinterMark" => AnnotationKind::PrinterMark {
mark_name: match find_entry(annot, "MN") {
Some(Object::Name(s)) => Some(s.clone()),
_ => None,
},
},
"TrapNet" => {
let last_modified = decode_text_string(find_entry(annot, "LastModified"));
let version = decode_indirect_ref_array(find_entry(annot, "Version"));
let annot_states = decode_optional_name_array(find_entry(annot, "AnnotStates"));
let font_fauxing = decode_indirect_ref_array(find_entry(annot, "FontFauxing"));
AnnotationKind::TrapNet {
last_modified,
version,
annot_states,
font_fauxing,
}
}
other => AnnotationKind::Other {
subtype: other.to_string(),
},
};
Ok(Some(PdfAnnotation {
source_page_index: page_index,
rect,
contents,
name,
modified,
flags,
colour,
border,
appearance,
appearance_state,
kind,
}))
}
fn decode_appearance_summary(
reader: &mut DocumentReader<'_>,
annot: &Dict,
) -> Result<Option<AnnotationAppearance>, PdfError> {
let ap = match find_entry(annot, "AP").cloned() {
Some(obj) => match reader.deref(obj) {
Ok(Object::Dict(d)) => d,
_ => return Ok(None),
},
None => return Ok(None),
};
let mut summary = AnnotationAppearance::default();
let mut states: Vec<String> = Vec::new();
for (key, flag) in [("N", 0usize), ("R", 1), ("D", 2)] {
let Some(entry) = find_entry(&ap, key).cloned() else {
continue;
};
match flag {
0 => summary.has_normal = true,
1 => summary.has_rollover = true,
_ => summary.has_down = true,
}
if let Ok(Object::Dict(sub)) = reader.deref(entry) {
for (state, _) in sub.entries() {
states.push(state.clone());
}
}
}
states.sort();
states.dedup();
summary.states = states;
Ok(Some(summary))
}
fn decode_link_target(
reader: &mut DocumentReader<'_>,
annot: &Dict,
page_index_map: &HashMap<u32, usize>,
) -> Result<Option<PdfLinkTarget>, PdfError> {
if let Some(dest) = find_entry(annot, "Dest").cloned() {
let dest = reader.deref(dest)?;
return Ok(decode_dest_value(dest, page_index_map));
}
if let Some(action) = find_entry(annot, "A").cloned() {
let action = reader.deref(action)?;
if let Object::Dict(adict) = action {
let s_kind = find_entry(&adict, "S").and_then(|v| match v {
Object::Name(s) => Some(s.clone()),
_ => None,
});
match s_kind.as_deref() {
Some("URI") => {
let uri = find_entry(&adict, "URI").and_then(|v| match v {
Object::LiteralString(b) | Object::HexString(b) => {
Some(String::from_utf8_lossy(b).into_owned())
}
_ => None,
});
return Ok(uri.map(PdfLinkTarget::Uri));
}
Some("GoTo") => {
if let Some(d) = find_entry(&adict, "D").cloned() {
let d = reader.deref(d)?;
return Ok(decode_dest_value(d, page_index_map));
}
}
_ => {}
}
}
}
Ok(None)
}
fn decode_dest_value(dest: Object, page_index_map: &HashMap<u32, usize>) -> Option<PdfLinkTarget> {
match dest {
Object::Array(items) => {
decode_explicit_dest(&items, page_index_map).map(PdfLinkTarget::Internal)
}
Object::Name(s) => Some(PdfLinkTarget::Named(s)),
Object::LiteralString(b) | Object::HexString(b) => Some(PdfLinkTarget::Named(
String::from_utf8_lossy(&b).into_owned(),
)),
_ => None,
}
}
fn decode_explicit_dest(
items: &[Object],
page_index_map: &HashMap<u32, usize>,
) -> Option<crate::outline::OutlineDestination> {
use crate::outline::OutlineDestination;
if items.len() < 2 {
return None;
}
let page_index = match &items[0] {
Object::Reference(id) => *page_index_map.get(&id.number)?,
_ => return None,
};
let mode = match &items[1] {
Object::Name(n) => n.as_str(),
_ => return None,
};
let opt = |o: Option<&Object>| match o {
Some(Object::Real(f)) => Some(*f as f32),
Some(Object::Integer(n)) => Some(*n as f32),
Some(Object::Null) | None => None,
_ => None,
};
let req = |o: Option<&Object>| -> Option<f32> {
match o {
Some(Object::Real(f)) => Some(*f as f32),
Some(Object::Integer(n)) => Some(*n as f32),
_ => None,
}
};
match mode {
"XYZ" => Some(OutlineDestination::Xyz {
page_index,
left: opt(items.get(2)),
top: opt(items.get(3)),
zoom: opt(items.get(4)).filter(|z| *z != 0.0),
}),
"Fit" => Some(OutlineDestination::Fit { page_index }),
"FitH" => Some(OutlineDestination::FitH {
page_index,
top: opt(items.get(2)),
}),
"FitV" => Some(OutlineDestination::FitV {
page_index,
left: opt(items.get(2)),
}),
"FitR" => Some(OutlineDestination::FitR {
page_index,
left: req(items.get(2))?,
bottom: req(items.get(3))?,
right: req(items.get(4))?,
top: req(items.get(5))?,
}),
"FitB" => Some(OutlineDestination::FitB { page_index }),
"FitBH" => Some(OutlineDestination::FitBH {
page_index,
top: opt(items.get(2)),
}),
"FitBV" => Some(OutlineDestination::FitBV {
page_index,
left: opt(items.get(2)),
}),
_ => None,
}
}
fn find_entry<'d>(d: &'d Dict, key: &str) -> Option<&'d Object> {
d.entries().iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
fn decode_real_array(o: Option<&Object>) -> Option<Vec<f32>> {
match o? {
Object::Array(items) => {
let mut out = Vec::with_capacity(items.len());
for it in items {
match it {
Object::Real(f) => out.push(*f as f32),
Object::Integer(n) => out.push(*n as f32),
_ => return None,
}
}
Some(out)
}
_ => None,
}
}
fn decode_rect_diffs(o: Option<&Object>) -> Option<[f32; 4]> {
let v = decode_real_array(o)?;
if v.len() == 4 {
Some([v[0], v[1], v[2], v[3]])
} else {
None
}
}
fn decode_real(o: Option<&Object>) -> Option<f32> {
match o? {
Object::Real(f) => Some(*f as f32),
Object::Integer(n) => Some(*n as f32),
_ => None,
}
}
fn decode_two_name_array(o: Option<&Object>) -> Option<[String; 2]> {
let arr = match o? {
Object::Array(items) => items,
_ => return None,
};
if arr.len() != 2 {
return None;
}
let a = match &arr[0] {
Object::Name(s) => s.clone(),
_ => return None,
};
let b = match &arr[1] {
Object::Name(s) => s.clone(),
_ => return None,
};
Some([a, b])
}
fn decode_ink_list(o: Option<&Object>) -> Vec<Vec<f32>> {
let Some(Object::Array(strokes)) = o else {
return Vec::new();
};
let mut out = Vec::with_capacity(strokes.len());
for s in strokes {
let Object::Array(coords) = s else { continue };
let mut flat = Vec::with_capacity(coords.len());
let mut ok = true;
for c in coords {
match c {
Object::Real(f) => flat.push(*f as f32),
Object::Integer(n) => flat.push(*n as f32),
_ => {
ok = false;
break;
}
}
}
if ok {
out.push(flat);
}
}
out
}
fn decode_fixed_print(o: &Object) -> Option<FixedPrint> {
let Object::Dict(d) = o else {
return None;
};
let mut out = FixedPrint::default();
if let Some(Object::Array(items)) = find_entry(d, "Matrix") {
if items.len() == 6 {
let mut tmp = [0f32; 6];
let mut ok = true;
for (i, it) in items.iter().enumerate() {
match it {
Object::Real(f) => tmp[i] = *f as f32,
Object::Integer(n) => tmp[i] = *n as f32,
_ => {
ok = false;
break;
}
}
}
if ok {
out.matrix = tmp;
}
}
}
if let Some(v) = decode_real(find_entry(d, "H")) {
out.h = v;
}
if let Some(v) = decode_real(find_entry(d, "V")) {
out.v = v;
}
Some(out)
}
fn decode_indirect_ref_array(o: Option<&Object>) -> Option<Vec<ObjectId>> {
let Object::Array(items) = o? else {
return None;
};
let mut out = Vec::with_capacity(items.len());
for it in items {
if let Object::Reference(id) = it {
out.push(*id);
}
}
Some(out)
}
fn decode_optional_name_array(o: Option<&Object>) -> Option<Vec<Option<String>>> {
let Object::Array(items) = o? else {
return None;
};
let mut out = Vec::with_capacity(items.len());
for it in items {
match it {
Object::Name(s) => out.push(Some(s.clone())),
Object::Null => out.push(None),
_ => out.push(None),
}
}
Some(out)
}
fn decode_filespec_name(filespec: &Dict) -> Option<String> {
let pick = filespec
.entries()
.iter()
.find(|(k, _)| k == "UF")
.or_else(|| filespec.entries().iter().find(|(k, _)| k == "F"));
decode_text_string(pick.map(|(_, v)| v))
}
fn decode_text_string(o: Option<&Object>) -> Option<String> {
match o? {
Object::LiteralString(b) => Some(String::from_utf8_lossy(b).into_owned()),
Object::HexString(b) => {
if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
let utf16: Vec<u16> = b[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
Some(String::from_utf16_lossy(&utf16))
} else {
Some(String::from_utf8_lossy(b).into_owned())
}
}
Object::Name(s) => Some(s.clone()),
_ => None,
}
}
fn decode_field_value(o: Option<&Object>) -> Option<String> {
match o? {
Object::LiteralString(b) | Object::HexString(b) => Some(decode_pdf_string_bytes(b)),
Object::Name(s) => Some(s.clone()),
Object::Integer(n) => Some(n.to_string()),
Object::Real(f) => Some(f.to_string()),
Object::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn decode_pdf_string_bytes(b: &[u8]) -> String {
if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
let utf16: Vec<u16> = b[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&utf16)
} else {
String::from_utf8_lossy(b).into_owned()
}
}
fn decode_three_d_view_selector(o: Option<&Object>) -> Option<ThreeDViewSelector> {
match o? {
Object::Reference(id) => Some(ThreeDViewSelector::View(*id)),
Object::Integer(n) => Some(ThreeDViewSelector::Index(*n)),
Object::LiteralString(b) | Object::HexString(b) => {
Some(ThreeDViewSelector::Name(decode_pdf_string_bytes(b)))
}
Object::Name(s) => Some(ThreeDViewSelector::Symbolic(s.clone())),
_ => None,
}
}
fn decode_three_d_activation(o: &Object) -> Option<ThreeDActivation> {
let Object::Dict(d) = o else {
return None;
};
let activation_when = match find_entry(d, "A") {
Some(Object::Name(s)) => Some(s.clone()),
_ => None,
};
let artwork_state_on_activation = match find_entry(d, "AIS") {
Some(Object::Name(s)) => Some(s.clone()),
_ => None,
};
let deactivation_when = match find_entry(d, "D") {
Some(Object::Name(s)) => Some(s.clone()),
_ => None,
};
let artwork_state_on_deactivation = match find_entry(d, "DIS") {
Some(Object::Name(s)) => Some(s.clone()),
_ => None,
};
let toolbar = match find_entry(d, "TB") {
Some(Object::Bool(b)) => Some(*b),
_ => None,
};
let navigation_panel = match find_entry(d, "NP") {
Some(Object::Bool(b)) => Some(*b),
_ => None,
};
Some(ThreeDActivation {
activation_when,
artwork_state_on_activation,
deactivation_when,
artwork_state_on_deactivation,
toolbar,
navigation_panel,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_text_string_handles_utf16be_bom() {
let o = Object::HexString(vec![0xFE, 0xFF, 0x4E, 0x2D, 0x65, 0x87]);
let s = decode_text_string(Some(&o)).unwrap();
assert_eq!(s, "中文");
}
#[test]
fn decode_text_string_handles_literal_ascii() {
let o = Object::LiteralString(b"hello".to_vec());
let s = decode_text_string(Some(&o)).unwrap();
assert_eq!(s, "hello");
}
#[test]
fn decode_real_array_mixes_int_and_real() {
let o = Object::Array(vec![
Object::Integer(1),
Object::Real(2.5),
Object::Integer(3),
]);
let v = decode_real_array(Some(&o)).unwrap();
assert_eq!(v, vec![1.0, 2.5, 3.0]);
}
#[test]
fn decode_real_array_rejects_non_numeric() {
let o = Object::Array(vec![Object::Integer(1), Object::Name("x".into())]);
assert!(decode_real_array(Some(&o)).is_none());
}
#[test]
fn decode_rect_diffs_requires_four_entries() {
let o = Object::Array(vec![
Object::Real(1.0),
Object::Real(2.0),
Object::Real(3.0),
Object::Real(4.0),
]);
assert_eq!(decode_rect_diffs(Some(&o)), Some([1.0, 2.0, 3.0, 4.0]));
let bad = Object::Array(vec![Object::Real(1.0)]);
assert!(decode_rect_diffs(Some(&bad)).is_none());
}
#[test]
fn decode_field_value_collapses_primitives() {
assert_eq!(
decode_field_value(Some(&Object::Integer(42))),
Some("42".into())
);
assert_eq!(
decode_field_value(Some(&Object::Bool(true))),
Some("true".into())
);
assert_eq!(
decode_field_value(Some(&Object::Name("Yes".into()))),
Some("Yes".into())
);
assert_eq!(
decode_field_value(Some(&Object::LiteralString(b"abc".to_vec()))),
Some("abc".into())
);
assert_eq!(decode_field_value(Some(&Object::Null)), None);
}
}