pdf_oxide 0.3.77

The fastest Rust PDF library — 0.8ms mean, 5× faster than the industry leaders, 100% pass rate on 3,830 real-world PDFs. Text extraction, Markdown/HTML conversion, PDF creation and editing.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
//! CCITT Group 4 decompression for bilevel images.
//!
//! This module handles decompression of CCITT Group 4 encoded bilevel (1-bit) images
//! extracted from PDF documents, and converts them to 8-bit grayscale for OCR processing.
//!
//! PDF Spec: ISO 32000-1:2008, Section 7.4.6 - CCITTFaxDecode Filter
//! CCITT Spec: ITU-T Recommendation T.6 - Facsimile coding schemes and coding control functions

use crate::decoders::CcittParams;
use crate::error::{Error, Result};

/// Decompresses CCITT encoded data (Group 3 or Group 4).
///
/// CCITT (Consultative Committee for International Telegraphy and Telephony) is a binary
/// compression format used in TIFF and PDF for bilevel (1-bit) images. This is the standard
/// compression for scanned documents.
///
/// # Arguments
///
/// * `data` - CCITT compressed data
/// * `params` - CCITT decompression parameters from PDF /DecodeParms dictionary
///
/// # Returns
///
/// A vector of bytes representing the decompressed bilevel image.
/// Each byte contains 8 pixels (MSB = leftmost pixel, LSB = rightmost pixel).
/// Pixels are encoded as: 0 = white, 1 = black (unless /BlackIs1=true, then inverted).
pub fn decompress_ccitt(data: &[u8], params: &CcittParams) -> Result<Vec<u8>> {
    // Validate required parameters
    if params.columns == 0 {
        return Err(Error::Decode("CCITT decompression requires /Columns parameter".to_string()));
    }

    // `fax` 0.3 takes u32 dimensions, which is what CcittParams already holds:
    // the previous `as u16` narrowing silently truncated /Columns > 65535.
    let width = params.columns;
    let height_opt = params.rows;

    log::debug!(
        "CCITT decompression: {} bytes, {}x{} pixels, K={}, BlackIs1={}",
        data.len(),
        params.columns,
        params.rows.unwrap_or(0),
        params.k,
        params.black_is_1
    );

    // Support both Group 3 and Group 4
    if params.is_group_3() {
        log::debug!("CCITT Group 3 decompression requested (K={})", params.k);
    } else {
        log::debug!("CCITT Group 4 decompression requested");
    }

    // Primary: the in-house decoder (Group 4 T.6, and Group 3 T.4 for K >= 0).
    // It honors /EncodedByteAlign (which the fax crate cannot — its bit reader
    // is private) and recovers partial content from truncated/damaged streams
    // instead of blanking the page.
    let in_house = crate::decoders::ccitt::decode(data, params);
    let fax_result = match in_house {
        Ok(decoded) => {
            if decoded.recovered_partial {
                log::warn!(
                    "CCITT: recovered {} rows then padded white (truncated/damaged stream, {}x{}, {} bytes)",
                    decoded.rows_decoded,
                    params.columns,
                    params.rows.unwrap_or(0),
                    data.len()
                );
            }
            Ok(decoded.data)
        },
        Err(in_house_err) => {
            // A stream the in-house decoder couldn't make progress on: fall
            // back to the legacy fax crate before giving up.
            log::debug!("CCITT in-house decode declined ({in_house_err}); trying fax crate");
            decompress_with_fax(data, width, height_opt, params)
        },
    };

    match fax_result {
        Ok(mut output) => {
            if params.black_is_1 {
                invert_bilevel_pixels(&mut output);
            }
            Ok(output)
        },
        Err(e) => {
            // Both decoders failed. Do NOT silently return an all-white page
            // (the old behavior that produced the blank-page bug) — warn loudly
            // and surface a controlled white fallback only as a last resort so
            // the failure is visible in logs rather than masked as success.
            log::warn!(
                "CCITT decompression failed ({}x{}, {} bytes, K={}, EncodedByteAlign={}): {} — substituting blank image (DECODE FAILED, not a blank scan)",
                params.columns,
                params.rows.unwrap_or(0),
                data.len(),
                params.k,
                params.encoded_byte_align,
                e
            );
            let bytes_per_row = (width as usize).div_ceil(8);
            let rows = usize::try_from(params.rows.unwrap_or(1)).map_err(|_| {
                Error::Decode("CCITT row count exceeds platform limits".to_string())
            })?;
            let expected_bytes = rows
                .checked_mul(bytes_per_row)
                .ok_or_else(|| Error::Decode("CCITT fallback size overflow".to_string()))?;
            let fallback_len = expected_bytes.max(bytes_per_row);
            let mut fallback = Vec::new();
            fallback.try_reserve_exact(fallback_len).map_err(|_| {
                Error::Decode(format!("Unable to allocate {fallback_len} bytes for CCITT fallback"))
            })?;
            fallback.resize(fallback_len, 0);
            Ok(fallback)
        },
    }
}

