use super::{collapse_blank_lines, escape_cell};
use crate::utils::display::format_float;
use calamine::{Data, Range, Reader, Sheets, open_workbook_auto_from_rs};
use std::io::Cursor;
const MAX_ROWS_PER_SHEET: usize = 200;
const MAX_COLS_PER_SHEET: usize = 32;
pub(super) fn convert(bytes: &[u8]) -> Result<String, String> {
let mut workbook: Sheets<Cursor<&[u8]>> = open_workbook_auto_from_rs(Cursor::new(bytes))
.map_err(|e| format!("cannot parse spreadsheet: {e}"))?;
let names = workbook.sheet_names();
let mut sections = Vec::new();
for (index, name) in names.iter().enumerate() {
let range = match workbook.worksheet_range_at(index) {
Some(Ok(range)) => range,
Some(Err(_)) | None => continue,
};
if let Some(section) = render_sheet(name, &range) {
sections.push(section);
}
}
Ok(collapse_blank_lines(§ions.join("\n\n")))
}
fn render_sheet(name: &str, range: &Range<Data>) -> Option<String> {
let mut rows: Vec<Vec<String>> = range
.rows()
.map(|row| row.iter().map(cell_text).collect())
.collect();
while rows
.last()
.is_some_and(|row| row.iter().all(|cell| cell.trim().is_empty()))
{
rows.pop();
}
if rows.is_empty() {
return None;
}
let cols = rows
.iter()
.map(|row| {
row.iter()
.rposition(|cell| !cell.trim().is_empty())
.map_or(0, |last| last + 1)
})
.max()
.unwrap_or(0)
.min(MAX_COLS_PER_SHEET);
if cols == 0 {
return None;
}
let mut out = format!("## Sheet: {name}\n\n");
for (index, row) in rows.iter().take(MAX_ROWS_PER_SHEET).enumerate() {
out.push('|');
for column in 0..cols {
let cell = row.get(column).map(String::as_str).unwrap_or("");
out.push(' ');
out.push_str(&escape_cell(cell));
out.push_str(" |");
}
out.push('\n');
if index == 0 {
out.push('|');
for _ in 0..cols {
out.push_str(" --- |");
}
out.push('\n');
}
}
if rows.len() > MAX_ROWS_PER_SHEET {
out.push_str(&format!(
"\n(sheet truncated: {} more rows omitted)\n",
rows.len() - MAX_ROWS_PER_SHEET
));
}
Some(out.trim_end().to_string())
}
fn cell_text(cell: &Data) -> String {
match cell {
Data::Empty => String::new(),
Data::String(text) => text.clone(),
Data::Int(value) => value.to_string(),
Data::Float(value) => format_float(*value),
Data::Bool(value) => value.to_string(),
Data::DateTime(datetime) => datetime
.as_datetime()
.map(|value| {
let midnight = value.time() == chrono::NaiveTime::MIN;
if midnight {
value.format("%Y-%m-%d").to_string()
} else {
value.format("%Y-%m-%d %H:%M:%S").to_string()
}
})
.unwrap_or_else(|| datetime.to_string()),
Data::DateTimeIso(text) | Data::DurationIso(text) => text.clone(),
Data::Error(error) => error.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use calamine::Cell;
#[test]
fn cells_render_without_escaping_regressions() {
assert_eq!(cell_text(&Data::Int(7)), "7");
assert_eq!(cell_text(&Data::String("a|b".to_string())), "a|b");
assert_eq!(cell_text(&Data::Bool(true)), "true");
assert_eq!(cell_text(&Data::Empty), "");
assert_eq!(
cell_text(&Data::Error(calamine::CellErrorType::Div0)),
"#DIV/0!"
);
}
#[test]
fn sheet_renders_header_separator_and_escapes_pipes() {
let range = Range::from_sparse(vec![
Cell::new((0, 0), Data::String("name".to_string())),
Cell::new((0, 1), Data::String("qty".to_string())),
Cell::new((1, 0), Data::String("a|b".to_string())),
Cell::new((1, 1), Data::Float(2.0)),
]);
let text = render_sheet("Sheet1", &range).expect("应有内容");
assert!(text.starts_with("## Sheet: Sheet1"), "{text}");
assert!(text.contains("| name | qty |"), "{text}");
assert!(text.contains("| --- | --- |"), "{text}");
assert!(text.contains("| a\\|b | 2 |"), "{text}");
}
#[test]
fn empty_sheet_is_skipped() {
let range = Range::new((0, 0), (2, 2));
assert_eq!(render_sheet("Empty", &range), None);
}
#[test]
fn trailing_empty_rows_are_trimmed() {
let range = Range::from_sparse(vec![
Cell::new((0, 0), Data::Int(1)),
Cell::new((5, 0), Data::Int(9)),
]);
let text = render_sheet("Rows", &range).expect("应有内容");
assert!(text.lines().last().unwrap().contains(" 9 |"), "{text}");
}
}