use std::collections::HashMap;
use std::fmt::Write as _;
use officemd_core::ir::{
DocumentKind, DocumentProperties, FormulaNote, Inline, OoxmlDocument, Paragraph, Sheet, Table,
TableCell,
};
use crate::error::CsvError;
#[derive(Debug, Clone)]
pub struct CsvExtractOptions {
pub delimiter: u8,
pub flexible: bool,
pub include_document_properties: bool,
pub sheet_name: String,
}
impl Default for CsvExtractOptions {
fn default() -> Self {
Self {
delimiter: b',',
flexible: true,
include_document_properties: false,
sheet_name: "Sheet1".to_string(),
}
}
}
use officemd_core::ir::synthetic_col_headers;
pub fn extract_tables_ir(content: &[u8]) -> Result<OoxmlDocument, CsvError> {
extract_tables_ir_with_options(content, CsvExtractOptions::default())
}
pub fn extract_tables_ir_with_options(
content: &[u8],
options: CsvExtractOptions,
) -> Result<OoxmlDocument, CsvError> {
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.delimiter(options.delimiter)
.flexible(options.flexible)
.from_reader(content);
let mut max_cols = 0usize;
let mut table_rows: Vec<Vec<TableCell>> = Vec::new();
let mut formulas = Vec::new();
for (row_idx, record) in reader.records().enumerate() {
let record = record?;
max_cols = max_cols.max(record.len());
let mut out_row = Vec::with_capacity(record.len().max(1));
for (col_idx, value) in record.iter().enumerate() {
let value = value.to_string();
if let Some(formula) = value.strip_prefix('=') {
let trimmed = formula.trim();
if !trimmed.is_empty() {
let col_name = col_to_name(col_idx + 1);
let mut cell_ref = String::with_capacity(col_name.len() + 20);
cell_ref.push_str(&col_name);
let _ = write!(cell_ref, "{}", row_idx + 1);
formulas.push(FormulaNote {
cell_ref,
formula: trimmed.to_string(),
});
}
}
out_row.push(TableCell {
content: vec![Paragraph {
inlines: vec![Inline::Text(value)],
}],
});
}
table_rows.push(out_row);
}
let cols = max_cols.max(1);
let headers = synthetic_col_headers(cols);
for row in &mut table_rows {
while row.len() < cols {
row.push(TableCell {
content: vec![Paragraph {
inlines: vec![Inline::Text(String::new())],
}],
});
}
}
if table_rows.is_empty() {
table_rows.push(vec![TableCell {
content: vec![Paragraph {
inlines: vec![Inline::Text(String::new())],
}],
}]);
}
let caption = Some(format!(
"Table 1 (rows 1–{}, cols A–{})",
table_rows.len().max(1),
col_to_name(cols)
));
let table = Table {
caption,
headers,
rows: table_rows,
synthetic_headers: true,
};
let properties = options
.include_document_properties
.then(|| build_properties(options.delimiter));
Ok(OoxmlDocument {
kind: DocumentKind::Xlsx,
properties,
sheets: vec![Sheet {
name: options.sheet_name,
tables: vec![table],
formulas,
hyperlinks: Vec::new(),
}],
..Default::default()
})
}
pub fn extract_tables_ir_json(content: &[u8]) -> Result<String, CsvError> {
let doc = extract_tables_ir(content)?;
serde_json::to_string(&doc).map_err(|e| CsvError::Json(e.to_string()))
}
pub fn extract_tables_ir_json_with_options(
content: &[u8],
delimiter: u8,
include_document_properties: bool,
) -> Result<String, CsvError> {
let doc = extract_tables_ir_with_options(
content,
CsvExtractOptions {
delimiter,
include_document_properties,
..Default::default()
},
)?;
serde_json::to_string(&doc).map_err(|e| CsvError::Json(e.to_string()))
}
fn build_properties(delimiter: u8) -> DocumentProperties {
let mut custom = HashMap::new();
custom.insert("source_format".to_string(), "csv".to_string());
custom.insert("delimiter".to_string(), char::from(delimiter).to_string());
DocumentProperties {
core: HashMap::new(),
app: HashMap::new(),
custom,
}
}
fn col_to_name(mut n: usize) -> String {
let mut reversed = Vec::new();
while n > 0 {
let rem = (n - 1) % 26;
#[allow(clippy::cast_possible_truncation)]
reversed.push((b'A' + rem as u8) as char);
n = (n - 1) / 26;
}
reversed.into_iter().rev().collect()
}