mod error;
use std::collections::HashMap;
use std::collections::HashSet;
pub use error::PdfError;
use hayro::RenderSettings;
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::Pdf;
use hayro::hayro_syntax::object::Array;
use hayro::hayro_syntax::object::Dict;
use hayro::hayro_syntax::object::Name;
use hayro::hayro_syntax::object::ObjectIdentifier;
use hayro::hayro_syntax::object::String as PdfString;
use hayro::hayro_syntax::object::dict::keys;
use hayro::vello_cpu::color::palette::css::WHITE;
#[derive(Clone, Debug)]
pub struct RenderedPage {
rgba: Vec<u8>,
width: u32,
height: u32,
}
impl RenderedPage {
#[must_use]
pub fn rgba(&self) -> &[u8] {
&self.rgba
}
#[must_use]
pub fn into_rgba(self) -> Vec<u8> {
self.rgba
}
#[must_use]
pub fn width(&self) -> u32 {
self.width
}
#[must_use]
pub fn height(&self) -> u32 {
self.height
}
}
pub struct Document {
pdf: Pdf,
}
impl Document {
pub fn load(bytes: Vec<u8>) -> Result<Self, PdfError> {
let pdf = Pdf::new(bytes).map_err(PdfError::from_load)?;
Ok(Self { pdf })
}
#[must_use]
pub fn page_count(&self) -> usize {
self.pdf.pages().len()
}
pub fn render_page(&self, index: usize, scale: f32) -> Result<RenderedPage, PdfError> {
let pages = self.pdf.pages();
let count = pages.len();
let page = pages
.get(index)
.ok_or(PdfError::PageOutOfRange { index, count })?;
let cache = hayro::RenderCache::new();
let interpreter = InterpreterSettings::default();
let render_settings = RenderSettings {
x_scale: scale,
y_scale: scale,
bg_color: WHITE,
..Default::default()
};
let pixmap = hayro::render(page, &cache, &interpreter, &render_settings);
let width = u32::from(pixmap.width());
let height = u32::from(pixmap.height());
let rgba = pixmap
.take_unpremultiplied()
.into_iter()
.flat_map(|px| [px.r, px.g, px.b, px.a])
.collect();
Ok(RenderedPage {
rgba,
width,
height,
})
}
#[must_use]
pub fn outline(&self) -> Vec<OutlineItem> {
let xref = self.pdf.xref();
let Some(catalog) = xref.get::<Dict>(xref.root_id()) else {
return Vec::new();
};
let Some(outlines) = catalog.get::<Dict>(keys::OUTLINES) else {
return Vec::new();
};
let Some(first) = outlines.get::<Dict>(keys::FIRST) else {
return Vec::new();
};
let page_index = self.page_id_index_map();
let mut visited = HashSet::new();
walk_outline_siblings(first, &page_index, &mut visited, 0)
}
fn page_id_index_map(&self) -> HashMap<ObjectIdentifier, usize> {
self.pdf
.pages()
.iter()
.enumerate()
.filter_map(|(index, page)| page.raw().obj_id().map(|id| (id, index)))
.collect()
}
}
#[derive(Clone, Debug)]
pub struct OutlineItem {
pub title: String,
pub page: Option<usize>,
pub children: Vec<OutlineItem>,
}
const MAX_OUTLINE_DEPTH: usize = 64;
fn walk_outline_siblings(
first: Dict<'_>,
page_index: &HashMap<ObjectIdentifier, usize>,
visited: &mut HashSet<ObjectIdentifier>,
depth: usize,
) -> Vec<OutlineItem> {
let mut items = Vec::new();
let mut current = Some(first);
while let Some(item) = current {
if let Some(id) = item.obj_id()
&& !visited.insert(id)
{
break;
}
let title = outline_title(&item).unwrap_or_default();
let page = outline_page(&item, page_index);
let children = if depth < MAX_OUTLINE_DEPTH {
item.get::<Dict>(keys::FIRST)
.map(|child| walk_outline_siblings(child, page_index, visited, depth + 1))
.unwrap_or_default()
} else {
Vec::new()
};
items.push(OutlineItem {
title,
page,
children,
});
current = item.get::<Dict>(keys::NEXT);
}
items
}
fn outline_title(item: &Dict<'_>) -> Option<String> {
item.get::<PdfString>(keys::TITLE)
.map(|s| decode_pdf_text_string(s.as_bytes()))
}
fn outline_page(item: &Dict<'_>, page_index: &HashMap<ObjectIdentifier, usize>) -> Option<usize> {
if let Some(dest) = item.get::<Array>(keys::DEST)
&& let Some(page) = dest_array_page(&dest, page_index)
{
return Some(page);
}
let action = item.get::<Dict>(keys::A)?;
if action.get::<Name>(keys::S).as_deref() != Some(b"GoTo".as_slice()) {
return None;
}
let dest = action.get::<Array>(keys::D)?;
dest_array_page(&dest, page_index)
}
fn dest_array_page(
dest: &Array<'_>,
page_index: &HashMap<ObjectIdentifier, usize>,
) -> Option<usize> {
let page_ref = dest.raw_iter().next()?.as_obj_ref()?;
page_index.get(&ObjectIdentifier::from(page_ref)).copied()
}
fn decode_pdf_text_string(bytes: &[u8]) -> String {
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
let units = rest
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]));
char::decode_utf16(units)
.map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER))
.collect()
} else {
bytes.iter().map(|&b| b as char).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL_PDF: &[u8] = b"%PDF-1.4\n\
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n\
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n\
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n\
xref\n\
0 4\n\
0000000000 65535 f \n\
0000000009 00000 n \n\
0000000052 00000 n \n\
0000000101 00000 n \n\
trailer<</Size 4/Root 1 0 R>>\n\
startxref\n\
164\n\
%%EOF";
#[test]
fn loads_and_counts_pages() {
let count = Document::load(MINIMAL_PDF.to_vec())
.map(|doc| doc.page_count())
.ok();
assert_eq!(count, Some(1));
}
#[test]
fn renders_page_to_rgba_of_expected_size() {
let page = Document::load(MINIMAL_PDF.to_vec())
.and_then(|doc| doc.render_page(0, 1.0))
.ok();
assert_eq!(
page.as_ref().map(|p| (p.width(), p.height())),
Some((612, 792))
);
assert!(
page.as_ref()
.is_some_and(|p| p.rgba().len() == p.width() as usize * p.height() as usize * 4)
);
assert!(page.as_ref().is_some_and(|p| {
p.rgba()
.chunks_exact(4)
.all(|px| px == [255, 255, 255, 255])
}));
}
#[test]
fn scale_changes_pixel_dimensions() {
let dims = Document::load(MINIMAL_PDF.to_vec())
.and_then(|doc| doc.render_page(0, 0.5))
.ok()
.map(|p| (p.width(), p.height()));
assert_eq!(dims, Some((306, 396)));
}
#[test]
fn out_of_range_page_errors() {
let result = Document::load(MINIMAL_PDF.to_vec()).and_then(|doc| doc.render_page(5, 1.0));
assert!(matches!(
result,
Err(PdfError::PageOutOfRange { index: 5, count: 1 })
));
}
#[test]
fn garbage_bytes_fail_to_parse() {
assert!(matches!(
Document::load(b"not a pdf".to_vec()),
Err(PdfError::Parse)
));
}
const RECT_PDF: &[u8] = b"%PDF-1.4\n\
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n\
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n\
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Contents 4 0 R>>endobj\n\
4 0 obj<</Length 25>>stream\n0 0 0 rg 10 10 80 80 re f\nendstream endobj\n\
trailer<</Size 5/Root 1 0 R>>\n%%EOF";
const OUTLINE_PDF: &[u8] = b"%PDF-1.4\n\
1 0 obj<</Type/Catalog/Pages 2 0 R/Outlines 4 0 R>>endobj\n\
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n\
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n\
4 0 obj<</Type/Outlines/First 5 0 R/Last 5 0 R/Count 1>>endobj\n\
5 0 obj<</Title(Chapter 1)/Parent 4 0 R/Dest[3 0 R/Fit]>>endobj\n\
trailer<</Size 6/Root 1 0 R>>\n%%EOF";
#[test]
fn outline_extracts_bookmark_with_page() {
let items = Document::load(OUTLINE_PDF.to_vec())
.map(|doc| doc.outline())
.unwrap_or_default();
assert_eq!(items.len(), 1);
assert_eq!(items.first().map(|i| i.title.as_str()), Some("Chapter 1"));
assert_eq!(items.first().and_then(|i| i.page), Some(0));
assert!(items.first().is_some_and(|i| i.children.is_empty()));
}
#[test]
fn outline_absent_returns_empty() {
let items = Document::load(MINIMAL_PDF.to_vec())
.map(|doc| doc.outline())
.unwrap_or_default();
assert!(items.is_empty());
}
#[test]
fn decodes_pdf_text_strings() {
assert_eq!(decode_pdf_text_string(b"Chapter 1"), "Chapter 1");
assert_eq!(
decode_pdf_text_string(&[0xFE, 0xFF, 0x00, 0x41, 0x00, 0x42]),
"AB"
);
}
#[test]
fn renders_actual_page_content_not_just_background() {
let page = Document::load(RECT_PDF.to_vec())
.and_then(|doc| doc.render_page(0, 1.0))
.ok();
assert_eq!(
page.as_ref().map(|p| (p.width(), p.height())),
Some((100, 100))
);
let has_black = page.as_ref().is_some_and(|p| {
p.rgba()
.chunks_exact(4)
.any(|px| px[0] < 16 && px[1] < 16 && px[2] < 16)
});
let has_white = page.as_ref().is_some_and(|p| {
p.rgba()
.chunks_exact(4)
.any(|px| px == [255, 255, 255, 255])
});
assert!(
has_black,
"expected the filled rectangle to render as black pixels"
);
assert!(has_white, "expected the page margin to stay white");
}
}