use crate::document::DocOutcome;
use crate::reader_output::{
DOC_COMMENTS, DOC_ENDNOTES, DOC_FOOTNOTES, DOC_HEADERS_FOOTERS, NO_VALUES, TEXT_BOX, Unshown,
UnshownKind, column_letters, sheet_header, slide_header, slide_notes_header, text_block,
text_lines,
};
use crate::util::one_line;
use office_oxide::cfb::blip::{BlipFormat, BlipImage};
use office_oxide::doc::{DocDocument, DocError, SubDocumentKind};
use office_oxide::ir::SheetKind;
use office_oxide::ppt::{OleObjectInfo, PptDocument, PptError, SlideText, TextType};
use office_oxide::xls::{CellValue, Sheet, XlsDocument, XlsError};
use std::io::Cursor;
use std::path::Path;
const DOC_EXTENSIONS: &[&str] = &["doc"];
const XLS_EXTENSIONS: &[&str] = &["xls"];
const PPT_EXTENSIONS: &[&str] = &["ppt"];
const OLE_LINKED: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Family {
Doc,
Xls,
Ppt,
}
impl Family {
#[must_use]
pub(crate) const fn name(self) -> &'static str {
match self {
Self::Doc => "doc",
Self::Xls => "xls",
Self::Ppt => "ppt",
}
}
}
#[must_use]
pub(crate) fn family_of(path: &Path) -> Option<Family> {
if crate::util::has_extension(path, DOC_EXTENSIONS) {
Some(Family::Doc)
} else if crate::util::has_extension(path, XLS_EXTENSIONS) {
Some(Family::Xls)
} else if crate::util::has_extension(path, PPT_EXTENSIONS) {
Some(Family::Ppt)
} else {
None
}
}
#[must_use]
pub(crate) fn convert(bytes: &[u8], family: Family) -> DocOutcome {
match crate::shutdown::contain_panics(|| parse(bytes, family)) {
Ok(outcome) => outcome,
Err(_) => corrupt(family),
}
}
fn parse(bytes: &[u8], family: Family) -> DocOutcome {
match family {
Family::Doc => convert_doc(bytes),
Family::Xls => convert_xls(bytes),
Family::Ppt => convert_ppt(bytes),
}
}
fn convert_doc(bytes: &[u8]) -> DocOutcome {
match DocDocument::from_reader(Cursor::new(bytes)) {
Ok(doc) => {
let mut sections: Vec<String> = Vec::new();
let body = paragraph_lines(doc.plain_text_ref()).join("\n");
if !body.is_empty() {
sections.push(body);
}
for (kind, label) in [
(SubDocumentKind::HeadersFooters, DOC_HEADERS_FOOTERS),
(SubDocumentKind::Footnotes, DOC_FOOTNOTES),
(SubDocumentKind::Endnotes, DOC_ENDNOTES),
(SubDocumentKind::Comments, DOC_COMMENTS),
] {
let lines = doc_story_lines(&doc, kind);
if !lines.is_empty() {
sections.push(text_lines(&format!("{label}:"), &lines));
}
}
for kind in [SubDocumentKind::TextBoxes, SubDocumentKind::HeaderTextBoxes] {
let lines = doc_story_lines(&doc, kind);
if !lines.is_empty() {
sections.push(text_lines(TEXT_BOX, &lines));
}
}
if let Some(rest) = doc
.plain_text()
.strip_prefix(&doc_plain_text_before_objects(&doc))
{
let lines = paragraph_lines(rest);
if !lines.is_empty() {
sections.push(lines.join("\n"));
}
}
let mut notes = Vec::new();
if !doc.text_complete() {
notes.push(
"the reader stopped early — some of the document's text may be missing"
.to_string(),
);
}
let mut unshown = Unshown::default();
count_pictures(&mut unshown, doc.images());
DocOutcome::Text {
text: sections.join("\n").trim_end().to_string(),
images: Vec::new(),
notes,
unshown,
all_page_text_lost: false,
}
}
Err(err) => doc_error(&err),
}
}
fn doc_story_lines(doc: &DocDocument, kind: SubDocumentKind) -> Vec<String> {
doc.subdocuments()
.iter()
.find(|sub| sub.kind == kind)
.map(|sub| paragraph_lines(&sub.text))
.unwrap_or_default()
}
fn doc_plain_text_before_objects(doc: &DocDocument) -> String {
let mut out = doc.plain_text_ref().to_string();
for sub in doc.subdocuments() {
let text = sub.text.trim();
if text.is_empty() {
continue;
}
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(text);
out.push('\n');
}
out
}
fn convert_xls(bytes: &[u8]) -> DocOutcome {
match XlsDocument::from_reader(Cursor::new(bytes)) {
Ok(doc) => {
let mut blocks = Vec::new();
for sheet in &doc.sheets {
blocks.push(text_block(
&sheet_header(&sheet.name, None),
NO_VALUES,
&sheet_lines(sheet),
));
}
let mut notes = Vec::new();
if doc.truncated() {
notes.push(
"the workbook was cut short — later sheets or cells may be missing".to_string(),
);
}
if !doc.chart_text().is_empty()
|| doc.sheets.iter().any(|sheet| !sheet.chart_text.is_empty())
{
notes.push(
"the workbook holds text from its charts (chart titles, series and trendline names, and axis titles), which is not extracted".to_string(),
);
}
let mut unshown = Unshown::default();
count_pictures(&mut unshown, doc.images());
unshown.add(
UnshownKind::ChartSheet,
doc.sheets
.iter()
.filter(|sheet| sheet.kind == SheetKind::Chart)
.count(),
);
DocOutcome::Text {
text: blocks.join("\n\n").trim_end().to_string(),
images: Vec::new(),
notes,
unshown,
all_page_text_lost: false,
}
}
Err(err) => xls_error(&err),
}
}
fn sheet_lines(sheet: &Sheet) -> Vec<String> {
let mut lines = Vec::new();
for (row, cells) in sheet.rows.iter().enumerate() {
for (col, value) in cells.iter().enumerate() {
if matches!(value, CellValue::Empty) {
continue;
}
let Some(display) = sheet.display_text(row, col) else {
continue;
};
let display = one_line(display.trim().to_owned());
if display.is_empty() {
continue;
}
let Ok(column) = u32::try_from(col) else {
continue;
};
let address = format!("{}{}", column_letters(column), row + 1);
lines.push(format!("{address}: {display}"));
}
}
lines
}
fn convert_ppt(bytes: &[u8]) -> DocOutcome {
match PptDocument::from_reader(Cursor::new(bytes)) {
Ok(doc) => {
let mut blocks = Vec::new();
for (index, slide) in doc.slides.iter().enumerate() {
let number = index + 1;
blocks.push(text_block(
&slide_header(number, false),
&crate::docgen::ppt_marks().no_text,
&slide_lines(slide),
));
let notes: Vec<String> = slide
.text_runs
.iter()
.filter(|run| run.text_type == TextType::Notes)
.flat_map(|run| paragraph_lines(&run.text))
.collect();
if !notes.is_empty() {
blocks.push(text_lines(&slide_notes_header(number), ¬es));
}
}
let mut notes = Vec::new();
if !doc.text_complete() {
notes.push(
"the reader stopped early — some of the presentation's text may be missing"
.to_string(),
);
}
let mut unshown = Unshown::default();
count_pictures(&mut unshown, doc.images());
for object in doc.slides.iter().flat_map(|slide| &slide.ole_object_refs) {
if object.kind == OLE_LINKED {
continue;
}
unshown.add(
match object.kind {
OleObjectInfo::KIND_MEDIA_VIDEO => UnshownKind::Video,
OleObjectInfo::KIND_MEDIA_AUDIO => UnshownKind::Audio,
_ => UnshownKind::Object,
},
1,
);
}
DocOutcome::Text {
text: blocks.join("\n\n").trim_end().to_string(),
images: Vec::new(),
notes,
unshown,
all_page_text_lost: false,
}
}
Err(err) => ppt_error(&err),
}
}
fn picture_kind(format: BlipFormat) -> UnshownKind {
match format {
BlipFormat::Emf | BlipFormat::Wmf | BlipFormat::Pict => UnshownKind::Drawing,
_ => UnshownKind::Image,
}
}
fn count_pictures(unshown: &mut Unshown, images: &[BlipImage]) {
for image in images {
unshown.add(picture_kind(image.format), 1);
}
}
fn slide_lines(slide: &SlideText) -> Vec<String> {
let mut lines: Vec<String> = slide
.text_runs
.iter()
.filter(|run| run.text_type != TextType::Notes)
.flat_map(|run| paragraph_lines(&run.text))
.collect();
for table in &slide.tables {
for cell in table.rows.iter().flatten() {
let text = cell
.iter()
.flat_map(|run| paragraph_lines(&run.text))
.collect::<Vec<_>>()
.join(" ");
if !text.is_empty() {
lines.push(text);
}
}
}
lines
}
fn paragraph_lines(text: &str) -> Vec<String> {
text.split(['\r', '\u{0B}', '\n'])
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect()
}
fn encrypted() -> DocOutcome {
DocOutcome::Unreadable {
reason: "password-protected".to_string(),
}
}
fn unsupported() -> DocOutcome {
DocOutcome::Unreadable {
reason: "an old Office version this tool cannot read".to_string(),
}
}
fn doc_error(err: &DocError) -> DocOutcome {
match err {
DocError::Encrypted => encrypted(),
DocError::UnsupportedVersion(_) => unsupported(),
_ => corrupt(Family::Doc),
}
}
fn xls_error(err: &XlsError) -> DocOutcome {
match err {
XlsError::Encrypted => encrypted(),
XlsError::UnsupportedVersion(_) => unsupported(),
_ => corrupt(Family::Xls),
}
}
fn ppt_error(err: &PptError) -> DocOutcome {
match err {
PptError::Encrypted => encrypted(),
_ => corrupt(Family::Ppt),
}
}
fn corrupt(family: Family) -> DocOutcome {
DocOutcome::Unreadable {
reason: format!("corrupt or unreadable .{}", family.name()),
}
}
#[cfg(test)]
#[expect(
clippy::cast_possible_truncation,
reason = "the hand-built fixtures' stream offsets and counts fit the CFB/BIFF/PPT record fields by construction"
)]
mod tests {
use super::*;
const END_OF_CHAIN: u32 = 0xFFFF_FFFE;
const FAT_SECT: u32 = 0xFFFF_FFFD;
const FREE_SECT: u32 = 0xFFFF_FFFF;
const NO_ENTRY: u32 = 0xFFFF_FFFF;
fn cfb(streams: &[(&str, &[u8])]) -> Vec<u8> {
let dir_sectors = (streams.len() + 1).div_ceil(4) as u32;
let fat_sector = dir_sectors;
let mut starts = Vec::new();
let mut runs = Vec::new();
let mut next = fat_sector + 1;
for (_, data) in streams {
let sectors = data.len().div_ceil(512).max(1) as u32;
starts.push(next);
runs.push(sectors);
next += sectors;
}
let total_sectors = next as usize;
assert!(total_sectors <= 128, "one FAT sector covers 128 sectors");
let mut file = vec![0u8; 512 * (1 + total_sectors)];
file[0..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
file[0x18..0x1A].copy_from_slice(&0x003Eu16.to_le_bytes());
file[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes());
file[0x1C..0x1E].copy_from_slice(&0xFFFEu16.to_le_bytes());
file[0x1E..0x20].copy_from_slice(&9u16.to_le_bytes());
file[0x20..0x22].copy_from_slice(&6u16.to_le_bytes());
file[0x2C..0x30].copy_from_slice(&1u32.to_le_bytes()); file[0x30..0x34].copy_from_slice(&0u32.to_le_bytes()); file[0x38..0x3C].copy_from_slice(&4096u32.to_le_bytes());
file[0x3C..0x40].copy_from_slice(&END_OF_CHAIN.to_le_bytes());
file[0x44..0x48].copy_from_slice(&END_OF_CHAIN.to_le_bytes());
file[0x4C..0x50].copy_from_slice(&fat_sector.to_le_bytes()); for i in 1..109 {
let off = 0x4C + i * 4;
file[off..off + 4].copy_from_slice(&FREE_SECT.to_le_bytes());
}
let dir = 512;
let root_child = if streams.is_empty() { NO_ENTRY } else { 1 };
write_entry(
&mut file[dir..dir + 128],
"Root Entry",
5,
root_child,
END_OF_CHAIN,
0,
);
for (index, (name, data)) in streams.iter().enumerate() {
let right = if index + 1 < streams.len() {
(index as u32) + 2
} else {
NO_ENTRY
};
let at = dir + (index + 1) * 128;
write_entry(
&mut file[at..at + 128],
name,
2,
NO_ENTRY,
starts[index],
data.len() as u32,
);
file[at + 0x48..at + 0x4C].copy_from_slice(&right.to_le_bytes());
let offset = 512 * (starts[index] as usize + 1);
file[offset..offset + data.len()].copy_from_slice(data);
}
let fat = 512 * (fat_sector as usize + 1);
let mut entries = vec![FREE_SECT; 128];
for k in 0..dir_sectors {
entries[k as usize] = if k + 1 == dir_sectors {
END_OF_CHAIN
} else {
k + 1
};
}
entries[fat_sector as usize] = FAT_SECT;
for (index, &start) in starts.iter().enumerate() {
for k in 0..runs[index] {
entries[(start + k) as usize] = if k + 1 == runs[index] {
END_OF_CHAIN
} else {
start + k + 1
};
}
}
for (index, value) in entries.iter().enumerate() {
file[fat + index * 4..fat + index * 4 + 4].copy_from_slice(&value.to_le_bytes());
}
file
}
fn write_entry(buf: &mut [u8], name: &str, kind: u8, child: u32, start: u32, size: u32) {
for (i, unit) in name.encode_utf16().enumerate() {
buf[i * 2..i * 2 + 2].copy_from_slice(&unit.to_le_bytes());
}
let name_size = ((name.encode_utf16().count() + 1) * 2) as u16;
buf[0x40..0x42].copy_from_slice(&name_size.to_le_bytes());
buf[0x42] = kind;
buf[0x43] = 1;
buf[0x44..0x48].copy_from_slice(&NO_ENTRY.to_le_bytes());
buf[0x48..0x4C].copy_from_slice(&NO_ENTRY.to_le_bytes());
buf[0x4C..0x50].copy_from_slice(&child.to_le_bytes());
buf[0x74..0x78].copy_from_slice(&start.to_le_bytes());
buf[0x78..0x7C].copy_from_slice(&size.to_le_bytes());
}
fn jpeg_blip() -> Vec<u8> {
let img: &[u8] = b"\xff\xd8\xff\xe0JFIF";
let rec_type: u16 = 0xF01D;
let inst: u16 = 0x46A;
let uid_sz = 17; let rec_len = uid_sz + img.len();
let mut buf = (inst << 4).to_le_bytes().to_vec();
buf.extend_from_slice(&rec_type.to_le_bytes());
buf.extend_from_slice(&(rec_len as u32).to_le_bytes());
buf.extend(vec![0u8; uid_sz]);
buf.extend_from_slice(img);
buf
}
const UNSHOWN_IMAGE: &str = "1 embedded image(s) not shown";
const UNSHOWN_OBJECT: &str = "1 embedded object part(s) not shown";
const DOC_TEXT_FC: u32 = 0x400;
fn doc_fib(ccp: &[u32; 8], fc_clx: u32, lcb_clx: u32, encrypted: bool) -> Vec<u8> {
let mut fib = vec![0u8; 1024];
fib[0..2].copy_from_slice(&0xA5ECu16.to_le_bytes());
fib[2..4].copy_from_slice(&0x00C1u16.to_le_bytes());
let flags = (1u16 << 9) | (u16::from(encrypted) << 8);
fib[0x0A..0x0C].copy_from_slice(&flags.to_le_bytes());
for (index, length) in ccp.iter().enumerate() {
let at = 0x4C + index * 4;
fib[at..at + 4].copy_from_slice(&length.to_le_bytes());
}
fib[0x01A2..0x01A6].copy_from_slice(&fc_clx.to_le_bytes());
fib[0x01A6..0x01AA].copy_from_slice(&lcb_clx.to_le_bytes());
fib
}
fn doc_clx(pieces: &[(u32, u32)]) -> Vec<u8> {
let mut cps = Vec::new();
let mut pcds = Vec::new();
let mut cp = 0u32;
for (fc, char_count) in pieces {
cps.extend_from_slice(&cp.to_le_bytes());
pcds.extend_from_slice(&0u16.to_le_bytes()); pcds.extend_from_slice(&fc.to_le_bytes()); pcds.extend_from_slice(&0u16.to_le_bytes()); cp += char_count;
}
cps.extend_from_slice(&cp.to_le_bytes());
cps.extend_from_slice(&pcds);
let mut clx = vec![0x02u8]; clx.extend_from_slice(&(cps.len() as u32).to_le_bytes());
clx.extend_from_slice(&cps);
clx
}
const DOC_STORY_SLOTS: [(SubDocumentKind, usize); 6] = [
(SubDocumentKind::Footnotes, 1),
(SubDocumentKind::HeadersFooters, 2),
(SubDocumentKind::Comments, 4),
(SubDocumentKind::Endnotes, 5),
(SubDocumentKind::TextBoxes, 6),
(SubDocumentKind::HeaderTextBoxes, 7),
];
fn doc_streams(
body: &str,
stories: &[(SubDocumentKind, &str)],
encrypted: bool,
) -> (Vec<u8>, Vec<u8>) {
let mut word = vec![0u8; DOC_TEXT_FC as usize];
let mut ccp = [0u32; 8]; let mut pieces = Vec::new();
let append = |word: &mut Vec<u8>, text: &str| {
let fc = word.len() as u32;
let count = text.encode_utf16().count() as u32;
word.extend(text.encode_utf16().flat_map(u16::to_le_bytes));
(fc, count)
};
let (body_fc, body_len) = append(&mut word, body);
pieces.push((body_fc, body_len));
ccp[0] = body_len;
for (kind, slot) in DOC_STORY_SLOTS {
let text = stories
.iter()
.find(|(story_kind, _)| *story_kind == kind)
.map_or("", |(_, text)| *text);
let (fc, count) = append(&mut word, text);
ccp[slot] = count;
if count > 0 {
pieces.push((fc, count));
}
}
let clx = doc_clx(&pieces);
word[..1024].copy_from_slice(&doc_fib(&ccp, 0, clx.len() as u32, encrypted));
(word, clx)
}
fn doc_fixture(body: &str, stories: &[(SubDocumentKind, &str)], encrypted: bool) -> Vec<u8> {
let (word, clx) = doc_streams(body, stories, encrypted);
cfb(&[("WordDocument", &word), ("1Table", &clx)])
}
fn doc_image_fixture(body: &str) -> Vec<u8> {
let (word, clx) = doc_streams(body, &[], false);
let data = jpeg_blip();
cfb(&[("WordDocument", &word), ("1Table", &clx), ("Data", &data)])
}
#[test]
fn doc_fixture_renders_its_text() {
let bytes = doc_fixture("Hello legacy doc", &[], false);
let DocOutcome::Text {
text,
images,
notes,
..
} = convert(&bytes, Family::Doc)
else {
panic!("expected text");
};
assert_eq!(text, "Hello legacy doc");
assert!(images.is_empty());
assert!(notes.is_empty());
}
#[test]
fn doc_embedded_image_is_reported_as_not_shown() {
let bytes = doc_image_fixture("Body text");
let DocOutcome::Text {
text,
images,
notes,
unshown,
..
} = convert(&bytes, Family::Doc)
else {
panic!("expected text");
};
assert_eq!(text, "Body text");
assert!(images.is_empty());
assert!(notes.is_empty());
assert_eq!(unshown.lines(), [UNSHOWN_IMAGE]);
}
#[test]
fn doc_object_names_mirror_stays_a_prefix_of_the_readers_own_text() {
let bytes = doc_fixture(
"Body text",
&[
(SubDocumentKind::Footnotes, "A footnote"),
(SubDocumentKind::HeadersFooters, "A header\rA footer"),
],
false,
);
let doc = DocDocument::from_reader(Cursor::new(&bytes)).expect("the fixture is read");
let plain = doc.plain_text();
assert!(
plain.starts_with(&doc_plain_text_before_objects(&doc)),
"the mirrored prefix must be a prefix of {plain:?}"
);
}
#[test]
fn doc_subdocument_stories_render_as_labelled_blocks() {
let bytes = doc_fixture(
"Body text",
&[
(SubDocumentKind::Footnotes, "A footnote"),
(SubDocumentKind::HeadersFooters, "A header\rA footer"),
(SubDocumentKind::Comments, "A comment"),
(SubDocumentKind::Endnotes, "An endnote"),
],
false,
);
let DocOutcome::Text { text, notes, .. } = convert(&bytes, Family::Doc) else {
panic!("expected text");
};
assert_eq!(
text,
"Body text\n\
Headers and footers:\n A header\n A footer\n\
Footnotes:\n A footnote\n\
Endnotes:\n An endnote\n\
Comments:\n A comment"
);
assert!(notes.is_empty());
}
#[test]
fn doc_empty_story_prints_no_block() {
let bytes = doc_fixture(
"Body only",
&[
(SubDocumentKind::Footnotes, " \r"),
(SubDocumentKind::Comments, "A comment"),
],
false,
);
let DocOutcome::Text { text, .. } = convert(&bytes, Family::Doc) else {
panic!("expected text");
};
assert_eq!(text, "Body only\nComments:\n A comment");
}
#[test]
fn encrypted_doc_reports_password_protected() {
let bytes = doc_fixture("secret", &[], true);
assert!(matches!(
convert(&bytes, Family::Doc),
DocOutcome::Unreadable { reason } if reason == "password-protected"
));
}
const RT_BOF: u16 = 0x0809;
const RT_EOF: u16 = 0x000A;
const RT_BOUNDSHEET: u16 = 0x0085;
const RT_SST: u16 = 0x00FC;
const RT_LABELSST: u16 = 0x00FD;
const RT_NUMBER: u16 = 0x0203;
const RT_FILEPASS: u16 = 0x002F;
const RT_SERIESTEXT: u16 = 0x100D;
const RT_MSODRAWINGGROUP: u16 = 0x00EB;
fn biff_rec(rec_type: u16, data: &[u8]) -> Vec<u8> {
let mut buf = rec_type.to_le_bytes().to_vec();
buf.extend_from_slice(&(data.len() as u16).to_le_bytes());
buf.extend_from_slice(data);
buf
}
fn bof(doctype: u16) -> Vec<u8> {
let mut body = 0x0600u16.to_le_bytes().to_vec();
body.extend_from_slice(&doctype.to_le_bytes());
body.extend_from_slice(&[0u8; 12]);
biff_rec(RT_BOF, &body)
}
fn boundsheet(name: &str) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&0u32.to_le_bytes()); body.push(0); body.push(0); body.push(name.encode_utf16().count() as u8);
body.push(0x01); for unit in name.encode_utf16() {
body.extend_from_slice(&unit.to_le_bytes());
}
biff_rec(RT_BOUNDSHEET, &body)
}
fn sst(text: &str) -> Vec<u8> {
let mut body = 1u32.to_le_bytes().to_vec(); body.extend_from_slice(&1u32.to_le_bytes()); body.extend_from_slice(&(text.encode_utf16().count() as u16).to_le_bytes());
body.push(0x01); for unit in text.encode_utf16() {
body.extend_from_slice(&unit.to_le_bytes());
}
biff_rec(RT_SST, &body)
}
fn labelsst(row: u16, col: u16) -> Vec<u8> {
let mut body = row.to_le_bytes().to_vec();
body.extend_from_slice(&col.to_le_bytes());
body.extend_from_slice(&0u16.to_le_bytes()); body.extend_from_slice(&0u32.to_le_bytes()); biff_rec(RT_LABELSST, &body)
}
fn number(row: u16, col: u16, value: f64) -> Vec<u8> {
let mut body = row.to_le_bytes().to_vec();
body.extend_from_slice(&col.to_le_bytes());
body.extend_from_slice(&0u16.to_le_bytes()); body.extend_from_slice(&value.to_le_bytes());
biff_rec(RT_NUMBER, &body)
}
fn xls_stream(shared: &str, encrypted: bool, extra: &[u8]) -> Vec<u8> {
let mut stream = bof(0x0005); stream.extend(boundsheet("Данные"));
stream.extend(sst(shared));
if encrypted {
stream.extend(biff_rec(RT_FILEPASS, &[0x12, 0x34, 0x56, 0x78]));
}
stream.extend(extra);
stream.extend(biff_rec(RT_EOF, &[]));
stream.extend(bof(0x0010)); stream.extend(labelsst(0, 0));
stream.extend(number(1, 1, 3.5));
stream.extend(biff_rec(RT_EOF, &[]));
stream
}
fn xls_fixture(shared: &str, encrypted: bool) -> Vec<u8> {
cfb(&[("Workbook", &xls_stream(shared, encrypted, &[]))])
}
fn xls_image_fixture() -> Vec<u8> {
let drawing = biff_rec(RT_MSODRAWINGGROUP, &jpeg_blip());
cfb(&[("Workbook", &xls_stream("Привет", false, &drawing))])
}
fn series_text(text: &str) -> Vec<u8> {
let mut data = vec![0u8, 0u8];
data.push(text.chars().count() as u8);
data.push(0);
data.extend_from_slice(text.as_bytes());
biff_rec(RT_SERIESTEXT, &data)
}
fn xls_chart_fixture() -> Vec<u8> {
let mut stream = bof(0x0005); stream.extend(boundsheet("Данные"));
stream.extend(boundsheet("Chart1"));
stream.extend(sst("Привет"));
stream.extend(biff_rec(RT_EOF, &[]));
stream.extend(bof(0x0010)); stream.extend(labelsst(0, 0));
stream.extend(biff_rec(RT_EOF, &[]));
stream.extend(bof(0x0020)); stream.extend(series_text("Revenue"));
stream.extend(biff_rec(RT_EOF, &[]));
cfb(&[("Workbook", &stream)])
}
#[test]
fn xls_chart_text_is_named_as_left_out() {
let bytes = xls_chart_fixture();
let DocOutcome::Text {
text,
notes,
unshown,
..
} = convert(&bytes, Family::Xls)
else {
panic!("expected text");
};
assert_eq!(
text,
"Sheet \"Данные\":\n A1: Привет\n\nSheet \"Chart1\": (no values)"
);
assert!(
notes
.iter()
.any(|note| note.contains("text from its charts")),
"got: {notes:?}"
);
assert_eq!(unshown.lines(), ["1 chart sheet(s) not shown"]);
}
#[test]
fn xls_fixture_renders_sheet_cells_like_xlsx() {
let bytes = xls_fixture("Привет", false);
let DocOutcome::Text { text, notes, .. } = convert(&bytes, Family::Xls) else {
panic!("expected text");
};
assert_eq!(text, "Sheet \"Данные\":\n A1: Привет\n B2: 3.5");
assert!(notes.is_empty());
}
#[test]
fn xls_embedded_image_is_reported_as_not_shown() {
let bytes = xls_image_fixture();
let DocOutcome::Text {
text,
images,
notes,
unshown,
..
} = convert(&bytes, Family::Xls)
else {
panic!("expected text");
};
assert_eq!(text, "Sheet \"Данные\":\n A1: Привет\n B2: 3.5");
assert!(images.is_empty());
assert!(notes.is_empty());
assert_eq!(unshown.lines(), [UNSHOWN_IMAGE]);
}
#[test]
fn xls_shared_string_break_stays_one_line() {
let bytes = xls_fixture("two\nlines", false);
let DocOutcome::Text { text, .. } = convert(&bytes, Family::Xls) else {
panic!("expected text");
};
assert_eq!(text, "Sheet \"Данные\":\n A1: two\\nlines\n B2: 3.5");
}
#[test]
fn encrypted_xls_reports_password_protected() {
let bytes = xls_fixture("Привет", true);
assert!(matches!(
convert(&bytes, Family::Xls),
DocOutcome::Unreadable { reason } if reason == "password-protected"
));
}
const RT_DOCUMENT: u16 = 0x03E8;
const RT_SLIDE: u16 = 0x03EE;
const RT_SLIDE_ATOM: u16 = 0x03EF;
const RT_NOTES: u16 = 0x03F0;
const RT_NOTES_ATOM: u16 = 0x03F1;
const RT_SLIDE_PERSIST_ATOM: u16 = 0x03F3;
const RT_SLIDE_LIST_WITH_TEXT: u16 = 0x0FF0;
const RT_TEXT_HEADER: u16 = 0x0F9F;
const RT_TEXT_BYTES: u16 = 0x0FA8;
const RT_USER_EDIT_ATOM: u16 = 0x0FF5;
const RT_CURRENT_USER_ATOM: u16 = 0x0FF6;
const RT_PERSIST_DIRECTORY_ATOM: u16 = 0x1772;
const SLWT_SLIDES: u16 = 0;
const SLWT_NOTES: u16 = 2;
const RT_CLIENT_TEXTBOX: u16 = 0xF00D;
const RT_SPGR_CONTAINER: u16 = 0xF003;
const RT_SHAPE: u16 = 0xF004;
const RT_CHILD_ANCHOR: u16 = 0xF00F;
const RT_CLIENT_DATA: u16 = 0xF011;
const RT_EXTERNAL_OBJECT_LIST: u16 = 0x0409;
const RT_EXTERNAL_OLE_EMBED: u16 = 0x0FCC;
const RT_EXTERNAL_OLE_OBJECT_ATOM: u16 = 0x0FC3;
const RT_EXTERNAL_OBJECT_REF_ATOM: u16 = 0x0BC1;
const RT_EX_MEDIA_ATOM: u16 = 0x1004;
const RT_EX_VIDEO: u16 = 0x1005;
const RT_EX_AVI_MOVIE: u16 = 0x1006;
const RT_EX_WAV_AUDIO_EMBEDDED: u16 = 0x100F;
#[derive(Clone, Copy)]
enum OleFixture {
Embedded(u32),
Linked(u32),
Video(u32),
Audio(u32),
}
impl OleFixture {
const fn obj_id(self) -> u32 {
match self {
Self::Embedded(id) | Self::Linked(id) | Self::Video(id) | Self::Audio(id) => id,
}
}
const fn kind(self) -> u32 {
match self {
Self::Linked(_) => super::OLE_LINKED,
_ => 0,
}
}
}
fn atom(rec_type: u16, instance: u16, data: &[u8]) -> Vec<u8> {
let mut buf = (instance << 4).to_le_bytes().to_vec();
buf.extend_from_slice(&rec_type.to_le_bytes());
buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
buf.extend_from_slice(data);
buf
}
fn container(rec_type: u16, instance: u16, children: &[u8]) -> Vec<u8> {
let mut buf = ((instance << 4) | 0x000F).to_le_bytes().to_vec();
buf.extend_from_slice(&rec_type.to_le_bytes());
buf.extend_from_slice(&(children.len() as u32).to_le_bytes());
buf.extend_from_slice(children);
buf
}
fn textbox(text_type: u32, text: &str) -> Vec<u8> {
let mut children = atom(RT_TEXT_HEADER, 0, &text_type.to_le_bytes());
children.extend(atom(RT_TEXT_BYTES, 0, text.as_bytes()));
container(RT_CLIENT_TEXTBOX, 0, &children)
}
fn table_cell(left: i32, top: i32, text: &str) -> Vec<u8> {
let mut anchor = left.to_le_bytes().to_vec();
anchor.extend_from_slice(&top.to_le_bytes());
anchor.extend_from_slice(&(left + 100).to_le_bytes());
anchor.extend_from_slice(&(top + 50).to_le_bytes());
let mut children = atom(RT_CHILD_ANCHOR, 0, &anchor);
children.extend(textbox(4, text));
container(RT_SHAPE, 0, &children)
}
fn table_group(cells: [&str; 4]) -> Vec<u8> {
let mut children = container(RT_SHAPE, 0, &[]);
children.extend(table_cell(0, 0, cells[0]));
children.extend(table_cell(0, 50, cells[2]));
children.extend(table_cell(100, 0, cells[1]));
children.extend(table_cell(100, 50, cells[3]));
container(RT_SPGR_CONTAINER, 0, &children)
}
fn ole_shape(obj_id: u32) -> Vec<u8> {
let client_data = container(
RT_CLIENT_DATA,
0,
&atom(RT_EXTERNAL_OBJECT_REF_ATOM, 0, &obj_id.to_le_bytes()),
);
container(RT_SHAPE, 0, &client_data)
}
fn ex_obj_list(ole: OleFixture) -> Vec<u8> {
match ole {
OleFixture::Embedded(_) | OleFixture::Linked(_) => {
let mut body = vec![0u8; 16];
body[4..8].copy_from_slice(&ole.kind().to_le_bytes());
body[8..12].copy_from_slice(&ole.obj_id().to_le_bytes());
let ole_atom = atom(RT_EXTERNAL_OLE_OBJECT_ATOM, 0, &body);
let embed = container(RT_EXTERNAL_OLE_EMBED, 0, &ole_atom);
container(RT_EXTERNAL_OBJECT_LIST, 0, &embed)
}
OleFixture::Video(obj_id) | OleFixture::Audio(obj_id) => {
let mut body = obj_id.to_le_bytes().to_vec();
body.extend([0u8; 4]); let media_atom = atom(RT_EX_MEDIA_ATOM, 0, &body);
let (container_type, children) = match ole {
OleFixture::Video(_) => {
(RT_EX_AVI_MOVIE, container(RT_EX_VIDEO, 0, &media_atom))
}
_ => (RT_EX_WAV_AUDIO_EMBEDDED, media_atom),
};
let media = container(container_type, 0, &children);
container(RT_EXTERNAL_OBJECT_LIST, 0, &media)
}
}
}
fn slide_container(
text: &str,
notes_id: u32,
table: Option<[&str; 4]>,
ole: Option<u32>,
) -> Vec<u8> {
let mut body = vec![0u8; 24];
body[16..20].copy_from_slice(¬es_id.to_le_bytes()); let mut children = atom(RT_SLIDE_ATOM, 0, &body);
children.extend(textbox(0, text));
if let Some(cells) = table {
children.extend(table_group(cells));
}
if let Some(obj_id) = ole {
children.extend(ole_shape(obj_id));
}
container(RT_SLIDE, 0, &children)
}
fn notes_container(text: &str, slide_id: u32) -> Vec<u8> {
let mut children = atom(RT_NOTES_ATOM, 0, &slide_id.to_le_bytes());
children.extend(textbox(2, text));
container(RT_NOTES, 0, &children)
}
fn slide_persist(persist_id: u32, slide_id: u32) -> Vec<u8> {
let mut body = persist_id.to_le_bytes().to_vec();
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(&slide_id.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
atom(RT_SLIDE_PERSIST_ATOM, 0, &body)
}
fn persist_directory(entries: &[(u32, u32)]) -> Vec<u8> {
let header = entries[0].0 | ((entries.len() as u32) << 20);
let mut body = header.to_le_bytes().to_vec();
for (_, offset) in entries {
body.extend_from_slice(&offset.to_le_bytes());
}
atom(RT_PERSIST_DIRECTORY_ATOM, 0, &body)
}
fn user_edit_atom(offset_persist_directory: u32, doc_persist_id_ref: u32) -> Vec<u8> {
let mut body = 0u32.to_le_bytes().to_vec(); body.extend_from_slice(&0u32.to_le_bytes()); body.extend_from_slice(&0u32.to_le_bytes()); body.extend_from_slice(&offset_persist_directory.to_le_bytes());
body.extend_from_slice(&doc_persist_id_ref.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); body.extend_from_slice(&0u32.to_le_bytes()); atom(RT_USER_EDIT_ATOM, 0, &body)
}
fn current_user(offset_to_current_edit: u32, encrypted: bool) -> Vec<u8> {
let mut body = 0u32.to_le_bytes().to_vec(); let token: u32 = if encrypted { 0xF3D1_C4DF } else { 0 };
body.extend_from_slice(&token.to_le_bytes()); body.extend_from_slice(&offset_to_current_edit.to_le_bytes());
body.extend_from_slice(&0u16.to_le_bytes());
body.extend_from_slice(&0u16.to_le_bytes());
body.push(0);
body.push(0);
body.extend_from_slice(&0u16.to_le_bytes());
atom(RT_CURRENT_USER_ATOM, 0, &body)
}
fn ppt_document_stream(
slide_text: &str,
notes: Option<&str>,
table: Option<[&str; 4]>,
ole: Option<OleFixture>,
) -> (Vec<u8>, u32) {
const SLIDE_ID: u32 = 256;
const NOTES_ID: u32 = 257;
let mut stream = Vec::new();
let mut doc_children = container(
RT_SLIDE_LIST_WITH_TEXT,
SLWT_SLIDES,
&slide_persist(2, SLIDE_ID),
);
if notes.is_some() {
let notes_list = container(
RT_SLIDE_LIST_WITH_TEXT,
SLWT_NOTES,
&slide_persist(3, NOTES_ID),
);
doc_children.extend(notes_list);
}
if let Some(ole) = ole {
doc_children.extend(ex_obj_list(ole));
}
let doc_offset = stream.len() as u32;
stream.extend(container(RT_DOCUMENT, 0, &doc_children));
let slide_offset = stream.len() as u32;
let notes_id = if notes.is_some() { NOTES_ID } else { 0 };
stream.extend(slide_container(
slide_text,
notes_id,
table,
ole.map(OleFixture::obj_id),
));
let mut entries = vec![(1u32, doc_offset), (2, slide_offset)];
if let Some(notes_text) = notes {
let notes_offset = stream.len() as u32;
stream.extend(notes_container(notes_text, SLIDE_ID));
entries.push((3, notes_offset));
}
let pd_offset = stream.len() as u32;
stream.extend(persist_directory(&entries));
let edit_offset = stream.len() as u32;
stream.extend(user_edit_atom(pd_offset, 1));
(stream, edit_offset)
}
fn ppt_cfb(
stream: &[u8],
edit_offset: u32,
encrypted: bool,
pictures: Option<&[u8]>,
) -> Vec<u8> {
let user = current_user(edit_offset, encrypted);
let mut streams = vec![("PowerPoint Document", stream), ("Current User", &user[..])];
if let Some(pictures) = pictures {
streams.push(("Pictures", pictures));
}
cfb(&streams)
}
fn ppt_fixture(slide_text: &str, notes: Option<&str>, encrypted: bool) -> Vec<u8> {
let (stream, edit_offset) = ppt_document_stream(slide_text, notes, None, None);
ppt_cfb(&stream, edit_offset, encrypted, None)
}
fn ppt_image_fixture(slide_text: &str) -> Vec<u8> {
let (stream, edit_offset) = ppt_document_stream(slide_text, None, None, None);
let pictures = jpeg_blip();
ppt_cfb(&stream, edit_offset, false, Some(&pictures))
}
fn ppt_ole_fixture(slide_text: &str, obj_id: u32) -> Vec<u8> {
let (stream, edit_offset) =
ppt_document_stream(slide_text, None, None, Some(OleFixture::Embedded(obj_id)));
ppt_cfb(&stream, edit_offset, false, None)
}
fn ppt_media_fixture(slide_text: &str, media: OleFixture) -> Vec<u8> {
let (stream, edit_offset) = ppt_document_stream(slide_text, None, None, Some(media));
ppt_cfb(&stream, edit_offset, false, None)
}
fn ppt_image_and_object_fixture(slide_text: &str, obj_id: u32) -> Vec<u8> {
let (stream, edit_offset) =
ppt_document_stream(slide_text, None, None, Some(OleFixture::Embedded(obj_id)));
let pictures = jpeg_blip();
ppt_cfb(&stream, edit_offset, false, Some(&pictures))
}
fn ppt_table_fixture(slide_text: &str, cells: [&str; 4]) -> Vec<u8> {
let (stream, edit_offset) = ppt_document_stream(slide_text, None, Some(cells), None);
ppt_cfb(&stream, edit_offset, false, None)
}
#[test]
fn ppt_fixture_renders_slide_and_notes_like_pptx() {
let bytes = ppt_fixture("Hello deck", Some("A speaker note"), false);
let DocOutcome::Text { text, notes, .. } = convert(&bytes, Family::Ppt) else {
panic!("expected text");
};
assert_eq!(
text,
"Slide 1:\n Hello deck\n\nSlide 1 notes:\n A speaker note"
);
assert!(notes.is_empty());
}
#[test]
fn ppt_without_notes_skips_the_notes_block() {
let bytes = ppt_fixture("Only slide text", None, false);
let DocOutcome::Text { text, .. } = convert(&bytes, Family::Ppt) else {
panic!("expected text");
};
assert_eq!(text, "Slide 1:\n Only slide text");
}
#[test]
fn encrypted_ppt_reports_password_protected() {
let bytes = ppt_fixture("secret", None, true);
assert!(matches!(
convert(&bytes, Family::Ppt),
DocOutcome::Unreadable { reason } if reason == "password-protected"
));
}
#[test]
fn ppt_slide_table_cells_render_after_the_slide_text() {
let bytes = ppt_table_fixture("Slide title", ["A1", "B1", "A2", "B2"]);
let DocOutcome::Text { text, .. } = convert(&bytes, Family::Ppt) else {
panic!("expected text");
};
assert_eq!(text, "Slide 1:\n Slide title\n A1\n B1\n A2\n B2");
}
#[test]
fn ppt_embedded_image_is_reported_as_not_shown() {
let bytes = ppt_image_fixture("Slide title");
let DocOutcome::Text {
text,
images,
notes,
unshown,
..
} = convert(&bytes, Family::Ppt)
else {
panic!("expected text");
};
assert_eq!(text, "Slide 1:\n Slide title");
assert!(images.is_empty());
assert!(notes.is_empty());
assert_eq!(unshown.lines(), [UNSHOWN_IMAGE]);
}
#[test]
fn ppt_embedded_object_is_reported_as_not_shown() {
let bytes = ppt_ole_fixture("Slide title", 1);
let DocOutcome::Text {
text,
images,
notes,
unshown,
..
} = convert(&bytes, Family::Ppt)
else {
panic!("expected text");
};
assert_eq!(text, "Slide 1:\n Slide title");
assert!(images.is_empty());
assert!(notes.is_empty());
assert_eq!(unshown.lines(), [UNSHOWN_OBJECT]);
}
#[test]
fn ppt_media_objects_are_reported_by_their_kind() {
for (media, expected) in [
(OleFixture::Video(1), "1 embedded video(s) not shown"),
(OleFixture::Audio(1), "1 embedded audio(s) not shown"),
] {
let bytes = ppt_media_fixture("Slide title", media);
let DocOutcome::Text { text, unshown, .. } = convert(&bytes, Family::Ppt) else {
panic!("expected text");
};
assert_eq!(text, "Slide 1:\n Slide title");
assert_eq!(unshown.lines(), [expected]);
}
}
#[test]
fn ppt_linked_object_is_not_reported_as_content() {
let (stream, edit_offset) =
ppt_document_stream("Slide title", None, None, Some(OleFixture::Linked(1)));
let bytes = ppt_cfb(&stream, edit_offset, false, None);
let DocOutcome::Text { text, unshown, .. } = convert(&bytes, Family::Ppt) else {
panic!("expected text");
};
assert_eq!(text, "Slide 1:\n Slide title");
assert!(unshown.lines().is_empty());
}
#[test]
fn ppt_image_and_object_are_reported_separately() {
let bytes = ppt_image_and_object_fixture("Slide title", 1);
let DocOutcome::Text { notes, unshown, .. } = convert(&bytes, Family::Ppt) else {
panic!("expected text");
};
assert!(notes.is_empty());
assert_eq!(unshown.lines(), [UNSHOWN_OBJECT, UNSHOWN_IMAGE]);
}
#[test]
fn unsupported_version_keeps_its_own_reason() {
for outcome in [
doc_error(&DocError::UnsupportedVersion("Word 6.0/95".to_string())),
xls_error(&XlsError::UnsupportedVersion("BIFF5".to_string())),
] {
assert!(matches!(
outcome,
DocOutcome::Unreadable { reason }
if reason == "an old Office version this tool cannot read"
));
}
assert!(matches!(
doc_error(&DocError::Encrypted),
DocOutcome::Unreadable { reason } if reason == "password-protected"
));
assert!(matches!(
doc_error(&DocError::Corrupted("bad".to_string())),
DocOutcome::Unreadable { reason } if reason == "corrupt or unreadable .doc"
));
assert!(matches!(
ppt_error(&PptError::Encrypted),
DocOutcome::Unreadable { reason } if reason == "password-protected"
));
assert!(matches!(
ppt_error(&PptError::Corrupted("bad".to_string())),
DocOutcome::Unreadable { reason } if reason == "corrupt or unreadable .ppt"
));
}
#[test]
fn family_of_lists_exactly_the_legacy_extensions() {
assert_eq!(family_of(Path::new("a.doc")), Some(Family::Doc));
assert_eq!(family_of(Path::new("a.DOC")), Some(Family::Doc));
assert_eq!(family_of(Path::new("a.xls")), Some(Family::Xls));
assert_eq!(family_of(Path::new("a.ppt")), Some(Family::Ppt));
assert_eq!(family_of(Path::new("a.docx")), None);
assert_eq!(family_of(Path::new("a.txt")), None);
}
#[test]
fn garbage_cfb_is_unreadable_not_unsupported() {
let mut bytes = crate::document::CFB_MAGIC.to_vec();
bytes.extend_from_slice(b"not a compound file body");
for family in [Family::Doc, Family::Xls, Family::Ppt] {
assert!(matches!(
convert(&bytes, family),
DocOutcome::Unreadable { reason } if reason.starts_with("corrupt or unreadable .")
));
}
}
}