Formulas over a linked source infer their format from the column's type, as they do from a sheet's cells: SUM, AVERAGE, MIN, MAX and lookups show a DECIMAL's decimals, a date, a timestamp, a time or a duration. Counts over a source show as whole numbers with thousands separators. Parquet DECIMAL columns carry their scale's decimals and INTERVAL columns read as durations; SQLite's declared TIME, DECIMAL(p,s), NUMERIC(p,s) and MONEY map to formats.
- commit
- d59b5ded81a8238b7c4169656d5857066844b4ee
- parent
- e12837c9408c2197df6933ec6ef3a1c981566772
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.8 of 3, Jev's estimate
| M | docs/ | 2 | +- |
| M | docs/ | 11 | +++ |
| M | internal/ | 22 | +++++-- |
| M | internal/ | 29 | ++++++++- |
| A | internal/ | 78 | ++++++++++++++++++++++++ |
| M | internal/ | 6 | +-- |
| M | internal/ | 47 | ++++++++++++-- |
| M | internal/ | 49 | +++++++++++++-- |
| A | internal/ | 97 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 13 | +++- |
| M | internal/ | 11 | +++ |
@@ -23,7 +23,7 @@ it back in its format.
2323 | CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed in the sheet's [locale](../sheets/locale.md) (`;` wins a tie there when it has a decimal comma), or with the other decimal separator when the file's numbers are written that way; formulas stay text | Values as shown in the locale, of the sheet shown, as Sheets' Download does, CSV with `;` between fields where the locale has a decimal comma; a selection of whole columns or rows downloads their data, not a million blank lines |2424 | Excel `.xlsx` | Every sheet, with formulas, formats and most of what a sheet holds: see [Excel files](excel.md) | The same |2525 | SQLite | Pick a table or view, or type a query; a header row names the columns | The sheet shown or the selection as a table, first row as column names; a table of that name is replaced |26-| Parquet | Every column, with dates and timestamps; lists joined with commas | |26+| Parquet | Every column, with dates, timestamps, times, decimals in their places and intervals as durations; lists joined with commas | |2727 | Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |2828 | JSON `.json`, `.ndjson`, `.jsonl` | A list of records (or records one after another, as NDJSON): keys become the header row, numbers, booleans and text keep their types, nested lists and records are their text | The sheet shown as a list of records named by its first row, numbers as numbers |2929 | Nushell `.nuon` | A nushell table with its types: file sizes, durations and dates become numbers in the Size, Duration and Date time formats; see [Nushell types](../nushell/types.md) | The sheet shown as a nushell table, first row as column names, types kept by the cells' formats |@@ -132,6 +132,17 @@ compute:
132132 | Criteria | COUNTIF, COUNTIFS, SUMIF, SUMIFS, AVERAGEIF, AVERAGEIFS, COUNTBLANK, SUMPRODUCT |133133 | Lookups | MATCH, XLOOKUP, VLOOKUP, HLOOKUP, INDEX, ROWS, COLUMNS |134134 135+A result keeps what its column means, as a formula over a sheet takes136+its inputs' format: SUM, AVERAGE, MIN, MAX and the lookups show in the137+format the column's type gives it. In Parquet that's a DECIMAL with138+its scale's decimals, a DATE as a date, a TIMESTAMP as a date and139+time, a TIME as a time and an INTERVAL as a duration; in SQLite the140+declared type: DATE, DATETIME or TIMESTAMP, TIME, DECIMAL(10,2) or141+NUMERIC(10,2) with its decimals, and MONEY as currency. A count over a142+source (COUNT, COUNTA, COUNTIF, COUNTIFS, COUNTBLANK, ROWS, COLUMNS)143+shows as a whole number with the locale's thousands separators:144+`2,000,000`. A format given to the cell wins over either.145+135146 Any other function (MEDIAN, SORT, FILTER, UNIQUE, TEXTJOIN) holds what136147 it reads, and is given a source's range as long as that holds no more137148 than `max-cells` cells ([Configuration](../reference/config.md#max-cells));@@ -11,6 +11,7 @@ import (
1111 "time"1212 1313 "github.com/parquet-go/parquet-go"14+ "github.com/parquet-go/parquet-go/deprecated"1415 "github.com/parquet-go/parquet-go/format"1516 1617 "github.com/FelineStateMachine/012/internal/sheet"@@ -23,6 +24,21 @@ type parquetColumn struct {
2324 // repeated is set on a list's column, whose rows hold several2425 // values; sources read it a row at a time (sourceparquet.go).2526 repeated bool27+ // interval is set on a column of the INTERVAL converted type: 1228+ // bytes of months, days and milliseconds.29+ interval bool30+}31+32+// parquetColumnOf is how a column of type t becomes cells.33+func parquetColumnOf(t parquet.Type) parquetColumn {34+ c := parquetColumn{kind: t.Kind()}35+ if lt := t.LogicalType(); lt != nil {36+ c.logical = lt.Value37+ }38+ if ct := t.ConvertedType(); ct != nil && *ct == deprecated.Interval {39+ c.interval = true40+ }41+ return c2642 }2743 2844 // importParquet reads every leaf column, named by its path, into a@@ -79,11 +95,7 @@ func parquetHeader(b *builder, schema *parquet.Schema) []parquetColumn {
7995 for i, path := range paths {8096 b.text(sheet.Addr{Col: i}, strings.Join(path, "."), sheet.Format{}, header)8197 if leaf, ok := schema.Lookup(path...); ok && leaf.Node != nil {82- t := leaf.Node.Type()83- cols[i].kind = t.Kind()84- if lt := t.LogicalType(); lt != nil {85- cols[i].logical = lt.Value86- }98+ cols[i] = parquetColumnOf(leaf.Node.Type())8799 }88100 }89101 return cols@@ -1,6 +1,7 @@
11 package fileio22 33 import (4+ "encoding/binary"45 "fmt"56 "math"67 "strings"@@ -130,7 +131,12 @@ func parquetValue(v parquet.Value, c parquetColumn) (sheet.LiveCell, bool) {
130131 return liveNum(d.Hours()/24, sheet.Format{Kind: sheet.FmtTime}), true131132 case *format.DecimalType:132133 if x, ok := decimal(v, lt.Scale); ok {133- return liveNum(x, sheet.Format{}), true134+ return liveNum(x, decimalFormat(lt)), true135+ }136+ }137+ if c.interval {138+ if d, ok := interval(v); ok {139+ return liveNum(d, sheet.Format{Kind: sheet.FmtDuration}), true134140 }135141 }136142 switch c.kind {@@ -165,7 +171,10 @@ func parquetFormat(c parquetColumn) (sheet.Format, bool) {
165171 case *format.TimeType:166172 return sheet.Format{Kind: sheet.FmtTime}, true167173 case *format.DecimalType:168- return sheet.Format{}, true174+ return decimalFormat(c.logical.(*format.DecimalType)), true175+ }176+ if c.interval {177+ return sheet.Format{Kind: sheet.FmtDuration}, true169178 }170179 switch c.kind {171180 case parquet.Int32, parquet.Int64, parquet.Float, parquet.Double:@@ -176,6 +185,22 @@ func parquetFormat(c parquetColumn) (sheet.Format, bool) {
176185 return sheet.Format{}, false177186 }178187 188+// decimalFormat is a DECIMAL's: a number with its scale's decimals.189+func decimalFormat(t *format.DecimalType) sheet.Format {190+ return sheet.Format{Kind: sheet.FmtNumber, Decimals: min(max(int(t.Scale), 0), sheet.MaxDecimals)}191+}192+193+// interval is an INTERVAL's length in days: its months as 30 days each,194+// its days, and its milliseconds.195+func interval(v parquet.Value) (float64, bool) {196+ b := v.ByteArray()197+ if len(b) != 12 {198+ return 0, false199+ }200+ months, days, ms := binary.LittleEndian.Uint32(b), binary.LittleEndian.Uint32(b[4:]), binary.LittleEndian.Uint32(b[8:])201+ return 30*float64(months) + float64(days) + float64(ms)/86_400_000, true202+}203+179204 // sqliteCell is a database value as a cell, reporting whether it was180205 // binary data that could only be described.181206 func sqliteCell(v any) (cell fileCell, blob bool) {@@ -0,0 +1,78 @@
1+package fileio2+3+import (4+ "context"5+ "database/sql"6+ "encoding/binary"7+ "path/filepath"8+ "testing"9+10+ "github.com/parquet-go/parquet-go"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// TestSQLiteDeclaredFormats checks the formats a SQLite source's16+// columns show in, from their declared types.17+func TestSQLiteDeclaredFormats(t *testing.T) {18+ name := filepath.Join(t.TempDir(), "typed.sqlite")19+ db, err := sql.Open("sqlite", name)20+ if err != nil {21+ t.Fatal(err)22+ }23+ _, err = db.Exec(`CREATE TABLE t (n INTEGER, price DECIMAL(10,2), rate NUMERIC(8, 4), plain NUMERIC, paid MONEY,24+ day DATE, at DATETIME, stamp TIMESTAMP, clock TIME, note TEXT)`)25+ db.Close()26+ if err != nil {27+ t.Fatal(err)28+ }29+ src, err := OpenSource(context.Background(), SourceSpec{Path: name, TempDir: t.TempDir()})30+ if err != nil {31+ t.Fatal(err)32+ }33+ defer src.Close()34+ want := []SourceColumn{35+ {Name: "n", Numeric: true},36+ {Name: "price", Format: sheet.Format{Kind: sheet.FmtNumber, Decimals: 2}, Numeric: true},37+ {Name: "rate", Format: sheet.Format{Kind: sheet.FmtNumber, Decimals: 4}, Numeric: true},38+ {Name: "plain", Numeric: true},39+ {Name: "paid", Format: sheet.Format{Kind: sheet.FmtCurrency, Decimals: 2}, Numeric: true},40+ {Name: "day", Format: sheet.Format{Kind: sheet.FmtDate}, Numeric: true},41+ {Name: "at", Format: sheet.Format{Kind: sheet.FmtDateTime}, Numeric: true},42+ {Name: "stamp", Format: sheet.Format{Kind: sheet.FmtDateTime}, Numeric: true},43+ {Name: "clock", Format: sheet.Format{Kind: sheet.FmtTime}, Numeric: true},44+ {Name: "note"},45+ }46+ got := src.Columns()47+ if len(got) != len(want) {48+ t.Fatalf("columns %+v", got)49+ }50+ for i := range want {51+ if got[i] != want[i] {52+ t.Errorf("column %d: %+v, want %+v", i, got[i], want[i])53+ }54+ }55+}56+57+// TestParquetInterval checks an INTERVAL's 12 bytes read as days: months58+// of 30 days, days, and milliseconds.59+func TestParquetInterval(t *testing.T) {60+ b := make([]byte, 12)61+ binary.LittleEndian.PutUint32(b, 1)62+ binary.LittleEndian.PutUint32(b[4:], 2)63+ binary.LittleEndian.PutUint32(b[8:], 43_200_000)64+ d, ok := interval(parquet.FixedLenByteArrayValue(b))65+ if !ok || d != 32.5 {66+ t.Errorf("interval %v %v, want 32.5 days", d, ok)67+ }68+ if _, ok := interval(parquet.ByteArrayValue([]byte{1, 2})); ok {69+ t.Error("read 2 bytes as an interval")70+ }71+ c := parquetColumn{kind: parquet.FixedLenByteArray, interval: true}72+ if lc, ok := parquetValue(parquet.FixedLenByteArrayValue(b), c); !ok || lc.F.Kind != sheet.FmtDuration || lc.V.Num != 32.5 {73+ t.Errorf("an interval's cell: %+v", lc)74+ }75+ if f, num := parquetFormat(c); f.Kind != sheet.FmtDuration || !num {76+ t.Errorf("an interval column shows in %+v", f)77+ }78+}@@ -54,12 +54,8 @@ func openParquetSource(spec SourceSpec) (*parquetSource, error) {
5454 for _, path := range schema.Columns() {5555 var c parquetColumn5656 if leaf, ok := schema.Lookup(path...); ok && leaf.Node != nil {57- t := leaf.Node.Type()58- c.kind = t.Kind()57+ c = parquetColumnOf(leaf.Node.Type())5958 c.repeated = leaf.MaxRepetitionLevel > 060- if lt := t.LogicalType(); lt != nil {61- c.logical = lt.Value62- }6359 }6460 f, num := parquetFormat(c)6561 s.cols = append(s.cols, c)@@ -146,15 +146,10 @@ func (s *sqliteSource) columns(ctx context.Context) error {
146146 for i, t := range types {147147 c := SourceColumn{Name: t.Name()}148148 decl := strings.ToUpper(t.DatabaseTypeName())149- switch {150- case decl == "":149+ if decl == "" {151150 untyped = append(untyped, i)152- case strings.Contains(decl, "DATETIME"), strings.Contains(decl, "TIMESTAMP"):153- c.Format, c.Numeric = sheet.Format{Kind: sheet.FmtDateTime}, true154- case strings.Contains(decl, "DATE"):155- c.Format, c.Numeric = sheet.Format{Kind: sheet.FmtDate}, true156- default:157- c.Numeric = numericAffinity(decl)151+ } else {152+ c.Format, c.Numeric = declaredFormat(decl)158153 }159154 s.names = append(s.names, t.Name())160155 s.meta = append(s.meta, c)@@ -165,6 +160,42 @@ func (s *sqliteSource) columns(ctx context.Context) error {
165160 return nil166161 }167162 163+// declaredFormat is the format a declared type shows its column's164+// values in, and whether it holds numbers: dates and times, the165+// decimals of a DECIMAL(10,2) or NUMERIC(10,2), and money as currency.166+func declaredFormat(decl string) (sheet.Format, bool) {167+ switch {168+ case strings.Contains(decl, "DATETIME"), strings.Contains(decl, "TIMESTAMP"):169+ return sheet.Format{Kind: sheet.FmtDateTime}, true170+ case strings.Contains(decl, "DATE"):171+ return sheet.Format{Kind: sheet.FmtDate}, true172+ case strings.Contains(decl, "TIME"):173+ return sheet.Format{Kind: sheet.FmtTime}, true174+ case strings.Contains(decl, "MONEY"), strings.Contains(decl, "CURRENCY"):175+ return sheet.Format{Kind: sheet.FmtCurrency, Decimals: 2}, true176+ case strings.HasPrefix(decl, "DECIMAL"), strings.HasPrefix(decl, "NUMERIC"):177+ if scale, ok := declaredScale(decl); ok {178+ return sheet.Format{Kind: sheet.FmtNumber, Decimals: scale}, true179+ }180+ }181+ return sheet.Format{}, numericAffinity(decl)182+}183+184+// declaredScale reads the scale of a type declared as DECIMAL(10,2).185+func declaredScale(decl string) (int, bool) {186+ _, args, found := strings.Cut(decl, "(")187+ args, _, closed := strings.Cut(args, ")")188+ _, scale, two := strings.Cut(args, ",")189+ if !found || !closed || !two {190+ return 0, false191+ }192+ n, err := strconv.Atoi(strings.TrimSpace(scale))193+ if err != nil || n < 0 || n > sheet.MaxDecimals {194+ return 0, false195+ }196+ return n, true197+}198+168199 // numericAffinity reports whether a declared type gives a column169200 // SQLite's INTEGER, REAL or NUMERIC affinity.170201 func numericAffinity(decl string) bool {@@ -33,8 +33,12 @@ func IsVolatile(n Node) bool {
3333 // InferFormat picks the format Sheets shows a formula's result in when3434 // the cell is Automatic: dates from date functions, and otherwise the3535 // format of the first formatted input, so =B2+B3 of currency shows36-// currency and a date plus days shows a date.37-func InferFormat(n Node, at func(string, Addr) Format) Format {36+// currency and a date plus days shows a date. at is the format a cell37+// shows in, a linked source's column the one its type says; paged38+// reports whether a sheet is a linked source's tab, whose counts show as39+// whole numbers with thousands separators.40+func InferFormat(n Node, at func(string, Addr) Format, paged func(string) bool) Format {41+ infer := func(n Node) Format { return InferFormat(n, at, paged) }3842 switch n := n.(type) {3943 case formula.Ref:4044 return at(n.Sheet, n.Addr)@@ -42,10 +46,10 @@ func InferFormat(n Node, at func(string, Addr) Format) Format {
4246 return at(n.Sheet, n.Rect.From)4347 case formula.Unary:4448 if n.Op == "-" || n.Op == "+" {45- return InferFormat(n.X, at)49+ return infer(n.X)4650 }4751 case formula.Binary:48- l, r := InferFormat(n.L, at), InferFormat(n.R, at)52+ l, r := infer(n.L), infer(n.R)4953 switch n.Op {5054 case "+", "-":5155 if n.Op == "-" && l.Kind.IsTime() && r.Kind.IsTime() {@@ -59,15 +63,46 @@ func InferFormat(n Node, at func(string, Addr) Format) Format {
5963 return firstFormat(l, r)6064 }6165 case formula.Call:62- if funcOf(n).format != nil {63- return funcOf(n).format(n.Args, func(n Node) Format { return InferFormat(n, at) })66+ f := funcOf(n)67+ if counting[f.Name] && paged != nil && countsPaged(n.Args, paged) {68+ return wholeNumber69+ }70+ if f.format != nil {71+ return f.format(n.Args, infer)6472 }6573 case formula.Array:66- return InferFormat(n.Rows[0][0], at)74+ return infer(n.Rows[0][0])6775 }6876 return Format{}6977 }7078 79+// counting are the functions that count cells or rows.80+var counting = map[string]bool{"COUNT": true, "COUNTA": true, "COUNTIF": true, "COUNTIFS": true,81+ "COUNTBLANK": true, "ROWS": true, "COLUMNS": true}82+83+// wholeNumber is a count's format over a linked source: the locale's84+// thousands separators and no decimals, so two million rows read85+// 2,000,000.86+var wholeNumber = Format{Kind: value.FmtNumber}87+88+// countsPaged reports whether a count's arguments name a range of a89+// linked source.90+func countsPaged(args []Node, paged func(string) bool) bool {91+ for _, a := range args {92+ sheet := ""93+ switch r := a.(type) {94+ case formula.Range:95+ sheet = r.Sheet96+ case formula.Ref:97+ sheet = r.Sheet98+ }99+ if sheet != "" && paged(sheet) {100+ return true101+ }102+ }103+ return false104+}105+71106 func firstFormat(fs ...Format) Format {72107 for _, f := range fs {73108 if !f.IsZero() && f.Kind != value.FmtText {@@ -0,0 +1,97 @@
1+package paged2+3+import (4+ "context"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/parquet-go/parquet-go"12+13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// priced is a row of a Parquet file whose types carry formats: a price17+// in DECIMAL(12,2), a day and a moment.18+type priced struct {19+ ID int64 `parquet:"id"`20+ Price int64 `parquet:"price,decimal(2:12)"`21+ Day int32 `parquet:"day,date"`22+ At int64 `parquet:"at,timestamp(millisecond)"`23+}24+25+func writePriced(t *testing.T, name string, n int) {26+ t.Helper()27+ f, err := os.Create(name)28+ if err != nil {29+ t.Fatal(err)30+ }31+ defer f.Close()32+ w := parquet.NewGenericWriter[priced](f, parquet.MaxRowsPerRowGroup(64))33+ day := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)34+ rows := make([]priced, n)35+ for i := range rows {36+ rows[i] = priced{ID: int64(i), Price: int64(i*7919%10000) + 100, Day: int32(day.Unix()/86400) + int32(i%90),37+ At: day.Add(time.Duration(i) * time.Hour).UnixMilli()}38+ }39+ if _, err := w.Write(rows); err != nil {40+ t.Fatal(err)41+ }42+ if err := w.Close(); err != nil {43+ t.Fatal(err)44+ }45+}46+47+// TestFormatsOverASource checks that a formula over a source's column48+// shows in the format its type gives, as the same formula over an49+// imported copy does, and that its counts show as whole numbers.50+func TestFormatsOverASource(t *testing.T) {51+ name := filepath.Join(t.TempDir(), "sales.parquet")52+ writePriced(t, name, 3000)53+ w, h := linked(t, name, 100000)54+ s := w.Sheet(0)55+ price := sheet.Format{Kind: sheet.FmtNumber, Decimals: 2}56+ cases := []struct {57+ formula string58+ want sheet.Format59+ }{60+ {"=SUM({T}[price])", price}, {"=AVERAGE({T}[price])", price}, {"=MIN({T}[price])", price},61+ {"=MAX({T}[price])", price}, {"=MEDIAN({T}[price])", price}, {"=SUM({T}[price])*2", price},62+ {"=MAX({T}[day])", sheet.Format{Kind: sheet.FmtDate}}, {"=MIN({T}[at])", sheet.Format{Kind: sheet.FmtDateTime}},63+ {"=XLOOKUP(5,{T}[id],{T}[price])", price}, {"=SUM({T}[id])", sheet.Format{}},64+ }65+ for i, c := range cases {66+ s.Set(sheet.Addr{Col: 10, Row: i}, strings.ReplaceAll(c.formula, "{T}", "sales"))67+ s.Set(sheet.Addr{Col: 11, Row: i}, strings.ReplaceAll(c.formula, "{T}", "ref"))68+ }69+ counts := []string{"=ROWS(sales)", "=COUNT(sales[price])", "=COUNTA(sales[id])", `=COUNTIF(sales[price],">50")`,70+ `=COUNTIFS(sales[id],">5",sales[day],">0")`, "=COUNTBLANK(sales[id])", "=COLUMNS(sales)"}71+ for i, f := range counts {72+ s.Set(sheet.Addr{Col: 12, Row: i}, f)73+ }74+ s.Set(sheet.Addr{Col: 13}, "=ROWS(ref)")75+ h.Settle(context.Background(), w, Links(w, same, ""))76+ for i, c := range cases {77+ got, ref := s.DisplayFormat(sheet.Addr{Col: 10, Row: i}), s.DisplayFormat(sheet.Addr{Col: 11, Row: i})78+ if got != c.want || ref != c.want {79+ t.Errorf("%s: %+v over the source, %+v over the copy, want %+v", c.formula, got, ref, c.want)80+ }81+ if g, r := s.ShownText(sheet.Addr{Col: 10, Row: i}), s.ShownText(sheet.Addr{Col: 11, Row: i}); g != r {82+ t.Errorf("%s shows %q over the source, %q over the copy", c.formula, g, r)83+ }84+ }85+ for i, f := range counts {86+ a := sheet.Addr{Col: 12, Row: i}87+ if got := s.DisplayFormat(a); got != (sheet.Format{Kind: sheet.FmtNumber}) {88+ t.Errorf("%s: %+v, want a whole number", f, got)89+ }90+ }91+ if got := s.ShownText(sheet.Addr{Col: 12}); got != "3,000" {92+ t.Errorf("ROWS shows %q", got)93+ }94+ if got := s.DisplayFormat(sheet.Addr{Col: 13}); !got.IsZero() {95+ t.Errorf("a count over a sheet shows in %+v", got)96+ }97+}@@ -143,7 +143,7 @@ func (e *evaluator) formula(s *Sheet, a Addr, c *Cell) {
143143 w.noteSpill(s, a, c, arr)144144 }145145 w.evaluating = outer146- c.auto = functions.InferFormat(expr, s.calcFmt)146+ c.auto = functions.InferFormat(expr, s.calcFmt, s.calcGet.Paged)147147 w.depth--148148 }149149 @@ -355,12 +355,17 @@ func (w *Workbook) values(s *Sheet) *reader {
355355 return w.lookupOn(s, func(t *Sheet, a Addr) Value { return t.Value(a) })356356 }357357 358-// formatFrom reads display formats for formulas on s, across sheets.358+// formatFrom reads display formats for formulas on s, across sheets: on359+// a linked source's tab, the format its column's type shows in.359360 func (w *Workbook) formatFrom(s *Sheet) func(string, Addr) Format {360361 return func(sheet string, a Addr) Format {361- if t := w.resolve(s, sheet); t != nil {362- return t.DisplayFormat(a)362+ t := w.resolve(s, sheet)363+ if t == nil {364+ return Format{}363365 }364- return Format{}366+ if r, ok := t.pagedRegion(); ok {367+ return t.sourceFormat(r, a)368+ }369+ return t.DisplayFormat(a)365370 }366371 }@@ -143,6 +143,17 @@ func (rd *reader) pagedFold(t *Sheet, reg Region, r Rect, s functions.Agg) (func
143143 return s, nil144144 }145145 146+// sourceFormat is the format the cell at a of t, the tab of the source147+// reg, shows in: its column's, as the source's type says, below the148+// header.149+func (t *Sheet) sourceFormat(reg Region, a Addr) Format {150+ tbl, shape, ok := t.sourceTable(reg)151+ if !ok || a.Row == 0 || !tbl.Contains(a) || a.Col >= len(shape.Formats) {152+ return Format{}153+ }154+ return shape.Formats[a.Col]155+}156+146157 // pagedBounds is Bounds over a source's tab: the part of r its table147158 // holds.148159 func (t *Sheet) pagedBounds(reg Region, r Rect) (Rect, bool) {