| 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. |
| 47 | package format |
| 48 | |
| 49 | import ( |
| 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. |
| 59 | func 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. |
| 86 | func 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. |
| 101 | func 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. |
| 137 | func 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. |
| 162 | func 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 | |