mod embedded;
pub(crate) use embedded::extract_ppt_embedded_objects;
use crate::error::{Result, XbergError};
use crate::types::{ExtractedImage, ProcessingWarning};
use bytes::Bytes;
use std::borrow::Cow;
use std::io::Cursor;
pub(super) 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,
pub title: Option<String>,
pub notes: Option<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 SLIDE_PERSIST_ATOM_SLIDE_ID_OFFSET: usize = 12;
const RT_NOTES: u16 = 0x03F0;
const RT_NOTES_ATOM: u16 = 0x03F1;
const NOTES_MASTER_SLIDE_ID_REF: u32 = 0x8000_0000;
const RT_TEXT_HEADER_ATOM: u16 = 0x0F9F;
const TEXT_TYPE_TITLE: u32 = 0;
const TEXT_TYPE_CENTER_TITLE: u32 = 6;
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 RT_DOCUMENT: u16 = 0x03E8;
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_SLIDE_LIST_SEARCH_DEPTH: usize = 16;
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)
}
fn assemble_full_text(slides: &[PptSlideText], loose_texts: &[String]) -> String {
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")
}
fn extract_images_if_requested(
comp: &mut cfb::CompoundFile<Cursor<&[u8]>>,
ppt_stream: &[u8],
live_slides: Option<&LiveSlides>,
extract_images: bool,
warnings: &mut Vec<ProcessingWarning>,
) -> Vec<ExtractedImage> {
if !extract_images {
return Vec::new();
}
match read_stream(comp, "/Pictures") {
Ok(pictures_stream) if !pictures_stream.is_empty() => {
let slide_numbers = picture_slide_numbers(ppt_stream, live_slides.map(|l| l.offsets.as_slice()));
extract_pictures_from_stream(&pictures_stream, &slide_numbers, warnings)
}
_ => Vec::new(),
}
}
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_ref(),
&mut processing_warnings,
)?;
let text = assemble_full_text(&slides, &loose_texts);
if slides.is_empty() && !loose_texts.is_empty() {
slides.push(PptSlideText {
number: 1,
text: loose_texts.join("\n"),
title: None,
notes: None,
});
}
let slide_count = slides.len();
let images = extract_images_if_requested(
&mut comp,
&ppt_stream,
live_slides.as_ref(),
extract_images,
&mut processing_warnings,
);
Ok(PptExtractionResult {
text: text.trim().to_string(),
slides,
slide_count,
metadata,
speaker_notes,
images,
processing_warnings,
})
}
pub(super) 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]]))
}
pub(super) struct LiveSlides {
pub(super) offsets: Vec<usize>,
outline_record_offset: usize,
slide_ids: Vec<u32>,
pub(super) persist: ahash::AHashMap<u32, usize>,
pub(super) document_payload: (usize, usize),
}
pub(super) fn live_slide_offsets(ppt_stream: &[u8], current_user_stream: &[u8]) -> Option<LiveSlides> {
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 document_id: Option<u32> = None;
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;
document_id.get_or_insert(read_u32_le(ppt_stream, content + 16)?);
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 document_offset = *persist.get(&document_id?)?;
let document_header = ppt_stream.get(document_offset..document_offset.checked_add(8)?)?;
if u16::from_le_bytes([document_header[2], document_header[3]]) != RT_DOCUMENT {
return None;
}
let document_len = u32::from_le_bytes([
document_header[4],
document_header[5],
document_header[6],
document_header[7],
]) as usize;
let document_content_start = document_offset.checked_add(8)?;
let document_content_end = document_content_start.checked_add(document_len)?;
let document_payload = ppt_stream.get(document_content_start..document_content_end)?;
let (relative_outline_offset, entries) = slide_persist_ids_in_presentation_order(document_payload, 0)?;
if entries.is_empty() {
return None;
}
let outline_record_offset = document_content_start + relative_outline_offset;
let offsets = entries
.iter()
.map(|&(persist_id, _)| {
let offset = *persist.get(&persist_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::<Option<Vec<usize>>>()?;
let slide_ids = entries.iter().map(|&(_, slide_id)| slide_id).collect();
Some(LiveSlides {
offsets,
outline_record_offset,
slide_ids,
persist,
document_payload: (document_content_start, document_content_end),
})
}
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(container: &[u8], depth: usize) -> Option<(usize, Vec<(u32, u32)>)> {
if depth > MAX_SLIDE_LIST_SEARCH_DEPTH {
return None;
}
let mut pos = 0usize;
while pos + 8 <= container.len() {
let ver_instance = u16::from_le_bytes([container[pos], container[pos + 1]]);
let rec_type = u16::from_le_bytes([container[pos + 2], container[pos + 3]]);
let rec_len = u32::from_le_bytes([
container[pos + 4],
container[pos + 5],
container[pos + 6],
container[pos + 7],
]) as usize;
let content_start = pos + 8;
let content_end = content_start.checked_add(rec_len)?;
if content_end > container.len() {
return None;
}
if rec_type == RT_SLIDE_LIST_WITH_TEXT && (ver_instance >> 4) == 0 {
let mut entries = Vec::new();
let mut inner = content_start;
while inner + 8 <= content_end {
let inner_type = u16::from_le_bytes([container[inner + 2], container[inner + 3]]);
let inner_len = u32::from_le_bytes([
container[inner + 4],
container[inner + 5],
container[inner + 6],
container[inner + 7],
]) as usize;
if inner_type == RT_SLIDE_PERSIST_ATOM {
let persist_id = read_u32_le(container, inner + 8)?;
let slide_id = read_u32_le(container, inner + 8 + SLIDE_PERSIST_ATOM_SLIDE_ID_OFFSET)?;
entries.push((persist_id, slide_id));
}
inner = inner.checked_add(8)?.checked_add(inner_len)?;
}
return Some((pos, entries));
}
if (ver_instance & 0x000F) == 0x0F
&& let Some((relative, entries)) =
slide_persist_ids_in_presentation_order(&container[content_start..content_end], depth + 1)
{
return Some((content_start + relative, entries));
}
pos = content_end;
}
None
}
fn record_outline_text(
outline_texts: &mut [Vec<String>],
outline_titles: &mut [Option<String>],
index: usize,
text_type: Option<u32>,
cleaned: String,
) {
if matches!(text_type, Some(TEXT_TYPE_TITLE | TEXT_TYPE_CENTER_TITLE)) && outline_titles[index].is_none() {
outline_titles[index] = Some(cleaned.clone());
}
outline_texts[index].push(cleaned);
}
struct TextDispatchState<'a> {
outline_texts: &'a mut Vec<Vec<String>>,
outline_titles: &'a mut Vec<Option<String>>,
outline_slide_index: Option<usize>,
outline_text_type: Option<u32>,
in_notes: bool,
current_notes_texts: &'a mut Vec<String>,
in_slide_text: bool,
current_slide_texts: &'a mut Vec<String>,
loose_texts: &'a mut Vec<String>,
}
fn dispatch_cleaned_text(cleaned: String, state: TextDispatchState) {
if cleaned.is_empty() {
return;
}
if let Some(index) = state.outline_slide_index {
record_outline_text(
state.outline_texts,
state.outline_titles,
index,
state.outline_text_type,
cleaned,
);
} else {
if state.in_notes {
state.current_notes_texts.push(cleaned.clone());
}
if state.in_slide_text {
state.current_slide_texts.push(cleaned);
} else if !state.in_notes {
state.loose_texts.push(cleaned);
}
}
}
fn commit_notes(
current_notes_texts: &mut Vec<String>,
current_notes_slide_id: Option<u32>,
slide_number_by_id: &ahash::AHashMap<u32, u32>,
next_positional_slide_number: &mut u32,
notes_by_slide: &mut std::collections::BTreeMap<u32, String>,
) {
if current_notes_texts.is_empty() {
return;
}
let notes_text = current_notes_texts.join("\n");
current_notes_texts.clear();
let trimmed = notes_text.trim();
if trimmed.is_empty() || current_notes_slide_id == Some(NOTES_MASTER_SLIDE_ID_REF) {
return;
}
let slide_number = if slide_number_by_id.is_empty() {
let number = *next_positional_slide_number;
*next_positional_slide_number += 1;
Some(number)
} else {
current_notes_slide_id.and_then(|id| slide_number_by_id.get(&id).copied())
};
if let Some(number) = slide_number {
notes_by_slide.insert(number, trimmed.to_string());
}
}
fn extract_texts_from_records(
data: &[u8],
include_master_slides: bool,
live: Option<&LiveSlides>,
warnings: &mut Vec<ProcessingWarning>,
) -> Result<(Vec<PptSlideText>, Vec<String>, Vec<String>)> {
let live_slides = live.map(|l| l.offsets.as_slice());
let live_outline_offset = live.map(|l| l.outline_record_offset);
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 in_notes = false;
let mut notes_end: Option<usize> = None;
let mut current_notes_texts: Vec<String> = Vec::new();
let mut current_notes_slide_id: Option<u32> = None;
let mut notes_by_slide: std::collections::BTreeMap<u32, String> = std::collections::BTreeMap::new();
let mut next_positional_slide_number: u32 = 1;
let slide_number_by_id: ahash::AHashMap<u32, u32> = live
.map(|l| {
l.slide_ids
.iter()
.enumerate()
.map(|(i, &id)| (id, i as u32 + 1))
.collect()
})
.unwrap_or_default();
let mut outline_texts: Vec<Vec<String>> = Vec::new();
let mut outline_end: Option<usize> = None;
let mut outline_slide_index: Option<usize> = None;
let mut outline_titles: Vec<Option<String>> = Vec::new();
let mut outline_text_type: Option<u32> = 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"),
title: None,
notes: None,
});
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
{
commit_notes(
&mut current_notes_texts,
current_notes_slide_id,
&slide_number_by_id,
&mut next_positional_slide_number,
&mut notes_by_slide,
);
current_notes_slide_id = None;
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"),
title: None,
notes: None,
});
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 {
commit_notes(
&mut current_notes_texts,
current_notes_slide_id,
&slide_number_by_id,
&mut next_positional_slide_number,
&mut notes_by_slide,
);
}
in_notes = true;
current_notes_slide_id = None;
notes_end = Some(content_end);
pos += 8;
continue;
}
RT_NOTES_ATOM => {
if in_notes && let Some(bytes) = data.get(content_start..content_start + 4) {
current_notes_slide_id = Some(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]));
}
pos = content_end;
continue;
}
RT_MAIN_MASTER if !include_master_slides => {
pos = content_end;
continue;
}
RT_SLIDE_LIST_WITH_TEXT => {
if live_outline_offset.is_none_or(|offset| offset == pos) {
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());
outline_titles.resize(next + 1, None);
}
outline_text_type = None;
pos = content_end;
continue;
}
RT_TEXT_HEADER_ATOM => {
if outline_slide_index.is_some()
&& let Some(bytes) = data.get(content_start..content_start + 4)
{
outline_text_type = Some(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]));
}
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);
dispatch_cleaned_text(
cleaned,
TextDispatchState {
outline_texts: &mut outline_texts,
outline_titles: &mut outline_titles,
outline_slide_index,
outline_text_type,
in_notes,
current_notes_texts: &mut current_notes_texts,
in_slide_text,
current_slide_texts: &mut current_slide_texts,
loose_texts: &mut loose_texts,
},
);
outline_text_type = None;
}
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);
dispatch_cleaned_text(
cleaned,
TextDispatchState {
outline_texts: &mut outline_texts,
outline_titles: &mut outline_titles,
outline_slide_index,
outline_text_type,
in_notes,
current_notes_texts: &mut current_notes_texts,
in_slide_text,
current_slide_texts: &mut current_slide_texts,
loose_texts: &mut loose_texts,
},
);
outline_text_type = None;
}
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"),
title: None,
notes: None,
});
}
commit_notes(
&mut current_notes_texts,
current_notes_slide_id,
&slide_number_by_id,
&mut next_positional_slide_number,
&mut notes_by_slide,
);
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;
};
slide.title = outline_titles.get(index).cloned().flatten();
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)
};
}
let speaker_notes: Vec<String> = notes_by_slide.values().cloned().collect();
for slide in &mut slides {
slide.notes = notes_by_slide.remove(&slide.number);
}
Ok((slides, loose_texts, speaker_notes))
}
pub(super) struct ArtRecord {
pub(super) rec_instance: u16,
pub(super) rec_type: u16,
pub(super) content_start: usize,
pub(super) content_end: usize,
}
pub(super) 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
}
struct BlipRecordHeader {
pos: usize,
rec_instance: u16,
rec_len: usize,
content_start: usize,
content_end: usize,
}
fn validate_blip_picture_len(
pos: usize,
rec_len: usize,
header_len: usize,
warnings: &mut Vec<ProcessingWarning>,
) -> Option<usize> {
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"
),
);
return None;
}
let picture_len = rec_len - header_len;
if picture_len == 0 {
return None;
}
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"
),
);
return None;
}
Some(picture_len)
}
fn extract_blip_image(
data: &[u8],
record: BlipRecordHeader,
format: Cow<'static, str>,
slide_numbers: &std::collections::HashMap<u32, u32>,
image_index: u32,
warnings: &mut Vec<ProcessingWarning>,
) -> Option<ExtractedImage> {
let BlipRecordHeader {
pos,
rec_instance,
rec_len,
content_start,
content_end,
} = record;
let header_len = if rec_instance & 0x1 == 1 { 33 } else { 17 };
validate_blip_picture_len(pos, rec_len, header_len, warnings)?;
let picture_start = content_start + header_len;
let picture_bytes = &data[picture_start..content_end];
Some(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,
})
}
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,
};
let record = BlipRecordHeader {
pos,
rec_instance,
rec_len,
content_start,
content_end,
};
if let Some(format) = format
&& let Some(image) = extract_blip_image(data, record, format, slide_numbers, image_index, warnings)
{
images.push(image);
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,
}
}
pub(super) 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;