A sheet can hold a pivot over a range of another sheet: rows and columns grouped by field values, values summarized (SUM, COUNTA, COUNT, COUNTUNIQUE, AVERAGE, MAX, MIN, shown as is or as a share), filters, totals and per-field sort. The results are derived cells the engine owns: recomputed when the source changes, refused by Set, copied as values, and saved as the definition in a version 5 file. Definition changes are undo steps; OnPivot reports each recomputation.
- commit
- 901e837a5e952f9058fbe6edc4c1e869b3083643
- parent
- 9a70f51870c8a0b2c3fc005c4c3d9d0d9c341482
- author
- Dami <dami.etoile@gmail.com>
| M | cmd/ | 4 | + |
| M | internal/ | 3 | + |
| M | internal/ | 39 | ++- |
| M | internal/ | 2 | +- |
| M | internal/ | 15 | +- |
| M | internal/ | 3 | +- |
| A | internal/ | 377 | +++++++++++++++++++++++++ |
| A | internal/ | 445 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 436 | +++++++++++++++++++++++++++++ |
| A | internal/ | 151 | ++++++++++ |
| A | internal/ | 416 | ++++++++++++++++++++++++++++ |
| M | internal/ | 6 | + |
| M | internal/ | 15 | +- |
| M | internal/ | 6 | + |
@@ -57,5 +57,9 @@ func startTelemetry(c telemetry.Config) (func() error, error) {
5757 slog.Bool("full", i.Full), slog.Int("evaluated", i.Evaluated), slog.Int("cells", i.Cells),5858 slog.Int("volatile", i.Volatile), slog.Bool("circular", i.Circular))5959 }60+ sheet.OnPivot = func(i sheet.PivotInfo) {61+ telemetry.Event("pivot", i.Duration,62+ slog.Int("records", i.Records), slog.Int("groups", i.Groups), slog.Int("cells", i.Cells), slog.Bool("failed", i.Failed))63+ }6064 return stop, nil6165 }@@ -31,6 +31,9 @@ func (s *Sheet) explain(a Addr, path []loc) string {
3131 here := loc{s, a}3232 c := s.cells.get(a)3333 v := s.Value(a)34+ if c != nil && c.derived && v.Kind == Error && s.pivot.err != "" {35+ return s.pivot.err36+ }3437 if c == nil || v.Kind != Error || !c.IsFormula() {3538 return ""3639 }@@ -20,7 +20,9 @@ const FileExt = ".012"
2020 // Version 4 holds several sheets; a workbook of one sheet whose formulas2121 // name no sheet is still written as version 2 or 3, its sheet's name in2222 // a "name" field that earlier builds ignore.23-const fileVersion = 423+// Version 5 is version 4 with pivot tables; a workbook without one is24+// still written as version 4.25+const fileVersion = 52426 2527 // The file is JSON with one entry per cell, keyed by address. A cell2628 // without formatting is just its input, as in version 1; a formatted cell@@ -58,6 +60,7 @@ type fileSheet struct {
5860 fileView5961 Cells map[string]json.RawMessage `json:"cells"`6062 Charts []fileChart `json:"charts,omitempty"`63+ Pivot *filePivot `json:"pivot,omitempty"` // version 56164 }6265 6366 // fileChart is a chart, one per line after the cells:@@ -188,7 +191,11 @@ func (w *Workbook) Write(out io.Writer) error {
188191 }189192 b.WriteString("\n}\n")190193 } else {191- fmt.Fprintf(&b, "{\n \"version\": %d,\n", fileVersion)194+ version := 4195+ if w.hasPivots() {196+ version = fileVersion197+ }198+ fmt.Fprintf(&b, "{\n \"version\": %d,\n", version)192199 if head := w.headLines(); head != "" {193200 b.WriteString(" " + head + ",\n")194201 }@@ -215,7 +222,7 @@ func (w *Workbook) Write(out io.Writer) error {
215222 // single reports whether the workbook fits the single-sheet format of216223 // versions 2 and 3: one sheet, and no formula naming a sheet.217224 func (w *Workbook) single() bool {218- return len(w.sheets) == 1 && len(w.crossUsers) == 0225+ return len(w.sheets) == 1 && len(w.crossUsers) == 0 && !w.hasPivots()219226 }220227 221228 func jsonString(s string) string {@@ -275,8 +282,10 @@ func (s *Sheet) writeBody(b *bytes.Buffer, indent, names string) error {
275282 return err276283 }277284 addrs := make([]Addr, 0, s.cells.len())278- for a := range s.cells.all() {279- addrs = append(addrs, a)285+ for a, c := range s.cells.all() {286+ if !c.derived { // a pivot's results are computed, not saved287+ addrs = append(addrs, a)288+ }280289 }281290 sortAddrs(addrs)282291 b.WriteString(indent + `"cells": {`)@@ -294,6 +303,12 @@ func (s *Sheet) writeBody(b *bytes.Buffer, indent, names string) error {
294303 b.WriteString("\n" + indent)295304 }296305 b.WriteString("}")306+ return s.writeObjects(b, indent)307+}308+309+// writeObjects writes the fields after the cells: the charts, one per310+// line, and the pivot table's definition.311+func (s *Sheet) writeObjects(b *bytes.Buffer, indent string) error {297312 if len(s.charts) > 0 {298313 b.WriteString(",\n" + indent + `"charts": [`)299314 for i, c := range s.charts {@@ -308,6 +323,13 @@ func (s *Sheet) writeBody(b *bytes.Buffer, indent, names string) error {
308323 }309324 b.WriteString("\n" + indent + "]")310325 }326+ if p := s.pivot.def; p != nil {327+ raw, err := encodePivot(p)328+ if err != nil {329+ return err330+ }331+ fmt.Fprintf(b, ",\n%s\"pivot\": %s", indent, raw)332+ }311333 return nil312334 }313335 @@ -429,5 +451,12 @@ func (s *Sheet) read(f fileSheet, version int) error {
429451 }430452 s.charts = append(s.charts, c)431453 }454+ if f.Pivot != nil {455+ p, err := decodePivot(*f.Pivot)456+ if err != nil {457+ return err458+ }459+ s.pivot = pivotState{def: p, stale: true}460+ }432461 return s.readView(f.fileView)433462 }@@ -86,7 +86,7 @@ func (s *Sheet) Replace(a Addr, query, repl string, o FindOptions) (bool, error)
8686 8787 func (s *Sheet) replace(a Addr, f *finder, repl string) (bool, error) {8888 c := s.cells.get(a)89- if c == nil || (c.IsFormula() && !f.o.InFormulas) {89+ if c == nil || c.derived || (c.IsFormula() && !f.o.InFormulas) {9090 return false, nil9191 }9292 old := f.text(c)@@ -44,6 +44,8 @@ type step struct {
4444 views map[*Sheet]*viewState4545 // charts holds each touched sheet's charts before the step.4646 charts map[*Sheet][]Chart47+ // pivots holds each touched sheet's pivot definition before the step.48+ pivots map[*Sheet]*Pivot4749 // sheets is the sheet list before the step, when it changed.4850 sheets *sheetList4951 // decimal is the arithmetic setting before the step, when it changed.@@ -64,7 +66,7 @@ type sheetList struct {
6466 6567 func newStep(label string, s *Sheet, focus Rect) *step {6668 return &step{label: label, sheet: s, focus: focus, cells: map[loc]*Cell{}, widths: map[colKey]int{},67- names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}}69+ names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}}6870 }6971 7072 func (st *step) empty() bool {@@ -73,7 +75,7 @@ func (st *step) empty() bool {
7375 7476 // widthOnly reports whether the step changed nothing but column widths.7577 func (st *step) widthOnly() bool {76- return len(st.cells) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && st.sheets == nil && st.decimal == nil78+ return len(st.cells) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && st.sheets == nil && st.decimal == nil7779 }7880 7981 func (c *Cell) clone() *Cell {@@ -286,6 +288,11 @@ func (w *Workbook) dropUnchanged(st *step) {
286288 delete(st.views, s)287289 }288290 }291+ for s, p := range st.pivots {292+ if samePivot(p, s.pivot.def) {293+ delete(st.pivots, s)294+ }295+ }289296 if st.sheets != nil && st.sheets.equal(w.sheetList()) {290297 st.sheets = nil291298 }@@ -403,6 +410,10 @@ func (w *Workbook) swap(from, to *[]*step) (Change, bool) {
403410 inv.charts[s] = s.charts404411 s.charts = slices.Clone(charts)405412 }413+ for s, p := range st.pivots {414+ inv.pivots[s] = s.pivot.def415+ s.pivot.def, s.pivot.stale = p, true416+ }406417 if st.decimal != nil {407418 cur := w.decimal408419 inv.decimal, w.decimal = &cur, *st.decimal@@ -124,6 +124,7 @@ func (s *Sheet) restructure(rows bool, sp formula.Span) {
124124 s.shiftWidths(sp)125125 }126126 s.shiftView(rows, sp)127+ s.shiftPivots(rows, sp)127128 })128129 }129130 @@ -157,7 +158,7 @@ type Clip struct {
157158 func (s *Sheet) Copy(r Rect) *Clip {158159 c := &Clip{Src: r, cells: map[Addr]*Cell{}}159160 for _, a := range s.cellsIn(r) {160- c.cells[Addr{Col: a.Col - r.From.Col, Row: a.Row - r.From.Row}] = s.cells.get(a).clone()161+ c.cells[Addr{Col: a.Col - r.From.Col, Row: a.Row - r.From.Row}] = s.cells.get(a).plain()161162 }162163 return c163164 }@@ -0,0 +1,377 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "reflect"7+ "slices"8+9+ "github.com/FelineStateMachine/012/internal/formula"10+)11+12+// A pivot table summarizes a range of another sheet, as Sheets' Insert >13+// Pivot table: rows grouped by the values of some columns (Rows), spread14+// across others (Columns), with values summarized per group (Values), and15+// rows left out by Filters. It lives on a sheet of its own, starting at16+// A1. The engine owns its results: they are derived cells, recomputed17+// whenever the source's cells change, never saved (the file keeps the18+// definition) and never edited by hand. A sheet has at most one pivot.19+type Pivot struct {20+ // Source names the sheet the data is on. Like a reference written21+ // with a sheet name, it follows renames, and while no sheet has the22+ // name the pivot shows #REF!.23+ Source string24+ // Range is the data on the source sheet; its first row holds the25+ // headers, which name the fields.26+ Range Rect27+ Rows []PivotGroup28+ Columns []PivotGroup29+ Values []PivotValue30+ Filters []PivotFilter31+ // RowTotals adds a Grand Total row, and a subtotal row after each32+ // outer group when there are several row groups. ColumnTotals adds a33+ // Grand Total column when there are column groups.34+ RowTotals, ColumnTotals bool35+ // Lost is set when the source range was deleted.36+ Lost bool37+}38+39+// PivotGroup is a column of the source whose distinct values become rows40+// or columns of the pivot.41+type PivotGroup struct {42+ Col int // column on the source sheet43+ Desc bool // Z to A, or largest first44+ // SortBy orders the groups by their label (0) or by the grand total45+ // of Values[SortBy-1].46+ SortBy int47+}48+49+// PivotValue is a column of the source summarized per group.50+type PivotValue struct {51+ Col int52+ Summarize Summarize53+ ShowAs ShowAs54+ Name string // the header; "" for Sheets' "SUM of Sales"55+}56+57+// PivotFilter leaves out the source rows whose value in Col doesn't meet58+// Criteria, as a filter's column does.59+type PivotFilter struct {60+ Col int61+ Criteria Criteria62+}63+64+// ShowAs is how a summarized value shows: as itself, or as a share of a65+// total, as Sheets' "Show as".66+type ShowAs uint867+68+const (69+ ShowValue ShowAs = iota70+ ShowPctRow71+ ShowPctColumn72+ ShowPctTotal73+ numShowAs74+)75+76+var showAsNames = [numShowAs]string{"", "percent_of_row", "percent_of_column", "percent_of_total"}77+var showAsTitles = [numShowAs]string{"Default", "% of row", "% of column", "% of grand total"}78+79+// ShowAsList lists the choices in Sheets' order.80+func ShowAsList() []ShowAs { return []ShowAs{ShowValue, ShowPctRow, ShowPctColumn, ShowPctTotal} }81+82+func (s ShowAs) String() string { return showAsNames[s] }83+func (s ShowAs) Title() string { return showAsTitles[s] }84+85+// ParseShowAs is the inverse of ShowAs.String.86+func ParseShowAs(name string) (ShowAs, bool) {87+ i := slices.Index(showAsNames[:], name)88+ return ShowAs(max(i, 0)), i >= 089+}90+91+// Errors of pivot tables.92+var (93+ // ErrPivotEdit is what editing a pivot table's results says, as94+ // Sheets refuses to.95+ ErrPivotEdit = errors.New("Pivot table results can't be edited: change the pivot with Data > Edit pivot table")96+ errPivotSelf = errors.New("A pivot table can't summarize its own sheet")97+ errPivotLoop = errors.New("A pivot table can't summarize a pivot table that reads it")98+)99+100+// pivotState is what a sheet keeps about its pivot. def is the definition101+// (in the undo history and the file, never modified in place); the rest102+// is derived from it and the source.103+type pivotState struct {104+ def *Pivot105+ stale bool // recompute at the end of the current change106+ out Rect // what the last result covered, from A1107+ err string // why the pivot shows #REF!, or ""108+}109+110+// clone returns a deep copy, so a stored pivot never shares slices.111+func (p *Pivot) clone() *Pivot {112+ if p == nil {113+ return nil114+ }115+ cp := *p116+ cp.Rows, cp.Columns, cp.Values = slices.Clone(p.Rows), slices.Clone(p.Columns), slices.Clone(p.Values)117+ cp.Filters = make([]PivotFilter, len(p.Filters))118+ for i, f := range p.Filters {119+ f.Criteria.Hidden = slices.Clone(f.Criteria.Hidden)120+ cp.Filters[i] = f121+ }122+ if len(p.Filters) == 0 {123+ cp.Filters = nil124+ }125+ return &cp126+}127+128+func samePivot(a, b *Pivot) bool {129+ if a == nil || b == nil {130+ return a == b131+ }132+ return reflect.DeepEqual(a.clone(), b.clone())133+}134+135+// Pivot returns a copy of the sheet's pivot table, and false if it has136+// none.137+func (s *Sheet) Pivot() (Pivot, bool) {138+ if s.pivot.def == nil {139+ return Pivot{}, false140+ }141+ return *s.pivot.def.clone(), true142+}143+144+// PivotRange returns the cells the pivot's results cover, from A1 (at145+// least A1, even while it shows nothing), and false without a pivot.146+func (s *Sheet) PivotRange() (Rect, bool) {147+ if s.pivot.def == nil {148+ return Rect{}, false149+ }150+ return s.pivot.out, true151+}152+153+// InPivot reports whether r overlaps the pivot table's results, which154+// can't be edited.155+func (s *Sheet) InPivot(r Rect) bool {156+ out, ok := s.PivotRange()157+ return ok && out.From.Col <= r.To.Col && r.From.Col <= out.To.Col &&158+ out.From.Row <= r.To.Row && r.From.Row <= out.To.Row159+}160+161+// PivotError says why the pivot shows #REF!, or "" when it doesn't.162+func (s *Sheet) PivotError() string { return s.pivot.err }163+164+// PivotSource returns the sheet the pivot reads, or nil when no sheet has165+// its name.166+func (s *Sheet) PivotSource() *Sheet {167+ if s.pivot.def == nil {168+ return nil169+ }170+ return s.wb.Lookup(s.pivot.def.Source)171+}172+173+// NextPivotName is the name Sheets gives a new pivot sheet: Pivot Table 1,174+// then 2 and so on.175+func (w *Workbook) NextPivotName() string {176+ for n := 1; ; n++ {177+ if name := fmt.Sprintf("Pivot Table %d", n); w.Lookup(name) == nil {178+ return name179+ }180+ }181+}182+183+// CreatePivot adds a sheet named name ("" for the next Pivot Table N)184+// right after src, holding p over r on src, as one undo step.185+func (w *Workbook) CreatePivot(src *Sheet, r Rect, name string, p Pivot) (*Sheet, error) {186+ if name == "" {187+ name = w.NextPivotName()188+ }189+ name = w.freeName(name)190+ if err := w.checkName(nil, name); err != nil {191+ return nil, err192+ }193+ p.Source, p.Range = src.name, r194+ s := w.newSheet(name)195+ w.change(s, "create "+name, Rect{}, func() {196+ w.recordSheets()197+ w.insert(s, w.Index(src)+1)198+ s.pivot.def = p.clone()199+ s.pivot.stale = true200+ s.pivot.out = Rect{}201+ })202+ return s, nil203+}204+205+// SetPivot replaces the sheet's pivot definition as one undo step,206+// labelled label ("edit pivot table" when empty).207+func (s *Sheet) SetPivot(p Pivot, label string) error {208+ if err := s.checkPivot(p); err != nil {209+ return err210+ }211+ if samePivot(&p, s.pivot.def) {212+ return nil213+ }214+ if label == "" {215+ label = "edit pivot table"216+ }217+ s.change(label, Rect{}, func() { s.putPivot(p.clone()) })218+ return nil219+}220+221+// checkPivot refuses a pivot reading its own sheet, or a pivot whose222+// results it reads.223+func (s *Sheet) checkPivot(p Pivot) error {224+ name := p.Source225+ for range len(s.wb.sheets) + 1 {226+ src := s.wb.Lookup(name)227+ switch {228+ case src == nil || src.pivot.def == nil:229+ return nil230+ case src == s && name == p.Source:231+ return errPivotSelf232+ case src == s:233+ return errPivotLoop234+ }235+ name = src.pivot.def.Source236+ }237+ return errPivotLoop238+}239+240+// putPivot stores a pivot definition (nil for none), recording the old one241+// for undo, and has it recomputed.242+func (s *Sheet) putPivot(p *Pivot) {243+ s.recordPivot()244+ s.pivot.def = p245+ s.pivot.stale = true246+}247+248+// recordPivot saves the sheet's pivot before its first change in the249+// open step.250+func (s *Sheet) recordPivot() {251+ if st := s.wb.hist.open; st != nil {252+ if _, seen := st.pivots[s]; !seen {253+ st.pivots[s] = s.pivot.def254+ }255+ }256+}257+258+// pivotsOn returns the sheets whose pivot reads src.259+func (w *Workbook) pivotsOn(src *Sheet) []*Sheet {260+ var out []*Sheet261+ for _, s := range w.sheets {262+ if p := s.pivot.def; p != nil && w.Lookup(p.Source) == src {263+ out = append(out, s)264+ }265+ }266+ return out267+}268+269+// renamePivots points pivots reading the sheet with key old at name.270+func (w *Workbook) renamePivots(old, name string) {271+ for _, s := range w.sheets {272+ if p := s.pivot.def; p != nil && formula.SheetKey(p.Source) == old {273+ np := p.clone()274+ np.Source = name275+ s.putPivot(np)276+ }277+ }278+}279+280+// shiftPivots keeps the pivots reading s in step with rows or columns281+// inserted or deleted on it: the range moves or resizes like a range282+// reference, fields on deleted columns go, and a pivot whose whole range283+// was deleted shows #REF!.284+func (s *Sheet) shiftPivots(rows bool, sp formula.Span) {285+ cell, rng := formula.AxisMaps(rows, sp)286+ for _, t := range s.wb.pivotsOn(s) {287+ p := t.pivot.def.clone()288+ if r, ok := rng(p.Range); ok && !p.Lost {289+ p.Range = r290+ } else {291+ p.Lost = true292+ }293+ if !rows {294+ col := func(c int) (int, bool) {295+ to, ok := cell(Addr{Col: c})296+ return to.Col, ok297+ }298+ p.Rows, p.Columns = shiftGroups(p.Rows, col), shiftGroups(p.Columns, col)299+ p.Values = shiftCols(p.Values, func(v *PivotValue) *int { return &v.Col }, col)300+ p.Filters = shiftCols(p.Filters, func(f *PivotFilter) *int { return &f.Col }, col)301+ }302+ if !samePivot(p, t.pivot.def) {303+ t.putPivot(p)304+ }305+ }306+}307+308+func shiftGroups(gs []PivotGroup, col func(int) (int, bool)) []PivotGroup {309+ return shiftCols(gs, func(g *PivotGroup) *int { return &g.Col }, col)310+}311+312+// shiftCols moves the column of each item, dropping those on deleted313+// columns.314+func shiftCols[T any](items []T, at func(*T) *int, col func(int) (int, bool)) []T {315+ var out []T316+ for _, it := range items {317+ c := at(&it)318+ if to, ok := col(*c); ok {319+ *c = to320+ out = append(out, it)321+ }322+ }323+ return out324+}325+326+// FieldName is the header of column col in the pivot's source, or327+// "Column B" when the header is blank.328+func (w *Workbook) FieldName(p Pivot, col int) string {329+ if src := w.Lookup(p.Source); src != nil && !p.Lost {330+ if h := src.displayText(Addr{Col: col, Row: p.Range.From.Row}); h != "" {331+ return h332+ }333+ }334+ return "Column " + ColName(col)335+}336+337+// ValueTitle is the header of a value: its name, or Sheets' "SUM of338+// Sales".339+func (w *Workbook) ValueTitle(p Pivot, v PivotValue) string {340+ if v.Name != "" {341+ return v.Name342+ }343+ return v.Summarize.Title() + " of " + w.FieldName(p, v.Col)344+}345+346+// DefaultSummarize is how a new value summarizes column col, as in347+// Sheets: SUM when the column holds a number, COUNTA otherwise.348+func (w *Workbook) DefaultSummarize(p Pivot, col int) Summarize {349+ if src := w.Lookup(p.Source); src != nil {350+ for row := p.Range.From.Row + 1; row <= p.Range.To.Row; row++ {351+ if src.Value(Addr{Col: col, Row: row}).Kind == Number {352+ return SumBy353+ }354+ }355+ }356+ return CountABy357+}358+359+// NewPivot starts a pivot over r on src with nothing chosen yet and both360+// grand totals on, as Sheets' new pivot tables.361+func NewPivot(src *Sheet, r Rect) Pivot {362+ return Pivot{Source: src.name, Range: r, RowTotals: true, ColumnTotals: true}363+}364+365+// FrequencyPivot is a frequency table of column col of r: each distinct366+// value with how many rows have it and their share, most frequent first,367+// as VisiData's Shift+F. It is a pivot like any other; it counts rows368+// rather than values, so blanks are counted too.369+func FrequencyPivot(src *Sheet, r Rect, col int) Pivot {370+ p := NewPivot(src, r)371+ p.Rows = []PivotGroup{{Col: col, Desc: true, SortBy: 1}}372+ p.Values = []PivotValue{373+ {Col: col, Summarize: CountRowsBy, Name: "Count"},374+ {Col: col, Summarize: CountRowsBy, ShowAs: ShowPctTotal, Name: "Percent"},375+ }376+ return p377+}@@ -0,0 +1,445 @@
1+package sheet2+3+import (4+ "bytes"5+ "reflect"6+ "strings"7+ "testing"8+)9+10+// sales is a small table: region, product, date, units, price.11+func sales(t *testing.T) *Workbook {12+ t.Helper()13+ return bookOf(t, page{"Sales", map[string]string{14+ "A1": "Region", "B1": "Product", "C1": "Date", "D1": "Units", "E1": "Price",15+ "A2": "East", "B2": "Pens", "C2": "1/5/2026", "D2": "10", "E2": "$2.50",16+ "A3": "West", "B3": "Ink", "C3": "1/5/2026", "D3": "4", "E3": "$9.00",17+ "A4": "east", "B4": "Ink", "C4": "2/9/2026", "D4": "6", "E4": "$9.00",18+ "A5": "North", "B5": "Pens", "C5": "2/9/2026", "D5": "", "E5": "$2.50",19+ "A6": "West", "B6": "Pens", "C6": "1/5/2026", "D6": "3", "E6": "$2.75",20+ }})21+}22+23+// pivotGrid is what a pivot sheet shows, row by row, cells separated by24+// |, trailing blanks dropped.25+func pivotGrid(s *Sheet) []string {26+ out, ok := s.PivotRange()27+ if !ok {28+ return nil29+ }30+ var rows []string31+ for row := 0; row <= out.To.Row; row++ {32+ var cells []string33+ for col := 0; col <= out.To.Col; col++ {34+ cells = append(cells, s.ShownText(Addr{Col: col, Row: row}))35+ }36+ rows = append(rows, strings.TrimRight(strings.Join(cells, "|"), "|"))37+ }38+ return rows39+}40+41+func checkGrid(t *testing.T, s *Sheet, want ...string) {42+ t.Helper()43+ if got := pivotGrid(s); !reflect.DeepEqual(got, want) {44+ t.Errorf("pivot shows\n %s\nwant\n %s\n(error %q)", strings.Join(got, "\n "), strings.Join(want, "\n "), s.PivotError())45+ }46+}47+48+func newPivot(t *testing.T, w *Workbook, p func(*Pivot)) *Sheet {49+ t.Helper()50+ src := w.Lookup("Sales")51+ used, _ := src.UsedRange()52+ pv := NewPivot(src, used)53+ p(&pv)54+ s, err := w.CreatePivot(src, used, "", pv)55+ if err != nil {56+ t.Fatal(err)57+ }58+ return s59+}60+61+func TestPivotGroupsRows(t *testing.T) {62+ w := sales(t)63+ s := newPivot(t, w, func(p *Pivot) {64+ p.Rows = []PivotGroup{{Col: 0}}65+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}, {Col: 3, Summarize: CountABy}}66+ })67+ if s.Name() != "Pivot Table 1" || w.Index(s) != 1 {68+ t.Errorf("sheet %q at %d, want Pivot Table 1 after Sales", s.Name(), w.Index(s))69+ }70+ // Text groups ignore case, keeping the first spelling, as Sheets.71+ checkGrid(t, s,72+ "Region|SUM of Units|COUNTA of Units",73+ "East|16|2",74+ "North|0|0",75+ "West|7|2",76+ "Grand Total|23|4")77+ if c := s.Cell(at("A5")); !c.Style.Bold {78+ t.Error("the Grand Total row isn't bold")79+ }80+}81+82+func TestPivotColumnsAndSubtotals(t *testing.T) {83+ w := sales(t)84+ s := newPivot(t, w, func(p *Pivot) {85+ p.Rows = []PivotGroup{{Col: 1}, {Col: 0}}86+ p.Columns = []PivotGroup{{Col: 2}}87+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}88+ })89+ // Dates group by day and show as dates.90+ checkGrid(t, s,91+ "|Date|1/5/2026|2/9/2026|Grand Total",92+ "Product|Region|SUM of Units|SUM of Units|SUM of Units",93+ "Ink|east||6|6",94+ "|West|4||4",95+ "Ink Total||4|6|10",96+ "Pens|East|10||10",97+ "|North||0|0",98+ "|West|3||3",99+ "Pens Total||13|0|13",100+ "Grand Total||17|6|23")101+ p, _ := s.Pivot()102+ p.RowTotals, p.ColumnTotals = false, false103+ if err := s.SetPivot(p, ""); err != nil {104+ t.Fatal(err)105+ }106+ checkGrid(t, s,107+ "|Date|1/5/2026|2/9/2026",108+ "Product|Region|SUM of Units|SUM of Units",109+ "Ink|east||6",110+ "|West|4",111+ "Pens|East|10",112+ "|North||0",113+ "|West|3")114+}115+116+func TestPivotSummaries(t *testing.T) {117+ w := sales(t)118+ var vals []PivotValue119+ for _, f := range Summaries() {120+ vals = append(vals, PivotValue{Col: 4, Summarize: f})121+ }122+ s := newPivot(t, w, func(p *Pivot) {123+ p.Rows = []PivotGroup{{Col: 1}}124+ p.Values = vals125+ })126+ // Sums, averages and extremes keep the column's currency format.127+ checkGrid(t, s,128+ "Product|SUM of Price|COUNTA of Price|COUNT of Price|COUNTUNIQUE of Price|AVERAGE of Price|MAX of Price|MIN of Price",129+ "Ink|$18.00|2|2|1|$9.00|$9.00|$9.00",130+ "Pens|$7.75|3|3|2|$2.58|$2.75|$2.50",131+ "Grand Total|$25.75|5|5|3|$5.15|$9.00|$2.50")132+}133+134+func TestPivotMixedKeysAndBlanks(t *testing.T) {135+ w := bookOf(t, page{"Sales", map[string]string{136+ "A1": "Key", "B1": "N",137+ "A2": "1", "B2": "1",138+ "A3": "'1", "B3": "2",139+ "A4": "TRUE", "B4": "4",140+ "A5": "", "B5": "8",141+ "A6": "b", "B6": "16",142+ "A7": "=1/0", "B7": "32",143+ "A8": "B", "B8": "=1/0",144+ }})145+ s := newPivot(t, w, func(p *Pivot) {146+ p.Rows = []PivotGroup{{Col: 0}}147+ p.Values = []PivotValue{{Col: 1, Summarize: SumBy}, {Col: 1, Summarize: CountBy}}148+ })149+ // Numbers, then text, booleans and errors; blanks last. 1 and "1"150+ // are different keys; an error in the values shows in their sums.151+ checkGrid(t, s,152+ "Key|SUM of N|COUNT of N",153+ "1|1|1",154+ "1|2|1",155+ "b|#DIV/0!|1",156+ "TRUE|4|1",157+ "#DIV/0!|32|1",158+ "(blank)|8|1",159+ "Grand Total|#DIV/0!|6")160+}161+162+func TestPivotSortByValueAndShowAs(t *testing.T) {163+ w := sales(t)164+ s := newPivot(t, w, func(p *Pivot) {165+ p.Rows = []PivotGroup{{Col: 0, Desc: true, SortBy: 1}}166+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}, {Col: 3, Summarize: SumBy, ShowAs: ShowPctTotal}}167+ })168+ checkGrid(t, s,169+ "Region|SUM of Units|SUM of Units",170+ "East|16|69.57%",171+ "West|7|30.43%",172+ "North|0|0.00%",173+ "Grand Total|23|100.00%")174+ p, _ := s.Pivot()175+ p.Rows[0] = PivotGroup{Col: 0, Desc: true}176+ s.SetPivot(p, "")177+ checkGrid(t, s,178+ "Region|SUM of Units|SUM of Units",179+ "West|7|30.43%",180+ "North|0|0.00%",181+ "East|16|69.57%",182+ "Grand Total|23|100.00%")183+}184+185+func TestPivotFilters(t *testing.T) {186+ w := sales(t)187+ s := newPivot(t, w, func(p *Pivot) {188+ p.Rows = []PivotGroup{{Col: 1}}189+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}190+ p.Filters = []PivotFilter{191+ {Col: 0, Criteria: Criteria{Hidden: []string{"North"}}},192+ {Col: 3, Criteria: Criteria{Cond: Condition{Op: CondGreater, Arg: "3"}}},193+ }194+ })195+ checkGrid(t, s,196+ "Product|SUM of Units",197+ "Ink|10",198+ "Pens|10",199+ "Grand Total|20")200+}201+202+func TestPivotFrequency(t *testing.T) {203+ w := sales(t)204+ src := w.Lookup("Sales")205+ r, _ := src.UsedRange()206+ s, err := w.CreatePivot(src, r, "Frequency of Product", FrequencyPivot(src, r, 1))207+ if err != nil {208+ t.Fatal(err)209+ }210+ checkGrid(t, s,211+ "Product|Count|Percent",212+ "Pens|3|60.00%",213+ "Ink|2|40.00%",214+ "Grand Total|5|100.00%")215+ // Blanks are counted too.216+ src.Set(at("B6"), "")217+ checkGrid(t, s,218+ "Product|Count|Percent",219+ "Ink|2|40.00%",220+ "Pens|2|40.00%",221+ "(blank)|1|20.00%",222+ "Grand Total|5|100.00%")223+}224+225+func TestPivotLiveUndoAndReads(t *testing.T) {226+ w := sales(t)227+ s := newPivot(t, w, func(p *Pivot) {228+ p.Rows = []PivotGroup{{Col: 0}}229+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}230+ })231+ src := w.Lookup("Sales")232+ // A formula elsewhere reads the pivot's result, and follows it.233+ if err := src.Set(at("G1"), "='Pivot Table 1'!B5*2"); err != nil {234+ t.Fatal(err)235+ }236+ if got := show(t, w, "Sales!G1"); got != "46" {237+ t.Fatalf("G1 = %s, want 46", got)238+ }239+ src.Set(at("D2"), "100")240+ checkGrid(t, s, "Region|SUM of Units", "East|106", "North|0", "West|7", "Grand Total|113")241+ if got := show(t, w, "Sales!G1"); got != "226" {242+ t.Errorf("G1 = %s after the edit, want 226", got)243+ }244+ // Rows inserted inside the source range grow it.245+ if err := src.InsertRows(5, 1); err != nil {246+ t.Fatal(err)247+ }248+ src.Set(at("A6"), "South")249+ src.Set(at("D6"), "1")250+ checkGrid(t, s, "Region|SUM of Units", "East|106", "North|0", "South|1", "West|7", "Grand Total|114")251+252+ for range 4 {253+ w.Undo()254+ }255+ checkGrid(t, s, "Region|SUM of Units", "East|16", "North|0", "West|7", "Grand Total|23")256+ if got := show(t, w, "Sales!G1"); got != "46" {257+ t.Errorf("G1 = %s after undo, want 46", got)258+ }259+ // Undoing the pivot's creation removes its sheet; redo brings it back.260+ w.Undo()261+ w.Undo()262+ if w.Lookup("Pivot Table 1") != nil {263+ t.Fatal("undo left the pivot sheet")264+ }265+ w.Redo()266+ checkGrid(t, w.Lookup("Pivot Table 1"), "Region|SUM of Units", "East|16", "North|0", "West|7", "Grand Total|23")267+}268+269+func TestPivotEditsAreOneStep(t *testing.T) {270+ w := sales(t)271+ s := newPivot(t, w, func(p *Pivot) { p.Rows = []PivotGroup{{Col: 0}} })272+ p, _ := s.Pivot()273+ p.Values = []PivotValue{{Col: 3, Summarize: MaxBy}}274+ if err := s.SetPivot(p, "add a value"); err != nil {275+ t.Fatal(err)276+ }277+ checkGrid(t, s, "Region|MAX of Units", "East|10", "North|0", "West|4", "Grand Total|10")278+ c, ok := w.Undo()279+ if !ok || c.Label != "add a value" {280+ t.Fatalf("undo %q, want add a value", c.Label)281+ }282+ checkGrid(t, s, "Region", "East", "North", "West")283+}284+285+func TestPivotRefusesEdits(t *testing.T) {286+ w := sales(t)287+ s := newPivot(t, w, func(p *Pivot) {288+ p.Rows = []PivotGroup{{Col: 0}}289+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}290+ })291+ if err := s.Set(at("B2"), "5"); err != ErrPivotEdit {292+ t.Errorf("Set in the results: %v, want ErrPivotEdit", err)293+ }294+ if !s.InPivot(NewRect(at("C4"), at("B9"))) || s.InPivot(NewRect(at("C1"), at("D9"))) {295+ t.Error("InPivot is wrong about the results' range B1:B5")296+ }297+ // Next to the results is fine; a result growing into it is an error298+ // the pivot shows, as in Sheets.299+ if err := s.Set(at("A6"), "note"); err != nil {300+ t.Fatal(err)301+ }302+ src := w.Lookup("Sales")303+ src.InsertRows(5, 1)304+ src.Set(at("A6"), "South")305+ if got := show(t, w, "'Pivot Table 1'!A1"); got != "#REF!" || !strings.Contains(s.ExplainError(at("A1")), "overwrite data in A6") {306+ t.Errorf("A1 = %s (%q), want #REF! explaining the overlap", got, s.ExplainError(at("A1")))307+ }308+ if got := show(t, w, "'Pivot Table 1'!A6"); got != "note" {309+ t.Errorf("A6 = %q, want the note kept", got)310+ }311+ // Copying results pastes their values.312+ w.Undo()313+ w.Undo()314+ clip := s.Copy(NewRect(at("A2"), at("B2")))315+ if _, err := src.Paste(clip, NewRect(at("H1"), at("H1")), false); err != nil {316+ t.Fatal(err)317+ }318+ if got := show(t, w, "Sales!input:I1"); got != "16" || src.Cell(at("I1")).derived {319+ t.Errorf("pasted %q, want the plain value 16", got)320+ }321+}322+323+func TestPivotFollowsSourceSheet(t *testing.T) {324+ w := sales(t)325+ s := newPivot(t, w, func(p *Pivot) {326+ p.Rows = []PivotGroup{{Col: 1}}327+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}328+ })329+ src := w.Lookup("Sales")330+ if err := w.RenameSheet(src, "Q1"); err != nil {331+ t.Fatal(err)332+ }333+ if p, _ := s.Pivot(); p.Source != "Q1" {334+ t.Errorf("source %q after renaming, want Q1", p.Source)335+ }336+ // A column inserted before the data moves the fields along.337+ if err := src.InsertCols(0, 1); err != nil {338+ t.Fatal(err)339+ }340+ p, _ := s.Pivot()341+ if p.Range != NewRect(at("B1"), at("F6")) || p.Rows[0].Col != 2 || p.Values[0].Col != 4 {342+ t.Errorf("after inserting a column: %+v", p)343+ }344+ checkGrid(t, s, "Product|SUM of Units", "Ink|10", "Pens|13", "Grand Total|23")345+ // Deleting the value column drops the value.346+ src.DeleteCols(4, 1)347+ checkGrid(t, s, "Product", "Ink", "Pens")348+ // Deleting the source sheet shows #REF!, and undo brings it back.349+ if err := w.DeleteSheet(src); err != nil {350+ t.Fatal(err)351+ }352+ if got := show(t, w, "'Pivot Table 1'!A1"); got != "#REF!" || !strings.Contains(s.PivotError(), "doesn't exist") {353+ t.Errorf("A1 = %s (%q) without the source", got, s.PivotError())354+ }355+ w.Undo()356+ checkGrid(t, s, "Product", "Ink", "Pens")357+}358+359+func TestPivotRefusesLoops(t *testing.T) {360+ w := sales(t)361+ a := newPivot(t, w, func(p *Pivot) { p.Rows = []PivotGroup{{Col: 0}} })362+ b, err := w.CreatePivot(a, NewRect(at("A1"), at("A4")), "", NewPivot(a, NewRect(at("A1"), at("A4"))))363+ if err != nil {364+ t.Fatal(err)365+ }366+ p, _ := a.Pivot()367+ p.Source = b.Name()368+ if err := a.SetPivot(p, ""); err != errPivotLoop {369+ t.Errorf("a loop: %v", err)370+ }371+ p.Source = a.Name()372+ if err := a.SetPivot(p, ""); err != errPivotSelf {373+ t.Errorf("its own sheet: %v", err)374+ }375+}376+377+func TestPivotFileRoundTrip(t *testing.T) {378+ w := sales(t)379+ s := newPivot(t, w, func(p *Pivot) {380+ p.Rows = []PivotGroup{{Col: 0, Desc: true, SortBy: 1}}381+ p.Columns = []PivotGroup{{Col: 1}}382+ p.Values = []PivotValue{{Col: 3, Summarize: AverageBy, ShowAs: ShowPctRow, Name: "Share"}}383+ p.Filters = []PivotFilter{{Col: 2, Criteria: Criteria{Hidden: []string{"2/9/2026"}, Cond: Condition{Op: CondNotEmpty}}}}384+ p.ColumnTotals = false385+ })386+ want := pivotGrid(s)387+ var buf bytes.Buffer388+ if err := w.Write(&buf); err != nil {389+ t.Fatal(err)390+ }391+ text := buf.String()392+ if !strings.Contains(text, `"version": 5`) || !strings.Contains(text, `"pivot": {"source":"Sales!A1:E6"`) {393+ t.Fatalf("file:\n%s", text)394+ }395+ if strings.Contains(text, `"Grand Total"`) {396+ t.Error("the pivot's results were saved")397+ }398+ w2, err := ReadBook(strings.NewReader(text))399+ if err != nil {400+ t.Fatal(err)401+ }402+ s2 := w2.Lookup("Pivot Table 1")403+ p1, _ := s.Pivot()404+ p2, _ := s2.Pivot()405+ if !reflect.DeepEqual(p1, p2) {406+ t.Errorf("read back\n%+v\nwant\n%+v", p2, p1)407+ }408+ if got := pivotGrid(s2); !reflect.DeepEqual(got, want) {409+ t.Errorf("read back shows %q, want %q", got, want)410+ }411+ // Without a pivot, the file stays version 4.412+ w2.DeleteSheet(s2)413+ w2.AddSheet("", 5)414+ buf.Reset()415+ w2.Write(&buf)416+ if !strings.Contains(buf.String(), `"version": 4`) {417+ t.Errorf("without pivots: %.40s", buf.String())418+ }419+}420+421+func TestPivotDuplicate(t *testing.T) {422+ w := sales(t)423+ s := newPivot(t, w, func(p *Pivot) { p.Rows = []PivotGroup{{Col: 1}} })424+ cp, err := w.DuplicateSheet(s)425+ if err != nil {426+ t.Fatal(err)427+ }428+ checkGrid(t, cp, "Product", "Ink", "Pens")429+ w.Lookup("Sales").Set(at("B2"), "Paper")430+ checkGrid(t, cp, "Product", "Ink", "Paper", "Pens")431+}432+433+func TestPivotTelemetry(t *testing.T) {434+ var got []PivotInfo435+ OnPivot = func(i PivotInfo) { got = append(got, i) }436+ defer func() { OnPivot = nil }()437+ w := sales(t)438+ newPivot(t, w, func(p *Pivot) {439+ p.Rows = []PivotGroup{{Col: 0}, {Col: 1}}440+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}441+ })442+ if len(got) != 1 || got[0].Records != 5 || got[0].Groups != 8 || got[0].Failed {443+ t.Errorf("reported %+v", got)444+ }445+}@@ -0,0 +1,436 @@
1+package sheet2+3+import (4+ "cmp"5+ "slices"6+ "strconv"7+ "strings"8+)9+10+// Summarize is how a pivot value summarizes a group's cells, as Sheets'11+// "Summarize by". Each is one entry of summaries.12+type Summarize uint813+14+const (15+ SumBy Summarize = iota16+ CountABy17+ CountBy18+ CountUniqueBy19+ AverageBy20+ MaxBy21+ MinBy22+ // CountRowsBy counts rows, blank or not. Sheets has no such choice;23+ // frequency tables use it so blank values are counted.24+ CountRowsBy25+ numSummarize26+)27+28+// summary is one way of summarizing: its name in files, its title (the29+// function Sheets names it after), what it does, whether the result30+// keeps the column's number format, and how it's computed.31+type summary struct {32+ name, title, desc string33+ keepsFormat bool34+ result func(a *accum) Value35+}36+37+var summaries = [numSummarize]summary{38+ SumBy: {"sum", "SUM", "Add the numbers", true, func(a *accum) Value {39+ return a.or(num(a.sum))40+ }},41+ CountABy: {"counta", "COUNTA", "Count the cells that aren't blank", false, func(a *accum) Value {42+ return num(float64(a.counta))43+ }},44+ CountBy: {"count", "COUNT", "Count the numbers", false, func(a *accum) Value {45+ return num(float64(a.n))46+ }},47+ CountUniqueBy: {"countunique", "COUNTUNIQUE", "Count the distinct values", false, func(a *accum) Value {48+ return num(float64(len(a.uniq)))49+ }},50+ AverageBy: {"average", "AVERAGE", "Average the numbers", true, func(a *accum) Value {51+ if a.n == 0 {52+ return a.or(ErrDiv0)53+ }54+ return a.or(num(a.sum / float64(a.n)))55+ }},56+ MaxBy: {"max", "MAX", "The largest number", true, func(a *accum) Value {57+ return a.or(num(a.hi))58+ }},59+ MinBy: {"min", "MIN", "The smallest number", true, func(a *accum) Value {60+ return a.or(num(a.lo))61+ }},62+ CountRowsBy: {"rows", "ROWS", "Count the rows, blank or not", false, func(a *accum) Value {63+ return num(float64(a.rows))64+ }},65+}66+67+// Summaries lists the choices of "Summarize by", in Sheets' order.68+func Summaries() []Summarize {69+ return []Summarize{SumBy, CountABy, CountBy, CountUniqueBy, AverageBy, MaxBy, MinBy}70+}71+72+func (f Summarize) String() string { return summaries[f].name }73+func (f Summarize) Title() string { return summaries[f].title }74+func (f Summarize) Desc() string { return summaries[f].desc }75+76+// ParseSummarize is the inverse of Summarize.String.77+func ParseSummarize(name string) (Summarize, bool) {78+ for i, s := range summaries {79+ if s.name == name {80+ return Summarize(i), true81+ }82+ }83+ return SumBy, false84+}85+86+// accum gathers one value of one group: what every summary needs, so a87+// value can be summarized any way without reading the source again.88+type accum struct {89+ rows, counta, n int90+ sum, lo, hi float6491+ err Value // the first error, which SUM and the rest show92+ uniq map[string]struct{}93+}94+95+func (a *accum) add(v Value, unique bool) {96+ a.rows++97+ switch v.Kind {98+ case Empty:99+ return100+ case Number:101+ if a.n == 0 {102+ a.lo, a.hi = v.Num, v.Num103+ }104+ a.lo, a.hi = min(a.lo, v.Num), max(a.hi, v.Num)105+ a.n++106+ a.sum += v.Num107+ case Error:108+ if a.err.Kind == Empty {109+ a.err = v110+ }111+ }112+ a.counta++113+ if unique {114+ if a.uniq == nil {115+ a.uniq = map[string]struct{}{}116+ }117+ a.uniq[valueKey(v, false)] = struct{}{}118+ }119+}120+121+// or returns the first error gathered, if any, else v.122+func (a *accum) or(v Value) Value {123+ if a.err.Kind == Error {124+ return a.err125+ }126+ return v127+}128+129+// valueKey identifies a value for grouping: numbers (dates too) by value,130+// text ignoring case when fold is set, as Sheets groups "east" with131+// "East". Numbers and text never share a key, so 1 and "1" are two132+// groups.133+func valueKey(v Value, fold bool) string {134+ switch v.Kind {135+ case Number:136+ return "n" + strconv.FormatFloat(v.Num, 'g', -1, 64)137+ case Text:138+ if fold {139+ return "t" + strings.ToLower(v.Str)140+ }141+ return "t" + v.Str142+ case Bool:143+ return "b" + strconv.FormatFloat(v.Num, 'g', -1, 64)144+ case Error:145+ return "e" + v.Str146+ }147+ return ""148+}149+150+// pivotNode is a group of source rows: the root holds them all, its151+// children the groups of the first row field, and so on. Each keeps its152+// accumulators by column group (-1 for all of them), then by value.153+type pivotNode struct {154+ label Value155+ format Format156+ kids []*pivotNode157+ byKey map[string]*pivotNode158+ acc map[int][]accum159+}160+161+// colGroup is one combination of column fields' values.162+type colGroup struct {163+ id int164+ labels []Value165+ formats []Format166+}167+168+// pivotCalc is a pivot being computed.169+type pivotCalc struct {170+ w *Workbook171+ p *Pivot172+ src *Sheet173+ root *pivotNode174+ cols []*colGroup // sorted once gathered175+ colBy map[string]*colGroup176+ unique []bool // by value: whether to gather distinct values177+ formats []Format // by value: the format results show in178+ records int // source rows summarized179+}180+181+func newPivotCalc(w *Workbook, p *Pivot, src *Sheet) *pivotCalc {182+ c := &pivotCalc{w: w, p: p, src: src, root: &pivotNode{}, colBy: map[string]*colGroup{}}183+ for _, v := range p.Values {184+ c.unique = append(c.unique, v.Summarize == CountUniqueBy)185+ c.formats = append(c.formats, c.valueFormat(v))186+ }187+ return c188+}189+190+// valueFormat is the format a value's results show in: percent for191+// shares, the column's own for sums, averages, minimums and maximums.192+func (c *pivotCalc) valueFormat(v PivotValue) Format {193+ if v.ShowAs != ShowValue {194+ return Preset(FmtPercent)195+ }196+ if !summaries[v.Summarize].keepsFormat {197+ return Format{}198+ }199+ for row := c.p.Range.From.Row + 1; row <= c.p.Range.To.Row; row++ {200+ if a := (Addr{Col: v.Col, Row: row}); !c.src.cells.get(a).Blank() {201+ return c.src.DisplayFormat(a)202+ }203+ }204+ return Format{}205+}206+207+// gather reads the source rows the filters let through, skipping rows208+// blank across the range, into groups.209+func (c *pivotCalc) gather() {210+ tests := pivotTests(c.p.Filters)211+ r := c.p.Range212+ for row := r.From.Row + 1; row <= r.To.Row; row++ {213+ if c.src.rowBlank(row, r) || !c.src.rowPasses(row, tests) {214+ continue215+ }216+ c.records++217+ col := c.colGroupOf(row)218+ n := c.root219+ c.add(n, col, row)220+ for _, g := range c.p.Rows {221+ n = n.child(c.src, Addr{Col: g.Col, Row: row})222+ c.add(n, col, row)223+ }224+ }225+ c.sortRows(c.root, 0)226+ c.sortCols()227+}228+229+// rowBlank reports whether row is blank across r's columns.230+func (s *Sheet) rowBlank(row int, r Rect) bool {231+ for col := r.From.Col; col <= r.To.Col; col++ {232+ if !s.cells.get(Addr{Col: col, Row: row}).Blank() {233+ return false234+ }235+ }236+ return true237+}238+239+// pivotTests prepares a pivot's filters as a filter's column tests.240+func pivotTests(fs []PivotFilter) []colTest {241+ var tests []colTest242+ for _, f := range fs {243+ if f.Criteria.IsZero() {244+ continue245+ }246+ t := colTest{col: f.Col, cond: f.Criteria.Cond.test()}247+ if len(f.Criteria.Hidden) > 0 {248+ t.hidden = make(map[string]bool, len(f.Criteria.Hidden))249+ for _, h := range f.Criteria.Hidden {250+ t.hidden[h] = true251+ }252+ }253+ tests = append(tests, t)254+ }255+ return tests256+}257+258+// child returns the group of n for the value at a, adding it if new.259+func (n *pivotNode) child(src *Sheet, a Addr) *pivotNode {260+ v := src.Value(a)261+ k := valueKey(v, true)262+ if kid, ok := n.byKey[k]; ok {263+ return kid264+ }265+ if n.byKey == nil {266+ n.byKey = map[string]*pivotNode{}267+ }268+ kid := &pivotNode{label: v, format: src.DisplayFormat(a)}269+ n.byKey[k] = kid270+ n.kids = append(n.kids, kid)271+ return kid272+}273+274+// colGroupOf returns the id of row's column group, or -1 without column275+// fields.276+func (c *pivotCalc) colGroupOf(row int) int {277+ if len(c.p.Columns) == 0 || len(c.p.Values) == 0 {278+ return -1279+ }280+ var b strings.Builder281+ for _, g := range c.p.Columns {282+ b.WriteString(valueKey(c.src.Value(Addr{Col: g.Col, Row: row}), true))283+ b.WriteByte(0)284+ }285+ k := b.String()286+ if cg, ok := c.colBy[k]; ok {287+ return cg.id288+ }289+ cg := &colGroup{id: len(c.cols)}290+ for _, g := range c.p.Columns {291+ a := Addr{Col: g.Col, Row: row}292+ cg.labels = append(cg.labels, c.src.Value(a))293+ cg.formats = append(cg.formats, c.src.DisplayFormat(a))294+ }295+ c.colBy[k] = cg296+ c.cols = append(c.cols, cg)297+ return cg.id298+}299+300+// add gathers row's values into n, for its column group and for all.301+func (c *pivotCalc) add(n *pivotNode, col, row int) {302+ if len(c.p.Values) == 0 {303+ return304+ }305+ if n.acc == nil {306+ n.acc = map[int][]accum{}307+ }308+ for _, id := range [2]int{-1, col} {309+ accs := n.acc[id]310+ if accs == nil {311+ accs = make([]accum, len(c.p.Values))312+ n.acc[id] = accs313+ }314+ for i, v := range c.p.Values {315+ accs[i].add(c.src.Value(Addr{Col: v.Col, Row: row}), c.unique[i])316+ }317+ if col == -1 {318+ return319+ }320+ }321+}322+323+// result is value i of n in column group col, as gathered (not yet shown324+// as a share), and false when no row of n is in col.325+func (c *pivotCalc) result(n *pivotNode, col, i int) (Value, bool) {326+ accs, ok := n.acc[col]327+ if !ok {328+ return Value{}, false329+ }330+ return summaries[c.p.Values[i].Summarize].result(&accs[i]), true331+}332+333+// sortRows orders n's children by the row field at depth, then theirs.334+func (c *pivotCalc) sortRows(n *pivotNode, depth int) {335+ if depth >= len(c.p.Rows) {336+ return337+ }338+ g := c.p.Rows[depth]339+ slices.SortStableFunc(n.kids, func(a, b *pivotNode) int {340+ return c.order(g, []Value{a.label}, []Value{b.label}, func(i int) (Value, Value) {341+ va, _ := c.result(a, -1, i)342+ vb, _ := c.result(b, -1, i)343+ return va, vb344+ })345+ })346+ for _, kid := range n.kids {347+ c.sortRows(kid, depth+1)348+ }349+}350+351+// sortCols orders the column groups by each column field in turn.352+func (c *pivotCalc) sortCols() {353+ slices.SortStableFunc(c.cols, func(a, b *colGroup) int {354+ for k, g := range c.p.Columns {355+ d := c.order(g, a.labels[k:k+1], b.labels[k:k+1], func(i int) (Value, Value) {356+ va, _ := c.result(c.root, a.id, i)357+ vb, _ := c.result(c.root, b.id, i)358+ return va, vb359+ })360+ if d != 0 {361+ return d362+ }363+ }364+ return 0365+ })366+}367+368+// order compares two groups by a field's order: by label, as Sheets sorts369+// (blanks last either way), or by a value's total, labels breaking ties.370+func (c *pivotCalc) order(g PivotGroup, a, b []Value, totals func(i int) (Value, Value)) int {371+ sign := 1372+ if g.Desc {373+ sign = -1374+ }375+ if i := g.SortBy - 1; i >= 0 && i < len(c.p.Values) {376+ va, vb := totals(i)377+ if d := compareTotals(va, vb); d != 0 {378+ return sign * d379+ }380+ sign = 1381+ }382+ return labelOrder(a[0], b[0], sign)383+}384+385+// labelOrder compares group labels A to Z (sign 1) or Z to A (-1), blanks386+// last either way.387+func labelOrder(a, b Value, sign int) int {388+ if (a.Kind == Empty) != (b.Kind == Empty) {389+ if a.Kind == Empty {390+ return 1391+ }392+ return -1393+ }394+ return sign * sortCompare(a, b)395+}396+397+// compareTotals orders totals by number, anything else (an error, a398+// group with no rows) below every number.399+func compareTotals(a, b Value) int {400+ an, bn := a.Kind == Number, b.Kind == Number401+ switch {402+ case an && bn:403+ return cmp.Compare(a.Num, b.Num)404+ case an:405+ return 1406+ case bn:407+ return -1408+ }409+ return 0410+}411+412+// shown is value i of n in column group col as the pivot shows it: as413+// gathered, or as a share of its row's, column's or the grand total.414+func (c *pivotCalc) shown(n *pivotNode, col, i int) (Value, bool) {415+ v, ok := c.result(n, col, i)416+ show := c.p.Values[i].ShowAs417+ if !ok || show == ShowValue || v.Kind != Number {418+ return v, ok419+ }420+ var total Value421+ switch show {422+ case ShowPctRow:423+ total, _ = c.result(n, -1, i)424+ case ShowPctColumn:425+ total, _ = c.result(c.root, col, i)426+ default:427+ total, _ = c.result(c.root, -1, i)428+ }429+ switch {430+ case total.Kind != Number:431+ return total, true432+ case total.Num == 0:433+ return ErrDiv0, true434+ }435+ return num(v.Num / total.Num), true436+}@@ -0,0 +1,151 @@
1+package sheet2+3+import (4+ "encoding/json"5+ "fmt"6+7+ "github.com/FelineStateMachine/012/internal/formula"8+)9+10+// filePivot is a pivot table in the file (version 5): its definition, on11+// one line after the cells of its sheet, never its results, which are12+// computed again on loading:13+//14+// "pivot": {"source": "Sales!A1:D200", "rows": [{"column": "B"}],15+// "values": [{"column": "D", "summarize": "sum"}], "rowTotals": true, "columnTotals": true}16+type filePivot struct {17+ Source string `json:"source"`18+ Rows []filePivotGroup `json:"rows,omitempty"`19+ Columns []filePivotGroup `json:"columns,omitempty"`20+ Values []filePivotValue `json:"values,omitempty"`21+ Filters []filePivotFilter `json:"filters,omitempty"`22+ RowTotals bool `json:"rowTotals"`23+ ColumnTotals bool `json:"columnTotals"`24+}25+26+type filePivotGroup struct {27+ Column string `json:"column"`28+ Order string `json:"order,omitempty"` // "desc" for Z to A29+ SortBy int `json:"sortBy,omitempty"` // by the total of this value, from 130+}31+32+type filePivotValue struct {33+ Column string `json:"column"`34+ Summarize string `json:"summarize"`35+ ShowAs string `json:"showAs,omitempty"`36+ Name string `json:"name,omitempty"`37+}38+39+type filePivotFilter struct {40+ Column string `json:"column"`41+ fileCriteria42+}43+44+// hasPivots reports whether any sheet has a pivot table, which needs45+// version 5.46+func (w *Workbook) hasPivots() bool {47+ for _, s := range w.sheets {48+ if s.pivot.def != nil {49+ return true50+ }51+ }52+ return false53+}54+55+func encodePivot(p *Pivot) ([]byte, error) {56+ rng := p.Range.String()57+ if p.Lost {58+ rng = "#REF!"59+ }60+ fp := filePivot{Source: formula.QuoteSheet(p.Source) + "!" + rng, RowTotals: p.RowTotals, ColumnTotals: p.ColumnTotals}61+ groups := func(gs []PivotGroup) []filePivotGroup {62+ var out []filePivotGroup63+ for _, g := range gs {64+ fg := filePivotGroup{Column: ColName(g.Col), SortBy: g.SortBy}65+ if g.Desc {66+ fg.Order = "desc"67+ }68+ out = append(out, fg)69+ }70+ return out71+ }72+ fp.Rows, fp.Columns = groups(p.Rows), groups(p.Columns)73+ for _, v := range p.Values {74+ fp.Values = append(fp.Values, filePivotValue{Column: ColName(v.Col), Summarize: v.Summarize.String(), ShowAs: v.ShowAs.String(), Name: v.Name})75+ }76+ for _, f := range p.Filters {77+ cr := f.Criteria78+ fp.Filters = append(fp.Filters, filePivotFilter{Column: ColName(f.Col),79+ fileCriteria: fileCriteria{Hidden: cr.Hidden, Condition: cr.Cond.Op.String(), Value: cr.Cond.Arg}})80+ }81+ return json.Marshal(fp)82+}83+84+func decodePivot(fp filePivot) (*Pivot, error) {85+ sheet, rest := SplitSheet(fp.Source)86+ if sheet == "" {87+ return nil, fmt.Errorf("pivot source %q names no sheet", fp.Source)88+ }89+ p := &Pivot{Source: sheet, RowTotals: fp.RowTotals, ColumnTotals: fp.ColumnTotals, Lost: rest == "#REF!"}90+ if !p.Lost {91+ r, ok := ParseRange(rest)92+ if !ok {93+ return nil, fmt.Errorf("invalid pivot source %q", fp.Source)94+ }95+ p.Range = r96+ }97+ var err error98+ if p.Rows, err = decodeGroups(fp.Rows); err != nil {99+ return nil, err100+ }101+ if p.Columns, err = decodeGroups(fp.Columns); err != nil {102+ return nil, err103+ }104+ for _, fv := range fp.Values {105+ v, err := decodeValue(fv)106+ if err != nil {107+ return nil, err108+ }109+ p.Values = append(p.Values, v)110+ }111+ for _, ff := range fp.Filters {112+ col, ok := ParseCol(ff.Column)113+ if !ok {114+ return nil, fmt.Errorf("invalid pivot filter column %q", ff.Column)115+ }116+ op, ok := ParseCondOp(ff.Condition)117+ if !ok {118+ return nil, fmt.Errorf("unknown pivot filter condition %q", ff.Condition)119+ }120+ p.Filters = append(p.Filters, PivotFilter{Col: col, Criteria: Criteria{Hidden: ff.Hidden, Cond: Condition{Op: op, Arg: ff.Value}}})121+ }122+ return p, nil123+}124+125+func decodeGroups(fgs []filePivotGroup) ([]PivotGroup, error) {126+ var out []PivotGroup127+ for _, fg := range fgs {128+ col, ok := ParseCol(fg.Column)129+ if !ok {130+ return nil, fmt.Errorf("invalid pivot column %q", fg.Column)131+ }132+ out = append(out, PivotGroup{Col: col, Desc: fg.Order == "desc", SortBy: max(fg.SortBy, 0)})133+ }134+ return out, nil135+}136+137+func decodeValue(fv filePivotValue) (PivotValue, error) {138+ col, ok := ParseCol(fv.Column)139+ if !ok {140+ return PivotValue{}, fmt.Errorf("invalid pivot value column %q", fv.Column)141+ }142+ f, ok := ParseSummarize(fv.Summarize)143+ if !ok {144+ return PivotValue{}, fmt.Errorf("unknown pivot summary %q", fv.Summarize)145+ }146+ show, ok := ParseShowAs(fv.ShowAs)147+ if !ok {148+ return PivotValue{}, fmt.Errorf("unknown pivot show as %q", fv.ShowAs)149+ }150+ return PivotValue{Col: col, Summarize: f, ShowAs: show, Name: fv.Name}, nil151+}@@ -0,0 +1,416 @@
1+package sheet2+3+import (4+ "fmt"5+ "slices"6+ "time"7+)8+9+// A pivot's results are laid out as Sheets lays them out, from A1: header10+// rows (one per column field, naming the field and its values, then one11+// naming the row fields and the values), then a row per group of the row12+// fields, their labels shown where they change, with a subtotal row after13+// each outer group and a Grand Total row when row totals are on. Columns14+// are the row labels, then each column group's values, then the Grand15+// Total column's.16+17+// pivotCell is one cell of a pivot's results.18+type pivotCell struct {19+ a Addr20+ v Value21+ f Format22+ bold bool23+}24+25+// pivotLayout is a pivot's results being laid out.26+type pivotLayout struct {27+ c *pivotCalc28+ cells []pivotCell29+ cols []*colGroup // the column groups shown, nil for the Grand Total30+ lc int // label columns31+ y int // the next row32+ over bool // something fell off the sheet33+}34+35+// blankLabel stands for a group of blank cells, as in Excel.36+const blankLabel = "(blank)"37+38+func (c *pivotCalc) layout() *pivotLayout {39+ l := &pivotLayout{c: c, lc: max(len(c.p.Rows), 1)}40+ if len(c.p.Values) > 0 {41+ if len(c.p.Columns) == 0 {42+ l.cols = []*colGroup{nil}43+ } else {44+ l.cols = slices.Clone(c.cols)45+ if c.p.ColumnTotals {46+ l.cols = append(l.cols, nil)47+ }48+ }49+ }50+ l.header()51+ l.body()52+ return l53+}54+55+func (l *pivotLayout) put(col, row int, v Value, f Format, bold bool) {56+ if col >= MaxCols || row >= MaxRows {57+ l.over = true58+ return59+ }60+ l.cells = append(l.cells, pivotCell{Addr{Col: col, Row: row}, v, f, bold})61+}62+63+func (l *pivotLayout) text(col, row int, s string, bold bool) {64+ l.put(col, row, Value{Kind: Text, Str: s}, Format{}, bold)65+}66+67+// label shows a group's value in its own format, or (blank).68+func (l *pivotLayout) label(col, row int, v Value, f Format, bold bool) {69+ if v.Kind == Empty {70+ l.text(col, row, blankLabel, bold)71+ return72+ }73+ l.put(col, row, v, f, bold)74+}75+76+func (l *pivotLayout) header() {77+ c := l.c78+ nv := len(c.p.Values)79+ if nv > 0 {80+ for k, g := range c.p.Columns {81+ l.text(l.lc-1, l.y, c.w.FieldName(*c.p, g.Col), true)82+ var prev *colGroup83+ for j, cg := range l.cols {84+ x := l.lc + j*nv85+ switch {86+ case cg == nil && k == 0:87+ l.text(x, l.y, "Grand Total", true)88+ case cg == nil:89+ case prev == nil || !sameLabels(prev.labels[:k+1], cg.labels[:k+1]):90+ l.label(x, l.y, cg.labels[k], cg.formats[k], true)91+ }92+ prev = cg93+ }94+ l.y++95+ }96+ }97+ for d, g := range c.p.Rows {98+ l.text(d, l.y, c.w.FieldName(*c.p, g.Col), true)99+ }100+ for j := range l.cols {101+ for i, v := range c.p.Values {102+ l.text(l.lc+j*nv+i, l.y, c.w.ValueTitle(*c.p, v), true)103+ }104+ }105+ l.y++106+}107+108+func sameLabels(a, b []Value) bool {109+ for i := range a {110+ if valueKey(a[i], true) != valueKey(b[i], true) {111+ return false112+ }113+ }114+ return true115+}116+117+func (l *pivotLayout) body() {118+ c := l.c119+ if len(c.p.Rows) == 0 {120+ if len(c.p.Values) > 0 {121+ l.total(c.root, "Grand Total", 0)122+ }123+ return124+ }125+ l.rows(c.root, 0)126+ if l.totals() {127+ l.total(c.root, "Grand Total", 0)128+ }129+}130+131+// rows lays out n's groups at depth: a label where each starts, then its132+// groups or, at the last depth, its values.133+func (l *pivotLayout) rows(n *pivotNode, depth int) {134+ last := depth == len(l.c.p.Rows)-1135+ for _, kid := range n.kids {136+ l.label(depth, l.y, kid.label, kid.format, false)137+ if last {138+ l.data(kid, false)139+ l.y++140+ continue141+ }142+ l.rows(kid, depth+1)143+ if l.totals() {144+ name := blankLabel145+ if kid.label.Kind != Empty {146+ name = FormatText(kid.label, kid.format)147+ }148+ l.total(kid, name+" Total", depth)149+ }150+ }151+}152+153+// totals reports whether total rows show: with row totals on, and values154+// to total.155+func (l *pivotLayout) totals() bool { return l.c.p.RowTotals && len(l.c.p.Values) > 0 }156+157+// total is a bold row of n's values labelled name in column col.158+func (l *pivotLayout) total(n *pivotNode, name string, col int) {159+ l.text(col, l.y, name, true)160+ l.data(n, true)161+ l.y++162+}163+164+// data lays out n's values in the current row.165+func (l *pivotLayout) data(n *pivotNode, bold bool) {166+ nv := len(l.c.p.Values)167+ for j, cg := range l.cols {168+ id := -1169+ if cg != nil {170+ id = cg.id171+ }172+ for i := range nv {173+ if v, ok := l.c.shown(n, id, i); ok {174+ l.put(l.lc+j*nv+i, l.y, v, l.c.formats[i], bold)175+ }176+ }177+ }178+}179+180+// PivotInfo describes one pivot recomputation, for telemetry. It holds181+// counts only, never contents.182+type PivotInfo struct {183+ Records int // source rows summarized184+ Groups int // groups of the row fields185+ Cells int // cells of the results186+ Failed bool187+ Duration time.Duration188+}189+190+// OnPivot, when set, is called after every pivot recomputation, like191+// OnRecalc.192+var OnPivot func(PivotInfo)193+194+// maxPivotChain bounds pivots reading pivots that are refreshed in turn195+// after one change.196+const maxPivotChain = 16197+198+// stalePivots returns the sheets whose pivot needs recomputing: its199+// definition changed, a cell in its results was overwritten, or a cell200+// of its source range is being recalculated. It reads the sheets' calc201+// marks, so it runs between affected and evaluate.202+func (w *Workbook) stalePivots() []*Sheet {203+ var out []*Sheet204+ for _, s := range w.sheets {205+ if s.pivot.stale {206+ out = append(out, s)207+ continue208+ }209+ p := s.pivot.def210+ if p == nil {211+ continue212+ }213+ if src := w.Lookup(p.Source); src != nil && src != s && touched(src.calc, p.Range) {214+ out = append(out, s)215+ }216+ }217+ return out218+}219+220+func touched(calc map[Addr]int, r Rect) bool {221+ for a := range calc {222+ if r.Contains(a) {223+ return true224+ }225+ }226+ return false227+}228+229+// allPivots returns every live sheet that has a pivot or was told to230+// recompute one.231+func (w *Workbook) allPivots() []*Sheet {232+ var out []*Sheet233+ for _, s := range w.sheets {234+ if s.pivot.def != nil || s.pivot.stale {235+ out = append(out, s)236+ }237+ }238+ return out239+}240+241+// refreshPivots recomputes the pivots of sheets, then recalculates what242+// reads their results, which may be other pivots.243+func (w *Workbook) refreshPivots(sheets []*Sheet) {244+ var changed []loc245+ for _, s := range sheets {246+ changed = append(changed, w.refreshPivot(s)...)247+ }248+ if len(changed) == 0 {249+ return250+ }251+ if w.pivotDepth >= maxPivotChain {252+ w.Circular = true253+ return254+ }255+ circular := w.Circular256+ w.pivotDepth++257+ w.recalc(changed)258+ w.pivotDepth--259+ w.Circular = w.Circular || circular260+}261+262+// refreshPivot recomputes s's pivot and writes its results, returning the263+// cells that changed.264+func (w *Workbook) refreshPivot(s *Sheet) []loc {265+ var start time.Time266+ if OnPivot != nil {267+ start = time.Now()268+ }269+ s.pivot.stale = false270+ want, calc, err := w.computePivot(s)271+ s.pivot.err = ""272+ if err != nil {273+ s.pivot.err = err.Error()274+ want = []pivotCell{{v: ErrRef, bold: true}}275+ if c := s.cells.get(Addr{}); c != nil && !c.derived && !c.Blank() {276+ want = nil // never write over what the user typed277+ }278+ }279+ s.pivot.out = Rect{}280+ for _, pc := range want {281+ s.pivot.out = union(s.pivot.out, Rect{From: pc.a, To: pc.a})282+ }283+ changed := s.writeDerived(want)284+ if OnPivot != nil {285+ info := PivotInfo{Cells: len(want), Failed: err != nil, Duration: time.Since(start)}286+ if calc != nil {287+ info.Records, info.Groups = calc.records, calc.groups()288+ }289+ OnPivot(info)290+ }291+ return changed292+}293+294+// groups counts the groups of the row fields, at every depth.295+func (c *pivotCalc) groups() int {296+ n := 0297+ var walk func(p *pivotNode)298+ walk = func(p *pivotNode) {299+ n += len(p.kids)300+ for _, k := range p.kids {301+ walk(k)302+ }303+ }304+ walk(c.root)305+ return n306+}307+308+// computePivot works out the cells of s's pivot, or why it can't.309+func (w *Workbook) computePivot(s *Sheet) ([]pivotCell, *pivotCalc, error) {310+ p := s.pivot.def311+ if p == nil || !s.live {312+ return nil, nil, nil313+ }314+ src := w.Lookup(p.Source)315+ switch {316+ case src == nil:317+ return nil, nil, fmt.Errorf("The pivot table's source sheet %s doesn't exist", p.Source)318+ case p.Lost:319+ return nil, nil, fmt.Errorf("The pivot table's source range was deleted")320+ case src == s:321+ return nil, nil, errPivotSelf322+ }323+ for _, col := range p.fieldCols() {324+ if col < p.Range.From.Col || col > p.Range.To.Col {325+ return nil, nil, fmt.Errorf("Column %s is outside the pivot table's source range %s", ColName(col), p.Range)326+ }327+ }328+ c := newPivotCalc(w, p, src)329+ c.gather()330+ l := c.layout()331+ if l.over {332+ return nil, c, fmt.Errorf("The pivot table doesn't fit on a sheet")333+ }334+ for _, pc := range l.cells {335+ if old := s.cells.get(pc.a); old != nil && !old.derived && !old.Blank() {336+ return nil, c, fmt.Errorf("The pivot table's results would overwrite data in %s", pc.a)337+ }338+ }339+ return l.cells, c, nil340+}341+342+// fieldCols lists the source columns the pivot's fields read.343+func (p *Pivot) fieldCols() []int {344+ var cols []int345+ for _, g := range slices.Concat(p.Rows, p.Columns) {346+ cols = append(cols, g.Col)347+ }348+ for _, v := range p.Values {349+ cols = append(cols, v.Col)350+ }351+ for _, f := range p.Filters {352+ cols = append(cols, f.Col)353+ }354+ return cols355+}356+357+// writeDerived makes the sheet's derived cells exactly want, touching only358+// cells that differ, outside the undo history (undo restores the source359+// and the definition, and the pivot is recomputed from them). It returns360+// the cells that changed.361+func (s *Sheet) writeDerived(want []pivotCell) []loc {362+ next := make(map[Addr]*Cell, len(want))363+ for _, pc := range want {364+ c := &Cell{Input: derivedInput(pc.v), Value: pc.v, Format: pc.f, derived: true}365+ c.Style.Bold = pc.bold366+ next[pc.a] = c367+ }368+ var changed []loc369+ for a, c := range s.cells.all() {370+ if c.derived && next[a] == nil {371+ s.setDerived(a, nil)372+ changed = append(changed, loc{s, a})373+ }374+ }375+ for a, c := range next {376+ if old := s.cells.get(a); old != nil && old.derived && old.Input == c.Input && old.Value == c.Value &&377+ old.Format == c.Format && old.Style == c.Style {378+ continue379+ }380+ s.setDerived(a, c)381+ changed = append(changed, loc{s, a})382+ }383+ return changed384+}385+386+// setDerived stores or removes a derived cell without recording it for387+// undo. Derived cells have no formula, so there are no references to388+// index.389+func (s *Sheet) setDerived(a Addr, c *Cell) {390+ s.version++391+ s.unlink(a)392+ if c != nil {393+ s.cells.set(a, c)394+ }395+}396+397+// derivedInput is what the formula bar shows for a result: the value.398+func derivedInput(v Value) string {399+ if v.Kind == Text {400+ return v.Str401+ }402+ return valueInput(v)403+}404+405+// plain returns a copy of c as an ordinary cell: a pivot's result becomes406+// the constant it shows, so copying results pastes values.407+func (c *Cell) plain() *Cell {408+ if c == nil || !c.derived {409+ return c.clone()410+ }411+ p, err := newCell(valueInput(c.Value), c.Format, c.Style, false)412+ if err != nil || p == nil {413+ return &Cell{Input: "'" + c.Value.String(), Value: c.Value, Format: c.Format, Style: c.Style}414+ }415+ return p416+}@@ -38,6 +38,7 @@ func (w *Workbook) recalcAll() {
3838 }3939 w.evaluate()4040 w.observe(true, start, n)41+ w.refreshPivots(w.allPivots())4142 }4243 4344 // Recalculation states of a cell.@@ -52,8 +53,10 @@ const (
5253 func (w *Workbook) recalc(changed []loc) {5354 start := recalcStart()5455 n := w.affected(changed)56+ stale := w.stalePivots()5557 w.evaluate()5658 w.observe(false, start, n)59+ w.refreshPivots(stale)5760 }5861 5962 // affected marks dirty, in each sheet's calc, the changed cells, volatile@@ -178,6 +181,9 @@ func (w *Workbook) evaluate() {
178181 return ErrRef179182 case st != dirty:180183 return c.Value184+ case c.derived: // a pivot's result, set when the pivot was computed185+ s.calc[a] = done186+ return c.Value181187 }182188 s.calc[a] = visiting183189 c.auto = Format{}@@ -37,6 +37,7 @@ type Cell struct {
3737 xrefs []xref // references that name a sheet, e.g. Sheet2!A13838 names []string // names in expr, as keys of Workbook.names3939 volatile bool // expr calls TODAY, NOW, RAND...40+ derived bool // a pivot table's result, owned by the engine: see pivot.go4041 }4142 4243 // IsFormula reports whether the cell holds a formula.@@ -77,7 +78,8 @@ type Sheet struct {
7778 calcGet lookup7879 calcFmt func(string, Addr) Format7980 80- charts []Chart // floating charts, bottom first; see chart.go81+ charts []Chart // floating charts, bottom first; see chart.go82+ pivot pivotState // the sheet's pivot table, if any; see pivot.go8183 8284 view viewState // frozen panes and the filter, see view.go8385 hidden hiddenCache // rows the filter hides, see filter.go@@ -202,8 +204,11 @@ func classify(input string) (Node, Format, error) {
202204 // Set stores an entry at a and recalculates affected cells. An empty input203205 // erases the contents but keeps the cell's formatting. A formula that204206 // fails to parse is rejected with a *ParseError and the sheet is left205-// unchanged.207+// unchanged. A pivot table's results can't be set: that's ErrPivotEdit.206208 func (s *Sheet) Set(a Addr, input string) error {209+ if s.InPivot(Rect{From: a, To: a}) {210+ return ErrPivotEdit211+ }207212 var err error208213 s.change("edit "+a.String(), Rect{From: a, To: a}, func() { err = s.put(a, input) })209214 return err@@ -291,10 +296,14 @@ func (c *Cell) setExpr(n Node) {
291296 292297 // place stores c at a (nil blanks it), recording the old cell for undo and293298 // keeping the dependency indexes current. Every cell mutation goes through294-// here.299+// here, except a pivot table writing its results (setDerived). Anything300+// placed over those has the pivot recomputed, which reports it in the way.295301 func (s *Sheet) place(a Addr, c *Cell) {296302 s.version++297303 s.record(a)304+ if s.pivot.def != nil && s.pivot.out.Contains(a) {305+ s.pivot.stale = true306+ }298307 s.unlink(a)299308 if c == nil {300309 return@@ -48,6 +48,10 @@ type Workbook struct {
4848 // the open change, which then recalculates everything: references by4949 // sheet name may now resolve differently.5050 structural bool51+52+ // pivotDepth counts pivots refreshed because a pivot they read53+ // changed, to stop a loop; see pivotlayout.go.54+ pivotDepth int5155 }5256 5357 // loc is a cell on a particular sheet.@@ -219,6 +223,7 @@ func (w *Workbook) DuplicateSheet(s *Sheet) (*Sheet, error) {
219223 cp.view = s.view220224 cp.view.filter = s.view.filter.clone()221225 cp.charts = slices.Clone(s.charts)226+ cp.pivot = pivotState{def: s.pivot.def.clone(), stale: s.pivot.def != nil, out: s.pivot.out}222227 w.change(cp, "duplicate "+s.name, Rect{}, func() {223228 w.recordSheets()224229 w.insert(cp, w.Index(s)+1)@@ -265,6 +270,7 @@ func (w *Workbook) RenameSheet(s *Sheet, name string) error {
265270 if old != formula.SheetKey(name) {266271 w.renameRefs(old, name)267272 }273+ w.renamePivots(old, name)268274 })269275 return nil270276 }