writer.rs (6461B)
1 use std::path::Path; 2 3 use anyhow::{bail, Context, Result}; 4 5 use crate::cell::{CellAssignment, CellValue}; 6 7 /// Write cell assignments to an Excel workbook, preserving existing content and formatting. 8 /// 9 /// Opens the workbook at `input_path`, resolves the target sheet, applies all assignments, 10 /// and saves the result to `output_path`. 11 /// 12 /// Returns `(count_of_cells_updated, resolved_sheet_name)`. 13 pub fn write_cells( 14 input_path: &Path, 15 output_path: &Path, 16 sheet_selector: &str, 17 assignments: &[CellAssignment], 18 ) -> Result<(usize, String)> { 19 // Validate file extension 20 let ext = input_path 21 .extension() 22 .and_then(|e| e.to_str()) 23 .unwrap_or(""); 24 match ext.to_ascii_lowercase().as_str() { 25 "xlsx" | "xlsm" => {} 26 "xls" => bail!( 27 "legacy .xls format is not supported — please convert to .xlsx first" 28 ), 29 other => bail!("unsupported file extension '.{}' — expected .xlsx or .xlsm", other), 30 } 31 32 // Open workbook 33 let mut book = umya_spreadsheet::reader::xlsx::read(input_path) 34 .with_context(|| format!("failed to open workbook '{}'", input_path.display()))?; 35 36 // Resolve sheet 37 let sheet_count = book.get_sheet_count(); 38 let sheet_index = resolve_sheet_index(&book, sheet_selector, sheet_count)?; 39 40 let sheet = book 41 .get_sheet_mut(&sheet_index) 42 .with_context(|| format!("failed to access sheet at index {}", sheet_index))?; 43 44 let sheet_name = sheet.get_name().to_string(); 45 46 // Apply assignments 47 for assignment in assignments { 48 apply_assignment(sheet, assignment); 49 } 50 51 // Save 52 umya_spreadsheet::writer::xlsx::write(&book, output_path) 53 .with_context(|| format!("failed to write workbook to '{}'", output_path.display()))?; 54 55 Ok((assignments.len(), sheet_name)) 56 } 57 58 /// Resolve a sheet selector string to a 0-based sheet index. 59 /// 60 /// - Empty string → first sheet (index 0) 61 /// - Try matching by name first 62 /// - Then try parsing as a 0-based numeric index 63 /// - On failure, list available sheet names in the error 64 fn resolve_sheet_index( 65 book: &umya_spreadsheet::Spreadsheet, 66 selector: &str, 67 sheet_count: usize, 68 ) -> Result<usize> { 69 if selector.is_empty() { 70 return Ok(0); 71 } 72 73 // Try name match 74 let sheets = book.get_sheet_collection_no_check(); 75 for (i, ws) in sheets.iter().enumerate() { 76 if ws.get_name() == selector { 77 return Ok(i); 78 } 79 } 80 81 // Try 0-based index 82 if let Ok(idx) = selector.parse::<usize>() { 83 if idx < sheet_count { 84 return Ok(idx); 85 } 86 } 87 88 // Build error with available names 89 let names: Vec<&str> = sheets.iter().map(|ws| ws.get_name()).collect(); 90 bail!( 91 "sheet '{}' not found — available sheets: [{}]", 92 selector, 93 names.join(", ") 94 ); 95 } 96 97 /// Apply a single cell assignment to a worksheet. 98 fn apply_assignment( 99 sheet: &mut umya_spreadsheet::Worksheet, 100 assignment: &CellAssignment, 101 ) { 102 let cell = sheet.get_cell_mut(assignment.cell.label.as_str()); 103 104 match &assignment.value { 105 CellValue::String(s) => { 106 cell.set_value_string(s); 107 } 108 CellValue::Integer(i) => { 109 cell.set_value_number(*i as f64); 110 } 111 CellValue::Float(f) => { 112 cell.set_value_number(*f); 113 } 114 CellValue::Bool(b) => { 115 cell.set_value_bool(*b); 116 } 117 CellValue::Date { year, month, day } => { 118 let serial = date_to_serial(*year, *month, *day); 119 cell.set_value_number(serial); 120 cell.get_style_mut() 121 .get_number_format_mut() 122 .set_format_code("yyyy-mm-dd"); 123 } 124 CellValue::Empty => { 125 cell.set_value_string(""); 126 } 127 } 128 } 129 130 /// Convert a (year, month, day) date to an Excel serial date number. 131 /// 132 /// Excel serial dates count days since January 0, 1900 (i.e., Jan 1, 1900 = 1). 133 /// This function accounts for the Lotus 1-2-3 bug: Excel erroneously treats 1900 134 /// as a leap year, so dates after Feb 28, 1900 are incremented by 1 to match 135 /// Excel's numbering. 136 fn date_to_serial(year: i32, month: u32, day: u32) -> f64 { 137 // Days in each month (non-leap) 138 let days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 139 140 let mut total_days: i64 = 0; 141 142 // Count full years from 1900 to year-1 143 for y in 1900..year { 144 total_days += if is_leap_year(y) { 366 } else { 365 }; 145 } 146 147 // Count full months in the target year 148 for m in 1..month { 149 total_days += days_in_month[m as usize] as i64; 150 if m == 2 && is_leap_year(year) { 151 total_days += 1; 152 } 153 } 154 155 // Add days 156 total_days += day as i64; 157 158 // Excel serial: Jan 1, 1900 = 1 (not 0) 159 // Lotus 1-2-3 bug: Excel thinks Feb 29, 1900 exists. 160 // Dates on or after Mar 1, 1900 (serial >= 61) need +1 to compensate. 161 // Feb 29, 1900 itself would be serial 60 in Excel's world (the phantom day). 162 if total_days >= 60 { 163 total_days += 1; 164 } 165 166 total_days as f64 167 } 168 169 /// Check if a year is a real leap year. 170 fn is_leap_year(year: i32) -> bool { 171 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) 172 } 173 174 #[cfg(test)] 175 mod tests { 176 use super::*; 177 178 #[test] 179 fn test_date_to_serial_known_dates() { 180 // Jan 1, 1900 = serial 1 181 assert_eq!(date_to_serial(1900, 1, 1), 1.0); 182 // Jan 1, 2024 = serial 45292 183 assert_eq!(date_to_serial(2024, 1, 1), 45292.0); 184 } 185 186 #[test] 187 fn test_date_to_serial_epoch_boundary() { 188 // Feb 28, 1900 = serial 59 (last real date before the phantom leap day) 189 assert_eq!(date_to_serial(1900, 2, 28), 59.0); 190 // Mar 1, 1900 = serial 61 (after the phantom Feb 29) 191 assert_eq!(date_to_serial(1900, 3, 1), 61.0); 192 } 193 194 #[test] 195 fn test_date_to_serial_common_dates() { 196 // Dec 31, 1899 is not representable (before epoch) — but Jan 2, 1900 = 2 197 assert_eq!(date_to_serial(1900, 1, 2), 2.0); 198 // Excel: 2000-01-01 = 36526 199 assert_eq!(date_to_serial(2000, 1, 1), 36526.0); 200 } 201 202 #[test] 203 fn test_is_leap_year() { 204 assert!(!is_leap_year(1900)); // not a real leap year 205 assert!(is_leap_year(2000)); // divisible by 400 206 assert!(is_leap_year(2024)); // divisible by 4, not by 100 207 assert!(!is_leap_year(1999)); // odd year 208 } 209 }