//! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel //! whose footprint intersects a region is set to zero (no ink for image masks), and the result is //! written as a new Flate image; the original object is untouched (other pages may use it). //! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the //! caller removes the whole image instead; that is fail-closed. use pdfcraft_content::{Matrix, overlaps}; use pdfcraft_cos::{Document, Object, Stream}; /// Most pixels an image may have to be cleared in place (larger ones are removed). const MAX_PIXELS: u64 = 64 << 20; fn components(doc: &Document, s: &Stream) -> Option { let cs = doc.resolve(s.dict.get(b"ColorSpace")?); let cs = &*cs; let name = match cs { Object::Name(n) => n.as_slice(), Object::Array(a) => a.first()?.as_name()?, _ => return None, }; Some(match name { b"DeviceGray" | b"G" | b"CalGray" | b"Indexed" | b"I" | b"Separation" => 1, b"DeviceRGB" | b"RGB" | b"CalRGB" | b"Lab" => 3, b"DeviceCMYK" | b"CMYK" => 4, b"ICCBased" => { // /N lives in the ICC profile stream, and shall be 1, 3 or 4 (ISO 32000-2 ยง8.6.5.5). match &*doc.resolve(cs.as_array()?.get(1)?) { Object::Stream(icc) => usize::try_from(icc.dict.int(b"N")?).ok().filter(|n| matches!(n, 1 | 3 | 4))?, _ => return None, } } _ => return None, }) } /// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects, /// `Err(())` when the image can't be cleared (the caller removes it). #[allow(clippy::result_unit_err)] pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result, ()> { decode_and_clear(doc, s, ctm, rects).ok_or(()) } fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Option> { // Separate masks can retain the original image data. if s.dict.contains(b"SMask") || s.dict.contains(b"Mask") { return None; } let supported = |n: &[u8]| { matches!( n, b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL" ) }; let ok = match s.dict.get(b"Filter") { None => true, Some(Object::Name(n)) => supported(n), Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some_and(supported)), _ => false, }; if !ok { return None; } let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; } let (w, h) = (w as usize, h as usize); let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))); let (ncomp, bpc) = if mask { (1, 1) } else { (components(doc, s)?, s.dict.int(b"BitsPerComponent")? as usize) }; if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { return None; } // Every size comes from the file: multiply checked, so an overflowing product refuses the image // instead of sizing the writes below. let bits_per_pixel = ncomp.checked_mul(bpc)?; let row = w.checked_mul(bits_per_pixel)?.div_ceil(8); let bytes = row.checked_mul(h)?; let row_bits = row.checked_mul(8)?; bytes.checked_mul(8)?; // Decode no further than the raster can need: a filter stage may legitimately produce up to // twice it (ASCIIHex text under Flate) plus a PNG predictor's byte per row. Data past that // can only be a decompression bomb; the image is then removed whole. let allowance = bytes.checked_add(h)?.checked_mul(2)?.checked_add(4096)?; let mut data = s.decoded_within(allowance).ok()?; if data.len() < bytes { return None; } data.truncate(bytes); // Image masks: a sample of 0 paints unless /Decode is [1 0]; cleared samples must not paint. let clear_bit = if mask { let inverted = s.dict.get(b"Decode").and_then(Object::as_array).and_then(|d| d.first()).and_then(Object::as_f64) == Some(1.0); !inverted } else { false }; let mut cleared = 0usize; for j in 0..h { // Image space has its origin at the bottom left; row 0 is at the top. let v0 = 1.0 - (j + 1) as f64 / h as f64; let v1 = 1.0 - j as f64 / h as f64; let row_box = ctm.bbox([0.0, v0, 1.0, v1]); if !rects.iter().any(|r| overlaps(*r, row_box, 0.0)) { continue; } for i in 0..w { let u0 = i as f64 / w as f64; let u1 = (i + 1) as f64 / w as f64; // The bounding box may cover extra pixels for a rotated or skewed image; that // conservative over-clear prevents a touched sample surviving in the file. let pixel_box = ctm.bbox([u0, v0, u1, v1]); if !rects.iter().any(|r| overlaps(*r, pixel_box, 0.0)) { continue; } cleared += 1; // `bytes * 8` fits, and every bit index is below it, so this arithmetic can't overflow. let start = j * row_bits + i * bits_per_pixel; for bit in start..start + bits_per_pixel { let (byte, shift) = (bit / 8, 7 - bit % 8); let b = data.get_mut(byte)?; if clear_bit { *b |= 1 << shift; } else { *b &= !(1 << shift); } } } } if cleared == 0 { return Some(None); } let mut dict = s.dict.clone(); dict.remove(b"Length"); dict.remove(b"DecodeParms"); dict.remove(b"Alternates"); Some(Some(Stream::flate(dict, &data))) }