fix(render): use unfiltered inline image byte length before EI heuristic (#3107)

This commit is contained in:
jonathanwxh-cell
2026-10-19 17:33:48 +08:00
parent 43550c448d
commit f9d37c089f
2 changed files with 391 additions and 3 deletions
+155
View File
@@ -0,0 +1,155 @@
//! Regression tests for inline images whose unfiltered samples contain `"EI "` (#3107).
use std::fmt::Write as _;
use std::sync::Arc;
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
fn build_pdf(pages: &[Vec<u8>], extra_objects: &[(usize, Vec<u8>)], page_xobjects: &str) -> Vec<u8> {
let n = pages.len();
let font = 3 + 2 * n;
let mut objs: Vec<(usize, Vec<u8>)> = Vec::new();
objs.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
objs.push((2, format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" ")).into_bytes()));
for (i, content) in pages.iter().enumerate() {
let page_num = 3 + 2 * i;
let stream_num = 4 + 2 * i;
objs.push((
page_num,
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {stream_num} 0 R /Resources << /Font << /F1 {font} 0 R >> {page_xobjects} >> >>")
.into_bytes(),
));
let mut stm = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
stm.extend_from_slice(content);
stm.extend_from_slice(b"\nendstream");
objs.push((stream_num, stm));
}
objs.push((font, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec()));
for (id, body) in extra_objects {
objs.push((*id, body.clone()));
}
objs.sort_by_key(|(id, _)| *id);
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::with_capacity(objs.len());
for (id, body) in &objs {
offs.push(out.len());
out.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
let xref_offset = out.len();
let size = objs.len() + 1;
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for off in offs {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes());
out
}
fn page_stream(image: &[u8], n: usize) -> Vec<u8> {
let mut s = b"q 200 0 0 200 100 300 cm ".to_vec();
s.extend_from_slice(image);
s.extend_from_slice(format!(" Q BT /F1 18 Tf 72 700 Td (After image P{n}) Tj ET").as_bytes());
s
}
fn mean_grey_in_box(rgba: &[u8], width: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> f64 {
let mut sum = 0u64;
let mut count = 0u64;
for y in y0..y1 {
for x in x0..x1 {
let idx = ((y * width + x) * 4) as usize;
sum += u64::from(rgba[idx]);
count += 1;
}
}
sum as f64 / count.max(1) as f64
}
fn dark_pixels_in_box(rgba: &[u8], width: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
let mut dark = 0usize;
for y in y0..y1 {
for x in x0..x1 {
let idx = ((y * width + x) * 4) as usize;
if rgba[idx] < 128 && rgba[idx + 1] < 128 && rgba[idx + 2] < 128 {
dark += 1;
}
}
}
dark
}
#[test]
fn unfiltered_inline_image_samples_containing_ei_are_not_executed_as_content() {
let samples = b"0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q";
let w = samples.len();
let mut hexed = String::with_capacity(w * 2 + 1);
for b in samples {
let _ = write!(hexed, "{b:02X}");
}
hexed.push('>');
let mut img1 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /L {w} ID ").into_bytes();
img1.extend_from_slice(samples);
img1.extend_from_slice(b" EI");
let mut img2 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /L {w} ID 0AB").into_bytes();
img2.extend_from_slice(&samples[3..]);
img2.extend_from_slice(b" EI");
let img3 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /F /AHx ID {hexed} EI").into_bytes();
let img4 = b"BI /W 4 /H 1 /BPC 8 /CS /G ID \x20\x60\xa0\xe0 EI".to_vec();
let pdf = Arc::new(build_pdf(&[page_stream(&img1, 1), page_stream(&img2, 2), page_stream(&img3, 3), page_stream(&img4, 4)], &[], ""));
let mut renderer = PageRenderer::new(Arc::clone(&pdf), RenderConfig::default());
let mut greys = Vec::new();
for page_idx in 0..4 {
let text_page = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(text_page.error.is_none(), "page {} text error: {:?}", page_idx + 1, text_page.error);
let text = text_page.text.expect("page text");
let plain = text.plain_text();
assert_eq!(plain.trim(), format!("After image P{}", page_idx + 1), "page {}", page_idx + 1);
assert!(text.find("Injected").is_empty(), "page {} must not match Injected in Find", page_idx + 1);
let rendered = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(rendered.error.is_none(), "page {} render error: {:?}", page_idx + 1, rendered.error);
let dark = dark_pixels_in_box(&rendered.rgba, rendered.width, 72, 172, 200, 196);
assert_eq!(dark, 0, "page {} must not draw Injected at (72, 600)", page_idx + 1);
let grey = mean_grey_in_box(&rendered.rgba, rendered.width, 110, 302, 290, 482);
greys.push(grey);
}
// Page 1 and Page 3 have the exact same 47 samples (unfiltered vs ASCIIHexDecode).
assert!((greys[0] - greys[2]).abs() < 1.0, "page 1 mean grey ({}) must match page 3 ({})", greys[0], greys[2]);
assert!((greys[0] - greys[1]).abs() < 2.0, "page 1 mean grey ({}) must match control page 2 ({})", greys[0], greys[1]);
assert!((greys[3] - 128.0).abs() < 2.0, "page 4 control mean grey ({})", greys[3]);
}
#[test]
fn form_xobject_inline_image_with_ei_in_samples_keeps_all_samples() {
let form_content = b"q 200 0 0 200 100 300 cm BI /W 12 /H 1 /BPC 8 /CS /G ID 0EI 1 0 0 rg EI Q BT /F1 18 Tf 72 700 Td (Form after) Tj ET";
let mut form_obj = format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Length {} >>\nstream\n",
form_content.len()
)
.into_bytes();
form_obj.extend_from_slice(form_content);
form_obj.extend_from_slice(b"\nendstream");
let page_content = b"/Fm1 Do".to_vec();
// 1 page => font is object 5, form XObject is object 6.
let pdf = Arc::new(build_pdf(&[page_content], &[(6, form_obj)], "/XObject << /Fm1 6 0 R >>"));
let mut renderer = PageRenderer::new(Arc::clone(&pdf), RenderConfig::default());
let rendered = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
let grey = mean_grey_in_box(&rendered.rgba, rendered.width, 110, 302, 290, 482);
assert!((grey - 54.37).abs() < 1.0, "form inline image mean grey {grey} must be ~54.37 (not ~2 from 1-byte cut)");
}
+236 -3
View File
@@ -35,12 +35,12 @@ pub mod ops;
use crate::content::ops::TypedInstruction;
use crate::object;
use crate::object::dict::InlineImageDict;
use crate::object::dict::{Dict, InlineImageDict};
use crate::object::name::{Name, skip_name_like};
use crate::object::{Array, Null, Number, Object, Stream};
use crate::reader::Reader;
use crate::reader::{Readable, ReaderContext, ReaderExt, Skippable};
use crate::trivia::{Comment, is_white_space_character};
use crate::trivia::{Comment, is_regular_character, is_white_space_character};
use crate::util::find_needle;
use core::array;
use core::fmt::{Debug, Formatter};
@@ -242,7 +242,28 @@ impl<'a> UntypedIter<'a> {
let mut lookahead_touched = false;
let mut accepted = false;
'outer: while let Some(pos) = find_needle(self.reader.tail()?, b"EI") {
// PdfCraft patch: an unfiltered inline image's byte length is determined by
// its parameters (and `/L` or `/Length` when present and consistent), so
// `EI` inside its samples does not cut the image short (#3107).
match unfiltered_image_end(stream_data, &dict, self.complete) {
UnfilteredEnd::Found(end_data, end_after_ei) => {
let image_data = stream_data.get(..end_data)?;
let stream = Stream::new(image_data, dict.clone());
self.stack.push(Object::Stream(stream))?;
self.reader.read_bytes(end_after_ei)?;
self.reader.skip_white_spaces();
accepted = true;
}
UnfilteredEnd::NeedsMore => {
self.needs_more = true;
return None;
}
UnfilteredEnd::Fallback => {}
}
'outer: while !accepted
&& let Some(pos) = find_needle(self.reader.tail()?, b"EI")
{
self.reader.read_bytes(pos)?;
if self.reader.peek_bytes(2) == Some(b"EI") {
@@ -852,6 +873,166 @@ mod macros {
pub(crate) use op6;
}
/// PdfCraft patch: outcome of checking an unfiltered inline image's deterministic byte length.
enum UnfilteredEnd {
/// Deterministic end found: `(end_data, end_after_ei)`.
Found(usize, usize),
/// Window ran out before the terminator after the expected sample bytes could be decided.
NeedsMore,
/// Not an unfiltered image with a valid terminator at its computed length; use the heuristic.
Fallback,
}
/// PdfCraft patch: an unfiltered inline image's sample byte count, including byte padding per row,
/// and `/Length` or `/L` when present and consistent (#3107).
fn unfiltered_image_len(dict: &Dict<'_>) -> Option<usize> {
for key in [b"Filter".as_slice(), b"F"] {
if let Some(filter) = dict.get::<Object<'_>>(key)
&& !matches!(filter, Object::Null(_))
&& !matches!(&filter, Object::Array(filters) if filters.iter::<Object<'_>>().next().is_none())
{
return None;
}
}
for (full, short) in [
(b"Width".as_slice(), b"W".as_slice()),
(b"Height", b"H"),
(b"ColorSpace", b"CS"),
(b"BitsPerComponent", b"BPC"),
(b"ImageMask", b"IM"),
(b"Length", b"L"),
] {
if let (Some(full), Some(short)) = (dict.get::<Object<'_>>(full), dict.get::<Object<'_>>(short))
&& full != short
{
return None;
}
}
let get = |full: &[u8], short: &[u8]| {
dict.get::<Object<'_>>(full)
.or_else(|| dict.get::<Object<'_>>(short))
};
let int = |obj: &Object<'_>| match obj {
Object::Number(n) => i32::try_from(n.as_i64())
.ok()
.filter(|&i| Number::from_i32(i) == *n)
.map(i64::from),
_ => None,
};
let width = usize::try_from(int(&get(b"Width", b"W")?)?)
.ok()
.filter(|n| *n > 0)?;
let height = usize::try_from(int(&get(b"Height", b"H")?)?)
.ok()
.filter(|n| *n > 0)?;
let mask = match get(b"ImageMask", b"IM") {
None | Some(Object::Null(_) | Object::Boolean(false)) => false,
Some(Object::Boolean(true)) => true,
_ => return None,
};
let bits = match get(b"BitsPerComponent", b"BPC") {
None | Some(Object::Null(_)) => None,
Some(value) => Some(int(&value)?),
};
let (components, bits) = if mask {
if bits.is_some_and(|bits| bits != 1) {
return None;
}
(1usize, 1usize)
} else {
let (components, max_bits) = match get(b"ColorSpace", b"CS")? {
Object::Name(name) => match name.as_ref() {
b"DeviceGray" | b"G" => (1, 16),
b"DeviceRGB" | b"RGB" => (3, 16),
b"DeviceCMYK" | b"CMYK" => (4, 16),
_ => return None,
},
Object::Array(space) => {
let mut items = space.iter::<Object<'_>>();
if !matches!(items.next(), Some(Object::Name(n)) if matches!(n.as_ref(), b"Indexed" | b"I"))
|| items.count() != 3
{
return None;
}
(1, 8)
}
_ => return None,
};
let bits = match bits? {
bits @ (1 | 2 | 4 | 8 | 16) => bits as usize,
_ => return None,
};
if bits > max_bits {
return None;
}
(components, bits)
};
let row = width.checked_mul(components)?.checked_mul(bits)?.div_ceil(8);
let len = row.checked_mul(height)?;
match get(b"Length", b"L") {
None | Some(Object::Null(_)) => {}
Some(value) if usize::try_from(int(&value)?).ok() == Some(len) => {}
Some(_) => return None,
}
Some(len)
}
/// PdfCraft patch: locate the deterministic `EI` terminator of an unfiltered inline image (#3107).
fn unfiltered_image_end(data: &[u8], dict: &Dict<'_>, complete: bool) -> UnfilteredEnd {
let Some(end_data) = unfiltered_image_len(dict) else {
return UnfilteredEnd::Fallback;
};
let Some(after) = data.get(end_data..) else {
return if complete {
UnfilteredEnd::Fallback
} else {
UnfilteredEnd::NeedsMore
};
};
let Some(&first) = after.first() else {
return if complete {
UnfilteredEnd::Fallback
} else {
UnfilteredEnd::NeedsMore
};
};
if !is_white_space_character(first) {
return UnfilteredEnd::Fallback;
}
let Some(separator) = after.iter().position(|&b| !is_white_space_character(b)) else {
return if complete {
UnfilteredEnd::Fallback
} else {
UnfilteredEnd::NeedsMore
};
};
let Some(rest) = after.get(separator..) else {
return UnfilteredEnd::Fallback;
};
if rest.first() != Some(&b'E') {
return UnfilteredEnd::Fallback;
}
let Some(&second) = rest.get(1) else {
return if complete {
UnfilteredEnd::Fallback
} else {
UnfilteredEnd::NeedsMore
};
};
if second != b'I' {
return UnfilteredEnd::Fallback;
}
let Some(end_after_ei) = end_data.checked_add(separator).and_then(|ei| ei.checked_add(2)) else {
return UnfilteredEnd::Fallback;
};
match rest.get(2) {
Some(&b) if !is_regular_character(b) => UnfilteredEnd::Found(end_data, end_after_ei),
Some(_) => UnfilteredEnd::Fallback,
None if complete => UnfilteredEnd::Found(end_data, end_after_ei),
None => UnfilteredEnd::NeedsMore,
}
}
#[cfg(test)]
mod tests {
use super::{ContentTokenizer, UntypedIter};
@@ -1014,4 +1195,56 @@ mod tests {
assert_eq!(windowed, expected, "{:?}", String::from_utf8_lossy(&data));
}
}
#[test]
fn unfiltered_inline_image_with_ei_in_samples_is_not_cut_early() {
// 48-byte unfiltered DeviceGray image whose first four bytes are `0EI `
// followed by valid page content operators (`Q BT ... Tj ET q`).
let data = b"q 100 0 0 100 72 650 cm \
BI /W 48 /H 1 /CS /G /BPC 8 ID 0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q! EI Q \
BT /F1 14 Tf 72 550 Td (After) Tj ET";
assert_eq!(
operators(data),
vec![
b"q".to_vec(),
b"cm".to_vec(),
b"BI".to_vec(),
b"Q".to_vec(),
b"BT".to_vec(),
b"Tf".to_vec(),
b"Td".to_vec(),
b"Tj".to_vec(),
b"ET".to_vec(),
]
);
let expected = slice_instructions(data);
for piece in 1..=data.len() {
assert_eq!(windowed_instructions(data, || piece), expected, "piece size {piece}");
}
}
#[test]
fn unfiltered_inline_image_variants_and_fallbacks() {
// ImageMask with 1-bit row padding (width 10 -> 2 bytes per row, height 2 -> 4 bytes)
let mask_stream = b"BI /IM true /W 10 /H 2 ID 0EI\n EI Q";
assert_eq!(operators(mask_stream), vec![b"BI".to_vec(), b"Q".to_vec()]);
let expected_mask = slice_instructions(mask_stream);
for piece in 1..=mask_stream.len() {
assert_eq!(windowed_instructions(mask_stream, || piece), expected_mask);
}
// Indexed color space with 4-bit samples (width 5 -> 3 bytes per row, height 2 -> 6 bytes)
// and explicit /L matching computed length.
let indexed_stream =
b"BI /Width 5 /Height 2 /CS [/I /RGB 1 <000000ffffff>] /BPC 4 /L 6 ID 0EI Q! EI Q";
assert_eq!(operators(indexed_stream), vec![b"BI".to_vec(), b"Q".to_vec()]);
let expected_indexed = slice_instructions(indexed_stream);
for piece in 1..=indexed_stream.len() {
assert_eq!(windowed_instructions(indexed_stream, || piece), expected_indexed);
}
// Conflicting /W and /Width or mismatched /L falls back to the heuristic scan.
let mismatch_l = b"BI /W 4 /H 1 /CS /G /BPC 8 /L 99 ID abcd EI Q";
assert_eq!(operators(mismatch_l), vec![b"BI".to_vec(), b"Q".to_vec()]);
}
}