Notes are written as Excel's legacy comments with the VML drawing Excel needs, and comments are read back as notes. A sheet Excel protects is imported unprotected, with a note, since Excel's protection locks where 012's warns. The excelize reference importer reads both too, and the differential corpus has notes and a protected sheet.
- commit
- 351734b3180d872950bac650eaa15aded202ed87
- parent
- 87f47fe1087e543e18bd6a1d21c589e9ee9028ad
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 12 | +++ |
| M | internal/ | 15 | ++- |
| M | internal/ | 6 | + |
| M | internal/ | 56 | ++++++++++--- |
| M | internal/ | 2 | + |
| M | internal/ | 19 | ++++- |
| A | internal/ | 134 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 62 | ++++++++++++++ |
| M | internal/ | 2 | + |
| M | internal/ | 2 | +- |
| M | internal/ | 8 | ++- |
| M | internal/ | 3 | + |
@@ -36,6 +36,10 @@ type Snapshot struct {
3636 FrozenRows, FrozenCols int3737 Filter *sheet.Filter3838 HiddenRows map[int]bool39+40+ // Notes are the cells' notes in the range, for formats that keep41+ // them (XLSX, as comments). A note may be on a cell with no contents.42+ Notes map[sheet.Addr]string3943 }4044 4145 // SnapName is a named range: its name, and the range on the sheet@@ -66,7 +70,9 @@ func (c SnapCell) Text() string {
6670 // row and column with contents, so exporting whole columns writes their6771 // data rather than a million blank lines.6872 func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {73+ notes := r6974 if r == (sheet.Rect{}) {75+ notes = sheet.Rect{To: sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}}7076 r, _ = s.UsedRange()7177 } else if data, ok := s.FilledBounds(r); ok {7278 r.To = data.To@@ -76,6 +82,12 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
7682 snap := &Snapshot{Range: r, Cells: map[sheet.Addr]SnapCell{}, Widths: s.Widths(), Name: name,7783 ColFormats: snapLines(s, false), RowFormats: snapLines(s, true), Filter: s.Filter()}7884 snap.FrozenRows, snap.FrozenCols = s.Frozen()85+ for _, a := range s.NotesIn(notes) {86+ if snap.Notes == nil {87+ snap.Notes = map[sheet.Addr]string{}88+ }89+ snap.Notes[a] = s.Note(a)90+ }7991 if f := snap.Filter; f != nil {8092 snap.HiddenRows = map[int]bool{}8193 for row := max(f.Range.From.Row, r.From.Row); row <= min(f.Range.To.Row, r.To.Row); row++ {@@ -61,17 +61,24 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
6161 hidden[active] = false6262 err := writeFile(name, func(out io.Writer) error {6363 zw := zip.NewWriter(out)64+ var withNotes []int6465 for i, sn := range sheets {6566 rows, err := w.writeSheet(zw, i, names[i], sn, i == active)6667 if err != nil {6768 return err6869 }6970 res.Rows += rows71+ if len(sn.Notes) > 0 {72+ withNotes = append(withNotes, i+1)73+ if err := writeNotes(zw, i+1, sn.Notes); err != nil {74+ return err75+ }76+ }7077 }7178 if err := writePart(zw, "xl/styles.xml", w.styles.xml()); err != nil {7279 return err7380 }74- if err := writePackage(zw, names, hidden, active, w.definedNames(snap.Names), filterRanges(sheets, names)); err != nil {81+ if err := writePackage(zw, names, hidden, active, w.definedNames(snap.Names), filterRanges(sheets, names), notesTypes(withNotes)); err != nil {7582 return err7683 }7784 return zw.Close()@@ -140,14 +147,14 @@ func filterRanges(sheets []*Snapshot, names []string) []string {
140147 // writePackage writes the parts around the worksheets and styles: the141148 // workbook with its sheets, active tab, names (with each filter's range,142149 // filters[i] for sheet i) and calculation settings, the relationships143-// and the content types.144-func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, defined [][2]string, filters []string) error {150+// and the content types, with extra ones for the sheets' parts.151+func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, defined [][2]string, filters []string, extraTypes string) error {145152 var types, rels, book strings.Builder146153 types.WriteString(xmlHead + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +147154 `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +148155 `<Default Extension="xml" ContentType="application/xml"/>` +149156 `<Override PartName="/xl/workbook.xml" ContentType="` + mlType + `sheet.main+xml"/>` +150- `<Override PartName="/xl/styles.xml" ContentType="` + mlType + `styles+xml"/>`)157+ `<Override PartName="/xl/styles.xml" ContentType="` + mlType + `styles+xml"/>` + extraTypes)151158 rels.WriteString(xmlHead + `<Relationships xmlns="` + relsNS + `">`)152159 book.WriteString(xmlHead + `<workbook xmlns="` + sheetMain + `" xmlns:r="` + officeRel + `">` +153160 `<bookViews><workbookView activeTab="` + strconv.Itoa(active) + `"/></bookViews><sheets>`)@@ -98,6 +98,10 @@ func writeExcelizeBook(t *testing.T, dir string) string {
9898 x.SetSheetRow("Q2 plan", "A"+itoa(10+i), &[]any{fruit, i})9999 }100100 x.AutoFilter("Q2 plan", "A10:B15", []excelize.AutoFilterOptions{{Column: "A", Expression: "x == apple or x == Plum"}, {Column: "B", Expression: "x >= 2"}})101+ // Notes, one in rich-text runs, and a protected sheet.102+ x.AddComment("Q1", excelize.Comment{Cell: "B1", Author: "Ann", Text: "Checked\nwith the bank"})103+ x.AddComment("Q2 plan", excelize.Comment{Cell: "D9", Paragraph: []excelize.RichTextRun{{Text: "Due: ", Font: &excelize.Font{Bold: true}}, {Text: "Friday"}}})104+ x.ProtectSheet("Q2 plan", &excelize.SheetProtectionOptions{})101105 x.SetActiveSheet(1)102106 path := filepath.Join(dir, "excelize.xlsx")103107 if err := x.SaveAs(path); err != nil {@@ -124,6 +128,8 @@ func writeExportedBook(t *testing.T, dir string) string {
124128 s.CreateFilter(sheet.NewRect(sheet.Addr{}, sheet.Addr{Col: 9, Row: 60}))125129 s.FilterColumn(1, sheet.Criteria{Hidden: []string{"beta", "gamma"}})126130 s.FilterColumn(3, sheet.Criteria{Cond: sheet.Condition{Op: sheet.CondContains, Arg: "1"}})131+ s.SetNote(sheet.Addr{Col: 1, Row: 2}, "A note\non two lines & <markup>")132+ f.SetNote(sheet.Addr{Col: 5, Row: 50}, "On a blank cell")127133 path := filepath.Join(dir, "exported.xlsx")128134 if _, err := Export(context.Background(), path, XLSX, SnapBook(f), ExportOptions{}); err != nil {129135 t.Fatal(err)@@ -69,13 +69,17 @@ func importXLSXExcelize(ctx context.Context, name string, opt Options) (*Result,
6969 return nil, err7070 }7171 excelizePanes(x, ws, next)72+ if err := excelizeNotes(x, ws, next); err != nil {73+ return nil, err74+ }7275 done += rows7376 }74- filters, err := referenceAutoFilters(name)77+ filters, protected, err := referenceAutoFilters(name)7578 if err != nil {7679 return nil, err7780 }7881 notes = append(notes, excelizeNames(x, book)...)82+ notes = append(notes, (&xlsxBook{protected: protected}).protectionNote()...)7983 active := book.Sheet(clamp(x.GetActiveSheetIndex(), 0, book.Len()-1))8084 book.SetActive(active)8185 for i, ws := range names {@@ -96,8 +100,29 @@ func excelizePanes(x *excelize.File, ws string, s *sheet.Sheet) {
96100 }97101 }98102 103+// excelizeNotes loads sheet ws's notes (Excel's legacy comments) into s.104+func excelizeNotes(x *excelize.File, ws string, s *sheet.Sheet) error {105+ comments, err := x.GetComments(ws)106+ if err != nil {107+ return err108+ }109+ for _, c := range comments {110+ text := c.Text111+ for _, run := range c.Paragraph {112+ text += run.Text113+ }114+ if a, ok := sheet.ParseAddr(c.Cell); ok {115+ s.LoadNote(a, text)116+ }117+ }118+ return nil119+}120+99121 // refWorksheet is the part of a worksheet referenceAutoFilters reads.100122 type refWorksheet struct {123+ Protection *struct {124+ Sheet string `xml:"sheet,attr"`125+ } `xml:"sheetProtection"`101126 AutoFilter *struct {102127 Ref string `xml:"ref,attr"`103128 Cols []struct {@@ -123,46 +148,51 @@ type refWorksheet struct {
123148 } `xml:"autoFilter"`124149 }125150 126-// referenceAutoFilters reads each sheet's autoFilter by decoding the127-// whole worksheet with encoding/xml, as excelize has no API for reading128-// one: the reference for the streaming reader's. Only the parts' names129-// come from 012's reader.130-func referenceAutoFilters(name string) ([]*xlsxAutoFilter, error) {151+// referenceAutoFilters reads each sheet's autoFilter, and the names of152+// the protected sheets, by decoding the whole worksheet with153+// encoding/xml, as excelize has no API for reading them: the reference154+// for the streaming reader's. Only the parts' names come from 012's155+// reader.156+func referenceAutoFilters(name string) ([]*xlsxAutoFilter, []string, error) {131157 f, err := os.Open(name)132158 if err != nil {133- return nil, err159+ return nil, nil, err134160 }135161 defer f.Close()136162 st, err := f.Stat()137163 if err != nil {138- return nil, err164+ return nil, nil, err139165 }140166 bk, err := openXLSX(f, st.Size(), defaultXLSXLimits)141167 if err != nil {142- return nil, err168+ return nil, nil, err143169 }144170 zr, err := zip.NewReader(f, st.Size())145171 if err != nil {146- return nil, err172+ return nil, nil, err147173 }148174 out := make([]*xlsxAutoFilter, len(bk.sheets))175+ var protected []string149176 for i, info := range bk.sheets {150177 if info.part == "" || info.kind != "worksheet" {151178 continue152179 }153180 rc, err := zr.Open(info.part)154181 if err != nil {155- return nil, err182+ return nil, nil, err156183 }157184 var ws refWorksheet158185 err = xml.NewDecoder(rc).Decode(&ws)159186 rc.Close()160187 if err != nil {161- return nil, err188+ return nil, nil, err162189 }163190 out[i] = ws.autoFilter()191+ if p := ws.Protection; p != nil && (p.Sheet == "1" || p.Sheet == "true") {192+ protected = append(protected, info.name)193+ }164194 }165- return out, nil195+ return out, protected, nil166196 }167197 168198 func (ws refWorksheet) autoFilter() *xlsxAutoFilter {@@ -20,6 +20,8 @@ type xlsxBook struct {
2020 sst sharedStrings2121 styles xlsxStyles2222 expanded int64 // bytes of shared formulas expanded, see formula23+ // protected names the sheets read so far that Excel protects.24+ protected []string2325 }2426 2527 // xlsxSheetInfo is a sheet as the workbook lists it.@@ -68,7 +68,7 @@ func (bk *xlsxBook) importBook(ctx context.Context, opt Options) (*Result, error
6868 }6969 done += rows7070 }71- notes := bk.importNames(book)71+ notes := append(bk.importNames(book), bk.protectionNote()...)7272 active := book.Sheet(clamp(bk.active, 0, book.Len()-1))7373 book.SetActive(active)7474 for i, info := range bk.sheets {@@ -118,6 +118,12 @@ func (bk *xlsxBook) importSheet(ctx context.Context, b *builder, i int, af **xls
118118 if *af, err = r.readTail(); err != nil {119119 return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)120120 }121+ if r.protected {122+ bk.protected = append(bk.protected, bk.sheets[i].name)123+ }124+ if err := bk.readNotes(b.s, bk.sheets[i].part); err != nil {125+ return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)126+ }121127 loadColStyles(b, width, func(c int) (xlsxStyle, bool) {122128 st := bk.styles.style(r.lineStyle(c + 1))123129 return st, st != (xlsxStyle{})@@ -266,6 +272,17 @@ func isoDate(s string) (float64, sheet.Format, bool) {
266272 return 0, sheet.Format{}, false267273 }268274 275+// protectionNote says which sheets were protected in the file: 012276+// leaves their protection out, since Excel's locks cells while 012's277+// only warns, and Data > Protect sheets and ranges adds that.278+func (bk *xlsxBook) protectionNote() []string {279+ if len(bk.protected) == 0 {280+ return nil281+ }282+ return []string{fmt.Sprintf("Protection of %s left out: Data > Protect sheets and ranges warns before edits",283+ strings.Join(bk.protected, ", "))}284+}285+269286 // importNames defines the workbook's named ranges that are a range on270287 // one sheet, such as Sales = Q3!$B$2:$B$20, and notes the others (sheet271288 // scoped names, constants, formulas).@@ -0,0 +1,134 @@
1+package fileio2+3+import (4+ "archive/zip"5+ "encoding/xml"6+ "fmt"7+ "io"8+ "maps"9+ "slices"10+ "strconv"11+ "strings"12+13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// Cell notes travel as Excel's notes (legacy comments, which Sheets17+// imports as notes and writes its notes as): a comments part per sheet18+// with the text, and the VML drawing Excel needs to show each note's19+// box, both related to the worksheet, which names the drawing in20+// <legacyDrawing>.21+22+const (23+ commentsType = mlType + "comments+xml"24+ vmlType = "application/vnd.openxmlformats-officedocument.vmlDrawing"25+ notesRelID = "rIdNotes"26+ vmlRelID = "rIdVML"27+)28+29+// writeNotes writes worksheet part n's notes: its relationships, the30+// comments part and the VML drawing.31+func writeNotes(zw *zip.Writer, n int, notes map[sheet.Addr]string) error {32+ num := strconv.Itoa(n)33+ addrs := slices.SortedFunc(maps.Keys(notes), func(a, b sheet.Addr) int {34+ if a.Row != b.Row {35+ return a.Row - b.Row36+ }37+ return a.Col - b.Col38+ })39+ var comments, vml strings.Builder40+ comments.WriteString(xmlHead + `<comments xmlns="` + sheetMain + `"><authors><author></author></authors><commentList>`)41+ fmt.Fprintf(&vml, `<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">`+42+ `<o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="%d"/></o:shapelayout>`+43+ `<v:shapetype id="_x0000_t202" coordsize="21600,21600" o:spt="202" path="m,l,21600r21600,l21600,xe">`+44+ `<v:stroke joinstyle="miter"/><v:path gradientshapeok="t" o:connecttype="rect"/></v:shapetype>`, n)45+ for k, a := range addrs {46+ comments.WriteString(`<comment ref="` + a.String() + `" authorId="0"><text><t xml:space="preserve">`)47+ comments.Write(appendEscaped(nil, notes[a], true))48+ comments.WriteString(`</t></text></comment>`)49+ fmt.Fprintf(&vml, `<v:shape id="_x0000_s%d" type="#_x0000_t202" style="position:absolute;margin-left:60pt;margin-top:2pt;width:108pt;height:60pt;z-index:%d;visibility:hidden" fillcolor="#ffffe1" o:insetmode="auto">`+50+ `<v:fill color2="#ffffe1"/><v:shadow on="t" color="black" obscured="t"/><v:path o:connecttype="none"/>`+51+ `<v:textbox style="mso-direction-alt:auto"><div style="text-align:left"></div></v:textbox>`+52+ `<x:ClientData ObjectType="Note"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>%d, 15, %d, 2, %d, 15, %d, 4</x:Anchor>`+53+ `<x:AutoFill>False</x:AutoFill><x:Row>%d</x:Row><x:Column>%d</x:Column></x:ClientData></v:shape>`,54+ n*1024+k+1, k+1, a.Col+1, a.Row, a.Col+3, a.Row+4, a.Row, a.Col)55+ }56+ comments.WriteString(`</commentList></comments>`)57+ vml.WriteString(`</xml>`)58+ rels := xmlHead + `<Relationships xmlns="` + relsNS + `">` +59+ `<Relationship Id="` + notesRelID + `" Type="` + officeRel + `/comments" Target="../comments` + num + `.xml"/>` +60+ `<Relationship Id="` + vmlRelID + `" Type="` + officeRel + `/vmlDrawing" Target="../drawings/vmlDrawing` + num + `.vml"/>` +61+ `</Relationships>`62+ for _, p := range []struct{ name, body string }{63+ {"xl/worksheets/_rels/sheet" + num + ".xml.rels", rels},64+ {"xl/comments" + num + ".xml", comments.String()},65+ {"xl/drawings/vmlDrawing" + num + ".vml", vml.String()},66+ } {67+ if err := writePart(zw, p.name, p.body); err != nil {68+ return err69+ }70+ }71+ return nil72+}73+74+// notesTypes are the content types of the notes of the worksheet parts75+// numbered in withNotes.76+func notesTypes(withNotes []int) string {77+ if len(withNotes) == 0 {78+ return ""79+ }80+ var b strings.Builder81+ b.WriteString(`<Default Extension="vml" ContentType="` + vmlType + `"/>`)82+ for _, n := range withNotes {83+ fmt.Fprintf(&b, `<Override PartName="/xl/comments%d.xml" ContentType="%s"/>`, n, commentsType)84+ }85+ return b.String()86+}87+88+// readNotes reads the notes of worksheet part, from the comments part89+// its relationships name, into s.90+func (bk *xlsxBook) readNotes(s *sheet.Sheet, part string) error {91+ rels, err := bk.pkg.rels(part)92+ if err != nil {93+ return err94+ }95+ for _, rel := range rels {96+ if rel.typ == "comments" && bk.pkg.has(rel.target) {97+ if err := bk.readComments(s, rel.target); err != nil {98+ return fmt.Errorf("notes: %w", err)99+ }100+ }101+ }102+ return nil103+}104+105+// readComments reads a comments part: each <comment ref="B2"> with the106+// text of its runs.107+func (bk *xlsxBook) readComments(s *sheet.Sheet, part string) error {108+ x, err := bk.pkg.open(part)109+ if x == nil || err != nil {110+ return err111+ }112+ defer x.close()113+ var buf []byte114+ for {115+ t, err := x.next()116+ if err == io.EOF {117+ return nil118+ }119+ if err != nil {120+ return err121+ }122+ se, ok := t.(xml.StartElement)123+ if !ok || se.Name.Local != "comment" {124+ continue125+ }126+ ref, _ := attr(se, "ref")127+ if buf, err = inlineText(x, buf[:0]); err != nil {128+ return err129+ }130+ if a, ok := sheet.ParseAddr(ref); ok {131+ s.LoadNote(a, string(unescapeOOXML(buf)))132+ }133+ }134+}@@ -0,0 +1,62 @@
1+package fileio2+3+import (4+ "context"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ "github.com/xuri/excelize/v2"10+)11+12+func TestXLSXNotes(t *testing.T) {13+ src := build(t, map[string]string{"A1": "Rent", "B1": "1450"})14+ src.SetNote(addr(t, "B1"), "Due on\nthe 1st")15+ src.SetNote(addr(t, "D9"), "Blank cell")16+ book := src.Book()17+ other, _ := book.AddSheet("Other", 1)18+ other.SetNote(addr(t, "A1"), "_x0041_ stays & <so> do these")19+ name := filepath.Join(t.TempDir(), "notes.xlsx")20+ if _, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{}); err != nil {21+ t.Fatal(err)22+ }23+ // excelize reads them as Excel would.24+ x, err := excelize.OpenFile(name)25+ if err != nil {26+ t.Fatal(err)27+ }28+ cs, err := x.GetComments(x.GetSheetName(0))29+ if err != nil || len(cs) != 2 || cs[0].Cell != "B1" || cs[0].Text != "Due on\nthe 1st" {30+ t.Errorf("excelize comments %+v, %v", cs, err)31+ }32+ x.Close()33+ got, err := Import(context.Background(), name, Options{})34+ if err != nil {35+ t.Fatal(err)36+ }37+ s := got.Sheet38+ if s.Note(addr(t, "B1")) != "Due on\nthe 1st" || s.Note(addr(t, "D9")) != "Blank cell" {39+ t.Errorf("notes %q %q", s.Note(addr(t, "B1")), s.Note(addr(t, "D9")))40+ }41+ if n := s.Book().Lookup("Other").Note(addr(t, "A1")); n != "_x0041_ stays & <so> do these" {42+ t.Errorf("escaped note %q", n)43+ }44+}45+46+func TestXLSXProtectionNote(t *testing.T) {47+ x := excelize.NewFile()48+ x.SetCellValue("Sheet1", "A1", 1)49+ x.ProtectSheet("Sheet1", &excelize.SheetProtectionOptions{})50+ name := filepath.Join(t.TempDir(), "protected.xlsx")51+ if err := x.SaveAs(name); err != nil {52+ t.Fatal(err)53+ }54+ x.Close()55+ got, err := Import(context.Background(), name, Options{})56+ if err != nil {57+ t.Fatal(err)58+ }59+ if len(got.Sheet.Protections()) != 0 || !strings.Contains(strings.Join(got.Notes, "; "), "Protection of Sheet1 left out") {60+ t.Errorf("protections %+v, notes %q", got.Sheet.Protections(), got.Notes)61+ }62+}@@ -82,7 +82,7 @@ func TestXLSXFrozenAndFilterRoundTrip(t *testing.T) {
8282 if !db {8383 t.Errorf("no _FilterDatabase name: %+v", x.GetDefinedName())8484 }85- afs, err := referenceAutoFilters(name)85+ afs, _, err := referenceAutoFilters(name)8686 if err != nil {8787 t.Fatal(err)8888 }@@ -70,7 +70,13 @@ func (r *xlsxSheetReader) readTail() (*xlsxAutoFilter, error) {
7070 if err != nil {7171 return nil, err7272 }73- if se, ok := t.(xml.StartElement); ok && se.Name.Local == "autoFilter" && r.x.depth == 2 && af == nil {73+ se, ok := t.(xml.StartElement)74+ switch {75+ case !ok || r.x.depth != 2:76+ case se.Name.Local == "sheetProtection":77+ v, _ := attr(se, "sheet")78+ r.protected = v == "1" || v == "true"79+ case se.Name.Local == "autoFilter" && af == nil:7480 if af, err = r.readAutoFilter(se); err != nil {7581 return nil, err7682 }@@ -98,6 +98,9 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
9898 w.styledRows(bw, snap, styled, sheet.MaxRows)9999 bw.WriteString(`</sheetData>`)100100 writeAutoFilter(bw, snap)101+ if len(snap.Notes) > 0 {102+ bw.WriteString(`<legacyDrawing r:id="` + vmlRelID + `"/>`)103+ }101104 bw.WriteString(`</worksheet>`)102105 return r.To.Row - r.From.Row + 1103106 }