An anchor goes out as an array formula over the cells it spills into (<f t="array" ref=...>) on a cell marked dynamic by cell metadata, with metadata.xml defining it as Excel writes it; the spilled cells hold their values. Formulas calling array functions are written so even when they compute one value, so Excel doesn't read them with @. New functions get their _xlfn. and _xlws. prefixes, names LET and LAMBDA bind get _xlpm., an ARRAYFORMULA around a whole formula is dropped, and functions only Sheets has (SORTN, FLATTEN, SPLIT, REGEXMATCH, REGEXEXTRACT, where Excel's returns the whole match), array literals holding references, and ARRAYFORMULA inside a formula go out as values. Reading an array formula back skips the values in the cells it spills into, so the formula spills again instead of being blocked by them. Co-Authored-By: Claude <noreply@anthropic.com>
- commit
- c47bcc32771595bab38eb23582f9643b12667a34
- parent
- 998955f3e73e10568ef00491239e6ffc370e419d
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 8 | +- |
| M | internal/ | 58 | ++++++++++-- |
| M | internal/ | 5 | +- |
| M | internal/ | 19 | +++- |
| A | internal/ | 124 | ++++++++++++++++++++++++++ |
| A | internal/ | 143 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 38 | +++++--- |
| M | internal/ | 32 | +++++++ |
| M | internal/ | 29 | ++++-- |
| M | internal/ | 9 | +- |
@@ -184,18 +184,20 @@ func (b *builder) boolean(a sheet.Addr, v bool, st sheet.Style) {
184184 185185 // formula stores a formula in 012's syntax (with the leading =), or,186186 // when it doesn't parse, the value it had in the file, stored by keep.187-func (b *builder) formula(a sheet.Addr, text string, f sheet.Format, st sheet.Style, keep func()) {187+// It reports whether the formula was stored.188+func (b *builder) formula(a sheet.Addr, text string, f sheet.Format, st sheet.Style, keep func()) bool {188189 if !b.fits(a) {189- return190+ return false190191 }191192 if _, err := sheet.Parse(text); err == nil && b.take(a) {192193 if b.s.Load(a, text, f, st) == nil {193- return194+ return true194195 }195196 b.cells--196197 b.rowCells = b.rowCells[:len(b.rowCells)-1]197198 }198199 b.kept(a, text, keep)200+ return false199201 }200202 201203 // kept stores a formula's value with keep, counting it for the notes.@@ -10,19 +10,51 @@ import (
1010 // #AND#). Excel files store formulas without the leading =, and prefix1111 // functions newer than Excel 2007 with _xlfn.1212 13-// xlfn lists 012's functions that Excel stores with the _xlfn. prefix.14-var xlfn = map[string]bool{15- "CONCAT": true, "DAYS": true, "IFNA": true, "IFS": true, "SWITCH": true,16- "TEXTJOIN": true, "XLOOKUP": true, "XOR": true,13+// xlfn gives the prefix Excel stores 012's functions newer than Excel14+// 2007 with.15+var xlfn = map[string]string{16+ "CONCAT": "_xlfn.", "DAYS": "_xlfn.", "IFNA": "_xlfn.", "IFS": "_xlfn.", "SWITCH": "_xlfn.",17+ "TEXTJOIN": "_xlfn.", "XLOOKUP": "_xlfn.", "XOR": "_xlfn.",18+ "FILTER": "_xlfn._xlws.", "SORT": "_xlfn._xlws.", "UNIQUE": "_xlfn.", "SEQUENCE": "_xlfn.",19+ "CHOOSECOLS": "_xlfn.", "CHOOSEROWS": "_xlfn.", "LET": "_xlfn.", "LAMBDA": "_xlfn.",20+ "MAP": "_xlfn.", "REDUCE": "_xlfn.", "SCAN": "_xlfn.", "BYROW": "_xlfn.", "BYCOL": "_xlfn.",21+ "MAKEARRAY": "_xlfn.", "REGEXREPLACE": "_xlfn.",22+}23+24+// sheetsOnly lists 012's functions, from Sheets, that Excel has nothing25+// the same as: formulas calling them are saved as values. (Excel's26+// REGEXEXTRACT returns the whole match where Sheets' returns the capture27+// group, and ARRAYFORMULA is dropped around a whole formula instead; see28+// xlsxarray.go.)29+var sheetsOnly = map[string]bool{30+ "SORTN": true, "FLATTEN": true, "SPLIT": true, "REGEXMATCH": true, "REGEXEXTRACT": true, "ARRAYFORMULA": true,1731 }1832 1933 // toExcelFormula translates a formula entry to Excel's syntax, or20-// reports false when Excel has no equivalent (JEV functions, 1-2-3's21-// #AND# operators), in which case the value is exported instead.34+// reports false when Excel has no equivalent (JEV functions, functions35+// only Sheets has, 1-2-3's #AND# operators), in which case the value is36+// exported instead.2237 //2338 // renamed maps the key (formula.SheetKey) of each sheet written under2439 // another name to that name: references to it are rewritten to name it.2540 func toExcelFormula(input string, renamed map[string]string) (string, bool) {41+ fx, _, ok := excelFormula(input, renamed)42+ return fx, ok43+}44+45+// excelFormula is toExcelFormula, and whether the formula computes an46+// array, for Excel to hold as a dynamic array formula (xlsxarray.go).47+func excelFormula(input string, renamed map[string]string) (string, bool, bool) {48+ input, arrays, ok := excelArrays(input)49+ if !ok {50+ return "", false, false51+ }52+ fx, ok := excelTokens(input, renamed)53+ return fx, arrays, ok54+}55+56+// excelTokens translates a formula's text to Excel's token by token.57+func excelTokens(input string, renamed map[string]string) (string, bool) {2658 src := strings.TrimPrefix(input, "=")2759 var b strings.Builder2860 for i := 0; i < len(src); {@@ -105,13 +137,15 @@ func excelName(b *strings.Builder, src string, i int, renamed map[string]string)
105137 }106138 call := k < len(src) && src[k] == '('107139 if call || at {140+ if prefixFold(name, "_xlpm.") > 0 {141+ b.WriteString(name) // a LAMBDA called by the name LET gave it142+ return j, true143+ }108144 name = strings.ToUpper(name)109- if strings.HasPrefix(name, "JEV.") {145+ if strings.HasPrefix(name, "JEV.") || sheetsOnly[name] {110146 return 0, false111147 }112- if xlfn[name] {113- name = "_xlfn." + name114- }148+ name = xlfn[name] + name115149 if at && !call {116150 name += "()" // @PI117151 }@@ -162,6 +196,10 @@ func fromExcelFormula(f string) string {
162196 i += p - 1163197 continue164198 }199+ if p := prefixFold(rest, "_xlpm."); p > 0 {200+ i += p - 1201+ continue202+ }165203 }166204 b.WriteByte(c)167205 }@@ -53,7 +53,8 @@ type SnapCell struct {
5353 Own sheet.Format // the cell's own format5454 Style sheet.Style5555 Formula bool56- Sheets []string // the sheets a formula names, as written56+ Sheets []string // the sheets a formula names, as written57+ Spill sheet.Rect // the cells a formula's array spills into, when it spills5758 }5859 5960 // Text is the cell as displayed, without a width limit.@@ -89,6 +90,7 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
8990 continue9091 }9192 c := s.Cell(a)93+ spill, _ := s.SpillArea(a)9294 snap.Cells[a] = SnapCell{9395 Input: c.Input,9496 Value: c.Value,@@ -97,6 +99,7 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
9799 Style: s.CellStyle(a),98100 Formula: c.IsFormula(),99101 Sheets: s.NamedSheets(a),102+ Spill: spill,100103 }101104 }102105 return snap@@ -71,7 +71,12 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
7171 if err := writePart(zw, "xl/styles.xml", w.styles.xml()); err != nil {7272 return err7373 }74- if err := writePackage(zw, names, hidden, active, w.definedNames(snap.Names), filterRanges(sheets, names)); err != nil {74+ if w.dynamic {75+ if err := writePart(zw, "xl/metadata.xml", metadataXML); err != nil {76+ return err77+ }78+ }79+ if err := writePackage(zw, names, hidden, active, w.definedNames(snap.Names), filterRanges(sheets, names), w.dynamic); err != nil {7580 return err7681 }7782 return zw.Close()@@ -140,8 +145,9 @@ func filterRanges(sheets []*Snapshot, names []string) []string {
140145 // writePackage writes the parts around the worksheets and styles: the141146 // workbook with its sheets, active tab, names (with each filter's range,142147 // 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 {148+// and the content types, with the cell metadata part when there are149+// dynamic array formulas (metadata).150+func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, defined [][2]string, filters []string, metadata bool) error {145151 var types, rels, book strings.Builder146152 types.WriteString(xmlHead + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +147153 `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +@@ -161,7 +167,12 @@ func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, def
161167 }162168 fmt.Fprintf(&book, `<sheet name="%s" sheetId="%s"%s r:id="rId%s"/>`, escapeXML(ws, true), n, state, n)163169 }164- fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="%s/styles" Target="styles.xml"/></Relationships>`, len(names)+1, officeRel)170+ fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="%s/styles" Target="styles.xml"/>`, len(names)+1, officeRel)171+ if metadata {172+ types.WriteString(`<Override PartName="/xl/metadata.xml" ContentType="` + mlType + `sheetMetadata+xml"/>`)173+ fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="%s/sheetMetadata" Target="metadata.xml"/>`, len(names)+2, officeRel)174+ }175+ rels.WriteString(`</Relationships>`)165176 types.WriteString(`</Types>`)166177 book.WriteString(`</sheets>`)167178 var dn strings.Builder@@ -0,0 +1,124 @@
1+package fileio2+3+import (4+ "github.com/FelineStateMachine/012/internal/formula"5+ "github.com/FelineStateMachine/012/internal/sheet"6+)7+8+// Array formulas in XLSX. Excel 365 keeps a formula that spills (a9+// dynamic array formula) as an array formula over the cells it spills10+// into, <f t="array" ref="B2:C9">, on a cell whose metadata (cm) marks11+// it dynamic, as metadata.xml defines; the cells it spills into hold12+// their values. Older Excels read it as a legacy array formula ({=...})13+// showing the same values. Formulas calling array functions are written14+// this way even when they compute one value, so Excel doesn't read them15+// with its implicit intersection (@).16+//17+// Names LET and LAMBDA bind are written with Excel's _xlpm. prefix, an18+// ARRAYFORMULA around a whole formula is dropped (a dynamic array formula19+// computes over arrays anyway), and array literals must hold constants,20+// as Excel's do, or the formula is saved as its value.21+22+// arrayFns are the functions that return arrays, in upper case.23+var arrayFns = map[string]bool{24+ "ARRAYFORMULA": true, "FILTER": true, "SORT": true, "SORTN": true, "UNIQUE": true, "SEQUENCE": true,25+ "TRANSPOSE": true, "FLATTEN": true, "CHOOSECOLS": true, "CHOOSEROWS": true, "LET": true, "LAMBDA": true,26+ "MAP": true, "SCAN": true, "BYROW": true, "BYCOL": true, "MAKEARRAY": true, "SPLIT": true,27+}28+29+// excelArrays rewrites a formula that may compute an array for Excel, as30+// above, and reports whether it does compute one (a dynamic array31+// formula), and false when Excel can't hold it.32+func excelArrays(input string) (string, bool, bool) {33+ if !mayHoldArrays(input) {34+ return input, false, true35+ }36+ n, err := sheet.Parse(input)37+ if err != nil {38+ return input, false, true // the text is translated as it is39+ }40+ arrays, ok := false, true41+ if c, isCall := n.(formula.Call); isCall && c.Fn.Signature().Name == "ARRAYFORMULA" {42+ n, arrays = c.Args[0], true43+ }44+ var walk func(n formula.Node)45+ walk = func(n formula.Node) {46+ switch n := n.(type) {47+ case formula.Call:48+ arrays = arrays || arrayFns[n.Fn.Signature().Name]49+ case formula.Array:50+ arrays = true51+ ok = ok && constants(n)52+ }53+ formula.EachChild(n, walk)54+ }55+ walk(n)56+ n, _ = formula.Rewrite(n, formula.Rewriter{Local: func(l formula.Local) formula.Node {57+ return formula.Name{Name: "_xlpm." + l.Name}58+ }})59+ return formula.Text(n), arrays, ok60+}61+62+// mayHoldArrays reports whether a formula's text may hold an array63+// literal or call an array function, so only those are parsed.64+func mayHoldArrays(input string) bool {65+ for i := 0; i < len(input); i++ {66+ c := input[i]67+ if c == '{' {68+ return true69+ }70+ if !isIdentByte(c) || i > 0 && isIdentByte(input[i-1]) {71+ continue72+ }73+ j := i74+ for j < len(input) && isIdentByte(input[j]) {75+ j++76+ }77+ if j < len(input) && input[j] == '(' && arrayFns[upperASCII(input[i:j])] {78+ return true79+ }80+ i = j - 181+ }82+ return false83+}84+85+func upperASCII(s string) string {86+ b := []byte(s)87+ for i, c := range b {88+ b[i] = upper(c)89+ }90+ return string(b)91+}92+93+// constants reports whether an array literal holds only constants.94+func constants(a formula.Array) bool {95+ for _, row := range a.Rows {96+ for _, e := range row {97+ if u, ok := e.(formula.Unary); ok && u.Op == "-" {98+ e = u.X99+ }100+ switch e.(type) {101+ case formula.Num, formula.Str, formula.Bool:102+ default:103+ return false104+ }105+ }106+ }107+ return true108+}109+110+// metadataXML defines cell metadata 1 as a dynamic array formula, as111+// Excel writes it.112+const metadataXML = xmlHead + `<metadata xmlns="` + sheetMain + `" xmlns:xda="http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray">` +113+ `<metadataTypes count="1"><metadataType name="XLDAPR" minSupportedVersion="120000" copy="1" pasteAll="1" pasteValues="1" merge="1" splitFirst="1" rowColShift="1" clearFormats="1" clearComments="1" assign="1" coerce="1" cellMeta="1"/></metadataTypes>` +114+ `<futureMetadata name="XLDAPR" count="1"><bk><extLst><ext uri="{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}"><xda:dynamicArrayProperties fDynamic="1" fCollapsed="0"/></ext></extLst></bk></futureMetadata>` +115+ `<cellMetadata count="1"><bk><rc t="1" v="0"/></bk></cellMetadata></metadata>`116+117+// arrayRef is the ref of a dynamic array formula at a: the cells it118+// spills into, or itself.119+func arrayRef(a sheet.Addr, spill sheet.Rect) string {120+ if spill == (sheet.Rect{}) || spill.From == spill.To {121+ return a.String()122+ }123+ return spill.From.String() + ":" + spill.To.String()124+}@@ -0,0 +1,143 @@
1+package fileio2+3+import (4+ "archive/zip"5+ "context"6+ "io"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ "github.com/xuri/excelize/v2"12+)13+14+func TestExcelArrayFormulas(t *testing.T) {15+ for _, tc := range []struct {16+ in, want string17+ arrays bool18+ ok bool19+ }{20+ {"=SEQUENCE(3)", "_xlfn.SEQUENCE(3)", true, true},21+ {"=FILTER(A1:A9, B1:B9>2)", "_xlfn._xlws.FILTER(A1:A9,B1:B9>2)", true, true},22+ {"=sort(unique(A:A))", "_xlfn._xlws.SORT(_xlfn.UNIQUE(A:A))", true, true},23+ {"=ARRAYFORMULA(A1:A3*2)", "A1:A3*2", true, true},24+ {"=SUM(ARRAYFORMULA(A1:A3*2))", "", false, false},25+ {"=LET(x, 2, x*3)", "_xlfn.LET(_xlpm.x,2,_xlpm.x*3)", true, true},26+ {"=LET(f, LAMBDA(v, v+1), f(2))", "_xlfn.LET(_xlpm.f,_xlfn.LAMBDA(_xlpm.v,_xlpm.v+1),_xlpm.f(2))", true, true},27+ {"=MAP(A1:A3, LAMBDA(v, v*2))", "_xlfn.MAP(A1:A3,_xlfn.LAMBDA(_xlpm.v,_xlpm.v*2))", true, true},28+ {"={1,-2;\"a\",TRUE}", `{1,-2;"a",TRUE}`, true, true},29+ {"={A1,B1}", "", false, false},30+ {"=SUM({1,2})", "SUM({1,2})", true, true},31+ {"=SORTN(A1:A9)", "", false, false},32+ {`=SPLIT(A1, ",")`, "", false, false},33+ {`=REGEXREPLACE(A1, "a+", "b")`, `_xlfn.REGEXREPLACE(A1, "a+", "b")`, false, true},34+ {`=REGEXMATCH(A1, "a")`, "", false, false},35+ {"=SUM(A1:A3*2)", "SUM(A1:A3*2)", false, true},36+ } {37+ got, arrays, ok := excelFormula(tc.in, nil)38+ if ok != tc.ok || ok && (got != tc.want || arrays != tc.arrays) {39+ t.Errorf("excelFormula(%q) = %q, %v, %v, want %q, %v, %v", tc.in, got, arrays, ok, tc.want, tc.arrays, tc.ok)40+ }41+ }42+ for in, want := range map[string]string{43+ "_xlfn._xlws.FILTER(A1:A9,B1:B9>2)": "=FILTER(A1:A9,B1:B9>2)",44+ "_xlfn.LET(_xlpm.x,2,_xlpm.x*3)": "=LET(x,2,x*3)",45+ "_xlfn.MAP(A1:A3,_xlfn.LAMBDA(_xlpm.v,1))": "=MAP(A1:A3,LAMBDA(v,1))",46+ } {47+ if got := fromExcelFormula(in); got != want {48+ t.Errorf("fromExcelFormula(%q) = %q, want %q", in, got, want)49+ }50+ }51+}52+53+// A spill goes out as Excel's dynamic array formula over the cells it54+// spills into, which hold its values, and comes back as the formula,55+// spilling again rather than blocked by its own values.56+func TestXLSXSpill(t *testing.T) {57+ src := build(t, map[string]string{"A1": "3", "A2": "1", "A3": "2", "C1": "=SORT(A1:A3)", "D1": "=SUM(A1:A3)", "E1": "=SPLIT(\"x,y\", \",\")"})58+ name := filepath.Join(t.TempDir(), "spill.xlsx")59+ res, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{})60+ if err != nil {61+ t.Fatal(err)62+ }63+ if len(res.Notes) != 1 || !strings.Contains(res.Notes[0], "e.g. E1") {64+ t.Errorf("notes %q", res.Notes)65+ }66+ parts := zipText(t, name)67+ ws := parts["xl/worksheets/sheet1.xml"]68+ for _, want := range []string{69+ `<c r="C1" cm="1"><f t="array" ref="C1:C3">_xlfn._xlws.SORT(A1:A3)</f><v>1</v></c>`,70+ `<c r="C2"><v>2</v></c>`, `<c r="C3"><v>3</v></c>`,71+ `<c r="D1"><f>SUM(A1:A3)</f><v>6</v></c>`,72+ `<c r="E1" t="inlineStr"><is><t xml:space="preserve">x</t></is></c>`,73+ } {74+ if !strings.Contains(ws, want) {75+ t.Errorf("sheet1.xml lacks %s:\n%s", want, ws)76+ }77+ }78+ if !strings.Contains(parts["xl/metadata.xml"], `fDynamic="1"`) ||79+ !strings.Contains(parts["[Content_Types].xml"], `/xl/metadata.xml`) ||80+ !strings.Contains(parts["xl/_rels/workbook.xml.rels"], `Target="metadata.xml"`) {81+ t.Error("the dynamic array metadata part is missing or not linked")82+ }83+ x, err := excelize.OpenFile(name)84+ if err != nil {85+ t.Fatal(err)86+ }87+ if v, _ := x.GetCellValue("Sheet1", "C3"); v != "3" {88+ t.Errorf("excelize reads C3 as %q", v)89+ }90+ x.Close()91+92+ got, err := Import(context.Background(), name, Options{})93+ if err != nil {94+ t.Fatal(err)95+ }96+ s := got.Sheet97+ if in := input(s, addr(t, "C1")); in != "=SORT(A1:A3)" {98+ t.Errorf("C1 read back as %q", in)99+ }100+ for cell, want := range map[string]string{"C1": "1", "C2": "2", "C3": "3"} {101+ if v := shown(s, addr(t, cell)); v != want {102+ t.Errorf("%s shows %q, want %q", cell, v, want)103+ }104+ }105+ if !s.Cell(addr(t, "C2")).Spilled() {106+ t.Error("C2 came back as a value, not spilled")107+ }108+}109+110+// A workbook without arrays has no metadata part.111+func TestXLSXNoArrays(t *testing.T) {112+ name := filepath.Join(t.TempDir(), "plain.xlsx")113+ if _, err := Export(context.Background(), name, XLSX, SnapBook(build(t, map[string]string{"A1": "=1+1"})), ExportOptions{}); err != nil {114+ t.Fatal(err)115+ }116+ if _, ok := zipText(t, name)["xl/metadata.xml"]; ok {117+ t.Error("metadata.xml written without dynamic arrays")118+ }119+}120+121+// zipText reads every part of a zip file.122+func zipText(t *testing.T, name string) map[string]string {123+ t.Helper()124+ zr, err := zip.OpenReader(name)125+ if err != nil {126+ t.Fatal(err)127+ }128+ defer zr.Close()129+ out := map[string]string{}130+ for _, f := range zr.File {131+ rc, err := f.Open()132+ if err != nil {133+ t.Fatal(err)134+ }135+ b, err := io.ReadAll(rc)136+ rc.Close()137+ if err != nil {138+ t.Fatal(err)139+ }140+ out[f.Name] = string(b)141+ }142+ return out143+}@@ -161,19 +161,11 @@ func (bk *xlsxBook) importRow(b *builder, r *xlsxSheetReader) error {
161161 j++162162 }163163 var c *xlsxCell164- id := 0165164 if j < len(cells) && cells[j].col == col {166165 c = &cells[j]167- id = c.style168166 }169- if id == 0 {170- id = row.style171- }172- if id == 0 {173- id = r.colStyle(col)174- }175- st := bk.styles.style(id)176- if c == nil || !c.kept() {167+ st := bk.styles.style(r.cellStyle(c, col))168+ if c == nil || !c.kept() || !c.hasF && r.spilled(a) {177169 b.put(a, "", st.format, st.style)178170 continue179171 }@@ -181,21 +173,37 @@ func (bk *xlsxBook) importRow(b *builder, r *xlsxSheetReader) error {
181173 if err != nil {182174 return err183175 }184- bk.importCell(b, a, c, f, st)176+ if bk.importCell(b, a, c, f, st) {177+ r.arrayFormula(a, c.f)178+ }185179 }186180 return nil187181 }188182 189-func (bk *xlsxBook) importCell(b *builder, a sheet.Addr, c *xlsxCell, formula string, st xlsxStyle) {183+// importCell stores a cell, reporting whether it stored a formula.184+// cellStyle is the cell format of the cell c (nil when it isn't written)185+// in column col of the current row: its own, else the row's, else the186+// column's.187+func (r *xlsxSheetReader) cellStyle(c *xlsxCell, col int) int {188+ if c != nil && c.style != 0 {189+ return c.style190+ }191+ if r.row.style != 0 {192+ return r.row.style193+ }194+ return r.colStyle(col)195+}196+197+func (bk *xlsxBook) importCell(b *builder, a sheet.Addr, c *xlsxCell, formula string, st xlsxStyle) bool {190198 if formula != "" {191- b.formula(a, fromExcelFormula(formula), st.format, st.style, func() { bk.keep(b, a, c, st) })192- return199+ return b.formula(a, fromExcelFormula(formula), st.format, st.style, func() { bk.keep(b, a, c, st) })193200 }194201 if c.value == "" {195202 b.put(a, "", st.format, st.style)196- return203+ return false197204 }198205 bk.keep(b, a, c, st)206+ return false199207 }200208 201209 // keep stores a cell's value: its type says whether it is a boolean or@@ -4,6 +4,7 @@ import (
44 "encoding/xml"55 "fmt"66 "io"7+ "slices"78 "strconv"89 "strings"910 @@ -59,6 +60,37 @@ type xlsxSheetReader struct {
5960 row xlsxRowData6061 shared map[int]sharedFormula6162 buf []byte63+ // arrays are the cells array formulas read so far spill into, below64+ // or right of them, whose values the formulas compute again.65+ arrays []sheet.Rect66+}67+68+// arrayFormula notes the cells the array formula f at a spills into.69+func (r *xlsxSheetReader) arrayFormula(a sheet.Addr, f cellFormula) {70+ if f.typ != "array" {71+ return72+ }73+ from, to, ok := strings.Cut(strings.ReplaceAll(f.ref, "$", ""), ":")74+ if !ok {75+ return76+ }77+ c0, r0, ok0 := parseCellRef(from)78+ c1, r1, ok1 := parseCellRef(to)79+ if ok0 && ok1 && c0 == a.Col+1 && r0 == a.Row+1 && c1 >= c0 && r1 >= r0 {80+ r.arrays = append(r.arrays, sheet.Rect{From: a, To: sheet.Addr{Col: c1 - 1, Row: r1 - 1}})81+ }82+}83+84+// spilled reports whether the cell at a is one an array formula read85+// before spills into, forgetting the formulas whose cells are all past.86+func (r *xlsxSheetReader) spilled(a sheet.Addr) bool {87+ r.arrays = slices.DeleteFunc(r.arrays, func(x sheet.Rect) bool { return x.To.Row < a.Row })88+ for _, x := range r.arrays {89+ if x.Contains(a) {90+ return true91+ }92+ }93+ return false6294 }6395 6496 // xlsxRowData is the row just read.@@ -32,6 +32,7 @@ type xlsxWriter struct {
3232 missingSheet string // the sheet missing's example names3333 known map[string]bool // keys of the sheets written3434 renamed map[string]string // the names of sheets written under another, by key35+ dynamic bool // a dynamic array formula was written: see xlsxarray.go3536 }3637 3738 // valueCount counts formulas written as values, keeping the first one's@@ -107,7 +108,7 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
107108 // or naming a sheet that isn't written, is written as its value alone,108109 // and counted.109110 func (w *xlsxWriter) cell(b []byte, ws string, a sheet.Addr, c SnapCell) []byte {110- fx := w.formula(ws, a, c)111+ fx, arrays := w.formula(ws, a, c)111112 b = append(b, `<c r="`...)112113 b = append(b, excelColName(a.Col+1)...)113114 b = strconv.AppendInt(b, int64(a.Row+1), 10)@@ -125,11 +126,22 @@ func (w *xlsxWriter) cell(b []byte, ws string, a sheet.Addr, c SnapCell) []byte
125126 b = append(b, typ...)126127 b = append(b, '"')127128 }129+ if fx != "" && arrays {130+ b = append(b, ` cm="1"`...)131+ w.dynamic = true132+ }128133 if fx == "" && v == "" {129134 return append(b, "/>"...)130135 }131136 b = append(b, '>')132- if fx != "" {137+ switch {138+ case fx != "" && arrays:139+ b = append(b, `<f t="array" ref="`...)140+ b = append(b, arrayRef(a, c.Spill)...)141+ b = append(b, `">`...)142+ b = appendEscaped(b, fx, false)143+ b = append(b, "</f>"...)144+ case fx != "":133145 b = append(b, "<f>"...)134146 b = appendEscaped(b, fx, false)135147 b = append(b, "</f>"...)@@ -148,23 +160,24 @@ func (w *xlsxWriter) cell(b []byte, ws string, a sheet.Addr, c SnapCell) []byte
148160 }149161 150162 // formula is c's formula in Excel's syntax, or "" when it has none or151-// is written as its value, which it counts.152-func (w *xlsxWriter) formula(ws string, a sheet.Addr, c SnapCell) string {163+// is written as its value, which it counts, and whether it is a dynamic164+// array formula: one that spills or calls array functions.165+func (w *xlsxWriter) formula(ws string, a sheet.Addr, c SnapCell) (string, bool) {153166 if !c.Formula {154- return ""167+ return "", false155168 }156169 if name, ok := w.unknownSheet(c); ok {157170 if w.missing.n == 0 {158171 w.missingSheet = name159172 }160173 w.missing.add(w.multi, ws, a)161- return ""174+ return "", false162175 }163- fx, ok := toExcelFormula(c.Input, w.renamed)176+ fx, arrays, ok := excelFormula(c.Input, w.renamed)164177 if !ok {165178 w.values.add(w.multi, ws, a)166179 }167- return fx180+ return fx, arrays || c.Spill != (sheet.Rect{})168181 }169182 170183 // cellValue is a cell's value as Excel stores it: the type attribute,@@ -11,13 +11,14 @@ type Rewriter struct {
1111 Ref func(Ref) Node1212 Range func(Range) Node1313 Name func(Name) Node14+ Local func(Local) Node1415 }1516 1617 // Rewrite returns n with its references mapped, and whether anything1718 // changed. Unchanged subtrees are shared.1819 func Rewrite(n Node, rw Rewriter) (Node, bool) {1920 switch n := n.(type) {20- case Ref, Range, Name:21+ case Ref, Range, Name, Local:2122 out := rw.leaf(n)2223 return out, out != n2324 case Unary:@@ -60,7 +61,7 @@ func Rewrite(n Node, rw Rewriter) (Node, bool) {
6061 return n, false6162 }6263 63-// leaf maps a reference, range or name.64+// leaf maps a reference, range, name or bound name.6465 func (rw Rewriter) leaf(n Node) Node {6566 switch n := n.(type) {6667 case Ref:@@ -75,6 +76,10 @@ func (rw Rewriter) leaf(n Node) Node {
7576 if rw.Name != nil {7677 return rw.Name(n)7778 }79+ case Local:80+ if rw.Local != nil {81+ return rw.Local(n)82+ }7883 }7984 return n8085 }