oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package format
2
3import (
4 "strconv"
5 "strings"
6)
7
8// numTok is one token of a numeric-mode format section, in source order.
9type 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.
20func 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.
59func 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
67func 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.
73func 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).
157func 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.
167func 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.
179func 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").
200func 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.
216func 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.
232func 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