# Conflicts: # ROADMAP.md
- commit
- 51fd960eac50b226296fefa6226c54921cd0301b
- parent
- be90933bb3d3e94e0f73b728a18197085a5f1960
- parent
- 799bdc22c01a7aa03dc7f714e14782056f6fe884
- author
- Dami <dami.etoile@gmail.com>
@@ -102,7 +102,7 @@ work left open. Sizes: S (a day or two), M (about a week), L (weeks).
102102 103103 | Item | Why | Size |104104 |---|---|---|105-| Open 012's XLSX output in real Excel and LibreOffice (a round-trip corpus, run by hand before releases). In progress: a first sample is being checked in Excel | The new writer is checked only by excelize and 012's reader | S |105+| Open 012's XLSX output in a real spreadsheet app before releases. Done for Google Sheets: sheets, hidden sheets, named ranges, implicit intersection, column and cell formats, widths, frozen panes, filters, links and missing-sheet values all match; repeat with Excel and LibreOffice when available | The writer is otherwise checked by excelize and 012's reader | S |106106 | Column and row formats travel with copy, cut and move; formulas reading blanks of a newly formatted column re-infer at once. Done: pastes and moves carry what cells show, whole lines their line formats; see [data.md](docs/data.md#copy-paste-and-fill) | Left open by step A | S |107107 | Save as onto another existing file asks before replacing (local and `012 serve`) | Only the open file is checked today | S |108108 | XLSX sheet names with characters Excel forbids: rewrite formulas that name them, not just the sheet | Formulas keep the old name today | S |@@ -177,7 +177,10 @@ the engine implements once per sheet (`reader` in `recalc.go`):
177177 Sheets are named as references write them, so resolving names, missing178178 sheets (`#REF!`), cycles and the depth limit stay the engine's. The179179 engine finds functions through the table (`LookupFunc`, which the parser180-uses) and evaluates with `functions.Eval`.180+uses) and evaluates a cell's formula with `functions.EvalAt`, giving the181+cell's address, so a range used where one value is wanted reads the cell182+in the formula's row or column (implicit intersection; the `Reader`183+keeps the address, and the one before it while formulas nest).181184 182185 The boundary costs no allocation on the hot paths, which an interface183186 usually would: a callback passed through an interface escapes to the@@ -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. A sheet whose name Excel can't take as is (spaces at its ends, or the same as another's but for them and case) gets one it can (without the spaces, with a number when two would clash, e.g. `Plan (2)`), and the formulas and named ranges naming it name that. 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), 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@@ -76,6 +76,29 @@ caret on the problem and says what's wrong on the context line, e.g.
7676 tighter than `^` as in Sheets and Excel, so `=-2^2` is 4. 1-2-3's `#AND#`,7777 `#OR#` and `#NOT#` still work; `AND()`, `OR()` and `NOT()` are the Sheets way.7878 79+## A range where one value is wanted80+81+A range used where a formula wants one value (with an operator, as an82+argument that takes a number or text, or as a cell's whole result) reads83+as one of its cells, as in Sheets and Excel (implicit intersection):84+85+- a single column gives the cell in the formula's own row, so with the86+ named range Rent = B2:B4, `=Rent*2` in F4 is B4*2 and `=B:B+1` in G5 is87+ B5+1;88+- a single row gives the cell in the formula's own column: `=B6:D6` in C889+ is C6;90+- a single cell is itself;91+- anything else is `#VALUE!`: a formula outside the range's rows (or92+ columns), and a range of several rows and columns.93+94+Ranges on other sheets work the same, by the formula's row or column:95+`='Q3 plan'!C2:C9` in A3 reads `'Q3 plan'!C3`. Functions that take ranges96+(`SUM`, `COUNTIF`, `MATCH`, `VLOOKUP`, `SUMPRODUCT` and the like)97+read the whole range. Inside their range arguments an expression over98+ranges, such as `SUM(B2:B4*2)` or `SUMPRODUCT(A1:A3*B1:B3)`, is `#VALUE!`:99+Sheets computes those as arrays, which 012 doesn't, and a single cell's100+answer there would be wrong rather than missing.101+79102 ## Values and errors80103 81104 Text in arithmetic is `#VALUE!` unless it reads as a number (`="3"*2` is 6);@@ -2,6 +2,8 @@ package fileio
22 33 import (44 "strings"5+6+ "github.com/FelineStateMachine/012/internal/formula"57 )68 79 // 012 reads Sheets' formula syntax and some of 1-2-3's (@SUM, A1..B3,@@ -17,11 +19,14 @@ var xlfn = map[string]bool{
1719 // toExcelFormula translates a formula entry to Excel's syntax, or1820 // reports false when Excel has no equivalent (JEV functions, 1-2-3's1921 // #AND# operators), in which case the value is exported instead.20-func toExcelFormula(input string) (string, bool) {22+//23+// renamed maps the key (formula.SheetKey) of each sheet written under24+// another name to that name: references to it are rewritten to name it.25+func toExcelFormula(input string, renamed map[string]string) (string, bool) {2126 src := strings.TrimPrefix(input, "=")2227 var b strings.Builder2328 for i := 0; i < len(src); {24- next, ok := excelToken(&b, src, i)29+ next, ok := excelToken(&b, src, i, renamed)2530 if !ok {2631 return "", false2732 }@@ -32,13 +37,15 @@ func toExcelFormula(input string) (string, bool) {
3237 3338 // excelToken writes the token starting at src[i] in Excel's syntax and3439 // returns the index just past it, or false when Excel has no equivalent.35-func excelToken(b *strings.Builder, src string, i int) (int, bool) {40+func excelToken(b *strings.Builder, src string, i int, renamed map[string]string) (int, bool) {3641 c := src[i]3742 switch {3843 case c == '\'':39- // A quoted sheet name, 'Q3 plan'!A1, is the same in Excel.44+ // A quoted sheet name, 'Q3 plan'!A1, is the same in Excel, unless45+ // the sheet is written under another name.4046 j := quoteEnd(src, i)41- b.WriteString(src[i:j])47+ name := strings.ReplaceAll(strings.TrimSuffix(src[i+1:j], "'"), "''", "'")48+ b.WriteString(excelSheetRef(src[i:j], name, j < len(src) && src[j] == '!', renamed))4249 return j, true4350 case c == '"':4451 j := stringEnd(src, i)@@ -57,7 +64,7 @@ func excelToken(b *strings.Builder, src string, i int) (int, bool) {
5764 b.WriteByte(':') // 1-2-3's A1..B35865 return i + 2, true5966 case c == '@' || isIdentByte(c) && !isDigitByte(c) && c != '.':60- return excelName(b, src, i)67+ return excelName(b, src, i, renamed)6168 }6269 b.WriteByte(c)6370 return i + 1, true@@ -81,7 +88,7 @@ func stringEnd(s string, i int) int {
8188 // excelName writes the name starting at src[i]: a function name is8289 // upper-cased and given Excel's _xlfn. prefix where it needs one, and8390 // 1-2-3's @PI becomes PI(); references and named ranges stay as they are.84-func excelName(b *strings.Builder, src string, i int) (int, bool) {91+func excelName(b *strings.Builder, src string, i int, renamed map[string]string) (int, bool) {8592 at := src[i] == '@'8693 j := i8794 if at {@@ -109,10 +116,23 @@ func excelName(b *strings.Builder, src string, i int) (int, bool) {
109116 name += "()" // @PI110117 }111118 }112- b.WriteString(name)119+ b.WriteString(excelSheetRef(name, name, !call && !at && j < len(src) && src[j] == '!', renamed))113120 return j, true114121 }115122 123+// excelSheetRef is text, a sheet name as written (name, unquoted) when124+// sheet says it is followed by !, with the name the sheet is written125+// under when that differs: always quoted, as Excel accepts.126+func excelSheetRef(text, name string, sheet bool, renamed map[string]string) string {127+ if !sheet {128+ return text129+ }130+ if to, ok := renamed[formula.SheetKey(name)]; ok {131+ return "'" + strings.ReplaceAll(to, "'", "''") + "'"132+ }133+ return text134+}135+116136 // fromExcelFormula translates an Excel formula (without its =) to a 012117137 // entry. The result may still fail to parse, e.g. with references to118138 // other sheets; the importer then keeps the cell's value.@@ -25,10 +25,24 @@ type Snapshot struct {
2525 // Sheets are every sheet of the workbook, in order, for formats that2626 // hold several (XLSX); the snapshot itself is one of them, the one2727 // shown. Nil exports just this snapshot. Names are the workbook's28- // named ranges as name and Excel reference, e.g. Q3!$B$2:$B$9.28+ // named ranges.2929 Sheets []*Snapshot30- Names [][2]string30+ Names []SnapName3131 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]bool39+}40+41+// SnapName is a named range: its name, and the range on the sheet42+// named Sheet.43+type SnapName struct {44+ Name, Sheet string45+ Range sheet.Rect3246 }3347 3448 // SnapCell is one non-blank cell of a snapshot.@@ -60,7 +74,16 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
6074 r.To = r.From6175 }6276 snap := &Snapshot{Range: r, Cells: map[sheet.Addr]SnapCell{}, Widths: s.Widths(), Name: name,63- ColFormats: snapLines(s, false), RowFormats: snapLines(s, true)}77+ ColFormats: snapLines(s, false), RowFormats: snapLines(s, true), Filter: s.Filter()}78+ snap.FrozenRows, snap.FrozenCols = s.Frozen()79+ if f := snap.Filter; f != nil {80+ snap.HiddenRows = map[int]bool{}81+ for row := max(f.Range.From.Row, r.From.Row); row <= min(f.Range.To.Row, r.To.Row); row++ {82+ if s.RowHidden(row) {83+ snap.HiddenRows[row] = true84+ }85+ }86+ }6487 for _, a := range s.Addrs() {6588 if !r.Contains(a) {6689 continue@@ -96,7 +119,7 @@ func SnapBook(s *sheet.Sheet) *Snapshot {
96119 out.Sheets = all97120 for _, n := range s.Book().Names() {98121 if !n.Gone() {99- out.Names = append(out.Names, [2]string{n.Name, excelRange(n.Sheet.Name(), n.Range)})122+ out.Names = append(out.Names, SnapName{Name: n.Name, Sheet: n.Sheet.Name(), Range: n.Range})100123 }101124 }102125 return out@@ -41,7 +41,7 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
4141 if len(sheets) == 0 {4242 sheets = []*Snapshot{snap}4343 }44- w := &xlsxWriter{styles: newXLSXStyleTable(), multi: len(sheets) > 1, known: map[string]bool{}}44+ w := &xlsxWriter{styles: newXLSXStyleTable(), multi: len(sheets) > 1, known: map[string]bool{}, renamed: map[string]string{}}4545 res := &ExportResult{}4646 names, hidden, active := make([]string, len(sheets)), make([]bool, len(sheets)), 04747 used := map[string]bool{}@@ -51,10 +51,11 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
5151 active = i5252 }5353 hidden[i] = sn.Hidden54- // A formula can name only a sheet written under its own name:55- // one renamed to suit Excel goes out as values, as a missing one.56- if names[i] == sn.Name {57- w.known[formula.SheetKey(sn.Name)] = true54+ // A sheet renamed to suit Excel is named so in formulas too.55+ key := formula.SheetKey(sn.Name)56+ w.known[key] = true57+ if names[i] != sn.Name {58+ w.renamed[key] = names[i]5859 }5960 }6061 hidden[active] = false@@ -70,7 +71,7 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
7071 if err := writePart(zw, "xl/styles.xml", w.styles.xml()); err != nil {7172 return err7273 }73- if err := writePackage(zw, names, hidden, active, snap.Names); err != nil {74+ if err := writePackage(zw, names, hidden, active, w.definedNames(snap.Names), filterRanges(sheets, names)); err != nil {7475 return err7576 }7677 return zw.Close()@@ -89,6 +90,20 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
8990 return res, nil9091 }9192 93+// definedNames are the named ranges as Excel's defined names hold them,94+// with the names their sheets are written under: Q3!$B$2:$B$9.95+func (w *xlsxWriter) definedNames(names []SnapName) [][2]string {96+ out := make([][2]string, len(names))97+ for i, n := range names {98+ ws := n.Sheet99+ if to, ok := w.renamed[formula.SheetKey(ws)]; ok {100+ ws = to101+ }102+ out[i] = [2]string{n.Name, excelRange(ws, n.Range)}103+ }104+ return out105+}106+92107 // uniqueSheetName is name, or name with a number when another sheet93108 // already took it (Excel's names ignore case).94109 func uniqueSheetName(name string, used map[string]bool) string {@@ -110,10 +125,23 @@ const (
110125 mlType = `application/vnd.openxmlformats-officedocument.spreadsheetml.`111126 )112127 128+// filterRanges is each sheet's filter range as Excel names it, with its129+// sheet (_xlnm._FilterDatabase), or "" for a sheet without a filter.130+func filterRanges(sheets []*Snapshot, names []string) []string {131+ out := make([]string, len(sheets))132+ for i, sn := range sheets {133+ if sn.Filter != nil {134+ out[i] = excelRange(names[i], sn.Filter.Range)135+ }136+ }137+ return out138+}139+113140 // 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 {141+// workbook with its sheets, active tab, names (with each filter's range,142+// filters[i] for sheet i) and calculation settings, the relationships143+// and the content types.144+func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, defined [][2]string, filters []string) error {117145 var types, rels, book strings.Builder118146 types.WriteString(xmlHead + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +119147 `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +@@ -136,12 +164,17 @@ func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, def
136164 fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="%s/styles" Target="styles.xml"/></Relationships>`, len(names)+1, officeRel)137165 types.WriteString(`</Types>`)138166 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))167+ var dn strings.Builder168+ for i, r := range filters {169+ if r != "" {170+ fmt.Fprintf(&dn, `<definedName name="_xlnm._FilterDatabase" localSheetId="%d" hidden="1">%s</definedName>`, i, escapeXML(r, false))143171 }144- book.WriteString(`</definedNames>`)172+ }173+ for _, n := range defined {174+ fmt.Fprintf(&dn, `<definedName name="%s">%s</definedName>`, escapeXML(n[0], true), escapeXML(n[1], false))175+ }176+ if dn.Len() > 0 {177+ book.WriteString(`<definedNames>` + dn.String() + `</definedNames>`)145178 }146179 book.WriteString(`<calcPr calcId="191029" fullCalcOnLoad="1"/></workbook>`)147180 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@@ -233,8 +233,10 @@ func TestExcelFormulas(t *testing.T) {
233233 {"=#REF!+1", "#REF!+1", true},234234 {"='Q3 #1'!A1..B2", "'Q3 #1'!A1:B2", true},235235 {"=SUM('Bob''s'!A1)", "SUM('Bob''s'!A1)", true},236+ {"='q3/q4'!A1+plan!B2:B3+Plan+'Plan'", "'Q3_Q4'!A1+'Plan (2)'!B2:B3+Plan+'Plan'", true},237+ {`=PLAN(1)&"plan!"&Other!A1`, `PLAN(1)&"plan!"&Other!A1`, true},236238 } {237- got, ok := toExcelFormula(tc.in)239+ got, ok := toExcelFormula(tc.in, map[string]string{"Q3/Q4": "Q3_Q4", "PLAN": "Plan (2)"})238240 if ok != tc.ok || ok && got != tc.want {239241 t.Errorf("toExcelFormula(%q) = %q, %v, want %q, %v", tc.in, got, ok, tc.want, tc.ok)240242 }@@ -375,28 +377,6 @@ func TestXLSXWriteText(t *testing.T) {
375377 }376378 }377379 378-// A sheet whose name Excel can't take as is gets another; formulas379-// naming it go out as values, like formulas naming a missing sheet.380-func TestXLSXRenamedSheetAsValue(t *testing.T) {381- src := build(t, map[string]string{"A1": "1"})382- book := src.Book()383- book.RenameSheet(src, "Plan")384- spaced, err := book.AddSheet(" Plan", 1)385- if err != nil {386- t.Fatal(err)387- }388- spaced.Set(addr(t, "A1"), "=' Plan'!A2+Plan!A1")389- name := filepath.Join(t.TempDir(), "renamed.xlsx")390- res, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{})391- if err != nil {392- t.Fatal(err)393- }394- want := "1 formula naming a sheet that doesn't exist saved as values, e.g. 'Plan (2)'!A1 (' Plan')"395- if len(res.Notes) != 1 || res.Notes[0] != want {396- t.Errorf("notes %q, want %q", res.Notes, want)397- }398-}399-400380 func TestUniqueSheetName(t *testing.T) {401381 used := map[string]bool{}402382 long := strings.Repeat("x", 31)@@ -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 @@ -0,0 +1,79 @@
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+// A sheet whose name Excel can't take as is (spaces at its ends, or the14+// same as another's but for them and case) gets another, and the15+// formulas and named ranges naming it name that one.16+func TestXLSXRenamedSheets(t *testing.T) {17+ src := build(t, map[string]string{"A1": "1", "A2": "=' Plan'!A2+Plan!A1", "A3": "='Q3 '!A1*2",18+ "A4": "=SUM('plan '!A1:A2)", "A5": "=SUM(Slash)+Spaced"})19+ book := src.Book()20+ book.RenameSheet(src, "Plan")21+ for _, sh := range []struct {22+ name string23+ cells map[string]string24+ }{25+ {" Plan", map[string]string{"A2": "10"}}, {"Q3 ", map[string]string{"A1": "20", "A2": "30"}},26+ {"plan ", map[string]string{"A1": "40", "A2": "50"}},27+ } {28+ name, cells := sh.name, sh.cells29+ s, err := book.AddSheet(name, book.Len())30+ if err != nil {31+ t.Fatal(err)32+ }33+ for a, in := range cells {34+ s.Set(addr(t, a), in)35+ }36+ }37+ book.DefineName("Slash", book.Lookup("Q3 "), sheet.NewRect(addr(t, "A1"), addr(t, "A2")))38+ book.DefineName("Spaced", book.Lookup(" Plan"), sheet.NewRect(addr(t, "A2"), addr(t, "A2")))39+ want := map[string]string{"A2": "11", "A3": "40", "A4": "90", "A5": "60"}40+ for a, v := range want {41+ if got := shown(src, addr(t, a)); got != v {42+ t.Fatalf("%s = %s before export, want %s", a, got, v)43+ }44+ }45+ name := filepath.Join(t.TempDir(), "renamed.xlsx")46+ res, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{})47+ if err != nil {48+ t.Fatal(err)49+ }50+ if len(res.Notes) > 0 {51+ t.Errorf("notes %q", res.Notes)52+ }53+ x, err := excelize.OpenFile(name)54+ if err != nil {55+ t.Fatal(err)56+ }57+ defer x.Close()58+ if f, _ := x.GetCellFormula("Plan", "A2"); f != "'Plan (2)'!A2+Plan!A1" {59+ t.Errorf("Excel formula in A2 = %q", f)60+ }61+ got, err := Import(context.Background(), name, Options{})62+ if err != nil {63+ t.Fatal(err)64+ }65+ s := got.Sheet.Book().Lookup("Plan")66+ for a, v := range want {67+ if g := shown(s, addr(t, a)); g != v {68+ t.Errorf("%s = %s after the round trip (%s), want %s", a, g, input(s, addr(t, a)), v)69+ }70+ }71+ if g := input(s, addr(t, "A4")); g != "=SUM('plan (3)'!A1:A2)" {72+ t.Errorf("A4 = %q after the round trip", g)73+ }74+ for n, ws := range map[string]string{"Slash": "Q3", "Spaced": "Plan (2)"} {75+ if nm, ok := got.Sheet.Book().LookupName(n); !ok || nm.Sheet.Name() != ws {76+ t.Errorf("name %s: %+v", n, nm)77+ }78+ }79+}@@ -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+}@@ -29,8 +29,9 @@ type xlsxWriter struct {
2929 // refuse.3030 values valueCount3131 missing valueCount32- missingSheet string // the sheet missing's example names33- known map[string]bool // keys of the sheets written32+ missingSheet string // the sheet missing's example names33+ known map[string]bool // keys of the sheets written34+ renamed map[string]string // the names of sheets written under another, by key3435 }3536 3637 // valueCount counts formulas written as values, keeping the first one's@@ -65,12 +66,12 @@ func (w *xlsxWriter) unknownSheet(c SnapCell) (string, bool) {
6566 func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active bool) int {6667 r := snap.Range6768 bw.WriteString(xmlHead + `<worksheet xmlns="` + sheetMain + `" xmlns:r="` + officeRel + `">`)68- 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"`)69+ if len(snap.HiddenRows) > 0 {70+ bw.WriteString(`<sheetPr filterMode="1"/>`) // a filter is hiding rows7271 }73- bw.WriteString(`/></sheetViews><sheetFormatPr defaultRowHeight="15"/>`)72+ 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)73+ writeSheetView(bw, snap, active)74+ bw.WriteString(`<sheetFormatPr defaultRowHeight="15"/>`)7475 w.writeCols(bw, snap)7576 bw.WriteString(`<sheetData>`)7677 styled := slices.Sorted(maps.Keys(snap.RowFormats)) // rows with a style of their own, written even without cells@@ -87,7 +88,7 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
8788 b = w.cell(b, ws, a, c)8889 }8990 }90- if len(b) > 0 || rowStyled {91+ if len(b) > 0 || rowStyled || snap.HiddenRows[row] {9192 w.rowStart(bw, snap, row)9293 bw.Write(b)9394 bw.WriteString(`</row>`)@@ -95,7 +96,9 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
9596 w.buf = b9697 }9798 w.styledRows(bw, snap, styled, sheet.MaxRows)98- bw.WriteString(`</sheetData></worksheet>`)99+ bw.WriteString(`</sheetData>`)100+ writeAutoFilter(bw, snap)101+ bw.WriteString(`</worksheet>`)99102 return r.To.Row - r.From.Row + 1100103 }101104 @@ -157,7 +160,7 @@ func (w *xlsxWriter) formula(ws string, a sheet.Addr, c SnapCell) string {
157160 w.missing.add(w.multi, ws, a)158161 return ""159162 }160- fx, ok := toExcelFormula(c.Input)163+ fx, ok := toExcelFormula(c.Input, w.renamed)161164 if !ok {162165 w.values.add(w.multi, ws, a)163166 }@@ -105,7 +105,7 @@ func matrixArg(n Node, get lookup) matrix {
105105 case formula.Ref:106106 return rectMatrix(n.Sheet, Rect{From: n.Addr, To: n.Addr}, get)107107 }108- v := eval(n, get)108+ v := evalArray(n, get)109109 return matrix{rows: 1, cols: 1, cell: func(int, int) Value { return v }, dataRows: 1, dataCols: 1, blank: v}110110 }111111 @@ -8,8 +8,23 @@ import (
88 )99 1010 // Eval computes a formula, reading the cells it refers to through get.11+// A range where one value is wanted reads as the cell in the row or12+// column last given to EvalAt (see intersect).1113 func Eval(n Node, get *Reader) Value { return eval(n, get) }1214 15+// EvalAt computes the formula in the cell at, so that a range used where16+// one value is wanted reads as the cell in at's row or column (implicit17+// intersection, as Sheets and Excel do). Evaluations nest, one cell's18+// formula reading another's through the same Reader, so the cell before19+// is given back afterwards.20+func EvalAt(n Node, get *Reader, at Addr) Value {21+ outer := get.here22+ get.here = at23+ v := eval(n, get)24+ get.here = outer25+ return v26+}27+1328 func eval(n Node, get lookup) Value {1429 switch n := n.(type) {1530 case formula.Num:@@ -27,10 +42,8 @@ func eval(n Node, get lookup) Value {
2742 case formula.Empty:2843 return Value{}2944 case formula.Range:30- // A range outside a function: Sheets uses the top-left cell here31- // for single-cell ranges and #VALUE! otherwise.32- if n.Rect.From == n.Rect.To {33- return get.cell(n.Sheet, n.Rect.From)45+ if a, ok := get.intersect(n.Rect); ok {46+ return get.cell(n.Sheet, a)3447 }3548 return value.ErrValue3649 case formula.Unary:@@ -43,7 +56,10 @@ func eval(n Node, get lookup) Value {
4356 return evalBinary(formula.Binary(n), get, true)4457 case formula.Call:4558 *get.depth++ // see the engine's evaluate.go; operators count in theirs59+ arrays := get.arrays60+ get.arrays = 0 // a function's own arguments take one value again4661 v := funcOf(n).call(n.Args, get)62+ get.arrays = arrays4763 *get.depth--4864 return v4965 }@@ -152,3 +168,41 @@ func evalBinary(n formula.Binary, get lookup, dec bool) Value {
152168 }153169 return value.ErrValue154170 }171+172+// intersect is the cell a range stands for where one value is wanted173+// (Intersect), in the formula being evaluated. Inside an argument that174+// takes ranges (evalArray) only a single cell does.175+func (rd *Reader) intersect(r Rect) (Addr, bool) {176+ if rd.arrays > 0 && r.From != r.To {177+ return Addr{}, false178+ }179+ return Intersect(r, rd.here)180+}181+182+// Intersect is the cell of r a formula in the cell at reads where it183+// wants one value: a single cell is itself; a single column gives the184+// cell in at's row, a single row the cell in at's column, when that lies185+// within r (implicit intersection, on whatever sheet r is on). Any other186+// range has no such cell, which is #VALUE!.187+func Intersect(r Rect, at Addr) (Addr, bool) {188+ switch {189+ case r.From == r.To:190+ return r.From, true191+ case r.From.Col == r.To.Col && at.Row >= r.From.Row && at.Row <= r.To.Row:192+ return Addr{Col: r.From.Col, Row: at.Row}, true193+ case r.From.Row == r.To.Row && at.Col >= r.From.Col && at.Col <= r.To.Col:194+ return Addr{Col: at.Col, Row: r.From.Row}, true195+ }196+ return Addr{}, false197+}198+199+// evalArray computes an expression given to an argument that takes200+// ranges (SUM(B2:B4*2), SUMPRODUCT(A1:A3*B1:B3)). Sheets computes those201+// over whole arrays, which 012 doesn't, so ranges in them stay #VALUE!202+// rather than reading one cell each.203+func evalArray(n Node, get lookup) Value {204+ get.arrays++205+ v := eval(n, get)206+ get.arrays--207+ return v208+}@@ -212,7 +212,7 @@ func eachOf(arg Node, get lookup, fn func(v Value, direct bool) *Value) *Value {
212212 })213213 return e214214 }215- return fn(eval(arg, get), true)215+ return fn(evalArray(arg, get), true)216216 }217217 218218 // Add counts v into the aggregate, with SUM's rules: blanks are skipped,@@ -51,6 +51,9 @@ type Reader struct {
5151 dense bool // read every address of a range; see NewReader5252 bufs []*scanBuf5353 level int // how many of bufs are in use by range reads in progress54+55+ here Addr // the cell whose formula is being evaluated; see EvalAt56+ arrays int // how many arguments taking ranges enclose the operator being evaluated; see evalArray5457 }5558 5659 // lookup is the parameter every evaluator takes.@@ -117,7 +117,7 @@ func (e *evaluator) formula(s *Sheet, a Addr, c *Cell) {
117117 expr := s.bound(c)118118 outer := w.evaluating119119 w.evaluating = loc{s, a}120- c.Value = functions.Eval(w.arith(expr), s.calcGet.lib)120+ c.Value = functions.EvalAt(w.arith(expr), s.calcGet.lib, a)121121 w.evaluating = outer122122 c.auto = functions.InferFormat(expr, s.calcFmt)123123 w.depth--@@ -51,7 +51,7 @@ func (s *Sheet) explain(a Addr, path []loc) string {
5151 return "Circular reference: " + strings.Join(append(names, here.label(home)), " → ")5252 }5353 path = append(path, here)54- n, from := s.errorOrigin(c.expr, v)54+ n, from := s.errorOrigin(c.expr, a, v)5555 if from != nil {5656 why := from.s.explain(from.a, path)5757 name := from.label(home)@@ -74,14 +74,15 @@ func (s *Sheet) explain(a Addr, path []loc) string {
7474 7575 // errorOrigin finds the innermost part of n that produces the error want7676 // itself rather than passing it on. When the error comes from a77-// referenced cell, it returns that cell.78-func (s *Sheet) errorOrigin(n Node, want Value) (Node, *loc) {79- return errorSearch{s: s, want: want, get: s.wb.values(s)}.find(n)77+// referenced cell, it returns that cell. The formula is the one at here.78+func (s *Sheet) errorOrigin(n Node, here Addr, want Value) (Node, *loc) {79+ return errorSearch{s: s, here: here, want: want, get: s.wb.values(s)}.find(n)8080 }8181 8282 // errorSearch follows an error through a formula on s to where it starts.8383 type errorSearch struct {8484 s *Sheet85+ here Addr // the formula's cell, for ranges read as one of their cells8586 want Value8687 get *reader8788 }@@ -104,8 +105,8 @@ func (e errorSearch) find(n Node) (Node, *loc) {
104105 return n, e.at(n.Sheet, n.Addr)105106 }106107 case formula.Range:107- if n.Rect.From == n.Rect.To && e.same(e.get.Cell(n.Sheet, n.Rect.From)) {108- return n, e.at(n.Sheet, n.Rect.From)108+ if a, ok := functions.Intersect(n.Rect, e.here); ok && e.same(e.get.Cell(n.Sheet, a)) {109+ return n, e.at(n.Sheet, a)109110 }110111 case formula.Unary:111112 return e.operand(n, n.X)@@ -121,7 +122,7 @@ func (e errorSearch) find(n Node) (Node, *loc) {
121122 // at n when none has.122123 func (e errorSearch) operand(n Node, operands ...Node) (Node, *loc) {123124 for _, x := range operands {124- if e.same(functions.Eval(x, e.get.lib)) {125+ if e.same(functions.EvalAt(x, e.get.lib, e.here)) {125126 return e.find(x)126127 }127128 }@@ -141,7 +142,7 @@ func (e errorSearch) inCall(n formula.Call) (Node, *loc) {
141142 }142143 continue143144 }144- if e.same(functions.Eval(arg, e.get.lib)) {145+ if e.same(functions.EvalAt(arg, e.get.lib, e.here)) {145146 return e.find(arg)146147 }147148 }@@ -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.@@ -0,0 +1,88 @@
1+package sheet2+3+import (4+ "strings"5+ "testing"6+)7+8+// A range where one value is wanted reads as the cell in the formula's9+// row (a column) or column (a row), as in Sheets and Excel; functions10+// that take ranges still read all of them.11+func TestImplicitIntersection(t *testing.T) {12+ for _, decimal := range []bool{false, true} {13+ s := sheetOf(t, map[string]string{14+ "A1": "Rent", "B1": "Amount",15+ "B2": "1400", "B3": "1450", "B4": "1500",16+ "B6": "10", "C6": "20", "D6": "30",17+ "F2": "=B2:B4*2", "F3": "=Rent*2", "F4": "=Rent*2", "F5": "=Rent*2",18+ "G3": "=B2:B4", "G4": "=ABS(-Rent)", "G5": "=B:B+1",19+ "H3": "=SUM(Rent)", "H4": "=SUMPRODUCT(Rent)", "H2": "=MATCH(1450,Rent,0)",20+ "H5": "=SUM(Rent*2)", "H6": "=SUMPRODUCT(Rent*2)",21+ "C7": "=B6:D6+1", "E7": "=B6:D6", "A8": "=B2:C4",22+ "J3": "=IF(TRUE,Rent)", "K3": "=Rent&\"!\"", "L3": "=COUNTIF(Rent,\">1420\")",23+ })24+ s.SetDecimal(decimal)25+ if err := s.DefineName("Rent", rect("B2:B4")); err != nil {26+ t.Fatal(err)27+ }28+ for a, want := range map[string]string{29+ "F2": "2800", "F3": "2900", "F4": "3000", "F5": "#VALUE!",30+ "G3": "1450", "G4": "1500", "G5": "1",31+ "H3": "4350", "H4": "4350", "H2": "2",32+ "H5": "#VALUE!", "H6": "#VALUE!", // array arguments: not intersected33+ "C7": "21", "E7": "#VALUE!", "A8": "#VALUE!",34+ "J3": "1450", "K3": "1450!", "L3": "2",35+ } {36+ if got := s.Value(at(a)).String(); got != want {37+ t.Errorf("decimal %v: %s = %s, want %s", decimal, a, got, want)38+ }39+ }40+ s.Set(at("B3"), "1475") // the intersected cell is a dependency41+ if got := s.Value(at("F3")).String(); got != "2950" {42+ t.Errorf("decimal %v: F3 after B3 changed = %s", decimal, got)43+ }44+ }45+}46+47+// Ranges on other sheets intersect with the formula's own row or column.48+func TestImplicitIntersectionOtherSheet(t *testing.T) {49+ s := New()50+ w := s.Book()51+ data, err := w.AddSheet("Q3 plan", 1)52+ if err != nil {53+ t.Fatal(err)54+ }55+ for a, in := range map[string]string{"C2": "5", "C3": "6", "C4": "7", "B9": "1", "C9": "2", "D9": "3"} {56+ if err := data.Set(at(a), in); err != nil {57+ t.Fatal(err)58+ }59+ }60+ if err := w.DefineName("Plan", data, rect("C2:C4")); err != nil {61+ t.Fatal(err)62+ }63+ for a, in := range map[string]string{64+ "A3": "='Q3 plan'!C2:C4*10", "A4": "=Plan+1", "A5": "=Plan",65+ "C1": "='Q3 plan'!B9:D9", "E1": "='Q3 plan'!B9:D9",66+ } {67+ if err := s.Set(at(a), in); err != nil {68+ t.Fatal(err)69+ }70+ }71+ for a, want := range map[string]string{"A3": "60", "A4": "8", "A5": "#VALUE!", "C1": "2", "E1": "#VALUE!"} {72+ if got := s.Value(at(a)).String(); got != want {73+ t.Errorf("%s = %s, want %s", a, got, want)74+ }75+ }76+ data.Set(at("C3"), "9")77+ if got := s.Value(at("A3")).String(); got != "90" {78+ t.Errorf("A3 after 'Q3 plan'!C3 changed = %s", got)79+ }80+}81+82+// An error read through an intersected range is traced to its cell.83+func TestImplicitIntersectionExplain(t *testing.T) {84+ s := sheetOf(t, map[string]string{"B2": "1", "B3": "=1/0", "B4": "3", "D3": "=B2:B4*2"})85+ if got := s.ExplainError(at("D3")); !strings.HasPrefix(got, "From B3") {86+ t.Errorf("D3 explained as %q", got)87+ }88+}@@ -16,7 +16,7 @@ func (s *Sheet) Link(a Addr) string {
1616 return ""1717 }1818 if call, ok := c.expr.(formula.Call); ok && funcOf(call).Name == "HYPERLINK" && c.IsFormula() {19- v := functions.Eval(call.Args[0], s.wb.values(s).lib)19+ v := functions.EvalAt(call.Args[0], s.wb.values(s).lib, a)2020 if v.Kind == Error {2121 return ""2222 }@@ -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+}@@ -0,0 +1,107 @@
1+package oracle2+3+import (4+ "testing"5+6+ "github.com/xuri/excelize/v2"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// intersections are formulas that use a range where one value is wanted,12+// each in the cell it is written in: the range reads as the cell in the13+// formula's row (a column) or column (a row), or #VALUE! when there is14+// none. Rent is the named range Sheet1!B2:B4; Other!B2:B4 and B9:D9 hold15+// other numbers. excelize reads the first cell of any range instead, so16+// it agrees only where that is the intersection, or where a function17+// takes the range; the other cases are skipped against excelize and give18+// what Excel and Sheets do (excel), which 012 is checked against.19+var intersections = []struct{ cell, formula, excel string }{20+ // The intersection is the first cell, or a function takes the range.21+ {"F2", "=B2:B4+1", ""}, {"F2", "=Rent*2", ""}, {"B8", "=B6:D6*3", ""},22+ {"F2", "=Other!B2:B4*10", ""}, {"B1", "=Other!B9:D9", ""},23+ {"H3", "=SUM(Rent)", ""}, {"H4", "=MATCH(1500,Rent,0)", ""}, {"H2", `=COUNTIF(Rent,">1420")`, ""},24+25+ // excelize reads the first cell where Excel and Sheets intersect.26+ {"F3", "=Rent*2", "2900"}, {"F4", "=Rent*2", "3000"}, {"F5", "=Rent*2", "#VALUE!"},27+ {"G3", "=B2:B4", "1450"}, {"G4", "=ABS(-Rent)", "1500"}, {"G3", `=Rent&"x"`, "1450x"},28+ {"C8", "=B6:D6*3", "60"}, {"E8", "=B6:D6*3", "#VALUE!"}, {"A9", "=B2:C4", "#VALUE!"},29+ {"F3", "=Other!B2:B4*10", "20"}, {"C1", "=Other!B9:D9", "5"}, {"F1", "=Other!B2:B4", "#VALUE!"},30+}31+32+func TestImplicitIntersection(t *testing.T) {33+ for _, decimal := range []bool{false, true} {34+ s, x := buildIntersect(t)35+ s.SetDecimal(decimal)36+ for _, tc := range intersections {37+ key := tc.cell + " " + tc.formula38+ if err := s.Set(addr(tc.cell), tc.formula); err != nil {39+ t.Fatalf("012 rejects %s: %v", key, err)40+ }41+ ours := s.Value(addr(tc.cell))42+ s.Set(addr(tc.cell), "")43+ if tc.excel != "" {44+ t.Logf("skip %s: excelize reads the range's first cell; Excel and Sheets intersect", key)45+ if !agree(ours, tc.excel) {46+ t.Errorf("decimal %v: %s: 012 %s, Excel and Sheets %s", decimal, key, show(ours), tc.excel)47+ }48+ continue49+ }50+ if theirs := calcAt(t, x, tc.cell, tc.formula); !agree(ours, theirs) {51+ t.Errorf("decimal %v: %s: 012 %s, excelize %q", decimal, key, show(ours), theirs)52+ }53+ }54+ }55+}56+57+// buildIntersect sets up the cells and the name Rent in both engines.58+func buildIntersect(t *testing.T) (*sheet.Sheet, *excelize.File) {59+ t.Helper()60+ s := sheet.New()61+ o, err := s.Book().AddSheet("Other", 1)62+ if err != nil {63+ t.Fatal(err)64+ }65+ x := excelize.NewFile()66+ if _, err := x.NewSheet("Other"); err != nil {67+ t.Fatal(err)68+ }69+ put := func(on *sheet.Sheet, name string, cells map[string]string) {70+ for a, in := range cells {71+ if err := on.Set(addr(a), in); err != nil {72+ t.Fatal(err)73+ }74+ if err := x.SetCellValue(name, a, on.Value(addr(a)).Num); err != nil {75+ t.Fatal(err)76+ }77+ }78+ }79+ put(s, "Sheet1", map[string]string{80+ "B2": "1400", "B3": "1450", "B4": "1500", "B6": "10", "C6": "20", "D6": "30", "C2": "7", "C3": "8", "C4": "9",81+ })82+ put(o, "Other", map[string]string{"B2": "1", "B3": "2", "B4": "3", "B9": "4", "C9": "5", "D9": "6"})83+ if err := s.DefineName("Rent", sheet.Rect{From: addr("B2"), To: addr("B4")}); err != nil {84+ t.Fatal(err)85+ }86+ if err := x.SetDefinedName(&excelize.DefinedName{Name: "Rent", RefersTo: "Sheet1!$B$2:$B$4"}); err != nil {87+ t.Fatal(err)88+ }89+ return s, x90+}91+92+// calcAt is excelize's result for the formula f in cell on Sheet1, which93+// is emptied again afterwards.94+func calcAt(t *testing.T, x *excelize.File, cell, f string) string {95+ t.Helper()96+ if err := x.SetCellFormula("Sheet1", cell, toExcel(f)); err != nil {97+ t.Fatal(err)98+ }99+ theirs, err := x.CalcCellValue("Sheet1", cell, excelize.Options{RawCellValue: true})100+ if err != nil && (theirs == "" || theirs[0] != '#') {101+ theirs = err.Error()102+ }103+ if err := x.SetCellValue("Sheet1", cell, nil); err != nil {104+ t.Fatal(err)105+ }106+ return theirs107+}