1use kurbo::Rect;
14use pdfrum_object::{Dict, Name, Object, PdfString, Resolve, decode_text, names as obj_names};
15
16use crate::annot::{Annotation, Subtype, is_popup};
17use crate::geom;
18use crate::names;
19
20#[derive(Debug, Clone, Default, PartialEq)]
48pub struct AnnotList {
49 pub annots: Vec<Annotation>,
52 pub source_indices: Vec<usize>,
55 pub popups: Vec<(usize, Annotation)>,
58}
59
60impl AnnotList {
61 #[must_use]
92 pub fn load<R: Resolve>(page: &Dict, page_width: f32, r: &R) -> AnnotList {
93 let mut list = AnnotList::default();
94 let Some(array) = page.array(obj_names::ANNOTS, r) else {
97 return list;
98 };
99 for index in 0..array.len() {
100 let Some(dict) = array.dict_at(index, r) else {
101 continue;
102 };
103 if is_popup(&dict, r) {
104 continue;
105 }
106 list.annots.push(Annotation::read(&dict, r));
107 list.source_indices.push(index);
108 }
109 for (index, annot) in list.annots.iter().enumerate() {
110 if let Some(popup) = create_popup(annot, page_width, r) {
111 list.popups.push((index, popup));
112 }
113 }
114 list
115 }
116}
117
118#[must_use]
122pub fn popup_appears_for(subtype: Subtype) -> bool {
123 matches!(
124 subtype,
125 Subtype::Text
126 | Subtype::Line
127 | Subtype::Square
128 | Subtype::Circle
129 | Subtype::Polygon
130 | Subtype::PolyLine
131 | Subtype::Highlight
132 | Subtype::Underline
133 | Subtype::Squiggly
134 | Subtype::StrikeOut
135 | Subtype::Stamp
136 | Subtype::Caret
137 | Subtype::Ink
138 | Subtype::FileAttachment
139 | Subtype::Redact
140 )
141}
142
143fn create_popup<R: Resolve>(parent: &Annotation, page_width: f32, r: &R) -> Option<Annotation> {
150 if !popup_appears_for(parent.subtype) {
151 return None;
152 }
153 let contents = parent.dict.byte_string(obj_names::CONTENTS, r)?;
154 if decode_text(&contents).is_empty() {
155 return None;
156 }
157
158 let rect = popup_rect(geom::normalize(parent.rect), page_width);
159 let title = parent.dict.byte_string(obj_names::T, r).unwrap_or_default();
160 let dict = Dict::from_pairs([
161 (obj_names::TYPE.clone(), Object::Name(names::ANNOT.clone())),
162 (
163 obj_names::SUBTYPE.clone(),
164 Object::Name(Name::new(Subtype::Popup.as_bytes().to_vec())),
165 ),
166 (obj_names::T.clone(), Object::Str(PdfString::literal(title))),
167 (
168 obj_names::CONTENTS.clone(),
169 Object::Str(PdfString::literal(contents)),
170 ),
171 (
172 obj_names::RECT.clone(),
173 Object::Array(pdfrum_object::Array::of([
174 Object::from(geom::left(rect)),
175 Object::from(geom::bottom(rect)),
176 Object::from(geom::right(rect)),
177 Object::from(geom::top(rect)),
178 ])),
179 ),
180 (obj_names::F.clone(), Object::Int(0)),
181 ]);
182 Some(Annotation::read(&dict, r))
183}
184
185fn popup_rect(parent: Rect, page_width: f32) -> Rect {
187 let (left, bottom, right, top) = (
188 geom::left(parent),
189 geom::bottom(parent),
190 geom::right(parent),
191 geom::top(parent),
192 );
193 let base = geom::rect(0.0, 0.0, 200.0, 200.0);
194 if left + 200.0 > page_width && bottom - 200.0 < 0.0 {
195 geom::translate(base, right - 200.0, top)
198 } else {
199 geom::translate(
200 base,
201 left.min(page_width - 200.0),
202 (bottom - 200.0).max(0.0),
203 )
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::{AnnotList, popup_appears_for, popup_rect};
210 use crate::annot::Subtype;
211 use crate::geom;
212 use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
213
214 fn dict(pairs: &[(&str, Object)]) -> Dict {
215 Dict::from_pairs(
216 pairs
217 .iter()
218 .map(|(k, v)| (Name::from(*k), v.clone()))
219 .collect::<Vec<_>>(),
220 )
221 }
222
223 fn page_with(annots: &[Object]) -> Dict {
224 dict(&[("Annots", Object::Array(Array::of(annots.to_vec())))])
225 }
226
227 fn text_annot(contents: &[u8]) -> Object {
228 Object::Dict(dict(&[
229 ("Subtype", Object::Name(Name::from("Text"))),
230 ("Contents", Object::Str(PdfString::literal(contents))),
231 (
232 "Rect",
233 Object::Array(Array::of([
234 Object::from(10.0_f32),
235 Object::from(300.0_f32),
236 Object::from(30.0_f32),
237 Object::from(320.0_f32),
238 ])),
239 ),
240 ]))
241 }
242
243 #[test]
244 fn a_text_annotation_with_content_gains_one_synthesized_popup() {
245 let page = page_with(&[text_annot(b"Aa\xE4\xA0")]);
246 let list = AnnotList::load(&page, 612.0, &NoResolve);
247 assert_eq!(list.annots.len(), 1);
248 assert_eq!(list.popups.len(), 1);
249 let popup = list.popups.first().map(|(_, popup)| popup);
251 assert_eq!(
252 popup
253 .and_then(|popup| popup.dict.string(pdfrum_object::names::CONTENTS))
254 .map(|s| s.bytes.to_vec()),
255 Some(b"Aa\xE4\xA0".to_vec())
256 );
257 }
258
259 #[test]
260 fn emptiness_is_tested_after_decoding_not_before() {
261 for contents in [
264 &b""[..],
265 &b"\xFE\xFF"[..],
266 &b"\xFE\xFF\x00\x1Bja\x00\x1B"[..],
267 ] {
268 let page = page_with(&[text_annot(contents)]);
269 let list = AnnotList::load(&page, 612.0, &NoResolve);
270 assert_eq!(list.popups.len(), 0, "{contents:?}");
271 }
272 }
273
274 #[test]
275 fn a_popup_in_the_file_is_dropped_from_the_list() {
276 let popup = Object::Dict(dict(&[("Subtype", Object::Name(Name::from("Popup")))]));
277 let page = page_with(&[popup, text_annot(b"x")]);
278 let list = AnnotList::load(&page, 612.0, &NoResolve);
279 assert_eq!(list.annots.len(), 1);
280 assert_eq!(list.source_indices, vec![1]);
283 }
284
285 #[test]
286 fn free_text_and_widgets_get_no_popup() {
287 assert!(!popup_appears_for(Subtype::FreeText));
288 assert!(!popup_appears_for(Subtype::Widget));
289 assert!(popup_appears_for(Subtype::Text));
290 assert!(popup_appears_for(Subtype::Redact));
291 }
292
293 #[test]
294 fn the_popup_hugs_the_parents_left_edge_until_the_page_runs_out() {
295 let parent = geom::rect(500.0, 260.046, 520.0, 360.046);
298 let placed = popup_rect(parent, 690.511);
299 assert!((geom::left(placed) - 490.511).abs() < 1e-3, "{placed:?}");
300 assert!((geom::bottom(placed) - 60.046).abs() < 1e-3);
301 }
302
303 #[test]
304 fn a_bottom_right_parent_hangs_its_popup_off_its_own_corner() {
305 let parent = geom::rect(500.0, 100.0, 560.0, 180.0);
307 let placed = popup_rect(parent, 612.0);
308 assert!((geom::left(placed) - 360.0).abs() < f32::EPSILON);
309 assert!((geom::bottom(placed) - 180.0).abs() < f32::EPSILON);
310 }
311}