diff --git a/docProps.go b/docProps.go index 975f51b0a2..49cbdaad61 100644 --- a/docProps.go +++ b/docProps.go @@ -16,6 +16,8 @@ import ( "encoding/xml" "io" "reflect" + "slices" + "time" ) // SetAppProps provides a function to set document application properties. The @@ -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 +} diff --git a/docProps_test.go b/docProps_test.go index dc26e2b285..014b3778c8 100644 --- a/docProps_test.go +++ b/docProps_test.go @@ -12,8 +12,11 @@ package excelize import ( + "fmt" "path/filepath" + "slices" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -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(`x`, 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(``, NameSpaceDocumentPropertiesVariantTypes, EXtURICustomPropertyFmtID))) + props, err = f.GetCustomProps() + assert.Equal(t, []CustomProperty{{Name: "Prop"}}, props) + assert.NoError(t, err) + assert.NoError(t, f.Close()) +} diff --git a/excelize.go b/excelize.go index 61bb6d3489..52b7fa33fb 100644 --- a/excelize.go +++ b/excelize.go @@ -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 { diff --git a/file.go b/file.go index 1ef8a8a5fb..03036c9163 100644 --- a/file.go +++ b/file.go @@ -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)) diff --git a/lib.go b/lib.go index 4cd0552ae1..7eeb813a19 100644 --- a/lib.go +++ b/lib.go @@ -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) { diff --git a/sheet_test.go b/sheet_test.go index d5cc4cfa7b..dc82346b8c 100644 --- a/sheet_test.go +++ b/sheet_test.go @@ -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+``)) + f.Pkg.Store(defaultXMLPathRels, []byte(xml.Header+``)) assert.Equal(t, "_rels/workbook.xml.rels", f.getWorkbookRelsPath()) } diff --git a/templates.go b/templates.go index b8cf159131..bdcb375cec 100644 --- a/templates.go +++ b/templates.go @@ -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" @@ -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" @@ -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}" @@ -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" diff --git a/workbook.go b/workbook.go index d1cb1da45b..d05acba918 100644 --- a/workbook.go +++ b/workbook.go @@ -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 { @@ -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 { diff --git a/xmlCustom.go b/xmlCustom.go new file mode 100644 index 0000000000..c0c1c5211d --- /dev/null +++ b/xmlCustom.go @@ -0,0 +1,127 @@ +// Copyright 2016 - 2025 The excelize Authors. All rights reserved. Use of +// this source code is governed by a BSD-style license that can be found in +// the LICENSE file. +// +// Package excelize providing a set of functions that allow you to write to and +// read from XLAM / XLSM / XLSX / XLTM / XLTX files. Supports reading and +// writing spreadsheet documents generated by Microsoft Excelâ„¢ 2007 and later. +// Supports complex components by high compatibility, and provided streaming +// API for generating or reading data from a worksheet with huge amounts of +// data. This library needs Go version 1.23 or later. + +package excelize + +import "encoding/xml" + +// xlsxCustomProperties directly maps the element for the custom file properties +// part, that represents additional information. The information can be used as +// metadata for XML. +type xlsxCustomProperties struct { + XMLName xml.Name `xml:"http://schemas.openxmlformats.org/officeDocument/2006/custom-properties Properties"` + Vt string `xml:"xmlns:vt,attr"` + Property []xlsxProperty `xml:"property"` +} + +// xlsxProperty directly maps the element specifies a single custom file +// property. Custom file property type is defined through child elements in the +// File Properties Variant Type namespace. Custom file property value can be set +// by setting the appropriate Variant Type child element value. +type xlsxProperty struct { + XMLName xml.Name `xml:"property"` + FmtID string `xml:"fmtid,attr"` + PID int `xml:"pid,attr"` + Name string `xml:"name,attr,omitempty"` + LinkTarget string `xml:"linkTarget,attr,omitempty"` + Vector *string `xml:"vt:vector"` + Array *string `xml:"vt:array"` + Blob *string `xml:"vt:blob"` + Oblob *string `xml:"vt:oblob"` + Empty *string `xml:"vt:empty"` + Null *string `xml:"vt:null"` + I1 *int8 `xml:"vt:i1"` + I2 *int16 `xml:"vt:i2"` + I4 *int32 `xml:"vt:i4"` + I8 *int64 `xml:"vt:i8"` + Int *int `xml:"vt:int"` + Ui1 *uint8 `xml:"vt:ui1"` + Ui2 *uint16 `xml:"vt:ui2"` + Ui4 *uint32 `xml:"vt:ui4"` + Ui8 *uint64 `xml:"vt:ui8"` + Uint *uint `xml:"vt:uint"` + R4 *float32 `xml:"vt:r4"` + R8 *float64 `xml:"vt:r8"` + Decimal *string `xml:"vt:decimal"` + Lpstr *string `xml:"vt:lpstr"` + Lpwstr *string `xml:"vt:lpwstr"` + Bstr *string `xml:"vt:bstr"` + Date *string `xml:"vt:date"` + FileTime *string `xml:"vt:filetime"` + Bool *bool `xml:"vt:bool"` + Cy *string `xml:"vt:cy"` + Error *string `xml:"vt:error"` + Stream *string `xml:"vt:stream"` + Ostream *string `xml:"vt:ostream"` + Storage *string `xml:"vt:storage"` + Ostorage *string `xml:"vt:ostorage"` + Vstream *string `xml:"vt:vstream"` + ClsID *string `xml:"vt:clsid"` +} + +// decodeCustomProperties specifies to an OOXML document custom properties. +// decodeCustomProperties just for deserialization. +type decodeCustomProperties struct { + XMLName xml.Name `xml:"http://schemas.openxmlformats.org/officeDocument/2006/custom-properties Properties"` + Vt string `xml:"xmlns:vt,attr"` + Property []decodeProperty `xml:"property"` +} + +// decodeProperty specifies to an OOXML document custom property. decodeProperty +// just for deserialization. +type decodeProperty struct { + XMLName xml.Name `xml:"property"` + FmtID string `xml:"fmtid,attr"` + PID int `xml:"pid,attr"` + Name string `xml:"name,attr,omitempty"` + LinkTarget string `xml:"linkTarget,attr,omitempty"` + Vector *string `xml:"vector"` + Array *string `xml:"array"` + Blob *string `xml:"blob"` + Oblob *string `xml:"oblob"` + Empty *string `xml:"empty"` + Null *string `xml:"null"` + I1 *int8 `xml:"i1"` + I2 *int16 `xml:"i2"` + I4 *int32 `xml:"i4"` + I8 *int64 `xml:"i8"` + Int *int `xml:"int"` + Ui1 *uint8 `xml:"ui1"` + Ui2 *uint16 `xml:"ui2"` + Ui4 *uint32 `xml:"ui4"` + Ui8 *uint64 `xml:"ui8"` + Uint *uint `xml:"uint"` + R4 *float32 `xml:"r4"` + R8 *float64 `xml:"r8"` + Decimal *string `xml:"decimal"` + Lpstr *string `xml:"lpstr"` + Lpwstr *string `xml:"lpwstr"` + Bstr *string `xml:"bstr"` + Date *string `xml:"date"` + FileTime *string `xml:"filetime"` + Bool *bool `xml:"bool"` + Cy *string `xml:"cy"` + Error *string `xml:"error"` + Stream *string `xml:"stream"` + Ostream *string `xml:"ostream"` + Storage *string `xml:"storage"` + Ostorage *string `xml:"ostorage"` + Vstream *string `xml:"vstream"` + ClsID *string `xml:"clsid"` +} + +// CustomProperty directly maps the custom property of the workbook. The value +// date type may be one of the following: int32, float64, string, bool, +// time.Time, or nil. +type CustomProperty struct { + Name string + Value interface{} +}