diff --git a/_examples/spreadsheet/comments/comments.xlsx b/_examples/spreadsheet/comments/comments.xlsx new file mode 100644 index 00000000..01f2d0db Binary files /dev/null and b/_examples/spreadsheet/comments/comments.xlsx differ diff --git a/_examples/spreadsheet/comments/main.go b/_examples/spreadsheet/comments/main.go new file mode 100644 index 00000000..83a892c3 --- /dev/null +++ b/_examples/spreadsheet/comments/main.go @@ -0,0 +1,23 @@ +// Copyright 2017 Baliance. All rights reserved. +package main + +import ( + "log" + + "baliance.com/gooxml/spreadsheet" +) + +func main() { + ss := spreadsheet.New() + sheet := ss.AddSheet() + + sheet.Cell("A1").SetString("Hello World!") + sheet.Comments().AddCommentWithStyle("A1", "Gopher", "This looks interesting.") + sheet.Comments().AddCommentWithStyle("C10", "Gopher", "This is a different comment.") + + if err := ss.Validate(); err != nil { + log.Fatalf("error validating sheet: %s", err) + } + + ss.SaveToFile("comments.xlsx") +} diff --git a/creator_test.go b/creator_test.go index e501a55d..1a259597 100644 --- a/creator_test.go +++ b/creator_test.go @@ -69,8 +69,7 @@ func TestRawEncode(t *testing.T) { end := strings.LastIndex(xmlStr, "") gotRaw := xmlStr[beg+20 : end] - - exp := "" + exp := "" if gotRaw != exp { t.Errorf("expected\n%q\ngot\n%q\n", exp, gotRaw) } diff --git a/document/document.go b/document/document.go index 00ea6c0b..1632c45d 100644 --- a/document/document.go +++ b/document/document.go @@ -329,9 +329,11 @@ func Read(r io.ReaderAt, size int64) (*Document, error) { decMap := zippkg.DecodeMap{} decMap.SetOnNewRelationshipFunc(doc.onNewRelationship) // we should discover all contents by starting with these two files - decMap.AddTarget(gooxml.ContentTypesFilename, doc.ContentTypes.X()) - decMap.AddTarget(gooxml.BaseRelsFilename, doc.Rels.X()) - decMap.Decode(files) + decMap.AddTarget(zippkg.Target{Path: gooxml.ContentTypesFilename, Ifc: doc.ContentTypes.X()}) + decMap.AddTarget(zippkg.Target{Path: gooxml.BaseRelsFilename, Ifc: doc.Rels.X()}) + if err := decMap.Decode(files); err != nil { + return nil, err + } for _, f := range files { if f == nil { @@ -467,23 +469,23 @@ func (d *Document) FormFields() []FormField { return ret } -func (doc *Document) onNewRelationship(decMap *zippkg.DecodeMap, target, typ string, files []*zip.File, rel *relationships.Relationship) error { +func (doc *Document) onNewRelationship(decMap *zippkg.DecodeMap, target, typ string, files []*zip.File, rel *relationships.Relationship, src zippkg.Target) error { dt := gooxml.DocTypeDocument switch typ { case gooxml.OfficeDocumentType: doc.x = wml.NewDocument() - decMap.AddTarget(target, doc.x) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: doc.x}) // look for the document relationships file as well - decMap.AddTarget(zippkg.RelationsPathFor(target), doc.docRels.X()) + decMap.AddTarget(zippkg.Target{Path: zippkg.RelationsPathFor(target), Ifc: doc.docRels.X()}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.CorePropertiesType: - decMap.AddTarget(target, doc.CoreProperties.X()) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: doc.CoreProperties.X()}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.ExtendedPropertiesType: - decMap.AddTarget(target, doc.AppProperties.X()) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: doc.AppProperties.X()}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.ThumbnailType: @@ -507,55 +509,55 @@ func (doc *Document) onNewRelationship(decMap *zippkg.DecodeMap, target, typ str } case gooxml.SettingsType: - decMap.AddTarget(target, doc.Settings.X()) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: doc.Settings.X()}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.NumberingType: doc.Numbering = NewNumbering() - decMap.AddTarget(target, doc.Numbering.X()) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: doc.Numbering.X()}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.StylesType: doc.Styles.Clear() - decMap.AddTarget(target, doc.Styles.X()) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: doc.Styles.X()}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.HeaderType: hdr := wml.NewHdr() + decMap.AddTarget(zippkg.Target{Path: target, Ifc: hdr, Index: uint32(len(doc.headers))}) doc.headers = append(doc.headers, hdr) - decMap.AddTarget(target, hdr) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, len(doc.headers)) case gooxml.FooterType: ftr := wml.NewFtr() + decMap.AddTarget(zippkg.Target{Path: target, Ifc: ftr, Index: uint32(len(doc.footers))}) doc.footers = append(doc.footers, ftr) - decMap.AddTarget(target, ftr) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, len(doc.footers)) case gooxml.ThemeType: thm := dml.NewTheme() + decMap.AddTarget(zippkg.Target{Path: target, Ifc: thm, Index: uint32(len(doc.themes))}) doc.themes = append(doc.themes, thm) - decMap.AddTarget(target, thm) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, len(doc.themes)) case gooxml.WebSettingsType: doc.webSettings = wml.NewWebSettings() - decMap.AddTarget(target, doc.webSettings) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: doc.webSettings}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.FontTableType: doc.fontTable = wml.NewFonts() - decMap.AddTarget(target, doc.fontTable) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: doc.fontTable}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.EndNotesType: doc.endNotes = wml.NewEndnotes() - decMap.AddTarget(target, doc.endNotes) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: doc.endNotes}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.FootNotesType: doc.footNotes = wml.NewFootnotes() - decMap.AddTarget(target, doc.footNotes) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: doc.footNotes}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.ImageType: diff --git a/filenames.go b/filenames.go index ce2a0ce8..1509b700 100644 --- a/filenames.go +++ b/filenames.go @@ -47,6 +47,11 @@ func RelativeFilename(dt DocType, typ string, index int) string { return fmt.Sprintf("../charts/chart%d.xml", index) case DrawingType, DrawingContentType: return fmt.Sprintf("../drawings/drawing%d.xml", index) + case CommentsType, CommentsContentType: + return fmt.Sprintf("../comments%d.xml", index) + + case VMLDrawingType, VMLDrawingContentType: + return fmt.Sprintf("../drawings/vmlDrawing%d.vml", index) case ThemeType, ThemeContentType: return fmt.Sprintf("theme/theme%d.xml", index) @@ -128,6 +133,8 @@ func AbsoluteFilename(dt DocType, typ string, index int) string { return "xl/styles.xml" case DocTypeDocument: return "word/styles.xml" + default: + log.Printf("unsupported type %s pair and %v", typ, dt) } case ChartType, ChartContentType: @@ -145,6 +152,23 @@ func AbsoluteFilename(dt DocType, typ string, index int) string { default: log.Printf("unsupported type %s pair and %v", typ, dt) } + + case CommentsType, CommentsContentType: + switch dt { + case DocTypeSpreadsheet: + return fmt.Sprintf("xl/comments%d.xml", index) + default: + log.Printf("unsupported type %s pair and %v", typ, dt) + } + + case VMLDrawingType, VMLDrawingContentType: + switch dt { + case DocTypeSpreadsheet: + return fmt.Sprintf("xl/drawings/vmlDrawing%d.vml", index) + default: + log.Fatalf("unsupported type %s pair and %v", typ, dt) + } + // SML case WorksheetType, WorksheetContentType: return fmt.Sprintf("xl/worksheets/sheet%d.xml", index) diff --git a/optional.go b/optional.go index 354ae19a..4d691581 100644 --- a/optional.go +++ b/optional.go @@ -9,6 +9,12 @@ package gooxml import "fmt" +// Float32 returns a copy of v as a pointer. +func Float32(v float32) *float32 { + x := v + return &x +} + // Float64 returns a copy of v as a pointer. func Float64(v float64) *float64 { x := v diff --git a/schemas.go b/schemas.go index 38ad30dd..a7605919 100644 --- a/schemas.go +++ b/schemas.go @@ -10,20 +10,20 @@ package gooxml // Consts for content types used throughout the package const ( // Common - OfficeDocumentType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" - StylesType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" - ThemeType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" - ThemeContentType = "application/vnd.openxmlformats-officedocument.theme+xml" - SettingsType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" - ImageType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" - CommentsType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" - ThumbnailType = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail" - DrawingType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" - DrawingContentType = "application/vnd.openxmlformats-officedocument.drawing+xml" - ChartType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" - ChartContentType = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml" - HyperLinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" - + OfficeDocumentType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" + StylesType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" + ThemeType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" + ThemeContentType = "application/vnd.openxmlformats-officedocument.theme+xml" + SettingsType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" + ImageType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" + CommentsType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" + CommentsContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml" + ThumbnailType = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail" + DrawingType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" + DrawingContentType = "application/vnd.openxmlformats-officedocument.drawing+xml" + ChartType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" + ChartContentType = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml" + HyperLinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" ExtendedPropertiesType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" CorePropertiesType = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" @@ -50,4 +50,8 @@ const ( SlideMasterContentType = "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml" SlideLayoutType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" SlideLayoutContentType = "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml" + + // VML + VMLDrawingType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" + VMLDrawingContentType = "application/vnd.openxmlformats-officedocument.vmlDrawing" ) diff --git a/spreadsheet/comment.go b/spreadsheet/comment.go new file mode 100644 index 00000000..1fd58116 --- /dev/null +++ b/spreadsheet/comment.go @@ -0,0 +1,50 @@ +// Copyright 2017 Baliance. All rights reserved. +// +// Use of this source code is governed by the terms of the Affero GNU General +// Public License version 3.0 as published by the Free Software Foundation and +// appearing in the file LICENSE included in the packaging of this file. A +// commercial license can be purchased by contacting sales@baliance.com. + +package spreadsheet + +import sml "baliance.com/gooxml/schema/schemas.openxmlformats.org/spreadsheetml" + +// Comment is a single comment within a sheet. +type Comment struct { + w *Workbook + x *sml.CT_Comment + cmts *sml.Comments +} + +// X returns the inner wrapped XML type. +func (c Comment) X() *sml.CT_Comment { + return c.x +} + +// CellReference returns the cell reference within a sheet that a comment refers +// to (e.g. "A1") +func (c Comment) CellReference() string { + return c.x.RefAttr +} + +// SetCellReference sets the cell reference within a sheet that a comment refers +// to (e.g. "A1") +func (c Comment) SetCellReference(cellRef string) { + c.x.RefAttr = cellRef +} + +// Author returns the author of the comment +func (c Comment) Author() string { + if c.x.AuthorIdAttr < uint32(len(c.cmts.Authors.Author)) { + return c.cmts.Authors.Author[c.x.AuthorIdAttr] + } + return "" +} + +// SetAuthor sets the author of the comment. If the comment body contains the +// author's name (as is the case with Excel and Comments.AddCommentWithStyle, it +// will not be changed). This method only changes the metadata author of the +// comment. +func (c Comment) SetAuthor(author string) { + c.x.AuthorIdAttr = Comments{c.w, c.cmts}.getOrCreateAuthor(author) +} diff --git a/spreadsheet/comments.go b/spreadsheet/comments.go new file mode 100644 index 00000000..fb74f470 --- /dev/null +++ b/spreadsheet/comments.go @@ -0,0 +1,85 @@ +// Copyright 2017 Baliance. All rights reserved. +// +// Use of this source code is governed by the terms of the Affero GNU General +// Public License version 3.0 as published by the Free Software Foundation and +// appearing in the file LICENSE included in the packaging of this file. A +// commercial license can be purchased by contacting sales@baliance.com. + +package spreadsheet + +import ( + "baliance.com/gooxml/color" + sml "baliance.com/gooxml/schema/schemas.openxmlformats.org/spreadsheetml" + "baliance.com/gooxml/vmldrawing" +) + +// Comments is the container for comments for a single sheet. +type Comments struct { + w *Workbook + x *sml.Comments +} + +// MakeComments constructs a new Comments wrapper. +func MakeComments(w *Workbook, x *sml.Comments) Comments { + return Comments{w, x} +} + +// X returns the inner wrapped XML type. +func (c Comments) X() *sml.Comments { + return c.x +} + +// Comments returns the list of comments for this sheet +func (c Comments) Comments() []Comment { + ret := []Comment{} + for _, cmt := range c.x.CommentList.Comment { + ret = append(ret, Comment{c.w, cmt, c.x}) + } + return ret +} + +func (c Comments) getOrCreateAuthor(author string) uint32 { + for i, knownAuthor := range c.x.Authors.Author { + if knownAuthor == author { + return uint32(i) + } + } + + // didn't find the author, so add a new one + authIdx := uint32(len(c.x.Authors.Author)) + c.x.Authors.Author = append(c.x.Authors.Author, author) + return authIdx +} + +// AddComment adds a new comment and returns a RichText which will contain the +// styled comment text. +func (c Comments) AddComment(cellRef string, author string) RichText { + + cmt := sml.NewCT_Comment() + c.x.CommentList.Comment = append(c.x.CommentList.Comment, cmt) + cmt.RefAttr = cellRef + cmt.AuthorIdAttr = c.getOrCreateAuthor(author) + cmt.Text = sml.NewCT_Rst() + return RichText{cmt.Text} +} + +// AddCommentWithStyle adds a new comment styled in a default way +func (c Comments) AddCommentWithStyle(cellRef string, author string, comment string) { + rt := c.AddComment(cellRef, author) + run := rt.AddRun() + run.SetBold(true) + run.SetSize(10) + run.SetColor(color.Black) + run.SetFont("Calibri") + run.SetText(author + ":") + + run = rt.AddRun() + run.SetSize(10) + run.SetFont("Calibri") + run.SetColor(color.Black) + run.SetText("\r\n" + comment + "\r\n") + + col, rowIdx, _ := ParseCellReference(cellRef) + colIdx := ColumnToIndex(col) + c.w.vmlDrawings[0].Shape = append(c.w.vmlDrawings[0].Shape, vmldrawing.NewCommentShape(int64(colIdx), int64(rowIdx-1))) +} diff --git a/spreadsheet/comments_test.go b/spreadsheet/comments_test.go new file mode 100644 index 00000000..a54f40e0 --- /dev/null +++ b/spreadsheet/comments_test.go @@ -0,0 +1,79 @@ +// Copyright 2017 Baliance. All rights reserved. +// +// Use of this source code is governed by the terms of the Affero GNU General +// Public License version 3.0 as published by the Free Software Foundation and +// appearing in the file LICENSE included in the packaging of this file. A +// commercial license can be purchased by contacting sales@baliance.com. + +package spreadsheet_test + +import ( + "testing" + + "baliance.com/gooxml/spreadsheet" +) + +func TestComments(t *testing.T) { + wb := spreadsheet.New() + sheet := wb.AddSheet() + c := sheet.Comments() + + expRef := "A1" + expAuth := "John Doe" + c.AddCommentWithStyle(expRef, expAuth, "This is my comment") + if c.X().Authors == nil { + t.Fatalf("author should be non-nil") + } + if len(c.X().Authors.Author) != 1 { + t.Errorf("expected one author, got %v", c.X().Authors.Author) + } + + auth := c.X().Authors.Author[0] + if auth != expAuth { + t.Errorf("expected author = %s, got %s", expAuth, auth) + } + if c.X().CommentList == nil { + t.Fatalf("commentlist should be non-nil") + } + if len(c.X().CommentList.Comment) != 1 { + t.Errorf("expected one comment, got %v", c.X().CommentList.Comment) + } + cmt := c.X().CommentList.Comment[0] + if cmt.AuthorIdAttr != 0 { + t.Errorf("expected author ID = 0, got %d", cmt.AuthorIdAttr) + } + if cmt.RefAttr != expRef { + t.Errorf("expected ref = %s, got %s", expRef, cmt.RefAttr) + } +} + +func TestCommentsReusesAuthorIDs(t *testing.T) { + wb := spreadsheet.New() + sheet := wb.AddSheet() + c := sheet.Comments() + + c.AddCommentWithStyle("A1", "foo", "This is my comment") + if c.X().Authors == nil { + t.Fatalf("author should be non-nil") + } + if len(c.X().Authors.Author) != 1 { + t.Errorf("expected one author, got %v", c.X().Authors.Author) + } + + c.AddCommentWithStyle("B1", "foo", "This is another comment") + if c.X().Authors == nil { + t.Fatalf("author should be non-nil") + } + if len(c.X().Authors.Author) != 1 { + t.Errorf("expected one author, got %v", c.X().Authors.Author) + } + + c.AddCommentWithStyle("C1", "bar", "This is the last comment") + if c.X().Authors == nil { + t.Fatalf("author should be non-nil") + } + if len(c.X().Authors.Author) != 2 { + t.Errorf("expected two authors, got %v", c.X().Authors.Author) + } + +} diff --git a/spreadsheet/new.go b/spreadsheet/new.go index f706986d..6a0b3b38 100644 --- a/spreadsheet/new.go +++ b/spreadsheet/new.go @@ -30,8 +30,9 @@ func New() *Workbook { wb.wbRels.AddRelationship(gooxml.RelativeFilename(gooxml.DocTypeSpreadsheet, gooxml.StylesType, 0), gooxml.StylesType) wb.ContentTypes = common.NewContentTypes() + wb.ContentTypes.AddDefault("vml", gooxml.VMLDrawingContentType) wb.ContentTypes.AddOverride(gooxml.AbsoluteFilename(gooxml.DocTypeSpreadsheet, gooxml.OfficeDocumentType, 0), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml") - wb.ContentTypes.AddOverride(gooxml.AbsoluteFilename(gooxml.DocTypeSpreadsheet, gooxml.StylesType, 0), "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml") + wb.ContentTypes.AddOverride(gooxml.AbsoluteFilename(gooxml.DocTypeSpreadsheet, gooxml.StylesType, 0), gooxml.SMLStyleSheetContentType) wb.SharedStrings = NewSharedStrings() wb.ContentTypes.AddOverride(gooxml.AbsoluteFilename(gooxml.DocTypeSpreadsheet, gooxml.SharedStingsType, 0), gooxml.SharedStringsContentType) diff --git a/spreadsheet/read.go b/spreadsheet/read.go index 0e4b40a6..7f3c59a2 100644 --- a/spreadsheet/read.go +++ b/spreadsheet/read.go @@ -37,9 +37,11 @@ func Read(r io.ReaderAt, size int64) (*Workbook, error) { decMap := zippkg.DecodeMap{} decMap.SetOnNewRelationshipFunc(wb.onNewRelationship) // we should discover all contents by starting with these two files - decMap.AddTarget(gooxml.ContentTypesFilename, wb.ContentTypes.X()) - decMap.AddTarget(gooxml.BaseRelsFilename, wb.Rels.X()) - decMap.Decode(files) + decMap.AddTarget(zippkg.Target{Path: gooxml.ContentTypesFilename, Ifc: wb.ContentTypes.X()}) + decMap.AddTarget(zippkg.Target{Path: gooxml.BaseRelsFilename, Ifc: wb.Rels.X()}) + if err := decMap.Decode(files); err != nil { + return nil, err + } // etra files are things we don't handle yet, or files that happened to have // been in the zip before. We just round-trip them. diff --git a/spreadsheet/sheet.go b/spreadsheet/sheet.go index 0c91aafe..60a6515a 100644 --- a/spreadsheet/sheet.go +++ b/spreadsheet/sheet.go @@ -16,6 +16,7 @@ import ( "baliance.com/gooxml" "baliance.com/gooxml/common" sml "baliance.com/gooxml/schema/schemas.openxmlformats.org/spreadsheetml" + "baliance.com/gooxml/vmldrawing" ) // Sheet is a single sheet within a workbook. @@ -374,3 +375,29 @@ func (s Sheet) Column(idx uint32) Column { colSet.Col = append(colSet.Col, col) return Column{col} } + +// Comments returns the comments for a sheet. +func (s Sheet) Comments() Comments { + for i, wks := range s.w.xws { + if wks == s.x { + if s.w.comments[i] == nil { + s.w.comments[i] = sml.NewComments() + s.w.xwsRels[i].AddAutoRelationship(gooxml.DocTypeSpreadsheet, i+1, gooxml.CommentsType) + s.w.ContentTypes.AddOverride(gooxml.AbsoluteFilename(gooxml.DocTypeSpreadsheet, gooxml.CommentsType, i+1), gooxml.CommentsContentType) + } + if len(s.w.vmlDrawings) == 0 { + s.w.vmlDrawings = append(s.w.vmlDrawings, vmldrawing.NewCommentDrawing()) + vmlID := s.w.xwsRels[i].AddAutoRelationship(gooxml.DocTypeSpreadsheet, 1, gooxml.VMLDrawingType) + if s.x.LegacyDrawing == nil { + s.x.LegacyDrawing = sml.NewCT_LegacyDrawing() + } + s.x.LegacyDrawing.IdAttr = vmlID.ID() + } + return Comments{s.w, s.w.comments[i]} + } + } + + log.Printf("attempted to access comments for non-existent sheet") + // should never occur + return Comments{} +} diff --git a/spreadsheet/testdata/comments.xlsx b/spreadsheet/testdata/comments.xlsx new file mode 100644 index 00000000..add7c103 Binary files /dev/null and b/spreadsheet/testdata/comments.xlsx differ diff --git a/spreadsheet/testdata/simple-1.xlsx b/spreadsheet/testdata/simple-1.xlsx index 0612fa6c..b0e08128 100644 Binary files a/spreadsheet/testdata/simple-1.xlsx and b/spreadsheet/testdata/simple-1.xlsx differ diff --git a/spreadsheet/testdata/simple-2.xlsx b/spreadsheet/testdata/simple-2.xlsx index a4f449a7..e7713b0a 100644 Binary files a/spreadsheet/testdata/simple-2.xlsx and b/spreadsheet/testdata/simple-2.xlsx differ diff --git a/spreadsheet/workbook.go b/spreadsheet/workbook.go index 12fd4c4e..883c4c2e 100644 --- a/spreadsheet/workbook.go +++ b/spreadsheet/workbook.go @@ -19,6 +19,7 @@ import ( "baliance.com/gooxml" "baliance.com/gooxml/common" + "baliance.com/gooxml/vmldrawing" "baliance.com/gooxml/zippkg" dml "baliance.com/gooxml/schema/schemas.openxmlformats.org/drawingml" @@ -36,12 +37,14 @@ type Workbook struct { StyleSheet StyleSheet SharedStrings SharedStrings + comments []*sml.Comments xws []*sml.Worksheet xwsRels []common.Relationships wbRels common.Relationships themes []*dml.Theme drawings []*sd.WsDr drawingRels []common.Relationships + vmlDrawings []*vmldrawing.Container charts []*crt.ChartSpace } @@ -71,9 +74,12 @@ func (wb *Workbook) AddSheet() Sheet { ws.Dimension.RefAttr = "A1" wb.xws = append(wb.xws, ws) wsRel := common.NewRelationships() + wb.xwsRels = append(wb.xwsRels, wsRel) ws.SheetData = sml.NewCT_SheetData() + wb.comments = append(wb.comments, nil) + dt := gooxml.DocTypeSpreadsheet // update the references rid := wb.wbRels.AddAutoRelationship(dt, len(wb.x.Sheets.Sheet), gooxml.WorksheetType) @@ -176,9 +182,20 @@ func (wb *Workbook) Save(w io.Writer) error { zippkg.MarshalXML(z, zippkg.RelationsPathFor(fn), wb.drawingRels[i].X()) } } + for i, drawing := range wb.vmlDrawings { + zippkg.MarshalXML(z, gooxml.AbsoluteFilename(dt, gooxml.VMLDrawingType, i+1), drawing) + // never seen relationships for a VML drawing yet + } + if err := zippkg.MarshalXML(z, gooxml.ContentTypesFilename, wb.ContentTypes.X()); err != nil { return err } + for i, cmt := range wb.comments { + if cmt == nil { + continue + } + zippkg.MarshalXML(z, gooxml.AbsoluteFilename(dt, gooxml.CommentsType, i+1), cmt) + } if err := wb.WriteExtraFiles(z); err != nil { return err @@ -237,52 +254,58 @@ func (wb Workbook) SheetCount() int { return len(wb.xws) } -func (wb *Workbook) onNewRelationship(decMap *zippkg.DecodeMap, target, typ string, files []*zip.File, rel *relationships.Relationship) error { +func (wb *Workbook) onNewRelationship(decMap *zippkg.DecodeMap, target, typ string, files []*zip.File, rel *relationships.Relationship, src zippkg.Target) error { dt := gooxml.DocTypeSpreadsheet switch typ { case gooxml.OfficeDocumentType: wb.x = sml.NewWorkbook() - decMap.AddTarget(target, wb.x) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: wb.x}) // look for the workbook relationships file as well wb.wbRels = common.NewRelationships() - decMap.AddTarget(zippkg.RelationsPathFor(target), wb.wbRels.X()) + decMap.AddTarget(zippkg.Target{Path: zippkg.RelationsPathFor(target), Ifc: wb.wbRels.X()}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.CorePropertiesType: - decMap.AddTarget(target, wb.CoreProperties.X()) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: wb.CoreProperties.X()}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.ExtendedPropertiesType: - decMap.AddTarget(target, wb.AppProperties.X()) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: wb.AppProperties.X()}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.WorksheetType: ws := sml.NewWorksheet() + idx := uint32(len(wb.xws)) wb.xws = append(wb.xws, ws) - decMap.AddTarget(target, ws) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: ws, Index: idx}) // look for worksheet rels wksRel := common.NewRelationships() - decMap.AddTarget(zippkg.RelationsPathFor(target), wksRel.X()) + decMap.AddTarget(zippkg.Target{Path: zippkg.RelationsPathFor(target), Ifc: wksRel.X(), Index: idx}) wb.xwsRels = append(wb.xwsRels, wksRel) + + // add a comments placeholder that will be replaced if we see a comments + // relationship for the current sheet + wb.comments = append(wb.comments, nil) + // fix the relationship target so it points to where we'll save // the worksheet rel.TargetAttr = gooxml.RelativeFilename(dt, typ, len(wb.xws)) case gooxml.StylesType: wb.StyleSheet = NewStyleSheet(wb) - decMap.AddTarget(target, wb.StyleSheet.X()) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: wb.StyleSheet.X()}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.ThemeType: thm := dml.NewTheme() wb.themes = append(wb.themes, thm) - decMap.AddTarget(target, thm) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: thm}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, len(wb.themes)) case gooxml.SharedStingsType: wb.SharedStrings = NewSharedStrings() - decMap.AddTarget(target, wb.SharedStrings.X()) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: wb.SharedStrings.X()}) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, 0) case gooxml.ThumbnailType: @@ -307,17 +330,30 @@ func (wb *Workbook) onNewRelationship(decMap *zippkg.DecodeMap, target, typ stri case gooxml.DrawingType: drawing := sd.NewWsDr() - decMap.AddTarget(target, drawing) + idx := uint32(len(wb.drawings)) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: drawing, Index: idx}) wb.drawings = append(wb.drawings, drawing) drel := common.NewRelationships() - decMap.AddTarget(zippkg.RelationsPathFor(target), drel.X()) + decMap.AddTarget(zippkg.Target{Path: zippkg.RelationsPathFor(target), Ifc: drel.X(), Index: idx}) wb.drawingRels = append(wb.drawingRels, drel) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, len(wb.drawings)) + case gooxml.VMLDrawingType: + vd := vmldrawing.NewContainer() + idx := uint32(len(wb.vmlDrawings)) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: vd, Index: idx}) + wb.vmlDrawings = append(wb.vmlDrawings, vd) + + case gooxml.CommentsType: + wb.comments[src.Index] = sml.NewComments() + decMap.AddTarget(zippkg.Target{Path: target, Ifc: wb.comments[src.Index], Index: src.Index}) + rel.TargetAttr = gooxml.RelativeFilename(dt, typ, len(wb.comments)) + case gooxml.ChartType: chart := crt.NewChartSpace() - decMap.AddTarget(target, chart) + idx := uint32(len(wb.charts)) + decMap.AddTarget(zippkg.Target{Path: target, Ifc: chart, Index: idx}) wb.charts = append(wb.charts, chart) rel.TargetAttr = gooxml.RelativeFilename(dt, typ, len(wb.charts)) diff --git a/spreadsheet/workbook_test.go b/spreadsheet/workbook_test.go index 178d578d..c0211be6 100644 --- a/spreadsheet/workbook_test.go +++ b/spreadsheet/workbook_test.go @@ -208,6 +208,7 @@ func TestAddDefinedName(t *testing.T) { t.Errorf("expected content = %s, got %s", ref, dn.Content()) } } + func ExampleWorkbook_AddDefinedName() { wb := spreadsheet.New() sheet := wb.AddSheet() @@ -216,3 +217,19 @@ func ExampleWorkbook_AddDefinedName() { fmt.Printf("%s refers to %s", productNames.Name(), productNames.Content()) // Output: ProductNames refers to 'Sheet 1'!$A$2:$A$6 } + +func TestOpenComments(t *testing.T) { + wb, err := spreadsheet.Open("./testdata/comments.xlsx") + if err != nil { + t.Fatalf("error opening workbook: %s", err) + } + + sheet := wb.Sheets()[0] + if len(sheet.Comments().Comments()) != 1 { + t.Fatalf("sheet should have returned 1 existing comments") + } + cmt := sheet.Comments().Comments()[0] + if cmt.Author() != "John Doe" { + t.Errorf("error reading comment author") + } +} diff --git a/testhelper/compare.go b/testhelper/compare.go index 1cad5c46..955a9e51 100644 --- a/testhelper/compare.go +++ b/testhelper/compare.go @@ -13,6 +13,7 @@ import ( "bytes" "encoding/xml" "flag" + "fmt" "io/ioutil" "log" "os" @@ -139,6 +140,8 @@ func compareFiles(exp, got *zip.File) func(t *testing.T) { gotAll, _ := ioutil.ReadAll(gf) if !bytes.Equal(expAll, gotAll) { dumpXmlDiff(t, expAll, gotAll) + fmt.Println(string(expAll)) + fmt.Println(string(gotAll)) t.Errorf("mismatched contents %d vs %d", len(expAll), len(gotAll)) } @@ -181,19 +184,19 @@ func dumpXmlDiff(t *testing.T, exp, got []byte) { xmlIndentFile(expF) xmlIndentFile(gotF) - a := exec.Command("diff", "-u", expF, gotF) - outp, err := a.StdoutPipe() + diff := exec.Command("diff", "-u", expF, gotF) + outp, err := diff.StdoutPipe() if err != nil { t.Fatalf("error running xmlindent: %s", err) } defer outp.Close() - errp, err := a.StderrPipe() + errp, err := diff.StderrPipe() if err != nil { t.Fatalf("error running xmlindent: %s", err) } defer errp.Close() - if err := a.Start(); err != nil { + if err := diff.Start(); err != nil { t.Fatalf("error string xmlindent: %s", err) } scanner := bufio.NewScanner(outp) @@ -201,7 +204,7 @@ func dumpXmlDiff(t *testing.T, exp, got []byte) { log.Println(scanner.Text()) } - if err := a.Wait(); err != nil { + if err := diff.Wait(); err != nil { errOutput, _ := ioutil.ReadAll(errp) t.Fatalf("error waiting on xmlindent: %s [%s]", string(errOutput), err) } diff --git a/vmldrawing/commentdrawing.go b/vmldrawing/commentdrawing.go new file mode 100644 index 00000000..c87944f0 --- /dev/null +++ b/vmldrawing/commentdrawing.go @@ -0,0 +1,96 @@ +// Copyright 2017 Baliance. All rights reserved. +// +// Use of this source code is governed by the terms of the Affero GNU General +// Public License version 3.0 as published by the Free Software Foundation and +// appearing in the file LICENSE included in the packaging of this file. A +// commercial license can be purchased by contacting sales@baliance.com. + +package vmldrawing + +import ( + "fmt" + + "baliance.com/gooxml" + "baliance.com/gooxml/schema/urn/schemas_microsoft_com/office/excel" + + st "baliance.com/gooxml/schema/schemas.openxmlformats.org/officeDocument/2006/sharedTypes" + vml "baliance.com/gooxml/schema/urn/schemas_microsoft_com/vml" +) + +// NewCommentDrawing constructs a new comment drawing. +func NewCommentDrawing() *Container { + c := NewContainer() + c.Layout = vml.NewOfcShapelayout() + c.Layout.ExtAttr = vml.ST_ExtEdit + c.Layout.Idmap = vml.NewOfcCT_IdMap() + c.Layout.Idmap.DataAttr = gooxml.String("1") + c.Layout.Idmap.ExtAttr = vml.ST_ExtEdit + + c.ShapeType = vml.NewShapetype() + c.ShapeType.IdAttr = gooxml.String("_x0000_t202") + c.ShapeType.CoordsizeAttr = gooxml.String("21600,21600") + c.ShapeType.SptAttr = gooxml.Float32(202) + c.ShapeType.PathAttr = gooxml.String("m0,0l0,21600,21600,21600,21600,0xe") + + se := vml.NewEG_ShapeElements() + c.ShapeType.EG_ShapeElements = append(c.ShapeType.EG_ShapeElements, se) + se.Path = vml.NewPath() + se.Path.GradientshapeokAttr = st.ST_TrueFalseT + se.Path.ConnecttypeAttr = vml.OfcST_ConnectTypeRect + + return c +} + +// NewCommentShape creates a new comment shape for a given cell index. The +// indices here are zero based. +func NewCommentShape(col, row int64) *vml.Shape { + shape := vml.NewShape() + shape.IdAttr = gooxml.String(fmt.Sprintf("cs_%d_%d", col, row)) + shape.TypeAttr = gooxml.String("#_x0000_t202") + // visibility of the comment box is controlled by this visibility style + shape.StyleAttr = gooxml.String("position:absolute;margin-left:80pt;margin-top:2pt;width:104pt;height:76pt;z-index:1;visibility:hidden") + shape.FillcolorAttr = gooxml.String("#fbf6d6") + shape.StrokecolorAttr = gooxml.String("#edeaa1") + //shape.InsetmodeAttr = vml.OfcST_InsetModeAuto + + fill := vml.NewEG_ShapeElements() + fill.Fill = vml.NewFill() + fill.Fill.Color2Attr = gooxml.String("#fbfe82") + fill.Fill.AngleAttr = gooxml.Float64(-180) + fill.Fill.TypeAttr = vml.ST_FillTypeGradient + fill.Fill.Fill = vml.NewOfcFill() + fill.Fill.Fill.ExtAttr = vml.ST_ExtView + fill.Fill.Fill.TypeAttr = vml.OfcST_FillTypeGradientUnscaled + + shape.EG_ShapeElements = append(shape.EG_ShapeElements, fill) + + shadow := vml.NewEG_ShapeElements() + shadow.Shadow = vml.NewShadow() + shadow.Shadow.OnAttr = st.ST_TrueFalseT + shadow.Shadow.ObscuredAttr = st.ST_TrueFalseT + shape.EG_ShapeElements = append(shape.EG_ShapeElements, shadow) + + fpath := vml.NewEG_ShapeElements() + fpath.Path = vml.NewPath() + fpath.Path.ConnecttypeAttr = vml.OfcST_ConnectTypeNone + shape.EG_ShapeElements = append(shape.EG_ShapeElements, fpath) + + tb := vml.NewEG_ShapeElements() + tb.Textbox = vml.NewTextbox() + tb.Textbox.StyleAttr = gooxml.String("mso-direction-alt:auto") + // TODO: add div? + shape.EG_ShapeElements = append(shape.EG_ShapeElements, tb) + + cd := vml.NewEG_ShapeElements() + cd.ClientData = excel.NewClientData() + cd.ClientData.ObjectTypeAttr = excel.ST_ObjectTypeNote + cd.ClientData.MoveWithCells = st.ST_TrueFalseBlankT + cd.ClientData.SizeWithCells = st.ST_TrueFalseBlankT + cd.ClientData.Anchor = gooxml.String("1, 15, 0, 2, 2, 54, 5, 3") + cd.ClientData.AutoFill = st.ST_TrueFalseBlankFalse + cd.ClientData.Row = gooxml.Int64(row) + cd.ClientData.Column = gooxml.Int64(col) + shape.EG_ShapeElements = append(shape.EG_ShapeElements, cd) + + return shape +} diff --git a/vmldrawing/container.go b/vmldrawing/container.go new file mode 100644 index 00000000..36e063d7 --- /dev/null +++ b/vmldrawing/container.go @@ -0,0 +1,80 @@ +// Copyright 2017 Baliance. All rights reserved. +// +// Use of this source code is governed by the terms of the Affero GNU General +// Public License version 3.0 as published by the Free Software Foundation and +// appearing in the file LICENSE included in the packaging of this file. A +// commercial license can be purchased by contacting sales@baliance.com. + +package vmldrawing + +import ( + "encoding/xml" + + "baliance.com/gooxml/schema/urn/schemas_microsoft_com/vml" +) + +type Container struct { + Layout *vml.OfcShapelayout + ShapeType *vml.Shapetype + Shape []*vml.Shape +} + +func NewContainer() *Container { + return &Container{} +} + +func (c *Container) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "xmlns:v"}, Value: "urn:schemas-microsoft-com:vml"}) + start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "xmlns:o"}, Value: "urn:schemas-microsoft-com:office:office"}) + start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "xmlns:x"}, Value: "urn:schemas-microsoft-com:office:excel"}) + start.Name.Local = "xml" + e.EncodeToken(start) + if c.Layout != nil { + se := xml.StartElement{Name: xml.Name{Local: "o:shapelayout"}} + e.EncodeElement(c.Layout, se) + } + if c.ShapeType != nil { + se := xml.StartElement{Name: xml.Name{Local: "v:shapetype"}} + e.EncodeElement(c.ShapeType, se) + } + for _, s := range c.Shape { + se := xml.StartElement{Name: xml.Name{Local: "v:shape"}} + e.EncodeElement(s, se) + } + return e.EncodeToken(xml.EndElement{Name: start.Name}) +} + +func (c *Container) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + c.Shape = nil +outer: + for { + tok, err := d.Token() + if err != nil { + return err + } + switch el := tok.(type) { + case xml.StartElement: + switch el.Name.Local { + case "shapelayout": + c.Layout = vml.NewOfcShapelayout() + if err := d.DecodeElement(c.Layout, &el); err != nil { + return err + } + case "shapetype": + c.ShapeType = vml.NewShapetype() + if err := d.DecodeElement(c.ShapeType, &el); err != nil { + return err + } + case "shape": + shp := vml.NewShape() + if err := d.DecodeElement(shp, &el); err != nil { + return err + } + c.Shape = append(c.Shape, shp) + } + case xml.EndElement: + break outer + } + } + return nil +} diff --git a/zippkg/decodemap.go b/zippkg/decodemap.go index 54af8853..d48315a7 100644 --- a/zippkg/decodemap.go +++ b/zippkg/decodemap.go @@ -26,32 +26,38 @@ import ( // target doesn't match where gooxml will write the file (e.g. read in // 'xl/worksheets/MyWorksheet.xml' and we'll write out // 'xl/worksheets/sheet1.xml') -type OnNewRelationshipFunc func(decMap *DecodeMap, target, typ string, files []*zip.File, rel *relationships.Relationship) error +type OnNewRelationshipFunc func(decMap *DecodeMap, target, typ string, files []*zip.File, rel *relationships.Relationship, src Target) error // DecodeMap is used to walk a tree of relationships, decoding files and passing // control back to the document. type DecodeMap struct { - pathsToIfcs map[string]interface{} + pathsToIfcs map[string]Target basePaths map[*relationships.Relationships]string - rels []*relationships.Relationships - decFunc OnNewRelationshipFunc + rels []Target + decodeFunc OnNewRelationshipFunc } // SetOnNewRelationshipFunc sets the function to be called when a new // relationship has been discovered. func (d *DecodeMap) SetOnNewRelationshipFunc(fn OnNewRelationshipFunc) { - d.decFunc = fn + d.decodeFunc = fn +} + +type Target struct { + Path string + Ifc interface{} + Index uint32 } // AddTarget allows documents to register decode targets. Path is a path that // will be found in the zip file and ifc is an XML element that the file will be // unmarshaled to. -func (d *DecodeMap) AddTarget(path string, ifc interface{}) { +func (d *DecodeMap) AddTarget(tgt Target) { if d.pathsToIfcs == nil { - d.pathsToIfcs = make(map[string]interface{}) + d.pathsToIfcs = make(map[string]Target) d.basePaths = make(map[*relationships.Relationships]string) } - d.pathsToIfcs[filepath.Clean(path)] = ifc + d.pathsToIfcs[filepath.Clean(tgt.Path)] = tgt } // Decode loops decoding targets registered with AddTarget and calling th @@ -62,11 +68,12 @@ func (d *DecodeMap) Decode(files []*zip.File) error { // if we've loaded any relationships files, notify the document so it // can create elements to receive the decoded version for len(d.rels) > 0 { - relFile := d.rels[len(d.rels)-1] + relSource := d.rels[len(d.rels)-1] d.rels = d.rels[0 : len(d.rels)-1] - for _, r := range relFile.Relationship { - bp, _ := d.basePaths[relFile] - d.decFunc(d, bp+r.TargetAttr, r.TypeAttr, files, r) + relRaw := relSource.Ifc.(*relationships.Relationships) + for _, r := range relRaw.Relationship { + bp, _ := d.basePaths[relRaw] + d.decodeFunc(d, bp+r.TargetAttr, r.TypeAttr, files, r, relSource) } } @@ -74,19 +81,20 @@ func (d *DecodeMap) Decode(files []*zip.File) error { if f == nil { continue } + // if there is a registered target for the file if dest, ok := d.pathsToIfcs[f.Name]; ok { delete(d.pathsToIfcs, f.Name) // decode to the target and mark the file as nil so we'll skip // it later - if err := Decode(f, dest); err != nil { + if err := Decode(f, dest.Ifc); err != nil { return err } files[i] = nil // we decoded a relationships file, so we need to traverse it - if drel, ok := dest.(*relationships.Relationships); ok { - d.rels = append(d.rels, drel) + if drel, ok := dest.Ifc.(*relationships.Relationships); ok { + d.rels = append(d.rels, dest) // find the path that any files mentioned in the // relationships file are relative to basePath, _ := filepath.Split(filepath.Clean(f.Name + "/../"))