use std::collections::HashMap;
use std::io::{Cursor, Read, Seek};
use std::str::FromStr;
use elixcee_types::ExcelError;
use zip::ZipArchive;
type MergeRect = ((u32, u32), (u32, u32));
pub struct WorkbookSheet {
pub name: String,
pub cells: HashMap<(u32, u32), SheetCell>,
pub sheet_id: Option<String>,
pub workbook_rel_id: Option<String>,
pub source_part_name: Option<String>,
pub merged_ranges: Vec<MergeRect>,
pub hidden_rows: Vec<(u32, u32)>,
pub hidden_columns: Vec<(u32, u32)>,
pub raw_style_indices: HashMap<(u32, u32), u32>,
pub formulas: HashMap<(u32, u32), String>,
pub cell_number_formats: HashMap<(u32, u32), String>,
pub sheet_state: Option<String>,
pub row_heights: HashMap<u32, f64>,
pub column_widths: Vec<(u32, u32, f64)>,
pub row_styles: HashMap<u32, u32>,
pub column_styles: Vec<(u32, u32, u32)>,
pub tables: Vec<TableDef>,
pub data_validations: Vec<DataValidationRule>,
pub autofilter: Option<AutoFilterDef>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum FilterCriteria {
Values(Vec<String>),
Custom {
op1: String,
val1: String,
and: bool,
op2: Option<String>,
val2: Option<String>,
},
Blank,
Top10 { top: bool, percent: bool, val: f64 },
DateGroup(Vec<DateGroupItem>),
}
#[derive(Clone, Debug, PartialEq)]
pub struct DateGroupItem {
pub year: Option<i32>,
pub month: Option<u32>,
pub day: Option<u32>,
pub hour: Option<u32>,
pub minute: Option<u32>,
pub second: Option<u32>,
pub date_time_grouping: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct FilterColumn {
pub col_offset: u32,
pub hidden_button: bool,
pub show_button: bool,
pub criteria: FilterCriteria,
pub raw_span: Option<String>,
pub dirty: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AutoFilterDef {
pub ref_range: MergeRect,
pub columns: Vec<FilterColumn>,
}
fn parse_filter_criteria_xml(filter_column_span: &str) -> Option<FilterCriteria> {
let mut iter = XmlIter::new(filter_column_span);
let mut values = Vec::new();
let mut date_groups = Vec::new();
let mut is_blank = false;
let mut custom_filters: Vec<(String, String)> = Vec::new();
let mut custom_and = true;
let mut top10: Option<(bool, bool, f64)> = None;
while let Some(ev) = iter.next_ev() {
let (Ev::Open(ref tag, ref attrs) | Ev::SelfClose(ref tag, ref attrs)) = ev else {
continue;
};
match tag.split(':').next_back().unwrap_or(tag.as_str()) {
"filters" => {
if matches!(attr_get(attrs, "blank"), Some("1")) {
is_blank = true;
}
}
"filter" => {
if let Some(v) = attr_get(attrs, "val") {
values.push(v.to_string());
}
}
"dateGroupItem" => {
date_groups.push(DateGroupItem {
year: attr_get(attrs, "year").and_then(|v| v.parse().ok()),
month: attr_get(attrs, "month").and_then(|v| v.parse().ok()),
day: attr_get(attrs, "day").and_then(|v| v.parse().ok()),
hour: attr_get(attrs, "hour").and_then(|v| v.parse().ok()),
minute: attr_get(attrs, "minute").and_then(|v| v.parse().ok()),
second: attr_get(attrs, "second").and_then(|v| v.parse().ok()),
date_time_grouping: attr_get(attrs, "dateTimeGrouping")
.unwrap_or("")
.to_string(),
});
}
"customFilters" => {
custom_and = matches!(attr_get(attrs, "and"), Some("1"));
}
"customFilter" => {
custom_filters.push((
attr_get(attrs, "operator").unwrap_or("equal").to_string(),
attr_get(attrs, "val").unwrap_or("").to_string(),
));
}
"top10" => {
top10 = Some((
attr_get(attrs, "top").map(|v| v != "0").unwrap_or(true),
matches!(attr_get(attrs, "percent"), Some("1")),
attr_get(attrs, "val")
.and_then(|v| v.parse().ok())
.unwrap_or(0.0),
));
}
_ => {}
}
}
if let Some((top, percent, val)) = top10 {
return Some(FilterCriteria::Top10 { top, percent, val });
}
if !custom_filters.is_empty() {
let mut it = custom_filters.into_iter();
let (op1, val1) = it.next().unwrap();
let (op2, val2) = match it.next() {
Some((o, v)) => (Some(o), Some(v)),
None => (None, None),
};
return Some(FilterCriteria::Custom {
op1,
val1,
and: custom_and,
op2,
val2,
});
}
if !date_groups.is_empty() {
return Some(FilterCriteria::DateGroup(date_groups));
}
if is_blank {
return Some(FilterCriteria::Blank);
}
if !values.is_empty() {
return Some(FilterCriteria::Values(values));
}
None
}
fn parse_filter_column_xml(span: &str) -> Option<FilterColumn> {
let (tag_start, tag_close_rel, full_name) = find_next_open_tag(span, 0)?;
let name_end = tag_start + 1 + full_name.len();
let raw_attrs = &span[name_end..name_end + tag_close_rel];
let attrs_str = raw_attrs.trim_end().strip_suffix('/').unwrap_or(raw_attrs);
let attrs = parse_attrs(attrs_str);
let col_offset = attr_get(&attrs, "colId")?.parse().ok()?;
Some(FilterColumn {
col_offset,
hidden_button: matches!(attr_get(&attrs, "hiddenButton"), Some("1")),
show_button: attr_get(&attrs, "showButton")
.map(|v| v != "0")
.unwrap_or(true),
criteria: parse_filter_criteria_xml(span)?,
raw_span: Some(span.to_string()),
dirty: false,
})
}
pub(crate) fn xlsx_autofilter(sheet_xml: &str) -> Option<AutoFilterDef> {
let span = extract_raw_element(sheet_xml, "autoFilter")?;
let ref_range = span_attr_str(&span, "ref").and_then(|s| parse_merge_ref(&s))?;
let columns = extract_records(&span, "autoFilter", "filterColumn")
.iter()
.filter_map(|s| parse_filter_column_xml(s))
.collect();
Some(AutoFilterDef { ref_range, columns })
}
#[derive(Clone, Debug, PartialEq)]
pub struct DataValidationRule {
pub validation_type: String,
pub operator: Option<String>,
pub formula1: Option<String>,
pub formula2: Option<String>,
pub allow_blank: bool,
pub show_input_message: bool,
pub prompt_title: Option<String>,
pub prompt: Option<String>,
pub show_error_message: bool,
pub error_style: Option<String>,
pub error_title: Option<String>,
pub error: Option<String>,
pub sqref: Vec<MergeRect>,
pub dirty: bool,
pub raw_span: String,
}
#[derive(Clone, Debug)]
pub struct DataValidationSpec {
pub validation_type: String,
pub operator: Option<String>,
pub formula1: Option<String>,
pub formula2: Option<String>,
pub allow_blank: bool,
pub show_input_message: bool,
pub prompt_title: Option<String>,
pub prompt: Option<String>,
pub show_error_message: bool,
pub error_style: Option<String>,
pub error_title: Option<String>,
pub error: Option<String>,
}
fn parse_sqref(s: &str) -> Vec<MergeRect> {
s.split_whitespace()
.filter_map(elixcee_types::parse_range_addr)
.collect()
}
fn parse_data_validation_xml(span: &str) -> Option<DataValidationRule> {
let mut iter = XmlIter::new(span);
let mut rule: Option<DataValidationRule> = None;
let mut in_formula1 = false;
let mut in_formula2 = false;
let mut f1_text = String::new();
let mut f2_text = String::new();
let as_bool = |attrs: &[Attr], name: &str| {
attr_get(attrs, name)
.map(|v| matches!(v, "1" | "true" | "TRUE"))
.unwrap_or(false)
};
while let Some(ev) = iter.next_ev() {
match ev {
Ev::Open(ref tag, ref attrs) | Ev::SelfClose(ref tag, ref attrs) => {
let local = tag.split(':').next_back().unwrap_or(tag.as_str());
match local {
"dataValidation" => {
rule = Some(DataValidationRule {
validation_type: attr_get(attrs, "type").unwrap_or("none").to_string(),
operator: attr_get(attrs, "operator").map(|s| s.to_string()),
formula1: None,
formula2: None,
allow_blank: as_bool(attrs, "allowBlank"),
show_input_message: as_bool(attrs, "showInputMessage"),
prompt_title: attr_get(attrs, "promptTitle").map(|s| s.to_string()),
prompt: attr_get(attrs, "prompt").map(|s| s.to_string()),
show_error_message: as_bool(attrs, "showErrorMessage"),
error_style: attr_get(attrs, "errorStyle").map(|s| s.to_string()),
error_title: attr_get(attrs, "errorTitle").map(|s| s.to_string()),
error: attr_get(attrs, "error").map(|s| s.to_string()),
sqref: attr_get(attrs, "sqref")
.map(parse_sqref)
.unwrap_or_default(),
dirty: false,
raw_span: span.to_string(),
});
}
"formula1" if !matches!(ev, Ev::SelfClose(_, _)) => {
in_formula1 = true;
f1_text.clear();
}
"formula2" if !matches!(ev, Ev::SelfClose(_, _)) => {
in_formula2 = true;
f2_text.clear();
}
_ => {}
}
}
Ev::Close(ref tag) => {
let local = tag.split(':').next_back().unwrap_or(tag.as_str());
match local {
"formula1" if in_formula1 => {
if let Some(r) = rule.as_mut() {
r.formula1 = Some(f1_text.clone());
}
in_formula1 = false;
}
"formula2" if in_formula2 => {
if let Some(r) = rule.as_mut() {
r.formula2 = Some(f2_text.clone());
}
in_formula2 = false;
}
_ => {}
}
}
Ev::Text(ref text) => {
if in_formula1 {
f1_text.push_str(text);
} else if in_formula2 {
f2_text.push_str(text);
}
}
}
}
rule
}
pub(crate) fn xlsx_data_validations(sheet_xml: &str) -> Vec<DataValidationRule> {
extract_records(sheet_xml, "dataValidations", "dataValidation")
.iter()
.filter_map(|span| parse_data_validation_xml(span))
.collect()
}
#[derive(Clone, Debug, PartialEq)]
pub struct TableColumn {
pub id: Option<String>,
pub name: String,
pub totals_row_function: Option<String>,
pub totals_row_label: Option<String>,
pub calculated_column_formula: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TableDef {
pub name: String,
pub display_name: String,
pub ref_range: MergeRect,
pub header_row_count: u32,
pub totals_row_count: u32,
pub totals_row_shown: bool,
pub columns: Vec<TableColumn>,
pub style_name: Option<String>,
pub auto_filter_ref: Option<MergeRect>,
pub autofilter_columns: Vec<FilterColumn>,
pub source_part: String,
pub(crate) pending_edits: Vec<TableEditOp>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum TableEditOp {
SetDisplayName(String),
Resize(MergeRect),
ResizeAutoFilter(MergeRect),
SetStyle(Option<String>),
SetTotalsRowShown(bool),
AddColumn(String),
RemoveColumn(String),
SetFilterColumn(u32, FilterCriteria),
ClearFilterColumn(u32),
}
fn col_letters(mut col: u32) -> String {
let mut s = String::new();
while col > 0 {
let rem = ((col - 1) % 26) as u8;
s.push((b'A' + rem) as char);
col = (col - 1) / 26;
}
s.chars().rev().collect()
}
fn format_merge_ref(rect: &MergeRect) -> String {
let ((r1, c1), (r2, c2)) = *rect;
format!("{}{}:{}{}", col_letters(c1), r1, col_letters(c2), r2)
}
fn span_attr_str(span: &str, attr_name: &str) -> Option<String> {
let (tag_start, tag_close_rel, full_name) = find_next_open_tag(span, 0)?;
let name_end = tag_start + 1 + full_name.len();
let raw_attrs = &span[name_end..name_end + tag_close_rel];
let attrs_str = raw_attrs.trim_end().strip_suffix('/').unwrap_or(raw_attrs);
attr_get(&parse_attrs(attrs_str), attr_name).map(|s| s.to_string())
}
const TABLE_CHILD_ORDER: &[&str] = &["autoFilter", "sortState", "tableColumns", "tableStyleInfo"];
pub(crate) fn apply_table_edits(table_xml: &str, edits: &[TableEditOp]) -> String {
let mut xml = table_xml.to_string();
for edit in edits {
xml = match edit {
TableEditOp::SetDisplayName(name) => with_attr(&xml, "displayName", name),
TableEditOp::Resize(rect) => with_attr(&xml, "ref", &format_merge_ref(rect)),
TableEditOp::ResizeAutoFilter(rect) => match extract_raw_element(&xml, "autoFilter") {
Some(old) => {
let new_child = with_attr(&old, "ref", &format_merge_ref(rect));
with_ordered_child(&xml, "autoFilter", TABLE_CHILD_ORDER, Some(&new_child))
}
None => xml,
},
TableEditOp::SetStyle(Some(name)) => {
let new_child = match extract_raw_element(&xml, "tableStyleInfo") {
Some(old) => with_attr(&old, "name", name),
None => format!("<tableStyleInfo name=\"{}\"/>", crate::xml_escape(name)),
};
with_ordered_child(&xml, "tableStyleInfo", TABLE_CHILD_ORDER, Some(&new_child))
}
TableEditOp::SetStyle(None) => {
with_ordered_child(&xml, "tableStyleInfo", TABLE_CHILD_ORDER, None)
}
TableEditOp::SetTotalsRowShown(shown) => {
with_attr(&xml, "totalsRowShown", if *shown { "1" } else { "0" })
}
TableEditOp::AddColumn(name) => {
let mut spans = extract_records(&xml, "tableColumns", "tableColumn");
let next_id = spans
.iter()
.filter_map(|s| span_attr_str(s, "id").and_then(|v| v.parse::<u32>().ok()))
.max()
.unwrap_or(0)
+ 1;
spans.push(format!(
"<tableColumn id=\"{next_id}\" name=\"{}\"/>",
crate::xml_escape(name)
));
let new_child = format!(
"<tableColumns count=\"{}\">{}</tableColumns>",
spans.len(),
spans.concat()
);
with_ordered_child(&xml, "tableColumns", TABLE_CHILD_ORDER, Some(&new_child))
}
TableEditOp::RemoveColumn(name) => {
let spans: Vec<String> = extract_records(&xml, "tableColumns", "tableColumn")
.into_iter()
.filter(|s| span_attr_str(s, "name").as_deref() != Some(name.as_str()))
.collect();
let new_child = format!(
"<tableColumns count=\"{}\">{}</tableColumns>",
spans.len(),
spans.concat()
);
with_ordered_child(&xml, "tableColumns", TABLE_CHILD_ORDER, Some(&new_child))
}
TableEditOp::SetFilterColumn(col_offset, criteria) => {
match extract_raw_element(&xml, "autoFilter") {
Some(af) => {
let ref_str = span_attr_str(&af, "ref").unwrap_or_default();
let mut cols: Vec<String> =
extract_records(&af, "autoFilter", "filterColumn")
.into_iter()
.filter(|c| {
span_attr_str(c, "colId").and_then(|v| v.parse::<u32>().ok())
!= Some(*col_offset)
})
.collect();
cols.push(crate::build_filter_column_xml(&FilterColumn {
col_offset: *col_offset,
hidden_button: false,
show_button: true,
criteria: criteria.clone(),
raw_span: None,
dirty: false,
}));
let new_af = crate::rebuild_autofilter_container(
Some(&af),
&ref_str,
&cols.concat(),
);
with_ordered_child(&xml, "autoFilter", TABLE_CHILD_ORDER, Some(&new_af))
}
None => xml,
}
}
TableEditOp::ClearFilterColumn(col_offset) => {
match extract_raw_element(&xml, "autoFilter") {
Some(af) => {
let ref_str = span_attr_str(&af, "ref").unwrap_or_default();
let cols: Vec<String> = extract_records(&af, "autoFilter", "filterColumn")
.into_iter()
.filter(|c| {
span_attr_str(c, "colId").and_then(|v| v.parse::<u32>().ok())
!= Some(*col_offset)
})
.collect();
let new_af = crate::rebuild_autofilter_container(
Some(&af),
&ref_str,
&cols.concat(),
);
with_ordered_child(&xml, "autoFilter", TABLE_CHILD_ORDER, Some(&new_af))
}
None => xml,
}
}
};
}
xml
}
pub(crate) fn relationship_ids(xml: &str) -> Vec<String> {
let mut ids = vec![];
let mut iter = XmlIter::new(xml);
while let Some(ev) = iter.next_ev() {
if let Ev::Open(ref tag, ref attrs) | Ev::SelfClose(ref tag, ref attrs) = ev {
let local = tag.split(':').next_back().unwrap_or(tag);
if local == "Relationship"
&& let Some(id) = attr_get(attrs, "Id")
{
ids.push(id.to_string());
}
}
}
ids
}
pub(crate) fn render_table_xml(table: &TableDef, table_id: u32) -> String {
let table_ref = format_merge_ref(&table.ref_range);
let mut out = format!(
concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
"<table xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" ",
"id=\"{}\" name=\"{}\" displayName=\"{}\" ref=\"{}\" headerRowCount=\"1\" ",
"totalsRowShown=\"0\">",
),
table_id,
crate::xml_escape(&table.name),
crate::xml_escape(&table.display_name),
table_ref,
);
if table.autofilter_columns.is_empty() {
out.push_str(&format!("<autoFilter ref=\"{table_ref}\"/>"));
} else {
let body: String = table
.autofilter_columns
.iter()
.map(crate::build_filter_column_xml)
.collect();
out.push_str(&format!(
"<autoFilter ref=\"{table_ref}\">{body}</autoFilter>"
));
}
out.push_str(&format!("<tableColumns count=\"{}\">", table.columns.len()));
for (i, col) in table.columns.iter().enumerate() {
out.push_str(&format!(
"<tableColumn id=\"{}\" name=\"{}\"/>",
i + 1,
crate::xml_escape(&col.name)
));
}
out.push_str("</tableColumns>");
if let Some(style) = &table.style_name {
out.push_str(&format!(
"<tableStyleInfo name=\"{}\" showFirstColumn=\"0\" showLastColumn=\"0\" \
showRowStripes=\"1\" showColumnStripes=\"0\"/>",
crate::xml_escape(style)
));
}
out.push_str("</table>\n");
out
}
pub enum SheetCell {
Integer(i64),
Float(f64),
Str(String),
Bool(bool),
Error(ExcelError),
}
pub fn read_workbook(path: &str) -> Result<Vec<WorkbookSheet>, String> {
let lower = path.to_lowercase();
if lower.ends_with(".ods") {
read_ods(path)
} else if lower.ends_with(".xlsx") || lower.ends_with(".xlsm") {
read_xlsx(path)
} else {
Err(format!("unsupported file format: {}", path))
}
}
pub fn read_workbook_from_bytes(bytes: &[u8]) -> Result<BufferWorkbook, String> {
let archive = ZipArchive::new(Cursor::new(bytes)).map_err(|e| e.to_string())?;
read_workbook_from_archive(archive)
}
pub struct BufferWorkbook {
pub sheets: Vec<BufferSheet>,
pub number_formats: HashMap<u32, String>,
pub date1904: bool,
}
pub struct BufferSheet {
pub sheet: WorkbookSheet,
pub formulas: HashMap<(u32, u32), String>,
pub dimension: Option<MergeRect>,
pub style_ids: HashMap<(u32, u32), u32>,
}
#[derive(Debug)]
struct Attr {
name: String,
value: String,
}
#[derive(Debug)]
enum Ev {
Open(String, Vec<Attr>),
Close(String),
SelfClose(String, Vec<Attr>),
Text(String),
}
struct XmlIter<'a> {
s: &'a str,
}
impl<'a> XmlIter<'a> {
fn new(s: &'a str) -> Self {
XmlIter { s }
}
fn next_ev(&mut self) -> Option<Ev> {
loop {
if self.s.is_empty() {
return None;
}
if !self.s.starts_with('<') {
let end = self.s.find('<').unwrap_or(self.s.len());
let raw = &self.s[..end];
self.s = &self.s[end..];
let text = xml_unescape(raw);
if text.is_empty() {
continue;
}
return Some(Ev::Text(text));
}
self.s = &self.s[1..];
if self.s.starts_with('/') {
self.s = &self.s[1..];
let end = self.s.find('>').unwrap_or(self.s.len());
let name = self.s[..end].trim().to_string();
self.s = &self.s[(end + 1).min(self.s.len())..];
return Some(Ev::Close(name));
}
if self.s.starts_with("!--") {
let end = self.s.find("-->").map(|p| p + 3).unwrap_or(self.s.len());
self.s = &self.s[end..];
continue;
}
if self.s.starts_with("![CDATA[") {
self.s = &self.s[8..];
let end = self.s.find("]]>").unwrap_or(self.s.len());
let text = self.s[..end].to_string();
self.s = &self.s[(end + 3).min(self.s.len())..];
if !text.is_empty() {
return Some(Ev::Text(text));
}
continue;
}
if self.s.starts_with('?') || self.s.starts_with('!') {
let end = self.s.find('>').map(|p| p + 1).unwrap_or(self.s.len());
self.s = &self.s[end..];
continue;
}
let tag_end = find_tag_close(self.s);
let tag_inner = self.s[..tag_end].trim_end();
let self_close = tag_inner.ends_with('/');
let tag_body = if self_close {
tag_inner[..tag_inner.len() - 1].trim_end()
} else {
tag_inner
};
self.s = &self.s[(tag_end + 1).min(self.s.len())..];
let name_end = tag_body
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(tag_body.len());
let name = tag_body[..name_end].to_string();
let attrs = parse_attrs(&tag_body[name_end..]);
if self_close {
return Some(Ev::SelfClose(name, attrs));
}
return Some(Ev::Open(name, attrs));
}
}
}
fn find_tag_close(s: &str) -> usize {
let mut in_quote = false;
let mut qchar = '"';
for (i, c) in s.char_indices() {
if in_quote {
if c == qchar {
in_quote = false;
}
} else {
match c {
'"' | '\'' => {
in_quote = true;
qchar = c;
}
'>' => return i,
_ => {}
}
}
}
s.len()
}
fn parse_attrs(mut s: &str) -> Vec<Attr> {
let mut attrs = vec![];
loop {
s = s.trim_start();
if s.is_empty() {
break;
}
let Some(eq) = s.find('=') else { break };
let name = s[..eq].trim().to_string();
if name.is_empty() {
break;
}
s = s[eq + 1..].trim_start();
let Some(quote) = s.chars().next() else { break };
if quote != '"' && quote != '\'' {
break;
}
s = &s[1..]; let end = s.find(quote).unwrap_or(s.len());
let value = xml_unescape(&s[..end]);
s = &s[(end + 1).min(s.len())..];
attrs.push(Attr { name, value });
}
attrs
}
fn attr_get<'a>(attrs: &'a [Attr], name: &str) -> Option<&'a str> {
attrs
.iter()
.find(|a| a.name == name || a.name.split(':').next_back() == Some(name))
.map(|a| a.value.as_str())
}
fn attr_is_true(attrs: &[Attr], name: &str) -> bool {
matches!(
attr_get(attrs, name),
Some("1") | Some("true") | Some("TRUE")
)
}
const MAX_ENTITY_BODY_LEN: usize = 12;
pub(crate) fn xml_unescape(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let after = &rest[amp + 1..];
let window_end = after.len().min(MAX_ENTITY_BODY_LEN);
let decoded = after[..window_end].find(';').and_then(|semi| {
let entity = &after[..semi];
let ch = match entity {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
_ => entity.strip_prefix('#').and_then(|numeric| {
let code = if let Some(hex) = numeric
.strip_prefix('x')
.or_else(|| numeric.strip_prefix('X'))
{
u32::from_str_radix(hex, 16).ok()
} else {
numeric.parse::<u32>().ok()
};
code.and_then(char::from_u32)
}),
};
ch.map(|c| (c, semi))
});
match decoded {
Some((c, semi)) => {
out.push(c);
rest = &after[semi + 1..];
}
None => {
out.push('&');
rest = after;
}
}
}
out.push_str(rest);
out
}
const ZIP_ENTRY_MAX_BYTES: u64 = 256 * 1024 * 1024;
const ZIP_MAX_ENTRIES: usize = 10_000;
const ZIP_MAX_TOTAL_BYTES: u64 = 1024 * 1024 * 1024;
const ZIP_MAX_COMPRESSION_RATIO: u64 = 1_000;
fn validate_zip_archive<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Result<(), String> {
if archive.len() > ZIP_MAX_ENTRIES {
return Err(format!(
"ZIP archive has too many entries ({}; maximum is {})",
archive.len(),
ZIP_MAX_ENTRIES
));
}
let mut total_uncompressed = 0u64;
for i in 0..archive.len() {
let entry = archive.by_index(i).map_err(|e| e.to_string())?;
let name = entry.name();
if name.starts_with('/')
|| name.starts_with('\\')
|| name.split('/').any(|part| part == "..")
|| name.contains('\0')
{
return Err(format!("ZIP entry has an unsafe path: {name}"));
}
let uncompressed = entry.size();
let compressed = entry.compressed_size();
if uncompressed > ZIP_ENTRY_MAX_BYTES {
return Err(format!(
"ZIP entry is too large: {name} ({} bytes; maximum is {})",
uncompressed, ZIP_ENTRY_MAX_BYTES
));
}
total_uncompressed = total_uncompressed
.checked_add(uncompressed)
.ok_or_else(|| "ZIP archive uncompressed size overflows u64".to_string())?;
if total_uncompressed > ZIP_MAX_TOTAL_BYTES {
return Err(format!(
"ZIP archive expands beyond the maximum size ({} bytes; maximum is {})",
total_uncompressed, ZIP_MAX_TOTAL_BYTES
));
}
if compressed > 0 && uncompressed / compressed > ZIP_MAX_COMPRESSION_RATIO {
return Err(format!(
"ZIP entry has an excessive compression ratio: {name} ({}:1; maximum is {}:1)",
uncompressed / compressed,
ZIP_MAX_COMPRESSION_RATIO
));
}
}
Ok(())
}
fn zip_read_text<R: Read + Seek>(
archive: &mut ZipArchive<R>,
name: &str,
) -> Result<String, String> {
let mut entry = archive
.by_name(name)
.map_err(|e| format!("{}: {}", name, e))?;
let mut s = String::new();
entry
.by_ref()
.take(ZIP_ENTRY_MAX_BYTES)
.read_to_string(&mut s)
.map_err(|e| e.to_string())?;
Ok(s)
}
#[cfg(feature = "python")]
pub(crate) fn validate_zip_archive_for_stream<R: Read + Seek>(
archive: &mut ZipArchive<R>,
) -> Result<(), String> {
validate_zip_archive(archive)
}
#[cfg(feature = "python")]
pub(crate) fn zip_read_text_for_stream<R: Read + Seek>(
archive: &mut ZipArchive<R>,
name: &str,
) -> Result<String, String> {
zip_read_text(archive, name)
}
#[cfg(feature = "python")]
pub(crate) fn xlsx_workbook_sheets_for_stream(
xml: &str,
) -> Vec<(String, String, Option<String>, Option<String>)> {
xlsx_workbook_sheets(xml)
}
#[cfg(feature = "python")]
pub(crate) fn xlsx_rels_for_stream(xml: &str, suffix: &str) -> HashMap<String, String> {
xlsx_rels(xml, suffix)
}
#[cfg(feature = "python")]
pub(crate) fn xlsx_sheet_cells_for_stream(xml: &str, shared: &[String]) -> XlsxSheetData {
xlsx_sheet_cells(xml, shared, &[])
}
#[cfg(feature = "python")]
pub(crate) fn xlsx_shared_strings_for_stream(xml: &str) -> Vec<String> {
xlsx_shared_strings(xml)
}
pub(crate) fn read_raw_zip_entries(path: &str) -> Result<HashMap<String, Vec<u8>>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
validate_zip_archive(&mut archive)?;
let mut out = HashMap::new();
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
let mut buf = Vec::new();
entry
.by_ref()
.take(ZIP_ENTRY_MAX_BYTES)
.read_to_end(&mut buf)
.map_err(|e| e.to_string())?;
out.insert(name, buf);
}
Ok(out)
}
pub(crate) type ContentTypeDecls = (Vec<(String, String)>, Vec<(String, String)>);
pub(crate) fn content_type_decls(xml: &str) -> ContentTypeDecls {
let mut defaults = vec![];
let mut overrides = vec![];
let mut iter = XmlIter::new(xml);
while let Some(ev) = iter.next_ev() {
if let Ev::Open(ref tag, ref attrs) | Ev::SelfClose(ref tag, ref attrs) = ev {
let local = tag.split(':').next_back().unwrap_or(tag);
match local {
"Default" => {
if let (Some(ext), Some(ct)) =
(attr_get(attrs, "Extension"), attr_get(attrs, "ContentType"))
{
defaults.push((ext.to_string(), ct.to_string()));
}
}
"Override" => {
if let (Some(part), Some(ct)) =
(attr_get(attrs, "PartName"), attr_get(attrs, "ContentType"))
{
overrides.push((part.to_string(), ct.to_string()));
}
}
_ => {}
}
}
}
(defaults, overrides)
}
pub(crate) fn extract_root_attrs(xml: &str, local_name: &str) -> Option<String> {
let (start, tag_close_rel, full_name) = find_next_open_tag(xml, 0)?;
if full_name.rsplit(':').next().unwrap_or(&full_name) != local_name {
return None;
}
let after_name = &xml[start + 1 + full_name.len()..];
let trimmed = after_name[..tag_close_rel].trim();
let attrs = trimmed.strip_suffix('/').unwrap_or(trimmed).trim_end();
if attrs.is_empty() {
None
} else {
Some(attrs.to_string())
}
}
pub(crate) const OFFICE_REL_NS: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
pub(crate) fn ensure_r_prefix_bound(attrs: &str) -> Option<String> {
match parse_attrs(attrs).into_iter().find(|a| a.name == "xmlns:r") {
Some(a) if a.value == OFFICE_REL_NS => Some(attrs.to_string()),
Some(_) => None,
None => Some(format!("{attrs} xmlns:r=\"{OFFICE_REL_NS}\"")),
}
}
pub(crate) fn extract_raw_element(xml: &str, local_name: &str) -> Option<String> {
let mut search_from = 0;
loop {
let (tag_start, tag_close_rel, full_name) = find_next_open_tag(xml, search_from)?;
if full_name.rsplit(':').next().unwrap_or(&full_name) != local_name {
search_from = tag_start + 1;
continue;
}
let name_end = tag_start + 1 + full_name.len();
let start_tag_end = name_end + tag_close_rel + 1;
let self_closing = xml[name_end..name_end + tag_close_rel]
.trim_end()
.ends_with('/');
if self_closing {
return Some(xml[tag_start..start_tag_end].to_string());
}
let close_tag = format!("</{}>", full_name);
let end_rel = xml[start_tag_end..].find(&close_tag)?;
let end = start_tag_end + end_rel + close_tag.len();
return Some(xml[tag_start..end].to_string());
}
}
pub(crate) fn extract_all_raw_elements(xml: &str, local_name: &str) -> Vec<String> {
let mut out = Vec::new();
let mut search_from = 0;
while let Some((tag_start, tag_close_rel, full_name)) = find_next_open_tag(xml, search_from) {
if full_name.rsplit(':').next().unwrap_or(&full_name) != local_name {
search_from = tag_start + 1;
continue;
}
let name_end = tag_start + 1 + full_name.len();
let start_tag_end = name_end + tag_close_rel + 1;
let self_closing = xml[name_end..name_end + tag_close_rel]
.trim_end()
.ends_with('/');
if self_closing {
out.push(xml[tag_start..start_tag_end].to_string());
search_from = start_tag_end;
continue;
}
let close_tag = format!("</{}>", full_name);
let Some(end_rel) = xml[start_tag_end..].find(&close_tag) else {
break;
};
let end = start_tag_end + end_rel + close_tag.len();
out.push(xml[tag_start..end].to_string());
search_from = end;
}
out
}
fn find_next_open_tag(xml: &str, mut search_from: usize) -> Option<(usize, usize, String)> {
loop {
let rel = xml[search_from..].find('<')?;
let tag_start = search_from + rel;
let after_lt = &xml[tag_start + 1..];
if after_lt.starts_with(['/', '!', '?']) {
search_from = tag_start + 1;
continue;
}
let name_end = after_lt
.find(|c: char| c.is_ascii_whitespace() || c == '>' || c == '/')
.unwrap_or(after_lt.len());
let full_name = after_lt[..name_end].to_string();
let rest = &after_lt[name_end..];
let tag_close_rel = find_tag_close(rest);
return Some((tag_start, tag_close_rel, full_name));
}
}
pub(crate) fn extract_hyperlinks(xml: &str, include_relationship_backed: bool) -> Vec<String> {
let Some(container) = extract_raw_element(xml, "hyperlinks") else {
return Vec::new();
};
let mut out = Vec::new();
let mut search_from = 0;
while let Some((tag_start, tag_close_rel, full_name)) =
find_next_open_tag(&container, search_from)
{
if full_name.rsplit(':').next().unwrap_or(&full_name) != "hyperlink" {
search_from = tag_start + 1;
continue;
}
let name_end = tag_start + 1 + full_name.len();
let start_tag_end = name_end + tag_close_rel + 1;
let attrs = parse_attrs(&container[name_end..name_end + tag_close_rel]);
let has_rid = attr_get(&attrs, "id").is_some();
if !has_rid || include_relationship_backed {
out.push(container[tag_start..start_tag_end].to_string());
}
search_from = start_tag_end;
}
out
}
pub(crate) fn extract_defined_name_elements(xml: &str) -> Vec<(String, (usize, usize))> {
let Some(container) = extract_raw_element(xml, "definedNames") else {
return Vec::new();
};
let mut out = Vec::new();
let mut search_from = 0;
while let Some((tag_start, tag_close_rel, full_name)) =
find_next_open_tag(&container, search_from)
{
if full_name.rsplit(':').next().unwrap_or(&full_name) != "definedName" {
search_from = tag_start + 1;
continue;
}
let name_end = tag_start + 1 + full_name.len();
let start_tag_end = name_end + tag_close_rel + 1;
let self_closing = container[name_end..name_end + tag_close_rel]
.trim_end()
.ends_with('/');
if self_closing {
let el = container[tag_start..start_tag_end].to_string();
let end = el.len();
out.push((el, (end, end)));
search_from = start_tag_end;
continue;
}
let close_tag = format!("</{}>", full_name);
let Some(end_rel) = container[start_tag_end..].find(&close_tag) else {
break;
};
let text_end = start_tag_end + end_rel;
let end = text_end + close_tag.len();
out.push((
container[tag_start..end].to_string(),
(start_tag_end - tag_start, text_end - tag_start),
));
search_from = end;
}
out
}
pub(crate) fn root_tag_has_rid(element_xml: &str) -> bool {
let Some((tag_start, tag_close_rel, full_name)) = find_next_open_tag(element_xml, 0) else {
return false;
};
let name_end = tag_start + 1 + full_name.len();
let attrs = parse_attrs(&element_xml[name_end..name_end + tag_close_rel]);
attr_get(&attrs, "id").is_some()
}
#[cfg(test)]
mod opaque_fragment_tests {
use super::*;
#[test]
fn extract_root_attrs_captures_namespaces_and_xr_uid_verbatim() {
let xml = concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
"<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" ",
"xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" ",
"mc:Ignorable=\"x14ac xr xr2 xr3\" xr:uid=\"{ACCE0F6A-5070-C341-A245-A04D433D82F2}\">\n",
"<sheetData/></worksheet>",
);
let attrs = extract_root_attrs(xml, "worksheet").unwrap();
assert!(
attrs
.starts_with("xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"")
);
assert!(attrs.contains("xr:uid=\"{ACCE0F6A-5070-C341-A245-A04D433D82F2}\""));
assert!(
!attrs.ends_with('/'),
"self-closing slash must not leak in: {attrs:?}"
);
}
#[test]
fn ensure_r_prefix_bound_leaves_a_correct_binding_untouched() {
let attrs = concat!(
"xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" ",
"xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"",
);
assert_eq!(ensure_r_prefix_bound(attrs), Some(attrs.to_string()));
}
#[test]
fn ensure_r_prefix_bound_appends_the_binding_when_absent() {
let attrs = concat!(
"xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" ",
"xmlns:rel=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"",
);
let result = ensure_r_prefix_bound(attrs).unwrap();
assert!(
result.starts_with(attrs),
"must not disturb the original attrs: {result}"
);
assert!(
result.contains(
"xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\""
),
"must append the r: binding: {result}"
);
}
#[test]
fn ensure_r_prefix_bound_appends_when_the_relationships_namespace_is_absent_entirely() {
let attrs = "xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"";
let result = ensure_r_prefix_bound(attrs).unwrap();
assert!(
result.contains(
"xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\""
),
"must append the r: binding even when no relationships namespace was declared \
under any prefix: {result}"
);
}
#[test]
fn ensure_r_prefix_bound_refuses_to_reuse_attrs_when_r_is_bound_to_a_different_uri() {
let attrs = concat!(
"xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" ",
"xmlns:r=\"http://example.com/totally-unrelated\"",
);
assert_eq!(
ensure_r_prefix_bound(attrs),
None,
"must not silently rebind an r: prefix a source is already using for \
something else -- caller falls back to the writer's own safe default instead"
);
}
#[test]
fn extract_root_attrs_returns_none_for_a_bare_no_attribute_root() {
let xml = "<?xml version=\"1.0\"?><worksheet><sheetData/></worksheet>";
assert_eq!(extract_root_attrs(xml, "worksheet"), None);
}
#[test]
fn extract_root_attrs_returns_none_on_local_name_mismatch() {
let xml = "<?xml version=\"1.0\"?><workbook foo=\"bar\"><sheets/></workbook>";
assert_eq!(extract_root_attrs(xml, "worksheet"), None);
}
#[test]
fn extract_raw_element_returns_the_full_subtree_verbatim() {
let xml = concat!(
"<?xml version=\"1.0\"?><worksheet>",
"<sheetViews><sheetView tabSelected=\"1\" workbookViewId=\"0\">",
"<pane xSplit=\"1\" ySplit=\"1\" topLeftCell=\"B2\" activePane=\"bottomRight\" state=\"frozen\"/>",
"<selection pane=\"bottomRight\" activeCell=\"B2\" sqref=\"B2\"/>",
"</sheetView></sheetViews>",
"<sheetData/></worksheet>",
);
let frag = extract_raw_element(xml, "sheetViews").unwrap();
assert_eq!(
frag,
concat!(
"<sheetViews><sheetView tabSelected=\"1\" workbookViewId=\"0\">",
"<pane xSplit=\"1\" ySplit=\"1\" topLeftCell=\"B2\" activePane=\"bottomRight\" state=\"frozen\"/>",
"<selection pane=\"bottomRight\" activeCell=\"B2\" sqref=\"B2\"/>",
"</sheetView></sheetViews>",
)
);
}
#[test]
fn extract_raw_element_handles_a_self_closing_form() {
let xml = "<worksheet><sheetViews/><sheetData/></worksheet>";
assert_eq!(
extract_raw_element(xml, "sheetViews"),
Some("<sheetViews/>".to_string())
);
}
#[test]
fn extract_raw_element_returns_none_when_absent() {
let xml = "<worksheet><sheetData/></worksheet>";
assert_eq!(extract_raw_element(xml, "sheetViews"), None);
}
#[test]
fn extract_raw_element_does_not_match_a_differently_named_element() {
let xml = "<worksheet><sheetView tabSelected=\"1\"/><sheetData/></worksheet>";
assert_eq!(extract_raw_element(xml, "sheetViews"), None);
}
#[test]
fn extract_raw_element_ignores_a_namespace_prefix_on_the_target_element() {
let xml = "<worksheet><x:sheetViews><x:sheetView/></x:sheetViews></worksheet>";
assert_eq!(
extract_raw_element(xml, "sheetViews"),
Some("<x:sheetViews><x:sheetView/></x:sheetViews>".to_string())
);
}
#[test]
fn extract_all_raw_elements_returns_every_occurrence_in_document_order() {
let xml = concat!(
"<worksheet><sheetData/>",
"<conditionalFormatting sqref=\"A1:A5\">",
"<cfRule type=\"cellIs\" dxfId=\"0\" priority=\"1\" operator=\"greaterThan\">",
"<formula>10</formula></cfRule></conditionalFormatting>",
"<conditionalFormatting sqref=\"B1:B5\">",
"<cfRule type=\"cellIs\" dxfId=\"1\" priority=\"2\" operator=\"lessThan\">",
"<formula>0</formula></cfRule></conditionalFormatting>",
"</worksheet>",
);
let all = extract_all_raw_elements(xml, "conditionalFormatting");
assert_eq!(all.len(), 2);
assert!(all[0].starts_with("<conditionalFormatting sqref=\"A1:A5\">"));
assert!(all[0].ends_with("</conditionalFormatting>"));
assert!(all[1].starts_with("<conditionalFormatting sqref=\"B1:B5\">"));
}
#[test]
fn extract_all_raw_elements_returns_empty_when_absent() {
let xml = "<worksheet><sheetData/></worksheet>";
assert_eq!(
extract_all_raw_elements(xml, "conditionalFormatting"),
Vec::<String>::new()
);
}
#[test]
fn extract_all_raw_elements_handles_a_single_self_closing_occurrence() {
let xml = "<worksheet><conditionalFormatting sqref=\"A1\"/></worksheet>";
assert_eq!(
extract_all_raw_elements(xml, "conditionalFormatting"),
vec!["<conditionalFormatting sqref=\"A1\"/>".to_string()]
);
}
#[test]
fn extract_all_raw_elements_does_not_match_a_differently_named_element() {
let xml = "<worksheet><conditionalFormattingRule/></worksheet>";
assert_eq!(
extract_all_raw_elements(xml, "conditionalFormatting"),
Vec::<String>::new()
);
}
#[test]
fn hyperlinks_returns_none_when_hyperlinks_absent() {
let xml = "<worksheet><sheetData/></worksheet>";
assert_eq!(extract_hyperlinks(xml, false), Vec::<String>::new());
assert_eq!(extract_hyperlinks(xml, true), Vec::<String>::new());
}
#[test]
fn hyperlinks_all_location_form_all_kept_either_way() {
let xml = concat!(
"<worksheet><sheetData/>",
"<hyperlinks><hyperlink ref=\"A1\" location=\"Sheet2!B2\" display=\"Sheet2!B2\" ",
"xr:uid=\"{7239724E-8623-EB4C-A548-F5CFD578FC11}\"/></hyperlinks>",
"</worksheet>",
);
let expected = vec![
"<hyperlink ref=\"A1\" location=\"Sheet2!B2\" display=\"Sheet2!B2\" \
xr:uid=\"{7239724E-8623-EB4C-A548-F5CFD578FC11}\"/>"
.to_string(),
];
assert_eq!(extract_hyperlinks(xml, false), expected);
assert_eq!(extract_hyperlinks(xml, true), expected);
}
#[test]
fn hyperlinks_all_rid_form_excluded_unless_relationship_backed_requested() {
let xml = concat!(
"<worksheet xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">",
"<sheetData/><hyperlinks><hyperlink ref=\"D6\" r:id=\"rId1\"/></hyperlinks>",
"</worksheet>",
);
assert_eq!(extract_hyperlinks(xml, false), Vec::<String>::new());
assert_eq!(
extract_hyperlinks(xml, true),
vec!["<hyperlink ref=\"D6\" r:id=\"rId1\"/>".to_string()]
);
}
#[test]
fn hyperlinks_mixed_container_respects_the_flag_per_child() {
let xml = concat!(
"<worksheet xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">",
"<sheetData/><hyperlinks>",
"<hyperlink ref=\"A1\" r:id=\"rId1\"/>",
"<hyperlink ref=\"B1\" location=\"Sheet2!A1\"/>",
"<hyperlink ref=\"C1\" r:id=\"rId2\"/>",
"</hyperlinks></worksheet>",
);
assert_eq!(
extract_hyperlinks(xml, false),
vec!["<hyperlink ref=\"B1\" location=\"Sheet2!A1\"/>".to_string()]
);
assert_eq!(
extract_hyperlinks(xml, true),
vec![
"<hyperlink ref=\"A1\" r:id=\"rId1\"/>".to_string(),
"<hyperlink ref=\"B1\" location=\"Sheet2!A1\"/>".to_string(),
"<hyperlink ref=\"C1\" r:id=\"rId2\"/>".to_string(),
]
);
}
#[test]
fn hyperlinks_returns_multiple_in_document_order() {
let xml = concat!(
"<worksheet><sheetData/><hyperlinks>",
"<hyperlink ref=\"A1\" location=\"Sheet2!A1\"/>",
"<hyperlink ref=\"B1\" location=\"Sheet3!A1\"/>",
"</hyperlinks></worksheet>",
);
let expected = vec![
"<hyperlink ref=\"A1\" location=\"Sheet2!A1\"/>".to_string(),
"<hyperlink ref=\"B1\" location=\"Sheet3!A1\"/>".to_string(),
];
assert_eq!(extract_hyperlinks(xml, false), expected);
assert_eq!(extract_hyperlinks(xml, true), expected);
}
#[test]
fn extract_defined_name_elements_returns_element_and_inner_text_span() {
let xml = concat!(
"<workbook><definedNames>",
"<definedName name=\"MyRange\">Sheet1!$A$1:$A$3</definedName>",
"<definedName name=\"Other\" localSheetId=\"0\">Sheet1!$B$1</definedName>",
"</definedNames></workbook>",
);
let elements = extract_defined_name_elements(xml);
assert_eq!(elements.len(), 2);
let (el0, (s0, e0)) = &elements[0];
assert_eq!(
el0,
"<definedName name=\"MyRange\">Sheet1!$A$1:$A$3</definedName>"
);
assert_eq!(&el0[*s0..*e0], "Sheet1!$A$1:$A$3");
let (el1, (s1, e1)) = &elements[1];
assert_eq!(
el1,
"<definedName name=\"Other\" localSheetId=\"0\">Sheet1!$B$1</definedName>"
);
assert_eq!(&el1[*s1..*e1], "Sheet1!$B$1");
}
#[test]
fn extract_defined_name_elements_empty_when_container_absent() {
let xml = "<workbook><sheets/></workbook>";
assert!(extract_defined_name_elements(xml).is_empty());
}
#[test]
fn extract_defined_name_elements_empty_when_no_children() {
let xml = "<workbook><definedNames/></workbook>";
assert!(extract_defined_name_elements(xml).is_empty());
}
#[test]
fn extract_cell_xfs_returns_self_closing_and_child_bearing_spans_verbatim() {
let xml = concat!(
"<styleSheet><cellXfs count=\"3\">",
"<xf/>",
"<xf numFmtId=\"4\" fontId=\"0\" fillId=\"0\" borderId=\"0\" applyNumberFormat=\"1\"/>",
"<xf numFmtId=\"0\" fontId=\"1\"><alignment horizontal=\"center\"/></xf>",
"</cellXfs></styleSheet>",
);
let xfs = extract_cell_xfs(xml);
assert_eq!(
xfs,
vec![
"<xf/>".to_string(),
"<xf numFmtId=\"4\" fontId=\"0\" fillId=\"0\" borderId=\"0\" applyNumberFormat=\"1\"/>"
.to_string(),
"<xf numFmtId=\"0\" fontId=\"1\"><alignment horizontal=\"center\"/></xf>".to_string(),
]
);
}
#[test]
fn extract_cell_xfs_empty_when_container_absent() {
assert!(extract_cell_xfs("<styleSheet><fonts/></styleSheet>").is_empty());
}
#[test]
fn with_num_fmt_id_inserts_into_a_bare_self_closing_xf() {
assert_eq!(with_num_fmt_id("<xf/>", 4), "<xf numFmtId=\"4\"/>");
}
#[test]
fn with_num_fmt_id_replaces_an_existing_numfmtid_preserving_other_attrs() {
let xf = "<xf numFmtId=\"9\" fontId=\"2\" fillId=\"1\" applyNumberFormat=\"1\"/>";
let out = with_num_fmt_id(xf, 4);
let attrs = parse_attrs(&out[3..out.len() - 2]);
assert_eq!(attr_get(&attrs, "numFmtId"), Some("4"));
assert_eq!(attr_get(&attrs, "fontId"), Some("2"));
assert_eq!(attr_get(&attrs, "fillId"), Some("1"));
assert_eq!(attr_get(&attrs, "applyNumberFormat"), Some("1"));
}
#[test]
fn with_num_fmt_id_preserves_child_elements_on_a_non_self_closing_xf() {
let xf = "<xf numFmtId=\"0\" fontId=\"1\"><alignment horizontal=\"center\"/></xf>";
let out = with_num_fmt_id(xf, 14);
assert!(out.ends_with("<alignment horizontal=\"center\"/></xf>"));
assert!(out.contains("numFmtId=\"14\""));
assert!(!out.contains("numFmtId=\"0\""));
}
#[test]
fn with_num_fmt_id_is_idempotent_for_dedup_when_called_twice_on_the_same_input() {
let xf = "<xf fontId=\"3\" borderId=\"2\"/>";
assert_eq!(with_num_fmt_id(xf, 7), with_num_fmt_id(xf, 7));
}
#[test]
fn resolve_number_format_id_reuses_a_builtin() {
let custom = HashMap::new();
match resolve_number_format_id("#,##0.00", &custom) {
ResolvedNumFmt::Existing(id) => assert_eq!(id, 4),
ResolvedNumFmt::New(_) => panic!("expected an existing builtin id"),
}
}
#[test]
fn resolve_number_format_id_reuses_an_existing_custom_entry() {
let mut custom = HashMap::new();
custom.insert(164, "0.00\"kg\"".to_string());
match resolve_number_format_id("0.00\"kg\"", &custom) {
ResolvedNumFmt::Existing(id) => assert_eq!(id, 164),
ResolvedNumFmt::New(_) => panic!("expected the existing custom id to be reused"),
}
}
#[test]
fn resolve_number_format_id_mints_164_when_no_custom_entries_exist() {
let custom = HashMap::new();
match resolve_number_format_id("0.00\"kg\"", &custom) {
ResolvedNumFmt::New(id) => assert_eq!(id, 164),
ResolvedNumFmt::Existing(_) => panic!("a genuinely custom format has no builtin match"),
}
}
#[test]
fn resolve_number_format_id_mints_one_past_the_highest_existing_custom_id() {
let mut custom = HashMap::new();
custom.insert(164, "0.00\"kg\"".to_string());
custom.insert(170, "0.00\"lb\"".to_string());
match resolve_number_format_id("[Red]0.00", &custom) {
ResolvedNumFmt::New(id) => assert_eq!(id, 171),
ResolvedNumFmt::Existing(_) => panic!("not a builtin or existing custom format"),
}
}
#[test]
fn extract_records_generalizes_to_fonts_and_fills() {
let xml = concat!(
"<styleSheet><fonts count=\"2\"><font/>",
"<font><b val=\"1\"/><sz val=\"14\"/></font></fonts>",
"<fills count=\"1\"><fill><patternFill patternType=\"none\"/></fill></fills>",
"</styleSheet>",
);
assert_eq!(
extract_records(xml, "fonts", "font"),
vec![
"<font/>".to_string(),
"<font><b val=\"1\"/><sz val=\"14\"/></font>".to_string(),
]
);
assert_eq!(
extract_records(xml, "fills", "fill"),
vec!["<fill><patternFill patternType=\"none\"/></fill>".to_string()]
);
}
#[test]
fn extract_records_matches_extract_cell_xfs_for_cellxfs() {
let xml = "<styleSheet><cellXfs count=\"1\"><xf fontId=\"1\"/></cellXfs></styleSheet>";
assert_eq!(extract_records(xml, "cellXfs", "xf"), extract_cell_xfs(xml));
}
#[test]
fn with_attr_matches_with_num_fmt_id_for_numfmtid() {
let xf = "<xf numFmtId=\"9\" fontId=\"2\"/>";
assert_eq!(with_attr(xf, "numFmtId", "4"), with_num_fmt_id(xf, 4));
}
#[test]
fn with_attr_sets_a_new_attribute_on_a_self_closing_span() {
assert_eq!(
with_attr("<xf fontId=\"0\"/>", "applyFont", "1"),
"<xf fontId=\"0\" applyFont=\"1\"/>"
);
}
#[test]
fn with_attr_preserves_children_on_a_non_self_closing_span() {
let out = with_attr(
"<xf fillId=\"0\"><alignment vertical=\"center\"/></xf>",
"fillId",
"3",
);
assert!(out.contains("fillId=\"3\""));
assert!(out.ends_with("<alignment vertical=\"center\"/></xf>"));
}
#[test]
fn named_style_xf_id_finds_a_real_fixture_shaped_entry() {
let xml = concat!(
"<styleSheet><cellStyles count=\"2\">",
"<cellStyle name=\"ハイパーリンク\" xfId=\"1\" builtinId=\"8\"/>",
"<cellStyle name=\"標準\" xfId=\"0\" builtinId=\"0\"/>",
"</cellStyles></styleSheet>",
);
assert_eq!(named_style_xf_id(xml, "ハイパーリンク"), Some(1));
assert_eq!(named_style_xf_id(xml, "標準"), Some(0));
}
#[test]
fn named_style_xf_id_none_for_an_unknown_name() {
let xml = "<styleSheet><cellStyles count=\"1\"><cellStyle name=\"標準\" xfId=\"0\"/></cellStyles></styleSheet>";
assert_eq!(named_style_xf_id(xml, "Bad"), None);
}
#[test]
fn named_style_xf_id_none_with_no_cellstyles_element() {
assert_eq!(
named_style_xf_id("<styleSheet></styleSheet>", "Normal"),
None
);
}
#[test]
fn span_attr_u32_reads_an_existing_attribute() {
assert_eq!(
span_attr_u32("<xf fontId=\"7\" borderId=\"2\"/>", "fontId"),
7
);
}
#[test]
fn span_attr_u32_defaults_to_zero_when_absent() {
assert_eq!(span_attr_u32("<xf/>", "fontId"), 0);
}
#[test]
fn with_child_inserts_into_a_bare_self_closing_parent() {
assert_eq!(
with_child("<font/>", "b", Some("<b val=\"1\"/>")),
"<font><b val=\"1\"/></font>"
);
}
#[test]
fn with_child_appends_to_an_existing_non_self_closing_parent() {
assert_eq!(
with_child("<font><sz val=\"11\"/></font>", "b", Some("<b val=\"1\"/>")),
"<font><sz val=\"11\"/><b val=\"1\"/></font>"
);
}
#[test]
fn with_child_replaces_an_existing_child_in_place() {
assert_eq!(
with_child(
"<font><b val=\"1\"/><sz val=\"11\"/></font>",
"sz",
Some("<sz val=\"14\"/>")
),
"<font><b val=\"1\"/><sz val=\"14\"/></font>"
);
}
#[test]
fn with_child_removes_an_existing_child_when_given_none() {
assert_eq!(
with_child("<font><b val=\"1\"/><sz val=\"11\"/></font>", "b", None),
"<font><sz val=\"11\"/></font>"
);
}
#[test]
fn with_child_remove_is_a_noop_when_child_absent() {
assert_eq!(
with_child("<font><sz val=\"11\"/></font>", "b", None),
"<font><sz val=\"11\"/></font>"
);
}
#[test]
fn with_ordered_child_inserts_at_correct_position_among_present_siblings() {
let out = with_ordered_child(
"<border><left style=\"thin\"/></border>",
"top",
&BORDER_SIDE_ORDER,
Some("<top style=\"thick\"/>"),
);
assert_eq!(
out,
"<border><left style=\"thin\"/><top style=\"thick\"/></border>"
);
}
#[test]
fn with_ordered_child_inserts_before_a_later_sibling_when_earlier_is_added() {
let out = with_ordered_child(
"<border><top style=\"thick\"/></border>",
"left",
&BORDER_SIDE_ORDER,
Some("<left style=\"thin\"/>"),
);
assert_eq!(
out,
"<border><left style=\"thin\"/><top style=\"thick\"/></border>"
);
}
#[test]
fn with_ordered_child_replaces_in_place_keeping_position() {
let out = with_ordered_child(
"<xf><alignment vertical=\"center\"/><protection locked=\"1\"/></xf>",
"alignment",
&XF_CHILD_ORDER,
Some("<alignment horizontal=\"center\"/>"),
);
assert_eq!(
out,
"<xf><alignment horizontal=\"center\"/><protection locked=\"1\"/></xf>"
);
}
#[test]
fn with_font_edit_only_touches_requested_properties() {
let font = "<font><u/><sz val=\"12\"/><color theme=\"10\"/><name val=\"Calibri\"/></font>";
let edit = FontEdit {
bold: Some(true),
..Default::default()
};
let out = with_font_edit(font, &edit);
assert!(out.contains("<b val=\"1\"/>"));
assert!(out.contains("<u/>"));
assert!(out.contains("<color theme=\"10\"/>"));
assert!(out.contains("<sz val=\"12\"/>"));
assert!(out.contains("<name val=\"Calibri\"/>"));
}
#[test]
fn with_font_edit_replaces_an_existing_property() {
let font = "<font><sz val=\"11\"/></font>";
let edit = FontEdit {
size: Some(14.0),
..Default::default()
};
let out = with_font_edit(font, &edit);
assert!(out.contains("<sz val=\"14\"/>"));
assert!(!out.contains("val=\"11\""));
}
#[test]
fn with_font_edit_bold_false_writes_explicit_val_zero_not_a_removal() {
let out = with_font_edit(
"<font/>",
&FontEdit {
bold: Some(false),
..Default::default()
},
);
assert_eq!(out, "<font><b val=\"0\"/></font>");
}
#[test]
fn build_solid_fill_uses_fgcolor_and_indexed_64_bgcolor_sentinel() {
assert_eq!(
build_solid_fill("FF4472C4"),
"<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FF4472C4\"/><bgColor indexed=\"64\"/></patternFill></fill>"
);
}
#[test]
fn with_border_edit_touches_only_the_requested_side() {
let border = "<border><left style=\"thin\"/><right/><top/><bottom/><diagonal/></border>";
let edit = BorderEdit {
top: Some(BorderSideEdit {
style: Some("thick".to_string()),
color_argb: Some("FF000000".to_string()),
}),
..Default::default()
};
let out = with_border_edit(border, &edit);
assert!(out.contains("<left style=\"thin\"/>"));
assert!(out.contains("<top style=\"thick\"><color rgb=\"FF000000\"/></top>"));
assert!(out.contains("<right/>"));
assert!(out.contains("<bottom/>"));
assert!(out.contains("<diagonal/>"));
}
#[test]
fn merged_alignment_span_preserves_existing_vertical_when_setting_horizontal() {
let xf = "<xf><alignment vertical=\"center\"/></xf>";
let edit = AlignmentEdit {
horizontal: Some("center".to_string()),
..Default::default()
};
let out = merged_alignment_span(xf, &edit);
assert!(out.contains("vertical=\"center\""));
assert!(out.contains("horizontal=\"center\""));
}
#[test]
fn merged_alignment_span_creates_a_fresh_alignment_when_none_exists() {
let out = merged_alignment_span(
"<xf/>",
&AlignmentEdit {
wrap_text: Some(true),
..Default::default()
},
);
assert_eq!(out, "<alignment wrapText=\"1\"/>");
}
#[test]
fn merged_protection_span_merges_onto_existing() {
let xf = "<xf><protection locked=\"1\"/></xf>";
let out = merged_protection_span(
xf,
&ProtectionEdit {
hidden: Some(true),
..Default::default()
},
);
assert!(out.contains("locked=\"1\""));
assert!(out.contains("hidden=\"1\""));
}
#[test]
fn root_tag_has_rid_false_for_a_relationship_free_page_setup() {
let xml = r#"<pageSetup paperSize="9" orientation="portrait" horizontalDpi="0" verticalDpi="0"/>"#;
assert!(!root_tag_has_rid(xml));
}
#[test]
fn root_tag_has_rid_true_for_an_rid_bearing_page_setup() {
let xml = r#"<pageSetup paperSize="9" r:id="rId1"/>"#;
assert!(root_tag_has_rid(xml));
}
}
pub(crate) fn workbook_rels_decls(xml: &str) -> Vec<(String, String)> {
let mut rels = vec![];
let mut iter = XmlIter::new(xml);
while let Some(ev) = iter.next_ev() {
if let Ev::Open(ref tag, ref attrs) | Ev::SelfClose(ref tag, ref attrs) = ev {
let local = tag.split(':').next_back().unwrap_or(tag);
if local == "Relationship"
&& let (Some(ty), Some(target)) =
(attr_get(attrs, "Type"), attr_get(attrs, "Target"))
{
rels.push((ty.to_string(), target.to_string()));
}
}
}
rels
}
fn read_xlsx(path: &str) -> Result<Vec<WorkbookSheet>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
validate_zip_archive(&mut archive)?;
Ok(read_workbook_from_archive(archive)?
.sheets
.into_iter()
.map(|bs| bs.sheet)
.collect())
}
fn read_workbook_from_archive<R: Read + Seek>(
mut archive: ZipArchive<R>,
) -> Result<BufferWorkbook, String> {
validate_zip_archive(&mut archive)?;
let wb_xml = zip_read_text(&mut archive, "xl/workbook.xml")?;
let sheet_refs = xlsx_workbook_sheets(&wb_xml);
let date1904 = xlsx_workbook_date1904(&wb_xml);
let rels_xml = zip_read_text(&mut archive, "xl/_rels/workbook.xml.rels")?;
let rels = xlsx_rels(&rels_xml, "/worksheet");
let shared: Vec<String> = match zip_read_text(&mut archive, "xl/sharedStrings.xml") {
Ok(xml) => xlsx_shared_strings(&xml),
Err(_) => vec![],
};
let styles = match zip_read_text(&mut archive, "xl/styles.xml") {
Ok(xml) => xlsx_styles(&xml),
Err(_) => XlsxStyles::default(),
};
let mut sheets = vec![];
for (name, rid, sheet_id, sheet_state) in sheet_refs {
let Some(target) = rels.get(&rid) else {
continue;
};
let zip_path = if let Some(rest) = target.strip_prefix('/') {
rest.to_string()
} else {
format!("xl/{}", target)
};
let sheet_xml = match zip_read_text(&mut archive, &zip_path) {
Ok(s) => s,
Err(_) => continue,
};
let sheet_data = xlsx_sheet_cells(&sheet_xml, &shared, &styles.cell_xfs);
let cell_number_formats: HashMap<(u32, u32), String> = sheet_data
.style_ids
.iter()
.filter_map(|(&pos, &fmt_id)| {
resolve_number_format(fmt_id, &styles.number_formats).map(|code| (pos, code))
})
.collect();
let mut tables = Vec::new();
let table_rids = xlsx_table_part_rids(&sheet_xml);
if !table_rids.is_empty()
&& let Ok(sheet_rels_xml) =
zip_read_text(&mut archive, &crate::part_rels_name(&zip_path))
{
let table_rels = xlsx_rels(&sheet_rels_xml, "/table");
let base = crate::rels_target_dir(&crate::part_rels_name(&zip_path)).to_string();
for rid in &table_rids {
let Some(target) = table_rels.get(rid) else {
continue;
};
let resolved = crate::normalize_part_path(&format!("{base}{target}"));
if let Ok(table_xml) = zip_read_text(&mut archive, &resolved)
&& let Some(mut t) = parse_table_xml(&table_xml)
{
t.source_part = resolved;
tables.push(t);
}
}
}
let data_validations = xlsx_data_validations(&sheet_xml);
let autofilter = xlsx_autofilter(&sheet_xml);
sheets.push(BufferSheet {
sheet: WorkbookSheet {
name,
cells: sheet_data.cells,
sheet_id,
workbook_rel_id: Some(rid),
source_part_name: Some(zip_path.clone()),
merged_ranges: sheet_data.merged_ranges,
hidden_rows: sheet_data.hidden_rows,
hidden_columns: sheet_data.hidden_columns,
raw_style_indices: sheet_data.raw_style_indices,
formulas: sheet_data.formulas.clone(),
cell_number_formats,
sheet_state,
row_heights: sheet_data.row_heights,
column_widths: sheet_data.column_widths,
row_styles: sheet_data.row_styles,
column_styles: sheet_data.column_styles,
tables,
data_validations,
autofilter,
},
formulas: sheet_data.formulas,
dimension: sheet_data.dimension,
style_ids: sheet_data.style_ids,
});
}
Ok(BufferWorkbook {
sheets,
number_formats: styles.number_formats,
date1904,
})
}
fn xlsx_workbook_date1904(xml: &str) -> bool {
let mut iter = XmlIter::new(xml);
while let Some(ev) = iter.next_ev() {
if let Ev::Open(ref tag, ref attrs) | Ev::SelfClose(ref tag, ref attrs) = ev
&& tag.split(':').next_back() == Some("workbookPr")
{
return attr_is_true(attrs, "date1904");
}
}
false
}
fn xlsx_workbook_sheets(xml: &str) -> Vec<(String, String, Option<String>, Option<String>)> {
let mut iter = XmlIter::new(xml);
let mut result = vec![];
while let Some(ev) = iter.next_ev() {
if let Ev::SelfClose(ref tag, ref attrs) = ev {
let local = tag.split(':').next_back().unwrap_or(tag);
if local == "sheet"
&& let (Some(name), Some(rid)) = (attr_get(attrs, "name"), attr_get(attrs, "id"))
{
let sheet_id = attr_get(attrs, "sheetId").map(|s| s.to_string());
let state = attr_get(attrs, "state").map(|s| s.to_string());
result.push((name.to_string(), rid.to_string(), sheet_id, state));
}
}
}
result
}
pub(crate) fn xlsx_defined_names(xml: &str) -> Vec<(String, String)> {
let mut iter = XmlIter::new(xml);
let mut result = vec![];
let mut current_name: Option<String> = None;
let mut current_text = String::new();
while let Some(ev) = iter.next_ev() {
match &ev {
Ev::Open(tag, attrs) => {
let local = tag.split(':').next_back().unwrap_or(tag);
if local == "definedName" {
current_name = attr_get(attrs, "name").map(|s| s.to_string());
current_text.clear();
}
}
Ev::Close(tag) => {
let local = tag.split(':').next_back().unwrap_or(tag);
if local == "definedName"
&& let Some(name) = current_name.take()
{
result.push((name, current_text.clone()));
}
}
Ev::Text(text) => {
if current_name.is_some() {
current_text.push_str(text);
}
}
Ev::SelfClose(_, _) => {}
}
}
result
}
fn xlsx_rels(xml: &str, type_suffix: &str) -> HashMap<String, String> {
let mut iter = XmlIter::new(xml);
let mut map = HashMap::new();
while let Some(ev) = iter.next_ev() {
if let Ev::SelfClose(ref tag, ref attrs) = ev {
let local = tag.split(':').next_back().unwrap_or(tag);
if local == "Relationship"
&& let (Some(id), Some(ty), Some(target)) = (
attr_get(attrs, "Id"),
attr_get(attrs, "Type"),
attr_get(attrs, "Target"),
)
&& ty.ends_with(type_suffix)
{
map.insert(id.to_string(), target.to_string());
}
}
}
map
}
fn xlsx_shared_strings(xml: &str) -> Vec<String> {
let mut iter = XmlIter::new(xml);
let mut strings = vec![];
let mut in_si = false;
let mut in_t = false;
let mut current = String::new();
while let Some(ev) = iter.next_ev() {
match &ev {
Ev::Open(tag, _) | Ev::SelfClose(tag, _) => {
let local = tag.split(':').next_back().unwrap_or(tag);
match local {
"si" => {
in_si = true;
current.clear();
}
"t" => {
in_t = true;
}
_ => {}
}
}
Ev::Close(tag) => {
let local = tag.split(':').next_back().unwrap_or(tag);
match local {
"si" => {
strings.push(current.clone());
in_si = false;
}
"t" => {
in_t = false;
}
_ => {}
}
}
Ev::Text(text) => {
if in_si && in_t {
current.push_str(text);
}
}
}
}
strings
}
#[derive(Default)]
struct XlsxStyles {
number_formats: HashMap<u32, String>,
cell_xfs: Vec<Option<u32>>,
}
fn xlsx_styles(xml: &str) -> XlsxStyles {
let mut iter = XmlIter::new(xml);
let mut number_formats: HashMap<u32, String> = HashMap::new();
let mut cell_xfs: Vec<Option<u32>> = Vec::new();
let mut in_cell_xfs = false;
while let Some(ev) = iter.next_ev() {
match &ev {
Ev::Open(tag, attrs) | Ev::SelfClose(tag, attrs) => {
let local = tag.split(':').next_back().unwrap_or(tag.as_str());
match local {
"numFmt" => {
if let (Some(id), Some(code)) = (
attr_get(attrs, "numFmtId").and_then(|s| s.parse::<u32>().ok()),
attr_get(attrs, "formatCode"),
) {
number_formats.insert(id, code.to_string());
}
}
"cellXfs" if matches!(ev, Ev::Open(_, _)) => {
in_cell_xfs = true;
}
"xf" if in_cell_xfs => {
cell_xfs
.push(attr_get(attrs, "numFmtId").and_then(|s| s.parse::<u32>().ok()));
}
_ => {}
}
}
Ev::Close(tag) => {
if tag.split(':').next_back() == Some("cellXfs") {
in_cell_xfs = false;
}
}
Ev::Text(_) => {}
}
}
XlsxStyles {
number_formats,
cell_xfs,
}
}
const BUILTIN_NUMBER_FORMATS: &[(u32, &str)] = &[
(0, "General"),
(1, "0"),
(2, "0.00"),
(3, "#,##0"),
(4, "#,##0.00"),
(5, "$#,##0;($#,##0)"),
(6, "$#,##0;[Red]($#,##0)"),
(7, "$#,##0.00;($#,##0.00)"),
(8, "$#,##0.00;[Red]($#,##0.00)"),
(9, "0%"),
(10, "0.00%"),
(11, "0.00E+00"),
(12, "# ?/?"),
(13, "# ??/??"),
(14, "m/d/yyyy"),
(15, "d-mmm-yy"),
(16, "d-mmm"),
(17, "mmm-yy"),
(18, "h:mm AM/PM"),
(19, "h:mm:ss AM/PM"),
(20, "h:mm"),
(21, "h:mm:ss"),
(22, "m/d/yyyy h:mm"),
(37, "#,##0 ;(#,##0)"),
(38, "#,##0 ;[Red](#,##0)"),
(39, "#,##0.00;(#,##0.00)"),
(40, "#,##0.00;[Red](#,##0.00)"),
(41, "_(* #,##0_);_(* (#,##0);_(* \"-\"_);_(@_)"),
(42, "_($* #,##0_);_($* (#,##0);_($* \"-\"_);_(@_)"),
(43, "_(* #,##0.00_);_(* (#,##0.00);_(* \"-\"??_);_(@_)"),
(44, "_($* #,##0.00_);_($* (#,##0.00);_($* \"-\"??_);_(@_)"),
(45, "mm:ss"),
(46, "[h]:mm:ss"),
(47, "mm:ss.0"),
(48, "##0.0E+0"),
(49, "@"),
];
fn resolve_number_format(num_fmt_id: u32, custom_formats: &HashMap<u32, String>) -> Option<String> {
if let Some(code) = custom_formats.get(&num_fmt_id) {
return Some(code.clone());
}
if num_fmt_id == 0 {
return None;
}
BUILTIN_NUMBER_FORMATS
.iter()
.find(|(id, _)| *id == num_fmt_id)
.map(|(_, code)| code.to_string())
}
pub(crate) fn custom_number_formats(xml: &str) -> HashMap<u32, String> {
xlsx_styles(xml).number_formats
}
pub(crate) enum ResolvedNumFmt {
Existing(u32),
New(u32),
}
pub(crate) fn resolve_number_format_id(
format_code: &str,
custom_formats: &HashMap<u32, String>,
) -> ResolvedNumFmt {
if let Some((&id, _)) = custom_formats
.iter()
.find(|(_, code)| code.as_str() == format_code)
{
return ResolvedNumFmt::Existing(id);
}
if let Some((id, _)) = BUILTIN_NUMBER_FORMATS
.iter()
.find(|(_, code)| *code == format_code)
{
return ResolvedNumFmt::Existing(*id);
}
let next = custom_formats
.keys()
.copied()
.max()
.map_or(164, |m| m.max(163) + 1);
ResolvedNumFmt::New(next)
}
pub(crate) fn extract_records(xml: &str, container: &str, record: &str) -> Vec<String> {
let Some(container_span) = extract_raw_element(xml, container) else {
return Vec::new();
};
let mut out = Vec::new();
let mut search_from = 0;
while let Some((tag_start, tag_close_rel, full_name)) =
find_next_open_tag(&container_span, search_from)
{
if full_name.rsplit(':').next().unwrap_or(&full_name) != record {
search_from = tag_start + 1;
continue;
}
let name_end = tag_start + 1 + full_name.len();
let start_tag_end = name_end + tag_close_rel + 1;
let self_closing = container_span[name_end..name_end + tag_close_rel]
.trim_end()
.ends_with('/');
if self_closing {
out.push(container_span[tag_start..start_tag_end].to_string());
search_from = start_tag_end;
continue;
}
let close_tag = format!("</{}>", full_name);
let Some(end_rel) = container_span[start_tag_end..].find(&close_tag) else {
break;
};
let end = start_tag_end + end_rel + close_tag.len();
out.push(container_span[tag_start..end].to_string());
search_from = end;
}
out
}
pub(crate) fn extract_cell_xfs(xml: &str) -> Vec<String> {
extract_records(xml, "cellXfs", "xf")
}
pub(crate) fn named_style_xf_id(xml: &str, name: &str) -> Option<u32> {
extract_records(xml, "cellStyles", "cellStyle")
.iter()
.find_map(|span| {
let (tag_start, tag_close_rel, full_name) = find_next_open_tag(span, 0)?;
let name_end = tag_start + 1 + full_name.len();
let raw_attrs = &span[name_end..name_end + tag_close_rel];
let attrs_str = raw_attrs.trim_end().strip_suffix('/').unwrap_or(raw_attrs);
let attrs = parse_attrs(attrs_str);
if attr_get(&attrs, "name") != Some(name) {
return None;
}
attr_get(&attrs, "xfId").and_then(|v| v.parse().ok())
})
}
pub(crate) fn with_attr(span: &str, attr_name: &str, attr_value: &str) -> String {
let Some((tag_start, tag_close_rel, full_name)) = find_next_open_tag(span, 0) else {
return span.to_string();
};
let name_end = tag_start + 1 + full_name.len();
let tag_close_abs = name_end + tag_close_rel;
let raw_attrs = &span[name_end..tag_close_abs];
let self_closing = raw_attrs.trim_end().ends_with('/');
let attrs_str = if self_closing {
raw_attrs.trim_end().strip_suffix('/').unwrap_or(raw_attrs)
} else {
raw_attrs
};
let mut new_attrs = String::new();
for a in parse_attrs(attrs_str) {
if a.name.rsplit(':').next() == Some(attr_name) {
continue;
}
new_attrs.push(' ');
new_attrs.push_str(&a.name);
new_attrs.push_str("=\"");
new_attrs.push_str(&crate::xml_escape(&a.value));
new_attrs.push('"');
}
new_attrs.push_str(&format!(
" {attr_name}=\"{}\"",
crate::xml_escape(attr_value)
));
if self_closing {
format!("<{full_name}{new_attrs}/>")
} else {
format!("<{full_name}{new_attrs}{}", &span[tag_close_abs..])
}
}
pub(crate) fn span_attr_u32(span: &str, attr_name: &str) -> u32 {
let Some((tag_start, tag_close_rel, full_name)) = find_next_open_tag(span, 0) else {
return 0;
};
let name_end = tag_start + 1 + full_name.len();
let raw_attrs = &span[name_end..name_end + tag_close_rel];
let attrs_str = raw_attrs.trim_end().strip_suffix('/').unwrap_or(raw_attrs);
attr_get(&parse_attrs(attrs_str), attr_name)
.and_then(|v| v.parse().ok())
.unwrap_or(0)
}
pub(crate) fn with_num_fmt_id(xf_span: &str, new_id: u32) -> String {
with_attr(xf_span, "numFmtId", &new_id.to_string())
}
pub(crate) fn with_child(parent_span: &str, child_tag: &str, new_child: Option<&str>) -> String {
let existing = extract_raw_element(parent_span, child_tag);
match (new_child, existing) {
(Some(new_child), Some(old)) => parent_span.replacen(old.as_str(), new_child, 1),
(Some(new_child), None) => insert_before_close(parent_span, new_child),
(None, Some(old)) => parent_span.replacen(&old, "", 1),
(None, None) => parent_span.to_string(),
}
}
pub(crate) fn with_ordered_child(
parent_span: &str,
child_tag: &str,
order: &[&str],
new_child: Option<&str>,
) -> String {
let existing = extract_raw_element(parent_span, child_tag);
let Some(new_child) = new_child else {
return match existing {
Some(old) => parent_span.replacen(&old, "", 1),
None => parent_span.to_string(),
};
};
if let Some(old) = &existing {
return parent_span.replacen(old.as_str(), new_child, 1);
}
if let Some(pos) = order.iter().position(|s| *s == child_tag) {
for later in &order[pos + 1..] {
if let Some(later_span) = extract_raw_element(parent_span, later) {
return parent_span.replacen(
later_span.as_str(),
&format!("{new_child}{later_span}"),
1,
);
}
}
}
insert_before_close(parent_span, new_child)
}
pub(crate) fn insert_before_close(parent_span: &str, new_child: &str) -> String {
let Some((tag_start, tag_close_rel, full_name)) = find_next_open_tag(parent_span, 0) else {
return parent_span.to_string();
};
let name_end = tag_start + 1 + full_name.len();
let tag_close_abs = name_end + tag_close_rel;
let self_closing = parent_span[name_end..tag_close_abs]
.trim_end()
.ends_with('/');
if self_closing {
let attrs = parent_span[name_end..tag_close_abs].trim_end();
let attrs = attrs.strip_suffix('/').unwrap_or(attrs);
format!("<{full_name}{attrs}>{new_child}</{full_name}>")
} else {
let close_tag = format!("</{full_name}>");
match parent_span.rfind(&close_tag) {
Some(pos) => format!(
"{}{}{}",
&parent_span[..pos],
new_child,
&parent_span[pos..]
),
None => parent_span.to_string(),
}
}
}
pub(crate) const BORDER_SIDE_ORDER: [&str; 5] = ["left", "right", "top", "bottom", "diagonal"];
pub(crate) const XF_CHILD_ORDER: [&str; 2] = ["alignment", "protection"];
#[derive(Debug, Clone, Default)]
pub struct FontEdit {
pub bold: Option<bool>,
pub italic: Option<bool>,
pub underline: Option<bool>,
pub strike: Option<bool>,
pub size: Option<f64>,
pub color_argb: Option<String>,
pub name: Option<String>,
}
impl FontEdit {
pub(crate) fn merge_from(&mut self, other: &FontEdit) {
if other.bold.is_some() {
self.bold = other.bold;
}
if other.italic.is_some() {
self.italic = other.italic;
}
if other.underline.is_some() {
self.underline = other.underline;
}
if other.strike.is_some() {
self.strike = other.strike;
}
if other.size.is_some() {
self.size = other.size;
}
if other.color_argb.is_some() {
self.color_argb = other.color_argb.clone();
}
if other.name.is_some() {
self.name = other.name.clone();
}
}
}
const FONT_CHILD_ORDER: [&str; 7] = ["name", "b", "i", "strike", "color", "sz", "u"];
pub(crate) fn with_font_edit(font_span: &str, edit: &FontEdit) -> String {
let mut out = font_span.to_string();
if let Some(b) = edit.bold {
out = with_child(
&out,
"b",
Some(&format!("<b val=\"{}\"/>", if b { 1 } else { 0 })),
);
}
if let Some(i) = edit.italic {
out = with_child(
&out,
"i",
Some(&format!("<i val=\"{}\"/>", if i { 1 } else { 0 })),
);
}
if let Some(u) = edit.underline {
out = with_child(
&out,
"u",
Some(if u {
"<u val=\"single\"/>"
} else {
"<u val=\"none\"/>"
}),
);
}
if let Some(s) = edit.strike {
out = with_child(
&out,
"strike",
Some(&format!("<strike val=\"{}\"/>", if s { 1 } else { 0 })),
);
}
if let Some(sz) = edit.size {
out = with_child(&out, "sz", Some(&format!("<sz val=\"{sz}\"/>")));
}
if let Some(color) = &edit.color_argb {
out = with_child(
&out,
"color",
Some(&format!("<color rgb=\"{}\"/>", crate::xml_escape(color))),
);
}
if let Some(name) = &edit.name {
out = with_child(
&out,
"name",
Some(&format!("<name val=\"{}\"/>", crate::xml_escape(name))),
);
}
let _ = &FONT_CHILD_ORDER; out
}
pub(crate) fn build_solid_fill(color_argb: &str) -> String {
format!(
"<fill><patternFill patternType=\"solid\"><fgColor rgb=\"{}\"/><bgColor indexed=\"64\"/></patternFill></fill>",
crate::xml_escape(color_argb)
)
}
#[derive(Debug, Clone, Default)]
pub struct BorderSideEdit {
pub style: Option<String>,
pub color_argb: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct BorderEdit {
pub left: Option<BorderSideEdit>,
pub right: Option<BorderSideEdit>,
pub top: Option<BorderSideEdit>,
pub bottom: Option<BorderSideEdit>,
pub diagonal: Option<BorderSideEdit>,
}
impl BorderEdit {
pub(crate) fn merge_from(&mut self, other: &BorderEdit) {
if other.left.is_some() {
self.left = other.left.clone();
}
if other.right.is_some() {
self.right = other.right.clone();
}
if other.top.is_some() {
self.top = other.top.clone();
}
if other.bottom.is_some() {
self.bottom = other.bottom.clone();
}
if other.diagonal.is_some() {
self.diagonal = other.diagonal.clone();
}
}
}
fn build_border_side_span(tag: &str, side: &BorderSideEdit) -> String {
let style_attr = side
.style
.as_ref()
.map(|s| format!(" style=\"{}\"", crate::xml_escape(s)))
.unwrap_or_default();
match &side.color_argb {
Some(color) => format!(
"<{tag}{style_attr}><color rgb=\"{}\"/></{tag}>",
crate::xml_escape(color)
),
None if style_attr.is_empty() => format!("<{tag}/>"),
None => format!("<{tag}{style_attr}/>"),
}
}
pub(crate) fn with_border_edit(border_span: &str, edit: &BorderEdit) -> String {
let mut out = border_span.to_string();
if let Some(side) = &edit.left {
out = with_ordered_child(
&out,
"left",
&BORDER_SIDE_ORDER,
Some(&build_border_side_span("left", side)),
);
}
if let Some(side) = &edit.right {
out = with_ordered_child(
&out,
"right",
&BORDER_SIDE_ORDER,
Some(&build_border_side_span("right", side)),
);
}
if let Some(side) = &edit.top {
out = with_ordered_child(
&out,
"top",
&BORDER_SIDE_ORDER,
Some(&build_border_side_span("top", side)),
);
}
if let Some(side) = &edit.bottom {
out = with_ordered_child(
&out,
"bottom",
&BORDER_SIDE_ORDER,
Some(&build_border_side_span("bottom", side)),
);
}
if let Some(side) = &edit.diagonal {
out = with_ordered_child(
&out,
"diagonal",
&BORDER_SIDE_ORDER,
Some(&build_border_side_span("diagonal", side)),
);
}
out
}
#[derive(Debug, Clone, Default)]
pub struct AlignmentEdit {
pub horizontal: Option<String>,
pub vertical: Option<String>,
pub wrap_text: Option<bool>,
pub indent: Option<u32>,
}
impl AlignmentEdit {
pub(crate) fn merge_from(&mut self, other: &AlignmentEdit) {
if other.horizontal.is_some() {
self.horizontal = other.horizontal.clone();
}
if other.vertical.is_some() {
self.vertical = other.vertical.clone();
}
if other.wrap_text.is_some() {
self.wrap_text = other.wrap_text;
}
if other.indent.is_some() {
self.indent = other.indent;
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ProtectionEdit {
pub locked: Option<bool>,
pub hidden: Option<bool>,
}
impl ProtectionEdit {
pub(crate) fn merge_from(&mut self, other: &ProtectionEdit) {
if other.locked.is_some() {
self.locked = other.locked;
}
if other.hidden.is_some() {
self.hidden = other.hidden;
}
}
}
pub(crate) fn merged_alignment_span(xf_span: &str, edit: &AlignmentEdit) -> String {
let mut out =
extract_raw_element(xf_span, "alignment").unwrap_or_else(|| "<alignment/>".to_string());
if let Some(h) = &edit.horizontal {
out = with_attr(&out, "horizontal", h);
}
if let Some(v) = &edit.vertical {
out = with_attr(&out, "vertical", v);
}
if let Some(w) = edit.wrap_text {
out = with_attr(&out, "wrapText", if w { "1" } else { "0" });
}
if let Some(indent) = edit.indent {
out = with_attr(&out, "indent", &indent.to_string());
}
out
}
pub(crate) fn merged_protection_span(xf_span: &str, edit: &ProtectionEdit) -> String {
let mut out =
extract_raw_element(xf_span, "protection").unwrap_or_else(|| "<protection/>".to_string());
if let Some(l) = edit.locked {
out = with_attr(&out, "locked", if l { "1" } else { "0" });
}
if let Some(h) = edit.hidden {
out = with_attr(&out, "hidden", if h { "1" } else { "0" });
}
out
}
pub(crate) struct XlsxSheetData {
pub(crate) cells: HashMap<(u32, u32), SheetCell>,
#[cfg_attr(not(feature = "python"), allow(dead_code))]
pub(crate) first_row: Option<u32>,
merged_ranges: Vec<MergeRect>,
hidden_rows: Vec<(u32, u32)>,
hidden_columns: Vec<(u32, u32)>,
row_heights: HashMap<u32, f64>,
column_widths: Vec<(u32, u32, f64)>,
row_styles: HashMap<u32, u32>,
column_styles: Vec<(u32, u32, u32)>,
formulas: HashMap<(u32, u32), String>,
dimension: Option<MergeRect>,
style_ids: HashMap<(u32, u32), u32>,
raw_style_indices: HashMap<(u32, u32), u32>,
}
fn xlsx_sheet_cells(xml: &str, shared: &[String], cell_xfs: &[Option<u32>]) -> XlsxSheetData {
let mut iter = XmlIter::new(xml);
let mut cells: HashMap<(u32, u32), SheetCell> = HashMap::new();
let mut merged_ranges: Vec<MergeRect> = Vec::new();
let mut hidden_rows: Vec<(u32, u32)> = Vec::new();
let mut hidden_columns: Vec<(u32, u32)> = Vec::new();
let mut pending_hidden_row_run: Option<(u32, u32)> = None;
let mut row_heights: HashMap<u32, f64> = HashMap::new();
let mut column_widths: Vec<(u32, u32, f64)> = Vec::new();
let mut row_styles: HashMap<u32, u32> = HashMap::new();
let mut column_styles: Vec<(u32, u32, u32)> = Vec::new();
let mut formulas: HashMap<(u32, u32), String> = HashMap::new();
let mut dimension: Option<MergeRect> = None;
let mut style_ids: HashMap<(u32, u32), u32> = HashMap::new();
let mut raw_style_indices: HashMap<(u32, u32), u32> = HashMap::new();
let mut first_row: Option<u32> = None;
let mut cur_row: u32 = 0;
let mut cur_col: u32 = 0;
let mut cur_type = String::new();
let mut in_v = false;
let mut v_preserve_space = false;
let mut in_f = false;
let mut cur_formula = String::new();
let mut in_is_t = false; let mut is_text = String::new();
while let Some(ev) = iter.next_ev() {
match ev {
Ev::Open(ref tag, ref attrs) | Ev::SelfClose(ref tag, ref attrs) => {
let local = tag.split(':').next_back().unwrap_or(tag.as_str());
match local {
"row" => {
if let Some(r) = attr_get(attrs, "r") {
cur_row = r.parse().unwrap_or(0);
if first_row.is_none() && cur_row != 0 {
first_row = Some(cur_row);
}
}
let hidden = attr_is_true(attrs, "hidden");
if hidden {
pending_hidden_row_run = Some(match pending_hidden_row_run {
Some((start, end)) if end + 1 == cur_row => (start, cur_row),
_ => {
if let Some(run) = pending_hidden_row_run {
hidden_rows.push(run);
}
(cur_row, cur_row)
}
});
} else if let Some(run) = pending_hidden_row_run.take() {
hidden_rows.push(run);
}
if attr_is_true(attrs, "customHeight")
&& let Some(ht) = attr_get(attrs, "ht").and_then(|s| s.parse().ok())
{
row_heights.insert(cur_row, ht);
}
if attr_is_true(attrs, "customFormat")
&& let Some(s) = attr_get(attrs, "s").and_then(|s| s.parse().ok())
{
row_styles.insert(cur_row, s);
}
}
"col" => {
if attr_is_true(attrs, "hidden") {
let min = attr_get(attrs, "min").and_then(|s| s.parse().ok());
let max = attr_get(attrs, "max").and_then(|s| s.parse().ok());
if let (Some(min), Some(max)) = (min, max) {
hidden_columns.push((min, max));
}
}
if attr_is_true(attrs, "customWidth") {
let min = attr_get(attrs, "min").and_then(|s| s.parse().ok());
let max = attr_get(attrs, "max").and_then(|s| s.parse().ok());
let width = attr_get(attrs, "width").and_then(|s| s.parse().ok());
if let (Some(min), Some(max), Some(width)) = (min, max, width) {
column_widths.push((min, max, width));
}
}
if let Some(style) = attr_get(attrs, "style").and_then(|s| s.parse().ok()) {
let min = attr_get(attrs, "min").and_then(|s| s.parse().ok());
let max = attr_get(attrs, "max").and_then(|s| s.parse().ok());
if let (Some(min), Some(max)) = (min, max) {
column_styles.push((min, max, style));
}
}
}
"c" => {
cur_type = attr_get(attrs, "t").unwrap_or("").to_string();
in_v = false;
if let Some(r) = attr_get(attrs, "r")
&& let Some((row, col)) = parse_cell_ref(r)
{
cur_row = row;
cur_col = col;
}
is_text.clear();
in_f = false;
cur_formula.clear();
let s_idx = if cur_row > 0 && cur_col > 0 {
attr_get(attrs, "s").and_then(|s| s.parse::<usize>().ok())
} else {
None
};
if let Some(idx) = s_idx {
raw_style_indices.insert((cur_row, cur_col), idx as u32);
if let Some(Some(fmt_id)) = cell_xfs.get(idx)
&& *fmt_id != 0
{
style_ids.insert((cur_row, cur_col), *fmt_id);
}
}
}
"v" => {
in_v = true;
v_preserve_space = attr_get(attrs, "xml:space") == Some("preserve");
}
"f" => {
if !matches!(ev, Ev::SelfClose(_, _)) {
in_f = true;
cur_formula.clear();
}
}
"t" => {
in_is_t = true;
is_text.clear();
}
"mergeCell" => {
if let Some(rect) = attr_get(attrs, "ref").and_then(parse_merge_ref) {
merged_ranges.push(rect);
}
}
"dimension" if dimension.is_none() => {
dimension = attr_get(attrs, "ref").and_then(parse_dimension_ref);
}
_ => {}
}
}
Ev::Close(ref tag) => {
let local = tag.split(':').next_back().unwrap_or(tag.as_str());
match local {
"v" => {
if in_v
&& cur_row > 0
&& cur_col > 0
&& let Some(c) = xlsx_parse_cell("", &cur_type, shared)
{
cells.insert((cur_row, cur_col), c);
}
in_v = false;
}
"t" => {
in_is_t = false;
}
"f" => {
if in_f && cur_row > 0 && cur_col > 0 && !cur_formula.is_empty() {
formulas.insert((cur_row, cur_col), cur_formula.clone());
}
in_f = false;
}
_ => {}
}
}
Ev::Text(ref text) => {
if in_v && cur_row > 0 && cur_col > 0 {
let raw = if v_preserve_space {
text.as_str()
} else {
text.trim()
};
let cell = xlsx_parse_cell(raw, &cur_type, shared);
if let Some(c) = cell {
cells.insert((cur_row, cur_col), c);
}
in_v = false;
} else if in_is_t {
is_text.push_str(text);
} else if in_f {
cur_formula.push_str(text);
}
}
}
if let Ev::Close(ref tag) = ev
&& tag.split(':').next_back() == Some("c")
&& cur_type == "inlineStr"
&& !is_text.is_empty()
&& cur_row > 0
&& cur_col > 0
{
cells.insert((cur_row, cur_col), SheetCell::Str(is_text.clone()));
is_text.clear();
}
}
if let Some(run) = pending_hidden_row_run.take() {
hidden_rows.push(run);
}
XlsxSheetData {
cells,
first_row,
merged_ranges,
hidden_rows,
hidden_columns,
row_heights,
column_widths,
row_styles,
column_styles,
formulas,
dimension,
style_ids,
raw_style_indices,
}
}
fn xlsx_parse_cell(v: &str, t: &str, shared: &[String]) -> Option<SheetCell> {
match t {
"s" => {
let idx: usize = v.parse().ok()?;
Some(SheetCell::Str(shared.get(idx)?.clone()))
}
"b" => Some(SheetCell::Bool(v == "1")),
"str" => Some(SheetCell::Str(v.to_string())),
"e" => Some(match ExcelError::from_str(v) {
Ok(err) => SheetCell::Error(err),
Err(()) => SheetCell::Str(v.to_string()),
}),
_ => {
let f: f64 = v.parse().ok()?;
Some(num_to_cell(f))
}
}
}
fn num_to_cell(f: f64) -> SheetCell {
if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
SheetCell::Integer(f as i64)
} else {
SheetCell::Float(f)
}
}
fn parse_cell_ref(r: &str) -> Option<(u32, u32)> {
let r = r.trim().to_uppercase();
let alpha_end = r.find(|c: char| c.is_ascii_digit())?;
if alpha_end == 0 {
return None;
}
let col = r[..alpha_end]
.chars()
.fold(0u32, |acc, c| acc * 26 + (c as u32 - 'A' as u32 + 1));
let row: u32 = r[alpha_end..].parse().ok()?;
Some((row, col))
}
fn parse_merge_ref(s: &str) -> Option<MergeRect> {
let i = s.find(':')?;
Some((parse_cell_ref(&s[..i])?, parse_cell_ref(&s[i + 1..])?))
}
fn parse_dimension_ref(s: &str) -> Option<MergeRect> {
let (start, end) = parse_merge_ref(s)?;
if start.0 <= end.0 && start.1 <= end.1 {
Some((start, end))
} else {
None
}
}
fn xlsx_table_part_rids(sheet_xml: &str) -> Vec<String> {
let Some(tp) = extract_raw_element(sheet_xml, "tableParts") else {
return Vec::new();
};
let mut iter = XmlIter::new(&tp);
let mut rids = Vec::new();
while let Some(ev) = iter.next_ev() {
if let Ev::SelfClose(ref tag, ref attrs) = ev
&& tag.split(':').next_back() == Some("tablePart")
&& let Some(rid) = attr_get(attrs, "id")
{
rids.push(rid.to_string());
}
}
rids
}
fn parse_table_xml(xml: &str) -> Option<TableDef> {
let mut iter = XmlIter::new(xml);
let mut table: Option<TableDef> = None;
let mut cur_column: Option<TableColumn> = None;
let mut in_calc_formula = false;
let mut calc_formula_text = String::new();
while let Some(ev) = iter.next_ev() {
match ev {
Ev::Open(ref tag, ref attrs) | Ev::SelfClose(ref tag, ref attrs) => {
let local = tag.split(':').next_back().unwrap_or(tag.as_str());
match local {
"table" => {
let name = attr_get(attrs, "name").unwrap_or("").to_string();
let display_name =
attr_get(attrs, "displayName").unwrap_or(&name).to_string();
if let Some(ref_range) = attr_get(attrs, "ref").and_then(parse_merge_ref) {
table = Some(TableDef {
name,
display_name,
ref_range,
header_row_count: attr_get(attrs, "headerRowCount")
.and_then(|s| s.parse().ok())
.unwrap_or(1),
totals_row_count: attr_get(attrs, "totalsRowCount")
.and_then(|s| s.parse().ok())
.unwrap_or(0),
totals_row_shown: attr_get(attrs, "totalsRowShown")
.map(|v| matches!(v, "1" | "true" | "TRUE"))
.unwrap_or(true),
columns: Vec::new(),
style_name: None,
auto_filter_ref: None,
autofilter_columns: Vec::new(),
source_part: String::new(),
pending_edits: Vec::new(),
});
}
}
"tableColumn" => {
cur_column = Some(TableColumn {
id: attr_get(attrs, "id").map(|s| s.to_string()),
name: attr_get(attrs, "name").unwrap_or("").to_string(),
totals_row_function: attr_get(attrs, "totalsRowFunction")
.map(|s| s.to_string()),
totals_row_label: attr_get(attrs, "totalsRowLabel")
.map(|s| s.to_string()),
calculated_column_formula: None,
});
if matches!(ev, Ev::SelfClose(_, _))
&& let (Some(t), Some(c)) = (table.as_mut(), cur_column.take())
{
t.columns.push(c);
}
}
"calculatedColumnFormula" if !matches!(ev, Ev::SelfClose(_, _)) => {
in_calc_formula = true;
calc_formula_text.clear();
}
"tableStyleInfo" => {
if let Some(t) = table.as_mut() {
t.style_name = attr_get(attrs, "name").map(|s| s.to_string());
}
}
"autoFilter" => {
if let Some(t) = table.as_mut() {
t.auto_filter_ref = attr_get(attrs, "ref").and_then(parse_merge_ref);
}
}
_ => {}
}
}
Ev::Close(ref tag) => {
let local = tag.split(':').next_back().unwrap_or(tag.as_str());
match local {
"calculatedColumnFormula" if in_calc_formula => {
if let Some(c) = cur_column.as_mut() {
c.calculated_column_formula = Some(calc_formula_text.clone());
}
in_calc_formula = false;
}
"tableColumn" => {
if let (Some(t), Some(c)) = (table.as_mut(), cur_column.take()) {
t.columns.push(c);
}
}
_ => {}
}
}
Ev::Text(ref text) => {
if in_calc_formula {
calc_formula_text.push_str(text);
}
}
}
}
if let Some(t) = table.as_mut()
&& t.auto_filter_ref.is_some()
&& let Some(af_span) = extract_raw_element(xml, "autoFilter")
{
t.autofilter_columns = extract_records(&af_span, "autoFilter", "filterColumn")
.iter()
.filter_map(|s| parse_filter_column_xml(s))
.collect();
}
table
}
#[cfg(test)]
mod table_parsing_tests {
use super::*;
const REAL_TABLE_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" id="1"
name="Table1" displayName="Table1" ref="A1:C4" totalsRowShown="0">
<autoFilter ref="A1:C4"/>
<tableColumns count="3">
<tableColumn id="1" name="Name"/>
<tableColumn id="2" name="Qty"/>
<tableColumn id="3" name="Status"/>
</tableColumns>
<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0"
showRowStripes="1" showColumnStripes="0"/>
</table>"#;
const TABLE_XML_WITH_GUIDS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" id="1"
xr:uid="{00000000-0001-0000-0000-000000000001}" name="Table1" displayName="Table1"
ref="A1:C4" totalsRowShown="0">
<autoFilter ref="A1:C4"/>
<tableColumns count="3">
<tableColumn id="1" xr3:uid="{00000000-0001-0000-0000-000000000002}" name="Name"/>
<tableColumn id="2" xr3:uid="{00000000-0001-0000-0000-000000000003}" name="Qty"/>
<tableColumn id="3" xr3:uid="{00000000-0001-0000-0000-000000000004}" name="Status"/>
</tableColumns>
<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0"
showRowStripes="1" showColumnStripes="0"/>
</table>"#;
#[test]
fn apply_table_edits_rename_preserves_id_and_guid_untouched() {
let out = apply_table_edits(
TABLE_XML_WITH_GUIDS,
&[TableEditOp::SetDisplayName("Renamed".to_string())],
);
assert!(out.contains(r#"displayName="Renamed""#));
assert!(out.contains(r#"id="1""#));
assert!(out.contains(r#"xr:uid="{00000000-0001-0000-0000-000000000001}""#));
assert!(out.contains(r#"xr3:uid="{00000000-0001-0000-0000-000000000002}""#));
assert!(out.contains(r#"xr3:uid="{00000000-0001-0000-0000-000000000003}""#));
assert!(out.contains(r#"xr3:uid="{00000000-0001-0000-0000-000000000004}""#));
}
#[test]
fn apply_table_edits_resize_updates_only_ref() {
let out = apply_table_edits(REAL_TABLE_XML, &[TableEditOp::Resize(((1, 1), (5, 3)))]);
let t = parse_table_xml(&out).unwrap();
assert_eq!(t.ref_range, ((1, 1), (5, 3)));
assert_eq!(t.auto_filter_ref, Some(((1, 1), (4, 3))));
}
#[test]
fn apply_table_edits_resize_auto_filter_updates_only_the_nested_ref() {
let out = apply_table_edits(
REAL_TABLE_XML,
&[TableEditOp::ResizeAutoFilter(((1, 1), (5, 3)))],
);
let t = parse_table_xml(&out).unwrap();
assert_eq!(t.ref_range, ((1, 1), (4, 3)));
assert_eq!(t.auto_filter_ref, Some(((1, 1), (5, 3))));
}
#[test]
fn apply_table_edits_set_filter_column_adds_a_fresh_filter_column() {
let out = apply_table_edits(
REAL_TABLE_XML,
&[TableEditOp::SetFilterColumn(
1,
FilterCriteria::Values(vec!["Yes".to_string()]),
)],
);
assert!(out.contains(r#"colId="1""#));
assert!(out.contains(r#"<filter val="Yes"/>"#));
assert!(out.contains(r#"ref="A1:C4""#));
}
#[test]
fn apply_table_edits_set_filter_column_replaces_an_existing_entry_for_the_same_col_offset() {
let with_one = apply_table_edits(
REAL_TABLE_XML,
&[TableEditOp::SetFilterColumn(1, FilterCriteria::Blank)],
);
let out = apply_table_edits(
&with_one,
&[TableEditOp::SetFilterColumn(
1,
FilterCriteria::Values(vec!["A".to_string()]),
)],
);
assert_eq!(out.matches("filterColumn").count(), 2); assert!(!out.contains("blank"));
assert!(out.contains(r#"<filter val="A"/>"#));
}
#[test]
fn apply_table_edits_set_filter_column_preserves_an_unrelated_columns_raw_bytes() {
let with_two = apply_table_edits(
REAL_TABLE_XML,
&[
TableEditOp::SetFilterColumn(0, FilterCriteria::Blank),
TableEditOp::SetFilterColumn(1, FilterCriteria::Values(vec!["A".to_string()])),
],
);
let out = apply_table_edits(
&with_two,
&[TableEditOp::SetFilterColumn(
1,
FilterCriteria::Values(vec!["B".to_string()]),
)],
);
assert!(out.contains(r#"colId="0""#));
assert!(out.contains(r#"<filters blank="1"/>"#));
assert!(out.contains(r#"<filter val="B"/>"#));
assert!(!out.contains(r#"<filter val="A"/>"#));
}
#[test]
fn apply_table_edits_clear_filter_column_removes_only_the_targeted_entry() {
let with_two = apply_table_edits(
REAL_TABLE_XML,
&[
TableEditOp::SetFilterColumn(0, FilterCriteria::Blank),
TableEditOp::SetFilterColumn(1, FilterCriteria::Values(vec!["A".to_string()])),
],
);
let out = apply_table_edits(&with_two, &[TableEditOp::ClearFilterColumn(0)]);
assert!(!out.contains(r#"colId="0""#));
assert!(out.contains(r#"colId="1""#));
let t = parse_table_xml(&out).unwrap();
assert_eq!(t.autofilter_columns.len(), 1);
assert_eq!(t.autofilter_columns[0].col_offset, 1);
}
#[test]
fn apply_table_edits_clear_filter_column_leaves_a_bare_self_closing_autofilter_when_empty() {
let with_one = apply_table_edits(
REAL_TABLE_XML,
&[TableEditOp::SetFilterColumn(0, FilterCriteria::Blank)],
);
let out = apply_table_edits(&with_one, &[TableEditOp::ClearFilterColumn(0)]);
assert!(out.contains(r#"<autoFilter ref="A1:C4"/>"#));
let t = parse_table_xml(&out).unwrap();
assert!(t.autofilter_columns.is_empty());
}
#[test]
fn parse_table_xml_reads_a_real_nested_filter_column() {
let xml = r#"<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
id="1" name="Table1" displayName="Table1" ref="A1:C4">
<autoFilter ref="A1:C4">
<filterColumn colId="0"><filters><filter val="X"/></filters></filterColumn>
</autoFilter>
<tableColumns count="3">
<tableColumn id="1" name="Name"/><tableColumn id="2" name="Qty"/>
<tableColumn id="3" name="Status"/>
</tableColumns>
</table>"#;
let t = parse_table_xml(xml).unwrap();
assert_eq!(t.autofilter_columns.len(), 1);
assert_eq!(t.autofilter_columns[0].col_offset, 0);
assert_eq!(
t.autofilter_columns[0].criteria,
FilterCriteria::Values(vec!["X".to_string()])
);
}
#[test]
fn apply_table_edits_set_style_replaces_the_whole_element() {
let out = apply_table_edits(
REAL_TABLE_XML,
&[TableEditOp::SetStyle(Some("TableStyleLight1".to_string()))],
);
let t = parse_table_xml(&out).unwrap();
assert_eq!(t.style_name.as_deref(), Some("TableStyleLight1"));
}
#[test]
fn apply_table_edits_set_totals_row_shown() {
let out = apply_table_edits(REAL_TABLE_XML, &[TableEditOp::SetTotalsRowShown(true)]);
let t = parse_table_xml(&out).unwrap();
assert!(t.totals_row_shown);
}
#[test]
fn apply_table_edits_add_column_assigns_a_fresh_id_and_preserves_existing_guids() {
let out = apply_table_edits(
TABLE_XML_WITH_GUIDS,
&[TableEditOp::AddColumn("Total".to_string())],
);
let t = parse_table_xml(&out).unwrap();
assert_eq!(t.columns.len(), 4);
assert_eq!(t.columns[3].name, "Total");
assert_eq!(t.columns[3].id.as_deref(), Some("4")); assert!(out.contains(r#"xr3:uid="{00000000-0001-0000-0000-000000000002}""#));
assert!(out.contains(r#"count="4""#));
}
#[test]
fn apply_table_edits_remove_column_drops_only_the_named_one() {
let out = apply_table_edits(
TABLE_XML_WITH_GUIDS,
&[TableEditOp::RemoveColumn("Qty".to_string())],
);
let t = parse_table_xml(&out).unwrap();
assert_eq!(t.columns.len(), 2);
assert_eq!(t.columns[0].name, "Name");
assert_eq!(t.columns[1].name, "Status");
assert!(out.contains(r#"xr3:uid="{00000000-0001-0000-0000-000000000002}""#));
assert!(out.contains(r#"xr3:uid="{00000000-0001-0000-0000-000000000004}""#));
assert!(!out.contains(r#"xr3:uid="{00000000-0001-0000-0000-000000000003}""#));
assert!(out.contains(r#"count="2""#));
}
#[test]
fn parse_table_xml_extracts_every_field_from_a_real_shape() {
let t = parse_table_xml(REAL_TABLE_XML).unwrap();
assert_eq!(t.name, "Table1");
assert_eq!(t.display_name, "Table1");
assert_eq!(t.ref_range, ((1, 1), (4, 3)));
assert_eq!(t.header_row_count, 1); assert_eq!(t.totals_row_count, 0);
assert!(!t.totals_row_shown); assert_eq!(t.style_name.as_deref(), Some("TableStyleMedium2"));
assert_eq!(t.auto_filter_ref, Some(((1, 1), (4, 3))));
assert_eq!(t.columns.len(), 3);
assert_eq!(t.columns[0].name, "Name");
assert_eq!(t.columns[1].name, "Qty");
assert_eq!(t.columns[2].name, "Status");
assert!(
t.columns
.iter()
.all(|c| c.calculated_column_formula.is_none())
);
}
#[test]
fn parse_table_xml_defaults_totals_row_shown_to_true_when_absent() {
let xml = r#"<table name="T" displayName="T" ref="A1:B2">
<tableColumns count="1"><tableColumn id="1" name="X"/></tableColumns>
</table>"#;
let t = parse_table_xml(xml).unwrap();
assert!(t.totals_row_shown);
assert!(t.auto_filter_ref.is_none());
assert!(t.style_name.is_none());
}
#[test]
fn parse_table_xml_captures_a_calculated_column_formula_as_raw_text() {
let xml = r#"<table name="T" displayName="T" ref="A1:B2">
<tableColumns count="1">
<tableColumn id="1" name="Total">
<calculatedColumnFormula>[@Qty]*[@Price]</calculatedColumnFormula>
</tableColumn>
</tableColumns>
</table>"#;
let t = parse_table_xml(xml).unwrap();
assert_eq!(
t.columns[0].calculated_column_formula.as_deref(),
Some("[@Qty]*[@Price]")
);
}
#[test]
fn parse_table_xml_returns_none_when_ref_is_missing_or_unparseable() {
assert!(parse_table_xml(r#"<table name="T" displayName="T"/>"#).is_none());
assert!(parse_table_xml(r#"<table name="T" displayName="T" ref="A1"/>"#).is_none());
}
#[test]
fn xlsx_table_part_rids_extracts_every_id_in_document_order() {
let sheet_xml = r#"<worksheet><tableParts count="2">
<tablePart r:id="rId3"/><tablePart r:id="rId7"/>
</tableParts></worksheet>"#;
assert_eq!(xlsx_table_part_rids(sheet_xml), vec!["rId3", "rId7"]);
}
#[test]
fn xlsx_table_part_rids_is_empty_without_a_tableparts_element() {
assert!(xlsx_table_part_rids("<worksheet></worksheet>").is_empty());
}
#[test]
fn relationship_ids_extracts_every_id_ignoring_type_and_target() {
let xml = concat!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
r#"<Relationship Id="rId1" Type="a" Target="b"/>"#,
r#"<Relationship Id="rId2" Type="c" Target="d"/>"#,
r#"</Relationships>"#,
);
assert_eq!(relationship_ids(xml), vec!["rId1", "rId2"]);
}
#[test]
fn relationship_ids_is_empty_for_a_relationships_document_with_no_entries() {
let xml = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>"#;
assert!(relationship_ids(xml).is_empty());
}
#[test]
fn insert_before_close_inserts_a_relationship_into_an_existing_rels_document() {
let xml = concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
r#"<Relationship Id="rId1" Type="a" Target="b"/>"#,
r#"</Relationships>"#,
);
let out = insert_before_close(xml, r#"<Relationship Id="rId2" Type="c" Target="d"/>"#);
assert_eq!(relationship_ids(&out), vec!["rId1", "rId2"]);
assert!(out.contains(r#"<Relationship Id="rId1" Type="a" Target="b"/>"#));
}
fn sample_table_def(ref_range: MergeRect) -> TableDef {
TableDef {
name: "Table1".to_string(),
display_name: "Table1".to_string(),
ref_range,
header_row_count: 1,
totals_row_count: 0,
totals_row_shown: false,
columns: vec![
TableColumn {
id: None,
name: "Name".to_string(),
totals_row_function: None,
totals_row_label: None,
calculated_column_formula: None,
},
TableColumn {
id: None,
name: "Qty".to_string(),
totals_row_function: None,
totals_row_label: None,
calculated_column_formula: None,
},
],
style_name: None,
auto_filter_ref: Some(ref_range),
autofilter_columns: Vec::new(),
source_part: String::new(),
pending_edits: Vec::new(),
}
}
#[test]
fn render_table_xml_produces_output_that_parse_table_xml_reads_back_correctly() {
let table = sample_table_def(((1, 1), (3, 2)));
let xml = render_table_xml(&table, 7);
let parsed = parse_table_xml(&xml).expect("must parse back");
assert_eq!(parsed.name, "Table1");
assert_eq!(parsed.display_name, "Table1");
assert_eq!(parsed.ref_range, ((1, 1), (3, 2)));
assert_eq!(parsed.header_row_count, 1);
assert!(!parsed.totals_row_shown);
assert_eq!(
parsed
.columns
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>(),
vec!["Name", "Qty"]
);
assert_eq!(parsed.auto_filter_ref, Some(((1, 1), (3, 2))));
assert!(parsed.style_name.is_none());
}
#[test]
fn render_table_xml_includes_a_style_when_one_is_set() {
let mut table = sample_table_def(((1, 1), (1, 1)));
table.style_name = Some("TableStyleMedium2".to_string());
let xml = render_table_xml(&table, 1);
assert!(xml.contains(r#"<tableStyleInfo name="TableStyleMedium2""#));
let parsed = parse_table_xml(&xml).expect("must parse back");
assert_eq!(parsed.style_name.as_deref(), Some("TableStyleMedium2"));
}
#[test]
fn render_table_xml_embeds_filter_criteria_already_set_before_the_first_save() {
let mut table = sample_table_def(((1, 1), (1, 1)));
table.autofilter_columns = vec![FilterColumn {
col_offset: 0,
hidden_button: false,
show_button: true,
criteria: FilterCriteria::Blank,
raw_span: None,
dirty: false,
}];
let xml = render_table_xml(&table, 1);
assert!(xml.contains(r#"colId="0""#));
assert!(xml.contains(r#"<filters blank="1"/>"#));
let parsed = parse_table_xml(&xml).expect("must parse back");
assert_eq!(parsed.autofilter_columns.len(), 1);
assert_eq!(parsed.autofilter_columns[0].criteria, FilterCriteria::Blank);
}
#[test]
fn render_table_xml_omits_xr_uid_extension_guids() {
let xml = render_table_xml(&sample_table_def(((1, 1), (1, 1))), 1);
assert!(!xml.contains("xr:uid"));
assert!(!xml.contains("xr3:uid"));
}
}
#[cfg(test)]
mod data_validation_parsing_tests {
use super::*;
const REAL_LIST_DV: &str = r#"<dataValidations count="1"><dataValidation type="list" allowBlank="1" showInputMessage="1" showErrorMessage="1" sqref="E1" xr:uid="{BF4C2CDE-5B18-5247-880B-6E29EFBEE104}"><formula1>"Yes,No,Maybe"</formula1></dataValidation></dataValidations>"#;
#[test]
fn xlsx_data_validations_extracts_every_field_from_a_real_shape() {
let sheet_xml = format!("<worksheet>{REAL_LIST_DV}</worksheet>");
let rules = xlsx_data_validations(&sheet_xml);
assert_eq!(rules.len(), 1);
let r = &rules[0];
assert_eq!(r.validation_type, "list");
assert_eq!(r.operator, None);
assert_eq!(r.formula1.as_deref(), Some(r#""Yes,No,Maybe""#));
assert_eq!(r.formula2, None);
assert!(r.allow_blank);
assert!(r.show_input_message);
assert!(r.show_error_message);
assert_eq!(r.sqref, vec![((1, 5), (1, 5))]);
assert!(!r.dirty);
assert!(r.raw_span.contains("xr:uid"));
}
#[test]
fn xlsx_data_validations_is_empty_without_a_datavalidations_element() {
assert!(xlsx_data_validations("<worksheet></worksheet>").is_empty());
}
#[test]
fn xlsx_data_validations_reads_an_operator_and_two_formulas() {
let sheet_xml = r#"<worksheet><dataValidations count="1"><dataValidation type="whole" operator="between" allowBlank="0" sqref="A1:A5"><formula1>1</formula1><formula2>10</formula2></dataValidation></dataValidations></worksheet>"#;
let rules = xlsx_data_validations(sheet_xml);
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].operator.as_deref(), Some("between"));
assert_eq!(rules[0].formula1.as_deref(), Some("1"));
assert_eq!(rules[0].formula2.as_deref(), Some("10"));
assert!(!rules[0].allow_blank);
assert_eq!(rules[0].sqref, vec![((1, 1), (5, 1))]);
}
#[test]
fn xlsx_data_validations_reads_multiple_records_in_document_order() {
let sheet_xml = r#"<worksheet><dataValidations count="2"><dataValidation type="list" sqref="A1"><formula1>"X,Y"</formula1></dataValidation><dataValidation type="custom" sqref="B1"><formula1>ISNUMBER(B1)</formula1></dataValidation></dataValidations></worksheet>"#;
let rules = xlsx_data_validations(sheet_xml);
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].validation_type, "list");
assert_eq!(rules[1].validation_type, "custom");
}
#[test]
fn xlsx_autofilter_is_none_without_an_autofilter_element() {
assert!(xlsx_autofilter("<worksheet></worksheet>").is_none());
}
#[test]
fn xlsx_autofilter_parses_a_bare_ref_with_no_columns() {
let af = xlsx_autofilter(r#"<worksheet><autoFilter ref="A1:C21"/></worksheet>"#).unwrap();
assert_eq!(af.ref_range, ((1, 1), (21, 3)));
assert!(af.columns.is_empty());
}
#[test]
fn xlsx_autofilter_parses_a_values_filter_column() {
let xml = r#"<worksheet><autoFilter ref="A1:E21"><filterColumn colId="0" hiddenButton="0" showButton="1"><filters><filter val="Item1"/><filter val="Item2"/></filters></filterColumn></autoFilter></worksheet>"#;
let af = xlsx_autofilter(xml).unwrap();
assert_eq!(af.columns.len(), 1);
let col = &af.columns[0];
assert_eq!(col.col_offset, 0);
assert!(!col.hidden_button);
assert!(col.show_button);
assert_eq!(
col.criteria,
FilterCriteria::Values(vec!["Item1".to_string(), "Item2".to_string()])
);
}
#[test]
fn xlsx_autofilter_parses_a_one_condition_custom_filter() {
let xml = r#"<worksheet><autoFilter ref="A1:B5"><filterColumn colId="1" hiddenButton="0" showButton="1"><customFilters and="0"><customFilter val="5" operator="greaterThan"/></customFilters></filterColumn></autoFilter></worksheet>"#;
let af = xlsx_autofilter(xml).unwrap();
assert_eq!(
af.columns[0].criteria,
FilterCriteria::Custom {
op1: "greaterThan".to_string(),
val1: "5".to_string(),
and: false,
op2: None,
val2: None,
}
);
}
#[test]
fn xlsx_autofilter_parses_a_two_condition_and_custom_filter() {
let xml = r#"<worksheet><autoFilter ref="A1:B5"><filterColumn colId="0" hiddenButton="0" showButton="1"><customFilters and="1"><customFilter val="10" operator="greaterThanOrEqual"/><customFilter val="20" operator="lessThanOrEqual"/></customFilters></filterColumn></autoFilter></worksheet>"#;
let af = xlsx_autofilter(xml).unwrap();
assert_eq!(
af.columns[0].criteria,
FilterCriteria::Custom {
op1: "greaterThanOrEqual".to_string(),
val1: "10".to_string(),
and: true,
op2: Some("lessThanOrEqual".to_string()),
val2: Some("20".to_string()),
}
);
}
#[test]
fn xlsx_autofilter_parses_a_blank_filter() {
let xml = r#"<worksheet><autoFilter ref="A1:D5"><filterColumn colId="3" hiddenButton="0" showButton="1"><filters blank="1"/></filterColumn></autoFilter></worksheet>"#;
let af = xlsx_autofilter(xml).unwrap();
assert_eq!(af.columns[0].criteria, FilterCriteria::Blank);
}
#[test]
fn xlsx_autofilter_parses_a_top10_filter() {
let xml = r#"<worksheet><autoFilter ref="A1:B21"><filterColumn colId="1" hiddenButton="0" showButton="1"><top10 top="1" percent="0" val="5"/></filterColumn></autoFilter></worksheet>"#;
let af = xlsx_autofilter(xml).unwrap();
assert_eq!(
af.columns[0].criteria,
FilterCriteria::Top10 {
top: true,
percent: false,
val: 5.0
}
);
}
#[test]
fn xlsx_autofilter_parses_a_date_group_filter() {
let xml = r#"<worksheet><autoFilter ref="A1:B5"><filterColumn colId="1" hiddenButton="0" showButton="1"><filters calendarType="gregorian"><dateGroupItem year="2024" month="1" dateTimeGrouping="month"/></filters></filterColumn></autoFilter></worksheet>"#;
let af = xlsx_autofilter(xml).unwrap();
assert_eq!(
af.columns[0].criteria,
FilterCriteria::DateGroup(vec![DateGroupItem {
year: Some(2024),
month: Some(1),
day: None,
hour: None,
minute: None,
second: None,
date_time_grouping: "month".to_string(),
}])
);
}
#[test]
fn xlsx_autofilter_reads_multiple_filter_columns_in_document_order() {
let xml = r#"<worksheet><autoFilter ref="A1:C21"><filterColumn colId="0" hiddenButton="0" showButton="1"><filters blank="1"/></filterColumn><filterColumn colId="2" hiddenButton="0" showButton="1"><filters blank="1"/></filterColumn></autoFilter></worksheet>"#;
let af = xlsx_autofilter(xml).unwrap();
assert_eq!(af.columns.len(), 2);
assert_eq!(af.columns[0].col_offset, 0);
assert_eq!(af.columns[1].col_offset, 2);
}
#[test]
fn parse_sqref_handles_single_cell_and_multi_area() {
assert_eq!(parse_sqref("E1"), vec![((1, 5), (1, 5))]);
assert_eq!(
parse_sqref("A1:A5 C1:C5"),
vec![((1, 1), (5, 1)), ((1, 3), (5, 3))]
);
}
#[test]
fn parse_sqref_tolerates_an_unparseable_token() {
assert_eq!(
parse_sqref("A1 !!! C1"),
vec![((1, 1), (1, 1)), ((1, 3), (1, 3))]
);
}
}
fn read_ods(path: &str) -> Result<Vec<WorkbookSheet>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
validate_zip_archive(&mut archive)?;
let xml = zip_read_text(&mut archive, "content.xml")?;
Ok(ods_parse(&xml))
}
fn ods_parse(xml: &str) -> Vec<WorkbookSheet> {
let mut iter = XmlIter::new(xml);
let mut sheets: Vec<WorkbookSheet> = vec![];
let mut in_sheet = false;
let mut row: u32 = 0;
let mut col: u32 = 0;
let mut in_text_p = false;
let mut cell_text = String::new();
let mut pending_cell: Option<OdsCellState> = None;
let mut row_repeat: u32 = 1;
let mut col_repeat: u32 = 1;
while let Some(ev) = iter.next_ev() {
match &ev {
Ev::Open(tag, attrs) | Ev::SelfClose(tag, attrs) => {
let local = tag.split(':').next_back().unwrap_or(tag.as_str());
match local {
"table" => {
let name = attr_get(attrs, "name").unwrap_or("sheet1").to_lowercase();
sheets.push(WorkbookSheet {
name,
cells: HashMap::new(),
sheet_id: None,
workbook_rel_id: None,
source_part_name: None,
merged_ranges: Vec::new(),
hidden_rows: Vec::new(),
hidden_columns: Vec::new(),
raw_style_indices: HashMap::new(),
formulas: HashMap::new(),
cell_number_formats: HashMap::new(),
sheet_state: None,
row_heights: HashMap::new(),
column_widths: Vec::new(),
row_styles: HashMap::new(),
column_styles: Vec::new(),
tables: Vec::new(),
data_validations: Vec::new(),
autofilter: None,
});
in_sheet = true;
row = 0;
col = 0;
row_repeat = 1;
}
"table-row" if in_sheet => {
row += row_repeat;
col = 0;
col_repeat = 1;
pending_cell = None;
row_repeat = attr_get(attrs, "number-rows-repeated")
.and_then(|v| v.parse().ok())
.filter(|n| *n >= 1)
.unwrap_or(1);
}
"table-cell" | "covered-table-cell" if in_sheet => {
if let Some(state) = pending_cell.take() {
emit_ods_cell(&mut sheets, state);
}
col += col_repeat;
col_repeat = attr_get(attrs, "number-columns-repeated")
.and_then(|v| v.parse().ok())
.filter(|n| *n >= 1)
.unwrap_or(1);
let cell_type = attr_get(attrs, "value-type").unwrap_or("").to_string();
let val_attr = attr_get(attrs, "value").unwrap_or("").to_string();
let bool_attr = attr_get(attrs, "boolean-value").unwrap_or("").to_string();
cell_text.clear();
in_text_p = false;
if local == "table-cell" {
let cols_spanned: u32 = attr_get(attrs, "number-columns-spanned")
.and_then(|v| v.parse().ok())
.unwrap_or(1);
let rows_spanned: u32 = attr_get(attrs, "number-rows-spanned")
.and_then(|v| v.parse().ok())
.unwrap_or(1);
if (cols_spanned > 1 || rows_spanned > 1)
&& let Some(sheet) = sheets.last_mut()
{
sheet.merged_ranges.push((
(row, col),
(row + rows_spanned - 1, col + cols_spanned - 1),
));
}
}
let make_state = || OdsCellState {
row,
col,
cell_type,
val_attr,
bool_attr,
text: String::new(),
};
if matches!(ev, Ev::SelfClose(_, _)) {
emit_ods_cell(&mut sheets, make_state());
pending_cell = None;
} else {
pending_cell = Some(make_state());
}
}
"p" if in_sheet => {
in_text_p = true;
}
_ => {}
}
}
Ev::Close(tag) => {
let local = tag.split(':').next_back().unwrap_or(tag.as_str());
match local {
"table" => {
in_sheet = false;
}
"table-cell" | "covered-table-cell" if in_sheet => {
if let Some(ref mut state) = pending_cell {
state.text.clone_from(&cell_text);
}
if let Some(state) = pending_cell.take() {
emit_ods_cell(&mut sheets, state);
}
in_text_p = false;
}
"p" => {
in_text_p = false;
}
_ => {}
}
}
Ev::Text(text) => {
if in_sheet && in_text_p {
cell_text.push_str(text);
}
}
}
}
sheets
}
struct OdsCellState {
row: u32,
col: u32,
cell_type: String,
val_attr: String,
bool_attr: String,
text: String,
}
fn emit_ods_cell(sheets: &mut [WorkbookSheet], state: OdsCellState) {
let sheet = match sheets.last_mut() {
Some(s) => s,
None => return,
};
let cell = ods_make_cell(&state);
if let Some(c) = cell {
sheet.cells.insert((state.row, state.col), c);
}
}
fn ods_make_cell(s: &OdsCellState) -> Option<SheetCell> {
match s.cell_type.as_str() {
"float" | "percentage" | "currency" => {
let f: f64 = s.val_attr.parse().ok()?;
Some(num_to_cell(f))
}
"string" => {
if s.text.is_empty() {
None
} else {
Some(SheetCell::Str(s.text.clone()))
}
}
"boolean" => Some(SheetCell::Bool(s.bool_attr == "true")),
_ => None, }
}
#[cfg(test)]
mod sheet_id_tests {
use super::*;
#[test]
fn xlsx_workbook_sheets_captures_non_contiguous_sheet_ids() {
let xml = r#"<?xml version="1.0"?>
<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
<sheet name="Sheet2" sheetId="5" r:id="rId2"/>
</sheets>
</workbook>"#;
let result = xlsx_workbook_sheets(xml);
assert_eq!(
result,
vec![
(
"Sheet1".to_string(),
"rId1".to_string(),
Some("1".to_string()),
None
),
(
"Sheet2".to_string(),
"rId2".to_string(),
Some("5".to_string()),
None
),
]
);
}
#[test]
fn xlsx_workbook_sheets_handles_a_missing_sheet_id() {
let xml = r#"<sheets><sheet name="Sheet1" r:id="rId1"/></sheets>"#;
let result = xlsx_workbook_sheets(xml);
assert_eq!(
result,
vec![("Sheet1".to_string(), "rId1".to_string(), None, None)]
);
}
#[test]
fn xlsx_workbook_sheets_captures_the_state_attribute() {
let xml = r#"<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
<sheet name="Sheet2" sheetId="2" r:id="rId2" state="hidden"/>
<sheet name="Sheet3" sheetId="3" r:id="rId3" state="veryHidden"/>
</sheets>"#;
let result = xlsx_workbook_sheets(xml);
let states: Vec<Option<String>> = result.into_iter().map(|(_, _, _, s)| s).collect();
assert_eq!(
states,
vec![
None,
Some("hidden".to_string()),
Some("veryHidden".to_string())
]
);
}
#[test]
fn ods_sheets_always_have_no_sheet_id() {
let xml = r#"<office:body><office:spreadsheet>
<table:table table:name="Sheet1"></table:table>
<table:table table:name="Sheet2"></table:table>
</office:spreadsheet></office:body>"#;
let sheets = ods_parse(xml);
assert_eq!(sheets.len(), 2);
assert!(sheets.iter().all(|s| s.sheet_id.is_none()));
}
}
#[cfg(test)]
mod defined_names_tests {
use super::*;
#[test]
fn xlsx_defined_names_captures_name_and_raw_text() {
let xml = r#"<workbook><definedNames>
<definedName name="MyRange">Sheet1!$A$1:$A$3</definedName>
<definedName name="Other" localSheetId="0">Sheet1!$B$1</definedName>
</definedNames></workbook>"#;
assert_eq!(
xlsx_defined_names(xml),
vec![
("MyRange".to_string(), "Sheet1!$A$1:$A$3".to_string()),
("Other".to_string(), "Sheet1!$B$1".to_string()),
]
);
}
#[test]
fn xlsx_defined_names_is_empty_when_absent() {
let xml = r#"<workbook><sheets><sheet name="Sheet1" r:id="rId1"/></sheets></workbook>"#;
assert_eq!(xlsx_defined_names(xml), Vec::<(String, String)>::new());
}
#[test]
fn xlsx_defined_names_xml_unescapes_the_text_content() {
let xml = r#"<definedNames><definedName name="X">Sheet1!$A$1 & "text"</definedName></definedNames>"#;
assert_eq!(
xlsx_defined_names(xml),
vec![("X".to_string(), "Sheet1!$A$1 & \"text\"".to_string())]
);
}
}
#[cfg(test)]
mod merge_tests {
use super::*;
#[test]
fn parse_merge_ref_reads_top_left_and_bottom_right() {
assert_eq!(parse_merge_ref("A1:C1"), Some(((1, 1), (1, 3))));
assert_eq!(parse_merge_ref("B3:B4"), Some(((3, 2), (4, 2))));
}
#[test]
fn parse_merge_ref_rejects_a_single_cell_with_no_colon() {
assert_eq!(parse_merge_ref("A1"), None);
}
#[test]
fn xlsx_sheet_cells_reads_merge_cells() {
let xml = r#"<worksheet>
<sheetData>
<row r="1"><c r="A1"><v>1</v></c></row>
</sheetData>
<mergeCells count="2">
<mergeCell ref="A1:C1"/>
<mergeCell ref="B3:B4"/>
</mergeCells>
</worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(data.cells.len(), 1);
assert_eq!(data.merged_ranges, vec![((1, 1), (1, 3)), ((3, 2), (4, 2))]);
}
#[test]
fn xlsx_sheet_cells_with_no_merge_cells_element_has_empty_merged_ranges() {
let xml = r#"<worksheet><sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert!(data.merged_ranges.is_empty());
assert!(data.hidden_rows.is_empty());
assert!(data.hidden_columns.is_empty());
}
#[test]
fn xlsx_sheet_cells_coalesces_consecutive_hidden_rows_into_intervals() {
let xml = r#"<worksheet>
<cols>
<col min="2" max="2" hidden="1"/>
</cols>
<sheetData>
<row r="1"><c r="A1"><v>1</v></c></row>
<row r="11" hidden="1"/>
<row r="12" hidden="1"/>
<row r="13" hidden="1"/>
<row r="14" hidden="1"/>
<row r="20"><c r="A20"><v>2</v></c></row>
<row r="30" hidden="1"/>
<row r="31" hidden="1"/>
</sheetData>
</worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(data.hidden_rows, vec![(11, 14), (30, 31)]);
assert_eq!(data.hidden_columns, vec![(2, 2)]);
}
#[test]
fn xlsx_sheet_cells_starts_a_new_interval_across_a_row_number_gap() {
let xml = r#"<worksheet><sheetData>
<row r="5" hidden="1"/>
<row r="7" hidden="1"/>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(data.hidden_rows, vec![(5, 5), (7, 7)]);
}
#[test]
fn xlsx_sheet_cells_reads_a_multi_column_hidden_col_span_without_coalescing() {
let xml = r#"<worksheet><cols>
<col min="2" max="4" hidden="1"/>
<col min="6" max="6"/>
</cols><sheetData></sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(data.hidden_columns, vec![(2, 4)]);
}
#[test]
fn xlsx_sheet_cells_accepts_the_xsd_boolean_true_literal_for_hidden() {
let xml = r#"<worksheet><cols>
<col min="1" max="1" hidden="true"/>
</cols><sheetData>
<row r="1" hidden="true"><c r="A1"><v>1</v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(data.hidden_columns, vec![(1, 1)]);
assert_eq!(data.hidden_rows, vec![(1, 1)]);
}
#[test]
fn xlsx_sheet_cells_reads_a_custom_row_height() {
let xml = r#"<worksheet><sheetData>
<row r="5" ht="30.5" customHeight="1"><c r="A5"><v>1</v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(data.row_heights.get(&5), Some(&30.5));
}
#[test]
fn xlsx_sheet_cells_ignores_ht_without_custom_height() {
let xml = r#"<worksheet><sheetData>
<row r="5" ht="30.5"><c r="A5"><v>1</v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert!(data.row_heights.is_empty());
}
#[test]
fn xlsx_sheet_cells_reads_a_custom_column_width_range() {
let xml = r#"<worksheet><cols>
<col min="2" max="4" width="12.5" customWidth="1"/>
</cols><sheetData></sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(data.column_widths, vec![(2, 4, 12.5)]);
}
#[test]
fn xlsx_sheet_cells_ignores_width_without_custom_width() {
let xml = r#"<worksheet><cols>
<col min="2" max="4" width="12.5"/>
</cols><sheetData></sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert!(data.column_widths.is_empty());
}
#[test]
fn xlsx_sheet_cells_row_height_and_hidden_are_independent() {
let xml = r#"<worksheet><sheetData>
<row r="5" ht="20" customHeight="1" hidden="1"/>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(data.row_heights.get(&5), Some(&20.0));
assert_eq!(data.hidden_rows, vec![(5, 5)]);
}
#[test]
fn ods_parse_reads_column_and_row_span_into_a_merged_range() {
let xml = r#"<office:spreadsheet>
<table:table table:name="Sheet1">
<table:table-row>
<table:table-cell table:number-columns-spanned="3" office:value-type="float" office:value="1"/>
<table:covered-table-cell/>
<table:covered-table-cell/>
</table:table-row>
</table:table>
</office:spreadsheet>"#;
let sheets = ods_parse(xml);
assert_eq!(sheets[0].merged_ranges, vec![((1, 1), (1, 3))]);
}
#[test]
fn ods_parse_ordinary_cells_have_no_merged_ranges() {
let xml = r#"<office:spreadsheet>
<table:table table:name="Sheet1">
<table:table-row>
<table:table-cell office:value-type="float" office:value="1"/>
</table:table-row>
</table:table>
</office:spreadsheet>"#;
let sheets = ods_parse(xml);
assert!(sheets[0].merged_ranges.is_empty());
}
#[test]
fn ods_parse_skips_column_position_past_a_repeated_empty_cell_run() {
let xml = r#"<office:spreadsheet>
<table:table table:name="Sheet1">
<table:table-row>
<table:table-cell table:number-columns-repeated="5"/>
<table:table-cell office:value-type="float" office:value="42"/>
</table:table-row>
</table:table>
</office:spreadsheet>"#;
let sheets = ods_parse(xml);
assert!(!sheets[0].cells.contains_key(&(1, 2)));
match sheets[0].cells.get(&(1, 6)) {
Some(SheetCell::Integer(v)) => assert_eq!(*v, 42),
other => panic!("expected Integer(42) at (1,6), got {:?}", other.is_some()),
}
}
#[test]
fn ods_parse_skips_row_position_past_a_repeated_empty_row_run() {
let xml = r#"<office:spreadsheet>
<table:table table:name="Sheet1">
<table:table-row table:number-rows-repeated="4">
<table:table-cell office:value-type="string"><text:p>skip</text:p></table:table-cell>
</table:table-row>
<table:table-row>
<table:table-cell office:value-type="float" office:value="7"/>
</table:table-row>
</table:table>
</office:spreadsheet>"#;
let sheets = ods_parse(xml);
assert!(!sheets[0].cells.contains_key(&(2, 1)));
match sheets[0].cells.get(&(5, 1)) {
Some(SheetCell::Integer(v)) => assert_eq!(*v, 7),
other => panic!("expected Integer(7) at (5,1), got {:?}", other.is_some()),
}
}
#[test]
fn xml_unescape_decodes_numeric_character_references() {
assert_eq!(xml_unescape("ABC"), "ABC");
}
#[test]
fn xml_unescape_does_not_double_unescape_a_literal_escaped_entity() {
assert_eq!(xml_unescape("&lt;"), "<");
}
#[test]
fn xml_unescape_leaves_an_unterminated_ampersand_literal() {
assert_eq!(xml_unescape("a & b"), "a & b");
assert_eq!(
xml_unescape("a ¬arealentity forever"),
"a ¬arealentity forever"
);
}
#[test]
fn xlsx_sheet_cells_records_a_zero_length_string_cell() {
let xml = r#"<worksheet><sheetData>
<row r="1"><c r="A1" t="str"><v></v></c><c r="B1" t="str"><v>after</v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
match data.cells.get(&(1, 1)) {
Some(SheetCell::Str(s)) => assert_eq!(s, ""),
other => panic!("expected Str(\"\") at A1, got {:?}", other.is_some()),
}
assert_eq!(data.cells.len(), 2);
}
#[test]
fn xlsx_sheet_cells_reads_a_t_e_cell_as_a_real_error_not_a_string() {
let xml = r#"<worksheet><sheetData>
<row r="1"><c r="A1" t="e"><v>#VALUE!</v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
match data.cells.get(&(1, 1)) {
Some(SheetCell::Error(e)) => assert_eq!(e.as_str(), "#VALUE!"),
other => panic!(
"expected Error(\"#VALUE!\") at A1, got {:?}",
other.is_some()
),
}
}
#[test]
fn xlsx_sheet_cells_falls_back_to_a_plain_string_for_an_unrecognized_t_e_value() {
let xml = r#"<worksheet><sheetData>
<row r="1"><c r="A1" t="e"><v>#SPILL!</v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
match data.cells.get(&(1, 1)) {
Some(SheetCell::Str(s)) => assert_eq!(s, "#SPILL!"),
other => panic!("expected Str(\"#SPILL!\") at A1, got {:?}", other.is_some()),
}
}
#[test]
fn xlsx_sheet_cells_honors_xml_space_preserve_on_v() {
let xml = r#"<worksheet><sheetData>
<row r="1"><c r="A1" t="str"><v xml:space="preserve"> padded </v></c><c r="B1" t="str"><v> not preserved </v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
match data.cells.get(&(1, 1)) {
Some(SheetCell::Str(s)) => assert_eq!(s, " padded "),
other => panic!(
"expected Str(\" padded \") at A1, got {:?}",
other.is_some()
),
}
match data.cells.get(&(1, 2)) {
Some(SheetCell::Str(s)) => assert_eq!(s, "not preserved"),
other => panic!(
"expected Str(\"not preserved\") at B1, got {:?}",
other.is_some()
),
}
}
#[test]
fn xlsx_sheet_cells_xml_space_preserve_on_a_numeric_v_still_parses_when_untrimmed() {
let xml = r#"<worksheet><sheetData>
<row r="1"><c r="A1"><v xml:space="preserve">42</v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
match data.cells.get(&(1, 1)) {
Some(SheetCell::Integer(n)) => assert_eq!(*n, 42),
other => panic!("expected Integer(42) at A1, got {:?}", other.is_some()),
}
}
#[test]
fn xlsx_sheet_cells_empty_v_on_a_numeric_cell_yields_no_cell() {
let xml = r#"<worksheet><sheetData><row r="1"><c r="A1"><v></v></c></row></sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert!(data.cells.is_empty());
}
#[test]
fn parse_dimension_ref_reads_a_colon_separated_range() {
assert_eq!(parse_dimension_ref("A1:C3"), Some(((1, 1), (3, 3))));
assert_eq!(parse_dimension_ref("A1:A1"), Some(((1, 1), (1, 1))));
}
#[test]
fn parse_dimension_ref_rejects_a_colon_less_single_cell_ref() {
assert_eq!(parse_dimension_ref("A1"), None);
}
#[test]
fn parse_dimension_ref_rejects_a_reversed_range() {
assert_eq!(parse_dimension_ref("C3:A1"), None);
}
#[test]
fn xlsx_sheet_cells_reads_a_dimension_wider_than_the_populated_cells() {
let xml = r#"<worksheet>
<dimension ref="A1:E10"/>
<sheetData>
<row r="1"><c r="A1" t="str"><v>a</v></c></row>
</sheetData>
</worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(data.dimension, Some(((1, 1), (10, 5))));
}
#[test]
fn xlsx_sheet_cells_dimension_is_none_when_the_tag_is_absent() {
let xml = r#"<worksheet><sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(data.dimension, None);
}
#[test]
fn xlsx_sheet_cells_captures_inline_formula_text() {
let xml = r#"<worksheet><sheetData>
<row r="1"><c r="A1"><f>SUM(B1:B2)</f><v>3</v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(
data.formulas.get(&(1, 1)).map(String::as_str),
Some("SUM(B1:B2)")
);
match data.cells.get(&(1, 1)) {
Some(SheetCell::Integer(v)) => assert_eq!(*v, 3),
other => panic!("expected Integer(3) at A1, got {:?}", other.is_some()),
}
}
#[test]
fn xlsx_sheet_cells_shared_formula_follower_with_no_inline_text_captures_nothing() {
let xml = r#"<worksheet><sheetData>
<row r="1"><c r="A1"><f t="shared" ref="A1:A2" si="0">B1</f><v>1</v></c>
<c r="A2"><f t="shared" si="0"/><v>2</v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(data.formulas.get(&(1, 1)).map(String::as_str), Some("B1"));
assert_eq!(data.formulas.get(&(1, 2)), None);
}
#[test]
fn xlsx_sheet_cells_formula_text_is_xml_unescaped() {
let xml = r#"<worksheet><sheetData>
<row r="1"><c r="A1"><f>A1&"x"</f><v>1</v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &[]);
assert_eq!(
data.formulas.get(&(1, 1)).map(String::as_str),
Some(r#"A1&"x""#)
);
}
#[test]
fn xlsx_styles_reads_custom_number_formats_and_cell_xfs_in_order() {
let xml = r#"<styleSheet>
<numFmts count="1"><numFmt numFmtId="164" formatCode="0.00"kg""/></numFmts>
<cellXfs count="3">
<xf numFmtId="0"/>
<xf numFmtId="2"/>
<xf numFmtId="164"/>
</cellXfs>
</styleSheet>"#;
let styles = xlsx_styles(xml);
assert_eq!(
styles.number_formats.get(&164).map(String::as_str),
Some(r#"0.00"kg""#)
);
assert_eq!(styles.cell_xfs, vec![Some(0), Some(2), Some(164)]);
}
#[test]
fn xlsx_styles_an_xf_with_no_numfmtid_attribute_resolves_to_none() {
let xml = r#"<styleSheet><cellXfs count="1"><xf fontId="0"/></cellXfs></styleSheet>"#;
let styles = xlsx_styles(xml);
assert_eq!(styles.cell_xfs, vec![None]);
}
#[test]
fn xlsx_styles_ignores_xf_entries_outside_cell_xfs() {
let xml = r#"<styleSheet>
<cellStyleXfs count="1"><xf numFmtId="9"/></cellStyleXfs>
<cellXfs count="1"><xf numFmtId="14"/></cellXfs>
</styleSheet>"#;
let styles = xlsx_styles(xml);
assert_eq!(styles.cell_xfs, vec![Some(14)]);
}
#[test]
fn xlsx_styles_handles_an_empty_self_closing_cell_xfs() {
let xml = r#"<styleSheet><cellXfs count="0"/></styleSheet>"#;
let styles = xlsx_styles(xml);
assert!(styles.cell_xfs.is_empty());
}
#[test]
fn resolve_number_format_finds_a_builtin_date_format() {
assert_eq!(
resolve_number_format(14, &HashMap::new()).as_deref(),
Some("m/d/yyyy")
);
}
#[test]
fn resolve_number_format_general_is_none() {
assert_eq!(resolve_number_format(0, &HashMap::new()), None);
}
#[test]
fn resolve_number_format_an_unknown_id_with_no_custom_definition_is_none() {
assert_eq!(resolve_number_format(9999, &HashMap::new()), None);
}
#[test]
fn resolve_number_format_prefers_a_custom_definition_over_the_builtin_table() {
let mut custom = HashMap::new();
custom.insert(14, "yyyy-mm-dd".to_string());
assert_eq!(
resolve_number_format(14, &custom).as_deref(),
Some("yyyy-mm-dd")
);
}
#[test]
fn resolve_number_format_finds_a_custom_format_above_id_163() {
let mut custom = HashMap::new();
custom.insert(164, "0.00\"kg\"".to_string());
assert_eq!(
resolve_number_format(164, &custom).as_deref(),
Some("0.00\"kg\"")
);
}
#[test]
fn xlsx_sheet_cells_resolves_a_cells_s_attribute_through_cell_xfs() {
let cell_xfs = vec![Some(0u32), Some(14u32)];
let xml = r#"<worksheet><sheetData>
<row r="1"><c r="A1" s="1"><v>45444</v></c><c r="B1" s="0"><v>1</v></c><c r="C1"><v>2</v></c></row>
</sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &cell_xfs);
assert_eq!(data.style_ids.get(&(1, 1)), Some(&14));
assert_eq!(data.style_ids.get(&(1, 2)), None);
assert_eq!(data.style_ids.get(&(1, 3)), None);
}
#[test]
fn xlsx_sheet_cells_an_out_of_range_s_index_resolves_to_no_style() {
let cell_xfs = vec![Some(14u32)];
let xml = r#"<worksheet><sheetData><row r="1"><c r="A1" s="99"><v>1</v></c></row></sheetData></worksheet>"#;
let data = xlsx_sheet_cells(xml, &[], &cell_xfs);
assert_eq!(data.style_ids.get(&(1, 1)), None);
}
#[test]
fn xlsx_workbook_date1904_defaults_to_false_when_absent() {
let xml = r#"<workbook><sheets></sheets></workbook>"#;
assert!(!xlsx_workbook_date1904(xml));
}
#[test]
fn xlsx_workbook_date1904_reads_the_declared_flag() {
let xml = r#"<workbook><workbookPr date1904="1"/><sheets></sheets></workbook>"#;
assert!(xlsx_workbook_date1904(xml));
let xml2 = r#"<workbook><workbookPr date1904="true"/></workbook>"#;
assert!(xlsx_workbook_date1904(xml2));
let xml3 = r#"<workbook><workbookPr date1904="0"/></workbook>"#;
assert!(!xlsx_workbook_date1904(xml3));
}
}
#[cfg(test)]
mod from_bytes_tests {
use super::*;
use std::io::Write;
use zip::write::SimpleFileOptions;
fn cell_map_eq(a: &HashMap<(u32, u32), SheetCell>, b: &HashMap<(u32, u32), SheetCell>) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().all(|(k, v)| match (v, b.get(k)) {
(SheetCell::Integer(x), Some(SheetCell::Integer(y))) => x == y,
(SheetCell::Float(x), Some(SheetCell::Float(y))) => x == y,
(SheetCell::Str(x), Some(SheetCell::Str(y))) => x == y,
(SheetCell::Bool(x), Some(SheetCell::Bool(y))) => x == y,
(SheetCell::Error(x), Some(SheetCell::Error(y))) => x == y,
_ => false,
})
}
#[test]
fn read_workbook_from_bytes_matches_read_workbook_on_a_real_xlsx_fixture() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/e2e/source.xlsx"
);
let from_path = read_workbook(path).expect("read_workbook(path) should succeed");
let bytes = std::fs::read(path).expect("fixture should be readable");
let from_bytes =
read_workbook_from_bytes(&bytes).expect("read_workbook_from_bytes should succeed");
assert_eq!(from_path.len(), from_bytes.sheets.len());
for (a, bs) in from_path.iter().zip(from_bytes.sheets.iter()) {
let b = &bs.sheet;
assert_eq!(a.name, b.name);
assert_eq!(a.sheet_id, b.sheet_id);
assert_eq!(a.merged_ranges, b.merged_ranges);
assert_eq!(a.hidden_rows, b.hidden_rows);
assert_eq!(a.hidden_columns, b.hidden_columns);
assert!(cell_map_eq(&a.cells, &b.cells));
}
}
#[test]
fn read_workbook_from_bytes_rejects_a_non_zip_buffer() {
assert!(read_workbook_from_bytes(b"not a zip file").is_err());
}
#[test]
fn zip_validation_rejects_path_traversal_entries() {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("../outside.xml", SimpleFileOptions::default())
.unwrap();
zip.write_all(b"not valid workbook data").unwrap();
let bytes = zip.finish().unwrap().into_inner();
let mut archive = ZipArchive::new(Cursor::new(bytes)).unwrap();
let error = validate_zip_archive(&mut archive).unwrap_err();
assert!(error.contains("unsafe path"));
}
}