1use std::collections::{HashMap, HashSet};
11
12use hayro::hayro_syntax::Pdf;
13use hayro::hayro_syntax::object::{Array, Dict, MaybeRef, Name, ObjRef, Rect, String as PdfString};
14use hayro::hayro_syntax::page::Rotation;
15
16#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct OutlineItem {
19 pub title: String,
21 pub level: usize,
23 pub page: Option<usize>,
25}
26
27const MAX_ITEMS: usize = 10_000;
29const MAX_DEPTH: usize = 32;
30
31pub fn outline(doc: &Pdf) -> Vec<OutlineItem> {
34 let xref = doc.xref();
35 let Some(catalog) = xref.get::<Dict>(xref.root_id()) else {
36 return Vec::new();
37 };
38 let Some(outlines) = catalog.get::<Dict>("Outlines") else {
39 return Vec::new();
40 };
41 let Some(first) = outlines.get_ref("First") else {
42 return Vec::new();
43 };
44
45 let page_index = build_page_index(doc);
46 let mut out = Vec::new();
47 let mut visited = HashSet::new();
48 walk_items(doc, first, 0, &page_index, &mut visited, &mut out);
49 out
50}
51
52fn walk_items(
54 doc: &Pdf,
55 start: ObjRef,
56 level: usize,
57 page_index: &HashMap<ObjRef, usize>,
58 visited: &mut HashSet<ObjRef>,
59 out: &mut Vec<OutlineItem>,
60) {
61 if level > MAX_DEPTH {
62 return;
63 }
64 let xref = doc.xref();
65 let mut cur = Some(start);
66 while let Some(r) = cur {
67 if out.len() >= MAX_ITEMS || !visited.insert(r) {
68 return;
69 }
70 let Some(item) = xref.get::<Dict>(r.into()) else {
71 return;
72 };
73 if let Some(title) = item.get::<PdfString>("Title") {
74 out.push(OutlineItem {
75 title: decode_pdf_string(title.as_bytes()),
76 level,
77 page: resolve_dest_page(&item, page_index),
78 });
79 }
80 if let Some(child) = item.get_ref("First") {
81 walk_items(doc, child, level + 1, page_index, visited, out);
82 }
83 cur = item.get_ref("Next");
84 }
85}
86
87fn resolve_dest_page(item: &Dict, page_index: &HashMap<ObjRef, usize>) -> Option<usize> {
90 let dest = item
91 .get::<Array>("Dest")
92 .or_else(|| item.get::<Dict>("A").and_then(|a| a.get::<Array>("D")))?;
93 dest_array_page(&dest, page_index)
94}
95
96fn dest_array_page(dest: &Array, page_index: &HashMap<ObjRef, usize>) -> Option<usize> {
99 match dest.raw_iter().next()? {
100 MaybeRef::Ref(page_ref) => page_index.get(&page_ref).copied(),
101 MaybeRef::NotRef(_) => None,
102 }
103}
104
105#[derive(Clone, Debug, PartialEq, Eq)]
107pub enum LinkTarget {
108 Page(usize),
110 Uri(String),
112}
113
114#[derive(Clone, Debug, PartialEq)]
117pub struct PdfLink {
118 pub x: f32,
119 pub y: f32,
120 pub w: f32,
121 pub h: f32,
122 pub target: LinkTarget,
123}
124
125pub fn page_links(doc: &Pdf) -> Vec<Vec<PdfLink>> {
129 let page_index = build_page_index(doc);
130 let mut out = Vec::with_capacity(doc.pages().len());
131 for page in doc.pages().iter() {
132 let mut links = Vec::new();
133 let cb = page.crop_box();
134 let (pw, ph) = (cb.width(), cb.height());
135 if !matches!(page.rotation(), Rotation::None) || pw <= 0.0 || ph <= 0.0 {
136 out.push(links);
137 continue;
138 }
139 if let Some(annots) = page.raw().get::<Array>("Annots") {
140 for annot in annots.iter::<Dict>() {
141 if annot
142 .get::<Name>("Subtype")
143 .is_none_or(|n| n.as_str() != "Link")
144 {
145 continue;
146 }
147 let (Some(target), Some(r)) =
148 (link_target(&annot, &page_index), annot.get::<Rect>("Rect"))
149 else {
150 continue;
151 };
152 let (ax0, ax1) = (r.x0.min(r.x1), r.x0.max(r.x1));
155 let (ay0, ay1) = (r.y0.min(r.y1), r.y0.max(r.y1));
156 links.push(PdfLink {
157 x: (((ax0 - cb.x0) / pw) as f32).clamp(0.0, 1.0),
158 y: (((cb.y1 - ay1) / ph) as f32).clamp(0.0, 1.0),
159 w: (((ax1 - ax0) / pw) as f32).clamp(0.0, 1.0),
160 h: (((ay1 - ay0) / ph) as f32).clamp(0.0, 1.0),
161 target,
162 });
163 }
164 }
165 out.push(links);
166 }
167 out
168}
169
170fn is_safe_external_uri(uri: &str) -> bool {
184 !uri.chars().any(|c| c.is_whitespace() || c.is_control())
185 && ["http://", "https://"].iter().any(|scheme| {
186 uri.as_bytes()
187 .get(..scheme.len())
188 .is_some_and(|got| got.eq_ignore_ascii_case(scheme.as_bytes()))
189 })
190}
191
192fn link_target(annot: &Dict, page_index: &HashMap<ObjRef, usize>) -> Option<LinkTarget> {
195 if let Some(dest) = annot.get::<Array>("Dest") {
196 return dest_array_page(&dest, page_index).map(LinkTarget::Page);
197 }
198 let action = annot.get::<Dict>("A")?;
199 match action.get::<Name>("S").as_ref().map(|n| n.as_str()) {
200 Some("URI") => {
201 let uri = decode_pdf_string(action.get::<PdfString>("URI")?.as_bytes());
202 if !is_safe_external_uri(&uri) {
203 log::warn!("pdf: dropped link annotation with unsupported URI scheme");
206 return None;
207 }
208 Some(LinkTarget::Uri(uri))
209 }
210 Some("GoTo") => {
211 dest_array_page(&action.get::<Array>("D")?, page_index).map(LinkTarget::Page)
212 }
213 _ => None,
214 }
215}
216
217fn build_page_index(doc: &Pdf) -> HashMap<ObjRef, usize> {
220 let xref = doc.xref();
221 let mut map = HashMap::new();
222 let Some(catalog) = xref.get::<Dict>(xref.root_id()) else {
223 return map;
224 };
225 let Some(root) = catalog.get_ref("Pages") else {
226 return map;
227 };
228 let mut idx = 0;
229 let mut visited = HashSet::new();
230 walk_pages(doc, root, 0, &mut idx, &mut visited, &mut map);
231 map
232}
233
234fn walk_pages(
235 doc: &Pdf,
236 r: ObjRef,
237 depth: usize,
238 idx: &mut usize,
239 visited: &mut HashSet<ObjRef>,
240 map: &mut HashMap<ObjRef, usize>,
241) {
242 if depth > MAX_DEPTH || !visited.insert(r) {
243 return;
244 }
245 let xref = doc.xref();
246 let Some(dict) = xref.get::<Dict>(r.into()) else {
247 return;
248 };
249 if let Some(kids) = dict.get::<Array>("Kids") {
251 for kid in kids.raw_iter() {
252 if let MaybeRef::Ref(kr) = kid {
253 walk_pages(doc, kr, depth + 1, idx, visited, map);
254 }
255 }
256 } else {
257 map.insert(r, *idx);
258 *idx += 1;
259 }
260}
261
262fn decode_pdf_string(bytes: &[u8]) -> String {
265 if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
266 let units: Vec<u16> = rest
267 .as_chunks::<2>()
268 .0
269 .iter()
270 .map(|c| u16::from_be_bytes(*c))
271 .collect();
272 String::from_utf16_lossy(&units).trim().to_string()
273 } else {
274 bytes
275 .iter()
276 .map(|&b| b as char)
277 .collect::<String>()
278 .trim()
279 .to_string()
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn utf16be_bom_decodes() {
289 let b = [0xFE, 0xFF, 0x00, b'H', 0x00, b'i'];
291 assert_eq!(decode_pdf_string(&b), "Hi");
292 }
293
294 #[test]
295 fn latin1_decodes_and_trims() {
296 assert_eq!(decode_pdf_string(b" Intro "), "Intro");
297 assert_eq!(decode_pdf_string(&[b'C', 0xE9]), "Cé"); }
299
300 #[test]
301 fn only_http_uris_survive() {
302 assert!(is_safe_external_uri("https://example.com"));
303 assert!(is_safe_external_uri("HTTP://EXAMPLE.COM"));
304 for bad in [
305 "file:///etc/passwd",
306 "smb://evil/share",
307 "javascript:alert(1)",
308 "mailto:a@b.c",
309 "//evil.com",
310 r"\\evil\share",
311 " javascript:alert(1)",
312 "java\tscript:x",
313 "",
314 ] {
315 assert!(!is_safe_external_uri(bad), "should reject {bad:?}");
316 }
317 }
318}