Skip to main content

pdfboss_core/
thumbnail.rs

1//! Thumbnail images (ISO 32000-1 §12.3.4): a page's `/Thumb` image XObject,
2//! read as a stream with the entries the clause makes significant.
3
4use crate::document::Page;
5use crate::object::{Object, Stream};
6use crate::source::AsyncObjectSource;
7
8/// A page's thumbnail image (ISO 32000-1 §12.3.4): the `/Thumb` image
9/// XObject, with the entries the clause makes significant read out and the
10/// rest of Table 89 left in the stream's dictionary.
11#[derive(Debug, Clone, PartialEq)]
12pub struct Thumbnail {
13    /// `/Width` in samples.
14    pub width: u32,
15    /// `/Height` in samples.
16    pub height: u32,
17    /// `/BitsPerComponent`; `None` when absent.
18    pub bits_per_component: Option<u32>,
19    /// `/ColorSpace` as written: the clause allows DeviceGray, DeviceRGB or
20    /// an Indexed space over one of them.
21    pub color_space: Option<Object>,
22    /// `/Decode`: the sample decode array as numbers, `None` when absent.
23    pub decode: Option<Vec<f64>>,
24    /// The image XObject itself: its dictionary and encoded data.
25    pub stream: Stream,
26}
27
28/// The thumbnail of `page`: the image stream its `/Thumb` entry names,
29/// `None` when absent or not a stream. Width and height are required by
30/// Table 89; a stream lacking either is no thumbnail.
31///
32/// Covers ISO 32000-1 §12.3.4.
33pub async fn thumbnail_with<S: AsyncObjectSource>(src: &S, page: &Page) -> Option<Thumbnail> {
34    let Object::Stream(stream) = src.resolve(page.dict().get("Thumb")?).await.ok()? else {
35        return None;
36    };
37    let dict = &stream.dict;
38    let count = |key: &str| u32::try_from(dict.get_int(key)?).ok();
39    Some(Thumbnail {
40        width: count("Width")?,
41        height: count("Height")?,
42        bits_per_component: count("BitsPerComponent"),
43        color_space: dict.get("ColorSpace").cloned(),
44        decode: dict
45            .get_array("Decode")
46            .map(|values| values.iter().filter_map(Object::as_f64).collect()),
47        stream,
48    })
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54    use crate::document::Document;
55    use crate::object::Name;
56    use pdfboss_testkit::PdfBuilder;
57
58    /// A one-page document whose page carries `page_extra`; object 7 is a
59    /// two-pixel DeviceRGB image stream and object 8 an Indexed one.
60    fn doc_with(page_extra: &str) -> Document {
61        let mut b = PdfBuilder::new();
62        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
63        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
64        b.object(
65            3,
66            &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] {page_extra} >>"),
67        );
68        b.stream(
69            7,
70            "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB \
71             /BitsPerComponent 8 /Decode [0 1 0 1 0 1] /Interpolate true",
72            &[255, 0, 0, 0, 0, 255],
73        );
74        b.stream(
75            8,
76            "/Width 1 /Height 1 /ColorSpace [/Indexed /DeviceRGB 0 <FF0000>] /BitsPerComponent 8",
77            &[0],
78        );
79        Document::load(b.build(1)).unwrap()
80    }
81
82    /// The stream and the five significant entries are read; `/Interpolate`
83    /// and the rest of Table 89 stay in the stream's dictionary only.
84    // Covers ISO 32000-1 §12.3.4.
85    #[test]
86    fn reads_the_pages_thumbnail_and_its_significant_entries() {
87        let doc = doc_with("/Thumb 7 0 R");
88        let page = doc.page(0).unwrap();
89        let thumbnail = doc.thumbnail(&page).unwrap();
90        assert_eq!((thumbnail.width, thumbnail.height), (2, 1));
91        assert_eq!(thumbnail.bits_per_component, Some(8));
92        assert_eq!(
93            thumbnail.color_space,
94            Some(Object::Name(Name("DeviceRGB".to_string())))
95        );
96        assert_eq!(thumbnail.decode, Some(vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0]));
97        assert_eq!(thumbnail.stream.data, vec![255, 0, 0, 0, 0, 255]);
98        assert!(thumbnail.stream.dict.get("Interpolate").is_some());
99    }
100
101    /// An Indexed colour space is kept as written and a missing `/Decode`
102    /// is `None`; a page without `/Thumb`, or whose `/Thumb` is not a
103    /// stream, has no thumbnail.
104    // Covers ISO 32000-1 §12.3.4.
105    #[test]
106    fn indexed_thumbnails_keep_their_space_and_non_streams_are_none() {
107        let doc = doc_with("/Thumb 8 0 R");
108        let thumbnail = doc.thumbnail(&doc.page(0).unwrap()).unwrap();
109        assert_eq!((thumbnail.width, thumbnail.height), (1, 1));
110        assert!(matches!(thumbnail.color_space, Some(Object::Array(_))));
111        assert_eq!(thumbnail.decode, None);
112        for page_extra in [
113            "",
114            "/Thumb 5",
115            "/Thumb << /Width 1 /Height 1 >>",
116            "/Thumb 3 0 R",
117        ] {
118            let doc = doc_with(page_extra);
119            assert_eq!(doc.thumbnail(&doc.page(0).unwrap()), None, "{page_extra}");
120        }
121    }
122}