1use 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#[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 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 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#[derive(Debug, Clone)]
124pub struct ShapeConfig {
125 pub shape_type: ShapeType,
127 pub from_cell: String,
129 pub to_cell: String,
131 pub text: Option<String>,
133 pub fill_color: Option<String>,
135 pub line_color: Option<String>,
137 pub line_width: Option<f64>,
139}
140
141const EMU_PER_POINT: f64 = 12700.0;
143
144pub 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}