cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Notes as XLSX comments both ways; protected sheets noted on import

Dami committed 351734b
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>
12 files changed, +301 -20
Minternal/fileio/snapshot.go12+++
Minternal/fileio/xlsx.go15++-
Minternal/fileio/xlsx_diff_test.go6+
Minternal/fileio/xlsx_excelize_test.go56++++++++++---
Minternal/fileio/xlsxbook.go2+
Minternal/fileio/xlsximport.go19++++-
Ainternal/fileio/xlsxnotes.go134++++++++++++++++++++++++++++++
Ainternal/fileio/xlsxnotes_test.go62++++++++++++++
Minternal/fileio/xlsxsheet.go2+
Minternal/fileio/xlsxview_test.go2+-
Minternal/fileio/xlsxviewread.go8++-
Minternal/fileio/xlsxwrite.go3+
M internal/fileio/snapshot.go +12 -0
M internal/fileio/xlsx.go +11 -4
@@ -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>`)
M internal/fileio/xlsx_diff_test.go +6 -0
M internal/fileio/xlsx_excelize_test.go +43 -13
@@ -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 {
M internal/fileio/xlsxbook.go +2 -0
M internal/fileio/xlsximport.go +18 -1
@@ -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).
A internal/fileio/xlsxnotes.go +134 -0
@@ -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+}
A internal/fileio/xlsxnotes_test.go +62 -0
@@ -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+}
M internal/fileio/xlsxsheet.go +2 -0
M internal/fileio/xlsxview_test.go +1 -1
M internal/fileio/xlsxviewread.go +7 -1
M internal/fileio/xlsxwrite.go +3 -0