use super::super::*;
use super::text_records::{container, record_header, text_chars_atom};
fn blip_record_one_uid(rec_instance: u16, rec_type: u16, picture_bytes: &[u8]) -> Vec<u8> {
assert_eq!(rec_instance & 0x1, 0, "one-UID recInstance must be even");
let rec_ver_instance = rec_instance << 4;
let rec_len = (17 + picture_bytes.len()) as u32;
let mut buf = record_header(rec_ver_instance, rec_type, rec_len);
buf.extend_from_slice(&[0u8; 16]);
buf.push(0xFF);
buf.extend_from_slice(picture_bytes);
buf
}
fn blip_record_two_uid(rec_instance: u16, rec_type: u16, picture_bytes: &[u8]) -> Vec<u8> {
assert_eq!(rec_instance & 0x1, 1, "two-UID recInstance must be odd");
let rec_ver_instance = rec_instance << 4;
let rec_len = (33 + picture_bytes.len()) as u32;
let mut buf = record_header(rec_ver_instance, rec_type, rec_len);
buf.extend_from_slice(&[0u8; 32]);
buf.push(0xFF);
buf.extend_from_slice(picture_bytes);
buf
}
#[test]
fn should_extract_jpeg_bytes_when_pictures_stream_has_one_uid_jpeg_blip() {
let picture = b"\xFF\xD8\xFFfake-jpeg-payload";
let data = blip_record_one_uid(0x46A, RT_BLIP_JPEG, picture);
let mut warnings = Vec::new();
let images = extract_pictures_from_stream(&data, &Default::default(), &mut warnings);
assert_eq!(images.len(), 1);
assert_eq!(images[0].format, "jpeg");
assert_eq!(images[0].image_index, 0);
assert_eq!(&images[0].data[..], &picture[..]);
assert!(warnings.is_empty());
}
#[test]
fn should_extract_png_bytes_when_pictures_stream_has_two_uid_png_blip() {
let picture = b"\x89PNG\r\n\x1a\nfake-png-payload";
let data = blip_record_two_uid(0x6E1, RT_BLIP_PNG, picture);
let mut warnings = Vec::new();
let images = extract_pictures_from_stream(&data, &Default::default(), &mut warnings);
assert_eq!(images.len(), 1);
assert_eq!(images[0].format, "png");
assert_eq!(&images[0].data[..], &picture[..]);
assert!(warnings.is_empty());
}
#[test]
fn should_extract_dib_bytes_and_tag_format_dib_when_pictures_stream_has_dib_blip() {
let picture = b"fake-dib-bitmap-payload";
let data = blip_record_one_uid(0x7A8, RT_BLIP_DIB, picture);
let mut warnings = Vec::new();
let images = extract_pictures_from_stream(&data, &Default::default(), &mut warnings);
assert_eq!(images.len(), 1);
assert_eq!(images[0].format, "dib");
assert_eq!(&images[0].data[..], &picture[..]);
}
#[test]
fn should_assign_sequential_image_index_when_pictures_stream_has_multiple_blips() {
let jpeg = blip_record_one_uid(0x46A, RT_BLIP_JPEG, b"jpeg-one");
let png = blip_record_one_uid(0x6E0, RT_BLIP_PNG, b"png-two");
let mut data = Vec::new();
data.extend_from_slice(&jpeg);
data.extend_from_slice(&png);
let mut warnings = Vec::new();
let images = extract_pictures_from_stream(&data, &Default::default(), &mut warnings);
assert_eq!(images.len(), 2);
assert_eq!(images[0].image_index, 0);
assert_eq!(images[0].format, "jpeg");
assert_eq!(images[1].image_index, 1);
assert_eq!(images[1].format, "png");
}
#[test]
fn should_skip_non_blip_records_when_walking_pictures_stream() {
const RT_UNRELATED: u16 = 0xF003;
let unrelated = record_header(0x0000, RT_UNRELATED, 4)
.into_iter()
.chain([1, 2, 3, 4])
.collect::<Vec<u8>>();
let jpeg = blip_record_one_uid(0x46A, RT_BLIP_JPEG, b"real-jpeg");
let mut data = Vec::new();
data.extend_from_slice(&unrelated);
data.extend_from_slice(&jpeg);
let mut warnings = Vec::new();
let images = extract_pictures_from_stream(&data, &Default::default(), &mut warnings);
assert_eq!(images.len(), 1);
assert_eq!(images[0].format, "jpeg");
}
#[test]
fn should_stop_without_panicking_when_blip_declares_length_past_buffer_end() {
let mut data = record_header(0x46A << 4, RT_BLIP_JPEG, u32::MAX);
data.extend_from_slice(&[0u8; 4]);
let mut warnings = Vec::new();
let images = extract_pictures_from_stream(&data, &Default::default(), &mut warnings);
assert!(images.is_empty());
assert!(
warnings.iter().any(|w| w.message.contains("truncated")),
"expected a truncation warning, got: {warnings:?}"
);
}
#[test]
fn should_skip_and_warn_when_blip_declared_length_is_shorter_than_uid_header() {
let data = record_header(0x46A << 4, RT_BLIP_JPEG, 5)
.into_iter()
.chain([0u8; 5])
.collect::<Vec<u8>>();
let mut warnings = Vec::new();
let images = extract_pictures_from_stream(&data, &Default::default(), &mut warnings);
assert!(images.is_empty());
assert!(
warnings
.iter()
.any(|w| w.message.contains("shorter than its UID header")),
"expected a UID-header-too-short warning, got: {warnings:?}"
);
}
#[test]
fn should_return_no_images_when_pictures_stream_is_empty() {
let mut warnings = Vec::new();
let images = extract_pictures_from_stream(&[], &Default::default(), &mut warnings);
assert!(images.is_empty());
assert!(warnings.is_empty());
}
fn build_test_ppt_ole(ppt_document_stream: &[u8], pictures_stream: Option<&[u8]>) -> Vec<u8> {
use std::io::Write;
let cursor = Cursor::new(Vec::new());
let mut comp = cfb::CompoundFile::create(cursor).expect("create in-memory OLE container");
comp.create_stream("/PowerPoint Document")
.expect("create PowerPoint Document stream")
.write_all(ppt_document_stream)
.expect("write PowerPoint Document stream");
if let Some(pictures) = pictures_stream {
comp.create_stream("/Pictures")
.expect("create Pictures stream")
.write_all(pictures)
.expect("write Pictures stream");
}
comp.into_inner().into_inner()
}
#[test]
fn should_populate_images_when_pictures_stream_has_a_blip_and_extract_images_is_true() {
let ppt_stream = container(RT_SLIDE, &text_chars_atom("Slide One"));
let picture = b"\xFF\xD8\xFFsynthetic-jpeg-bytes";
let pictures_stream = blip_record_one_uid(0x46A, RT_BLIP_JPEG, picture);
let content = build_test_ppt_ole(&ppt_stream, Some(&pictures_stream));
let result = extract_ppt_text_with_options(&content, false, true).expect("synthetic OLE container should parse");
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].format, "jpeg");
assert_eq!(result.images[0].data.len(), picture.len());
assert_eq!(&result.images[0].data[..], &picture[..]);
}
#[test]
fn should_return_no_images_when_extract_images_is_false() {
let ppt_stream = container(RT_SLIDE, &text_chars_atom("Slide One"));
let pictures_stream = blip_record_one_uid(0x46A, RT_BLIP_JPEG, b"jpeg-bytes");
let content = build_test_ppt_ole(&ppt_stream, Some(&pictures_stream));
let result = extract_ppt_text_with_options(&content, false, false).expect("synthetic OLE container should parse");
assert!(
result.images.is_empty(),
"extract_images=false must skip the Pictures stream entirely"
);
}
#[tokio::test]
async fn should_read_real_embedded_image_bytes_for_ocr_only_config_at_extract_content() {
use crate::core::config::{ExtractionConfig, OcrConfig};
use crate::core::mime::LEGACY_POWERPOINT_MIME_TYPE;
use crate::extractors::ppt::PptExtractor;
use crate::plugins::InternalDocumentExtractor;
let ppt_stream = container(RT_SLIDE, &text_chars_atom("Slide One"));
let picture = b"\xFF\xD8\xFFsynthetic-jpeg-bytes";
let pictures_stream = blip_record_one_uid(0x46A, RT_BLIP_JPEG, picture);
let content = build_test_ppt_ole(&ppt_stream, Some(&pictures_stream));
let config = ExtractionConfig {
ocr: Some(OcrConfig::default()),
..Default::default()
};
let extractor = PptExtractor::new();
let internal_doc = extractor
.extract_content(&content, LEGACY_POWERPOINT_MIME_TYPE, &config)
.await
.expect("synthetic OLE container must extract");
assert_eq!(internal_doc.images.len(), 1, "the single blip must yield one image");
assert_eq!(
internal_doc.images[0].data.as_ref(),
&picture[..],
"an OCR-only config must still read the real embedded-image bytes, not skip the Pictures stream"
);
}
#[test]
fn should_return_no_images_when_pictures_stream_is_absent() {
let ppt_stream = container(RT_SLIDE, &text_chars_atom("Slide One"));
let content = build_test_ppt_ole(&ppt_stream, None);
let result = extract_ppt_text_with_options(&content, false, true).expect("synthetic OLE container should parse");
assert!(result.images.is_empty());
}
fn bse_record(pictures_offset: u32) -> Vec<u8> {
let mut content = Vec::new();
content.extend_from_slice(&[0x06, 0x06]); content.extend_from_slice(&[0u8; 16]); content.extend_from_slice(&[0u8; 2]); content.extend_from_slice(&0u32.to_le_bytes()); content.extend_from_slice(&1u32.to_le_bytes()); content.extend_from_slice(&pictures_offset.to_le_bytes()); content.extend_from_slice(&[0u8; 4]); let mut buf = record_header(0x0000, MSOFBT_BSE, content.len() as u32);
buf.extend_from_slice(&content);
buf
}
fn opt_with_pib(pib: u32) -> Vec<u8> {
let mut content = Vec::new();
content.extend_from_slice(&(MSO_PROPERTY_PIB | 0x4000).to_le_bytes());
content.extend_from_slice(&pib.to_le_bytes());
let mut buf = record_header(1 << 4, MSOFBT_OPT[0], content.len() as u32);
buf.extend_from_slice(&content);
buf
}
#[test]
fn should_attribute_each_picture_to_the_slide_whose_drawing_references_it() {
let first_picture = b"\xFF\xD8\xFFfirst-picture";
let second_picture = b"\xFF\xD8\xFFsecond";
let first_blip = blip_record_one_uid(0x46A, RT_BLIP_JPEG, first_picture);
let second_blip = blip_record_one_uid(0x46A, RT_BLIP_JPEG, second_picture);
let second_offset = first_blip.len() as u32;
let mut pictures_stream = first_blip.clone();
pictures_stream.extend_from_slice(&second_blip);
let mut bstore = bse_record(0);
bstore.extend_from_slice(&bse_record(second_offset));
let drawing_group = container(0xF001, &bstore);
let mut stream = drawing_group;
stream.extend_from_slice(&container(RT_SLIDE, &opt_with_pib(2)));
let mut slide_two = text_chars_atom("Slide Two");
slide_two.extend_from_slice(&opt_with_pib(1));
stream.extend_from_slice(&container(RT_SLIDE, &slide_two));
let content = build_test_ppt_ole(&stream, Some(&pictures_stream));
let result = extract_ppt_text_with_options(&content, false, true).expect("synthetic deck should parse");
assert_eq!(result.images.len(), 2, "both blips must still be extracted");
assert_eq!(&result.images[0].data[..], &first_picture[..]);
assert_eq!(
result.images[0].page_number,
Some(2),
"first blip is referenced by slide 2"
);
assert_eq!(&result.images[1].data[..], &second_picture[..]);
assert_eq!(
result.images[1].page_number,
Some(1),
"second blip is referenced by slide 1"
);
}
#[test]
fn should_leave_a_picture_no_shape_references_without_a_slide_number() {
let picture = b"\xFF\xD8\xFForphan";
let pictures_stream = blip_record_one_uid(0x46A, RT_BLIP_JPEG, picture);
let mut stream = container(0xF001, &bse_record(0));
stream.extend_from_slice(&container(RT_SLIDE, &text_chars_atom("Slide One")));
let content = build_test_ppt_ole(&stream, Some(&pictures_stream));
let result = extract_ppt_text_with_options(&content, false, true).expect("synthetic deck should parse");
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].page_number, None);
}
#[test]
fn should_ignore_a_blip_index_beyond_the_blip_store() {
let picture = b"\xFF\xD8\xFFonly";
let pictures_stream = blip_record_one_uid(0x46A, RT_BLIP_JPEG, picture);
let mut stream = container(0xF001, &bse_record(0));
stream.extend_from_slice(&container(RT_SLIDE, &opt_with_pib(99)));
let content = build_test_ppt_ole(&stream, Some(&pictures_stream));
let result = extract_ppt_text_with_options(&content, false, true).expect("synthetic deck should parse");
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].page_number, None);
}