use std::path::Path;
use ooxml_core::opc::{self, OpcPackage, Part, PartName, RelType, Relationship, Relationships};
use crate::error::{Error, Result};
use crate::shared_strings::SharedStringsTable;
use crate::worksheet::Worksheet;
const NS_SPREADSHEETML: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
const NS_RELATIONSHIPS: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const CT_WORKBOOK: &str =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml";
const CT_WORKSHEET: &str =
"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml";
const CT_SHARED_STRINGS: &str =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml";
#[derive(Debug, Clone)]
pub struct Workbook {
sheets: Vec<Worksheet>,
shared_strings: SharedStringsTable,
}
impl Default for Workbook {
fn default() -> Self {
Self::new()
}
}
impl Workbook {
pub fn new() -> Self {
let mut wb = Workbook {
sheets: Vec::new(),
shared_strings: SharedStringsTable::new(),
};
wb.sheets.push(Worksheet::new("Sheet1"));
wb
}
pub fn with_no_sheets() -> Self {
Workbook {
sheets: Vec::new(),
shared_strings: SharedStringsTable::new(),
}
}
pub fn add_sheet(&mut self, name: &str) -> Result<usize> {
validate_sheet_name(name)?;
if self.sheets.iter().any(|s| s.name() == name) {
return Err(Error::InvalidSheetName(format!(
"duplicate sheet name: '{}'",
name
)));
}
self.sheets.push(Worksheet::new(name));
Ok(self.sheets.len() - 1)
}
pub fn remove_sheet(&mut self, index: usize) -> Result<Worksheet> {
if index >= self.sheets.len() {
return Err(Error::IndexOutOfRange(index));
}
Ok(self.sheets.remove(index))
}
pub fn sheet_count(&self) -> usize {
self.sheets.len()
}
pub fn get_sheet(&self, index: usize) -> Option<&Worksheet> {
self.sheets.get(index)
}
pub fn get_sheet_mut(&mut self, index: usize) -> Option<&mut Worksheet> {
self.sheets.get_mut(index)
}
pub fn get_sheet_index_by_name(&self, name: &str) -> Option<usize> {
self.sheets.iter().position(|s| s.name() == name)
}
pub fn get_sheet_by_name(&self, name: &str) -> Option<&Worksheet> {
self.get_sheet_index_by_name(name)
.and_then(|i| self.get_sheet(i))
}
pub fn get_sheet_by_name_mut(&mut self, name: &str) -> Option<&mut Worksheet> {
if let Some(i) = self.get_sheet_index_by_name(name) {
return self.get_sheet_mut(i);
}
None
}
pub fn iter_sheets(&self) -> impl Iterator<Item = &Worksheet> {
self.sheets.iter()
}
pub fn iter_sheets_mut(&mut self) -> impl Iterator<Item = &mut Worksheet> {
self.sheets.iter_mut()
}
pub fn shared_strings(&self) -> &SharedStringsTable {
&self.shared_strings
}
pub fn shared_strings_mut(&mut self) -> &mut SharedStringsTable {
&mut self.shared_strings
}
pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
let pkg = self.to_opc_package();
Ok(pkg.save(path)?)
}
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let pkg = OpcPackage::load(path.as_ref())?;
Self::from_opc(pkg)
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
let pkg = self.to_opc_package();
Ok(pkg.to_bytes()?)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
use std::io::{Cursor, Read};
let cursor = Cursor::new(bytes);
let mut zip = zip::ZipArchive::new(cursor)?;
let mut pkg = OpcPackage::new();
let mut ct_xml = String::new();
zip.by_name("[Content_Types].xml")?
.read_to_string(&mut ct_xml)?;
pkg.content_types = parse_content_types(&ct_xml)?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
let name = entry.name().to_string();
if name == "[Content_Types].xml" || entry.is_dir() {
continue;
}
let mut blob = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut blob)?;
let ct = if name.ends_with(".rels") {
opc::ct::RELATIONSHIPS.to_string()
} else {
opc::package::derive_content_type(&pkg.content_types, &format!("/{}", name))
};
let partname = format!("/{}", name);
let part = Part::new(PartName::from_unchecked(partname), ct, blob);
pkg.parts.insert(part.partname.as_str().to_string(), part);
}
Self::from_opc(pkg)
}
pub fn to_opc_package(&self) -> OpcPackage {
let mut pkg = OpcPackage::new();
let mut fresh_sst = SharedStringsTable::new();
let mut workbook_xml = String::with_capacity(256);
workbook_xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n");
workbook_xml.push_str(&format!(
"<workbook xmlns=\"{}\" xmlns:r=\"{}\">",
NS_SPREADSHEETML, NS_RELATIONSHIPS
));
workbook_xml.push_str("<sheets>");
let mut wb_rels = Relationships::new();
let mut rid_counter: u32 = 0;
for (idx, sheet) in self.sheets.iter().enumerate() {
let rid = format!("rId{}", idx + 1);
rid_counter = (idx + 1) as u32;
let sheet_partname = format!("/xl/worksheets/sheet{}.xml", idx + 1);
let sheet_xml = sheet.to_xml(&mut fresh_sst);
pkg.put_part(Part::new(
PartName::from_unchecked(sheet_partname.clone()),
CT_WORKSHEET.to_string(),
sheet_xml.into_bytes(),
));
workbook_xml.push_str(&format!(
"<sheet name=\"{}\" sheetId=\"{}\" r:id=\"{}\"/>",
xml_escape_attr(sheet.name()),
idx + 1,
rid
));
wb_rels
.add(Relationship::internal_str(
rid.clone(),
RelType::Other(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
.to_string(),
),
format!("worksheets/sheet{}.xml", idx + 1),
))
.ok();
}
workbook_xml.push_str("</sheets>");
workbook_xml.push_str("</workbook>");
if !fresh_sst.is_empty() {
rid_counter += 1;
let sst_rid = format!("rId{}", rid_counter);
let sst_xml = fresh_sst.to_xml();
pkg.put_part(Part::new(
PartName::from_unchecked("/xl/sharedStrings.xml".to_string()),
CT_SHARED_STRINGS.to_string(),
sst_xml.into_bytes(),
));
wb_rels
.add(Relationship::internal_str(
sst_rid,
RelType::Other(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings"
.to_string(),
),
"sharedStrings.xml",
))
.ok();
}
pkg.put_part(Part::new(
PartName::from_unchecked("/xl/workbook.xml".to_string()),
CT_WORKBOOK.to_string(),
workbook_xml.into_bytes(),
));
pkg.put_part(Part::new(
PartName::from_unchecked("/xl/_rels/workbook.xml.rels".to_string()),
opc::ct::RELATIONSHIPS.to_string(),
wb_rels.to_xml().into_bytes(),
));
let mut root_rels = Relationships::new();
root_rels
.add(Relationship::internal_str(
"rId1",
RelType::OfficeDocument,
"xl/workbook.xml",
))
.ok();
pkg.put_part(Part::new(
PartName::from_unchecked("/_rels/.rels".to_string()),
opc::ct::RELATIONSHIPS.to_string(),
root_rels.to_xml().into_bytes(),
));
pkg
}
pub fn from_opc(pkg: OpcPackage) -> Result<Self> {
let mut sst = SharedStringsTable::new();
if let Some(part) = pkg.get_part("/xl/sharedStrings.xml") {
let xml = part
.blob_text()
.ok_or_else(|| Error::Schema("sharedStrings.xml is not UTF-8".to_string()))?;
sst = SharedStringsTable::from_xml(&xml)?;
}
let wb_part = pkg
.get_part("/xl/workbook.xml")
.ok_or_else(|| Error::Schema("missing /xl/workbook.xml".to_string()))?;
let wb_xml = wb_part
.blob_text()
.ok_or_else(|| Error::Schema("workbook.xml is not UTF-8".to_string()))?;
let sheet_meta = parse_workbook_xml(&wb_xml)?;
let mut rid_to_target: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
if let Some(rels_part) = pkg.get_part("/xl/_rels/workbook.xml.rels") {
let rels_xml = rels_part
.blob_text()
.ok_or_else(|| Error::Schema("workbook.xml.rels is not UTF-8".to_string()))?;
let rels = Relationships::from_xml(&rels_xml)?;
for r in rels.iter() {
rid_to_target.insert(r.id.clone(), r.target.as_str().to_string());
}
}
let mut sheets = Vec::with_capacity(sheet_meta.len());
for meta in &sheet_meta {
let target = rid_to_target.get(&meta.rid).ok_or_else(|| {
Error::Schema(format!("workbook.xml.rels missing rid '{}'", meta.rid))
})?;
let partname = format!("/xl/{}", target);
let part = pkg
.get_part(&partname)
.ok_or_else(|| Error::Schema(format!("missing worksheet part: {}", partname)))?;
let xml = part
.blob_text()
.ok_or_else(|| Error::Schema(format!("{} is not UTF-8", partname)))?;
let mut ws = Worksheet::from_xml(&xml, &sst)?;
ws.set_name(&meta.name);
sheets.push(ws);
}
Ok(Workbook {
sheets,
shared_strings: sst,
})
}
}
struct SheetMeta {
name: String,
rid: String,
}
fn parse_workbook_xml(xml: &str) -> Result<Vec<SheetMeta>> {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
let mut metas = Vec::new();
let mut rd = Reader::from_str(xml);
let mut buf = Vec::new();
loop {
match rd.read_event_into(&mut buf) {
Ok(Event::Empty(e)) | Ok(Event::Start(e)) => {
if e.name().as_ref() == b"sheet" {
let mut name = None;
let mut rid = None;
for attr in e.attributes().flatten() {
let v = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.ok()
.map(|v| v.to_string())
.unwrap_or_default();
match attr.key.as_ref() {
b"name" => name = Some(v),
b"id" => rid = Some(v), _ => {}
}
}
for attr in e.attributes().flatten() {
let key_str = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
if key_str.ends_with(":id") {
rid = Some(
attr.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.ok()
.map(|v| v.to_string())
.unwrap_or_default(),
);
}
}
let (Some(name), Some(rid)) = (name, rid) else {
return Err(Error::Schema(
"workbook.xml <sheet> missing name or r:id".to_string(),
));
};
metas.push(SheetMeta { name, rid });
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(Error::Xml(format!("workbook.xml parse: {e}"))),
_ => {}
}
buf.clear();
}
Ok(metas)
}
fn parse_content_types(xml: &str) -> Result<opc::ContentTypes> {
let ct = opc::package::parse_content_types_public(xml)?;
Ok(ct)
}
fn validate_sheet_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::InvalidSheetName(
"sheet name cannot be empty".to_string(),
));
}
let char_count = name.chars().count();
if char_count > 31 {
return Err(Error::InvalidSheetName(format!(
"sheet name too long: {} chars (max 31)",
char_count
)));
}
for c in name.chars() {
if matches!(c, '[' | ']' | ':' | '*' | '?' | '/' | '\\') {
return Err(Error::InvalidSheetName(format!(
"sheet name contains illegal char '{}': '{}'",
c, name
)));
}
}
if name.starts_with('\'') || name.ends_with('\'') {
return Err(Error::InvalidSheetName(format!(
"sheet name cannot start or end with apostrophe: '{}'",
name
)));
}
Ok(())
}
fn xml_escape_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CellValue;
#[test]
fn new_has_default_sheet() {
let wb = Workbook::new();
assert_eq!(wb.sheet_count(), 1);
assert_eq!(wb.get_sheet(0).unwrap().name(), "Sheet1");
}
#[test]
fn add_sheet_increments_count() {
let mut wb = Workbook::new();
let idx = wb.add_sheet("Data").unwrap();
assert_eq!(idx, 1);
assert_eq!(wb.sheet_count(), 2);
assert_eq!(wb.get_sheet(1).unwrap().name(), "Data");
}
#[test]
fn add_sheet_duplicate_name_errors() {
let mut wb = Workbook::new();
assert!(wb.add_sheet("Sheet1").is_err());
}
#[test]
fn add_sheet_invalid_name_errors() {
let mut wb = Workbook::new();
assert!(wb.add_sheet("").is_err());
assert!(wb.add_sheet("a/b").is_err());
assert!(wb.add_sheet("a:b").is_err());
assert!(wb.add_sheet("a*b").is_err());
assert!(wb.add_sheet("a?b").is_err());
assert!(wb.add_sheet("a[b]b").is_err());
assert!(wb.add_sheet("a\\b").is_err());
assert!(wb.add_sheet("'quoted'").is_err());
let long_name = "a".repeat(32);
assert!(wb.add_sheet(&long_name).is_err());
}
#[test]
fn add_sheet_max_length_ok() {
let mut wb = Workbook::new();
let name_31 = "a".repeat(31);
assert!(wb.add_sheet(&name_31).is_ok());
}
#[test]
fn remove_sheet() {
let mut wb = Workbook::new();
wb.add_sheet("Data").unwrap();
let removed = wb.remove_sheet(0).unwrap();
assert_eq!(removed.name(), "Sheet1");
assert_eq!(wb.sheet_count(), 1);
assert_eq!(wb.get_sheet(0).unwrap().name(), "Data");
}
#[test]
fn remove_sheet_out_of_range() {
let mut wb = Workbook::new();
assert!(wb.remove_sheet(5).is_err());
}
#[test]
fn get_sheet_by_name() {
let mut wb = Workbook::new();
wb.add_sheet("Data").unwrap();
assert!(wb.get_sheet_by_name("Sheet1").is_some());
assert!(wb.get_sheet_by_name("Data").is_some());
assert!(wb.get_sheet_by_name("NonExist").is_none());
assert_eq!(wb.get_sheet_index_by_name("Data"), Some(1));
}
#[test]
fn iter_sheets() {
let mut wb = Workbook::new();
wb.add_sheet("Data").unwrap();
let names: Vec<&str> = wb.iter_sheets().map(|s| s.name()).collect();
assert_eq!(names, vec!["Sheet1", "Data"]);
}
#[test]
fn set_cell_through_workbook() {
let mut wb = Workbook::new();
let sheet = wb.get_sheet_mut(0).unwrap();
sheet.set_cell("A1", CellValue::Number(42.0)).unwrap();
assert_eq!(
sheet.get_cell("A1").unwrap().unwrap().value(),
&CellValue::Number(42.0)
);
}
#[test]
fn to_opc_package_has_required_parts() {
let mut wb = Workbook::new();
{
let s = wb.get_sheet_mut(0).unwrap();
s.set_cell("A1", CellValue::Number(1.0)).unwrap();
s.set_cell("A2", CellValue::String("Hello".to_string()))
.unwrap();
}
let pkg = wb.to_opc_package();
assert!(pkg.get_part("/xl/workbook.xml").is_some());
assert!(pkg.get_part("/xl/worksheets/sheet1.xml").is_some());
assert!(pkg.get_part("/xl/sharedStrings.xml").is_some());
assert!(pkg.get_part("/xl/_rels/workbook.xml.rels").is_some());
assert!(pkg.get_part("/_rels/.rels").is_some());
assert!(pkg.content_types.has_override("/xl/workbook.xml"));
assert!(pkg.content_types.has_override("/xl/worksheets/sheet1.xml"));
assert!(pkg.content_types.has_override("/xl/sharedStrings.xml"));
}
#[test]
fn to_opc_package_no_shared_strings_when_empty() {
let wb = Workbook::new();
let pkg = wb.to_opc_package();
assert!(pkg.get_part("/xl/sharedStrings.xml").is_none());
}
#[test]
fn round_trip_basic() {
let mut wb = Workbook::new();
{
let s = wb.get_sheet_mut(0).unwrap();
s.set_cell("A1", CellValue::Number(1.0)).unwrap();
s.set_cell("B1", CellValue::Number(2.0)).unwrap();
s.set_cell("A2", CellValue::String("Hello".to_string()))
.unwrap();
s.set_cell("B2", CellValue::String("World".to_string()))
.unwrap();
s.set_cell("C3", CellValue::Boolean(true)).unwrap();
}
let bytes = wb.to_bytes().unwrap();
let wb2 = Workbook::from_bytes(&bytes).unwrap();
assert_eq!(wb2.sheet_count(), 1);
let s2 = wb2.get_sheet(0).unwrap();
assert_eq!(
s2.get_cell("A1").unwrap().unwrap().value(),
&CellValue::Number(1.0)
);
assert_eq!(
s2.get_cell("A2").unwrap().unwrap().value(),
&CellValue::String("Hello".to_string())
);
assert_eq!(
s2.get_cell("B2").unwrap().unwrap().value(),
&CellValue::String("World".to_string())
);
assert_eq!(
s2.get_cell("C3").unwrap().unwrap().value(),
&CellValue::Boolean(true)
);
}
#[test]
fn round_trip_multiple_sheets() {
let mut wb = Workbook::new();
wb.add_sheet("Data").unwrap();
{
let s0 = wb.get_sheet_mut(0).unwrap();
s0.set_cell("A1", CellValue::String("Sheet1 content".to_string()))
.unwrap();
}
{
let s1 = wb.get_sheet_mut(1).unwrap();
s1.set_cell("A1", CellValue::Number(100.0)).unwrap();
s1.set_cell("B2", CellValue::String("Data content".to_string()))
.unwrap();
}
let bytes = wb.to_bytes().unwrap();
let wb2 = Workbook::from_bytes(&bytes).unwrap();
assert_eq!(wb2.sheet_count(), 2);
assert_eq!(wb2.get_sheet(0).unwrap().name(), "Sheet1");
assert_eq!(wb2.get_sheet(1).unwrap().name(), "Data");
assert_eq!(
wb2.get_sheet(0)
.unwrap()
.get_cell("A1")
.unwrap()
.unwrap()
.value(),
&CellValue::String("Sheet1 content".to_string())
);
assert_eq!(
wb2.get_sheet(1)
.unwrap()
.get_cell("B2")
.unwrap()
.unwrap()
.value(),
&CellValue::String("Data content".to_string())
);
}
#[test]
fn round_trip_special_chars_in_string() {
let mut wb = Workbook::new();
let text = "Hello <World> & \"Friends\"".to_string();
{
let s = wb.get_sheet_mut(0).unwrap();
s.set_cell("A1", CellValue::String(text.clone())).unwrap();
}
let bytes = wb.to_bytes().unwrap();
let wb2 = Workbook::from_bytes(&bytes).unwrap();
assert_eq!(
wb2.get_sheet(0)
.unwrap()
.get_cell("A1")
.unwrap()
.unwrap()
.value(),
&CellValue::String(text)
);
}
#[test]
fn round_trip_save_load_file() {
let mut wb = Workbook::new();
{
let s = wb.get_sheet_mut(0).unwrap();
s.set_cell("A1", CellValue::Number(42.0)).unwrap();
s.set_cell("A2", CellValue::String("File test".to_string()))
.unwrap();
}
let path = std::env::temp_dir().join("xlsx_rs_round_trip_test.xlsx");
wb.save(&path).unwrap();
let wb2 = Workbook::load(&path).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(wb2.sheet_count(), 1);
let s2 = wb2.get_sheet(0).unwrap();
assert_eq!(
s2.get_cell("A1").unwrap().unwrap().value(),
&CellValue::Number(42.0)
);
assert_eq!(
s2.get_cell("A2").unwrap().unwrap().value(),
&CellValue::String("File test".to_string())
);
}
#[test]
fn validate_sheet_name_rules() {
assert!(validate_sheet_name("OK").is_ok());
assert!(validate_sheet_name("Sheet 1").is_ok());
assert!(validate_sheet_name("中文表").is_ok());
assert!(validate_sheet_name("").is_err());
assert!(validate_sheet_name("a[b").is_err());
assert!(validate_sheet_name(&"x".repeat(32)).is_err());
assert!(validate_sheet_name(&"x".repeat(31)).is_ok());
}
}