cat / 012
clone

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

Imports and exports: max-cells budget, column and row styles

Dami committed f571a69
Imports keep whole rows up to the max-cells setting (two million cells
by default, about 600 MB) and say how many rows they left out; the grid
edges still apply past that, and WK1 keeps its own 8192 x 256. XLSX
column and row styles import as line formats and export from them.
Exporting a selection trims it to its data, so whole columns export
their rows rather than a million blank lines.
commit
f571a69b37078d4ac001d54a73d84769a21cfa58
parent
08ee498c8d16e719404833293a7b3023c0e084dd
author
Dami <dami.etoile@gmail.com>
16 files changed, +344 -58
Mdocs/config.md13+++
Minternal/config/registry.go9++-
Minternal/fileio/builder.go76++++++++++++++++++--
Minternal/fileio/csv.go19+++--
Minternal/fileio/csv_test.go37++++++++--
Minternal/fileio/fileio.go3+
Minternal/fileio/parquet.go6+-
Minternal/fileio/parquet_test.go12++--
Minternal/fileio/snapshot.go19++++-
Minternal/fileio/sqlite.go4+-
Minternal/fileio/sqlite_test.go6+-
Minternal/fileio/stress_test.go2+-
Minternal/fileio/wk1.go2+-
Minternal/fileio/xlsx.go29++++---
Ainternal/fileio/xlsxlines.go115++++++++++++++++++++++++++++++
Ainternal/fileio/xlsxlines_test.go50+++++++++++++
M docs/config.md +13 -0
M internal/config/registry.go +8 -1
@@ -65,11 +65,12 @@ const (
6565 GroupJEV = "JEV functions"6666 GroupTelemetry = "Telemetry"6767 GroupServe = "012 serve"68+ GroupData = "Data"6869 GroupFiles = "Config files"6970 )7071 7172 // Groups is the order groups are listed in.72-var Groups = []string{GroupAppearance, GroupJEV, GroupTelemetry, GroupServe, GroupFiles}73+var Groups = []string{GroupAppearance, GroupData, GroupJEV, GroupTelemetry, GroupServe, GroupFiles}7374 7475 // Options is every setting. Add an option here and read it with7576 // Config.String, Bool or List; parsing, `012 config`, docs/config.md
@@ -91,6 +92,12 @@ var Options = []Option{
9192 Desc: "Keys in the grid. `default` works like Google Sheets; `vim` adds hjkl, counts, operators, " +9293 "visual selection and a : command line (File > Settings > Vim keys)."},9394 95+ {Name: "max-cells", Kind: Int, Group: GroupData, Default: "2000000", Env: []string{"O12_MAX_CELLS"}, Live: true,96+ Desc: "The most cells an import keeps, and a paste or fill writes at once. A sheet takes about " +97+ "300 bytes a cell, so the default of two million is about 600 MB. Imports keep whole rows " +98+ "up to the budget and say how many they left out; larger pastes and fills are refused. " +99+ "The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is."},100+94101 {Name: "jev-api-key-command", Kind: Command, Group: GroupJEV,95102 Desc: "A command that prints the TypeSafe API key, used when TYPESAFE_API_KEY isn't set and the " +96103 "credential store has no key, e.g. `op read op://Private/TypeSafe/credential` or " +
M internal/fileio/builder.go +68 -8
@@ -20,22 +20,74 @@ type builder struct {
2020 rowsOut, colsOut int // how far past the sheet's edges the data went2121 values int // formulas kept as their values2222 valueExample string23+24+ // The cell budget (max-cells): rows are kept whole while their cells25+ // fit, and none after the first that doesn't.26+ maxCells int27+ cells int // cells stored28+ row int // the row being filled29+ rowCells []sheet.Addr // its cells stored so far30+ full bool // the budget ran out at row fullRow31+ fullRow int32+ lastRow int // the last row the data reached2333 }2434 25-func newBuilder() *builder { return &builder{s: sheet.New()} }35+// newBuilder starts a workbook keeping at most maxCells cells; 0 means36+// the max-cells setting, and a negative number no budget (the format's37+// own limits apply).38+func newBuilder(maxCells int) *builder {39+ if maxCells == 0 {40+ maxCells = sheet.MaxCells()41+ }42+ return &builder{s: sheet.New(), maxCells: maxCells}43+}2644 27-// fits reports whether a is inside the worksheet, recording by how much28-// data overflowed when it isn't.45+// fits reports whether a is inside the worksheet and the budget,46+// recording by how much data overflowed when it isn't.2947 func (b *builder) fits(a sheet.Addr) bool {48+ b.lastRow = max(b.lastRow, a.Row)3049 if a.Row >= sheet.MaxRows {3150 b.rowsOut = max(b.rowsOut, a.Row-sheet.MaxRows+1)3251 }3352 if a.Col >= sheet.MaxCols {3453 b.colsOut = max(b.colsOut, a.Col-sheet.MaxCols+1)3554 }36- return a.Valid()55+ return a.Valid() && !b.full56+}57+58+// nextSheet starts filling another sheet, whose rows start again at 1.59+func (b *builder) nextSheet(s *sheet.Sheet) {60+ b.s, b.row, b.rowCells = s, 0, b.rowCells[:0]61+}62+63+// take counts a cell about to be stored against the budget, reporting64+// false when it doesn't fit: then the row it's on is taken out again, so65+// the sheet ends with whole rows.66+func (b *builder) take(a sheet.Addr) bool {67+ if b.maxCells < 0 {68+ return true69+ }70+ if a.Row != b.row {71+ b.row, b.rowCells = a.Row, b.rowCells[:0]72+ }73+ if b.cells < b.maxCells {74+ b.cells++75+ b.rowCells = append(b.rowCells, a)76+ return true77+ }78+ for _, c := range b.rowCells {79+ b.s.Unload(c)80+ }81+ b.cells -= len(b.rowCells)82+ b.rowCells = b.rowCells[:0]83+ b.full, b.fullRow = true, a.Row84+ return false3785 }3886 87+// isFull reports whether the budget ran out, so an importer can stop88+// reading rows.89+func (b *builder) isFull() bool { return b.full }90+3991 // flatten replaces line breaks and tabs, which a cell can't show, with4092 // spaces.4193 func flatten(s string) string {
@@ -93,7 +145,7 @@ func numInput(v float64) string {
93145 }94146 95147 func (b *builder) put(a sheet.Addr, input string, f sheet.Format, st sheet.Style) {96- if input == "" && f.IsZero() && st.IsZero() || !b.fits(a) {148+ if input == "" && f.IsZero() && st.IsZero() || !b.fits(a) || !b.take(a) {97149 return98150 }99151 if err := b.s.Load(a, input, f, st); err != nil {
@@ -131,8 +183,12 @@ func (b *builder) formula(a sheet.Addr, text string, f sheet.Format, st sheet.St
131183 if !b.fits(a) {132184 return133185 }134- if _, err := sheet.Parse(text); err == nil && b.s.Load(a, text, f, st) == nil {135- return186+ if _, err := sheet.Parse(text); err == nil && b.take(a) {187+ if b.s.Load(a, text, f, st) == nil {188+ return189+ }190+ b.cells--191+ b.rowCells = b.rowCells[:len(b.rowCells)-1]136192 }137193 b.kept(a, text, keep)138194 }
@@ -156,7 +212,11 @@ func (b *builder) finish(notes []string) (*sheet.Sheet, []string) {
156212 if b.values > 0 {157213 notes = append(notes, fmt.Sprintf("%s kept as values, e.g. %s", count(b.values, "formula", "formulas"), b.valueExample))158214 }159- if b.rowsOut > 0 {215+ switch {216+ case b.full:217+ notes = append(notes, fmt.Sprintf("only the first %s rows fit in max-cells (%s cells); %s left out",218+ thousands(b.fullRow), thousands(b.maxCells), count(max(b.lastRow, b.fullRow)-b.fullRow+1+b.rowsOut, "row", "rows")))219+ case b.rowsOut > 0:160220 notes = append(notes, fmt.Sprintf("only the first %s rows fit; %s left out", thousands(sheet.MaxRows), count(b.rowsOut, "row", "rows")))161221 }162222 if b.colsOut > 0 {
M internal/fileio/csv.go +10 -9
@@ -138,11 +138,11 @@ func (c *countingReader) Read(p []byte) (int, error) {
138138 }139139 140140 func importCSV(ctx context.Context, name string, opt Options) (*Result, error) {141- return importDelimited(ctx, name, CSV, opt.Progress)141+ return importDelimited(ctx, name, CSV, opt)142142 }143143 144144 func importTSV(ctx context.Context, name string, opt Options) (*Result, error) {145- return importDelimited(ctx, name, TSV, opt.Progress)145+ return importDelimited(ctx, name, TSV, opt)146146 }147147 148148 func exportCSV(_ context.Context, name string, snap *Snapshot, _ ExportOptions) (*ExportResult, error) {
@@ -153,7 +153,8 @@ func exportTSV(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
153153 return exportDelimited(name, TSV, snap)154154 }155155 156-func importDelimited(ctx context.Context, name string, k Kind, prog *Progress) (*Result, error) {156+func importDelimited(ctx context.Context, name string, k Kind, opt Options) (*Result, error) {157+ prog := opt.Progress157158 f, err := os.Open(name)158159 if err != nil {159160 return nil, err
@@ -165,7 +166,7 @@ func importDelimited(ctx context.Context, name string, k Kind, prog *Progress) (
165166 size = st.Size()166167 }167168 counter := &countingReader{r: f}168- s, rows, notes, err := readDelimited(ctx, counter, k, func(rows int) {169+ s, rows, notes, err := readDelimited(ctx, counter, k, opt.MaxCells, func(rows int) {169170 prog.setRows(rows)170171 prog.setFrac(counter.n, size)171172 })
@@ -175,10 +176,10 @@ func importDelimited(ctx context.Context, name string, k Kind, prog *Progress) (
175176 return &Result{Sheet: s, Rows: rows, Notes: notes}, nil176177 }177178 178-// readDelimited reads CSV or TSV text, sniffing the delimiter of CSV.179-// Each field is entered as if typed. progress is called every few180-// hundred rows.181-func readDelimited(ctx context.Context, in io.Reader, k Kind, progress func(rows int)) (*sheet.Sheet, int, []string, error) {179+// readDelimited reads CSV or TSV text, sniffing the delimiter of CSV,180+// keeping up to maxCells cells (see newBuilder). Each field is entered as181+// if typed. progress is called every few hundred rows.182+func readDelimited(ctx context.Context, in io.Reader, k Kind, maxCells int, progress func(rows int)) (*sheet.Sheet, int, []string, error) {182183 br := bufio.NewReaderSize(in, sniffSize)183184 text, enc, err := decode(br)184185 if err != nil {
@@ -196,7 +197,7 @@ func readDelimited(ctx context.Context, in io.Reader, k Kind, progress func(rows
196197 cr.FieldsPerRecord = -1197198 cr.ReuseRecord = true198199 199- b := newBuilder()200+ b := newBuilder(maxCells)200201 row := 0201202 for ; ; row++ {202203 // Report before reading on: a pipe may keep the next read waiting.
M internal/fileio/csv_test.go +29 -8
@@ -77,7 +77,7 @@ func TestSniff(t *testing.T) {
7777 7878 func readText(t *testing.T, k Kind, text string) (*sheet.Sheet, []string) {7979 t.Helper()80- s, _, notes, err := readDelimited(context.Background(), strings.NewReader(text), k, func(int) {})80+ s, _, notes, err := readDelimited(context.Background(), strings.NewReader(text), k, 0, func(int) {})8181 if err != nil {8282 t.Fatal(err)8383 }
@@ -180,21 +180,42 @@ func TestReadDelimited(t *testing.T) {
180180 }181181 }182182 183+// Past the edges of the grid, and past the max-cells budget, imports184+// keep what fits (whole rows, for the budget) and say what they left out.183185 func TestReadDelimitedTruncates(t *testing.T) {184186 var b strings.Builder185- for range sheet.MaxRows + 5 {186- b.WriteString("x," + strings.Repeat("y,", sheet.MaxCols+2) + "\n")187+ b.WriteString("x," + strings.Repeat("y,", sheet.MaxCols+2) + "\n")188+ for range sheet.MaxRows + 4 {189+ b.WriteString("x\n")187190 }188- s, notes := readText(t, CSV, b.String())189- if got := input(s, sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}); got != "y" {190- t.Errorf("last cell = %q", got)191+ s, _, notes, err := readDelimited(context.Background(), strings.NewReader(b.String()), CSV, sheet.MaxRows+sheet.MaxCols, func(int) {})192+ if err != nil {193+ t.Fatal(err)194+ }195+ if got := input(s, sheet.Addr{Col: sheet.MaxCols - 1}) + input(s, sheet.Addr{Row: sheet.MaxRows - 1}); got != "yx" {196+ t.Errorf("last cells = %q", got)191197 }192198 joined := strings.Join(notes, "; ")193- for _, want := range []string{"only the first 8,192 rows fit; 5 rows left out", "only columns A to IV fit; 3 columns left out"} {199+ for _, want := range []string{"only the first 1,048,576 rows fit; 5 rows left out", "only columns A to XFD fit; 3 columns left out"} {194200 if !strings.Contains(joined, want) {195201 t.Errorf("notes %q, want %q", joined, want)196202 }197203 }204+205+ b.Reset()206+ for range 100 {207+ b.WriteString(strings.Repeat("7,", 9) + "7\n")208+ }209+ s, _, notes, err = readDelimited(context.Background(), strings.NewReader(b.String()), CSV, 255, func(int) {})210+ if err != nil {211+ t.Fatal(err)212+ }213+ if n := s.Len(); n != 250 || input(s, sheet.Addr{Col: 9, Row: 24}) != "7" {214+ t.Errorf("kept %d cells, want 25 whole rows", n)215+ }216+ if want := "only the first 25 rows fit in max-cells (255 cells); 75 rows left out"; strings.Join(notes, "; ") != want {217+ t.Errorf("notes %q, want %q", notes, want)218+ }198219 }199220 200221 func TestDelimitedRoundTrip(t *testing.T) {
@@ -259,6 +280,6 @@ func FuzzReadDelimited(f *testing.F) {
259280 if tsv {260281 k = TSV261282 }262- readDelimited(context.Background(), strings.NewReader(string(data)), k, func(int) {})283+ readDelimited(context.Background(), strings.NewReader(string(data)), k, 0, func(int) {})263284 })264285 }
M internal/fileio/fileio.go +3 -0
M internal/fileio/parquet.go +3 -3
M internal/fileio/parquet_test.go +6 -6
M internal/fileio/snapshot.go +15 -4
@@ -18,6 +18,10 @@ type Snapshot struct {
1818 Widths map[int]int // non-default column widths1919 Name string // what to call the data: a sheet or table name2020 21+ // ColFormats and RowFormats are the formats of whole columns and22+ // rows, for formats that keep them (XLSX).23+ ColFormats, RowFormats map[int]LineFormat24+2125 // Sheets are every sheet of the workbook, in order, for formats that2226 // hold several (XLSX); the snapshot itself is one of them, the one2327 // shown. Nil exports just this snapshot. Names are the workbook's
@@ -42,12 +46,19 @@ func (c SnapCell) Text() string {
4246 }4347 4448 // Snap copies the cells of s in r. A zero r means the whole sheet, from45-// A1 to the last cell with contents.49+// A1 to the last cell with contents; any other r is trimmed to its last50+// row and column with contents, so exporting whole columns writes their51+// data rather than a million blank lines.4652 func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {4753 if r == (sheet.Rect{}) {4854 r, _ = s.UsedRange()55+ } else if data, ok := s.FilledBounds(r); ok {56+ r.To = data.To57+ } else {58+ r.To = r.From4959 }50- snap := &Snapshot{Range: r, Cells: map[sheet.Addr]SnapCell{}, Widths: s.Widths(), Name: name}60+ snap := &Snapshot{Range: r, Cells: map[sheet.Addr]SnapCell{}, Widths: s.Widths(), Name: name,61+ ColFormats: snapLines(s, false), RowFormats: snapLines(s, true)}5162 for _, a := range s.Addrs() {5263 if !r.Contains(a) {5364 continue
@@ -57,8 +68,8 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
5768 Input: c.Input,5869 Value: c.Value,5970 Format: s.DisplayFormat(a),60- Own: c.Format,61- Style: c.Style,71+ Own: s.CellFormat(a),72+ Style: s.CellStyle(a),6273 Formula: c.IsFormula(),6374 }6475 }
M internal/fileio/sqlite.go +2 -2
M internal/fileio/sqlite_test.go +3 -3
M internal/fileio/stress_test.go +1 -1
M internal/fileio/wk1.go +1 -1
M internal/fileio/xlsx.go +17 -12
@@ -4,7 +4,6 @@ import (
44 "context"55 "fmt"66 "io"7- "math"87 "strconv"98 "strings"109
@@ -47,20 +46,22 @@ func importXLSX(ctx context.Context, name string, opt Options) (*Result, error)
4746 total += dims[i]4847 }4948 50- b := newBuilder()49+ b := newBuilder(opt.MaxCells)5150 book := b.s.Book()5251 styles := map[int]xlsxStyle{}5352 var notes []string5453 done := 05554 for i, ws := range names {55+ next := b.s5656 if i == 0 {5757 err = book.RenameSheet(b.s, ws)5858 } else {59- b.s, err = book.AddSheet(ws, i)59+ next, err = book.AddSheet(ws, i)6060 }6161 if err != nil {6262 return nil, fmt.Errorf("sheet %s: %w", ws, err)6363 }64+ b.nextSheet(next)6465 rows, err := importXLSXSheet(ctx, x, b, ws, styles, func(row int) {6566 prog.setRows(done + row)6667 prog.setFrac(int64(done+row), int64(total))
@@ -101,6 +102,7 @@ func importXLSXSheet(ctx context.Context, x *excelize.File, b *builder, ws strin
101102 if err != nil {102103 return 0, err103104 }105+ importXLSXRowStyle(x, b, rows, row, styles)104106 for col, raw := range cols {105107 a := sheet.Addr{Col: col, Row: row}106108 if !b.fits(a) {
@@ -113,14 +115,7 @@ func importXLSXSheet(ctx context.Context, x *excelize.File, b *builder, ws strin
113115 if err := rows.Error(); err != nil {114116 return 0, err115117 }116- for c := range sheet.MaxCols {117- colName, _ := excelize.ColumnNumberToName(c + 1)118- w, err := x.GetColWidth(ws, colName)119- if err != nil || math.Abs(w-excelDefaultWidth) < 0.01 || math.Abs(w-9.140625) < 0.01 {120- continue121- }122- b.s.SetColWidth(c, max(int(math.Round(w))+excelPadding, 1))123- }118+ importXLSXColumns(x, b, ws, styles)124119 return row, nil125120 }126121
@@ -311,6 +306,9 @@ func (w *xlsxWriter) sheet(ws string, snap *Snapshot) (int, error) {
311306 return 0, err312307 }313308 }309+ if err := w.colStyles(sw, snap.ColFormats); err != nil {310+ return 0, err311+ }314312 r := snap.Range315313 line := make([]any, r.To.Col-r.From.Col+1)316314 for row := r.From.Row; row <= r.To.Row; row++ {
@@ -324,9 +322,16 @@ func (w *xlsxWriter) sheet(ws string, snap *Snapshot) (int, error) {
324322 }325323 }326324 cell, _ := excelize.CoordinatesToCellName(r.From.Col+1, row+1)327- if err := sw.SetRow(cell, line); err != nil {325+ opts, err := w.rowOpts(snap.RowFormats, row)326+ if err != nil {328327 return 0, err329328 }329+ if err := sw.SetRow(cell, line, opts...); err != nil {330+ return 0, err331+ }332+ }333+ if err := w.styledRowsAfter(sw, snap.RowFormats, r.To.Row); err != nil {334+ return 0, err330335 }331336 if err := sw.Flush(); err != nil {332337 return 0, err
A internal/fileio/xlsxlines.go +115 -0
@@ -0,0 +1,115 @@
1+package fileio2+3+import (4+ "maps"5+ "math"6+ "slices"7+8+ "github.com/xuri/excelize/v2"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Column and row styles: Excel keeps a style for a whole column (in14+// <cols>) or row, as 012 keeps line formats; both travel both ways.15+16+// LineFormat is the format and style of a whole column or row.17+type LineFormat struct {18+ Format sheet.Format19+ Style sheet.Style20+}21+22+// snapLines copies a sheet's column (row false) or row formats.23+func snapLines(s *sheet.Sheet, row bool) map[int]LineFormat {24+ fs, ss := s.ColFormats(), s.ColStyles()25+ if row {26+ fs, ss = s.RowFormats(), s.RowStyles()27+ }28+ out := map[int]LineFormat{}29+ for n, f := range fs {30+ l := out[n]31+ l.Format = f32+ out[n] = l33+ }34+ for n, st := range ss {35+ l := out[n]36+ l.Style = st37+ out[n] = l38+ }39+ return out40+}41+42+// importXLSXColumns reads the sheet's column widths and styles.43+func importXLSXColumns(x *excelize.File, b *builder, ws string, styles map[int]xlsxStyle) {44+ for c := range sheet.MaxCols {45+ colName, _ := excelize.ColumnNumberToName(c + 1)46+ if w, err := x.GetColWidth(ws, colName); err == nil && math.Abs(w-excelDefaultWidth) >= 0.01 && math.Abs(w-9.140625) >= 0.01 {47+ b.s.SetColWidth(c, max(int(math.Round(w))+excelPadding, 1))48+ }49+ if id, err := x.GetColStyle(ws, colName); err == nil && id != 0 {50+ if st := styleOf(x, id, styles); st != (xlsxStyle{}) {51+ b.s.LoadLineFormat(false, c, st.format, st.style)52+ }53+ }54+ }55+}56+57+// importXLSXRowStyle keeps the style of row, if it has one of its own.58+func importXLSXRowStyle(x *excelize.File, b *builder, rows *excelize.Rows, row int, styles map[int]xlsxStyle) {59+ if id := rows.GetRowOpts().StyleID; id != 0 && row < sheet.MaxRows {60+ if st := styleOf(x, id, styles); st != (xlsxStyle{}) {61+ b.s.LoadLineFormat(true, row, st.format, st.style)62+ }63+ }64+}65+66+// colStyles sets the snapshot's column styles on a stream, before any67+// row: runs of columns with the same style as one range.68+func (w *xlsxWriter) colStyles(sw *excelize.StreamWriter, cols map[int]LineFormat) error {69+ ns := slices.Sorted(maps.Keys(cols))70+ for i := 0; i < len(ns); {71+ j := i + 172+ for j < len(ns) && ns[j] == ns[j-1]+1 && cols[ns[j]] == cols[ns[i]] {73+ j++74+ }75+ l := cols[ns[i]]76+ id, err := w.styleFor(l.Format, l.Style)77+ if err != nil {78+ return err79+ }80+ if err := sw.SetColStyle(ns[i]+1, ns[j-1]+1, id); err != nil {81+ return err82+ }83+ i = j84+ }85+ return nil86+}87+88+// rowOpts is how the stream writes row: with its style, if it has one.89+func (w *xlsxWriter) rowOpts(rows map[int]LineFormat, row int) ([]excelize.RowOpts, error) {90+ l, ok := rows[row]91+ if !ok {92+ return nil, nil93+ }94+ id, err := w.styleFor(l.Format, l.Style)95+ return []excelize.RowOpts{{StyleID: id}}, err96+}97+98+// styledRowsAfter writes the rows past the exported range that have a99+// style of their own, as empty rows.100+func (w *xlsxWriter) styledRowsAfter(sw *excelize.StreamWriter, rows map[int]LineFormat, last int) error {101+ for _, row := range slices.Sorted(maps.Keys(rows)) {102+ if row <= last {103+ continue104+ }105+ opts, err := w.rowOpts(rows, row)106+ if err != nil {107+ return err108+ }109+ cell, _ := excelize.CoordinatesToCellName(1, row+1)110+ if err := sw.SetRow(cell, nil, opts...); err != nil {111+ return err112+ }113+ }114+ return nil115+}
A internal/fileio/xlsxlines_test.go +50 -0
@@ -0,0 +1,50 @@
1+package fileio2+3+import (4+ "context"5+ "path/filepath"6+ "testing"7+8+ "github.com/xuri/excelize/v2"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Column and row formats go to Excel as column and row styles and come14+// back as line formats, without a cell per formatted blank.15+func TestXLSXLineFormats(t *testing.T) {16+ src := build(t, map[string]string{"A1": "Item", "B1": "Price", "B2": "3.5", "A3": "total"})17+ src.SetFormat(sheet.Rect{From: sheet.Addr{Col: 1}, To: sheet.Addr{Col: 2, Row: sheet.MaxRows - 1}}, sheet.Preset(sheet.FmtCurrency))18+ src.SetStyle(sheet.Rect{From: sheet.Addr{Row: 2}, To: sheet.Addr{Col: sheet.MaxCols - 1, Row: 2}}, func(st *sheet.Style) { st.Bold = true })19+ src.SetStyle(sheet.Rect{From: sheet.Addr{Row: 99}, To: sheet.Addr{Col: sheet.MaxCols - 1, Row: 99}}, func(st *sheet.Style) { st.Italic = true })20+21+ name := filepath.Join(t.TempDir(), "lines.xlsx")22+ if _, err := Export(context.Background(), name, XLSX, Snap(src, sheet.Rect{}, "Lines"), ExportOptions{}); err != nil {23+ t.Fatal(err)24+ }25+ x, err := excelize.OpenFile(name)26+ if err != nil {27+ t.Fatal(err)28+ }29+ if id, _ := x.GetColStyle("Lines", "C"); id == 0 {30+ t.Error("column C has no style in Excel")31+ }32+ x.Close()33+34+ res, err := Import(context.Background(), name, Options{})35+ if err != nil {36+ t.Fatal(err)37+ }38+ got := res.Sheet39+ for _, a := range []string{"B9", "C1048576"} {40+ if f := got.DisplayFormat(addr(t, a)); f.Kind != sheet.FmtCurrency {41+ t.Errorf("%s shows %v, want currency", a, f.Kind)42+ }43+ }44+ if !got.CellStyle(addr(t, "D3")).Bold || !got.CellStyle(addr(t, "Q100")).Italic || got.CellStyle(addr(t, "D4")).Bold {45+ t.Error("row styles didn't come back")46+ }47+ if n := len(got.ColFormats()); n != 2 {48+ t.Errorf("%d column formats, want 2", n)49+ }50+}