Skip to main content

edgeparse_core/pdf/
image_codecs.rs

1//! Image XObject codec decode for the OCR path.
2//!
3//! Routes PDF `/Filter` values to pixel buffers. DCT / Flate / raw samples are
4//! always available when the `image` feature is on; JBIG2 uses `hayro-jbig2`
5//! behind `codecs-jbig2`; JPX uses best-effort fallbacks (and `jpeg2k` when
6//! `codecs-jpx` is enabled).
7
8#![cfg(feature = "image")]
9
10use image::GrayImage;
11
12/// Result of decoding an Image XObject stream to grayscale for OCR.
13#[derive(Debug, Clone)]
14pub struct DecodedGray {
15    /// Grayscale raster.
16    pub gray: GrayImage,
17    /// Filter that produced the pixels (for diagnostics).
18    pub filter: String,
19}
20
21/// Decode image stream bytes into a grayscale raster suitable for OCR.
22pub fn decode_image_to_gray(
23    data: &[u8],
24    filter: &str,
25    width: u32,
26    height: u32,
27) -> Option<DecodedGray> {
28    let primary = primary_filter(filter);
29    let gray = match primary {
30        "DCTDecode" => decode_dct(data),
31        "JPXDecode" => decode_jpx(data, width, height),
32        "JBIG2Decode" => decode_jbig2(data, width, height),
33        "CCITTFaxDecode" => decode_ccitt_best_effort(data, width, height),
34        _ => decode_raw_or_flate(data, width, height).or_else(|| decode_dct(data)),
35    }?;
36    Some(DecodedGray {
37        gray,
38        filter: primary.to_string(),
39    })
40}
41
42fn primary_filter(filter: &str) -> &str {
43    filter
44        .split([' ', ',', '[', ']'])
45        .map(str::trim)
46        .rfind(|s| !s.is_empty())
47        .unwrap_or(filter)
48}
49
50fn decode_dct(data: &[u8]) -> Option<GrayImage> {
51    image::load_from_memory(data).ok().map(|img| img.to_luma8())
52}
53
54fn decode_raw_or_flate(data: &[u8], width: u32, height: u32) -> Option<GrayImage> {
55    if width == 0 || height == 0 {
56        return None;
57    }
58    let n = (width as usize).checked_mul(height as usize)?;
59    if data.len() == n {
60        return GrayImage::from_raw(width, height, data.to_vec());
61    }
62    if data.len() == n * 3 {
63        let mut luma = Vec::with_capacity(n);
64        for px in data.chunks_exact(3) {
65            let y = (0.299 * f64::from(px[0]) + 0.587 * f64::from(px[1]) + 0.114 * f64::from(px[2]))
66                .round()
67                .clamp(0.0, 255.0) as u8;
68            luma.push(y);
69        }
70        return GrayImage::from_raw(width, height, luma);
71    }
72    if data.len() == n * 4 {
73        let mut luma = Vec::with_capacity(n);
74        for px in data.chunks_exact(4) {
75            let y = (0.299 * f64::from(px[0]) + 0.587 * f64::from(px[1]) + 0.114 * f64::from(px[2]))
76                .round()
77                .clamp(0.0, 255.0) as u8;
78            luma.push(y);
79        }
80        return GrayImage::from_raw(width, height, luma);
81    }
82    None
83}
84
85fn decode_ccitt_best_effort(data: &[u8], width: u32, height: u32) -> Option<GrayImage> {
86    if width == 0 || height == 0 {
87        return None;
88    }
89    let row_bytes = (width as usize).div_ceil(8);
90    let expected = row_bytes.checked_mul(height as usize)?;
91    if data.len() < expected {
92        return None;
93    }
94    let mut luma = Vec::with_capacity((width * height) as usize);
95    for y in 0..height as usize {
96        let row = &data[y * row_bytes..(y + 1) * row_bytes];
97        for x in 0..width as usize {
98            let byte = row[x / 8];
99            let bit = 7 - (x % 8);
100            let on = ((byte >> bit) & 1) != 0;
101            luma.push(if on { 0 } else { 255 });
102        }
103    }
104    GrayImage::from_raw(width, height, luma)
105}
106
107#[cfg(feature = "codecs-jpx")]
108fn decode_jpx(data: &[u8], width: u32, height: u32) -> Option<GrayImage> {
109    // jpeg2k crate: decode via from_bytes → get pixels.
110    let img = jpeg2k::Image::from_bytes(data).ok()?;
111    let w = img.width().max(1);
112    let h = img.height().max(1);
113    let _ = (width, height);
114    let pixels = img.get_pixels(None).ok()?;
115    let mut luma = Vec::with_capacity((w * h) as usize);
116    // Assume RGBA or RGB interleaved.
117    let bpp = if pixels.len() >= (w * h * 4) as usize {
118        4
119    } else if pixels.len() >= (w * h * 3) as usize {
120        3
121    } else if pixels.len() >= (w * h) as usize {
122        1
123    } else {
124        return None;
125    };
126    if bpp == 1 {
127        return GrayImage::from_raw(w, h, pixels[..(w * h) as usize].to_vec());
128    }
129    for px in pixels.chunks_exact(bpp) {
130        let y = (0.299 * f64::from(px[0]) + 0.587 * f64::from(px[1]) + 0.114 * f64::from(px[2]))
131            .round()
132            .clamp(0.0, 255.0) as u8;
133        luma.push(y);
134    }
135    GrayImage::from_raw(w, h, luma)
136}
137
138#[cfg(not(feature = "codecs-jpx"))]
139fn decode_jpx(data: &[u8], width: u32, height: u32) -> Option<GrayImage> {
140    // Without OpenJPEG: try raw / JPEG-in-JPX fallbacks so hybrid pages still OCR.
141    decode_raw_or_flate(data, width, height).or_else(|| decode_dct(data))
142}
143
144#[cfg(feature = "codecs-jbig2")]
145fn decode_jbig2(data: &[u8], _width: u32, _height: u32) -> Option<GrayImage> {
146    use hayro_jbig2::{Decoder, Image as Jbig2Image};
147
148    let image = Jbig2Image::new_embedded(data, None)
149        .or_else(|_| Jbig2Image::new(data))
150        .ok()?;
151    let w = image.width();
152    let h = image.height();
153    let mut buf = vec![255u8; (w as usize).saturating_mul(h as usize)];
154
155    struct LumaBuf<'a> {
156        buf: &'a mut [u8],
157        pos: usize,
158    }
159    impl Decoder for LumaBuf<'_> {
160        fn push_pixel(&mut self, black: bool) {
161            if self.pos < self.buf.len() {
162                self.buf[self.pos] = if black { 0 } else { 255 };
163                self.pos += 1;
164            }
165        }
166        fn push_pixel_chunk(&mut self, black: bool, chunk_count: u32) {
167            let luma = if black { 0 } else { 255 };
168            let count = (chunk_count as usize).saturating_mul(8);
169            let end = (self.pos + count).min(self.buf.len());
170            self.buf[self.pos..end].fill(luma);
171            self.pos = end;
172        }
173        fn next_line(&mut self) {}
174    }
175
176    let mut decoder = LumaBuf {
177        buf: &mut buf,
178        pos: 0,
179    };
180    image.decode(&mut decoder).ok()?;
181    GrayImage::from_raw(w, h, buf)
182}
183
184#[cfg(not(feature = "codecs-jbig2"))]
185fn decode_jbig2(data: &[u8], width: u32, height: u32) -> Option<GrayImage> {
186    decode_ccitt_best_effort(data, width, height)
187        .or_else(|| decode_raw_or_flate(data, width, height))
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn raw_gray_roundtrip() {
196        let data = vec![0u8, 128, 255, 64];
197        let g = decode_image_to_gray(&data, "FlateDecode", 2, 2).unwrap();
198        assert_eq!(g.gray.width(), 2);
199        assert_eq!(g.gray.height(), 2);
200    }
201
202    #[test]
203    fn primary_filter_from_array_like() {
204        assert_eq!(primary_filter("FlateDecode"), "FlateDecode");
205        assert_eq!(primary_filter("[ FlateDecode ]"), "FlateDecode");
206    }
207
208    #[test]
209    fn jbig2_filter_does_not_panic_without_feature() {
210        let _ = decode_image_to_gray(&[0u8; 16], "JBIG2Decode", 4, 4);
211    }
212
213    #[test]
214    fn jpx_filter_does_not_panic_without_feature() {
215        let _ = decode_image_to_gray(&[0u8; 16], "JPXDecode", 4, 4);
216    }
217}