diff --git a/examples/README.md b/examples/README.md index 89b109f8..91ea622b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,8 @@ This directory contains some example of Calamine usage. - `read_hyperlinks.rs`: Reads the hyperlinks defined in an XLSX worksheet, either by sheet name or by sheet index. - `read_picture_data.rs`: Reads pictures and their metadata from an XLSX file. +- `read_properties.rs`: Reads the workbook properties (core and extended) + from an XLSX or XLSB file. ### Serialization examples diff --git a/examples/read_properties.rs b/examples/read_properties.rs new file mode 100644 index 00000000..49d0727d --- /dev/null +++ b/examples/read_properties.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// +// Copyright 2016-2026, Johann Tuffe. + +//! Demonstrates reading workbook properties from an XLSX or XLSB file. +//! +//! This example reads the core and extended document properties (such as +//! creator, application, and company) from a workbook. +//! +//! Run the example like this: +//! +//! ```text +//! $ cargo run -q --example read_properties -- tests/issues.xlsx +//! +//! Core / Extended properties: +//! creator: Some("Johann Tuffe") +//! last_modified_by: Some("Johann Tuffe") +//! application: Some("Microsoft Excel") +//! company: Some("SOCIETE GENERALE") +//! ``` + +use calamine::open_workbook_auto; +use std::env; +use std::process::exit; + +fn main() { + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + exit(1); + } + + let path = &args[1]; + let mut excel = match open_workbook_auto(path) { + Ok(excel) => excel, + Err(e) => { + eprintln!("Cannot open {path}: {e}"); + exit(1); + } + }; + + let props = match excel.workbook_properties() { + Ok(props) => props, + Err(e) => { + eprintln!("Cannot read workbook properties from {path}: {e}"); + exit(1); + } + }; + + println!("Core / Extended properties:"); + println!(" creator: {:?}", props.creator); + println!(" last_modified_by: {:?}", props.last_modified_by); + println!(" created: {:?}", props.created); + println!(" modified: {:?}", props.modified); + println!(" title: {:?}", props.title); + println!(" application: {:?}", props.application); + println!(" app_version: {:?}", props.app_version); + println!(" company: {:?}", props.company); + println!(" template: {:?}", props.template); + println!(" manager: {:?}", props.manager); +} diff --git a/src/auto.rs b/src/auto.rs index a380b26f..b8612fd1 100644 --- a/src/auto.rs +++ b/src/auto.rs @@ -10,7 +10,7 @@ use crate::vba::VbaProject; use crate::Picture; use crate::{ open_workbook, open_workbook_from_rs, Data, DataRef, HeaderRow, Metadata, Ods, Range, Reader, - ReaderRef, Xls, Xlsb, Xlsx, + ReaderRef, WorkbookProperties, Xls, Xlsb, Xlsx, }; use std::fs::File; @@ -29,6 +29,19 @@ pub enum Sheets { Ods(Ods), } +impl Sheets { + /// Get workbook document properties for formats that support them. + pub fn workbook_properties(&mut self) -> Result<&WorkbookProperties, Error> { + match self { + Sheets::Xlsx(e) => e.workbook_properties().map_err(Error::Xlsx), + Sheets::Xlsb(e) => e.workbook_properties().map_err(Error::Xlsb), + Sheets::Xls(_) | Sheets::Ods(_) => Err(Error::Msg( + "Workbook properties are not supported for this format", + )), + } + } +} + /// Opens a workbook and define the file type at runtime. /// /// Whenever possible use the statically known `open_workbook` function instead diff --git a/src/lib.rs b/src/lib.rs index e869c490..294cd695 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -228,6 +228,68 @@ pub struct Metadata { names: Vec<(String, String)>, } +/// Workbook document properties. +/// +/// Depending on the file format, these fields may be read from workbook-level +/// document property parts. +/// +/// Most fields are optional because they depend on the file format and +/// whether the producing application wrote them. +#[derive(Debug, Default, Clone, PartialEq)] +#[non_exhaustive] +pub struct WorkbookProperties { + /// Core property: creator (dc:creator). + pub creator: Option, + + /// Core property: last modifier (cp:lastModifiedBy). + pub last_modified_by: Option, + + /// Core property: creation date (dcterms:created). + pub created: Option, + + /// Core property: modification date (dcterms:modified). + pub modified: Option, + + /// Core property: title (dc:title). + pub title: Option, + + /// Core property: subject (dc:subject). + pub subject: Option, + + /// Core property: description/comments (dc:description). + pub description: Option, + + /// Core property: keywords (dc:keywords). + pub keywords: Option, + + /// Core property: category (dc:category). + pub category: Option, + + /// Core property: content status (cp:contentStatus). + pub content_status: Option, + + /// Core property: revision (cp:revision). + pub revision: Option, + + /// Core property: version (cp:version). + pub version: Option, + + /// Extended property: application name (ap:Application). + pub application: Option, + + /// Extended property: application version (ap:AppVersion). + pub app_version: Option, + + /// Extended property: company (ap:Company). + pub company: Option, + + /// Extended property: template (ap:Template). + pub template: Option, + + /// Extended property: manager (ap:Manager). + pub manager: Option, +} + /// Type of sheet. /// /// Only Excel formats support this. Default value for ODS is diff --git a/src/xlsb/mod.rs b/src/xlsb/mod.rs index cab7ab54..5d945b1c 100644 --- a/src/xlsb/mod.rs +++ b/src/xlsb/mod.rs @@ -27,8 +27,10 @@ use crate::utils::{ read_usize, }; use crate::vba::VbaProject; +use crate::xlsx::{read_app_properties, read_core_properties}; use crate::{ Cell, Data, HeaderRow, Metadata, Range, Reader, ReaderRef, Sheet, SheetType, SheetVisible, + WorkbookProperties, }; /// A Xlsb specific error @@ -160,6 +162,8 @@ pub struct Xlsb { formats: Vec, is_1904: bool, metadata: Metadata, + /// Workbook document properties, parsed lazily on first access. + workbook_properties: Option>, #[cfg(feature = "picture")] pictures: Option)>>, options: XlsbOptions, @@ -438,6 +442,24 @@ impl Xlsb { ) } + /// Get workbook document properties. + /// + /// Missing fields are returned as `None`. + pub fn workbook_properties(&mut self) -> Result<&WorkbookProperties, XlsbError> { + if self.workbook_properties.is_none() { + let mut props = WorkbookProperties::default(); + read_core_properties(&mut self.zip, &mut props, &self.zip_path_cache) + .map_err(xlsx_error_to_xlsb)?; + read_app_properties(&mut self.zip, &mut props, &self.zip_path_cache) + .map_err(xlsx_error_to_xlsb)?; + self.workbook_properties = Some(Box::new(props)); + } + Ok(self + .workbook_properties + .as_deref() + .expect("workbook properties are initialized above")) + } + #[cfg(feature = "picture")] fn read_pictures(&mut self) -> Result<(), XlsbError> { let mut pics = Vec::new(); @@ -483,6 +505,7 @@ impl Reader for Xlsb { formats: Vec::new(), is_1904: false, metadata: Metadata::default(), + workbook_properties: None, #[cfg(feature = "picture")] pictures: None, options: XlsbOptions::default(), @@ -1053,3 +1076,16 @@ fn check_for_password_protected(reader: &mut RS) -> Result<(), Ok(()) } + +fn xlsx_error_to_xlsb(e: crate::xlsx::XlsxError) -> XlsbError { + use crate::xlsx::XlsxError; + match e { + XlsxError::Io(e) => XlsbError::Io(e), + XlsxError::Zip(e) => XlsbError::Zip(e), + XlsxError::Vba(e) => XlsbError::Vba(e), + XlsxError::Xml(e) => XlsbError::Xml(e), + XlsxError::XmlAttr(e) => XlsbError::XmlAttr(e), + XlsxError::Encoding(e) => XlsbError::Xml(e.into()), + e => XlsbError::FileNotFound(e.to_string()), + } +} diff --git a/src/xlsx/mod.rs b/src/xlsx/mod.rs index bd3854ec..65695342 100644 --- a/src/xlsx/mod.rs +++ b/src/xlsx/mod.rs @@ -32,7 +32,7 @@ use crate::vba::VbaProject; use crate::Picture; use crate::{ Cell, CellErrorType, Data, Dimensions, HeaderRow, Metadata, Range, Reader, ReaderRef, Sheet, - SheetType, SheetVisible, Table, + SheetType, SheetVisible, Table, WorkbookProperties, }; pub use cells_reader::{ XlsxCellFormula, XlsxCellFormulaMetadataRecord, XlsxCellReader, XlsxFormulaMetadata, @@ -265,6 +265,8 @@ pub struct Xlsx { is_1904: bool, /// Metadata metadata: Metadata, + /// Workbook document properties, parsed lazily on first access. + workbook_properties: Option>, /// Pictures #[cfg(feature = "picture")] pictures: Option>, @@ -547,6 +549,22 @@ impl Xlsx { Ok(()) } + /// Get workbook document properties. + /// + /// Missing fields are returned as `None`. + pub fn workbook_properties(&mut self) -> Result<&WorkbookProperties, XlsxError> { + if self.workbook_properties.is_none() { + let mut props = WorkbookProperties::default(); + read_core_properties(&mut self.zip, &mut props, &self.zip_path_cache)?; + read_app_properties(&mut self.zip, &mut props, &self.zip_path_cache)?; + self.workbook_properties = Some(Box::new(props)); + } + Ok(self + .workbook_properties + .as_deref() + .expect("workbook properties are initialized above")) + } + fn read_relationships(&mut self) -> Result, (String, String)>, XlsxError> { let rels_path = format!("{}_rels/workbook.xml.rels", self.xl_path); let mut xml = match xml_reader(&mut self.zip, rels_path.as_ref(), &self.zip_path_cache) { @@ -2552,6 +2570,7 @@ impl Reader for Xlsx { sheets: Vec::new(), tables: None, metadata: Metadata::default(), + workbook_properties: None, #[cfg(feature = "picture")] pictures: None, merged_regions: None, @@ -2751,7 +2770,7 @@ fn col_from_cell_ref(cell_ref: &[u8]) -> u32 { col.saturating_sub(1) } -fn xml_reader<'a, RS: Read + Seek>( +pub(crate) fn xml_reader<'a, RS: Read + Seek>( zip: &'a mut ZipArchive, path: &str, cache: &HashMap, @@ -4568,6 +4587,7 @@ mod tests { formats: vec![], is_1904: false, metadata: Metadata::default(), + workbook_properties: None, #[cfg(feature = "picture")] pictures: None, merged_regions: None, @@ -4582,3 +4602,155 @@ mod tests { assert_eq!("String 3", &xlsx.strings[2]); } } + +/// Read the package core properties (`docProps/core.xml`). +pub(crate) fn read_core_properties( + zip: &mut ZipArchive, + props: &mut WorkbookProperties, + cache: &HashMap, +) -> Result<(), XlsxError> { + let Some(xml) = xml_reader(zip, "docProps/core.xml", cache) else { + return Ok(()); + }; + let mut xml = xml?; + + let mut buf = Vec::with_capacity(256); + let mut current = None; + + loop { + buf.clear(); + match xml.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let name = e.local_name(); + let name_ref = name.as_ref(); + current = match name_ref { + b"creator" => Some(DocProperty::Creator), + b"lastModifiedBy" => Some(DocProperty::LastModifiedBy), + b"created" => Some(DocProperty::Created), + b"modified" => Some(DocProperty::Modified), + b"title" => Some(DocProperty::Title), + b"subject" => Some(DocProperty::Subject), + b"description" => Some(DocProperty::Description), + b"keywords" => Some(DocProperty::Keywords), + b"category" => Some(DocProperty::Category), + b"contentStatus" => Some(DocProperty::ContentStatus), + b"revision" => Some(DocProperty::Revision), + b"version" => Some(DocProperty::Version), + b"Application" => Some(DocProperty::Application), + b"AppVersion" => Some(DocProperty::AppVersion), + b"Company" => Some(DocProperty::Company), + b"Template" => Some(DocProperty::Template), + b"Manager" => Some(DocProperty::Manager), + _ => None, + }; + } + Ok(Event::Text(t)) => { + if let Some(field) = current { + let value = t.xml10_content()?; + match field { + DocProperty::Creator => props.creator = Some(value.into_owned()), + DocProperty::LastModifiedBy => { + props.last_modified_by = Some(value.into_owned()); + } + DocProperty::Created => props.created = Some(value.into_owned()), + DocProperty::Modified => props.modified = Some(value.into_owned()), + DocProperty::Title => props.title = Some(value.into_owned()), + DocProperty::Subject => props.subject = Some(value.into_owned()), + DocProperty::Description => props.description = Some(value.into_owned()), + DocProperty::Keywords => props.keywords = Some(value.into_owned()), + DocProperty::Category => props.category = Some(value.into_owned()), + DocProperty::ContentStatus => { + props.content_status = Some(value.into_owned()) + } + DocProperty::Revision => props.revision = Some(value.into_owned()), + DocProperty::Version => props.version = Some(value.into_owned()), + DocProperty::Application => props.application = Some(value.into_owned()), + DocProperty::AppVersion => props.app_version = Some(value.into_owned()), + DocProperty::Company => props.company = Some(value.into_owned()), + DocProperty::Template => props.template = Some(value.into_owned()), + DocProperty::Manager => props.manager = Some(value.into_owned()), + } + current = None; + } + } + Ok(Event::End(_)) => current = None, + Ok(Event::Eof) => break, + Err(e) => return Err(XlsxError::Xml(e)), + _ => (), + } + } + Ok(()) +} + +/// Read the package extended properties (`docProps/app.xml`). +pub(crate) fn read_app_properties( + zip: &mut ZipArchive, + props: &mut WorkbookProperties, + cache: &HashMap, +) -> Result<(), XlsxError> { + let Some(xml) = xml_reader(zip, "docProps/app.xml", cache) else { + return Ok(()); + }; + let mut xml = xml?; + + let mut buf = Vec::with_capacity(256); + let mut current = None; + + loop { + buf.clear(); + match xml.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let name = e.local_name(); + let name_ref = name.as_ref(); + current = match name_ref { + b"Application" => Some(DocProperty::Application), + b"AppVersion" => Some(DocProperty::AppVersion), + b"Company" => Some(DocProperty::Company), + b"Template" => Some(DocProperty::Template), + b"Manager" => Some(DocProperty::Manager), + _ => None, + }; + } + Ok(Event::Text(t)) => { + if let Some(field) = current { + let value = t.xml10_content()?; + match field { + DocProperty::Application => props.application = Some(value.into_owned()), + DocProperty::AppVersion => props.app_version = Some(value.into_owned()), + DocProperty::Company => props.company = Some(value.into_owned()), + DocProperty::Template => props.template = Some(value.into_owned()), + DocProperty::Manager => props.manager = Some(value.into_owned()), + _ => {} + } + current = None; + } + } + Ok(Event::End(_)) => current = None, + Ok(Event::Eof) => break, + Err(e) => return Err(XlsxError::Xml(e)), + _ => (), + } + } + Ok(()) +} + +#[derive(Clone, Copy)] +enum DocProperty { + Creator, + LastModifiedBy, + Created, + Modified, + Title, + Subject, + Description, + Keywords, + Category, + ContentStatus, + Revision, + Version, + Application, + AppVersion, + Company, + Template, + Manager, +} diff --git a/tests/test.rs b/tests/test.rs index 5dc12f30..539e994d 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -3609,6 +3609,59 @@ fn test_xlsx_strict_iso_paths() { let _: Xlsx<_> = wb("strict_iso_paths.xlsx"); } +#[test] +fn test_xlsx_workbook_properties() { + let mut excel: Xlsx<_> = wb("workbook_properties.xlsx"); + let props = excel.workbook_properties().expect("workbook properties"); + + assert_eq!(props.creator.as_deref(), Some("Test Creator")); + assert_eq!(props.last_modified_by.as_deref(), Some("Last Modifier")); + assert_eq!(props.created.as_deref(), Some("2024-01-15T08:30:00Z")); + assert_eq!(props.modified.as_deref(), Some("2024-06-20T14:22:00Z")); + assert_eq!(props.title.as_deref(), Some("Workbook Title")); + assert_eq!(props.subject.as_deref(), Some("Test Subject")); + assert_eq!(props.description.as_deref(), Some("A test workbook")); + assert_eq!(props.keywords.as_deref(), Some("test, calamine")); + assert_eq!(props.category.as_deref(), Some("Testing")); + assert_eq!(props.content_status.as_deref(), Some("Draft")); + assert_eq!(props.revision.as_deref(), Some("3")); + assert_eq!(props.version.as_deref(), Some("1.2")); + assert_eq!(props.application.as_deref(), Some("Microsoft Excel")); + assert_eq!(props.app_version.as_deref(), Some("16.0300")); + assert_eq!(props.company.as_deref(), Some("Contoso")); + assert_eq!(props.template.as_deref(), Some("Book.xltx")); + assert_eq!(props.manager.as_deref(), Some("Jane Doe")); +} + +#[test] +fn test_xlsx_workbook_properties_missing() { + let mut excel: Xlsx<_> = wb("workbook_properties_missing.xlsx"); + let props = excel.workbook_properties().expect("workbook properties"); + + assert!(props.creator.is_none()); + assert!(props.last_modified_by.is_none()); + assert!(props.application.is_none()); + assert!(props.company.is_none()); +} + +#[test] +fn test_xlsb_workbook_properties() { + let mut excel: Xlsb<_> = wb("issues.xlsb"); + let props = excel.workbook_properties().expect("workbook properties"); + + assert_eq!( + props.creator.as_deref(), + Some("Johann Tuffe (jtuffe010814)") + ); + assert_eq!( + props.last_modified_by.as_deref(), + Some("Johann Tuffe (jtuffe010814)") + ); + assert_eq!(props.application.as_deref(), Some("Microsoft Excel")); + assert_eq!(props.app_version.as_deref(), Some("16.0300")); + assert_eq!(props.company.as_deref(), Some("SOCIETE GENERALE")); +} + #[test] fn xls_empty_string() { // Empty strings should be retained, not converted to None. See issue #678 diff --git a/tests/workbook_properties.xlsx b/tests/workbook_properties.xlsx new file mode 100644 index 00000000..b7d048dc Binary files /dev/null and b/tests/workbook_properties.xlsx differ diff --git a/tests/workbook_properties_missing.xlsx b/tests/workbook_properties_missing.xlsx new file mode 100644 index 00000000..622a0eec Binary files /dev/null and b/tests/workbook_properties_missing.xlsx differ