Skip to main content

sheetkit_core/
vml.rs

1//! VML (Vector Markup Language) support for Excel legacy comment rendering.
2//!
3//! Excel uses VML drawing parts (`xl/drawings/vmlDrawingN.vml`) to render
4//! comment/note pop-up boxes in the UI. This module generates minimal VML
5//! markup for new comments and tracks preserved VML bytes for round-tripping.
6
7use crate::utils::cell_ref::cell_name_to_coordinates;
8
9/// Default comment box width in columns (roughly 2 columns).
10const DEFAULT_COMMENT_WIDTH_COLS: u32 = 2;
11/// Default comment box height in rows (roughly 4 rows).
12const DEFAULT_COMMENT_HEIGHT_ROWS: u32 = 4;
13
14/// Build a complete VML drawing document containing shapes for each comment cell.
15///
16/// `cells` is a list of cell references (e.g. `["A1", "B3"]`).
17/// Returns the VML XML string.
18pub fn build_vml_drawing(cells: &[&str]) -> String {
19    let mut shapes = String::new();
20    for (i, cell) in cells.iter().enumerate() {
21        let shape_id = 1025 + i;
22        if let Ok((col, row)) = cell_name_to_coordinates(cell) {
23            let anchor = comment_anchor(col, row);
24            let row_0 = row - 1;
25            let col_0 = col - 1;
26            let z = i + 1;
27            write_vml_shape(&mut shapes, shape_id, z, &anchor, row_0, col_0);
28        }
29    }
30
31    let mut doc = String::with_capacity(1024 + shapes.len());
32    doc.push_str("<xml xmlns:v=\"urn:schemas-microsoft-com:vml\"");
33    doc.push_str(" xmlns:o=\"urn:schemas-microsoft-com:office:office\"");
34    doc.push_str(" xmlns:x=\"urn:schemas-microsoft-com:office:excel\">\n");
35    doc.push_str(" <o:shapelayout v:ext=\"edit\">\n");
36    doc.push_str("  <o:idmap v:ext=\"edit\" data=\"1\"/>\n");
37    doc.push_str(" </o:shapelayout>\n");
38    doc.push_str(" <v:shapetype id=\"_x0000_t202\" coordsize=\"21600,21600\"");
39    doc.push_str(" o:spt=\"202\" path=\"m,l,21600r21600,l21600,xe\">\n");
40    doc.push_str("  <v:stroke joinstyle=\"miter\"/>\n");
41    doc.push_str("  <v:path gradientshapeok=\"t\" o:connecttype=\"rect\"/>\n");
42    doc.push_str(" </v:shapetype>\n");
43    doc.push_str(&shapes);
44    doc.push_str("</xml>\n");
45    doc
46}
47
48/// Write a single VML shape element for a comment to the output string.
49fn write_vml_shape(
50    out: &mut String,
51    shape_id: usize,
52    z_index: usize,
53    anchor: &str,
54    row_0: u32,
55    col_0: u32,
56) {
57    use std::fmt::Write;
58    let _ = write!(out, " <v:shape id=\"_x0000_s{}\"", shape_id);
59    out.push_str(" type=\"#_x0000_t202\"");
60    let _ = write!(
61        out,
62        " style=\"position:absolute;margin-left:59.25pt;margin-top:1.5pt;\
63         width:108pt;height:59.25pt;z-index:{};visibility:hidden\"",
64        z_index
65    );
66    out.push_str(" fillcolor=\"#ffffe1\" o:insetmode=\"auto\">\n");
67    out.push_str("  <v:fill color2=\"#ffffe1\"/>\n");
68    out.push_str("  <v:shadow on=\"t\" color=\"black\" obscured=\"t\"/>\n");
69    out.push_str("  <v:path o:connecttype=\"none\"/>\n");
70    out.push_str("  <v:textbox/>\n");
71    out.push_str("  <x:ClientData ObjectType=\"Note\">\n");
72    out.push_str("   <x:MoveWithCells/>\n");
73    out.push_str("   <x:SizeWithCells/>\n");
74    let _ = writeln!(out, "   <x:Anchor>{}</x:Anchor>", anchor);
75    let _ = writeln!(out, "   <x:Row>{}</x:Row>", row_0);
76    let _ = writeln!(out, "   <x:Column>{}</x:Column>", col_0);
77    out.push_str("  </x:ClientData>\n");
78    out.push_str(" </v:shape>\n");
79}
80
81/// Compute the 8-value anchor string for a comment box near a cell.
82///
83/// Format: "LeftCol, LeftOff, TopRow, TopOff, RightCol, RightOff, BottomRow, BottomOff"
84/// Offsets are in Excel internal units (EMU/15). We use zero offsets for simplicity.
85fn comment_anchor(col: u32, row: u32) -> String {
86    let left_col = col;
87    let top_row = if row > 1 { row - 2 } else { 0 };
88    let right_col = col + DEFAULT_COMMENT_WIDTH_COLS;
89    let bottom_row = top_row + DEFAULT_COMMENT_HEIGHT_ROWS;
90    format!("{left_col}, 15, {top_row}, 10, {right_col}, 15, {bottom_row}, 4")
91}
92
93/// Extract comment cell references from an existing VML drawing XML string.
94///
95/// Scans for `<x:Row>` and `<x:Column>` elements in the VML and returns
96/// (row_0based, col_0based) pairs.
97pub fn extract_vml_comment_cells(vml_xml: &str) -> Vec<(u32, u32)> {
98    let mut cells = Vec::new();
99    let mut current_row: Option<u32> = None;
100    let mut current_col: Option<u32> = None;
101
102    for line in vml_xml.lines() {
103        let trimmed = line.trim();
104        if let Some(val) = extract_element_value(trimmed, "x:Row") {
105            current_row = val.parse().ok();
106        }
107        if let Some(val) = extract_element_value(trimmed, "x:Column") {
108            current_col = val.parse().ok();
109        }
110        if current_row.is_some() && current_col.is_some() {
111            cells.push((current_row.unwrap(), current_col.unwrap()));
112            current_row = None;
113            current_col = None;
114        }
115    }
116    cells
117}
118
119/// Simple extraction of text content from an XML element like `<tag>value</tag>`.
120fn extract_element_value<'a>(line: &'a str, tag: &str) -> Option<&'a str> {
121    let open = format!("<{}>", tag);
122    let close = format!("</{}>", tag);
123    if let Some(start) = line.find(&open) {
124        let val_start = start + open.len();
125        if let Some(end) = line.find(&close) {
126            return Some(&line[val_start..end]);
127        }
128    }
129    None
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn test_build_vml_drawing_single_cell() {
138        let vml = build_vml_drawing(&["A1"]);
139        assert!(vml.contains("xmlns:v=\"urn:schemas-microsoft-com:vml\""));
140        assert!(vml.contains("<x:Row>0</x:Row>"));
141        assert!(vml.contains("<x:Column>0</x:Column>"));
142        assert!(vml.contains("ObjectType=\"Note\""));
143        assert!(vml.contains("_x0000_t202"));
144        assert!(vml.contains("fillcolor=\"#ffffe1\""));
145    }
146
147    #[test]
148    fn test_build_vml_drawing_multiple_cells() {
149        let vml = build_vml_drawing(&["A1", "C5"]);
150        assert!(vml.contains("<x:Row>0</x:Row>"));
151        assert!(vml.contains("<x:Column>0</x:Column>"));
152        assert!(vml.contains("<x:Row>4</x:Row>"));
153        assert!(vml.contains("<x:Column>2</x:Column>"));
154        assert!(vml.contains("_x0000_s1025"));
155        assert!(vml.contains("_x0000_s1026"));
156    }
157
158    #[test]
159    fn test_build_vml_drawing_empty() {
160        let vml = build_vml_drawing(&[]);
161        assert!(vml.contains("<o:shapelayout"));
162        assert!(vml.contains("<v:shapetype"));
163        assert!(!vml.contains("<v:shape id="));
164    }
165
166    #[test]
167    fn test_extract_vml_comment_cells() {
168        let vml = build_vml_drawing(&["B3", "D10"]);
169        let cells = extract_vml_comment_cells(&vml);
170        assert_eq!(cells.len(), 2);
171        assert_eq!(cells[0], (2, 1));
172        assert_eq!(cells[1], (9, 3));
173    }
174
175    #[test]
176    fn test_comment_anchor_format() {
177        let anchor = comment_anchor(1, 1);
178        assert!(anchor.contains(", "));
179        let parts: Vec<&str> = anchor.split(", ").collect();
180        assert_eq!(parts.len(), 8);
181    }
182
183    #[test]
184    fn test_extract_element_value() {
185        assert_eq!(
186            extract_element_value("<x:Row>5</x:Row>", "x:Row"),
187            Some("5")
188        );
189        assert_eq!(
190            extract_element_value("<x:Column>3</x:Column>", "x:Column"),
191            Some("3")
192        );
193        assert_eq!(extract_element_value("no match here", "x:Row"), None);
194    }
195}