n2words
Version:
Convert numbers to words in 70+ languages with zero dependencies. Supports BigInt, decimals, and browser/Node.js environments.
406 lines (340 loc) • 13.3 kB
JavaScript
/**
* Georgian (Georgia) language converter
*
* CLDR: ka-GE | Georgian as used in Georgia
*
* Georgian-specific rules:
* - Vigesimal (base-20) system for 20-99
* - 40 = ორმოცი (2×20), 60 = სამოცი (3×20), 80 = ოთხმოცი (4×20)
* - 30/50/70/90 use "და" + "ათი": ოცდაათი (20+10), ორმოცდაათი (40+10)
* - Compound numbers use "და" (da = "and") connector
* - Hundreds: unit prefix + ას (ორასი = 200)
* - Short scale for large numbers (მილიონი, მილიარდი, ტრილიონი)
*/
import { parseCardinalValue } from './utils/parse-cardinal.js'
import { parseCurrencyValue } from './utils/parse-currency.js'
import { parseOrdinalValue } from './utils/parse-ordinal.js'
import { checkMax } from './utils/check-max.js'
import { western } from './utils/scale.js'
// ============================================================================
// Vocabulary (module-level constants)
// ============================================================================
// Numbers 0-9 (primitives)
const ONES = ['ნული', 'ერთი', 'ორი', 'სამი', 'ოთხი', 'ხუთი', 'ექვსი', 'შვიდი', 'რვა', 'ცხრა']
// Numbers 10-19
const TEENS = ['ათი', 'თერთმეტი', 'თორმეტი', 'ცამეტი', 'თოთხმეტი', 'თხუთმეტი', 'თექვსმეტი', 'ჩვიდმეტი', 'თვრამეტი', 'ცხრამეტი']
// Vigesimal bases: 20, 40, 60, 80 (with და connector forms)
const VIGESIMAL = ['', 'ოცი', 'ორმოცი', 'სამოცი', 'ოთხმოცი']
const VIGESIMAL_DA = ['', 'ოცდა', 'ორმოცდა', 'სამოცდა', 'ოთხმოცდა']
// Prefixes for hundreds (unit forms without final vowel)
const HUNDRED_PREFIXES = ['', '', 'ორ', 'სამ', 'ოთხ', 'ხუთ', 'ექვს', 'შვიდ', 'რვა', 'ცხრა']
const HUNDRED = 'ასი'
const HUNDRED_STEM = 'ას' // Without final vowel
const THOUSAND = 'ათასი'
const THOUSAND_STEM = 'ათას' // Without final vowel
// Scale words (short scale) - indexed by segment position
const SCALES = ['', '', 'მილიონი', 'მილიარდი', 'ტრილიონი', 'კვადრილიონი', 'კვინტილიონი', 'სექსტილიონი']
// 3-digit grouping; the table tops out at sextillion. Past it the builder clamps
// to the last scale word (`SCALES[scaleIndex] || SCALES[SCALES.length - 1]`),
// silently collapsing the magnitude — so cap there.
export const cardinalMax = western(SCALES.length - 1)
export const ordinalMax = western(SCALES.length - 1)
export const currencyMax = western(SCALES.length - 1)
const ZERO = 'ნული'
const NEGATIVE = 'მინუს'
const DECIMAL_SEP = 'მთელი'
// Ordinal suffix (Georgian adds -ე to form ordinals)
const ORDINAL_SUFFIX = 'ე'
// Ordinal forms for 1-9 (these are special)
const ORDINAL_ONES = ['', 'პირველი', 'მეორე', 'მესამე', 'მეოთხე', 'მეხუთე', 'მეექვსე', 'მეშვიდე', 'მერვე', 'მეცხრე']
// Currency (Georgian Lari)
const LARI = 'ლარი'
const TETRI = 'თეთრი'
// ============================================================================
// Segment Building
// ============================================================================
/**
* Builds segment word for 0-99 using vigesimal system.
* @param {number} n - Number 0-99
* @returns {string} Georgian word
*/
function buildTens(n) {
if (n < 10) return ONES[n]
if (n < 20) return TEENS[n - 10]
const vigesimalGroup = Math.trunc(n / 20)
const remainder = n % 20
if (remainder === 0) {
return VIGESIMAL[vigesimalGroup]
}
// Use და connector: ოცდა + remainder
const base = VIGESIMAL_DA[vigesimalGroup]
if (remainder < 10) {
return base + ONES[remainder]
}
return base + TEENS[remainder - 10]
}
/**
* Builds segment word for 0-999.
* Returns object with full form and stem form (without final vowel).
* @param {number} n - Number 0-999
* @returns {{full: string, stem: string}} Georgian words
*/
function buildSegment(n) {
if (n === 0) return { full: '', stem: '' }
if (n < 100) {
const word = buildTens(n)
// Remove final vowel for stem
const lastChar = word.slice(-1)
const stem = (lastChar === 'ი' || lastChar === 'ა') ? word.slice(0, -1) : word
return { full: word, stem }
}
const hundreds = Math.trunc(n / 100)
const remainder = n % 100
// Build hundreds: ასი (100), ორასი (200), etc.
let hundredWord
if (hundreds === 1) {
hundredWord = remainder > 0 ? HUNDRED_STEM : HUNDRED
}
else {
hundredWord = HUNDRED_PREFIXES[hundreds] + (remainder > 0 ? HUNDRED_STEM : HUNDRED)
}
if (remainder > 0) {
const remainderWord = buildTens(remainder)
const full = hundredWord + ' ' + remainderWord
// Stem removes final vowel from remainder
const lastChar = remainderWord.slice(-1)
const remainderStem = (lastChar === 'ი' || lastChar === 'ა') ? remainderWord.slice(0, -1) : remainderWord
return { full, stem: hundredWord + ' ' + remainderStem }
}
// Hundreds only - stem removes final ი
return { full: hundredWord, stem: hundredWord.slice(0, -1) }
}
// ============================================================================
// Conversion Functions
// ============================================================================
/**
* Converts a non-negative integer to Georgian words.
* @param {bigint} n - Non-negative integer to convert
* @returns {string} Georgian words
*/
function integerToWords(n) {
if (n === 0n) return ZERO
// Fast path: numbers < 1000
if (n < 1000n) {
const { full } = buildSegment(Number(n))
return full
}
// Fast path: numbers < 1,000,000 (thousands)
if (n < 1_000_000n) {
const thousands = Number(n / 1000n)
const remainder = Number(n % 1000n)
let result
if (thousands === 1) {
// "ათასი" not "ერთი ათასი"
result = remainder > 0 ? THOUSAND_STEM : THOUSAND
}
else {
// Use stem form before ათასი
const { stem: thousandsPart } = buildSegment(thousands)
result = thousandsPart + ' ' + (remainder > 0 ? THOUSAND_STEM : THOUSAND)
}
if (remainder > 0) {
const { full: remainderWord } = buildSegment(remainder)
result += ' ' + remainderWord
}
return result
}
// For numbers >= 1,000,000, use scale decomposition
return buildLargeNumberWords(n)
}
/**
* Builds words for numbers >= 1,000,000.
* @param {bigint} n - Number >= 1,000,000
* @returns {string} Georgian words
*/
function buildLargeNumberWords(n) {
const numStr = n.toString()
const len = numStr.length
// Build segments of 3 digits from left to right
const segments = []
const segmentSize = 3
const remainderLen = len % segmentSize
let pos = 0
if (remainderLen > 0) {
segments.push(Number(numStr.slice(0, remainderLen)))
pos = remainderLen
}
while (pos < len) {
segments.push(Number(numStr.slice(pos, pos + segmentSize)))
pos += segmentSize
}
// Convert segments to words
const parts = []
let scaleIndex = segments.length - 1
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
if (segment !== 0) {
if (scaleIndex === 0) {
// Units (no scale)
const { full } = buildSegment(segment)
parts.push(full)
}
else if (scaleIndex === 1) {
// Thousands - check if there's a remainder
const hasRemainder = segments.slice(i + 1).some(s => s !== 0)
const thousandWord = hasRemainder ? THOUSAND_STEM : THOUSAND
if (segment === 1) {
parts.push(thousandWord)
}
else {
const { stem } = buildSegment(segment)
parts.push(stem + ' ' + thousandWord)
}
}
else {
// Million and above
const scaleWord = SCALES[scaleIndex] || SCALES[SCALES.length - 1]
if (segment === 1) {
parts.push('ერთი ' + scaleWord)
}
else {
const { full } = buildSegment(segment)
parts.push(full + ' ' + scaleWord)
}
}
}
scaleIndex--
}
return parts.join(' ')
}
/**
* Converts decimal digits to Georgian words.
* @param {string} decimalPart - Decimal digits (without the point)
* @returns {string} Georgian words for decimal part
*/
function decimalPartToWords(decimalPart) {
let result = ''
// Handle leading zeros
let i = 0
while (i < decimalPart.length && decimalPart[i] === '0') {
if (result) result += ' '
result += ZERO
i++
}
// Convert remainder as a single number
const remainder = decimalPart.slice(i)
if (remainder) {
if (result) result += ' '
result += integerToWords(BigInt(remainder))
}
return result
}
/**
* Converts a numeric value to Georgian words.
*
* This is the main public API. It accepts any valid numeric input
* (number, string, or bigint) and handles parsing internally.
* @param {number | string | bigint} value - The numeric value to convert
* @returns {string} The number in Georgian words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a valid number format
* @example
* toCardinal(21) // 'ოცდაერთი'
* toCardinal(100) // 'ასი'
* toCardinal(1000) // 'ათასი'
*/
function toCardinal(value) {
const { isNegative, integerPart, decimalPart } = parseCardinalValue(value)
// Both the integer part and the decimal's significant digits are spelled via
// the scale builder, so both must clear the ceiling.
checkMax(integerPart, cardinalMax, decimalPart)
let result = ''
if (isNegative) {
result = NEGATIVE + ' '
}
result += integerToWords(integerPart)
if (decimalPart) {
result += ' ' + DECIMAL_SEP + ' ' + decimalPartToWords(decimalPart)
}
return result
}
// ============================================================================
// Ordinal Functions
// ============================================================================
/**
* Converts a non-negative integer to Georgian ordinal words.
* Georgian ordinals are formed by:
* - 1-9: special forms (პირველი, მეორე, მესამე, etc.)
* - 10+: მე- prefix + cardinal + -ე suffix
* @param {bigint} n - Non-negative integer to convert
* @returns {string} Georgian ordinal words
*/
function integerToOrdinal(n) {
if (n === 0n) return ''
if (n <= 9n) return ORDINAL_ONES[Number(n)]
// For 10+, use მე- + cardinal stem + -ე
const cardinal = integerToWords(n)
// Remove final vowel and add -ე suffix
const lastChar = cardinal.slice(-1)
let stem
if (lastChar === 'ი' || lastChar === 'ა') {
stem = cardinal.slice(0, -1)
}
else {
stem = cardinal
}
return 'მე' + stem + ORDINAL_SUFFIX
}
/**
* Converts a numeric value to Georgian ordinal words.
* @param {number | string | bigint} value - The numeric value to convert
* @returns {string} The ordinal in Georgian words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a positive integer
* @example
* toOrdinal(1) // 'პირველი'
* toOrdinal(10) // 'მეათე'
* toOrdinal(21) // 'მეოცდაერთე'
*/
function toOrdinal(value) {
const n = parseOrdinalValue(value)
// Ordinals build on the cardinal speller, so they share its ceiling.
checkMax(n, ordinalMax)
return integerToOrdinal(n)
}
// ============================================================================
// Currency Functions
// ============================================================================
/**
* Converts a numeric value to Georgian Lari currency words.
* @param {number | string | bigint} value - The numeric value to convert
* @returns {string} The currency in Georgian words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a valid number format
* @example
* toCurrency(1) // 'ერთი ლარი'
* toCurrency(2.50) // 'ორი ლარი ორმოცდაათი თეთრი'
*/
function toCurrency(value) {
const { isNegative, dollars, cents } = parseCurrencyValue(value)
checkMax(dollars, currencyMax)
const parts = []
if (isNegative) {
parts.push(NEGATIVE)
}
// Lari
if (dollars > 0n || cents === 0n) {
const lariWord = integerToWords(dollars)
parts.push(lariWord + ' ' + LARI)
}
// Tetri
if (cents > 0n) {
const tetriWord = integerToWords(cents)
parts.push(tetriWord + ' ' + TETRI)
}
return parts.join(' ')
}
// ============================================================================
// Public API
// ============================================================================
export { toCardinal, toOrdinal, toCurrency }