Each table goes out as a table part related to its worksheet, with its columns, style and, when the sheet's filter is on its range, the filter; header cells are written as text and structured references in Excel's file spelling (#This Row for @). Tables come in from table parts, a totals row left as cells. Formulas reading a table the file doesn't hold, a notebook output's, go out as values. The oracle reads tables excelize writes and checks formulas over them against Excel's results.
- commit
- aefc926f7f198a47c8f7728f82e99ea3661cdf22
- parent
- e4667c6a8acecb9eab0e4fceb6c6a1e294aebb14
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.1 of 3, Jev's estimate
| M | docs/ | 28 | +++- |
| M | internal/ | 19 | ++ |
| M | internal/ | 19 | ++- |
| M | internal/ | 34 | ++++- |
| M | internal/ | 2 | + |
| M | internal/ | 4 | + |
| M | internal/ | 19 | ++- |
| M | internal/ | 12 | ++ |
| A | internal/ | 236 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 140 | ++++++++++++++++++ |
| M | internal/ | 14 | +- |
| M | internal/ | 20 | ++- |
| M | internal/ | 5 | + |
| M | internal/ | 19 | ++ |
| M | internal/ | 37 | +++-- |
| M | oracle/ | 18 | ++ |
| M | oracle/ | 82 | ++++++++++- |
| A | oracle/ | 76 | ++++++++++ |
@@ -10,7 +10,7 @@ sidebar_position: 2
1010 number formats, bold, italic, underline, strikethrough, alignment,1111 [wrapped text, borders, row heights and merged cells](#layout-in-excel),1212 column widths, column and row styles, frozen panes, [filters](#filters-in-excel),13-notes (Excel's notes, its legacy comments; threaded comments aren't13+[tables](#tables-in-excel), notes (Excel's notes, its legacy comments; threaded comments aren't1414 read), [conditional formatting and data validation](#rules-in-excel),1515 [array formulas](#arrays-in-excel),1616 dates in the 1904 system, and hidden sheets (unless it's the one Excel@@ -20,7 +20,8 @@ showed).
2020 cached, column and row formats as column and row styles, frozen rows and2121 columns as frozen panes, a filter as Excel's with the rows it hides2222 hidden, notes as Excel's notes (which Sheets reads as notes), and23-formulas that spill as Excel 365's [dynamic array formulas](#arrays-in-excel).23+formulas that spill as Excel 365's [dynamic array formulas](#arrays-in-excel), and24+[tables](#tables-in-excel) as Excel's tables.2425 2526 What changes on the way:2627 @@ -32,6 +33,7 @@ What changes on the way:
3233 | Excel's sheet protection | Comes in unprotected, with a note saying so: Excel's protection locks cells where 012's only warns. Protected ranges aren't written |3334 | Array formulas (Excel 365's dynamic arrays and older `{=...}` ones) | Come in as formulas that spill again, without the values Excel kept in the cells they spill into |3435 | Functions only Sheets has, and array literals holding references | Go out as values, counted in the download's note: see [Arrays in Excel](#arrays-in-excel) |36+| Formulas reading a notebook output or a linked file by column (`app[status]`) | Go out as values: the file holds the output's cells but no table for Excel to read |3537 | JEV functions, `#AND#`, formulas naming a sheet that doesn't exist | Go out as values (their `#REF!`, for a missing sheet: Excel would refuse the reference). The download's result counts the formulas saved as values, with an example |3638 | A sheet name Excel can't take as is (spaces at its ends, or the same as another's but for them and case) | Written as one it can: without the spaces, with a number when two would clash (`Plan (2)`); formulas and named ranges naming it name that |3739 | Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) | Refused, with a message saying which |@@ -70,8 +72,26 @@ with a note saying how many criteria and which: two conditions in one
7072 column (`and`, `or`), wildcards other than at the ends of the text (`a?c`,7173 `a*c`), top 10, dynamic filters (above average, this month), dates7274 grouped by year or month, and filtering by color or icon. The filter73-itself still comes in over its range. Filters of Excel tables (as74-opposed to the sheet's AutoFilter) aren't read.75+itself still comes in over its range. A table's filter is its own in76+Excel: see [Tables in Excel](#tables-in-excel).77+78+## Tables in Excel79+80+A [table](../sheets/tables.md) goes out as an Excel table over the same81+range, with its columns' names, and an Excel table comes in as a table:82+83+| 012 | Excel |84+|---|---|85+| The table's name | Its `name` and `displayName` |86+| Structured references (`Sales[@Amount]`) | The same, with `@` written as Excel's files spell it: `Sales[[#This Row],[Amount]]` |87+| Header style | Excel's default table style, `TableStyleMedium2`; coming in, any table style |88+| Banded rows | Row stripes (`showRowStripes`) |89+| A filter over the table's range | The table's filter, rather than the sheet's |90+| A number in the header row | Written as the text it shows, which Excel needs of a header |91+92+Coming in, a totals row stays as cells below the table, and formulas93+reading `[#Totals]` show `#REF!`; a table without a header row, or named94+as a named range or another table is, is left out with a note.7595 7696 ## Arrays in Excel7797 @@ -131,6 +131,9 @@ func excelName(b *strings.Builder, src string, i int, renamed map[string]string)
131131 j++132132 }133133 name := src[start:j]134+ if !at && j < len(src) && src[j] == '[' {135+ return excelTableRef(b, src, start, j)136+ }134137 k := j135138 for k < len(src) && src[k] == ' ' {136139 k++@@ -154,6 +157,22 @@ func excelName(b *strings.Builder, src string, i int, renamed map[string]string)
154157 return j, true155158 }156159 160+// excelTableRef writes the structured reference whose table's name161+// starts at src[start] and whose brackets start at src[open] as Excel's162+// files hold it: Sales[[#This Row],[Amount]] for Sales[@Amount].163+func excelTableRef(b *strings.Builder, src string, start, open int) (int, bool) {164+ end := formula.StructuredEnd(src, open)165+ if end < 0 {166+ return 0, false167+ }168+ x, ok := formula.ExcelTableRef(src[start:end])169+ if !ok {170+ return 0, false171+ }172+ b.WriteString(x)173+ return end, true174+}175+157176 // excelSheetRef is text, a sheet name as written (name, unquoted) when158177 // sheet says it is followed by !, with the name the sheet is written159178 // under when that differs: always quoted, as Excel accepts.@@ -56,6 +56,10 @@ type Snapshot struct {
5656 // the merged cells in the range, for formats that keep them (XLSX).5757 Heights map[int]int5858 Merges []sheet.Rect59+60+ // Tables are the tables wholly in the range, for formats that keep61+ // them (XLSX).62+ Tables []sheet.Table5963 }6064 6165 // SnapName is a named range: its name, and the range on the sheet@@ -74,6 +78,7 @@ type SnapCell struct {
7478 Style sheet.Style7579 Formula bool7680 Sheets []string // the sheets a formula names, as written81+ Tables []string // the tables a formula reads, as written7782 }7883 7984 // Text is the cell as displayed, without a width limit.@@ -86,8 +91,8 @@ func (c SnapCell) Text() string {
8691 // row and column with contents, so exporting whole columns writes their8792 // data rather than a million blank lines.8893 func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {89- notes := r90- if r == (sheet.Rect{}) {94+ notes, whole := r, r == (sheet.Rect{})95+ if whole {9196 notes = sheet.Rect{To: sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}}9297 r, _ = s.UsedRange()9398 } else if data, ok := s.FilledBounds(r); ok {@@ -100,6 +105,15 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
100105 CondFormats: s.CondFormats(), Validations: s.Validations()}101106 snap.FrozenRows, snap.FrozenCols = s.Frozen()102107 snap.Heights, snap.Merges = heightsMergesIn(s, r)108+ for _, t := range s.Tables() {109+ if whole {110+ // A whole sheet's range holds its tables, blank rows and all.111+ snap.Range.To.Col, snap.Range.To.Row = max(snap.Range.To.Col, t.Range.To.Col), max(snap.Range.To.Row, t.Range.To.Row)112+ }113+ if snap.Range.Contains(t.Range.From) && snap.Range.Contains(t.Range.To) {114+ snap.Tables = append(snap.Tables, t)115+ }116+ }103117 for _, a := range s.NotesIn(notes) {104118 if snap.Notes == nil {105119 snap.Notes = map[sheet.Addr]string{}@@ -133,6 +147,7 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
133147 Style: s.CellStyle(a),134148 Formula: c.IsFormula(),135149 Sheets: s.NamedSheets(a),150+ Tables: s.NamedTables(a),136151 }137152 }138153 return snap@@ -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(snap.Locale), multi: len(sheets) > 1, known: map[string]bool{}, renamed: map[string]string{}}44+ w := &xlsxWriter{styles: newXLSXStyleTable(snap.Locale), multi: len(sheets) > 1, known: map[string]bool{}, renamed: map[string]string{}, tables: tableKeys(sheets)}4545 res := &ExportResult{}4646 names, hidden, active := make([]string, len(sheets)), make([]bool, len(sheets)), 04747 used := map[string]bool{}@@ -70,15 +70,16 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
7070 res.Rows += rows7171 if len(sn.Notes) > 0 {7272 withNotes = append(withNotes, i+1)73- if err := writeNotes(zw, i+1, sn.Notes); err != nil {74- return err75- }73+ }74+ if err := w.writeSheetParts(zw, i+1, sn); err != nil {75+ return err7676 }7777 }7878 if err := w.writeShared(zw); err != nil {7979 return err8080 }81- if err := writePackage(zw, names, hidden, active, w.definedNames(snap.Names), filterRanges(sheets, names), notesTypes(withNotes), w.dynamic); err != nil {81+ types := notesTypes(withNotes) + strings.Join(w.tableTypes, "")82+ if err := writePackage(zw, names, hidden, active, w.definedNames(snap.Names), filterRanges(sheets, names), types, w.dynamic); err != nil {8283 return err8384 }8485 return zw.Close()@@ -98,6 +99,27 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
9899 return res, nil99100 }100101 102+// writeSheetParts writes the parts worksheet part n relates to, its103+// notes and tables, and the relationships to them.104+func (w *xlsxWriter) writeSheetParts(zw *zip.Writer, n int, sn *Snapshot) error {105+ var rels []string106+ if len(sn.Notes) > 0 {107+ if err := writeNotes(zw, n, sn.Notes); err != nil {108+ return err109+ }110+ rels = append(rels, notesRels(n)...)111+ }112+ tables, err := w.writeTables(zw, sn)113+ if err != nil {114+ return err115+ }116+ if rels = append(rels, tables...); len(rels) == 0 {117+ return nil118+ }119+ return writePart(zw, "xl/worksheets/_rels/sheet"+strconv.Itoa(n)+".xml.rels",120+ xmlHead+`<Relationships xmlns="`+relsNS+`">`+strings.Join(rels, "")+`</Relationships>`)121+}122+101123 // writeShared writes the parts the worksheets share: the styles, and the102124 // cell metadata of dynamic array formulas when any was written.103125 func (w *xlsxWriter) writeShared(zw *zip.Writer) error {@@ -150,7 +172,7 @@ const (
150172 func filterRanges(sheets []*Snapshot, names []string) []string {151173 out := make([]string, len(sheets))152174 for i, sn := range sheets {153- if sn.Filter != nil {175+ if sn.Filter != nil && tableFilter(sn) < 0 { // a table's filter is its own154176 out[i] = excelRange(names[i], sn.Filter.Range)155177 }156178 }@@ -23,6 +23,8 @@ type xlsxBook struct {
2323 // protected names the sheets read so far that Excel protects.2424 protected []string2525 skips ruleSkips // rules left out, see xlsxrulesimport.go26+ // tablesLeft counts the tables left out, see xlsxtables.go.27+ tablesLeft int2628 }2729 2830 // xlsxSheetInfo is a sheet as the workbook lists it.@@ -73,6 +73,7 @@ func (bk *xlsxBook) importBook(ctx context.Context, opt Options) (*Result, error
7373 done += rows7474 }7575 notes := append(bk.importNames(book), bk.protectionNote()...)76+ notes = append(notes, bk.tablesNote()...)7677 active := book.Sheet(clamp(bk.active, 0, book.Len()-1))7778 book.SetActive(active)7879 for i, info := range bk.sheets {@@ -133,6 +134,9 @@ func (bk *xlsxBook) importSheet(ctx context.Context, b *builder, i int, af **xls
133134 if err := bk.readNotes(b.s, bk.sheets[i].part); err != nil {134135 return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)135136 }137+ if err := bk.readTables(b.s, bk.sheets[i].part, af); err != nil {138+ return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)139+ }136140 loadColStyles(b, width, func(c int) (xlsxStyle, bool) {137141 st := bk.styles.style(r.lineStyle(c + 1))138142 return st, st != (xlsxStyle{})@@ -26,8 +26,8 @@ const (
2626 vmlRelID = "rIdVML"2727 )2828 29-// writeNotes writes worksheet part n's notes: its relationships, the30-// comments part and the VML drawing.29+// writeNotes writes worksheet part n's notes: the comments part and the30+// VML drawing, which notesRels relate to it.3131 func writeNotes(zw *zip.Writer, n int, notes map[sheet.Addr]string) error {3232 num := strconv.Itoa(n)3333 addrs := slices.SortedFunc(maps.Keys(notes), func(a, b sheet.Addr) int {@@ -55,12 +55,7 @@ func writeNotes(zw *zip.Writer, n int, notes map[sheet.Addr]string) error {
5555 }5656 comments.WriteString(`</commentList></comments>`)5757 vml.WriteString(`</xml>`)58- rels := xmlHead + `<Relationships xmlns="` + relsNS + `">` +59- `<Relationship Id="` + notesRelID + `" Type="` + officeRel + `/comments" Target="../comments` + num + `.xml"/>` +60- `<Relationship Id="` + vmlRelID + `" Type="` + officeRel + `/vmlDrawing" Target="../drawings/vmlDrawing` + num + `.vml"/>` +61- `</Relationships>`6258 for _, p := range []struct{ name, body string }{63- {"xl/worksheets/_rels/sheet" + num + ".xml.rels", rels},6459 {"xl/comments" + num + ".xml", comments.String()},6560 {"xl/drawings/vmlDrawing" + num + ".vml", vml.String()},6661 } {@@ -71,6 +66,16 @@ func writeNotes(zw *zip.Writer, n int, notes map[sheet.Addr]string) error {
7166 return nil7267 }7368 69+// notesRels are the relationships from worksheet part n to its notes'70+// parts.71+func notesRels(n int) []string {72+ num := strconv.Itoa(n)73+ return []string{74+ `<Relationship Id="` + notesRelID + `" Type="` + officeRel + `/comments" Target="../comments` + num + `.xml"/>`,75+ `<Relationship Id="` + vmlRelID + `" Type="` + officeRel + `/vmlDrawing" Target="../drawings/vmlDrawing` + num + `.vml"/>`,76+ }77+}78+7479 // notesTypes are the content types of the notes of the worksheet parts7580 // numbered in withNotes.7681 func notesTypes(withNotes []int) string {@@ -240,11 +240,23 @@ func mergeParts(p map[string]string, name, content string) map[string]string {
240240 return p241241 }242242 243+// tableParts is a workbook of one sheet with a table over A1:B3 and a244+// formula reading it.245+func tableParts() map[string]string {246+ p := oneSheetParts("", `<row r="1"><c r="A1" t="inlineStr"><is><t>Item</t></is></c><c r="B1" t="inlineStr"><is><t>Cost</t></is></c></row>`+247+ `<row r="2"><c r="B2"><v>5</v></c><c r="C2"><f>SUM(T[Cost])+T[[#This Row],[Cost]]</f></c></row>`)248+ p["xl/worksheets/sheet1.xml"] = strings.Replace(p["xl/worksheets/sheet1.xml"], `</worksheet>`, `<tableParts count="1"><tablePart r:id="rT"/></tableParts></worksheet>`, 1)249+ p["xl/worksheets/_rels/sheet1.xml.rels"] = `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rT" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table1.xml"/></Relationships>`250+ p["xl/tables/table1.xml"] = `<table ` + mainNS + ` id="1" name="T" displayName="T" ref="A1:B3" totalsRowCount="1"><autoFilter ref="A1:B2"/><tableColumns count="2"><tableColumn id="1" name="Item"/><tableColumn id="2" name="Cost"/></tableColumns><tableStyleInfo name="TableStyleLight1" showRowStripes="1"/></table>`251+ return p252+}253+243254 // FuzzReadXLSX feeds the reader arbitrary files: it must fail cleanly,244255 // never panic or run past its limits.245256 func FuzzReadXLSX(f *testing.F) {246257 f.Add(zipParts(f, kitchenParts()))247258 f.Add(zipParts(f, oneSheetParts(`<workbookPr date1904="1"/>`, `<row><c r="B2" t="d"><v>2026-01-01</v></c></row>`)))259+ f.Add(zipParts(f, tableParts()))248260 f.Fuzz(func(t *testing.T, data []byte) {249261 lim := defaultXLSXLimits250262 lim.part, lim.total = 4<<20, 8<<20@@ -0,0 +1,236 @@
1+package fileio2+3+import (4+ "archive/zip"5+ "bufio"6+ "encoding/xml"7+ "fmt"8+ "io"9+ "strconv"10+ "strings"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// Tables travel as Excel's tables: a table part per table, related to16+// its worksheet, which lists them in <tableParts>. A filter on a table's17+// range goes in the table part, as Excel keeps a table's filter, rather18+// than on the sheet. A banded table has row stripes, and a styled header19+// Excel's default table style; a table with neither has no style.20+// Excel needs a table's header cells to hold text, so a number there is21+// written as the text it shows.22+23+const (24+ tableType = mlType + "table+xml"25+ tableRelID = "rIdTable"26+ tableStyle = "TableStyleMedium2"27+ maxTableCol = 16384 // tableColumns Excel reads, a sheet's width28+)29+30+// tableFilter is the index of the table whose range the sheet's filter31+// has, or -1.32+func tableFilter(snap *Snapshot) int {33+ if snap.Filter == nil {34+ return -135+ }36+ for i, t := range snap.Tables {37+ if t.Range == snap.Filter.Range {38+ return i39+ }40+ }41+ return -142+}43+44+// headerCells are the cells of the header rows of the snapshot's45+// tables.46+func headerCells(snap *Snapshot) map[sheet.Addr]bool {47+ if len(snap.Tables) == 0 {48+ return nil49+ }50+ out := map[sheet.Addr]bool{}51+ for _, t := range snap.Tables {52+ for c := t.Range.From.Col; c <= t.Range.To.Col; c++ {53+ out[sheet.Addr{Col: c, Row: t.Range.From.Row}] = true54+ }55+ }56+ return out57+}58+59+// writeTableParts lists the snapshot's tables in its worksheet, by60+// their relationships.61+func writeTableParts(bw *bufio.Writer, snap *Snapshot) {62+ if len(snap.Tables) == 0 {63+ return64+ }65+ fmt.Fprintf(bw, `<tableParts count="%d">`, len(snap.Tables))66+ for i := range snap.Tables {67+ fmt.Fprintf(bw, `<tablePart r:id="%s%d"/>`, tableRelID, i+1)68+ }69+ bw.WriteString(`</tableParts>`)70+}71+72+// writeTables writes the table parts of a worksheet's tables, numbered73+// on from the tables written before, and returns their relationships74+// from the worksheet.75+func (w *xlsxWriter) writeTables(zw *zip.Writer, snap *Snapshot) ([]string, error) {76+ var rels []string77+ onFilter := tableFilter(snap)78+ for i, t := range snap.Tables {79+ w.tableParts++80+ n := strconv.Itoa(w.tableParts)81+ var body strings.Builder82+ bw := bufio.NewWriter(&body)83+ fmt.Fprintf(bw, xmlHead+`<table xmlns="%s" id="%s" name="%s" displayName="%s" ref="%s" totalsRowShown="0">`,84+ sheetMain, n, escapeXML(t.Name, true), escapeXML(t.Name, true), excelRect(t.Range))85+ if i == onFilter {86+ writeAutoFilter(bw, snap)87+ }88+ fmt.Fprintf(bw, `<tableColumns count="%d">`, len(t.Cols))89+ for j, col := range t.Cols {90+ fmt.Fprintf(bw, `<tableColumn id="%d" name="%s"/>`, j+1, escapeXML(col, true))91+ }92+ bw.WriteString(`</tableColumns><tableStyleInfo`)93+ if t.Header {94+ bw.WriteString(` name="` + tableStyle + `"`)95+ }96+ fmt.Fprintf(bw, ` showFirstColumn="0" showLastColumn="0" showRowStripes="%d" showColumnStripes="0"/></table>`, b2i(t.Banded))97+ if err := bw.Flush(); err != nil {98+ return nil, err99+ }100+ if err := writePart(zw, "xl/tables/table"+n+".xml", body.String()); err != nil {101+ return nil, err102+ }103+ w.tableTypes = append(w.tableTypes, `<Override PartName="/xl/tables/table`+n+`.xml" ContentType="`+tableType+`"/>`)104+ rels = append(rels, fmt.Sprintf(`<Relationship Id="%s%d" Type="%s/table" Target="../tables/table%s.xml"/>`, tableRelID, i+1, officeRel, n))105+ }106+ return rels, nil107+}108+109+func b2i(b bool) int {110+ if b {111+ return 1112+ }113+ return 0114+}115+116+// tableKeys are the keys of the tables the snapshots hold, which117+// formulas may read in the file.118+func tableKeys(sheets []*Snapshot) map[string]bool {119+ out := map[string]bool{}120+ for _, sn := range sheets {121+ for _, t := range sn.Tables {122+ out[strings.ToUpper(t.Name)] = true123+ }124+ }125+ return out126+}127+128+// unknownTable reports whether c's formula reads a table the file129+// doesn't hold, such as a notebook output's, which Excel would refuse.130+func (w *xlsxWriter) unknownTable(c SnapCell) bool {131+ for _, name := range c.Tables {132+ if !w.tables[strings.ToUpper(name)] {133+ return true134+ }135+ }136+ return false137+}138+139+// tablesNote says how many tables an import left out, or nothing.140+func (bk *xlsxBook) tablesNote() []string {141+ if bk.tablesLeft == 0 {142+ return nil143+ }144+ return []string{count(bk.tablesLeft, "table", "tables") + " left out: without a header row, or named as a range or another table is"}145+}146+147+// readTables loads the tables of worksheet part, from the table parts148+// its relationships name, into s, whose cells are read. A table's149+// filter goes in af when the sheet has none of its own. Tables 012150+// can't hold (no header row, a name taken) are left out, and counted.151+func (bk *xlsxBook) readTables(s *sheet.Sheet, part string, af **xlsxAutoFilter) error {152+ rels, err := bk.pkg.rels(part)153+ if err != nil {154+ return err155+ }156+ for _, rel := range rels {157+ if rel.typ != "table" || !bk.pkg.has(rel.target) {158+ continue159+ }160+ t, tf, err := bk.readTable(rel.target)161+ if err != nil {162+ return fmt.Errorf("table: %w", err)163+ }164+ if t == nil || s.LoadTable(*t) != nil {165+ bk.tablesLeft++166+ continue167+ }168+ if *af == nil && tf != nil {169+ *af = tf170+ }171+ }172+ return nil173+}174+175+// readTable reads a table part: its name, range, columns and style,176+// and its filter; nil for a table without a header row. A totals row177+// is left as cells below the table.178+func (bk *xlsxBook) readTable(part string) (*sheet.Table, *xlsxAutoFilter, error) {179+ x, err := bk.pkg.open(part)180+ if x == nil || err != nil {181+ return nil, nil, err182+ }183+ defer x.close()184+ var t sheet.Table185+ var af *xlsxAutoFilter186+ for {187+ tok, err := x.next()188+ if err == io.EOF {189+ break190+ }191+ if err != nil {192+ return nil, nil, err193+ }194+ se, ok := tok.(xml.StartElement)195+ if !ok {196+ continue197+ }198+ switch se.Name.Local {199+ case "table":200+ if !tableHead(&t, se) {201+ return nil, nil, x.skip()202+ }203+ case "autoFilter":204+ if af, err = readAutoFilter(x, se); err != nil {205+ return nil, nil, err206+ }207+ case "tableColumn":208+ if len(t.Cols) < maxTableCol {209+ t.Cols = append(t.Cols, attrOr(se, "name", ""))210+ }211+ case "tableStyleInfo":212+ t.Header = attrOr(se, "name", "") != ""213+ t.Banded = boolAttr(se, "showRowStripes", false)214+ }215+ }216+ if t.Range.To.Row == t.Range.From.Row {217+ return nil, nil, nil218+ }219+ return &t, af, nil220+}221+222+// tableHead reads a <table>'s name and range into t, the range less a223+// totals row, and reports false for a table 012 can't hold: no header224+// row, or no range. LoadTable checks the name.225+func tableHead(t *sheet.Table, se xml.StartElement) bool {226+ t.Name = attrOr(se, "displayName", attrOr(se, "name", ""))227+ r, ok := sheet.ParseRange(strings.ReplaceAll(attrOr(se, "ref", ""), "$", ""))228+ if !ok || attrOr(se, "headerRowCount", "1") == "0" {229+ return false230+ }231+ if n, err := strconv.Atoi(attrOr(se, "totalsRowCount", "0")); err == nil && n > 0 {232+ r.To.Row = max(r.To.Row-n, r.From.Row)233+ }234+ t.Range = r235+ return true236+}@@ -0,0 +1,140 @@
1+package fileio2+3+import (4+ "context"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ "github.com/xuri/excelize/v2"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Tables go out as Excel's tables, with their filters and style, and15+// formulas reading them in Excel's syntax; excelize and 012 read them16+// back.17+func TestXLSXTables(t *testing.T) {18+ src := build(t, map[string]string{19+ "A1": "Region", "B1": "2024", "C1": "Amount",20+ "A2": "North", "B2": "2", "C2": "10",21+ "A3": "South", "B3": "3", "C3": "20",22+ "E1": "=SUM(Sales[Amount])", "D2": "=Sales[@Amount]*2",23+ })24+ if err := src.CreateTable("Sales", rect(t, "A1:C3")); err != nil {25+ t.Fatal(err)26+ }27+ src.Book().SetTableStyle("Sales", false, true)28+ src.CreateFilter(rect(t, "A1:C3"))29+ src.FilterColumn(0, sheet.Criteria{Hidden: []string{"South"}})30+ name := filepath.Join(t.TempDir(), "tables.xlsx")31+ if _, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{}); err != nil {32+ t.Fatal(err)33+ }34+ x, err := excelize.OpenFile(name)35+ if err != nil {36+ t.Fatal(err)37+ }38+ ts, err := x.GetTables("Sheet1")39+ if err != nil || len(ts) != 1 || ts[0].Name != "Sales" || ts[0].Range != "A1:C3" || ts[0].StyleName != "TableStyleMedium2" {40+ t.Errorf("excelize tables %+v, %v", ts, err)41+ }42+ if f, _ := x.GetCellFormula("Sheet1", "D2"); f != "Sales[[#This Row],[Amount]]*2" {43+ t.Errorf("formula %q", f)44+ }45+ if v, _ := x.GetCellValue("Sheet1", "B1"); v != "2024" {46+ t.Errorf("header %q", v)47+ }48+ x.Close()49+ got, err := Import(context.Background(), name, Options{})50+ if err != nil {51+ t.Fatal(err)52+ }53+ s := got.Sheet54+ tb, ok := s.TableAt(addr(t, "B2"))55+ if !ok || tb.Name != "Sales" || tb.Range != rect(t, "A1:C3") || tb.Banded || !tb.Header || strings.Join(tb.Cols, ",") != "Region,2024,Amount" {56+ t.Fatalf("table read back %+v", tb)57+ }58+ if r, ok := s.FilterRange(); !ok || r != rect(t, "A1:C3") || !s.RowHidden(2) {59+ t.Errorf("filter %v %v, row 3 hidden %v", r, ok, s.RowHidden(2))60+ }61+ for a, want := range map[string]string{"E1": "30", "D2": "20"} {62+ if v := s.Value(addr(t, a)).String(); v != want {63+ t.Errorf("%s = %s, want %s", a, v, want)64+ }65+ }66+}67+68+// A formula reading a table the file doesn't hold, a notebook output's,69+// is written as its value, as Excel would refuse it.70+func TestXLSXUnknownTable(t *testing.T) {71+ src := build(t, map[string]string{"A1": "=SUM(app[ms])+1"})72+ name := filepath.Join(t.TempDir(), "outputs.xlsx")73+ res, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{})74+ if err != nil {75+ t.Fatal(err)76+ }77+ if !strings.Contains(strings.Join(res.Notes, "; "), "saved as values") {78+ t.Errorf("notes %q", res.Notes)79+ }80+ x, _ := excelize.OpenFile(name)81+ defer x.Close()82+ if f, _ := x.GetCellFormula("Sheet1", "A1"); f != "" {83+ t.Errorf("formula kept: %q", f)84+ }85+}86+87+// Tables Excel makes, a totals row and all, come in as tables.88+func TestXLSXTablesFromExcelize(t *testing.T) {89+ x := excelize.NewFile()90+ for a, v := range map[string]any{"A1": "Item", "B1": "Cost", "A2": "Rent", "B2": 1450, "A3": "Food", "B3": 300, "A4": "Total", "B4": 1750} {91+ x.SetCellValue("Sheet1", a, v)92+ }93+ x.SetCellFormula("Sheet1", "D1", "SUM(Costs[Cost])")94+ stripes := true95+ if err := x.AddTable("Sheet1", &excelize.Table{Range: "A1:B3", Name: "Costs", StyleName: "TableStyleLight9", ShowRowStripes: &stripes}); err != nil {96+ t.Fatal(err)97+ }98+ name := filepath.Join(t.TempDir(), "excelize.xlsx")99+ if err := x.SaveAs(name); err != nil {100+ t.Fatal(err)101+ }102+ x.Close()103+ got, err := Import(context.Background(), name, Options{})104+ if err != nil {105+ t.Fatal(err)106+ }107+ s := got.Sheet108+ tb, ok := s.TableAt(addr(t, "A2"))109+ if !ok || tb.Name != "Costs" || !tb.Banded || !tb.Header || strings.Join(tb.Cols, ",") != "Item,Cost" {110+ t.Fatalf("table %+v", tb)111+ }112+ if v := s.Value(addr(t, "D1")).String(); v != "1750" {113+ t.Errorf("D1 = %s", v)114+ }115+}116+117+// A table's totals row stays cells below it.118+func TestXLSXTableTotalsRow(t *testing.T) {119+ b, err := readParts(t, tableParts(), defaultXLSXLimits)120+ if err != nil {121+ t.Fatal(err)122+ }123+ s := b.Sheet124+ tb, ok := s.TableAt(addr(t, "A1"))125+ if !ok || tb.Range != rect(t, "A1:B2") || !tb.Banded || !tb.Header {126+ t.Fatalf("table %+v", tb)127+ }128+ if v := s.Value(addr(t, "C2")).String(); v != "10" {129+ t.Errorf("C2 = %s", v)130+ }131+}132+133+func rect(t *testing.T, s string) sheet.Rect {134+ t.Helper()135+ r, ok := sheet.ParseRange(s)136+ if !ok {137+ t.Fatalf("bad range %s", s)138+ }139+ return r140+}@@ -94,21 +94,27 @@ func (r *xlsxSheetReader) readTail() (*xlsxAutoFilter, error) {
9494 9595 // readAutoFilter reads an <autoFilter>, just started.9696 func (r *xlsxSheetReader) readAutoFilter(se xml.StartElement) (*xlsxAutoFilter, error) {97+ return readAutoFilter(r.x, se)98+}99+100+// readAutoFilter reads an <autoFilter> of a worksheet or a table, just101+// started in x.102+func readAutoFilter(x *xmlStream, se xml.StartElement) (*xlsxAutoFilter, error) {97103 af := &xlsxAutoFilter{}98104 af.ref, _ = attr(se, "ref")99- for depth := r.x.depth; ; {100- t, err := r.x.next()105+ for depth := x.depth; ; {106+ t, err := x.next()101107 if err != nil {102108 return nil, eofAsUnexpected(err)103109 }104- if _, ok := t.(xml.EndElement); ok && r.x.depth < depth {110+ if _, ok := t.(xml.EndElement); ok && x.depth < depth {105111 return af, nil106112 }107113 se, ok := t.(xml.StartElement)108114 if !ok {109115 continue110116 }111- if se.Name.Local == "filterColumn" && r.x.depth == depth+1 {117+ if se.Name.Local == "filterColumn" && x.depth == depth+1 {112118 af.cols = append(af.cols, xlsxFilterColumn{col: intAttr(se, "colId", -1)})113119 } else if n := len(af.cols); n > 0 {114120 af.cols[n-1].add(se)@@ -35,6 +35,13 @@ type xlsxWriter struct {
3535 dynamic bool // a dynamic array formula was written: see xlsxarray.go3636 spills map[sheet.Addr]sheet.Rect // the sheet being written's Snapshot.Spills3737 rules ruleNotes // rules left out, see xlsxrules.go38+ // Tables: see xlsxtables.go. tables are the keys of those written,39+ // headers the header cells of the sheet being written, tableParts40+ // how many parts are written and tableTypes their content types.41+ tables map[string]bool42+ headers map[sheet.Addr]bool43+ tableParts int44+ tableTypes []string3845 }3946 4047 // valueCount counts formulas written as values, keeping the first one's@@ -68,7 +75,7 @@ func (w *xlsxWriter) unknownSheet(c SnapCell) (string, bool) {
6875 // Errors stick in bw.6976 func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active bool) int {7077 r := snap.Range71- w.spills = snap.Spills78+ w.spills, w.headers = snap.Spills, headerCells(snap)7279 bw.WriteString(xmlHead + `<worksheet xmlns="` + sheetMain + `" xmlns:r="` + officeRel + `">`)7380 if len(snap.HiddenRows) > 0 {7481 bw.WriteString(`<sheetPr filterMode="1"/>`) // a filter is hiding rows@@ -101,12 +108,15 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
101108 }102109 w.styledRows(bw, snap, styled, sheet.MaxRows)103110 bw.WriteString(`</sheetData>`)104- writeAutoFilter(bw, snap)111+ if tableFilter(snap) < 0 {112+ writeAutoFilter(bw, snap)113+ }105114 writeMerges(bw, snap)106115 w.writeRules(bw, ws, snap)107116 if len(snap.Notes) > 0 {108117 bw.WriteString(`<legacyDrawing r:id="` + vmlRelID + `"/>`)109118 }119+ writeTableParts(bw, snap)110120 bw.WriteString(`</worksheet>`)111121 return r.To.Row - r.From.Row + 1112122 }@@ -129,6 +139,9 @@ func (w *xlsxWriter) cell(b []byte, ws string, a sheet.Addr, c SnapCell) []byte
129139 b = append(b, '"')130140 }131141 typ, v, inline := cellValue(c, fx != "")142+ if w.headers[a] && fx == "" && v != "" {143+ typ, v, inline = "inlineStr", c.Text(), true // a table's header is text144+ }132145 if typ != "" {133146 b = append(b, ` t="`...)134147 b = append(b, typ...)@@ -182,6 +195,9 @@ func (w *xlsxWriter) formula(ws string, a sheet.Addr, c SnapCell) (string, bool)
182195 return "", false183196 }184197 fx, arrays, ok := excelFormula(c.Input, w.renamed)198+ if w.unknownTable(c) {199+ fx, ok = "", false200+ }185201 if !ok {186202 w.values.add(w.multi, ws, a)187203 }@@ -226,6 +226,11 @@ func bracketEnd(src string, i int) int {
226226 return -1227227 }228228 229+// StructuredEnd returns the index just past the brackets of a230+// structured reference whose "[" is at src[i], or -1 when they aren't231+// closed.232+func StructuredEnd(src string, i int) int { return bracketEnd(src, i) }233+229234 // escaped are the characters a column's name escapes with ' in a230235 // structured reference.231236 const escaped = "[]#'"@@ -47,6 +47,25 @@ func (s *Sheet) NamedSheets(a Addr) []string {
4747 return out4848 }4949 50+// NamedTables returns the tables the formula at a reads by structured51+// references, as written and without repeats (in any case), or nil for52+// anything else. Exporters check that they write them.53+func (s *Sheet) NamedTables(a Addr) []string {54+ c := s.cells.get(a)55+ if c == nil || c.expr == nil || len(c.names) == 0 {56+ return nil57+ }58+ var out []string59+ seen := map[string]bool{}60+ formula.WalkTables(c.expr, func(t formula.TableRef) {61+ if k := nameKey(t.Table); !seen[k] {62+ seen[k] = true63+ out = append(out, t.Table)64+ }65+ })66+ return out67+}68+5069 // ShiftEntry returns an entry as if it were typed in one cell and copied5170 // dc columns and dr rows away: a formula's relative references move and5271 // $absolute ones stay, as in a paste. Other entries come back unchanged.@@ -40,25 +40,38 @@ func encodeTables(ts []Table) []fileTable {
4040 }4141 4242 // readTables restores a sheet's tables from a file, its cells already43-// read. Columns the file doesn't name, or names in a number other than44-// the range's width, are named from the header row.43+// read.4544 func (s *Sheet) readTables(fts []fileTable) error {4645 for _, ft := range fts {47- if err := s.wb.checkTableName(ft.Name, ""); err != nil {48- return fmt.Errorf("table %q: %w", ft.Name, err)49- }5046 r, ok := ParseRange(ft.Range)51- if !ok || r.To.Row == r.From.Row {47+ if !ok {5248 return fmt.Errorf("table %q: invalid range %q", ft.Name, ft.Range)5349 }54- if err := s.checkTableRange(r, ""); err != nil {50+ if err := s.LoadTable(Table{Name: ft.Name, Range: r, Cols: ft.Columns, Banded: ft.Banded, Header: ft.Header}); err != nil {5551 return fmt.Errorf("table %q: %w", ft.Name, err)5652 }57- t := Table{Name: ft.Name, Range: r, Cols: columnNames(ft.Columns, nil), Banded: ft.Banded, Header: ft.Header}58- if len(t.Cols) != r.To.Col-r.From.Col+1 {59- t.Cols = columnNames(s.rowTexts(Rect{From: r.From, To: Addr{Col: r.To.Col, Row: r.From.Row}}), nil)60- }61- s.view.tables = append(s.view.tables, t)6253 }6354 return nil6455 }56+57+// LoadTable adds t to the sheet as a loader does, without recording58+// undo, its cells loaded. Columns t doesn't name, or names in a number59+// other than its range's width, are named from its header row, and the60+// names are made unique.61+func (s *Sheet) LoadTable(t Table) error {62+ if err := s.wb.checkTableName(t.Name, ""); err != nil {63+ return err64+ }65+ if t.Range.To.Row == t.Range.From.Row {66+ return fmt.Errorf("the range %s has no row below its header", t.Range)67+ }68+ if err := s.checkTableRange(t.Range, ""); err != nil {69+ return err70+ }71+ t.Cols = columnNames(t.Cols, nil)72+ if len(t.Cols) != t.Range.To.Col-t.Range.From.Col+1 {73+ t.Cols = columnNames(s.rowTexts(Rect{From: t.Range.From, To: Addr{Col: t.Range.To.Col, Row: t.Range.From.Row}}), nil)74+ }75+ s.view.tables = append(s.view.tables, t)76+ return nil77+}@@ -8,15 +8,33 @@ require (
88 )99 1010 require (11+ github.com/andybalholm/brotli v1.1.1 // indirect1112 github.com/cockroachdb/apd/v3 v3.2.3 // indirect13+ github.com/dustin/go-humanize v1.0.1 // indirect14+ github.com/google/uuid v1.6.0 // indirect15+ github.com/klauspost/compress v1.17.9 // indirect16+ github.com/mattn/go-isatty v0.0.24 // indirect17+ github.com/ncruces/go-strftime v1.0.0 // indirect18+ github.com/parquet-go/bitpack v1.0.0 // indirect19+ github.com/parquet-go/jsonlite v1.0.0 // indirect20+ github.com/parquet-go/parquet-go v0.32.0 // indirect21+ github.com/pierrec/lz4/v4 v4.1.21 // indirect22+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect1223 github.com/richardlehane/mscfb v1.0.7 // indirect1324 github.com/richardlehane/msoleps v1.0.6 // indirect1425 github.com/tiendc/go-deepcopy v1.7.2 // indirect26+ github.com/twpayne/go-geom v1.6.1 // indirect1527 github.com/xuri/efp v0.0.1 // indirect1628 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect1729 golang.org/x/crypto v0.57.0 // indirect1830 golang.org/x/net v0.59.0 // indirect31+ golang.org/x/sys v0.48.0 // indirect1932 golang.org/x/text v0.42.0 // indirect33+ google.golang.org/protobuf v1.36.11 // indirect34+ modernc.org/libc v1.75.7 // indirect35+ modernc.org/mathutil v1.7.1 // indirect36+ modernc.org/memory v1.12.1 // indirect37+ modernc.org/sqlite v1.59.0 // indirect2038 )2139 2240 replace github.com/FelineStateMachine/012 => ../@@ -1,11 +1,47 @@
1+github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=2+github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=3+github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=4+github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=5+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=6+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=7+github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=8+github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=19 github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80=210 github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=311 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=412 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=5-github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=6-github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=13+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=14+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=15+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=16+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=17+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=18+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=19+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=20+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=21+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=22+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=23+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=24+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=25+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=26+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=27+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=28+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=29+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=30+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=31+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=32+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=33+github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=34+github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=35+github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=36+github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=37+github.com/parquet-go/parquet-go v0.32.0 h1:NWDqTUHfrCS4cJP/Fj2HlxvqsrVedWG3sayMkf+znzM=38+github.com/parquet-go/parquet-go v0.32.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=39+github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=40+github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=741 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=842 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=43+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=44+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=945 github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=1046 github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=1147 github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=@@ -14,19 +50,61 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
1450 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=1551 github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=1652 github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=53+github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=54+github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=1755 github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=1856 github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=1957 github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=2058 github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=2159 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=2260 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=61+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=62+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=2363 golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=2464 golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=2565 golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=2666 golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=67+golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=68+golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=2769 golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=2870 golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=71+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=72+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=73+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=74+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=2975 golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=3076 golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=77+golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=78+golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=79+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=80+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=3181 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=3282 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=83+modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=84+modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=85+modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=86+modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=87+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=88+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=89+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=90+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=91+modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=92+modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=93+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=94+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=95+modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=96+modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=97+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=98+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=99+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=100+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=101+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=102+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=103+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=104+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=105+modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=106+modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=107+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=108+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=109+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=110+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=@@ -0,0 +1,76 @@
1+package oracle2+3+import (4+ "context"5+ "path/filepath"6+ "testing"7+8+ "github.com/xuri/excelize/v2"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+)12+13+// tableFormulas read the table Sales (A1:C5: Region, Units, Amount) by14+// structured references, each in the cell it is written in. excel is15+// what Excel gives where excelize can't compute a structured reference16+// (it reads tables but its calculation engine doesn't resolve them); 01217+// is checked against it there, and against excelize elsewhere.18+var tableFormulas = []struct{ cell, formula, excel string }{19+ {"E1", "SUM(Sales[Amount])", "100"},20+ {"E2", "SUM(Sales[[Units]:[Amount]])", "113"},21+ {"E3", "ROWS(Sales[#All])", "5"},22+ {"E4", "ROWS(Sales)", "4"},23+ {"E5", "Sales[[#Headers],[Units]]", "Units"},24+ {"E6", `COUNTIF(Sales[Region],"North")`, "2"},25+ {"D3", "Sales[[#This Row],[Amount]]*2", "40"},26+ {"E7", "SUM(C2:C5)", ""},27+}28+29+// TestTablesFromExcel builds a workbook with a table in excelize, as30+// Excel would save it, and reads it with 012's XLSX reader: the table31+// comes in, and formulas reading it compute what Excel computes.32+func TestTablesFromExcel(t *testing.T) {33+ x := excelize.NewFile()34+ rows := [][]any{{"Region", "Units", "Amount"}, {"North", 2, 10}, {"South", 3, 20}, {"North", 1, 30}, {"East", 7, 40}}35+ for i, row := range rows {36+ cell, _ := excelize.CoordinatesToCellName(1, i+1)37+ if err := x.SetSheetRow("Sheet1", cell, &row); err != nil {38+ t.Fatal(err)39+ }40+ }41+ if err := x.AddTable("Sheet1", &excelize.Table{Range: "A1:C5", Name: "Sales"}); err != nil {42+ t.Fatal(err)43+ }44+ for _, tc := range tableFormulas {45+ if err := x.SetCellFormula("Sheet1", tc.cell, tc.formula); err != nil {46+ t.Fatal(err)47+ }48+ }49+ name := filepath.Join(t.TempDir(), "tables.xlsx")50+ if err := x.SaveAs(name); err != nil {51+ t.Fatal(err)52+ }53+ got, err := fileio.Import(context.Background(), name, fileio.Options{})54+ if err != nil {55+ t.Fatal(err)56+ }57+ s := got.Sheet58+ if tb, ok := s.TableAt(addr("B2")); !ok || tb.Name != "Sales" || tb.Range.String() != "A1:C5" {59+ t.Fatalf("table %+v", tb)60+ }61+ for _, tc := range tableFormulas {62+ ours := s.Value(addr(tc.cell))63+ if tc.excel != "" {64+ t.Logf("skip %s: excelize doesn't compute structured references", tc.formula)65+ if !agree(ours, tc.excel) {66+ t.Errorf("%s: 012 %s, Excel %s", tc.formula, show(ours), tc.excel)67+ }68+ continue69+ }70+ theirs, _ := x.CalcCellValue("Sheet1", tc.cell, excelize.Options{RawCellValue: true})71+ if !agree(ours, theirs) {72+ t.Errorf("%s: 012 %s, excelize %q", tc.formula, show(ours), theirs)73+ }74+ }75+ x.Close()76+}