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>
| M | docs/ | 13 | +++ |
| M | internal/ | 9 | ++- |
| M | internal/ | 76 | ++++++++++++++++++-- |
| M | internal/ | 19 | +++-- |
| M | internal/ | 37 | ++++++++-- |
| M | internal/ | 3 | + |
| M | internal/ | 6 | +- |
| M | internal/ | 12 | ++-- |
| M | internal/ | 19 | ++++- |
| M | internal/ | 4 | +- |
| M | internal/ | 6 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 29 | ++++--- |
| A | internal/ | 115 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 50 | +++++++++++++ |
@@ -106,6 +106,19 @@ Keys in the grid. `default` works like Google Sheets; `vim` adds hjkl, counts, o
106106 | Environment | `O12_KEYMAP` |107107 | Applies | File > Settings > Reload config |108108 109+### Data110+111+#### `max-cells`112+113+The most cells an import keeps, and a paste or fill writes at once. A sheet takes about 300 bytes a cell, so the default of two million is about 600 MB. Imports keep whole rows up to the budget and say how many they left out; larger pastes and fills are refused. The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is.114+115+| | |116+|---|---|117+| Type | number |118+| Default | `2000000` |119+| Environment | `O12_MAX_CELLS` |120+| Applies | File > Settings > Reload config |121+109122 ### JEV functions110123 111124 #### `jev-api-key-command`@@ -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 " +@@ -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 {@@ -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.@@ -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 }@@ -76,6 +76,9 @@ type Options struct {
7676 // With neither, a database with one table imports it.7777 Table, Query string7878 Progress *Progress79+ // MaxCells is the most cells to keep, whole rows at a time; 0 is the80+ // max-cells setting (sheet.MaxCells). WK1 files keep their own limits.81+ MaxCells int7982 }8083 8184 // Result is an imported sheet and what the import had to leave out or@@ -42,13 +42,13 @@ func importParquet(ctx context.Context, name string, opt Options) (*Result, erro
4242 if err != nil {4343 return nil, err4444 }45- b := newBuilder()45+ b := newBuilder(opt.MaxCells)4646 r := &parquetReader{4747 b: b, cols: parquetHeader(b, pf.Schema()), prog: prog,4848 row: 1, total: pf.NumRows(), buf: make([]parquet.Row, 256),4949 }5050 for _, rg := range pf.RowGroups() {51- if r.row >= sheet.MaxRows {51+ if r.row >= sheet.MaxRows || b.isFull() {5252 break5353 }5454 if err := r.group(ctx, rg); err != nil {@@ -102,7 +102,7 @@ type parquetReader struct {
102102 func (r *parquetReader) group(ctx context.Context, rg parquet.RowGroup) error {103103 rows := rg.Rows()104104 defer rows.Close()105- for r.row < sheet.MaxRows {105+ for r.row < sheet.MaxRows && !r.b.isFull() {106106 if err := ctx.Err(); err != nil {107107 return err108108 }@@ -68,13 +68,13 @@ func TestParquetImport(t *testing.T) {
6868 }6969 }7070 71-// TestParquetPastLimit reads rows up to the sheet's last and counts the71+// TestParquetPastLimit reads rows up to the cell budget and counts the7272 // rest from the file's metadata.7373 func TestParquetPastLimit(t *testing.T) {7474 type num struct {7575 N int32 `parquet:"n"`7676 }77- rows := make([]num, sheet.MaxRows+4)77+ rows := make([]num, 8196)7878 for i := range rows {7979 rows[i].N = int32(i + 1)8080 }@@ -92,18 +92,18 @@ func TestParquetPastLimit(t *testing.T) {
9292 }9393 f.Close()9494 95- res, err := Import(context.Background(), name, Options{})95+ res, err := Import(context.Background(), name, Options{MaxCells: 8192})9696 if err != nil {9797 t.Fatal(err)9898 }99- last := sheet.Addr{Row: sheet.MaxRows - 1}100- if g := shown(res.Sheet, last); g != "8191" {99+ last := sheet.Addr{Row: 8191}100+ if g := shown(res.Sheet, last); g != "8191" || res.Sheet.Len() != 8192 {101101 t.Errorf("last row shows %q, want 8191", g)102102 }103103 if res.Rows != len(rows)+1 {104104 t.Errorf("rows %d, want %d", res.Rows, len(rows)+1)105105 }106- if len(res.Notes) != 1 || res.Notes[0] != "only the first 8,192 rows fit; 5 rows left out" {106+ if len(res.Notes) != 1 || res.Notes[0] != "only the first 8,192 rows fit in max-cells (8,192 cells); 5 rows left out" {107107 t.Errorf("notes %q", res.Notes)108108 }109109 }@@ -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 }@@ -121,7 +121,7 @@ func importSQLite(ctx context.Context, name string, opt Options) (*Result, error
121121 return nil, sqliteErr(err)122122 }123123 124- b := newBuilder()124+ b := newBuilder(opt.MaxCells)125125 header := sheet.Style{Bold: true}126126 for c, name := range cols {127127 b.text(sheet.Addr{Col: c}, name, sheet.Format{}, header)@@ -182,7 +182,7 @@ func readSQLiteRows(ctx context.Context, rows *sql.Rows, b *builder, ncols, tota
182182 prog.setRows(row)183183 prog.setFrac(int64(row), int64(total))184184 }185- if row >= sheet.MaxRows {185+ if row >= sheet.MaxRows || b.isFull() {186186 if total >= 0 {187187 row = max(row, total+1)188188 break@@ -130,15 +130,15 @@ func TestSQLitePastLimit(t *testing.T) {
130130 if err != nil {131131 t.Fatal(err)132132 }133- for _, opt := range []Options{{Table: "nums"}, {Query: "SELECT n FROM nums"}} {133+ for _, opt := range []Options{{Table: "nums", MaxCells: 8192}, {Query: "SELECT n FROM nums", MaxCells: 8192}} {134134 res, err := Import(context.Background(), name, opt)135135 if err != nil {136136 t.Fatal(err)137137 }138- if g := shown(res.Sheet, sheet.Addr{Row: sheet.MaxRows - 1}); g != "8191" {138+ if g := shown(res.Sheet, sheet.Addr{Row: 8191}); g != "8191" {139139 t.Errorf("%+v: last row shows %q, want 8191", opt, g)140140 }141- if res.Rows != 8196 || len(res.Notes) != 1 || res.Notes[0] != "only the first 8,192 rows fit; 4 rows left out" {141+ if res.Rows != 8196 || len(res.Notes) != 1 || res.Notes[0] != "only the first 8,192 rows fit in max-cells (8,192 cells); 4 rows left out" {142142 t.Errorf("%+v: rows %d, notes %q", opt, res.Rows, res.Notes)143143 }144144 }@@ -83,7 +83,7 @@ func BenchmarkImport(b *testing.B) {
8383 // BenchmarkExport writes a dense numeric sheet in each export format and8484 // reports the file size.8585 func BenchmarkExport(b *testing.B) {86- for _, size := range []struct{ rows, cols int }{{sheet.MaxRows, 26}, {sheet.MaxRows, 256}} {86+ for _, size := range []struct{ rows, cols int }{{stress.Rows, 26}, {stress.Rows, 256}} {8787 s := stress.Dense(size.rows, size.cols)8888 snap := Snap(s, sheet.Rect{}, "data")8989 for _, k := range []Kind{CSV, TSV, XLSX, SQLite} {@@ -82,7 +82,7 @@ type wk1Reader struct {
8282 // readWK1 reads a worksheet record by record. progress gets the number8383 // of cells read so far.8484 func readWK1(ctx context.Context, r io.Reader, progress func(cells int)) (*sheet.Sheet, []string, int, error) {85- w := &wk1Reader{b: newBuilder(), version: wk1File}85+ w := &wk1Reader{b: newBuilder(-1), version: wk1File} // the format's own 8192 x 2568686 cells := 08787 for i := 0; ; i++ {8888 op, data, err := w.next(r, i)@@ -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@@ -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+}@@ -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+}