Skip to content

Commit d434acd

Browse files
authored
This closes qax-os#2146, introduce 2 new functions GetCustomProps and SetCustomProps (qax-os#2147)
- Add new CustomProperty data type - Support setting excel custom properties - Update unit tests
1 parent 9fe3a92 commit d434acd

File tree

9 files changed

+359
-25
lines changed

9 files changed

+359
-25
lines changed

docProps.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
"encoding/xml"
1717
"io"
1818
"reflect"
19+
"slices"
20+
"time"
1921
)
2022

2123
// SetAppProps provides a function to set document application properties. The
@@ -236,3 +238,117 @@ func (f *File) GetDocProps() (ret *DocProperties, err error) {
236238
}
237239
return
238240
}
241+
242+
// SetCustomProps provides a function to set custom file properties by given
243+
// property name and value. If the property name already exists, it will be
244+
// updated, otherwise a new property will be added. The value can be of type
245+
// int32, float64, bool, string, time.Time or nil. The property will be delete
246+
// if the value is nil. The function returns an error if the property value is
247+
// not of the correct type.
248+
func (f *File) SetCustomProps(prop CustomProperty) error {
249+
if prop.Name == "" {
250+
return ErrParameterInvalid
251+
}
252+
props := new(decodeCustomProperties)
253+
if err := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(f.readXML(defaultXMLPathDocPropsCustom)))).
254+
Decode(props); err != nil && err != io.EOF {
255+
return err
256+
}
257+
customProps := xlsxCustomProperties{Vt: NameSpaceDocumentPropertiesVariantTypes.Value}
258+
idx, pID := -1, 1
259+
for i := range props.Property {
260+
p := new(xlsxProperty)
261+
setPtrFields(reflect.ValueOf(&props.Property[i]).Elem(), reflect.ValueOf(p).Elem())
262+
if pID < props.Property[i].PID {
263+
pID = props.Property[i].PID
264+
}
265+
if props.Property[i].Name == prop.Name {
266+
idx = i
267+
}
268+
customProps.Property = append(customProps.Property, *p)
269+
}
270+
if idx != -1 && prop.Value == nil {
271+
customProps.Property = slices.Delete(customProps.Property, idx, idx+1)
272+
}
273+
if prop.Value != nil {
274+
property := xlsxProperty{Name: prop.Name, FmtID: EXtURICustomPropertyFmtID}
275+
if err := property.setCustomProps(prop.Value); err != nil {
276+
return err
277+
}
278+
if idx != -1 {
279+
property.PID = customProps.Property[idx].PID
280+
customProps.Property[idx] = property
281+
} else {
282+
property.PID = pID + 1
283+
customProps.Property = append(customProps.Property, property)
284+
}
285+
}
286+
_ = f.addRels(defaultXMLPathRels, SourceRelationshipCustomProperties, defaultXMLPathDocPropsCustom, "")
287+
if err := f.addContentTypePart(0, "customProperties"); err != nil {
288+
return err
289+
}
290+
output, err := xml.Marshal(customProps)
291+
f.saveFileList(defaultXMLPathDocPropsCustom, output)
292+
return err
293+
}
294+
295+
// setCustomProps sets the custom property value based on its type.
296+
func (prop *xlsxProperty) setCustomProps(value interface{}) error {
297+
switch v := value.(type) {
298+
case int32:
299+
prop.I4 = &v
300+
case float64:
301+
prop.R8 = float64Ptr(v)
302+
case bool:
303+
prop.Bool = boolPtr(v)
304+
case string:
305+
prop.Lpwstr = stringPtr(value.(string))
306+
case time.Time:
307+
prop.FileTime = stringPtr(value.(time.Time).Format(time.RFC3339))
308+
default:
309+
return ErrParameterInvalid
310+
}
311+
return nil
312+
}
313+
314+
// GetCustomProps provides a function to get custom file properties.
315+
func (f *File) GetCustomProps() ([]CustomProperty, error) {
316+
var customProps []CustomProperty
317+
props := new(decodeCustomProperties)
318+
if err := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(f.readXML(defaultXMLPathDocPropsCustom)))).
319+
Decode(props); err != nil && err != io.EOF {
320+
return customProps, err
321+
}
322+
for _, p := range props.Property {
323+
prop := CustomProperty{Name: p.Name}
324+
var err error
325+
if prop.Value, err = p.getCustomProps(); err != nil {
326+
return customProps, err
327+
}
328+
customProps = append(customProps, prop)
329+
}
330+
return customProps, nil
331+
}
332+
333+
// getCustomProps gets the custom property value based on its type.
334+
func (p *decodeProperty) getCustomProps() (interface{}, error) {
335+
s := reflect.ValueOf(p).Elem()
336+
for i := range s.NumField() {
337+
if 11 <= i && i <= 20 && !s.Field(i).IsNil() {
338+
return int32(s.Field(i).Elem().Int()), nil // Field vt:i1 to vt:uint
339+
}
340+
if 21 <= i && i <= 22 && !s.Field(i).IsNil() {
341+
return s.Field(i).Elem().Float(), nil // Field vt:r4 to vt:r8
342+
}
343+
if p.Bool != nil {
344+
return *p.Bool, nil
345+
}
346+
if p.Lpwstr != nil {
347+
return *p.Lpwstr, nil
348+
}
349+
if p.FileTime != nil {
350+
return time.Parse(time.RFC3339, *p.FileTime)
351+
}
352+
}
353+
return nil, nil
354+
}

