use std::str;
use crate::error::PdfError;
use crate::objects::ObjectId;
use crate::reader::document::DocumentReader;
use crate::reader::images::ColorSpace;
use crate::reader::text::collect_page_leaves;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InlineImageFilter {
Raw,
DctDecode,
JpxDecode,
Jbig2Decode,
CcittFaxDecode,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PdfInlineImage {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
pub color_space: ColorSpace,
pub bits_per_component: u8,
pub filter: InlineImageFilter,
pub image_mask: bool,
pub source_page_index: u32,
pub source_page_obj: ObjectId,
}
impl<'a> DocumentReader<'a> {
pub fn inline_images(&mut self) -> Result<Vec<PdfInlineImage>, PdfError> {
inline_images(self)
}
}
pub fn inline_images(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfInlineImage>, PdfError> {
let leaves = collect_page_leaves(reader)?;
let mut out = Vec::new();
for (page_index, leaf) in leaves.iter().enumerate() {
let content = match crate::reader::text::concatenate_page_contents(reader, *leaf)? {
Some(b) => b,
None => continue,
};
for image in extract_inline_images_from_stream(&content)? {
out.push(PdfInlineImage {
source_page_index: (page_index as u32) + 1,
source_page_obj: *leaf,
..image
});
}
}
Ok(out)
}
pub fn extract_inline_images_from_stream(bytes: &[u8]) -> Result<Vec<PdfInlineImage>, PdfError> {
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
let Some(bi_start) = find_keyword(bytes, b"BI", i) else {
break;
};
let (image, end) = parse_one_inline_image(bytes, bi_start + 2)?;
out.push(image);
i = end;
}
Ok(out)
}
fn find_keyword(bytes: &[u8], kw: &[u8], from: usize) -> Option<usize> {
let mut i = from;
while i + kw.len() <= bytes.len() {
if &bytes[i..i + kw.len()] == kw {
let prev_ok = i == 0 || is_ws_or_delim(bytes[i - 1]);
let next_ok = i + kw.len() == bytes.len() || is_ws_or_delim(bytes[i + kw.len()]);
if prev_ok && next_ok {
return Some(i);
}
}
i += 1;
}
None
}
fn is_ws_or_delim(b: u8) -> bool {
matches!(
b,
0x00 | b'\t'
| b'\n'
| 0x0C
| b'\r'
| b' '
| b'('
| b')'
| b'<'
| b'>'
| b'['
| b']'
| b'{'
| b'}'
| b'/'
| b'%'
)
}
fn is_ws(b: u8) -> bool {
matches!(b, 0x00 | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
}
fn parse_one_inline_image(bytes: &[u8], mut i: usize) -> Result<(PdfInlineImage, usize), PdfError> {
let mut dict_entries: Vec<(String, DictValue)> = Vec::new();
loop {
i = skip_ws_and_comments(bytes, i);
if i + 2 <= bytes.len() && &bytes[i..i + 2] == b"ID" {
let next = i + 2;
if next >= bytes.len() || !is_ws(bytes[next]) {
return Err(PdfError::other(
"PDF inline image: `ID` must be followed by exactly one whitespace byte",
));
}
i = next + 1;
break;
}
if i >= bytes.len() {
return Err(PdfError::other(
"PDF inline image: stream ended before `ID` keyword",
));
}
if bytes[i] != b'/' {
return Err(PdfError::other(format!(
"PDF inline image: expected `/Key` in BI dict at byte {i} (got {:#x})",
bytes[i]
)));
}
let (key, after_key) = read_name(bytes, i)?;
i = skip_ws_and_comments(bytes, after_key);
if i >= bytes.len() {
return Err(PdfError::other(format!(
"PDF inline image: stream ended after key `{key}` in BI dict"
)));
}
let (val, after_val) = read_dict_value(bytes, i)?;
dict_entries.push((key, val));
i = after_val;
}
let payload_start = i;
let ei_offset = find_inline_image_ei(bytes, payload_start)
.ok_or_else(|| PdfError::other("PDF inline image: no terminating `EI` keyword found"))?;
let payload_end = if ei_offset > payload_start && is_ws(bytes[ei_offset - 1]) {
ei_offset - 1
} else {
ei_offset
};
let payload = bytes[payload_start..payload_end].to_vec();
let resume = ei_offset + 2;
let mut width: Option<u32> = None;
let mut height: Option<u32> = None;
let mut bpc: Option<u8> = None;
let mut cs: Option<ColorSpace> = None;
let mut filter_names: Vec<String> = Vec::new();
let mut image_mask = false;
for (key, val) in &dict_entries {
match key.as_str() {
"W" | "Width" => width = val.as_u32(),
"H" | "Height" => height = val.as_u32(),
"BPC" | "BitsPerComponent" => bpc = val.as_u8(),
"IM" | "ImageMask" => image_mask = val.as_bool().unwrap_or(false),
"CS" | "ColorSpace" => cs = val.as_color_space(),
"F" | "Filter" => filter_names = val.as_name_list(),
_ => {} }
}
let width = width.ok_or_else(|| PdfError::other("PDF inline image: missing /W"))?;
let height = height.ok_or_else(|| PdfError::other("PDF inline image: missing /H"))?;
let bpc_default: u8 = if image_mask { 1 } else { 8 };
let bpc = bpc.unwrap_or(bpc_default);
let (peeled, terminal) = peel_inline_filters(payload, &filter_names)?;
let color_space = if image_mask {
ColorSpace::DeviceGray
} else {
cs.unwrap_or(ColorSpace::DeviceRGB)
};
Ok((
PdfInlineImage {
data: peeled,
width,
height,
color_space,
bits_per_component: bpc,
filter: terminal,
image_mask,
source_page_index: 0,
source_page_obj: ObjectId {
number: 0,
generation: 0,
},
},
resume,
))
}
fn find_inline_image_ei(bytes: &[u8], from: usize) -> Option<usize> {
let mut i = from;
while i + 2 <= bytes.len() {
if &bytes[i..i + 2] == b"EI" {
let prev_ok = i > 0 && is_ws(bytes[i - 1]);
let next_ok = i + 2 == bytes.len() || is_ws_or_delim(bytes[i + 2]);
if prev_ok && next_ok {
return Some(i);
}
}
i += 1;
}
None
}
#[derive(Clone, Debug)]
enum DictValue {
Name(String),
Integer(i64),
#[allow(dead_code)]
Real(f64),
Bool(bool),
NameList(Vec<String>),
#[allow(dead_code)]
Raw(Vec<u8>),
}
impl DictValue {
fn as_u32(&self) -> Option<u32> {
match self {
DictValue::Integer(n) if *n >= 0 => Some(*n as u32),
DictValue::Real(f) if *f >= 0.0 => Some(*f as u32),
_ => None,
}
}
fn as_u8(&self) -> Option<u8> {
match self {
DictValue::Integer(n) if (1..=16).contains(n) => Some(*n as u8),
_ => None,
}
}
fn as_bool(&self) -> Option<bool> {
match self {
DictValue::Bool(b) => Some(*b),
_ => None,
}
}
fn as_color_space(&self) -> Option<ColorSpace> {
match self {
DictValue::Name(n) => Some(match n.as_str() {
"G" | "DeviceGray" => ColorSpace::DeviceGray,
"RGB" | "DeviceRGB" => ColorSpace::DeviceRGB,
"CMYK" | "DeviceCMYK" => ColorSpace::DeviceCMYK,
"I" | "Indexed" => ColorSpace::Indexed,
other => ColorSpace::Other(other.to_owned()),
}),
_ => None,
}
}
fn as_name_list(&self) -> Vec<String> {
match self {
DictValue::Name(n) => vec![n.clone()],
DictValue::NameList(v) => v.clone(),
_ => Vec::new(),
}
}
}
fn read_name(bytes: &[u8], from: usize) -> Result<(String, usize), PdfError> {
debug_assert_eq!(bytes[from], b'/');
let mut end = from + 1;
while end < bytes.len() {
let b = bytes[end];
if is_ws(b)
|| matches!(
b,
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
)
{
break;
}
end += 1;
}
let name = String::from_utf8_lossy(&bytes[from + 1..end]).into_owned();
Ok((name, end))
}
fn read_dict_value(bytes: &[u8], from: usize) -> Result<(DictValue, usize), PdfError> {
let b = bytes[from];
if b == b'/' {
let (name, end) = read_name(bytes, from)?;
return Ok((DictValue::Name(name), end));
}
if b == b't' && bytes.len() >= from + 4 && &bytes[from..from + 4] == b"true" {
return Ok((DictValue::Bool(true), from + 4));
}
if b == b'f' && bytes.len() >= from + 5 && &bytes[from..from + 5] == b"false" {
return Ok((DictValue::Bool(false), from + 5));
}
if b == b'[' {
let mut i = from + 1;
let mut names: Vec<String> = Vec::new();
let mut had_non_name = false;
loop {
i = skip_ws_and_comments(bytes, i);
if i >= bytes.len() {
return Err(PdfError::other(
"PDF inline image: unterminated `[` in BI dict",
));
}
if bytes[i] == b']' {
i += 1;
break;
}
if bytes[i] == b'/' {
let (n, end) = read_name(bytes, i)?;
names.push(n);
i = end;
} else {
had_non_name = true;
let end = skip_token(bytes, i);
if end == i {
return Err(PdfError::other(format!(
"PDF inline image: unexpected byte {:#x} in BI dict array",
bytes[i]
)));
}
i = end;
}
}
if had_non_name {
return Ok((DictValue::Raw(bytes[from..i].to_vec()), i));
}
return Ok((DictValue::NameList(names), i));
}
if b == b'<' && bytes.get(from + 1) == Some(&b'<') {
let end = skip_balanced_dict(bytes, from)?;
return Ok((DictValue::Raw(bytes[from..end].to_vec()), end));
}
if b == b'<' {
let mut end = from + 1;
while end < bytes.len() && bytes[end] != b'>' {
end += 1;
}
if end < bytes.len() {
end += 1;
}
return Ok((DictValue::Raw(bytes[from..end].to_vec()), end));
}
if b == b'(' {
let mut end = from + 1;
let mut depth = 1i32;
while end < bytes.len() && depth > 0 {
match bytes[end] {
b'\\' => end = end.saturating_add(2),
b'(' => {
depth += 1;
end += 1;
}
b')' => {
depth -= 1;
end += 1;
}
_ => end += 1,
}
}
let end = end.min(bytes.len());
return Ok((DictValue::Raw(bytes[from..end].to_vec()), end));
}
if matches!(b, b'+' | b'-' | b'.' | b'0'..=b'9') {
let mut end = from;
if matches!(bytes[end], b'+' | b'-') {
end += 1;
}
let mut saw_dot = false;
let mut saw_digit = false;
while end < bytes.len() {
let c = bytes[end];
if c.is_ascii_digit() {
end += 1;
saw_digit = true;
} else if c == b'.' && !saw_dot {
end += 1;
saw_dot = true;
} else {
break;
}
}
if !saw_digit {
return Err(PdfError::other(format!(
"PDF inline image: malformed number at byte {from}"
)));
}
let s = str::from_utf8(&bytes[from..end]).map_err(|_| {
PdfError::other(format!("PDF inline image: non-UTF-8 number at byte {from}"))
})?;
if saw_dot {
let f: f64 = s
.parse()
.map_err(|_| PdfError::other(format!("PDF inline image: bad real `{s}`")))?;
return Ok((DictValue::Real(f), end));
}
let n: i64 = s
.parse()
.map_err(|_| PdfError::other(format!("PDF inline image: bad integer `{s}`")))?;
return Ok((DictValue::Integer(n), end));
}
Err(PdfError::other(format!(
"PDF inline image: unrecognised value token starting with {:#x} at byte {from}",
b
)))
}
fn skip_ws_and_comments(bytes: &[u8], mut i: usize) -> usize {
loop {
while i < bytes.len() && is_ws(bytes[i]) {
i += 1;
}
if i < bytes.len() && bytes[i] == b'%' {
while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
i += 1;
}
continue;
}
return i;
}
}
fn skip_token(bytes: &[u8], from: usize) -> usize {
let mut end = from;
while end < bytes.len()
&& !is_ws(bytes[end])
&& !matches!(bytes[end], b'/' | b'[' | b']' | b'(' | b')' | b'<' | b'>')
{
end += 1;
}
end
}
fn skip_balanced_dict(bytes: &[u8], from: usize) -> Result<usize, PdfError> {
debug_assert!(bytes[from] == b'<' && bytes.get(from + 1) == Some(&b'<'));
let mut i = from + 2;
let mut depth = 1i32;
while i + 1 < bytes.len() && depth > 0 {
if bytes[i] == b'<' && bytes[i + 1] == b'<' {
depth += 1;
i += 2;
} else if bytes[i] == b'>' && bytes[i + 1] == b'>' {
depth -= 1;
i += 2;
} else if bytes[i] == b'(' {
let mut depth2 = 1i32;
i += 1;
while i < bytes.len() && depth2 > 0 {
match bytes[i] {
b'\\' => i += 2,
b'(' => {
depth2 += 1;
i += 1;
}
b')' => {
depth2 -= 1;
i += 1;
}
_ => i += 1,
}
}
} else {
i += 1;
}
}
if depth != 0 {
return Err(PdfError::other(
"PDF inline image: unterminated `<<` in BI dict",
));
}
Ok(i)
}
fn peel_inline_filters(
mut payload: Vec<u8>,
chain: &[String],
) -> Result<(Vec<u8>, InlineImageFilter), PdfError> {
let (terminal_name, peel_count) = match chain.last().map(|s| s.as_str()) {
Some("DCT" | "DCTDecode") => (InlineImageFilter::DctDecode, chain.len() - 1),
Some("JPX" | "JPXDecode") => (InlineImageFilter::JpxDecode, chain.len() - 1),
Some("JBIG2" | "JBIG2Decode") => (InlineImageFilter::Jbig2Decode, chain.len() - 1),
Some("CCF" | "CCITTFaxDecode") => (InlineImageFilter::CcittFaxDecode, chain.len() - 1),
_ => (InlineImageFilter::Raw, chain.len()),
};
for filter in &chain[..peel_count] {
payload = match filter.as_str() {
"A85" | "ASCII85Decode" => crate::reader::filters::ascii85_decode(&payload)?,
"AHx" | "ASCIIHexDecode" => crate::reader::filters::ascii_hex_decode(&payload)?,
"Fl" | "FlateDecode" => crate::reader::filters::flate_decompress(&payload)?,
"RL" | "RunLengthDecode" => crate::reader::filters::run_length_decode(&payload)?,
"LZW" | "LZWDecode" => crate::reader::filters::lzw_decode(&payload)?,
other => {
return Err(PdfError::other(format!(
"PDF inline image: unsupported wrapping filter `{other}`"
)));
}
};
}
Ok((payload, terminal_name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_bi_keyword_at_start() {
let stream = b"BI /W 4 /H 4 ID 0123456789ABCDEF EI";
let pos = find_keyword(stream, b"BI", 0).unwrap();
assert_eq!(pos, 0);
}
#[test]
fn finds_bi_keyword_after_other_ops() {
let stream = b"q 100 0 0 100 0 0 cm BI /W 1 /H 1 /BPC 8 /CS /G ID \x42 EI Q";
let pos = find_keyword(stream, b"BI", 0).unwrap();
assert_eq!(&stream[pos..pos + 2], b"BI");
}
#[test]
fn rejects_bi_substring_inside_longer_kw() {
let stream = b"q BIM ID 0 EI Q";
assert!(find_keyword(stream, b"BI", 0).is_none());
}
#[test]
fn ei_termination_requires_surrounding_ws() {
let stream = b"abcEIxyz EI rest";
let pos = find_inline_image_ei(stream, 0).unwrap();
assert_eq!(&stream[pos..pos + 2], b"EI");
assert!(pos > 4); }
#[test]
fn extracts_one_inline_image_minimal() {
let stream: &[u8] = b"BI /W 1 /H 4 /CS /G /BPC 8 ID \x00\x01\x02\x03 EI";
let images = extract_inline_images_from_stream(stream).unwrap();
assert_eq!(images.len(), 1);
let img = &images[0];
assert_eq!(img.width, 1);
assert_eq!(img.height, 4);
assert_eq!(img.bits_per_component, 8);
assert_eq!(img.color_space, ColorSpace::DeviceGray);
assert_eq!(img.data, [0x00, 0x01, 0x02, 0x03]);
assert_eq!(img.filter, InlineImageFilter::Raw);
}
#[test]
fn extracts_dct_inline_image_preserves_payload() {
let payload: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
let mut stream: Vec<u8> = b"BI /W 8 /H 8 /CS /RGB /F /DCT ID ".to_vec();
stream.extend_from_slice(payload);
stream.extend_from_slice(b" EI");
let images = extract_inline_images_from_stream(&stream).unwrap();
assert_eq!(images.len(), 1);
assert_eq!(images[0].filter, InlineImageFilter::DctDecode);
assert_eq!(images[0].data, payload);
assert_eq!(images[0].color_space, ColorSpace::DeviceRGB);
}
#[test]
fn image_mask_defaults_to_1bpc_devicegray() {
let stream: &[u8] = b"BI /W 8 /H 8 /IM true ID \xFF EI";
let images = extract_inline_images_from_stream(stream).unwrap();
assert_eq!(images.len(), 1);
assert!(images[0].image_mask);
assert_eq!(images[0].bits_per_component, 1);
assert_eq!(images[0].color_space, ColorSpace::DeviceGray);
}
#[test]
fn long_keys_accepted_alongside_abbreviated() {
let stream: &[u8] =
b"BI /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 4 ID \x12\x34 EI";
let images = extract_inline_images_from_stream(stream).unwrap();
assert_eq!(images.len(), 1);
assert_eq!(images[0].width, 2);
assert_eq!(images[0].height, 2);
assert_eq!(images[0].bits_per_component, 4);
}
#[test]
fn filter_list_with_a85_wrapper_peels_correctly() {
let raw: &[u8] = &[0x4D, 0x61, 0x6E, 0x20];
let mut stream: Vec<u8> = b"BI /W 4 /H 1 /CS /G /F [/A85] ID ".to_vec();
stream.extend_from_slice(b"9jqo^~>");
stream.extend_from_slice(b" EI");
let images = extract_inline_images_from_stream(&stream).unwrap();
assert_eq!(images.len(), 1);
assert_eq!(images[0].data, raw);
assert_eq!(images[0].filter, InlineImageFilter::Raw);
}
#[test]
fn two_inline_images_in_one_stream() {
let stream: &[u8] =
b"BI /W 1 /H 1 /CS /G /BPC 8 ID \xAA EI BI /W 1 /H 1 /CS /G /BPC 8 ID \xBB EI";
let images = extract_inline_images_from_stream(stream).unwrap();
assert_eq!(images.len(), 2);
assert_eq!(images[0].data, [0xAA]);
assert_eq!(images[1].data, [0xBB]);
}
#[test]
fn payload_containing_ei_substring_is_preserved() {
let stream: &[u8] = b"BI /W 5 /H 1 /CS /G /BPC 8 ID EIfoo EI";
let images = extract_inline_images_from_stream(stream).unwrap();
assert_eq!(images.len(), 1);
assert_eq!(&images[0].data, b"EIfoo");
}
#[test]
fn unterminated_inline_image_errors() {
let stream: &[u8] = b"BI /W 1 /H 1 /CS /G /BPC 8 ID \xAA";
let err = extract_inline_images_from_stream(stream).unwrap_err();
assert!(err.to_string().contains("EI"));
}
#[test]
fn rejects_missing_width() {
let stream: &[u8] = b"BI /H 1 /CS /G ID \xAA EI";
let err = extract_inline_images_from_stream(stream).unwrap_err();
assert!(err.to_string().contains("/W"));
}
}