oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package format turns a computed calc.Value plus a number-format code
2// into the exact string a cell displays.
3//
4// Why this package exists: ruling 2 put one engine behind cell values —
5// the Go server calculates, the client never does. But a value is not
6// what a cell shows: 123.456 under a currency format displays as
7// "€123.46". If the browser rendered that from the raw number, number
8// formatting would become a second implementation of the same truth —
9// the exact bug class ruling 2 exists to prevent, one layer down. So Go
10// formats, and the client paints the string it is handed. Formats are
11// still EDITED as structured records (e.g. "currency, 2 decimal
12// places") — the client sends that, never a rendered string; this
13// package is what turns such a record's canonical string form (e.g.
14// "#,##0.00") into the text a person reads.
15//
16// Rounding is half-away-from-zero, matching Excel, not Go's
17// strconv/fmt default of round-half-to-even ("banker's rounding") — see
18// roundDecimal in numeric.go.
19//
20// Supported format constructs (see numeric.go and dates.go for the
21// tokenizers):
22// - Numeric placeholders '0' (zero-pad) and '#' (blank if absent),
23// a single '.' decimal point, ',' for thousands grouping, '%' for
24// percent (scales by 100), literal text via "quoted" runs or a
25// \-escaped single character, and any other bare character as a
26// literal (so a currency symbol like "€" or "$" needs no quoting).
27// - Up to four ';'-separated sections: positive;negative;zero;text,
28// per Excel's own convention. With one section it is used for
29// every number and a negative value gets an automatic "-" prefix
30// over the absolute value. With two or more sections, the negative
31// (and zero) sections are used verbatim against the absolute value
32// with NO automatic sign — matching Excel, where a two-section
33// format such as "0.00;(0.00)" supplies its own negative styling.
34// - Date/time tokens y, yy, yyyy, m, mm, mmm, mmmm, d, dd, ddd, dddd,
35// h, hh, s, ss and "AM/PM" / "am/pm". 'm'/'mm' is read as minutes
36// when adjacent to an hour or a seconds token, month otherwise —
37// see resolveMinutes in dates.go for the exact adjacency rule.
38//
39// Explicitly NOT supported — any of these anywhere outside a quoted or
40// \-escaped literal makes the whole section unsupported, and Display
41// falls back to General rather than silently mangling output: '?'
42// (fraction/alignment placeholders), '@' outside the 4th (text)
43// section, 'E'/'e' (scientific notation), '_' (space padding) and '*'
44// (fill). Three-or-more-y year runs, and hh/ss beyond two digits, are
45// unsupported for the same reason. A '0' or '#' placeholder inside a
46// date section (e.g. for fractional seconds) is unsupported.
47package format
48
49import (
50 "math"
51 "strings"
52
53 "amadan.net/oficina/sheets/internal/sheets/calc"
54)
55
56// Display renders v as the string a cell shows under the number-format
57// code numFmt (e.g. "General", "0.00", "#,##0.00", "yyyy-mm-dd"). This
58// is the one place a computed calc.Value becomes display text.
59func Display(v calc.Value, numFmt string) string {
60 switch v.Kind {
61 case calc.KindErr:
62 // An error displays as its Excel text regardless of number
63 // format — a currency format must not render #DIV/0! as an
64 // amount.
65 return v.Err.String()
66 case calc.KindEmpty:
67 // KindEmpty is the zero value of ValueKind (see calc/eval.go's
68 // doc comment): a cell nobody has written must not render as
69 // the number "0".
70 return ""
71 case calc.KindBool:
72 if v.Bool {
73 return "TRUE"
74 }
75 return "FALSE"
76 case calc.KindStr:
77 return displayText(v.Str, numFmt)
78 default: // calc.KindNum
79 return displayNumber(v.Num, numFmt)
80 }
81}
82
83// displayText renders a text value. Only a 4-section format code
84// supplies a text section (Excel convention); anything else leaves text
85// unformatted, exactly as Excel's General format does for text.
86func displayText(s, numFmt string) string {
87 sections := splitSections(numFmt)
88 if len(sections) < 4 {
89 return s
90 }
91 out, ok := renderTextSection(sections[3], s)
92 if !ok {
93 return s
94 }
95 return out
96}
97
98// displayNumber renders a numeric value, choosing a format section by
99// sign per the package doc, then rendering it — falling back to General
100// if the chosen section turns out to use an unsupported construct.
101func displayNumber(num float64, numFmt string) string {
102 code := numFmt
103 if strings.TrimSpace(code) == "" {
104 code = "General"
105 }
106 sections := splitSections(code)
107 sec, negate := chooseNumberSection(sections, num)
108 // math.Abs, not "if val < 0 { val = -val }": that guard leaves
109 // negative zero (-0.0 < 0 is false under IEEE 754) unclipped, and
110 // strconv.FormatFloat prints it as "-0" — a leaked sign on a cell
111 // that should read plain "0.00". math.Abs clears the sign bit.
112 val := math.Abs(num)
113 out, ok := renderSection(sec, val)
114 if !ok {
115 out, _ = renderSection("General", val)
116 if negate {
117 return "-" + out
118 }
119 return out
120 }
121 if negate {
122 return "-" + out
123 }
124 return out
125}
126
127// chooseNumberSection picks which section applies to num, and whether an
128// automatic "-" belongs in front of it. Every branch renders against
129// abs(num) — see the package doc's section-count rules: a single-section
130// format gets an automatic sign over the absolute value, while two or
131// more sections supply their own negative styling (over the absolute
132// value too) with no automatic sign, exactly as Excel does. The
133// automatic sign only applies when that single section actually renders
134// the number (numeric or date placeholders) — a literal-only section
135// like "n/a" is a fixed label, not a number with its sign stripped, so
136// it gets no automatic "-" either.
137func chooseNumberSection(sections []string, num float64) (section string, negate bool) {
138 switch len(sections) {
139 case 0:
140 return "General", num < 0
141 case 1:
142 return sections[0], num < 0 && rendersNumber(sections[0])
143 case 2:
144 if num < 0 {
145 return sections[1], false
146 }
147 return sections[0], false
148 default: // 3 or more (a 4th section, if present, is for text only)
149 switch {
150 case num < 0:
151 return sections[1], false
152 case num == 0:
153 return sections[2], false
154 default:
155 return sections[0], false
156 }
157 }
158}
159
160// splitSections splits a format code on top-level ';' — one not inside a
161// "quoted" literal or immediately after a \escape.
162func splitSections(code string) []string {
163 var sections []string
164 var cur strings.Builder
165 inQuote := false
166 for i := 0; i < len(code); i++ {
167 c := code[i]
168 switch {
169 case c == '\\' && i+1 < len(code):
170 cur.WriteByte(c)
171 cur.WriteByte(code[i+1])
172 i++
173 case c == '"':
174 inQuote = !inQuote
175 cur.WriteByte(c)
176 case c == ';' && !inQuote:
177 sections = append(sections, cur.String())
178 cur.Reset()
179 default:
180 cur.WriteByte(c)
181 }
182 }
183 sections = append(sections, cur.String())
184 return sections
185}
186