use crate::error::{Result, XbergError};
use crate::types::{ExtractedImage, ProcessingWarning};
use bytes::Bytes;
use std::borrow::Cow;
use std::io::Cursor;
const PPT_WARNING_SOURCE: &str = "ppt";
#[cfg_attr(alef, alef(skip))]
pub struct PptExtractionResult {
pub text: String,
pub slides: Vec<PptSlideText>,
pub slide_count: usize,
pub metadata: PptMetadata,
pub speaker_notes: Vec<String>,
pub images: Vec<ExtractedImage>,
pub processing_warnings: Vec<ProcessingWarning>,
}
#[cfg_attr(alef, alef(skip))]
pub struct PptSlideText {
pub number: u32,
pub text: String,
}
#[cfg_attr(alef, alef(skip))]
#[derive(Default)]
pub struct PptMetadata {
pub title: Option<String>,
pub subject: Option<String>,
pub author: Option<String>,
pub last_author: Option<String>,
}
const RT_TEXT_CHARS_ATOM: u16 = 0x0FA0;
const RT_TEXT_BYTES_ATOM: u16 = 0x0FA8;
const RT_SLIDE: u16 = 0x03EE;
const RT_MAIN_MASTER: u16 = 0x03F8;
const RT_SLIDE_LIST_WITH_TEXT: u16 = 0x0FF0;
const RT_SLIDE_PERSIST_ATOM: u16 = 0x03F3;
const RT_NOTES: u16 = 0x03F0;
const RT_BLIP_JPEG: u16 = 0xF01D;
const RT_BLIP_JPEG_ALT: u16 = 0xF02A;
const RT_BLIP_PNG: u16 = 0xF01E;
const RT_BLIP_DIB: u16 = 0xF01F;
const MSOFBT_BSE: u16 = 0xF007;
const BSE_FO_DELAY_OFFSET: usize = 28;
const MSOFBT_OPT: [u16; 3] = [0xF00B, 0xF121, 0xF122];
const FOPT_ENTRY_LEN: usize = 6;
const FOPT_PROPERTY_ID_MASK: u16 = 0x3FFF;
const MSO_PROPERTY_PIB: u16 = 0x0104;
const MAX_OFFICE_ART_DEPTH: usize = 16;
const RT_USER_EDIT_ATOM: u16 = 0x0FF5;
const RT_PERSIST_DIRECTORY_ATOM: u16 = 0x1772;
const CURRENT_USER_OFFSET_TO_CURRENT_EDIT: usize = 16;
const PERSIST_ID_MASK: u32 = 0x000F_FFFF;
const PERSIST_COUNT_SHIFT: u32 = 20;
const MAX_USER_EDIT_CHAIN: usize = 4096;
const MAX_PICTURE_SIZE: usize = 100 * 1024 * 1024;
#[cfg(test)]
pub(crate) fn extract_ppt_text(content: &[u8]) -> Result<PptExtractionResult> {
extract_ppt_text_with_options(content, false, true)
}
pub(crate) fn extract_ppt_text_with_options(
content: &[u8],
include_master_slides: bool,
extract_images: bool,
) -> Result<PptExtractionResult> {
let cursor = Cursor::new(content);
let mut comp = cfb::CompoundFile::open(cursor)
.map_err(|e| XbergError::parsing(format!("Failed to open PPT as OLE container: {e}")))?;
let metadata = extract_ppt_metadata(&mut comp);
let ppt_stream = read_stream(&mut comp, "/PowerPoint Document")?;
if ppt_stream.is_empty() {
return Err(XbergError::parsing("PowerPoint Document stream is empty"));
}
let mut processing_warnings = Vec::new();
let current_user_stream = read_stream(&mut comp, "/Current User").unwrap_or_default();
let live_slides = live_slide_offsets(&ppt_stream, ¤t_user_stream);
if live_slides.is_none() {
tracing::debug!(
target: "xberg::ppt",
"no live slide list resolved from the persist chain; using stream order"
);
}
let (mut slides, loose_texts, speaker_notes) = extract_texts_from_records(
&ppt_stream,
include_master_slides,
live_slides.as_deref(),
&mut processing_warnings,
)?;
let text = slides
.iter()
.map(|s| s.text.as_str())
.chain(loose_texts.iter().map(String::as_str))
.filter(|t| !t.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if slides.is_empty() && !loose_texts.is_empty() {
slides.push(PptSlideText {
number: 1,
text: loose_texts.join("\n"),
});
}
let slide_count = slides.len();
let images = if extract_images {
match read_stream(&mut comp, "/Pictures") {
Ok(pictures_stream) if !pictures_stream.is_empty() => {
let slide_numbers = picture_slide_numbers(&ppt_stream, live_slides.as_deref());
extract_pictures_from_stream(&pictures_stream, &slide_numbers, &mut processing_warnings)
}
_ => Vec::new(),
}
} else {
Vec::new()
};
Ok(PptExtractionResult {
text: text.trim().to_string(),
slides,
slide_count,
metadata,
speaker_notes,
images,
processing_warnings,
})
}
fn read_u32_le(data: &[u8], offset: usize) -> Option<u32> {
let bytes = data.get(offset..offset.checked_add(4)?)?;
Some(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
fn live_slide_offsets(ppt_stream: &[u8], current_user_stream: &[u8]) -> Option<Vec<usize>> {
let first_edit = read_u32_le(current_user_stream, CURRENT_USER_OFFSET_TO_CURRENT_EDIT)? as usize;
let mut persist: ahash::AHashMap<u32, usize> = ahash::AHashMap::new();
let mut next_edit = Some(first_edit);
let mut hops = 0usize;
while let Some(edit_offset) = next_edit {
hops += 1;
if hops > MAX_USER_EDIT_CHAIN {
return None;
}
let header = ppt_stream.get(edit_offset..edit_offset.checked_add(8)?)?;
if u16::from_le_bytes([header[2], header[3]]) != RT_USER_EDIT_ATOM {
return None;
}
let content = edit_offset + 8;
let offset_last_edit = read_u32_le(ppt_stream, content + 8)? as usize;
let offset_persist_directory = read_u32_le(ppt_stream, content + 12)? as usize;
merge_persist_directory(ppt_stream, offset_persist_directory, &mut persist)?;
next_edit = match offset_last_edit {
0 => None,
previous if previous < edit_offset => Some(previous),
_ => return None,
};
}
let slide_ids = slide_persist_ids_in_presentation_order(ppt_stream)?;
if slide_ids.is_empty() {
return None;
}
slide_ids
.into_iter()
.map(|id| {
let offset = *persist.get(&id)?;
let header = ppt_stream.get(offset..offset.checked_add(8)?)?;
(u16::from_le_bytes([header[2], header[3]]) == RT_SLIDE).then_some(offset)
})
.collect()
}
fn merge_persist_directory(
ppt_stream: &[u8],
directory_offset: usize,
persist: &mut ahash::AHashMap<u32, usize>,
) -> Option<()> {
let header = ppt_stream.get(directory_offset..directory_offset.checked_add(8)?)?;
if u16::from_le_bytes([header[2], header[3]]) != RT_PERSIST_DIRECTORY_ATOM {
return None;
}
let length = u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize;
let start = directory_offset + 8;
let entries = ppt_stream.get(start..start.checked_add(length)?)?;
let mut cursor = 0usize;
while cursor + 4 <= entries.len() {
let packed = read_u32_le(entries, cursor)?;
let first_id = packed & PERSIST_ID_MASK;
let count = (packed >> PERSIST_COUNT_SHIFT) as usize;
cursor += 4;
for index in 0..count {
let offset = read_u32_le(entries, cursor)? as usize;
cursor += 4;
persist.entry(first_id + index as u32).or_insert(offset);
}
}
Some(())
}
fn slide_persist_ids_in_presentation_order(ppt_stream: &[u8]) -> Option<Vec<u32>> {
let mut pos = 0usize;
while pos + 8 <= ppt_stream.len() {
let ver_instance = u16::from_le_bytes([ppt_stream[pos], ppt_stream[pos + 1]]);
let rec_type = u16::from_le_bytes([ppt_stream[pos + 2], ppt_stream[pos + 3]]);
let rec_len = u32::from_le_bytes([
ppt_stream[pos + 4],
ppt_stream[pos + 5],
ppt_stream[pos + 6],
ppt_stream[pos + 7],
]) as usize;
let content_start = pos + 8;
let content_end = content_start.checked_add(rec_len)?;
if content_end > ppt_stream.len() {
return None;
}
if rec_type == RT_SLIDE_LIST_WITH_TEXT && (ver_instance >> 4) == 0 {
let mut ids = Vec::new();
let mut inner = content_start;
while inner + 8 <= content_end {
let inner_type = u16::from_le_bytes([ppt_stream[inner + 2], ppt_stream[inner + 3]]);
let inner_len = u32::from_le_bytes([
ppt_stream[inner + 4],
ppt_stream[inner + 5],
ppt_stream[inner + 6],
ppt_stream[inner + 7],
]) as usize;
if inner_type == RT_SLIDE_PERSIST_ATOM {
ids.push(read_u32_le(ppt_stream, inner + 8)?);
}
inner = inner.checked_add(8)?.checked_add(inner_len)?;
}
return Some(ids);
}
pos = if (ver_instance & 0x000F) == 0x0F {
content_start
} else {
content_end
};
}
None
}
fn extract_texts_from_records(
data: &[u8],
include_master_slides: bool,
live_slides: Option<&[usize]>,
warnings: &mut Vec<ProcessingWarning>,
) -> Result<(Vec<PptSlideText>, Vec<String>, Vec<String>)> {
let mut slides: Vec<PptSlideText> = Vec::new();
let mut loose_texts = Vec::new();
let mut current_slide_number: u32 = 0;
let mut pos = 0;
let mut in_slide_text = false;
let mut slide_end: Option<usize> = None;
let mut current_slide_texts: Vec<String> = Vec::new();
let mut speaker_notes = Vec::new();
let mut in_notes = false;
let mut notes_end: Option<usize> = None;
let mut current_notes_texts: Vec<String> = Vec::new();
let mut outline_texts: Vec<Vec<String>> = Vec::new();
let mut outline_end: Option<usize> = None;
let mut outline_slide_index: Option<usize> = None;
while pos + 8 <= data.len() {
if let Some(end) = slide_end
&& pos >= end
{
slides.push(PptSlideText {
number: current_slide_number,
text: current_slide_texts.join("\n"),
});
current_slide_texts.clear();
in_slide_text = false;
slide_end = None;
}
if let Some(end) = outline_end
&& pos >= end
{
outline_end = None;
outline_slide_index = None;
}
if let Some(end) = notes_end
&& pos >= end
{
if !current_notes_texts.is_empty() {
let notes_text = current_notes_texts.join("\n");
let trimmed = notes_text.trim().to_string();
if !trimmed.is_empty() {
speaker_notes.push(trimmed);
}
current_notes_texts.clear();
}
in_notes = false;
notes_end = None;
}
let rec_ver_instance = u16::from_le_bytes([data[pos], data[pos + 1]]);
let rec_ver = rec_ver_instance & 0x000F;
let rec_type = u16::from_le_bytes([data[pos + 2], data[pos + 3]]);
let rec_len = u32::from_le_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]]) as usize;
if rec_len > data.len() - pos {
crate::core::diagnostics::push_warning(
warnings,
PPT_WARNING_SOURCE,
"Record stream ended with a truncated record; the remaining presentation content was not extracted",
);
break;
}
let is_container = rec_ver == 0x0F;
let content_start = pos + 8;
let content_end = content_start + rec_len;
match rec_type {
RT_SLIDE => {
let presentation_number = match live_slides {
Some(offsets) => match offsets.iter().position(|&offset| offset == pos) {
Some(index) => index as u32 + 1,
None => {
pos = content_end;
continue;
}
},
None => current_slide_number + 1,
};
if in_slide_text {
slides.push(PptSlideText {
number: current_slide_number,
text: current_slide_texts.join("\n"),
});
current_slide_texts.clear();
}
current_slide_number = presentation_number;
in_slide_text = true;
slide_end = Some(content_end);
pos += 8;
continue;
}
RT_NOTES => {
if in_notes && !current_notes_texts.is_empty() {
let notes_text = current_notes_texts.join("\n");
let trimmed = notes_text.trim().to_string();
if !trimmed.is_empty() {
speaker_notes.push(trimmed);
}
current_notes_texts.clear();
}
in_notes = true;
notes_end = Some(content_end);
pos += 8;
continue;
}
RT_MAIN_MASTER if !include_master_slides => {
pos = content_end;
continue;
}
RT_SLIDE_LIST_WITH_TEXT => {
outline_end = Some(content_end);
outline_slide_index = None;
pos += 8;
continue;
}
RT_SLIDE_PERSIST_ATOM if outline_end.is_some() => {
let next = outline_slide_index.map_or(0, |i| i + 1);
outline_slide_index = Some(next);
if outline_texts.len() <= next {
outline_texts.resize(next + 1, Vec::new());
}
pos = content_end;
continue;
}
RT_TEXT_CHARS_ATOM => {
if content_end <= data.len() {
let text_data = &data[content_start..content_end];
let chars: Vec<u16> = text_data
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
let text = String::from_utf16_lossy(&chars);
let cleaned = clean_ppt_text(&text);
if !cleaned.is_empty() {
if let Some(index) = outline_slide_index {
outline_texts[index].push(cleaned);
} else {
if in_notes {
current_notes_texts.push(cleaned.clone());
}
if in_slide_text {
current_slide_texts.push(cleaned);
} else if !in_notes {
loose_texts.push(cleaned);
}
}
}
}
pos = content_end;
continue;
}
RT_TEXT_BYTES_ATOM => {
if content_end <= data.len() {
let text_data = &data[content_start..content_end];
let text: String = text_data.iter().map(|&b| cp1252_to_char(b)).collect();
let cleaned = clean_ppt_text(&text);
if !cleaned.is_empty() {
if let Some(index) = outline_slide_index {
outline_texts[index].push(cleaned);
} else {
if in_notes {
current_notes_texts.push(cleaned.clone());
}
if in_slide_text {
current_slide_texts.push(cleaned);
} else if !in_notes {
loose_texts.push(cleaned);
}
}
}
}
pos = content_end;
continue;
}
_ => {}
}
if is_container {
pos += 8;
} else {
pos = content_end;
}
}
if in_slide_text {
slides.push(PptSlideText {
number: current_slide_number,
text: current_slide_texts.join("\n"),
});
}
if !current_notes_texts.is_empty() {
let notes_text = current_notes_texts.join("\n");
let trimmed = notes_text.trim().to_string();
if !trimmed.is_empty() {
speaker_notes.push(trimmed);
}
}
if live_slides.is_some() {
slides.sort_by_key(|slide| slide.number);
}
for (index, outline) in outline_texts.iter().enumerate() {
let Some(slide) = slides.get_mut(index) else {
continue;
};
let recovered: Vec<&str> = outline
.iter()
.map(String::as_str)
.filter(|line| !slide.text.lines().any(|existing| existing == *line))
.collect();
if recovered.is_empty() {
continue;
}
let joined = recovered.join("\n");
slide.text = if slide.text.is_empty() {
joined
} else {
format!("{joined}\n{}", slide.text)
};
}
Ok((slides, loose_texts, speaker_notes))
}
struct ArtRecord {
rec_instance: u16,
rec_type: u16,
content_start: usize,
content_end: usize,
}
fn collect_art_records(data: &[u8], start: usize, end: usize, depth: usize, out: &mut Vec<ArtRecord>) {
if depth > MAX_OFFICE_ART_DEPTH {
return;
}
let mut pos = start;
while pos + 8 <= end {
let rec_ver_instance = u16::from_le_bytes([data[pos], data[pos + 1]]);
let rec_type = u16::from_le_bytes([data[pos + 2], data[pos + 3]]);
let rec_len = u32::from_le_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]]) as usize;
let content_start = pos + 8;
let Some(content_end) = content_start.checked_add(rec_len).filter(|&candidate| candidate <= end) else {
return;
};
out.push(ArtRecord {
rec_instance: rec_ver_instance >> 4,
rec_type,
content_start,
content_end,
});
if rec_ver_instance & 0x000F == 0x0F {
collect_art_records(data, content_start, content_end, depth + 1, out);
}
pos = content_end;
}
}
fn blip_stream_offsets(data: &[u8], records: &[ArtRecord]) -> Vec<u32> {
records
.iter()
.filter(|record| record.rec_type == MSOFBT_BSE)
.filter_map(|record| {
let at = record.content_start.checked_add(BSE_FO_DELAY_OFFSET)?;
(at.checked_add(4)? <= record.content_end).then(|| read_u32_le(data, at))?
})
.collect()
}
fn slide_blip_indices(data: &[u8], records: &[ArtRecord], slide_start: usize, slide_end: usize) -> Vec<u32> {
let mut indices = Vec::new();
for record in records
.iter()
.filter(|record| MSOFBT_OPT.contains(&record.rec_type))
.filter(|record| record.content_start >= slide_start && record.content_end <= slide_end)
{
for entry in 0..record.rec_instance as usize {
let Some(at) = entry
.checked_mul(FOPT_ENTRY_LEN)
.and_then(|o| record.content_start.checked_add(o))
else {
break;
};
if at + FOPT_ENTRY_LEN > record.content_end {
break;
}
let opid = u16::from_le_bytes([data[at], data[at + 1]]);
if opid & FOPT_PROPERTY_ID_MASK == MSO_PROPERTY_PIB
&& let Some(pib) = read_u32_le(data, at + 2)
&& pib > 0
{
indices.push(pib);
}
}
}
indices
}
fn picture_slide_numbers(data: &[u8], live_slides: Option<&[usize]>) -> std::collections::HashMap<u32, u32> {
let mut records = Vec::new();
collect_art_records(data, 0, data.len(), 0, &mut records);
let blip_offsets = blip_stream_offsets(data, &records);
let mut by_offset = std::collections::HashMap::new();
if blip_offsets.is_empty() {
return by_offset;
}
let slide_ranges: Vec<(usize, usize)> = match live_slides {
Some(offsets) => offsets
.iter()
.filter_map(|&offset| {
records
.iter()
.find(|record| record.rec_type == RT_SLIDE && record.content_start == offset + 8)
.map(|record| (record.content_start, record.content_end))
})
.collect(),
None => records
.iter()
.filter(|record| record.rec_type == RT_SLIDE)
.map(|record| (record.content_start, record.content_end))
.collect(),
};
for (index, (slide_start, slide_end)) in slide_ranges.iter().enumerate() {
let slide_number = index as u32 + 1;
for pib in slide_blip_indices(data, &records, *slide_start, *slide_end) {
if let Some(&offset) = blip_offsets.get(pib as usize - 1) {
by_offset.entry(offset).or_insert(slide_number);
}
}
}
by_offset
}
fn extract_pictures_from_stream(
data: &[u8],
slide_numbers: &std::collections::HashMap<u32, u32>,
warnings: &mut Vec<ProcessingWarning>,
) -> Vec<ExtractedImage> {
let mut images = Vec::new();
let mut pos = 0usize;
let mut image_index: u32 = 0;
while pos + 8 <= data.len() {
let rec_ver_instance = u16::from_le_bytes([data[pos], data[pos + 1]]);
let rec_instance = rec_ver_instance >> 4;
let rec_type = u16::from_le_bytes([data[pos + 2], data[pos + 3]]);
let rec_len = u32::from_le_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]]) as usize;
let remaining = data.len() - (pos + 8);
if rec_len > remaining {
crate::core::diagnostics::push_warning(
warnings,
PPT_WARNING_SOURCE,
"Pictures stream ended with a truncated record; the remaining embedded images were not extracted",
);
break;
}
let content_start = pos + 8;
let content_end = content_start + rec_len;
let format: Option<Cow<'static, str>> = match rec_type {
RT_BLIP_JPEG | RT_BLIP_JPEG_ALT => Some(Cow::Borrowed("jpeg")),
RT_BLIP_PNG => Some(Cow::Borrowed("png")),
RT_BLIP_DIB => Some(Cow::Borrowed("dib")),
_ => None,
};
if let Some(format) = format {
let header_len = if rec_instance & 0x1 == 1 { 33 } else { 17 };
if rec_len < header_len {
crate::core::diagnostics::push_warning(
warnings,
PPT_WARNING_SOURCE,
format!(
"Blip record at offset {pos} (recLen={rec_len}) is shorter than its UID header \
({header_len} bytes); skipped"
),
);
pos = content_end;
continue;
}
let picture_len = rec_len - header_len;
if picture_len == 0 {
pos = content_end;
continue;
}
if picture_len > MAX_PICTURE_SIZE {
crate::core::diagnostics::push_warning(
warnings,
PPT_WARNING_SOURCE,
format!(
"Embedded picture at offset {pos} ({picture_len} bytes) exceeds the \
{MAX_PICTURE_SIZE}-byte size cap and was skipped"
),
);
pos = content_end;
continue;
}
let picture_start = content_start + header_len;
let picture_bytes = &data[picture_start..content_end];
images.push(ExtractedImage {
data: Bytes::copy_from_slice(picture_bytes),
format,
image_index,
page_number: u32::try_from(pos)
.ok()
.and_then(|offset| slide_numbers.get(&offset))
.copied(),
width: None,
height: None,
colorspace: None,
bits_per_component: None,
is_mask: false,
description: None,
ocr_result: None,
bounding_box: None,
source_path: None,
image_kind: None,
kind_confidence: None,
cluster_id: None,
caption: None,
qr_codes: None,
data_base64: None,
});
image_index += 1;
}
pos = content_end;
}
images
}
fn clean_ppt_text(text: &str) -> String {
let mut result = String::with_capacity(text.len());
for c in text.chars() {
match c {
'\r' => result.push('\n'),
'\x0B' => result.push('\n'),
c if c < '\x20' && c != '\n' && c != '\t' => {}
_ => result.push(c),
}
}
let cleaned = result
.lines()
.map(|line| line.trim_end())
.collect::<Vec<_>>()
.join("\n");
let trimmed = cleaned.trim();
if trimmed.chars().all(|c| c == '*' || c == '\n' || c.is_whitespace()) {
return String::new();
}
cleaned
}
fn cp1252_to_char(b: u8) -> char {
match b {
0x80 => '\u{20AC}',
0x82 => '\u{201A}',
0x83 => '\u{0192}',
0x84 => '\u{201E}',
0x85 => '\u{2026}',
0x86 => '\u{2020}',
0x87 => '\u{2021}',
0x88 => '\u{02C6}',
0x89 => '\u{2030}',
0x8A => '\u{0160}',
0x8B => '\u{2039}',
0x8C => '\u{0152}',
0x8E => '\u{017D}',
0x91 => '\u{2018}',
0x92 => '\u{2019}',
0x93 => '\u{201C}',
0x94 => '\u{201D}',
0x95 => '\u{2022}',
0x96 => '\u{2013}',
0x97 => '\u{2014}',
0x98 => '\u{02DC}',
0x99 => '\u{2122}',
0x9A => '\u{0161}',
0x9B => '\u{203A}',
0x9C => '\u{0153}',
0x9E => '\u{017E}',
0x9F => '\u{0178}',
b => b as char,
}
}
fn read_stream(comp: &mut cfb::CompoundFile<Cursor<&[u8]>>, name: &str) -> Result<Vec<u8>> {
use std::io::Read;
let mut stream = comp
.open_stream(name)
.map_err(|e| XbergError::parsing(format!("Failed to open stream '{name}': {e}")))?;
let mut data = Vec::new();
stream
.read_to_end(&mut data)
.map_err(|e| XbergError::parsing(format!("Failed to read stream '{name}': {e}")))?;
Ok(data)
}
fn extract_ppt_metadata(comp: &mut cfb::CompoundFile<Cursor<&[u8]>>) -> PptMetadata {
let mut meta = PptMetadata::default();
if let Ok(data) = read_stream(comp, "/\x05SummaryInformation") {
parse_summary_info(&data, &mut meta);
}
meta
}
fn parse_summary_info(data: &[u8], meta: &mut PptMetadata) {
if data.len() < 48 {
return;
}
let set_offset = u32::from_le_bytes([data[44], data[45], data[46], data[47]]) as usize;
if set_offset + 8 > data.len() {
return;
}
let num_props = u32::from_le_bytes([
data[set_offset + 4],
data[set_offset + 5],
data[set_offset + 6],
data[set_offset + 7],
]) as usize;
let props_start = set_offset + 8;
for i in 0..num_props {
let entry_offset = props_start + i * 8;
if entry_offset + 8 > data.len() {
break;
}
let prop_id = u32::from_le_bytes([
data[entry_offset],
data[entry_offset + 1],
data[entry_offset + 2],
data[entry_offset + 3],
]);
let prop_offset = u32::from_le_bytes([
data[entry_offset + 4],
data[entry_offset + 5],
data[entry_offset + 6],
data[entry_offset + 7],
]) as usize;
let abs_offset = set_offset + prop_offset;
if abs_offset + 8 > data.len() {
continue;
}
if let Some(value) = read_property_value(data, abs_offset) {
match prop_id {
2 => meta.title = Some(value),
3 => meta.subject = Some(value),
4 => meta.author = Some(value),
8 => meta.last_author = Some(value),
_ => {}
}
}
}
}
fn read_property_value(data: &[u8], offset: usize) -> Option<String> {
if offset + 8 > data.len() {
return None;
}
let vt_type = u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]);
match vt_type {
30 => {
let len =
u32::from_le_bytes([data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]]) as usize;
if len == 0 || offset + 8 + len > data.len() {
return None;
}
let bytes = &data[offset + 8..offset + 8 + len];
let trimmed = bytes.iter().take_while(|&&b| b != 0).copied().collect::<Vec<_>>();
Some(String::from_utf8_lossy(&trimmed).to_string())
}
31 => {
let len =
u32::from_le_bytes([data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]]) as usize;
if len == 0 || offset + 8 + len * 2 > data.len() {
return None;
}
let bytes = &data[offset + 8..offset + 8 + len * 2];
let chars: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.take_while(|&c| c != 0)
.collect();
Some(String::from_utf16_lossy(&chars))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_ppt_text() {
assert_eq!(clean_ppt_text("Hello\rWorld"), "Hello\nWorld");
assert_eq!(clean_ppt_text("A\x0BB"), "A\nB");
}
#[test]
fn test_cp1252_to_char() {
assert_eq!(cp1252_to_char(b'A'), 'A');
assert_eq!(cp1252_to_char(0x80), '\u{20AC}');
}
#[test]
fn test_extract_ppt_real_file() {
let test_file = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test_documents/ppt/simple.ppt");
if !test_file.exists() {
return;
}
let content = std::fs::read(&test_file).expect("Failed to read test PPT");
let result = extract_ppt_text(&content).expect("Failed to extract PPT text");
assert!(!result.text.is_empty(), "PPT extraction should produce text");
}
#[test]
fn test_extract_ppt_invalid_data() {
let result = extract_ppt_text(b"not a ppt file");
assert!(result.is_err());
}
#[test]
fn test_extract_ppt_real_file_reports_two_slides() {
let test_file = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test_documents/ppt/simple.ppt");
if !test_file.exists() {
return;
}
let content = std::fs::read(&test_file).expect("Failed to read test PPT");
let result = extract_ppt_text(&content).expect("Failed to extract PPT text");
assert_eq!(result.slide_count, 2, "simple.ppt has exactly two Slide containers");
}
fn record_header(rec_ver_instance: u16, rec_type: u16, rec_len: u32) -> Vec<u8> {
let mut buf = Vec::with_capacity(8);
buf.extend_from_slice(&rec_ver_instance.to_le_bytes());
buf.extend_from_slice(&rec_type.to_le_bytes());
buf.extend_from_slice(&rec_len.to_le_bytes());
buf
}
fn container(rec_type: u16, children: &[u8]) -> Vec<u8> {
let mut buf = record_header(0x000F, rec_type, children.len() as u32);
buf.extend_from_slice(children);
buf
}
fn text_chars_atom(text: &str) -> Vec<u8> {
let utf16: Vec<u8> = text.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
let mut buf = record_header(0x0000, RT_TEXT_CHARS_ATOM, utf16.len() as u32);
buf.extend_from_slice(&utf16);
buf
}
fn slide_persist_atom(persist_id: u32) -> Vec<u8> {
let mut buf = record_header(0x0000, RT_SLIDE_PERSIST_ATOM, 20);
buf.extend_from_slice(&persist_id.to_le_bytes());
buf.extend_from_slice(&[0u8; 16]);
buf
}
fn persist_directory(entries: &[(u32, u32)]) -> Vec<u8> {
let mut body = Vec::new();
for (id, offset) in entries {
body.extend_from_slice(&((1u32 << PERSIST_COUNT_SHIFT) | (id & PERSIST_ID_MASK)).to_le_bytes());
body.extend_from_slice(&offset.to_le_bytes());
}
let mut buf = record_header(0x0000, RT_PERSIST_DIRECTORY_ATOM, body.len() as u32);
buf.extend_from_slice(&body);
buf
}
fn user_edit_atom(offset_last_edit: u32, offset_persist_directory: u32) -> Vec<u8> {
let mut buf = record_header(0x0000, RT_USER_EDIT_ATOM, 28);
buf.extend_from_slice(&1u32.to_le_bytes()); buf.extend_from_slice(&[0u8; 4]); buf.extend_from_slice(&offset_last_edit.to_le_bytes());
buf.extend_from_slice(&offset_persist_directory.to_le_bytes());
buf.extend_from_slice(&[0u8; 12]); buf
}
fn current_user_stream(offset_to_current_edit: u32) -> Vec<u8> {
let mut buf = record_header(0x0000, 0x0FF6, 0x14);
buf.extend_from_slice(&0x14u32.to_le_bytes()); buf.extend_from_slice(&0xE391_C05Fu32.to_le_bytes()); buf.extend_from_slice(&offset_to_current_edit.to_le_bytes());
buf.extend_from_slice(&[0u8; 8]);
buf
}
#[test]
fn a_superseded_slide_revision_is_not_extracted_as_a_slide() {
let stale = container(RT_SLIDE, &text_chars_atom("stale revision"));
let live_one = container(RT_SLIDE, &text_chars_atom("first slide"));
let live_two = container(RT_SLIDE, &text_chars_atom("second slide"));
let mut data = Vec::new();
let stale_offset = data.len() as u32;
data.extend_from_slice(&stale);
let one_offset = data.len() as u32;
data.extend_from_slice(&live_one);
let two_offset = data.len() as u32;
data.extend_from_slice(&live_two);
let slide_list = container(
RT_SLIDE_LIST_WITH_TEXT,
&[slide_persist_atom(1), slide_persist_atom(2)].concat(),
);
data.extend_from_slice(&slide_list);
let old_dir_offset = data.len() as u32;
data.extend_from_slice(&persist_directory(&[(1, stale_offset)]));
let new_dir_offset = data.len() as u32;
data.extend_from_slice(&persist_directory(&[(1, one_offset), (2, two_offset)]));
let old_edit_offset = data.len() as u32;
data.extend_from_slice(&user_edit_atom(0, old_dir_offset));
let new_edit_offset = data.len() as u32;
data.extend_from_slice(&user_edit_atom(old_edit_offset, new_dir_offset));
let live =
live_slide_offsets(&data, ¤t_user_stream(new_edit_offset)).expect("the persist chain must resolve");
assert_eq!(
live,
vec![one_offset as usize, two_offset as usize],
"the stale revision's offset must not appear in the live slide list"
);
let mut warnings = Vec::new();
let (slides, _, _) = extract_texts_from_records(&data, false, Some(&live), &mut warnings)
.expect("record parsing should succeed");
assert_eq!(slides.len(), 2, "three Slide containers, two live slides");
assert_eq!(slides[0].text, "first slide");
assert_eq!(slides[1].text, "second slide");
assert!(
!slides.iter().any(|slide| slide.text.contains("stale")),
"a superseded revision must not reach the output"
);
let mut stream_order_warnings = Vec::new();
let (stream_order_slides, _, _) = extract_texts_from_records(&data, false, None, &mut stream_order_warnings)
.expect("record parsing should succeed");
assert_eq!(
stream_order_slides.len(),
3,
"stream order counts the superseded revision"
);
assert_eq!(stream_order_slides[0].text, "stale revision");
}
#[test]
fn slides_are_numbered_by_presentation_order_not_stream_order() {
let first_in_stream = container(RT_SLIDE, &text_chars_atom("appears second"));
let second_in_stream = container(RT_SLIDE, &text_chars_atom("appears first"));
let mut data = Vec::new();
let stream_a = data.len() as u32;
data.extend_from_slice(&first_in_stream);
let stream_b = data.len() as u32;
data.extend_from_slice(&second_in_stream);
let slide_list = container(
RT_SLIDE_LIST_WITH_TEXT,
&[slide_persist_atom(1), slide_persist_atom(2)].concat(),
);
data.extend_from_slice(&slide_list);
let dir_offset = data.len() as u32;
data.extend_from_slice(&persist_directory(&[(1, stream_b), (2, stream_a)]));
let edit_offset = data.len() as u32;
data.extend_from_slice(&user_edit_atom(0, dir_offset));
let live =
live_slide_offsets(&data, ¤t_user_stream(edit_offset)).expect("the persist chain must resolve");
assert_eq!(live, vec![stream_b as usize, stream_a as usize]);
let mut warnings = Vec::new();
let (slides, _, _) = extract_texts_from_records(&data, false, Some(&live), &mut warnings)
.expect("record parsing should succeed");
assert_eq!(
slides.iter().map(|s| (s.number, s.text.as_str())).collect::<Vec<_>>(),
vec![(1, "appears first"), (2, "appears second")],
"slide numbers and order come from the slide list, not the byte order"
);
}
#[test]
fn an_unreadable_persist_chain_falls_back_to_stream_order() {
let mut data = Vec::new();
data.extend_from_slice(&container(RT_SLIDE, &text_chars_atom("one")));
data.extend_from_slice(&container(RT_SLIDE, &text_chars_atom("two")));
assert!(
live_slide_offsets(&data, &[]).is_none(),
"an empty Current User stream resolves nothing"
);
assert!(
live_slide_offsets(&data, ¤t_user_stream(9_999)).is_none(),
"an offsetToCurrentEdit past the end of the stream resolves nothing"
);
let mut warnings = Vec::new();
let (slides, _, _) =
extract_texts_from_records(&data, false, None, &mut warnings).expect("record parsing should succeed");
assert_eq!(slides.len(), 2, "stream order still yields both slides");
}
#[test]
fn test_extract_texts_segments_on_slide_not_slide_list_with_text() {
const RT_SLIDE_LIST_WITH_TEXT: u16 = 0x0FF0;
const RT_SLIDE_PERSIST_ATOM: u16 = 0x03F3;
let slide_persist_atom = record_header(0x0000, RT_SLIDE_PERSIST_ATOM, 0);
let bogus_slwt = container(RT_SLIDE_LIST_WITH_TEXT, &slide_persist_atom);
let slide1 = container(RT_SLIDE, &text_chars_atom("Slide One"));
let slide2 = container(RT_SLIDE, &text_chars_atom("Slide Two"));
let mut data = Vec::new();
data.extend_from_slice(&bogus_slwt);
data.extend_from_slice(&slide1);
data.extend_from_slice(&slide2);
let mut warnings = Vec::new();
let (slides, loose_texts, notes) =
extract_texts_from_records(&data, false, None, &mut warnings).expect("record parsing should succeed");
assert_eq!(
slides.len(),
2,
"each Slide container is one slide, not each SlideListWithText"
);
assert_eq!(slides[0].number, 1);
assert_eq!(slides[0].text, "Slide One");
assert_eq!(slides[1].number, 2);
assert_eq!(slides[1].text, "Slide Two");
assert!(loose_texts.is_empty());
assert!(notes.is_empty());
assert!(warnings.is_empty(), "well-formed records should not warn: {warnings:?}");
}
#[test]
fn test_extract_texts_recovers_titles_from_slide_list_with_text() {
const RT_SLIDE_LIST_WITH_TEXT: u16 = 0x0FF0;
const RT_SLIDE_PERSIST_ATOM: u16 = 0x03F3;
let mut outline = Vec::new();
outline.extend_from_slice(&record_header(0x0000, RT_SLIDE_PERSIST_ATOM, 0));
outline.extend_from_slice(&text_chars_atom("Search strategy development"));
outline.extend_from_slice(&record_header(0x0000, RT_SLIDE_PERSIST_ATOM, 0));
outline.extend_from_slice(&text_chars_atom("Results and discussion"));
let slwt = container(RT_SLIDE_LIST_WITH_TEXT, &outline);
let slide1 = container(RT_SLIDE, &text_chars_atom("=> FILE HCAPLUS"));
let slide2 = container(RT_SLIDE, &text_chars_atom("=> DISPLAY L1"));
let mut data = Vec::new();
data.extend_from_slice(&slwt);
data.extend_from_slice(&slide1);
data.extend_from_slice(&slide2);
let mut warnings = Vec::new();
let (slides, loose_texts, notes) =
extract_texts_from_records(&data, false, None, &mut warnings).expect("record parsing should succeed");
assert_eq!(slides.len(), 2, "still one slide per Slide container");
assert_eq!(slides[0].number, 1);
assert_eq!(slides[0].text, "Search strategy development\n=> FILE HCAPLUS");
assert_eq!(slides[1].number, 2);
assert_eq!(slides[1].text, "Results and discussion\n=> DISPLAY L1");
assert!(
loose_texts.is_empty(),
"outline text belongs to a slide, not to loose text"
);
assert!(notes.is_empty());
assert!(warnings.is_empty(), "well-formed records should not warn: {warnings:?}");
}
#[test]
fn test_outline_title_already_drawn_on_the_slide_is_not_duplicated() {
const RT_SLIDE_LIST_WITH_TEXT: u16 = 0x0FF0;
const RT_SLIDE_PERSIST_ATOM: u16 = 0x03F3;
let mut outline = Vec::new();
outline.extend_from_slice(&record_header(0x0000, RT_SLIDE_PERSIST_ATOM, 0));
outline.extend_from_slice(&text_chars_atom("Title Slide"));
let slwt = container(RT_SLIDE_LIST_WITH_TEXT, &outline);
let mut slide_children = Vec::new();
slide_children.extend_from_slice(&text_chars_atom("Title Slide"));
slide_children.extend_from_slice(&text_chars_atom("With a subtitle"));
let slide1 = container(RT_SLIDE, &slide_children);
let mut data = Vec::new();
data.extend_from_slice(&slwt);
data.extend_from_slice(&slide1);
let mut warnings = Vec::new();
let (slides, _loose, _notes) =
extract_texts_from_records(&data, false, None, &mut warnings).expect("record parsing should succeed");
assert_eq!(slides.len(), 1);
assert_eq!(slides[0].text, "Title Slide\nWith a subtitle");
}
#[test]
fn test_extract_texts_closes_notes_range_before_next_slide() {
let notes = container(RT_NOTES, &text_chars_atom("Speaker notes"));
let slide1 = container(RT_SLIDE, &text_chars_atom("Slide One"));
let mut data = Vec::new();
data.extend_from_slice(¬es);
data.extend_from_slice(&slide1);
let mut warnings = Vec::new();
let (slides, loose_texts, speaker_notes) =
extract_texts_from_records(&data, false, None, &mut warnings).expect("record parsing should succeed");
assert_eq!(slides.len(), 1);
assert_eq!(slides[0].number, 1);
assert_eq!(slides[0].text, "Slide One");
assert!(loose_texts.is_empty());
assert_eq!(speaker_notes, vec!["Speaker notes".to_string()]);
}
#[test]
fn should_number_slides_by_persist_order_when_a_middle_slide_has_no_text() {
let slide1 = container(RT_SLIDE, &text_chars_atom("Slide One"));
let slide2 = container(RT_SLIDE, &[]); let slide3 = container(RT_SLIDE, &text_chars_atom("Slide Three"));
let mut data = Vec::new();
data.extend_from_slice(&slide1);
data.extend_from_slice(&slide2);
data.extend_from_slice(&slide3);
let mut warnings = Vec::new();
let (slides, _loose_texts, _notes) =
extract_texts_from_records(&data, false, None, &mut warnings).expect("record parsing should succeed");
assert_eq!(
slides.len(),
3,
"the empty middle slide must still produce a slide entry"
);
assert_eq!(slides[0].number, 1);
assert_eq!(slides[0].text, "Slide One");
assert_eq!(slides[1].number, 2);
assert_eq!(
slides[1].text, "",
"a slide with no text atoms has empty text, not a missing entry"
);
assert_eq!(slides[2].number, 3);
assert_eq!(slides[2].text, "Slide Three");
}
#[test]
fn should_keep_one_slide_entry_when_slide_text_contains_internal_blank_line() {
let atom_with_trailing_blank_paragraph = text_chars_atom("Title\r\r");
let atom_body = text_chars_atom("Body");
let mut slide_children = Vec::new();
slide_children.extend_from_slice(&atom_with_trailing_blank_paragraph);
slide_children.extend_from_slice(&atom_body);
let slide1 = container(RT_SLIDE, &slide_children);
let mut data = Vec::new();
data.extend_from_slice(&slide1);
let mut warnings = Vec::new();
let (slides, _loose_texts, _notes) =
extract_texts_from_records(&data, false, None, &mut warnings).expect("record parsing should succeed");
assert_eq!(
slides.len(),
1,
"one Slide container is one slide, however its joined text looks"
);
assert_eq!(slides[0].number, 1);
assert_eq!(
slides[0].text, "Title\n\nBody",
"the slide's own text legitimately contains an internal blank line"
);
}
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"
);
}
#[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);
}
}