#![warn(missing_docs)]
mod tests;
use crate::COL_MAX;
use crate::MAX_AUTOFIT_WIDTH_PIXELS;
use crate::ROW_MAX;
#[cfg(feature = "serde")]
use crate::IntoExcelDateTime;
#[cfg(feature = "serde")]
use serde::Serializer;
use crate::worksheet::ColNum;
use crate::worksheet::RowNum;
use crate::XlsxError;
pub fn column_number_to_name(col_num: ColNum) -> String {
let mut col_name = String::new();
let mut col_num = u32::from(col_num + 1);
while col_num > 0 {
let mut remainder = col_num % 26;
if remainder == 0 {
remainder = 26;
}
let col_letter = char::from_u32(64 + remainder).unwrap();
col_name = format!("{col_letter}{col_name}");
col_num = (col_num - 1) / 26;
}
col_name
}
pub fn column_name_to_number(column: &str) -> ColNum {
if column.is_empty() {
return 0;
}
let mut col_num = 0;
for char in column.chars() {
col_num = (col_num * 26) + (char as u16 - 'A' as u16 + 1);
}
col_num - 1
}
pub fn row_col_to_cell(row: RowNum, col: ColNum) -> String {
format!("{}{}", column_number_to_name(col), row + 1)
}
pub fn row_col_to_cell_absolute(row: RowNum, col: ColNum) -> String {
format!("${}${}", column_number_to_name(col), row + 1)
}
pub fn cell_range(
first_row: RowNum,
first_col: ColNum,
last_row: RowNum,
last_col: ColNum,
) -> String {
let range1 = row_col_to_cell(first_row, first_col);
let range2 = row_col_to_cell(last_row, last_col);
if range1 == range2 {
range1
} else {
format!("{range1}:{range2}")
}
}
pub fn cell_range_absolute(
first_row: RowNum,
first_col: ColNum,
last_row: RowNum,
last_col: ColNum,
) -> String {
let range1 = row_col_to_cell_absolute(first_row, first_col);
let range2 = row_col_to_cell_absolute(last_row, last_col);
if range1 == range2 {
range1
} else {
format!("{range1}:{range2}")
}
}
pub fn worksheet_range(
sheet_name: &str,
first_row: RowNum,
first_col: ColNum,
last_row: RowNum,
last_col: ColNum,
) -> String {
chart_range(sheet_name, first_row, first_col, last_row, last_col)
}
pub fn worksheet_range_absolute(
sheet_name: &str,
first_row: RowNum,
first_col: ColNum,
last_row: RowNum,
last_col: ColNum,
) -> String {
chart_range_abs(sheet_name, first_row, first_col, last_row, last_col)
}
#[cfg(feature = "serde")]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
pub fn serialize_datetime_to_excel<S>(
datetime: impl IntoExcelDateTime,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_f64(datetime.to_excel_serial_date())
}
#[cfg(feature = "serde")]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
pub fn serialize_option_datetime_to_excel<S>(
datetime: &Option<impl IntoExcelDateTime>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match datetime {
Some(datetime) => serializer.serialize_f64(datetime.to_excel_serial_date()),
None => serializer.serialize_none(),
}
}
#[cfg(feature = "serde")]
#[doc(hidden)]
#[deprecated(since = "0.88.0", note = "use `serialize_datetime_to_excel()` instead")]
pub fn serialize_chrono_naive_to_excel<S>(
datetime: impl IntoExcelDateTime,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_f64(datetime.to_excel_serial_date())
}
#[cfg(feature = "serde")]
#[doc(hidden)]
#[deprecated(
since = "0.88.0",
note = "use `serialize_option_datetime_to_excel()` instead"
)]
pub fn serialize_chrono_option_naive_to_excel<S>(
datetime: &Option<impl IntoExcelDateTime>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match datetime {
Some(datetime) => serializer.serialize_f64(datetime.to_excel_serial_date()),
None => serializer.serialize_none(),
}
}
pub(crate) fn chart_range(
sheet_name: &str,
first_row: RowNum,
first_col: ColNum,
last_row: RowNum,
last_col: ColNum,
) -> String {
let sheet_name = quote_sheet_name(sheet_name);
let range1 = row_col_to_cell(first_row, first_col);
let range2 = row_col_to_cell(last_row, last_col);
if range1 == range2 {
format!("{sheet_name}!{range1}")
} else {
format!("{sheet_name}!{range1}:{range2}")
}
}
pub(crate) fn chart_range_abs(
sheet_name: &str,
first_row: RowNum,
first_col: ColNum,
last_row: RowNum,
last_col: ColNum,
) -> String {
let sheet_name = quote_sheet_name(sheet_name);
let range1 = row_col_to_cell_absolute(first_row, first_col);
let range2 = row_col_to_cell_absolute(last_row, last_col);
if range1 == range2 {
format!("{sheet_name}!{range1}")
} else {
format!("{sheet_name}!{range1}:{range2}")
}
}
pub(crate) fn chart_error_range(
sheet_name: &str,
first_row: RowNum,
first_col: ColNum,
last_row: RowNum,
last_col: ColNum,
) -> String {
let sheet_name = quote_sheet_name(sheet_name);
let range1 = row_col_to_cell(first_row, first_col);
let range2 = row_col_to_cell(last_row, last_col);
if range1 == range2 {
format!("{sheet_name}!{range1}/({first_row}, {first_col})")
} else {
format!("{sheet_name}!{range1}:{range2}/({first_row}, {first_col}, {last_row}, {last_col})")
}
}
#[allow(clippy::if_same_then_else)]
pub fn quote_sheet_name(sheetname: &str) -> String {
let mut sheetname = sheetname.to_string();
let uppercase_sheetname = sheetname.to_uppercase();
let mut requires_quoting = false;
let col_max = u64::from(COL_MAX);
let row_max = u64::from(ROW_MAX);
let (string_part, number_part) = split_cell_reference(&sheetname);
let column_part_is_ascii = column_part_is_ascii(&sheetname);
let mut number_parts = number_part.split(|c: char| !c.is_ascii_digit());
let rc_number_part = number_parts.next().unwrap_or_default();
if !sheetname.starts_with('\'') {
if !sheetname
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '.' || is_emoji(c))
{
requires_quoting = true;
}
else if sheetname.starts_with(|c: char| c.is_ascii_digit() || c == '.' || is_emoji(c)) {
requires_quoting = true;
}
else if column_part_is_ascii
&& (1..=3).contains(&string_part.len())
&& number_part.chars().all(|c| c.is_ascii_digit())
{
let col = column_name_to_number(&string_part);
let col = u64::from(col + 1);
let row = number_part.parse::<u64>().unwrap_or_default();
if row > 0 && row <= row_max && col <= col_max {
requires_quoting = true;
}
}
else if string_part == "R" {
let row = rc_number_part.parse::<u64>().unwrap_or_default();
if row > 0 && row <= row_max {
requires_quoting = true;
}
}
else if string_part == "RC" || string_part == "C" {
let col = rc_number_part.parse::<u64>().unwrap_or_default();
if col > 0 && col <= col_max {
requires_quoting = true;
}
}
else if uppercase_sheetname == "R"
|| uppercase_sheetname == "C"
|| uppercase_sheetname == "RC"
{
requires_quoting = true;
}
}
if requires_quoting {
sheetname = sheetname.replace('\'', "''");
sheetname = format!("'{sheetname}'");
}
sheetname
}
pub(crate) fn unquote_sheetname(sheetname: &str) -> String {
if sheetname.starts_with('\'') && sheetname.ends_with('\'') {
let sheetname = sheetname[1..sheetname.len() - 1].to_string();
sheetname.replace("''", "'")
} else {
sheetname.to_string()
}
}
pub(crate) fn split_local_name(name: &str) -> Option<(&str, &str)> {
if !name.starts_with('\'') {
return name
.find('!')
.map(|position| (&name[..position], &name[position + 1..]));
}
let mut chars = name.char_indices().skip(1).peekable();
while let Some((index, char)) = chars.next() {
if char == '\'' {
match chars.peek() {
Some((_, '\'')) => {
chars.next();
}
Some((next_index, '!')) => {
return Some((&name[..=index], &name[next_index + 1..]));
}
_ => return None,
}
}
}
None
}
fn is_emoji(c: char) -> bool {
if c < '\u{203C}' {
return false;
}
if c < '\u{01F004}' {
return matches!(c,
'\u{203C}' | '\u{2049}' | '\u{2122}' | '\u{2139}' | '\u{2194}'..='\u{2199}' |
'\u{21A9}' | '\u{21AA}' | '\u{231A}' | '\u{231B}' | '\u{2328}' | '\u{23CF}' |
'\u{23E9}'..='\u{23F3}' | '\u{23F8}'..='\u{23FA}' | '\u{24C2}' | '\u{25AA}' |
'\u{25AB}' | '\u{25B6}' | '\u{25C0}' | '\u{25FB}'..='\u{25FE}' |
'\u{2600}'..='\u{2604}' | '\u{260E}' | '\u{2611}' | '\u{2614}' | '\u{2615}' |
'\u{2618}' | '\u{261D}' | '\u{2620}' | '\u{2622}' | '\u{2623}' | '\u{2626}' |
'\u{262A}' | '\u{262E}' | '\u{262F}' | '\u{2638}'..='\u{263A}' | '\u{2640}' |
'\u{2642}' | '\u{2648}'..='\u{2653}' | '\u{265F}' | '\u{2660}' | '\u{2663}' |
'\u{2665}' | '\u{2666}' | '\u{2668}' | '\u{267B}' | '\u{267E}' | '\u{267F}' |
'\u{2692}'..='\u{2697}' | '\u{2699}' | '\u{269B}' | '\u{269C}' | '\u{26A0}' |
'\u{26A1}' | '\u{26A7}' | '\u{26AA}' | '\u{26AB}' | '\u{26B0}' | '\u{26B1}' |
'\u{26BD}' | '\u{26BE}' | '\u{26C4}' | '\u{26C5}' | '\u{26C8}' | '\u{26CE}' |
'\u{26CF}' | '\u{26D1}' | '\u{26D3}' | '\u{26D4}' | '\u{26E9}' | '\u{26EA}' |
'\u{26F0}'..='\u{26F5}' | '\u{26F7}'..='\u{26FA}' | '\u{26FD}' | '\u{2702}' |
'\u{2705}' | '\u{2708}'..='\u{270D}' | '\u{270F}' | '\u{2712}' | '\u{2714}' |
'\u{2716}' | '\u{271D}' | '\u{2721}' | '\u{2728}' | '\u{2733}' | '\u{2734}' |
'\u{2744}' | '\u{2747}' | '\u{274C}' | '\u{274E}' | '\u{2753}'..='\u{2755}' |
'\u{2757}' | '\u{2763}' | '\u{2764}' | '\u{2795}'..='\u{2797}' | '\u{27A1}' |
'\u{27B0}' | '\u{27BF}' | '\u{2934}' | '\u{2935}' | '\u{2B05}'..='\u{2B07}' |
'\u{2B1B}' | '\u{2B1C}' | '\u{2B50}' | '\u{2B55}' | '\u{3030}' | '\u{303D}' |
'\u{3297}' | '\u{3299}'
);
}
matches!(c,
'\u{01F004}' | '\u{01F0CF}' | '\u{01F170}' | '\u{01F171}' | '\u{01F17E}' | '\u{01F17F}' |
'\u{01F18E}' | '\u{01F191}'..='\u{01F19A}' | '\u{01F1E6}'..='\u{01F1FF}' | '\u{01F201}' |
'\u{01F202}' | '\u{01F21A}' | '\u{01F22F}' | '\u{01F232}'..='\u{01F23A}' | '\u{01F250}' |
'\u{01F251}' | '\u{01F300}'..='\u{01F321}' | '\u{01F324}'..='\u{01F393}' | '\u{01F396}' |
'\u{01F397}' | '\u{01F399}'..='\u{01F39B}' | '\u{01F39E}'..='\u{01F3F0}' |
'\u{01F3F3}'..='\u{01F3F5}' | '\u{01F3F7}'..='\u{01F4FD}' | '\u{01F4FF}'..='\u{01F53D}' |
'\u{01F549}'..='\u{01F54E}' | '\u{01F550}'..='\u{01F567}' | '\u{01F56F}' | '\u{01F570}' |
'\u{01F573}'..='\u{01F57A}' | '\u{01F587}' | '\u{01F58A}'..='\u{01F58D}' | '\u{01F590}' |
'\u{01F595}' | '\u{01F596}' | '\u{01F5A4}' | '\u{01F5A5}' | '\u{01F5A8}' | '\u{01F5B1}' |
'\u{01F5B2}' | '\u{01F5BC}' | '\u{01F5C2}'..='\u{01F5C4}' | '\u{01F5D1}'..='\u{01F5D3}' |
'\u{01F5DC}'..='\u{01F5DE}' | '\u{01F5E1}' | '\u{01F5E3}' | '\u{01F5E8}' | '\u{01F5EF}' |
'\u{01F5F3}' | '\u{01F5FA}'..='\u{01F64F}' | '\u{01F680}'..='\u{01F6C5}' |
'\u{01F6CB}'..='\u{01F6D2}' | '\u{01F6D5}'..='\u{01F6D7}' | '\u{01F6DC}'..='\u{01F6E5}' |
'\u{01F6E9}' | '\u{01F6EB}' | '\u{01F6EC}' | '\u{01F6F0}' | '\u{01F6F3}'..='\u{01F6FC}' |
'\u{01F7E0}'..='\u{01F7EB}' | '\u{01F7F0}' | '\u{01F90C}'..='\u{01F93A}' |
'\u{01F93C}'..='\u{01F945}' | '\u{01F947}'..='\u{01F9FF}' | '\u{01FA70}'..='\u{01FA7C}' |
'\u{01FA80}'..='\u{01FA88}' | '\u{01FA90}'..='\u{01FABD}' | '\u{01FABF}'..='\u{01FAC5}' |
'\u{01FACE}'..='\u{01FADB}' | '\u{01FAE0}'..='\u{01FAE8}' | '\u{01FAF0}'..='\u{01FAF8}'
)
}
fn column_part_is_ascii(name: &str) -> bool {
match name.find(|c: char| c.is_ascii_digit()) {
Some(position) => name[..position].chars().all(|c| c.is_ascii_alphabetic()),
None => false,
}
}
pub(crate) fn split_cell_reference(sheetname: &str) -> (String, String) {
match sheetname.find(|c: char| c.is_ascii_digit()) {
Some(position) => (
(sheetname[..position]).to_uppercase(),
(sheetname[position..]).to_uppercase(),
),
None => (String::new(), String::new()),
}
}
pub(crate) fn is_valid_range(range: &str) -> bool {
if range.is_empty() {
return false;
}
if !range.starts_with(|c: char| c.is_ascii_uppercase())
|| !range.ends_with(|c: char| c.is_ascii_digit())
{
return false;
}
if !range
.chars()
.all(|c: char| c.is_ascii_uppercase() || c.is_ascii_digit() || c == ':')
{
return false;
}
true
}
fn is_cell_reference(name: &str) -> bool {
let col_max = u64::from(COL_MAX);
let row_max = u64::from(ROW_MAX);
let column_part_is_ascii = column_part_is_ascii(name);
let name = name.to_uppercase();
let (string_part, number_part) = split_cell_reference(&name);
let mut number_parts = number_part.split(|c: char| !c.is_ascii_digit());
let rc_number_part = number_parts.next().unwrap_or_default();
if column_part_is_ascii
&& (1..=3).contains(&string_part.len())
&& number_part.chars().all(|c| c.is_ascii_digit())
{
let col = column_name_to_number(&string_part);
let col = u64::from(col + 1);
let row = number_part.parse::<u64>().unwrap_or_default();
if row > 0 && row <= row_max && col <= col_max {
return true;
}
}
if string_part == "R" {
let row = rc_number_part.parse::<u64>().unwrap_or_default();
if row > 0 && row <= row_max {
return true;
}
}
if string_part == "RC" || string_part == "C" {
let col = rc_number_part.parse::<u64>().unwrap_or_default();
if col > 0 && col <= col_max {
return true;
}
}
name == "R" || name == "C" || name == "RC"
}
pub fn check_sheet_name(name: &str) -> Result<(), XlsxError> {
let error_message = format!("Invalid Excel worksheet name '{name}'");
validate_sheetname(name, &error_message)
}
pub(crate) fn validate_sheetname(name: &str, message: &str) -> Result<(), XlsxError> {
if name.is_empty() {
return Err(XlsxError::SheetnameCannotBeBlank(message.to_string()));
}
if name.chars().count() > 31 {
return Err(XlsxError::SheetnameLengthExceeded(message.to_string()));
}
if name.contains(['*', '?', ':', '[', ']', '\\', '/']) {
return Err(XlsxError::SheetnameContainsInvalidCharacter(
message.to_string(),
));
}
if name.starts_with('\'') || name.ends_with('\'') {
return Err(XlsxError::SheetnameStartsOrEndsWithApostrophe(
message.to_string(),
));
}
Ok(())
}
pub fn check_name(name: &str) -> Result<(), XlsxError> {
if name.is_empty() {
return Err(XlsxError::NameError(
name.to_string(),
"Name cannot be blank".to_string(),
));
}
if name.chars().count() > 255 {
return Err(XlsxError::NameError(
name.to_string(),
"Name exceeds Excel limit of 255 characters".to_string(),
));
}
let invalid_chars = [
' ', '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '/', ':', ';', '<',
'=', '>', '@', '[', ']', '^', '`', '{', '|', '}', '~',
];
if let Some(invalid_char) = name
.chars()
.find(|c| invalid_chars.contains(c) || c.is_control())
{
return Err(XlsxError::NameError(
name.to_string(),
format!("Name contains a character that isn't allowed by Excel: {invalid_char:?}"),
));
}
let first_char = name.chars().next().unwrap();
if first_char.is_ascii_digit() || matches!(first_char, '.' | '?') {
return Err(XlsxError::NameError(
name.to_string(),
format!("Name starts with a character that isn't allowed by Excel: {first_char:?}"),
));
}
if name.chars().count() == 2
&& name.starts_with('\\')
&& name.ends_with(|c: char| c.is_ascii_alphanumeric())
{
return Err(XlsxError::NameError(
name.to_string(),
"Name cannot be a backslash followed by a single letter".to_string(),
));
}
let uppercase_name = name.to_uppercase();
if uppercase_name == "TRUE" || uppercase_name == "FALSE" {
return Err(XlsxError::NameError(
name.to_string(),
"Name cannot be one of Excel's logical constants 'TRUE' or 'FALSE'".to_string(),
));
}
if is_cell_reference(name) {
return Err(XlsxError::NameError(
name.to_string(),
"Name cannot be an Excel cell reference like 'A1' or 'R1C1'".to_string(),
));
}
if [
"_XLNM.PRINT_AREA",
"_XLNM._FILTERDATABASE",
"_XLNM.PRINT_TITLES",
]
.contains(&uppercase_name.as_str())
{
return Err(XlsxError::NameError(
name.to_string(),
"Name cannot match Excel's internally reserved names".to_string(),
));
}
Ok(())
}
pub(crate) fn validate_vba_name(name: &str) -> Result<(), XlsxError> {
if name.is_empty() {
return Err(XlsxError::VbaNameError(
"VBA name cannot be blank".to_string(),
));
}
if name.chars().count() > 31 {
return Err(XlsxError::VbaNameError(
"VBA name exceeds Excel limit of 31 characters: {name}".to_string(),
));
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(XlsxError::VbaNameError(
"VBA name contains non-word character: {name}".to_string(),
));
}
if !name.chars().next().unwrap().is_alphabetic() {
return Err(XlsxError::VbaNameError(
"VBA name must start with letter character: {name}".to_string(),
));
}
Ok(())
}
pub fn cell_autofit_width(string: &str) -> u32 {
let cell_padding = 7;
pixel_width(string) + cell_padding
}
#[allow(clippy::match_same_arms)]
pub(crate) fn pixel_width(string: &str) -> u32 {
let mut length = 0;
if string.chars().count() > 233 {
return MAX_AUTOFIT_WIDTH_PIXELS;
}
for char in string.chars() {
match char {
' ' | '\'' => length += 3,
',' | '.' | ':' | ';' | 'I' | '`' | 'i' | 'j' | 'l' => length += 4,
'!' | '(' | ')' | '-' | 'J' | '[' | ']' | 'f' | 'r' | 't' | '{' | '}' => length += 5,
'"' | '/' | 'L' | '\\' | 'c' | 's' | 'z' => length += 6,
'#' | '$' | '*' | '+' | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
| '<' | '=' | '>' | '?' | 'E' | 'F' | 'S' | 'T' | 'Y' | 'Z' | '^' | '_' | 'a' | 'g'
| 'k' | 'v' | 'x' | 'y' | '|' | '~' => length += 7,
'B' | 'C' | 'K' | 'P' | 'R' | 'X' | 'b' | 'd' | 'e' | 'h' | 'n' | 'o' | 'p' | 'q'
| 'u' => length += 8,
'A' | 'D' | 'G' | 'H' | 'U' | 'V' => length += 9,
'&' | 'N' | 'O' | 'Q' => length += 10,
'%' | 'w' => length += 11,
'M' | 'm' => length += 12,
'@' | 'W' => length += 13,
_ => length += 8,
}
}
std::cmp::min(length, MAX_AUTOFIT_WIDTH_PIXELS)
}
pub(crate) fn hash_password(password: &str) -> u16 {
let mut hash: u16 = 0;
let length = password.len() as u16;
if password.is_empty() {
return 0;
}
for byte in password.as_bytes().iter().rev() {
hash = ((hash >> 14) & 0x01) | ((hash << 1) & 0x7FFF);
hash ^= u16::from(*byte);
}
hash = ((hash >> 14) & 0x01) | ((hash << 1) & 0x7FFF);
hash ^= length;
hash ^= 0xCE4B;
hash
}
pub(crate) fn formula_to_string(formula: &str) -> String {
let mut formula = formula.to_string();
if formula.starts_with('=') {
formula.remove(0);
}
formula
}
pub(crate) fn default_column_metrics(width: u32) -> Option<(u32, u32, u32)> {
match width {
56 => Some((6, 5, 1533)),
64 => Some((7, 5, 1790)),
72 => Some((8, 5, 2043)),
80 => Some((9, 7, 2300)),
96 => Some((11, 7, 2810)),
104 => Some((12, 7, 3065)),
120 => Some((13, 9, 3323)),
_ => None,
}
}
pub(crate) trait ToXmlBoolean {
fn to_xml_bool(self) -> String;
}
impl ToXmlBoolean for bool {
fn to_xml_bool(self) -> String {
u8::from(self).to_string()
}
}