diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index c7c8bca9e..0d65519ec 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -225,7 +225,7 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize } // One whitespace byte separates ID from the data. let begin = (lx.pos + 1).min(data.len()); - let end = unfiltered_image_end(data, begin, &dict).or_else(|| end_of_image(data, begin)); + let end = unfiltered_image_end(data, begin, &dict).or_else(|| declared_image_end(data, begin, &dict)).or_else(|| image_data_end(data, begin)); Ok(match end { Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end), None => (dict, data[begin..].to_vec(), data.len()), @@ -302,6 +302,42 @@ fn unfiltered_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize if data.get(ei..end) == Some(b"EI") && data.get(end).is_none_or(|b| is_ws(*b) || is_delim(*b)) { Some((end_data, end)) } else { None } } +/// The length declared by `/L` (or `/Length`, ISO 32000-2 ยง8.9.7), used when `EI` follows the +/// declared bytes after optional whitespace. A declared length without that `EI` is ignored. +fn declared_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize, usize)> { + let length = dict.get(b"L").or_else(|| dict.get(b"Length"))?.as_int()?; + let end_data = begin.checked_add(usize::try_from(length).ok()?)?; + let after = data.get(end_data..)?; + let ei = end_data.checked_add(after.iter().position(|b| !is_ws(*b))?)?; + let end = ei.checked_add(2)?; + if data.get(ei..end) == Some(b"EI") && data.get(end).is_none_or(|b| is_ws(*b) || is_delim(*b)) { Some((end_data, end)) } else { None } +} + +/// Where inline image data of unknown length ends. A candidate `EI` followed by binary bytes before +/// the next `EI` lies inside the (compressed) data, unless another inline image starts first. +fn image_data_end(data: &[u8], begin: usize) -> Option<(usize, usize)> { + let mut candidate = end_of_image(data, begin)?; + loop { + let (end_data, end) = candidate; + let Some(next) = end_of_image(data, end) else { + return Some(candidate); + }; + let between = data.get(end..next.0).unwrap_or_default(); + if !between.iter().any(|b| !b.is_ascii()) || starts_inline_image(between) { + return Some((end_data, end)); + } + candidate = next; + } +} + +/// Whether `bytes` hold a whitespace-delimited `BI` operator. +fn starts_inline_image(bytes: &[u8]) -> bool { + bytes + .windows(2) + .enumerate() + .any(|(i, pair)| pair == b"BI" && (i == 0 || is_ws(bytes[i - 1])) && bytes.get(i + 2).is_none_or(|b| is_ws(*b) || is_delim(*b))) +} + /// Where inline image data starting at `begin` ends: (end of the data, position after `EI`). The /// data ends at whitespace + `EI` + (whitespace, a delimiter or the end). fn end_of_image(data: &[u8], begin: usize) -> Option<(usize, usize)> { diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 1119bdaf9..599e6696e 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -151,6 +151,32 @@ fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { } } +/// A zlib stream (one stored block) for 64 DeviceGray samples whose bytes hold " EI\n" at offset 41, +/// the way a stored deflate block copies incompressible samples verbatim (#3579). +const FLATE_WITH_EI: &str = "789c014000bfffed746159d50788f7ea604984cd08e00b4c8983207f56fe82c77ef8dfb8b9a3a586382045490a3120\ + 31206c2025630aaa4353e6813e233b96539c18bc3a2c5009e9d51b81"; + +#[test] +fn flate_inline_image_is_not_cut_at_an_ei_inside_its_compressed_bytes() { + let flate: Vec = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect(); + assert_eq!(flate.len(), 75); + assert_eq!(&flate[41..45], b" EI\n"); + for parameters in [b"/W 64 /H 1 /BPC 8 /CS /G /F /Fl".as_slice(), b"/W 64 /H 1 /BPC 8 /CS /G /F /Fl /L 75"] { + let mut src = b"q 200 0 0 200 100 300 cm BI ".to_vec(); + src.extend_from_slice(parameters); + src.extend_from_slice(b" ID "); + src.extend_from_slice(&flate); + src.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET"); + let parsed = parse(&src); + let label = String::from_utf8_lossy(parameters); + assert_eq!(parsed.ops[2].inline.as_ref().unwrap().1.len(), 75, "{label}"); + let names: Vec<&[u8]> = parsed.ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!(names, [&b"q"[..], b"cm", b"BI", b"Q", b"BT", b"Tf", b"Td", b"Tj", b"ET"], "{label}"); + assert_eq!(parsed.skipped, 0, "{label}"); + assert_eq!(parse(&serialize_ops(&parsed.ops)).ops[2].inline, parsed.ops[2].inline, "{label}"); + } +} + fn inline_pdf(content: &[u8], object_streams: bool) -> Vec { use pdfcraft_cos::{Document, SaveOptions, Stream, write_full};