/// Decompress CCITT data using the fax crate.
///
/// The fax crate is more lenient with malformed EOFB markers compared to ccitt-t4-t6,
/// which makes it better suited for handling real-world PDF files that don't strictly
/// comply with the CCITT specification.
fn decompress_with_fax(
    data: &[u8],
    width: u32,
    height: Option<u32>,
    params: &CcittParams,
) -> Result<Vec<u8>> {
    let width_usize = width as usize;

    log::debug!(
        "Attempting CCITT decompression with fax crate: width={}, height={:?}, data_len={}, K={}",
        width,
        height,
        data.len(),
        params.k
    );

    // Try with original data first
    match try_decode_with_fax(data, width_usize, height, params) {
        Ok(output) if !output.is_empty() => {
            return Ok(output);
        },
        Ok(_empty) => {
            log::debug!("First attempt returned no data, trying with leading zeros stripped");
        },
        Err(e) => {
            log::debug!("First attempt failed: {}, trying with leading zeros stripped", e);
        },
    }

    // If that failed, try stripping leading zeros (common in some PDFs)
    let first_nonzero = data
        .iter()
        .position(|byte| *byte != 0)
        .unwrap_or(data.len());
    let trimmed_len = data.len() - first_nonzero;
    let mut trimmed_data = Vec::new();
    trimmed_data.try_reserve_exact(trimmed_len).map_err(|_| {
        Error::Decode(format!("Unable to allocate {trimmed_len} bytes for trimmed CCITT input"))
    })?;
    trimmed_data.extend_from_slice(&data[first_nonzero..]);

    if trimmed_data.len() < data.len() && !trimmed_data.is_empty() {
        log::debug!(
            "Stripped {} leading zero bytes ({} -> {}), attempting decompression",
            data.len() - trimmed_data.len(),
            data.len(),
            trimmed_data.len()
        );

        log::debug!(
            "Data after stripping zeros, first 32 bytes: {}",
            trimmed_data
                .iter()
                .take(32)
                .map(|b| format!("{:02x}", b))
                .collect::<Vec<_>>()
                .join(" ")
        );

        match try_decode_with_fax(&trimmed_data, width_usize, height, params) {
            Ok(output) if !output.is_empty() => {
                log::trace!("Successfully decompressed after stripping leading zeros!");
                return Ok(output);
            },
            Ok(_) => {
                log::debug!("Strip attempt also returned no data");
            },
            Err(e) => {
                log::debug!("Strip attempt also failed: {}", e);
            },
        }
    }

    // Both attempts failed - return error
    Err(Error::Decode(
        "CCITT decompression failed: fax decoder returned no output".to_string(),
    ))
}

fn try_decode_with_fax(
    data: &[u8],
    width: usize,
    height: Option<u32>,
    params: &CcittParams,
) -> Result<Vec<u8>> {
    use fax::decoder;

    let bytes_per_row = width.div_ceil(8);
    let max_rows = height.map(|rows| rows as usize);
    let mut output = Vec::new();
    if let Some(rows) = max_rows {
        let capacity = bytes_per_row
            .checked_mul(rows)
            .ok_or_else(|| Error::Decode("CCITT fax output size overflow".to_string()))?;
        output.try_reserve_exact(capacity).map_err(|_| {
            Error::Decode(format!("Unable to allocate {capacity} bytes for CCITT fax output"))
        })?;
    }
    let mut rows_decoded = 0usize;

    // Use fax crate's decoder which is more lenient with malformed EOFB
    let bytes_iter = data.iter().copied();

    let success = if params.is_group_4() {
        log::debug!("Using Group 4 (T.6) decoder");
        let mut callback_error = None;
        let success =
            decoder::decode_g4(bytes_iter, width as u32, height, |transitions: &[u32]| {
                if callback_error.is_none() && max_rows.is_none_or(|rows| rows_decoded < rows) {
                    if let Err(error) = append_transition_row(&mut output, transitions, width) {
                        callback_error = Some(error);
                        return;
                    }
                    rows_decoded += 1;
                }
            });
        if let Some(error) = callback_error {
            return Err(error);
        }
        success
    } else {
        log::debug!("Using Group 3 (T.4) decoder");
        // Group 3 has a different signature - no width/height params in callback
        let mut callback_error = None;
        let success = decoder::decode_g3(bytes_iter, |transitions: &[u32]| {
            if callback_error.is_none() && max_rows.is_none_or(|rows| rows_decoded < rows) {
                if let Err(error) = append_transition_row(&mut output, transitions, width) {
                    callback_error = Some(error);
                    return;
                }
                rows_decoded += 1;
            }
        });
        if let Some(error) = callback_error {
            return Err(error);
        }
        success
    };

    // Check if decoder succeeded and returned data
    if success.is_some() && !output.is_empty() {
        log::debug!(
            "CCITT decompression successful: {} bytes input -> {} bytes output ({} rows)",
            data.len(),
            output.len(),
            rows_decoded
        );
        Ok(output)
    } else if success.is_some() {
        // Decoder succeeded but produced no output - unusual but valid
        log::debug!("CCITT decoder returned success but no rows produced");
        Ok(Vec::new())
    } else {
        // Decoder failed
        log::warn!("CCITT fax decoder returned None");
        Err(Error::Decode("CCITT fax decoder failed".to_string()))
    }
}

