Skip to content

This close #2146, support setting excel custom properties #2147

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions docProps.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (
"encoding/xml"
"io"
"reflect"
"slices"
"time"
)

// SetAppProps provides a function to set document application properties. The
Expand Down Expand Up @@ -236,3 +238,117 @@ func (f *File) GetDocProps() (ret *DocProperties, err error) {
}
return
}

// SetCustomProps provides a function to set custom file properties by given
// property name and value. If the property name already exists, it will be
// updated, otherwise a new property will be added. The value can be of type
// int32, float64, bool, string, time.Time or nil. The property will be delete
// if the value is nil. The function returns an error if the property value is
// not of the correct type.
func (f *File) SetCustomProps(prop CustomProperty) error {
if prop.Name == "" {
return ErrParameterInvalid
}
props := new(decodeCustomProperties)
if err := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(f.readXML(defaultXMLPathDocPropsCustom)))).
Decode(props); err != nil && err != io.EOF {
return err
}
customProps := xlsxCustomProperties{Vt: NameSpaceDocumentPropertiesVariantTypes.Value}
idx, pID := -1, 1
for i := range props.Property {
p := new(xlsxProperty)
setPtrFields(reflect.ValueOf(&props.Property[i]).Elem(), reflect.ValueOf(p).Elem())
if pID < props.Property[i].PID {
pID = props.Property[i].PID
}
if props.Property[i].Name == prop.Name {
idx = i
}
customProps.Property = append(customProps.Property, *p)
}
if idx != -1 && prop.Value == nil {
customProps.Property = slices.Delete(customProps.Property, idx, idx+1)
}
if prop.Value != nil {
property := xlsxProperty{Name: prop.Name, FmtID: EXtURICustomPropertyFmtID}
if err := property.setCustomProps(prop.Value); err != nil {
return err
}
if idx != -1 {
property.PID = customProps.Property[idx].PID
customProps.Property[idx] = property
} else {
property.PID = pID + 1
customProps.Property = append(customProps.Property, property)
}
}
_ = f.addRels(defaultXMLPathRels, SourceRelationshipCustomProperties, defaultXMLPathDocPropsCustom, "")
if err := f.addContentTypePart(0, "customProperties"); err != nil {
return err
}
output, err := xml.Marshal(customProps)
f.saveFileList(defaultXMLPathDocPropsCustom, output)
return err
}

// setCustomProps sets the custom property value based on its type.
func (prop *xlsxProperty) setCustomProps(value interface{}) error {
switch v := value.(type) {
case int32:
prop.I4 = &v
case float64:
prop.R8 = float64Ptr(v)
case bool:
prop.Bool = boolPtr(v)
case string:
prop.Lpwstr = stringPtr(value.(string))
case time.Time:
prop.FileTime = stringPtr(value.(time.Time).Format(time.RFC3339))
default:
return ErrParameterInvalid
}
return nil
}

// GetCustomProps provides a function to get custom file properties.
func (f *File) GetCustomProps() ([]CustomProperty, error) {
var customProps []CustomProperty
props := new(decodeCustomProperties)
if err := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(f.readXML(defaultXMLPathDocPropsCustom)))).
Decode(props); err != nil && err != io.EOF {
return customProps, err
}
for _, p := range props.Property {
prop := CustomProperty{Name: p.Name}
var err error
if prop.Value, err = p.getCustomProps(); err != nil {
return customProps, err
}
customProps = append(customProps, prop)
}
return customProps, nil
}

// getCustomProps gets the custom property value based on its type.
func (p *decodeProperty) getCustomProps() (interface{}, error) {
s := reflect.ValueOf(p).Elem()
for i := range s.NumField() {
if 11 <= i && i <= 20 && !s.Field(i).IsNil() {
return int32(s.Field(i).Elem().Int()), nil // Field vt:i1 to vt:uint
}
if 21 <= i && i <= 22 && !s.Field(i).IsNil() {
return s.Field(i).Elem().Float(), nil // Field vt:r4 to vt:r8
}
if p.Bool != nil {
return *p.Bool, nil
}
if p.Lpwstr != nil {
return *p.Lpwstr, nil
}
if p.FileTime != nil {
return time.Parse(time.RFC3339, *p.FileTime)
}
}
return nil, nil
}
70 changes: 70 additions & 0 deletions docProps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
package excelize

