Skip to main content

excelize_rs/
shape.rs

1//! Shape support.
2//!
3//! Ported from Go `shape.go`.
4
5use crate::File;
6use crate::constants::{
7    DEFAULT_LINE_WIDTH, DEFAULT_SHAPE_SIZE, EMU, SOURCE_RELATIONSHIP_DRAWING_ML,
8};
9use crate::errors::{ErrParameterInvalid, ErrTransparency, Result};
10use crate::lib_util::{cell_name_to_coordinates, column_number_to_name};
11use crate::styles::Font;
12use crate::xml::common::{AttrValInt, AttrValString, RichTextRun};
13use crate::xml::drawing::{
14    ABodyPr, AEndParaRPr, AFontRef, AP, AR, ARPr, ARef, ASolidFill, ASrgbClr, GraphicOptions,
15    LineOptions, LineType, Shape, XdrCNvSpPr, XdrCellAnchor, XdrClientData, XdrNvSpPr, XdrSp,
16    XdrStyle, XdrTxBody, XlsxCNvPr, XlsxCTTextFont, XlsxFrom, XlsxOff, XlsxPositiveSize2D,
17    XlsxPrstGeom, XlsxSpPr, XlsxTo, XlsxXfrm,
18};
19
20const SUPPORTED_DRAWING_UNDERLINE_TYPES: &[&str] = &[
21    "none",
22    "sng",
23    "dbl",
24    "heavy",
25    "dotted",
26    "dottedHeavy",
27    "dash",
28    "dashHeavy",
29    "dashLong",
30    "dashLongHeavy",
31    "dotDash",
32    "dotDashHeavy",
33    "dotDotDash",
34    "dotDotDashHeavy",
35    "wavy",
36    "wavyHeavy",
37    "wavyDbl",
38];
39
40impl File {
41    /// Add a shape to a worksheet.
42    pub fn add_shape(&self, sheet: &str, opts: &Shape) -> Result<()> {
43        let mut options = opts.clone();
44        parse_shape_options(&mut options)?;
45
46        let mut ws = self.work_sheet_reader(sheet)?;
47
48        // Create or reuse the worksheet drawing relationship.
49        let drawing_id = self.count_drawings() + 1;
50        let mut drawing_xml = format!("xl/drawings/drawing{drawing_id}.xml");
51        let mut sheet_rels_drawing_xml = format!("../drawings/drawing{drawing_id}.xml");
52
53        if let Some(ref drawing) = ws.drawing {
54            sheet_rels_drawing_xml = self
55                .get_sheet_relationships_target_by_id(sheet, drawing.rid.as_deref().unwrap_or(""));
56            drawing_xml = sheet_rels_drawing_xml.replace("..", "xl");
57        } else {
58            let sheet_xml_path = self.get_sheet_xml_path(sheet).unwrap_or_default();
59            let sheet_rels = format!(
60                "xl/worksheets/_rels/{}{}.rels",
61                sheet_xml_path.strip_prefix("xl/worksheets/").unwrap_or(""),
62                ""
63            );
64            let r_id = self.add_rels(
65                &sheet_rels,
66                SOURCE_RELATIONSHIP_DRAWING_ML,
67                &sheet_rels_drawing_xml,
68                "",
69            );
70            self.add_sheet_drawing(sheet, r_id)?;
71            self.add_sheet_name_space(sheet, "r");
72        }
73
74        self.add_drawing_shape(sheet, &drawing_xml, &opts.cell, &options)?;
75        self.add_content_type_part(drawing_id, "drawings")?;
76        Ok(())
77    }
78
79    /// Create the drawing shape element and append it to the drawing part.
80    fn add_drawing_shape(
81        &self,
82        sheet: &str,
83        drawing_xml: &str,
84        cell: &str,
85        opts: &Shape,
86    ) -> Result<()> {
87        let (mut ws_dr, mut anchor, c_nv_pr_id) = self.shape_cell_anchor(
88            sheet,
89            drawing_xml,
90            cell,
91            opts.width as i32,
92            opts.height as i32,
93            &opts.format,
94        )?;
95
96        let shape_color = opts
97            .fill
98            .color
99            .first()
100            .map(|c| c.trim_start_matches('#').to_uppercase())
101            .unwrap_or_default();
102        let line_color = opts
103            .line
104            .fill
105            .color
106            .first()
107            .map(|c| c.trim_start_matches('#').to_uppercase())
108            .unwrap_or_default();
109
110        let mut shape = XdrSp {
111            macro_name: opts.macro_name.clone(),
112            text_link: String::new(),
113            nv_sp_pr: Some(XdrNvSpPr {
114                c_nv_pr: Some(XlsxCNvPr {
115                    id: c_nv_pr_id,
116                    name: if opts.format.name.is_empty() {
117                        format!("Shape {c_nv_pr_id}")
118                    } else {
119                        opts.format.name.clone()
120                    },
121                    descr: opts.format.alt_text.clone(),
122                    title: None,
123                    hlink_click: None,
124                }),
125                c_nv_sp_pr: Some(XdrCNvSpPr { tx_box: true }),
126            }),
127            sp_pr: Some(XlsxSpPr {
128                xfrm: XlsxXfrm {
129                    off: XlsxOff { x: 0, y: 0 },
130                    ext: XlsxPositiveSize2D {
131                        cx: opts.width as i64 * EMU as i64,
132                        cy: opts.height as i64 * EMU as i64,
133                    },
134                },
135                prst_geom: XlsxPrstGeom {
136                    prst: opts.r#type.clone(),
137                },
138                ln: crate::chart::draw_chart_ln(&opts.line),
139                ..Default::default()
140            }),
141            style: Some(XdrStyle {
142                ln_ref: Some(shape_ref(&line_color, 2)),
143                fill_ref: Some(shape_ref(&shape_color, 1)),
144                effect_ref: Some(shape_ref("", 0)),
145                font_ref: Some(AFontRef {
146                    idx: "minor".to_string(),
147                    scheme_clr: Some(AttrValString {
148                        val: Some("tx1".to_string()),
149                    }),
150                }),
151            }),
152            tx_body: Some(XdrTxBody {
153                body_pr: Some(ABodyPr {
154                    vert_overflow: Some("clip".to_string()),
155                    horz_overflow: Some("clip".to_string()),
156                    wrap: Some("none".to_string()),
157                    rtl_col: Some(false),
158                    anchor: Some("t".to_string()),
159                    ..Default::default()
160                }),
161                p: Vec::new(),
162            }),
163        };
164
165        if opts.fill.transparency > 0 {
166            let val = (100 - opts.fill.transparency) * 1000;
167            if let Some(ref mut sp_pr) = shape.sp_pr {
168                sp_pr.solid_fill = Some(ASolidFill {
169                    srgb_clr: Some(ASrgbClr {
170                        val: Some(shape_color.clone()),
171                        alpha: Some(AttrValInt {
172                            val: Some(val as i64),
173                        }),
174                        ..Default::default()
175                    }),
176                    ..Default::default()
177                });
178            }
179        }
180
181        let default_font = self
182            .get_default_font()
183            .unwrap_or_else(|_| "Calibri".to_string());
184        let paragraphs = if opts.paragraph.is_empty() {
185            vec![RichTextRun {
186                font: Some(Font {
187                    bold: Some(false),
188                    italic: Some(false),
189                    underline: Some("none".to_string()),
190                    name: Some(default_font.clone()),
191                    size: Some(11.0),
192                    color: Some("000000".to_string()),
193                    ..Default::default()
194                }),
195                text: " ".to_string(),
196            }]
197        } else {
198            opts.paragraph.clone()
199        };
200
201        if let Some(ref mut tx_body) = shape.tx_body {
202            for p in paragraphs {
203                let font = p.font.unwrap_or_default();
204                let u = if SUPPORTED_DRAWING_UNDERLINE_TYPES
205                    .iter()
206                    .any(|&t| t == font.underline.as_deref().unwrap_or("none"))
207                {
208                    font.underline.unwrap_or_else(|| "none".to_string())
209                } else {
210                    "none".to_string()
211                };
212                let text = if p.text.is_empty() {
213                    " ".to_string()
214                } else {
215                    p.text
216                };
217                let srgb_clr = font
218                    .color
219                    .as_deref()
220                    .unwrap_or("")
221                    .trim_start_matches('#')
222                    .to_uppercase();
223                let mut r_pr = ARPr {
224                    i: font.italic.unwrap_or(false),
225                    b: font.bold.unwrap_or(false),
226                    lang: Some("en-US".to_string()),
227                    alt_lang: Some("en-US".to_string()),
228                    u: Some(u),
229                    sz: font.size.map(|s| s * 100.0),
230                    latin: Some(XlsxCTTextFont {
231                        typeface: font.name.clone().unwrap_or_else(|| default_font.clone()),
232                        charset: None,
233                        panose: None,
234                        pitch_family: None,
235                    }),
236                    ea: Some(XlsxCTTextFont {
237                        typeface: font.name.clone().unwrap_or_else(|| default_font.clone()),
238                        charset: None,
239                        panose: None,
240                        pitch_family: None,
241                    }),
242                    cs: Some(XlsxCTTextFont {
243                        typeface: font.name.clone().unwrap_or_else(|| default_font.clone()),
244                        charset: None,
245                        panose: None,
246                        pitch_family: None,
247                    }),
248                    ..Default::default()
249                };
250                if srgb_clr.len() == 6 {
251                    r_pr.solid_fill = Some(ASolidFill {
252                        srgb_clr: Some(ASrgbClr {
253                            val: Some(srgb_clr),
254                            ..Default::default()
255                        }),
256                        ..Default::default()
257                    });
258                }
259                tx_body.p.push(AP {
260                    r: Some(AR {
261                        r_pr: Some(r_pr),
262                        t: Some(text),
263                    }),
264                    end_para_r_pr: Some(AEndParaRPr {
265                        lang: "en-US".to_string(),
266                        ..Default::default()
267                    }),
268                    ..Default::default()
269                });
270            }
271        }
272
273        anchor.sp = Some(shape);
274        anchor.client_data = Some(XdrClientData {
275            f_locks_with_sheet: opts.format.locked.unwrap_or(true),
276            f_prints_with_sheet: opts.format.print_object.unwrap_or(true),
277        });
278
279        if opts.format.positioning == "oneCell" {
280            ws_dr.one_cell_anchor.push(anchor);
281        } else {
282            ws_dr.two_cell_anchor.push(anchor);
283        }
284        self.drawings.insert(drawing_xml.to_string(), ws_dr);
285        Ok(())
286    }
287
288    /// Compute the cell anchor for a shape.
289    fn shape_cell_anchor(
290        &self,
291        sheet: &str,
292        drawing_xml: &str,
293        cell: &str,
294        width: i32,
295        height: i32,
296        format: &GraphicOptions,
297    ) -> Result<(crate::xml::drawing::XlsxWsDr, XdrCellAnchor, i64)> {
298        let (from_col, from_row) = cell_name_to_coordinates(cell)?;
299        let w = (width as f64 * format.scale_x) as i32;
300        let h = (height as f64 * format.scale_y) as i32;
301        let (col_start, row_start, col_end, row_end, x1, y1, x2, y2) =
302            self.position_object_pixels(sheet, from_col, from_row, w, h, format)?;
303
304        let (ws_dr, c_nv_pr_id) = self.drawing_parser(drawing_xml)?;
305        let mut anchor = XdrCellAnchor::default();
306        anchor.from = Some(XlsxFrom {
307            col: col_start as i64,
308            col_off: x1 as i64 * EMU as i64,
309            row: row_start as i64,
310            row_off: y1 as i64 * EMU as i64,
311        });
312
313        if format.positioning != "oneCell" {
314            anchor.to = Some(XlsxTo {
315                col: col_end as i64,
316                col_off: x2 as i64 * EMU as i64,
317                row: row_end as i64,
318                row_off: y2 as i64 * EMU as i64,
319            });
320            anchor.edit_as = Some(format.positioning.clone());
321        } else {
322            anchor.ext = Some(XlsxPositiveSize2D {
323                cx: x2 as i64 * EMU as i64,
324                cy: y2 as i64 * EMU as i64,
325            });
326        }
327
328        Ok((ws_dr, anchor, c_nv_pr_id))
329    }
330}
331
332fn parse_shape_options(opts: &mut Shape) -> Result<()> {
333    if opts.r#type.is_empty() {
334        return Err(Box::new(ErrParameterInvalid));
335    }
336    if opts.width == 0 {
337        opts.width = DEFAULT_SHAPE_SIZE as u64;
338    }
339    if opts.height == 0 {
340        opts.height = DEFAULT_SHAPE_SIZE as u64;
341    }
342    if opts.line.width == 0.0 {
343        opts.line.width = DEFAULT_LINE_WIDTH as f64;
344    }
345    if !(0..=100).contains(&opts.fill.transparency) {
346        return Err(Box::new(ErrTransparency));
347    }
348    if opts.format.scale_x == 0.0 {
349        opts.format.scale_x = 1.0;
350    }
351    if opts.format.scale_y == 0.0 {
352        opts.format.scale_y = 1.0;
353    }
354    Ok(())
355}
356
357fn shape_ref(color: &str, idx: i64) -> ARef {
358    if color.is_empty() {
359        ARef {
360            idx: 0,
361            scrgb_clr: Some(crate::xml::drawing::AScrgbClr {
362                r: 0.0,
363                g: 0.0,
364                b: 0.0,
365            }),
366            ..Default::default()
367        }
368    } else {
369        ARef {
370            idx,
371            srgb_clr: Some(AttrValString {
372                val: Some(color.to_string()),
373            }),
374            ..Default::default()
375        }
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::options::Options;
383    use crate::xml::drawing::Fill;
384
385    #[test]
386    fn add_shape_creates_drawing_part() {
387        let f = File::new_with_options(Options::default());
388        f.add_shape(
389            "Sheet1",
390            &Shape {
391                cell: "G6".to_string(),
392                r#type: "rect".to_string(),
393                width: 180,
394                height: 40,
395                fill: Fill {
396                    r#type: "pattern".to_string(),
397                    pattern: 1,
398                    color: vec!["8EB9FF".to_string()],
399                    ..Default::default()
400                },
401                line: LineOptions {
402                    width: 1.2,
403                    fill: Fill {
404                        r#type: "pattern".to_string(),
405                        pattern: 1,
406                        color: vec!["4286F4".to_string()],
407                        ..Default::default()
408                    },
409                    ..Default::default()
410                },
411                paragraph: vec![RichTextRun {
412                    text: "Rectangle Shape".to_string(),
413                    font: Some(Font {
414                        bold: Some(true),
415                        italic: Some(true),
416                        name: Some("Times New Roman".to_string()),
417                        size: Some(18.0),
418                        color: Some("777777".to_string()),
419                        underline: Some("sng".to_string()),
420                        ..Default::default()
421                    }),
422                }],
423                ..Default::default()
424            },
425        )
426        .unwrap();
427
428        let ws = f.work_sheet_reader("Sheet1").unwrap();
429        assert!(ws.drawing.is_some());
430
431        let target = f.get_sheet_relationships_target_by_id(
432            "Sheet1",
433            ws.drawing.as_ref().unwrap().rid.as_deref().unwrap(),
434        );
435        let drawing_xml = target.replace("..", "xl");
436        let (ws_dr, _) = f.drawing_parser(&drawing_xml).unwrap();
437        assert_eq!(ws_dr.two_cell_anchor.len(), 1);
438
439        let anchor = &ws_dr.two_cell_anchor[0];
440        assert_eq!(anchor.from.as_ref().unwrap().col, 6); // G = 7th col, 0-based = 6
441        assert_eq!(anchor.from.as_ref().unwrap().row, 5); // row 6, 0-based = 5
442        let sp = anchor.sp.as_ref().unwrap();
443        assert_eq!(sp.sp_pr.as_ref().unwrap().prst_geom.prst, "rect");
444    }
445}