cat / 012
clone

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

Split XLSX sheet writing, WK1 record reading and the delimited round trip test into helpers

Dami committed 7fcd326
The WK1 reader also reuses one buffer for record payloads.
commit
7fcd326e053e39bada37f305ed65f973fb046a30
parent
2e9e581bcf9fc1cfa1befc7c70b16401b714cf45
author
Dami <dami.etoile@gmail.com>
4 files changed, +133 -96
Minternal/fileio/csv_test.go67++++++++++++---------
Minternal/fileio/wk1.go97+++++++++++++++++++-----------
Minternal/fileio/xlsx.go61+++++++++++--------
Minternal/fileio/xlsx_test.go4+-
M internal/fileio/csv_test.go +38 -29
@@ -36,6 +36,16 @@ func shown(s *sheet.Sheet, a sheet.Addr) string {
3636 return sheet.FormatText(s.Value(a), s.DisplayFormat(a))3737 }3838 39+// sameShown checks that every cell of src shows the same in got.40+func sameShown(t *testing.T, src, got *sheet.Sheet) {41+ t.Helper()42+ for _, a := range src.Addrs() {43+ if want, g := shown(src, a), shown(got, a); g != want {44+ t.Errorf("%s shows %q after the round trip, want %q", a, g, want)45+ }46+ }47+}48+3949 func input(s *sheet.Sheet, a sheet.Addr) string {4050 if c := s.Cell(a); c != nil {4151 return c.Input
@@ -189,35 +199,34 @@ func TestDelimitedRoundTrip(t *testing.T) {
189199 })190200 src.SetFormat(sheet.NewRect(addr(t, "D2"), addr(t, "D3")), sheet.Preset(sheet.FmtPercent))191201 for _, k := range []Kind{CSV, TSV} {192- t.Run(k.String(), func(t *testing.T) {193- name := filepath.Join(t.TempDir(), "out"+k.Ext())194- res, err := Export(context.Background(), name, k, Snap(src, sheet.Rect{}, "out"), ExportOptions{})195- if err != nil {196- t.Fatal(err)197- }198- if res.Rows != 4 || len(res.Notes) != 1 || res.Notes[0] != "3 formulas saved as values" {199- t.Errorf("result %+v", res)200- }201- got, err := Import(context.Background(), name, Options{})202- if err != nil {203- t.Fatal(err)204- }205- if n := got.Sheet.Name(); n != "out" || got.Sheet.CanUndo() {206- t.Errorf("imported sheet named %q, undoable %v", n, got.Sheet.CanUndo())207- }208- for _, a := range src.Addrs() {209- if want, g := shown(src, a), shown(got.Sheet, a); g != want {210- t.Errorf("%s shows %q after the round trip, want %q", a, g, want)211- }212- }213- // Values come back as numbers with their formats, not text.214- if v := got.Sheet.Value(addr(t, "C4")); v.Kind != sheet.Number || v.Num != 2062.4 {215- t.Errorf("C4 = %+v", v)216- }217- if f := got.Sheet.DisplayFormat(addr(t, "D2")); f.Kind != sheet.FmtPercent {218- t.Errorf("D2 format = %+v", f)219- }220- })202+ t.Run(k.String(), func(t *testing.T) { roundTripDelimited(t, src, k) })203+ }204+}205+206+// roundTripDelimited exports src as k, imports it back and compares.207+func roundTripDelimited(t *testing.T, src *sheet.Sheet, k Kind) {208+ name := filepath.Join(t.TempDir(), "out"+k.Ext())209+ res, err := Export(context.Background(), name, k, Snap(src, sheet.Rect{}, "out"), ExportOptions{})210+ if err != nil {211+ t.Fatal(err)212+ }213+ if res.Rows != 4 || len(res.Notes) != 1 || res.Notes[0] != "3 formulas saved as values" {214+ t.Errorf("result %+v", res)215+ }216+ got, err := Import(context.Background(), name, Options{})217+ if err != nil {218+ t.Fatal(err)219+ }220+ if n := got.Sheet.Name(); n != "out" || got.Sheet.CanUndo() {221+ t.Errorf("imported sheet named %q, undoable %v", n, got.Sheet.CanUndo())222+ }223+ sameShown(t, src, got.Sheet)224+ // Values come back as numbers with their formats, not text.225+ if v := got.Sheet.Value(addr(t, "C4")); v.Kind != sheet.Number || v.Num != 2062.4 {226+ t.Errorf("C4 = %+v", v)227+ }228+ if f := got.Sheet.DisplayFormat(addr(t, "D2")); f.Kind != sheet.FmtPercent {229+ t.Errorf("D2 format = %+v", f)221230 }222231 }223232
M internal/fileio/wk1.go +60 -37
@@ -75,33 +75,19 @@ type wk1Reader struct {
7575 style map[sheet.Addr]sheet.Style7676 names int7777 maxRow int78+ buf []byte // the payload of the record being read7879 }7980 8081 // readWK1 reads a worksheet record by record. progress gets the number8182 // of cells read so far.8283 func readWK1(ctx context.Context, r io.Reader, progress func(cells int)) (*sheet.Sheet, []string, int, error) {8384 w := &wk1Reader{b: newBuilder(), version: wk1File}84- var head [4]byte8585 cells := 08686 for i := 0; ; i++ {87- if _, err := io.ReadFull(r, head[:]); err != nil {88- if i == 0 {89- return nil, nil, 0, errors.New("not a Lotus 1-2-3 worksheet: the file is empty")90- }91- if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {92- break // no EOF record; keep what was read, as 1-2-3 does93- }87+ op, data, err := w.next(r, i)88+ if err != nil {9489 return nil, nil, 0, err9590 }96- op := binary.LittleEndian.Uint16(head[0:])97- n := binary.LittleEndian.Uint16(head[2:])98- if i == 0 && (op != wkBOF || n != 2) {99- return nil, nil, 0, errors.New("not a Lotus 1-2-3 worksheet: it doesn't start with a BOF record")100- }101- data := make([]byte, n)102- if _, err := io.ReadFull(r, data); err != nil {103- return nil, nil, 0, fmt.Errorf("record %d (opcode 0x%02X) is cut off", i+1, op)104- }10591 if op == wkEOF {10692 break10793 }
@@ -124,6 +110,35 @@ func readWK1(ctx context.Context, r io.Reader, progress func(cells int)) (*sheet
124110 return s, notes, w.maxRow + 1, nil125111 }126112 113+// next reads record i, returning its opcode and payload, which is only114+// valid until the next call. The end of the file reads as an EOF record:115+// a worksheet may lack one, and 1-2-3 keeps what was read.116+func (w *wk1Reader) next(r io.Reader, i int) (uint16, []byte, error) {117+ var head [4]byte118+ if _, err := io.ReadFull(r, head[:]); err != nil {119+ switch {120+ case i == 0:121+ return 0, nil, errors.New("not a Lotus 1-2-3 worksheet: the file is empty")122+ case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):123+ return wkEOF, nil, nil124+ }125+ return 0, nil, err126+ }127+ op := binary.LittleEndian.Uint16(head[0:])128+ n := int(binary.LittleEndian.Uint16(head[2:]))129+ if i == 0 && (op != wkBOF || n != 2) {130+ return 0, nil, errors.New("not a Lotus 1-2-3 worksheet: it doesn't start with a BOF record")131+ }132+ if cap(w.buf) < n {133+ w.buf = make([]byte, n)134+ }135+ data := w.buf[:n]136+ if _, err := io.ReadFull(r, data); err != nil {137+ return 0, nil, fmt.Errorf("record %d (opcode 0x%02X) is cut off", i+1, op)138+ }139+ return op, data, nil140+}141+127142 // record handles one record, reporting whether it was a cell.128143 func (w *wk1Reader) record(op uint16, d []byte) bool {129144 switch op {
@@ -172,26 +187,7 @@ func (w *wk1Reader) record(op uint16, d []byte) bool {
172187 case wkLabel:173188 w.label(a, body)174189 case wkFormula:175- if len(body) < 10 {176- return false177- }178- v := math.Float64frombits(binary.LittleEndian.Uint64(body))179- size := int(binary.LittleEndian.Uint16(body[8:]))180- code := body[10:]181- if size < len(code) {182- code = code[:size]183- }184- w.hasString = false185- text, ok := lotusFormula(code, a, w.version)186- keep := func() {187- w.b.number(a, fromExcelSerial(v, f), f, sheet.Style{})188- w.pending, w.hasString = a, true189- }190- if !ok {191- w.b.kept(a, text, keep)192- return true193- }194- w.b.formula(a, "="+text, f, sheet.Style{}, keep)190+ return w.formula(a, f, body)195191 case wkString:196192 if w.hasString && w.pending == a {197193 w.b.text(a, lics(cstring(body)), sheet.Format{}, sheet.Style{})
@@ -202,6 +198,33 @@ func (w *wk1Reader) record(op uint16, d []byte) bool {
202198 return true203199 }204200 201+// formula stores a formula: its cached value, the bytecode's size and202+// the bytecode. A formula that can't be translated keeps its value, or203+// the text in the STRING record that may follow.204+func (w *wk1Reader) formula(a sheet.Addr, f sheet.Format, body []byte) bool {205+ if len(body) < 10 {206+ return false207+ }208+ v := math.Float64frombits(binary.LittleEndian.Uint64(body))209+ size := int(binary.LittleEndian.Uint16(body[8:]))210+ code := body[10:]211+ if size < len(code) {212+ code = code[:size]213+ }214+ w.hasString = false215+ text, ok := lotusFormula(code, a, w.version)216+ keep := func() {217+ w.b.number(a, fromExcelSerial(v, f), f, sheet.Style{})218+ w.pending, w.hasString = a, true219+ }220+ if !ok {221+ w.b.kept(a, text, keep)222+ return true223+ }224+ w.b.formula(a, "="+text, f, sheet.Style{}, keep)225+ return true226+}227+205228 // label stores a label, whose first character sets its alignment: '206229 // left, " right, ^ centered, \ repeated to fill the cell.207230 func (w *wk1Reader) label(a sheet.Addr, body []byte) {
M internal/fileio/xlsx.go +34 -27
@@ -314,39 +314,16 @@ func (w *xlsxWriter) sheet(ws string, snap *Snapshot) (int, error) {
314314 }315315 }316316 r := snap.Range317+ line := make([]any, r.To.Col-r.From.Col+1)317318 for row := r.From.Row; row <= r.To.Row; row++ {318- var line []any319319 for col := r.From.Col; col <= r.To.Col; col++ {320320 a := sheet.Addr{Col: col, Row: row}321- c, ok := snap.Cells[a]322- if !ok {323- line = append(line, nil)324- continue325- }326- // A formula's inferred format is written too, so its result327- // shows the same in Excel.328- f := c.Format329- id := 0330- if !f.IsZero() || !c.Style.IsZero() {331- if id, err = w.styleFor(f, c.Style); err != nil {321+ line[col-r.From.Col] = nil322+ if c, ok := snap.Cells[a]; ok {323+ if line[col-r.From.Col], err = w.cell(ws, a, c); err != nil {332324 return 0, err333325 }334326 }335- xc := excelize.Cell{StyleID: id, Value: xlsxValue(c.Value, f)}336- if c.Formula {337- if fx, ok := toExcelFormula(c.Input); ok {338- xc.Formula = fx339- } else {340- w.values++341- if w.example == "" {342- w.example = a.String()343- if w.multi {344- w.example = sheet.QuoteSheet(ws) + "!" + a.String()345- }346- }347- }348- }349- line = append(line, xc)350327 }351328 cell, _ := excelize.CoordinatesToCellName(r.From.Col+1, row+1)352329 if err := sw.SetRow(cell, line); err != nil {
@@ -359,6 +336,36 @@ func (w *xlsxWriter) sheet(ws string, snap *Snapshot) (int, error) {
359336 return r.To.Row - r.From.Row + 1, nil360337 }361338 339+// cell is the snapshot cell c at a as excelize writes it. A formula with340+// no Excel equivalent is written as its value, and counted.341+func (w *xlsxWriter) cell(ws string, a sheet.Addr, c SnapCell) (excelize.Cell, error) {342+ // A formula's inferred format is written too, so its result shows343+ // the same in Excel.344+ id := 0345+ if !c.Format.IsZero() || !c.Style.IsZero() {346+ var err error347+ if id, err = w.styleFor(c.Format, c.Style); err != nil {348+ return excelize.Cell{}, err349+ }350+ }351+ xc := excelize.Cell{StyleID: id, Value: xlsxValue(c.Value, c.Format)}352+ if !c.Formula {353+ return xc, nil354+ }355+ if fx, ok := toExcelFormula(c.Input); ok {356+ xc.Formula = fx357+ return xc, nil358+ }359+ w.values++360+ if w.example == "" {361+ w.example = a.String()362+ if w.multi {363+ w.example = sheet.QuoteSheet(ws) + "!" + a.String()364+ }365+ }366+ return xc, nil367+}368+362369 // xlsxValue is a computed value as excelize writes it.363370 func xlsxValue(v sheet.Value, f sheet.Format) any {364371 switch v.Kind {
M internal/fileio/xlsx_test.go +1 -3