/// Convert run-length transition positions to byte-packed pixels.
///
/// The transitions array contains positions where the color changes from white to black
/// or black to white, starting with white. For example, [3, 5, 8] means:
/// - Pixels 0-2: white
/// - Pixels 3-4: black
/// - Pixels 5-7: white
///
/// Generic over the transition scalar so both transition producers can share it
/// without converting a row: the in-house decoder emits `u16`, while the `fax`
/// crate emits `u32` (widened in fax 0.3). Positions are compared in `usize`,
/// so neither width is truncated.
pub(crate) fn transitions_to_bytes<T: Copy + Into<u32>>(
    transitions: &[T],
    width: usize,
) -> Result<Vec<u8>> {
    let bytes_per_row = width.div_ceil(8);
    let mut row_bytes = Vec::new();
    row_bytes.try_reserve_exact(bytes_per_row).map_err(|_| {
        Error::Decode(format!("Unable to allocate {bytes_per_row} bytes for a CCITT row"))
    })?;
    row_bytes.resize(bytes_per_row, 0);

    let mut is_black = false; // Start with white
    let mut start_pos: usize = 0;

    for &transition_pos in transitions {
        let transition_pos = Into::<u32>::into(transition_pos) as usize;
        if is_black {
            // Fill black pixels from start_pos to transition_pos
            for pixel_idx in start_pos..transition_pos.min(width) {
                let byte_idx = pixel_idx / 8;
                let bit_idx = 7 - (pixel_idx % 8);
                row_bytes[byte_idx] |= 1 << bit_idx;
            }
        }
        // Switch color for next run
        is_black = !is_black;
        start_pos = transition_pos;
    }

    // Handle remaining pixels in the last run
    if is_black && start_pos < width {
        for pixel_idx in start_pos..width {
            let byte_idx = pixel_idx / 8;
            let bit_idx = 7 - (pixel_idx % 8);
            row_bytes[byte_idx] |= 1 << bit_idx;
        }
    }

    Ok(row_bytes)
}

pub(crate) fn append_transition_row<T: Copy + Into<u32>>(
    output: &mut Vec<u8>,
    transitions: &[T],
    width: usize,
) -> Result<()> {
    let row = transitions_to_bytes(transitions, width)?;
    output.try_reserve(row.len()).map_err(|_| {
        Error::Decode(format!("Unable to grow CCITT output by {} bytes", row.len()))
    })?;
    output.extend_from_slice(&row);
    Ok(())
}

/// Decompresses CCITT Group 4 encoded data (legacy API for backwards compatibility).
///
/// This is a convenience function that uses default CCITT parameters.
#[deprecated(
    since = "0.1.5",
    note = "Use decompress_ccitt with CcittParams instead"
)]
pub fn decompress_ccitt_group4(data: &[u8], width: u32, height: u32) -> Result<Vec<u8>> {
    let params = CcittParams {
        columns: width,
        rows: Some(height),
        ..Default::default()
    };
    decompress_ccitt(data, &params)
}

/// Invert all bits in a bilevel image.
///
/// This is used when /BlackIs1=true to convert from:
/// - white=1, black=0 (inverted representation)
///
/// to standard PDF representation:
/// - white=0, black=1
fn invert_bilevel_pixels(data: &mut [u8]) {
    for byte in data.iter_mut() {
        *byte = !*byte;
    }
}

