1use std::str;
81
82use crate::error::PdfError;
83use crate::objects::ObjectId;
84use crate::reader::document::DocumentReader;
85use crate::reader::images::ColorSpace;
86use crate::reader::text::collect_page_leaves;
87
88#[derive(Clone, Debug, PartialEq, Eq)]
98pub enum InlineImageFilter {
99 Raw,
102 DctDecode,
106 JpxDecode,
108 Jbig2Decode,
110 CcittFaxDecode,
115}
116
117#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct PdfInlineImage {
133 pub data: Vec<u8>,
136 pub width: u32,
138 pub height: u32,
140 pub color_space: ColorSpace,
145 pub bits_per_component: u8,
150 pub filter: InlineImageFilter,
152 pub image_mask: bool,
157 pub source_page_index: u32,
159 pub source_page_obj: ObjectId,
162}
163
164impl<'a> DocumentReader<'a> {
165 pub fn inline_images(&mut self) -> Result<Vec<PdfInlineImage>, PdfError> {
172 inline_images(self)
173 }
174}
175
176pub fn inline_images(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfInlineImage>, PdfError> {
179 let leaves = collect_page_leaves(reader)?;
180 let mut out = Vec::new();
181 for (page_index, leaf) in leaves.iter().enumerate() {
182 let content = match crate::reader::text::concatenate_page_contents(reader, *leaf)? {
183 Some(b) => b,
184 None => continue,
185 };
186 for image in extract_inline_images_from_stream(&content)? {
187 out.push(PdfInlineImage {
188 source_page_index: (page_index as u32) + 1,
189 source_page_obj: *leaf,
190 ..image
191 });
192 }
193 }
194 Ok(out)
195}
196
197pub fn extract_inline_images_from_stream(bytes: &[u8]) -> Result<Vec<PdfInlineImage>, PdfError> {
205 let mut out = Vec::new();
206 let mut i = 0;
207 while i < bytes.len() {
208 let Some(bi_start) = find_keyword(bytes, b"BI", i) else {
213 break;
214 };
215 let (image, end) = parse_one_inline_image(bytes, bi_start + 2)?;
217 out.push(image);
218 i = end;
219 }
220 Ok(out)
221}
222
223fn find_keyword(bytes: &[u8], kw: &[u8], from: usize) -> Option<usize> {
228 let mut i = from;
229 while i + kw.len() <= bytes.len() {
230 if &bytes[i..i + kw.len()] == kw {
231 let prev_ok = i == 0 || is_ws_or_delim(bytes[i - 1]);
232 let next_ok = i + kw.len() == bytes.len() || is_ws_or_delim(bytes[i + kw.len()]);
233 if prev_ok && next_ok {
234 return Some(i);
235 }
236 }
237 i += 1;
238 }
239 None
240}
241
242fn is_ws_or_delim(b: u8) -> bool {
243 matches!(
244 b,
245 0x00 | b'\t'
246 | b'\n'
247 | 0x0C
248 | b'\r'
249 | b' '
250 | b'('
251 | b')'
252 | b'<'
253 | b'>'
254 | b'['
255 | b']'
256 | b'{'
257 | b'}'
258 | b'/'
259 | b'%'
260 )
261}
262
263fn is_ws(b: u8) -> bool {
264 matches!(b, 0x00 | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
265}
266
267pub(crate) fn parse_one_inline_image(
272 bytes: &[u8],
273 mut i: usize,
274) -> Result<(PdfInlineImage, usize), PdfError> {
275 let mut dict_entries: Vec<(String, DictValue)> = Vec::new();
281 loop {
282 i = skip_ws_and_comments(bytes, i);
283 if i + 2 <= bytes.len() && &bytes[i..i + 2] == b"ID" {
284 let next = i + 2;
290 if next >= bytes.len() || !is_ws(bytes[next]) {
291 return Err(PdfError::other(
292 "PDF inline image: `ID` must be followed by exactly one whitespace byte",
293 ));
294 }
295 i = next + 1;
296 break;
297 }
298 if i >= bytes.len() {
299 return Err(PdfError::other(
300 "PDF inline image: stream ended before `ID` keyword",
301 ));
302 }
303 if bytes[i] != b'/' {
305 return Err(PdfError::other(format!(
306 "PDF inline image: expected `/Key` in BI dict at byte {i} (got {:#x})",
307 bytes[i]
308 )));
309 }
310 let (key, after_key) = read_name(bytes, i)?;
311 i = skip_ws_and_comments(bytes, after_key);
312 if i >= bytes.len() {
313 return Err(PdfError::other(format!(
314 "PDF inline image: stream ended after key `{key}` in BI dict"
315 )));
316 }
317 let (val, after_val) = read_dict_value(bytes, i)?;
318 dict_entries.push((key, val));
319 i = after_val;
320 }
321
322 let payload_start = i;
326 let ei_offset = find_inline_image_ei(bytes, payload_start)
327 .ok_or_else(|| PdfError::other("PDF inline image: no terminating `EI` keyword found"))?;
328 let payload_end = if ei_offset > payload_start && is_ws(bytes[ei_offset - 1]) {
332 ei_offset - 1
333 } else {
334 ei_offset
335 };
336 let payload = bytes[payload_start..payload_end].to_vec();
337 let resume = ei_offset + 2; let mut width: Option<u32> = None;
341 let mut height: Option<u32> = None;
342 let mut bpc: Option<u8> = None;
343 let mut cs: Option<ColorSpace> = None;
344 let mut filter_names: Vec<String> = Vec::new();
345 let mut image_mask = false;
346 for (key, val) in &dict_entries {
347 match key.as_str() {
348 "W" | "Width" => width = val.as_u32(),
349 "H" | "Height" => height = val.as_u32(),
350 "BPC" | "BitsPerComponent" => bpc = val.as_u8(),
351 "IM" | "ImageMask" => image_mask = val.as_bool().unwrap_or(false),
352 "CS" | "ColorSpace" => cs = val.as_color_space(),
353 "F" | "Filter" => filter_names = val.as_name_list(),
354 _ => {} }
356 }
357
358 let width = width.ok_or_else(|| PdfError::other("PDF inline image: missing /W"))?;
363 let height = height.ok_or_else(|| PdfError::other("PDF inline image: missing /H"))?;
364 let bpc_default: u8 = if image_mask { 1 } else { 8 };
365 let bpc = bpc.unwrap_or(bpc_default);
366
367 let (peeled, terminal) = peel_inline_filters(payload, &filter_names)?;
372
373 let color_space = if image_mask {
375 ColorSpace::DeviceGray
376 } else {
377 cs.unwrap_or(ColorSpace::DeviceRGB)
378 };
379
380 Ok((
381 PdfInlineImage {
382 data: peeled,
383 width,
384 height,
385 color_space,
386 bits_per_component: bpc,
387 filter: terminal,
388 image_mask,
389 source_page_index: 0,
390 source_page_obj: ObjectId {
391 number: 0,
392 generation: 0,
393 },
394 },
395 resume,
396 ))
397}
398
399pub(crate) fn find_inline_image_ei(bytes: &[u8], from: usize) -> Option<usize> {
403 let mut i = from;
404 while i + 2 <= bytes.len() {
405 if &bytes[i..i + 2] == b"EI" {
406 let prev_ok = i > 0 && is_ws(bytes[i - 1]);
407 let next_ok = i + 2 == bytes.len() || is_ws_or_delim(bytes[i + 2]);
408 if prev_ok && next_ok {
409 return Some(i);
410 }
411 }
412 i += 1;
413 }
414 None
415}
416
417#[derive(Clone, Debug)]
423enum DictValue {
424 Name(String),
425 Integer(i64),
426 #[allow(dead_code)]
427 Real(f64),
428 Bool(bool),
429 NameList(Vec<String>),
430 #[allow(dead_code)]
433 Raw(Vec<u8>),
434}
435
436impl DictValue {
437 fn as_u32(&self) -> Option<u32> {
438 match self {
439 DictValue::Integer(n) if *n >= 0 => Some(*n as u32),
440 DictValue::Real(f) if *f >= 0.0 => Some(*f as u32),
441 _ => None,
442 }
443 }
444 fn as_u8(&self) -> Option<u8> {
445 match self {
446 DictValue::Integer(n) if (1..=16).contains(n) => Some(*n as u8),
447 _ => None,
448 }
449 }
450 fn as_bool(&self) -> Option<bool> {
451 match self {
452 DictValue::Bool(b) => Some(*b),
453 _ => None,
454 }
455 }
456 fn as_color_space(&self) -> Option<ColorSpace> {
457 match self {
460 DictValue::Name(n) => Some(match n.as_str() {
461 "G" | "DeviceGray" => ColorSpace::DeviceGray,
462 "RGB" | "DeviceRGB" => ColorSpace::DeviceRGB,
463 "CMYK" | "DeviceCMYK" => ColorSpace::DeviceCMYK,
464 "I" | "Indexed" => ColorSpace::Indexed,
465 other => ColorSpace::Other(other.to_owned()),
466 }),
467 _ => None,
468 }
469 }
470 fn as_name_list(&self) -> Vec<String> {
471 match self {
472 DictValue::Name(n) => vec![n.clone()],
473 DictValue::NameList(v) => v.clone(),
474 _ => Vec::new(),
475 }
476 }
477}
478
479fn read_name(bytes: &[u8], from: usize) -> Result<(String, usize), PdfError> {
480 debug_assert_eq!(bytes[from], b'/');
481 let mut end = from + 1;
482 while end < bytes.len() {
483 let b = bytes[end];
484 if is_ws(b)
485 || matches!(
486 b,
487 b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
488 )
489 {
490 break;
491 }
492 end += 1;
493 }
494 let name = String::from_utf8_lossy(&bytes[from + 1..end]).into_owned();
495 Ok((name, end))
496}
497
498fn read_dict_value(bytes: &[u8], from: usize) -> Result<(DictValue, usize), PdfError> {
499 let b = bytes[from];
500 if b == b'/' {
501 let (name, end) = read_name(bytes, from)?;
502 return Ok((DictValue::Name(name), end));
503 }
504 if b == b't' && bytes.len() >= from + 4 && &bytes[from..from + 4] == b"true" {
505 return Ok((DictValue::Bool(true), from + 4));
506 }
507 if b == b'f' && bytes.len() >= from + 5 && &bytes[from..from + 5] == b"false" {
508 return Ok((DictValue::Bool(false), from + 5));
509 }
510 if b == b'[' {
511 let mut i = from + 1;
515 let mut names: Vec<String> = Vec::new();
516 let mut had_non_name = false;
517 loop {
518 i = skip_ws_and_comments(bytes, i);
519 if i >= bytes.len() {
520 return Err(PdfError::other(
521 "PDF inline image: unterminated `[` in BI dict",
522 ));
523 }
524 if bytes[i] == b']' {
525 i += 1;
526 break;
527 }
528 if bytes[i] == b'/' {
529 let (n, end) = read_name(bytes, i)?;
530 names.push(n);
531 i = end;
532 } else {
533 had_non_name = true;
537 let end = skip_token(bytes, i);
538 if end == i {
539 return Err(PdfError::other(format!(
540 "PDF inline image: unexpected byte {:#x} in BI dict array",
541 bytes[i]
542 )));
543 }
544 i = end;
545 }
546 }
547 if had_non_name {
548 return Ok((DictValue::Raw(bytes[from..i].to_vec()), i));
549 }
550 return Ok((DictValue::NameList(names), i));
551 }
552 if b == b'<' && bytes.get(from + 1) == Some(&b'<') {
553 let end = skip_balanced_dict(bytes, from)?;
555 return Ok((DictValue::Raw(bytes[from..end].to_vec()), end));
556 }
557 if b == b'<' {
558 let mut end = from + 1;
560 while end < bytes.len() && bytes[end] != b'>' {
561 end += 1;
562 }
563 if end < bytes.len() {
564 end += 1;
565 }
566 return Ok((DictValue::Raw(bytes[from..end].to_vec()), end));
567 }
568 if b == b'(' {
569 let mut end = from + 1;
572 let mut depth = 1i32;
573 while end < bytes.len() && depth > 0 {
574 match bytes[end] {
575 b'\\' => end = end.saturating_add(2),
576 b'(' => {
577 depth += 1;
578 end += 1;
579 }
580 b')' => {
581 depth -= 1;
582 end += 1;
583 }
584 _ => end += 1,
585 }
586 }
587 let end = end.min(bytes.len());
593 return Ok((DictValue::Raw(bytes[from..end].to_vec()), end));
594 }
595 if matches!(b, b'+' | b'-' | b'.' | b'0'..=b'9') {
596 let mut end = from;
598 if matches!(bytes[end], b'+' | b'-') {
599 end += 1;
600 }
601 let mut saw_dot = false;
602 let mut saw_digit = false;
603 while end < bytes.len() {
604 let c = bytes[end];
605 if c.is_ascii_digit() {
606 end += 1;
607 saw_digit = true;
608 } else if c == b'.' && !saw_dot {
609 end += 1;
610 saw_dot = true;
611 } else {
612 break;
613 }
614 }
615 if !saw_digit {
616 return Err(PdfError::other(format!(
617 "PDF inline image: malformed number at byte {from}"
618 )));
619 }
620 let s = str::from_utf8(&bytes[from..end]).map_err(|_| {
621 PdfError::other(format!("PDF inline image: non-UTF-8 number at byte {from}"))
622 })?;
623 if saw_dot {
624 let f: f64 = s
625 .parse()
626 .map_err(|_| PdfError::other(format!("PDF inline image: bad real `{s}`")))?;
627 return Ok((DictValue::Real(f), end));
628 }
629 let n: i64 = s
630 .parse()
631 .map_err(|_| PdfError::other(format!("PDF inline image: bad integer `{s}`")))?;
632 return Ok((DictValue::Integer(n), end));
633 }
634 Err(PdfError::other(format!(
635 "PDF inline image: unrecognised value token starting with {:#x} at byte {from}",
636 b
637 )))
638}
639
640fn skip_ws_and_comments(bytes: &[u8], mut i: usize) -> usize {
641 loop {
642 while i < bytes.len() && is_ws(bytes[i]) {
643 i += 1;
644 }
645 if i < bytes.len() && bytes[i] == b'%' {
646 while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
647 i += 1;
648 }
649 continue;
650 }
651 return i;
652 }
653}
654
655fn skip_token(bytes: &[u8], from: usize) -> usize {
656 let mut end = from;
657 while end < bytes.len()
658 && !is_ws(bytes[end])
659 && !matches!(bytes[end], b'/' | b'[' | b']' | b'(' | b')' | b'<' | b'>')
660 {
661 end += 1;
662 }
663 end
664}
665
666fn skip_balanced_dict(bytes: &[u8], from: usize) -> Result<usize, PdfError> {
667 debug_assert!(bytes[from] == b'<' && bytes.get(from + 1) == Some(&b'<'));
668 let mut i = from + 2;
669 let mut depth = 1i32;
670 while i + 1 < bytes.len() && depth > 0 {
671 if bytes[i] == b'<' && bytes[i + 1] == b'<' {
672 depth += 1;
673 i += 2;
674 } else if bytes[i] == b'>' && bytes[i + 1] == b'>' {
675 depth -= 1;
676 i += 2;
677 } else if bytes[i] == b'(' {
678 let mut depth2 = 1i32;
680 i += 1;
681 while i < bytes.len() && depth2 > 0 {
682 match bytes[i] {
683 b'\\' => i += 2,
684 b'(' => {
685 depth2 += 1;
686 i += 1;
687 }
688 b')' => {
689 depth2 -= 1;
690 i += 1;
691 }
692 _ => i += 1,
693 }
694 }
695 } else {
696 i += 1;
697 }
698 }
699 if depth != 0 {
700 return Err(PdfError::other(
701 "PDF inline image: unterminated `<<` in BI dict",
702 ));
703 }
704 Ok(i)
705}
706
707fn peel_inline_filters(
710 mut payload: Vec<u8>,
711 chain: &[String],
712) -> Result<(Vec<u8>, InlineImageFilter), PdfError> {
713 let (terminal_name, peel_count) = match chain.last().map(|s| s.as_str()) {
717 Some("DCT" | "DCTDecode") => (InlineImageFilter::DctDecode, chain.len() - 1),
718 Some("JPX" | "JPXDecode") => (InlineImageFilter::JpxDecode, chain.len() - 1),
719 Some("JBIG2" | "JBIG2Decode") => (InlineImageFilter::Jbig2Decode, chain.len() - 1),
720 Some("CCF" | "CCITTFaxDecode") => (InlineImageFilter::CcittFaxDecode, chain.len() - 1),
721 _ => (InlineImageFilter::Raw, chain.len()),
722 };
723 for filter in &chain[..peel_count] {
724 payload = match filter.as_str() {
725 "A85" | "ASCII85Decode" => crate::reader::filters::ascii85_decode(&payload)?,
726 "AHx" | "ASCIIHexDecode" => crate::reader::filters::ascii_hex_decode(&payload)?,
727 "Fl" | "FlateDecode" => crate::reader::filters::flate_decompress(&payload)?,
728 "RL" | "RunLengthDecode" => crate::reader::filters::run_length_decode(&payload)?,
729 "LZW" | "LZWDecode" => crate::reader::filters::lzw_decode(&payload)?,
731 other => {
732 return Err(PdfError::other(format!(
733 "PDF inline image: unsupported wrapping filter `{other}`"
734 )));
735 }
736 };
737 }
738 Ok((payload, terminal_name))
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 #[test]
746 fn finds_bi_keyword_at_start() {
747 let stream = b"BI /W 4 /H 4 ID 0123456789ABCDEF EI";
748 let pos = find_keyword(stream, b"BI", 0).unwrap();
749 assert_eq!(pos, 0);
750 }
751
752 #[test]
753 fn finds_bi_keyword_after_other_ops() {
754 let stream = b"q 100 0 0 100 0 0 cm BI /W 1 /H 1 /BPC 8 /CS /G ID \x42 EI Q";
755 let pos = find_keyword(stream, b"BI", 0).unwrap();
756 assert_eq!(&stream[pos..pos + 2], b"BI");
757 }
758
759 #[test]
760 fn rejects_bi_substring_inside_longer_kw() {
761 let stream = b"q BIM ID 0 EI Q";
763 assert!(find_keyword(stream, b"BI", 0).is_none());
764 }
765
766 #[test]
767 fn ei_termination_requires_surrounding_ws() {
768 let stream = b"abcEIxyz EI rest";
771 let pos = find_inline_image_ei(stream, 0).unwrap();
772 assert_eq!(&stream[pos..pos + 2], b"EI");
774 assert!(pos > 4); }
776
777 #[test]
778 fn extracts_one_inline_image_minimal() {
779 let stream: &[u8] = b"BI /W 1 /H 4 /CS /G /BPC 8 ID \x00\x01\x02\x03 EI";
781 let images = extract_inline_images_from_stream(stream).unwrap();
782 assert_eq!(images.len(), 1);
783 let img = &images[0];
784 assert_eq!(img.width, 1);
785 assert_eq!(img.height, 4);
786 assert_eq!(img.bits_per_component, 8);
787 assert_eq!(img.color_space, ColorSpace::DeviceGray);
788 assert_eq!(img.data, [0x00, 0x01, 0x02, 0x03]);
789 assert_eq!(img.filter, InlineImageFilter::Raw);
790 }
791
792 #[test]
793 fn extracts_dct_inline_image_preserves_payload() {
794 let payload: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
797 let mut stream: Vec<u8> = b"BI /W 8 /H 8 /CS /RGB /F /DCT ID ".to_vec();
798 stream.extend_from_slice(payload);
799 stream.extend_from_slice(b" EI");
800 let images = extract_inline_images_from_stream(&stream).unwrap();
801 assert_eq!(images.len(), 1);
802 assert_eq!(images[0].filter, InlineImageFilter::DctDecode);
803 assert_eq!(images[0].data, payload);
804 assert_eq!(images[0].color_space, ColorSpace::DeviceRGB);
805 }
806
807 #[test]
808 fn image_mask_defaults_to_1bpc_devicegray() {
809 let stream: &[u8] = b"BI /W 8 /H 8 /IM true ID \xFF EI";
810 let images = extract_inline_images_from_stream(stream).unwrap();
811 assert_eq!(images.len(), 1);
812 assert!(images[0].image_mask);
813 assert_eq!(images[0].bits_per_component, 1);
814 assert_eq!(images[0].color_space, ColorSpace::DeviceGray);
815 }
816
817 #[test]
818 fn long_keys_accepted_alongside_abbreviated() {
819 let stream: &[u8] =
820 b"BI /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 4 ID \x12\x34 EI";
821 let images = extract_inline_images_from_stream(stream).unwrap();
822 assert_eq!(images.len(), 1);
823 assert_eq!(images[0].width, 2);
824 assert_eq!(images[0].height, 2);
825 assert_eq!(images[0].bits_per_component, 4);
826 }
827
828 #[test]
829 fn filter_list_with_a85_wrapper_peels_correctly() {
830 let raw: &[u8] = &[0x4D, 0x61, 0x6E, 0x20];
833 let mut stream: Vec<u8> = b"BI /W 4 /H 1 /CS /G /F [/A85] ID ".to_vec();
834 stream.extend_from_slice(b"9jqo^~>");
836 stream.extend_from_slice(b" EI");
837 let images = extract_inline_images_from_stream(&stream).unwrap();
838 assert_eq!(images.len(), 1);
839 assert_eq!(images[0].data, raw);
840 assert_eq!(images[0].filter, InlineImageFilter::Raw);
841 }
842
843 #[test]
844 fn two_inline_images_in_one_stream() {
845 let stream: &[u8] =
846 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";
847 let images = extract_inline_images_from_stream(stream).unwrap();
848 assert_eq!(images.len(), 2);
849 assert_eq!(images[0].data, [0xAA]);
850 assert_eq!(images[1].data, [0xBB]);
851 }
852
853 #[test]
854 fn payload_containing_ei_substring_is_preserved() {
855 let stream: &[u8] = b"BI /W 5 /H 1 /CS /G /BPC 8 ID EIfoo EI";
859 let images = extract_inline_images_from_stream(stream).unwrap();
860 assert_eq!(images.len(), 1);
861 assert_eq!(&images[0].data, b"EIfoo");
862 }
863
864 #[test]
865 fn unterminated_inline_image_errors() {
866 let stream: &[u8] = b"BI /W 1 /H 1 /CS /G /BPC 8 ID \xAA";
867 let err = extract_inline_images_from_stream(stream).unwrap_err();
868 assert!(err.to_string().contains("EI"));
869 }
870
871 #[test]
872 fn rejects_missing_width() {
873 let stream: &[u8] = b"BI /H 1 /CS /G ID \xAA EI";
874 let err = extract_inline_images_from_stream(stream).unwrap_err();
875 assert!(err.to_string().contains("/W"));
876 }
877}