pptx_rs/shape/picture.rs
1//! `Picture`:图片(`<p:pic>`)。
2//!
3//! 图片是高阶对象中**唯一**自带二进制 blob 的类型——其它形状都是纯 XML。
4//! 保存时 [`Picture::blob`] 会被 `presentation::to_opc_package` 注入为
5//! `/ppt/media/imageN.<ext>` part,并自动建立 `r:id` 关系。
6//!
7//! # 与 python-pptx 的对应
8//!
9//! - `pptx.shapes.picture.Picture` ←→ [`Picture`];
10//! - `Slide.shapes.add_picture(path, left, top, width, height)` 返回 [`Picture`]。
11//!
12//! # 内容类型推导
13//!
14//! [`content_type_for`] 按扩展名查表;未知扩展名回退到 `application/octet-stream`。
15//! 该 Content-Type 会同步写入 `[Content_Types].xml`。
16//!
17//! # Base64 诊断
18//!
19//! [`Picture::base64`] 把当前图片 base64 编码——主要服务于在线图片预览 / 调试,
20//! 不参与持久化。
21
22use std::path::Path;
23
24use base64::Engine;
25
26use crate::oxml::shape::Pic as OxmlPic;
27use crate::oxml::sppr::ShapeProperties;
28use crate::shape::base::Shape;
29use crate::units::Emu;
30
31/// 一张图片。
32#[derive(Clone, Debug, Default)]
33pub struct Picture {
34 /// 内部 oxml 句柄。
35 pub(crate) pic: OxmlPic,
36 /// 图片字节。保存时写入 zip。
37 pub blob: Option<Vec<u8>>,
38 /// 扩展名(含 `.`,如 `.png`),用于推导 Content-Type。
39 pub ext: String,
40}
41
42impl Picture {
43 /// 从本地文件创建。
44 ///
45 /// # 错误
46 /// - [`crate::Error::Io`]:文件读取失败。
47 pub fn from_path(path: impl AsRef<Path>) -> crate::Result<Self> {
48 let path = path.as_ref();
49 let blob = std::fs::read(path)?;
50 let ext = path
51 .extension()
52 .and_then(|e| e.to_str())
53 .map(|e| format!(".{}", e.to_lowercase()))
54 .unwrap_or_else(|| ".png".to_string());
55 Ok(Picture {
56 pic: OxmlPic::default(),
57 blob: Some(blob),
58 ext,
59 })
60 }
61
62 /// 从内存创建。
63 pub fn from_bytes(bytes: impl Into<Vec<u8>>, ext: impl Into<String>) -> Self {
64 let ext_s = ext.into();
65 let ext_norm = if ext_s.starts_with('.') {
66 ext_s
67 } else {
68 format!(".{}", ext_s)
69 };
70 Picture {
71 pic: OxmlPic::default(),
72 blob: Some(bytes.into()),
73 ext: ext_norm,
74 }
75 }
76
77 /// 从 oxml [`OxmlPic`] 构造(不携带 blob,用于读取路径)。
78 pub fn from_pic(pic: OxmlPic) -> Self {
79 Picture {
80 pic,
81 blob: None,
82 ext: ".png".to_string(),
83 }
84 }
85
86 /// 取出 oxml 引用。
87 pub fn pic(&self) -> &OxmlPic {
88 &self.pic
89 }
90 /// 取出 oxml 可变引用。
91 pub fn pic_mut(&mut self) -> &mut OxmlPic {
92 &mut self.pic
93 }
94
95 /// 形状属性不可变引用。
96 pub fn properties(&self) -> &ShapeProperties {
97 &self.pic.properties
98 }
99 /// 形状属性可变引用。
100 pub fn properties_mut(&mut self) -> &mut ShapeProperties {
101 &mut self.pic.properties
102 }
103
104 /// 设置图片填充模式(拉伸/平铺/无)。
105 ///
106 /// 对应 OOXML `<a:stretch>` / `<a:tile>` 元素。
107 /// 默认为 `BlipFillMode::Stretch`(拉伸铺满)。
108 pub fn set_fill_mode(&mut self, mode: crate::oxml::sppr::BlipFillMode) {
109 self.pic.fill_mode = mode;
110 }
111
112 /// 设置为拉伸填充模式(便捷方法)。
113 pub fn set_stretch(&mut self) {
114 self.pic.fill_mode = crate::oxml::sppr::BlipFillMode::Stretch;
115 }
116
117 /// 设置为平铺填充模式。
118 ///
119 /// # 参数
120 /// - `tx` / `ty`:水平/垂直偏移(EMU),`None` 表示不设置;
121 /// - `sx` / `sy`:水平/垂直缩放(千分比,100000 = 100%),`None` 表示不设置;
122 /// - `flip`:翻转模式(`"none"` / `"x"` / `"y"` / `"xy"`),`None` 表示不设置;
123 /// - `algn`:对齐方式(`"tl"` / `"ctr"` / `"br"` 等),`None` 表示不设置。
124 pub fn set_tile(
125 &mut self,
126 tx: Option<i64>,
127 ty: Option<i64>,
128 sx: Option<i32>,
129 sy: Option<i32>,
130 flip: Option<&str>,
131 algn: Option<&str>,
132 ) {
133 self.pic.fill_mode = crate::oxml::sppr::BlipFillMode::Tile {
134 tx,
135 ty,
136 sx,
137 sy,
138 flip: flip.map(|s| s.to_string()),
139 algn: algn.map(|s| s.to_string()),
140 };
141 }
142
143 /// 取当前填充模式。
144 pub fn fill_mode(&self) -> &crate::oxml::sppr::BlipFillMode {
145 &self.pic.fill_mode
146 }
147
148 /// 裁剪图片(`picture.crop_left/top/right/bottom` 的复合设置)。
149 ///
150 /// # 参数
151 /// - `left` / `top` / `right` / `bottom`:**千分比**(取值 `0..=100000`),
152 /// 表示从原图四边裁掉的占比。如 `left=25000` 即裁掉左 25%。
153 /// 对应 python-pptx `picture.crop_left = 0.25`(库内做一次 ×100000 转换)。
154 ///
155 /// # 示例
156 /// ```ignore
157 /// // 裁掉左 10%、右 10%,保持上下
158 /// pic.crop(10_000, 0, 10_000, 0);
159 /// ```
160 pub fn crop(&mut self, left: i32, top: i32, right: i32, bottom: i32) {
161 self.pic.src_rect = Some((left, top, right, bottom));
162 }
163
164 /// 设置裁剪(TODO-044 高阶 API,`crop` 的 python-pptx 风格别名)。
165 ///
166 /// 与 [`crop`](Self::crop) 完全等价,仅方法名对齐 python-pptx
167 /// `picture.set_crop(left, top, right, bottom)` 风格。
168 pub fn set_crop(&mut self, left: i32, top: i32, right: i32, bottom: i32) {
169 self.crop(left, top, right, bottom);
170 }
171
172 /// 清除裁剪(恢复原图)。
173 pub fn clear_crop(&mut self) {
174 self.pic.src_rect = None;
175 }
176
177 /// 取当前裁剪矩形(千分比),未裁剪返回 `None`。
178 pub fn crop_rect(&self) -> Option<(i32, i32, i32, i32)> {
179 self.pic.src_rect
180 }
181
182 /// 取左侧裁剪量(千分比,TODO-044 高阶 API)。
183 ///
184 /// 未裁剪时返回 0。
185 pub fn crop_left(&self) -> i32 {
186 self.pic.src_rect.map(|(l, _, _, _)| l).unwrap_or(0)
187 }
188 /// 取顶部裁剪量(千分比,TODO-044 高阶 API)。
189 pub fn crop_top(&self) -> i32 {
190 self.pic.src_rect.map(|(_, t, _, _)| t).unwrap_or(0)
191 }
192 /// 取右侧裁剪量(千分比,TODO-044 高阶 API)。
193 pub fn crop_right(&self) -> i32 {
194 self.pic.src_rect.map(|(_, _, r, _)| r).unwrap_or(0)
195 }
196 /// 取底部裁剪量(千分比,TODO-044 高阶 API)。
197 pub fn crop_bottom(&self) -> i32 {
198 self.pic.src_rect.map(|(_, _, _, b)| b).unwrap_or(0)
199 }
200
201 /// 设置左侧裁剪量(千分比,TODO-044 高阶 API),保留其它三边。
202 pub fn set_crop_left(&mut self, left: i32) {
203 let mut r = self.pic.src_rect.unwrap_or((0, 0, 0, 0));
204 r.0 = left;
205 self.pic.src_rect = Some(r);
206 }
207 /// 设置顶部裁剪量(千分比,TODO-044 高阶 API),保留其它三边。
208 pub fn set_crop_top(&mut self, top: i32) {
209 let mut r = self.pic.src_rect.unwrap_or((0, 0, 0, 0));
210 r.1 = top;
211 self.pic.src_rect = Some(r);
212 }
213 /// 设置右侧裁剪量(千分比,TODO-044 高阶 API),保留其它三边。
214 pub fn set_crop_right(&mut self, right: i32) {
215 let mut r = self.pic.src_rect.unwrap_or((0, 0, 0, 0));
216 r.2 = right;
217 self.pic.src_rect = Some(r);
218 }
219 /// 设置底部裁剪量(千分比,TODO-044 高阶 API),保留其它三边。
220 pub fn set_crop_bottom(&mut self, bottom: i32) {
221 let mut r = self.pic.src_rect.unwrap_or((0, 0, 0, 0));
222 r.3 = bottom;
223 self.pic.src_rect = Some(r);
224 }
225
226 // --------------------- 占位符 API(TODO-007 高阶) ---------------------
227 //
228 // 对标 python-pptx 中"图片占位符"——版式中的 `<p:ph type="pic"/>`。
229 // 调用方通常通过 `slide.add_picture_to_placeholder(idx, path)` 创建带占位符
230 // 标记的图片,PowerPoint 会按版式中的占位符位置/尺寸自动布局。
231
232 /// 标记本图片为占位符填充(`<p:ph type="pic" idx="..."/>`)。
233 ///
234 /// # 参数
235 /// - `ph_idx`:占位符 idx(对应版式中 `<p:ph idx="N"/>` 的 N)。
236 /// - `ph_type`:占位符类型字符串(通常 `"pic"`,传 `None` 则不写出 `type` 属性)。
237 ///
238 /// # 示例
239 /// ```no_run
240 /// use pptx_rs::Presentation;
241 /// use pptx_rs::units::Inches;
242 ///
243 /// let mut p = Presentation::new().unwrap();
244 /// let counter = p.id_counter();
245 /// let s = p.slides_mut().add_slide(counter).unwrap();
246 /// // 假设版式有 idx=10 的图片占位符
247 /// let mut pic = s.shapes_mut().add_picture("logo.png", Inches(1.0), Inches(1.0), Inches(4.0), Inches(3.0)).unwrap();
248 /// pic.set_placeholder(10, Some("pic"));
249 /// ```
250 pub fn set_placeholder(&mut self, ph_idx: u32, ph_type: Option<&str>) {
251 self.pic.is_placeholder = true;
252 self.pic.ph_idx = Some(ph_idx);
253 self.pic.ph_type = ph_type.map(|s| s.to_string());
254 }
255
256 /// 清除占位符标记(让本图片回到"自由图片"状态)。
257 pub fn clear_placeholder(&mut self) {
258 self.pic.is_placeholder = false;
259 self.pic.ph_idx = None;
260 self.pic.ph_type = None;
261 }
262
263 /// 是否为占位符填充。
264 pub fn is_placeholder(&self) -> bool {
265 self.pic.is_placeholder
266 }
267
268 /// 占位符 idx(仅当 [`is_placeholder`](Self::is_placeholder) 为 true 时有意义)。
269 pub fn ph_idx(&self) -> Option<u32> {
270 self.pic.ph_idx
271 }
272
273 /// 占位符类型字符串(如 `"pic"`)。
274 pub fn ph_type(&self) -> Option<&str> {
275 self.pic.ph_type.as_deref()
276 }
277
278 /// base64 输出当前图片(便于诊断 / 浏览器内嵌)。
279 pub fn base64(&self) -> Option<String> {
280 self.blob
281 .as_ref()
282 .map(|b| base64::engine::general_purpose::STANDARD.encode(b))
283 }
284
285 // --------------------- 媒体 API(TODO-033 高阶) ---------------------
286 //
287 // 对标 python-pptx 中"媒体形状"——OOXML 中视频/音频形状实际上是带
288 // `<a:videoFile r:link="..."/>` / `<a:audioFile r:link="..."/>` 的 `<p:pic>`,
289 // 用海报帧图片(`<a:blip r:embed="..."/>`)作为视觉占位。
290 // 调用方通常通过 `slide.shapes_mut().add_video(...)` / `add_audio(...)` 创建,
291 // 这里提供低层 setter 供高级用户手动构造媒体形状。
292
293 /// 把本图片标记为**视频**形状(`<a:videoFile r:link="..."/>`)。
294 ///
295 /// 调用后 `Pic::write_xml` 会在 `<p:nvPr>` 内写出 `<a:videoFile r:link="..."/>`,
296 /// PowerPoint 渲染时会把该 `<p:pic>` 当作视频形状处理(双击播放)。
297 ///
298 /// # 参数
299 /// - `rid`:媒体 part 的关系 id(指向 `/ppt/media/mediaN.mp4`)。
300 /// 该 rid **必须**与 `slideN.xml.rels` 中的 `<Relationship Type=".../video"/>` 一致,
301 /// 由 [`crate::slide::ShapesMut::add_video`] 自动分配,或由调用方手动维护。
302 ///
303 /// # 与海报帧 rid 的区别
304 /// - 海报帧图片的 `r:embed` 关系通过 `pic_mut().rid` 设置(指向 imageN.png);
305 /// - `set_video` 设置的是**视频文件**的 `r:link` 关系(指向 mediaN.mp4)。
306 /// 二者独立,互不影响。
307 ///
308 /// # 示例
309 /// ```no_run
310 /// # use pptx_rs::Presentation;
311 /// # use pptx_rs::units::Inches;
312 /// # let mut p = Presentation::new().unwrap();
313 /// # let counter = p.id_counter();
314 /// # let s = p.slides_mut().add_slide(counter).unwrap();
315 /// let mut pic = s.shapes_mut().add_picture("poster.png",
316 /// Inches(1.0), Inches(1.0), Inches(4.0), Inches(3.0)).unwrap();
317 /// pic.set_video("rIdVideo1");
318 /// ```
319 pub fn set_video(&mut self, rid: impl Into<String>) {
320 self.pic.media = Some(crate::oxml::shape::MediaKind::Video { rid: rid.into() });
321 }
322
323 /// 把本图片标记为**音频**形状(`<a:audioFile r:link="..."/>`)。
324 ///
325 /// 与 [`Self::set_video`] 对称,仅媒体类型不同。
326 ///
327 /// # 参数
328 /// - `rid`:媒体 part 的关系 id(指向 `/ppt/media/mediaN.mp3`)。
329 pub fn set_audio(&mut self, rid: impl Into<String>) {
330 self.pic.media = Some(crate::oxml::shape::MediaKind::Audio { rid: rid.into() });
331 }
332
333 /// 取当前媒体类型(视频/音频/None)。
334 ///
335 /// 返回 `None` 表示普通图片;`Some(MediaKind::Video { rid })` / `Some(MediaKind::Audio { rid })`
336 /// 表示已通过 [`Self::set_video`] / [`Self::set_audio`] 标记为媒体形状。
337 pub fn media_kind(&self) -> Option<&crate::oxml::shape::MediaKind> {
338 self.pic.media.as_ref()
339 }
340
341 /// 清除媒体标记(让本图片回到普通图片状态)。
342 pub fn clear_media(&mut self) {
343 self.pic.media = None;
344 }
345}
346
347impl Shape for Picture {
348 fn id(&self) -> u32 {
349 self.pic.id
350 }
351 fn set_id(&mut self, id: u32) {
352 self.pic.id = id;
353 }
354 fn name(&self) -> &str {
355 &self.pic.name
356 }
357 fn set_name(&mut self, name: String) {
358 self.pic.name = name;
359 }
360 fn shape_type(&self) -> &'static str {
361 "picture"
362 }
363
364 fn left(&self) -> Emu {
365 self.pic.properties.xfrm.off_x.unwrap_or_default()
366 }
367 fn set_left(&mut self, emu: Emu) {
368 self.pic.properties.xfrm.off_x = Some(emu);
369 }
370 fn top(&self) -> Emu {
371 self.pic.properties.xfrm.off_y.unwrap_or_default()
372 }
373 fn set_top(&mut self, emu: Emu) {
374 self.pic.properties.xfrm.off_y = Some(emu);
375 }
376 fn width(&self) -> Emu {
377 self.pic.properties.xfrm.ext_cx.unwrap_or_default()
378 }
379 fn set_width(&mut self, emu: Emu) {
380 self.pic.properties.xfrm.ext_cx = Some(emu);
381 }
382 fn height(&self) -> Emu {
383 self.pic.properties.xfrm.ext_cy.unwrap_or_default()
384 }
385 fn set_height(&mut self, emu: Emu) {
386 self.pic.properties.xfrm.ext_cy = Some(emu);
387 }
388
389 fn rotation(&self) -> f64 {
390 self.pic.properties.rot_deg.unwrap_or(0.0)
391 }
392 fn set_rotation(&mut self, deg: f64) {
393 self.pic.properties.rot_deg = Some(deg);
394 let rot = (deg * 60_000.0) as i32;
395 self.pic.properties.xfrm.rot = Some(rot);
396 }
397}
398
399/// 推导 Content-Type(按扩展名)。
400///
401/// 未知扩展名回退到 `application/octet-stream`。
402pub fn content_type_for(ext: &str) -> &'static str {
403 match ext.to_ascii_lowercase().as_str() {
404 ".png" => "image/png",
405 ".jpg" | ".jpeg" => "image/jpeg",
406 ".gif" => "image/gif",
407 ".bmp" => "image/bmp",
408 ".svg" => "image/svg+xml",
409 ".tif" | ".tiff" => "image/tiff",
410 _ => "application/octet-stream",
411 }
412}