Frozen rows and columns go out as a frozen <pane> and come back through Sheet.LoadFrozen. A filter goes out as <autoFilter> with the values shown or a custom filter for its condition, the rows it hides written hidden and a _xlnm._FilterDatabase name; an AutoFilter comes back as a filter (Sheet.LoadFilter, no undo) with the criteria 012 can apply, and a note on the rest. The excelize reference importer reads panes with GetPanes and AutoFilters with encoding/xml, and the differential corpus has both. Co-Authored-By: Claude <noreply@anthropic.com>
- commit
- 8bd3c19c262923e348adbc6f7d79d49e66568ae1
- parent
- f3fe273d338d006868a825582e75f2a53da347a1
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 26 | +++- |
| M | internal/ | 18 | ++- |
| M | internal/ | 36 | +++- |
| M | internal/ | 15 | ++ |
| M | internal/ | 122 | ++++++++++++- |
| M | internal/ | 16 | +- |
| M | internal/ | 3 | + |
| M | internal/ | 10 | +- |
| A | internal/ | 184 | +++++++++++++++++++ |
| A | internal/ | 176 | ++++++++++++++++++ |
| A | internal/ | 293 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 13 | +- |
| M | internal/ | 4 | + |
| M | internal/ | 14 | + |
@@ -19,11 +19,35 @@ File > Import asks where the data goes, as Sheets' Import location does
1919 | Format | Import | Download |2020 |---|---|---|2121 | CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed; formulas stay text | Values as shown, as Sheets' Download does |22-| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, column and row styles, shared formulas, dates in the 1904 system; sheets Excel hid stay hidden (unless it's the one Excel showed). Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed. Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) are refused with a message saying which | Every sheet, the same, with formulas in Excel's syntax and their results cached, and column and row formats as column and row styles. JEV functions and `#AND#` save as values, and so do formulas naming a sheet that doesn't exist (their `#REF!`; Excel would refuse the reference) or a sheet whose name Excel can't take as is (renamed in the file, e.g. `Plan (2)`), and pivot tables: Excel gets the results, not a pivot. The download's result counts the formulas saved as values, with an example |22+| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, column and row styles, frozen panes, filters (see [Filters in XLSX](#filters-in-xlsx)), shared formulas, dates in the 1904 system; sheets Excel hid stay hidden (unless it's the one Excel showed). Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed. Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) are refused with a message saying which | Every sheet, the same, with formulas in Excel's syntax and their results cached, column and row formats as column and row styles, frozen rows and columns as frozen panes, and a filter as Excel's with the rows it hides hidden. JEV functions and `#AND#` save as values, and so do formulas naming a sheet that doesn't exist (their `#REF!`; Excel would refuse the reference) or a sheet whose name Excel can't take as is (renamed in the file, e.g. `Plan (2)`), and pivot tables: Excel gets the results, not a pivot. The download's result counts the formulas saved as values, with an example |2323 | SQLite | Pick a table or view, or type a query; a header row names the columns | The sheet shown or the selection as a table, first row as column names; a table of that name is replaced |2424 | Parquet | Every column, with dates and timestamps; lists joined with commas | |2525 | Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |2626 27+## Filters in XLSX28+29+A sheet's filter goes out as Excel's AutoFilter over the same range, and30+an AutoFilter comes in as a filter:31+32+| 012 | Excel |33+|---|---|34+| Filter by values: the values unchecked | The list of values shown (Excel keeps those checked), with blanks when they're shown. Values are compared as displayed, ignoring case |35+| Is empty | Blanks only |36+| Is not empty | Custom filter: does not equal a space |37+| Text contains, does not contain, starts with, ends with, is exactly | Custom filter: equals or does not equal `*text*`, `text*`, `*text`, `text`, with Excel's wildcards in the text escaped (`~*`) |38+| Greater than, less than, is equal to, is not equal to (and or equal to) | Custom filter with that operator; numbers typed as `$1,200` or `12%` go out as the number |39+40+The rows the filter hides are written hidden, since Excel shows a file's41+rows as saved rather than filtering again. A column with both unchecked42+values and a condition goes out as the list of values both let through,43+and comes back as that list. What 012's filters can't do is left out,44+with a note saying how many criteria and which: two conditions in one45+column (`and`, `or`), wildcards other than at the ends of the text (`a?c`,46+`a*c`), top 10, dynamic filters (above average, this month), dates47+grouped by year or month, and filtering by color or icon. The filter48+itself still comes in over its range. Filters of Excel tables (as49+opposed to the sheet's AutoFilter) aren't read.50+2751 ## The native format2852 2953 `.012` files are JSON with one line per cell, keyed by address, so diffs@@ -29,6 +29,13 @@ type Snapshot struct {
2929 Sheets []*Snapshot3030 Names [][2]string3131 Hidden bool // a hidden sheet of Sheets, written hidden32+33+ // FrozenRows and FrozenCols are the frozen panes; Filter is the34+ // sheet's filter, if any, and HiddenRows the rows of Range it hides,35+ // for formats that keep them (XLSX).36+ FrozenRows, FrozenCols int37+ Filter *sheet.Filter38+ HiddenRows map[int]bool3239 }3340 3441 // SnapCell is one non-blank cell of a snapshot.@@ -60,7 +67,16 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
6067 r.To = r.From6168 }6269 snap := &Snapshot{Range: r, Cells: map[sheet.Addr]SnapCell{}, Widths: s.Widths(), Name: name,63- ColFormats: snapLines(s, false), RowFormats: snapLines(s, true)}70+ ColFormats: snapLines(s, false), RowFormats: snapLines(s, true), Filter: s.Filter()}71+ snap.FrozenRows, snap.FrozenCols = s.Frozen()72+ if f := snap.Filter; f != nil {73+ snap.HiddenRows = map[int]bool{}74+ for row := max(f.Range.From.Row, r.From.Row); row <= min(f.Range.To.Row, r.To.Row); row++ {75+ if s.RowHidden(row) {76+ snap.HiddenRows[row] = true77+ }78+ }79+ }6480 for _, a := range s.Addrs() {6581 if !r.Contains(a) {6682 continue@@ -70,7 +70,7 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
7070 if err := writePart(zw, "xl/styles.xml", w.styles.xml()); err != nil {7171 return err7272 }73- if err := writePackage(zw, names, hidden, active, snap.Names); err != nil {73+ if err := writePackage(zw, names, hidden, active, snap.Names, filterRanges(sheets, names)); err != nil {7474 return err7575 }7676 return zw.Close()@@ -110,10 +110,23 @@ const (
110110 mlType = `application/vnd.openxmlformats-officedocument.spreadsheetml.`111111 )112112 113+// filterRanges is each sheet's filter range as Excel names it, with its114+// sheet (_xlnm._FilterDatabase), or "" for a sheet without a filter.115+func filterRanges(sheets []*Snapshot, names []string) []string {116+ out := make([]string, len(sheets))117+ for i, sn := range sheets {118+ if sn.Filter != nil {119+ out[i] = excelRange(names[i], sn.Filter.Range)120+ }121+ }122+ return out123+}124+113125 // writePackage writes the parts around the worksheets and styles: the114-// workbook with its sheets, active tab, names and calculation settings,115-// the relationships and the content types.116-func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, defined [][2]string) error {126+// workbook with its sheets, active tab, names (with each filter's range,127+// filters[i] for sheet i) and calculation settings, the relationships128+// and the content types.129+func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, defined [][2]string, filters []string) error {117130 var types, rels, book strings.Builder118131 types.WriteString(xmlHead + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +119132 `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +@@ -136,12 +149,17 @@ func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, def
136149 fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="%s/styles" Target="styles.xml"/></Relationships>`, len(names)+1, officeRel)137150 types.WriteString(`</Types>`)138151 book.WriteString(`</sheets>`)139- if len(defined) > 0 {140- book.WriteString(`<definedNames>`)141- for _, n := range defined {142- fmt.Fprintf(&book, `<definedName name="%s">%s</definedName>`, escapeXML(n[0], true), escapeXML(n[1], false))152+ var dn strings.Builder153+ for i, r := range filters {154+ if r != "" {155+ fmt.Fprintf(&dn, `<definedName name="_xlnm._FilterDatabase" localSheetId="%d" hidden="1">%s</definedName>`, i, escapeXML(r, false))143156 }144- book.WriteString(`</definedNames>`)157+ }158+ for _, n := range defined {159+ fmt.Fprintf(&dn, `<definedName name="%s">%s</definedName>`, escapeXML(n[0], true), escapeXML(n[1], false))160+ }161+ if dn.Len() > 0 {162+ book.WriteString(`<definedNames>` + dn.String() + `</definedNames>`)145163 }146164 book.WriteString(`<calcPr calcId="191029" fullCalcOnLoad="1"/></workbook>`)147165 for _, p := range []struct{ name, body string }{@@ -90,6 +90,14 @@ func writeExcelizeBook(t *testing.T, dir string) string {
9090 x.SetColWidth("Q2 plan", "C", "C", 30)9191 x.SetDefinedName(&excelize.DefinedName{Name: "Rent", RefersTo: "Q1!$B$1"})9292 x.SetDefinedName(&excelize.DefinedName{Name: "Mine", RefersTo: "Q1!$B$1", Scope: "Q2 plan"})93+ // Frozen panes, and filters: values, conditions and one 012 can't apply.94+ x.SetPanes("Q1", &excelize.Panes{Freeze: true, XSplit: 1, YSplit: 1, TopLeftCell: "B2", ActivePane: "bottomRight"})95+ x.SetPanes("Q2 plan", &excelize.Panes{Freeze: true, YSplit: 2, TopLeftCell: "A3", ActivePane: "bottomLeft"})96+ x.AutoFilter("Q1", "A1:H4", []excelize.AutoFilterOptions{{Column: "E", Expression: "x == *gap"}, {Column: "B", Expression: "x > 1 and x < 5000"}})97+ for i, fruit := range []string{"Fruit", "Apple", "Pear", "banana", "Plum", "Apple"} {98+ x.SetSheetRow("Q2 plan", "A"+itoa(10+i), &[]any{fruit, i})99+ }100+ x.AutoFilter("Q2 plan", "A10:B15", []excelize.AutoFilterOptions{{Column: "A", Expression: "x == apple or x == Plum"}, {Column: "B", Expression: "x >= 2"}})93101 x.SetActiveSheet(1)94102 path := filepath.Join(dir, "excelize.xlsx")95103 if err := x.SaveAs(path); err != nil {@@ -109,6 +117,13 @@ func writeExportedBook(t *testing.T, dir string) string {
109117 f.Set(sheet.Addr{Col: 1, Row: row}, "=SUM($A$1:A"+itoa(row+1)+")")110118 }111119 f.SetColWidth(0, 20)120+ f.SetFrozen(1, 2)121+ f.CreateFilter(sheet.NewRect(sheet.Addr{}, sheet.Addr{Col: 1, Row: 39}))122+ f.FilterColumn(1, sheet.Criteria{Cond: sheet.Condition{Op: sheet.CondGreater, Arg: "500"}})123+ s.SetFrozen(1, 0)124+ s.CreateFilter(sheet.NewRect(sheet.Addr{}, sheet.Addr{Col: 9, Row: 60}))125+ s.FilterColumn(1, sheet.Criteria{Hidden: []string{"beta", "gamma"}})126+ s.FilterColumn(3, sheet.Criteria{Cond: sheet.Condition{Op: sheet.CondContains, Arg: "1"}})112127 path := filepath.Join(dir, "exported.xlsx")113128 if _, err := Export(context.Background(), path, XLSX, SnapBook(f), ExportOptions{}); err != nil {114129 t.Fatal(err)@@ -4,9 +4,12 @@ package fileio
44 // as the reference for TestXLSXDifferential.55 66 import (7+ "archive/zip"78 "context"9+ "encoding/xml"810 "fmt"911 "math"12+ "os"1013 "strconv"1114 "strings"1215 @@ -65,8 +68,13 @@ func importXLSXExcelize(ctx context.Context, name string, opt Options) (*Result,
6568 if err != nil {6669 return nil, err6770 }71+ excelizePanes(x, ws, next)6872 done += rows6973 }74+ filters, err := referenceAutoFilters(name)75+ if err != nil {76+ return nil, err77+ }7078 notes = append(notes, excelizeNames(x, book)...)7179 active := book.Sheet(clamp(x.GetActiveSheetIndex(), 0, book.Len()-1))7280 book.SetActive(active)@@ -78,7 +86,119 @@ func importXLSXExcelize(ctx context.Context, name string, opt Options) (*Result,
7886 b.s = active7987 prog.setRows(done)8088 s, notes := b.finish(notes)81- return &Result{Sheet: s, Rows: done, Notes: notes}, nil89+ return &Result{Sheet: s, Rows: done, Notes: append(notes, applyFilters(book, filters)...)}, nil90+}91+92+// excelizePanes freezes s as sheet ws is frozen.93+func excelizePanes(x *excelize.File, ws string, s *sheet.Sheet) {94+ if p, err := x.GetPanes(ws); err == nil && p.Freeze {95+ s.LoadFrozen(p.YSplit, p.XSplit)96+ }97+}98+99+// refWorksheet is the part of a worksheet referenceAutoFilters reads.100+type refWorksheet struct {101+ AutoFilter *struct {102+ Ref string `xml:"ref,attr"`103+ Cols []struct {104+ ColID int `xml:"colId,attr"`105+ Filters *struct {106+ Blank string `xml:"blank,attr"`107+ Filter []struct {108+ Val string `xml:"val,attr"`109+ } `xml:"filter"`110+ Dates []struct{} `xml:"dateGroupItem"`111+ } `xml:"filters"`112+ Custom *struct {113+ Filter []struct {114+ Op string `xml:"operator,attr"`115+ Val string `xml:"val,attr"`116+ } `xml:"customFilter"`117+ } `xml:"customFilters"`118+ Top10 *struct{} `xml:"top10"`119+ Dynamic *struct{} `xml:"dynamicFilter"`120+ Color *struct{} `xml:"colorFilter"`121+ Icon *struct{} `xml:"iconFilter"`122+ } `xml:"filterColumn"`123+ } `xml:"autoFilter"`124+}125+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) {131+ f, err := os.Open(name)132+ if err != nil {133+ return nil, err134+ }135+ defer f.Close()136+ st, err := f.Stat()137+ if err != nil {138+ return nil, err139+ }140+ bk, err := openXLSX(f, st.Size(), defaultXLSXLimits)141+ if err != nil {142+ return nil, err143+ }144+ zr, err := zip.NewReader(f, st.Size())145+ if err != nil {146+ return nil, err147+ }148+ out := make([]*xlsxAutoFilter, len(bk.sheets))149+ for i, info := range bk.sheets {150+ if info.part == "" || info.kind != "worksheet" {151+ continue152+ }153+ rc, err := zr.Open(info.part)154+ if err != nil {155+ return nil, err156+ }157+ var ws refWorksheet158+ err = xml.NewDecoder(rc).Decode(&ws)159+ rc.Close()160+ if err != nil {161+ return nil, err162+ }163+ out[i] = ws.autoFilter()164+ }165+ return out, nil166+}167+168+func (ws refWorksheet) autoFilter() *xlsxAutoFilter {169+ a := ws.AutoFilter170+ if a == nil {171+ return nil172+ }173+ af := &xlsxAutoFilter{ref: a.Ref}174+ for _, c := range a.Cols {175+ fc := xlsxFilterColumn{col: c.ColID}176+ if fs := c.Filters; fs != nil {177+ fc.filters, fc.blank, fc.dates = true, fs.Blank == "1" || fs.Blank == "true", len(fs.Dates) > 0178+ for _, v := range fs.Filter {179+ fc.values = append(fc.values, v.Val)180+ }181+ }182+ if c.Custom != nil {183+ for _, cf := range c.Custom.Filter {184+ op := cf.Op185+ if op == "" {186+ op = "equal"187+ }188+ fc.custom = append(fc.custom, xlsxCustomFilter{op: op, val: cf.Val})189+ }190+ }191+ for _, o := range []struct {192+ set bool193+ what string194+ }{{c.Top10 != nil, "top 10"}, {c.Dynamic != nil, "a dynamic filter"}, {c.Color != nil, "by color"}, {c.Icon != nil, "by icon"}} {195+ if o.set {196+ fc.other = o.what197+ }198+ }199+ af.cols = append(af.cols, fc)200+ }201+ return af82202 }83203 84204 // excelizeSheet reads one sheet into b.s, reporting rows read, and@@ -46,6 +46,7 @@ func (bk *xlsxBook) importBook(ctx context.Context, opt Options) (*Result, error
4646 b := newBuilder(ctx, opt.MaxCells)4747 book := b.s.Book()4848 done := 049+ filters := make([]*xlsxAutoFilter, len(bk.sheets))4950 var err error5051 for i, info := range bk.sheets {5152 next := b.s@@ -58,7 +59,7 @@ func (bk *xlsxBook) importBook(ctx context.Context, opt Options) (*Result, error
5859 return nil, fmt.Errorf("sheet %s: %w", info.name, err)5960 }6061 b.nextSheet(next)61- rows, err := bk.importSheet(ctx, b, i, func(row int) {62+ rows, err := bk.importSheet(ctx, b, i, &filters[i], func(row int) {6263 prog.setRows(done + row)6364 prog.setFrac(int64(done+row), int64(total))6465 })@@ -78,17 +79,19 @@ func (bk *xlsxBook) importBook(ctx context.Context, opt Options) (*Result, error
7879 b.s = active7980 prog.setRows(done)8081 s, notes := b.finish(notes)81- return &Result{Sheet: s, Rows: done, Notes: notes}, nil82+ return &Result{Sheet: s, Rows: done, Notes: append(notes, applyFilters(book, filters)...)}, nil8283 }8384 84-// importSheet reads sheet i into b.s, reporting rows read, and returns85-// the number of its last row.86-func (bk *xlsxBook) importSheet(ctx context.Context, b *builder, i int, report func(int)) (int, error) {85+// importSheet reads sheet i into b.s, with its frozen panes, reporting86+// rows read, and returns the number of its last row. Its autoFilter, if87+// any, goes in af, to apply once values are computed.88+func (bk *xlsxBook) importSheet(ctx context.Context, b *builder, i int, af **xlsxAutoFilter, report func(int)) (int, error) {8789 r, err := bk.openSheet(i)8890 if err != nil {8991 return 0, err9092 }9193 defer r.close()94+ b.s.LoadFrozen(r.frozenRows, r.frozenCols)9295 last, width := 0, 09396 for n := 0; ; n++ {9497 ok, err := r.next()@@ -112,6 +115,9 @@ func (bk *xlsxBook) importSheet(ctx context.Context, b *builder, i int, report f
112115 width = max(width, r.row.cells[n-1].col)113116 }114117 }118+ if *af, err = r.readTail(); err != nil {119+ return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)120+ }115121 loadColStyles(b, width, func(c int) (xlsxStyle, bool) {116122 st := bk.styles.style(r.lineStyle(c + 1))117123 return st, st != (xlsxStyle{})@@ -122,6 +122,9 @@ func (w *xlsxWriter) rowStart(bw *bufio.Writer, snap *Snapshot, row int) {
122122 if l, ok := snap.RowFormats[row]; ok {123123 fmt.Fprintf(bw, ` s="%d" customFormat="1"`, w.styles.id(l.Format, l.Style))124124 }125+ if snap.HiddenRows[row] {126+ bw.WriteString(` hidden="1"`)127+ }125128 bw.WriteString(`>`)126129 }127130 @@ -51,6 +51,10 @@ type xlsxSheetReader struct {
5151 5252 colStyles []int // by column, see colStyle5353 54+ // The first sheet view's frozen panes (see readPane).55+ paneRead bool56+ frozenRows, frozenCols int57+5458 inData bool // inside <sheetData>5559 row xlsxRowData5660 shared map[int]sharedFormula@@ -101,10 +105,14 @@ func (r *xlsxSheetReader) readHead() error {
101105 return err102106 }103107 se, ok := t.(xml.StartElement)104- if !ok || r.x.depth != 2 && se.Name.Local != "col" {108+ if !ok || r.x.depth != 2 && se.Name.Local != "col" && se.Name.Local != "pane" {105109 continue106110 }107111 switch se.Name.Local {112+ case "pane":113+ if r.x.depth == 4 {114+ r.readPane(se)115+ }108116 case "sheetData":109117 r.inData = true110118 return nil@@ -0,0 +1,184 @@
1+package fileio2+3+import (4+ "bufio"5+ "fmt"6+ "maps"7+ "slices"8+ "strconv"9+ "strings"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Frozen panes and filters in XLSX: written here, read in xlsxviewread.go.15+//16+// A filter is an <autoFilter> over its range, after the cells, with a17+// <filterColumn> for each column with criteria (colId counts from the18+// range's first column). Hidden values become the list of values shown19+// (<filters>, blank="1" for blanks), as Excel keeps them; a condition20+// alone becomes a custom filter where Excel has one (text conditions as21+// wildcard patterns), else the values it lets through. The rows the22+// filter hides are written hidden, since Excel doesn't filter again on23+// opening.24+25+// excelRect writes r as Excel's A1:G4.26+func excelRect(r sheet.Rect) string {27+ a := func(x sheet.Addr) string { return excelColName(x.Col+1) + strconv.Itoa(x.Row+1) }28+ if r.From == r.To {29+ return a(r.From)30+ }31+ return a(r.From) + ":" + a(r.To)32+}33+34+// writeSheetView writes the sheet's view: whether it is the tab shown,35+// and its frozen panes.36+func writeSheetView(bw *bufio.Writer, snap *Snapshot, active bool) {37+ bw.WriteString(`<sheetViews><sheetView workbookViewId="0"`)38+ if active {39+ bw.WriteString(` tabSelected="1"`)40+ }41+ rows, cols := snap.FrozenRows, snap.FrozenCols42+ if rows == 0 && cols == 0 {43+ bw.WriteString(`/></sheetViews>`)44+ return45+ }46+ bw.WriteString(`><pane`)47+ if cols > 0 {48+ fmt.Fprintf(bw, ` xSplit="%d"`, cols)49+ }50+ if rows > 0 {51+ fmt.Fprintf(bw, ` ySplit="%d"`, rows)52+ }53+ pane := "bottomRight"54+ switch {55+ case cols == 0:56+ pane = "bottomLeft"57+ case rows == 0:58+ pane = "topRight"59+ }60+ fmt.Fprintf(bw, ` topLeftCell="%s" activePane="%s" state="frozen"/><selection pane="%s"/></sheetView></sheetViews>`,61+ excelColName(cols+1)+strconv.Itoa(rows+1), pane, pane)62+}63+64+// writeAutoFilter writes the sheet's filter, if it has one.65+func writeAutoFilter(bw *bufio.Writer, snap *Snapshot) {66+ f := snap.Filter67+ if f == nil {68+ return69+ }70+ fmt.Fprintf(bw, `<autoFilter ref="%s"`, excelRect(f.Range))71+ cols := slices.Sorted(maps.Keys(f.Cols))72+ if len(cols) == 0 {73+ bw.WriteString(`/>`)74+ return75+ }76+ bw.WriteString(`>`)77+ for _, c := range cols {78+ if c < f.Range.From.Col || c > f.Range.To.Col || f.Cols[c].IsZero() {79+ continue80+ }81+ fmt.Fprintf(bw, `<filterColumn colId="%d">%s</filterColumn>`, c-f.Range.From.Col, filterColumnXML(snap, c, f.Cols[c]))82+ }83+ bw.WriteString(`</autoFilter>`)84+}85+86+// filterColumnXML is the criteria of column c as Excel keeps them.87+func filterColumnXML(snap *Snapshot, c int, cr sheet.Criteria) string {88+ if len(cr.Hidden) == 0 {89+ if x, ok := customFilterXML(cr.Cond); ok {90+ return x91+ }92+ }93+ hidden := map[string]bool{}94+ for _, h := range cr.Hidden {95+ hidden[h] = true96+ }97+ shown, blanks := filterColumnValues(snap, c)98+ var b strings.Builder99+ b.WriteString(`<filters`)100+ if blanks && !hidden[""] && cr.Cond.Matches(sheet.Value{}, "") {101+ b.WriteString(` blank="1"`)102+ }103+ b.WriteString(`>`)104+ for _, t := range slices.Sorted(maps.Keys(shown)) {105+ if !hidden[t] && cr.Cond.Matches(shown[t], t) {106+ fmt.Fprintf(&b, `<filter val="%s"/>`, escapeXML(t, true))107+ }108+ }109+ b.WriteString(`</filters>`)110+ return b.String()111+}112+113+// filterColumnValues is the distinct values shown in column c of the114+// filter's data rows, with a value for each, and whether any is blank.115+func filterColumnValues(snap *Snapshot, c int) (map[string]sheet.Value, bool) {116+ r := snap.Filter.Range117+ last := min(r.To.Row, snap.Range.To.Row)118+ shown := map[string]sheet.Value{}119+ blanks := r.To.Row > last120+ for row := r.From.Row + 1; row <= last; row++ {121+ cell, ok := snap.Cells[sheet.Addr{Col: c, Row: row}]122+ t := ""123+ if ok {124+ t = cell.Text()125+ }126+ if t == "" {127+ blanks = true128+ continue129+ }130+ if _, seen := shown[t]; !seen {131+ shown[t] = cell.Value132+ }133+ }134+ return shown, blanks135+}136+137+// excelOps are the custom filter operators for 012's comparisons.138+var excelOps = map[sheet.CondOp]string{139+ sheet.CondGreater: "greaterThan", sheet.CondGreaterEq: "greaterThanOrEqual",140+ sheet.CondLess: "lessThan", sheet.CondLessEq: "lessThanOrEqual",141+ sheet.CondEqual: "equal", sheet.CondNotEqual: "notEqual",142+}143+144+// customFilterXML is a condition as one of Excel's custom filters, or145+// false when Excel has none like it.146+func customFilterXML(c sheet.Condition) (string, bool) {147+ custom := func(op, val string) (string, bool) {148+ attr := ""149+ if op != "equal" {150+ attr = ` operator="` + op + `"`151+ }152+ return fmt.Sprintf(`<customFilters><customFilter%s val="%s"/></customFilters>`, attr, escapeXML(val, true)), true153+ }154+ arg := escapeWildcards(c.Arg)155+ switch c.Op {156+ case sheet.CondEmpty:157+ return `<filters blank="1"/>`, true158+ case sheet.CondNotEmpty:159+ return custom("notEqual", " ")160+ case sheet.CondContains:161+ return custom("equal", "*"+arg+"*")162+ case sheet.CondNotContains:163+ return custom("notEqual", "*"+arg+"*")164+ case sheet.CondStartsWith:165+ return custom("equal", arg+"*")166+ case sheet.CondEndsWith:167+ return custom("equal", "*"+arg)168+ case sheet.CondExactly:169+ return custom("equal", arg)170+ }171+ op, ok := excelOps[c.Op]172+ if !ok {173+ return "", false174+ }175+ if n, f, isNum := sheet.ParseValue(strings.TrimSpace(c.Arg)); isNum {176+ return custom(op, numInput(toExcelSerial(n, f)))177+ }178+ return custom(op, arg)179+}180+181+// escapeWildcards escapes Excel's wildcards, so text matches as is.182+func escapeWildcards(s string) string {183+ return strings.NewReplacer("~", "~~", "*", "~*", "?", "~?").Replace(s)184+}@@ -0,0 +1,176 @@
1+package fileio2+3+import (4+ "context"5+ "path/filepath"6+ "reflect"7+ "slices"8+ "strings"9+ "testing"10+11+ "github.com/xuri/excelize/v2"12+13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// months is a small table with a header row: months, amounts and notes.17+func months(t *testing.T) *sheet.Sheet {18+ return build(t, map[string]string{19+ "A1": "Month", "B1": "Amount", "C1": "Note",20+ "A2": "Jan", "B2": "$1,200", "C2": "rent*",21+ "A3": "Feb", "B3": "80", "C3": "food",22+ "A4": "Mar", "B4": "1450", "C4": "",23+ "A5": "Apr", "B5": "=B4*2", "C5": "Fees",24+ })25+}26+27+// exportImport writes s as XLSX and reads it back.28+func exportImport(t *testing.T, s *sheet.Sheet) (string, *Result) {29+ t.Helper()30+ name := filepath.Join(t.TempDir(), "view.xlsx")31+ if _, err := Export(context.Background(), name, XLSX, SnapBook(s), ExportOptions{}); err != nil {32+ t.Fatal(err)33+ }34+ got, err := Import(context.Background(), name, Options{})35+ if err != nil {36+ t.Fatal(err)37+ }38+ return name, got39+}40+41+func hiddenRows(s *sheet.Sheet) []int {42+ var out []int43+ for r := range 10 {44+ if s.RowHidden(r) {45+ out = append(out, r)46+ }47+ }48+ return out49+}50+51+// Frozen panes and a filter go out as Excel's and come back the same.52+func TestXLSXFrozenAndFilterRoundTrip(t *testing.T) {53+ src := months(t)54+ src.SetFrozen(1, 2)55+ src.CreateFilter(sheet.NewRect(addr(t, "A1"), addr(t, "C5")))56+ src.FilterColumn(0, sheet.Criteria{Hidden: []string{"Feb"}})57+ src.FilterColumn(1, sheet.Criteria{Cond: sheet.Condition{Op: sheet.CondLess, Arg: "2000"}})58+ want := []int{2, 4} // Feb, and Apr's 290059+ if got := hiddenRows(src); !slices.Equal(got, want) {60+ t.Fatalf("hidden before export: %v", got)61+ }62+ name, got := exportImport(t, src)63+64+ // excelize reads what Excel would.65+ x, err := excelize.OpenFile(name)66+ if err != nil {67+ t.Fatal(err)68+ }69+ defer x.Close()70+ if p, _ := x.GetPanes("Sheet1"); !p.Freeze || p.XSplit != 2 || p.YSplit != 1 || p.TopLeftCell != "C2" || p.ActivePane != "bottomRight" {71+ t.Errorf("panes %+v", p)72+ }73+ for row, shown := range map[int]bool{1: true, 2: true, 3: false, 4: true, 5: false} {74+ if v, _ := x.GetRowVisible("Sheet1", row); v != shown {75+ t.Errorf("row %d visible %v in Excel", row, v)76+ }77+ }78+ db := false79+ for _, dn := range x.GetDefinedName() {80+ db = db || dn.Name == "_xlnm._FilterDatabase" && dn.RefersTo == "Sheet1!$A$1:$C$5" && dn.Scope == "Sheet1"81+ }82+ if !db {83+ t.Errorf("no _FilterDatabase name: %+v", x.GetDefinedName())84+ }85+ afs, err := referenceAutoFilters(name)86+ if err != nil {87+ t.Fatal(err)88+ }89+ wantAF := &xlsxAutoFilter{ref: "A1:C5", cols: []xlsxFilterColumn{90+ {col: 0, filters: true, values: []string{"Apr", "Jan", "Mar"}},91+ {col: 1, custom: []xlsxCustomFilter{{op: "lessThan", val: "2000"}}},92+ }}93+ if !reflect.DeepEqual(afs[0], wantAF) {94+ t.Errorf("autoFilter %+v, want %+v", afs[0], wantAF)95+ }96+97+ s := got.Sheet98+ if r, c := s.Frozen(); r != 1 || c != 2 {99+ t.Errorf("frozen %d rows, %d cols", r, c)100+ }101+ if !reflect.DeepEqual(s.Filter(), src.Filter()) {102+ t.Errorf("filter %+v, want %+v", s.Filter(), src.Filter())103+ }104+ if h := hiddenRows(s); !slices.Equal(h, want) {105+ t.Errorf("hidden after import: %v", h)106+ }107+ if s.Book().CanUndo() {108+ t.Error("import left an undo step")109+ }110+}111+112+// Each condition goes out as Excel's custom filter (or its blanks113+// filter) and comes back hiding the same rows.114+func TestXLSXFilterConditions(t *testing.T) {115+ for _, c := range []sheet.Condition{116+ {Op: sheet.CondEmpty}, {Op: sheet.CondNotEmpty},117+ {Op: sheet.CondContains, Arg: "E"}, {Op: sheet.CondContains, Arg: "t*"}, {Op: sheet.CondNotContains, Arg: "oo"},118+ {Op: sheet.CondStartsWith, Arg: "rent"}, {Op: sheet.CondEndsWith, Arg: "s"}, {Op: sheet.CondExactly, Arg: "FOOD"},119+ {Op: sheet.CondGreater, Arg: "100"}, {Op: sheet.CondGreaterEq, Arg: "$1,450"}, {Op: sheet.CondLess, Arg: "1200"},120+ {Op: sheet.CondLessEq, Arg: "80"}, {Op: sheet.CondEqual, Arg: "1450"}, {Op: sheet.CondNotEqual, Arg: "80"},121+ } {122+ col := 2 // text conditions on the notes, the others on the amounts123+ if c.Op >= sheet.CondGreater {124+ col = 1125+ }126+ src := months(t)127+ src.CreateFilter(sheet.NewRect(addr(t, "A1"), addr(t, "C5")))128+ src.FilterColumn(col, sheet.Criteria{Cond: c})129+ want := hiddenRows(src)130+ _, got := exportImport(t, src)131+ s := got.Sheet132+ cr := s.Filter().Cols[col]133+ if cr.Cond.Op != c.Op || len(cr.Hidden) > 0 {134+ t.Errorf("%s %q came back as %+v", c.Op, c.Arg, cr)135+ }136+ if h := hiddenRows(s); !slices.Equal(h, want) {137+ t.Errorf("%s %q hides %v, want %v", c.Op, c.Arg, h, want)138+ }139+ if len(got.Notes) > 0 {140+ t.Errorf("%s: notes %q", c.Op, got.Notes)141+ }142+ }143+}144+145+// A values list with a condition goes out as the values both let146+// through; criteria Excel has and 012 hasn't are left out with a note.147+func TestXLSXFilterLimits(t *testing.T) {148+ src := months(t)149+ src.CreateFilter(sheet.NewRect(addr(t, "A1"), addr(t, "C5")))150+ src.FilterColumn(0, sheet.Criteria{Hidden: []string{"Feb"}, Cond: sheet.Condition{Op: sheet.CondStartsWith, Arg: "a"}})151+ _, got := exportImport(t, src)152+ if cr := got.Sheet.Filter().Cols[0]; !slices.Equal(cr.Hidden, []string{"Feb", "Jan", "Mar"}) {153+ t.Errorf("values and condition came back as %+v", cr)154+ }155+156+ dir := t.TempDir()157+ x := excelize.NewFile()158+ for i, row := range [][]any{{"Name", "Score"}, {"a", 1}, {"b", 7}, {"c", 3}} {159+ x.SetSheetRow("Sheet1", "A"+itoa(i+1), &row)160+ }161+ x.AutoFilter("Sheet1", "A1:B4", []excelize.AutoFilterOptions{{Column: "B", Expression: "x > 1 and x < 5"}, {Column: "A", Expression: "x == a?"}})162+ name := filepath.Join(dir, "limits.xlsx")163+ if err := x.SaveAs(name); err != nil {164+ t.Fatal(err)165+ }166+ res, err := Import(context.Background(), name, Options{})167+ if err != nil {168+ t.Fatal(err)169+ }170+ if f := res.Sheet.Filter(); f == nil || len(f.Cols) != 0 || f.Range != sheet.NewRect(addr(t, "A1"), addr(t, "B4")) {171+ t.Errorf("filter %+v", f)172+ }173+ if n := strings.Join(res.Notes, "; "); n != `2 criteria of a filter 012 can't apply left out, e.g. column B on Sheet1 (two conditions)` {174+ t.Errorf("notes %q", n)175+ }176+}@@ -0,0 +1,293 @@
1+package fileio2+3+import (4+ "encoding/xml"5+ "fmt"6+ "io"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Reading frozen panes and filters; see xlsxview.go for how they are14+// written.15+16+// readPane reads a <pane> of the sheet's first view: frozen panes17+// (state frozen or frozenSplit) freeze xSplit columns and ySplit rows.18+// A split that isn't frozen is left out.19+func (r *xlsxSheetReader) readPane(se xml.StartElement) {20+ if r.paneRead {21+ return22+ }23+ r.paneRead = true24+ if st, _ := attr(se, "state"); st != "frozen" && st != "frozenSplit" {25+ return26+ }27+ split := func(name string) int {28+ f, err := strconv.ParseFloat(attrOr(se, name, "0"), 64)29+ if err != nil || !(f > 0) {30+ return 031+ }32+ return int(min(f, sheet.MaxFrozen))33+ }34+ r.frozenCols, r.frozenRows = split("xSplit"), split("ySplit")35+}36+37+// xlsxAutoFilter is a worksheet's <autoFilter>: its range and the38+// criteria of its columns.39+type xlsxAutoFilter struct {40+ ref string41+ cols []xlsxFilterColumn42+}43+44+// xlsxFilterColumn is a <filterColumn>.45+type xlsxFilterColumn struct {46+ col int // colId, from the range's first column47+ filters bool // a <filters> element: the values shown48+ values []string // its <filter val>s49+ blank bool // blanks are shown50+ dates bool // it has <dateGroupItem>s51+ custom []xlsxCustomFilter52+ other string // a kind of criteria 012 has no equivalent for53+}54+55+// xlsxCustomFilter is a <customFilter>: an operator and a value.56+type xlsxCustomFilter struct{ op, val string }57+58+// maxFilterValues caps the values one filter column lists.59+const maxFilterValues = excelRows60+61+// readTail reads what follows the rows, returning the sheet's62+// autoFilter or nil.63+func (r *xlsxSheetReader) readTail() (*xlsxAutoFilter, error) {64+ var af *xlsxAutoFilter65+ for {66+ t, err := r.x.next()67+ if err == io.EOF {68+ return af, nil69+ }70+ if err != nil {71+ return nil, err72+ }73+ if se, ok := t.(xml.StartElement); ok && se.Name.Local == "autoFilter" && r.x.depth == 2 && af == nil {74+ if af, err = r.readAutoFilter(se); err != nil {75+ return nil, err76+ }77+ }78+ }79+}80+81+// readAutoFilter reads an <autoFilter>, just started.82+func (r *xlsxSheetReader) readAutoFilter(se xml.StartElement) (*xlsxAutoFilter, error) {83+ af := &xlsxAutoFilter{}84+ af.ref, _ = attr(se, "ref")85+ for depth := r.x.depth; ; {86+ t, err := r.x.next()87+ if err != nil {88+ return nil, eofAsUnexpected(err)89+ }90+ if _, ok := t.(xml.EndElement); ok && r.x.depth < depth {91+ return af, nil92+ }93+ se, ok := t.(xml.StartElement)94+ if !ok {95+ continue96+ }97+ if se.Name.Local == "filterColumn" && r.x.depth == depth+1 {98+ af.cols = append(af.cols, xlsxFilterColumn{col: intAttr(se, "colId", -1)})99+ } else if n := len(af.cols); n > 0 {100+ af.cols[n-1].add(se)101+ }102+ }103+}104+105+// add reads an element inside the column's <filterColumn>.106+func (fc *xlsxFilterColumn) add(se xml.StartElement) {107+ switch se.Name.Local {108+ case "filters":109+ fc.filters, fc.blank = true, boolAttr(se, "blank", false)110+ case "filter":111+ if len(fc.values) < maxFilterValues {112+ fc.values = append(fc.values, attrOr(se, "val", ""))113+ }114+ case "dateGroupItem":115+ fc.dates = true116+ case "customFilter":117+ fc.custom = append(fc.custom, xlsxCustomFilter{op: attrOr(se, "operator", "equal"), val: attrOr(se, "val", "")})118+ case "top10":119+ fc.other = "top 10"120+ case "dynamicFilter":121+ fc.other = "a dynamic filter"122+ case "colorFilter":123+ fc.other = "by color"124+ case "iconFilter":125+ fc.other = "by icon"126+ }127+}128+129+// applyFilters puts each sheet's autoFilter (filters[i] on sheet i) on130+// book, whose values are computed, and returns the notes on what was131+// left out.132+func applyFilters(book *sheet.Workbook, filters []*xlsxAutoFilter) []string {133+ var fn filterNotes134+ for i, af := range filters {135+ if af != nil {136+ fn.apply(book.Sheet(i), af)137+ }138+ }139+ return fn.notes()140+}141+142+// filterNotes counts the filter criteria left out across a workbook,143+// keeping the first as an example.144+type filterNotes struct {145+ n int146+ example string147+}148+149+// apply puts filter af on s, whose values are computed: a filter over150+// its range, with the criteria of each column that 012 can apply.151+func (fn *filterNotes) apply(s *sheet.Sheet, af *xlsxAutoFilter) {152+ r, ok := sheet.ParseRange(strings.ReplaceAll(af.ref, "$", ""))153+ if !ok {154+ fn.leftOut(s, "", "its range "+strconv.Quote(af.ref))155+ return156+ }157+ s.LoadFilter(&sheet.Filter{Range: r}) // no criteria yet, so every column lists all its values158+ f := &sheet.Filter{Range: r, Cols: map[int]sheet.Criteria{}}159+ for _, fc := range af.cols {160+ c := r.From.Col + fc.col161+ if fc.col < 0 || c > r.To.Col {162+ continue163+ }164+ cr, why := fc.criteria(s, c)165+ if why != "" {166+ fn.leftOut(s, sheet.ColName(c), why)167+ continue168+ }169+ if !cr.IsZero() {170+ f.Cols[c] = cr171+ }172+ }173+ s.LoadFilter(f)174+}175+176+func (fn *filterNotes) leftOut(s *sheet.Sheet, col, why string) {177+ fn.n++178+ if fn.example == "" {179+ where := "on " + sheet.QuoteSheet(s.Name())180+ if col != "" {181+ where = "column " + col + " " + where182+ }183+ fn.example = where + " (" + why + ")"184+ }185+}186+187+// notes is what the notes say of the criteria left out.188+func (fn *filterNotes) notes() []string {189+ if fn.n == 0 {190+ return nil191+ }192+ return []string{fmt.Sprintf("%s of a filter 012 can't apply left out, e.g. %s",193+ count(fn.n, "criterion", "criteria"), fn.example)}194+}195+196+// criteria is the column's criteria in 012's terms, for column c of s,197+// or why they can't be.198+func (fc *xlsxFilterColumn) criteria(s *sheet.Sheet, c int) (sheet.Criteria, string) {199+ switch {200+ case fc.other != "":201+ return sheet.Criteria{}, fc.other202+ case fc.dates:203+ return sheet.Criteria{}, "dates by year or month"204+ case len(fc.custom) > 1:205+ return sheet.Criteria{}, "two conditions"206+ case len(fc.custom) == 1:207+ cond, ok := conditionOf(fc.custom[0])208+ if !ok {209+ return sheet.Criteria{}, "the condition " + fc.custom[0].op + " " + strconv.Quote(fc.custom[0].val)210+ }211+ return sheet.Criteria{Cond: cond}, ""212+ case !fc.filters:213+ return sheet.Criteria{}, ""214+ case len(fc.values) == 0 && fc.blank:215+ return sheet.Criteria{Cond: sheet.Condition{Op: sheet.CondEmpty}}, ""216+ }217+ // Excel lists the values shown, matching them as displayed and218+ // ignoring case; 012 keeps those hidden.219+ shown := make(map[string]bool, len(fc.values))220+ for _, v := range fc.values {221+ shown[strings.ToLower(v)] = true222+ }223+ var cr sheet.Criteria224+ for _, v := range s.FilterValues(c) {225+ if v.Text == "" && !fc.blank || v.Text != "" && !shown[strings.ToLower(v.Text)] {226+ cr.Hidden = append(cr.Hidden, v.Text)227+ }228+ }229+ return cr, ""230+}231+232+// excelConds are 012's comparisons by custom filter operator.233+var excelConds = map[string]sheet.CondOp{234+ "greaterThan": sheet.CondGreater, "greaterThanOrEqual": sheet.CondGreaterEq,235+ "lessThan": sheet.CondLess, "lessThanOrEqual": sheet.CondLessEq,236+}237+238+// conditionOf is a custom filter as a condition: comparisons as they239+// are; equal and notEqual with wildcards only at the ends as contains,240+// starts with and ends with; notEqual to a space as not empty.241+func conditionOf(cf xlsxCustomFilter) (sheet.Condition, bool) {242+ if op, ok := excelConds[cf.op]; ok {243+ return sheet.Condition{Op: op, Arg: cf.val}, true244+ }245+ lead, core, trail, ok := wildcardShape(cf.val)246+ if !ok {247+ return sheet.Condition{}, false248+ }249+ switch op := cf.op; {250+ case op == "notEqual" && cf.val == " ":251+ return sheet.Condition{Op: sheet.CondNotEmpty}, true252+ case op == "equal" && lead && trail:253+ return sheet.Condition{Op: sheet.CondContains, Arg: core}, true254+ case op == "equal" && lead:255+ return sheet.Condition{Op: sheet.CondEndsWith, Arg: core}, true256+ case op == "equal" && trail:257+ return sheet.Condition{Op: sheet.CondStartsWith, Arg: core}, true258+ case op == "equal":259+ if _, _, isNum := sheet.ParseValue(strings.TrimSpace(core)); isNum {260+ return sheet.Condition{Op: sheet.CondEqual, Arg: core}, true261+ }262+ return sheet.Condition{Op: sheet.CondExactly, Arg: core}, true263+ case op == "notEqual" && lead && trail:264+ return sheet.Condition{Op: sheet.CondNotContains, Arg: core}, true265+ case op == "notEqual" && !lead && !trail:266+ return sheet.Condition{Op: sheet.CondNotEqual, Arg: core}, true267+ }268+ return sheet.Condition{}, false269+}270+271+// wildcardShape splits a filter pattern into whether it starts and ends272+// with *, and the text between, unescaped (~ escapes the next character);273+// false when it has other wildcards.274+func wildcardShape(p string) (lead bool, core string, trail bool, ok bool) {275+ var b strings.Builder276+ rs := []rune(p)277+ for i := 0; i < len(rs); i++ {278+ switch r := rs[i]; {279+ case r == '~' && i+1 < len(rs):280+ i++281+ b.WriteRune(rs[i])282+ case r == '*' && i == 0:283+ lead = true284+ case r == '*' && i == len(rs)-1:285+ trail = true286+ case r == '*' || r == '?':287+ return false, "", false, false288+ default:289+ b.WriteRune(r)290+ }291+ }292+ return lead, b.String(), trail, true293+}@@ -66,11 +66,8 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
6666 r := snap.Range6767 bw.WriteString(xmlHead + `<worksheet xmlns="` + sheetMain + `" xmlns:r="` + officeRel + `">`)6868 fmt.Fprintf(bw, `<dimension ref="%s%d:%s%d"/>`, excelColName(r.From.Col+1), r.From.Row+1, excelColName(r.To.Col+1), r.To.Row+1)69- bw.WriteString(`<sheetViews><sheetView workbookViewId="0"`)70- if active {71- bw.WriteString(` tabSelected="1"`)72- }73- bw.WriteString(`/></sheetViews><sheetFormatPr defaultRowHeight="15"/>`)69+ writeSheetView(bw, snap, active)70+ bw.WriteString(`<sheetFormatPr defaultRowHeight="15"/>`)7471 w.writeCols(bw, snap)7572 bw.WriteString(`<sheetData>`)7673 styled := slices.Sorted(maps.Keys(snap.RowFormats)) // rows with a style of their own, written even without cells@@ -87,7 +84,7 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
8784 b = w.cell(b, ws, a, c)8885 }8986 }90- if len(b) > 0 || rowStyled {87+ if len(b) > 0 || rowStyled || snap.HiddenRows[row] {9188 w.rowStart(bw, snap, row)9289 bw.Write(b)9390 bw.WriteString(`</row>`)@@ -95,7 +92,9 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
9592 w.buf = b9693 }9794 w.styledRows(bw, snap, styled, sheet.MaxRows)98- bw.WriteString(`</sheetData></worksheet>`)95+ bw.WriteString(`</sheetData>`)96+ writeAutoFilter(bw, snap)97+ bw.WriteString(`</worksheet>`)9998 return r.To.Row - r.From.Row + 110099 }101100 @@ -301,6 +301,10 @@ func (s *Sheet) ShownText(a Addr) string {
301301 return FormatText(c.Value, s.DisplayFormat(a))302302 }303303 304+// Matches reports whether a cell with value v, shown as shown, meets the305+// condition, as the filter tests it.306+func (c Condition) Matches(v Value, shown string) bool { return c.test()(v, shown) }307+304308 // test compiles the condition. Text conditions ignore case; comparisons305309 // need a number for a number (entered as $1,200 or 12% too) and text for306310 // text, and never match blanks.@@ -34,3 +34,17 @@ func (s *Sheet) LoadColWidth(c, w int) {
3434 // Unload removes a cell a loader stored, as an importer does with a row3535 // that doesn't fit whole.3636 func (s *Sheet) Unload(a Addr) { s.place(a, nil) }37+38+// LoadFrozen freezes the first rows rows and cols columns, as a loader39+// does: without recording undo. SetFrozen is the undoable way.40+func (s *Sheet) LoadFrozen(rows, cols int) {41+ s.view.frozenRows, s.view.frozenCols = clampInt(rows, 0, MaxFrozen), clampInt(cols, 0, MaxFrozen)42+}43+44+// LoadFilter puts filter f on the sheet (nil removes it), as a loader45+// does: without recording undo. Its criteria apply once values are46+// computed, as they do whenever values change.47+func (s *Sheet) LoadFilter(f *Filter) {48+ s.view.filter = f.clone()49+ s.hidden.valid = false50+}