TEXT(0.75, "# ?/?") is 3/4: fraction patterns, with a whole part or without, a denominator of up to so many digits or a fixed one. ROUND adds half a unit and drops the rest on 15 digits, so rounding at the 15th digit carries as in Sheets. A number written in a formula keeps 15 significant digits.
- commit
- 5f4d97f9438ac4614d5d1048be03af7d96c66498
- parent
- 434643b1ab0e0adb9ff5f7f8d6751b61f2cc7f45
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.0 of 3, Jev's estimate
| M | internal/ | 4 | +- |
| A | internal/ | 35 | ++++++ |
| A | internal/ | 166 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 55 | ++++++++++ |
| M | internal/ | 3 | + |
| M | internal/ | 12 | ++- |
@@ -4,6 +4,8 @@ import (
44 "fmt"55 "strconv"66 "strings"7+8+ "github.com/FelineStateMachine/012/internal/numfmt"79 )810 911 // Binding powers, lowest to highest, following Sheets' precedence.@@ -133,7 +135,7 @@ func (p *parser) prefix() (Node, error) {
133135 if r, ok := p.lines(t, ""); ok {134136 return r, nil135137 }136- v, err := strconv.ParseFloat(t.text, 64)138+ v, err := strconv.ParseFloat(numfmt.Entered(t.text), 64)137139 if err != nil {138140 return nil, &ParseError{Pos: t.pos, Msg: "Invalid number " + t.text}139141 }@@ -0,0 +1,35 @@
1+package numfmt2+3+// Entered keeps the first 15 significant digits of a number written in4+// decimal (digits, a point, an exponent) and makes the digits after them5+// zeros, as Sheets and Excel do with a number typed into a formula:6+// =123456789012345678 is 123456789012345000. Text with no more than 157+// digits comes back as it is. Numbers typed into cells keep every digit,8+// as files and imports write them with 17 to round-trip.9+func Entered(s string) string {10+ sig := 011+ for i := 0; i < len(s); i++ {12+ switch c := s[i]; {13+ case c == 'e' || c == 'E':14+ return s15+ case c < '0' || c > '9':16+ case sig > 0 || c != '0':17+ sig++18+ if sig > 15 {19+ return zeroDigits(s, i)20+ }21+ }22+ }23+ return s24+}25+26+// zeroDigits makes the digits of s from i up to any exponent zeros.27+func zeroDigits(s string, i int) string {28+ b := []byte(s)29+ for ; i < len(b) && b[i] != 'e' && b[i] != 'E'; i++ {30+ if b[i] >= '1' && b[i] <= '9' {31+ b[i] = '0'32+ }33+ }34+ return string(b)35+}@@ -0,0 +1,166 @@
1+package numfmt2+3+import (4+ "math"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/locale"9+)10+11+// Fraction patterns: "# ?/?" shows 1.75 as 1 3/4, "# ??/??" allows12+// denominators up to 99, "# ?/8" shows eighths, and "?/?" with no whole13+// part shows 7/4. Placeholders pad as in numbers: ? with a space, 0 with14+// a zero, # with nothing.15+16+// fracLayout is where a fraction section puts each part: the tokens of17+// the whole number (with anything before it), those between it and the18+// numerator, the numerator's and denominator's placeholders, and the19+// rest.20+type fracLayout struct {21+ whole, gap, num, den, rest []ptok22+ fixed int // the denominator written in the pattern, or 023+}24+25+// layoutFraction finds a fraction in a number section: digit26+// placeholders, a /, and placeholders or digits for the denominator.27+func layoutFraction(toks []ptok) (fracLayout, bool) {28+ slash := -129+ for i := 1; i+1 < len(toks); i++ {30+ if toks[i].kind == ptLit && toks[i].s == "/" && toks[i-1].kind == ptDigit && denToken(toks[i+1]) {31+ slash = i32+ break33+ }34+ }35+ if slash < 0 {36+ return fracLayout{}, false37+ }38+ start := slash39+ for start > 0 && toks[start-1].kind == ptDigit {40+ start--41+ }42+ end := slash + 143+ for end < len(toks) && denToken(toks[end]) {44+ end++45+ }46+ l := fracLayout{num: toks[start:slash], den: toks[slash+1 : end], rest: toks[end:]}47+ last := start - 148+ for last >= 0 && toks[last].kind != ptDigit {49+ last--50+ }51+ l.whole, l.gap = toks[:last+1], toks[last+1:start]52+ var d strings.Builder53+ for _, t := range l.den {54+ d.WriteString(t.s)55+ }56+ if n, err := strconv.Atoi(d.String()); err == nil && n > 0 {57+ l.fixed = n58+ }59+ return l, true60+}61+62+// denToken reports whether t can be part of a denominator: a digit63+// placeholder or a digit written out.64+func denToken(t ptok) bool {65+ return t.kind == ptDigit || t.kind == ptLit && len(t.s) == 1 && t.s[0] >= '0' && t.s[0] <= '9'66+}67+68+// hasWhole reports whether the section shows a whole number before the69+// fraction.70+func (l fracLayout) hasWhole() bool { return hasKind(l.whole, ptDigit) }71+72+// formatFraction renders |v| with a fraction section as shown in loc;73+// neg adds a minus sign.74+func formatFraction(v float64, neg bool, l fracLayout, loc *locale.Locale) string {75+ whole := math.Floor(v)76+ n, d := l.approximate(v - whole)77+ if n == d {78+ whole, n = whole+1, 079+ }80+ if !l.hasWhole() {81+ n += int(whole) * d82+ whole = 083+ }84+ var b strings.Builder85+ if neg && (whole != 0 || n != 0) {86+ b.WriteByte('-')87+ }88+ w := ""89+ if l.hasWhole() {90+ w = formatNumber(whole, false, l.whole, loc)91+ } else {92+ w = literals(l.whole)93+ }94+ switch {95+ case n == 0 && l.hasWhole():96+ if strings.TrimSpace(w) == "" {97+ w = "0"98+ }99+ b.WriteString(w)100+ case n == 0 && !l.hasWhole() && l.fixed == 0:101+ b.WriteString(w + "0/1")102+ default:103+ b.WriteString(w)104+ if strings.TrimSpace(w) != "" {105+ b.WriteString(literals(l.gap))106+ }107+ b.WriteString(padDigits(strconv.Itoa(n), l.num, true))108+ b.WriteByte('/')109+ if l.fixed > 0 {110+ b.WriteString(strconv.Itoa(l.fixed))111+ } else {112+ b.WriteString(padDigits(strconv.Itoa(d), l.den, false))113+ }114+ }115+ b.WriteString(literals(l.rest))116+ return b.String()117+}118+119+// approximate is the fraction n/d nearest f (0 <= f < 1): with the120+// pattern's denominator, or the nearest with a denominator of as many121+// digits as its placeholders, the smallest such denominator on a tie.122+func (l fracLayout) approximate(f float64) (n, d int) {123+ if l.fixed > 0 {124+ return int(math.Round(f * float64(l.fixed))), l.fixed125+ }126+ maxD := int(math.Pow10(min(len(l.den), 5))) - 1127+ n, d = int(math.Round(f)), 1128+ best := math.Abs(f - float64(n))129+ for q := 2; q <= maxD && best > 0; q++ {130+ p := math.Round(f * float64(q))131+ if e := math.Abs(f - p/float64(q)); e < best {132+ n, d, best = int(p), q, e133+ }134+ }135+ return n, d136+}137+138+// padDigits fills the placeholders phs with digits: those it has room139+// for are padded, a ? with a space and a 0 with a zero, on the left of a140+// numerator (right) or the right of a denominator (left).141+func padDigits(digits string, phs []ptok, right bool) string {142+ var pad strings.Builder143+ for i := len(digits); i < len(phs); i++ {144+ switch phs[i].s {145+ case "?":146+ pad.WriteByte(' ')147+ case "0":148+ pad.WriteByte('0')149+ }150+ }151+ if right {152+ return pad.String() + digits153+ }154+ return digits + pad.String()155+}156+157+// literals is the text of the literal tokens among toks.158+func literals(toks []ptok) string {159+ var b strings.Builder160+ for _, t := range toks {161+ if t.kind == ptLit {162+ b.WriteString(t.s)163+ }164+ }165+ return b.String()166+}@@ -41,6 +41,24 @@ func TestFormat(t *testing.T) {
4141 {12.5, "General", "12.5"},4242 {5551234, "000-0000", "555-1234"},4343 {1.5, "General.00", "1.5.00"}, // placeholders after General get zeros44+ // Fractions, as Sheets shows them.45+ {0.75, "# ?/?", "3/4"},46+ {1.75, "# ?/?", "1 3/4"},47+ {-1.75, "# ?/?", "-1 3/4"},48+ {0.75, "0 ?/?", "0 3/4"},49+ {2, "# ?/?", "2"},50+ {0, "# ?/?", "0"},51+ {1.99, "# ?/?", "2"},52+ {0.3333, "# ?/?", "1/3"},53+ {3.14159, "# ??/??", "3 14/99"},54+ {1.5, "# ??/??", "1 1/2 "},55+ {0.1234, "# ??/??", "10/81"},56+ {1.3, "# ?/4", "1 1/4"},57+ {0.5, "# ?/8", "4/8"},58+ {0.37, "# ??/100", "37/100"},59+ {1.75, "?/?", "7/4"},60+ {1.5, `# ?/? "cups"`, "1 1/2 cups"},61+ {1234.5, "#,##0 ?/?", "1,234 1/2"},4462 // Dates and times: 46291 is 2026-09-26, a Saturday.4563 {46291, "m/d/yyyy", "9/26/2026"},4664 {46291, "yyyy-mm-dd", "2026-09-26"},@@ -122,3 +140,40 @@ func TestFormatLCID(t *testing.T) {
122140 }123141 }124142 }143+144+func TestRound(t *testing.T) {145+ tests := []struct {146+ x float64147+ places int148+ mode Rounding149+ want float64150+ }{151+ {1.005, 2, HalfUp, 1.01},152+ {2.675, 2, HalfUp, 2.68},153+ {-2.5, 0, HalfUp, -3},154+ {1234.5, -2, HalfUp, 1200},155+ {1.23456789012345, 14, HalfUp, 1.23456789012346}, // the half added carries, as in Sheets156+ {1.23456789012345, 15, HalfUp, 1.23456789012345},157+ {2.301, 1, Up, 2.4},158+ {-2.39, 1, Down, -2.3},159+ }160+ for _, tt := range tests {161+ if got := Round(tt.x, tt.places, tt.mode); got != tt.want {162+ t.Errorf("Round(%v, %d, %v) = %v, want %v", tt.x, tt.places, tt.mode, got, tt.want)163+ }164+ }165+}166+167+func TestEntered(t *testing.T) {168+ for in, want := range map[string]string{169+ "123456789012345678": "123456789012345000",170+ "123456789012345": "123456789012345",171+ "0.00012345678901234567": "0.00012345678901234500",172+ "1.2345678901234567e10": "1.2345678901234500e10",173+ "12.5": "12.5",174+ } {175+ if got := Entered(in); got != want {176+ t.Errorf("Entered(%s) = %s, want %s", in, got, want)177+ }178+ }179+}@@ -65,6 +65,9 @@ func FormatIn(v float64, pat string, loc *locale.Locale) string {
6565 if isDatePattern(toks) {6666 return formatDate(v, toks, patternNames(sec, loc))6767 }68+ if l, ok := layoutFraction(toks); ok {69+ return formatFraction(math.Abs(v), neg, l, loc)70+ }6871 return formatNumber(math.Abs(v), neg, toks, loc)6972 }7073 @@ -87,8 +87,18 @@ func increment(d []byte, point int) ([]byte, int) {
8787 8888 // Round rounds x to places decimal places (negative places round to8989 // tens, hundreds...) with mode, on x's 15 significant digits.90+//91+// HalfUp rounds as Sheets' ROUND does: it adds half a unit of the last92+// place kept and drops the digits after it, on the sum's 15 significant93+// digits. That is Fixed's rounding, except at the 15th digit itself,94+// where the half added carries: ROUND(1.23456789012345, 14) is95+// 1.23456789012346.9096 func Round(x float64, places int, mode Rounding) float64 {91- whole, frac := Fixed(x, places, mode)97+ a := math.Abs(x)98+ if mode == HalfUp {99+ a, mode = a+0.5*math.Pow10(-places), Down100+ }101+ whole, frac := Fixed(a, places, mode)92102 s := whole93103 if s == "" {94104 s = "0"