1mod error;
28
29use std::collections::HashMap;
30use std::collections::HashSet;
31
32pub use error::PdfError;
33use hayro::RenderSettings;
34use hayro::hayro_interpret::InterpreterSettings;
35use hayro::hayro_syntax::Pdf;
36use hayro::hayro_syntax::object::Array;
37use hayro::hayro_syntax::object::Dict;
38use hayro::hayro_syntax::object::Name;
39use hayro::hayro_syntax::object::ObjectIdentifier;
40use hayro::hayro_syntax::object::String as PdfString;
41use hayro::hayro_syntax::object::dict::keys;
42use hayro::vello_cpu::color::palette::css::WHITE;
43
44#[derive(Clone, Debug)]
46pub struct RenderedPage {
47 rgba: Vec<u8>,
48 width: u32,
49 height: u32,
50}
51
52impl RenderedPage {
53 #[must_use]
56 pub fn rgba(&self) -> &[u8] {
57 &self.rgba
58 }
59
60 #[must_use]
62 pub fn into_rgba(self) -> Vec<u8> {
63 self.rgba
64 }
65
66 #[must_use]
68 pub fn width(&self) -> u32 {
69 self.width
70 }
71
72 #[must_use]
74 pub fn height(&self) -> u32 {
75 self.height
76 }
77}
78
79pub struct Document {
81 pdf: Pdf,
82}
83
84impl Document {
85 pub fn load(bytes: Vec<u8>) -> Result<Self, PdfError> {
91 let pdf = Pdf::new(bytes).map_err(PdfError::from_load)?;
92 Ok(Self { pdf })
93 }
94
95 #[must_use]
97 pub fn page_count(&self) -> usize {
98 self.pdf.pages().len()
99 }
100
101 pub fn render_page(&self, index: usize, scale: f32) -> Result<RenderedPage, PdfError> {
108 let pages = self.pdf.pages();
109 let count = pages.len();
110 let page = pages
111 .get(index)
112 .ok_or(PdfError::PageOutOfRange { index, count })?;
113
114 let cache = hayro::RenderCache::new();
115 let interpreter = InterpreterSettings::default();
116 let render_settings = RenderSettings {
117 x_scale: scale,
118 y_scale: scale,
119 bg_color: WHITE,
120 ..Default::default()
121 };
122
123 let pixmap = hayro::render(page, &cache, &interpreter, &render_settings);
124 let width = u32::from(pixmap.width());
125 let height = u32::from(pixmap.height());
126 let rgba = pixmap
127 .take_unpremultiplied()
128 .into_iter()
129 .flat_map(|px| [px.r, px.g, px.b, px.a])
130 .collect();
131
132 Ok(RenderedPage {
133 rgba,
134 width,
135 height,
136 })
137 }
138
139 #[must_use]
147 pub fn outline(&self) -> Vec<OutlineItem> {
148 let xref = self.pdf.xref();
149 let Some(catalog) = xref.get::<Dict>(xref.root_id()) else {
150 return Vec::new();
151 };
152 let Some(outlines) = catalog.get::<Dict>(keys::OUTLINES) else {
153 return Vec::new();
154 };
155 let Some(first) = outlines.get::<Dict>(keys::FIRST) else {
156 return Vec::new();
157 };
158 let page_index = self.page_id_index_map();
159 let mut visited = HashSet::new();
160 walk_outline_siblings(first, &page_index, &mut visited, 0)
161 }
162
163 fn page_id_index_map(&self) -> HashMap<ObjectIdentifier, usize> {
166 self.pdf
167 .pages()
168 .iter()
169 .enumerate()
170 .filter_map(|(index, page)| page.raw().obj_id().map(|id| (id, index)))
171 .collect()
172 }
173}
174
175#[derive(Clone, Debug)]
177pub struct OutlineItem {
178 pub title: String,
180 pub page: Option<usize>,
185 pub children: Vec<OutlineItem>,
187}
188
189const MAX_OUTLINE_DEPTH: usize = 64;
192
193fn walk_outline_siblings(
195 first: Dict<'_>,
196 page_index: &HashMap<ObjectIdentifier, usize>,
197 visited: &mut HashSet<ObjectIdentifier>,
198 depth: usize,
199) -> Vec<OutlineItem> {
200 let mut items = Vec::new();
201 let mut current = Some(first);
202 while let Some(item) = current {
203 if let Some(id) = item.obj_id()
205 && !visited.insert(id)
206 {
207 break;
208 }
209 let title = outline_title(&item).unwrap_or_default();
210 let page = outline_page(&item, page_index);
211 let children = if depth < MAX_OUTLINE_DEPTH {
212 item.get::<Dict>(keys::FIRST)
213 .map(|child| walk_outline_siblings(child, page_index, visited, depth + 1))
214 .unwrap_or_default()
215 } else {
216 Vec::new()
217 };
218 items.push(OutlineItem {
219 title,
220 page,
221 children,
222 });
223 current = item.get::<Dict>(keys::NEXT);
224 }
225 items
226}
227
228fn outline_title(item: &Dict<'_>) -> Option<String> {
230 item.get::<PdfString>(keys::TITLE)
231 .map(|s| decode_pdf_text_string(s.as_bytes()))
232}
233
234fn outline_page(item: &Dict<'_>, page_index: &HashMap<ObjectIdentifier, usize>) -> Option<usize> {
238 if let Some(dest) = item.get::<Array>(keys::DEST)
240 && let Some(page) = dest_array_page(&dest, page_index)
241 {
242 return Some(page);
243 }
244 let action = item.get::<Dict>(keys::A)?;
246 if action.get::<Name>(keys::S).as_deref() != Some(b"GoTo".as_slice()) {
247 return None;
248 }
249 let dest = action.get::<Array>(keys::D)?;
250 dest_array_page(&dest, page_index)
251}
252
253fn dest_array_page(
256 dest: &Array<'_>,
257 page_index: &HashMap<ObjectIdentifier, usize>,
258) -> Option<usize> {
259 let page_ref = dest.raw_iter().next()?.as_obj_ref()?;
260 page_index.get(&ObjectIdentifier::from(page_ref)).copied()
261}
262
263fn decode_pdf_text_string(bytes: &[u8]) -> String {
267 if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
268 let units = rest
269 .chunks_exact(2)
270 .map(|c| u16::from_be_bytes([c[0], c[1]]));
271 char::decode_utf16(units)
272 .map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER))
273 .collect()
274 } else {
275 bytes.iter().map(|&b| b as char).collect()
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 const MINIMAL_PDF: &[u8] = b"%PDF-1.4\n\
2861 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n\
2872 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n\
2883 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n\
289xref\n\
2900 4\n\
2910000000000 65535 f \n\
2920000000009 00000 n \n\
2930000000052 00000 n \n\
2940000000101 00000 n \n\
295trailer<</Size 4/Root 1 0 R>>\n\
296startxref\n\
297164\n\
298%%EOF";
299
300 #[test]
304 fn loads_and_counts_pages() {
305 let count = Document::load(MINIMAL_PDF.to_vec())
306 .map(|doc| doc.page_count())
307 .ok();
308 assert_eq!(count, Some(1));
309 }
310
311 #[test]
312 fn renders_page_to_rgba_of_expected_size() {
313 let page = Document::load(MINIMAL_PDF.to_vec())
314 .and_then(|doc| doc.render_page(0, 1.0))
315 .ok();
316 assert_eq!(
318 page.as_ref().map(|p| (p.width(), p.height())),
319 Some((612, 792))
320 );
321 assert!(
323 page.as_ref()
324 .is_some_and(|p| p.rgba().len() == p.width() as usize * p.height() as usize * 4)
325 );
326 assert!(page.as_ref().is_some_and(|p| {
328 p.rgba()
329 .chunks_exact(4)
330 .all(|px| px == [255, 255, 255, 255])
331 }));
332 }
333
334 #[test]
335 fn scale_changes_pixel_dimensions() {
336 let dims = Document::load(MINIMAL_PDF.to_vec())
337 .and_then(|doc| doc.render_page(0, 0.5))
338 .ok()
339 .map(|p| (p.width(), p.height()));
340 assert_eq!(dims, Some((306, 396)));
341 }
342
343 #[test]
344 fn out_of_range_page_errors() {
345 let result = Document::load(MINIMAL_PDF.to_vec()).and_then(|doc| doc.render_page(5, 1.0));
346 assert!(matches!(
347 result,
348 Err(PdfError::PageOutOfRange { index: 5, count: 1 })
349 ));
350 }
351
352 #[test]
353 fn garbage_bytes_fail_to_parse() {
354 assert!(matches!(
355 Document::load(b"not a pdf".to_vec()),
356 Err(PdfError::Parse)
357 ));
358 }
359
360 const RECT_PDF: &[u8] = b"%PDF-1.4\n\
3641 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n\
3652 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n\
3663 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Contents 4 0 R>>endobj\n\
3674 0 obj<</Length 25>>stream\n0 0 0 rg 10 10 80 80 re f\nendstream endobj\n\
368trailer<</Size 5/Root 1 0 R>>\n%%EOF";
369
370 const OUTLINE_PDF: &[u8] = b"%PDF-1.4\n\
3751 0 obj<</Type/Catalog/Pages 2 0 R/Outlines 4 0 R>>endobj\n\
3762 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n\
3773 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n\
3784 0 obj<</Type/Outlines/First 5 0 R/Last 5 0 R/Count 1>>endobj\n\
3795 0 obj<</Title(Chapter 1)/Parent 4 0 R/Dest[3 0 R/Fit]>>endobj\n\
380trailer<</Size 6/Root 1 0 R>>\n%%EOF";
381
382 #[test]
383 fn outline_extracts_bookmark_with_page() {
384 let items = Document::load(OUTLINE_PDF.to_vec())
385 .map(|doc| doc.outline())
386 .unwrap_or_default();
387 assert_eq!(items.len(), 1);
388 assert_eq!(items.first().map(|i| i.title.as_str()), Some("Chapter 1"));
389 assert_eq!(items.first().and_then(|i| i.page), Some(0));
390 assert!(items.first().is_some_and(|i| i.children.is_empty()));
391 }
392
393 #[test]
394 fn outline_absent_returns_empty() {
395 let items = Document::load(MINIMAL_PDF.to_vec())
396 .map(|doc| doc.outline())
397 .unwrap_or_default();
398 assert!(items.is_empty());
399 }
400
401 #[test]
403 fn decodes_pdf_text_strings() {
404 assert_eq!(decode_pdf_text_string(b"Chapter 1"), "Chapter 1");
405 assert_eq!(
406 decode_pdf_text_string(&[0xFE, 0xFF, 0x00, 0x41, 0x00, 0x42]),
407 "AB"
408 );
409 }
410
411 #[test]
412 fn renders_actual_page_content_not_just_background() {
413 let page = Document::load(RECT_PDF.to_vec())
414 .and_then(|doc| doc.render_page(0, 1.0))
415 .ok();
416 assert_eq!(
417 page.as_ref().map(|p| (p.width(), p.height())),
418 Some((100, 100))
419 );
420 let has_black = page.as_ref().is_some_and(|p| {
422 p.rgba()
423 .chunks_exact(4)
424 .any(|px| px[0] < 16 && px[1] < 16 && px[2] < 16)
425 });
426 let has_white = page.as_ref().is_some_and(|p| {
428 p.rgba()
429 .chunks_exact(4)
430 .any(|px| px == [255, 255, 255, 255])
431 });
432 assert!(
433 has_black,
434 "expected the filled rectangle to render as black pixels"
435 );
436 assert!(has_white, "expected the page margin to stay white");
437 }
438}