Skip to main content

sheetkit_core/
shape.rs

1//! Shape insertion and management.
2//!
3//! Provides types for configuring preset geometry shapes in worksheets
4//! and helpers for building the corresponding drawing XML structures.
5
6use sheetkit_xml::drawing::{
7    AExt, BodyPr, CNvPr, CNvSpPr, ClientData, Ln, LstStyle, MarkerType, NvSpPr, Offset, Paragraph,
8    PrstGeom, RunProperties, Shape, ShapeSpPr, SolidFill, SrgbClr, TextRun, TwoCellAnchor, TxBody,
9    Xfrm,
10};
11
12use crate::error::{Error, Result};
13use crate::utils::cell_ref::cell_name_to_coordinates;
14
15/// Preset geometry shape types supported by OOXML.
16#[derive(Debug, Clone, PartialEq)]
17pub enum ShapeType {
18    Rect,
19    RoundRect,
20    Ellipse,
21    Triangle,
22    Diamond,
23    Pentagon,
24    Hexagon,
25    Octagon,
26    RightArrow,
27    LeftArrow,
28    UpArrow,
29    DownArrow,
30    LeftRightArrow,
31    UpDownArrow,
32    Star4,
33    Star5,
34    Star6,
35    FlowchartProcess,
36    FlowchartDecision,
37    FlowchartTerminator,
38    FlowchartData,
39    Heart,
40    Lightning,
41    Plus,
42    Minus,
43    Cloud,
44    Callout1,
45    Callout2,
46}
47
48impl ShapeType {
49    /// Return the OOXML preset geometry string for this shape type.
50    pub fn preset_name(&self) -> &str {
51        match self {
52            ShapeType::Rect => "rect",
53            ShapeType::RoundRect => "roundRect",
54            ShapeType::Ellipse => "ellipse",
55            ShapeType::Triangle => "triangle",
56            ShapeType::Diamond => "diamond",
57            ShapeType::Pentagon => "pentagon",
58            ShapeType::Hexagon => "hexagon",
59            ShapeType::Octagon => "octagon",
60            ShapeType::RightArrow => "rightArrow",
61            ShapeType::LeftArrow => "leftArrow",
62            ShapeType::UpArrow => "upArrow",
63            ShapeType::DownArrow => "downArrow",
64            ShapeType::LeftRightArrow => "leftRightArrow",
65            ShapeType::UpDownArrow => "upDownArrow",
66            ShapeType::Star4 => "star4",
67            ShapeType::Star5 => "star5",
68            ShapeType::Star6 => "star6",
69            ShapeType::FlowchartProcess => "flowChartProcess",
70            ShapeType::FlowchartDecision => "flowChartDecision",
71            ShapeType::FlowchartTerminator => "flowChartTerminator",
72            ShapeType::FlowchartData => "flowChartInputOutput",
73            ShapeType::Heart => "heart",
74            ShapeType::Lightning => "lightningBolt",
75            ShapeType::Plus => "mathPlus",
76            ShapeType::Minus => "mathMinus",
77            ShapeType::Cloud => "cloud",
78            ShapeType::Callout1 => "wedgeRectCallout",
79            ShapeType::Callout2 => "wedgeRoundRectCallout",
80        }
81    }
82
83    /// Parse a string into a `ShapeType`.
84    ///
85    /// Accepts both the camelCase OOXML preset names and simplified lowercase
86    /// identifiers (e.g., `"rect"`, `"roundRect"`, `"ellipse"`).
87    pub fn parse(s: &str) -> Result<Self> {
88        match s.to_lowercase().as_str() {
89            "rect" | "rectangle" => Ok(ShapeType::Rect),
90            "roundrect" | "roundedrectangle" => Ok(ShapeType::RoundRect),
91            "ellipse" | "circle" | "oval" => Ok(ShapeType::Ellipse),
92            "triangle" => Ok(ShapeType::Triangle),
93            "diamond" => Ok(ShapeType::Diamond),
94            "pentagon" => Ok(ShapeType::Pentagon),
95            "hexagon" => Ok(ShapeType::Hexagon),
96            "octagon" => Ok(ShapeType::Octagon),
97            "rightarrow" => Ok(ShapeType::RightArrow),
98            "leftarrow" => Ok(ShapeType::LeftArrow),
99            "uparrow" => Ok(ShapeType::UpArrow),
100            "downarrow" => Ok(ShapeType::DownArrow),
101            "leftrightarrow" => Ok(ShapeType::LeftRightArrow),
102            "updownarrow" => Ok(ShapeType::UpDownArrow),
103            "star4" => Ok(ShapeType::Star4),
104            "star5" => Ok(ShapeType::Star5),
105            "star6" => Ok(ShapeType::Star6),
106            "flowchartprocess" => Ok(ShapeType::FlowchartProcess),
107            "flowchartdecision" => Ok(ShapeType::FlowchartDecision),
108            "flowchartterminator" => Ok(ShapeType::FlowchartTerminator),
109            "flowchartdata" => Ok(ShapeType::FlowchartData),
110            "heart" => Ok(ShapeType::Heart),
111            "lightning" | "lightningbolt" => Ok(ShapeType::Lightning),
112            "plus" | "mathplus" => Ok(ShapeType::Plus),
113            "minus" | "mathminus" => Ok(ShapeType::Minus),
114            "cloud" => Ok(ShapeType::Cloud),
115            "callout1" | "wedgerectcallout" => Ok(ShapeType::Callout1),
116            "callout2" | "wedgeroundrectcallout" => Ok(ShapeType::Callout2),
117            _ => Err(Error::Internal(format!("unknown shape type: {s}"))),
118        }
119    }
120}
121
122/// Configuration for inserting a shape into a worksheet.
123#[derive(Debug, Clone)]
124pub struct ShapeConfig {
125    /// Preset geometry shape type.
126    pub shape_type: ShapeType,
127    /// Top-left anchor cell (e.g., `"B2"`).
128    pub from_cell: String,
129    /// Bottom-right anchor cell (e.g., `"F10"`).
130    pub to_cell: String,
131    /// Optional text content displayed inside the shape.
132    pub text: Option<String>,
133    /// Optional fill color as a hex string (e.g., `"4472C4"`).
134    pub fill_color: Option<String>,
135    /// Optional line/border color as a hex string (e.g., `"2F528F"`).
136    pub line_color: Option<String>,
137    /// Optional line width in points. Converted to EMU internally.
138    pub line_width: Option<f64>,
139}
140
141/// Points-to-EMU conversion factor. 1 point = 12700 EMU.
142const EMU_PER_POINT: f64 = 12700.0;
143
144/// Build a `TwoCellAnchor` containing a shape.
145///
146/// The shape spans from `config.from_cell` to `config.to_cell`. An
147/// incrementing `shape_id` is used for the non-visual properties.
148pub fn build_shape_anchor(config: &ShapeConfig, shape_id: u32) -> Result<TwoCellAnchor> {
149    let (from_col, from_row) = cell_name_to_coordinates(&config.from_cell)?;
150    let (to_col, to_row) = cell_name_to_coordinates(&config.to_cell)?;
151
152    let from_marker = MarkerType {
153        col: from_col - 1,
154        col_off: 0,
155        row: from_row - 1,
156        row_off: 0,
157    };
158    let to_marker = MarkerType {
159        col: to_col - 1,
160        col_off: 0,
161        row: to_row - 1,
162        row_off: 0,
163    };
164
165    let solid_fill = config.fill_color.as_ref().map(|color| SolidFill {
166        srgb_clr: SrgbClr { val: color.clone() },
167    });
168
169    let ln = if config.line_color.is_some() || config.line_width.is_some() {
170        Some(Ln {
171            w: config.line_width.map(|pts| (pts * EMU_PER_POINT) as u64),
172            solid_fill: config.line_color.as_ref().map(|color| SolidFill {
173                srgb_clr: SrgbClr { val: color.clone() },
174            }),
175        })
176    } else {
177        None
178    };
179
180    let tx_body = config.text.as_ref().map(|text| TxBody {
181        body_pr: BodyPr {},
182        lst_style: LstStyle {},
183        paragraphs: vec![Paragraph {
184            runs: vec![TextRun {
185                r_pr: Some(RunProperties {
186                    lang: Some("en-US".to_string()),
187                    sz: None,
188                }),
189                t: text.clone(),
190            }],
191        }],
192    });
193
194    let shape = Shape {
195        nv_sp_pr: NvSpPr {
196            c_nv_pr: CNvPr {
197                id: shape_id,
198                name: format!("Shape {}", shape_id),
199            },
200            c_nv_sp_pr: CNvSpPr {},
201        },
202        sp_pr: ShapeSpPr {
203            xfrm: Xfrm {
204                off: Offset { x: 0, y: 0 },
205                ext: AExt { cx: 0, cy: 0 },
206            },
207            prst_geom: PrstGeom {
208                prst: config.shape_type.preset_name().to_string(),
209            },
210            solid_fill,
211            ln,
212        },
213        tx_body,
214    };
215
216    Ok(TwoCellAnchor {
217        from: from_marker,
218        to: to_marker,
219        graphic_frame: None,
220        pic: None,
221        shape: Some(shape),
222        client_data: ClientData {},
223    })
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::workbook::Workbook;
230    use tempfile::TempDir;
231
232    #[test]
233    fn test_shape_type_preset_names() {
234        assert_eq!(ShapeType::Rect.preset_name(), "rect");
235        assert_eq!(ShapeType::RoundRect.preset_name(), "roundRect");
236        assert_eq!(ShapeType::Ellipse.preset_name(), "ellipse");
237        assert_eq!(ShapeType::Triangle.preset_name(), "triangle");
238        assert_eq!(ShapeType::Diamond.preset_name(), "diamond");
239        assert_eq!(ShapeType::Heart.preset_name(), "heart");
240        assert_eq!(ShapeType::Lightning.preset_name(), "lightningBolt");
241        assert_eq!(ShapeType::Plus.preset_name(), "mathPlus");
242        assert_eq!(ShapeType::Minus.preset_name(), "mathMinus");
243        assert_eq!(ShapeType::Cloud.preset_name(), "cloud");
244        assert_eq!(ShapeType::Callout1.preset_name(), "wedgeRectCallout");
245        assert_eq!(ShapeType::Callout2.preset_name(), "wedgeRoundRectCallout");
246        assert_eq!(
247            ShapeType::FlowchartProcess.preset_name(),
248            "flowChartProcess"
249        );
250        assert_eq!(
251            ShapeType::FlowchartDecision.preset_name(),
252            "flowChartDecision"
253        );
254    }
255
256    #[test]
257    fn test_shape_type_from_str() {
258        assert_eq!(ShapeType::parse("rect").unwrap(), ShapeType::Rect);
259        assert_eq!(ShapeType::parse("rectangle").unwrap(), ShapeType::Rect);
260        assert_eq!(ShapeType::parse("roundRect").unwrap(), ShapeType::RoundRect);
261        assert_eq!(ShapeType::parse("ellipse").unwrap(), ShapeType::Ellipse);
262        assert_eq!(ShapeType::parse("circle").unwrap(), ShapeType::Ellipse);
263        assert_eq!(ShapeType::parse("heart").unwrap(), ShapeType::Heart);
264        assert_eq!(ShapeType::parse("lightning").unwrap(), ShapeType::Lightning);
265        assert_eq!(ShapeType::parse("callout1").unwrap(), ShapeType::Callout1);
266        assert!(ShapeType::parse("nonexistent").is_err());
267    }
268
269    #[test]
270    fn test_build_shape_anchor_basic() {
271        let config = ShapeConfig {
272            shape_type: ShapeType::Rect,
273            from_cell: "B2".to_string(),
274            to_cell: "F10".to_string(),
275            text: None,
276            fill_color: None,
277            line_color: None,
278            line_width: None,
279        };
280
281        let anchor = build_shape_anchor(&config, 2).unwrap();
282        assert_eq!(anchor.from.col, 1);
283        assert_eq!(anchor.from.row, 1);
284        assert_eq!(anchor.to.col, 5);
285        assert_eq!(anchor.to.row, 9);
286        assert!(anchor.graphic_frame.is_none());
287        assert!(anchor.pic.is_none());
288
289        let shape = anchor.shape.as_ref().unwrap();
290        assert_eq!(shape.nv_sp_pr.c_nv_pr.id, 2);
291        assert_eq!(shape.nv_sp_pr.c_nv_pr.name, "Shape 2");
292        assert_eq!(shape.sp_pr.prst_geom.prst, "rect");
293        assert!(shape.sp_pr.solid_fill.is_none());
294        assert!(shape.sp_pr.ln.is_none());
295        assert!(shape.tx_body.is_none());
296    }
297
298    #[test]
299    fn test_build_shape_anchor_with_text() {
300        let config = ShapeConfig {
301            shape_type: ShapeType::Ellipse,
302            from_cell: "A1".to_string(),
303            to_cell: "D5".to_string(),
304            text: Some("Hello World".to_string()),
305            fill_color: None,
306            line_color: None,
307            line_width: None,
308        };
309
310        let anchor = build_shape_anchor(&config, 3).unwrap();
311        let shape = anchor.shape.as_ref().unwrap();
312        assert_eq!(shape.sp_pr.prst_geom.prst, "ellipse");
313
314        let tx_body = shape.tx_body.as_ref().unwrap();
315        assert_eq!(tx_body.paragraphs.len(), 1);
316        assert_eq!(tx_body.paragraphs[0].runs.len(), 1);
317        assert_eq!(tx_body.paragraphs[0].runs[0].t, "Hello World");
318    }
319
320    #[test]
321    fn test_build_shape_anchor_with_fill_and_line() {
322        let config = ShapeConfig {
323            shape_type: ShapeType::Diamond,
324            from_cell: "C3".to_string(),
325            to_cell: "H8".to_string(),
326            text: None,
327            fill_color: Some("4472C4".to_string()),
328            line_color: Some("2F528F".to_string()),
329            line_width: Some(2.0),
330        };
331
332        let anchor = build_shape_anchor(&config, 4).unwrap();
333        let shape = anchor.shape.as_ref().unwrap();
334        assert_eq!(shape.sp_pr.prst_geom.prst, "diamond");
335
336        let fill = shape.sp_pr.solid_fill.as_ref().unwrap();
337        assert_eq!(fill.srgb_clr.val, "4472C4");
338
339        let ln = shape.sp_pr.ln.as_ref().unwrap();
340        assert_eq!(ln.w, Some(25400));
341        let ln_fill = ln.solid_fill.as_ref().unwrap();
342        assert_eq!(ln_fill.srgb_clr.val, "2F528F");
343    }
344
345    #[test]
346    fn test_build_shape_anchor_invalid_cell() {
347        let config = ShapeConfig {
348            shape_type: ShapeType::Rect,
349            from_cell: "INVALID".to_string(),
350            to_cell: "F10".to_string(),
351            text: None,
352            fill_color: None,
353            line_color: None,
354            line_width: None,
355        };
356
357        assert!(build_shape_anchor(&config, 1).is_err());
358    }
359
360    #[test]
361    fn test_build_shape_various_types() {
362        let types = vec![
363            (ShapeType::Star4, "star4"),
364            (ShapeType::Star5, "star5"),
365            (ShapeType::Star6, "star6"),
366            (ShapeType::RightArrow, "rightArrow"),
367            (ShapeType::LeftArrow, "leftArrow"),
368            (ShapeType::UpArrow, "upArrow"),
369            (ShapeType::DownArrow, "downArrow"),
370            (ShapeType::Pentagon, "pentagon"),
371            (ShapeType::Hexagon, "hexagon"),
372            (ShapeType::Octagon, "octagon"),
373        ];
374
375        for (shape_type, expected_preset) in types {
376            let config = ShapeConfig {
377                shape_type,
378                from_cell: "A1".to_string(),
379                to_cell: "C3".to_string(),
380                text: None,
381                fill_color: None,
382                line_color: None,
383                line_width: None,
384            };
385            let anchor = build_shape_anchor(&config, 1).unwrap();
386            let shape = anchor.shape.as_ref().unwrap();
387            assert_eq!(shape.sp_pr.prst_geom.prst, expected_preset);
388        }
389    }
390
391    #[test]
392    fn test_add_shape_to_workbook_and_save() {
393        let dir = TempDir::new().unwrap();
394        let path = dir.path().join("add_shape_basic.xlsx");
395
396        let mut wb = Workbook::new();
397        let config = ShapeConfig {
398            shape_type: ShapeType::Rect,
399            from_cell: "B2".to_string(),
400            to_cell: "F10".to_string(),
401            text: Some("Test Shape".to_string()),
402            fill_color: Some("FF0000".to_string()),
403            line_color: None,
404            line_width: None,
405        };
406        wb.add_shape("Sheet1", &config).unwrap();
407        wb.save(&path).unwrap();
408
409        let file = std::fs::File::open(&path).unwrap();
410        let mut archive = zip::ZipArchive::new(file).unwrap();
411        assert!(archive.by_name("xl/drawings/drawing1.xml").is_ok());
412
413        let drawing_xml = {
414            use std::io::Read;
415            let mut buf = String::new();
416            archive
417                .by_name("xl/drawings/drawing1.xml")
418                .unwrap()
419                .read_to_string(&mut buf)
420                .unwrap();
421            buf
422        };
423        assert!(drawing_xml.contains("rect"));
424        assert!(drawing_xml.contains("FF0000"));
425        assert!(drawing_xml.contains("Test Shape"));
426    }
427
428    #[test]
429    fn test_add_shape_sheet_not_found() {
430        let mut wb = Workbook::new();
431        let config = ShapeConfig {
432            shape_type: ShapeType::Rect,
433            from_cell: "A1".to_string(),
434            to_cell: "C3".to_string(),
435            text: None,
436            fill_color: None,
437            line_color: None,
438            line_width: None,
439        };
440        let result = wb.add_shape("NoSheet", &config);
441        assert!(matches!(
442            result.unwrap_err(),
443            crate::error::Error::SheetNotFound { .. }
444        ));
445    }
446
447    #[test]
448    fn test_add_multiple_shapes_same_sheet_save() {
449        let dir = TempDir::new().unwrap();
450        let path = dir.path().join("multi_shape.xlsx");
451
452        let mut wb = Workbook::new();
453        wb.add_shape(
454            "Sheet1",
455            &ShapeConfig {
456                shape_type: ShapeType::Rect,
457                from_cell: "A1".to_string(),
458                to_cell: "C3".to_string(),
459                text: None,
460                fill_color: None,
461                line_color: None,
462                line_width: None,
463            },
464        )
465        .unwrap();
466        wb.add_shape(
467            "Sheet1",
468            &ShapeConfig {
469                shape_type: ShapeType::Ellipse,
470                from_cell: "E1".to_string(),
471                to_cell: "H5".to_string(),
472                text: Some("Circle".to_string()),
473                fill_color: Some("00FF00".to_string()),
474                line_color: None,
475                line_width: None,
476            },
477        )
478        .unwrap();
479        wb.save(&path).unwrap();
480
481        let file = std::fs::File::open(&path).unwrap();
482        let mut archive = zip::ZipArchive::new(file).unwrap();
483        let drawing_xml = {
484            use std::io::Read;
485            let mut buf = String::new();
486            archive
487                .by_name("xl/drawings/drawing1.xml")
488                .unwrap()
489                .read_to_string(&mut buf)
490                .unwrap();
491            buf
492        };
493        assert!(drawing_xml.contains("rect"));
494        assert!(drawing_xml.contains("ellipse"));
495        assert!(drawing_xml.contains("Circle"));
496        assert!(drawing_xml.contains("00FF00"));
497    }
498
499    #[test]
500    fn test_save_with_shape() {
501        let dir = TempDir::new().unwrap();
502        let path = dir.path().join("with_shape.xlsx");
503
504        let mut wb = Workbook::new();
505        let config = ShapeConfig {
506            shape_type: ShapeType::RoundRect,
507            from_cell: "B2".to_string(),
508            to_cell: "F10".to_string(),
509            text: Some("Hello".to_string()),
510            fill_color: Some("4472C4".to_string()),
511            line_color: Some("2F528F".to_string()),
512            line_width: Some(1.5),
513        };
514        wb.add_shape("Sheet1", &config).unwrap();
515        wb.save(&path).unwrap();
516
517        let file = std::fs::File::open(&path).unwrap();
518        let mut archive = zip::ZipArchive::new(file).unwrap();
519        assert!(archive.by_name("xl/drawings/drawing1.xml").is_ok());
520
521        let drawing_xml = {
522            use std::io::Read;
523            let mut buf = String::new();
524            archive
525                .by_name("xl/drawings/drawing1.xml")
526                .unwrap()
527                .read_to_string(&mut buf)
528                .unwrap();
529            buf
530        };
531        assert!(drawing_xml.contains("roundRect"));
532        assert!(drawing_xml.contains("4472C4"));
533        assert!(drawing_xml.contains("Hello"));
534    }
535
536    #[test]
537    fn test_save_shape_roundtrip() {
538        let dir = TempDir::new().unwrap();
539        let path = dir.path().join("shape_roundtrip.xlsx");
540
541        let mut wb = Workbook::new();
542        wb.add_shape(
543            "Sheet1",
544            &ShapeConfig {
545                shape_type: ShapeType::Heart,
546                from_cell: "A1".to_string(),
547                to_cell: "D5".to_string(),
548                text: None,
549                fill_color: Some("FF0000".to_string()),
550                line_color: None,
551                line_width: None,
552            },
553        )
554        .unwrap();
555        wb.save(&path).unwrap();
556
557        let wb2 = Workbook::open(&path).unwrap();
558        let ws = wb2.worksheet_ref("Sheet1").unwrap();
559        assert!(ws.drawing.is_some());
560    }
561
562    #[test]
563    fn test_add_shape_with_chart_same_sheet_save() {
564        use crate::chart::{ChartConfig, ChartSeries, ChartType};
565        let dir = TempDir::new().unwrap();
566        let path = dir.path().join("shape_and_chart.xlsx");
567
568        let mut wb = Workbook::new();
569        let chart_config = ChartConfig {
570            chart_type: ChartType::Col,
571            title: Some("Chart".to_string()),
572            series: vec![ChartSeries {
573                name: "S1".to_string(),
574                categories: "Sheet1!$A$1:$A$3".to_string(),
575                values: "Sheet1!$B$1:$B$3".to_string(),
576                x_values: None,
577                bubble_sizes: None,
578            }],
579            show_legend: true,
580            view_3d: None,
581        };
582        wb.add_chart("Sheet1", "E1", "L10", &chart_config).unwrap();
583
584        let shape_config = ShapeConfig {
585            shape_type: ShapeType::Rect,
586            from_cell: "A12".to_string(),
587            to_cell: "D18".to_string(),
588            text: Some("Label".to_string()),
589            fill_color: None,
590            line_color: None,
591            line_width: None,
592        };
593        wb.add_shape("Sheet1", &shape_config).unwrap();
594        wb.save(&path).unwrap();
595
596        let file = std::fs::File::open(&path).unwrap();
597        let mut archive = zip::ZipArchive::new(file).unwrap();
598        assert!(archive.by_name("xl/drawings/drawing1.xml").is_ok());
599        assert!(archive.by_name("xl/charts/chart1.xml").is_ok());
600
601        let drawing_xml = {
602            use std::io::Read;
603            let mut buf = String::new();
604            archive
605                .by_name("xl/drawings/drawing1.xml")
606                .unwrap()
607                .read_to_string(&mut buf)
608                .unwrap();
609            buf
610        };
611        assert!(drawing_xml.contains("rect"));
612        assert!(drawing_xml.contains("Label"));
613    }
614
615    #[test]
616    fn test_shape_line_width_only() {
617        let config = ShapeConfig {
618            shape_type: ShapeType::Rect,
619            from_cell: "A1".to_string(),
620            to_cell: "C3".to_string(),
621            text: None,
622            fill_color: None,
623            line_color: None,
624            line_width: Some(3.0),
625        };
626        let anchor = build_shape_anchor(&config, 1).unwrap();
627        let shape = anchor.shape.as_ref().unwrap();
628        let ln = shape.sp_pr.ln.as_ref().unwrap();
629        assert_eq!(ln.w, Some(38100));
630        assert!(ln.solid_fill.is_none());
631    }
632}