oficina / sheets Public

Number formatting in Go, so the client paints strings

Paul Campbell 010720162affa67077fb384694be3473ab2a1928 parent 17e26e2
5 files changed, +890 −0
  • internal/sheets/format/dates.go +301 −0
  • internal/sheets/format/format.go +176 −0
  • internal/sheets/format/format_test.go +38 −0
  • internal/sheets/format/numeric.go +235 −0
  • internal/sheets/format/section.go +140 −0
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++
+ }
+ }
+}