use std::collections::HashMap;
use crate::error::{Error, Result};
use super::error::{invalid, io_err};
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub(super) enum XlsxCellValue {
Empty,
String(String),
Number(f64),
Boolean(bool),
Error(String),
}
impl XlsxCellValue {
pub(super) fn to_display_string(&self) -> String {
match self {
XlsxCellValue::Empty => String::new(),
XlsxCellValue::String(s) => s.clone(),
XlsxCellValue::Number(n) => format_number(*n),
XlsxCellValue::Boolean(b) => {
if *b {
"true".to_string()
} else {
"false".to_string()
}
}
XlsxCellValue::Error(s) => s.clone(),
}
}
}
pub(super) fn format_number(n: f64) -> String {
if n.is_nan() {
return "NaN".to_string();
}
if n.is_infinite() {
return if n.is_sign_negative() {
"-Infinity".to_string()
} else {
"Infinity".to_string()
};
}
if n.fract() == 0.0 && n.abs() < 1e16 {
format!("{}", n as i64)
} else {
format!("{n}")
}
}
pub(super) fn col_letters(col: usize) -> String {
let mut out = Vec::new();
let mut n = col as i64;
loop {
let rem = (n % 26) as u8;
out.push(b'A' + rem);
n = n / 26 - 1;
if n < 0 {
break;
}
}
out.reverse();
String::from_utf8(out).unwrap_or_else(|_| "A".to_string())
}
pub(super) fn encode_ref(row: usize, col: usize) -> String {
let mut s = col_letters(col);
s.push_str(&(row + 1).to_string());
s
}
pub(super) fn parse_ref(r: &str) -> Result<(usize, usize)> {
let bytes = r.as_bytes();
let mut i = 0;
let mut col: usize = 0;
while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
let c = bytes[i].to_ascii_uppercase();
col = col * 26 + ((c - b'A' + 1) as usize);
i += 1;
}
if i == 0 {
return Err(invalid(format!("xlsx: invalid cell ref '{r}': no letters")));
}
if col == 0 {
return Err(invalid(format!(
"xlsx: invalid cell ref '{r}': zero column"
)));
}
let col_zero = col - 1;
let row_str = &r[i..];
if row_str.is_empty() {
return Err(invalid(format!("xlsx: invalid cell ref '{r}': no row")));
}
let row: usize = row_str
.parse()
.map_err(|_| invalid(format!("xlsx: invalid row in cell ref '{r}'")))?;
if row == 0 {
return Err(invalid(format!("xlsx: invalid cell ref '{r}': row 0")));
}
Ok((row - 1, col_zero))
}
#[derive(Debug, Default)]
pub(super) struct SharedStringsBuilder {
order: Vec<String>,
index: HashMap<String, u32>,
}
impl SharedStringsBuilder {
pub(super) fn new() -> Self {
Self {
order: Vec::new(),
index: HashMap::new(),
}
}
pub(super) fn intern(&mut self, s: &str) -> u32 {
if let Some(&idx) = self.index.get(s) {
return idx;
}
let idx = self.order.len() as u32;
self.order.push(s.to_string());
self.index.insert(s.to_string(), idx);
idx
}
pub(super) fn len(&self) -> usize {
self.order.len()
}
pub(super) fn into_ordered(self) -> Vec<String> {
self.order
}
}
pub(super) fn xml_escape(s: &str) -> String {
if !s
.bytes()
.any(|b| b == b'&' || b == b'<' || b == b'>' || b == b'"' || b == b'\'')
{
return s.to_string();
}
let mut out = String::with_capacity(s.len() + 8);
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
pub(super) fn validate_sheet_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(invalid("xlsx: sheet name must not be empty"));
}
if name.chars().count() > 31 {
return Err(invalid(format!(
"xlsx: sheet name '{name}' exceeds 31 characters"
)));
}
for ch in name.chars() {
if matches!(ch, ':' | '\\' | '/' | '?' | '*' | '[' | ']') {
return Err(invalid(format!(
"xlsx: sheet name '{name}' contains invalid character '{ch}'"
)));
}
}
Ok(())
}
#[inline]
pub(super) fn fail(msg: impl Into<String>) -> Error {
io_err(msg.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn col_letters_works_for_single_and_multi_letter_columns() {
assert_eq!(col_letters(0), "A");
assert_eq!(col_letters(25), "Z");
assert_eq!(col_letters(26), "AA");
assert_eq!(col_letters(27), "AB");
assert_eq!(col_letters(701), "ZZ");
assert_eq!(col_letters(702), "AAA");
}
#[test]
fn parse_ref_roundtrips_encode_ref() {
for (r, c) in [(0_usize, 0_usize), (5, 25), (100, 26), (1023, 702)] {
let enc = encode_ref(r, c);
let (pr, pc) = parse_ref(&enc).expect("valid ref");
assert_eq!((pr, pc), (r, c), "roundtrip {r},{c} via {enc}");
}
}
#[test]
fn shared_strings_intern_dedups() {
let mut b = SharedStringsBuilder::new();
assert_eq!(b.intern("a"), 0);
assert_eq!(b.intern("b"), 1);
assert_eq!(b.intern("a"), 0);
assert_eq!(b.len(), 2);
}
#[test]
fn xml_escape_handles_special_chars() {
assert_eq!(
xml_escape("a&b<c>d\"e'f"),
"a&b<c>d"e'f"
);
assert_eq!(xml_escape("plain"), "plain");
}
#[test]
fn validate_sheet_name_rejects_bad_chars() {
assert!(validate_sheet_name("").is_err());
assert!(validate_sheet_name("a/b").is_err());
assert!(validate_sheet_name("ok sheet").is_ok());
}
}