spreadsheet: add comment support

This adds comment support for sheets.  Excel requires a VML drawing with
the comment box shape for each comment to display the comment.
LibreOffice displays comments fine with or without the shape, and
creates the shape for its own comments.  For the sake of compatibility,
we create comment shapes as well.

I know of no other use for the legacy VML support other than comment
boxes...
This commit is contained in:
Todd 2017-09-08 21:25:01 -05:00
parent 2b50dca676
commit 3bc4675cf6
22 changed files with 613 additions and 71 deletions

Binary file not shown.

View File

@ -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")
}

View File

@ -69,8 +69,7 @@ func TestRawEncode(t *testing.T) {
end := strings.LastIndex(xmlStr, "</w:hdrShapeDefaults>")
gotRaw := xmlStr[beg+20 : end]
exp := "<o:shapedefaults v:ext=\"edit\" spidmax=\"2049\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:v=\"urn:schemas-microsoft-com:vml\"><o:idmap v:ext=\"edit\" data=\"1\"/></o:shapedefaults>"
exp := "<o:shapedefaults xmlns=\"urn:schemas-microsoft-com:office:office\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" xmlns:s=\"http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" spidmax=\"2049\" ext=\"edit\"/>"
if gotRaw != exp {
t.Errorf("expected\n%q\ngot\n%q\n", exp, gotRaw)
}

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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"
)

50
spreadsheet/comment.go Normal file
View File

@ -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)
}

85
spreadsheet/comments.go Normal file
View File

@ -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)))
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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.

View File

@ -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{}
}

BIN
spreadsheet/testdata/comments.xlsx vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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))

View File

@ -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")
}
}

View File

@ -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)
}

View File

@ -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
}

80
vmldrawing/container.go Normal file
View File

@ -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
}

View File

@ -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 + "/../"))