├── go.mod ├── go.sum ├── language.go ├── LICENSE ├── num2words_test.go ├── README.md ├── num2words_float.go ├── lang └── ukrainian │ ├── data.go │ ├── ukrainian.go │ └── ukrainian_test.go └── num2words.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/divan/num2words 2 | 3 | go 1.23.0 4 | 5 | require github.com/smartystreets/goconvey v1.8.1 6 | 7 | require ( 8 | github.com/gopherjs/gopherjs v1.17.2 // indirect 9 | github.com/jtolds/gls v4.20.0+incompatible // indirect 10 | github.com/smarty/assertions v1.15.0 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 2 | github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= 3 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 4 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 5 | github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= 6 | github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= 7 | github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= 8 | github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= 9 | -------------------------------------------------------------------------------- /language.go: -------------------------------------------------------------------------------- 1 | // Package num2words provides language definitions for number-to-words conversion. 2 | package num2words 3 | 4 | // Gender represents grammatical gender for languages that require it 5 | type Gender int 6 | 7 | const ( 8 | Masculine Gender = iota 9 | Feminine 10 | Neuter 11 | ) 12 | 13 | // Case represents grammatical case for languages that require it 14 | type Case int 15 | 16 | const ( 17 | Nominative Case = iota 18 | Genitive 19 | Dative 20 | Accusative 21 | Instrumental 22 | Locative 23 | Vocative 24 | ) 25 | 26 | // NounForms contains different forms of a noun for proper agreement with numbers 27 | type NounForms struct { 28 | Singular string // Used with 1: "один день" 29 | NominativePlural string // Used with 2-4: "два дні" 30 | GenitivePlural string // Used with 5+: "п'ять днів" 31 | } 32 | 33 | // NounSelector is a function that selects the appropriate noun form 34 | // based on the number and gender context 35 | type NounSelector func(count int, gender Gender) string -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Ivan Daniluk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /num2words_test.go: -------------------------------------------------------------------------------- 1 | package num2words 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestConvertFloat(t *testing.T) { 8 | testCases := []struct { 9 | number float64 10 | precision Precision 11 | want string 12 | }{ 13 | {123.45, 2, "one hundred twenty-three point forty-five"}, 14 | {123.45, 1, "one hundred twenty-three point four"}, 15 | {-123.45, 2, "minus one hundred twenty-three point forty-five"}, 16 | {0.42, 2, "zero point forty-two"}, 17 | {3.14, 2, "three point fourteen"}, // Added test case for "3.14" 18 | } 19 | 20 | for _, tc := range testCases { 21 | got := ConvertFloat(tc.number, tc.precision) 22 | if got != tc.want { 23 | t.Errorf("ConvertFloat(%f, %d) = %s; want %s", tc.number, tc.precision, got, tc.want) 24 | } 25 | } 26 | } 27 | 28 | func TestConvertFloatAnd(t *testing.T) { 29 | testCases := []struct { 30 | number float64 31 | precision Precision 32 | want string 33 | }{ 34 | {123.45, 2, "one hundred and twenty-three point forty-five"}, 35 | {123.45, 1, "one hundred and twenty-three point four"}, 36 | {-123.45, 2, "minus one hundred and twenty-three point forty-five"}, 37 | {0.42, 2, "zero point forty-two"}, 38 | {3.14, 2, "three point fourteen"}, // Added test case for "3.14" 39 | } 40 | 41 | for _, tc := range testCases { 42 | got := ConvertFloatAnd(tc.number, tc.precision) 43 | if got != tc.want { 44 | t.Errorf("ConvertFloatAnd(%f, %d) = %s; want %s", tc.number, tc.precision, got, tc.want) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # num2words 2 | 3 | [![GoDoc](https://godoc.org/github.com/divan/num2words?status.svg)](https://godoc.org/github.com/divan/num2words) 4 | 5 | num2words - Numbers to words converter in Go (Golang) 6 | 7 | ## Usage 8 | 9 | First, import package num2words 10 | 11 | `import github.com/divan/num2words` 12 | 13 | Convert number 14 | 15 | ```go 16 | str := num2words.Convert(17) // outputs "seventeen" 17 | ... 18 | str := num2words.Convert(1024) // outputs "one thousand twenty four" 19 | ... 20 | str := num2words.Convert(-123) // outputs "minus one hundred twenty three" 21 | ``` 22 | 23 | Convert number with " and " between number groups: 24 | 25 | ```go 26 | str := num2words.ConvertAnd(514) // outputs "five hundred and fourteen" 27 | ... 28 | str := num2words.ConvertAnd(123) // outputs "one hundred and twenty three" 29 | ``` 30 | 31 | ## Language Support 32 | 33 | Ukrainian language support with gender agreement and noun forms: 34 | 35 | ```go 36 | str, _ := num2words.ConvertLang(42, "uk") // outputs "сорок два" 37 | str, _ := num2words.ConvertLang(42, "en") // outputs "forty-two" 38 | ``` 39 | 40 | Direct Ukrainian API with advanced features: 41 | 42 | ```go 43 | import "github.com/divan/num2words/lang/ukrainian" 44 | 45 | str := ukrainian.Convert(42) // outputs "сорок два" 46 | 47 | // Gender agreement (masculine/feminine/neuter) 48 | str := ukrainian.ConvertWithOptions(1, ukrainian.Options{ 49 | Gender: ukrainian.Feminine, 50 | }) // outputs "одна" 51 | 52 | // With noun forms (automatically handles singular/plural/genitive) 53 | str := ukrainian.ConvertWithOptions(2, ukrainian.Options{ 54 | Gender: ukrainian.Feminine, 55 | WithNoun: true, 56 | NounForms: &ukrainian.NounForms{ 57 | Singular: "гривня", 58 | NominativePlural: "гривні", 59 | GenitivePlural: "гривень", 60 | }, 61 | }) // outputs "дві гривні" 62 | ``` 63 | -------------------------------------------------------------------------------- /num2words_float.go: -------------------------------------------------------------------------------- 1 | package num2words 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strings" 7 | ) 8 | 9 | // Precision represents the number of decimal places to convert 10 | // -1 means automatic precision (removes trailing zeros) 11 | type Precision int 12 | 13 | const ( 14 | AutoPrecision Precision = -1 15 | ) 16 | 17 | // ConvertFloat converts float number into words representation. 18 | // precision controls decimal places (-1 for automatic precision) 19 | func ConvertFloat(number float64, precision Precision) string { 20 | return convertFloat(number, precision, false) 21 | } 22 | 23 | // ConvertFloatAnd converts float number into words representation 24 | // with " and " added between number groups. 25 | // precision controls decimal places (-1 for automatic precision) 26 | func ConvertFloatAnd(number float64, precision Precision) string { 27 | return convertFloat(number, precision, true) 28 | } 29 | 30 | func convertFloat(number float64, precision Precision, useAnd bool) string { 31 | // Handle integer part - use Trunc instead of Floor to avoid rounding 32 | intPart := int(math.Trunc(math.Abs(number))) 33 | fraction := math.Abs(number) - math.Trunc(math.Abs(number)) 34 | 35 | // Special handling for -0.x numbers 36 | if intPart == 0 && number < 0 { 37 | result := "zero" 38 | if fraction == 0 { 39 | return "zero" 40 | } 41 | return "minus " + result + handleDecimalPart(fraction, precision, useAnd) 42 | } 43 | 44 | result := convert(intPart, useAnd) 45 | 46 | if fraction == 0 { 47 | if number < 0 { 48 | return "minus " + result 49 | } 50 | return result 51 | } 52 | 53 | if number < 0 { 54 | return "minus " + result + handleDecimalPart(fraction, precision, useAnd) 55 | } 56 | return result + handleDecimalPart(fraction, precision, useAnd) 57 | } 58 | 59 | func handleDecimalPart(fraction float64, precision Precision, useAnd bool) string { 60 | var decimalDigits int 61 | if precision == AutoPrecision { 62 | // Remove trailing zeros automatically 63 | decimalStr := fmt.Sprintf("%.9f", fraction) 64 | decimalStr = strings.TrimRight(strings.TrimRight(decimalStr[2:], "0"), ".") 65 | decimalDigits = len(decimalStr) 66 | if decimalDigits == 0 { 67 | return "" 68 | } 69 | fraction = fraction * math.Pow10(decimalDigits) 70 | } else { 71 | decimalDigits = int(precision) 72 | fraction = fraction * math.Pow10(decimalDigits) 73 | } 74 | 75 | // Use Trunc to avoid any rounding 76 | decimalPart := int(math.Trunc(fraction)) 77 | if decimalPart == 0 { 78 | return "" 79 | } 80 | 81 | return " point " + convert(decimalPart, useAnd) 82 | } 83 | -------------------------------------------------------------------------------- /lang/ukrainian/data.go: -------------------------------------------------------------------------------- 1 | // Package ukrainian contains Ukrainian language data for number conversion. 2 | package ukrainian 3 | 4 | // Gender represents grammatical gender 5 | type Gender int 6 | 7 | const ( 8 | Masculine Gender = iota 9 | Feminine 10 | Neuter 11 | ) 12 | 13 | // smallNumbers contains basic numbers 0-19 for each gender 14 | var smallNumbers = map[Gender][]string{ 15 | Masculine: { 16 | "нуль", "один", "два", "три", "чотири", "п'ять", "шість", "сім", "вісім", "дев'ять", 17 | "десять", "одинадцять", "дванадцять", "тринадцять", "чотирнадцять", "п'ятнадцять", 18 | "шістнадцять", "сімнадцять", "вісімнадцять", "дев'ятнадцять", 19 | }, 20 | Feminine: { 21 | "нуль", "одна", "дві", "три", "чотири", "п'ять", "шість", "сім", "вісім", "дев'ять", 22 | "десять", "одинадцять", "дванадцять", "тринадцять", "чотирнадцять", "п'ятнадцять", 23 | "шістнадцять", "сімнадцять", "вісімнадцять", "дев'ятнадцять", 24 | }, 25 | Neuter: { 26 | "нуль", "одне", "два", "три", "чотири", "п'ять", "шість", "сім", "вісім", "дев'ять", 27 | "десять", "одинадцять", "дванадцять", "тринадцять", "чотирнадцять", "п'ятнадцять", 28 | "шістнадцять", "сімнадцять", "вісімнадцять", "дев'ятнадцять", 29 | }, 30 | } 31 | 32 | // tensWords contains tens from 20-90 33 | var tensWords = []string{ 34 | "", "", "двадцять", "тридцять", "сорок", "п'ятдесят", 35 | "шістдесят", "сімдесят", "вісімдесят", "дев'яносто", 36 | } 37 | 38 | // scaleNumbers contains scale words (thousand, million, etc.) 39 | var scaleNumbers = []string{ 40 | "", "тисяч", "мільйонів", "мільярдів", 41 | } 42 | 43 | // Special scale forms for proper agreement 44 | var scaleNumbersForms = map[int]map[string]string{ 45 | 1: { // thousands - feminine 46 | "1": "тисяча", // одна тисяча 47 | "2-4": "тисячі", // дві тисячі, три тисячі, чотири тисячі 48 | "5+": "тисяч", // п'ять тисяч 49 | }, 50 | 2: { // millions - masculine 51 | "1": "мільйон", // один мільйон 52 | "2-4": "мільйони", // два мільйони 53 | "5+": "мільйонів", // п'ять мільйонів 54 | }, 55 | 3: { // billions - masculine 56 | "1": "мільярд", // один мільярд 57 | "2-4": "мільярди", // два мільярди 58 | "5+": "мільярдів", // п'ять мільярдів 59 | }, 60 | } 61 | 62 | // hundredsWord is the word for "hundred" 63 | const hundredsWord = "сто" 64 | 65 | // Special hundreds forms for agreement 66 | var hundredsForms = map[int]string{ 67 | 1: "сто", // один сто 68 | 2: "двісті", // два двісті -> just "двісті" 69 | 3: "триста", // три триста -> just "триста" 70 | 4: "чотириста", // чотири чотириста -> just "чотириста" 71 | 5: "п'ятсот", // п'ять п'ятсот -> just "п'ятсот" 72 | 6: "шістсот", // шість шістсот -> just "шістсот" 73 | 7: "сімсот", // сім сімсот -> just "сімсот" 74 | 8: "вісімсот", // вісім вісімсот -> just "вісімсот" 75 | 9: "дев'ятсот", // дев'ять дев'ятсот -> just "дев'ятсот" 76 | } 77 | 78 | // GetScaleWord returns the appropriate scale word based on the number 79 | func GetScaleWord(number int, scaleIndex int) string { 80 | if scaleIndex == 0 { 81 | return "" 82 | } 83 | 84 | forms, exists := scaleNumbersForms[scaleIndex] 85 | if !exists { 86 | return scaleNumbers[scaleIndex] 87 | } 88 | 89 | lastDigit := number % 10 90 | lastTwoDigits := number % 100 91 | 92 | // Special case for teens (11-14) 93 | if lastTwoDigits >= 11 && lastTwoDigits <= 14 { 94 | return forms["5+"] 95 | } 96 | 97 | switch lastDigit { 98 | case 1: 99 | return forms["1"] 100 | case 2, 3, 4: 101 | return forms["2-4"] 102 | default: 103 | return forms["5+"] 104 | } 105 | } 106 | 107 | // GetHundredsWord returns the appropriate hundreds word 108 | func GetHundredsWord(hundreds int) string { 109 | if form, exists := hundredsForms[hundreds]; exists { 110 | return form 111 | } 112 | return hundredsWord 113 | } -------------------------------------------------------------------------------- /num2words.go: -------------------------------------------------------------------------------- 1 | // Package num2words implements numbers to words converter. 2 | package num2words 3 | 4 | import ( 5 | "fmt" 6 | "math" 7 | 8 | "github.com/divan/num2words/lang/ukrainian" 9 | ) 10 | 11 | // how many digit's groups to process 12 | const groupsNumber int = 4 13 | 14 | var _smallNumbers = []string{ 15 | "zero", "one", "two", "three", "four", 16 | "five", "six", "seven", "eight", "nine", 17 | "ten", "eleven", "twelve", "thirteen", "fourteen", 18 | "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", 19 | } 20 | var _tens = []string{ 21 | "", "", "twenty", "thirty", "forty", "fifty", 22 | "sixty", "seventy", "eighty", "ninety", 23 | } 24 | var _scaleNumbers = []string{ 25 | "", "thousand", "million", "billion", 26 | } 27 | 28 | type digitGroup int 29 | 30 | // Convert converts number into the words representation. 31 | func Convert(number int) string { 32 | return convert(number, false) 33 | } 34 | 35 | // ConvertAnd converts number into the words representation 36 | // with " and " added between number groups. 37 | func ConvertAnd(number int) string { 38 | return convert(number, true) 39 | } 40 | 41 | func convert(number int, useAnd bool) string { 42 | // Zero rule 43 | if number == 0 { 44 | return _smallNumbers[0] 45 | } 46 | 47 | // Divide into three-digits group 48 | var groups [groupsNumber]digitGroup 49 | positive := math.Abs(float64(number)) 50 | 51 | // Form three-digit groups 52 | for i := 0; i < groupsNumber; i++ { 53 | groups[i] = digitGroup(math.Mod(positive, 1000)) 54 | positive /= 1000 55 | } 56 | 57 | var textGroup [groupsNumber]string 58 | for i := 0; i < groupsNumber; i++ { 59 | textGroup[i] = digitGroup2Text(groups[i], useAnd) 60 | } 61 | combined := textGroup[0] 62 | and := useAnd && (groups[0] > 0 && groups[0] < 100) 63 | 64 | for i := 1; i < groupsNumber; i++ { 65 | if groups[i] != 0 { 66 | prefix := textGroup[i] + " " + _scaleNumbers[i] 67 | 68 | if len(combined) != 0 { 69 | prefix += separator(and) 70 | } 71 | 72 | and = false 73 | 74 | combined = prefix + combined 75 | } 76 | } 77 | 78 | if number < 0 { 79 | combined = "minus " + combined 80 | } 81 | 82 | return combined 83 | } 84 | 85 | func intMod(x, y int) int { 86 | return int(math.Mod(float64(x), float64(y))) 87 | } 88 | 89 | func digitGroup2Text(group digitGroup, useAnd bool) (ret string) { 90 | hundreds := group / 100 91 | tensUnits := intMod(int(group), 100) 92 | 93 | if hundreds != 0 { 94 | ret += _smallNumbers[hundreds] + " hundred" 95 | 96 | if tensUnits != 0 { 97 | ret += separator(useAnd) 98 | } 99 | } 100 | 101 | tens := tensUnits / 10 102 | units := intMod(tensUnits, 10) 103 | 104 | if tens >= 2 { 105 | ret += _tens[tens] 106 | 107 | if units != 0 { 108 | ret += "-" + _smallNumbers[units] 109 | } 110 | } else if tensUnits != 0 { 111 | ret += _smallNumbers[tensUnits] 112 | } 113 | 114 | return 115 | } 116 | 117 | // separator returns proper separator string between 118 | // number groups. 119 | func separator(useAnd bool) string { 120 | if useAnd { 121 | return " and " 122 | } 123 | return " " 124 | } 125 | 126 | // ConvertLang converts number into words for the specified language 127 | func ConvertLang(number int, lang string) (string, error) { 128 | switch lang { 129 | case "uk", "ukrainian": 130 | return ukrainian.Convert(number), nil 131 | case "en", "english", "": 132 | return Convert(number), nil 133 | default: 134 | return "", fmt.Errorf("unsupported language: %s", lang) 135 | } 136 | } 137 | 138 | // ConvertLangAnd converts number into words with "and" for the specified language 139 | func ConvertLangAnd(number int, lang string) (string, error) { 140 | switch lang { 141 | case "uk", "ukrainian": 142 | return ukrainian.ConvertAnd(number), nil 143 | case "en", "english", "": 144 | return ConvertAnd(number), nil 145 | default: 146 | return "", fmt.Errorf("unsupported language: %s", lang) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /lang/ukrainian/ukrainian.go: -------------------------------------------------------------------------------- 1 | // Package ukrainian provides Ukrainian language support for num2words. 2 | package ukrainian 3 | 4 | import ( 5 | "fmt" 6 | "math" 7 | "strings" 8 | ) 9 | 10 | // Case represents grammatical case 11 | type Case int 12 | 13 | const ( 14 | Nominative Case = iota 15 | Genitive 16 | Dative 17 | Accusative 18 | Instrumental 19 | Locative 20 | Vocative 21 | ) 22 | 23 | // NounForms contains different forms of a noun for proper agreement with numbers 24 | type NounForms struct { 25 | Singular string // Used with 1: "один день" 26 | NominativePlural string // Used with 2-4: "два дні" 27 | GenitivePlural string // Used with 5+: "п'ять днів" 28 | } 29 | 30 | // NounSelector is a function that selects the appropriate noun form 31 | type NounSelector func(count int, gender Gender) string 32 | 33 | // Options contains Ukrainian-specific conversion options 34 | type Options struct { 35 | Gender Gender // Gender for number agreement (1-2) 36 | Case Case // Grammatical case (future use) 37 | UseAnd bool // Whether to use "і" between groups 38 | WithNoun bool // Whether to include noun 39 | NounForms *NounForms // Noun forms for agreement 40 | NounSelector NounSelector // Custom noun selection function 41 | } 42 | 43 | // Convert converts a number to Ukrainian words using default options 44 | func Convert(number int) string { 45 | return ConvertWithOptions(number, Options{ 46 | Gender: Masculine, 47 | Case: Nominative, 48 | }) 49 | } 50 | 51 | // ConvertAnd converts a number to Ukrainian words with "і" between groups 52 | func ConvertAnd(number int) string { 53 | return ConvertWithOptions(number, Options{ 54 | Gender: Masculine, 55 | Case: Nominative, 56 | UseAnd: true, 57 | }) 58 | } 59 | 60 | // ConvertWithOptions converts a number to Ukrainian words with specified options 61 | func ConvertWithOptions(number int, opts Options) string { 62 | // Zero rule 63 | if number == 0 { 64 | result := smallNumbers[Masculine][0] 65 | if opts.WithNoun { 66 | result += " " + selectNoun(0, opts) 67 | } 68 | return result 69 | } 70 | 71 | // Handle negative numbers 72 | negative := number < 0 73 | if negative { 74 | number = -number 75 | } 76 | 77 | // Divide into three-digit groups 78 | const groupsNumber = 4 79 | var groups [groupsNumber]int 80 | positive := float64(number) 81 | 82 | // Form three-digit groups 83 | for i := 0; i < groupsNumber; i++ { 84 | groups[i] = int(math.Mod(positive, 1000)) 85 | positive /= 1000 86 | } 87 | 88 | var textGroup [groupsNumber]string 89 | for i := 0; i < groupsNumber; i++ { 90 | if groups[i] != 0 { 91 | textGroup[i] = digitGroup2Text(groups[i], opts, i) 92 | } 93 | } 94 | 95 | // Combine groups 96 | combined := textGroup[0] 97 | useAnd := opts.UseAnd && (groups[0] > 0 && groups[0] < 100) 98 | 99 | for i := 1; i < groupsNumber; i++ { 100 | if groups[i] != 0 { 101 | scaleWord := GetScaleWord(groups[i], i) 102 | prefix := textGroup[i] + " " + scaleWord 103 | 104 | if len(combined) != 0 { 105 | if useAnd { 106 | prefix += " і " 107 | } else { 108 | prefix += " " 109 | } 110 | } 111 | 112 | useAnd = false 113 | combined = prefix + combined 114 | } 115 | } 116 | 117 | if negative { 118 | combined = "мінус " + combined 119 | } 120 | 121 | // Add noun if requested 122 | if opts.WithNoun { 123 | combined += " " + selectNoun(number, opts) 124 | } 125 | 126 | return combined 127 | } 128 | 129 | // ConvertFloat converts a float number to Ukrainian words 130 | func ConvertFloat(number float64, opts Options) string { 131 | if number == 0 { 132 | return smallNumbers[opts.Gender][0] 133 | } 134 | 135 | // Handle negative numbers 136 | negative := number < 0 137 | if negative { 138 | number = -number 139 | } 140 | 141 | // Split into integer and fractional parts 142 | intPart := int(number) 143 | fracPart := number - float64(intPart) 144 | 145 | result := ConvertWithOptions(intPart, Options{ 146 | Gender: opts.Gender, 147 | Case: opts.Case, 148 | UseAnd: opts.UseAnd, 149 | }) 150 | 151 | // Add fractional part if it exists 152 | if fracPart > 0 { 153 | // Convert decimal part intelligently 154 | fracStr := fmt.Sprintf("%.10f", fracPart)[2:] // Remove "0." 155 | fracStr = strings.TrimRight(fracStr, "0") // Remove trailing zeros 156 | 157 | if len(fracStr) > 0 { 158 | // Check if we have leading zeros - if so, read digit by digit 159 | hasLeadingZero := fracStr[0] == '0' 160 | 161 | if hasLeadingZero { 162 | // Read each digit separately to preserve leading zeros 163 | var digitWords []string 164 | for _, digit := range fracStr { 165 | digitNum := int(digit - '0') 166 | digitWords = append(digitWords, smallNumbers[opts.Gender][digitNum]) 167 | } 168 | result += " кома " + strings.Join(digitWords, " ") 169 | } else { 170 | // Read as a number 171 | fracInt := 0 172 | for _, digit := range fracStr { 173 | fracInt = fracInt*10 + int(digit-'0') 174 | } 175 | result += " кома " + ConvertWithOptions(fracInt, Options{ 176 | Gender: opts.Gender, 177 | Case: opts.Case, 178 | }) 179 | } 180 | } 181 | } 182 | 183 | if negative { 184 | result = "мінус " + result 185 | } 186 | 187 | return result 188 | } 189 | 190 | // selectNoun selects the appropriate noun form based on the number 191 | func selectNoun(count int, opts Options) string { 192 | if opts.NounSelector != nil { 193 | return opts.NounSelector(count, opts.Gender) 194 | } 195 | 196 | if opts.NounForms != nil { 197 | return selectNounFromForms(count, opts.NounForms) 198 | } 199 | 200 | return "" 201 | } 202 | 203 | // selectNounFromForms selects the appropriate noun form from NounForms 204 | func selectNounFromForms(count int, forms *NounForms) string { 205 | if count < 0 { 206 | count = -count 207 | } 208 | 209 | lastDigit := count % 10 210 | lastTwoDigits := count % 100 211 | 212 | // Special case for teens (11-14) 213 | if lastTwoDigits >= 11 && lastTwoDigits <= 14 { 214 | return forms.GenitivePlural 215 | } 216 | 217 | switch lastDigit { 218 | case 1: 219 | return forms.Singular 220 | case 2, 3, 4: 221 | return forms.NominativePlural 222 | default: // 0, 5, 6, 7, 8, 9 223 | return forms.GenitivePlural 224 | } 225 | } 226 | 227 | // digitGroup2Text converts a three-digit group to text 228 | func digitGroup2Text(group int, opts Options, groupIndex int) string { 229 | if group == 0 { 230 | return "" 231 | } 232 | 233 | hundreds := group / 100 234 | tensUnits := group % 100 235 | 236 | var result string 237 | 238 | // Handle hundreds 239 | if hundreds != 0 { 240 | if hundreds == 1 { 241 | // For 1xx, always just say "сто" 242 | result += GetHundredsWord(hundreds) 243 | } else { 244 | // For 2-9 hundreds, Ukrainian uses special forms without the digit 245 | result += GetHundredsWord(hundreds) 246 | } 247 | 248 | if tensUnits != 0 { 249 | if opts.UseAnd { 250 | result += " і " 251 | } else { 252 | result += " " 253 | } 254 | } 255 | } 256 | 257 | // Handle tens and units 258 | if tensUnits != 0 { 259 | result += convertTensUnits(tensUnits, opts, groupIndex) 260 | } 261 | 262 | return result 263 | } 264 | 265 | // convertTensUnits converts the tens and units part 266 | func convertTensUnits(tensUnits int, opts Options, groupIndex int) string { 267 | tens := tensUnits / 10 268 | units := tensUnits % 10 269 | 270 | // Determine gender for this group 271 | gender := opts.Gender 272 | // Special handling for thousands (group 1) - they are feminine in Ukrainian 273 | if groupIndex == 1 { 274 | gender = Feminine 275 | } 276 | 277 | if tens >= 2 { 278 | result := tensWords[tens] 279 | if units != 0 { 280 | result += " " + smallNumbers[gender][units] 281 | } 282 | return result 283 | } else if tensUnits != 0 { 284 | return smallNumbers[gender][tensUnits] 285 | } 286 | 287 | return "" 288 | } -------------------------------------------------------------------------------- /lang/ukrainian/ukrainian_test.go: -------------------------------------------------------------------------------- 1 | package ukrainian 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBasicNumbers(t *testing.T) { 8 | tests := []struct { 9 | number int 10 | expected string 11 | }{ 12 | {0, "нуль"}, 13 | {1, "один"}, 14 | {2, "два"}, 15 | {3, "три"}, 16 | {4, "чотири"}, 17 | {5, "п'ять"}, 18 | {10, "десять"}, 19 | {11, "одинадцять"}, 20 | {12, "дванадцять"}, 21 | {19, "дев'ятнадцять"}, 22 | {20, "двадцять"}, 23 | {21, "двадцять один"}, 24 | {30, "тридцять"}, 25 | {42, "сорок два"}, 26 | {99, "дев'яносто дев'ять"}, 27 | } 28 | 29 | for _, test := range tests { 30 | result := Convert(test.number) 31 | if result != test.expected { 32 | t.Errorf("Convert(%d) = %s, expected %s", test.number, result, test.expected) 33 | } 34 | } 35 | } 36 | 37 | func TestGenderAgreement(t *testing.T) { 38 | tests := []struct { 39 | number int 40 | gender Gender 41 | expected string 42 | }{ 43 | // Masculine 44 | {1, Masculine, "один"}, 45 | {2, Masculine, "два"}, 46 | {21, Masculine, "двадцять один"}, 47 | {22, Masculine, "двадцять два"}, 48 | 49 | // Feminine 50 | {1, Feminine, "одна"}, 51 | {2, Feminine, "дві"}, 52 | {21, Feminine, "двадцять одна"}, 53 | {22, Feminine, "двадцять дві"}, 54 | 55 | // Neuter 56 | {1, Neuter, "одне"}, 57 | {2, Neuter, "два"}, 58 | {21, Neuter, "двадцять одне"}, 59 | {22, Neuter, "двадцять два"}, 60 | } 61 | 62 | for _, test := range tests { 63 | result := ConvertWithOptions(test.number, Options{Gender: test.gender}) 64 | if result != test.expected { 65 | t.Errorf("ConvertWithOptions(%d, gender=%v) = %s, expected %s", 66 | test.number, test.gender, result, test.expected) 67 | } 68 | } 69 | } 70 | 71 | func TestHundreds(t *testing.T) { 72 | tests := []struct { 73 | number int 74 | expected string 75 | }{ 76 | {100, "сто"}, 77 | {200, "двісті"}, 78 | {300, "триста"}, 79 | {400, "чотириста"}, 80 | {500, "п'ятсот"}, 81 | {600, "шістсот"}, 82 | {700, "сімсот"}, 83 | {800, "вісімсот"}, 84 | {900, "дев'ятсот"}, 85 | {101, "сто один"}, 86 | {111, "сто одинадцять"}, 87 | {123, "сто двадцять три"}, 88 | {999, "дев'ятсот дев'яносто дев'ять"}, 89 | } 90 | 91 | for _, test := range tests { 92 | result := Convert(test.number) 93 | if result != test.expected { 94 | t.Errorf("Convert(%d) = %s, expected %s", test.number, result, test.expected) 95 | } 96 | } 97 | } 98 | 99 | func TestThousands(t *testing.T) { 100 | tests := []struct { 101 | number int 102 | expected string 103 | }{ 104 | {1000, "одна тисяча"}, 105 | {2000, "дві тисячі"}, 106 | {3000, "три тисячі"}, 107 | {4000, "чотири тисячі"}, 108 | {5000, "п'ять тисяч"}, 109 | {10000, "десять тисяч"}, 110 | {11000, "одинадцять тисяч"}, 111 | {12000, "дванадцять тисяч"}, 112 | {21000, "двадцять одна тисяча"}, 113 | {22000, "двадцять дві тисячі"}, 114 | {25000, "двадцять п'ять тисяч"}, 115 | {1001, "одна тисяча один"}, 116 | {1234, "одна тисяча двісті тридцять чотири"}, 117 | {2345, "дві тисячі триста сорок п'ять"}, 118 | } 119 | 120 | for _, test := range tests { 121 | result := Convert(test.number) 122 | if result != test.expected { 123 | t.Errorf("Convert(%d) = %s, expected %s", test.number, result, test.expected) 124 | } 125 | } 126 | } 127 | 128 | func TestMillions(t *testing.T) { 129 | tests := []struct { 130 | number int 131 | expected string 132 | }{ 133 | {1000000, "один мільйон"}, 134 | {2000000, "два мільйони"}, 135 | {3000000, "три мільйони"}, 136 | {4000000, "чотири мільйони"}, 137 | {5000000, "п'ять мільйонів"}, 138 | {10000000, "десять мільйонів"}, 139 | {11000000, "одинадцять мільйонів"}, 140 | {21000000, "двадцять один мільйон"}, 141 | {22000000, "двадцять два мільйони"}, 142 | {1234567, "один мільйон двісті тридцять чотири тисячі п'ятсот шістдесят сім"}, 143 | } 144 | 145 | for _, test := range tests { 146 | result := Convert(test.number) 147 | if result != test.expected { 148 | t.Errorf("Convert(%d) = %s, expected %s", test.number, result, test.expected) 149 | } 150 | } 151 | } 152 | 153 | func TestTeensSpecialCase(t *testing.T) { 154 | // Test that 11-14 always use genitive plural forms 155 | tests := []struct { 156 | number int 157 | expected string 158 | }{ 159 | {11, "одинадцять"}, 160 | {12, "дванадцять"}, 161 | {13, "тринадцять"}, 162 | {14, "чотирнадцять"}, 163 | {111, "сто одинадцять"}, 164 | {1011, "одна тисяча одинадцять"}, 165 | {11000, "одинадцять тисяч"}, // teens use genitive plural 166 | {12000, "дванадцять тисяч"}, 167 | {13000, "тринадцять тисяч"}, 168 | {14000, "чотирнадцять тисяч"}, 169 | } 170 | 171 | for _, test := range tests { 172 | result := Convert(test.number) 173 | if result != test.expected { 174 | t.Errorf("Convert(%d) = %s, expected %s", test.number, result, test.expected) 175 | } 176 | } 177 | } 178 | 179 | func TestNegativeNumbers(t *testing.T) { 180 | tests := []struct { 181 | number int 182 | expected string 183 | }{ 184 | {-1, "мінус один"}, 185 | {-42, "мінус сорок два"}, 186 | {-100, "мінус сто"}, 187 | {-1234, "мінус одна тисяча двісті тридцять чотири"}, 188 | } 189 | 190 | for _, test := range tests { 191 | result := Convert(test.number) 192 | if result != test.expected { 193 | t.Errorf("Convert(%d) = %s, expected %s", test.number, result, test.expected) 194 | } 195 | } 196 | } 197 | 198 | func TestWithAnd(t *testing.T) { 199 | tests := []struct { 200 | number int 201 | expected string 202 | }{ 203 | {101, "сто і один"}, 204 | {1001, "одна тисяча і один"}, 205 | {1234, "одна тисяча двісті і тридцять чотири"}, 206 | {123456, "сто і двадцять три тисячі чотириста і п'ятдесят шість"}, 207 | } 208 | 209 | for _, test := range tests { 210 | result := ConvertAnd(test.number) 211 | if result != test.expected { 212 | t.Errorf("ConvertAnd(%d) = %s, expected %s", test.number, result, test.expected) 213 | } 214 | } 215 | } 216 | 217 | func TestNounForms(t *testing.T) { 218 | hryvnaForms := &NounForms{ 219 | Singular: "гривня", 220 | NominativePlural: "гривні", 221 | GenitivePlural: "гривень", 222 | } 223 | 224 | tests := []struct { 225 | number int 226 | expected string 227 | }{ 228 | {0, "нуль гривень"}, 229 | {1, "одна гривня"}, 230 | {2, "дві гривні"}, 231 | {3, "три гривні"}, 232 | {4, "чотири гривні"}, 233 | {5, "п'ять гривень"}, 234 | {10, "десять гривень"}, 235 | {11, "одинадцять гривень"}, // teens special case 236 | {12, "дванадцять гривень"}, 237 | {13, "тринадцять гривень"}, 238 | {14, "чотирнадцять гривень"}, 239 | {15, "п'ятнадцять гривень"}, 240 | {21, "двадцять одна гривня"}, 241 | {22, "двадцять дві гривні"}, 242 | {25, "двадцять п'ять гривень"}, 243 | {100, "сто гривень"}, // 100 uses genitive plural 244 | {101, "сто одна гривня"}, 245 | {102, "сто дві гривні"}, 246 | {105, "сто п'ять гривень"}, 247 | {11000, "одинадцять тисяч гривень"}, 248 | } 249 | 250 | for _, test := range tests { 251 | result := ConvertWithOptions(test.number, Options{ 252 | Gender: Feminine, 253 | WithNoun: true, 254 | NounForms: hryvnaForms, 255 | }) 256 | if result != test.expected { 257 | t.Errorf("ConvertWithOptions(%d, with гривня) = %s, expected %s", 258 | test.number, result, test.expected) 259 | } 260 | } 261 | } 262 | 263 | func TestNounSelector(t *testing.T) { 264 | daySelector := func(count int, gender Gender) string { 265 | if count < 0 { 266 | count = -count 267 | } 268 | 269 | lastDigit := count % 10 270 | lastTwoDigits := count % 100 271 | 272 | // Special case for teens (11-14) 273 | if lastTwoDigits >= 11 && lastTwoDigits <= 14 { 274 | return "днів" 275 | } 276 | 277 | switch lastDigit { 278 | case 1: 279 | return "день" 280 | case 2, 3, 4: 281 | return "дні" 282 | default: 283 | return "днів" 284 | } 285 | } 286 | 287 | tests := []struct { 288 | number int 289 | expected string 290 | }{ 291 | {1, "один день"}, 292 | {2, "два дні"}, 293 | {5, "п'ять днів"}, 294 | {11, "одинадцять днів"}, 295 | {21, "двадцять один день"}, 296 | {22, "двадцять два дні"}, 297 | } 298 | 299 | for _, test := range tests { 300 | result := ConvertWithOptions(test.number, Options{ 301 | Gender: Masculine, 302 | WithNoun: true, 303 | NounSelector: daySelector, 304 | }) 305 | if result != test.expected { 306 | t.Errorf("ConvertWithOptions(%d, with день) = %s, expected %s", 307 | test.number, result, test.expected) 308 | } 309 | } 310 | } 311 | 312 | func TestFloatNumbers(t *testing.T) { 313 | tests := []struct { 314 | number float64 315 | expected string 316 | }{ 317 | {0.0, "нуль"}, 318 | {1.0, "один"}, 319 | {3.14, "три кома чотирнадцять"}, 320 | {42.5, "сорок два кома п'ять"}, 321 | {100.01, "сто кома нуль один"}, 322 | {-3.14, "мінус три кома чотирнадцять"}, 323 | } 324 | 325 | for _, test := range tests { 326 | result := ConvertFloat(test.number, Options{Gender: Masculine}) 327 | if result != test.expected { 328 | t.Errorf("ConvertFloat(%f) = %s, expected %s", test.number, result, test.expected) 329 | } 330 | } 331 | } 332 | 333 | func TestComplexNumbers(t *testing.T) { 334 | tests := []struct { 335 | number int 336 | expected string 337 | }{ 338 | {1234567890, "один мільярд двісті тридцять чотири мільйони п'ятсот шістдесят сім тисяч вісімсот дев'яносто"}, 339 | {999999999, "дев'ятсот дев'яносто дев'ять мільйонів дев'ятсот дев'яносто дев'ять тисяч дев'ятсот дев'яносто дев'ять"}, 340 | } 341 | 342 | for _, test := range tests { 343 | result := Convert(test.number) 344 | if result != test.expected { 345 | t.Errorf("Convert(%d) = %s, expected %s", test.number, result, test.expected) 346 | } 347 | } 348 | } 349 | 350 | // Benchmark tests 351 | func BenchmarkConvert(b *testing.B) { 352 | for i := 0; i < b.N; i++ { 353 | Convert(12345) 354 | } 355 | } 356 | 357 | func BenchmarkConvertWithGender(b *testing.B) { 358 | for i := 0; i < b.N; i++ { 359 | ConvertWithOptions(12345, Options{Gender: Feminine}) 360 | } 361 | } 362 | 363 | func BenchmarkConvertWithNoun(b *testing.B) { 364 | forms := &NounForms{ 365 | Singular: "гривня", 366 | NominativePlural: "гривні", 367 | GenitivePlural: "гривень", 368 | } 369 | 370 | for i := 0; i < b.N; i++ { 371 | ConvertWithOptions(12345, Options{ 372 | Gender: Feminine, 373 | WithNoun: true, 374 | NounForms: forms, 375 | }) 376 | } 377 | } 378 | --------------------------------------------------------------------------------