use crate::ops::InlineImage;
use crate::tokenize::{ContentLexer, Element};
use pdfrum_common::{DiagKind, Diagnostics, Limits, Severity};
use pdfrum_filters::{Filter, decode_ascii_hex, decode_ascii85, decode_run_length};
use pdfrum_object::{Dict, Name, NoResolve, Object};
const KEY_ABBR: [(&[u8], &[u8]); 9] = [
(b"BPC", b"BitsPerComponent"),
(b"CS", b"ColorSpace"),
(b"D", b"Decode"),
(b"DP", b"DecodeParms"),
(b"F", b"Filter"),
(b"H", b"Height"),
(b"IM", b"ImageMask"),
(b"I", b"Interpolate"),
(b"W", b"Width"),
];
const VALUE_ABBR: [(&[u8], &[u8]); 11] = [
(b"G", b"DeviceGray"),
(b"RGB", b"DeviceRGB"),
(b"CMYK", b"DeviceCMYK"),
(b"I", b"Indexed"),
(b"AHx", b"ASCIIHexDecode"),
(b"A85", b"ASCII85Decode"),
(b"LZW", b"LZWDecode"),
(b"Fl", b"FlateDecode"),
(b"RL", b"RunLengthDecode"),
(b"CCF", b"CCITTFaxDecode"),
(b"DCT", b"DCTDecode"),
];
#[must_use]
pub fn expand_key_abbreviation(key: &[u8]) -> &[u8] {
KEY_ABBR
.iter()
.find(|(abbr, _)| *abbr == key)
.map_or(key, |(_, full)| *full)
}
#[must_use]
pub fn expand_value_abbreviation(value: &[u8]) -> &[u8] {
VALUE_ABBR
.iter()
.find(|(abbr, _)| *abbr == value)
.map_or(value, |(_, full)| *full)
}
fn expand_value(value: &Object) -> Object {
match value {
Object::Name(n) => Object::Name(Name::new(expand_value_abbreviation(n.as_bytes()))),
Object::Array(a) => Object::Array(a.iter().map(expand_value).collect()),
Object::Dict(d) => Object::Dict(
d.iter()
.map(|(k, v)| (k.clone(), expand_value(v)))
.collect(),
),
other => other.clone(),
}
}
fn expand_dict(dict: &Dict) -> Dict {
dict.iter()
.map(|(key, value)| {
(
Name::new(expand_key_abbreviation(key.as_bytes())),
expand_value(value),
)
})
.collect()
}
pub(crate) fn read(
lexer: &mut ContentLexer<'_>,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<InlineImage> {
let start = lexer.pos();
let mut dict = Dict::new();
loop {
let save = lexer.pos();
match lexer.next_element() {
Element::Keyword(word) if word != b"ID" => {
let _ = save;
lexer.seek(start);
diags.record(
Severity::Recovered,
DiagKind::InlineImageAbandoned,
Some(start as u64),
);
return None;
}
Element::Name(key) => {
let value = read_dict_value(lexer);
dict.push(key, value);
}
_ => break,
}
}
let dict = expand_dict(&dict);
let data = read_stream(lexer, &dict, limits, diags)?;
if scan_for_ei(lexer) == EiScan::EndOfData {
diags.record(
Severity::Recovered,
DiagKind::InlineImageAbandoned,
Some(start as u64),
);
return None;
}
let mut dict = dict;
if dict.raw(&Name::from("Subtype")).is_none() {
dict.push(Name::from("Subtype"), Object::Name(Name::from("Image")));
}
Some(InlineImage {
dict,
data: data.into_boxed_slice(),
})
}
fn read_dict_value(lexer: &mut ContentLexer<'_>) -> Object {
match lexer.next_element() {
Element::Number(n) => Object::Real(n),
Element::Name(n) => Object::Name(n),
Element::Object(o) => o,
Element::Keyword(_) | Element::Eof => Object::Null,
}
}
fn first_filter(dict: &Dict) -> (Option<Name>, Dict) {
let params = dict.raw(&Name::from("DecodeParms"));
match dict.raw(&Name::from("Filter")) {
Some(Object::Name(n)) => (
Some(n.clone()),
match params {
Some(Object::Dict(d)) => d.clone(),
_ => Dict::new(),
},
),
Some(Object::Array(a)) => (
a.name_at(0).cloned(),
match params.and_then(Object::as_array).and_then(|p| p.raw_at(0)) {
Some(Object::Dict(d)) => d.clone(),
_ => Dict::new(),
},
),
_ => (None, Dict::new()),
}
}
fn read_stream(
lexer: &mut ContentLexer<'_>,
dict: &Dict,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<Vec<u8>> {
if matches!(
lexer.data().get(lexer.pos()),
Some(0x00 | 0x09 | 0x0A | 0x0C | 0x0D | 0x20)
) {
lexer.seek(lexer.pos() + 1);
}
let data_start = lexer.pos();
let rest = lexer.data().get(data_start..).unwrap_or(&[]);
let (filter, params) = first_filter(dict);
let width = dict.int(&Name::from("Width"), &NoResolve).unwrap_or(0);
let height = dict.int(&Name::from("Height"), &NoResolve).unwrap_or(0);
let size = match filter.as_ref().and_then(Filter::from_name) {
None if filter.is_none() => {
let (bpc, comps) = unfiltered_sample_shape(dict);
let pitch = pitch8(bpc, comps, width)?;
let total = pitch.checked_mul(u64::try_from(height).ok()?)?;
usize::try_from(total).ok()?.min(rest.len())
}
None => {
diags.record(
Severity::Suspicious,
DiagKind::InlineImageUnsupported,
Some(data_start as u64),
);
return None;
}
Some(f) => decoded_source_len(f, rest, ¶ms, width, height, limits, diags)?,
};
lexer.seek(data_start + size);
let Some(absorbed) = absorb_to_ei(lexer, data_start + size) else {
diags.record(
Severity::Recovered,
DiagKind::InlineImageAbandoned,
Some(data_start as u64),
);
return None;
};
if absorbed > 0 {
diags.record(
Severity::Recovered,
DiagKind::InlineImageResync,
Some(data_start as u64),
);
}
let end = (data_start + size + absorbed).min(lexer.data().len());
let data = lexer.data().get(data_start..end).unwrap_or(&[]).to_vec();
lexer.seek(end);
Some(data)
}
fn unfiltered_sample_shape(dict: &Dict) -> (i64, i64) {
let Some(cs) = dict.raw(&Name::from("ColorSpace")) else {
return (1, 1);
};
let comps = match cs {
Object::Name(n) => match n.as_bytes() {
b"DeviceGray" => 1,
b"DeviceCMYK" => 4,
_ => 3,
},
Object::Array(a) if a.name_at(0).is_some_and(|n| n.as_bytes() == b"Indexed") => 1,
_ => 3,
};
let bpc = dict
.int(&Name::from("BitsPerComponent"), &NoResolve)
.unwrap_or(0);
(bpc, comps)
}
fn pitch8(bpc: i64, comps: i64, width: i64) -> Option<u64> {
if bpc < 0 || comps < 0 || width < 0 {
return None;
}
let bits = u64::try_from(bpc)
.ok()?
.checked_mul(u64::try_from(comps).ok()?)?
.checked_mul(u64::try_from(width).ok()?)?;
Some(bits.checked_add(7)? / 8)
}
fn decoded_source_len(
filter: Filter,
data: &[u8],
params: &Dict,
width: i64,
height: i64,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<usize> {
match filter {
Filter::AsciiHex => Some(decode_ascii_hex(data).1),
Filter::Ascii85 => decode_ascii85(data).ok().map(|(_, used)| used),
Filter::RunLength => decode_run_length(data, diags).ok().map(|(_, used)| used),
Filter::Flate | Filter::Lzw | Filter::Crypt | Filter::CcittFax => {
let _ = (params, limits, width, height);
Some(data.len())
}
Filter::Dct => jpeg_frame_len(data).or(Some(data.len())),
Filter::Jpx | Filter::Jbig2 => {
diags.record(Severity::Suspicious, DiagKind::InlineImageUnsupported, None);
None
}
}
}
fn jpeg_frame_len(data: &[u8]) -> Option<usize> {
let mut i = 2usize; while i + 1 < data.len() {
if data.get(i) == Some(&0xFF) && data.get(i + 1) == Some(&0xD9) {
return Some(i + 2);
}
i += 1;
}
None
}
fn absorb_to_ei(lexer: &mut ContentLexer<'_>, from: usize) -> Option<usize> {
let mut absorbed = 0usize;
let mut cursor = from;
lexer.seek(from);
loop {
let before = lexer.pos();
match lexer.next_element() {
Element::Eof => return None,
Element::Keyword(word) if word == b"EI" => {
lexer.seek(cursor);
return Some(absorbed);
}
_ => {
let after = lexer.pos();
absorbed = absorbed.saturating_add(after.saturating_sub(before));
cursor = after;
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EiScan {
Closed,
EndOfData,
}
fn scan_for_ei(lexer: &mut ContentLexer<'_>) -> EiScan {
loop {
match lexer.next_element() {
Element::Eof => return EiScan::EndOfData,
Element::Keyword(word) if word == b"EI" => return EiScan::Closed,
_ => {}
}
}
}
#[must_use]
pub fn as_xobject_dict(image: &InlineImage) -> Dict {
let mut dict = image.dict.clone();
dict.push(
Name::from("Length"),
Object::Int(i64::try_from(image.data.len()).unwrap_or(0)),
);
dict
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{expand_key_abbreviation, expand_value_abbreviation};
use crate::ops::Op;
use pdfrum_common::{DiagKind, Diagnostics, Limits};
use pdfrum_object::{Name, NoResolve, Object};
fn parse(src: &[u8]) -> (Vec<Op>, Diagnostics) {
let mut diags = Diagnostics::default();
let ops = crate::parse_content(src, &Limits::default(), &mut diags);
(ops, diags)
}
#[test]
fn key_abbreviations_match_whole_keys_only() {
assert_eq!(expand_key_abbreviation(b"BPC"), b"BitsPerComponent");
assert_eq!(expand_key_abbreviation(b"W"), b"Width");
assert_eq!(expand_key_abbreviation(b""), b"");
assert_eq!(expand_key_abbreviation(b"NoInList"), b"NoInList");
assert_eq!(expand_key_abbreviation(b"WW"), b"WW");
}
#[test]
fn value_abbreviations_match_whole_values_only() {
assert_eq!(expand_value_abbreviation(b"G"), b"DeviceGray");
assert_eq!(expand_value_abbreviation(b"DCT"), b"DCTDecode");
assert_eq!(expand_value_abbreviation(b""), b"");
assert_eq!(expand_value_abbreviation(b"NoInList"), b"NoInList");
assert_eq!(expand_value_abbreviation(b"II"), b"II");
}
#[test]
fn i_is_interpolate_as_a_key_and_indexed_as_a_value() {
assert_eq!(expand_key_abbreviation(b"I"), b"Interpolate");
assert_eq!(expand_value_abbreviation(b"I"), b"Indexed");
}
#[test]
fn an_inline_image_with_no_ei_takes_the_rest_of_the_stream_with_it() {
let (ops, diags) = parse(
b"0 0 1 rg 0 0 200 200 re f\n BI /W 2 /H 2 /BPC 8 /CS /G ID \x00\x66\xcc\xff\n 0 1 0 rg 100 0 100 100 re f",
);
assert!(
!ops.iter().any(|op| matches!(op, Op::InlineImage(_))),
"the unterminated image is not emitted"
);
let fills = ops.iter().filter(|op| matches!(op, Op::Fill())).count();
assert_eq!(
fills, 1,
"only the fill before the BI is parsed, got {ops:?}"
);
assert!(
diags
.entries()
.iter()
.any(|d| d.what == DiagKind::InlineImageAbandoned),
"and the recovery is recorded rather than silent"
);
}
#[test]
fn an_inline_image_that_does_close_leaves_the_rest_of_the_stream_alone() {
let (ops, _) = parse(
b"0 0 1 rg 0 0 200 200 re f\n BI /W 2 /H 2 /BPC 8 /CS /G ID \x00\x66\xcc\xff EI\n 0 1 0 rg 100 0 100 100 re f",
);
assert!(
ops.iter().any(|op| matches!(op, Op::InlineImage(_))),
"a closed image is emitted, got {ops:?}"
);
assert_eq!(
ops.iter().filter(|op| matches!(op, Op::Fill())).count(),
2,
"and both fills are parsed"
);
}
#[test]
fn an_unfiltered_inline_image_is_sized_from_its_samples() {
let (ops, _) = parse(b"BI /W 2 /H 2 /BPC 1 ID \x00\xff EI Q");
let Some(Op::InlineImage(img)) = ops.first() else {
panic!("expected an inline image, got {ops:?}");
};
assert_eq!(&*img.data, b"\x00\xff");
assert_eq!(
img.dict.int(&Name::from("Width"), &NoResolve),
Some(2),
"the /W abbreviation should have been expanded"
);
assert!(matches!(ops.get(1), Some(Op::RestoreState())));
}
#[test]
fn a_non_id_keyword_abandons_the_image() {
let (ops, diags) = parse(b"BI /W 2 Tj 5 w");
assert!(diags.contains(&DiagKind::InlineImageAbandoned));
assert!(ops.iter().any(|op| matches!(op, Op::ShowText(_))));
assert!(
ops.iter()
.any(|op| matches!(op, Op::SetLineWidth(w) if (*w - 5.0).abs() < 1e-6))
);
}
#[test]
fn exactly_one_whitespace_byte_after_id_is_skipped() {
let (ops, _) = parse(b"BI /W 2 /H 1 /BPC 8 /CS /G ID \x01 EI");
let Some(Op::InlineImage(img)) = ops.first() else {
panic!("expected an inline image, got {ops:?}");
};
assert_eq!(&*img.data, b" \x01");
}
#[test]
fn inline_jpx_and_jbig2_produce_nothing() {
for filter in [&b"/JPXDecode"[..], b"/JBIG2Decode"] {
let mut src = b"BI /W 2 /H 2 /F ".to_vec();
src.extend_from_slice(filter);
src.extend_from_slice(b" ID \x01\x02 EI 3 w");
let (ops, diags) = parse(&src);
assert!(
!ops.iter().any(|op| matches!(op, Op::InlineImage(_))),
"{filter:?} should produce no image"
);
assert!(diags.contains(&DiagKind::InlineImageUnsupported));
}
}
#[test]
fn a_string_containing_ei_inside_the_data_is_absorbed() {
let (ops, diags) = parse(b"BI /W 2 /H 2 /BPC 1 ID \x01\x02 (EI) EI");
let Some(Op::InlineImage(img)) = ops.first() else {
panic!("expected an inline image, got {ops:?}");
};
assert!(img.data.len() > 2, "the trailing token should be absorbed");
assert!(diags.contains(&DiagKind::InlineImageResync));
}
#[test]
fn an_overstated_length_clamps_to_the_remaining_stream() {
let (ops, _) = parse(b"BI /W 1000 /H 1000 /BPC 1 ID \x01\x02\x03");
assert!(!ops.iter().any(|op| matches!(op, Op::InlineImage(_))));
}
#[test]
fn value_abbreviations_expand_through_arrays() {
let (ops, _) = parse(b"BI /W 1 /H 1 /BPC 8 /F [/AHx] ID 41> EI");
let Some(Op::InlineImage(img)) = ops.first() else {
panic!("expected an inline image, got {ops:?}");
};
let Some(Object::Array(filters)) = img.dict.raw(&Name::from("Filter")) else {
panic!("expected a /Filter array, got {:?}", img.dict);
};
assert_eq!(filters.len(), 1);
assert_eq!(
filters
.raw_at(0)
.and_then(Object::as_name)
.map(Name::as_bytes),
Some(&b"ASCIIHexDecode"[..])
);
}
#[test]
fn subtype_image_is_established() {
let (ops, _) = parse(b"BI /W 1 /H 1 /BPC 1 ID \x00 EI");
let Some(Op::InlineImage(img)) = ops.first() else {
panic!("expected an inline image, got {ops:?}");
};
assert_eq!(
img.dict.raw(&Name::from("Subtype")),
Some(&Object::Name(Name::from("Image")))
);
}
}