docProps_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
package excelize
1313

1414
import (
15+
"fmt"
1516
"path/filepath"
17+
"slices"
1618
"testing"
19+
"time"
1720

1821
"github.com/stretchr/testify/assert"
1922
)
@@ -116,3 +119,70 @@ func TestGetDocProps(t *testing.T) {
116119
_, err = f.GetDocProps()
117120
assert.EqualError(t, err, "XML syntax error on line 1: invalid UTF-8")
118121
}
122+
123+
func TestCustomProps(t *testing.T) {
124+
f := NewFile()
125+
expected := []CustomProperty{
126+
{Name: "Text Prop", Value: "text"},
127+
{Name: "Boolean Prop 1", Value: true},
128+
{Name: "Boolean Prop 2", Value: false},
129+
{Name: "Number Prop 1", Value: -123.456},
130+
{Name: "Number Prop 2", Value: int32(1)},
131+
{Name: "Date Prop", Value: time.Date(2021, time.September, 11, 0, 0, 0, 0, time.UTC)},
132+
}
133+
for _, prop := range expected {
134+
assert.NoError(t, f.SetCustomProps(prop))
135+
}
136+
props, err := f.GetCustomProps()
137+
assert.NoError(t, err)
138+
assert.Equal(t, expected, props)
139+
140+
// Test delete custom property
141+
assert.NoError(t, f.SetCustomProps(CustomProperty{Name: "Boolean Prop 1", Value: nil}))
142+
props, err = f.GetCustomProps()
143+
assert.NoError(t, err)
144+
expected = slices.Delete(expected, 1, 2)
145+
assert.Equal(t, expected, props)
146+
147+
// Test change custom property value data type
148+
assert.NoError(t, f.SetCustomProps(CustomProperty{Name: "Boolean Prop 2", Value: "true"}))
149+
props, err = f.GetCustomProps()
150+
assert.NoError(t, err)
151+
assert.Equal(t, props[1].Value, "true")
152+
153+
// Test set custom property with unsupported value data type
154+
assert.Equal(t, ErrParameterInvalid, f.SetCustomProps(CustomProperty{Name: "Boolean Prop 2", Value: 1}))
155+
156+
assert.NoError(t, f.SaveAs(filepath.Join("test", "TestSetCustomProps.xlsx")))
157+
assert.NoError(t, f.Close())
158+
159+
// Test set custom property without property name
160+
f = NewFile()
161+
assert.Equal(t, ErrParameterInvalid, f.SetCustomProps(CustomProperty{}))
162+
163+
// Test set custom property with unsupported charset
164+
f.Pkg.Store(defaultXMLPathDocPropsCustom, MacintoshCyrillicCharset)
165+
assert.EqualError(t, f.SetCustomProps(CustomProperty{Name: "Prop"}), "XML syntax error on line 1: invalid UTF-8")
166+
167+
// Test get custom property with unsupported charset
168+
_, err = f.GetCustomProps()
169+
assert.EqualError(t, err, "XML syntax error on line 1: invalid UTF-8")
170+
171+
// Test set custom property with unsupported charset content types
172+
f = NewFile()
173+
f.ContentTypes = nil
174+
f.Pkg.Store(defaultXMLPathContentTypes, MacintoshCyrillicCharset)
175+
assert.EqualError(t, f.SetCustomProps(CustomProperty{Name: "Prop"}), "XML syntax error on line 1: invalid UTF-8")
176+
177+
// Test get custom property with unsupported charset
178+
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)))
179+
_, err = f.GetCustomProps()
180+
assert.EqualError(t, err, "parsing time \"x\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"x\" as \"2006\"")
181+
182+
// Test get custom property with unsupported value data type
183+
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)))
184+
props, err = f.GetCustomProps()
185+
assert.Equal(t, []CustomProperty{{Name: "Prop"}}, props)
186+
assert.NoError(t, err)
187+
assert.NoError(t, f.Close())
188+
}

