Skip to content

feat: parse workbook properties (core + extended) - #685

Open
Alex-ley-scrub wants to merge 5 commits into
tafia:masterfrom
Alex-ley-scrub:feat/677-workbook-properties
Open

feat: parse workbook properties (core + extended)#685
Alex-ley-scrub wants to merge 5 commits into
tafia:masterfrom
Alex-ley-scrub:feat/677-workbook-properties

Conversation

@Alex-ley-scrub

@Alex-ley-scrub Alex-ley-scrub commented Jul 22, 2026

Copy link
Copy Markdown

Closes #677.

This PR adds support for reading workbook document properties from XLSX and XLSB files:

  • Core properties from docProps/core.xml:

    • creator
    • last_modified_by
    • created
    • modified
    • title
    • subject
    • description
    • keywords
    • category
    • content_status
    • revision
    • version
  • Extended properties from docProps/app.xml:

    • application
    • app_version
    • company
    • template
    • manager

Usage:

use calamine::{open_workbook_auto};

let mut excel = open_workbook_auto(path)?;
let props = excel.workbook_properties()?;
println!("creator: {:?}", props.creator);
println!("last modified by: {:?}", props.last_modified_by);

If a file does not contain docProps/core.xml or docProps/app.xml, all properties remain None and parsing continues normally.

Tests and fixtures covering both populated and missing properties are included.

Adds WorkbookProperties to Metadata, exposing XLSX docProps/core.xml
and docProps/app.xml fields such as creator, last_modified_by,
application, company, etc.

Closes tafia#677
@jmcnamara jmcnamara self-assigned this Jul 28, 2026
Comment thread src/lib.rs
Comment thread src/xlsx/mod.rs Outdated
@jmcnamara

jmcnamara commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Overall this is a good (and useful) PR. I would have probably mapped some of the elements to different types but I think having everything as Option<String> is a good interface. So keep that as it is.

Could you also add an example to the examples directory in the format of some of the other examples. Also add an entry to examples/Readme.md.

There is also the possibility to have user defined custom fields but that can be left for another PR if it is requested. In that case the field could be custom: HashMap<String, String>.

@jmcnamara jmcnamara added the awaiting user changes Awaiting changes to a PR to fix requested changes or CI issues. label Jul 28, 2026
@Alex-ley-scrub

Alex-ley-scrub commented Jul 29, 2026

Copy link
Copy Markdown
Author

thanks @jmcnamara

yeah I can do custom props as well potentially - i.e. docProps/custom.xml - I actually did a PR for that in openpyxl 6 years ago: https://foss.heptapod.net/openpyxl/openpyxl/-/merge_requests/384

there's a lot of prior art as well:

I think we may as well add it in here too, whilst you are I are both in this context etc.

I'll do this, no worries:

Could you also add an example to the examples directory in the format of some of the other examples. Also add an entry to examples/Readme.md.

- Close the unclosed delimiter in test_xlsx_workbook_properties_missing.
- Add blank lines between WorkbookProperties fields for readability.
- Rename PropField to DocProperty.
@Alex-ley-scrub

Copy link
Copy Markdown
Author

@jmcnamara for the docProps/custom.xml

We'd need to support:

  • "vt:i4" - i.e. int like 4
  • "vt:r8" - i.e. float/decimal like 3.142
  • "vt:bool" - i.e. bool like true
  • "vt:filetime" - i.e. datetime / iso timestamp like "2020-08-24T20:19:22Z"
  • "vt:lpwstr" - i.e. string like "hello" but also can be self closing tag used with <propety linkTarget="SomeName"><vt:lpwstr /></property>

so would you still want the type as custom: HashMap<String, String> and just let the caller figure it out? Or we could return an enum with the VariantTypes info?

@Alex-ley-scrub

Copy link
Copy Markdown
Author

