1pub mod build;
9pub mod cfb;
10pub mod chp;
11mod comments;
12pub mod fib;
13mod fields;
14pub mod fkp;
15pub mod fmt;
16pub mod fonts;
17pub mod images;
18pub mod lists;
19pub mod pap;
20pub mod pieces;
21pub mod sep;
22pub mod sprm;
23pub mod stsh;
24pub mod tap;
25mod textboxes;
26
27pub use build::parse_doc;
28pub use cfb::is_cfb as is_doc_format;
29
30use cfb::CompoundFile;
31use fib::Fib;
32use pieces::PieceTable;
33
34pub struct DocFile {
36 pub word: Vec<u8>,
37 pub table: Vec<u8>,
38 pub data: Vec<u8>,
39 pub fib: Fib,
40 pub pieces: PieceTable,
41}
42
43impl DocFile {
44 pub fn open(bytes: &[u8]) -> Result<DocFile, String> {
45 let mut compound = CompoundFile::parse_selected(
46 bytes,
47 Some(&["WordDocument", "0Table", "1Table", "Data"]),
48 )?;
49 let word = compound
50 .take_stream("WordDocument")?
51 .ok_or_else(|| "Invalid .doc: missing WordDocument stream".to_string())?;
52 let fib = Fib::parse(&word)?;
53 let table = match compound.take_stream(fib.table_stream_name())? {
54 Some(table) => Some(table),
55 None => compound.take_stream(if fib.f_which_tbl_stm {
56 "0Table"
57 } else {
58 "1Table"
59 })?,
60 }
61 .ok_or_else(|| "Invalid .doc: missing Table stream".to_string())?;
62 let data = compound.take_stream("Data")?.unwrap_or_default();
63
64 let clx = fib
65 .table_slice(&table, fib::fcidx::CLX)
66 .ok_or_else(|| "Invalid .doc: missing piece table (Clx)".to_string())?;
67 let pieces = PieceTable::parse(clx)?;
68 let declared_cp_max = fib
69 .ccp
70 .checked_total()
71 .ok_or_else(|| "Invalid .doc: FIB story character counts overflow".to_string())?;
72 if declared_cp_max > pieces.cp_max() {
73 return Err(format!(
74 "Invalid .doc: FIB stories end at CP {declared_cp_max}, beyond piece table CP {}",
75 pieces.cp_max()
76 ));
77 }
78
79 Ok(DocFile {
80 word,
81 table,
82 data,
83 fib,
84 pieces,
85 })
86 }
87
88 pub fn text(&self, cp_start: u32, cp_end: u32) -> Vec<u16> {
90 self.pieces.text(&self.word, cp_start, cp_end)
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use crate::{build_doc_model, DocNode, ParagraphChildNode};
98
99 fn document_bytes(text: &str, compressed: bool) -> Vec<u8> {
100 let units: Vec<u16> = text.encode_utf16().collect();
101 let mut word = vec![0u8; 512];
102 word[0..2].copy_from_slice(&0xa5ecu16.to_le_bytes());
103 word[2..4].copy_from_slice(&0x00c1u16.to_le_bytes());
104 word[10..12].copy_from_slice(&0x0204u16.to_le_bytes());
105 word[32..34].copy_from_slice(&14u16.to_le_bytes());
106 word[62..64].copy_from_slice(&22u16.to_le_bytes());
107 word[76..80].copy_from_slice(&(units.len() as u32).to_le_bytes());
108 word[152..154].copy_from_slice(&34u16.to_le_bytes());
109 word[422..426].copy_from_slice(&21u32.to_le_bytes());
110 for unit in &units {
111 if compressed {
112 assert!(*unit <= 0x7f);
113 word.push(*unit as u8);
114 } else {
115 word.extend(unit.to_le_bytes());
116 }
117 }
118 let mut table = vec![0u8; 21];
119 table[0] = 2;
120 table[1..5].copy_from_slice(&16u32.to_le_bytes());
121 table[9..13].copy_from_slice(&(units.len() as u32).to_le_bytes());
122 let offset = if compressed { 0x40000400u32 } else { 512u32 };
123 table[15..19].copy_from_slice(&offset.to_le_bytes());
124 cfb::tests::build_cfb(&[("WordDocument", &word), ("1Table", &table)])
125 }
126
127 #[test]
128 fn parses_generated_binary_text_through_model_pipeline() {
129 for (text, compressed) in [
130 ("Plain text\rSecond line\r", true),
131 ("Caf\u{e9} \u{201c}text\u{201d}\rSecond line\r", false),
132 ] {
133 let bytes = document_bytes(text, compressed);
134 let doc = DocFile::open(&bytes).expect("open generated document");
135 assert_eq!(
136 String::from_utf16_lossy(&doc.text(0, doc.fib.ccp.text)),
137 text
138 );
139 let package = parse_doc(&bytes).expect("parse generated document");
140 let model = build_doc_model(&package);
141 let paragraphs: Vec<String> = model
142 .nodes
143 .iter()
144 .filter_map(|node| {
145 let DocNode::Paragraph(paragraph) = node else {
146 return None;
147 };
148 Some(
149 paragraph
150 .children
151 .iter()
152 .filter_map(|child| {
153 if let ParagraphChildNode::Text(run) = child {
154 Some(run.text.as_str())
155 } else {
156 None
157 }
158 })
159 .collect(),
160 )
161 })
162 .collect();
163 assert_eq!(
164 paragraphs,
165 text.trim_end_matches('\r').split('\r').collect::<Vec<_>>()
166 );
167 }
168 }
169}