Skip to main content

extend_docx/doc/
pieces.rs

1//! Piece table ([MS-DOC] 2.8.35 Clx / 2.8.36 PlcPcd): maps the logical
2//! character-position (CP) space onto WordDocument-stream byte offsets (FCs).
3//! Pieces are either 8-bit (Windows-1252) or 16-bit (UTF-16LE); both kinds can
4//! coexist in one document, and fast-saved files store text non-contiguously,
5//! so all text access must go through this table.
6
7#[derive(Debug, Clone, Copy)]
8pub struct Piece {
9    pub cp_start: u32,
10    pub cp_end: u32,
11    /// Byte offset of the piece's first character in the WordDocument stream.
12    pub fc: u32,
13    pub compressed: bool,
14    pub prm: u16,
15}
16
17#[derive(Debug)]
18pub struct PieceTable {
19    pub pieces: Vec<Piece>,
20    /// Property-modifier grpprls hoisted from the Clx Prc entries; referenced
21    /// by Pcd.prm in fast-saved documents.
22    pub grpprls: Vec<Vec<u8>>,
23}
24
25/// Windows-1252 high range (0x80-0x9F); the rest of the codepage is identical
26/// to Latin-1.
27const CP1252_HIGH: [u16; 32] = [
28    0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160,
29    0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022,
30    0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178,
31];
32
33pub fn cp1252_to_utf16(byte: u8) -> u16 {
34    if (0x80..0xA0).contains(&byte) {
35        CP1252_HIGH[(byte - 0x80) as usize]
36    } else {
37        byte as u16
38    }
39}
40
41fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, String> {
42    bytes
43        .get(offset..offset + 2)
44        .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
45        .ok_or_else(|| "Clx: unexpected end of data".to_string())
46}
47
48fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, String> {
49    bytes
50        .get(offset..offset + 4)
51        .map(|chunk| u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
52        .ok_or_else(|| "Clx: unexpected end of data".to_string())
53}
54
55impl PieceTable {
56    /// Parses a Clx: zero or more Prc entries (0x01) followed by one Pcdt (0x02).
57    pub fn parse(clx: &[u8]) -> Result<PieceTable, String> {
58        let mut grpprls = Vec::new();
59        let mut pos = 0usize;
60        while pos < clx.len() {
61            match clx[pos] {
62                0x01 => {
63                    let cb = read_u16(clx, pos + 1)? as usize;
64                    let grpprl = clx
65                        .get(pos + 3..pos + 3 + cb)
66                        .ok_or_else(|| "Clx: Prc grpprl out of bounds".to_string())?;
67                    grpprls.push(grpprl.to_vec());
68                    pos += 3 + cb;
69                }
70                0x02 => {
71                    let lcb = read_u32(clx, pos + 1)? as usize;
72                    let plc = clx
73                        .get(pos + 5..pos + 5 + lcb)
74                        .ok_or_else(|| "Clx: PlcPcd out of bounds".to_string())?;
75                    let pieces = parse_plc_pcd(plc)?;
76                    return Ok(PieceTable { pieces, grpprls });
77                }
78                tag => return Err(format!("Clx: unexpected tag 0x{tag:02X}")),
79            }
80        }
81        Err("Clx: missing Pcdt (piece table)".to_string())
82    }
83
84    pub fn cp_max(&self) -> u32 {
85        self.pieces.last().map(|piece| piece.cp_end).unwrap_or(0)
86    }
87
88    fn piece_index_for(&self, cp: u32) -> Option<usize> {
89        // Pieces are sorted by cp_start; binary search the containing piece.
90        let mut low = 0usize;
91        let mut high = self.pieces.len();
92        while low < high {
93            let mid = (low + high) / 2;
94            let piece = &self.pieces[mid];
95            if cp < piece.cp_start {
96                high = mid;
97            } else if cp >= piece.cp_end {
98                low = mid + 1;
99            } else {
100                return Some(mid);
101            }
102        }
103        None
104    }
105
106    pub fn piece_for(&self, cp: u32) -> Option<&Piece> {
107        self.piece_index_for(cp).map(|index| &self.pieces[index])
108    }
109
110    /// Byte offset in the WordDocument stream of the character at `cp`.
111    pub fn cp_to_fc(&self, cp: u32) -> Option<u32> {
112        let piece = self.piece_for(cp)?;
113        let delta = cp - piece.cp_start;
114        Some(if piece.compressed {
115            piece.fc + delta
116        } else {
117            piece.fc + delta * 2
118        })
119    }
120
121    /// Extracts text for `[cp_start, cp_end)` as UTF-16 code units, exactly one
122    /// unit per CP so callers can index by CP. Out-of-range or unmapped CPs
123    /// yield U+FFFD.
124    pub fn text(&self, word_stream: &[u8], cp_start: u32, cp_end: u32) -> Vec<u16> {
125        let mut out = Vec::with_capacity((cp_end.saturating_sub(cp_start)) as usize);
126        let mut cp = cp_start;
127        while cp < cp_end {
128            let Some(piece) = self.piece_for(cp) else {
129                out.push(0xFFFD);
130                cp += 1;
131                continue;
132            };
133            let take_end = piece.cp_end.min(cp_end);
134            let delta = (cp - piece.cp_start) as usize;
135            let count = (take_end - cp) as usize;
136            if piece.compressed {
137                let start = piece.fc as usize + delta;
138                for index in 0..count {
139                    out.push(
140                        word_stream
141                            .get(start + index)
142                            .map(|byte| cp1252_to_utf16(*byte))
143                            .unwrap_or(0xFFFD),
144                    );
145                }
146            } else {
147                let start = piece.fc as usize + delta * 2;
148                for index in 0..count {
149                    out.push(
150                        word_stream
151                            .get(start + index * 2..start + index * 2 + 2)
152                            .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
153                            .unwrap_or(0xFFFD),
154                    );
155                }
156            }
157            cp = take_end;
158        }
159        out
160    }
161}
162
163fn parse_plc_pcd(plc: &[u8]) -> Result<Vec<Piece>, String> {
164    if plc.len() < 4 || !(plc.len() - 4).is_multiple_of(12) {
165        return Err(format!("PlcPcd: invalid length {}", plc.len()));
166    }
167    let count = (plc.len() - 4) / 12;
168    let mut pieces = Vec::with_capacity(count);
169    for index in 0..count {
170        let cp_start = read_u32(plc, index * 4)?;
171        let cp_end = read_u32(plc, (index + 1) * 4)?;
172        let pcd_offset = (count + 1) * 4 + index * 8;
173        let raw_fc = read_u32(plc, pcd_offset + 2)?;
174        let prm = read_u16(plc, pcd_offset + 6)?;
175        let compressed = raw_fc & 0x4000_0000 != 0;
176        let fc_value = raw_fc & 0x3FFF_FFFF;
177        let fc = if compressed { fc_value / 2 } else { fc_value };
178        if cp_end < cp_start {
179            return Err("PlcPcd: descending CP entries".to_string());
180        }
181        pieces.push(Piece {
182            cp_start,
183            cp_end,
184            fc,
185            compressed,
186            prm,
187        });
188    }
189    Ok(pieces)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn build_plc_pcd(entries: &[(u32, u32, u32, u16)]) -> Vec<u8> {
197        // entries: (cp_start, cp_end, raw_fc, prm); consecutive entries must share boundaries
198        let mut clx = vec![0x02u8];
199        let lcb = (entries.len() * 12 + 4) as u32;
200        clx.extend_from_slice(&lcb.to_le_bytes());
201        for entry in entries {
202            clx.extend_from_slice(&entry.0.to_le_bytes());
203        }
204        clx.extend_from_slice(&entries.last().unwrap().1.to_le_bytes());
205        for entry in entries {
206            clx.extend_from_slice(&0u16.to_le_bytes());
207            clx.extend_from_slice(&entry.2.to_le_bytes());
208            clx.extend_from_slice(&entry.3.to_le_bytes());
209        }
210        clx
211    }
212
213    #[test]
214    fn parses_mixed_encoding_pieces() {
215        // Stream: 8-bit "Hi\x93" at byte 0 (fc raw = 0x40000000 | 0), then
216        // UTF-16 "Wörld" at byte 4.
217        let mut stream = Vec::new();
218        stream.extend_from_slice(b"Hi\x93\x00");
219        for unit in "W\u{F6}rld".encode_utf16() {
220            stream.extend_from_slice(&unit.to_le_bytes());
221        }
222
223        let clx = build_plc_pcd(&[(0, 3, 0x4000_0000, 0), (3, 8, 4, 0)]);
224        let table = PieceTable::parse(&clx).expect("parse clx");
225        assert_eq!(table.cp_max(), 8);
226
227        let text = String::from_utf16_lossy(&table.text(&stream, 0, 8));
228        assert_eq!(text, "Hi\u{201C}W\u{F6}rld");
229
230        // cp 1 is in the compressed piece: fc = 0/2 + 1.
231        assert_eq!(table.cp_to_fc(1), Some(1));
232        // cp 4 is the second UTF-16 char: fc = 4 + 2.
233        assert_eq!(table.cp_to_fc(4), Some(6));
234    }
235
236    #[test]
237    fn parses_prc_entries_before_pcdt() {
238        let mut clx = vec![0x01u8];
239        clx.extend_from_slice(&2u16.to_le_bytes());
240        clx.extend_from_slice(&[0xAA, 0xBB]);
241        let pcdt = build_plc_pcd(&[(0, 1, 0x4000_0000, 0x0003)]);
242        clx.extend_from_slice(&pcdt);
243
244        let table = PieceTable::parse(&clx).expect("parse clx");
245        assert_eq!(table.grpprls.len(), 1);
246        assert_eq!(table.grpprls[0], vec![0xAA, 0xBB]);
247        assert_eq!(table.pieces[0].prm, 0x0003);
248    }
249}