Skip to main content

rust_docx/doc/
mod.rs

1//! Legacy Word 97-2003 binary `.doc` parser ([MS-DOC]).
2//!
3//! Parses the binary format and synthesizes an in-memory [`OoxmlPackage`]
4//! (document.xml, styles.xml, numbering.xml, headers/footers, settings, rels,
5//! media assets) so the existing DOCX model-building pipeline is reused
6//! unchanged downstream.
7
8pub 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
35/// The opened streams and core tables of a .doc file.
36pub 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    /// UTF-16 code units for `[cp_start, cp_end)`, one unit per CP.
90    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}