excelize.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,8 @@ func (f *File) setRels(rID, relPath, relType, target, targetMode string) int {
419419
// relationship type, target and target mode.
420420
func (f *File) addRels(relPath, relType, target, targetMode string) int {
421421
uniqPart := map[string]string{
422-
SourceRelationshipSharedStrings: "/xl/sharedStrings.xml",
422+
SourceRelationshipCustomProperties: "/docProps/custom.xml",
423+
SourceRelationshipSharedStrings: "/xl/sharedStrings.xml",
423424
}
424425
rels, _ := f.relsReader(relPath)
425426
if rels == nil {

file.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import (
3131
// f := NewFile()
3232
func NewFile(opts ...Options) *File {
3333
f := newFile()
34-
f.Pkg.Store("_rels/.rels", []byte(xml.Header+templateRels))
34+
f.Pkg.Store(defaultXMLPathRels, []byte(xml.Header+templateRels))
3535
f.Pkg.Store(defaultXMLPathDocPropsApp, []byte(xml.Header+templateDocpropsApp))
3636
f.Pkg.Store(defaultXMLPathDocPropsCore, []byte(xml.Header+templateDocpropsCore))
3737
f.Pkg.Store(defaultXMLPathWorkbookRels, []byte(xml.Header+templateWorkbookRels))

lib.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,19 @@ func assignFieldValue(field string, immutable, mutable reflect.Value) {
892892
}
893893
}
894894

895+
// setPtrFields assigns the fields of the immutable struct to the mutable
896+
// struct. The fields name of the immutable struct must match the field names of
897+
// the mutable struct.
898+
func setPtrFields(immutable, mutable reflect.Value) {
899+
for i := range immutable.NumField() {
900+
srcField := immutable.Type().Field(i)
901+
dstField := mutable.FieldByName(srcField.Name)
902+
if dstField.IsValid() && dstField.CanSet() && dstField.Type() == immutable.Field(i).Type() {
903+
dstField.Set(immutable.Field(i))
904+
}
905+
}
906+
}
907+
895908
// setNoPtrFieldsVal assigns values from the pointer or no-pointer structs
896909
// fields (immutable) value to no-pointer struct field.
897910
func setNoPtrFieldsVal(fields []string, immutable, mutable reflect.Value) {

sheet_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,14 +524,14 @@ func TestWorksheetWriter(t *testing.T) {
524524

525525
func TestGetWorkbookPath(t *testing.T) {
526526
f := NewFile()
527-
f.Pkg.Delete("_rels/.rels")
527+
f.Pkg.Delete(defaultXMLPathRels)
528528
assert.Empty(t, f.getWorkbookPath())
529529
}
530530

531531
func TestGetWorkbookRelsPath(t *testing.T) {
532532
f := NewFile()
533533
f.Pkg.Delete("xl/_rels/.rels")
534-
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>`))
534+
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>`))
535535
assert.Equal(t, "_rels/workbook.xml.rels", f.getWorkbookRelsPath())
536536
}
537537

templates.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ var (
4343
// Source relationship and namespace.
4444
const (
4545
ContentTypeAddinMacro = "application/vnd.ms-excel.addin.macroEnabled.main+xml"
46+
ContentTypeCustomProperties = "application/vnd.openxmlformats-officedocument.custom-properties+xml"
4647
ContentTypeDrawing = "application/vnd.openxmlformats-officedocument.drawing+xml"
4748
ContentTypeDrawingML = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"
4849
ContentTypeMacro = "application/vnd.ms-excel.sheet.macroEnabled.main+xml"
@@ -71,6 +72,7 @@ const (
7172
SourceRelationshipChart = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"
7273
SourceRelationshipChartsheet = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet"
7374
SourceRelationshipComments = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
75+
SourceRelationshipCustomProperties = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"
7476
SourceRelationshipDialogsheet = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/dialogsheet"
7577
SourceRelationshipDrawingML = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing"
7678
SourceRelationshipDrawingVML = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing"
@@ -102,6 +104,7 @@ const (
102104
ExtURICalcFeatures = "{B58B0392-4F1F-4190-BB64-5DF3571DCE5F}"
103105
ExtURIConditionalFormattingRuleID = "{B025F937-C7B1-47D3-B67F-A62EFF666E3E}"
104106
ExtURIConditionalFormattings = "{78C0D931-6437-407d-A8EE-F0AAD7539E65}"
107+
EXtURICustomPropertyFmtID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"
105108
ExtURIDataField = "{E15A36E0-9728-4E99-A89B-3F7291B0FE68}"
106109
ExtURIDataModel = "{FCE2AD5D-F65C-4FA6-A056-5C36A1767C68}"
107110
ExtURIDataValidations = "{CCE6A557-97BC-4b89-ADB6-D9C93CAAB3DF}"
@@ -278,6 +281,8 @@ const (
278281
defaultXMLPathContentTypes = "[Content_Types].xml"
279282
defaultXMLPathDocPropsApp = "docProps/app.xml"
280283
defaultXMLPathDocPropsCore = "docProps/core.xml"
284+
defaultXMLPathDocPropsCustom = "docProps/custom.xml"
285+
defaultXMLPathRels = "_rels/.rels"
281286
defaultXMLPathSharedStrings = "xl/sharedStrings.xml"
282287
defaultXMLPathStyles = "xl/styles.xml"
283288
defaultXMLPathTheme = "xl/theme/theme1.xml"

workbook.go

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ func (f *File) setWorkbook(name string, sheetID, rid int) {
198198
// getWorkbookPath provides a function to get the path of the workbook.xml in
199199
// the spreadsheet.
200200
func (f *File) getWorkbookPath() (path string) {
201-
if rels, _ := f.relsReader("_rels/.rels"); rels != nil {
201+
if rels, _ := f.relsReader(defaultXMLPathRels); rels != nil {
202202
rels.mu.Lock()
203203
defer rels.mu.Unlock()
204204
for _, rel := range rels.Relationships {
@@ -364,28 +364,30 @@ func (f *File) addContentTypePart(index int, contentType string) error {
364364
"drawings": f.setContentTypePartImageExtensions,
365365
}
366366
partNames := map[string]string{
367-
"chart": "/xl/charts/chart" + strconv.Itoa(index) + ".xml",
368-
"chartsheet": "/xl/chartsheets/sheet" + strconv.Itoa(index) + ".xml",
369-
"comments": "/xl/comments" + strconv.Itoa(index) + ".xml",
370-
"drawings": "/xl/drawings/drawing" + strconv.Itoa(index) + ".xml",
371-
"table": "/xl/tables/table" + strconv.Itoa(index) + ".xml",
372-
"pivotTable": "/xl/pivotTables/pivotTable" + strconv.Itoa(index) + ".xml",
373-
"pivotCache": "/xl/pivotCache/pivotCacheDefinition" + strconv.Itoa(index) + ".xml",
374-
"sharedStrings": "/xl/sharedStrings.xml",
375-
"slicer": "/xl/slicers/slicer" + strconv.Itoa(index) + ".xml",
376-
"slicerCache": "/xl/slicerCaches/slicerCache" + strconv.Itoa(index) + ".xml",
367+
"chart": "/xl/charts/chart" + strconv.Itoa(index) + ".xml",
368+
"chartsheet": "/xl/chartsheets/sheet" + strconv.Itoa(index) + ".xml",
369+
"comments": "/xl/comments" + strconv.Itoa(index) + ".xml",
370+
"customProperties": "/docProps/custom.xml",
371+
"drawings": "/xl/drawings/drawing" + strconv.Itoa(index) + ".xml",
372+
"table": "/xl/tables/table" + strconv.Itoa(index) + ".xml",
373+
"pivotTable": "/xl/pivotTables/pivotTable" + strconv.Itoa(index) + ".xml",
374+
"pivotCache": "/xl/pivotCache/pivotCacheDefinition" + strconv.Itoa(index) + ".xml",
375+
"sharedStrings": "/xl/sharedStrings.xml",
376+
"slicer": "/xl/slicers/slicer" + strconv.Itoa(index) + ".xml",
377+
"slicerCache": "/xl/slicerCaches/slicerCache" + strconv.Itoa(index) + ".xml",
377378
}
378379
contentTypes := map[string]string{
379-
"chart": ContentTypeDrawingML,
380-
"chartsheet": ContentTypeSpreadSheetMLChartsheet,
381-
"comments": ContentTypeSpreadSheetMLComments,
382-
"drawings": ContentTypeDrawing,
383-
"table": ContentTypeSpreadSheetMLTable,
384-
"pivotTable": ContentTypeSpreadSheetMLPivotTable,
385-
"pivotCache": ContentTypeSpreadSheetMLPivotCacheDefinition,
386-
"sharedStrings": ContentTypeSpreadSheetMLSharedStrings,
387-
"slicer": ContentTypeSlicer,
388-
"slicerCache": ContentTypeSlicerCache,
380+
"chart": ContentTypeDrawingML,
381+
"chartsheet": ContentTypeSpreadSheetMLChartsheet,
382+
"comments": ContentTypeSpreadSheetMLComments,
383+
"customProperties": ContentTypeCustomProperties,
384+
"drawings": ContentTypeDrawing,
385+
"table": ContentTypeSpreadSheetMLTable,
386+
"pivotTable": ContentTypeSpreadSheetMLPivotTable,
387+
"pivotCache": ContentTypeSpreadSheetMLPivotCacheDefinition,
388+
"sharedStrings": ContentTypeSpreadSheetMLSharedStrings,
389+
"slicer": ContentTypeSlicer,
390+
"slicerCache": ContentTypeSlicerCache,
389391
}
390392
s, ok := setContentType[contentType]
391393
if ok {

0 commit comments

Comments
 (0)