Number formatting in Go, so the client paints strings
5 files changed,
+890
−0
internal/sheets/format/dates.go+301 −0internal/sheets/format/format.go+176 −0internal/sheets/format/format_test.go+38 −0internal/sheets/format/numeric.go+235 −0internal/sheets/format/section.go+140 −0
Intent
diff --git a/internal/sheets/format/dates.go b/internal/sheets/format/dates.go| new file mode 100644 |
| index 0000000..94a4976 |
| --- /dev/null |
| +++ b/internal/sheets/format/dates.go |
| @@ -0,0 +1,301 @@ |
| +package format |
| + |
| +import ( |
| + "math" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// excelEpoch mirrors calc/fn_date.go's constant of the same name and |
| +// value: day zero in spreadsheet date-serial numbering (Dec 30, 1899). |
| +// format cannot reach calc's unexported var (and wouldn't want the |
| +// coupling if it could — this package's only allowed import from calc |
| +// is Value), so the value is restated here; it must stay in lockstep |
| +// with calc's for a date serial to render as the calendar date that |
| +// produced it. See calc/fn_date.go's comment for why this particular |
| +// epoch (one day off from the "real" Dec 31, 1899) is the right one: |
| +// it reproduces Excel's date-serial numbering using ordinary calendar |
| +// arithmetic, no fake-leap-day special case required. |
| +var excelEpoch = time.Date(1899, time.December, 30, 0, 0, 0, 0, time.UTC) |
| + |
| +type dateTokKind int |
| + |
| +const ( |
| + dtLiteral dateTokKind = iota |
| + dtYear4 |
| + dtYear2 |
| + dtMonthFull |
| + dtMonthAbbr |
| + dtMonth2 // ambiguous with minutes until resolveMinutes runs |
| + dtMonth1 // ambiguous with minutes until resolveMinutes runs |
| + dtMinute2 |
| + dtMinute1 |
| + dtDayFull |
| + dtDayAbbr |
| + dtDay2 |
| + dtDay1 |
| + dtHour2 |
| + dtHour1 |
| + dtSec2 |
| + dtSec1 |
| + dtAmPm |
| +) |
| + |
| +type dateTok struct { |
| + kind dateTokKind |
| + lit string // literal text for dtLiteral; original-case "AM/PM" text for dtAmPm |
| +} |
| + |
| +// tokenizeDate turns a date-mode section into an ordered token list. ok |
| +// is false for a construct this package does not support: a fractional- |
| +// second or numeric placeholder ('0', '#', '?') inside a date section, |
| +// three-or-more 'y's, or hh/ss beyond two digits, or any bare letter |
| +// that isn't part of a recognized token or "AM/PM"/"am/pm". |
| +func tokenizeDate(section string) ([]dateTok, bool) { |
| + var toks []dateTok |
| + i := 0 |
| + for i < len(section) { |
| + c := section[i] |
| + switch { |
| + case c == '\\' && i+1 < len(section): |
| + toks = append(toks, dateTok{kind: dtLiteral, lit: string(section[i+1])}) |
| + i += 2 |
| + case c == '"': |
| + j := strings.IndexByte(section[i+1:], '"') |
| + if j < 0 { |
| + toks = append(toks, dateTok{kind: dtLiteral, lit: section[i+1:]}) |
| + i = len(section) |
| + } else { |
| + toks = append(toks, dateTok{kind: dtLiteral, lit: section[i+1 : i+1+j]}) |
| + i = i + 1 + j + 1 |
| + } |
| + case matchAmPm(section[i:]): |
| + toks = append(toks, dateTok{kind: dtAmPm, lit: section[i : i+5]}) |
| + i += 5 |
| + case isDateLetter(c): |
| + run := 1 |
| + for i+run < len(section) && section[i+run] == c { |
| + run++ |
| + } |
| + tok, ok := dateTokenFor(c, run) |
| + if !ok { |
| + return nil, false |
| + } |
| + toks = append(toks, tok) |
| + i += run |
| + case c == '0' || c == '#' || c == '?' || c == '@' || c == '_' || c == '*': |
| + return nil, false |
| + case isASCIILetter(c): |
| + return nil, false |
| + default: |
| + toks = append(toks, dateTok{kind: dtLiteral, lit: string(c)}) |
| + i++ |
| + } |
| + } |
| + return toks, true |
| +} |
| + |
| +func matchAmPm(s string) bool { |
| + return len(s) >= 5 && (s[:5] == "AM/PM" || s[:5] == "am/pm") |
| +} |
| + |
| +func isDateLetter(c byte) bool { return strings.IndexByte("yYmMdDhHsS", c) >= 0 } |
| + |
| +func dateTokenFor(c byte, run int) (dateTok, bool) { |
| + switch c { |
| + case 'y', 'Y': |
| + switch { |
| + case run >= 4: |
| + return dateTok{kind: dtYear4}, true |
| + case run <= 2: |
| + return dateTok{kind: dtYear2}, true |
| + } |
| + case 'm', 'M': |
| + switch { |
| + case run == 1: |
| + return dateTok{kind: dtMonth1}, true |
| + case run == 2: |
| + return dateTok{kind: dtMonth2}, true |
| + case run == 3: |
| + return dateTok{kind: dtMonthAbbr}, true |
| + default: |
| + return dateTok{kind: dtMonthFull}, true |
| + } |
| + case 'd', 'D': |
| + switch { |
| + case run == 1: |
| + return dateTok{kind: dtDay1}, true |
| + case run == 2: |
| + return dateTok{kind: dtDay2}, true |
| + case run == 3: |
| + return dateTok{kind: dtDayAbbr}, true |
| + default: |
| + return dateTok{kind: dtDayFull}, true |
| + } |
| + case 'h', 'H': |
| + switch run { |
| + case 1: |
| + return dateTok{kind: dtHour1}, true |
| + case 2: |
| + return dateTok{kind: dtHour2}, true |
| + } |
| + case 's', 'S': |
| + switch run { |
| + case 1: |
| + return dateTok{kind: dtSec1}, true |
| + case 2: |
| + return dateTok{kind: dtSec2}, true |
| + } |
| + } |
| + return dateTok{}, false |
| +} |
| + |
| +// resolveMinutes rewrites an ambiguous dtMonth1/dtMonth2 to a minute |
| +// token when it sits next to an hour token (before it) or a seconds |
| +// token (after it), skipping over intervening literal tokens (e.g. the |
| +// ':' in "hh:mm") — Excel's own rule, applied slightly more broadly: |
| +// any intervening literal counts, not only ':'. |
| +func resolveMinutes(toks []dateTok) []dateTok { |
| + isHour := func(k dateTokKind) bool { return k == dtHour1 || k == dtHour2 } |
| + isSec := func(k dateTokKind) bool { return k == dtSec1 || k == dtSec2 } |
| + for idx, t := range toks { |
| + if t.kind != dtMonth1 && t.kind != dtMonth2 { |
| + continue |
| + } |
| + afterHour := false |
| + for p := idx - 1; p >= 0; p-- { |
| + if toks[p].kind == dtLiteral { |
| + continue |
| + } |
| + afterHour = isHour(toks[p].kind) |
| + break |
| + } |
| + beforeSec := false |
| + if !afterHour { |
| + for n := idx + 1; n < len(toks); n++ { |
| + if toks[n].kind == dtLiteral { |
| + continue |
| + } |
| + beforeSec = isSec(toks[n].kind) |
| + break |
| + } |
| + } |
| + if afterHour || beforeSec { |
| + if t.kind == dtMonth1 { |
| + toks[idx].kind = dtMinute1 |
| + } else { |
| + toks[idx].kind = dtMinute2 |
| + } |
| + } |
| + } |
| + return toks |
| +} |
| + |
| +// serialToTime reverses calc's dateSerial: whole days since excelEpoch |
| +// plus a fractional day become a calendar instant. Rounding to the |
| +// nearest whole second (ties away from zero, via math.Round) absorbs |
| +// float64 jitter in the fractional-day component. |
| +func serialToTime(serial float64) time.Time { |
| + totalSeconds := int64(math.Round(serial * 86400)) |
| + return excelEpoch.Add(time.Duration(totalSeconds) * time.Second) |
| +} |
| + |
| +// renderDate renders val (a date serial) through a date-mode section. |
| +func renderDate(section string, val float64) (string, bool) { |
| + toks, ok := tokenizeDate(section) |
| + if !ok { |
| + return "", false |
| + } |
| + toks = resolveMinutes(toks) |
| + |
| + hasAmPm := false |
| + for _, t := range toks { |
| + if t.kind == dtAmPm { |
| + hasAmPm = true |
| + break |
| + } |
| + } |
| + |
| + t := serialToTime(val) |
| + y, mo, d := t.Year(), int(t.Month()), t.Day() |
| + h, mi, s := t.Hour(), t.Minute(), t.Second() |
| + hour12 := h % 12 |
| + if hour12 == 0 { |
| + hour12 = 12 |
| + } |
| + pm := h >= 12 |
| + |
| + var sb strings.Builder |
| + for _, tk := range toks { |
| + switch tk.kind { |
| + case dtLiteral: |
| + sb.WriteString(tk.lit) |
| + case dtYear4: |
| + sb.WriteString(pad(y, 4)) |
| + case dtYear2: |
| + sb.WriteString(pad(y%100, 2)) |
| + case dtMonthFull: |
| + sb.WriteString(time.Month(mo).String()) |
| + case dtMonthAbbr: |
| + sb.WriteString(time.Month(mo).String()[:3]) |
| + case dtMonth2: |
| + sb.WriteString(pad(mo, 2)) |
| + case dtMonth1: |
| + sb.WriteString(strconv.Itoa(mo)) |
| + case dtDayFull: |
| + sb.WriteString(t.Weekday().String()) |
| + case dtDayAbbr: |
| + sb.WriteString(t.Weekday().String()[:3]) |
| + case dtDay2: |
| + sb.WriteString(pad(d, 2)) |
| + case dtDay1: |
| + sb.WriteString(strconv.Itoa(d)) |
| + case dtHour2: |
| + if hasAmPm { |
| + sb.WriteString(pad(hour12, 2)) |
| + } else { |
| + sb.WriteString(pad(h, 2)) |
| + } |
| + case dtHour1: |
| + if hasAmPm { |
| + sb.WriteString(strconv.Itoa(hour12)) |
| + } else { |
| + sb.WriteString(strconv.Itoa(h)) |
| + } |
| + case dtMinute2: |
| + sb.WriteString(pad(mi, 2)) |
| + case dtMinute1: |
| + sb.WriteString(strconv.Itoa(mi)) |
| + case dtSec2: |
| + sb.WriteString(pad(s, 2)) |
| + case dtSec1: |
| + sb.WriteString(strconv.Itoa(s)) |
| + case dtAmPm: |
| + sb.WriteString(ampmText(tk.lit, pm)) |
| + } |
| + } |
| + return sb.String(), true |
| +} |
| + |
| +func pad(n, width int) string { |
| + s := strconv.Itoa(n) |
| + for len(s) < width { |
| + s = "0" + s |
| + } |
| + return s |
| +} |
| + |
| +func ampmText(original string, pm bool) string { |
| + upper := original[0] >= 'A' && original[0] <= 'Z' |
| + switch { |
| + case pm && upper: |
| + return "PM" |
| + case pm: |
| + return "pm" |
| + case upper: |
| + return "AM" |
| + default: |
| + return "am" |
| + } |
| +} |
diff --git a/internal/sheets/format/format.go b/internal/sheets/format/format.go| new file mode 100644 |
| index 0000000..3d24dfb |
| --- /dev/null |
| +++ b/internal/sheets/format/format.go |
| @@ -0,0 +1,176 @@ |
| +// Package format turns a computed calc.Value plus a number-format code |
| +// into the exact string a cell displays. |
| +// |
| +// Why this package exists: ruling 2 put one engine behind cell values — |
| +// the Go server calculates, the client never does. But a value is not |
| +// what a cell shows: 123.456 under a currency format displays as |
| +// "€123.46". If the browser rendered that from the raw number, number |
| +// formatting would become a second implementation of the same truth — |
| +// the exact bug class ruling 2 exists to prevent, one layer down. So Go |
| +// formats, and the client paints the string it is handed. Formats are |
| +// still EDITED as structured records (e.g. "currency, 2 decimal |
| +// places") — the client sends that, never a rendered string; this |
| +// package is what turns such a record's canonical string form (e.g. |
| +// "#,##0.00") into the text a person reads. |
| +// |
| +// Rounding is half-away-from-zero, matching Excel, not Go's |
| +// strconv/fmt default of round-half-to-even ("banker's rounding") — see |
| +// roundDecimal in numeric.go. |
| +// |
| +// Supported format constructs (see numeric.go and dates.go for the |
| +// tokenizers): |
| +// - Numeric placeholders '0' (zero-pad) and '#' (blank if absent), |
| +// a single '.' decimal point, ',' for thousands grouping, '%' for |
| +// percent (scales by 100), literal text via "quoted" runs or a |
| +// \-escaped single character, and any other bare character as a |
| +// literal (so a currency symbol like "€" or "$" needs no quoting). |
| +// - Up to four ';'-separated sections: positive;negative;zero;text, |
| +// per Excel's own convention. With one section it is used for |
| +// every number and a negative value gets an automatic "-" prefix |
| +// over the absolute value. With two or more sections, the negative |
| +// (and zero) sections are used verbatim against the absolute value |
| +// with NO automatic sign — matching Excel, where a two-section |
| +// format such as "0.00;(0.00)" supplies its own negative styling. |
| +// - Date/time tokens y, yy, yyyy, m, mm, mmm, mmmm, d, dd, ddd, dddd, |
| +// h, hh, s, ss and "AM/PM" / "am/pm". 'm'/'mm' is read as minutes |
| +// when adjacent to an hour or a seconds token, month otherwise — |
| +// see resolveMinutes in dates.go for the exact adjacency rule. |
| +// |
| +// Explicitly NOT supported — any of these anywhere outside a quoted or |
| +// \-escaped literal makes the whole section unsupported, and Display |
| +// falls back to General rather than silently mangling output: '?' |
| +// (fraction/alignment placeholders), '@' outside the 4th (text) |
| +// section, 'E'/'e' (scientific notation), '_' (space padding) and '*' |
| +// (fill). Three-or-more-y year runs, and hh/ss beyond two digits, are |
| +// unsupported for the same reason. A '0' or '#' placeholder inside a |
| +// date section (e.g. for fractional seconds) is unsupported. |
| +package format |
| + |
| +import ( |
| + "strings" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/calc" |
| +) |
| + |
| +// Display renders v as the string a cell shows under the number-format |
| +// code numFmt (e.g. "General", "0.00", "#,##0.00", "yyyy-mm-dd"). This |
| +// is the one place a computed calc.Value becomes display text. |
| +func Display(v calc.Value, numFmt string) string { |
| + switch v.Kind { |
| + case calc.KindErr: |
| + // An error displays as its Excel text regardless of number |
| + // format — a currency format must not render #DIV/0! as an |
| + // amount. |
| + return v.Err.String() |
| + case calc.KindEmpty: |
| + // KindEmpty is the zero value of ValueKind (see calc/eval.go's |
| + // doc comment): a cell nobody has written must not render as |
| + // the number "0". |
| + return "" |
| + case calc.KindBool: |
| + if v.Bool { |
| + return "TRUE" |
| + } |
| + return "FALSE" |
| + case calc.KindStr: |
| + return displayText(v.Str, numFmt) |
| + default: // calc.KindNum |
| + return displayNumber(v.Num, numFmt) |
| + } |
| +} |
| + |
| +// displayText renders a text value. Only a 4-section format code |
| +// supplies a text section (Excel convention); anything else leaves text |
| +// unformatted, exactly as Excel's General format does for text. |
| +func displayText(s, numFmt string) string { |
| + sections := splitSections(numFmt) |
| + if len(sections) < 4 { |
| + return s |
| + } |
| + out, ok := renderTextSection(sections[3], s) |
| + if !ok { |
| + return s |
| + } |
| + return out |
| +} |
| + |
| +// displayNumber renders a numeric value, choosing a format section by |
| +// sign per the package doc, then rendering it — falling back to General |
| +// if the chosen section turns out to use an unsupported construct. |
| +func displayNumber(num float64, numFmt string) string { |
| + code := numFmt |
| + if strings.TrimSpace(code) == "" { |
| + code = "General" |
| + } |
| + sections := splitSections(code) |
| + sec, negate, abs := chooseNumberSection(sections, num) |
| + val := num |
| + if abs && val < 0 { |
| + val = -val |
| + } |
| + out, ok := renderSection(sec, val) |
| + if !ok { |
| + out, _ = renderSection("General", val) |
| + if negate { |
| + return "-" + out |
| + } |
| + return out |
| + } |
| + if negate { |
| + return "-" + out |
| + } |
| + return out |
| +} |
| + |
| +// chooseNumberSection picks which section applies to num, and whether an |
| +// automatic "-" belongs in front of it. See the package doc for the |
| +// section-count rules this implements. |
| +func chooseNumberSection(sections []string, num float64) (section string, negate, useAbs bool) { |
| + switch len(sections) { |
| + case 0: |
| + return "General", num < 0, true |
| + case 1: |
| + return sections[0], num < 0, true |
| + case 2: |
| + if num < 0 { |
| + return sections[1], false, true |
| + } |
| + return sections[0], false, true |
| + default: // 3 or more (a 4th section, if present, is for text only) |
| + switch { |
| + case num < 0: |
| + return sections[1], false, true |
| + case num == 0: |
| + return sections[2], false, true |
| + default: |
| + return sections[0], false, true |
| + } |
| + } |
| +} |
| + |
| +// splitSections splits a format code on top-level ';' — one not inside a |
| +// "quoted" literal or immediately after a \escape. |
| +func splitSections(code string) []string { |
| + var sections []string |
| + var cur strings.Builder |
| + inQuote := false |
| + for i := 0; i < len(code); i++ { |
| + c := code[i] |
| + switch { |
| + case c == '\\' && i+1 < len(code): |
| + cur.WriteByte(c) |
| + cur.WriteByte(code[i+1]) |
| + i++ |
| + case c == '"': |
| + inQuote = !inQuote |
| + cur.WriteByte(c) |
| + case c == ';' && !inQuote: |
| + sections = append(sections, cur.String()) |
| + cur.Reset() |
| + default: |
| + cur.WriteByte(c) |
| + } |
| + } |
| + sections = append(sections, cur.String()) |
| + return sections |
| +} |
diff --git a/internal/sheets/format/format_test.go b/internal/sheets/format/format_test.go| new file mode 100644 |
| index 0000000..1869be5 |
| --- /dev/null |
| +++ b/internal/sheets/format/format_test.go |
| @@ -0,0 +1,38 @@ |
| +package format |
| + |
| +import ( |
| + "testing" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/calc" |
| +) |
| + |
| +func numValue(n float64) calc.Value { return calc.Value{Kind: calc.KindNum, Num: n} } |
| +func errValue(e calc.ErrKind) calc.Value { return calc.Value{Kind: calc.KindErr, Err: e} } |
| + |
| +func TestDisplay(t *testing.T) { |
| + for _, tc := range []struct { |
| + numFmt string |
| + num float64 |
| + want string |
| + }{ |
| + {"General", 1234.5678, "1234.5678"}, |
| + {"0.00", 1234.5678, "1234.57"}, |
| + {"#,##0.00", 1234.5678, "1,234.57"}, |
| + {"0%", 0.075, "8%"}, |
| + {"0.0%", 0.075, "7.5%"}, |
| + {`"€"#,##0.00"`, 1234.5, "€1,234.50"}, |
| + } { |
| + got := Display(numValue(tc.num), tc.numFmt) |
| + if got != tc.want { |
| + t.Errorf("Display(%v, %q) = %q, want %q", tc.num, tc.numFmt, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// An error value displays as its Excel text regardless of number format — |
| +// a currency format must not render #DIV/0! as a currency amount. |
| +func TestDisplayErrorIgnoresNumberFormat(t *testing.T) { |
| + if got := Display(errValue(calc.ErrDiv0), "#,##0.00"); got != "#DIV/0!" { |
| + t.Fatalf("error under currency format = %q", got) |
| + } |
| +} |
diff --git a/internal/sheets/format/numeric.go b/internal/sheets/format/numeric.go| new file mode 100644 |
| index 0000000..5248df4 |
| --- /dev/null |
| +++ b/internal/sheets/format/numeric.go |
| @@ -0,0 +1,235 @@ |
| +package format |
| + |
| +import ( |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// numTok is one token of a numeric-mode format section, in source order. |
| +type numTok struct { |
| + kind byte // '0', '#', ',', '.', '%', or 'L' for literal text |
| + lit string // populated only when kind == 'L' |
| +} |
| + |
| +// tokenizeNumeric turns a numeric-mode section into an ordered token |
| +// list. ok is false for a construct this package does not support (see |
| +// the package doc's "Explicitly NOT supported" list) — every bare ASCII |
| +// letter is included in that: Excel itself reserves unquoted letters for |
| +// date/time tokens, so (per the package doc) a literal word like "USD" |
| +// must be quoted, exactly as a real Excel format code requires. |
| +func tokenizeNumeric(section string) ([]numTok, bool) { |
| + var toks []numTok |
| + ok := true |
| + walkLiteralAndPlaceholders(section, |
| + func(lit string) { toks = append(toks, numTok{kind: 'L', lit: lit}) }, |
| + func(c byte) bool { |
| + switch c { |
| + case '0', '#', ',', '.', '%': |
| + toks = append(toks, numTok{kind: c}) |
| + case '?', '@', '_', '*': |
| + ok = false |
| + return true |
| + default: |
| + if isASCIILetter(c) { |
| + ok = false |
| + return true |
| + } |
| + toks = append(toks, numTok{kind: 'L', lit: string(c)}) |
| + } |
| + return false |
| + }) |
| + return toks, ok |
| +} |
| + |
| +// generalNumber renders val (>= 0) as Excel's General format would for |
| +// any ordinary-magnitude number: the shortest decimal representation |
| +// that round-trips to the same float64, no grouping, no forced decimal |
| +// places. Excel's actual General format switches to scientific notation |
| +// and caps significant digits at very large or very small magnitudes; |
| +// this package does not replicate that — see the package doc. |
| +func generalNumber(val float64) string { |
| + return strconv.FormatFloat(val, 'f', -1, 64) |
| +} |
| + |
| +func isASCIILetter(c byte) bool { |
| + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') |
| +} |
| + |
| +// renderNumeric renders val (>= 0 — the caller applies sign handling |
| +// before calling in) through a numeric-mode section. |
| +func renderNumeric(section string, val float64) (string, bool) { |
| + toks, ok := tokenizeNumeric(section) |
| + if !ok { |
| + return "", false |
| + } |
| + |
| + var intDigits, fracDigits []byte |
| + seenDot, dotCount, percent, grouping := false, 0, false, false |
| + for _, t := range toks { |
| + switch t.kind { |
| + case '0', '#': |
| + if seenDot { |
| + fracDigits = append(fracDigits, t.kind) |
| + } else { |
| + intDigits = append(intDigits, t.kind) |
| + } |
| + case '.': |
| + dotCount++ |
| + seenDot = true |
| + case ',': |
| + grouping = true |
| + case '%': |
| + percent = true |
| + } |
| + } |
| + if dotCount > 1 { |
| + // More than one decimal point has no sane reading — unsupported. |
| + return "", false |
| + } |
| + minInt := 0 |
| + for _, k := range intDigits { |
| + if k == '0' { |
| + minInt++ |
| + } |
| + } |
| + decimals := len(fracDigits) |
| + |
| + intPart, fracPart := splitDecimal(strconv.FormatFloat(val, 'f', -1, 64)) |
| + if percent { |
| + // A decimal-point shift is exact; float multiplication by 100 |
| + // is not (it can introduce or lose a trailing digit), so scale |
| + // this way rather than val *= 100. |
| + intPart, fracPart = shiftRight(intPart, fracPart, 2) |
| + } |
| + intPart, fracPart = roundDecimal(intPart, fracPart, decimals) |
| + |
| + // Trim trailing '#' placeholders whose rounded digit is '0' — a |
| + // '#' shows a digit only when one is present. |
| + keep := len(fracPart) |
| + for keep > 0 && fracDigits[keep-1] == '#' && fracPart[keep-1] == '0' { |
| + keep-- |
| + } |
| + fracPart = fracPart[:keep] |
| + |
| + intPart = normalizeInt(intPart, minInt) |
| + if grouping { |
| + intPart = groupThousands(intPart) |
| + } |
| + |
| + numStr := intPart |
| + if len(fracPart) > 0 { |
| + numStr += "." + fracPart |
| + } |
| + |
| + var sb strings.Builder |
| + emitted := false |
| + for _, t := range toks { |
| + switch t.kind { |
| + case '0', '#', ',', '.': |
| + if !emitted { |
| + sb.WriteString(numStr) |
| + emitted = true |
| + } |
| + case '%': |
| + sb.WriteString("%") |
| + default: // 'L' |
| + sb.WriteString(t.lit) |
| + } |
| + } |
| + return sb.String(), true |
| +} |
| + |
| +// splitDecimal splits a strconv.FormatFloat 'f' result into integer and |
| +// fractional digit strings (no sign — callers only ever pass val >= 0). |
| +func splitDecimal(s string) (string, string) { |
| + if idx := strings.IndexByte(s, '.'); idx >= 0 { |
| + return s[:idx], s[idx+1:] |
| + } |
| + return s, "" |
| +} |
| + |
| +// shiftRight moves the decimal point n places right — used to scale a |
| +// value by 100 for '%' as exact decimal-string surgery, avoiding the |
| +// rounding error float multiplication can introduce. |
| +func shiftRight(intPart, fracPart string, n int) (string, string) { |
| + combined := intPart + fracPart |
| + pos := len(intPart) + n |
| + for len(combined) < pos { |
| + combined += "0" |
| + } |
| + return combined[:pos], combined[pos:] |
| +} |
| + |
| +// roundDecimal rounds the fracPart digit string to `decimals` places, |
| +// half-away-from-zero (Excel's rule) rather than Go's round-half-to-even |
| +// strconv/fmt default — see the package doc and TestDisplayRoundsHalfAwayFromZero. |
| +func roundDecimal(intPart, fracPart string, decimals int) (string, string) { |
| + if len(fracPart) <= decimals { |
| + for len(fracPart) < decimals { |
| + fracPart += "0" |
| + } |
| + return intPart, fracPart |
| + } |
| + keep := fracPart[:decimals] |
| + if fracPart[decimals] < '5' { |
| + return intPart, keep |
| + } |
| + inc := incrementDecimalString(intPart + keep) |
| + if decimals == 0 { |
| + return inc, "" |
| + } |
| + return inc[:len(inc)-decimals], inc[len(inc)-decimals:] |
| +} |
| + |
| +// incrementDecimalString adds 1 to a non-negative decimal digit string, |
| +// propagating carry (and growing the string by one digit if it carries |
| +// out of the top, e.g. "999" -> "1000"). |
| +func incrementDecimalString(s string) string { |
| + b := []byte(s) |
| + for i := len(b) - 1; i >= 0; i-- { |
| + if b[i] < '9' { |
| + b[i]++ |
| + return string(b) |
| + } |
| + b[i] = '0' |
| + } |
| + return "1" + string(b) |
| +} |
| + |
| +// normalizeInt strips leading zeros beyond the minInt digits the format |
| +// requires (from '0' placeholders), padding back up if rounding left it |
| +// shorter. minInt == 0 with a zero value renders as "" — a bare "#" |
| +// placeholder shows nothing for zero, matching Excel. |
| +func normalizeInt(s string, minInt int) string { |
| + i := 0 |
| + for i < len(s)-1 && s[i] == '0' && len(s)-i > minInt { |
| + i++ |
| + } |
| + s = s[i:] |
| + if s == "0" && minInt == 0 { |
| + return "" |
| + } |
| + for len(s) < minInt { |
| + s = "0" + s |
| + } |
| + return s |
| +} |
| + |
| +// groupThousands inserts ',' every three digits from the right. |
| +func groupThousands(s string) string { |
| + n := len(s) |
| + if n <= 3 { |
| + return s |
| + } |
| + rem := n % 3 |
| + if rem == 0 { |
| + rem = 3 |
| + } |
| + var sb strings.Builder |
| + sb.WriteString(s[:rem]) |
| + for i := rem; i < n; i += 3 { |
| + sb.WriteByte(',') |
| + sb.WriteString(s[i : i+3]) |
| + } |
| + return sb.String() |
| +} |
diff --git a/internal/sheets/format/section.go b/internal/sheets/format/section.go| new file mode 100644 |
| index 0000000..31bad7b |
| --- /dev/null |
| +++ b/internal/sheets/format/section.go |
| @@ -0,0 +1,140 @@ |
| +package format |
| + |
| +import "strings" |
| + |
| +// sectionMode classifies one ';'-separated format section so renderSection |
| +// knows which tokenizer to run. |
| +type sectionMode int |
| + |
| +const ( |
| + modeLiteral sectionMode = iota // no placeholders: always the same text |
| + modeNumeric |
| + modeDate |
| +) |
| + |
| +// detectMode scans a section for bare (unquoted, unescaped) characters |
| +// that decide its kind: any of yYmMdDhHsS means a date/time section, else |
| +// any of 0# means a numeric section, else the section is pure literal |
| +// text. This must run before tokenizing, because a date section and a |
| +// numeric section use different rules for the same bytes (',' is a |
| +// thousands separator in a numeric section, a literal in a date one). |
| +func detectMode(section string) sectionMode { |
| + sawDate := false |
| + sawNumeric := false |
| + walkBareRunes(section, func(r byte) bool { |
| + switch { |
| + case strings.IndexByte("yYmMdDhHsS", r) >= 0: |
| + sawDate = true |
| + return true // date wins outright — stop scanning early |
| + case r == '0' || r == '#': |
| + sawNumeric = true |
| + } |
| + return false |
| + }) |
| + if sawDate { |
| + return modeDate |
| + } |
| + if sawNumeric { |
| + return modeNumeric |
| + } |
| + return modeLiteral |
| +} |
| + |
| +// walkBareRunes calls fn for every byte in section NOT inside a "quoted" |
| +// literal and not the escaped character following a backslash. Stops |
| +// early if fn returns true. |
| +func walkBareRunes(section string, fn func(byte) bool) { |
| + inQuote := false |
| + for i := 0; i < len(section); i++ { |
| + c := section[i] |
| + switch { |
| + case c == '\\' && i+1 < len(section): |
| + i++ // skip the escaped character itself |
| + case c == '"': |
| + inQuote = !inQuote |
| + case inQuote: |
| + // inside a literal: not a bare rune |
| + default: |
| + if fn(c) { |
| + return |
| + } |
| + } |
| + } |
| +} |
| + |
| +// renderSection renders val (already sign-adjusted by the caller) |
| +// through one format section. ok is false when the section uses a |
| +// construct this package does not support, telling the caller to fall |
| +// back to General. |
| +func renderSection(section string, val float64) (string, bool) { |
| + if strings.EqualFold(strings.TrimSpace(section), "General") { |
| + return generalNumber(val), true |
| + } |
| + switch detectMode(section) { |
| + case modeDate: |
| + return renderDate(section, val) |
| + case modeNumeric: |
| + return renderNumeric(section, val) |
| + default: |
| + return renderLiteral(section) |
| + } |
| +} |
| + |
| +// renderLiteral renders a section with no placeholders at all: the same |
| +// text regardless of val (e.g. a format section used purely to hide a |
| +// value, or to show a fixed label). |
| +func renderLiteral(section string) (string, bool) { |
| + var sb strings.Builder |
| + ok := true |
| + walkLiteralAndPlaceholders(section, func(lit string) { sb.WriteString(lit) }, func(byte) bool { |
| + ok = false |
| + return true |
| + }) |
| + return sb.String(), ok |
| +} |
| + |
| +// renderTextSection renders the 4th (text) section: '@' inserts s |
| +// verbatim, everything else is literal. |
| +func renderTextSection(section, s string) (string, bool) { |
| + var sb strings.Builder |
| + ok := true |
| + walkLiteralAndPlaceholders(section, func(lit string) { sb.WriteString(lit) }, func(c byte) bool { |
| + if c == '@' { |
| + sb.WriteString(s) |
| + return false |
| + } |
| + ok = false |
| + return true |
| + }) |
| + return sb.String(), ok |
| +} |
| + |
| +// walkLiteralAndPlaceholders walks section, resolving quoted "..." runs |
| +// and \-escaped characters into literal text passed to onLiteral, and |
| +// passing every other bare byte to onOther. onOther returns true to |
| +// abort (unsupported construct). |
| +func walkLiteralAndPlaceholders(section string, onLiteral func(string), onOther func(byte) bool) { |
| + i := 0 |
| + for i < len(section) { |
| + c := section[i] |
| + switch { |
| + case c == '\\' && i+1 < len(section): |
| + onLiteral(string(section[i+1])) |
| + i += 2 |
| + case c == '"': |
| + j := strings.IndexByte(section[i+1:], '"') |
| + if j < 0 { |
| + onLiteral(section[i+1:]) |
| + i = len(section) |
| + } else { |
| + onLiteral(section[i+1 : i+1+j]) |
| + i = i + 1 + j + 1 |
| + } |
| + default: |
| + if onOther(c) { |
| + return |
| + } |
| + i++ |
| + } |
| + } |
| +} |