| 1 | package format |
| 2 | |
| 3 | import ( |
| 4 | "strconv" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // numTok is one token of a numeric-mode format section, in source order. |
| 9 | type numTok struct { |
| 10 | kind byte // '0', '#', ',', '.', '%', or 'L' for literal text |
| 11 | lit string // populated only when kind == 'L' |
| 12 | } |
| 13 | |
| 14 | // tokenizeNumeric turns a numeric-mode section into an ordered token |
| 15 | // list. ok is false for a construct this package does not support (see |
| 16 | // the package doc's "Explicitly NOT supported" list) — every bare ASCII |
| 17 | // letter is included in that: Excel itself reserves unquoted letters for |
| 18 | // date/time tokens, so (per the package doc) a literal word like "USD" |
| 19 | // must be quoted, exactly as a real Excel format code requires. |
| 20 | func tokenizeNumeric(section string) ([]numTok, bool) { |
| 21 | var toks []numTok |
| 22 | ok := true |
| 23 | walkLiteralAndPlaceholders(section, |
| 24 | func(lit string) { toks = append(toks, numTok{kind: 'L', lit: lit}) }, |
| 25 | func(c byte) bool { |
| 26 | switch c { |
| 27 | case '0', '#', ',', '.', '%': |
| 28 | toks = append(toks, numTok{kind: c}) |
| 29 | case '?', '@', '_', '*': |
| 30 | ok = false |
| 31 | return true |
| 32 | default: |
| 33 | if isASCIILetter(c) { |
| 34 | ok = false |
| 35 | return true |
| 36 | } |
| 37 | toks = append(toks, numTok{kind: 'L', lit: string(c)}) |
| 38 | } |
| 39 | return false |
| 40 | }) |
| 41 | return toks, ok |
| 42 | } |
| 43 | |
| 44 | // generalNumber renders val (>= 0) as Excel's General format would for |
| 45 | // any ordinary-magnitude number: at most 15 significant digits, no |
| 46 | // grouping, no forced decimal places, trailing zeros dropped. |
| 47 | // |
| 48 | // Fifteen, not the shortest round-trip representation this used to |
| 49 | // print: a double holds 15 decimal digits for certain and the 16th and |
| 50 | // 17th are binary noise, so =C4*0.1 with C4 = 920.5 showed |
| 51 | // 92.05000000000001 (TestGeneralShowsAtMostFifteenSignificantDigits). |
| 52 | // Excel's General stops at 15 too. Only the display rounds; the stored |
| 53 | // value and everything computed from it stay the exact double (AGENTS.md: |
| 54 | // cell values are IEEE doubles end to end). |
| 55 | // |
| 56 | // Excel's actual General format also switches to scientific notation at |
| 57 | // very large or very small magnitudes; this package does not replicate |
| 58 | // that — see the package doc. |
| 59 | func generalNumber(val float64) string { |
| 60 | rounded, err := strconv.ParseFloat(strconv.FormatFloat(val, 'g', 15, 64), 64) |
| 61 | if err != nil { |
| 62 | return strconv.FormatFloat(val, 'f', -1, 64) |
| 63 | } |
| 64 | return strconv.FormatFloat(rounded, 'f', -1, 64) |
| 65 | } |
| 66 | |
| 67 | func isASCIILetter(c byte) bool { |
| 68 | return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') |
| 69 | } |
| 70 | |
| 71 | // renderNumeric renders val (>= 0 — the caller applies sign handling |
| 72 | // before calling in) through a numeric-mode section. |
| 73 | func renderNumeric(section string, val float64) (string, bool) { |
| 74 | toks, ok := tokenizeNumeric(section) |
| 75 | if !ok { |
| 76 | return "", false |
| 77 | } |
| 78 | |
| 79 | var intDigits, fracDigits []byte |
| 80 | seenDot, dotCount, percent, grouping := false, 0, false, false |
| 81 | for _, t := range toks { |
| 82 | switch t.kind { |
| 83 | case '0', '#': |
| 84 | if seenDot { |
| 85 | fracDigits = append(fracDigits, t.kind) |
| 86 | } else { |
| 87 | intDigits = append(intDigits, t.kind) |
| 88 | } |
| 89 | case '.': |
| 90 | dotCount++ |
| 91 | seenDot = true |
| 92 | case ',': |
| 93 | grouping = true |
| 94 | case '%': |
| 95 | percent = true |
| 96 | } |
| 97 | } |
| 98 | if dotCount > 1 { |
| 99 | // More than one decimal point has no sane reading — unsupported. |
| 100 | return "", false |
| 101 | } |
| 102 | minInt := 0 |
| 103 | for _, k := range intDigits { |
| 104 | if k == '0' { |
| 105 | minInt++ |
| 106 | } |
| 107 | } |
| 108 | decimals := len(fracDigits) |
| 109 | |
| 110 | intPart, fracPart := splitDecimal(strconv.FormatFloat(val, 'f', -1, 64)) |
| 111 | if percent { |
| 112 | // A decimal-point shift is exact; float multiplication by 100 |
| 113 | // is not (it can introduce or lose a trailing digit), so scale |
| 114 | // this way rather than val *= 100. |
| 115 | intPart, fracPart = shiftRight(intPart, fracPart, 2) |
| 116 | } |
| 117 | intPart, fracPart = roundDecimal(intPart, fracPart, decimals) |
| 118 | |
| 119 | // Trim trailing '#' placeholders whose rounded digit is '0' — a |
| 120 | // '#' shows a digit only when one is present. |
| 121 | keep := len(fracPart) |
| 122 | for keep > 0 && fracDigits[keep-1] == '#' && fracPart[keep-1] == '0' { |
| 123 | keep-- |
| 124 | } |
| 125 | fracPart = fracPart[:keep] |
| 126 | |
| 127 | intPart = normalizeInt(intPart, minInt) |
| 128 | if grouping { |
| 129 | intPart = groupThousands(intPart) |
| 130 | } |
| 131 | |
| 132 | numStr := intPart |
| 133 | if len(fracPart) > 0 { |
| 134 | numStr += "." + fracPart |
| 135 | } |
| 136 | |
| 137 | var sb strings.Builder |
| 138 | emitted := false |
| 139 | for _, t := range toks { |
| 140 | switch t.kind { |
| 141 | case '0', '#', ',', '.': |
| 142 | if !emitted { |
| 143 | sb.WriteString(numStr) |
| 144 | emitted = true |
| 145 | } |
| 146 | case '%': |
| 147 | sb.WriteString("%") |
| 148 | default: // 'L' |
| 149 | sb.WriteString(t.lit) |
| 150 | } |
| 151 | } |
| 152 | return sb.String(), true |
| 153 | } |
| 154 | |
| 155 | // splitDecimal splits a strconv.FormatFloat 'f' result into integer and |
| 156 | // fractional digit strings (no sign — callers only ever pass val >= 0). |
| 157 | func splitDecimal(s string) (string, string) { |
| 158 | if idx := strings.IndexByte(s, '.'); idx >= 0 { |
| 159 | return s[:idx], s[idx+1:] |
| 160 | } |
| 161 | return s, "" |
| 162 | } |
| 163 | |
| 164 | // shiftRight moves the decimal point n places right — used to scale a |
| 165 | // value by 100 for '%' as exact decimal-string surgery, avoiding the |
| 166 | // rounding error float multiplication can introduce. |
| 167 | func shiftRight(intPart, fracPart string, n int) (string, string) { |
| 168 | combined := intPart + fracPart |
| 169 | pos := len(intPart) + n |
| 170 | for len(combined) < pos { |
| 171 | combined += "0" |
| 172 | } |
| 173 | return combined[:pos], combined[pos:] |
| 174 | } |
| 175 | |
| 176 | // roundDecimal rounds the fracPart digit string to `decimals` places, |
| 177 | // half-away-from-zero (Excel's rule) rather than Go's round-half-to-even |
| 178 | // strconv/fmt default — see the package doc and TestDisplayRoundsHalfAwayFromZero. |
| 179 | func roundDecimal(intPart, fracPart string, decimals int) (string, string) { |
| 180 | if len(fracPart) <= decimals { |
| 181 | for len(fracPart) < decimals { |
| 182 | fracPart += "0" |
| 183 | } |
| 184 | return intPart, fracPart |
| 185 | } |
| 186 | keep := fracPart[:decimals] |
| 187 | if fracPart[decimals] < '5' { |
| 188 | return intPart, keep |
| 189 | } |
| 190 | inc := incrementDecimalString(intPart + keep) |
| 191 | if decimals == 0 { |
| 192 | return inc, "" |
| 193 | } |
| 194 | return inc[:len(inc)-decimals], inc[len(inc)-decimals:] |
| 195 | } |
| 196 | |
| 197 | // incrementDecimalString adds 1 to a non-negative decimal digit string, |
| 198 | // propagating carry (and growing the string by one digit if it carries |
| 199 | // out of the top, e.g. "999" -> "1000"). |
| 200 | func incrementDecimalString(s string) string { |
| 201 | b := []byte(s) |
| 202 | for i := len(b) - 1; i >= 0; i-- { |
| 203 | if b[i] < '9' { |
| 204 | b[i]++ |
| 205 | return string(b) |
| 206 | } |
| 207 | b[i] = '0' |
| 208 | } |
| 209 | return "1" + string(b) |
| 210 | } |
| 211 | |
| 212 | // normalizeInt strips leading zeros beyond the minInt digits the format |
| 213 | // requires (from '0' placeholders), padding back up if rounding left it |
| 214 | // shorter. minInt == 0 with a zero value renders as "" — a bare "#" |
| 215 | // placeholder shows nothing for zero, matching Excel. |
| 216 | func normalizeInt(s string, minInt int) string { |
| 217 | i := 0 |
| 218 | for i < len(s)-1 && s[i] == '0' && len(s)-i > minInt { |
| 219 | i++ |
| 220 | } |
| 221 | s = s[i:] |
| 222 | if s == "0" && minInt == 0 { |
| 223 | return "" |
| 224 | } |
| 225 | for len(s) < minInt { |
| 226 | s = "0" + s |
| 227 | } |
| 228 | return s |
| 229 | } |
| 230 | |
| 231 | // groupThousands inserts ',' every three digits from the right. |
| 232 | func groupThousands(s string) string { |
| 233 | n := len(s) |
| 234 | if n <= 3 { |
| 235 | return s |
| 236 | } |
| 237 | rem := n % 3 |
| 238 | if rem == 0 { |
| 239 | rem = 3 |
| 240 | } |
| 241 | var sb strings.Builder |
| 242 | sb.WriteString(s[:rem]) |
| 243 | for i := rem; i < n; i += 3 { |
| 244 | sb.WriteByte(',') |
| 245 | sb.WriteString(s[i : i+3]) |
| 246 | } |
| 247 | return sb.String() |
| 248 | } |
| 249 | |