Repository navigation
CSV export: comma and semicolon types named after what the file holds #2466
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
579490e
fa26784
a2e227a
e0494a7
728dfb5
6d45059
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -248,8 +248,10 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8 | |
| FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), | ||
| FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), | ||
| // A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`). | ||
| FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default), | ||
| FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default), | ||
| FileKind::Csv => { | ||
| wb.sheet(sheet).map(|sh| csv(wb, sh, CsvStyle::plain(wb.csv_delimiter.unwrap_or(b',')))).transpose().map(Option::unwrap_or_default) | ||
| } | ||
| FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, CsvStyle::plain(b'\t'))).transpose().map(Option::unwrap_or_default), | ||
| FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), | ||
| FileKind::Html => to_html(wb, sheet).map(String::into_bytes), | ||
| } | ||
|
|
@@ -275,23 +277,75 @@ pub fn source_path(path: &str) -> Option<std::path::PathBuf> { | |
| std::path::absolute(path).ok() | ||
| } | ||
|
|
||
| /// How a CSV export is written. | ||
| /// | ||
| /// The names say what the file holds. Excel's do not: with the Windows list separator set to `;` | ||
| /// (Italian, German, French… regions), Excel's «CSV UTF-8 (Comma delimited)» writes semicolons | ||
| /// and decimal commas, and its «CSV (Comma delimited)» writes the same in ANSI (observed in Excel | ||
| /// 365 with the Italian interface, 2026-10). GridCraft's comma CSV holds commas, and the | ||
| /// semicolon CSV is a separate, explicit choice. | ||
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| pub struct CsvStyle { | ||
| /// Field delimiter (`,` or `;`). | ||
| pub delimiter: u8, | ||
| /// Numbers are written with a decimal comma and a period between digit groups (`1.234,5`), | ||
| /// as regions that use `;` as list separator read them. Dates and times are left as shown. | ||
| pub decimal_comma: bool, | ||
| /// A UTF-8 byte order mark first, which Excel needs to read the file as UTF-8 (without it, | ||
| /// accented letters come out garbled in Excel on Windows). | ||
| pub bom: bool, | ||
| } | ||
|
|
||
| impl CsvStyle { | ||
| pub const fn plain(delimiter: u8) -> CsvStyle { | ||
| CsvStyle { delimiter, decimal_comma: false, bom: false } | ||
| } | ||
| } | ||
|
|
||
| /// The active sheet of `wb` as CSV in `style`. | ||
| pub fn csv_bytes(wb: &Workbook, style: CsvStyle) -> Result<Vec<u8>> { | ||
| match wb.sheet(wb.active_sheet()) { | ||
| Some(sh) => csv(wb, sh, style), | ||
| None => Ok(Vec::new()), | ||
| } | ||
| } | ||
|
|
||
| /// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all | ||
| /// characters in each cell" (Microsoft Support, *Excel formatting and features that are not | ||
| /// transferred to other file formats*): number formats applied, never column-width `#####`. | ||
| fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> { | ||
| gridcraft_xlsx::write_csv(sh, delimiter, |c| { | ||
| fn csv(wb: &Workbook, sh: &Sheet, style: CsvStyle) -> Result<Vec<u8>> { | ||
| let mut body = gridcraft_xlsx::write_csv(sh, style.delimiter, |c| { | ||
| let value = sh.value(c); | ||
| let code = wb.styles.get(sh.style_id(c)).num_fmt.as_str(); | ||
| // General's grid-width formatting rounds large identifiers to scientific notation. | ||
| // Delimited files have no column width, so preserve the whole numeric value instead. | ||
| if wb.styles.get(sh.style_id(c)).num_fmt.as_str().eq_ignore_ascii_case("General") | ||
| && let gridcraft_core::Value::Number(number) = sh.value(c) | ||
| let text = if code.eq_ignore_ascii_case("General") | ||
| && let gridcraft_core::Value::Number(number) = value | ||
| && number.is_finite() | ||
| { | ||
| number.to_string() | ||
| } else { | ||
| crate::display::value_text(wb, sh, c) | ||
| }; | ||
| if style.decimal_comma && value.is_number() && !crate::display::number_format(code).is_date() { | ||
| // `1,234.5` becomes `1.234,5`: the decimal point and the group separator swap. | ||
| text.chars() | ||
| .map(|ch| match ch { | ||
| '.' => ',', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parse exported decimal-comma numbers as numbers on reopen. Even when the reader selects semicolon correctly, it passes
|
||
| ',' => '.', | ||
| other => other, | ||
| }) | ||
| .collect() | ||
| } else { | ||
| text | ||
| } | ||
| }) | ||
| .map_err(|e| EngineError::Other(e.to_string())) | ||
| .map_err(|e| EngineError::Other(e.to_string()))?; | ||
| if style.bom { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Avoid copying the full CSV body to add the BOM. When
|
||
| // In place: a body near the size cap is not copied into a second buffer. | ||
| body.splice(0..0, *b"\xEF\xBB\xBF"); | ||
| } | ||
| Ok(body) | ||
| } | ||
|
Comment on lines
342
to
349
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Convert only numeric separators.
Move decimal-comma conversion into number-format rendering so it changes numeric decimal/group tokens before custom literals are appended. Do not swap characters in the final displayed string.
|
||
|
|
||
| pub(crate) fn esc(s: &str) -> String { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve column boundaries when importing semicolon CSV.
If two numeric cells export as
1,5;2,25, the CSV reader counts two commas and one semicolon. Its delimiter detector selects comma, so reopening the file splits two cells into three fields. Make delimiter detection account for decimal commas before treating them as field separators. Add a round-trip test with more numeric cells than semicolon separators. (raw.githubusercontent.com)