import (
"fmt"
"path/filepath"
"slices"
"testing"
"time"

"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -116,3 +119,70 @@ func TestGetDocProps(t *testing.T) {
_, err = f.GetDocProps()
assert.EqualError(t, err, "XML syntax error on line 1: invalid UTF-8")
}

func TestCustomProps(t *testing.T) {
f := NewFile()
expected := []CustomProperty{
{Name: "Text Prop", Value: "text"},
{Name: "Boolean Prop 1", Value: true},
{Name: "Boolean Prop 2", Value: false},
{Name: "Number Prop 1", Value: -123.456},
{Name: "Number Prop 2", Value: int32(1)},
{Name: "Date Prop", Value: time.Date(2021, time.September, 11, 0, 0, 0, 0, time.UTC)},
}
for _, prop := range expected {
assert.NoError(t, f.SetCustomProps(prop))
}
props, err := f.GetCustomProps()
assert.NoError(t, err)
assert.Equal(t, expected, props)

// Test delete custom property
assert.NoError(t, f.SetCustomProps(CustomProperty{Name: "Boolean Prop 1", Value: nil}))
props, err = f.GetCustomProps()
assert.NoError(t, err)
expected = slices.Delete(expected, 1, 2)
assert.Equal(t, expected, props)

// Test change custom property value data type
assert.NoError(t, f.SetCustomProps(CustomProperty{Name: "Boolean Prop 2", Value: "true"}))
props, err = f.GetCustomProps()
assert.NoError(t, err)
assert.Equal(t, props[1].Value, "true")

// Test set custom property with unsupported value data type
assert.Equal(t, ErrParameterInvalid, f.SetCustomProps(CustomProperty{Name: "Boolean Prop 2", Value: 1}))

assert.NoError(t, f.SaveAs(filepath.Join("test", "TestSetCustomProps.xlsx")))
assert.NoError(t, f.Close())

// Test set custom property without property name
f = NewFile()
assert.Equal(t, ErrParameterInvalid, f.SetCustomProps(CustomProperty{}))

// Test set custom property with unsupported charset
f.Pkg.Store(defaultXMLPathDocPropsCustom, MacintoshCyrillicCharset)
assert.EqualError(t, f.SetCustomProps(CustomProperty{Name: "Prop"}), "XML syntax error on line 1: invalid UTF-8")

// Test get custom property with unsupported charset
_, err = f.GetCustomProps()
assert.EqualError(t, err, "XML syntax error on line 1: invalid UTF-8")

// Test set custom property with unsupported charset content types
f = NewFile()
f.ContentTypes = nil
f.Pkg.Store(defaultXMLPathContentTypes, MacintoshCyrillicCharset)
assert.EqualError(t, f.SetCustomProps(CustomProperty{Name: "Prop"}), "XML syntax error on line 1: invalid UTF-8")

// Test get custom property with unsupported charset
f.Pkg.Store(defaultXMLPathDocPropsCustom, []byte(fmt.Sprintf(`<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:vt="%s"><property fmtid="%s" pid="2" name="Prop"><vt:filetime>x</vt:filetime></property></Properties>`, NameSpaceDocumentPropertiesVariantTypes, EXtURICustomPropertyFmtID)))
_, err = f.GetCustomProps()
assert.EqualError(t, err, "parsing time \"x\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"x\" as \"2006\"")

// Test get custom property with unsupported value data type
f.Pkg.Store(defaultXMLPathDocPropsCustom, []byte(fmt.Sprintf(`<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:vt="%s"><property fmtid="%s" pid="2" name="Prop"><vt:cy></vt:cy></property></Properties>`, NameSpaceDocumentPropertiesVariantTypes, EXtURICustomPropertyFmtID)))
props, err = f.GetCustomProps()
assert.Equal(t, []CustomProperty{{Name: "Prop"}}, props)
assert.NoError(t, err)
assert.NoError(t, f.Close())
}
3 changes: 2 additions & 1 deletion excelize.go
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,8 @@ func (f *File) setRels(rID, relPath, relType, target, targetMode string) int {
// relationship type, target and target mode.
func (f *File) addRels(relPath, relType, target, targetMode string) int {
uniqPart := map[string]string{
SourceRelationshipSharedStrings: "/xl/sharedStrings.xml",
SourceRelationshipCustomProperties: "/docProps/custom.xml",
SourceRelationshipSharedStrings: "/xl/sharedStrings.xml",
}
rels, _ := f.relsReader(relPath)
if rels == nil {
Expand Down
2 changes: 1 addition & 1 deletion file.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import (
// f := NewFile()
func NewFile(opts ...Options) *File {
f := newFile()
f.Pkg.Store("_rels/.rels", []byte(xml.Header+templateRels))
f.Pkg.Store(defaultXMLPathRels, []byte(xml.Header+templateRels))
f.Pkg.Store(defaultXMLPathDocPropsApp, []byte(xml.Header+templateDocpropsApp))
f.Pkg.Store(defaultXMLPathDocPropsCore, []byte(xml.Header+templateDocpropsCore))
f.Pkg.Store(defaultXMLPathWorkbookRels, []byte(xml.Header+templateWorkbookRels))
Expand Down
13 changes: 13 additions & 0 deletions lib.go
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,19 @@ func assignFieldValue(field string, immutable, mutable reflect.Value) {
}
}

// setPtrFields assigns the fields of the immutable struct to the mutable
// struct. The fields name of the immutable struct must match the field names of
// the mutable struct.
func setPtrFields(immutable, mutable reflect.Value) {
for i := range immutable.NumField() {
srcField := immutable.Type().Field(i)
dstField := mutable.FieldByName(srcField.Name)
if dstField.IsValid() && dstField.CanSet() && dstField.Type() == immutable.Field(i).Type() {
dstField.Set(immutable.Field(i))
}
}
}

// setNoPtrFieldsVal assigns values from the pointer or no-pointer structs
// fields (immutable) value to no-pointer struct field.
func setNoPtrFieldsVal(fields []string, immutable, mutable reflect.Value) {
Expand Down
4 changes: 2 additions & 2 deletions sheet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,14 +524,14 @@ func TestWorksheetWriter(t *testing.T) {

func TestGetWorkbookPath(t *testing.T) {
f := NewFile()
f.Pkg.Delete("_rels/.rels")
f.Pkg.Delete(defaultXMLPathRels)
assert.Empty(t, f.getWorkbookPath())
}

func TestGetWorkbookRelsPath(t *testing.T) {
f := NewFile()
f.Pkg.Delete("xl/_rels/.rels")
f.Pkg.Store("_rels/.rels", []byte(xml.Header+`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" Target="/workbook.xml"/></Relationships>`))
f.Pkg.Store(defaultXMLPathRels, []byte(xml.Header+`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" Target="/workbook.xml"/></Relationships>`))
assert.Equal(t, "_rels/workbook.xml.rels", f.getWorkbookRelsPath())
}

Expand Down
5 changes: 5 additions & 0 deletions templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ var (
// Source relationship and namespace.
const (
ContentTypeAddinMacro = "application/vnd.ms-excel.addin.macroEnabled.main+xml"
ContentTypeCustomProperties = "application/vnd.openxmlformats-officedocument.custom-properties+xml"
ContentTypeDrawing = "application/vnd.openxmlformats-officedocument.drawing+xml"
ContentTypeDrawingML = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"
ContentTypeMacro = "application/vnd.ms-excel.sheet.macroEnabled.main+xml"
Expand Down Expand Up @@ -71,6 +72,7 @@ const (
SourceRelationshipChart = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"
SourceRelationshipChartsheet = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet"
SourceRelationshipComments = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
SourceRelationshipCustomProperties = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"
SourceRelationshipDialogsheet = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/dialogsheet"
SourceRelationshipDrawingML = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing"
SourceRelationshipDrawingVML = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing"
Expand Down Expand Up @@ -102,6 +104,7 @@ const (
ExtURICalcFeatures = "{B58B0392-4F1F-4190-BB64-5DF3571DCE5F}"
ExtURIConditionalFormattingRuleID = "{B025F937-C7B1-47D3-B67F-A62EFF666E3E}"
ExtURIConditionalFormattings = "{78C0D931-6437-407d-A8EE-F0AAD7539E65}"
EXtURICustomPropertyFmtID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"
ExtURIDataField = "{E15A36E0-9728-4E99-A89B-3F7291B0FE68}"
ExtURIDataModel = "{FCE2AD5D-F65C-4FA6-A056-5C36A1767C68}"
ExtURIDataValidations = "{CCE6A557-97BC-4b89-ADB6-D9C93CAAB3DF}"
Expand Down Expand Up @@ -278,6 +281,8 @@ const (
defaultXMLPathContentTypes = "[Content_Types].xml"
defaultXMLPathDocPropsApp = "docProps/app.xml"
defaultXMLPathDocPropsCore = "docProps/core.xml"
defaultXMLPathDocPropsCustom = "docProps/custom.xml"
defaultXMLPathRels = "_rels/.rels"
defaultXMLPathSharedStrings = "xl/sharedStrings.xml"
defaultXMLPathStyles = "xl/styles.xml"
defaultXMLPathTheme = "xl/theme/theme1.xml"
Expand Down
44 changes: 23 additions & 21 deletions workbook.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ func (f *File) setWorkbook(name string, sheetID, rid int) {
// getWorkbookPath provides a function to get the path of the workbook.xml in
// the spreadsheet.
func (f *File) getWorkbookPath() (path string) {
if rels, _ := f.relsReader("_rels/.rels"); rels != nil {
if rels, _ := f.relsReader(defaultXMLPathRels); rels != nil {
rels.mu.Lock()
defer rels.mu.Unlock()
for _, rel := range rels.Relationships {
Expand Down Expand Up @@ -364,28 +364,30 @@ func (f *File) addContentTypePart(index int, contentType string) error {
"drawings": f.setContentTypePartImageExtensions,
}
partNames := map[string]string{
"chart": "/xl/charts/chart" + strconv.Itoa(index) + ".xml",
"chartsheet": "/xl/chartsheets/sheet" + strconv.Itoa(index) + ".xml",
"comments": "/xl/comments" + strconv.Itoa(index) + ".xml",
"drawings": "/xl/drawings/drawing" + strconv.Itoa(index) + ".xml",
"table": "/xl/tables/table" + strconv.Itoa(index) + ".xml",
"pivotTable": "/xl/pivotTables/pivotTable" + strconv.Itoa(index) + ".xml",
"pivotCache": "/xl/pivotCache/pivotCacheDefinition" + strconv.Itoa(index) + ".xml",
"sharedStrings": "/xl/sharedStrings.xml",
"slicer": "/xl/slicers/slicer" + strconv.Itoa(index) + ".xml",
"slicerCache": "/xl/slicerCaches/slicerCache" + strconv.Itoa(index) + ".xml",
"chart": "/xl/charts/chart" + strconv.Itoa(index) + ".xml",
"chartsheet": "/xl/chartsheets/sheet" + strconv.Itoa(index) + ".xml",
"comments": "/xl/comments" + strconv.Itoa(index) + ".xml",
"customProperties": "/docProps/custom.xml",
"drawings": "/xl/drawings/drawing" + strconv.Itoa(index) + ".xml",
"table": "/xl/tables/table" + strconv.Itoa(index) + ".xml",
"pivotTable": "/xl/pivotTables/pivotTable" + strconv.Itoa(index) + ".xml",
"pivotCache": "/xl/pivotCache/pivotCacheDefinition" + strconv.Itoa(index) + ".xml",
"sharedStrings": "/xl/sharedStrings.xml",
"slicer": "/xl/slicers/slicer" + strconv.Itoa(index) + ".xml",
"slicerCache": "/xl/slicerCaches/slicerCache" + strconv.Itoa(index) + ".xml",
}
contentTypes := map[string]string{
"chart": ContentTypeDrawingML,
"chartsheet": ContentTypeSpreadSheetMLChartsheet,
"comments": ContentTypeSpreadSheetMLComments,
"drawings": ContentTypeDrawing,
"table": ContentTypeSpreadSheetMLTable,
"pivotTable": ContentTypeSpreadSheetMLPivotTable,
"pivotCache": ContentTypeSpreadSheetMLPivotCacheDefinition,
"sharedStrings": ContentTypeSpreadSheetMLSharedStrings,
"slicer": ContentTypeSlicer,
"slicerCache": ContentTypeSlicerCache,
"chart": ContentTypeDrawingML,
"chartsheet": ContentTypeSpreadSheetMLChartsheet,
"comments": ContentTypeSpreadSheetMLComments,
"customProperties": ContentTypeCustomProperties,
"drawings": ContentTypeDrawing,
"table": ContentTypeSpreadSheetMLTable,
"pivotTable": ContentTypeSpreadSheetMLPivotTable,
"pivotCache": ContentTypeSpreadSheetMLPivotCacheDefinition,
"sharedStrings": ContentTypeSpreadSheetMLSharedStrings,
"slicer": ContentTypeSlicer,
"slicerCache": ContentTypeSlicerCache,
}
s, ok := setContentType[contentType]
if ok {
Expand Down
Loading