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