use chrono::{Duration, NaiveDate};
use crate::api::DateFormat;
use crate::builder::ConversionConfig;
use crate::error::XlsxToMdError;
use crate::types::{CellValue, RawCellData};
#[derive(Debug)]
pub(crate) struct CellFormatter {
date_formatter: DateFormatter,
number_formatter: NumberFormatter,
}
impl CellFormatter {
pub fn new() -> Self {
Self {
date_formatter: DateFormatter,
number_formatter: NumberFormatter,
}
}
pub fn format_cell(
&self,
raw_cell: &RawCellData,
config: &ConversionConfig,
is_1904: bool,
) -> Result<String, XlsxToMdError> {
use crate::api::FormulaMode;
if config.formula_mode == FormulaMode::Formula {
if let Some(ref formula) = raw_cell.formula {
return Ok(formula.clone());
}
}
let formatted_value = match &raw_cell.value {
CellValue::Number(n) => {
if self.is_date_value(*n, &raw_cell.format_id, &raw_cell.format_string) {
self.date_formatter.format(*n, config, is_1904)?
} else {
self.number_formatter.format(*n, &raw_cell.format_string)?
}
}
CellValue::String(s) => {
if let Some(ref rich_text_segments) = raw_cell.rich_text {
self.format_rich_text(rich_text_segments)
} else {
self.escape_markdown(s)
}
}
CellValue::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
CellValue::Error(e) => e.clone(),
CellValue::Empty => String::new(),
};
if let Some(ref url) = raw_cell.hyperlink {
let display_text = if formatted_value.is_empty() {
url.clone()
} else {
formatted_value
};
Ok(format!("[{}]({})", display_text, url))
} else {
Ok(formatted_value)
}
}
fn is_date_value(
&self,
_value: f64,
format_id: &Option<u16>,
format_string: &Option<String>,
) -> bool {
if let Some(id) = format_id {
if matches!(id, 14..=22 | 45..=47) {
return true;
}
}
if let Some(ref format_str) = format_string {
let format_lower = format_str.to_lowercase();
if format_lower.contains("yy")
|| format_lower.contains("mm")
|| format_lower.contains("dd")
|| format_lower.contains("hh")
{
return true;
}
}
false
}
fn escape_markdown(&self, s: &str) -> String {
s.replace('\\', "\\\\")
.replace('|', "\\|")
.replace('\n', "<br>")
}
fn format_rich_text(&self, segments: &[crate::types::RichTextSegment]) -> String {
let mut result = String::new();
for segment in segments {
let mut text = self.escape_markdown(&segment.text);
if segment.format.bold && segment.format.italic {
text = format!("***{}***", text);
} else if segment.format.bold {
text = format!("**{}**", text);
} else if segment.format.italic {
text = format!("*{}*", text);
}
result.push_str(&text);
}
result
}
}
impl Default for CellFormatter {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub(crate) struct DateFormatter;
impl DateFormatter {
pub fn format(
&self,
serial_value: f64,
config: &ConversionConfig,
is_1904: bool,
) -> Result<String, XlsxToMdError> {
let (epoch, days_offset) = if is_1904 {
let epoch = NaiveDate::from_ymd_opt(1904, 1, 1)
.ok_or_else(|| XlsxToMdError::Config("Invalid epoch date".to_string()))?;
(epoch, 0i64)
} else {
let epoch = NaiveDate::from_ymd_opt(1899, 12, 30)
.ok_or_else(|| XlsxToMdError::Config("Invalid epoch date".to_string()))?;
(epoch, 1i64)
};
let days = serial_value.floor() as i64;
let date = epoch
.checked_add_signed(Duration::days(days + days_offset))
.ok_or_else(|| {
XlsxToMdError::Config(format!(
"Date calculation overflow: serial_value={}, is_1904={}",
serial_value, is_1904
))
})?;
let formatted = match &config.date_format {
DateFormat::Iso8601 => date.format("%Y-%m-%d").to_string(),
DateFormat::Custom(format_str) => date.format(format_str).to_string(),
};
Ok(formatted)
}
}
#[derive(Debug)]
pub(crate) struct NumberFormatter;
impl NumberFormatter {
pub fn format(
&self,
value: f64,
format_string: &Option<String>,
) -> Result<String, XlsxToMdError> {
if let Some(ref format_str) = format_string {
match crate::format::FormatParser::new(format_str) {
Ok(parser) => {
match parser.format_number(value) {
Ok(formatted) => Ok(formatted),
Err(_) => {
Ok(value.to_string())
}
}
}
Err(_) => {
Ok(value.to_string())
}
}
} else {
Ok(value.to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::{DateFormat, FormulaMode};
use crate::types::{CellCoord, CellValue, RawCellData};
fn create_test_config() -> ConversionConfig {
ConversionConfig::default()
}
fn create_test_config_with_date_format(date_format: DateFormat) -> ConversionConfig {
ConversionConfig {
date_format,
..Default::default()
}
}
fn create_test_config_with_formula_mode(formula_mode: FormulaMode) -> ConversionConfig {
ConversionConfig {
formula_mode,
..Default::default()
}
}
#[test]
fn test_escape_markdown() {
let formatter = CellFormatter::new();
assert_eq!(formatter.escape_markdown("test"), "test");
assert_eq!(formatter.escape_markdown("test|value"), "test\\|value");
assert_eq!(formatter.escape_markdown("test\nvalue"), "test<br>value");
assert_eq!(formatter.escape_markdown("test\\value"), "test\\\\value");
assert_eq!(
formatter.escape_markdown("test|value\nwith\\backslash"),
"test\\|value<br>with\\\\backslash"
);
}
#[test]
fn test_is_date_value_by_format_id() {
let formatter = CellFormatter::new();
assert!(formatter.is_date_value(1.0, &Some(14), &None));
assert!(formatter.is_date_value(1.0, &Some(22), &None));
assert!(formatter.is_date_value(1.0, &Some(45), &None));
assert!(!formatter.is_date_value(1.0, &Some(1), &None)); }
#[test]
fn test_is_date_value_by_format_string() {
let formatter = CellFormatter::new();
assert!(formatter.is_date_value(1.0, &None, &Some("yyyy-mm-dd".to_string())));
assert!(formatter.is_date_value(1.0, &None, &Some("MM/DD/YY".to_string())));
assert!(formatter.is_date_value(1.0, &None, &Some("hh:mm:ss".to_string())));
assert!(!formatter.is_date_value(1.0, &None, &Some("#,##0".to_string())));
}
#[test]
fn test_is_date_value_by_range() {
let formatter = CellFormatter::new();
assert!(!formatter.is_date_value(1.0, &None, &None));
assert!(!formatter.is_date_value(100.0, &None, &None));
assert!(!formatter.is_date_value(10000.0, &None, &None));
assert!(!formatter.is_date_value(0.0, &None, &None));
assert!(!formatter.is_date_value(70000.0, &None, &None));
assert!(!formatter.is_date_value(-1.0, &None, &None));
}
#[test]
fn test_date_formatter_iso8601() {
let formatter = DateFormatter;
let config = create_test_config_with_date_format(DateFormat::Iso8601);
let result = formatter.format(1.0, &config, false).unwrap();
assert_eq!(result, "1900-01-01");
let result = formatter.format(2.0, &config, false).unwrap();
assert_eq!(result, "1900-01-02");
let result = formatter.format(45658.0, &config, false).unwrap();
assert_eq!(result, "2025-01-02");
}
#[test]
fn test_date_formatter_custom() {
let formatter = DateFormatter;
let config =
create_test_config_with_date_format(DateFormat::Custom("%Y/%m/%d".to_string()));
let result = formatter.format(1.0, &config, false).unwrap();
assert_eq!(result, "1900/01/01");
}
#[test]
fn test_date_formatter_1904_epoch() {
let formatter = DateFormatter;
let config = create_test_config_with_date_format(DateFormat::Iso8601);
let result = formatter.format(0.0, &config, true).unwrap();
assert_eq!(result, "1904-01-01");
let result = formatter.format(1.0, &config, true).unwrap();
assert_eq!(result, "1904-01-02");
let result = formatter.format(365.0, &config, true).unwrap();
assert_eq!(result, "1904-12-31");
let result = formatter.format(366.0, &config, true).unwrap();
assert_eq!(result, "1905-01-01");
let result_1900 = formatter.format(0.0, &config, false).unwrap();
assert_eq!(result_1900, "1899-12-31");
let result_1900 = formatter.format(1.0, &config, false).unwrap();
assert_eq!(result_1900, "1900-01-01");
}
#[test]
fn test_number_formatter() {
let formatter = NumberFormatter;
assert_eq!(formatter.format(123.45, &None).unwrap(), "123.45");
assert_eq!(formatter.format(0.0, &None).unwrap(), "0");
assert_eq!(formatter.format(-123.45, &None).unwrap(), "-123.45");
}
#[test]
fn test_format_cell_number() {
let formatter = CellFormatter::new();
let config = create_test_config();
let raw_cell = RawCellData {
coord: CellCoord::new(0, 0),
value: CellValue::Number(123.45),
format_id: None,
format_string: None,
formula: None,
hyperlink: None,
rich_text: None,
};
let result = formatter.format_cell(&raw_cell, &config, false).unwrap();
assert_eq!(result, "123.45");
}
#[test]
fn test_format_cell_string() {
let formatter = CellFormatter::new();
let config = create_test_config();
let raw_cell = RawCellData {
coord: CellCoord::new(0, 0),
value: CellValue::String("test|value".to_string()),
format_id: None,
format_string: None,
formula: None,
hyperlink: None,
rich_text: None,
};
let result = formatter.format_cell(&raw_cell, &config, false).unwrap();
assert_eq!(result, "test\\|value");
}
#[test]
fn test_format_cell_bool() {
let formatter = CellFormatter::new();
let config = create_test_config();
let raw_cell_true = RawCellData {
coord: CellCoord::new(0, 0),
value: CellValue::Bool(true),
format_id: None,
format_string: None,
formula: None,
hyperlink: None,
rich_text: None,
};
let raw_cell_false = RawCellData {
coord: CellCoord::new(0, 0),
value: CellValue::Bool(false),
format_id: None,
format_string: None,
formula: None,
hyperlink: None,
rich_text: None,
};
assert_eq!(
formatter
.format_cell(&raw_cell_true, &config, false)
.unwrap(),
"TRUE"
);
assert_eq!(
formatter
.format_cell(&raw_cell_false, &config, false)
.unwrap(),
"FALSE"
);
}
#[test]
fn test_format_cell_error() {
let formatter = CellFormatter::new();
let config = create_test_config();
let raw_cell = RawCellData {
coord: CellCoord::new(0, 0),
value: CellValue::Error("#DIV/0!".to_string()),
format_id: None,
format_string: None,
formula: None,
hyperlink: None,
rich_text: None,
};
let result = formatter.format_cell(&raw_cell, &config, false).unwrap();
assert_eq!(result, "#DIV/0!");
}
#[test]
fn test_format_cell_empty() {
let formatter = CellFormatter::new();
let config = create_test_config();
let raw_cell = RawCellData {
coord: CellCoord::new(0, 0),
value: CellValue::Empty,
format_id: None,
format_string: None,
formula: None,
hyperlink: None,
rich_text: None,
};
let result = formatter.format_cell(&raw_cell, &config, false).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_format_cell_date() {
let formatter = CellFormatter::new();
let config = create_test_config_with_date_format(DateFormat::Iso8601);
let raw_cell = RawCellData {
coord: CellCoord::new(0, 0),
value: CellValue::Number(1.0),
format_id: Some(14), format_string: None,
formula: None,
hyperlink: None,
rich_text: None,
};
let result = formatter.format_cell(&raw_cell, &config, false).unwrap();
assert_eq!(result, "1900-01-01");
}
#[test]
fn test_format_cell_formula_mode_cached() {
let formatter = CellFormatter::new();
let config = create_test_config_with_formula_mode(FormulaMode::CachedValue);
let raw_cell = RawCellData {
coord: CellCoord::new(0, 0),
value: CellValue::Number(100.0),
format_id: None,
format_string: None,
formula: Some("=SUM(A1:A10)".to_string()),
hyperlink: None,
rich_text: None,
};
let result = formatter.format_cell(&raw_cell, &config, false).unwrap();
assert_eq!(result, "100");
}
#[test]
fn test_format_cell_formula_mode_formula() {
let formatter = CellFormatter::new();
let config = create_test_config_with_formula_mode(FormulaMode::Formula);
let raw_cell = RawCellData {
coord: CellCoord::new(0, 0),
value: CellValue::Number(100.0),
format_id: None,
format_string: None,
formula: Some("=SUM(A1:A10)".to_string()),
hyperlink: None,
rich_text: None,
};
let result = formatter.format_cell(&raw_cell, &config, false).unwrap();
assert_eq!(result, "=SUM(A1:A10)");
}
#[test]
fn test_format_cell_formula_mode_formula_no_formula() {
let formatter = CellFormatter::new();
let config = create_test_config_with_formula_mode(FormulaMode::Formula);
let raw_cell = RawCellData {
coord: CellCoord::new(0, 0),
value: CellValue::Number(100.0),
format_id: None,
format_string: None,
formula: None, hyperlink: None,
rich_text: None,
};
let result = formatter.format_cell(&raw_cell, &config, false).unwrap();
assert_eq!(result, "100");
}
#[allow(unused_doc_comments)]
mod property_tests {
use super::*;
use proptest::prelude::*;
#[allow(unused_doc_comments)]
proptest! {
#[test]
fn test_date_conversion_monotonicity(
serial1 in 1.0f64..50000.0,
serial2 in 1.0f64..50000.0
) {
let formatter = DateFormatter;
let config = ConversionConfig {
date_format: DateFormat::Iso8601,
..Default::default()
};
let date1 = formatter.format(serial1, &config, false).unwrap();
let date2 = formatter.format(serial2, &config, false).unwrap();
if serial1 < serial2 {
prop_assert!(date1 < date2,
"Date monotonicity violated: serial1={} ({}) < serial2={} ({})",
serial1, date1, serial2, date2);
} else if serial1 > serial2 {
prop_assert!(date1 > date2,
"Date monotonicity violated: serial1={} ({}) > serial2={} ({})",
serial1, date1, serial2, date2);
} else {
prop_assert_eq!(date1.clone(), date2.clone(),
"Date equality violated: serial1={} ({}) == serial2={} ({})",
serial1, date1, serial2, date2);
}
}
}
#[allow(unused_doc_comments)]
proptest! {
#[test]
fn test_date_conversion_monotonicity_1904(
serial1 in 0.0f64..50000.0,
serial2 in 0.0f64..50000.0
) {
let formatter = DateFormatter;
let config = ConversionConfig {
date_format: DateFormat::Iso8601,
..Default::default()
};
let date1 = formatter.format(serial1, &config, true).unwrap();
let date2 = formatter.format(serial2, &config, true).unwrap();
if serial1 < serial2 {
prop_assert!(date1 < date2,
"Date monotonicity violated (1904): serial1={} ({}) < serial2={} ({})",
serial1, date1, serial2, date2);
} else if serial1 > serial2 {
prop_assert!(date1 > date2,
"Date monotonicity violated (1904): serial1={} ({}) > serial2={} ({})",
serial1, date1, serial2, date2);
} else {
prop_assert_eq!(date1.clone(), date2.clone(),
"Date equality violated (1904): serial1={} ({}) == serial2={} ({})",
serial1, date1, serial2, date2);
}
}
}
}
}