From 240ea53b8b336c4f040e056354584f9241965d31 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:53:44 +0200 Subject: [PATCH] fix(content): skip an inline image with an unreadable key to its EI (#1318) (#1404) When a key in an inline image dictionary was not a name (`BI /W 4 H 4 ID ... EI`), the parser ended the image at the end of the stream and counted nothing as skipped, so every operator after the image was lost: text_lines found no text on a page whose text follows such an image. An image whose parameters can't be read is now skipped up to the EI that ends it, the skipped bytes are counted, and parsing resumes after it. Without an EI the image still runs to the end of the stream, as before. Adds tests::an_unreadable_inline_image_is_skipped_to_its_end, which fails before this change. --- crates/content/src/lib.rs | 52 ++++++++++++++++++++++++++----------- crates/content/src/tests.rs | 14 ++++++++++ 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index a56c952d..61845527 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -125,12 +125,19 @@ pub fn parse(data: &[u8]) -> Parsed { start.get_or_insert(at); operands.push(Object::Null); } - b"BI" => { - let (dict, img, end) = inline_image(data, lx.pos); - lx.pos = end; - out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end }); - operands.clear(); - } + b"BI" => match inline_image(data, lx.pos) { + Ok((dict, img, end)) => { + lx.pos = end; + out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end }); + operands.clear(); + } + Err(end) => { + out.skipped += end.saturating_sub(at); + lx.pos = end; + start = None; + operands.clear(); + } + }, _ => { let s = start.take().unwrap_or(at); out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos }); @@ -141,8 +148,9 @@ pub fn parse(data: &[u8]) -> Parsed { out } -/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). -fn inline_image(data: &[u8], pos: usize) -> (Dict, Vec, usize) { +/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds +/// the position after the `EI` of an image whose parameters can't be read. +fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize> { let mut lx = Lexer::new(data, pos); let mut dict = Dict::new(); loop { @@ -150,17 +158,31 @@ fn inline_image(data: &[u8], pos: usize) -> (Dict, Vec, usize) { if lx.eat_keyword(b"ID") { break; } - let Ok(Object::Name(k)) = lx.object() else { - return (dict, Vec::new(), data.len()); + let at = lx.pos; + let entry = match lx.object() { + Ok(Object::Name(k)) => lx.object().ok().map(|v| (k, v)), + _ => None, }; - let Ok(v) = lx.object() else { - return (dict, Vec::new(), data.len()); + let Some((k, v)) = entry else { + // Without an `EI` after it, the image runs to the end of the stream, as before. + return match end_of_image(data, at) { + Some((_, end)) => Err(end), + None => Ok((dict, Vec::new(), data.len())), + }; }; dict.set(k, v); } // One whitespace byte separates ID from the data. let begin = (lx.pos + 1).min(data.len()); - // The data ends at whitespace + `EI` + (whitespace or end). Search for that pattern. + Ok(match end_of_image(data, begin) { + Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end), + None => (dict, data[begin..].to_vec(), data.len()), + }) +} + +/// 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)> { let mut i = begin; while i + 2 <= data.len() { if data[i] == b'E' @@ -169,11 +191,11 @@ fn inline_image(data: &[u8], pos: usize) -> (Dict, Vec, usize) { && data.get(i + 2).is_none_or(|b| is_ws(*b) || is_delim(*b)) { let end_data = if i > begin && is_ws(data[i - 1]) { i - 1 } else { i }; - return (dict, data[begin..end_data].to_vec(), i + 2); + return Some((end_data, i + 2)); } i += 1; } - (dict, data[begin..].to_vec(), data.len()) + None } /// Write one operator (with a trailing newline). diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index e3c6d8d3..858c0e11 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -48,6 +48,20 @@ fn damage_is_skipped_not_fatal() { assert!(parse(b"BI /W 1").ops.len() == 1); } +/// Issue #1318: an inline image whose dictionary can't be read is skipped up to its `EI`, counted, +/// and what follows it is parsed. +#[test] +fn an_unreadable_inline_image_is_skipped_to_its_end() { + let src = b"q BI /W 4 H 4 ID aa EI Q BT (After) Tj ET"; + let p = parse(src); + let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!(names, [&b"q"[..], b"Q", b"BT", b"Tj", b"ET"]); + assert_eq!(p.skipped, b"BI /W 4 H 4 ID aa EI".len()); + let control = parse(b"q BI /W 4 /H 4 ID aa EI Q BT (After) Tj ET"); + assert_eq!(control.skipped, 0); + assert_eq!(control.ops.len(), 6); +} + #[test] fn matrices_compose_and_invert() { let a = Matrix([2.0, 0.0, 0.0, 3.0, 10.0, 20.0]);