Added docProps/custom.xml support as discussed:

  • New CustomPropertyValue enum covering vt:i4, vt:r8, vt:bool, vt:filetime, and vt:lpwstr (including linkTarget linked strings).
  • Accessors for as_i32, as_f64, as_bool, as_str, vt_type, plus a chrono-gated as_datetime for file times.
  • Added examples/read_properties.rs and updated examples/Readme.md.
  • New fixture tests/workbook_custom_properties.xlsx with tests for all supported types.

cargo test and cargo clippy --all-targets --examples are green.

@jmcnamara

Copy link
Copy Markdown
Collaborator

https://foss.heptapod.net/openpyxl/openpyxl/-/merge_requests/384

That is good work. :-)

for the docProps/custom.xml We'd need to support:

In rust_xlsxwriter I implemented a CustomProperty type for this:

https://github.com/jmcnamara/rust_xlsxwriter/blob/main/src/properties.rs#L613-L621

However that is probably overkill. Custom properties will not be a requirement for most users and anyone who did need them probably wouldn't be concerned with the type or could probably figure it out from the field name. Hence my suggestion to just use HashMap<String, String>.

However, even at that I don't think it is worth the effort of adding custom property support, at this time.

A better use of your/my time would be to add property support for the other file types: xlsb, xls, and ods. I never insist that features are rolled out across all file types but it seems important that users be able to access the author and creation/modification data, at a minimum.

Maybe you could look at xlsb. I can look at ODS (or vice-versa). @sftse would it be possible to extract basic property information like author and creation/modification from xls using the current cfb support?

@Alex-ley-scrub
Alex-ley-scrub force-pushed the feat/677-workbook-properties branch from dca97b7 to 7442f0d Compare July 30, 2026 07:55
@Alex-ley-scrub

Copy link
Copy Markdown
Author

Updated examples/read_properties.rs to match the conventions in the examples directory:

  • Added a module-level rustdoc comment explaining what the example does and how to run it, including expected output.
  • Removed the padded alignment in the println! calls.

cargo run -q --example read_properties -- tests/workbook_custom_properties.xlsx now prints:

Core / Extended properties:
  creator: Test Creator
  application: Microsoft Excel

Custom properties:
  MyInt: 4 (vt:i4)
  MyBool: true (vt:bool)
  MyDateTime: 2020-08-24T20:19:22Z (vt:filetime)
  MyString: hello (vt:lpwstr)
  MyLink: SomeName (vt:lpwstr)
  MyFloat: 2.5 (vt:r8)

@Alex-ley-scrub Alex-ley-scrub changed the title feat: parse workbook properties (core + extended) feat: parse workbook properties (core + extended + custom) Jul 30, 2026
@Alex-ley-scrub
Alex-ley-scrub force-pushed the feat/677-workbook-properties branch 2 times, most recently from 380f322 to beb3e1e Compare July 30, 2026 19:59
@Alex-ley-scrub Alex-ley-scrub changed the title feat: parse workbook properties (core + extended + custom) feat: parse workbook properties (core + extended) Jul 30, 2026
@jmcnamara

jmcnamara commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Thanks. Your additions for custom property handling came at more or less the same time as my comments about omitting it. If I had seen the work first I would have left that go. However, splitting them out into a separate PR is good too. It makes this review a bit simpler.

I'll review the current code but one high level comment. The current approach reads (or tries to read) the document properties for every xlsx/xlsb file (and later others) even if the user doesn't need them. That is a performance overhead, even if (relative to the data in the worksheets) it isn't a big one. A better approach might be to load the properties lazily when requested and cache them for repeated lookups (unlikely).

Something like this:

)$ git diff props-eager props-lazy src
diff --git a/src/lib.rs b/src/lib.rs
index fac7b06..40262de 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -226,8 +226,6 @@ pub struct Metadata {
     sheets: Vec<Sheet>,
     /// Map of sheet names/sheet path within zip archive
     names: Vec<(String, String)>,
-    /// Workbook properties (core and extended)
-    pub(crate) workbook_properties: WorkbookProperties,
 }
 
 /// Workbook document properties.
