n2words
Version:
Convert numbers to words in 70+ languages with zero dependencies. Supports BigInt, decimals, and browser/Node.js environments.
421 lines (357 loc) • 15.2 kB
JavaScript
/**
* Arabic (Saudi Arabia) language converter
*
* CLDR: ar-SA | Modern Standard Arabic as used in Saudi Arabia
*
* Self-contained converter with gender agreement and complex pluralization.
*
* Key features:
* - Gender agreement (masculine/feminine forms)
* - Complex pluralization (singular/dual/plural)
* - Traditional Arabic number naming conventions
* - "و" (and) conjunction between segments
*/
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'
import { resolveOptions } from './utils/resolve-options.js'
// ============================================================================
// Vocabulary
// ============================================================================
const TENS = ['عشرون', 'ثلاثون', 'أربعون', 'خمسون', 'ستون', 'سبعون', 'ثمانون', 'تسعون']
const HUNDREDS = ['', 'مائة', 'مئتان', 'ثلاثمائة', 'أربعمائة', 'خمسمائة', 'ستمائة', 'سبعمائة', 'ثمانمائة', 'تسعمائة']
// Magnitude words with three forms: singular, appended (tanween), plural
const SCALE_WORDS = ['مائة', 'ألف', 'مليون', 'مليار', 'تريليون', 'كوادريليون', 'كوينتليون', 'سكستيليون']
// Supported magnitude ceiling (checked at the public entry points), derived from the scale table.
export const cardinalMax = western(SCALE_WORDS.length - 1)
export const ordinalMax = western(SCALE_WORDS.length - 1)
export const currencyMax = western(SCALE_WORDS.length - 1)
const SCALE_APPENDED = ['', 'ألفاً', 'مليوناً', 'ملياراً', 'تريليوناً', 'كوادريليوناً', 'كوينتليوناً', 'سكستيليوناً']
const SCALE_PLURAL = ['', 'آلاف', 'ملايين', 'مليارات', 'تريليونات', 'كوادريليونات', 'كوينتليونات', 'سكستيليونات']
// Dual forms
const DUAL = ['مئتان', 'ألفان', 'مليونان', 'ملياران', 'تريليونان', 'كوادريليونان', 'كوينتليونان', 'سكستيليونان']
const DUAL_APPENDED = ['مئتا', 'ألفا', 'مليونا', 'مليارا', 'تريليونا', 'كوادريليونا', 'كوينتليونا', 'سكستيليونا']
// Gender-specific forms (1-19)
const ONES_MASC = ['واحد', 'اثنان', 'ثلاثة', 'أربعة', 'خمسة', 'ستة', 'سبعة', 'ثمانية', 'تسعة', 'عشرة', 'أحد عشر', 'اثنا عشر', 'ثلاثة عشر', 'أربعة عشر', 'خمسة عشر', 'ستة عشر', 'سبعة عشر', 'ثمانية عشر', 'تسعة عشر']
const ONES_FEM = ['واحدة', 'اثنتان', 'ثلاث', 'أربع', 'خمس', 'ست', 'سبع', 'ثمان', 'تسع', 'عشر', 'إحدى عشرة', 'اثنتا عشرة', 'ثلاث عشرة', 'أربع عشرة', 'خمس عشرة', 'ست عشرة', 'سبع عشرة', 'ثماني عشرة', 'تسع عشرة']
const ZERO = 'صفر'
const NEGATIVE = 'ناقص'
const DECIMAL_SEP = 'فاصلة'
const AND = 'و'
// ============================================================================
// Ordinal Vocabulary
// ============================================================================
// Masculine ordinal forms (1-10)
const ORDINAL_MASC = ['الأول', 'الثاني', 'الثالث', 'الرابع', 'الخامس', 'السادس', 'السابع', 'الثامن', 'التاسع', 'العاشر']
// Feminine ordinal forms (1-10)
const ORDINAL_FEM = ['الأولى', 'الثانية', 'الثالثة', 'الرابعة', 'الخامسة', 'السادسة', 'السابعة', 'الثامنة', 'التاسعة', 'العاشرة']
// ============================================================================
// Currency Vocabulary (Saudi Riyal)
// ============================================================================
// Riyal: singular, dual, plural (3-10), plural (11+)
const RIYAL_SINGULAR = 'ريال'
const RIYAL_DUAL = 'ريالان'
const RIYAL_PLURAL_3_10 = 'ريالات'
const RIYAL_PLURAL_11 = 'ريالاً'
// Halala: singular, dual, plural (3-10), plural (11+)
const HALALA_SINGULAR = 'هللة'
const HALALA_DUAL = 'هللتان'
const HALALA_PLURAL_3_10 = 'هللات'
const HALALA_PLURAL_11 = 'هللة'
// ============================================================================
// Conversion Functions
// ============================================================================
/**
* Convert a 3-digit group to words.
* Returns a clean string with no leading/trailing spaces.
* Arabic "و" (and) is attached to following word: "مائة وخمسة" not "مائة و خمسة"
* @param {number} groupNumber - The 3-digit group value (0-999)
* @param {number} groupLevel - The scale level of this group (0 = units, 1 = thousands, ...)
* @param {bigint} fullNumber - The full number being converted
* @param {string[]} ones - Gender-specific ones words (1-19)
* @returns {string} The group rendered as words
*/
function segmentToWords(groupNumber, groupLevel, fullNumber, ones) {
const tensValue = groupNumber % 100
const hundredsDigit = Math.trunc(groupNumber / 100)
let result = ''
// Process hundreds
if (hundredsDigit > 0) {
if (tensValue === 0 && hundredsDigit === 2) {
result = DUAL[0]
}
else {
const hundredsWord = HUNDREDS[hundredsDigit]
if (hundredsWord) {
result = hundredsWord
if (tensValue !== 0) {
result += ' ' + AND // "مائة و" - و attaches to next word
}
}
}
}
// Process tens and ones
if (tensValue > 0) {
if (tensValue < 20) {
if (tensValue === 2 && hundredsDigit === 0 && groupLevel > 0) {
const numValue = Number(fullNumber)
const pow = Math.trunc(Math.log10(numValue))
if (pow % 3 === 0 && fullNumber === BigInt(2 * Math.pow(10, pow))) {
result += (groupNumber === 2 ? DUAL[groupLevel] : DUAL_APPENDED[groupLevel])
}
else {
result += DUAL[groupLevel]
}
}
else if (tensValue === 1 && groupLevel > 0) {
result += SCALE_WORDS[groupLevel]
}
else {
result += ones[tensValue - 1]
}
}
else {
const onesDigit = tensValue % 10
const tensIndex = Math.trunc(tensValue / 10) - 2
if (onesDigit > 0) {
result += ones[onesDigit - 1] + ' ' + AND // "ستة و" attaches to tens
}
result += TENS[tensIndex]
}
}
return result
}
/**
* Convert a non-negative integer to Arabic words.
* @param {bigint} n - The non-negative integer to convert
* @param {string} gender - 'masculine' or 'feminine'
* @returns {string} The number rendered as words
*/
function integerToWords(n, gender) {
if (n === 0n) return ZERO
const ones = gender === 'feminine' ? ONES_FEM : ONES_MASC
let temp = n
let group = 0
const groups = []
while (temp > 0n) {
const numberToProcess = Number(temp % 1000n)
temp = temp / 1000n
if (numberToProcess > 0) {
const groupDescription = segmentToWords(numberToProcess, group, n, ones)
if (groupDescription) {
let groupText = groupDescription
// Add scale word for groups > 0
if (group > 0 && numberToProcess > 2) {
const remainder = numberToProcess % 100
if (remainder === 1) {
groupText += ' ' + SCALE_WORDS[group]
}
else if (numberToProcess >= 3 && numberToProcess <= 10) {
groupText += ' ' + SCALE_PLURAL[group]
}
else {
groupText += ' ' + (groups.length > 0 ? SCALE_APPENDED[group] : SCALE_WORDS[group])
}
}
groups.unshift(groupText)
}
}
group++
}
// Join groups with و (and) - space before و, attaches to next word
// Use simple join since segmentToWords returns clean strings
if (groups.length === 1) return groups[0]
// Build result: "group1 وgroup2 وgroup3"
let result = groups[0]
for (let i = 1; i < groups.length; i++) {
result += ' ' + AND + groups[i]
}
return result
}
/**
* Convert the fractional digits of a number to Arabic words.
* @param {string} decimalPart - The decimal digits (after the separator)
* @param {string} gender - 'masculine' or 'feminine'
* @returns {string} The decimal part rendered as words
*/
function decimalPartToWords(decimalPart, gender) {
const parts = []
let i = 0
while (i < decimalPart.length && decimalPart[i] === '0') {
parts.push(ZERO)
i++
}
const remainder = decimalPart.slice(i)
if (remainder) {
parts.push(integerToWords(BigInt(remainder), gender))
}
return parts.join(' ')
}
/**
* @typedef {object} CardinalOptions
* @property {('masculine'|'feminine')} [gender] - Grammatical gender
* @property {string} [negativeWord] - Custom word for negative numbers
*/
/** @type {Required<CardinalOptions>} */
export const cardinalDefaults = { gender: 'masculine', negativeWord: NEGATIVE }
/** @type {{ gender: ReadonlyArray<Required<CardinalOptions>['gender']> }} */
export const cardinalValues = { gender: ['masculine', 'feminine'] }
/**
* Converts a numeric value to Arabic words.
* @param {number | string | bigint} value - The numeric value to convert
* @param {CardinalOptions} [options] - Optional configuration
* @returns {string} The number in Arabic words
* @example
* toCardinal(1) // 'واحد'
* toCardinal(1, {gender: 'feminine'}) // 'واحدة'
*/
function toCardinal(value, options) {
const { isNegative, integerPart, decimalPart } = parseCardinalValue(value)
checkMax(integerPart, cardinalMax, decimalPart)
// Apply option defaults
const { gender, negativeWord } = resolveOptions(options, cardinalDefaults, cardinalValues)
const parts = []
if (isNegative) {
parts.push(negativeWord)
}
parts.push(integerToWords(integerPart, gender))
if (decimalPart) {
parts.push(DECIMAL_SEP)
parts.push(decimalPartToWords(decimalPart, gender))
}
return parts.join(' ')
}
// ============================================================================
// ORDINAL: toOrdinal(value, options?)
// ============================================================================
/**
* Gets the Arabic ordinal form for a number.
*
* Arabic ordinals 1-10 have special forms, beyond 10 use cardinal + position.
* @param {bigint} n - Positive integer to convert
* @param {string} gender - 'masculine' or 'feminine'
* @returns {string} Arabic ordinal words
*/
function integerToOrdinal(n, gender) {
const ordinals = gender === 'feminine' ? ORDINAL_FEM : ORDINAL_MASC
// Special ordinals for 1-10
if (n >= 1n && n <= 10n) {
return ordinals[Number(n) - 1]
}
// For 11 and above, use cardinal form with "ال" prefix for definiteness
const cardinal = integerToWords(n, gender)
return 'ال' + cardinal
}
/**
* @typedef {object} OrdinalOptions
* @property {('masculine'|'feminine')} [gender] - Grammatical gender
*/
/** @type {Required<OrdinalOptions>} */
export const ordinalDefaults = { gender: 'masculine' }
/** @type {{ gender: ReadonlyArray<Required<OrdinalOptions>['gender']> }} */
export const ordinalValues = { gender: ['masculine', 'feminine'] }
/**
* Converts a numeric value to Arabic ordinal words.
* @param {number | string | bigint} value - The numeric value to convert (positive integer)
* @param {OrdinalOptions} [options] - Optional configuration
* @returns {string} The number as ordinal words
* @throws {TypeError} If value is not a valid numeric type
* @throws {RangeError} If value is negative, zero, or has a decimal part
* @example
* toOrdinal(1) // 'الأول'
* toOrdinal(1, {gender: 'feminine'}) // 'الأولى'
* toOrdinal(3) // 'الثالث'
*/
function toOrdinal(value, options) {
const integerPart = parseOrdinalValue(value)
checkMax(integerPart, ordinalMax)
const { gender } = resolveOptions(options, ordinalDefaults, ordinalValues)
return integerToOrdinal(integerPart, gender)
}
// ============================================================================
// CURRENCY: toCurrency(value, options?)
// ============================================================================
/**
* Gets the appropriate currency word form based on number.
*
* Arabic has complex pluralization:
* - 1: singular
* - 2: dual
* - 3-10: plural form 1
* - 11+: plural form 2 (different ending)
* @param {bigint} n - The riyal count
* @returns {string} The appropriate riyal word form
*/
function getRiyalForm(n) {
if (n === 1n) return RIYAL_SINGULAR
if (n === 2n) return RIYAL_DUAL
if (n >= 3n && n <= 10n) return RIYAL_PLURAL_3_10
return RIYAL_PLURAL_11
}
/**
* Gets the appropriate halala word form based on number.
* @param {bigint} n - The halala count
* @returns {string} The appropriate halala word form
*/
function getHalalaForm(n) {
if (n === 1n) return HALALA_SINGULAR
if (n === 2n) return HALALA_DUAL
if (n >= 3n && n <= 10n) return HALALA_PLURAL_3_10
return HALALA_PLURAL_11
}
/**
* Converts a numeric value to Arabic currency words (Saudi Riyal).
* @param {number | string | bigint} value - The currency amount to convert
* @returns {string} The amount in Arabic currency words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a valid number format
* @example
* toCurrency(42.50) // 'اثنان وأربعون ريالاً وخمسون هللة'
* toCurrency(1) // 'ريال واحد'
* toCurrency(0.01) // 'هللة واحدة'
*/
function toCurrency(value) {
const { isNegative, dollars: riyals, cents: halalas } = parseCurrencyValue(value)
checkMax(riyals, currencyMax)
// Build result
let result = ''
if (isNegative) result = NEGATIVE + ' '
// Riyals part - show if non-zero, or if no halalas
if (riyals > 0n || halalas === 0n) {
// Special case for 1 and 2: currency word comes first
if (riyals === 1n) {
result += RIYAL_SINGULAR + ' ' + ONES_MASC[0]
}
else if (riyals === 2n) {
result += RIYAL_DUAL
}
else {
const riyalWord = integerToWords(riyals, 'masculine')
result += riyalWord + ' ' + getRiyalForm(riyals)
}
}
// Halalas part
if (halalas > 0n) {
if (riyals > 0n) {
result += ' ' + AND
}
// Special case for 1 and 2: currency word comes first
if (halalas === 1n) {
result += HALALA_SINGULAR + ' ' + ONES_FEM[0]
}
else if (halalas === 2n) {
result += HALALA_DUAL
}
else {
const halalaWord = integerToWords(halalas, 'feminine')
result += halalaWord + ' ' + getHalalaForm(halalas)
}
}
return result
}
// ============================================================================
// Exports
// ============================================================================
export { toCardinal, toOrdinal, toCurrency }