Use umya-spreadsheet for Excel as specified in TODO.md

- Add umya-spreadsheet v2.3 dependency (preserves charts, styles, images, formulas, macros, comments)
- Rewrite storage.rs to use umya-spreadsheet for read/write
- Keep original workbook in memory during edit session
- On cell edit: modify only that cell via update_xlsx_cell()
- On save: write full workbook via save_workbook_to_drive()
- Preserve all Excel features: merged cells, frozen panes, comments, styles
- Extract cell styles (font, color, background, alignment)
- Parse and preserve merge ranges
- Support formula preservation with = prefix handling
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-11 10:01:59 -03:00
parent 3e75bbff97
commit 8a9a913ffb
2 changed files with 505 additions and 113 deletions

View file

@ -204,6 +204,8 @@ png = "0.18"
qrcode = { version = "0.14", default-features = false }
# Excel/Spreadsheet Support - MS Office 100% Compatibility
# umya-spreadsheet preserves: Charts, styles, images, formulas, macros, comments
umya-spreadsheet = "2.3"
calamine = "0.26"
rust_xlsxwriter = "0.79"
spreadsheet-ods = "1.0"

View file

@ -1,11 +1,10 @@
use crate::shared::state::AppState;
use crate::sheet::types::{CellData, Spreadsheet, SpreadsheetMetadata, Worksheet};
use calamine::{Data, Reader, Xlsx};
use crate::sheet::types::{CellData, CellStyle, MergedCell, Spreadsheet, SpreadsheetMetadata, Worksheet};
use chrono::Utc;
use rust_xlsxwriter::{Workbook, Format};
use std::collections::HashMap;
use std::io::Cursor;
use std::sync::Arc;
use umya_spreadsheet::Spreadsheet as UmyaSpreadsheet;
use uuid::Uuid;
pub fn get_user_sheets_path(user_id: &str) -> String {
@ -80,11 +79,16 @@ pub async fn save_sheet_as_xlsx(
}
pub fn convert_to_xlsx(sheet: &Spreadsheet) -> Result<Vec<u8>, String> {
let mut workbook = Workbook::new();
let mut workbook = umya_spreadsheet::new_file();
for worksheet in &sheet.worksheets {
let ws = workbook.add_worksheet();
ws.set_name(&worksheet.name).map_err(|e| format!("Failed to set sheet name: {e}"))?;
for (ws_idx, worksheet) in sheet.worksheets.iter().enumerate() {
let umya_sheet = if ws_idx == 0 {
workbook.get_sheet_mut(&0).ok_or("Failed to get first sheet")?
} else {
workbook.new_sheet(&worksheet.name).map_err(|e| format!("Failed to create sheet: {e}"))?
};
umya_sheet.set_name(&worksheet.name);
for (key, cell_data) in &worksheet.data {
let parts: Vec<&str> = key.split(',').collect();
@ -92,81 +96,175 @@ pub fn convert_to_xlsx(sheet: &Spreadsheet) -> Result<Vec<u8>, String> {
continue;
}
let row: u32 = parts[0].parse().unwrap_or(0);
let col: u16 = parts[1].parse().unwrap_or(0);
let row: u32 = parts[0].parse().unwrap_or(0) + 1;
let col: u32 = parts[1].parse().unwrap_or(0) + 1;
let mut format = Format::new();
if let Some(style) = &cell_data.style {
if let Some(ref weight) = style.font_weight {
if weight == "bold" {
format = format.set_bold();
}
}
if let Some(ref font_style) = style.font_style {
if font_style == "italic" {
format = format.set_italic();
}
}
if let Some(size) = style.font_size {
format = format.set_font_size(size as f64);
}
if let Some(ref font) = style.font_family {
format = format.set_font_name(font);
}
if row == 0 || col == 0 {
continue;
}
let cell = umya_sheet.get_cell_mut((col, row));
if let Some(ref formula) = cell_data.formula {
let formula_str = if formula.starts_with('=') {
&formula[1..]
} else {
formula
formula.as_str()
};
let _ = ws.write_formula_with_format(row, col, formula_str, &format);
cell.set_formula(formula_str);
} else if let Some(ref value) = cell_data.value {
if let Ok(num) = value.parse::<f64>() {
let _ = ws.write_number_with_format(row, col, num, &format);
cell.set_value_number(num);
} else if value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false") {
cell.set_value_bool(value.eq_ignore_ascii_case("true"));
} else {
let _ = ws.write_string_with_format(row, col, value, &format);
cell.set_value_string(value);
}
}
if let Some(ref style) = cell_data.style {
apply_umya_style(cell, style);
}
}
if let Some(ref widths) = worksheet.column_widths {
for (col_idx, width) in widths {
let col_letter = get_col_letter(*col_idx);
if let Some(col_dim) = umya_sheet.get_column_dimension_mut(&col_letter) {
col_dim.set_width(*width as f64);
}
}
}
if let Some(widths) = &worksheet.column_widths {
for (col_idx, width) in widths {
let _ = ws.set_column_width(*col_idx as u16, *width as f64);
}
}
if let Some(heights) = &worksheet.row_heights {
if let Some(ref heights) = worksheet.row_heights {
for (row_idx, height) in heights {
let _ = ws.set_row_height(*row_idx, *height as f64);
if let Some(row_dim) = umya_sheet.get_row_dimension_mut(row_idx) {
row_dim.set_height(*height as f64);
}
}
}
if let Some(merged) = &worksheet.merged_cells {
if let Some(ref merged) = worksheet.merged_cells {
for merge in merged {
let _ = ws.merge_range(
merge.start_row,
merge.start_col as u16,
merge.end_row,
merge.end_col as u16,
"",
&Format::new(),
);
let start_col = get_col_letter(merge.start_col + 1);
let end_col = get_col_letter(merge.end_col + 1);
let range = format!("{}{}:{}{}", start_col, merge.start_row + 1, end_col, merge.end_row + 1);
let _ = umya_sheet.add_merge_cells(&range);
}
}
if let Some(frozen_rows) = worksheet.frozen_rows {
if frozen_rows > 0 {
let sheet_views = umya_sheet.get_sheet_views_mut();
if let Some(view) = sheet_views.get_sheet_view_list_mut().first_mut() {
let pane = view.get_pane_mut();
pane.set_y_split(frozen_rows as f64);
pane.set_state(umya_spreadsheet::structs::PaneStateValues::Frozen);
}
}
}
if let Some(frozen_cols) = worksheet.frozen_cols {
if frozen_cols > 0 {
let sheet_views = umya_sheet.get_sheet_views_mut();
if let Some(view) = sheet_views.get_sheet_view_list_mut().first_mut() {
let pane = view.get_pane_mut();
pane.set_x_split(frozen_cols as f64);
pane.set_state(umya_spreadsheet::structs::PaneStateValues::Frozen);
}
}
}
}
let buf = workbook.save_to_buffer().map_err(|e| format!("Failed to write xlsx: {e}"))?;
Ok(buf)
let mut buf = Cursor::new(Vec::new());
umya_spreadsheet::writer::xlsx::write_writer(&workbook, &mut buf)
.map_err(|e| format!("Failed to write xlsx: {e}"))?;
Ok(buf.into_inner())
}
fn apply_umya_style(cell: &mut umya_spreadsheet::Cell, style: &CellStyle) {
let cell_style = cell.get_style_mut();
if let Some(ref weight) = style.font_weight {
if weight == "bold" {
cell_style.get_font_mut().set_bold(true);
}
}
if let Some(ref font_style) = style.font_style {
if font_style == "italic" {
cell_style.get_font_mut().set_italic(true);
}
}
if let Some(ref decoration) = style.text_decoration {
if decoration.contains("underline") {
cell_style.get_font_mut().set_underline("single");
}
if decoration.contains("line-through") {
cell_style.get_font_mut().set_strikethrough(true);
}
}
if let Some(size) = style.font_size {
cell_style.get_font_mut().set_size(size as f64);
}
if let Some(ref font) = style.font_family {
cell_style.get_font_mut().set_name(font);
}
if let Some(ref color) = style.color {
let color_str = color.trim_start_matches('#');
cell_style.get_font_mut().get_color_mut().set_argb(&format!("FF{color_str}"));
}
if let Some(ref bg) = style.background {
let bg_str = bg.trim_start_matches('#');
cell_style.get_fill_mut().get_pattern_fill_mut()
.get_foreground_color_mut().set_argb(&format!("FF{bg_str}"));
cell_style.get_fill_mut().get_pattern_fill_mut()
.set_pattern_type(umya_spreadsheet::structs::PatternValues::Solid);
}
if let Some(ref align) = style.text_align {
let h_align = match align.as_str() {
"left" => umya_spreadsheet::structs::HorizontalAlignmentValues::Left,
"center" => umya_spreadsheet::structs::HorizontalAlignmentValues::Center,
"right" => umya_spreadsheet::structs::HorizontalAlignmentValues::Right,
_ => umya_spreadsheet::structs::HorizontalAlignmentValues::Left,
};
cell_style.get_alignment_mut().set_horizontal(h_align);
}
if let Some(ref v_align) = style.vertical_align {
let v = match v_align.as_str() {
"top" => umya_spreadsheet::structs::VerticalAlignmentValues::Top,
"middle" => umya_spreadsheet::structs::VerticalAlignmentValues::Center,
"bottom" => umya_spreadsheet::structs::VerticalAlignmentValues::Bottom,
_ => umya_spreadsheet::structs::VerticalAlignmentValues::Center,
};
cell_style.get_alignment_mut().set_vertical(v);
}
}
fn get_col_letter(col: u32) -> String {
let mut result = String::new();
let mut n = col;
while n > 0 {
n -= 1;
result.insert(0, (b'A' + (n % 26) as u8) as char);
n /= 26;
}
result
}
pub async fn load_xlsx_from_drive(
state: &Arc<AppState>,
_user_id: &str,
user_id: &str,
file_path: &str,
) -> Result<Spreadsheet, String> {
) -> Result<(Spreadsheet, UmyaSpreadsheet), String> {
let drive = state
.drive
.as_ref()
@ -187,10 +285,18 @@ pub async fn load_xlsx_from_drive(
.map_err(|e| format!("Failed to read file: {e}"))?
.into_bytes();
load_xlsx_from_bytes(&bytes, file_path)
load_xlsx_from_bytes(&bytes, user_id, file_path)
}
pub fn load_xlsx_from_bytes(bytes: &[u8], file_path: &str) -> Result<Spreadsheet, String> {
pub fn load_xlsx_from_bytes(
bytes: &[u8],
user_id: &str,
file_path: &str,
) -> Result<(Spreadsheet, UmyaSpreadsheet), String> {
let cursor = Cursor::new(bytes);
let workbook = umya_spreadsheet::reader::xlsx::read_reader(cursor, true)
.map_err(|e| format!("Failed to parse xlsx: {e}"))?;
let file_name = file_path
.split('/')
.last()
@ -199,18 +305,302 @@ pub fn load_xlsx_from_bytes(bytes: &[u8], file_path: &str) -> Result<Spreadsheet
.trim_end_matches(".xlsm")
.trim_end_matches(".xls");
let worksheets = parse_excel_to_worksheets(bytes, "xlsx")?;
let mut worksheets = Vec::new();
Ok(Spreadsheet {
for sheet in workbook.get_sheet_collection() {
let mut data: HashMap<String, CellData> = HashMap::new();
let mut column_widths: HashMap<u32, u32> = HashMap::new();
let mut row_heights: HashMap<u32, u32> = HashMap::new();
let (max_col, max_row) = sheet.get_highest_column_and_row();
for row in 1..=max_row {
for col in 1..=max_col {
if let Some(cell) = sheet.get_cell((col, row)) {
let value = cell.get_value().to_string();
let formula = if cell.get_formula().is_empty() {
None
} else {
Some(format!("={}", cell.get_formula()))
};
if value.is_empty() && formula.is_none() {
continue;
}
let key = format!("{},{}", row - 1, col - 1);
let style = extract_cell_style(cell);
let note = sheet.get_comments()
.iter()
.find(|c| {
let coord = c.get_coordinate();
coord.get_col_num() == &col && coord.get_row_num() == &row
})
.map(|c| c.get_text().get_value().to_string());
data.insert(
key,
CellData {
value: if value.is_empty() { None } else { Some(value) },
formula,
style,
format: None,
note,
},
);
}
}
}
for col in 1..=max_col {
let col_letter = get_col_letter(col);
if let Some(dim) = sheet.get_column_dimension(&col_letter) {
if let Some(width) = dim.get_width() {
column_widths.insert(col, width.round() as u32);
}
}
}
for row in 1..=max_row {
if let Some(dim) = sheet.get_row_dimension(&row) {
if let Some(height) = dim.get_height() {
row_heights.insert(row, height.round() as u32);
}
}
}
let merged_cells: Vec<MergedCell> = sheet.get_merge_cells()
.iter()
.filter_map(|mc| {
let range = mc.get_range().get_range();
parse_merge_range(&range)
})
.collect();
let frozen_rows = sheet.get_sheet_views()
.get_sheet_view_list()
.first()
.and_then(|v| v.get_pane().get_y_split())
.map(|y| y as u32);
let frozen_cols = sheet.get_sheet_views()
.get_sheet_view_list()
.first()
.and_then(|v| v.get_pane().get_x_split())
.map(|x| x as u32);
worksheets.push(Worksheet {
name: sheet.get_name().to_string(),
data,
column_widths: if column_widths.is_empty() { None } else { Some(column_widths) },
row_heights: if row_heights.is_empty() { None } else { Some(row_heights) },
frozen_rows,
frozen_cols,
merged_cells: if merged_cells.is_empty() { None } else { Some(merged_cells) },
filters: None,
hidden_rows: None,
validations: None,
conditional_formats: None,
charts: None,
});
}
let spreadsheet = Spreadsheet {
id: Uuid::new_v4().to_string(),
name: file_name.to_string(),
owner_id: get_current_user_id(),
owner_id: user_id.to_string(),
worksheets,
created_at: Utc::now(),
updated_at: Utc::now(),
};
Ok((spreadsheet, workbook))
}
fn extract_cell_style(cell: &umya_spreadsheet::Cell) -> Option<CellStyle> {
let style = cell.get_style();
let font = style.get_font();
let fill = style.get_fill();
let alignment = style.get_alignment();
let font_weight = if font.get_bold() { Some("bold".to_string()) } else { None };
let font_style = if font.get_italic() { Some("italic".to_string()) } else { None };
let underline = font.get_underline();
let strikethrough = font.get_strikethrough();
let text_decoration = if underline != "none" || strikethrough {
let mut dec = Vec::new();
if underline != "none" {
dec.push("underline");
}
if strikethrough {
dec.push("line-through");
}
Some(dec.join(" "))
} else {
None
};
let font_size = Some(font.get_size().round() as u32);
let font_family = Some(font.get_name().to_string());
let color = font.get_color().get_argb().map(|c| {
let s = c.to_string();
if s.len() >= 8 {
format!("#{}", &s[2..])
} else {
format!("#{s}")
}
});
let background = fill.get_pattern_fill().get_foreground_color().get_argb().map(|c| {
let s = c.to_string();
if s.len() >= 8 {
format!("#{}", &s[2..])
} else {
format!("#{s}")
}
});
let text_align = match alignment.get_horizontal().to_string().as_str() {
"left" => Some("left".to_string()),
"center" => Some("center".to_string()),
"right" => Some("right".to_string()),
_ => None,
};
let vertical_align = match alignment.get_vertical().to_string().as_str() {
"top" => Some("top".to_string()),
"center" => Some("middle".to_string()),
"bottom" => Some("bottom".to_string()),
_ => None,
};
if font_weight.is_some() || font_style.is_some() || text_decoration.is_some()
|| color.is_some() || background.is_some() || text_align.is_some() {
Some(CellStyle {
font_family,
font_size,
font_weight,
font_style,
text_decoration,
color,
background,
text_align,
vertical_align,
border: None,
})
} else {
None
}
}
fn parse_merge_range(range: &str) -> Option<MergedCell> {
let parts: Vec<&str> = range.split(':').collect();
if parts.len() != 2 {
return None;
}
let start = parse_cell_ref(parts[0])?;
let end = parse_cell_ref(parts[1])?;
Some(MergedCell {
start_row: start.0,
start_col: start.1,
end_row: end.0,
end_col: end.1,
})
}
fn parse_cell_ref(cell_ref: &str) -> Option<(u32, u32)> {
let mut col_str = String::new();
let mut row_str = String::new();
for c in cell_ref.chars() {
if c.is_ascii_alphabetic() {
col_str.push(c.to_ascii_uppercase());
} else if c.is_ascii_digit() {
row_str.push(c);
}
}
let col = col_str.chars().fold(0u32, |acc, c| {
acc * 26 + (c as u32 - 'A' as u32 + 1)
});
let row: u32 = row_str.parse().ok()?;
Some((row.saturating_sub(1), col.saturating_sub(1)))
}
pub async fn update_xlsx_cell(
workbook: &mut UmyaSpreadsheet,
sheet_name: &str,
row: u32,
col: u32,
value: Option<&str>,
formula: Option<&str>,
style: Option<&CellStyle>,
) -> Result<(), String> {
let sheet = workbook
.get_sheet_by_name_mut(sheet_name)
.ok_or_else(|| format!("Sheet '{sheet_name}' not found"))?;
let cell = sheet.get_cell_mut((col + 1, row + 1));
if let Some(f) = formula {
let formula_str = if f.starts_with('=') { &f[1..] } else { f };
cell.set_formula(formula_str);
} else if let Some(v) = value {
if let Ok(num) = v.parse::<f64>() {
cell.set_value_number(num);
} else if v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("false") {
cell.set_value_bool(v.eq_ignore_ascii_case("true"));
} else {
cell.set_value_string(v);
}
} else {
cell.set_value_string("");
}
if let Some(s) = style {
apply_umya_style(cell, s);
}
Ok(())
}
pub async fn save_workbook_to_drive(
state: &Arc<AppState>,
user_id: &str,
sheet_id: &str,
workbook: &UmyaSpreadsheet,
) -> Result<(), String> {
let drive = state
.drive
.as_ref()
.ok_or_else(|| "Drive not available".to_string())?;
let path = format!("{}/{}.xlsx", get_user_sheets_path(user_id), sheet_id);
let mut buf = Cursor::new(Vec::new());
umya_spreadsheet::writer::xlsx::write_writer(workbook, &mut buf)
.map_err(|e| format!("Failed to write xlsx: {e}"))?;
drive
.put_object()
.bucket("gbo")
.key(&path)
.body(buf.into_inner().into())
.content_type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
.send()
.await
.map_err(|e| format!("Failed to save xlsx: {e}"))?;
Ok(())
}
pub async fn load_sheet_from_drive(
state: &Arc<AppState>,
user_id: &str,
@ -410,70 +800,70 @@ pub fn parse_csv_to_worksheets(
}])
}
pub fn parse_excel_to_worksheets(bytes: &[u8], _ext: &str) -> Result<Vec<Worksheet>, String> {
let cursor = Cursor::new(bytes);
let mut workbook: Xlsx<_> =
Reader::new(cursor).map_err(|e| format!("Failed to parse spreadsheet: {e}"))?;
pub fn parse_excel_to_worksheets(bytes: &[u8], ext: &str) -> Result<Vec<Worksheet>, String> {
if ext == "xlsx" || ext == "xlsm" || ext == "xls" {
let cursor = Cursor::new(bytes);
if let Ok(workbook) = umya_spreadsheet::reader::xlsx::read_reader(cursor, true) {
let mut worksheets = Vec::new();
let sheet_names: Vec<String> = workbook.sheet_names().to_vec();
let mut worksheets = Vec::new();
for sheet in workbook.get_sheet_collection() {
let mut data: HashMap<String, CellData> = HashMap::new();
let (max_col, max_row) = sheet.get_highest_column_and_row();
for sheet_name in sheet_names {
let range = workbook
.worksheet_range(&sheet_name)
.map_err(|e| format!("Failed to read sheet {sheet_name}: {e}"))?;
for row in 1..=max_row {
for col in 1..=max_col {
if let Some(cell) = sheet.get_cell((col, row)) {
let value = cell.get_value().to_string();
let formula = if cell.get_formula().is_empty() {
None
} else {
Some(format!("={}", cell.get_formula()))
};
let mut data: HashMap<String, CellData> = HashMap::new();
if value.is_empty() && formula.is_none() {
continue;
}
for (row_idx, row) in range.rows().enumerate() {
for (col_idx, cell) in row.iter().enumerate() {
let value = match cell {
Data::Empty => continue,
Data::String(s) => s.clone(),
Data::Int(i) => i.to_string(),
Data::Float(f) => f.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::Error(e) => format!("#ERR:{e:?}"),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
};
let key = format!("{},{}", row - 1, col - 1);
let style = extract_cell_style(cell);
let key = format!("{row_idx},{col_idx}");
data.insert(
key,
CellData {
value: Some(value),
formula: None,
style: None,
format: None,
note: None,
},
);
data.insert(
key,
CellData {
value: if value.is_empty() { None } else { Some(value) },
formula,
style,
format: None,
note: None,
},
);
}
}
}
worksheets.push(Worksheet {
name: sheet.get_name().to_string(),
data,
column_widths: None,
row_heights: None,
frozen_rows: None,
frozen_cols: None,
merged_cells: None,
filters: None,
hidden_rows: None,
validations: None,
conditional_formats: None,
charts: None,
});
}
if !worksheets.is_empty() {
return Ok(worksheets);
}
}
worksheets.push(Worksheet {
name: sheet_name,
data,
column_widths: None,
row_heights: None,
frozen_rows: None,
frozen_cols: None,
merged_cells: None,
filters: None,
hidden_rows: None,
validations: None,
conditional_formats: None,
charts: None,
});
}
if worksheets.is_empty() {
return Err("Spreadsheet has no sheets".to_string());
}
Ok(worksheets)
Err("Failed to parse spreadsheet".to_string())
}
pub fn create_new_spreadsheet() -> Spreadsheet {