@@ -293,13 +291,6 @@ pub struct WorkbookProperties {
     pub manager: Option<String>,
 }
 
-impl Metadata {
-    /// Returns the workbook document properties.
-    pub fn workbook_properties(&self) -> &WorkbookProperties {
-        &self.workbook_properties
-    }
-}
-
 /// Type of sheet.
 ///
 /// Only Excel formats support this. Default value for ODS is
diff --git a/src/ods.rs b/src/ods.rs
index dd98f74..05a2304 100644
--- a/src/ods.rs
+++ b/src/ods.rs
@@ -206,7 +206,6 @@ where
         let metadata = Metadata {
             sheets: sheets_metadata,
             names: defined_names,
-            workbook_properties: crate::WorkbookProperties::default(),
         };
 
         Ok(Ods {
diff --git a/src/xlsb/mod.rs b/src/xlsb/mod.rs
index dfb85a2..6faa72a 100644
--- a/src/xlsb/mod.rs
+++ b/src/xlsb/mod.rs
@@ -162,6 +162,8 @@ pub struct Xlsb<RS> {
     formats: Vec<CellFormat>,
     is_1904: bool,
     metadata: Metadata,
+    /// Workbook document properties, parsed lazily on first access
+    workbook_properties: Option<Box<WorkbookProperties>>,
     #[cfg(feature = "picture")]
     pictures: Option<Vec<(String, Vec<u8>)>>,
     options: XlsbOptions,
@@ -440,14 +442,28 @@ impl<RS: Read + Seek> Xlsb<RS> {
         )
     }
 
-    fn read_properties(&mut self) -> Result<(), XlsbError> {
-        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.metadata.workbook_properties = props;
-        Ok(())
+    /// Get the workbook document properties.
+    ///
+    /// Reads the core [`WorkbookProperties`] document properties, such as file
+    /// author and creation date/time. Missing fields are set as `None`.
+    ///
+    /// # Errors
+    ///
+    /// - [`XlsbError::Xml`] if a `docProps` part is present but malformed.
+    ///
+    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("populated immediately above"))
     }
 
     #[cfg(feature = "picture")]
@@ -495,6 +511,7 @@ impl<RS: Read + Seek> Reader<RS> for Xlsb<RS> {
             formats: Vec::new(),
             is_1904: false,
             metadata: Metadata::default(),
+            workbook_properties: None,
             #[cfg(feature = "picture")]
             pictures: None,
             options: XlsbOptions::default(),
@@ -504,7 +521,6 @@ impl<RS: Read + Seek> Reader<RS> for Xlsb<RS> {
         xlsb.read_styles()?;
         let relationships = xlsb.read_relationships()?;
         xlsb.read_workbook(&relationships)?;
-        xlsb.read_properties()?;
         #[cfg(feature = "picture")]
         xlsb.read_pictures()?;
 
diff --git a/src/xlsx/mod.rs b/src/xlsx/mod.rs
index c2728ea..d87d12e 100644
--- a/src/xlsx/mod.rs
+++ b/src/xlsx/mod.rs
@@ -265,6 +265,8 @@ pub struct Xlsx<RS> {
     is_1904: bool,
     /// Metadata
     metadata: Metadata,
+    /// Workbook document properties, parsed lazily on first access.
+    workbook_properties: Option<Box<WorkbookProperties>>,
     /// Pictures
     #[cfg(feature = "picture")]
     pictures: Option<Vec<Picture>>,
@@ -547,12 +549,26 @@ impl<RS: Read + Seek> Xlsx<RS> {
         Ok(())
     }
 
-    fn read_properties(&mut self) -> Result<(), XlsxError> {
-        let mut core = WorkbookProperties::default();
-        read_core_properties(&mut self.zip, &mut core, &self.zip_path_cache)?;
-        read_app_properties(&mut self.zip, &mut core, &self.zip_path_cache)?;
-        self.metadata.workbook_properties = core;
-        Ok(())
+    /// Get the workbook document properties.
+    ///
+    /// Reads the core [`WorkbookProperties`] document properties, such as file
+    /// author and creation date/time. Missing fields are set as `None`.
+    ///
+    /// # Errors
+    ///
+    /// - [`XlsxError::Xml`] if a `docProps` part is present but malformed.
+    ///
+    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("populated immediately above"))
     }
 
     fn read_relationships(&mut self) -> Result<HashMap<Vec<u8>, (String, String)>, XlsxError> {
@@ -2560,6 +2576,7 @@ impl<RS: Read + Seek> Reader<RS> for Xlsx<RS> {
             sheets: Vec::new(),
             tables: None,
             metadata: Metadata::default(),
+            workbook_properties: None,
             #[cfg(feature = "picture")]
             pictures: None,
             merged_regions: None,
@@ -2571,7 +2588,6 @@ impl<RS: Read + Seek> Reader<RS> for Xlsx<RS> {
         xlsx.read_styles()?;
         let relationships = xlsx.read_relationships()?;
         xlsx.read_workbook(&relationships)?;
-        xlsx.read_properties()?;
         #[cfg(feature = "picture")]
         xlsx.read_pictures()?;
 
@@ -4577,6 +4593,7 @@ mod tests {
             formats: vec![],
             is_1904: false,
             metadata: Metadata::default(),
+            workbook_properties: None,
             #[cfg(feature = "picture")]
             pictures: None,
             merged_regions: None,

Comment thread tests/test.rs
Comment thread src/lib.rs Outdated
Reuses the existing XLSX property parsers (now pub(crate)) since XLSB
uses the same OPC XML package layout for document properties.
@Alex-ley-scrub
Alex-ley-scrub force-pushed the feat/677-workbook-properties branch from beb3e1e to af315b4 Compare August 4, 2026 10:43
@Alex-ley-scrub

Alex-ley-scrub commented Aug 11, 2026

Copy link
Copy Markdown
Author

I've addressed the lazy-loading feedback from #685 (comment) @jmcnamara

Workbook properties are now loaded lazily on first access (and cached) instead of being parsed eagerly during reader initialization, and the access pattern is now workbook.workbook_properties()? for the supported readers.

I also opened the deferred custom-properties follow-up as a stacked PR on my fork: Alex-ley-scrub#1 - I can then open a PR from my fork into this repo once this PR here merges.
Tracking issue: #702

Want/need me to do anything else @jmcnamara?

Comment on lines +7 to +8
//! This example reads the core and extended document properties (such as
//! creator, application, and company) from a workbook.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Omit the phrase "core and extended" here and on any public docs. They won't mean anything to most end users.

Comment thread examples/README.md
- `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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Omit the phrase "core and extended" here.

@jmcnamara

Copy link
Copy Markdown
Collaborator

@Alex-ley-scrub Thanks for the work here. Just one minor change required.

Comment thread src/xlsb/mod.rs
/// Get workbook document properties.
///
/// Missing fields are returned as `None`.
pub fn workbook_properties(&mut self) -> Result<&WorkbookProperties, XlsbError> {

@jmcnamara jmcnamara Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this again, it is probably better to return WorkbookProperties via a clone() rather than the reference &WorkbookProperties which could keep the workbook owned and could prevent other method calls.

WorkbookProperties isn't one of the performance critical or heavyweight internal structures that needs to be returned as a ref. Side note, there are probably inconsistencies of this ref/value return usage in other places in the code but that is a separate issue.

@jmcnamara

Copy link
Copy Markdown
Collaborator

@Alex-ley-scrub Ping on this. There are some requested changes outstanding.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting user changes Awaiting changes to a PR to fix requested changes or CI issues. next_release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature request: Parse Workbook Properties

2 participants