pptx_rs/ppt97/record.rs
1//! PPT 97-2003 二进制 record 树解析工具。
2//!
3//! 本模块提供 .ppt 文件(PowerPoint 97-2003 二进制格式)底层 record 树的
4//! 解析能力,是 [`crate::ppt97`] 模块的水印注入与加密功能的基础设施。
5//!
6//! # 与 python-pptx 的对应
7//!
8//! python-pptx **不支持** .ppt 97-2003 二进制格式(仅支持 .pptx)。
9//! 本模块填补了这一空白,对标 LibreOffice 的 `oox::ole` 和 Apache POI 的
10//! `org.apache.poi.hslf`(HSLF = Horrible Slide Layout Format)。
11//!
12//! # 规范依据
13//!
14//! - [MS-PPT]:PowerPoint 97-2003 二进制文件格式规范
15//! - [MS-ODRAW]:Office Drawing 97-2003 二进制格式规范(Escher OfficeArt)
16//! - [MS-CFB]:Compound File Binary 文件格式规范(OLE2 容器)
17//!
18//! # record 树结构
19//!
20//! .ppt 文件的 `PowerPoint Document` stream 是一棵 record 树:
21//!
22//! ```text
23//! record header (8 bytes): verInst(u16) + recType(u16) + recLen(u32)
24//! record data (recLen bytes)
25//! ```
26//!
27//! - `ver`(4 bit):版本号,`0xF` 表示 container(含子 record)
28//! - `inst`(12 bit):实例号,语义随 recType 变化
29//! - `recType`(u16):record 类型码(如 0x03F8 = MainMaster)
30//! - `recLen`(u32):data 字段字节数(不含 header 的 8 字节)
31//!
32//! [MS-PPT]: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt
33//! [MS-ODRAW]: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw
34//! [MS-CFB]: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-cfb
35
36use crate::error::{Error, Result};
37
38// ============================================================================
39// Record 类型常量(MS-PPT 规范)
40// ============================================================================
41
42/// MainMaster record type(MS-PPT 规范:RT_MainMaster = 0x03F8)。
43///
44/// 母版是幻灯片的背景模板,水印注入目标即为此 record 的 PPDrawing 子树。
45/// 一个 .ppt 文件可包含多个 MainMaster(每个对应一套版式)。
46pub const RT_MAIN_MASTER: u16 = 0x03F8;
47
48/// PPDrawing record type(MS-PPT 规范:RT_PPDrawing = 0x040C)。
49///
50/// PPDrawing 是 MainMaster / Slide 的子 record,承载 Escher OfficeArt
51/// 绘图树(DgContainer → SpgrContainer → SpContainer)。
52pub const RT_PPDRAWING: u16 = 0x040C;
53
54/// CurrentUserAtom record type(MS-PPT 规范:RT_CurrentUserAtom = 0x0FF6)。
55///
56/// 位于 `Current User` stream,记录 offsetToCurrentEdit(指向最新 UserEditAtom)。
57pub const RT_CURRENT_USER_ATOM: u16 = 0x0FF6;
58
59/// UserEditAtom record type(MS-PPT 规范:RT_UserEditAtom = 0x0FF5)。
60///
61/// 记录用户最后一次编辑的元信息:offsetPersistDirectory、maxPersistWritten、
62/// encryptSessionPersistIdRef(加密时存在,recLen 由 28 变为 32)。
63pub const RT_USER_EDIT_ATOM: u16 = 0x0FF5;
64
65/// PersistDirectoryAtom record type(MS-PPT 规范:RT_PersistDirectoryAtom = 0x1772)。
66///
67/// persist 目录:记录每个 persistId 到 stream offset 的映射,
68/// 是 .ppt 文件"对象寻址"的核心数据结构。
69pub const RT_PERSIST_DIRECTORY_ATOM: u16 = 0x1772;
70
71/// 解析 8 字节 record header。
72///
73/// .ppt record header 结构(MS-PPT 规范):
74///
75/// ```text
76/// offset 长度 字段 说明
77/// 0 2 verInst ver(低4位) | inst(高12位)
78/// 2 2 recType record 类型码
79/// 4 4 recLen data 字段长度(不含 header 自身)
80/// ```
81///
82/// # 参数
83/// - `data`:PowerPoint Document stream 字节
84/// - `offset`:record header 起始偏移
85///
86/// # 返回
87/// - 成功:`(ver, inst, rec_type, rec_len)` 四元组
88/// - `ver`:低 4 位版本号,`0xF` 表示 container
89/// - `inst`:高 12 位实例号
90/// - `rec_type`:record 类型码
91/// - `rec_len`:data 字段长度
92///
93/// # 错误
94/// - [`Error::Ppt97`]:`offset + 8` 超出 `data` 范围。
95pub fn parse_record_header(data: &[u8], offset: usize) -> Result<(u8, u16, u16, u32)> {
96 if offset + 8 > data.len() {
97 return Err(Error::ppt97(format!(
98 "record header parse: offset {} + 8 out of range (data len {})",
99 offset,
100 data.len()
101 )));
102 }
103 let ver_inst = u16::from_le_bytes([data[offset], data[offset + 1]]);
104 let rec_type = u16::from_le_bytes([data[offset + 2], data[offset + 3]]);
105 let rec_len = u32::from_le_bytes([
106 data[offset + 4],
107 data[offset + 5],
108 data[offset + 6],
109 data[offset + 7],
110 ]);
111 let ver = (ver_inst & 0x0F) as u8;
112 let inst = (ver_inst >> 4) & 0x0FFF;
113 Ok((ver, inst, rec_type, rec_len))
114}
115
116/// 读取小端 u32。
117///
118/// # 参数
119/// - `data`:数据源
120/// - `offset`:读取起始位置
121///
122/// # 返回
123/// 小端 u32 值。
124///
125/// # 错误
126/// - [`Error::Ppt97`]:`offset + 4` 超出 `data` 范围。
127pub fn read_u32_le(data: &[u8], offset: usize) -> Result<u32> {
128 if offset + 4 > data.len() {
129 return Err(Error::ppt97(format!(
130 "read_u32_le: offset {} + 4 out of range (data len {})",
131 offset,
132 data.len()
133 )));
134 }
135 Ok(u32::from_le_bytes([
136 data[offset],
137 data[offset + 1],
138 data[offset + 2],
139 data[offset + 3],
140 ]))
141}
142
143/// 写入小端 u32(原地修改)。
144///
145/// # 参数
146/// - `data`:可变数据源
147/// - `offset`:写入起始位置
148/// - `val`:待写入的 u32 值
149///
150/// # 错误
151/// - [`Error::Ppt97`]:`offset + 4` 超出 `data` 范围。
152pub fn write_u32_le(data: &mut [u8], offset: usize, val: u32) -> Result<()> {
153 if offset + 4 > data.len() {
154 return Err(Error::ppt97(format!(
155 "write_u32_le: offset {} + 4 out of range (data len {})",
156 offset,
157 data.len()
158 )));
159 }
160 let bytes = val.to_le_bytes();
161 data[offset..offset + 4].copy_from_slice(&bytes);
162 Ok(())
163}
164
165/// 解析 PersistDirectoryAtom,返回 persist entries 列表。
166///
167/// PersistDirectoryAtom 结构(MS-PPT 规范 2.3.3):
168///
169/// ```text
170/// record header (8 bytes): type=0x1772, len=可变
171/// rgPersistDirEntry[]:
172/// PersistDirectoryEntry:
173/// - persistId (20 bits) + cPersist (12 bits) = 4 bytes
174/// - rgPersistOffset[cPersist] (cPersist * 4 bytes): 每个 entry 的 stream offset
175/// ```
176///
177/// 一个 PersistDirectoryAtom 可包含**多个** PersistDirectoryEntry,每个 entry
178/// 有自己的 persistId 起始值和 cPersist。persistId 在 entry 内从 `persistId`
179/// 开始递增(`persistId + 0`、`persistId + 1`、...、`persistId + cPersist - 1`)。
180///
181/// # 参数
182/// - `data`:PowerPoint Document stream 字节
183/// - `offset`:PersistDirectoryAtom 的起始偏移
184///
185/// # 返回
186/// - 成功:`Vec<(persistId, stream_offset)>` 列表
187///
188/// # 错误
189/// - [`Error::Ppt97`]:record type 不匹配 / 数据越界
190pub fn parse_persist_directory(data: &[u8], offset: usize) -> Result<Vec<(u32, u32)>> {
191 let (_, _, rec_type, rec_len) = parse_record_header(data, offset)?;
192 if rec_type != RT_PERSIST_DIRECTORY_ATOM {
193 return Err(Error::ppt97(format!(
194 "persist directory parse: expected type 0x{:04X}, got 0x{:04X}",
195 RT_PERSIST_DIRECTORY_ATOM, rec_type
196 )));
197 }
198
199 // PersistDirectoryAtom data 范围:header(8) 之后 recLen 字节
200 let pd_start = offset + 8;
201 let pd_end = pd_start + rec_len as usize;
202 if pd_end > data.len() {
203 return Err(Error::ppt97(format!(
204 "persist directory parse: data out of range (start {}, end {}, data len {})",
205 pd_start,
206 pd_end,
207 data.len()
208 )));
209 }
210
211 let pd_data = &data[pd_start..pd_end];
212 let mut entries = Vec::new();
213 let mut pos = 0usize;
214
215 // 遍历 rgPersistDirEntry[],每个 entry 由 4 字节头 + cPersist 个 4 字节 offset 组成
216 while pos + 4 <= pd_data.len() {
217 let entry = u32::from_le_bytes([
218 pd_data[pos],
219 pd_data[pos + 1],
220 pd_data[pos + 2],
221 pd_data[pos + 3],
222 ]);
223 // persistId: 20 bits, cPersist: 12 bits(MS-PPT 规范)
224 let persist_id = entry & 0xFFFFF;
225 let c_persist = (entry >> 20) & 0xFFF;
226 pos += 4;
227
228 for j in 0..c_persist {
229 if pos + 4 > pd_data.len() {
230 return Err(Error::ppt97(format!(
231 "persist directory parse: entry (pid={}, cPersist={}) offset out of range",
232 persist_id, c_persist
233 )));
234 }
235 let persist_offset = u32::from_le_bytes([
236 pd_data[pos],
237 pd_data[pos + 1],
238 pd_data[pos + 2],
239 pd_data[pos + 3],
240 ]);
241 entries.push((persist_id + j, persist_offset));
242 pos += 4;
243 }
244 }
245
246 Ok(entries)
247}
248
249/// 在 PowerPoint Document stream 中找到所有 MainMaster record 的 offset。
250///
251/// 遍历顶层 record,收集所有 `type=0x03F8 (MainMaster)` 的 container record。
252///
253/// 水印注入目标即为此函数返回的每个 MainMaster 的 PPDrawing 子树。
254/// 一个 .ppt 文件可包含多个 MainMaster(每个对应一套版式)。
255///
256/// # 参数
257/// - `data`:PowerPoint Document stream 字节
258///
259/// # 返回
260/// - 成功:所有 MainMaster 的起始 offset 列表(按文件中出现顺序)
261///
262/// # 错误
263/// - [`Error::Ppt97`]:record header 解析失败 / record 越界
264pub fn find_main_masters(data: &[u8]) -> Result<Vec<usize>> {
265 let mut masters = Vec::new();
266 let mut pos = 0usize;
267 while pos + 8 <= data.len() {
268 let (ver, _, rec_type, rec_len) = parse_record_header(data, pos)?;
269 let is_container = ver == 0xF;
270 let total_len = 8usize + rec_len as usize;
271
272 if is_container && rec_type == RT_MAIN_MASTER {
273 masters.push(pos);
274 }
275
276 // 防御性检查:rec_len 异常时停止遍历,避免死循环
277 if total_len == 0 {
278 break;
279 }
280 pos += total_len;
281 // 非 container 且 rec_len=0 是终止标记(MS-PPT 规范)
282 if !is_container && rec_len == 0 {
283 break;
284 }
285 }
286 Ok(masters)
287}
288
289/// 在 MainMaster container 中找到 PPDrawing 的 offset。
290///
291/// MainMaster (container) 的子 record 包括 SlideAtom、Environment、PPDrawing 等。
292/// 遍历 MainMaster 的子 record,找到 `type=0x040C (PPDrawing)` 的 container。
293///
294/// # 参数
295/// - `data`:PowerPoint Document stream 字节
296/// - `master_offset`:MainMaster record 的起始偏移
297///
298/// # 返回
299/// - 成功:`Some(ppd_offset)` 找到 PPDrawing;`None` 未找到
300///
301/// # 错误
302/// - [`Error::Ppt97`]:record header 解析失败 / MainMaster 越界
303pub fn find_ppdrawing_in_master(data: &[u8], master_offset: usize) -> Result<Option<usize>> {
304 let (_, _, _, master_len) = parse_record_header(data, master_offset)?;
305 let master_end = master_offset + 8 + master_len as usize;
306
307 let mut pos = master_offset + 8;
308 while pos + 8 <= master_end {
309 let (ver, _, rec_type, rec_len) = parse_record_header(data, pos)?;
310 let is_container = ver == 0xF;
311 let total_len = 8usize + rec_len as usize;
312
313 if is_container && rec_type == RT_PPDRAWING {
314 return Ok(Some(pos));
315 }
316
317 if total_len == 0 {
318 break;
319 }
320 pos += total_len;
321 if !is_container && rec_len == 0 {
322 break;
323 }
324 }
325 Ok(None)
326}