Skip to main content

karet_pdf/
lib.rs

1//! `karet-pdf` — headless, pure-Rust PDF rasterization for karet.
2//!
3//! It wraps the [`hayro`] PDF interpreter/renderer (pure Rust, no C-sys
4//! dependencies) to turn PDF bytes into [`RenderedPage`]s of straight
5//! (un-premultiplied) 8-bit RGBA pixels. A renderer such as `karet-fileview` can
6//! then hand those pixels to the Kitty graphics protocol (or a halfblock
7//! fallback). The crate is headless — no ratatui, no terminal — so a PDF can be
8//! turned into pixels anywhere.
9//!
10//! Parsing happens once in [`Document::load`]; pages are rasterized on demand via
11//! [`Document::render_page`], so a large document is not fully rendered up front.
12//!
13//! ```no_run
14//! # fn demo(bytes: Vec<u8>) -> Result<(), karet_pdf::PdfError> {
15//! let doc = karet_pdf::Document::load(bytes)?;
16//! for i in 0..doc.page_count() {
17//!     let page = doc.render_page(i, 2.0)?; // 2× the native 72-DPI size
18//!     assert_eq!(
19//!         page.rgba().len(),
20//!         page.width() as usize * page.height() as usize * 4
21//!     );
22//! }
23//! # Ok(())
24//! # }
25//! ```
26
27mod 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/// A single rasterized PDF page: straight 8-bit RGBA pixels plus dimensions.
45#[derive(Clone, Debug)]
46pub struct RenderedPage {
47    rgba: Vec<u8>,
48    width: u32,
49    height: u32,
50}
51
52impl RenderedPage {
53    /// The straight (un-premultiplied) RGBA8 pixels: row-major, 4 bytes per pixel,
54    /// exactly `width * height * 4` bytes long.
55    #[must_use]
56    pub fn rgba(&self) -> &[u8] {
57        &self.rgba
58    }
59
60    /// Consume the page, returning ownership of its RGBA8 pixel buffer.
61    #[must_use]
62    pub fn into_rgba(self) -> Vec<u8> {
63        self.rgba
64    }
65
66    /// The rendered page width, in pixels.
67    #[must_use]
68    pub fn width(&self) -> u32 {
69        self.width
70    }
71
72    /// The rendered page height, in pixels.
73    #[must_use]
74    pub fn height(&self) -> u32 {
75        self.height
76    }
77}
78
79/// A parsed PDF document. Load once, then rasterize pages on demand.
80pub struct Document {
81    pdf: Pdf,
82}
83
84impl Document {
85    /// Parse a PDF document from its raw bytes.
86    ///
87    /// # Errors
88    /// Returns [`PdfError::Parse`] if the bytes are not a readable PDF, or
89    /// [`PdfError::Encrypted`] if the document is password-protected.
90    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    /// The number of pages in the document.
96    #[must_use]
97    pub fn page_count(&self) -> usize {
98        self.pdf.pages().len()
99    }
100
101    /// Rasterize page `index` (0-based) at `scale` (1.0 renders at the native
102    /// 72-DPI size; 2.0 is twice as large) over an opaque white background,
103    /// producing straight RGBA8 pixels.
104    ///
105    /// # Errors
106    /// Returns [`PdfError::PageOutOfRange`] if `index >= self.page_count()`.
107    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    /// Extract the document's navigation outline (bookmarks) as a tree.
140    ///
141    /// Each entry maps to a 0-based page index where its destination can be
142    /// resolved. A document with no outline — or a malformed one — yields an empty
143    /// `Vec` rather than an error. Named destinations (`/Names` → `/Dests` name
144    /// trees) and remote/URI actions are not resolved: those entries keep their
145    /// title but report `page = None`.
146    #[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    /// Map each page's indirect-object id to its 0-based index, so an outline
164    /// destination's target page reference can be resolved to a page number.
165    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/// One entry in a PDF's navigation outline (a bookmark / table-of-contents node).
176#[derive(Clone, Debug)]
177pub struct OutlineItem {
178    /// The bookmark's display title, decoded from the PDF text string.
179    pub title: String,
180    /// The 0-based page index the bookmark points to, if it targets an in-document
181    /// page via an explicit destination or a `GoTo` action. `None` when the entry
182    /// has no destination or uses one this crate does not resolve (named
183    /// destinations, remote/URI actions).
184    pub page: Option<usize>,
185    /// Nested child bookmarks, in document order.
186    pub children: Vec<OutlineItem>,
187}
188
189/// Cap on outline nesting depth, guarding against a pathological or cyclic `/First`
190/// chain blowing the stack.
191const MAX_OUTLINE_DEPTH: usize = 64;
192
193/// Walk one chain of `/Next` siblings, recursing into each entry's `/First` child.
194fn 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        // Cycle guard: stop if a `/Next`/`/First` link points back at a seen node.
204        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
228/// Read and decode an outline entry's `/Title`.
229fn outline_title(item: &Dict<'_>) -> Option<String> {
230    item.get::<PdfString>(keys::TITLE)
231        .map(|s| decode_pdf_text_string(s.as_bytes()))
232}
233
234/// Resolve an outline entry's target page index from `/Dest` or a `GoTo` `/A`
235/// action. Named destinations (a `/Dest` name/string, or a `/D` name) require a
236/// name-tree walk hayro does not provide, so those return `None`.
237fn outline_page(item: &Dict<'_>, page_index: &HashMap<ObjectIdentifier, usize>) -> Option<usize> {
238    // 1) Explicit destination array: /Dest [ pageRef /Fit ... ].
239    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    // 2) GoTo action: /A << /S /GoTo /D [ pageRef ... ] >>.
245    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
253/// The first element of a destination array is the target page reference; map it
254/// to a 0-based page index.
255fn 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
263/// Decode a PDF text string into a Rust `String` without external crates: UTF-16BE
264/// when it opens with a `FE FF` byte-order mark, otherwise byte-for-byte as
265/// Latin-1 / PDFDocEncoding.
266fn 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    /// A minimal, valid single-page PDF (an empty US-Letter page). Kept inline so
284    /// the test needs no on-disk fixture.
285    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    // The workspace clippy policy denies unwrap/expect/panic even in tests, so these
301    // extract values through `Result`/`Option` combinators and assert on those.
302
303    #[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        // 612×792 pt at scale 1.0 → 612×792 px.
317        assert_eq!(
318            page.as_ref().map(|p| (p.width(), p.height())),
319            Some((612, 792))
320        );
321        // The RGBA buffer is exactly width*height*4 bytes.
322        assert!(
323            page.as_ref()
324                .is_some_and(|p| p.rgba().len() == p.width() as usize * p.height() as usize * 4)
325        );
326        // The empty page renders as opaque white.
327        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    /// A 100×100 PDF whose content stream fills a black 80×80 rectangle — so
361    /// rendering it actually exercises hayro's content interpreter, not just the
362    /// background fill. The `Length` (25) matches the content bytes exactly.
363    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    /// A minimal single-page PDF carrying an `/Outlines` dictionary with one
371    /// bookmark ("Chapter 1") whose `/Dest` targets page object `3 0 R` (page 0).
372    /// Like `RECT_PDF`, it has no xref table or `startxref`, so hayro parses it via
373    /// its brute-force fallback and no byte-accurate offsets are needed.
374    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    /// Non-UTF-16 titles decode as Latin-1; a `FE FF` BOM decodes as UTF-16BE.
402    #[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        // The interpreter must have drawn the rectangle: some pixels are black…
421        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        // …and the margin is still white.
427        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}