Skip to main content

extend_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;
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
34/// The opened streams and core tables of a .doc file.
35pub 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    /// UTF-16 code units for `[cp_start, cp_end)`, one unit per CP.
89    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}