1// Package numfmt renders numbers the way spreadsheets show them: number2// format patterns as in Sheets' custom number formats and TEXT(),3// General's shortest form, rounding on the 15 significant digits users4// see, and the serial day numbers dates and times are stored as. It knows5// nothing of cells or sheets.6package numfmt78import (9 "math"10 "strconv"11 "strings"12 "unicode/utf8"1314 "src.rest/cat/012/internal/locale"15)1617// Number format patterns: "#,##0.00", "0.0%", "0.00E+00",18// "$#,##0;($#,##0)", "m/d/yyyy", "h:mm am/pm", "[h]:mm:ss". Up to four19// ;-separated sections apply to positive, negative, zero and text values.2021type ptKind uint82223const (24 ptLit ptKind = iota25 ptDigit // 0, # or ?26 ptDot // decimal point27 ptComma // thousands separator or scaling28 ptPercent //29 ptExp // E+ or E-30 ptGeneral // "General"31 ptText // @32 ptFill // *x: repeat x to fill the cell33 ptYear // y, yy, yyyy34 ptMonth // m..mmmmm: month, or minutes next to h or s35 ptDay // d..dddd36 ptHour // h, hh37 ptSecond // s, ss38 ptAMPM // AM/PM or A/P39 ptElapsed // [h], [m], [s]40 ptFrac // .0, .00 after seconds41)4243type ptok struct {44 kind ptKind45 s string // literal text, digit placeholder char, or the token as written46 n int // repeat count for date tokens47}4849// Format renders v with a number format pattern, as TEXT() does.50func Format(v float64, pat string) string { return FormatIn(v, pat, locale.Canonical) }5152// FormatIn renders v with a number format pattern as shown in loc: the53// pattern is written as in en-US ("#,##0.00") and the number shows loc's54// decimal and thousands separators ("1.234,50" in de-DE).55func FormatIn(v float64, pat string, loc *locale.Locale) string {56 secs := splitSections(pat)57 sec, neg := secs[0], v < 058 switch {59 case len(secs) >= 3 && v == 0:60 sec = secs[2]61 case len(secs) >= 2 && v < 0 && strings.TrimSpace(secs[1]) != "":62 sec, v, neg = secs[1], -v, false63 }64 toks := lexPattern(sec)65 if isDatePattern(toks) {66 return formatDate(v, toks, patternNames(sec, loc))67 }68 if l, ok := layoutFraction(toks); ok {69 return formatFraction(math.Abs(v), neg, l, loc)70 }71 return formatNumber(math.Abs(v), neg, toks, loc)72}7374// patternNames are the names of months and days a date pattern shows:75// those of the locale its [$-407] tag names, as in Excel, or else loc's.76func patternNames(sec string, loc *locale.Locale) *locale.Names {77 for rest := sec; ; {78 i := strings.Index(rest, "[$")79 if i < 0 {80 return loc.Names()81 }82 rest = rest[i+2:]83 end := strings.IndexByte(rest, ']')84 if end < 0 {85 return loc.Names()86 }87 _, hex, ok := strings.Cut(rest[:end], "-")88 if id, err := strconv.ParseUint(hex, 16, 32); ok && err == nil {89 if l, found := locale.ByLCID(uint16(id)); found {90 return l.Names()91 }92 }93 rest = rest[end:]94 }95}9697// splitSections splits a pattern on ; outside quotes and escapes.98func splitSections(pat string) []string {99 var secs []string100 start, inQuote := 0, false101 for i := 0; i < len(pat); i++ {102 switch c := pat[i]; {103 case c == '"':104 inQuote = !inQuote105 case inQuote:106 case c == '\\':107 i++108 case c == ';':109 secs = append(secs, pat[start:i])110 start = i + 1111 }112 }113 return append(secs, pat[start:])114}115116// punctuation are the one-character tokens other than digit placeholders,117// by character; ptLit for none. Arrays rather than maps: patterns are118// lexed for every formatted cell drawn.119var punctuation = [256]ptKind{'.': ptDot, ',': ptComma, '%': ptPercent, '@': ptText}120121// words are the tokens spelled with letters, matched ignoring case.122var words = []struct {123 word string124 tok ptok125}{126 {"am/pm", ptok{kind: ptAMPM, s: "AM/PM"}},127 {"a/p", ptok{kind: ptAMPM, s: "A/P"}},128 {"general", ptok{kind: ptGeneral}},129}130131// dateLetters are the letters that repeat to make date and time tokens.132var dateLetters = [256]ptKind{'y': ptYear, 'm': ptMonth, 'd': ptDay, 'h': ptHour, 's': ptSecond}133134// patternLexer splits one section of a pattern into tokens.135type patternLexer struct {136 sec string137 lower string // sec with ASCII letters lower-cased, at the same byte offsets138 i int139 toks []ptok140}141142func lexPattern(sec string) []ptok {143 lx := patternLexer{sec: sec, lower: asciiLower(sec)}144 for lx.i < len(sec) {145 lx.next()146 }147 return lx.toks148}149150// emit adds t, which was written in the next width bytes.151func (lx *patternLexer) emit(t ptok, width int) {152 lx.toks = append(lx.toks, t)153 lx.i += width154}155156func (lx *patternLexer) lit(s string, width int) { lx.emit(ptok{kind: ptLit, s: s}, width) }157158func (lx *patternLexer) next() {159 c, lc := lx.sec[lx.i], lx.lower[lx.i]160 switch {161 case c == '"':162 lx.quoted()163 case c == '[':164 lx.bracketed()165 case (c == '\\' || c == '_' || c == '*') && lx.i+1 < len(lx.sec):166 lx.escaped(c)167 case c == '0' || c == '#' || c == '?':168 lx.emit(ptok{kind: ptDigit, s: lx.sec[lx.i : lx.i+1]}, 1)169 case punctuation[c] != ptLit:170 lx.emit(ptok{kind: punctuation[c]}, 1)171 case lc == 'e' && lx.i+1 < len(lx.sec) && (lx.sec[lx.i+1] == '+' || lx.sec[lx.i+1] == '-'):172 lx.emit(ptok{kind: ptExp, s: lx.sec[lx.i+1 : lx.i+2]}, 2)173 case lx.word(): // emitted174 case dateLetters[lc] != ptLit:175 j := lx.i176 for j < len(lx.sec) && lx.lower[j] == lc {177 j++178 }179 lx.emit(ptok{kind: dateLetters[lc], s: lx.sec[lx.i:j], n: j - lx.i}, j-lx.i)180 default:181 _, w := utf8.DecodeRuneInString(lx.sec[lx.i:])182 lx.lit(lx.sec[lx.i:lx.i+w], w)183 }184}185186// quoted reads "text", shown as is; an unclosed quote runs to the end.187func (lx *patternLexer) quoted() {188 end := strings.IndexByte(lx.sec[lx.i+1:], '"')189 if end < 0 {190 end = len(lx.sec) - lx.i - 1191 }192 lx.lit(lx.sec[lx.i+1:lx.i+1+end], end+2)193}194195// escaped reads the character after \ (shown as is), _ (a space as wide196// as it) or * (repeated to fill the cell).197func (lx *patternLexer) escaped(c byte) {198 _, w := utf8.DecodeRuneInString(lx.sec[lx.i+1:])199 next := lx.sec[lx.i+1 : lx.i+1+w]200 switch c {201 case '\\':202 lx.lit(next, 1+w)203 case '_':204 lx.lit(" ", 1+w)205 default:206 lx.emit(ptok{kind: ptFill, s: next}, 1+w)207 }208}209210// bracketed reads [h], [mm] and [ss] (elapsed time) and [$€-407]211// (a currency symbol). Colors and conditions are ignored; an unclosed212// bracket is literal text.213func (lx *patternLexer) bracketed() {214 end := strings.IndexByte(lx.sec[lx.i:], ']')215 if end < 0 {216 lx.lit(lx.sec[lx.i:], len(lx.sec)-lx.i)217 return218 }219 inner := lx.lower[lx.i+1 : lx.i+end]220 switch {221 case inner != "" && strings.Trim(inner, inner[:1]) == "" && strings.ContainsAny(inner[:1], "hms"):222 lx.toks = append(lx.toks, ptok{kind: ptElapsed, s: inner[:1], n: len(inner)})223 case strings.HasPrefix(inner, "$"):224 sym, _, _ := strings.Cut(lx.sec[lx.i+2:lx.i+end], "-")225 lx.toks = append(lx.toks, ptok{kind: ptLit, s: sym})226 }227 lx.i += end + 1228}229230// word reads AM/PM, A/P or General, reporting whether one was there.231func (lx *patternLexer) word() bool {232 for _, w := range words {233 if strings.HasPrefix(lx.lower[lx.i:], w.word) {234 lx.emit(w.tok, len(w.word))235 return true236 }237 }238 return false239}240241// asciiLower lower-cases ASCII letters only, keeping byte offsets.242func asciiLower(s string) string {243 b := []byte(s)244 for i, c := range b {245 if c >= 'A' && c <= 'Z' {246 b[i] = c + 'a' - 'A'247 }248 }249 return string(b)250}251252// isDatePattern reports whether a section formats dates and times.253func isDatePattern(toks []ptok) bool {254 for _, t := range toks {255 switch t.kind {256 case ptYear, ptMonth, ptDay, ptHour, ptSecond, ptAMPM, ptElapsed:257 return true258 }259 }260 return false261}262263// hasKind reports whether any token is of kind k.264func hasKind(toks []ptok, k ptKind) bool {265 for _, t := range toks {266 if t.kind == k {267 return true268 }269 }270 return false271}