use std::collections::HashMap;
use std::io::{Cursor, Read, Seek};
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 enum SheetCell {
Integer(i64),
Float(f64),
Str(String),
Bool(bool),
}
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;
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 = 64 * 1024 * 1024;
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)
}
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())?;
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());
}
}
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_relationship_free_hyperlinks(xml: &str) -> 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]);
if attr_get(&attrs, "id").is_none() {
out.push(container[tag_start..start_tag_end].to_string());
}
search_from = start_tag_end;
}
out
}
#[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 relationship_free_hyperlinks_returns_none_when_hyperlinks_absent() {
let xml = "<worksheet><sheetData/></worksheet>";
assert_eq!(
extract_relationship_free_hyperlinks(xml),
Vec::<String>::new()
);
}
#[test]
fn relationship_free_hyperlinks_all_location_form_all_kept() {
let xml = concat!(
"<worksheet><sheetData/>",
"<hyperlinks><hyperlink ref=\"A1\" location=\"Sheet2!B2\" display=\"Sheet2!B2\" ",
"xr:uid=\"{7239724E-8623-EB4C-A548-F5CFD578FC11}\"/></hyperlinks>",
"</worksheet>",
);
assert_eq!(
extract_relationship_free_hyperlinks(xml),
vec![
"<hyperlink ref=\"A1\" location=\"Sheet2!B2\" display=\"Sheet2!B2\" \
xr:uid=\"{7239724E-8623-EB4C-A548-F5CFD578FC11}\"/>"
.to_string()
]
);
}
#[test]
fn relationship_free_hyperlinks_all_rid_form_returns_empty() {
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_relationship_free_hyperlinks(xml),
Vec::<String>::new()
);
}
#[test]
fn relationship_free_hyperlinks_mixed_container_keeps_only_location_only_children() {
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_relationship_free_hyperlinks(xml),
vec!["<hyperlink ref=\"B1\" location=\"Sheet2!A1\"/>".to_string()]
);
}
#[test]
fn relationship_free_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>",
);
assert_eq!(
extract_relationship_free_hyperlinks(xml),
vec![
"<hyperlink ref=\"A1\" location=\"Sheet2!A1\"/>".to_string(),
"<hyperlink ref=\"B1\" location=\"Sheet3!A1\"/>".to_string(),
]
);
}
}
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 archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
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> {
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);
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) 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);
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(),
},
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>)> {
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());
result.push((name.to_string(), rid.to_string(), sheet_id));
}
}
}
result
}
fn xlsx_rels(xml: &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("/worksheet")
{
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,
}
}
struct XlsxSheetData {
cells: HashMap<(u32, u32), SheetCell>,
merged_ranges: Vec<MergeRect>,
hidden_rows: Vec<(u32, u32)>,
hidden_columns: Vec<(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 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 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);
}
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);
}
}
"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));
}
}
}
"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,
merged_ranges,
hidden_rows,
hidden_columns,
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" | "e" => Some(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 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())?;
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(),
});
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())
),
(
"Sheet2".to_string(),
"rId2".to_string(),
Some("5".to_string())
),
]
);
}
#[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)]
);
}
#[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 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 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_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 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::*;
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,
_ => 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());
}
}