Formats show mmm, mmmm, ddd and dddd in the locale's language, in the form a date with its day takes where the language has one (Polish, Czech, Russian, Finnish), with Japanese and Chinese words for AM/PM; a [$-407] tag in a pattern shows that locale's names, as in Excel. Dates typed with the locale's month names are read as dates. XLSX downloads write the Currency, Date, Time and Date time formats in the workbook's locale, tagged with its LCID, and read them back as those formats in the same locale (as custom patterns in another). A filter's values list shows values as the locale does while keeping them in en-US's form, and conditions on text (filters and conditional formats) test the text the locale shows, keeping their value as typed. Parse errors of formulas typed in a locale name its separators and the text typed: 'Expected ; or ) in SUM'.
- commit
- b820f463a8579ec24cdbb6f4c4894538f6e0e32c
- parent
- 9471af439d88bfe1f5f990ec1590e858a84452cb
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.2 of 3, Jev's estimate
@@ -25,7 +25,7 @@ shows it is derived from the table rather than listing it again.
2525 | `chart.types`, with `sheet.ChartTypes` | a type: name and order (sheet, saved in files), a layout drawing text and image and the series its legend lists (chart) | `chart.Draw`, `chart.Image`, chart editor, Insert > Chart |2626 | theme roles (`theme.Theme`) | a role: dark and light styles on the 16 ANSI colors, with a contrast minimum for schemes (`minContrast`) | every style in the UI, drawn in the terminal's palette or any color scheme (`FromPalette`); `TestEveryThemeReadable` checks each role under every built-in scheme |2727 | `config.Options` | an option: name, type, default, environment variables, flag, live or not, description, check | parsing and warnings, flags, `012 config` and its default file, [Configuration](../reference/config.md), Reload config |28-| `locale.table` | a locale: tag, name, separators, date order and patterns, currency and its place | parsing typed entries, display, formula separators, CSV, File > Settings > Locale, the `locale` option's values |28+| `locale.table` | a locale: tag, name, Windows LCID, separators, date order and patterns, currency and its place; its language's month and day names (`locale.names`) | parsing typed entries, display, formula separators, CSV, File > Settings > Locale, the `locale` option's values |2929 3030 Adding a function, command, format, chart type, option or role means3131 adding an entry (and its file), not editing switch statements elsewhere.@@ -27,6 +27,7 @@ What changes on the way:
2727 | | |2828 |---|---|2929 | Formulas 012 can't read (unknown functions) | Keep their values |30+| Currency, Date and Time formats in a sheet in another [locale](../sheets/locale.md#what-doesnt) than en-US | Go out in the locale's form, tagged with it (`[$€-407]`), and come back as those formats in that locale |3031 | Excel pivot tables | Come in as the values they showed; 012's go out as their results, not a pivot |3132 | Excel's sheet protection | Comes in unprotected, with a note saying so: Excel's protection locks cells where 012's only warns. Protected ranges aren't written |3233 | Array formulas (Excel 365's dynamic arrays and older `{=...}` ones) | Come in as formulas that spill again, without the values Excel kept in the cells they spill into |@@ -68,13 +68,26 @@ one, an ordinary space does.
6868 (Format > Number) show the locale's symbol and order; a6969 [custom format](formatting.md) keeps its own symbols and order, only7070 its separators follow the locale, as in Sheets.71+- **Month and day names.** `mmm`, `mmmm`, `ddd` and `dddd` in a format72+ show the locale's language (`Sa 26. September` in German), in the73+ form a date with its day takes where the language has one (Polish74+ `26 września 2026`, but `wrzesień 2026`); `AM/PM` shows 午前 and 午後75+ in Japanese and 上午 and 下午 in Chinese. A format tagged with a76+ locale as Excel writes them (`[$-407]mmmm`) shows that locale's77+ names. Dates typed with the locale's month names (`26. Okt. 2026`)78+ are read as dates too.7179 - **Formulas.** Where the decimal separator is a comma, arguments are7280 separated by `;` and the values of an array's row by `\`: `=IF(A1>1,5;7381 "yes"; "no")`, `={1\2;3\4}`. Function names stay in English, as in7482 Sheets. The formula bar, editing a cell and the function hints7583 (`ROUND(value; [places])`) all use the locale's form, as do formulas7684 in [conditional formatting, data validation](rules.md) and filter77- conditions.85+ conditions. A formula that doesn't parse says so in the locale's86+ syntax: `Expected ; or ) in ROUND`.87+- **Filters and text conditions.** A filter's list of values shows them88+ as the sheet does (`1.234,50`), and conditions on text (contains,89+ starts with, is exactly) test the text the locale shows, in filters90+ and conditional formats alike, so their values are kept as typed.7891 - **The clipboard and CSV files.** Copying writes numbers with the7992 locale's decimal separator and pasting reads text the locale's way.8093 CSV files are [read and written](../files/README.md) in the locale@@ -90,6 +103,11 @@ en-US (`1,234.5`, `09/26/2026`, `=ROUND(A1*1.19, 2)`; see
90103 the sheet and changes no value. Functions compute the same everywhere91104 too: `VALUE("1,5")` and `TEXT(A1, "0.00")` read and write en-US's form92105 in any locale, and macros and their [API](../reference/macro-api.md)93-set and read entries in en-US's form. [Excel files](../files/excel.md)94-are written with en-US's Currency (`$`) and Date (`m/d/yyyy`) formats95-whatever the locale, so their formats read back as the same formats.106+set and read entries in en-US's form.107+108+[Excel files](../files/excel.md) are downloaded with the locale's109+Currency, Date, Time and Date time formats, tagged with it as Excel110+writes them (`#,##0.00\ [$€-407]`, `[$-407]d.m.yyyy`), so Excel shows111+them as the sheet does. Opened in a sheet of the same locale they are112+those formats again; in another they keep their symbol and order as113+custom formats.@@ -1,9 +1,11 @@
11 package fileio22 33 import (4+ "fmt"45 "strings"56 "sync"67 8+ "github.com/FelineStateMachine/012/internal/locale"79 "github.com/FelineStateMachine/012/internal/sheet"810 )911 @@ -47,6 +49,84 @@ func excelCode(f sheet.Format) string {
4749 return ""4850 }4951 52+// excelCodeIn is the Excel code for f in a workbook in loc: the53+// Currency, Date, Time and Date time formats in loc's form, tagged with54+// its LCID as Excel writes them ([$€-407], [$-407]d.m.yyyy), so Excel55+// shows them as the sheet does and they read back as the same formats.56+// Other formats, and every format in en-US, are excelCode's.57+func excelCodeIn(f sheet.Format, loc *locale.Locale) string {58+ if loc.IsCanonical() || f.Pattern != "" {59+ return excelCode(f)60+ }61+ tag := fmt.Sprintf("-%X]", loc.LCID)62+ switch f.Kind {63+ case sheet.FmtCurrency:64+ num := "#,##0"65+ if f.Decimals > 0 {66+ num += "." + strings.Repeat("0", min(f.Decimals, sheet.MaxDecimals))67+ }68+ sym, gap := "[$"+loc.Currency+tag, ""69+ if loc.Space {70+ gap = `\ `71+ }72+ if loc.After {73+ return num + gap + sym74+ }75+ return sym + gap + num76+ case sheet.FmtDate:77+ return "[$" + tag + loc.Date78+ case sheet.FmtTime:79+ return "[$" + tag + excelAMPM(loc.Time)80+ case sheet.FmtDateTime:81+ return "[$" + tag + excelAMPM(loc.DateTime())82+ }83+ return excelCode(f)84+}85+86+// excelAMPM spells a pattern's am/pm as Excel does.87+func excelAMPM(pat string) string { return strings.Replace(pat, "am/pm", "AM/PM", 1) }88+89+// localFormat is a format as excelCodeIn writes it in a locale.90+type localFormat struct {91+ f sheet.Format92+ loc *locale.Locale93+}94+95+var (96+ localOnce sync.Once97+ localCodes map[string]localFormat // normalized code -> format and locale98+)99+100+// formatIn maps an Excel number format to a 012 format for a workbook101+// in loc. A code excelCodeIn writes for loc is its format again; one102+// written for another locale keeps its own symbol and order, as a103+// custom or date pattern.104+func formatIn(id int, code string, loc *locale.Locale) sheet.Format {105+ if code != "" && !loc.IsCanonical() {106+ localOnce.Do(func() {107+ localCodes = map[string]localFormat{}108+ for _, l := range locale.All()[1:] {109+ for _, f := range localKinds() {110+ localCodes[normCode(excelCodeIn(f, l))] = localFormat{f, l}111+ }112+ }113+ })114+ if lf, ok := localCodes[normCode(code)]; ok && lf.loc == loc {115+ return lf.f116+ }117+ }118+ return formatOf(id, code)119+}120+121+// localKinds are the formats written in the workbook's locale.122+func localKinds() []sheet.Format {123+ out := []sheet.Format{{Kind: sheet.FmtDate}, {Kind: sheet.FmtTime}, {Kind: sheet.FmtDateTime}}124+ for d := range sheet.MaxDecimals + 1 {125+ out = append(out, sheet.Format{Kind: sheet.FmtCurrency, Decimals: d})126+ }127+ return out128+}129+50130 func orDefault(s, def string) string {51131 if s == "" {52132 return def@@ -41,7 +41,7 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
4141 if len(sheets) == 0 {4242 sheets = []*Snapshot{snap}4343 }44- w := &xlsxWriter{styles: newXLSXStyleTable(), multi: len(sheets) > 1, known: map[string]bool{}, renamed: map[string]string{}}44+ w := &xlsxWriter{styles: newXLSXStyleTable(snap.Locale), multi: len(sheets) > 1, known: map[string]bool{}, renamed: map[string]string{}}4545 res := &ExportResult{}4646 names, hidden, active := make([]string, len(sheets)), make([]bool, len(sheets)), 04747 used := map[string]bool{}@@ -45,6 +45,10 @@ func (bk *xlsxBook) importBook(ctx context.Context, opt Options) (*Result, error
4545 }4646 b := newBuilder(ctx, opt.MaxCells)4747 book := b.s.Book()48+ bk.styles.loc = opt.Locale49+ if bk.styles.loc == nil {50+ bk.styles.loc = book.Locale()51+ }4852 done := 04953 filters := make([]*xlsxAutoFilter, len(bk.sheets))5054 var err error@@ -0,0 +1,58 @@
1+package fileio2+3+import (4+ "context"5+ "path/filepath"6+ "testing"7+8+ "github.com/FelineStateMachine/012/internal/locale"9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// TestXLSXLocaleFormats checks a workbook in de-DE is downloaded with13+// its Currency and Date formats in de-DE's codes, which read back as14+// those formats in de-DE, and keep their euro and day-first order as15+// patterns in a workbook in en-US.16+func TestXLSXLocaleFormats(t *testing.T) {17+ src := build(t, map[string]string{"A1": "$1,234.50", "B1": "9/26/2026", "C1": "14:30:00", "D1": "$5"})18+ if !src.Book().SetLocale("de-DE") {19+ t.Fatal("no de-DE")20+ }21+ r := func(s string) sheet.Rect { return sheet.NewRect(addr(t, s), addr(t, s)) }22+ src.SetFormat(r("C1"), sheet.Format{Kind: sheet.FmtTime})23+ de, _ := locale.Lookup("de-DE")24+ for f, want := range map[sheet.Format]string{25+ {Kind: sheet.FmtCurrency, Decimals: 2}: `#,##0.00\ [$€-407]`,26+ {Kind: sheet.FmtDate}: "[$-407]d.m.yyyy",27+ {Kind: sheet.FmtTime}: "[$-407]hh:mm:ss",28+ } {29+ if got := excelCodeIn(f, de); got != want {30+ t.Errorf("%+v in de-DE = %q, want %q", f, got, want)31+ }32+ }33+ name := filepath.Join(t.TempDir(), "de.xlsx")34+ if _, err := Export(context.Background(), name, XLSX, Snap(src, sheet.Rect{}, "De"), ExportOptions{}); err != nil {35+ t.Fatal(err)36+ }37+ got, err := Import(context.Background(), name, Options{Locale: de})38+ if err != nil {39+ t.Fatal(err)40+ }41+ for _, cell := range []string{"A1", "B1", "C1", "D1"} {42+ a := addr(t, cell)43+ if g, w := got.Sheet.Cell(a).Format, src.Cell(a).Format; g != w {44+ t.Errorf("de-DE %s format %+v, want %+v", cell, g, w)45+ }46+ }47+ got, err = Import(context.Background(), name, Options{Locale: locale.Canonical})48+ if err != nil {49+ t.Fatal(err)50+ }51+ for cell, want := range map[string]string{"A1": "1,234.50 €", "B1": "26.9.2026"} {52+ a := addr(t, cell)53+ c := got.Sheet.Cell(a)54+ if g := sheet.FormatText(c.Value, c.Format); g != want {55+ t.Errorf("en-US %s shows %q, want %q", cell, g, want)56+ }57+ }58+}@@ -7,6 +7,7 @@ import (
77 "strconv"88 "strings"99 10+ "github.com/FelineStateMachine/012/internal/locale"1011 "github.com/FelineStateMachine/012/internal/sheet"1112 )1213 @@ -20,6 +21,9 @@ type xlsxStyles struct {
2021 xfs []xlsxXf2122 cache map[int]xlsxStyle2223 dxfs []sheet.RuleStyle // conditional formats' styles, see xlsxrulesread.go24+ // loc is the importing workbook's locale, whose Currency and Date25+ // codes read as those formats (see formatIn).26+ loc *locale.Locale2327 }2428 2529 type xlsxFont struct{ bold, italic, strike, underline bool }@@ -75,7 +79,7 @@ func (s *xlsxStyles) format(id int) sheet.Format {
7579 if builtinNumFmt(id) {7680 return formatOf(id, "")7781 }78- return formatOf(0, s.numFmts[id])82+ return formatIn(0, s.numFmts[id], s.loc)7983 }8084 8185 // builtinNumFmt reports whether id is one of Excel's built-in number@@ -10,6 +10,7 @@ import (
1010 "unicode/utf8"1111 1212 "github.com/FelineStateMachine/012/internal/formula"13+ "github.com/FelineStateMachine/012/internal/locale"1314 "github.com/FelineStateMachine/012/internal/sheet"1415 )1516 @@ -291,6 +292,7 @@ type xlsxStyleTable struct {
291292 fonts []sheet.Style292293 borders []sheet.Borders // see xlsxlayout.go; index 0 is none293294 dxfs []sheet.RuleStyle // conditional formats' styles, see xlsxrules.go295+ loc *locale.Locale // the workbook's, whose Currency and Date codes it writes294296 }295297 296298 // dxf is the index of the differential style for a rule's style.@@ -335,8 +337,8 @@ func (t *xlsxStyleTable) dxfsXML() string {
335337 return b.String() + `</dxfs>`336338 }337339 338-func newXLSXStyleTable() *xlsxStyleTable {339- return &xlsxStyleTable{ids: map[xlsxStyle]int{{}: 0}, xfs: []xlsxStyle{{}}, fonts: []sheet.Style{{}}, borders: []sheet.Borders{0}}340+func newXLSXStyleTable(loc *locale.Locale) *xlsxStyleTable {341+ return &xlsxStyleTable{loc: loc, ids: map[xlsxStyle]int{{}: 0}, xfs: []xlsxStyle{{}}, fonts: []sheet.Style{{}}, borders: []sheet.Borders{0}}340342 }341343 342344 // id is the index of the cell format for f and st.@@ -356,7 +358,7 @@ func (t *xlsxStyleTable) xml() string {
356358 var xfs strings.Builder357359 for _, x := range t.xfs {358360 numFmt := 0359- if code := excelCode(x.format); code != "" {361+ if code := excelCodeIn(x.format, t.loc); code != "" {360362 i := slices.Index(t.codes, code)361363 if i < 0 {362364 i = len(t.codes)@@ -30,6 +30,7 @@ type token struct {
3030 type ParseError struct {3131 Pos int3232 Msg string33+ src string // the formula parsed, for LocalizeError3334 }3435 3536 func (e *ParseError) Error() string { return e.Msg }@@ -147,7 +148,7 @@ func (lx *lexer) str() error {
147148 lx.emit(tokStr, text, start)148149 return nil149150 }150- return &ParseError{start, "Missing closing quote"}151+ return &ParseError{Pos: start, Msg: "Missing closing quote"}151152 }152153 153154 // quotedSheet reads a quoted sheet name and its "!", with ” for a@@ -156,7 +157,7 @@ func (lx *lexer) quotedSheet() error {
156157 start := lx.i157158 name, end, ok := quotedName(lx.src, start)158159 if !ok || end >= len(lx.src) || lx.src[end] != '!' {159- return &ParseError{start, "Expected ! after a quoted sheet name"}160+ return &ParseError{Pos: start, Msg: "Expected ! after a quoted sheet name"}160161 }161162 lx.i = end + 1162163 lx.emit(tokSheet, name, start)@@ -184,7 +185,7 @@ func (lx *lexer) ident() error {
184185 name = name[1:]185186 }186187 if name == "" {187- return &ParseError{start, "Missing function name after @"}188+ return &ParseError{Pos: start, Msg: "Missing function name after @"}188189 }189190 kind := tokIdent190191 if at || lx.parenFollows() {@@ -213,7 +214,7 @@ func (lx *lexer) hash() error {
213214 }214215 end := strings.IndexByte(rest[1:], '#')215216 if end < 0 {216- return &ParseError{start, "Unexpected #"}217+ return &ParseError{Pos: start, Msg: "Unexpected #"}217218 }218219 op := strings.ToUpper(rest[:end+2])219220 for _, known := range hashOps {@@ -223,7 +224,7 @@ func (lx *lexer) hash() error {
223224 return nil224225 }225226 }226- return &ParseError{start, "Unknown operator " + op}227+ return &ParseError{Pos: start, Msg: "Unknown operator " + op}227228 }228229 229230 func (lx *lexer) operator() error {@@ -240,5 +241,5 @@ func (lx *lexer) operator() error {
240241 lx.emit(tokOp, rest[:1], start)241242 return nil242243 }243- return &ParseError{start, fmt.Sprintf("Unexpected %q", rest[0])}244+ return &ParseError{Pos: start, Msg: fmt.Sprintf("Unexpected %q", rest[0])}244245 }@@ -1,6 +1,11 @@
11 package formula22 3-import "github.com/FelineStateMachine/012/internal/locale"3+import (4+ "errors"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/locale"8+)49 510 // Formulas are stored and parsed in en-US's syntax: 1.5 for a number, ,611 // between arguments and ; between an array's rows. A locale whose@@ -23,6 +28,47 @@ func Delocalize(src string, loc *locale.Locale) string {
2328 return translate(src, loc, false)2429 }2530 31+// LocalizeError writes the message of a parse error in err (the error32+// itself, or one it wraps) in loc's syntax, as the formula was typed:33+// "Expected ; or ) in ROUND" where arguments are separated by ;, and34+// the text typed where it names what it didn't expect. The position is35+// the same in both syntaxes.36+func LocalizeError(err error, loc *locale.Locale) error {37+ var pe *ParseError38+ if SameSyntax(loc) || !errors.As(err, &pe) {39+ return err40+ }41+ msg := localMessage(pe, loc)42+ if msg == pe.Msg {43+ return err44+ }45+ if err == error(pe) {46+ return &ParseError{Pos: pe.Pos, Msg: msg, src: pe.src}47+ }48+ return errors.New(strings.Replace(err.Error(), pe.Msg, msg, 1))49+}50+51+// localMessage is a parse error's message in loc's syntax.52+func localMessage(pe *ParseError, loc *locale.Locale) string {53+ typed := Localize(pe.src, loc)54+ arg, col := string(loc.ArgSep()), string(loc.ColSep())55+ switch msg := pe.Msg; {56+ case strings.HasPrefix(msg, "Expected , or ) in "):57+ return "Expected " + arg + " or )" + strings.TrimPrefix(msg, "Expected , or )")58+ case msg == "Expected , ; or } in an array":59+ return "Expected " + col + " ; or } in an array"60+ case strings.HasPrefix(msg, "Wrong number of arguments to "):61+ return strings.ReplaceAll(msg, ", ", arg+" ")62+ }63+ for _, lead := range []string{"Unexpected ", "Invalid number "} {64+ tok, ok := strings.CutPrefix(pe.Msg, lead)65+ if end := pe.Pos + len(tok); ok && pe.Pos >= 0 && end <= len(typed) && len(typed) == len(pe.src) {66+ return lead + typed[pe.Pos:end]67+ }68+ }69+ return pe.Msg70+}71+2672 // SameSyntax reports whether loc writes formulas as they are stored.2773 func SameSyntax(loc *locale.Locale) bool {2874 return loc == nil || loc.Decimal == '.' && loc.ArgSep() == ',' && loc.ColSep() == ','@@ -1,6 +1,7 @@
11 package formula22 33 import (4+ "fmt"45 "testing"56 67 "github.com/FelineStateMachine/012/internal/locale"@@ -72,3 +73,39 @@ func TestLocalizePartial(t *testing.T) {
7273 }7374 }7475 }76+77+// TestLocalizeError checks parse errors name what was typed in the78+// locale: its separators and the text at the error.79+func TestLocalizeError(t *testing.T) {80+ de := mustLocale(t, "de-DE")81+ for typed, want := range map[string]string{82+ "=SUM(1;2 3)": "Expected ; or ) in SUM",83+ "=1;5": "Unexpected ;",84+ "={1\\2 3}": "Expected \\ ; or } in an array",85+ "=SUM(1;2": "Expected ; or ) in SUM",86+ } {87+ stored := Delocalize(typed, de)88+ _, err := Parse(stored, testFuncs)89+ if err == nil {90+ t.Errorf("%q parsed", typed)91+ continue92+ }93+ got := LocalizeError(err, de)94+ if got.Error() != want {95+ t.Errorf("%q: %q, want %q", typed, got, want)96+ }97+ pe, ok := got.(*ParseError)98+ if !ok || pe.Pos != err.(*ParseError).Pos {99+ t.Errorf("%q: %T lost the position", typed, got)100+ }101+ wrapped := LocalizeError(fmt.Errorf("Formula: %w", err), de)102+ if wrapped.Error() != "Formula: "+want {103+ t.Errorf("%q wrapped: %q", typed, wrapped)104+ }105+ }106+ en := mustLocale(t, "en-US")107+ _, err := Parse("=SUM(1,2 3)", testFuncs)108+ if got := LocalizeError(err, en); got != err {109+ t.Errorf("en-US changed %v", got)110+ }111+}@@ -51,22 +51,23 @@ func Parse(src string, funcs Funcs) (Node, error) {
5151 offset := len(src) - len(body)5252 toks, err := lex(body)5353 if err != nil {54- return nil, shift(err, offset)54+ return nil, shift(err, offset, src)5555 }5656 p := &parser{src: body, toks: toks, funcs: funcs}5757 n, err := p.expr(0)5858 if err == nil && p.peek().kind != tokEOF {59- err = &ParseError{p.peek().pos, "Unexpected " + p.peek().text}59+ err = &ParseError{Pos: p.peek().pos, Msg: "Unexpected " + p.peek().text}6060 }6161 if err != nil {62- return nil, shift(err, offset)62+ return nil, shift(err, offset, src)6363 }6464 return n, nil6565 }6666 67-func shift(err error, offset int) error {67+func shift(err error, offset int, src string) error {6868 if pe, ok := err.(*ParseError); ok {6969 pe.Pos += offset70+ pe.src = src7071 }7172 return err7273 }@@ -90,7 +91,7 @@ func (p *parser) isOp(op string) bool {
9091 // Every level of nesting passes through here, so here it's counted.9192 func (p *parser) expr(minPower int) (Node, error) {9293 if p.depth >= MaxDepth {93- return nil, &ParseError{p.peek().pos, fmt.Sprintf("Formula is nested too deeply (more than %d levels)", MaxDepth)}94+ return nil, &ParseError{Pos: p.peek().pos, Msg: fmt.Sprintf("Formula is nested too deeply (more than %d levels)", MaxDepth)}9495 }9596 p.depth++9697 n, err := p.climb(minPower)@@ -134,7 +135,7 @@ func (p *parser) prefix() (Node, error) {
134135 }135136 v, err := strconv.ParseFloat(t.text, 64)136137 if err != nil {137- return nil, &ParseError{t.pos, "Invalid number " + t.text}138+ return nil, &ParseError{Pos: t.pos, Msg: "Invalid number " + t.text}138139 }139140 return Num{v}, nil140141 case tokStr:@@ -147,7 +148,7 @@ func (p *parser) prefix() (Node, error) {
147148 return r, nil148149 }149150 if ref.kind != tokIdent {150- return nil, &ParseError{ref.pos, "Expected a cell after " + QuoteSheet(t.text) + "!"}151+ return nil, &ParseError{Pos: ref.pos, Msg: "Expected a cell after " + QuoteSheet(t.text) + "!"}151152 }152153 return p.ident(ref, t.text)153154 case tokFunc:@@ -157,9 +158,9 @@ func (p *parser) prefix() (Node, error) {
157158 case tokOp:158159 return p.prefixOp(t)159160 case tokEOF:160- return nil, &ParseError{t.pos, "Formula is incomplete"}161+ return nil, &ParseError{Pos: t.pos, Msg: "Formula is incomplete"}161162 }162- return nil, &ParseError{t.pos, "Unexpected " + t.text}163+ return nil, &ParseError{Pos: t.pos, Msg: "Unexpected " + t.text}163164 }164165 165166 // prefixOp parses what starts with an operator: a parenthesized@@ -172,7 +173,7 @@ func (p *parser) prefixOp(t token) (Node, error) {
172173 return nil, err173174 }174175 if !p.isOp(")") {175- return nil, &ParseError{p.peek().pos, "Missing )"}176+ return nil, &ParseError{Pos: p.peek().pos, Msg: "Missing )"}176177 }177178 p.next()178179 return n, nil@@ -189,7 +190,7 @@ func (p *parser) prefixOp(t token) (Node, error) {
189190 }190191 return Unary{Op: t.text, X: x}, nil191192 }192- return nil, &ParseError{t.pos, "Unexpected " + t.text}193+ return nil, &ParseError{Pos: t.pos, Msg: "Unexpected " + t.text}193194 }194195 195196 // ident parses a reference, a range, a boolean or a name. sheet is the@@ -201,7 +202,7 @@ func (p *parser) ident(t token, sheet string) (Node, error) {
201202 a, abs, isRef := ParseRef(t.text)202203 if !isRef {203204 if sheet != "" {204- return nil, &ParseError{t.pos, "Expected a cell after " + QuoteSheet(sheet) + "!"}205+ return nil, &ParseError{Pos: t.pos, Msg: "Expected a cell after " + QuoteSheet(sheet) + "!"}205206 }206207 switch t.text {207208 case "TRUE":@@ -224,13 +225,13 @@ func (p *parser) ident(t token, sheet string) (Node, error) {
224225 // The second corner may repeat the sheet: Sheet2!A1:Sheet2!B3.225226 if end.kind == tokSheet {226227 if SheetKey(end.text) != SheetKey(sheet) {227- return nil, &ParseError{end.pos, "A range can't span sheets"}228+ return nil, &ParseError{Pos: end.pos, Msg: "A range can't span sheets"}228229 }229230 end = p.next()230231 }231232 b, bAbs, ok := ParseRef(end.text)232233 if end.kind != tokIdent || !ok {233- return nil, &ParseError{end.pos, "Expected a cell after " + sep.text}234+ return nil, &ParseError{Pos: end.pos, Msg: "Expected a cell after " + sep.text}234235 }235236 r := NewRange(a, b, abs, bAbs)236237 r.Sheet = sheet@@ -290,7 +291,7 @@ func (p *parser) call(t token) (Node, error) {
290291 }291292 fn, ok := p.funcs(t.text)292293 if !ok {293- return nil, &ParseError{t.pos, "Unknown function " + t.text}294+ return nil, &ParseError{Pos: t.pos, Msg: "Unknown function " + t.text}294295 }295296 sig := fn.Signature()296297 n := Call{Fn: fn}@@ -305,7 +306,7 @@ func (p *parser) call(t token) (Node, error) {
305306 n.Args = args306307 }307308 if !sig.accepts(len(n.Args)) {308- return nil, &ParseError{t.pos, fmt.Sprintf("Wrong number of arguments to %s(%s)", sig.Name, sig.Args)}309+ return nil, &ParseError{Pos: t.pos, Msg: fmt.Sprintf("Wrong number of arguments to %s(%s)", sig.Name, sig.Args)}309310 }310311 if err := checkBinds(sig, n.Args, t.pos); err != nil {311312 return nil, err@@ -349,7 +350,7 @@ func (p *parser) args(name string, binds Binding) ([]Node, error) {
349350 case sep.kind == tokOp && sep.text == ")":350351 return args, nil351352 case sep.kind != tokOp || (sep.text != "," && sep.text != ";"):352- return nil, &ParseError{sep.pos, "Expected , or ) in " + name}353+ return nil, &ParseError{Pos: sep.pos, Msg: "Expected , or ) in " + name}353354 }354355 }355356 }@@ -393,7 +394,7 @@ func checkBinds(sig Signature, args []Node, pos int) error {
393394 continue394395 }395396 if _, ok := a.(Local); !ok {396- return &ParseError{pos, fmt.Sprintf("Argument %d of %s must be a name, like x or total", i+1, sig.Name)}397+ return &ParseError{Pos: pos, Msg: fmt.Sprintf("Argument %d of %s must be a name, like x or total", i+1, sig.Name)}397398 }398399 }399400 return nil@@ -403,7 +404,7 @@ func checkBinds(sig Signature, args []Node, pos int) error {
403404 // in a row and rows by ";", through the "}".404405 func (p *parser) array(open token) (Node, error) {405406 if p.isOp("}") {406- return nil, &ParseError{open.pos, "An array needs at least one value"}407+ return nil, &ParseError{Pos: open.pos, Msg: "An array needs at least one value"}407408 }408409 rows := [][]Node{nil}409410 for {@@ -420,7 +421,7 @@ func (p *parser) array(open token) (Node, error) {
420421 case sep.kind == tokOp && sep.text == ";":421422 rows = append(rows, nil)422423 default:423- return nil, &ParseError{sep.pos, "Expected , ; or } in an array"}424+ return nil, &ParseError{Pos: sep.pos, Msg: "Expected , ; or } in an array"}424425 }425426 }426427 }@@ -26,6 +26,9 @@ func (o DateOrder) String() string { return [...]string{"MDY", "DMY", "YMD"}[o]
2626 type Locale struct {2727 Tag string // BCP 47, as written in files and the config: "de-DE"2828 Name string // as the picker lists it: "German (Germany)"29+ // LCID is Windows' number for the locale, which Excel's format30+ // codes name it by: [$€-407], [$-407]d.m.yyyy.31+ LCID uint162932 3033 Decimal byte // the decimal separator: '.' or ','3134 Group string // the thousands separator shown: ",", ".", a no-break space, an apostrophe@@ -38,6 +41,8 @@ type Locale struct {
3841 Currency string // the currency symbol: "€"3942 After bool // the symbol follows the number: "1.234,56 €"4043 Space bool // a space between the symbol and the number: "R$ 1.234,56"44+45+ names *Names // the language's month and day names, see Names4146 }4247 4348 // Canonical is en-US: what cells, formulas and files store, whatever the@@ -56,30 +61,30 @@ const (
5661 // leading zeros, as en-US's m/d/yyyy does, so most fit a default column5762 // (26.9.2026); where a country writes ISO dates they keep theirs.5863 var table = []Locale{59- {Tag: "en-US", Name: "English (United States)", Decimal: '.', Group: ",", Order: MDY, DateSep: '/', Date: "m/d/yyyy", Time: "h:mm:ss am/pm", Currency: "$"},60- {Tag: "en-GB", Name: "English (United Kingdom)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "£"},61- {Tag: "en-CA", Name: "English (Canada)", Decimal: '.', Group: ",", Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "h:mm:ss am/pm", Currency: "$"},62- {Tag: "en-AU", Name: "English (Australia)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "h:mm:ss am/pm", Currency: "$"},63- {Tag: "de-DE", Name: "German (Germany)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},64- {Tag: "de-CH", Name: "German (Switzerland)", Decimal: '.', Group: "’", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "CHF", Space: true},65- {Tag: "fr-FR", Name: "French (France)", Decimal: ',', Group: nnbsp, Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},66- {Tag: "fr-CA", Name: "French (Canada)", Decimal: ',', Group: nbsp, Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "hh:mm:ss", Currency: "$", After: true, Space: true},67- {Tag: "es-ES", Name: "Spanish (Spain)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "h:mm:ss", Currency: "€", After: true, Space: true},68- {Tag: "es-MX", Name: "Spanish (Mexico)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "$"},69- {Tag: "it-IT", Name: "Italian (Italy)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},70- {Tag: "pt-BR", Name: "Portuguese (Brazil)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "R$", Space: true},71- {Tag: "pt-PT", Name: "Portuguese (Portugal)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},72- {Tag: "nl-NL", Name: "Dutch (Netherlands)", Decimal: ',', Group: ".", Order: DMY, DateSep: '-', Date: "d-m-yyyy", Time: "hh:mm:ss", Currency: "€", Space: true},73- {Tag: "sv-SE", Name: "Swedish (Sweden)", Decimal: ',', Group: nbsp, Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "hh:mm:ss", Currency: "kr", After: true, Space: true},74- {Tag: "da-DK", Name: "Danish (Denmark)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "kr.", After: true, Space: true},75- {Tag: "nb-NO", Name: "Norwegian (Norway)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "kr", Space: true},76- {Tag: "fi-FI", Name: "Finnish (Finland)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "€", After: true, Space: true},77- {Tag: "pl-PL", Name: "Polish (Poland)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "zł", After: true, Space: true},78- {Tag: "cs-CZ", Name: "Czech (Czechia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "Kč", After: true, Space: true},79- {Tag: "ru-RU", Name: "Russian (Russia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "₽", After: true, Space: true},80- {Tag: "tr-TR", Name: "Turkish (Türkiye)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "₺"},81- {Tag: "ja-JP", Name: "Japanese (Japan)", Decimal: '.', Group: ",", Order: YMD, DateSep: '/', Date: "yyyy/m/d", Time: "h:mm:ss", Currency: "¥"},82- {Tag: "zh-CN", Name: "Chinese (China)", Decimal: '.', Group: ",", Order: YMD, DateSep: '/', Date: "yyyy/m/d", Time: "h:mm:ss", Currency: "¥"},64+ {Tag: "en-US", LCID: 0x409, Name: "English (United States)", Decimal: '.', Group: ",", Order: MDY, DateSep: '/', Date: "m/d/yyyy", Time: "h:mm:ss am/pm", Currency: "$"},65+ {Tag: "en-GB", LCID: 0x809, Name: "English (United Kingdom)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "£"},66+ {Tag: "en-CA", LCID: 0x1009, Name: "English (Canada)", Decimal: '.', Group: ",", Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "h:mm:ss am/pm", Currency: "$"},67+ {Tag: "en-AU", LCID: 0xC09, Name: "English (Australia)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "h:mm:ss am/pm", Currency: "$"},68+ {Tag: "de-DE", LCID: 0x407, Name: "German (Germany)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},69+ {Tag: "de-CH", LCID: 0x807, Name: "German (Switzerland)", Decimal: '.', Group: "’", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "CHF", Space: true},70+ {Tag: "fr-FR", LCID: 0x40C, Name: "French (France)", Decimal: ',', Group: nnbsp, Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},71+ {Tag: "fr-CA", LCID: 0xC0C, Name: "French (Canada)", Decimal: ',', Group: nbsp, Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "hh:mm:ss", Currency: "$", After: true, Space: true},72+ {Tag: "es-ES", LCID: 0xC0A, Name: "Spanish (Spain)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "h:mm:ss", Currency: "€", After: true, Space: true},73+ {Tag: "es-MX", LCID: 0x80A, Name: "Spanish (Mexico)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "$"},74+ {Tag: "it-IT", LCID: 0x410, Name: "Italian (Italy)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},75+ {Tag: "pt-BR", LCID: 0x416, Name: "Portuguese (Brazil)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "R$", Space: true},76+ {Tag: "pt-PT", LCID: 0x816, Name: "Portuguese (Portugal)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},77+ {Tag: "nl-NL", LCID: 0x413, Name: "Dutch (Netherlands)", Decimal: ',', Group: ".", Order: DMY, DateSep: '-', Date: "d-m-yyyy", Time: "hh:mm:ss", Currency: "€", Space: true},78+ {Tag: "sv-SE", LCID: 0x41D, Name: "Swedish (Sweden)", Decimal: ',', Group: nbsp, Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "hh:mm:ss", Currency: "kr", After: true, Space: true},79+ {Tag: "da-DK", LCID: 0x406, Name: "Danish (Denmark)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "kr.", After: true, Space: true},80+ {Tag: "nb-NO", LCID: 0x414, Name: "Norwegian (Norway)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "kr", Space: true},81+ {Tag: "fi-FI", LCID: 0x40B, Name: "Finnish (Finland)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "€", After: true, Space: true},82+ {Tag: "pl-PL", LCID: 0x415, Name: "Polish (Poland)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "zł", After: true, Space: true},83+ {Tag: "cs-CZ", LCID: 0x405, Name: "Czech (Czechia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "Kč", After: true, Space: true},84+ {Tag: "ru-RU", LCID: 0x419, Name: "Russian (Russia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "₽", After: true, Space: true},85+ {Tag: "tr-TR", LCID: 0x41F, Name: "Turkish (Türkiye)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "₺"},86+ {Tag: "ja-JP", LCID: 0x411, Name: "Japanese (Japan)", Decimal: '.', Group: ",", Order: YMD, DateSep: '/', Date: "yyyy/m/d", Time: "h:mm:ss", Currency: "¥"},87+ {Tag: "zh-CN", LCID: 0x804, Name: "Chinese (China)", Decimal: '.', Group: ",", Order: YMD, DateSep: '/', Date: "yyyy/m/d", Time: "h:mm:ss", Currency: "¥"},8388 }8489 8590 // All returns every locale, en-US first, then as the table lists them.@@ -123,6 +128,17 @@ func Lookup(tag string) (*Locale, bool) {
123128 return nil, false124129 }125130 131+// ByLCID finds a locale by its Windows number, as Excel's format codes132+// write it.133+func ByLCID(id uint16) (*Locale, bool) {134+ for i := range table {135+ if table[i].LCID == id {136+ return &table[i], true137+ }138+ }139+ return nil, false140+}141+126142 // FromPOSIX reads a POSIX locale, as LANG and LC_ALL hold it127143 // ("de_DE.UTF-8", "fr_FR@euro"), returning the tag of the locale in the128144 // table it names. C, POSIX and locales outside the table give "".@@ -64,3 +64,30 @@ func TestTable(t *testing.T) {
6464 t.Errorf("en-US date time %q", got)6565 }6666 }67+68+// TestNames checks every locale has a whole set of names, abbreviations69+// no longer than the names, and both or neither of AM and PM.70+func TestNames(t *testing.T) {71+ for _, l := range locale.All() {72+ n := l.Names()73+ for m := range 12 {74+ if n.Months[m] == "" || n.Short[m] == "" {75+ t.Errorf("%s: month %d unnamed", l.Tag, m+1)76+ }77+ }78+ for d := range 7 {79+ if n.Days[d] == "" || n.DaysShort[d] == "" || len(n.DaysShort[d]) > len(n.Days[d]) {80+ t.Errorf("%s: day %d is %q, %q", l.Tag, d, n.Days[d], n.DaysShort[d])81+ }82+ }83+ if (n.AM == "") != (n.PM == "") {84+ t.Errorf("%s: AM %q PM %q", l.Tag, n.AM, n.PM)85+ }86+ if got, ok := locale.ByLCID(l.LCID); !ok || got != l {87+ t.Errorf("%s: LCID %X finds %v", l.Tag, l.LCID, got)88+ }89+ }90+ if de, _ := locale.Lookup("de-DE"); de.Names().Months[2] != "März" {91+ t.Errorf("de-DE March is %q", de.Names().Months[2])92+ }93+}@@ -0,0 +1,192 @@
1+package locale2+3+import "strings"4+5+// Names are a language's names of months and days, as date formats show6+// them (mmm, mmmm, ddd, dddd) and typed dates may spell them, and its7+// words for the two halves of a 12-hour clock.8+type Names struct {9+ // Months are the months' full names as a date writes them, January10+ // first: "września" in "26 września 2026".11+ Months [12]string12+ // Alone are the full names where a month stands without its day13+ // ("wrzesień 2026"), in languages whose form differs; empty where14+ // it's Months.15+ Alone [12]string16+ // Short are the abbreviations mmm shows: "Sep", "sept.", "9月".17+ Short [12]string18+ // Days are the days' full names, Sunday first; DaysShort their19+ // abbreviations, as ddd shows them.20+ Days, DaysShort [7]string21+ // AM and PM are what AM/PM shows, where the language has words of22+ // its own; empty keeps AM and PM.23+ AM, PM string24+}25+26+// Month is month m's (1 to 12) full name, standing alone or in a date27+// with its day.28+func (n *Names) Month(m int, alone bool) string {29+ if alone && n.Alone[m-1] != "" {30+ return n.Alone[m-1]31+ }32+ return n.Months[m-1]33+}34+35+// English are en-US's names, which every English locale shares.36+var English = &Names{37+ Months: [12]string{"January", "February", "March", "April", "May", "June", "July",38+ "August", "September", "October", "November", "December"},39+ Short: [12]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"},40+ Days: [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"},41+ DaysShort: [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"},42+}43+44+// names are the languages' names, by the language of a locale's tag.45+// Abbreviations are Excel's where Excel has them, so a sheet shows what46+// the same format shows there; a date of numbers stays the more compact.47+var names = map[string]*Names{48+ "en": English,49+ "de": {50+ Months: [12]string{"Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",51+ "August", "September", "Oktober", "November", "Dezember"},52+ Short: [12]string{"Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"},53+ Days: [7]string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"},54+ DaysShort: [7]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"},55+ },56+ "fr": {57+ Months: [12]string{"janvier", "février", "mars", "avril", "mai", "juin", "juillet",58+ "août", "septembre", "octobre", "novembre", "décembre"},59+ Short: [12]string{"janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."},60+ Days: [7]string{"dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"},61+ DaysShort: [7]string{"dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."},62+ },63+ "es": {64+ Months: [12]string{"enero", "febrero", "marzo", "abril", "mayo", "junio", "julio",65+ "agosto", "septiembre", "octubre", "noviembre", "diciembre"},66+ Short: [12]string{"ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"},67+ Days: [7]string{"domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"},68+ DaysShort: [7]string{"dom", "lun", "mar", "mié", "jue", "vie", "sáb"},69+ },70+ "it": {71+ Months: [12]string{"gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio",72+ "agosto", "settembre", "ottobre", "novembre", "dicembre"},73+ Short: [12]string{"gen", "feb", "mar", "apr", "mag", "giu", "lug", "ago", "set", "ott", "nov", "dic"},74+ Days: [7]string{"domenica", "lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato"},75+ DaysShort: [7]string{"dom", "lun", "mar", "mer", "gio", "ven", "sab"},76+ },77+ "pt": {78+ Months: [12]string{"janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho",79+ "agosto", "setembro", "outubro", "novembro", "dezembro"},80+ Short: [12]string{"jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"},81+ Days: [7]string{"domingo", "segunda-feira", "terça-feira", "quarta-feira", "quinta-feira",82+ "sexta-feira", "sábado"},83+ DaysShort: [7]string{"dom", "seg", "ter", "qua", "qui", "sex", "sáb"},84+ },85+ "nl": {86+ Months: [12]string{"januari", "februari", "maart", "april", "mei", "juni", "juli",87+ "augustus", "september", "oktober", "november", "december"},88+ Short: [12]string{"jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"},89+ Days: [7]string{"zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag"},90+ DaysShort: [7]string{"zo", "ma", "di", "wo", "do", "vr", "za"},91+ },92+ "sv": {93+ Months: [12]string{"januari", "februari", "mars", "april", "maj", "juni", "juli",94+ "augusti", "september", "oktober", "november", "december"},95+ Short: [12]string{"jan", "feb", "mar", "apr", "maj", "jun", "jul", "aug", "sep", "okt", "nov", "dec"},96+ Days: [7]string{"söndag", "måndag", "tisdag", "onsdag", "torsdag", "fredag", "lördag"},97+ DaysShort: [7]string{"sön", "mån", "tis", "ons", "tors", "fre", "lör"},98+ },99+ "da": {100+ Months: [12]string{"januar", "februar", "marts", "april", "maj", "juni", "juli",101+ "august", "september", "oktober", "november", "december"},102+ Short: [12]string{"jan", "feb", "mar", "apr", "maj", "jun", "jul", "aug", "sep", "okt", "nov", "dec"},103+ Days: [7]string{"søndag", "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag"},104+ DaysShort: [7]string{"søn", "man", "tir", "ons", "tor", "fre", "lør"},105+ },106+ "nb": {107+ Months: [12]string{"januar", "februar", "mars", "april", "mai", "juni", "juli",108+ "august", "september", "oktober", "november", "desember"},109+ Short: [12]string{"jan", "feb", "mar", "apr", "mai", "jun", "jul", "aug", "sep", "okt", "nov", "des"},110+ Days: [7]string{"søndag", "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag"},111+ DaysShort: [7]string{"søn", "man", "tir", "ons", "tor", "fre", "lør"},112+ },113+ "fi": {114+ Months: [12]string{"tammikuuta", "helmikuuta", "maaliskuuta", "huhtikuuta", "toukokuuta",115+ "kesäkuuta", "heinäkuuta", "elokuuta", "syyskuuta", "lokakuuta", "marraskuuta", "joulukuuta"},116+ Alone: [12]string{"tammikuu", "helmikuu", "maaliskuu", "huhtikuu", "toukokuu", "kesäkuu",117+ "heinäkuu", "elokuu", "syyskuu", "lokakuu", "marraskuu", "joulukuu"},118+ Short: [12]string{"tammi", "helmi", "maalis", "huhti", "touko", "kesä", "heinä", "elo",119+ "syys", "loka", "marras", "joulu"},120+ Days: [7]string{"sunnuntai", "maanantai", "tiistai", "keskiviikko", "torstai", "perjantai",121+ "lauantai"},122+ DaysShort: [7]string{"su", "ma", "ti", "ke", "to", "pe", "la"},123+ },124+ "pl": {125+ Months: [12]string{"stycznia", "lutego", "marca", "kwietnia", "maja", "czerwca", "lipca",126+ "sierpnia", "września", "października", "listopada", "grudnia"},127+ Alone: [12]string{"styczeń", "luty", "marzec", "kwiecień", "maj", "czerwiec", "lipiec",128+ "sierpień", "wrzesień", "październik", "listopad", "grudzień"},129+ Short: [12]string{"sty", "lut", "mar", "kwi", "maj", "cze", "lip", "sie", "wrz", "paź", "lis", "gru"},130+ Days: [7]string{"niedziela", "poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota"},131+ DaysShort: [7]string{"niedz.", "pon.", "wt.", "śr.", "czw.", "pt.", "sob."},132+ },133+ "cs": {134+ Months: [12]string{"ledna", "února", "března", "dubna", "května", "června", "července",135+ "srpna", "září", "října", "listopadu", "prosince"},136+ Alone: [12]string{"leden", "únor", "březen", "duben", "květen", "červen", "červenec",137+ "srpen", "září", "říjen", "listopad", "prosinec"},138+ Short: [12]string{"led", "úno", "bře", "dub", "kvě", "čvn", "čvc", "srp", "zář", "říj", "lis", "pro"},139+ Days: [7]string{"neděle", "pondělí", "úterý", "středa", "čtvrtek", "pátek", "sobota"},140+ DaysShort: [7]string{"ne", "po", "út", "st", "čt", "pá", "so"},141+ },142+ "ru": {143+ Months: [12]string{"января", "февраля", "марта", "апреля", "мая", "июня", "июля",144+ "августа", "сентября", "октября", "ноября", "декабря"},145+ Alone: [12]string{"январь", "февраль", "март", "апрель", "май", "июнь", "июль",146+ "август", "сентябрь", "октябрь", "ноябрь", "декабрь"},147+ Short: [12]string{"янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"},148+ Days: [7]string{"воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница",149+ "суббота"},150+ DaysShort: [7]string{"вс", "пн", "вт", "ср", "чт", "пт", "сб"},151+ },152+ "tr": {153+ Months: [12]string{"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz",154+ "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık"},155+ Short: [12]string{"Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara"},156+ Days: [7]string{"Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"},157+ DaysShort: [7]string{"Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"},158+ },159+ "ja": {160+ Months: [12]string{"1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"},161+ Short: [12]string{"1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"},162+ Days: [7]string{"日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日"},163+ DaysShort: [7]string{"日", "月", "火", "水", "木", "金", "土"},164+ AM: "午前", PM: "午後",165+ },166+ "zh": {167+ Months: [12]string{"一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月",168+ "十一月", "十二月"},169+ Short: [12]string{"1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"},170+ Days: [7]string{"星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"},171+ DaysShort: [7]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"},172+ AM: "上午", PM: "下午",173+ },174+}175+176+// Names are the locale's names of months and days, in its language;177+// English for a nil locale.178+func (l *Locale) Names() *Names {179+ if l == nil || l.names == nil {180+ return English181+ }182+ return l.names183+}184+185+// Each locale finds its language's names once, as dates are drawn with186+// them every frame.187+func init() {188+ for i := range table {189+ lang, _, _ := strings.Cut(table[i].Tag, "-")190+ table[i].names = names[lang]191+ }192+}@@ -4,6 +4,9 @@ import (
44 "math"55 "strconv"66 "strings"7+ "unicode/utf8"8+9+ "github.com/FelineStateMachine/012/internal/locale"710 )811 912 // clock is a serial broken into the fields date and time patterns show.@@ -16,10 +19,11 @@ type clock struct {
1619 elapsed int64 // whole seconds since day 0, for [h], [m] and [s]1720 fracDigits int // decimals of a second shown1821 sub int // the fraction of the second, in fracDigits digits22+ names *locale.Names1923 }2024 21-func newClock(v float64, toks []ptok) clock {22- c := clock{toks: toks, ampm: hasKind(toks, ptAMPM), fracDigits: secondDecimals(toks)}25+func newClock(v float64, toks []ptok, names *locale.Names) clock {26+ c := clock{toks: toks, ampm: hasKind(toks, ptAMPM), fracDigits: secondDecimals(toks), names: names}2327 unit := math.Pow(10, float64(c.fracDigits))2428 ticks := int64(math.Round(v * 86400 * unit)) // in 10^-fracDigits seconds2529 perDay := int64(86400 * unit)@@ -52,14 +56,15 @@ func secondDecimals(toks []ptok) int {
5256 return n5357 }5458 55-// formatDate renders serial v as a date, time or duration.56-func formatDate(v float64, toks []ptok) string {59+// formatDate renders serial v as a date, time or duration, with the60+// names of months and days in names' language.61+func formatDate(v float64, toks []ptok, names *locale.Names) string {5762 var b strings.Builder5863 if v < 0 && hasKind(toks, ptElapsed) {5964 b.WriteByte('-') // a negative duration6065 v = -v6166 }62- c := newClock(v, toks)67+ c := newClock(v, toks, names)6368 for i := range toks {6469 c.write(&b, i)6570 }@@ -107,8 +112,9 @@ func (c *clock) write(b *strings.Builder, i int) {
107112 }108113 109114 // monthText writes m to mmmmm at token i: minutes (see isMinute), the110-// month's number, or its name abbreviated to three letters, one, or111-// spelled out.115+// month's number, or its name abbreviated, as its first letter, or116+// spelled out, in the form a date with a day of the month takes where117+// the language has one ("26 września", "wrzesień 2026").112118 func (c *clock) monthText(n, i int) string {113119 switch {114120 case n <= 2 && isMinute(c.toks, i):@@ -116,11 +122,24 @@ func (c *clock) monthText(n, i int) string {
116122 case n <= 2:117123 return padN(c.month, n)118124 case n == 3:119- return MonthNames[c.month-1][:3]120- case n == 5:121- return MonthNames[c.month-1][:1]125+ return c.names.Short[c.month-1]126+ }127+ name := c.names.Month(c.month, !hasDayOfMonth(c.toks))128+ if n == 5 {129+ r, _ := utf8.DecodeRuneInString(name)130+ return strings.ToUpper(string(r))131+ }132+ return name133+}134+135+// hasDayOfMonth reports whether a pattern shows the day of the month.136+func hasDayOfMonth(toks []ptok) bool {137+ for _, t := range toks {138+ if t.kind == ptDay && t.n <= 2 {139+ return true140+ }122141 }123- return MonthNames[c.month-1]142+ return false124143 }125144 126145 // dayText writes d to dddd: the day of the month or of the week.@@ -129,9 +148,9 @@ func (c *clock) dayText(n int) string {
129148 case 1, 2:130149 return padN(c.day, n)131150 case 3:132- return DayNames[c.weekday][:3]151+ return c.names.DaysShort[c.weekday]133152 }134- return DayNames[c.weekday]153+ return c.names.Days[c.weekday]135154 }136155 137156 func (c *clock) hour12() int {@@ -152,10 +171,14 @@ func (c *clock) elapsedText(t ptok) string {
152171 return padN(int(c.elapsed), t.n)153172 }154173 155-// meridiem writes AM/PM or A/P.174+// meridiem writes AM/PM or A/P, or the language's words for them.156175 func (c *clock) meridiem(style string) string {157176 pm := c.hour >= 12158177 switch {178+ case c.names.AM != "" && pm:179+ return c.names.PM180+ case c.names.AM != "":181+ return c.names.AM159182 case style == "A/P" && pm:160183 return "P"161184 case style == "A/P":@@ -1,6 +1,10 @@
11 package numfmt22 3-import "testing"3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/012/internal/locale"7+)48 59 func TestFormat(t *testing.T) {610 tests := []struct {@@ -77,3 +81,44 @@ func TestAdjustDecimals(t *testing.T) {
7781 t.Errorf("AdjustDecimals(0.0, -1) = %q", got)7882 }7983 }84+85+// TestFormatNames checks month and day names follow the locale's86+// language, in the form a date with a day takes where the language has87+// one, and its words for AM and PM.88+func TestFormatNames(t *testing.T) {89+ const sat = 46291.6041666667 // 2026-09-26 14:30, a Saturday90+ for _, tt := range []struct{ tag, pat, want string }{91+ {"de-DE", "dddd, d. mmmm yyyy", "Samstag, 26. September 2026"},92+ {"de-DE", "ddd d mmm", "Sa 26 Sep"},93+ {"fr-FR", "dddd d mmmm", "samedi 26 septembre"},94+ {"fr-FR", "d mmm yy", "26 sept. 26"},95+ {"pl-PL", "d mmmm yyyy", "26 września 2026"},96+ {"pl-PL", "mmmm yyyy", "wrzesień 2026"},97+ {"ru-RU", "mmmmm", "С"},98+ {"ja-JP", "h:mm AM/PM", "2:30 午後"},99+ {"zh-CN", "dddd", "星期六"},100+ {"en-GB", "ddd d mmm", "Sat 26 Sep"},101+ {"de-DE", "h:mm AM/PM", "2:30 PM"},102+ } {103+ l, _ := locale.Lookup(tt.tag)104+ if got := FormatIn(sat, tt.pat, l); got != tt.want {105+ t.Errorf("%s FormatIn(%q) = %q, want %q", tt.tag, tt.pat, got, tt.want)106+ }107+ }108+}109+110+// TestFormatLCID checks a [$-407] tag shows its locale's names, as in111+// Excel, whatever the locale shown in.112+func TestFormatLCID(t *testing.T) {113+ for _, tt := range []struct{ pat, want string }{114+ {"[$-407]d. mmmm yyyy", "26. September 2026"},115+ {"[$-40C]dddd", "samedi"},116+ {"[$-10407]mmm", "Sep"},117+ {"[$€-407]mmmm", "€September"},118+ {"[$-F800]dddd", "Saturday"},119+ } {120+ if got := Format(46291, tt.pat); got != tt.want {121+ t.Errorf("Format(%q) = %q, want %q", tt.pat, got, tt.want)122+ }123+ }124+}@@ -7,6 +7,7 @@ package numfmt
77 88 import (99 "math"10+ "strconv"1011 "strings"1112 "unicode/utf8"1213 @@ -62,11 +63,34 @@ func FormatIn(v float64, pat string, loc *locale.Locale) string {
6263 }6364 toks := lexPattern(sec)6465 if isDatePattern(toks) {65- return formatDate(v, toks)66+ return formatDate(v, toks, patternNames(sec, loc))6667 }6768 return formatNumber(math.Abs(v), neg, toks, loc)6869 }6970 71+// patternNames are the names of months and days a date pattern shows:72+// those of the locale its [$-407] tag names, as in Excel, or else loc's.73+func patternNames(sec string, loc *locale.Locale) *locale.Names {74+ for rest := sec; ; {75+ i := strings.Index(rest, "[$")76+ if i < 0 {77+ return loc.Names()78+ }79+ rest = rest[i+2:]80+ end := strings.IndexByte(rest, ']')81+ if end < 0 {82+ return loc.Names()83+ }84+ _, hex, ok := strings.Cut(rest[:end], "-")85+ if id, err := strconv.ParseUint(hex, 16, 32); ok && err == nil {86+ if l, found := locale.ByLCID(uint16(id)); found {87+ return l.Names()88+ }89+ }90+ rest = rest[end:]91+ }92+}93+7094 // splitSections splits a pattern on ; outside quotes and escapes.7195 func splitSections(pat string) []string {7296 var secs []string@@ -79,6 +79,11 @@ func (op CondOp) String() string { return condNames[op] }
7979 // Title names the condition for people, e.g. "Greater than".8080 func (op CondOp) Title() string { return condTitles[op] }8181 82+// OnText reports whether the condition tests the text a cell shows83+// (contains, starts with ...), which is in the locale's rendering, so84+// its value is kept as typed rather than read as a number or date.85+func (op CondOp) OnText() bool { return op >= CondContains && op <= CondExactly }86+8287 // TakesArg reports whether the condition compares with a value.8388 func (op CondOp) TakesArg() bool { return op != CondNone && op != CondEmpty && op != CondNotEmpty }8489 @@ -258,6 +263,7 @@ type colTest struct {
258263 col int259264 hidden map[string]bool260265 cond func(Value, string) bool266+ local bool // cond tests the text as the locale shows it261267 }262268 263269 // criteriaTests prepares the filter's criteria, leaving out column skip.@@ -268,7 +274,7 @@ func (s *Sheet) criteriaTests(f *Filter, skip int) []colTest {
268274 if c == skip || cr.IsZero() {269275 continue270276 }271- t := colTest{col: c, cond: cr.Cond.test()}277+ t := colTest{col: c, cond: cr.Cond.test(), local: cr.Cond.Op.OnText() && !s.Locale().IsCanonical()}272278 if len(cr.Hidden) > 0 {273279 t.hidden = make(map[string]bool, len(cr.Hidden))274280 for _, h := range cr.Hidden {@@ -284,7 +290,13 @@ func (s *Sheet) rowPasses(row int, tests []colTest) bool {
284290 for _, t := range tests {285291 a := Addr{Col: t.col, Row: row}286292 shown := s.ShownText(a)287- if t.hidden[shown] || !t.cond(s.Value(a), shown) {293+ if t.hidden[shown] {294+ return false295+ }296+ if t.local {297+ shown = s.LocalText(a)298+ }299+ if !t.cond(s.Value(a), shown) {288300 return false289301 }290302 }@@ -363,7 +375,8 @@ func (c Condition) test() func(v Value, shown string) bool {
363375 // FilterValue is one entry of a filter's values list: a value as shown,364376 // how many rows have it, and whether it is checked (shown).365377 type FilterValue struct {366- Text string // "" for blanks378+ Text string // as en-US shows it, as filters keep it; "" for blanks379+ Label string // as the sheet's locale shows it, for the list367380 Count int368381 Shown bool369382 }@@ -390,6 +403,7 @@ func (s *Sheet) valuesList(r Rect, col int, tests []colTest, hide []string, skip
390403 }391404 counts := map[string]int{}392405 values := map[string]Value{}406+ labels := map[string]string{}393407 data := s.filterData(r)394408 for row := r.From.Row + 1; row <= data.To.Row; row++ {395409 if !s.rowPasses(row, tests) || skipBlank && s.rowBlank(row, r) {@@ -398,7 +412,7 @@ func (s *Sheet) valuesList(r Rect, col int, tests []colTest, hide []string, skip
398412 a := Addr{Col: col, Row: row}399413 t := s.ShownText(a)400414 if _, seen := counts[t]; !seen {401- values[t] = s.Value(a)415+ values[t], labels[t] = s.Value(a), s.localLabel(a, t)402416 }403417 counts[t]++404418 }@@ -411,7 +425,7 @@ func (s *Sheet) valuesList(r Rect, col int, tests []colTest, hide []string, skip
411425 // they can be checked again.412426 for h := range hidden {413427 if _, ok := counts[h]; !ok {414- counts[h] = 0428+ counts[h], labels[h] = 0, LocalArg(h, s.Locale())415429 values[h] = Value{Kind: Text, Str: h}416430 if n, _, ok := ParseValue(h); ok {417431 values[h] = Value{Kind: Number, Num: n}@@ -420,7 +434,7 @@ func (s *Sheet) valuesList(r Rect, col int, tests []colTest, hide []string, skip
420434 }421435 out := make([]FilterValue, 0, len(counts))422436 for t, n := range counts {423- out = append(out, FilterValue{Text: t, Count: n, Shown: !hidden[t]})437+ out = append(out, FilterValue{Text: t, Label: labels[t], Count: n, Shown: !hidden[t]})424438 }425439 slices.SortFunc(out, func(a, b FilterValue) int {426440 if (a.Text == "") != (b.Text == "") {@@ -111,13 +111,13 @@ func TestFilterValues(t *testing.T) {
111111 s := fruit(t)112112 s.CreateFilter(Rect{From: at("A1"), To: at("B6")})113113 s.FilterColumn(1, Criteria{Hidden: []string{"3", "gone"}})114- want := []FilterValue{{"3", 2, false}, {"10", 1, true}, {"$25", 1, true}, {"gone", 0, false}, {"", 1, true}}114+ want := []FilterValue{{"3", "3", 2, false}, {"10", "10", 1, true}, {"$25", "$25", 1, true}, {"gone", "gone", 0, false}, {"", "", 1, true}}115115 if got := s.FilterValues(1); !reflect.DeepEqual(got, want) {116116 t.Errorf("values\n got %v\nwant %v", got, want)117117 }118118 // Other columns' criteria narrow the list.119119 s.FilterColumn(0, Criteria{Cond: Condition{CondContains, "apple"}})120- want = []FilterValue{{"3", 0, false}, {"10", 1, true}, {"$25", 1, true}, {"gone", 0, false}}120+ want = []FilterValue{{"3", "3", 0, false}, {"10", "10", 1, true}, {"$25", "$25", 1, true}, {"gone", "gone", 0, false}}121121 if got := s.FilterValues(1); !reflect.DeepEqual(got, want) {122122 t.Errorf("narrowed values\n got %v\nwant %v", got, want)123123 }@@ -163,6 +163,45 @@ func LocalArg(arg string, loc *locale.Locale) string {
163163 return arg164164 }165165 166+// LocalizeError writes a formula's parse error, alone or wrapped, as the167+// formula was typed in loc: "Expected ; or ) in ROUND" in de-DE.168+func LocalizeError(err error, loc *locale.Locale) error { return formula.LocalizeError(err, loc) }169+170+// LocalText is the cell's value as its format displays it in the171+// sheet's locale, with no width limit: what text conditions test.172+func (s *Sheet) LocalText(a Addr) string {173+ if !s.cells.filledAt(a) {174+ return ""175+ }176+ return FormatTextIn(s.cells.value(a), s.DisplayFormat(a), s.Locale())177+}178+179+// localLabel is the cell at a, shown as shown in en-US, as its180+// locale shows it.181+func (s *Sheet) localLabel(a Addr, shown string) string {182+ if shown == "" || s.Locale().IsCanonical() {183+ return shown184+ }185+ return s.LocalText(a)186+}187+188+// CondArg is a condition's value typed in loc as it's kept: as typed189+// for a condition on text, as CanonicalArg for the rest.190+func CondArg(op CondOp, typed string, loc *locale.Locale) string {191+ if op.OnText() {192+ return typed193+ }194+ return CanonicalArg(typed, loc)195+}196+197+// LocalCondArg is the inverse of CondArg, for editing a value again.198+func LocalCondArg(op CondOp, arg string, loc *locale.Locale) string {199+ if op.OnText() {200+ return arg201+ }202+ return LocalArg(arg, loc)203+}204+166205 // textIn is v as General shows it in loc: 1,5 in de-DE.167206 func textIn(v Value, loc *locale.Locale) string {168207 if v.Kind == Number {@@ -2,6 +2,7 @@ package sheet
22 33 import (44 "bytes"5+ "reflect"56 "strings"67 "testing"78 @@ -131,3 +132,32 @@ func TestDefaultLocale(t *testing.T) {
131132 t.Error("the default isn't en-US")132133 }133134 }135+136+// TestFilterInLocale checks the filter's values list shows values as137+// the locale does while keeping them in en-US's form, and text138+// conditions test the text the locale shows.139+func TestFilterInLocale(t *testing.T) {140+ s := fruit(t)141+ if !s.Book().SetLocale("de-DE") {142+ t.Fatal("no de-DE")143+ }144+ s.Set(at("B2"), "1,234.5")145+ s.CreateFilter(Rect{From: at("A1"), To: at("B6")})146+ labels := map[string]string{}147+ for _, v := range s.FilterValues(1) {148+ labels[v.Text] = v.Label149+ }150+ if labels["1,234.50"] != "1.234,50" || labels["$25"] != "25\u00a0€" {151+ t.Errorf("labels %q", labels)152+ }153+ s.FilterColumn(1, Criteria{Hidden: []string{"$25"}, Cond: Condition{CondContains, "4,5"}})154+ if got := shownRows(s); !reflect.DeepEqual(got, []int{1, 2, 7, 8}) {155+ t.Errorf("contains 4,5 shows rows %v", got)156+ }157+ if got := CondArg(CondContains, "4,5", s.Locale()); got != "4,5" {158+ t.Errorf("text condition kept as %q", got)159+ }160+ if got := CondArg(CondGreater, "4,5", s.Locale()); got != "4.5" {161+ t.Errorf("number condition kept as %q", got)162+ }163+}@@ -8,6 +8,7 @@ import (
88 99 "github.com/FelineStateMachine/012/internal/formula"1010 "github.com/FelineStateMachine/012/internal/functions"11+ "github.com/FelineStateMachine/012/internal/locale"1112 )1213 1314 // Look is how a sheet's rules draw a cell: a conditional format's style@@ -196,13 +197,16 @@ func (t *ruleTest) arg(c *looksCache, s *Sheet, k int, a Addr) Value {
196197 func (t *ruleTest) match(c *looksCache, s *Sheet, a Addr, v Value) bool {197198 switch {198199 case t.shared != nil:199- return t.shared(v, s.ShownText(a))200+ return t.shared(v, s.LocalText(a))200201 case t.op == RuleFormula:201202 return t.args[0] != nil && truthy(c.evalFrom(s, t.args[0], t.from, a))202203 }203204 if cop, ok := filterOps[t.op]; ok { // a shared test with a formula value204- arg := t.arg(c, s, 0, a)205- return Condition{Op: cop, Arg: FormatText(arg, Format{})}.test()(v, s.ShownText(a))205+ arg, loc := t.arg(c, s, 0, a), locale.Canonical206+ if cop.OnText() {207+ loc = s.Locale()208+ }209+ return Condition{Op: cop, Arg: FormatTextIn(arg, Format{}, loc)}.test()(v, s.LocalText(a))206210 }207211 if v.Kind != Number {208212 return false@@ -99,7 +99,7 @@ func TestPivotFilterValues(t *testing.T) {
9999 p, _ := s.Pivot()100100 // Region's values among the rows Product's filter lets through.101101 got := w.PivotFilterValues(p, 0)102- want := []FilterValue{{"East", 1, true}, {"North", 1, true}, {"West", 1, false}}102+ want := []FilterValue{{"East", "East", 1, true}, {"North", "North", 1, true}, {"West", "West", 1, false}}103103 if !reflect.DeepEqual(got, want) {104104 t.Errorf("values %v, want %v", got, want)105105 }@@ -146,6 +146,10 @@ func (op RuleOp) Args() int {
146146 return 1147147 }148148 149+// OnText reports whether the test is on the text a cell shows, in the150+// locale's rendering, so its value is kept as typed (see CondOp.OnText).151+func (op RuleOp) OnText() bool { return op >= RuleContains && op <= RuleExactly }152+149153 // ParseRuleOp is the inverse of RuleOp.String.150154 func ParseRuleOp(s string) (RuleOp, bool) {151155 i := slices.Index(ruleOpNames[:], s)@@ -172,7 +172,7 @@ func (m *Model) fillEntry() bool {
172172 func (m *Model) entryError(err error, input string) {173173 var pe *sheet.ParseError174174 m.mode = modeEdit175- m.entry.hint = err.Error()175+ m.entry.hint = sheet.LocalizeError(err, m.locale()).Error()176176 if errors.As(err, &pe) {177177 m.line.Pos = utf8.RuneCountInString(input[:min(pe.Pos, len(input))])178178 }@@ -126,7 +126,7 @@ func (m *Model) openFilterPicker(col int) {
126126 return127127 }128128 title := "Filter " + sheet.ColName(col)129- if h := m.sheet.ShownText(sheet.Addr{Col: col, Row: r.From.Row}); h != "" {129+ if h := m.sheet.LocalText(sheet.Addr{Col: col, Row: r.From.Row}); h != "" {130130 title += " " + h131131 }132132 m.openValuesPicker(title, m.colStart(col), m.sheet.FilterValues(col), m.sheet.Filter().Cols[col].Cond,@@ -141,7 +141,7 @@ func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue
141141 for _, v := range p.values {142142 p.checked[v.Text] = v.Shown143143 }144- p.fields[1] = sheet.LocalArg(p.cond.Arg, m.locale())144+ p.fields[1] = sheet.LocalCondArg(p.cond.Op, p.cond.Arg, m.locale())145145 m.openOverlay(p)146146 m.line.Clear()147147 p.search()@@ -173,12 +173,16 @@ func (p *filterPicker) Changed() {
173173 }174174 }175175 176-// valueLabel is how a value shows in the list.177-func valueLabel(text string) string {178- if text == "" {176+// valueLabel is how a value shows in the list: as the sheet's locale177+// shows it.178+func valueLabel(v sheet.FilterValue) string {179+ switch {180+ case v.Text == "":179181 return "(Blanks)"182+ case v.Label != "":183+ return v.Label180184 }181- return text185+ return v.Text182186 }183187 184188 // search narrows the list to the values matching the search.@@ -194,7 +198,7 @@ func (p *filterPicker) search() {
194198 }195199 labels := make([]string, len(p.values))196200 for i, v := range p.values {197- labels[i] = valueLabel(v.Text)201+ labels[i] = valueLabel(v)198202 }199203 for _, mt := range fuzzy.Find(q, labels) {200204 p.shown = append(p.shown, mt.Index)@@ -239,7 +243,7 @@ func (p *filterPicker) apply(m *Model) {
239243 cr.Hidden = append(cr.Hidden, v.Text)240244 }241245 }242- cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: sheet.CanonicalArg(strings.TrimSpace(p.fields[1]), m.locale())}246+ cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: sheet.CondArg(p.cond.Op, strings.TrimSpace(p.fields[1]), m.locale())}243247 if !cr.Cond.Op.TakesArg() {244248 cr.Cond.Arg = ""245249 } else if cr.Cond.Arg == "" {@@ -374,7 +378,7 @@ func (p *filterPicker) rows(m *Model) int {
374378 func (p *filterPicker) box(m *Model) (x, y, inner int) {375379 w := 0376380 for _, v := range p.values {377- w = max(w, ansi.StringWidth(valueLabel(v.Text))+len(strconv.Itoa(v.Count)))381+ w = max(w, ansi.StringWidth(valueLabel(v))+len(strconv.Itoa(v.Count)))378382 }379383 inner = clamp(w+10, 36, 48)380384 inner = min(inner, m.width-2)@@ -439,7 +443,7 @@ func (p *filterPicker) Layout() []overlay.Box {
439443 dim := false440444 if i > 0 {441445 v := p.values[p.shown[i-1]]442- label, count, checked, dim = valueLabel(v.Text), strconv.Itoa(v.Count), p.checked[v.Text], v.Text == ""446+ label, count, checked, dim = valueLabel(v), strconv.Itoa(v.Count), p.checked[v.Text], v.Text == ""443447 }444448 box := "[ ] "445449 if checked {@@ -71,3 +71,20 @@ func TestLocalePasteAndCopy(t *testing.T) {
7171 t.Errorf("copied %q", got)7272 }7373 }74+75+// In German, a formula that doesn't parse says which separator was76+// expected in German's syntax, and dates show German month names.77+func TestLocaleErrorsAndNames(t *testing.T) {78+ m := newModel()79+ m.book().SetLocale("de-DE")80+ press(t, m, "=SUM(1;2 3)", "<enter>")81+ if ctx := line(m, contextLine); !strings.Contains(ctx, "Expected ; or ) in SUM") {82+ t.Errorf("context %q", ctx)83+ }84+ press(t, m, "<esc>", "26.10.2026", "<enter>")85+ m.sheet.SetFormat(sheet.NewRect(addr("A1"), addr("A1")), sheet.Format{Kind: sheet.FmtCustom, Pattern: "dddd d. mmmm"})86+ m.sheet.SetColWidth(0, 22)87+ if !strings.Contains(screen(m), "Montag 26. Oktober") {88+ t.Errorf("no German names:\n%s", screen(m))89+ }90+}@@ -122,7 +122,10 @@ func (cfKind) form(h Host, i int, sel sheet.Rect) form {
122122 }123123 return f124124 }125- f.op, f.args = max(indexOf(sheet.CondFormatOps(), r.Op), 0), localArgs(r.Args, f.loc)125+ f.op, f.args = max(indexOf(sheet.CondFormatOps(), r.Op), 0), r.Args126+ if !r.Op.OnText() {127+ f.args = localArgs(r.Args, f.loc)128+ }126129 st := r.Style127130 f.text, f.fill = int(st.Text), int(st.Fill)128131 f.bold, f.italic, f.under, f.strike = st.Bold, st.Italic, st.Underline, st.Strikethrough@@ -163,6 +166,9 @@ func (f *cfForm) rule() (sheet.CondFormat, error) {
163166 }164167 r.Op = sheet.CondFormatOps()[f.op]165168 r.Args = canonicalArgs(f.args, f.loc)169+ if r.Op.OnText() {170+ r.Args = [2]string{strings.TrimSpace(f.args[0]), strings.TrimSpace(f.args[1])}171+ }166172 r.Style = sheet.RuleStyle{Text: sheet.Color(f.text), Fill: sheet.Color(f.fill), Bold: f.bold,167173 Italic: f.italic, Underline: f.under, Strikethrough: f.strike}168174 return r, nil@@ -192,7 +192,7 @@ func (e *Editor) formKey(k tea.KeyPressMsg) {
192192 case key == "enter":193193 e.leave()194194 if err := e.f.save(e.h); err != nil {195- e.msg = err.Error()195+ e.msg = sheet.LocalizeError(err, e.h.Sheet().Locale()).Error()196196 return197197 }198198 e.f, e.msg = nil, ""@@ -47,9 +47,58 @@ func Canonicalize(s string, loc *locale.Locale) (string, bool) {
4747 return c, true4848 }4949 }50+ if c, ok := canonicalMonthDate(t, loc); ok {51+ if _, _, ok := ParseDateTime(c); ok {52+ return c, true53+ }54+ }5055 return s, false5156 }5257 58+// canonicalMonthDate rewrites a date whose month is named in loc's59+// language (26. September 2026, 26 wrz 2026) with the month's English60+// name, which dates read everywhere, dropping the dot German and Czech61+// put after the day. It reports false when no word names a month.62+func canonicalMonthDate(t string, loc *locale.Locale) (string, bool) {63+ n := loc.Names()64+ if n == locale.English {65+ return "", false66+ }67+ words := strings.Fields(t)68+ found := false69+ for i, w := range words {70+ if m, long, ok := localMonth(w, n); ok && !found {71+ words[i], found = locale.English.Short[m], true72+ if long {73+ words[i] = locale.English.Months[m]74+ }75+ continue76+ }77+ if d, ok := strings.CutSuffix(w, "."); ok && d != "" && strings.Trim(d, "0123456789") == "" {78+ words[i] = d79+ }80+ }81+ return strings.Join(words, " "), found82+}83+84+// localMonth finds the month (0 to 11) a word names in n, ignoring case85+// and a trailing dot, and whether it's the full name.86+func localMonth(w string, n *locale.Names) (m int, long, ok bool) {87+ w = strings.ToLower(w)88+ bare := strings.TrimSuffix(w, ".")89+ for i := range 12 {90+ switch bare {91+ case strings.ToLower(n.Months[i]), strings.ToLower(n.Alone[i]):92+ if bare != "" {93+ return i, true, true94+ }95+ case strings.ToLower(strings.TrimSuffix(n.Short[i], ".")):96+ return i, false, true97+ }98+ }99+ return 0, false, false100+}101+53102 // Localize is the inverse of Canonicalize: a number, date or time entry54103 // as stored, written as typed in loc. Anything else is returned as is.55104 func Localize(s string, loc *locale.Locale) string {@@ -72,6 +72,13 @@ func TestParseValueInLocales(t *testing.T) {
7272 {"ja-JP", "¥1,000", true, 1000, value.FmtCurrency, "$1,000"},7373 {"zh-CN", "26/9/26", true, date, value.FmtDate, "9/26/26"},7474 {"de-CH", "26.9", true, 26.9, value.FmtAuto, "26.9"},75+ {"de-DE", "26. September 2026", true, date, value.FmtDate, "26 September 2026"},76+ {"de-DE", "26 Sep 2026", true, date, value.FmtDate, "26 Sep 2026"},77+ {"de-DE", "26. Okt. 2026", true, date + 30, value.FmtDate, "26 Oct 2026"},78+ {"fr-FR", "26 sept. 2026", true, date, value.FmtDate, "26 Sep 2026"},79+ {"pl-PL", "26 września 2026", true, date, value.FmtDate, "26 September 2026"},80+ {"cs-CZ", "26. září 2026", true, date, value.FmtDate, "26 September 2026"},81+ {"es-ES", "26 de septiembre", false, 0, 0, ""},7582 }7683 for _, c := range cases {7784 l := loc(t, c.tag)