use chrono::NaiveDate;
use docparser_core::error::{DocError, Result};
use docparser_core::plugin::{ParseCapability, RawDoc, RawPage};
use quick_xml::events::Event;
use quick_xml::Reader;
use crate::zip_read_str;
pub struct XlsxRawBackend;
impl ParseCapability for XlsxRawBackend {
fn id(&self) -> &str {
"office-raw"
}
fn formats(&self) -> &[&str] {
&["xlsx"]
}
fn parse(&self, path: &str) -> Result<RawDoc> {
let file = std::fs::File::open(path)
.map_err(|e| DocError::corrupt(format!("cannot open {path}: {e}")))?;
let mut zip = zip::ZipArchive::new(file)
.map_err(|e| DocError::corrupt(format!("ZIP open failed: {e}")))?;
let wb_xml = zip_read_str(&mut zip, "xl/workbook.xml")?
.ok_or_else(|| DocError::corrupt("missing xl/workbook.xml"))?;
let sheet_names = parse_sheet_names(&wb_xml)?;
let sst = SharedStrings::new(&mut zip)?;
let styles = Styles::new(&mut zip)?;
let mut pages = Vec::new();
for (idx, (_rid, name)) in sheet_names.iter().enumerate() {
let sheet_path = format!("xl/worksheets/sheet{}.xml", idx + 1);
let xml = zip_read_str(&mut zip, &sheet_path)?.unwrap_or_default();
let rows = parse_sheet_rows(&xml, &sst, &styles)?;
let text = rows
.iter()
.map(|r| r.join(","))
.collect::<Vec<_>>()
.join("\n");
pages.push(RawPage {
name: Some(name.clone()),
items: vec![docparser_core::plugin::RawTextItem { text, bbox: None }],
..Default::default()
});
}
Ok(RawDoc {
pages,
..Default::default()
})
}
}
struct SharedStrings {
items: Vec<String>,
}
impl SharedStrings {
fn new(zip: &mut zip::ZipArchive<std::fs::File>) -> Result<Self> {
let xml = match zip_read_str(zip, "xl/sharedStrings.xml")? {
Some(s) => s,
None => {
return Ok(SharedStrings { items: Vec::new() });
}
};
let items = parse_sst_items(&xml)?;
Ok(SharedStrings { items })
}
fn get(&self, idx: usize) -> Option<&str> {
self.items.get(idx).map(|s| s.as_str())
}
}
fn parse_sst_items(xml: &str) -> Result<Vec<String>> {
let mut reader = Reader::from_str(xml);
let mut buf = Vec::new();
let mut items = Vec::new();
let mut in_t = false;
let mut text = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => match e.name().local_name().as_ref() {
crate::tags::SI => {
text.clear();
}
crate::tags::T => {
in_t = true;
}
_ => {}
},
Ok(Event::End(ref e)) => match e.name().local_name().as_ref() {
crate::tags::SI => {
items.push(text.trim().to_string());
text.clear();
}
crate::tags::T => {
in_t = false;
}
crate::tags::SST => {
break;
}
_ => {}
},
Ok(Event::Text(ref e)) => {
if in_t {
let raw = e.xml10_content();
if let Ok(t) = quick_xml::escape::unescape(&raw) {
text.push_str(&t);
}
}
}
Ok(Event::GeneralRef(ref e)) => {
if in_t {
crate::push_general_ref(&mut text, e);
}
}
Ok(Event::CData(ref e)) => {
if in_t {
text.push_str(e.as_ref());
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(DocError::corrupt(format!("SST parse error: {e}"))),
_ => {}
}
buf.clear();
}
Ok(items)
}
#[derive(Default)]
struct Styles {
num_fmts: std::collections::HashMap<usize, String>,
xf_num_fmt_ids: Vec<usize>,
}
impl Styles {
fn new(zip: &mut zip::ZipArchive<std::fs::File>) -> Result<Self> {
let xml = match zip_read_str(zip, "xl/styles.xml")? {
Some(s) => s,
None => return Ok(Styles::default()),
};
let mut s = Styles::default();
s.parse(&xml)?;
Ok(s)
}
fn parse(&mut self, xml: &str) -> Result<()> {
let mut reader = Reader::from_str(xml);
let mut buf = Vec::new();
let mut _in_num_fmts = false;
let mut _in_cell_xfs = false;
let mut _num_fmt_id_val: Option<usize> = None;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => match e.name().local_name().as_ref() {
crate::tags::NUM_FMT => {
_num_fmt_id_val = None;
let mut fmt_id = 0usize;
let mut fmt_code = String::new();
for attr in e.attributes().filter_map(|a| a.ok()) {
match attr.key.as_ref() {
"numFmtId" => fmt_id = attr.value.parse().unwrap_or(0),
"formatCode" => fmt_code = attr.value.to_string(),
_ => {}
}
}
if fmt_id >= 164 {
self.num_fmts.insert(fmt_id, fmt_code);
}
}
crate::tags::XF => {
let mut num_fmt_id = 0usize;
for attr in e.attributes().filter_map(|a| a.ok()) {
if attr.key.as_ref() == "numFmtId" {
num_fmt_id = attr.value.parse().unwrap_or(0);
}
}
self.xf_num_fmt_ids.push(num_fmt_id);
}
_ => {}
},
Ok(Event::Empty(ref e)) => match e.name().local_name().as_ref() {
crate::tags::NUM_FMT => {
let mut fmt_id = 0usize;
let mut fmt_code = String::new();
for attr in e.attributes().filter_map(|a| a.ok()) {
match attr.key.as_ref() {
"numFmtId" => fmt_id = attr.value.parse().unwrap_or(0),
"formatCode" => fmt_code = attr.value.to_string(),
_ => {}
}
}
if fmt_id >= 164 {
self.num_fmts.insert(fmt_id, fmt_code);
}
}
crate::tags::XF => {
let mut num_fmt_id = 0usize;
for attr in e.attributes().filter_map(|a| a.ok()) {
if attr.key.as_ref() == "numFmtId" {
num_fmt_id = attr.value.parse().unwrap_or(0);
}
}
self.xf_num_fmt_ids.push(num_fmt_id);
}
_ => {}
},
Ok(Event::End(ref e)) => {
if e.name().local_name().as_ref() == crate::tags::CELL_XFS {
_in_cell_xfs = false;
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(DocError::corrupt(format!("Styles parse error: {e}"))),
_ => {}
}
buf.clear();
}
Ok(())
}
fn is_date_format(&self, xf_idx: Option<usize>) -> bool {
let num_fmt_id = xf_idx
.and_then(|i| self.xf_num_fmt_ids.get(i))
.copied()
.unwrap_or(0);
matches!(num_fmt_id,
14..=22 | 27..=36 | 45..=47 | 50..=58 | 71..=81
) || self.num_fmts.contains_key(&num_fmt_id)
}
fn code_for(&self, xf_idx: Option<usize>) -> Option<&str> {
let num_fmt_id = xf_idx
.and_then(|i| self.xf_num_fmt_ids.get(i))
.copied()
.unwrap_or(0);
if num_fmt_id == 0 {
return None;
}
if let Some(code) = self.num_fmts.get(&num_fmt_id) {
return Some(code.as_str());
}
BUILTIN_FORMATS
.iter()
.find(|(id, _)| *id == num_fmt_id)
.map(|(_, code)| *code)
}
}
const BUILTIN_FORMATS: &[(usize, &str)] = &[
(14, "mm-dd-yy"),
(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/yy h:mm"),
(45, "mm:ss"),
(46, "[h]:mm:ss"),
(47, "mmss.0"),
];
fn ole_serial_to_date(serial: f64) -> Option<NaiveDate> {
let epoch = NaiveDate::from_ymd_opt(1899, 12, 30)?;
let mut days = serial as i64;
if serial > 0.0 && serial < 60.0 {
days += 1;
}
epoch.checked_add_signed(chrono::Duration::days(days))
}
fn fmt_to_strftime(fmt: &str) -> String {
let is_12h = fmt.contains("AM/PM");
let s = fmt
.replace("yyyy", "%Y")
.replace("yy", "%y")
.replace("mmm", "%b")
.replace("dd", "%d");
let s = s.replace(":mm", ":M_M").replace("mm:", "M_M:");
let s = s
.replace("mm", "=*=")
.replace("m", "=*=")
.replace("=*=", "%m");
let s = s.replace("M_M", "%M");
let s = s.replace("ss", "%S").replace("AM/PM", "%p");
if is_12h {
s.replace("h", "%I")
} else {
s.replace("h", "%H")
}
}
fn format_excel_date(serial: f64, format_code: &str) -> String {
if let Some(date) = ole_serial_to_date(serial) {
let strfmt = fmt_to_strftime(format_code);
date.format(&strfmt).to_string()
} else {
format!("num:{}", serial)
}
}
fn format_number(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return String::new();
}
if let Ok(num) = trimmed.parse::<f64>() {
if num.fract() == 0.0 && num.is_finite() {
format!("{}", num as i64)
} else {
format!("{}", num)
}
} else {
trimmed.to_string()
}
}
fn parse_sheet_names(xml: &str) -> Result<Vec<(String, String)>> {
let mut reader = Reader::from_str(xml);
let mut buf = Vec::new();
let mut sheets = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
if e.name().local_name().as_ref() == "sheet" {
let mut rid = String::new();
let mut name = String::new();
for attr in e.attributes().filter_map(|a| a.ok()) {
match attr.key.as_ref() {
"r:id" => rid = attr.value.to_string(),
"name" => name = attr.value.to_string(),
"id" if rid.is_empty() => rid = attr.value.to_string(),
_ => {}
}
}
if !name.is_empty() {
sheets.push((rid, name));
}
}
}
Ok(Event::End(ref e)) => {
if e.name().local_name().as_ref() == "workbook" {
break;
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(DocError::corrupt(format!("workbook parse error: {e}"))),
_ => {}
}
buf.clear();
}
Ok(sheets)
}
fn parse_sheet_rows(xml: &str, sst: &SharedStrings, styles: &Styles) -> Result<Vec<Vec<String>>> {
let mut reader = Reader::from_str(xml);
let mut buf = Vec::new();
let mut rows: Vec<Vec<String>> = Vec::new();
let mut current_row: Vec<(usize, String)> = Vec::new(); let mut cell_ref = String::new();
let mut cell_type = 's'; let mut cell_style: Option<usize> = None;
let mut cell_value = String::new();
let mut in_v = false;
let mut in_is = false;
let mut in_is_t = false;
fn col_from_ref(r: &str) -> usize {
let letters: String = r.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
let mut col = 0usize;
for c in letters.chars() {
col = col * 26 + (c.to_ascii_uppercase() as u8 - b'A' + 1) as usize;
}
if col > 0 {
col - 1
} else {
0
}
}
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
match e.name().local_name().as_ref() {
crate::tags::C => {
cell_type = 's';
cell_style = None;
cell_value.clear();
cell_ref.clear();
for attr in e.attributes().filter_map(|a| a.ok()) {
match attr.key.as_ref() {
"r" => cell_ref = attr.value.to_string(),
"t" => {
cell_type = match attr.value.as_ref() {
"s" => 's', "n" => 'n', "b" => 'b', "e" => 'e', "str" | "inlineStr" => 'i', _ => 's',
};
}
"s" => cell_style = attr.value.parse().ok(),
_ => {}
}
}
}
crate::tags::V => {
in_v = true;
}
crate::tags::IS => {
in_is = true;
}
crate::tags::T if in_is => in_is_t = true,
_ => {}
}
}
Ok(Event::End(ref e)) => {
match e.name().local_name().as_ref() {
crate::tags::C => {
let text = match cell_type {
's' => {
let idx: usize = cell_value.trim().parse().unwrap_or(0);
sst.get(idx).unwrap_or("").to_string()
}
'i' => {
cell_value.trim().to_string()
}
'b' => {
if cell_value.trim() == "1" {
"True".to_string()
} else {
"False".to_string()
}
}
'e' => {
format!("ERROR:{}", cell_value.trim())
}
_ => {
let val = cell_value.trim();
if val.is_empty() {
String::new()
} else if styles.is_date_format(cell_style) {
if let Ok(num) = val.parse::<f64>() {
if let Some(code) = styles.code_for(cell_style) {
format_excel_date(num, code)
} else {
format!("num:{}", val)
}
} else {
format!("num:{}", val)
}
} else {
format_number(val)
}
}
};
let col = col_from_ref(&cell_ref);
current_row.push((col, text));
in_v = false;
in_is = false;
in_is_t = false;
}
crate::tags::ROW => {
current_row.sort_by_key(|(col, _)| *col);
let row_str: Vec<String> =
current_row.iter().map(|(_, t)| t.clone()).collect();
rows.push(row_str);
current_row.clear();
}
crate::tags::V => {
in_v = false;
}
crate::tags::IS => {
in_is = false;
}
crate::tags::T if in_is_t => in_is_t = false,
_ => {}
}
}
Ok(Event::Text(ref e)) => {
if in_v || in_is_t {
let raw = e.xml10_content();
if let Ok(t) = quick_xml::escape::unescape(&raw) {
cell_value.push_str(&t);
}
}
}
Ok(Event::GeneralRef(ref e)) => {
if in_v || in_is_t {
crate::push_general_ref(&mut cell_value, e);
}
}
Ok(Event::CData(ref e)) => {
if in_v || in_is_t {
cell_value.push_str(e.as_ref());
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(DocError::corrupt(format!("sheet parse error: {e}"))),
_ => {}
}
buf.clear();
}
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sst_empty() {
let sst = SharedStrings { items: vec![] };
assert_eq!(sst.get(0), None);
}
#[test]
fn test_sst_parse() {
let xml = r#"<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="2">
<si><t>Hello</t></si>
<si><t>World</t></si>
</sst>"#;
let items = parse_sst_items(xml).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0], "Hello");
assert_eq!(items[1], "World");
}
#[test]
fn test_sheet_names() {
let xml = r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Sheet1" r:id="rId1"/>
<sheet name="Sheet2" r:id="rId2"/>
</sheets>
</workbook>"#;
let sheets = parse_sheet_names(xml).unwrap();
assert_eq!(sheets.len(), 2);
assert_eq!(sheets[0].1, "Sheet1");
}
#[test]
fn smoke_parse_minimal_xlsx_zip() {
use std::io::Write;
const WORKBOOK: &str = concat!(
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" "#,
r#"xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
r#"<sheets><sheet name="S1" sheetId="1" r:id="rId1"/></sheets></workbook>"#
);
const SST: &str = concat!(
r#"<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">"#,
r#"<si><t>甲</t></si><si><t>乙 & 丙</t></si></sst>"#
);
const SHEET: &str = concat!(
r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">"#,
r#"<sheetData><row r="1">"#,
r#"<c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c>"#,
r#"</row></sheetData></worksheet>"#
);
let dir = std::env::temp_dir().join("docparser_office_xlsx_smoke");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("min.xlsx");
{
let f = std::fs::File::create(&path).unwrap();
let mut zw = zip::ZipWriter::new(f);
let opts = zip::write::SimpleFileOptions::default();
for (name, body) in [
("xl/workbook.xml", WORKBOOK),
("xl/sharedStrings.xml", SST),
("xl/worksheets/sheet1.xml", SHEET),
] {
zw.start_file(name, opts).unwrap();
zw.write_all(body.as_bytes()).unwrap();
}
zw.finish().unwrap();
}
let raw = XlsxRawBackend.parse(path.to_str().unwrap()).unwrap();
std::fs::remove_file(&path).ok();
assert_eq!(raw.pages.len(), 1);
assert_eq!(raw.pages[0].name.as_deref(), Some("S1"));
assert_eq!(raw.pages[0].items.len(), 1);
assert_eq!(raw.pages[0].items[0].text, "甲,乙 & 丙");
}
#[test]
fn test_col_ref() {
fn col(r: &str) -> usize {
let letters: String = r.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
let mut col = 0usize;
for c in letters.chars() {
col = col * 26 + (c.to_ascii_uppercase() as u8 - b'A' + 1) as usize;
}
if col > 0 {
col - 1
} else {
0
}
}
assert_eq!(col("A1"), 0);
assert_eq!(col("Z1"), 25);
assert_eq!(col("AA1"), 26);
assert_eq!(col("AB1"), 27);
}
}