mod papx;
use crate::error::{Result, XbergError};
use crate::types::ProcessingWarning;
use std::io::Cursor;
const DOC_WARNING_SOURCE: &str = "doc";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct DocParagraph {
pub content: String,
pub list: Option<DocListMembership>,
pub heading_level: Option<u8>,
}
struct MainText {
content: String,
paragraphs: Vec<DocParagraph>,
subdocuments: Vec<DocSubdocument>,
}
impl MainText {
fn text_only(content: String) -> Self {
Self {
content,
paragraphs: Vec::new(),
subdocuments: Vec::new(),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct DocSubdocument {
pub kind: DocSubdocumentKind,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) enum DocSubdocumentKind {
Header,
Footer,
HeaderFooter,
Footnote,
Comment,
TextBox,
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub(crate) struct DocListMembership {
pub level: u8,
pub ordered: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct DocExtractionResult {
pub content: String,
pub metadata: DocMetadata,
pub processing_warnings: Vec<ProcessingWarning>,
pub paragraphs: Vec<DocParagraph>,
pub subdocuments: Vec<DocSubdocument>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub(crate) struct DocMetadata {
pub title: Option<String>,
pub subject: Option<String>,
pub author: Option<String>,
pub last_author: Option<String>,
pub created: Option<String>,
pub modified: Option<String>,
pub revision_number: Option<String>,
}
pub(crate) fn extract_doc_text(content: &[u8]) -> Result<DocExtractionResult> {
let cursor = Cursor::new(content);
let mut comp = cfb::CompoundFile::open(cursor)
.map_err(|e| XbergError::parsing(format!("Failed to open DOC as OLE container: {e}")))?;
let metadata = extract_doc_metadata(&mut comp);
let word_doc = read_stream(&mut comp, "/WordDocument")?;
if word_doc.len() < 12 {
return Err(XbergError::parsing("WordDocument stream too short"));
}
let w_ident = u16::from_le_bytes([word_doc[0], word_doc[1]]);
if !matches!(w_ident, 0xA5DC | 0xA5EC) {
return Err(XbergError::parsing(format!(
"Invalid DOC magic number: 0x{w_ident:04X}, expected 0xA5DC or 0xA5EC"
)));
}
let n_fib = u16::from_le_bytes([word_doc[2], word_doc[3]]);
let flags_a = u16::from_le_bytes([word_doc[0x0A], word_doc[0x0B]]);
if w_ident == 0xA5DC || (0x0065..=0x0068).contains(&n_fib) {
if flags_a & 0x0004 != 0 {
return Err(XbergError::parsing(
"Fast-saved Word 6/95 documents are not supported; save the document without fast-save history and retry",
));
}
return extract_text_word6(&word_doc).map(|text| DocExtractionResult {
content: text,
metadata,
processing_warnings: Vec::new(),
paragraphs: Vec::new(),
subdocuments: Vec::new(),
});
}
let use_1table = (flags_a & 0x0200) != 0;
let table_stream_name = if use_1table { "/1Table" } else { "/0Table" };
let table_stream = read_stream(&mut comp, table_stream_name)?;
let mut processing_warnings = Vec::new();
extract_text_word97(&word_doc, &table_stream, &mut processing_warnings).map(|main| DocExtractionResult {
content: main.content,
metadata,
processing_warnings,
paragraphs: main.paragraphs,
subdocuments: main.subdocuments,
})
}
const FIB_LW_IDX_CCP_TEXT: usize = 3;
const FIB_FC_LCB_IDX_CLX: usize = 33;
const FIB_FC_LCB_IDX_PLCFFND_TXT: usize = 3;
const FIB_FC_LCB_IDX_PLCFAND_TXT: usize = 5;
const FIB_FC_LCB_IDX_PLCF_HDD: usize = 11;
const FIB_LW_IDX_CCP_FTN: usize = 4;
const FIB_LW_IDX_CCP_HDD: usize = 5;
const FIB_LW_IDX_CCP_ATN: usize = 7;
const FIB_LW_IDX_CCP_EDN: usize = 8;
const FIB_LW_IDX_CCP_TXBX: usize = 9;
const FIB_LW_IDX_CCP_HDR_TXBX: usize = 10;
fn read_lw_field(word_doc: &[u8], rg_lw_offset: usize, index: usize) -> usize {
let off = rg_lw_offset + index * 4;
if word_doc.len() < off + 4 {
return 0;
}
u32::from_le_bytes([word_doc[off], word_doc[off + 1], word_doc[off + 2], word_doc[off + 3]]) as usize
}
#[derive(Debug, Clone, Copy)]
struct SubdocRange {
start: usize,
end: usize,
}
impl SubdocRange {
fn len(&self) -> usize {
self.end.saturating_sub(self.start)
}
}
struct SubdocRanges {
main: SubdocRange,
footnote: SubdocRange,
header: SubdocRange,
annotation: SubdocRange,
endnote: SubdocRange,
textbox: SubdocRange,
header_textbox: SubdocRange,
}
impl SubdocRanges {
fn from_fib(word_doc: &[u8], rg_lw_offset: usize, ccp_text: usize) -> Self {
let ccp_ftn = read_lw_field(word_doc, rg_lw_offset, FIB_LW_IDX_CCP_FTN);
let ccp_hdd = read_lw_field(word_doc, rg_lw_offset, FIB_LW_IDX_CCP_HDD);
let ccp_atn = read_lw_field(word_doc, rg_lw_offset, FIB_LW_IDX_CCP_ATN);
let ccp_edn = read_lw_field(word_doc, rg_lw_offset, FIB_LW_IDX_CCP_EDN);
let ccp_txbx = read_lw_field(word_doc, rg_lw_offset, FIB_LW_IDX_CCP_TXBX);
let ccp_hdr_txbx = read_lw_field(word_doc, rg_lw_offset, FIB_LW_IDX_CCP_HDR_TXBX);
let main = SubdocRange {
start: 0,
end: ccp_text,
};
let footnote = SubdocRange {
start: main.end,
end: main.end + ccp_ftn,
};
let header = SubdocRange {
start: footnote.end,
end: footnote.end + ccp_hdd,
};
let annotation = SubdocRange {
start: header.end,
end: header.end + ccp_atn,
};
let endnote = SubdocRange {
start: annotation.end,
end: annotation.end + ccp_edn,
};
let textbox = SubdocRange {
start: endnote.end,
end: endnote.end + ccp_txbx,
};
let header_textbox = SubdocRange {
start: textbox.end,
end: textbox.end + ccp_hdr_txbx,
};
Self {
main,
footnote,
header,
annotation,
endnote,
textbox,
header_textbox,
}
}
fn has_unextracted_subdocument(&self) -> bool {
self.endnote.len() > 0 || self.header_textbox.len() > 0
}
fn total_cp(&self) -> usize {
self.header_textbox.end
}
}
#[derive(Default)]
struct SubdocumentText {
main: String,
main_fc_ends: Vec<u32>,
footnote: String,
header: String,
annotation: String,
textbox: String,
}
mod piece_table;
use piece_table::extract_text_word97;
const PARAGRAPH_MARK: char = '\r';
fn split_main_paragraphs(main: &str, main_fc_ends: &[u32], list_tables: &papx::ListTables) -> Vec<DocParagraph> {
let mut paragraphs = Vec::new();
let mut start = 0usize;
for (i, c) in main.chars().enumerate() {
if c != PARAGRAPH_MARK {
continue;
}
push_paragraph(
&mut paragraphs,
main,
start,
i,
main_fc_ends.get(i).copied(),
list_tables,
);
start = i + 1;
}
let char_count = main.chars().count();
if start < char_count {
let last_fc = main_fc_ends.get(char_count.saturating_sub(1)).copied();
push_paragraph(&mut paragraphs, main, start, char_count, last_fc, list_tables);
}
paragraphs
}
fn push_paragraph(
out: &mut Vec<DocParagraph>,
main: &str,
start: usize,
end: usize,
mark_fc_end: Option<u32>,
list_tables: &papx::ListTables,
) {
let raw: String = main.chars().skip(start).take(end.saturating_sub(start)).collect();
let content = normalize_doc_text(&raw);
if content.is_empty() {
return;
}
let list = mark_fc_end
.and_then(|fc_end| list_tables.membership_for_paragraph_end(fc_end))
.map(|(level, ordered)| DocListMembership { level, ordered });
let heading_level = mark_fc_end.and_then(|fc_end| list_tables.heading_level_for_paragraph_end(fc_end));
out.push(DocParagraph {
content,
list,
heading_level,
});
}
fn extract_text_contiguous(word_doc: &[u8], ccp_text: usize) -> Result<String> {
if word_doc.len() < 0x20 {
return extract_text_fallback(word_doc, ccp_text);
}
let fc_min = u32::from_le_bytes([word_doc[0x18], word_doc[0x19], word_doc[0x1A], word_doc[0x1B]]) as usize;
let fc_mac = u32::from_le_bytes([word_doc[0x1C], word_doc[0x1D], word_doc[0x1E], word_doc[0x1F]]) as usize;
if fc_min == 0 || fc_min >= word_doc.len() {
return extract_text_fallback(word_doc, ccp_text);
}
let data_len = fc_mac.saturating_sub(fc_min).min(word_doc.len() - fc_min);
if data_len == 0 {
return extract_text_fallback(word_doc, ccp_text);
}
let text_data = &word_doc[fc_min..fc_min + data_len];
let null_count = text_data.iter().filter(|&&b| b == 0).count();
let is_unicode = data_len >= ccp_text * 2 || null_count > data_len / 4;
let text = if is_unicode {
let chars: Vec<u16> = text_data
.chunks_exact(2)
.take(ccp_text)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&chars)
} else {
text_data.iter().take(ccp_text).map(|&b| cp1252_to_char(b)).collect()
};
let normalized = normalize_doc_text(&text);
if normalized.is_empty() {
return extract_text_fallback(word_doc, ccp_text);
}
Ok(normalized)
}
fn extract_text_fallback(word_doc: &[u8], _ccp_text: usize) -> Result<String> {
let mut result = String::new();
let mut text_run = String::new();
for &b in word_doc.iter().skip(256) {
if b == 0x0D || b == 0x0A || b == 0x09 || (0x20..=0xFE).contains(&b) {
text_run.push(cp1252_to_char(b));
} else if !text_run.is_empty() {
if text_run.len() >= 3 {
if !result.is_empty() {
result.push(' ');
}
result.push_str(&text_run);
}
text_run.clear();
}
}
if text_run.len() >= 3 {
if !result.is_empty() {
result.push(' ');
}
result.push_str(&text_run);
}
if result.is_empty() {
return Err(XbergError::parsing("No text content found in DOC file"));
}
Ok(normalize_doc_text(&result))
}
fn extract_text_word6(word_doc: &[u8]) -> Result<String> {
if word_doc.len() < 0x50 {
return Err(XbergError::parsing("Word 6/95 file too short"));
}
let ccp_text = u32::from_le_bytes([word_doc[0x34], word_doc[0x35], word_doc[0x36], word_doc[0x37]]) as usize;
let fc_min = u32::from_le_bytes([word_doc[0x18], word_doc[0x19], word_doc[0x1A], word_doc[0x1B]]) as usize;
let Some(fc_mac) = fc_min.checked_add(ccp_text) else {
return Err(XbergError::parsing(
"Word 6/95 text range overflows the document stream",
));
};
if fc_mac > word_doc.len() {
return extract_text_fallback(word_doc, ccp_text);
}
let text_bytes = &word_doc[fc_min..fc_mac];
let mut result = String::with_capacity(ccp_text);
for &b in text_bytes {
result.push(cp1252_to_char(b));
}
Ok(normalize_doc_text(&result))
}
const FIELD_BEGIN: char = '\x13';
const FIELD_SEPARATOR: char = '\x14';
const FIELD_END: char = '\x15';
const NON_BREAKING_HYPHEN: char = '\u{2011}';
fn scan_field_begin_termination(text: &str) -> Vec<bool> {
let mut terminated: Vec<bool> = Vec::new();
let mut open: Vec<usize> = Vec::new();
for c in text.chars() {
match c {
FIELD_BEGIN => {
terminated.push(false);
open.push(terminated.len() - 1);
}
FIELD_END => {
if let Some(index) = open.pop() {
terminated[index] = true;
}
}
_ => {}
}
}
terminated
}
fn normalize_doc_text(text: &str) -> String {
let stripped = strip_doc_field_instructions(text);
collapse_excess_newlines(&stripped).trim().to_string()
}
fn strip_doc_field_instructions(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let begin_terminated = scan_field_begin_termination(text);
let mut begin_ordinal = 0usize;
let mut field_stack: Vec<bool> = Vec::new();
let mut instruction_depth = 0usize;
for c in text.chars() {
match c {
FIELD_BEGIN => {
let terminated = begin_terminated.get(begin_ordinal).copied().unwrap_or(false);
begin_ordinal += 1;
if terminated {
field_stack.push(true);
instruction_depth += 1;
}
continue;
}
FIELD_SEPARATOR => {
if let Some(in_instruction) = field_stack.last_mut()
&& *in_instruction
{
*in_instruction = false;
instruction_depth -= 1;
}
continue;
}
FIELD_END => {
if let Some(in_instruction) = field_stack.pop()
&& in_instruction
{
instruction_depth -= 1;
}
continue;
}
_ => {}
}
if instruction_depth > 0 {
continue;
}
match c {
'\r' => result.push('\n'),
'\x07' => result.push('\t'),
'\x0B' => result.push('\n'),
'\x0C' => result.push('\n'),
'\x01' | '\x08' => {}
'\x1E' => result.push(NON_BREAKING_HYPHEN),
c if c < '\x20' && c != '\n' && c != '\t' => {}
_ => result.push(c),
}
}
result
}
fn collapse_excess_newlines(text: &str) -> String {
let mut prev_newline = false;
let mut prev_prev_newline = false;
let mut cleaned = String::with_capacity(text.len());
for c in text.chars() {
if c == '\n' {
if prev_prev_newline && prev_newline {
continue;
}
prev_prev_newline = prev_newline;
prev_newline = true;
} else {
prev_prev_newline = false;
prev_newline = false;
}
cleaned.push(c);
}
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)
}
mod metadata;
use metadata::extract_doc_metadata;
#[cfg(test)]
pub(crate) mod tests;