/// Convert 1-bit bilevel image to 8-bit grayscale.
///
/// Each bit in the input is expanded to a full byte where:
/// - 0 (white) -> 0xFF (white in 8-bit)
/// - 1 (black) -> 0x00 (black in 8-bit)
///
/// # Arguments
///
/// * `bilevel_data` - Packed bilevel image data (1 bit per pixel)
/// * `width` - Image width in pixels
/// * `height` - Image height in pixels
///
/// # Returns
///
/// A vector of 8-bit grayscale pixels suitable for image processing and OCR.
pub fn bilevel_to_grayscale(bilevel_data: &[u8], width: u32, height: u32) -> Vec<u8> {
    let width = width as usize;
    let height = height as usize;
    let mut grayscale = Vec::with_capacity(width * height);

    for row_idx in 0..height {
        // Each row in bilevel data is padded to byte boundary
        let row_start = row_idx * width.div_ceil(8);

        for col_idx in 0..width {
            let byte_idx = row_start + (col_idx / 8);
            if byte_idx < bilevel_data.len() {
                let bit_pos = 7 - (col_idx % 8);
                let bit = (bilevel_data[byte_idx] >> bit_pos) & 1;
                // 0 (white) -> 0xFF, 1 (black) -> 0x00
                // Standard interpretation for CCITT/fax images
                grayscale.push(if bit == 0 { 0xFF } else { 0x00 });
            } else {
                // Out of bounds - default to white
                grayscale.push(0xFF);
            }
        }
    }

    grayscale
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_bilevel_to_grayscale() {
        // Test converting 1-bit bilevel to 8-bit grayscale
        // Pattern: 10000001 (black, white, white, white, white, white, white, black)
        let bilevel = vec![0b10000001];
        let grayscale = bilevel_to_grayscale(&bilevel, 8, 1);

        assert_eq!(grayscale.len(), 8);
        assert_eq!(grayscale[0], 0x00, "Pixel 0 should be black");
        assert_eq!(grayscale[1], 0xFF, "Pixel 1 should be white");
        assert_eq!(grayscale[7], 0x00, "Pixel 7 should be black");
    }

    #[test]
    fn test_bilevel_to_grayscale_padding() {
        // Test with non-byte-aligned width
        // Pattern: 10000001
        // Pixels 0-4: 1=black, 0=white, 0=white, 0=white, 0=white
        let bilevel = vec![0b10000001];
        let grayscale = bilevel_to_grayscale(&bilevel, 5, 1);

        assert_eq!(grayscale.len(), 5);
        assert_eq!(grayscale[0], 0x00); // bit 7 = 1 (black)
        assert_eq!(grayscale[1], 0xFF); // bit 6 = 0 (white)
        assert_eq!(grayscale[4], 0xFF); // bit 3 = 0 (white)
    }

    #[test]
    fn test_transitions_to_bytes() {
        // Test transitions to build pattern: WW|BBB|WW|B
        // Transitions at positions [2, 5, 7]:
        // - White from 0-2 (2 pixels)
        // - Black from 2-5 (3 pixels)
        // - White from 5-7 (2 pixels)
        // - Black from 7-8 (1 pixel)
        // Should produce: 0b00111001 = 57 (0x39)
        // Exercised for both transition scalars: the in-house decoder emits
        // u16, the fax crate (0.3+) emits u32, and both share this packer.
        let row_u16 = transitions_to_bytes(&[2u16, 5, 7], 8).expect("pack u16 row");
        assert_eq!(row_u16.len(), 1);
        assert_eq!(row_u16[0], 0b00111001);

        let row_u32 = transitions_to_bytes(&[2u32, 5, 7], 8).expect("pack u32 row");
        assert_eq!(row_u32, row_u16, "u32 and u16 transitions must pack identically");
    }

    /// A transition beyond u16::MAX must not wrap: fax 0.3 widened transitions
    /// to u32, and positions are compared in usize.
    #[test]
    fn test_transitions_to_bytes_beyond_u16() {
        let width = 70_000usize;
        // black run from 65_536 to 65_544 - a position that u16 could not hold
        let row = transitions_to_bytes(&[65_536u32, 65_544], width).expect("pack wide row");
        assert_eq!(row.len(), width.div_ceil(8));
        assert_eq!(row[65_536 / 8], 0xFF, "the 8 pixels at 65536.. must be black");
        assert!(row[..65_536 / 8].iter().all(|&b| b == 0), "everything before must stay white");
    }
}