The pattern renderer, Sheets' General form, rounding on 15 significant
digits and the serial-day calendar need nothing from cells, so they
become their own package with the engine depending on it. The pattern
lexer is table-driven and the number and date renderers are split into
a layout and a writer per token, bringing them under the cognitive
complexity limit. Placeholders after General ("General.00") no longer
panic with an index out of range.- commit
- 462d874a57e6451c3e0cabc367b7944ce82a2ff2
- parent
- c0e3923663f7f260444e21041b0f89d60fd24a1e
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 107 | +++++ |
| A | internal/ | 218 | ++++++++++ |
| A | internal/ | 92 | ++++ |
| A | internal/ | 258 | +++++++++++ |
| A | internal/ | 79 | +++ |
| A | internal/ | 237 | ++++++++++ |
| A | internal/ | 164 | +++++++ |
| M | internal/ | 104 | +---- |
| M | internal/ | 15 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 58 | +-- |
| M | internal/ | 72 | --- |
| M | internal/ | 8 | +- |
| M | internal/ | 38 | +- |
| M | internal/ | 8 | +- |
| M | internal/ | 93 | +---- |
| D | internal/ | 678 | ------------------------------ |
| M | internal/ | 18 | +- |
@@ -0,0 +1,107 @@
1+package numfmt2+3+import (4+ "math"5+ "time"6+)7+8+// Dates are serial numbers as in Sheets: whole days since 1899-12-309+// (day 0), with the time of day as the fraction. Unlike Excel there is no10+// fictitious 1900-02-29, and dates before 1900 are negative.11+12+var (13+ MonthNames = [...]string{"January", "February", "March", "April", "May", "June", "July",14+ "August", "September", "October", "November", "December"}15+ DayNames = [...]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}16+)17+18+// epochDays is 1899-12-30 as days since 0000-03-01 (see daysFromCivil).19+var epochDays = daysFromCivil(1899, 12, 30)20+21+// daysFromCivil counts days since 0000-03-01 in the proleptic Gregorian22+// calendar (Howard Hinnant's algorithm). Months outside 1-12 normalize.23+func daysFromCivil(y, m, d int) int {24+ y, m = AddMonths(y, m, 0)25+ if m <= 2 {26+ y--27+ }28+ era := floorDivInt(y, 400)29+ yoe := y - era*40030+ mp := (m + 9) % 1231+ doy := (153*mp+2)/5 + d - 132+ doe := yoe*365 + yoe/4 - yoe/100 + doy33+ return era*146097 + doe34+}35+36+// Civil converts a serial day number to year, month and day.37+func Civil(serial int64) (y, m, d int) {38+ z := int(serial) + epochDays39+ era := floorDivInt(z, 146097)40+ doe := z - era*14609741+ yoe := (doe - doe/1460 + doe/36524 - doe/146096) / 36542+ y = yoe + era*40043+ doy := doe - (365*yoe + yoe/4 - yoe/100)44+ mp := (5*doy + 2) / 15345+ d = doy - (153*mp+2)/5 + 146+ m = (mp+2)%12 + 147+ if m <= 2 {48+ y++49+ }50+ return y, m, d51+}52+53+// DateSerial returns the serial of a date; month and day overflow into54+// the next month or year, as DATE() allows.55+func DateSerial(y, m, d int) float64 {56+ return float64(daysFromCivil(y, m, 1) + d - 1 - epochDays)57+}58+59+// TimeSerial is the fraction of a day for a time of day.60+func TimeSerial(h, m int, s float64) float64 {61+ return (float64(h)*3600 + float64(m)*60 + s) / 8640062+}63+64+// AddMonths adds n months to month m of year y, returning a month from65+// 1 to 12 and the year it falls in. m itself may be out of range.66+func AddMonths(y, m, n int) (int, int) {67+ m += n68+ y += floorDivInt(m-1, 12)69+ m = ((m-1)%12+12)%12 + 170+ return y, m71+}72+73+// DaysIn is the number of days in month m of year y.74+func DaysIn(y, m int) int {75+ return int(DateSerial(y, m+1, 1) - DateSerial(y, m, 1))76+}77+78+// Weekday returns 0 for Sunday through 6 for Saturday. Day 0 was a79+// Saturday.80+func Weekday(serial int64) int {81+ return int(((serial+6)%7 + 7) % 7)82+}83+84+// SerialOf converts a time to a serial in its own time zone.85+func SerialOf(t time.Time) float64 {86+ h, m, s := t.Clock()87+ return DateSerial(t.Year(), int(t.Month()), t.Day()) +88+ TimeSerial(h, m, float64(s)+float64(t.Nanosecond())/1e9)89+}90+91+// SplitSerial returns the date and time parts of a serial, rounded to92+// the second.93+func SplitSerial(v float64) (days int64, secs int) {94+ total := int64(math.Round(v * 86400))95+ days = floorDiv(total, 86400)96+ return days, int(total - days*86400)97+}98+99+func floorDiv(a, b int64) int64 {100+ q := a / b101+ if (a%b != 0) && ((a < 0) != (b < 0)) {102+ q--103+ }104+ return q105+}106+107+func floorDivInt(a, b int) int { return int(floorDiv(int64(a), int64(b))) }@@ -0,0 +1,218 @@
1+package numfmt2+3+import (4+ "math"5+ "strconv"6+ "strings"7+)8+9+// clock is a serial broken into the fields date and time patterns show.10+type clock struct {11+ toks []ptok12+ year, month, day int13+ weekday int // 0 for Sunday14+ hour, minute, second int15+ ampm bool // hours count 1 to 1216+ elapsed int64 // whole seconds since day 0, for [h], [m] and [s]17+ fracDigits int // decimals of a second shown18+ sub int // the fraction of the second, in fracDigits digits19+}20+21+func newClock(v float64, toks []ptok) clock {22+ c := clock{toks: toks, ampm: hasKind(toks, ptAMPM), fracDigits: secondDecimals(toks)}23+ unit := math.Pow(10, float64(c.fracDigits))24+ ticks := int64(math.Round(v * 86400 * unit)) // in 10^-fracDigits seconds25+ perDay := int64(86400 * unit)26+ days := floorDiv(ticks, perDay)27+ inDay := ticks - days*perDay28+ secs := inDay / int64(unit)29+ c.sub = int(inDay % int64(unit))30+ c.elapsed = ticks / int64(unit)31+ c.year, c.month, c.day = Civil(days)32+ c.weekday = Weekday(days)33+ c.hour, c.minute, c.second = int(secs/3600), int(secs/60%60), int(secs%60)34+ return c35+}36+37+// secondDecimals is how many decimals of a second a pattern shows: .0,38+// .00 or .000 right after seconds.39+func secondDecimals(toks []ptok) int {40+ n := 041+ for i, t := range toks {42+ seconds := t.kind == ptSecond || t.kind == ptElapsed && t.s == "s"43+ if !seconds || i+1 >= len(toks) || toks[i+1].kind != ptDot {44+ continue45+ }46+ zeros := 047+ for j := i + 2; j < len(toks) && toks[j].kind == ptDigit && toks[j].s == "0"; j++ {48+ zeros++49+ }50+ n = max(n, min(zeros, 3))51+ }52+ return n53+}54+55+// formatDate renders serial v as a date, time or duration.56+func formatDate(v float64, toks []ptok) string {57+ var b strings.Builder58+ if v < 0 && hasKind(toks, ptElapsed) {59+ b.WriteByte('-') // a negative duration60+ v = -v61+ }62+ c := newClock(v, toks)63+ for i := range toks {64+ c.write(&b, i)65+ }66+ return b.String()67+}68+69+// write writes token i of the pattern.70+func (c *clock) write(b *strings.Builder, i int) {71+ switch t := c.toks[i]; t.kind {72+ case ptLit:73+ b.WriteString(t.s)74+ case ptYear:75+ if t.n <= 2 {76+ b.WriteString(padN(c.year%100, 2))77+ } else {78+ b.WriteString(strconv.Itoa(c.year))79+ }80+ case ptMonth:81+ b.WriteString(c.monthText(t.n, i))82+ case ptDay:83+ b.WriteString(c.dayText(t.n))84+ case ptHour:85+ b.WriteString(padN(c.hour12(), t.n))86+ case ptSecond:87+ b.WriteString(padN(c.second, t.n))88+ case ptElapsed:89+ b.WriteString(c.elapsedText(t))90+ case ptAMPM:91+ b.WriteString(c.meridiem(t.s))92+ case ptDot:93+ b.WriteByte('.')94+ if secondsFraction(c.toks, i) {95+ b.WriteString(padN(c.sub, c.fracDigits))96+ }97+ case ptDigit:98+ // Fraction-of-second digits are written with the dot.99+ if !afterSecondsDot(c.toks, i) {100+ b.WriteString(t.s)101+ }102+ case ptComma:103+ b.WriteByte(',')104+ case ptPercent:105+ b.WriteByte('%')106+ }107+}108+109+// monthText writes m to mmmmm at token i: minutes (see isMinute), the110+// month's number, or its name abbreviated to three letters, one, or111+// spelled out.112+func (c *clock) monthText(n, i int) string {113+ switch {114+ case n <= 2 && isMinute(c.toks, i):115+ return padN(c.minute, n)116+ case n <= 2:117+ return padN(c.month, n)118+ case n == 3:119+ return MonthNames[c.month-1][:3]120+ case n == 5:121+ return MonthNames[c.month-1][:1]122+ }123+ return MonthNames[c.month-1]124+}125+126+// dayText writes d to dddd: the day of the month or of the week.127+func (c *clock) dayText(n int) string {128+ switch n {129+ case 1, 2:130+ return padN(c.day, n)131+ case 3:132+ return DayNames[c.weekday][:3]133+ }134+ return DayNames[c.weekday]135+}136+137+func (c *clock) hour12() int {138+ if c.ampm {139+ return (c.hour+11)%12 + 1140+ }141+ return c.hour142+}143+144+// elapsedText writes [h], [m] or [s]: the whole duration in that unit.145+func (c *clock) elapsedText(t ptok) string {146+ switch t.s {147+ case "h":148+ return padN(int(c.elapsed/3600), t.n)149+ case "m":150+ return padN(int(c.elapsed/60), t.n)151+ }152+ return padN(int(c.elapsed), t.n)153+}154+155+// meridiem writes AM/PM or A/P.156+func (c *clock) meridiem(style string) string {157+ pm := c.hour >= 12158+ switch {159+ case style == "A/P" && pm:160+ return "P"161+ case style == "A/P":162+ return "A"163+ case pm:164+ return "PM"165+ }166+ return "AM"167+}168+169+// secondsFraction reports whether the dot at i starts fractions of a170+// second (s.00).171+func secondsFraction(toks []ptok, i int) bool {172+ return i > 0 && i+1 < len(toks) && toks[i+1].kind == ptDigit &&173+ (toks[i-1].kind == ptSecond || toks[i-1].kind == ptElapsed && toks[i-1].s == "s")174+}175+176+// afterSecondsDot reports whether the digit at i belongs to fractions of177+// a second, which are written with the dot.178+func afterSecondsDot(toks []ptok, i int) bool {179+ j := i - 1180+ for j >= 0 && toks[j].kind == ptDigit {181+ j--182+ }183+ return j >= 0 && toks[j].kind == ptDot && secondsFraction(toks, j)184+}185+186+// isMinute resolves m and mm: minutes right after hours or before187+// seconds, otherwise the month.188+func isMinute(toks []ptok, i int) bool {189+ for j := i - 1; j >= 0; j-- {190+ switch toks[j].kind {191+ case ptHour:192+ return true193+ case ptElapsed:194+ return toks[j].s == "h"195+ case ptYear, ptMonth, ptDay, ptSecond:196+ j = -1197+ }198+ }199+ for j := i + 1; j < len(toks); j++ {200+ switch toks[j].kind {201+ case ptSecond:202+ return true203+ case ptElapsed:204+ return toks[j].s == "s"205+ case ptYear, ptMonth, ptDay, ptHour:206+ return false207+ }208+ }209+ return false210+}211+212+func padN(v, n int) string {213+ s := strconv.Itoa(v)214+ if len(s) < n {215+ s = strings.Repeat("0", n-len(s)) + s216+ }217+ return s218+}@@ -0,0 +1,92 @@
1+package numfmt2+3+import "strings"4+5+// AdjustDecimals adds (delta > 0) or removes zeros after the decimal6+// point of every section of a number pattern, as Sheets' Increase and7+// Decrease decimal places do. Date and time patterns are returned8+// unchanged.9+func AdjustDecimals(pat string, delta int) string {10+ secs := splitSections(pat)11+ for i, sec := range secs {12+ if isDatePattern(lexPattern(sec)) {13+ return pat14+ }15+ secs[i] = adjustSection(sec, delta)16+ }17+ return strings.Join(secs, ";")18+}19+20+func adjustSection(sec string, delta int) string {21+ // Find the last digit placeholder outside quotes; decimals follow a '.'.22+ last, dot := -1, -123+ inQuote := false24+ for i := 0; i < len(sec); i++ {25+ switch c := sec[i]; {26+ case c == '"':27+ inQuote = !inQuote28+ case inQuote:29+ case c == '\\' || c == '_' || c == '*':30+ i++31+ case c == '.' && dot < 0:32+ dot = i33+ case c == '0' || c == '#' || c == '?':34+ last = i35+ case c == 'E' || c == 'e':36+ if last >= 0 {37+ // Stop at an exponent: its digits aren't decimals.38+ i = len(sec)39+ }40+ }41+ }42+ if last < 0 {43+ return sec44+ }45+ switch {46+ case delta > 0 && dot < 0:47+ return sec[:last+1] + "." + strings.Repeat("0", delta) + sec[last+1:]48+ case delta > 0:49+ return sec[:last+1] + strings.Repeat("0", delta) + sec[last+1:]50+ case dot < 0 || last < dot:51+ return sec52+ }53+ decimals := last - dot54+ remove := min(-delta, decimals)55+ if remove == decimals { // drop the point too56+ return sec[:dot] + sec[last+1:]57+ }58+ return sec[:last+1-remove] + sec[last+1:]59+}60+61+// Accounting lays out Sheets' Accounting format with dec decimals in62+// width columns: the $ at the left, the number right-aligned with room63+// for a closing parenthesis, negatives in parentheses and zero as a dash.64+// It reports false when the number doesn't fit.65+func Accounting(v float64, dec, width int) (string, bool) {66+ whole, frac := Fixed(v, dec, HalfUp)67+ var num string68+ switch {69+ case strings.Trim(whole+frac, "0") == "":70+ num = "-" + strings.Repeat(" ", dec) + " "71+ case v < 0:72+ num = "(" + Format(-v, "#,##0"+decimals(dec)) + ")"73+ default:74+ num = Format(v, "#,##0"+decimals(dec)) + " "75+ }76+ gap := width - 1 - len(num)77+ switch {78+ case gap < 0:79+ return "", false80+ case gap >= 1:81+ // Leave a column of padding before the $, like other cells.82+ return " $" + strings.Repeat(" ", gap-1) + num, true83+ }84+ return "$" + strings.Repeat(" ", gap) + num, true85+}86+87+func decimals(n int) string {88+ if n <= 0 {89+ return ""90+ }91+ return "." + strings.Repeat("0", n)92+}@@ -0,0 +1,258 @@
1+package numfmt2+3+import (4+ "math"5+ "strconv"6+ "strings"7+)8+9+// numLayout is where a number section puts each part of the number: the10+// token indexes of its integer, decimal and exponent placeholders, its11+// decimal point and exponent, and how it scales the value.12+type numLayout struct {13+ toks []ptok14+ intPH, fracPH, expPH []int15+ dot, exp int // token index, or -1 for none16+ percents int // each % multiplies by 10017+ general bool18+ // grouped is set by commas between the integer placeholders, unless19+ // literals sit among them too (a phone number pattern).20+ grouped bool21+ scale int // commas right after the last integer placeholder divide by 1000 each22+}23+24+func layoutNumber(toks []ptok) numLayout {25+ l := numLayout{toks: toks, dot: -1, exp: -1}26+ for i, t := range toks {27+ switch t.kind {28+ case ptDigit:29+ l.addPlaceholder(i)30+ case ptDot:31+ if l.dot < 0 && l.exp < 0 {32+ l.dot = i33+ }34+ case ptExp:35+ if l.exp < 0 {36+ l.exp = i37+ }38+ case ptPercent:39+ l.percents++40+ case ptGeneral:41+ l.general = true42+ }43+ }44+ if len(l.intPH) > 0 {45+ first, last := l.intPH[0], l.intPH[len(l.intPH)-1]46+ commas, literals := false, false47+ for i := first + 1; i < last; i++ {48+ commas = commas || toks[i].kind == ptComma49+ literals = literals || toks[i].kind == ptLit50+ }51+ l.grouped = commas && !literals52+ for i := last + 1; i < len(toks) && toks[i].kind == ptComma; i++ {53+ l.scale++54+ }55+ }56+ return l57+}58+59+// addPlaceholder files the digit placeholder at token i under the part of60+// the number it falls in.61+func (l *numLayout) addPlaceholder(i int) {62+ switch {63+ case l.exp >= 0:64+ l.expPH = append(l.expPH, i)65+ case l.dot >= 0:66+ l.fracPH = append(l.fracPH, i)67+ default:68+ l.intPH = append(l.intPH, i)69+ }70+}71+72+// numDigits are the digits of a number as a section shows it.73+type numDigits struct {74+ whole string // integer digits, "" for zero75+ frac string // one digit per decimal placeholder76+ exp string // exponent digits, zero-padded to the exponent's placeholders77+ expNeg bool78+}79+80+// digits rounds v to what the layout shows.81+func (l *numLayout) digits(v float64) numDigits {82+ switch {83+ case l.general && len(l.intPH) == 0:84+ // General shows its own decimals; placeholders after it get zeros.85+ return numDigits{whole: General(v), frac: strings.Repeat("0", len(l.fracPH))}86+ case l.exp >= 0:87+ return l.scientific(v)88+ }89+ whole, frac := Fixed(v, len(l.fracPH), HalfUp)90+ return numDigits{whole: whole, frac: frac}91+}92+93+// scientific splits v into a mantissa of one integer digit and an94+// exponent.95+func (l *numLayout) scientific(v float64) numDigits {96+ e := 097+ if v != 0 {98+ e = int(math.Floor(math.Log10(v)))99+ }100+ whole, frac := Fixed(v/math.Pow(10, float64(e)), len(l.fracPH), HalfUp)101+ if whole == "10" { // rounding carried into another digit102+ e++103+ whole, frac = Fixed(v/math.Pow(10, float64(e)), len(l.fracPH), HalfUp)104+ }105+ exp := strconv.Itoa(max(e, -e))106+ if len(exp) < len(l.expPH) {107+ exp = strings.Repeat("0", len(l.expPH)-len(exp)) + exp108+ }109+ return numDigits{whole: whole, frac: frac, exp: exp, expNeg: e < 0}110+}111+112+// place spreads the digits over the integer and decimal placeholders,113+// returning each placeholder's text by token index.114+func (l *numLayout) place(d numDigits) []string {115+ out := make([]string, len(l.toks))116+ // Right to left; the leftmost placeholder takes any remaining digits.117+ rem := d.whole118+ for k := len(l.intPH) - 1; k >= 0; k-- {119+ i := l.intPH[k]120+ switch ph := l.toks[i].s; {121+ case k == 0 && rem != "":122+ out[i], rem = rem, ""123+ case rem != "":124+ out[i], rem = rem[len(rem)-1:], rem[:len(rem)-1]125+ case ph == "0":126+ out[i] = "0"127+ case ph == "?":128+ out[i] = " "129+ }130+ }131+ // Trailing zeros disappear under # and turn to spaces under ?.132+ trimming := true133+ for k := len(l.fracPH) - 1; k >= 0; k-- {134+ i := l.fracPH[k]135+ digit := d.frac[k : k+1]136+ switch ph := l.toks[i].s; {137+ case trimming && digit == "0" && ph == "#":138+ case trimming && digit == "0" && ph == "?":139+ out[i] = " "140+ default:141+ trimming = false142+ out[i] = digit143+ }144+ }145+ return out146+}147+148+// formatNumber renders |v| with a number section; neg adds a minus sign.149+func formatNumber(v float64, neg bool, toks []ptok) string {150+ l := layoutNumber(toks)151+ for range l.percents {152+ v *= 100153+ }154+ for range l.scale {155+ v /= 1000156+ }157+ d := l.digits(v)158+ if neg && strings.Trim(d.whole+d.frac, "0") == "" {159+ neg = false // don't show -0.00160+ }161+ placed := l.place(d)162+ var b strings.Builder163+ if neg {164+ b.WriteByte('-')165+ }166+ for i, t := range toks {167+ l.write(&b, i, t, d, placed)168+ }169+ return b.String()170+}171+172+// write writes token i of the section.173+func (l *numLayout) write(b *strings.Builder, i int, t ptok, d numDigits, placed []string) {174+ switch t.kind {175+ case ptLit:176+ b.WriteString(t.s)177+ case ptDigit:178+ l.writeDigit(b, i, d, placed)179+ case ptDot:180+ if i == l.dot {181+ b.WriteByte('.')182+ }183+ case ptPercent:184+ b.WriteByte('%')185+ case ptExp:186+ if i == l.exp {187+ b.WriteString(expSign(t.s, d.expNeg))188+ }189+ case ptGeneral:190+ if len(l.intPH) == 0 {191+ b.WriteString(d.whole)192+ }193+ case ptComma:194+ // Grouping and scaling commas are consumed; others are literal.195+ if len(l.intPH) == 0 || i < l.intPH[0] || (l.dot >= 0 && i > l.dot) {196+ b.WriteByte(',')197+ }198+ }199+}200+201+// writeDigit writes the digit placeholder at token i: the integer part202+// grouped in thousands at the first integer placeholder, the exponent at203+// the first exponent placeholder, otherwise the placeholder's own digit.204+func (l *numLayout) writeDigit(b *strings.Builder, i int, d numDigits, placed []string) {205+ inInt := len(l.intPH) > 0 && i >= l.intPH[0] && i <= l.intPH[len(l.intPH)-1]206+ switch {207+ case inInt && l.grouped && i == l.intPH[0]:208+ var digits strings.Builder209+ for _, p := range l.intPH {210+ digits.WriteString(placed[p])211+ }212+ b.WriteString(group(digits.String()))213+ case inInt:214+ if !l.grouped {215+ b.WriteString(placed[i])216+ }217+ case len(l.expPH) > 0 && i == l.expPH[0]:218+ b.WriteString(d.exp)219+ case len(l.expPH) > 0 && i > l.expPH[0]:220+ default:221+ b.WriteString(placed[i])222+ }223+}224+225+// expSign writes an exponent marker: E- for negative exponents, E+ when226+// the pattern asks for the sign.227+func expSign(sign string, neg bool) string {228+ switch {229+ case neg:230+ return "E-"231+ case sign == "+":232+ return "E+"233+ }234+ return "E"235+}236+237+// group inserts thousands separators into a run of digits (and leading238+// spaces from ? placeholders).239+func group(s string) string {240+ lead := len(s) - len(strings.TrimLeft(s, " "))241+ digits := s[lead:]242+ if len(digits) <= 3 {243+ return s244+ }245+ var b strings.Builder246+ b.WriteString(s[:lead])247+ first := len(digits) % 3248+ if first > 0 {249+ b.WriteString(digits[:first])250+ }251+ for i := first; i < len(digits); i += 3 {252+ if i > 0 {253+ b.WriteByte(',')254+ }255+ b.WriteString(digits[i : i+3])256+ }257+ return b.String()258+}@@ -0,0 +1,79 @@
1+package numfmt2+3+import "testing"4+5+func TestFormat(t *testing.T) {6+ tests := []struct {7+ v float648+ pat string9+ want string10+ }{11+ {1234.5, "#,##0.00", "1,234.50"},12+ {-1234.5, "#,##0.00", "-1,234.50"},13+ {0.5, "#,##0", "1"},14+ {0, "#,##0.00", "0.00"},15+ {1234567.891, "#,##0", "1,234,568"},16+ {1.005, "0.00", "1.01"}, // 15 significant digits, not binary17+ {2.5, "0", "3"},18+ {-2.5, "0", "-3"},19+ {-0.001, "0.00", "0.00"}, // no negative zero20+ {0.1234, "0%", "12%"},21+ {0.1234, "0.00%", "12.34%"},22+ {1234.5, "0.00E+00", "1.23E+03"},23+ {0.000123, "0.00E+00", "1.23E-04"},24+ {9.999, "0.00E+00", "1.00E+01"},25+ {0, "0.00E+00", "0.00E+00"},26+ {1234.5, `"$"#,##0.00`, "$1,234.50"},27+ {-1234.5, `"$"#,##0.00`, "-$1,234.50"},28+ {-1234.5, "#,##0.00;(#,##0.00)", "(1,234.50)"},29+ {0, "#,##0;(#,##0);\"zero\"", "zero"},30+ {1.5, "0.0#", "1.5"},31+ {1.25, "0.0#", "1.25"},32+ {1.5, "0.??", "1.5 "},33+ {5, "000", "005"},34+ {12345, "#,##0,", "12"},35+ {1234.5, "#,##0.00 \"kg\"", "1,234.50 kg"},36+ {0.5, "#.##", ".5"},37+ {12.5, "General", "12.5"},38+ {5551234, "000-0000", "555-1234"},39+ {1.5, "General.00", "1.5.00"}, // placeholders after General get zeros40+ // Dates and times: 46291 is 2026-09-26, a Saturday.41+ {46291, "m/d/yyyy", "9/26/2026"},42+ {46291, "yyyy-mm-dd", "2026-09-26"},43+ {46291, "mmm d, yyyy", "Sep 26, 2026"},44+ {46291, "dddd, mmmm d", "Saturday, September 26"},45+ {46291, "ddd mmmmm yy", "Sat S 26"},46+ {46291.6041666667, "h:mm am/pm", "2:30 PM"},47+ {46291.6041666667, "hh:mm:ss", "14:30:00"},48+ {0.5, "h:mm:ss AM/PM", "12:00:00 PM"},49+ {0, "h:mm a/p", "12:00 A"},50+ {1.0423611111, "[h]:mm:ss", "25:01:00"},51+ {-0.5, "[h]:mm", "-12:00"},52+ {0.000011574, "h:mm:ss.00", "0:00:01.00"},53+ {0.0000173611, "mm:ss.0", "00:01.5"},54+ {46291.99999999, "m/d/yyyy h:mm", "9/27/2026 0:00"},55+ {0, "m/d/yyyy", "12/30/1899"},56+ {-1, "yyyy-mm-dd", "1899-12-29"},57+ {61, "yyyy-mm-dd", "1900-03-01"},58+ {2958465, "yyyy-mm-dd", "9999-12-31"},59+ }60+ for _, tt := range tests {61+ if got := Format(tt.v, tt.pat); got != tt.want {62+ t.Errorf("Format(%v, %q) = %q, want %q", tt.v, tt.pat, got, tt.want)63+ }64+ }65+}66+67+func TestAdjustDecimals(t *testing.T) {68+ for _, tt := range []struct{ pat, want string }{69+ {"0.00", "0.000"}, {"#,##0", "#,##0.0"}, {`"$"#,##0.00_);("$"#,##0.00)`, `"$"#,##0.000_);("$"#,##0.000)`},70+ {"0.00E+00", "0.000E+00"}, {"0%", "0.0%"},71+ } {72+ if got := AdjustDecimals(tt.pat, 1); got != tt.want {73+ t.Errorf("AdjustDecimals(%q, 1) = %q, want %q", tt.pat, got, tt.want)74+ }75+ }76+ if got := AdjustDecimals("0.0", -1); got != "0" {77+ t.Errorf("AdjustDecimals(0.0, -1) = %q", got)78+ }79+}@@ -0,0 +1,237 @@
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.6+package numfmt7+8+import (9+ "math"10+ "strings"11+ "unicode/utf8"12+)13+14+// Number format patterns: "#,##0.00", "0.0%", "0.00E+00",15+// "$#,##0;($#,##0)", "m/d/yyyy", "h:mm am/pm", "[h]:mm:ss". Up to four16+// ;-separated sections apply to positive, negative, zero and text values.17+18+type ptKind uint819+20+const (21+ ptLit ptKind = iota22+ ptDigit // 0, # or ?23+ ptDot // decimal point24+ ptComma // thousands separator or scaling25+ ptPercent //26+ ptExp // E+ or E-27+ ptGeneral // "General"28+ ptText // @29+ ptFill // *x: repeat x to fill the cell30+ ptYear // y, yy, yyyy31+ ptMonth // m..mmmmm: month, or minutes next to h or s32+ ptDay // d..dddd33+ ptHour // h, hh34+ ptSecond // s, ss35+ ptAMPM // AM/PM or A/P36+ ptElapsed // [h], [m], [s]37+ ptFrac // .0, .00 after seconds38+)39+40+type ptok struct {41+ kind ptKind42+ s string // literal text, digit placeholder char, or the token as written43+ n int // repeat count for date tokens44+}45+46+// Format renders v with a number format pattern, as TEXT() does.47+func Format(v float64, pat string) string {48+ secs := splitSections(pat)49+ sec, neg := secs[0], v < 050+ switch {51+ case len(secs) >= 3 && v == 0:52+ sec = secs[2]53+ case len(secs) >= 2 && v < 0 && strings.TrimSpace(secs[1]) != "":54+ sec, v, neg = secs[1], -v, false55+ }56+ toks := lexPattern(sec)57+ if isDatePattern(toks) {58+ return formatDate(v, toks)59+ }60+ return formatNumber(math.Abs(v), neg, toks)61+}62+63+// splitSections splits a pattern on ; outside quotes and escapes.64+func splitSections(pat string) []string {65+ var secs []string66+ start, inQuote := 0, false67+ for i := 0; i < len(pat); i++ {68+ switch c := pat[i]; {69+ case c == '"':70+ inQuote = !inQuote71+ case inQuote:72+ case c == '\\':73+ i++74+ case c == ';':75+ secs = append(secs, pat[start:i])76+ start = i + 177+ }78+ }79+ return append(secs, pat[start:])80+}81+82+// punctuation are the one-character tokens other than digit placeholders,83+// by character; ptLit for none. Arrays rather than maps: patterns are84+// lexed for every formatted cell drawn.85+var punctuation = [256]ptKind{'.': ptDot, ',': ptComma, '%': ptPercent, '@': ptText}86+87+// words are the tokens spelled with letters, matched ignoring case.88+var words = []struct {89+ word string90+ tok ptok91+}{92+ {"am/pm", ptok{kind: ptAMPM, s: "AM/PM"}},93+ {"a/p", ptok{kind: ptAMPM, s: "A/P"}},94+ {"general", ptok{kind: ptGeneral}},95+}96+97+// dateLetters are the letters that repeat to make date and time tokens.98+var dateLetters = [256]ptKind{'y': ptYear, 'm': ptMonth, 'd': ptDay, 'h': ptHour, 's': ptSecond}99+100+// patternLexer splits one section of a pattern into tokens.101+type patternLexer struct {102+ sec string103+ lower string // sec with ASCII letters lower-cased, at the same byte offsets104+ i int105+ toks []ptok106+}107+108+func lexPattern(sec string) []ptok {109+ lx := patternLexer{sec: sec, lower: asciiLower(sec)}110+ for lx.i < len(sec) {111+ lx.next()112+ }113+ return lx.toks114+}115+116+// emit adds t, which was written in the next width bytes.117+func (lx *patternLexer) emit(t ptok, width int) {118+ lx.toks = append(lx.toks, t)119+ lx.i += width120+}121+122+func (lx *patternLexer) lit(s string, width int) { lx.emit(ptok{kind: ptLit, s: s}, width) }123+124+func (lx *patternLexer) next() {125+ c, lc := lx.sec[lx.i], lx.lower[lx.i]126+ switch {127+ case c == '"':128+ lx.quoted()129+ case c == '[':130+ lx.bracketed()131+ case (c == '\\' || c == '_' || c == '*') && lx.i+1 < len(lx.sec):132+ lx.escaped(c)133+ case c == '0' || c == '#' || c == '?':134+ lx.emit(ptok{kind: ptDigit, s: lx.sec[lx.i : lx.i+1]}, 1)135+ case punctuation[c] != ptLit:136+ lx.emit(ptok{kind: punctuation[c]}, 1)137+ case lc == 'e' && lx.i+1 < len(lx.sec) && (lx.sec[lx.i+1] == '+' || lx.sec[lx.i+1] == '-'):138+ lx.emit(ptok{kind: ptExp, s: lx.sec[lx.i+1 : lx.i+2]}, 2)139+ case lx.word(): // emitted140+ case dateLetters[lc] != ptLit:141+ j := lx.i142+ for j < len(lx.sec) && lx.lower[j] == lc {143+ j++144+ }145+ lx.emit(ptok{kind: dateLetters[lc], s: lx.sec[lx.i:j], n: j - lx.i}, j-lx.i)146+ default:147+ _, w := utf8.DecodeRuneInString(lx.sec[lx.i:])148+ lx.lit(lx.sec[lx.i:lx.i+w], w)149+ }150+}151+152+// quoted reads "text", shown as is; an unclosed quote runs to the end.153+func (lx *patternLexer) quoted() {154+ end := strings.IndexByte(lx.sec[lx.i+1:], '"')155+ if end < 0 {156+ end = len(lx.sec) - lx.i - 1157+ }158+ lx.lit(lx.sec[lx.i+1:lx.i+1+end], end+2)159+}160+161+// escaped reads the character after \ (shown as is), _ (a space as wide162+// as it) or * (repeated to fill the cell).163+func (lx *patternLexer) escaped(c byte) {164+ _, w := utf8.DecodeRuneInString(lx.sec[lx.i+1:])165+ next := lx.sec[lx.i+1 : lx.i+1+w]166+ switch c {167+ case '\\':168+ lx.lit(next, 1+w)169+ case '_':170+ lx.lit(" ", 1+w)171+ default:172+ lx.emit(ptok{kind: ptFill, s: next}, 1+w)173+ }174+}175+176+// bracketed reads [h], [mm] and [ss] (elapsed time) and [$€-407]177+// (a currency symbol). Colors and conditions are ignored; an unclosed178+// bracket is literal text.179+func (lx *patternLexer) bracketed() {180+ end := strings.IndexByte(lx.sec[lx.i:], ']')181+ if end < 0 {182+ lx.lit(lx.sec[lx.i:], len(lx.sec)-lx.i)183+ return184+ }185+ inner := lx.lower[lx.i+1 : lx.i+end]186+ switch {187+ case inner != "" && strings.Trim(inner, inner[:1]) == "" && strings.ContainsAny(inner[:1], "hms"):188+ lx.toks = append(lx.toks, ptok{kind: ptElapsed, s: inner[:1], n: len(inner)})189+ case strings.HasPrefix(inner, "$"):190+ sym, _, _ := strings.Cut(lx.sec[lx.i+2:lx.i+end], "-")191+ lx.toks = append(lx.toks, ptok{kind: ptLit, s: sym})192+ }193+ lx.i += end + 1194+}195+196+// word reads AM/PM, A/P or General, reporting whether one was there.197+func (lx *patternLexer) word() bool {198+ for _, w := range words {199+ if strings.HasPrefix(lx.lower[lx.i:], w.word) {200+ lx.emit(w.tok, len(w.word))201+ return true202+ }203+ }204+ return false205+}206+207+// asciiLower lower-cases ASCII letters only, keeping byte offsets.208+func asciiLower(s string) string {209+ b := []byte(s)210+ for i, c := range b {211+ if c >= 'A' && c <= 'Z' {212+ b[i] = c + 'a' - 'A'213+ }214+ }215+ return string(b)216+}217+218+// isDatePattern reports whether a section formats dates and times.219+func isDatePattern(toks []ptok) bool {220+ for _, t := range toks {221+ switch t.kind {222+ case ptYear, ptMonth, ptDay, ptHour, ptSecond, ptAMPM, ptElapsed:223+ return true224+ }225+ }226+ return false227+}228+229+// hasKind reports whether any token is of kind k.230+func hasKind(toks []ptok, k ptKind) bool {231+ for _, t := range toks {232+ if t.kind == k {233+ return true234+ }235+ }236+ return false237+}@@ -0,0 +1,164 @@
1+package numfmt2+3+import (4+ "math"5+ "strconv"6+ "strings"7+)8+9+// Rounding is how Fixed and Round treat the digits they drop.10+type Rounding int11+12+const (13+ HalfUp Rounding = iota // ROUND and number formats14+ Up // away from zero: ROUNDUP15+ Down // toward zero: ROUNDDOWN16+)17+18+// Fixed renders |x| rounded to n decimal places (n may be negative) as19+// its integer digits (no leading zeros, "" for zero) and exactly n20+// decimal digits. It works on the 15 significant digits spreadsheets21+// show, so 1.005 rounds to 1.01 as users expect rather than to the 1.0022+// its binary value would give.23+func Fixed(x float64, n int, mode Rounding) (whole, frac string) {24+ x = math.Abs(x)25+ nFrac := max(n, 0)26+ if x == 0 || math.IsInf(x, 0) || math.IsNaN(x) {27+ return "", strings.Repeat("0", nFrac)28+ }29+ s := strconv.FormatFloat(x, 'e', 14, 64)30+ mant, e, _ := strings.Cut(s, "e")31+ exp, _ := strconv.Atoi(e)32+ d := []byte(strings.Replace(mant, ".", "", 1))33+ point := exp + 1 // digits before the decimal point34+ if point < 0 {35+ d = append([]byte(strings.Repeat("0", -point)), d...)36+ point = 037+ }38+ keep := point + n39+ if keep < 0 {40+ // Far smaller than the rounding unit.41+ if mode == Up {42+ return "1" + strings.Repeat("0", -n), strings.Repeat("0", nFrac)43+ }44+ return "", strings.Repeat("0", nFrac)45+ }46+ if keep < len(d) {47+ if roundsUp(d[keep:], mode) {48+ d, point = increment(d[:keep], point)49+ } else {50+ d = d[:keep]51+ }52+ }53+ for len(d) < point+nFrac {54+ d = append(d, '0')55+ }56+ whole = strings.TrimLeft(string(d[:point]), "0")57+ frac = string(d[point : point+nFrac])58+ return whole, frac59+}60+61+// roundsUp reports whether dropping the digits dropped rounds the kept62+// ones up.63+func roundsUp(dropped []byte, mode Rounding) bool {64+ switch mode {65+ case HalfUp:66+ return dropped[0] >= '5'67+ case Up:68+ return strings.Trim(string(dropped), "0") != ""69+ }70+ return false71+}72+73+// increment adds one to the last of the digits d, carrying; a carry out74+// of the first digit adds a digit before the point.75+func increment(d []byte, point int) ([]byte, int) {76+ for i := len(d) - 1; i >= 0; i-- {77+ if d[i] != '9' {78+ d[i]++79+ return d, point80+ }81+ d[i] = '0'82+ }83+ return append([]byte{'1'}, d...), point + 184+}85+86+// Round rounds x to places decimal places (negative places round to87+// tens, hundreds...) with mode, on x's 15 significant digits.88+func Round(x float64, places int, mode Rounding) float64 {89+ whole, frac := Fixed(x, places, mode)90+ s := whole91+ if s == "" {92+ s = "0"93+ }94+ if frac != "" {95+ s += "." + frac96+ }97+ r, _ := strconv.ParseFloat(s, 64)98+ if x < 0 {99+ r = -r100+ }101+ return r102+}103+104+// General renders a number the way Sheets shows it in General format and105+// in text: up to 15 significant digits, no exponent for ordinary106+// magnitudes.107+func General(v float64) string {108+ if v == 0 {109+ return "0"110+ }111+ r, _ := strconv.ParseFloat(strconv.FormatFloat(v, 'g', 15, 64), 64)112+ if a := math.Abs(r); a >= 1e21 || a < 1e-9 {113+ return strconv.FormatFloat(r, 'E', -1, 64)114+ }115+ return strconv.FormatFloat(r, 'f', -1, 64)116+}117+118+// GeneralFit renders v in General format within width columns: as many119+// digits as fit, rounding decimals first, then scientific notation, then120+// #s.121+func GeneralFit(v float64, width int) string {122+ if width <= 0 {123+ return ""124+ }125+ s := General(v)126+ if strings.ContainsRune(s, 'E') {127+ s = strconv.FormatFloat(v, 'f', -1, 64)128+ }129+ if len(s) <= width {130+ return s131+ }132+ if f, ok := fewerDecimals(v, s, width); ok {133+ return f134+ }135+ for prec := width; prec >= 0; prec-- {136+ if e := strconv.FormatFloat(v, 'E', prec, 64); len(e) <= width {137+ return e138+ }139+ }140+ return strings.Repeat("#", width)141+}142+143+// fewerDecimals rounds v, written in full as s, to the decimals that fit144+// in width, but never rounds a non-zero number down to zero.145+func fewerDecimals(v float64, s string, width int) (string, bool) {146+ dot := strings.IndexByte(s, '.')147+ if dot < 0 || dot > width {148+ return "", false149+ }150+ for prec := max(width-dot-1, 0); prec >= 0; prec-- {151+ f := strconv.FormatFloat(v, 'f', prec, 64)152+ if len(f) > width {153+ continue154+ }155+ if r, _ := strconv.ParseFloat(f, 64); r == 0 && v != 0 {156+ return "", false157+ }158+ if strings.Contains(f, ".") {159+ f = strings.TrimRight(strings.TrimRight(f, "0"), ".")160+ }161+ return f, true162+ }163+ return "", false164+}@@ -1,99 +1,19 @@
11 package sheet22 33 import (4- "math"54 "strconv"65 "strings"76 "time"8-)97 10-// Dates are serial numbers as in Sheets: whole days since 1899-12-3011-// (day 0), with the time of day as the fraction. Unlike Excel there is no12-// fictitious 1900-02-29, and dates before 1900 are negative.8+ "github.com/FelineStateMachine/012/internal/numfmt"9+)1310 14-// Now is the clock used by TODAY() and NOW(); tests replace it.11+// Now is the clock used by TODAY() and NOW(), and for the year of dates12+// typed without one; tests replace it.1513 var Now = time.Now1614 17-// epochDays is 1899-12-30 as days since 0000-03-01 (see daysFromCivil).18-var epochDays = daysFromCivil(1899, 12, 30)19-20-// daysFromCivil counts days since 0000-03-01 in the proleptic Gregorian21-// calendar (Howard Hinnant's algorithm). Months outside 1-12 normalize.22-func daysFromCivil(y, m, d int) int {23- y += floorDivInt(m-1, 12)24- m = (m-1)%12 + 125- if m <= 0 {26- m += 1227- }28- if m <= 2 {29- y--30- }31- era := floorDivInt(y, 400)32- yoe := y - era*40033- mp := (m + 9) % 1234- doy := (153*mp+2)/5 + d - 135- doe := yoe*365 + yoe/4 - yoe/100 + doy36- return era*146097 + doe37-}38-39-// civil converts a serial day number to year, month and day.40-func civil(serial int64) (y, m, d int) {41- z := int(serial) + epochDays42- era := floorDivInt(z, 146097)43- doe := z - era*14609744- yoe := (doe - doe/1460 + doe/36524 - doe/146096) / 36545- y = yoe + era*40046- doy := doe - (365*yoe + yoe/4 - yoe/100)47- mp := (5*doy + 2) / 15348- d = doy - (153*mp+2)/5 + 149- m = (mp+2)%12 + 150- if m <= 2 {51- y++52- }53- return y, m, d54-}55-56-// dateSerial returns the serial of a date; month and day overflow into57-// the next month or year, as DATE() allows.58-func dateSerial(y, m, d int) float64 {59- return float64(daysFromCivil(y, m, 1) + d - 1 - epochDays)60-}61-62-// timeSerial is the fraction of a day for a time of day.63-func timeSerial(h, m int, s float64) float64 {64- return (float64(h)*3600 + float64(m)*60 + s) / 8640065-}66-67-// weekday returns 0 for Sunday through 6 for Saturday. Day 0 was a68-// Saturday.69-func weekday(serial int64) int {70- return int(((serial+6)%7 + 7) % 7)71-}72-73-// serialOf converts a time to a serial in its own time zone.74-func serialOf(t time.Time) float64 {75- h, m, s := t.Clock()76- return dateSerial(t.Year(), int(t.Month()), t.Day()) +77- timeSerial(h, m, float64(s)+float64(t.Nanosecond())/1e9)78-}79-80-func floorDiv(a, b int64) int64 {81- q := a / b82- if (a%b != 0) && ((a < 0) != (b < 0)) {83- q--84- }85- return q86-}87-88-func floorDivInt(a, b int) int { return int(floorDiv(int64(a), int64(b))) }89-90-// splitSerial returns the date and time parts of a serial, rounded to91-// the second.92-func splitSerial(v float64) (days int64, secs int) {93- total := int64(math.Round(v * 86400))94- days = floorDiv(total, 86400)95- return days, int(total - days*86400)96-}15+// Typed dates and times become serial day numbers, as in Sheets; see16+// numfmt's calendar.9717 9818 // parseDateTime recognizes dates and times typed into a cell the way9919 // Sheets does in the en-US locale: 9/26/2026, 9/26/26, 9/26 (this year),@@ -189,7 +109,7 @@ func parseTime(s string) (float64, Format, bool) {
189109 case h >= 24:190110 f = Preset(FmtDuration)191111 }192- return timeSerial(h, m, sec), f, true112+ return numfmt.TimeSerial(h, m, sec), f, true193113 }194114 195115 // parseDate recognizes the date forms listed at parseDateTime.@@ -243,7 +163,7 @@ func parseDate(s string) (float64, Format, bool) {
243163 // monthName matches a month's full name or three-letter abbreviation.244164 func monthName(w string) (month int, long, ok bool) {245165 w = strings.ToLower(strings.TrimSuffix(w, "."))246- for i, n := range monthNames {166+ for i, n := range numfmt.MonthNames {247167 n = strings.ToLower(n)248168 if w == n {249169 return i + 1, len(w) > 3, true@@ -271,12 +191,8 @@ func ymd(ys, ms, ds string, f Format) (float64, Format, bool) {
271191 y += 1900272192 }273193 }274- if m < 1 || m > 12 || d < 1 || d > daysIn(y, m) {194+ if m < 1 || m > 12 || d < 1 || d > numfmt.DaysIn(y, m) {275195 return 0, Format{}, false276196 }277- return dateSerial(y, m, d), f, true278-}279-280-func daysIn(y, m int) int {281- return int(dateSerial(y, m+1, 1) - dateSerial(y, m, 1))197+ return numfmt.DateSerial(y, m, d), f, true282198 }@@ -3,6 +3,7 @@ package sheet
33 import (44 "sync"55 6+ "github.com/FelineStateMachine/012/internal/numfmt"67 "github.com/cockroachdb/apd/v3"78 )89 @@ -83,11 +84,11 @@ func decArith(op string, a, b float64) (Value, bool) {
8384 8485 // decRounding maps ROUND's modes to apd's: half away from zero, away8586 // from zero, toward zero.86-var decRounding = [...]apd.Rounder{roundHalfUp: apd.RoundHalfUp, roundUp: apd.RoundUp, roundDown: apd.RoundDown}87+var decRounding = [...]apd.Rounder{numfmt.HalfUp: apd.RoundHalfUp, numfmt.Up: apd.RoundUp, numfmt.Down: apd.RoundDown}8788 8889 // decRound rounds x to places decimal places (negative places round to8990 // tens, hundreds...) in decimal.90-func decRound(x float64, places int, mode roundMode) (Value, bool) {91+func decRound(x float64, places int, mode numfmt.Rounding) (Value, bool) {9192 var d, z apd.Decimal9293 if _, err := d.SetFloat64(x); err != nil {9394 return Value{}, false@@ -158,13 +159,13 @@ var decEvals = map[string]func(args []Node, get lookup) (Value, bool){
158159 }159160 return fromDec(&q)160161 },161- "ROUND": decRounder(roundHalfUp),162- "ROUNDUP": decRounder(roundUp),163- "ROUNDDOWN": decRounder(roundDown),164- "TRUNC": decRounder(roundDown),162+ "ROUND": decRounder(numfmt.HalfUp),163+ "ROUNDUP": decRounder(numfmt.Up),164+ "ROUNDDOWN": decRounder(numfmt.Down),165+ "TRUNC": decRounder(numfmt.Down),165166 }166167 167-func decRounder(mode roundMode) func([]Node, lookup) (Value, bool) {168+func decRounder(mode numfmt.Rounding) func([]Node, lookup) (Value, bool) {168169 return func(args []Node, get lookup) (Value, bool) {169170 x, err := numArg(args[0], get)170171 if err != nil {@@ -3,6 +3,8 @@ package sheet
33 import (44 "math"55 "strings"6+7+ "github.com/FelineStateMachine/012/internal/numfmt"68 )79 810 // Kind is the type of a computed cell value.@@ -27,7 +29,7 @@ type Value struct {
2729 func (v Value) String() string {2830 switch v.Kind {2931 case Number:30- return numString(v.Num)32+ return numfmt.General(v.Num)3133 case Bool:3234 if v.Num != 0 {3335 return "TRUE"@@ -3,6 +3,8 @@ package sheet
33 import (44 "strconv"55 "strings"6+7+ "github.com/FelineStateMachine/012/internal/numfmt"68 )79 810 // FormatKind is a number format from Sheets' Format > Number menu.@@ -150,7 +152,7 @@ func (f Format) WithDecimals(delta int, v float64) Format {
150152 f.Pattern += "." + strings.Repeat("0", d)151153 }152154 case f.Kind == FmtCustom:153- f.Pattern = adjustPatternDecimals(f.Pattern, delta)155+ f.Pattern = numfmt.AdjustDecimals(f.Pattern, delta)154156 }155157 return f156158 }@@ -165,60 +167,6 @@ func visibleDecimals(v float64) int {
165167 return min(len(frac), 10)166168 }167169 168-// adjustPatternDecimals adds or removes a 0 after the decimal point of169-// every section of a number pattern.170-func adjustPatternDecimals(pat string, delta int) string {171- secs := splitSections(pat)172- for i, sec := range secs {173- if isDatePattern(lexPattern(sec)) {174- return pat175- }176- secs[i] = adjustSection(sec, delta)177- }178- return strings.Join(secs, ";")179-}180-181-func adjustSection(sec string, delta int) string {182- // Find the last digit placeholder outside quotes; decimals follow a '.'.183- last, dot := -1, -1184- inQuote := false185- for i := 0; i < len(sec); i++ {186- switch c := sec[i]; {187- case c == '"':188- inQuote = !inQuote189- case inQuote:190- case c == '\\' || c == '_' || c == '*':191- i++192- case c == '.' && dot < 0:193- dot = i194- case c == '0' || c == '#' || c == '?':195- last = i196- case c == 'E' || c == 'e':197- if last >= 0 {198- // Stop at an exponent: its digits aren't decimals.199- i = len(sec)200- }201- }202- }203- if last < 0 {204- return sec205- }206- switch {207- case delta > 0 && dot < 0:208- return sec[:last+1] + "." + strings.Repeat("0", delta) + sec[last+1:]209- case delta > 0:210- return sec[:last+1] + strings.Repeat("0", delta) + sec[last+1:]211- case dot < 0 || last < dot:212- return sec213- }214- decimals := last - dot215- remove := min(-delta, decimals)216- if remove == decimals { // drop the point too217- return sec[:dot] + sec[last+1:]218- }219- return sec[:last+1-remove] + sec[last+1:]220-}221-222170 func clampInt(v, lo, hi int) int { return max(lo, min(v, hi)) }223171 224172 // Align is a cell's horizontal alignment.@@ -15,67 +15,6 @@ func fixedNow(t *testing.T) {
1515 t.Cleanup(func() { Now = old })1616 }1717 18-func TestFormatPattern(t *testing.T) {19- tests := []struct {20- v float6421- pat string22- want string23- }{24- {1234.5, "#,##0.00", "1,234.50"},25- {-1234.5, "#,##0.00", "-1,234.50"},26- {0.5, "#,##0", "1"},27- {0, "#,##0.00", "0.00"},28- {1234567.891, "#,##0", "1,234,568"},29- {1.005, "0.00", "1.01"}, // 15 significant digits, not binary30- {2.5, "0", "3"},31- {-2.5, "0", "-3"},32- {-0.001, "0.00", "0.00"}, // no negative zero33- {0.1234, "0%", "12%"},34- {0.1234, "0.00%", "12.34%"},35- {1234.5, "0.00E+00", "1.23E+03"},36- {0.000123, "0.00E+00", "1.23E-04"},37- {9.999, "0.00E+00", "1.00E+01"},38- {0, "0.00E+00", "0.00E+00"},39- {1234.5, `"$"#,##0.00`, "$1,234.50"},40- {-1234.5, `"$"#,##0.00`, "-$1,234.50"},41- {-1234.5, "#,##0.00;(#,##0.00)", "(1,234.50)"},42- {0, "#,##0;(#,##0);\"zero\"", "zero"},43- {1.5, "0.0#", "1.5"},44- {1.25, "0.0#", "1.25"},45- {1.5, "0.??", "1.5 "},46- {5, "000", "005"},47- {12345, "#,##0,", "12"},48- {1234.5, "#,##0.00 \"kg\"", "1,234.50 kg"},49- {0.5, "#.##", ".5"},50- {12.5, "General", "12.5"},51- {5551234, "000-0000", "555-1234"},52- // Dates and times: 46291 is 2026-09-26, a Saturday.53- {46291, "m/d/yyyy", "9/26/2026"},54- {46291, "yyyy-mm-dd", "2026-09-26"},55- {46291, "mmm d, yyyy", "Sep 26, 2026"},56- {46291, "dddd, mmmm d", "Saturday, September 26"},57- {46291, "ddd mmmmm yy", "Sat S 26"},58- {46291.6041666667, "h:mm am/pm", "2:30 PM"},59- {46291.6041666667, "hh:mm:ss", "14:30:00"},60- {0.5, "h:mm:ss AM/PM", "12:00:00 PM"},61- {0, "h:mm a/p", "12:00 A"},62- {1.0423611111, "[h]:mm:ss", "25:01:00"},63- {-0.5, "[h]:mm", "-12:00"},64- {0.000011574, "h:mm:ss.00", "0:00:01.00"},65- {0.0000173611, "mm:ss.0", "00:01.5"},66- {46291.99999999, "m/d/yyyy h:mm", "9/27/2026 0:00"},67- {0, "m/d/yyyy", "12/30/1899"},68- {-1, "yyyy-mm-dd", "1899-12-29"},69- {61, "yyyy-mm-dd", "1900-03-01"},70- {2958465, "yyyy-mm-dd", "9999-12-31"},71- }72- for _, tt := range tests {73- if got := FormatPattern(tt.v, tt.pat); got != tt.want {74- t.Errorf("FormatPattern(%v, %q) = %q, want %q", tt.v, tt.pat, got, tt.want)75- }76- }77-}78-7918 func TestDisplay(t *testing.T) {8019 tests := []struct {8120 v Value@@ -273,17 +212,6 @@ func TestAdjustDecimals(t *testing.T) {
273212 t.Errorf("%q %+v %+d = %q, want %q", tt.in, tt.f, tt.delta, got, tt.want)274213 }275214 }276- for _, tt := range []struct{ pat, want string }{277- {"0.00", "0.000"}, {"#,##0", "#,##0.0"}, {`"$"#,##0.00_);("$"#,##0.00)`, `"$"#,##0.000_);("$"#,##0.000)`},278- {"0.00E+00", "0.000E+00"}, {"0%", "0.0%"},279- } {280- if got := adjustPatternDecimals(tt.pat, 1); got != tt.want {281- t.Errorf("adjustPatternDecimals(%q, 1) = %q, want %q", tt.pat, got, tt.want)282- }283- }284- if got := adjustPatternDecimals("0.0", -1); got != "0" {285- t.Errorf("adjustPatternDecimals(0.0, -1) = %q", got)286- }287215 }288216 289217 func TestFileV2RoundTrip(t *testing.T) {@@ -3,6 +3,8 @@ package sheet
33 import (44 "math"55 "slices"6+7+ "github.com/FelineStateMachine/012/internal/numfmt"68 )79 810 // FuncDef describes a spreadsheet function. The table drives parsing@@ -98,7 +100,7 @@ func init() {
98100 &FuncDef{Name: "INT", Args: "value", Desc: "Round down to the nearest integer", Min: 1, Max: 1, eval: math1(math.Floor), format: inherit},99101 &FuncDef{Name: "SQRT", Args: "value", Desc: "Square root", Min: 1, Max: 1, eval: math1(math.Sqrt)},100102 &FuncDef{Name: "ROUND", Args: "value, [places]", Desc: "Round to a number of decimal places, halves away from zero", Min: 1, Max: 2,101- eval: rounder(roundHalfUp), format: inheritFrom(0)},103+ eval: rounder(numfmt.HalfUp), format: inheritFrom(0)},102104 &FuncDef{Name: "MOD", Args: "dividend, divisor", Desc: "Remainder, with the sign of the divisor", Min: 2, Max: 2,103105 eval: numeric(func(x []float64) Value {104106 if x[1] == 0 {@@ -281,7 +283,7 @@ func constant(v Value) func([]Node, lookup) Value {
281283 282284 // rounder builds ROUND, ROUNDUP, ROUNDDOWN and TRUNC: round on the 15 digits a283285 // spreadsheet shows, so ROUNDUP(2.3, 1) stays 2.3.284-func rounder(mode roundMode) func([]Node, lookup) Value {286+func rounder(mode numfmt.Rounding) func([]Node, lookup) Value {285287 return func(args []Node, get lookup) Value {286288 x, err := numArg(args[0], get)287289 if err != nil {@@ -291,6 +293,6 @@ func rounder(mode roundMode) func([]Node, lookup) Value {
291293 if err != nil {292294 return *err293295 }294- return num(roundTo(x, clampInt(places, -308, 308), mode))296+ return num(numfmt.Round(x, clampInt(places, -308, 308), mode))295297 }296298 }@@ -3,6 +3,8 @@ package sheet
33 import (44 "math"55 "strings"6+7+ "github.com/FelineStateMachine/012/internal/numfmt"68 )79 810 var (@@ -22,7 +24,7 @@ func init() {
2224 if y < 0 || y >= 10000 || math.Abs(m) > 1e6 || math.Abs(d) > 1e8 {2325 return ErrNum2426 }25- v := dateSerial(int(y), int(m), int(d))27+ v := numfmt.DateSerial(int(y), int(m), int(d))2628 if v < 0 {2729 return ErrNum2830 }@@ -37,9 +39,9 @@ func init() {
3739 return num(math.Mod(secs, 86400) / 86400)3840 }), format: timeFormat},3941 &FuncDef{Name: "TODAY", Desc: "Today's date, updated on every change", Max: 0, Volatile: true,40- eval: func([]Node, lookup) Value { return num(math.Floor(serialOf(Now()))) }, format: dateFormat},42+ eval: func([]Node, lookup) Value { return num(math.Floor(numfmt.SerialOf(Now()))) }, format: dateFormat},4143 &FuncDef{Name: "NOW", Desc: "The current date and time, updated on every change", Max: 0, Volatile: true,42- eval: func([]Node, lookup) Value { return num(serialOf(Now())) }, format: dateTimeFormat},44+ eval: func([]Node, lookup) Value { return num(numfmt.SerialOf(Now())) }, format: dateTimeFormat},4345 &FuncDef{Name: "YEAR", Args: "date", Desc: "Year of a date", Min: 1, Max: 1,4446 eval: datePart(func(y, _, _ int) int { return y })},4547 &FuncDef{Name: "MONTH", Args: "date", Desc: "Month of a date, 1 to 12", Min: 1, Max: 1,@@ -56,8 +58,8 @@ func init() {
5658 if err != nil {5759 return *err5860 }59- days, _ := splitSerial(d)60- wd := weekday(days) // 0 = Sunday61+ days, _ := numfmt.SplitSerial(d)62+ wd := numfmt.Weekday(days) // 0 = Sunday6163 switch typ {6264 case 1:6365 return num(float64(wd + 1))@@ -151,8 +153,8 @@ func datePart(part func(y, m, d int) int) func([]Node, lookup) Value {
151153 if err != nil {152154 return *err153155 }154- days, _ := splitSerial(d)155- return num(float64(part(civil(days))))156+ days, _ := numfmt.SplitSerial(d)157+ return num(float64(part(numfmt.Civil(days))))156158 }157159 }158160 @@ -162,7 +164,7 @@ func timePart(part func(secs int) int) func([]Node, lookup) Value {
162164 if err != nil {163165 return *err164166 }165- _, secs := splitSerial(d)167+ _, secs := numfmt.SplitSerial(d)166168 return num(float64(part(secs)))167169 }168170 }@@ -180,18 +182,16 @@ func monthShift(endOfMonth bool) func([]Node, lookup) Value {
180182 if err != nil {181183 return *err182184 }183- y, m, day := civil(int64(math.Floor(d)))184- m += months185- y += floorDivInt(m-1, 12)186- m = ((m-1)%12+12)%12 + 1185+ y, m, day := numfmt.Civil(int64(math.Floor(d)))186+ y, m = numfmt.AddMonths(y, m, months)187187 if y < 1 || y > 9999 {188188 return ErrNum189189 }190- last := daysIn(y, m)190+ last := numfmt.DaysIn(y, m)191191 if endOfMonth || day > last {192192 day = last193193 }194- v := dateSerial(y, m, day)194+ v := numfmt.DateSerial(y, m, day)195195 if v < 0 {196196 return ErrNum197197 }@@ -216,8 +216,8 @@ func datedif(args []Node, get lookup) Value {
216216 if s > e {217217 return ErrNum218218 }219- sy, sm, sd := civil(s)220- ey, em, ed := civil(e)219+ sy, sm, sd := numfmt.Civil(s)220+ ey, em, ed := numfmt.Civil(e)221221 months := (ey-sy)*12 + em - sm222222 if ed < sd {223223 months--@@ -240,14 +240,14 @@ func datedif(args []Node, get lookup) Value {
240240 if pm == 0 {241241 py, pm = ey-1, 12242242 }243- return num(float64(e) - dateSerial(py, pm, sd))243+ return num(float64(e) - numfmt.DateSerial(py, pm, sd))244244 case "YD":245245 // Days since the last anniversary of the start date.246246 y := ey247247 if em < sm || em == sm && ed < sd {248248 y--249249 }250- anniv := dateSerial(y, sm, min(sd, daysIn(y, sm)))250+ anniv := numfmt.DateSerial(y, sm, min(sd, numfmt.DaysIn(y, sm)))251251 return num(float64(e) - anniv)252252 }253253 return ErrNum@@ -282,7 +282,7 @@ func networkdays(args []Node, get lookup) Value {
282282 }283283 n := 0284284 for d := s; d <= e; d++ {285- if wd := weekday(d); wd != 0 && wd != 6 && !holidays[d] {285+ if wd := numfmt.Weekday(d); wd != 0 && wd != 6 && !holidays[d] {286286 n++287287 }288288 }@@ -3,6 +3,8 @@ package sheet
33 import (44 "math"55 "math/rand/v2"6+7+ "github.com/FelineStateMachine/012/internal/numfmt"68 )79 810 func init() {@@ -23,11 +25,11 @@ func init() {
2325 &FuncDef{Name: "POWER", Args: "base, exponent", Desc: "A number raised to a power", Min: 2, Max: 2,2426 eval: numeric(func(x []float64) Value { return powerOf(x[0], x[1]) })},2527 &FuncDef{Name: "ROUNDUP", Args: "value, [places]", Desc: "Round away from zero", Min: 1, Max: 2,26- eval: rounder(roundUp), format: inheritFrom(0)},28+ eval: rounder(numfmt.Up), format: inheritFrom(0)},2729 &FuncDef{Name: "ROUNDDOWN", Args: "value, [places]", Desc: "Round toward zero", Min: 1, Max: 2,28- eval: rounder(roundDown), format: inheritFrom(0)},30+ eval: rounder(numfmt.Down), format: inheritFrom(0)},2931 &FuncDef{Name: "TRUNC", Args: "value, [places]", Desc: "Drop decimals past a number of places", Min: 1, Max: 2,30- eval: rounder(roundDown), format: inheritFrom(0)},32+ eval: rounder(numfmt.Down), format: inheritFrom(0)},3133 &FuncDef{Name: "CEILING", Args: "value, [factor]", Desc: "Round up to a multiple of factor", Min: 1, Max: 2,3234 eval: toMultiple(math.Ceil), format: inheritFrom(0)},3335 &FuncDef{Name: "FLOOR", Args: "value, [factor]", Desc: "Round down to a multiple of factor", Min: 1, Max: 2,@@ -1,10 +1,11 @@
11 package sheet22 33 import (4- "math"54 "strconv"65 "strings"76 "unicode/utf8"7+8+ "github.com/FelineStateMachine/012/internal/numfmt"89 )910 1011 // ParseNumber recognizes numbers the way Google Sheets does on entry:@@ -119,22 +120,25 @@ func Display(v Value, f Format, width int) (string, Align) {
119120 }120121 switch f.Kind {121122 case FmtAuto:122- return formatGeneral(v.Num, inner), AlignRight123+ return numfmt.GeneralFit(v.Num, inner), AlignRight123124 case FmtText:124- return numString(v.Num), AlignLeft125+ return numfmt.General(v.Num), AlignLeft125126 case FmtAccounting:126- if s, ok := accounting(v.Num, f.Decimals, width); ok {127+ if s, ok := numfmt.Accounting(v.Num, f.Decimals, width); ok {127128 return s, AlignFill128129 }129130 return strings.Repeat("#", inner), AlignRight130131 }131- s := FormatPattern(v.Num, f.pattern())132+ s := numfmt.Format(v.Num, f.pattern())132133 if utf8.RuneCountInString(s) > inner {133134 s = strings.Repeat("#", inner)134135 }135136 return s, AlignRight136137 }137138 139+// FormatPattern renders v with a number format pattern, as TEXT() does.140+func FormatPattern(v float64, pat string) string { return numfmt.Format(v, pat) }141+138142 // FormatText renders v under f with no width limit, e.g. for TEXT() or139143 // copying out of the grid.140144 func FormatText(v Value, f Format) string {@@ -143,41 +147,9 @@ func FormatText(v Value, f Format) string {
143147 }144148 switch f.Kind {145149 case FmtAuto, FmtText:146- return numString(v.Num)147- }148- return FormatPattern(v.Num, f.pattern())149-}150-151-// accounting lays out Sheets' Accounting format in width columns: the $152-// at the left, the number right-aligned with room for a closing153-// parenthesis, negatives in parentheses and zero as a dash.154-func accounting(v float64, dec, width int) (string, bool) {155- ip, fp := fixed(v, dec, roundHalfUp)156- var num string157- switch {158- case strings.Trim(ip+fp, "0") == "":159- num = "-" + strings.Repeat(" ", dec) + " "160- case v < 0:161- num = "(" + FormatPattern(-v, "#,##0"+decimals(dec)) + ")"162- default:163- num = FormatPattern(v, "#,##0"+decimals(dec)) + " "164- }165- gap := width - 1 - len(num)166- switch {167- case gap < 0:168- return "", false169- case gap >= 1:170- // Leave a column of padding before the $, like other cells.171- return " $" + strings.Repeat(" ", gap-1) + num, true172- }173- return "$" + strings.Repeat(" ", gap) + num, true174-}175-176-func decimals(n int) string {177- if n <= 0 {178- return ""150+ return numfmt.General(v.Num)179151 }180- return "." + strings.Repeat("0", n)152+ return numfmt.Format(v.Num, f.pattern())181153 }182154 183155 // FormatValue renders v in width columns with one column of padding, the@@ -198,45 +170,7 @@ func FormatValue(v Value, width int) string {
198170 // FormatNumber formats a number in the General format within width199171 // columns, for use outside the grid (e.g. the status line).200172 func FormatNumber(v float64, width int) string {201- return formatGeneral(v, width)202-}203-204-// formatGeneral mimics General number format: as many digits as fit,205-// rounding decimals first, then scientific notation, then #s.206-func formatGeneral(v float64, width int) string {207- if width <= 0 {208- return ""209- }210- s := numString(v)211- if strings.ContainsRune(s, 'E') {212- s = strconv.FormatFloat(v, 'f', -1, 64)213- }214- if len(s) <= width {215- return s216- }217- // Try fewer decimals before falling back to scientific notation, but218- // never round a non-zero number down to zero.219- if dot := strings.IndexByte(s, '.'); dot >= 0 && dot <= width {220- for prec := max(width-dot-1, 0); prec >= 0; prec-- {221- f := strconv.FormatFloat(v, 'f', prec, 64)222- if len(f) > width {223- continue224- }225- if r, _ := strconv.ParseFloat(f, 64); r != 0 || v == 0 {226- if strings.Contains(f, ".") {227- f = strings.TrimRight(strings.TrimRight(f, "0"), ".")228- }229- return f230- }231- break232- }233- }234- for prec := width; prec >= 0; prec-- {235- if e := strconv.FormatFloat(v, 'E', prec, 64); len(e) <= width {236- return e237- }238- }239- return strings.Repeat("#", width)173+ return numfmt.GeneralFit(v, width)240174 }241175 242176 func padLeft(s string, width int) string {@@ -253,6 +187,3 @@ func centerPad(s string, width int) string {
253187 pad := width - len(s)254188 return strings.Repeat(" ", pad/2) + s + strings.Repeat(" ", pad-pad/2)255189 }256-257-// isWhole reports whether v is an integer.258-func isWhole(v float64) bool { return v == math.Trunc(v) }@@ -1,678 +0,0 @@
1-package sheet2-3-import (4- "math"5- "strconv"6- "strings"7- "unicode/utf8"8-)9-10-// Number format patterns, as in Sheets' custom number formats and TEXT():11-// "#,##0.00", "0.0%", "0.00E+00", "$#,##0;($#,##0)", "m/d/yyyy",12-// "h:mm am/pm", "[h]:mm:ss". Up to four ;-separated sections apply to13-// positive, negative, zero and text values.14-15-type ptKind uint816-17-const (18- ptLit ptKind = iota19- ptDigit // 0, # or ?20- ptDot // decimal point21- ptComma // thousands separator or scaling22- ptPercent //23- ptExp // E+ or E-24- ptGeneral // "General"25- ptText // @26- ptFill // *x: repeat x to fill the cell27- ptYear // y, yy, yyyy28- ptMonth // m..mmmmm: month, or minutes next to h or s29- ptDay // d..dddd30- ptHour // h, hh31- ptSecond // s, ss32- ptAMPM // AM/PM or A/P33- ptElapsed // [h], [m], [s]34- ptFrac // .0, .00 after seconds35-)36-37-type ptok struct {38- kind ptKind39- s string // literal text, digit placeholder char, or the token as written40- n int // repeat count for date tokens41-}42-43-// splitSections splits a pattern on ; outside quotes and escapes.44-func splitSections(pat string) []string {45- var secs []string46- start, inQuote := 0, false47- for i := 0; i < len(pat); i++ {48- switch c := pat[i]; {49- case c == '"':50- inQuote = !inQuote51- case inQuote:52- case c == '\\':53- i++54- case c == ';':55- secs = append(secs, pat[start:i])56- start = i + 157- }58- }59- return append(secs, pat[start:])60-}61-62-func lexPattern(sec string) []ptok {63- var toks []ptok64- lit := func(s string) { toks = append(toks, ptok{kind: ptLit, s: s}) }65- lower := asciiLower(sec) // same byte offsets as sec66- for i := 0; i < len(sec); {67- c := sec[i]68- lc := lower[i]69- switch {70- case c == '"':71- end := strings.IndexByte(sec[i+1:], '"')72- if end < 0 {73- end = len(sec) - i - 174- }75- lit(sec[i+1 : i+1+end])76- i += end + 277- case c == '\\' && i+1 < len(sec):78- _, w := utf8.DecodeRuneInString(sec[i+1:])79- lit(sec[i+1 : i+1+w])80- i += 1 + w81- case c == '_' && i+1 < len(sec):82- _, w := utf8.DecodeRuneInString(sec[i+1:])83- lit(" ")84- i += 1 + w85- case c == '*' && i+1 < len(sec):86- _, w := utf8.DecodeRuneInString(sec[i+1:])87- toks = append(toks, ptok{kind: ptFill, s: sec[i+1 : i+1+w]})88- i += 1 + w89- case c == '[':90- end := strings.IndexByte(sec[i:], ']')91- if end < 0 {92- lit(sec[i:])93- i = len(sec)94- break95- }96- inner := lower[i+1 : i+end]97- switch {98- case inner != "" && strings.Trim(inner, string(inner[0])) == "" && strings.ContainsAny(inner[:1], "hms"):99- toks = append(toks, ptok{kind: ptElapsed, s: inner[:1], n: len(inner)})100- case strings.HasPrefix(inner, "$"):101- // Currency like [$€-407]: show the symbol.102- sym, _, _ := strings.Cut(sec[i+2:i+end], "-")103- lit(sym)104- }105- // Colors and conditions are ignored.106- i += end + 1107- case c == '0' || c == '#' || c == '?':108- toks = append(toks, ptok{kind: ptDigit, s: string(c)})109- i++110- case c == '.':111- toks = append(toks, ptok{kind: ptDot})112- i++113- case c == ',':114- toks = append(toks, ptok{kind: ptComma})115- i++116- case c == '%':117- toks = append(toks, ptok{kind: ptPercent})118- i++119- case c == '@':120- toks = append(toks, ptok{kind: ptText})121- i++122- case (lc == 'e') && i+1 < len(sec) && (sec[i+1] == '+' || sec[i+1] == '-'):123- toks = append(toks, ptok{kind: ptExp, s: sec[i+1 : i+2]})124- i += 2125- case strings.HasPrefix(lower[i:], "am/pm"):126- toks = append(toks, ptok{kind: ptAMPM, s: "AM/PM"})127- i += 5128- case strings.HasPrefix(lower[i:], "a/p"):129- toks = append(toks, ptok{kind: ptAMPM, s: "A/P"})130- i += 3131- case strings.HasPrefix(lower[i:], "general"):132- toks = append(toks, ptok{kind: ptGeneral})133- i += 7134- case strings.IndexByte("ymdhs", lc) >= 0:135- j := i136- for j < len(sec) && lower[j] == lc {137- j++138- }139- kind := map[byte]ptKind{'y': ptYear, 'm': ptMonth, 'd': ptDay, 'h': ptHour, 's': ptSecond}[lc]140- toks = append(toks, ptok{kind: kind, s: sec[i:j], n: j - i})141- i = j142- default:143- _, w := utf8.DecodeRuneInString(sec[i:])144- lit(sec[i : i+w])145- i += w146- }147- }148- return toks149-}150-151-// asciiLower lower-cases ASCII letters only, keeping byte offsets.152-func asciiLower(s string) string {153- b := []byte(s)154- for i, c := range b {155- if c >= 'A' && c <= 'Z' {156- b[i] = c + 'a' - 'A'157- }158- }159- return string(b)160-}161-162-// isDatePattern reports whether a section formats dates and times.163-func isDatePattern(toks []ptok) bool {164- for _, t := range toks {165- switch t.kind {166- case ptYear, ptMonth, ptDay, ptHour, ptSecond, ptAMPM, ptElapsed:167- return true168- }169- }170- return false171-}172-173-// FormatPattern renders v with a number format pattern, as TEXT() does.174-func FormatPattern(v float64, pat string) string {175- secs := splitSections(pat)176- sec, neg := secs[0], v < 0177- switch {178- case len(secs) >= 3 && v == 0:179- sec = secs[2]180- case len(secs) >= 2 && v < 0 && strings.TrimSpace(secs[1]) != "":181- sec, v, neg = secs[1], -v, false182- }183- toks := lexPattern(sec)184- if isDatePattern(toks) {185- return formatDateToks(v, toks)186- }187- return formatNumToks(math.Abs(v), neg, toks)188-}189-190-func formatNumToks(v float64, neg bool, toks []ptok) string {191- // Find the parts of the number: integer placeholders, the decimal192- // point, decimal placeholders and an exponent.193- dot, exp := -1, -1194- var intPH, fracPH, expPH []int195- percents, general := 0, false196- for i, t := range toks {197- switch t.kind {198- case ptDigit:199- switch {200- case exp >= 0:201- expPH = append(expPH, i)202- case dot >= 0:203- fracPH = append(fracPH, i)204- default:205- intPH = append(intPH, i)206- }207- case ptDot:208- if dot < 0 && exp < 0 {209- dot = i210- }211- case ptExp:212- if exp < 0 {213- exp = i214- }215- case ptPercent:216- percents++217- case ptGeneral:218- general = true219- }220- }221- // Commas between integer placeholders group thousands; commas right222- // after the last one scale by 1000 each.223- grouping, scale := false, 0224- if len(intPH) > 0 {225- first, last := intPH[0], intPH[len(intPH)-1]226- for i := first; i < last; i++ {227- if toks[i].kind == ptComma {228- grouping = true229- }230- }231- for i := last + 1; i < len(toks) && toks[i].kind == ptComma; i++ {232- scale++233- }234- }235- for range percents {236- v *= 100237- }238- for range scale {239- v /= 1000240- }241-242- var intDigits, fracDigits, expText string243- expNeg := false244- switch {245- case general && len(intPH) == 0:246- intDigits = numString(v)247- case exp >= 0:248- e := 0249- if v != 0 {250- e = int(math.Floor(math.Log10(v)))251- }252- ip, fp := fixed(v/math.Pow(10, float64(e)), len(fracPH), roundHalfUp)253- if ip == "10" {254- e++255- ip, fp = fixed(v/math.Pow(10, float64(e)), len(fracPH), roundHalfUp)256- }257- intDigits, fracDigits = ip, fp258- expNeg = e < 0259- expText = strconv.Itoa(absInt(e))260- if len(expText) < len(expPH) {261- expText = strings.Repeat("0", len(expPH)-len(expText)) + expText262- }263- default:264- intDigits, fracDigits = fixed(v, len(fracPH), roundHalfUp)265- }266- if neg && strings.Trim(intDigits+fracDigits, "0") == "" {267- neg = false // don't show -0.00268- }269-270- // Fill integer placeholders right to left; the leftmost one takes any271- // remaining digits.272- intOut := make(map[int]string, len(intPH))273- rem := intDigits274- for k := len(intPH) - 1; k >= 0; k-- {275- ph := toks[intPH[k]].s276- switch {277- case k == 0 && rem != "":278- intOut[intPH[k]], rem = rem, ""279- case rem != "":280- intOut[intPH[k]], rem = rem[len(rem)-1:], rem[:len(rem)-1]281- case ph == "0":282- intOut[intPH[k]] = "0"283- case ph == "?":284- intOut[intPH[k]] = " "285- }286- }287- // Decimal placeholders: trailing zeros disappear under # and turn to288- // spaces under ?.289- fracOut := make(map[int]string, len(fracPH))290- trimming := true291- for k := len(fracPH) - 1; k >= 0; k-- {292- d := fracDigits[k : k+1]293- ph := toks[fracPH[k]].s294- switch {295- case trimming && d == "0" && ph == "#":296- fracOut[fracPH[k]] = ""297- case trimming && d == "0" && ph == "?":298- fracOut[fracPH[k]] = " "299- default:300- trimming = false301- fracOut[fracPH[k]] = d302- }303- }304-305- var b strings.Builder306- if neg {307- b.WriteByte('-')308- }309- literalsInInt := false310- for i := range toks {311- if len(intPH) > 0 && i > intPH[0] && i < intPH[len(intPH)-1] && toks[i].kind == ptLit {312- literalsInInt = true313- }314- }315- for i, t := range toks {316- switch t.kind {317- case ptLit:318- b.WriteString(t.s)319- case ptDigit:320- switch {321- case len(intPH) > 0 && i == intPH[0] && grouping && !literalsInInt:322- var digits strings.Builder323- for _, p := range intPH {324- digits.WriteString(intOut[p])325- }326- b.WriteString(group(digits.String()))327- case len(intPH) > 0 && i >= intPH[0] && i <= intPH[len(intPH)-1]:328- if !grouping || literalsInInt {329- b.WriteString(intOut[i])330- }331- case len(expPH) > 0 && i == expPH[0]:332- b.WriteString(expText)333- case len(expPH) > 0 && i > expPH[0]:334- default:335- b.WriteString(fracOut[i])336- }337- case ptDot:338- if i == dot {339- b.WriteByte('.')340- }341- case ptPercent:342- b.WriteByte('%')343- case ptExp:344- if i == exp {345- switch {346- case expNeg:347- b.WriteString("E-")348- case t.s == "+":349- b.WriteString("E+")350- default:351- b.WriteString("E")352- }353- }354- case ptGeneral:355- if len(intPH) == 0 {356- b.WriteString(intDigits)357- }358- case ptComma:359- // Grouping and scaling commas are consumed; others are literal.360- if len(intPH) == 0 || i < intPH[0] || (dot >= 0 && i > dot) {361- b.WriteByte(',')362- }363- }364- }365- return b.String()366-}367-368-// group inserts thousands separators into a run of digits (and leading369-// spaces from ? placeholders).370-func group(s string) string {371- lead := len(s) - len(strings.TrimLeft(s, " "))372- digits := s[lead:]373- if len(digits) <= 3 {374- return s375- }376- var b strings.Builder377- b.WriteString(s[:lead])378- first := len(digits) % 3379- if first > 0 {380- b.WriteString(digits[:first])381- }382- for i := first; i < len(digits); i += 3 {383- if i > 0 {384- b.WriteByte(',')385- }386- b.WriteString(digits[i : i+3])387- }388- return b.String()389-}390-391-func absInt(v int) int {392- if v < 0 {393- return -v394- }395- return v396-}397-398-type roundMode int399-400-const (401- roundHalfUp roundMode = iota // ROUND and number formats402- roundUp // away from zero: ROUNDUP403- roundDown // toward zero: ROUNDDOWN404-)405-406-// fixed renders |x| rounded to n decimal places (n may be negative) as407-// its integer digits (no leading zeros, "" for zero) and exactly n408-// decimal digits. It works on the 15 significant digits spreadsheets409-// show, so 1.005 rounds to 1.01 as users expect rather than to the 1.00410-// its binary value would give.411-func fixed(x float64, n int, mode roundMode) (intPart, frac string) {412- x = math.Abs(x)413- nFrac := max(n, 0)414- if x == 0 || math.IsInf(x, 0) || math.IsNaN(x) {415- return "", strings.Repeat("0", nFrac)416- }417- s := strconv.FormatFloat(x, 'e', 14, 64)418- mant, e, _ := strings.Cut(s, "e")419- exp, _ := strconv.Atoi(e)420- d := []byte(strings.Replace(mant, ".", "", 1))421- point := exp + 1 // digits before the decimal point422- if point < 0 {423- d = append([]byte(strings.Repeat("0", -point)), d...)424- point = 0425- }426- keep := point + n427- if keep < 0 {428- // Far smaller than the rounding unit.429- if mode == roundUp {430- return "1" + strings.Repeat("0", -n), strings.Repeat("0", nFrac)431- }432- return "", strings.Repeat("0", nFrac)433- }434- if keep < len(d) {435- var up bool436- switch mode {437- case roundHalfUp:438- up = d[keep] >= '5'439- case roundUp:440- up = strings.Trim(string(d[keep:]), "0") != ""441- }442- d = d[:keep]443- if up {444- i := len(d) - 1445- for ; i >= 0; i-- {446- if d[i] == '9' {447- d[i] = '0'448- continue449- }450- d[i]++451- break452- }453- if i < 0 {454- d = append([]byte{'1'}, d...)455- point++456- }457- }458- }459- for len(d) < point+nFrac {460- d = append(d, '0')461- }462- intPart = strings.TrimLeft(string(d[:point]), "0")463- frac = string(d[point : point+nFrac])464- return intPart, frac465-}466-467-// roundTo rounds x to places decimal places (negative places round to468-// tens, hundreds...) with mode, on x's 15 significant digits.469-func roundTo(x float64, places int, mode roundMode) float64 {470- ip, fp := fixed(x, places, mode)471- s := ip472- if s == "" {473- s = "0"474- }475- if fp != "" {476- s += "." + fp477- }478- r, _ := strconv.ParseFloat(s, 64)479- if x < 0 {480- r = -r481- }482- return r483-}484-485-// numString renders a number the way Sheets shows it in General format486-// and in text: up to 15 significant digits, no exponent for ordinary487-// magnitudes.488-func numString(v float64) string {489- if v == 0 {490- return "0"491- }492- r, _ := strconv.ParseFloat(strconv.FormatFloat(v, 'g', 15, 64), 64)493- if a := math.Abs(r); a >= 1e21 || a < 1e-9 {494- return strconv.FormatFloat(r, 'E', -1, 64)495- }496- return strconv.FormatFloat(r, 'f', -1, 64)497-}498-499-// formatDateToks renders serial v as a date, time or duration.500-func formatDateToks(v float64, toks []ptok) string {501- fracDigits, ampm := 0, false502- for i, t := range toks {503- switch t.kind {504- case ptAMPM:505- ampm = true506- case ptSecond, ptElapsed:507- // .0, .00 or .000 right after seconds show fractions.508- if (t.kind == ptSecond || t.s == "s") && i+1 < len(toks) && toks[i+1].kind == ptDot {509- n := 0510- for j := i + 2; j < len(toks) && toks[j].kind == ptDigit && toks[j].s == "0"; j++ {511- n++512- }513- fracDigits = max(fracDigits, min(n, 3))514- }515- }516- }517- neg := false518- for _, t := range toks {519- if t.kind == ptElapsed && v < 0 {520- neg, v = true, -v521- break522- }523- }524- unit := math.Pow(10, float64(fracDigits))525- ticks := int64(math.Round(v * 86400 * unit)) // in 10^-fracDigits seconds526- perDay := int64(86400 * unit)527- days := floorDiv(ticks, perDay)528- inDay := ticks - days*perDay529- secs := inDay / int64(unit)530- sub := inDay % int64(unit)531- y, mo, d := civil(days)532- hour, minute, second := int(secs/3600), int(secs/60%60), int(secs%60)533-534- var b strings.Builder535- if neg {536- b.WriteByte('-')537- }538- for i, t := range toks {539- switch t.kind {540- case ptLit:541- b.WriteString(t.s)542- case ptYear:543- if t.n <= 2 {544- b.WriteString(pad2(y % 100))545- } else {546- b.WriteString(strconv.Itoa(y))547- }548- case ptMonth:549- if t.n <= 2 && isMinute(toks, i) {550- b.WriteString(padN(minute, t.n))551- break552- }553- switch t.n {554- case 1, 2:555- b.WriteString(padN(mo, t.n))556- case 3:557- b.WriteString(monthNames[mo-1][:3])558- case 5:559- b.WriteString(monthNames[mo-1][:1])560- default:561- b.WriteString(monthNames[mo-1])562- }563- case ptDay:564- switch t.n {565- case 1, 2:566- b.WriteString(padN(d, t.n))567- case 3:568- b.WriteString(dayNames[weekday(days)][:3])569- default:570- b.WriteString(dayNames[weekday(days)])571- }572- case ptHour:573- h := hour574- if ampm {575- h = (h+11)%12 + 1576- }577- b.WriteString(padN(h, t.n))578- case ptSecond:579- b.WriteString(padN(second, t.n))580- case ptElapsed:581- total := ticks / int64(unit)582- switch t.s {583- case "h":584- b.WriteString(padN(int(total/3600), t.n))585- case "m":586- b.WriteString(padN(int(total/60), t.n))587- default:588- b.WriteString(padN(int(total), t.n))589- }590- case ptAMPM:591- pm := hour >= 12592- switch {593- case t.s == "A/P" && pm:594- b.WriteString("P")595- case t.s == "A/P":596- b.WriteString("A")597- case pm:598- b.WriteString("PM")599- default:600- b.WriteString("AM")601- }602- case ptDot:603- b.WriteByte('.')604- if secondsFraction(toks, i) {605- b.WriteString(padN(int(sub), fracDigits))606- }607- case ptDigit:608- // Fraction-of-second digits are written with the dot.609- if !afterSecondsDot(toks, i) {610- b.WriteString(t.s)611- }612- case ptComma:613- b.WriteByte(',')614- case ptPercent:615- b.WriteByte('%')616- }617- }618- return b.String()619-}620-621-// secondsFraction reports whether the dot at i starts fractions of a622-// second (s.00).623-func secondsFraction(toks []ptok, i int) bool {624- return i > 0 && i+1 < len(toks) && toks[i+1].kind == ptDigit &&625- (toks[i-1].kind == ptSecond || toks[i-1].kind == ptElapsed && toks[i-1].s == "s")626-}627-628-// afterSecondsDot reports whether the digit at i belongs to fractions of629-// a second, which are written with the dot.630-func afterSecondsDot(toks []ptok, i int) bool {631- j := i - 1632- for j >= 0 && toks[j].kind == ptDigit {633- j--634- }635- return j >= 0 && toks[j].kind == ptDot && secondsFraction(toks, j)636-}637-638-// isMinute resolves m and mm: minutes right after hours or before639-// seconds, otherwise the month.640-func isMinute(toks []ptok, i int) bool {641- for j := i - 1; j >= 0; j-- {642- switch toks[j].kind {643- case ptHour:644- return true645- case ptElapsed:646- return toks[j].s == "h"647- case ptYear, ptMonth, ptDay, ptSecond:648- j = -1649- }650- }651- for j := i + 1; j < len(toks); j++ {652- switch toks[j].kind {653- case ptSecond:654- return true655- case ptElapsed:656- return toks[j].s == "s"657- case ptYear, ptMonth, ptDay, ptHour:658- return false659- }660- }661- return false662-}663-664-var (665- monthNames = [...]string{"January", "February", "March", "April", "May", "June", "July",666- "August", "September", "October", "November", "December"}667- dayNames = [...]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}668-)669-670-func pad2(v int) string { return padN(v, 2) }671-672-func padN(v, n int) string {673- s := strconv.Itoa(v)674- if len(s) < n {675- s = strings.Repeat("0", n-len(s)) + s676- }677- return s678-}@@ -5,6 +5,8 @@ import (
55 "regexp"66 "strconv"77 "strings"8+9+ "github.com/FelineStateMachine/012/internal/numfmt"810 )911 1012 // Fill series: dragging the fill handle in Sheets continues what the@@ -135,15 +137,11 @@ func numberSeries(cells []*Cell) func(int) string {
135137 }136138 if date {137139 if step, ok := monthStep(vals); ok {138- y, m, d := civil(int64(vals[0]))140+ y, m, d := numfmt.Civil(int64(vals[0]))139141 return func(i int) string {140- mm := m + step*i141- y2, m2 := y+floorDivInt(mm-1, 12), (mm-1)%12+1142- if m2 <= 0 {143- m2 += 12144- }145- day := min(d, daysIn(y2, m2))146- return input(i, dateSerial(y2, m2, day)+vals[0]-math.Floor(vals[0]))142+ y2, m2 := numfmt.AddMonths(y, m, step*i)143+ day := min(d, numfmt.DaysIn(y2, m2))144+ return input(i, numfmt.DateSerial(y2, m2, day)+vals[0]-math.Floor(vals[0]))147145 }148146 }149147 }@@ -167,7 +165,7 @@ func numberSeries(cells []*Cell) func(int) string {
167165 // months, and the spacing.168166 func monthStep(vals []float64) (int, bool) {169167 months := func(v float64) (int, int) {170- y, m, d := civil(int64(math.Floor(v)))168+ y, m, d := numfmt.Civil(int64(math.Floor(v)))171169 return y*12 + m - 1, d172170 }173171 m0, d0 := months(vals[0])@@ -206,7 +204,7 @@ type nameList struct {
206204 short int // letters in the short form207205 }208206 209-var nameLists = []nameList{{monthNames[:], 3}, {dayNames[:], 3}}207+var nameLists = []nameList{{numfmt.MonthNames[:], 3}, {numfmt.DayNames[:], 3}}210208 211209 // nameAt finds a month or day name: which list, the index, whether it is212210 // long, and whether it could be either (May).