Skip to main content

pptx_rs/shape/
freeform.rs

1//! `Freeform` / `FreeformBuilder`:手绘自由形(`<a:custGeom>`)。
2//!
3//! 第一版只暴露"加路径点 → 关闭"的最小接口,足够画折线/多边形。
4//! 写入时把每个点用 `<a:moveTo>` / `<a:lnTo>` 表达,最终通过
5//! [`crate::slide::ShapesMut::add_freeform`] 添加到 slide。
6//!
7//! # 与 python-pptx 的对应
8//!
9//! - python-pptx **未提供** 等价 API;
10//! - 本库把 Freeform 落地为 [`AutoShape`] + `<a:custGeom>`(自定义几何),
11//!   `FreeformBuilder::build` 会把累积的路径点序列化为 `<a:pathLst>`。
12//!
13//! # 设计要点
14//!
15//! - [`FreeformBuilder`] 是**流式 API**:`move_to` / `line_to` 链式调用;
16//! - `build` 时把 builder 转为 [`Freeform`](不可再改);
17//! - 路径坐标系:所有点的 EMU 坐标直接作为 `<a:pt x="..." y="..."/>` 输出;
18//!   `<a:path>` 的 `w` / `h` 属性取路径边界框尺寸,让 PowerPoint 按比例缩放。
19//!
20//! # 示例
21//!
22//! ```no_run
23//! use pptx_rs::shape::FreeformBuilder;
24//! use pptx_rs::EmuExt;
25//! use pptx_rs::Inches;
26//!
27//! let mut builder = FreeformBuilder::new();
28//! builder.move_to(Inches(1.0).emu(), Inches(1.0).emu());
29//! builder.line_to(Inches(3.0).emu(), Inches(1.0).emu());
30//! builder.line_to(Inches(2.0).emu(), Inches(2.0).emu());
31//! builder.close();
32//! let _f = builder.build("triangle");
33//! ```
34
35use crate::oxml::shape::Sp as OxmlSp;
36use crate::oxml::sppr::{CustomGeometry, GeomRect, Geometry, Path, PathSegment, ShapeProperties};
37use crate::oxml::txbody::TextBody;
38use crate::shape::autoshape::AutoShape;
39use crate::shape::base::Shape;
40use crate::units::Emu;
41
42/// 一个 2D 点。
43#[derive(Copy, Clone, Debug, Default)]
44pub struct Point {
45    /// x 坐标(EMU)。
46    pub x: Emu,
47    /// y 坐标(EMU)。
48    pub y: Emu,
49}
50
51/// `FreeformBuilder`:累积路径点。
52#[derive(Clone, Debug, Default)]
53pub struct FreeformBuilder {
54    points: Vec<Point>,
55    auto_close: bool,
56}
57
58impl FreeformBuilder {
59    /// 新建。
60    pub fn new() -> Self {
61        FreeformBuilder {
62            points: Vec::new(),
63            auto_close: false,
64        }
65    }
66
67    /// 在 `(x, y)` 处下笔(相当于 SVG `M`)。
68    pub fn move_to(&mut self, x: Emu, y: Emu) -> &mut Self {
69        self.points.push(Point { x, y });
70        self
71    }
72
73    /// 连接到 `(x, y)`(相当于 SVG `L`)。
74    pub fn line_to(&mut self, x: Emu, y: Emu) -> &mut Self {
75        self.points.push(Point { x, y });
76        self
77    }
78
79    /// 闭合路径(首尾相连,相当于 SVG `Z`)。
80    pub fn close(&mut self) -> &mut Self {
81        self.auto_close = true;
82        self
83    }
84
85    /// 取所有点。
86    pub fn points(&self) -> &[Point] {
87        &self.points
88    }
89
90    /// 构造一个 [`Freeform`]`(一个用 `<a:custGeom>` 描述的 AutoShape)。
91    ///
92    /// # 行为
93    ///
94    /// 1. 把累积的点序列化为 `<a:pathLst>`,首点为 `<a:moveTo>`,后续点为 `<a:lnTo>`;
95    /// 2. 若调用过 [`Self::close`],追加 `<a:close/>` 段;
96    /// 3. `<a:path>` 的 `w` / `h` 取路径边界框尺寸(最小 x/y 到最大 x/y);
97    /// 4. `spPr.xfrm` 的位置默认为路径最小点,尺寸为边界框尺寸——调用方可后续
98    ///    通过 `set_left/set_top/set_width/set_height` 覆盖。
99    ///
100    /// # 边界情况
101    ///
102    /// - 点数 < 2:仍会构造一个空路径,PowerPoint 会按形状的 xfrm 尺寸渲染空白区域;
103    /// - 单点(仅 `move_to`):等价于空路径,几何上不可见。
104    #[allow(clippy::field_reassign_with_default)]
105    pub fn build(self, name: impl Into<String>) -> Freeform {
106        let mut sp = OxmlSp::default();
107        sp.id = 0;
108        sp.name = name.into();
109        sp.properties = ShapeProperties::default();
110        sp.text = TextBody::new();
111
112        // 把点序列化为路径段:首点 moveTo,后续点 lnTo,最后按需 close。
113        let mut segments: Vec<PathSegment> = Vec::with_capacity(self.points.len() + 1);
114        for (i, p) in self.points.iter().enumerate() {
115            let x = p.x.value();
116            let y = p.y.value();
117            if i == 0 {
118                segments.push(PathSegment::MoveTo { x, y });
119            } else {
120                segments.push(PathSegment::LineTo { x, y });
121            }
122        }
123        if self.auto_close {
124            segments.push(PathSegment::Close);
125        }
126
127        // 计算路径边界框,作为 <a:path w="..." h="..."> 的取值。
128        // PowerPoint 会按 w/h 比例把 path 缩放到 spPr.xfrm 的尺寸。
129        let (path_w, path_h, min_x, min_y) = compute_bbox(&self.points);
130
131        let path = Path {
132            width: path_w,
133            height: path_h,
134            fill: None,
135            stroke: None,
136            segments,
137        };
138        let geom = CustomGeometry {
139            fill: None,
140            stroke: None,
141            // 内嵌区域设为边界框,让 PowerPoint 文本布局有合理边界。
142            rect: Some(GeomRect {
143                l: min_x.to_string(),
144                t: min_y.to_string(),
145                r: (min_x + path_w).to_string(),
146                b: (min_y + path_h).to_string(),
147            }),
148            path_list: vec![path],
149        };
150        sp.properties.geometry = Some(Geometry::Custom(geom));
151
152        // 同步设置 xfrm 的位置和尺寸,让形状默认就能显示出路径形状。
153        // 调用方仍可通过 set_left/set_top/set_width/set_height 覆盖。
154        sp.properties.xfrm.off_x = Some(Emu(min_x));
155        sp.properties.xfrm.off_y = Some(Emu(min_y));
156        sp.properties.xfrm.ext_cx = Some(Emu(path_w));
157        sp.properties.xfrm.ext_cy = Some(Emu(path_h));
158
159        Freeform {
160            shape: AutoShape::from_sp(sp),
161        }
162    }
163}
164
165/// 计算点列表的边界框,返回 `(width, height, min_x, min_y)`。
166///
167/// 空列表返回 `(0, 0, 0, 0)`。
168fn compute_bbox(points: &[Point]) -> (i64, i64, i64, i64) {
169    if points.is_empty() {
170        return (0, 0, 0, 0);
171    }
172    let mut min_x = points[0].x.value();
173    let mut min_y = points[0].y.value();
174    let mut max_x = min_x;
175    let mut max_y = min_y;
176    for p in &points[1..] {
177        let x = p.x.value();
178        let y = p.y.value();
179        if x < min_x {
180            min_x = x;
181        }
182        if x > max_x {
183            max_x = x;
184        }
185        if y < min_y {
186            min_y = y;
187        }
188        if y > max_y {
189            max_y = y;
190        }
191    }
192    (max_x - min_x, max_y - min_y, min_x, min_y)
193}
194
195/// 自由形。
196#[derive(Clone, Debug, Default)]
197pub struct Freeform {
198    /// 内部包装的 [`AutoShape`]。
199    pub(crate) shape: AutoShape,
200}
201
202impl Freeform {
203    /// 借用内部 AutoShape(便于复用 `set_outer_shadow` / `set_fill` 等高阶 API)。
204    pub fn as_shape(&self) -> &AutoShape {
205        &self.shape
206    }
207    /// 借用内部 AutoShape 的可变引用。
208    pub fn as_shape_mut(&mut self) -> &mut AutoShape {
209        &mut self.shape
210    }
211}
212
213impl Shape for Freeform {
214    fn id(&self) -> u32 {
215        self.shape.id()
216    }
217    fn set_id(&mut self, id: u32) {
218        self.shape.set_id(id);
219    }
220    fn name(&self) -> &str {
221        self.shape.name()
222    }
223    fn set_name(&mut self, name: String) {
224        self.shape.set_name(name);
225    }
226    fn shape_type(&self) -> &'static str {
227        "freeform"
228    }
229    fn left(&self) -> Emu {
230        self.shape.left()
231    }
232    fn set_left(&mut self, emu: Emu) {
233        self.shape.set_left(emu);
234    }
235    fn top(&self) -> Emu {
236        self.shape.top()
237    }
238    fn set_top(&mut self, emu: Emu) {
239        self.shape.set_top(emu);
240    }
241    fn width(&self) -> Emu {
242        self.shape.width()
243    }
244    fn set_width(&mut self, emu: Emu) {
245        self.shape.set_width(emu);
246    }
247    fn height(&self) -> Emu {
248        self.shape.height()
249    }
250    fn set_height(&mut self, emu: Emu) {
251        self.shape.set_height(emu);
252    }
253    fn rotation(&self) -> f64 {
254        self.shape.rotation()
255    }
256    fn set_rotation(&mut self, deg: f64) {
257        self.shape.set_rotation(deg);
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::oxml::writer::XmlWriter;
265    use crate::EmuExt;
266    use crate::Inches;
267
268    /// `build` 输出 custGeom 而非 prstGeom=rect(TODO-024 回归检测)。
269    #[test]
270    fn build_outputs_cust_geom() {
271        let mut b = FreeformBuilder::new();
272        b.move_to(Inches(1.0).emu(), Inches(1.0).emu())
273            .line_to(Inches(3.0).emu(), Inches(1.0).emu())
274            .line_to(Inches(2.0).emu(), Inches(2.0).emu())
275            .close();
276        let f = b.build("triangle");
277        let mut w = XmlWriter::new();
278        f.shape.sp().properties.write_xml(&mut w, "p:spPr");
279        let xml = &w.buf;
280        assert!(
281            xml.contains("<a:custGeom>"),
282            "must output custGeom, xml: {}",
283            xml
284        );
285        assert!(
286            !xml.contains("<a:prstGeom"),
287            "must not output prstGeom, xml: {}",
288            xml
289        );
290        assert!(xml.contains("<a:moveTo>"), "xml: {}", xml);
291        assert!(xml.contains("<a:lnTo>"), "xml: {}", xml);
292        assert!(xml.contains("<a:close/>"), "xml: {}", xml);
293    }
294
295    /// `build` 在空路径时不 panic,仍能写出 custGeom。
296    #[test]
297    fn build_with_empty_points() {
298        let b = FreeformBuilder::new();
299        let f = b.build("empty");
300        let mut w = XmlWriter::new();
301        f.shape.sp().properties.write_xml(&mut w, "p:spPr");
302        let xml = &w.buf;
303        assert!(xml.contains("<a:custGeom>"), "xml: {}", xml);
304    }
305
306    /// 边界框计算正确(两点形成一条对角线)。
307    #[test]
308    fn bbox_two_points() {
309        let pts = [
310            Point {
311                x: Inches(1.0).emu(),
312                y: Inches(2.0).emu(),
313            },
314            Point {
315                x: Inches(4.0).emu(),
316                y: Inches(6.0).emu(),
317            },
318        ];
319        let (w, h, min_x, min_y) = compute_bbox(&pts);
320        assert_eq!(w, Inches(3.0).emu().value());
321        assert_eq!(h, Inches(4.0).emu().value());
322        assert_eq!(min_x, Inches(1.0).emu().value());
323        assert_eq!(min_y, Inches(2.0).emu().value());
324    }
325}