use kurbo::Rect;
use pdfrum_object::{Dict, Name, Object, PdfString, Resolve, decode_text, names as obj_names};
use crate::annot::{Annotation, Subtype, is_popup};
use crate::geom;
use crate::names;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AnnotList {
pub annots: Vec<Annotation>,
pub source_indices: Vec<usize>,
pub popups: Vec<(usize, Annotation)>,
}
impl AnnotList {
#[must_use]
pub fn load<R: Resolve>(page: &Dict, page_width: f32, r: &R) -> AnnotList {
let mut list = AnnotList::default();
let Some(array) = page.array(obj_names::ANNOTS, r) else {
return list;
};
for index in 0..array.len() {
let Some(dict) = array.dict_at(index, r) else {
continue;
};
if is_popup(&dict, r) {
continue;
}
list.annots.push(Annotation::read(&dict, r));
list.source_indices.push(index);
}
for (index, annot) in list.annots.iter().enumerate() {
if let Some(popup) = create_popup(annot, page_width, r) {
list.popups.push((index, popup));
}
}
list
}
}
#[must_use]
pub fn popup_appears_for(subtype: Subtype) -> bool {
matches!(
subtype,
Subtype::Text
| Subtype::Line
| Subtype::Square
| Subtype::Circle
| Subtype::Polygon
| Subtype::PolyLine
| Subtype::Highlight
| Subtype::Underline
| Subtype::Squiggly
| Subtype::StrikeOut
| Subtype::Stamp
| Subtype::Caret
| Subtype::Ink
| Subtype::FileAttachment
| Subtype::Redact
)
}
fn create_popup<R: Resolve>(parent: &Annotation, page_width: f32, r: &R) -> Option<Annotation> {
if !popup_appears_for(parent.subtype) {
return None;
}
let contents = parent.dict.byte_string(obj_names::CONTENTS, r)?;
if decode_text(&contents).is_empty() {
return None;
}
let rect = popup_rect(geom::normalize(parent.rect), page_width);
let title = parent.dict.byte_string(obj_names::T, r).unwrap_or_default();
let dict = Dict::from_pairs([
(obj_names::TYPE.clone(), Object::Name(names::ANNOT.clone())),
(
obj_names::SUBTYPE.clone(),
Object::Name(Name::new(Subtype::Popup.as_bytes().to_vec())),
),
(obj_names::T.clone(), Object::Str(PdfString::literal(title))),
(
obj_names::CONTENTS.clone(),
Object::Str(PdfString::literal(contents)),
),
(
obj_names::RECT.clone(),
Object::Array(pdfrum_object::Array::of([
Object::from(geom::left(rect)),
Object::from(geom::bottom(rect)),
Object::from(geom::right(rect)),
Object::from(geom::top(rect)),
])),
),
(obj_names::F.clone(), Object::Int(0)),
]);
Some(Annotation::read(&dict, r))
}
fn popup_rect(parent: Rect, page_width: f32) -> Rect {
let (left, bottom, right, top) = (
geom::left(parent),
geom::bottom(parent),
geom::right(parent),
geom::top(parent),
);
let base = geom::rect(0.0, 0.0, 200.0, 200.0);
if left + 200.0 > page_width && bottom - 200.0 < 0.0 {
geom::translate(base, right - 200.0, top)
} else {
geom::translate(
base,
left.min(page_width - 200.0),
(bottom - 200.0).max(0.0),
)
}
}
#[cfg(test)]
mod tests {
use super::{AnnotList, popup_appears_for, popup_rect};
use crate::annot::Subtype;
use crate::geom;
use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
fn dict(pairs: &[(&str, Object)]) -> Dict {
Dict::from_pairs(
pairs
.iter()
.map(|(k, v)| (Name::from(*k), v.clone()))
.collect::<Vec<_>>(),
)
}
fn page_with(annots: &[Object]) -> Dict {
dict(&[("Annots", Object::Array(Array::of(annots.to_vec())))])
}
fn text_annot(contents: &[u8]) -> Object {
Object::Dict(dict(&[
("Subtype", Object::Name(Name::from("Text"))),
("Contents", Object::Str(PdfString::literal(contents))),
(
"Rect",
Object::Array(Array::of([
Object::from(10.0_f32),
Object::from(300.0_f32),
Object::from(30.0_f32),
Object::from(320.0_f32),
])),
),
]))
}
#[test]
fn a_text_annotation_with_content_gains_one_synthesized_popup() {
let page = page_with(&[text_annot(b"Aa\xE4\xA0")]);
let list = AnnotList::load(&page, 612.0, &NoResolve);
assert_eq!(list.annots.len(), 1);
assert_eq!(list.popups.len(), 1);
let popup = list.popups.first().map(|(_, popup)| popup);
assert_eq!(
popup
.and_then(|popup| popup.dict.string(pdfrum_object::names::CONTENTS))
.map(|s| s.bytes.to_vec()),
Some(b"Aa\xE4\xA0".to_vec())
);
}
#[test]
fn emptiness_is_tested_after_decoding_not_before() {
for contents in [
&b""[..],
&b"\xFE\xFF"[..],
&b"\xFE\xFF\x00\x1Bja\x00\x1B"[..],
] {
let page = page_with(&[text_annot(contents)]);
let list = AnnotList::load(&page, 612.0, &NoResolve);
assert_eq!(list.popups.len(), 0, "{contents:?}");
}
}
#[test]
fn a_popup_in_the_file_is_dropped_from_the_list() {
let popup = Object::Dict(dict(&[("Subtype", Object::Name(Name::from("Popup")))]));
let page = page_with(&[popup, text_annot(b"x")]);
let list = AnnotList::load(&page, 612.0, &NoResolve);
assert_eq!(list.annots.len(), 1);
assert_eq!(list.source_indices, vec![1]);
}
#[test]
fn free_text_and_widgets_get_no_popup() {
assert!(!popup_appears_for(Subtype::FreeText));
assert!(!popup_appears_for(Subtype::Widget));
assert!(popup_appears_for(Subtype::Text));
assert!(popup_appears_for(Subtype::Redact));
}
#[test]
fn the_popup_hugs_the_parents_left_edge_until_the_page_runs_out() {
let parent = geom::rect(500.0, 260.046, 520.0, 360.046);
let placed = popup_rect(parent, 690.511);
assert!((geom::left(placed) - 490.511).abs() < 1e-3, "{placed:?}");
assert!((geom::bottom(placed) - 60.046).abs() < 1e-3);
}
#[test]
fn a_bottom_right_parent_hangs_its_popup_off_its_own_corner() {
let parent = geom::rect(500.0, 100.0, 560.0, 180.0);
let placed = popup_rect(parent, 612.0);
assert!((geom::left(placed) - 360.0).abs() < f32::EPSILON);
assert!((geom::bottom(placed) - 180.0).abs() < f32::EPSILON);
}
}