Keep filtered inline images whole when their data contains EI

Fixes #3579

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-20 12:57:55 +00:00
co-authored by Claude Haiku 5.5
parent 6fb6b7cf6f
commit 07f3c97491
2 changed files with 63 additions and 1 deletions
+37 -1
View File
@@ -225,7 +225,7 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, 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)> {
+26
View File
@@ -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<u8> = (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<u8> {
use pdfcraft_cos::{Document, SaveOptions, Stream, write_full};