pptx_rs/ppt97/watermark.rs
1//! .ppt 文件水印注入。
2//!
3//! 本模块为 PowerPoint 97-2003 二进制格式(`.ppt`)文件注入水印。
4//! 水印作为 **MainMaster 母版的背景元素**注入到 PPDrawing 的 SpgrContainer 中,
5//! 位于"组形状本身"之后(z-order 最低的真正子形状),实现真正水印的视觉效果:
6//!
7//! - 全屏覆盖(ClientAnchor 0,0 → 5760,4320)
8//! - 大字号、可配置旋转角度、中灰色(默认)
9//! - 无填充、无边框、锁定不可编辑(FOPT 保护位 0x01C2)
10//! - 普通视图下不可选中/编辑(符合业界水印常识)
11//!
12//! # 与 python-pptx 的对应
13//!
14//! python-pptx 不支持 .ppt 二进制格式。本模块对标 Aspose.Slides 的
15//! `ISlideMaster.add_watermark` 和 LibreOffice 的母版背景元素注入。
16//!
17//! # 规范依据
18//!
19//! - [MS-ODRAW]:Office Drawing 97-2003 二进制格式(Escher OfficeArt)
20//! - [MS-PPT] 2.3.4:PPDrawing / OfficeArtClientTextbox 等
21//!
22//! # 水印 SpContainer 结构
23//!
24//! ```text
25//! SpContainer (0xF004, container)
26//! ├── FSP (0xF00A): 形状属性,inst=0xCA (TextBox)
27//! ├── FOPT (0xF00B): 形状选项(无填充、无线条、锁定、旋转)
28//! ├── ClientAnchor (0xF010): 形状锚点(全屏覆盖)
29//! └── ClientTextbox (0xF00D, container): 文本框
30//! ├── TextHeaderAtom (0x0F9F): 文本类型
31//! ├── TextCharsAtom (0x0FA0): 水印文本(UTF-16LE)
32//! └── StyleTextPropAtom (0x0FA1): 文本样式(字号、颜色)
33//! ```
34//!
35//! [MS-ODRAW]: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw
36//! [MS-PPT]: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt
37
38use crate::error::{Error, Result};
39use crate::ppt97::record::{
40 find_main_masters, find_ppdrawing_in_master, parse_record_header, read_u32_le, write_u32_le,
41 RT_MAIN_MASTER,
42};
43
44// ============================================================================
45// OfficeArt record type(MS-ODRAW 规范)
46// ============================================================================
47
48/// DgContainer record type(Drawing Container)。
49const RT_DG_CONTAINER: u16 = 0xF002;
50
51/// SpgrContainer record type(Shape Group Container)。
52pub const RT_SPGR_CONTAINER: u16 = 0xF003;
53
54/// SpContainer record type(Shape Container)。
55pub const RT_SP_CONTAINER: u16 = 0xF004;
56
57/// FSP record type(File Shape Properties,ver=2,inst=MSOSPT 形状类型)。
58pub const RT_FSP: u16 = 0xF00A;
59
60/// FOPT record type(File Option,ver=3,inst=属性个数)。
61pub const RT_FOPT: u16 = 0xF00B;
62
63/// ClientTextbox record type(container 版本,ver=0xF)。
64pub const RT_CLIENT_TEXTBOX: u16 = 0xF00D;
65
66/// ClientAnchor record type(形状锚点,ver=0,inst=0)。
67pub const RT_CLIENT_ANCHOR: u16 = 0xF010;
68
69// ============================================================================
70// PPT Text record types
71// ============================================================================
72
73/// TextHeaderAtom record type。
74const RT_TEXT_HEADER_ATOM: u16 = 0x0F9F;
75
76/// TextCharsAtom record type(UTF-16LE 文本)。
77const RT_TEXT_CHARS_ATOM: u16 = 0x0FA0;
78
79/// StyleTextPropAtom record type(文本样式属性)。
80const RT_STYLE_TEXT_PROP_ATOM: u16 = 0x0FA1;
81
82// ============================================================================
83// 水印配置
84// ============================================================================
85
86/// 水印配置参数。
87///
88/// 参考 python-pptx 的 `shapes.add_textbox()` + `font` 组合 API 和
89/// Aspose.Slides 的 `PortionFormat` 参数化模式,将水印的可变属性集中管理,
90/// 避免硬编码散落在 [`build_watermark_spcontainer`] 各处。
91///
92/// # 字段对应关系
93///
94/// - `text` ↔ python-pptx `text_frame.text` / Aspose `add_text_frame(text)`
95/// - `font_size_pt` ↔ python-pptx `font.size = Pt(n)` / Aspose `font_height`
96/// - `color_rgb` ↔ python-pptx `font.color.rgb` / Aspose `solid_fill_color`
97/// - `rotation_deg` ↔ python-pptx `shape.rotation` / Aspose `shape.rotation`
98///
99/// # 示例
100///
101/// ```no_run
102/// use pptx_rs::ppt97::watermark::WatermarkConfig;
103///
104/// let config = WatermarkConfig {
105/// text: "机密".to_string(),
106/// font_size_pt: 72,
107/// color_rgb: (180, 180, 180),
108/// rotation_deg: -30,
109/// };
110/// ```
111#[derive(Clone, Debug)]
112pub struct WatermarkConfig {
113 /// 水印文本内容。
114 pub text: String,
115 /// 字号(磅),常见水印字号 44pt。
116 pub font_size_pt: u16,
117 /// 文字颜色 (r, g, b),每个分量 0-255。
118 pub color_rgb: (u8, u8, u8),
119 /// 旋转角度(度),正值顺时针,负值逆时针。
120 pub rotation_deg: i32,
121}
122
123impl Default for WatermarkConfig {
124 /// 默认水印配置:44pt 中灰色 "pptx-rs 水印",45 度旋转。
125 fn default() -> Self {
126 Self {
127 text: "pptx-rs 水印".to_string(),
128 font_size_pt: 44,
129 color_rgb: (200, 200, 200),
130 rotation_deg: 45,
131 }
132 }
133}
134
135// ============================================================================
136// 水印注入主逻辑
137// ============================================================================
138
139/// 在 PowerPoint Document stream 中注入水印。
140///
141/// 完整流程:
142/// 1. 找到所有 MainMaster record
143/// 2. 对每个 MainMaster 的 PPDrawing:
144/// - 幂等性检查(已存在水印文本则跳过)
145/// - 分配唯一 shapeId(避免与已有形状冲突)
146/// - 构造水印 SpContainer
147/// - 插入到 SpgrContainer 的"组形状本身"之后(z-order 最低的真正形状)
148/// - 更新 MainMaster / PPDrawing / DgContainer / SpgrContainer 的 recLen
149/// 3. 重新计算所有 persist 对象的 offset(因插入导致后移)
150/// 4. 更新 PersistDirectoryAtom 中存储的 offset
151///
152/// # 参数
153/// - `ppt_data`:PowerPoint Document stream(原地修改)
154/// - `config`:水印配置
155/// - `persist_entries`:旧的 persist entries(注入前的 offset)
156/// - `pd_offset_old`:旧的 PersistDirectoryAtom offset
157///
158/// # 返回
159/// - 成功:`(total_inserted, new_entries, pd_offset_new)`
160/// - `total_inserted`:总共插入的字节数
161/// - `new_entries`:更新后的 persist entries
162/// - `pd_offset_new`:新的 PersistDirectoryAtom offset
163///
164/// # 错误
165/// - [`Error::Ppt97`]:record 解析失败 / PPDrawing 结构异常 / shapeId 扫描失败
166#[allow(clippy::type_complexity)]
167pub fn inject_watermark(
168 ppt_data: &mut Vec<u8>,
169 config: &WatermarkConfig,
170 persist_entries: &[(u32, u32)],
171 pd_offset_old: usize,
172) -> Result<(usize, Vec<(u32, u32)>, usize)> {
173 // 返回类型 (total_inserted, new_entries, pd_offset_new) 较复杂,但语义清晰,
174 // 拆分为多个返回值会比封装成结构体更直观(调用方需要分别使用这三个值)。
175 let master_offsets = find_main_masters(ppt_data)?;
176 let mut insertions: Vec<(usize, usize)> = Vec::new();
177 let mut total_inserted = 0;
178
179 // 预计算水印文本的 UTF-16LE 字节序列,用于幂等性检测
180 let watermark_utf16: Vec<u8> = config
181 .text
182 .encode_utf16()
183 .flat_map(|c| c.to_le_bytes())
184 .collect();
185
186 // 扫描所有 MainMaster 中已有的最大 shapeId,避免 ID 冲突
187 // 参考 Apache POI XSLFSheet.allocateShapeId 的 BitSet + nextClearBit 模式
188 let mut next_shape_id: u32 = find_max_shape_id(ppt_data, &master_offsets)? + 1;
189 // 确保 shapeId 不低于常见起始值 0x1000(与 PowerPoint 内部约定一致)
190 if next_shape_id < 0x1000 {
191 next_shape_id = 0x1000;
192 }
193
194 // 从后往前处理 MainMaster,记录所有插入点
195 for master_offset in master_offsets.iter().rev() {
196 let master_offset = *master_offset;
197 if let Some(ppd_offset) = find_ppdrawing_in_master(ppt_data, master_offset)? {
198 // 幂等性检查:扫描 PPDrawing 中是否已存在水印文本
199 if has_watermark_text(ppt_data, ppd_offset, &watermark_utf16) {
200 // 已存在水印,跳过注入(幂等性保证)
201 continue;
202 }
203
204 let shape_id = next_shape_id;
205 next_shape_id += 1;
206 let watermark_sp = build_watermark_spcontainer(shape_id, config);
207
208 let (insert_pos, insert_len) =
209 inject_watermark_into_ppdrawing(ppt_data, ppd_offset, &watermark_sp)?;
210
211 // 更新 MainMaster 的 recLen
212 let (_, _, _, master_len) = parse_record_header(ppt_data, master_offset)?;
213 write_u32_le(ppt_data, master_offset + 4, master_len + insert_len as u32)?;
214
215 insertions.push((insert_pos, insert_len));
216 total_inserted += insert_len;
217 }
218 }
219
220 // 计算每个 persist 对象的新 offset
221 // 对于旧 offset O,新 offset = O + sum(len for all (pos, len) where pos < O)
222 // 因为插入点 pos < O 时,插入发生在 O 之前,O 需要后移
223 let mut new_entries = Vec::with_capacity(persist_entries.len());
224 for (pid, offset) in persist_entries {
225 let mut new_offset = *offset;
226 for (pos, len) in &insertions {
227 if *pos < new_offset as usize {
228 new_offset += *len as u32;
229 }
230 }
231 new_entries.push((*pid, new_offset));
232 }
233
234 // 计算 PersistDirectoryAtom 的新 offset
235 let mut pd_offset_new = pd_offset_old;
236 for (pos, len) in &insertions {
237 if *pos < pd_offset_new {
238 pd_offset_new += len;
239 }
240 }
241
242 // 更新 PersistDirectoryAtom 中存储的 offset
243 // PersistDirectoryAtom 结构:header(8) + entry(4) + rgPersistOffset(cPersist * 4)
244 let pd_data_start = pd_offset_new + 8;
245
246 // 更新每个 persist offset
247 for (i, (_, new_offset)) in new_entries.iter().enumerate() {
248 let offset_pos = pd_data_start + 4 + i * 4;
249 write_u32_le(ppt_data, offset_pos, *new_offset)?;
250 }
251
252 Ok((total_inserted, new_entries, pd_offset_new))
253}
254
255/// 在 PPDrawing 中注入水印 SpContainer。
256///
257/// PPDrawing 的完整结构(MS-ODRAW 规范):
258///
259/// ```text
260/// PPDrawing (container, 0x040C)
261/// └── DgContainer (container, 0xF002)
262/// └── SpgrContainer (container, 0xF003)
263/// ├── SpContainer (0xF004) — 组形状本身(FSP.inst=0, MSOSPT_Min)
264/// ├── SpContainer (0xF004) — 其他形状(z-order 从低到高)
265/// └── ...
266/// ```
267///
268/// **关键设计**:水印插入到 SpgrContainer 的"组形状本身"之后,成为 z-order
269/// 最低的真正子形状(背景层)。这正是 PowerPoint 自带"插入水印"功能的实现方式:
270///
271/// - 视觉上被其他内容覆盖,符合水印背景特性
272/// - 配合 FOPT 锁定属性(0x01C2=0x0D),在普通视图下不可选中/编辑
273///
274/// # 参数
275/// - `data`:PowerPoint Document stream(原地修改)
276/// - `ppd_offset`:PPDrawing record 的起始 offset
277/// - `watermark_sp`:构造好的水印 SpContainer 字节序列
278///
279/// # 返回
280/// - 成功:`(insert_pos, insert_len)` 插入位置与字节数
281///
282/// # 错误
283/// - [`Error::Ppt97`]:PPDrawing 中找不到 DgContainer / SpgrContainer / 组形状本身
284pub fn inject_watermark_into_ppdrawing(
285 data: &mut Vec<u8>,
286 ppd_offset: usize,
287 watermark_sp: &[u8],
288) -> Result<(usize, usize)> {
289 let (_, _, _, ppd_len) = parse_record_header(data, ppd_offset)?;
290
291 // 第 1 层:在 PPDrawing 中找到 DgContainer (0xF002)
292 let ppd_end = ppd_offset + 8 + ppd_len as usize;
293 let mut pos = ppd_offset + 8;
294 let mut dg_offset = None;
295 while pos + 8 <= ppd_end {
296 let (ver, _, rec_type, rec_len) = parse_record_header(data, pos)?;
297 let is_container = ver == 0xF;
298 let total_len = 8 + rec_len as usize;
299
300 if is_container && rec_type == RT_DG_CONTAINER {
301 dg_offset = Some(pos);
302 break;
303 }
304
305 pos += total_len;
306 if !is_container && rec_len == 0 {
307 break;
308 }
309 }
310
311 let dg_offset = dg_offset.ok_or_else(|| {
312 Error::ppt97("inject_watermark: DgContainer (0xF002) not found in PPDrawing")
313 })?;
314 let (_, _, _, dg_len) = parse_record_header(data, dg_offset)?;
315
316 // 第 2 层:在 DgContainer 中找到 SpgrContainer (0xF003)
317 let dg_end = dg_offset + 8 + dg_len as usize;
318 let mut pos = dg_offset + 8;
319 let mut spgr_offset = None;
320 while pos + 8 <= dg_end {
321 let (ver, _, rec_type, rec_len) = parse_record_header(data, pos)?;
322 let is_container = ver == 0xF;
323 let total_len = 8 + rec_len as usize;
324
325 if is_container && rec_type == RT_SPGR_CONTAINER {
326 spgr_offset = Some(pos);
327 break;
328 }
329
330 pos += total_len;
331 if !is_container && rec_len == 0 {
332 break;
333 }
334 }
335
336 let spgr_offset = spgr_offset.ok_or_else(|| {
337 Error::ppt97("inject_watermark: SpgrContainer (0xF003) not found in DgContainer")
338 })?;
339 let (_, _, _, spgr_len) = parse_record_header(data, spgr_offset)?;
340 let spgr_end = spgr_offset + 8 + spgr_len as usize;
341
342 // 关键设计:把水印插入到 SpgrContainer 的"组形状本身"之后,
343 // 成为 z-order 最低的真正子形状(背景层)。
344 //
345 // MS-ODRAW 规范 2.2.17:SpgrContainer 的第一个 SpContainer 必须是"组形状本身"
346 // (FSP.inst=0, MSOSPT_Min=0),描述整个组的属性;后续 SpContainer 才是
347 // 真正的子形状,按 z-order 从低到高排列。
348 //
349 // 之前把水印插到 SpgrContainer 末尾(z-order 最高),导致:
350 // 1. 水印在最上层,遮挡其他内容
351 // 2. 水印易被选中编辑,违背水印作为背景元素的特性
352 // 现在插到"组形状本身"之后,水印成为 z-order 最低的真正形状:
353 // - 视觉上被其他内容覆盖,符合水印背景特性
354 // - 配合 FOPT 锁定属性(0x01C2=0x0D),在普通视图下不可选中/编辑
355 // - 这正是 PowerPoint 自带"插入水印"功能的实现方式
356 let mut pos = spgr_offset + 8;
357 let mut first_sp_end = None;
358 while pos + 8 <= spgr_end {
359 let (ver, _, rec_type, rec_len) = parse_record_header(data, pos)?;
360 let is_container = ver == 0xF;
361 let total_len = 8 + rec_len as usize;
362
363 // 找到第一个 SpContainer(组形状本身),记录其结束位置
364 if is_container && rec_type == RT_SP_CONTAINER {
365 first_sp_end = Some(pos + total_len);
366 break;
367 }
368
369 pos += total_len;
370 if !is_container && rec_len == 0 {
371 break;
372 }
373 }
374
375 // 插入位置:第一个 SpContainer 之后(z-order 最低的真正形状位置)
376 // 如果找不到第一个 SpContainer(异常情况),退回到末尾插入以保证兼容
377 let insert_pos = first_sp_end.unwrap_or(spgr_end);
378 data.splice(insert_pos..insert_pos, watermark_sp.iter().copied());
379
380 let insert_len = watermark_sp.len();
381
382 // 更新三层 container 的 recLen(从内到外):
383 // 1. SpgrContainer 的 recLen
384 write_u32_le(data, spgr_offset + 4, spgr_len + insert_len as u32)?;
385 // 2. DgContainer 的 recLen
386 write_u32_le(data, dg_offset + 4, dg_len + insert_len as u32)?;
387 // 3. PPDrawing 的 recLen
388 write_u32_le(data, ppd_offset + 4, ppd_len + insert_len as u32)?;
389
390 Ok((insert_pos, insert_len))
391}
392
393/// 构造水印 SpContainer。
394///
395/// 结构(MS-ODRAW 规范):
396///
397/// ```text
398/// SpContainer (0xF004, container)
399/// ├── FSP (0xF00A): ver=2, inst=0xCA (MSOSPT_TextBox=202)
400/// │ ├── shapeId (u32): 唯一形状 ID
401/// │ └── flags (u32): fHaveAnchor + fHaveSpt
402/// ├── FOPT (0xF00B): ver=3, inst=属性个数
403/// │ ├── 0x00BD (rotation): 旋转角度(16.16 固定点数)
404/// │ ├── 0x0180 (fillType): 0 = No fill
405/// │ ├── 0x01BF (Fill Style Boolean): fNoFill + fFillOK + fNoFillHitTest
406/// │ ├── 0x01C1 (Line Style Boolean): fNoLine + fLineOK + fNoLineDrawDash
407/// │ └── 0x01C2 (Protection Boolean): 锁定分组+文本编辑+选择
408/// ├── ClientAnchor (0xF010): ver=0, 全屏覆盖 (0,0 → 5760,4320)
409/// └── ClientTextbox (0xF00D, container): 文本框
410/// ├── TextHeaderAtom (0x0F9F): txType=4 (not body)
411/// ├── TextCharsAtom (0x0FA0): 水印文本(UTF-16LE)
412/// └── StyleTextPropAtom (0x0FA1): 文本样式(字号、颜色)
413/// ```
414///
415/// # FOPT 保护位详解
416///
417/// `0x01C2` (Protection Boolean Properties) = `0x0000000D`:
418/// - bit 0 (fLockAgainstGrouping):防止选择、分组、移动
419/// - bit 2 (fLockAgainstTextEdit):防止文本编辑
420/// - bit 3 (fLockAgainstSelection):防止选择
421///
422/// # 参数
423/// - `shape_id`:水印形状的唯一 ID(由调用方确保不冲突)
424/// - `config`:水印配置(文本、字号、颜色、旋转角度)
425///
426/// # 返回
427/// 完整的 SpContainer 字节序列(含 record header)。
428pub fn build_watermark_spcontainer(shape_id: u32, config: &WatermarkConfig) -> Vec<u8> {
429 let mut children = Vec::new();
430
431 // 1. FSP (0xF00A): 形状属性
432 // ver=0x2, inst=0xCA (MSOSPT_TextBox=202), type=0xF00A, len=8
433 // shapeId (4 bytes) + flags (4 bytes)
434 // MSOSPT_TextBox = 202 = 0xCA(MS-ODRAW 规范 2.4.14 MSOSPT 枚举)
435 let mut fsp = Vec::new();
436 let ver_inst: u16 = (0xCA << 4) | 0x2; // inst=0xCA (textBox=202), ver=0x2
437 fsp.extend_from_slice(&ver_inst.to_le_bytes());
438 fsp.extend_from_slice(&RT_FSP.to_le_bytes());
439 fsp.extend_from_slice(&8u32.to_le_bytes()); // len=8
440 fsp.extend_from_slice(&shape_id.to_le_bytes()); // shapeId
441 // flags (MS-ODRAW 2.3.1.1 FSP):
442 // bit 7 (0x80): fHaveAnchor — 1 = 形状有 anchor(必须有,否则 PowerPoint 忽略 ClientAnchor)
443 // bit 9 (0x200): fHaveSpt — 1 = 形状有 shape type
444 // 正确值:fHaveAnchor + fHaveSpt = 0x80 + 0x200 = 0x280
445 fsp.extend_from_slice(&0x00000280u32.to_le_bytes()); // flags: fHaveAnchor + fHaveSpt
446 children.extend_from_slice(&fsp);
447
448 // 2. FOPT (0xF00B): 形状属性(无填充、无线条,旋转,锁定不可编辑)
449 // FOPT 属性按 property ID 升序排列
450 let mut fopt_props = Vec::new();
451 // 0x00BD (rotation): 旋转角度(固定点数 16.16 格式,1度 = 65536)
452 let rotation_fixed: u32 = ((config.rotation_deg as i64) * 65536) as u32;
453 fopt_props.extend_from_slice(&0x00BDu16.to_le_bytes());
454 fopt_props.extend_from_slice(&rotation_fixed.to_le_bytes());
455 // 0x0180 (fillType): 0 = No fill(明确指定无填充,避免 PowerPoint 使用默认白色背景)
456 fopt_props.extend_from_slice(&0x0180u16.to_le_bytes());
457 fopt_props.extend_from_slice(&0x00000000u32.to_le_bytes());
458 // 0x01BF (Fill Style Boolean Properties): fNoFill + fFillOK + fNoFillHitTest = 0x00000043
459 fopt_props.extend_from_slice(&0x01BFu16.to_le_bytes());
460 fopt_props.extend_from_slice(&0x00000043u32.to_le_bytes());
461 // 0x01C1 (Line Style Boolean Properties): fNoLine + fLineOK + fNoLineDrawDash = 0x00000043
462 fopt_props.extend_from_slice(&0x01C1u16.to_le_bytes());
463 fopt_props.extend_from_slice(&0x00000043u32.to_le_bytes());
464 // 0x01C2 (Protection Boolean Properties): 锁定分组+文本编辑+选择 = 0x0000000D
465 fopt_props.extend_from_slice(&0x01C2u16.to_le_bytes());
466 fopt_props.extend_from_slice(&0x0000000Du32.to_le_bytes());
467
468 let num_props = fopt_props.len() / 6;
469 let mut fopt = Vec::new();
470 let ver_inst: u16 = ((num_props as u16) << 4) | 0x3; // inst=num_props, ver=0x3
471 fopt.extend_from_slice(&ver_inst.to_le_bytes());
472 fopt.extend_from_slice(&RT_FOPT.to_le_bytes());
473 fopt.extend_from_slice(&(fopt_props.len() as u32).to_le_bytes());
474 fopt.extend_from_slice(&fopt_props);
475 children.extend_from_slice(&fopt);
476
477 // 3. ClientAnchor (0xF010): 形状位置
478 // ver=0, inst=0, len=8(SmallRectStruct: 4 个 int16: top, left, right, bottom)
479 // 单位是 master units(1/576 英寸),slide 标准尺寸 5760 x 4320(10 x 7.5 英寸)
480 // 水印覆盖整个 slide 区域,配合旋转和大字号,让水印文字斜向铺满整个幻灯片
481 let mut anchor = Vec::new();
482 let ver_inst: u16 = 0; // inst=0, ver=0
483 anchor.extend_from_slice(&ver_inst.to_le_bytes());
484 anchor.extend_from_slice(&RT_CLIENT_ANCHOR.to_le_bytes());
485 anchor.extend_from_slice(&8u32.to_le_bytes()); // len=8
486 anchor.extend_from_slice(&0i16.to_le_bytes()); // top = 0
487 anchor.extend_from_slice(&0i16.to_le_bytes()); // left = 0
488 anchor.extend_from_slice(&5760i16.to_le_bytes()); // right = 5760(10 英寸)
489 anchor.extend_from_slice(&4320i16.to_le_bytes()); // bottom = 4320(7.5 英寸)
490 children.extend_from_slice(&anchor);
491
492 // 4. ClientTextbox (0xF00D, container): 文本框
493 let mut textbox_children = Vec::new();
494
495 // 4.1 TextHeaderAtom (0x0F9F): txType=4 (not body)
496 let mut text_header = Vec::new();
497 let ver_inst: u16 = 0;
498 text_header.extend_from_slice(&ver_inst.to_le_bytes());
499 text_header.extend_from_slice(&RT_TEXT_HEADER_ATOM.to_le_bytes());
500 text_header.extend_from_slice(&4u32.to_le_bytes()); // len=4
501 text_header.extend_from_slice(&4u32.to_le_bytes()); // txType=4 (not body)
502 textbox_children.extend_from_slice(&text_header);
503
504 // 4.2 TextCharsAtom (0x0FA0): 水印文本(UTF-16LE)
505 let text_utf16: Vec<u8> = config
506 .text
507 .encode_utf16()
508 .flat_map(|c| c.to_le_bytes())
509 .collect();
510 let text_char_count = config.text.encode_utf16().count() as u32;
511 let mut text_chars = Vec::new();
512 let ver_inst: u16 = 0;
513 text_chars.extend_from_slice(&ver_inst.to_le_bytes());
514 text_chars.extend_from_slice(&RT_TEXT_CHARS_ATOM.to_le_bytes());
515 text_chars.extend_from_slice(&(text_utf16.len() as u32).to_le_bytes());
516 text_chars.extend_from_slice(&text_utf16);
517 textbox_children.extend_from_slice(&text_chars);
518
519 // 4.3 StyleTextPropAtom (0x0FA1): 文本样式(字体大小、颜色)
520 // StyleTextPropAtom 结构(MS-PPT 2.9.17 规范):
521 // lfo (4 bytes): 段落格式 run 数量
522 // rgTextPFRun[lfo]: 段落格式 run
523 // rgTextCFRun[lfo]: 字符格式 run
524 // TextPFRun: count(4) + indentLevel(2) + pfFlags(4) = 10 bytes
525 // TextCFRun: count(4) + cfFlags(4) + [sz(2) if fSize] + [color(4) if fColor]
526 // cfFlags 位定义:
527 // bit 6 (0x40): fSize — 设置字体大小
528 // bit 7 (0x80): fColor — 设置文字颜色
529 let mut style_data = Vec::new();
530 style_data.extend_from_slice(&1u32.to_le_bytes()); // lfo = 1
531 style_data.extend_from_slice(&text_char_count.to_le_bytes()); // count
532 style_data.extend_from_slice(&0u16.to_le_bytes()); // indentLevel = 0
533 style_data.extend_from_slice(&0u32.to_le_bytes()); // pfFlags = 0
534 style_data.extend_from_slice(&text_char_count.to_le_bytes()); // count
535 style_data.extend_from_slice(&0x000000C0u32.to_le_bytes()); // cfFlags: fSize + fColor
536 // 字号(单位是 1/100 pt)
537 let font_size_value = config.font_size_pt as u32 * 100;
538 style_data.extend_from_slice(&(font_size_value as u16).to_le_bytes()); // sz
539 // color: ColorIndexStruct (red, green, blue, index)
540 // 半透明效果:使用中灰色模拟半透明(PPT 97-2003 文本透明度需要复杂扩展属性)
541 style_data.extend_from_slice(&config.color_rgb.0.to_le_bytes()); // red
542 style_data.extend_from_slice(&config.color_rgb.1.to_le_bytes()); // green
543 style_data.extend_from_slice(&config.color_rgb.2.to_le_bytes()); // blue
544 style_data.extend_from_slice(&0u8.to_le_bytes()); // index = 0 (RGB)
545
546 let mut style_atom = Vec::new();
547 let ver_inst: u16 = 0;
548 style_atom.extend_from_slice(&ver_inst.to_le_bytes());
549 style_atom.extend_from_slice(&RT_STYLE_TEXT_PROP_ATOM.to_le_bytes());
550 style_atom.extend_from_slice(&(style_data.len() as u32).to_le_bytes());
551 style_atom.extend_from_slice(&style_data);
552 textbox_children.extend_from_slice(&style_atom);
553
554 // 组装 ClientTextbox
555 let mut client_textbox = Vec::new();
556 let ver_inst: u16 = 0xF; // container
557 client_textbox.extend_from_slice(&ver_inst.to_le_bytes());
558 client_textbox.extend_from_slice(&RT_CLIENT_TEXTBOX.to_le_bytes());
559 client_textbox.extend_from_slice(&(textbox_children.len() as u32).to_le_bytes());
560 client_textbox.extend_from_slice(&textbox_children);
561 children.extend_from_slice(&client_textbox);
562
563 // 组装 SpContainer
564 let mut sp_container = Vec::new();
565 let ver_inst: u16 = 0xF; // container
566 sp_container.extend_from_slice(&ver_inst.to_le_bytes());
567 sp_container.extend_from_slice(&RT_SP_CONTAINER.to_le_bytes());
568 sp_container.extend_from_slice(&(children.len() as u32).to_le_bytes());
569 sp_container.extend_from_slice(&children);
570
571 sp_container
572}
573
574/// 扫描所有 MainMaster 的 PPDrawing 中已有的最大 shapeId。
575///
576/// 参考 Apache POI `XSLFSheet.allocateShapeId()` 的 BitSet 模式:
577/// 遍历 SpgrContainer 中所有 FSP record (0xF00A),读取其 shapeId 字段,
578/// 返回最大值。新水印 shape 从 max+1 开始分配,避免 ID 冲突。
579///
580/// # 参数
581/// - `data`:PowerPoint Document stream
582/// - `master_offsets`:所有 MainMaster 的 offset 列表
583///
584/// # 返回
585/// 所有 MainMaster 中最大的 shapeId(若无形状返回 0)。
586pub fn find_max_shape_id(data: &[u8], master_offsets: &[usize]) -> Result<u32> {
587 let mut max_id: u32 = 0;
588 for &master_offset in master_offsets {
589 if let Some(ppd_offset) = find_ppdrawing_in_master(data, master_offset)? {
590 max_id = max_id.max(scan_shape_ids_in_ppdrawing(data, ppd_offset)?);
591 }
592 }
593 Ok(max_id)
594}
595
596/// 递归扫描 PPDrawing 子树中所有 FSP record 的 shapeId,返回最大值。
597///
598/// PPDrawing → DgContainer → SpgrContainer → [SpContainer | SpgrContainer]...
599/// 每个 SpContainer 内含一个 FSP (0xF00A),其前 4 字节数据是 shapeId。
600/// SpgrContainer 可嵌套,需递归遍历。
601fn scan_shape_ids_in_ppdrawing(data: &[u8], ppd_offset: usize) -> Result<u32> {
602 let (_, _, _, ppd_len) = parse_record_header(data, ppd_offset)?;
603 let ppd_end = ppd_offset + 8 + ppd_len as usize;
604 let mut max_id: u32 = 0;
605 let mut pos = ppd_offset + 8;
606
607 while pos + 8 <= ppd_end {
608 let (ver, _, rec_type, rec_len) = parse_record_header(data, pos)?;
609 let is_container = ver == 0xF;
610 let total_len = 8 + rec_len as usize;
611
612 if is_container {
613 // 递归扫描 container 子节点
614 let child_max = scan_shape_ids_in_ppdrawing(data, pos)?;
615 max_id = max_id.max(child_max);
616 } else if rec_type == RT_FSP && rec_len >= 8 {
617 // FSP record: shapeId 在 header 之后的第 4 字节
618 let shape_id = read_u32_le(data, pos + 8)?;
619 if shape_id > max_id {
620 max_id = shape_id;
621 }
622 }
623
624 pos += total_len;
625 if !is_container && rec_len == 0 {
626 break;
627 }
628 }
629
630 Ok(max_id)
631}
632
633/// 检查 PPDrawing 中是否已存在指定水印文本(幂等性检查)。
634///
635/// 参考 Aspose.Slides 按 shape name/text 查重的幂等性模式:
636/// 将水印文本编码为 UTF-16LE(与 TextCharsAtom 0x0FA0 的编码一致),
637/// 在 PPDrawing 的数据范围内搜索该字节序列。
638///
639/// # 参数
640/// - `data`:PowerPoint Document stream
641/// - `ppd_offset`:PPDrawing record 的起始 offset
642/// - `watermark_utf16`:水印文本的 UTF-16LE 字节序列
643///
644/// # 返回
645/// - `true`:PPDrawing 中已存在水印文本(应跳过注入)
646/// - `false`:未找到水印文本(可以注入)
647pub fn has_watermark_text(data: &[u8], ppd_offset: usize, watermark_utf16: &[u8]) -> bool {
648 let (_, _, _, ppd_len) = match parse_record_header(data, ppd_offset) {
649 Ok(v) => v,
650 Err(_) => return false,
651 };
652 let ppd_end = ppd_offset + 8 + ppd_len as usize;
653 if ppd_end > data.len() {
654 return false;
655 }
656 // 在 PPDrawing 的完整数据范围内搜索水印文本的 UTF-16LE 字节序列
657 data[ppd_offset..ppd_end]
658 .windows(watermark_utf16.len())
659 .any(|w| w == watermark_utf16)
660}
661
662/// 抑制未使用常量警告(RT_MAIN_MASTER 在 inject_watermark 中通过 find_main_masters 间接使用)。
663#[allow(dead_code)]
664const _UNUSED_RT_MAIN_MASTER: u16 = RT_MAIN_MASTER;