Skip to main content

docling_pdf/render/
jbig2.rs

1//! `JBIG2Decode` images (ISO 32000-1, 7.4.7): the embedded JBIG2 streams of
2//! scanned PDFs, decoded with `hayro-jbig2` (#811).
3//!
4//! A scanned page's text is often a JBIG2 `/ImageMask` stencil drawn over
5//! a JPEG background. Without a decoder the stencil drew nothing — not
6//! even the mid-gray placeholder the other undecodable images get, since a
7//! stencil paints only where it has samples — so such a page rendered as
8//! its background alone, blank to the layout model and to OCR, with no
9//! signal but a debug line. Decoded, the stencil is the text it carries.
10//!
11//! What the filter decides (7.4.7): the stream is the embedded organization
12//! of T.88 Annex D.3 — a sequence of segments, no file header — and the
13//! filter parameter `/JBIG2Globals` names a stream of segments (symbol
14//! dictionaries, tables) shared by every image of the document, decoded
15//! ahead of the image's own. The filter's output is 1 bit per pixel with
16//! **0 = black**: JBIG2 codes black as 1, the PDF filter inverts it so that
17//! the samples read as `DeviceGray` 1-bit (0 = black) and a stencil with the
18//! default `/Decode [0 1]` paints its black pixels. The image dictionary's
19//! `/Width` × `/Height` is what the samples are read as; a stream whose own
20//! size differs is cropped or padded white, as the CCITT path does.
21
22use hayro_jbig2::{Decoder, Image};
23
24/// The decoded samples, packed 1 bit per pixel, `width.div_ceil(8)` bytes
25/// per row for `height` rows, 0 = black (the filter's output, 7.4.7).
26pub struct Decoded {
27    pub stride: usize,
28    pub data: Vec<u8>,
29}
30
31/// Collects the decoder's pixels into packed rows of the image's own width;
32/// `next_line` files each row into the output at the dictionary's size.
33struct Rows {
34    /// The image's width (the decoder pushes exactly this many pixels per
35    /// row) and the row under construction, 1 = white.
36    image_width: usize,
37    row: Vec<u8>,
38    x: usize,
39    /// The output at the dictionary's size.
40    stride: usize,
41    height: usize,
42    out: Vec<u8>,
43    y: usize,
44}
45
46impl Rows {
47    fn file_row(&mut self) {
48        if self.y < self.height {
49            let n = self.stride.min(self.row.len());
50            let at = self.y * self.stride;
51            self.out[at..at + n].copy_from_slice(&self.row[..n]);
52        }
53        self.y += 1;
54        self.row.fill(0xff);
55        self.x = 0;
56    }
57}
58
59impl Decoder for Rows {
60    fn push_pixel(&mut self, black: bool) {
61        if black && self.x < self.image_width {
62            self.row[self.x / 8] &= !(0x80 >> (self.x % 8));
63        }
64        self.x += 1;
65    }
66
67    fn push_pixel_chunk(&mut self, black: bool, chunk_count: u32) {
68        // Byte-aligned by contract: whole bytes of one colour.
69        let bytes = chunk_count as usize;
70        if black {
71            let start = (self.x / 8).min(self.row.len());
72            let end = (start + bytes).min(self.row.len());
73            self.row[start..end].fill(0x00);
74        }
75        self.x += bytes * 8;
76    }
77
78    fn next_line(&mut self) {
79        self.file_row();
80    }
81}
82
83/// Decode `data` (an embedded JBIG2 stream) with `globals` (the
84/// `/JBIG2Globals` stream's content, already unfiltered) as the filter
85/// would, into `width` × `height` packed 1-bit rows, 0 = black.
86pub fn decode(
87    data: &[u8],
88    globals: Option<&[u8]>,
89    width: usize,
90    height: usize,
91) -> Result<Decoded, String> {
92    if width == 0 || height == 0 {
93        return Err("empty image".into());
94    }
95    let image = Image::new_embedded(data, globals).map_err(|e| format!("{e:?}"))?;
96    let (iw, ih) = (image.width() as usize, image.height() as usize);
97    if iw == 0 || ih == 0 {
98        return Err(format!("JBIG2 image is {iw} × {ih}"));
99    }
100    // The same ceiling as the renderer's other decoders: a crafted header
101    // must not allocate gigabytes.
102    if iw.saturating_mul(ih) > 80_000_000 || width.saturating_mul(height) > 80_000_000 {
103        return Err(format!("JBIG2 image too large ({iw} × {ih})"));
104    }
105    let stride = width.div_ceil(8);
106    let mut rows = Rows {
107        image_width: iw,
108        // One byte of slack: a chunk may run past the last partial byte.
109        row: vec![0xff; iw.div_ceil(8) + 1],
110        x: 0,
111        stride,
112        height,
113        out: vec![0xff; stride * height],
114        y: 0,
115    };
116    image.decode(&mut rows).map_err(|e| format!("{e:?}"))?;
117    // A last row the decoder did not close.
118    if rows.x > 0 {
119        rows.file_row();
120    }
121    Ok(Decoded {
122        stride,
123        data: rows.out,
124    })
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn fixture(name: &str) -> Vec<u8> {
132        std::fs::read(
133            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
134                .join("tests/data/jbig2")
135                .join(name),
136        )
137        .unwrap_or_else(|e| panic!("{name}: {e}"))
138    }
139
140    /// The reference bitmap of a fixture, 1 bit per pixel packed like the
141    /// filter's output (0 = black), from the PNG the decoder's own test
142    /// suite checks against.
143    fn reference(name: &str) -> (usize, usize, Vec<u8>) {
144        let img = image::load_from_memory(&fixture(name)).unwrap().to_luma8();
145        let (w, h) = (img.width() as usize, img.height() as usize);
146        let stride = w.div_ceil(8);
147        let mut out = vec![0xffu8; stride * h];
148        for (y, row) in img.rows().enumerate() {
149            for (x, p) in row.enumerate() {
150                if p.0[0] < 128 {
151                    out[y * stride + x / 8] &= !(0x80 >> (x % 8));
152                }
153            }
154        }
155        (w, h, out)
156    }
157
158    /// An MMR-coded generic region (the T.6 coding a scanner's JBIG2 shares
159    /// with CCITT G4) decodes to the reference, 0 = black.
160    #[test]
161    fn mmr_generic_region_matches_the_reference() {
162        let (w, h, want) = reference("generic_mmr.png");
163        let got = decode(&fixture("generic_mmr.jbig2"), None, w, h).unwrap();
164        assert_eq!(got.stride, w.div_ceil(8));
165        assert_eq!(got.data, want);
166        // Some black: the reference is a picture, not a blank page.
167        assert!(got.data.iter().any(|&b| b != 0xff));
168    }
169
170    /// A symbol dictionary in `/JBIG2Globals` and the text region that uses
171    /// it in the image's own stream — the layout a PDF producer writes for
172    /// a scanned document's text.
173    #[test]
174    fn text_region_with_global_symbol_dictionary() {
175        let (w, h, want) = reference("symbol_global.png");
176        let globals = fixture("symbol_global.globals.jbig2");
177        let got = decode(&fixture("symbol_global.jbig2"), Some(&globals), w, h).unwrap();
178        assert_eq!(got.data, want);
179        // Without its globals the text region has no symbols to draw.
180        assert!(decode(&fixture("symbol_global.jbig2"), None, w, h).is_err());
181    }
182
183    /// The dictionary's size wins: a wider or taller declaration pads white,
184    /// a smaller one crops.
185    #[test]
186    fn dictionary_size_crops_or_pads() {
187        let (w, h, want) = reference("generic_mmr.png");
188        let padded = decode(&fixture("generic_mmr.jbig2"), None, w + 9, h + 2).unwrap();
189        assert_eq!(padded.stride, (w + 9).div_ceil(8));
190        for y in 0..h {
191            let got = &padded.data[y * padded.stride..y * padded.stride + w.div_ceil(8)];
192            let exp = &want[y * w.div_ceil(8)..(y + 1) * w.div_ceil(8)];
193            // The bits past the image width are white in both.
194            let mask = 0xffu8 << ((8 - w % 8) % 8);
195            let (gl, el) = (got.last().unwrap() & mask, exp.last().unwrap() & mask);
196            assert_eq!(&got[..got.len() - 1], &exp[..exp.len() - 1], "row {y}");
197            assert_eq!(gl, el, "row {y} tail");
198        }
199        assert!(padded.data[h * padded.stride..].iter().all(|&b| b == 0xff));
200        let cropped = decode(&fixture("generic_mmr.jbig2"), None, 8, 3).unwrap();
201        assert_eq!(cropped.data.len(), 3);
202        assert_eq!(
203            &cropped.data[..],
204            &[want[0], want[w.div_ceil(8)], want[2 * w.div_ceil(8)]]
205        );
206    }
207
208    #[test]
209    fn garbage_is_an_error_not_a_panic() {
210        assert!(decode(b"not a jbig2 stream", None, 10, 10).is_err());
211        assert!(decode(&[], None, 10, 10).is_err());
212        assert!(decode(&fixture("generic_mmr.jbig2"), None, 0, 10).is_err());
213    }
214}