| 1 | package format |
| 2 | |
| 3 | import "strings" |
| 4 | |
| 5 | // sectionMode classifies one ';'-separated format section so renderSection |
| 6 | // knows which tokenizer to run. |
| 7 | type sectionMode int |
| 8 | |
| 9 | const ( |
| 10 | modeLiteral sectionMode = iota // no placeholders: always the same text |
| 11 | modeNumeric |
| 12 | modeDate |
| 13 | ) |
| 14 | |
| 15 | // detectMode scans a section for bare (unquoted, unescaped) characters |
| 16 | // that decide its kind: any of yYmMdDhHsS means a date/time section, else |
| 17 | // any of 0# means a numeric section, else the section is pure literal |
| 18 | // text. This must run before tokenizing, because a date section and a |
| 19 | // numeric section use different rules for the same bytes (',' is a |
| 20 | // thousands separator in a numeric section, a literal in a date one). |
| 21 | func detectMode(section string) sectionMode { |
| 22 | sawDate := false |
| 23 | sawNumeric := false |
| 24 | walkBareRunes(section, func(r byte) bool { |
| 25 | switch { |
| 26 | case strings.IndexByte("yYmMdDhHsS", r) >= 0: |
| 27 | sawDate = true |
| 28 | return true // date wins outright — stop scanning early |
| 29 | case r == '0' || r == '#': |
| 30 | sawNumeric = true |
| 31 | } |
| 32 | return false |
| 33 | }) |
| 34 | if sawDate { |
| 35 | return modeDate |
| 36 | } |
| 37 | if sawNumeric { |
| 38 | return modeNumeric |
| 39 | } |
| 40 | return modeLiteral |
| 41 | } |
| 42 | |
| 43 | // walkBareRunes calls fn for every byte in section NOT inside a "quoted" |
| 44 | // literal and not the escaped character following a backslash. Stops |
| 45 | // early if fn returns true. |
| 46 | func walkBareRunes(section string, fn func(byte) bool) { |
| 47 | inQuote := false |
| 48 | for i := 0; i < len(section); i++ { |
| 49 | c := section[i] |
| 50 | switch { |
| 51 | case c == '\\' && i+1 < len(section): |
| 52 | i++ // skip the escaped character itself |
| 53 | case c == '"': |
| 54 | inQuote = !inQuote |
| 55 | case inQuote: |
| 56 | // inside a literal: not a bare rune |
| 57 | default: |
| 58 | if fn(c) { |
| 59 | return |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // rendersNumber reports whether section actually renders the number |
| 66 | // (via "General", numeric, or date placeholders) rather than being a |
| 67 | // fixed literal label — used to decide whether a single-section format |
| 68 | // gets an automatic "-" for a negative value. |
| 69 | func rendersNumber(section string) bool { |
| 70 | if strings.EqualFold(strings.TrimSpace(section), "General") { |
| 71 | return true |
| 72 | } |
| 73 | return detectMode(section) != modeLiteral |
| 74 | } |
| 75 | |
| 76 | // renderSection renders val (already sign-adjusted by the caller) |
| 77 | // through one format section. ok is false when the section uses a |
| 78 | // construct this package does not support, telling the caller to fall |
| 79 | // back to General. |
| 80 | func renderSection(section string, val float64) (string, bool) { |
| 81 | if strings.EqualFold(strings.TrimSpace(section), "General") { |
| 82 | return generalNumber(val), true |
| 83 | } |
| 84 | switch detectMode(section) { |
| 85 | case modeDate: |
| 86 | return renderDate(section, val) |
| 87 | case modeNumeric: |
| 88 | return renderNumeric(section, val) |
| 89 | default: |
| 90 | return renderLiteral(section) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // renderLiteral renders a section with no digit or date placeholders at |
| 95 | // all: the same text regardless of val (e.g. a section used purely to |
| 96 | // hide a value, or to show a fixed label like "-" or "n/a"). A bare |
| 97 | // ASCII letter is still rejected — Excel reserves unquoted letters for |
| 98 | // format tokens, so an unrecognized one (e.g. a stray "q") is treated |
| 99 | // as an unsupported construct rather than assumed to be a literal word. |
| 100 | func renderLiteral(section string) (string, bool) { |
| 101 | var sb strings.Builder |
| 102 | ok := true |
| 103 | walkLiteralAndPlaceholders(section, func(lit string) { sb.WriteString(lit) }, func(c byte) bool { |
| 104 | if isASCIILetter(c) || strings.IndexByte("?@_*", c) >= 0 { |
| 105 | ok = false |
| 106 | return true |
| 107 | } |
| 108 | sb.WriteByte(c) |
| 109 | return false |
| 110 | }) |
| 111 | return sb.String(), ok |
| 112 | } |
| 113 | |
| 114 | // renderTextSection renders the 4th (text) section: '@' inserts s |
| 115 | // verbatim, everything else is literal. |
| 116 | func renderTextSection(section, s string) (string, bool) { |
| 117 | var sb strings.Builder |
| 118 | ok := true |
| 119 | walkLiteralAndPlaceholders(section, func(lit string) { sb.WriteString(lit) }, func(c byte) bool { |
| 120 | if c == '@' { |
| 121 | sb.WriteString(s) |
| 122 | return false |
| 123 | } |
| 124 | ok = false |
| 125 | return true |
| 126 | }) |
| 127 | return sb.String(), ok |
| 128 | } |
| 129 | |
| 130 | // walkLiteralAndPlaceholders walks section, resolving quoted "..." runs |
| 131 | // and \-escaped characters into literal text passed to onLiteral, and |
| 132 | // passing every other bare byte to onOther. onOther returns true to |
| 133 | // abort (unsupported construct). |
| 134 | func walkLiteralAndPlaceholders(section string, onLiteral func(string), onOther func(byte) bool) { |
| 135 | i := 0 |
| 136 | for i < len(section) { |
| 137 | c := section[i] |
| 138 | switch { |
| 139 | case c == '\\' && i+1 < len(section): |
| 140 | onLiteral(string(section[i+1])) |
| 141 | i += 2 |
| 142 | case c == '"': |
| 143 | j := strings.IndexByte(section[i+1:], '"') |
| 144 | if j < 0 { |
| 145 | onLiteral(section[i+1:]) |
| 146 | i = len(section) |
| 147 | } else { |
| 148 | onLiteral(section[i+1 : i+1+j]) |
| 149 | i = i + 1 + j + 1 |
| 150 | } |
| 151 | default: |
| 152 | if onOther(c) { |
| 153 | return |
| 154 | } |
| 155 | i++ |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |