Skip to main content

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}