diff --git a/Cargo.toml b/Cargo.toml index 4b29ddd9d..eb6b4dc7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/sheet/storage.rs b/src/sheet/storage.rs index 29a421d39..44da4c78d 100644 --- a/src/sheet/storage.rs +++ b/src/sheet/storage.rs @@ -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, 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, 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::() { - 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, - _user_id: &str, + user_id: &str, file_path: &str, -) -> Result { +) -> 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 { +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 = HashMap::new(); + let mut column_widths: HashMap = HashMap::new(); + let mut row_heights: HashMap = 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 = 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 { + 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 { + 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::() { + 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, + 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, user_id: &str, @@ -410,70 +800,70 @@ pub fn parse_csv_to_worksheets( }]) } -pub fn parse_excel_to_worksheets(bytes: &[u8], _ext: &str) -> Result, 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, 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 = workbook.sheet_names().to_vec(); - let mut worksheets = Vec::new(); + for sheet in workbook.get_sheet_collection() { + let mut data: HashMap = 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 = 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 {