cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Write spills to XLSX as Excel's dynamic array formulas

Dami committed c47bcc3
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>
10 files changed, +422 -43
Minternal/fileio/builder.go8+-
Minternal/fileio/formula.go58++++++++++--
Minternal/fileio/snapshot.go5+-
Minternal/fileio/xlsx.go19+++-
Ainternal/fileio/xlsxarray.go124++++++++++++++++++++++++++
Ainternal/fileio/xlsxarray_test.go143++++++++++++++++++++++++++++++
Minternal/fileio/xlsximport.go38+++++---
Minternal/fileio/xlsxsheet.go32+++++++
Minternal/fileio/xlsxwrite.go29++++--
Minternal/formula/rewrite.go9+-
M internal/fileio/builder.go +5 -3
M internal/fileio/formula.go +48 -10
@@ -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 }
M internal/fileio/snapshot.go +4 -1
M internal/fileio/xlsx.go +15 -4
@@ -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
A internal/fileio/xlsxarray.go +124 -0
@@ -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+}
A internal/fileio/xlsxarray_test.go +143 -0
@@ -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+}
M internal/fileio/xlsximport.go +23 -15
@@ -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
M internal/fileio/xlsxsheet.go +32 -0
M internal/fileio/xlsxwrite.go +21 -8
@@ -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,
M internal/formula/rewrite.go +7 -2