use std::collections::{HashMap, HashSet};
use crate::error::{Error, Result};
use super::error::{invalid, io_err};
pub(super) const MAX_XLSX_ROWS: usize = 1_048_576;
pub(super) const MAX_XLSX_COLS: usize = 16_384;
#[derive(Debug, Clone, PartialEq)]
pub(super) enum XlsxCellValue {
Empty,
String(String),
Number { value: f64, raw: String },
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 { raw, .. } => raw.clone(),
XlsxCellValue::Boolean(b) => {
if *b {
"true".to_string()
} else {
"false".to_string()
}
}
XlsxCellValue::Error(s) => s.clone(),
}
}
pub(super) fn as_number(&self) -> Option<f64> {
match self {
XlsxCellValue::Number { value, .. } => Some(*value),
_ => None,
}
}
}
pub(super) fn format_int(n: i64) -> String {
n.to_string()
}
pub(super) fn format_float(n: f64) -> String {
debug_assert!(n.is_finite(), "format_float called with a non-finite value");
let s = format!("{n}");
if s.contains('.') || s.contains('e') || s.contains('E') {
s
} else {
format!("{s}.0")
}
}
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();
let digit = (c - b'A' + 1) as usize;
col = col
.checked_mul(26)
.and_then(|v| v.checked_add(digit))
.filter(|&v| v <= MAX_XLSX_COLS)
.ok_or_else(|| {
invalid(format!(
"xlsx: invalid cell ref '{r}': column out of range (max {MAX_XLSX_COLS})"
))
})?;
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")));
}
if row > MAX_XLSX_ROWS {
return Err(invalid(format!(
"xlsx: invalid cell ref '{r}': row out of range (max {MAX_XLSX_ROWS})"
)));
}
Ok((row - 1, col_zero))
}
#[derive(Debug, Default)]
pub(super) struct SharedStringsBuilder {
order: Vec<String>,
index: HashMap<String, u32>,
total_refs: u32,
}
impl SharedStringsBuilder {
pub(super) fn new() -> Self {
Self {
order: Vec::new(),
index: HashMap::new(),
total_refs: 0,
}
}
pub(super) fn intern(&mut self, s: &str) -> u32 {
self.total_refs = self.total_refs.saturating_add(1);
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
}
#[allow(dead_code)] pub(super) fn len(&self) -> usize {
self.order.len()
}
pub(super) fn into_ordered(self) -> (Vec<String>, u32) {
(self.order, self.total_refs)
}
}
fn is_xml_illegal_control(c: char) -> bool {
matches!(c, '\u{0}'..='\u{8}' | '\u{B}' | '\u{C}' | '\u{E}'..='\u{1F}')
}
fn is_plain_xml_char(c: char) -> bool {
!matches!(c, '&' | '<' | '>' | '"' | '\'') && !is_xml_illegal_control(c)
}
pub(super) fn xml_escape(s: &str) -> String {
if s.chars().all(is_plain_xml_char) {
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("'"),
c if is_xml_illegal_control(c) => {
}
c => out.push(c),
}
}
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}'"
)));
}
if is_xml_illegal_control(ch) {
return Err(invalid(format!(
"xlsx: sheet name '{name}' contains a control character that cannot appear in XML"
)));
}
}
Ok(())
}
pub(super) fn validate_unique_sheet_names<'a, I>(names: I) -> Result<()>
where
I: IntoIterator<Item = &'a str>,
{
let mut seen: HashSet<String> = HashSet::new();
for name in names {
let key = name.to_lowercase();
if !seen.insert(key) {
return Err(invalid(format!(
"xlsx: duplicate sheet name '{name}' (Excel sheet names are case-insensitive)"
)));
}
}
Ok(())
}
#[inline]
#[allow(dead_code)] 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 parse_ref_accepts_excels_own_maximum() {
let (r, c) = parse_ref("XFD1048576").expect("Excel's own max cell ref must be accepted");
assert_eq!(r, MAX_XLSX_ROWS - 1);
assert_eq!(c, MAX_XLSX_COLS - 1);
}
#[test]
fn parse_ref_rejects_column_beyond_excel_max() {
assert!(parse_ref("XFE1").is_err());
}
#[test]
fn parse_ref_rejects_row_beyond_excel_max() {
assert!(parse_ref("A1048577").is_err());
}
#[test]
fn parse_ref_rejects_absurdly_long_column_without_overflow_panic() {
let huge = format!("{}1", "A".repeat(200));
assert!(parse_ref(&huge).is_err());
}
#[test]
fn shared_strings_intern_dedups_and_tracks_total_refs() {
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);
let (unique, total) = b.into_ordered();
assert_eq!(unique, vec!["a".to_string(), "b".to_string()]);
assert_eq!(total, 3);
}
#[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 xml_escape_strips_illegal_control_chars() {
let s = "a\u{0}b\u{1}c\u{B}d\u{1F}e";
assert_eq!(xml_escape(s), "abcde");
assert_eq!(xml_escape("a\tb\nc\rd"), "a\tb\nc\rd");
}
#[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());
}
#[test]
fn validate_sheet_name_rejects_control_chars() {
assert!(validate_sheet_name("Sheet\u{1}1").is_err());
assert!(validate_sheet_name("Sheet\u{0}1").is_err());
assert!(validate_sheet_name("Sheet\t1").is_ok());
}
#[test]
fn validate_unique_sheet_names_rejects_case_insensitive_duplicates() {
assert!(validate_unique_sheet_names(["Sheet1", "Sheet2"]).is_ok());
assert!(validate_unique_sheet_names(["Sheet1", "sheet1"]).is_err());
assert!(validate_unique_sheet_names(["Data", "DATA"]).is_err());
}
#[test]
fn format_int_has_no_decimal_point() {
assert_eq!(format_int(3), "3");
assert_eq!(format_int(-42), "-42");
assert_eq!(format_int(0), "0");
}
#[test]
fn format_float_always_has_a_decimal_or_exponent() {
assert_eq!(format_float(3.0), "3.0");
assert_eq!(format_float(3.5), "3.5");
assert_eq!(format_float(-2.0), "-2.0");
assert_eq!(format_float(0.0), "0.0");
for v in [1e15_f64, 1e300_f64, 1.0 / 3.0] {
let s = format_float(v);
assert!(
s.contains('.') || s.contains('e') || s.contains('E'),
"format_float({v}) = {s:?} must look non-integer"
);
}
}
}