From d1b0293a5d9cce58126e60a6f673027dd4a80d4b Mon Sep 17 00:00:00 2001 From: Ozt-y Date: Sun, 18 Oct 2026 19:05:46 +0530 Subject: [PATCH 1/3] fix(content): accept inline image EI without preceding whitespace (#3108) --- crates/content/src/lib.rs | 8 ++++---- crates/content/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index c7c8bca9e..143387c59 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -293,23 +293,23 @@ fn unfiltered_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize let row = width.checked_mul(components)?.checked_mul(bits)?.div_ceil(8); let end_data = begin.checked_add(row.checked_mul(height)?)?; let after = data.get(end_data..)?; - if !after.first().is_some_and(|b| is_ws(*b)) { + let separator = after.iter().position(|b| !is_ws(*b))?; + if separator == 0 && data.get(end_data.checked_sub(1)?).is_some_and(|b| is_ws(*b)) { return None; } - let separator = after.iter().position(|b| !is_ws(*b))?; let ei = end_data.checked_add(separator)?; 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 starting at `begin` ends: (end of the data, position after `EI`). The -/// data ends at whitespace + `EI` + (whitespace, a delimiter or the end). +/// data ends at whitespace or `>` + `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' && data.get(i + 1) == Some(&b'I') - && (i == begin || is_ws(data[i - 1])) + && (i == begin || is_ws(data[i - 1]) || data[i - 1] == b'>') && 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 }; diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 1119bdaf9..2ce806fba 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -388,3 +388,28 @@ fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() { assert_eq!(empty.piece_of(0), 0); assert_eq!(empty.piece_of(usize::MAX), 0); } + +#[test] +fn inline_image_terminator_without_preceding_whitespace() { + let cases: &[(&[u8], &[u8])] = &[ + (b"BI /W 4 /H 1 /BPC 8 /CS /G /F /AHx ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /Filter /ASCIIHexDecode ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab()EI Q BT /F1 18 Tf (After) Tj ET", b"ab()"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab[]EI Q BT /F1 18 Tf (After) Tj ET", b"ab[]"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G ID \x20\x60\xa0\xe0EI Q BT /F1 18 Tf (After) Tj ET", b"\x20\x60\xa0\xe0"), + ]; + + for (src, expected_data) in cases { + let p = parse(src); + assert_eq!(p.skipped, 0, "stream should have 0 skipped bytes: {}", String::from_utf8_lossy(src)); + let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!( + names, + [&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"], + "operators after inline image must be preserved: {}", + String::from_utf8_lossy(src) + ); + let (_, img) = p.ops[0].inline.as_ref().unwrap(); + assert_eq!(img, expected_data, "inline image data must match: {}", String::from_utf8_lossy(src)); + } +} From 494567771767b4d8c39c25fd359ce19bf5220ee4 Mon Sep 17 00:00:00 2001 From: Ozt-y Date: Sun, 18 Oct 2026 19:32:09 +0530 Subject: [PATCH 2/3] fix(content): restrict '>EI' terminator detection to ASCIIHex filter --- crates/content/src/lib.rs | 25 ++++++++++++++++++++----- crates/content/src/tests.rs | 11 +++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 143387c59..be6e7c36c 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -219,19 +219,34 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize return Ok((dict, Vec::new(), data.len())); } // Without an `EI` after it, the image runs to the end of the stream. - return Err(end_of_image(data, at).map_or(data.len(), |(_, end)| end)); + return Err(end_of_image(data, at, false).map_or(data.len(), |(_, end)| end)); }; dict.set(k, v); } // 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(|| end_of_image(data, begin, is_ascii_hex_filter(&dict))); Ok(match end { Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end), None => (dict, data[begin..].to_vec(), data.len()), }) } +fn is_ascii_hex_filter(dict: &Dict) -> bool { + for key in [b"Filter".as_slice(), b"F"] { + if let Some(filter) = dict.get(key) { + match filter { + Object::Name(name) => return matches!(name.as_slice(), b"ASCIIHexDecode" | b"AHx"), + Object::Array(filters) => { + return matches!(filters.first().and_then(Object::as_name), Some(b"ASCIIHexDecode" | b"AHx")); + } + _ => return false, + } + } + } + false +} + /// An unfiltered image's samples determine its length, including byte padding per row. /// Binary samples may themselves contain whitespace + `EI` + whitespace, so use the known /// length when the actual terminator follows it. Unknown or damaged layouts keep the heuristic. @@ -303,13 +318,13 @@ fn unfiltered_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize } /// Where inline image data starting at `begin` ends: (end of the data, position after `EI`). The -/// data ends at whitespace or `>` + `EI` + (whitespace, a delimiter or the end). -fn end_of_image(data: &[u8], begin: usize) -> Option<(usize, usize)> { +/// data ends at whitespace (or `>` for ASCIIHex) + `EI` + (whitespace, a delimiter or the end). +fn end_of_image(data: &[u8], begin: usize, allow_gt_ei: bool) -> Option<(usize, usize)> { let mut i = begin; while i + 2 <= data.len() { if data[i] == b'E' && data.get(i + 1) == Some(&b'I') - && (i == begin || is_ws(data[i - 1]) || data[i - 1] == b'>') + && (i == begin || is_ws(data[i - 1]) || (allow_gt_ei && data[i - 1] == b'>')) && 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 }; diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 2ce806fba..e450d5a5f 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -413,3 +413,14 @@ fn inline_image_terminator_without_preceding_whitespace() { assert_eq!(img, expected_data, "inline image data must match: {}", String::from_utf8_lossy(src)); } } + +#[test] +fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() { + let src = b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET"; + let p = parse(src); + assert_eq!(p.skipped, 0); + let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!(names, [&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"]); + let (_, img) = p.ops[0].inline.as_ref().unwrap(); + assert_eq!(img, b"!!!!>EI !!~>"); +} From 9451e0652c79bc827c4b08092dbfebd17c1c3ba5 Mon Sep 17 00:00:00 2001 From: Ozt-y Date: Tue, 20 Oct 2026 18:57:43 +0530 Subject: [PATCH 3/3] fix(content): recognize ~>EI terminator for ASCII85 inline images and cover filter arrays --- crates/content/src/lib.rs | 33 +++++++++++++++++++-------------- crates/content/src/tests.rs | 23 ++++++++++++++++------- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index be6e7c36c..b65d157a0 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -219,32 +219,36 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize return Ok((dict, Vec::new(), data.len())); } // Without an `EI` after it, the image runs to the end of the stream. - return Err(end_of_image(data, at, false).map_or(data.len(), |(_, end)| end)); + return Err(end_of_image(data, at, b"").map_or(data.len(), |(_, end)| end)); }; dict.set(k, v); } // 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, is_ascii_hex_filter(&dict))); + let end = unfiltered_image_end(data, begin, &dict).or_else(|| end_of_image(data, begin, filter_eod(&dict))); Ok(match end { Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end), None => (dict, data[begin..].to_vec(), data.len()), }) } -fn is_ascii_hex_filter(dict: &Dict) -> bool { +/// The end-of-data marker (`>` for ASCIIHex, `~>` for ASCII85) of the outer stream filter, if any. +fn filter_eod(dict: &Dict) -> &'static [u8] { for key in [b"Filter".as_slice(), b"F"] { if let Some(filter) = dict.get(key) { - match filter { - Object::Name(name) => return matches!(name.as_slice(), b"ASCIIHexDecode" | b"AHx"), - Object::Array(filters) => { - return matches!(filters.first().and_then(Object::as_name), Some(b"ASCIIHexDecode" | b"AHx")); - } - _ => return false, - } + let name = match filter { + Object::Name(name) => Some(name.as_slice()), + Object::Array(filters) => filters.first().and_then(Object::as_name), + _ => None, + }; + return match name { + Some(b"ASCIIHexDecode" | b"AHx") => b">", + Some(b"ASCII85Decode" | b"A85") => b"~>", + _ => b"", + }; } } - false + b"" } /// An unfiltered image's samples determine its length, including byte padding per row. @@ -318,13 +322,14 @@ fn unfiltered_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize } /// Where inline image data starting at `begin` ends: (end of the data, position after `EI`). The -/// data ends at whitespace (or `>` for ASCIIHex) + `EI` + (whitespace, a delimiter or the end). -fn end_of_image(data: &[u8], begin: usize, allow_gt_ei: bool) -> Option<(usize, usize)> { +/// data ends at whitespace (or `eod`: `>` for ASCIIHex, `~>` for ASCII85) + `EI` + (whitespace, +/// a delimiter or the end). +fn end_of_image(data: &[u8], begin: usize, eod: &[u8]) -> Option<(usize, usize)> { let mut i = begin; while i + 2 <= data.len() { if data[i] == b'E' && data.get(i + 1) == Some(&b'I') - && (i == begin || is_ws(data[i - 1]) || (allow_gt_ei && data[i - 1] == b'>')) + && (i == begin || is_ws(data[i - 1]) || (!eod.is_empty() && data[begin..i].ends_with(eod))) && 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 }; diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index e450d5a5f..911cce53d 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -394,6 +394,11 @@ fn inline_image_terminator_without_preceding_whitespace() { let cases: &[(&[u8], &[u8])] = &[ (b"BI /W 4 /H 1 /BPC 8 /CS /G /F /AHx ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"), (b"BI /W 4 /H 1 /BPC 8 /CS /G /Filter /ASCIIHexDecode ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/AHx] ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/AHx /RL] ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /Filter /ASCII85Decode ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/A85 /RL] ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"), (b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab()EI Q BT /F1 18 Tf (After) Tj ET", b"ab()"), (b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab[]EI Q BT /F1 18 Tf (After) Tj ET", b"ab[]"), (b"BI /W 4 /H 1 /BPC 8 /CS /G ID \x20\x60\xa0\xe0EI Q BT /F1 18 Tf (After) Tj ET", b"\x20\x60\xa0\xe0"), @@ -416,11 +421,15 @@ fn inline_image_terminator_without_preceding_whitespace() { #[test] fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() { - let src = b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET"; - let p = parse(src); - assert_eq!(p.skipped, 0); - let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect(); - assert_eq!(names, [&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"]); - let (_, img) = p.ops[0].inline.as_ref().unwrap(); - assert_eq!(img, b"!!!!>EI !!~>"); + for src in [ + b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET".as_slice(), + b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/A85 /AHx] ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET".as_slice(), + ] { + let p = parse(src); + assert_eq!(p.skipped, 0); + let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!(names, [&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"]); + let (_, img) = p.ops[0].inline.as_ref().unwrap(); + assert_eq!(img, b"!!!!>EI !!~>"); + } }