n2words
Version:
Convert numbers to words in 70+ languages with zero dependencies. Supports BigInt, decimals, and browser/Node.js environments.
580 lines (495 loc) • 16.4 kB
JavaScript
/**
* Modern Hebrew (Israel) language converter
*
* CLDR: he-IL | Modern Hebrew as used in Israel
*
* Key features:
* - Feminine grammatical forms (default in Modern Hebrew)
* - Dual forms for 2, 200, 2000
* - Special 1-9 thousands construct state
* - "ו" (ve) conjunction rules vary by position
* - Per-digit decimal reading
*/
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 { bounded, western } from './utils/scale.js'
import { resolveOptions } from './utils/resolve-options.js'
// ============================================================================
// Vocabulary (arrays for indexed access - faster than object property lookup)
// ============================================================================
// Feminine forms (default in Modern Hebrew) - index 0 unused
const ONES = ['', 'אחת', 'שתים', 'שלש', 'ארבע', 'חמש', 'שש', 'שבע', 'שמונה', 'תשע']
const TEENS = ['עשר', 'אחת עשרה', 'שתים עשרה', 'שלש עשרה', 'ארבע עשרה', 'חמש עשרה', 'שש עשרה', 'שבע עשרה', 'שמונה עשרה', 'תשע עשרה']
const TENS = ['', '', 'עשרים', 'שלשים', 'ארבעים', 'חמישים', 'ששים', 'שבעים', 'שמונים', 'תשעים']
const HUNDREDS = ['', 'מאה', 'מאתיים', 'שלש מאות', 'ארבע מאות', 'חמש מאות', 'שש מאות', 'שבע מאות', 'שמונה מאות', 'תשע מאות']
// Special forms for 1-9 thousand (index 0 unused)
const THOUSANDS_SPECIAL = ['', 'אלף', 'אלפיים', 'שלשת אלפים', 'ארבעת אלפים', 'חמשת אלפים', 'ששת אלפים', 'שבעת אלפים', 'שמונת אלפים', 'תשעת אלפים']
// Scale words (index 1 = thousands, 2 = millions, etc.)
const SCALE = ['', 'אלף', 'מיליון', 'מיליארד', 'טריליון', 'קוודרליון', 'קווינטיליון']
const SCALE_PLURAL = ['', 'אלפים', 'מיליונים', 'מיליארדים', 'טריליונים', 'קוודרליונים', 'קווינטיליונים']
// Supported magnitude ceilings (checked at the public entry points). Cardinal
// SCALE reaches index SCALE.length-1 (10^18), so cardinals/currency must stay
// below 10^(SCALE.length * 3) = 10^21. Ordinals are bounded lower: past 10^6
// the millions multiplier is built with buildScaleSegment (0-999), so n must
// stay below 10^9. Decimals are read digit-by-digit (no ceiling).
export const cardinalMax = western(SCALE.length - 1)
export const ordinalMax = bounded(9)
export const currencyMax = western(SCALE.length - 1)
const ZERO = 'אפס'
const NEGATIVE = 'מינוס'
const DECIMAL_SEP = 'נקודה'
// Masculine forms for currency and ordinals
const ONES_MASC = ['', 'אחד', 'שניים', 'שלשה', 'ארבעה', 'חמשה', 'ששה', 'שבעה', 'שמונה', 'תשעה']
// Ordinal forms (masculine, used by default)
const ORDINAL_ONES = ['', 'ראשון', 'שני', 'שלישי', 'רביעי', 'חמישי', 'שישי', 'שביעי', 'שמיני', 'תשיעי']
const ORDINAL_TEENS = ['עשירי', 'אחד עשר', 'שנים עשר', 'שלשה עשר', 'ארבעה עשר', 'חמשה עשר', 'ששה עשר', 'שבעה עשר', 'שמונה עשר', 'תשעה עשר']
const ORDINAL_TENS = ['', '', 'עשרים', 'שלשים', 'ארבעים', 'חמישים', 'ששים', 'שבעים', 'שמונים', 'תשעים']
const ORDINAL_HUNDRED = 'מאה'
// Currency (New Israeli Shekel)
const SHEKEL_SINGULAR = 'שקל'
const SHEKEL_PLURAL = 'שקלים'
const AGORA_SINGULAR = 'אגורה'
const AGORA_PLURAL = 'אגורות'
// ============================================================================
// Segment Building
// ============================================================================
/**
* Builds segment word for scale segments (thousands, millions, etc.).
* "ו" is added before tens and ones when following hundreds.
* @param {number} n - Segment value (0-999)
* @param {string} andWord - Conjunction word
* @returns {string} Hebrew words for the segment
*/
function buildScaleSegment(n, andWord) {
if (n === 0) return ''
const ones = n % 10
const tens = Math.trunc(n / 10) % 10
const hundreds = Math.trunc(n / 100)
let result = ''
// Hundreds
if (hundreds > 0) {
result = HUNDREDS[hundreds]
}
// Tens and ones
if (tens === 1) {
// Teens (10-19)
const teenWord = TEENS[ones]
if (result) {
result += ' ' + andWord + teenWord
}
else {
result = teenWord
}
}
else {
// Tens (20-90)
if (tens >= 2) {
if (result) {
result += ' ' + andWord + TENS[tens]
}
else {
result = TENS[tens]
}
}
// Ones
if (ones > 0) {
if (result) {
result += ' ' + andWord + ONES[ones]
}
else {
result = ONES[ones]
}
}
}
return result
}
/**
* Builds segment word for units segment (no scale word).
* "ו" is only added before the final ones digit.
* @param {number} n - Segment value (0-999)
* @param {string} andWord - Conjunction word
* @returns {string} Hebrew words for the segment
*/
function buildUnitsSegment(n, andWord) {
if (n === 0) return ''
const ones = n % 10
const tens = Math.trunc(n / 10) % 10
const hundreds = Math.trunc(n / 100)
let result = ''
// Hundreds
if (hundreds > 0) {
result = HUNDREDS[hundreds]
}
// Tens (no conjunction)
if (tens === 1) {
// Teens (10-19)
if (result) {
result += ' ' + TEENS[ones]
}
else {
result = TEENS[ones]
}
}
else {
if (tens >= 2) {
if (result) {
result += ' ' + TENS[tens]
}
else {
result = TENS[tens]
}
}
// Ones - conjunction only here
if (ones > 0) {
if (result) {
result += ' ' + andWord + ONES[ones]
}
else {
result = ONES[ones]
}
}
}
return result
}
// ============================================================================
// Conversion Functions
// ============================================================================
/**
* Converts a non-negative integer to Hebrew words.
* @param {bigint} n - Non-negative integer to convert
* @param {string} andWord - Conjunction word
* @returns {string} Hebrew words
*/
function integerToWords(n, andWord) {
if (n === 0n) return ZERO
// Fast path: numbers < 1000
if (n < 1000n) {
return buildUnitsSegment(Number(n), andWord)
}
// Extract segments using BigInt modulo
const segments = []
let temp = n
while (temp > 0n) {
segments.push(Number(temp % 1000n))
temp = temp / 1000n
}
// Build result string directly
let result = ''
for (let i = segments.length - 1; i >= 0; i--) {
const segment = segments[i]
if (segment === 0) continue
if (i === 0) {
// Units segment (no scale word)
const segmentWord = buildUnitsSegment(segment, andWord)
if (result) {
// Add "ו" before single-digit units when following scale words
if (segment <= 9) {
result += ' ' + andWord + segmentWord
}
else {
result += ' ' + segmentWord
}
}
else {
result = segmentWord
}
}
else if (i === 1) {
// Thousands - special handling for 1-9
if (segment <= 9) {
if (result) result += ' '
result += THOUSANDS_SPECIAL[segment]
}
else {
const segmentWord = buildScaleSegment(segment, andWord)
if (result) result += ' '
result += segmentWord + ' ' + SCALE[1]
}
}
else {
// Millions and above
if (segment === 1) {
if (result) result += ' '
result += SCALE[i]
}
else {
const segmentWord = buildScaleSegment(segment, andWord)
if (result) result += ' '
result += segmentWord + ' ' + SCALE_PLURAL[i]
}
}
}
return result
}
/**
* Converts decimal digits to Hebrew words (digit by digit).
* @param {string} decimalPart - Decimal digits (without the point)
* @returns {string} Hebrew words for decimal part
*/
function decimalPartToWords(decimalPart) {
let result = ''
for (let i = 0; i < decimalPart.length; i++) {
const d = parseInt(decimalPart[i], 10)
if (result) result += ' '
result += d === 0 ? ZERO : ONES[d]
}
return result
}
/**
* @typedef {object} CardinalOptions
* @property {string} [andWord] - Custom conjunction word
*/
/** @type {Required<CardinalOptions>} */
export const cardinalDefaults = { andWord: 'ו' }
/**
* Converts a numeric value to Modern Hebrew words.
* @param {number | string | bigint} value - The numeric value to convert
* @param {CardinalOptions} [options] - Optional configuration
* @returns {string} The number in Modern Hebrew words
*/
function toCardinal(value, options) {
const { isNegative, integerPart, decimalPart } = parseCardinalValue(value)
checkMax(integerPart, cardinalMax)
// Apply option defaults
const { andWord } = resolveOptions(options, cardinalDefaults)
let result = ''
if (isNegative) {
result = NEGATIVE + ' '
}
result += integerToWords(integerPart, andWord)
if (decimalPart) {
result += ' ' + DECIMAL_SEP + ' ' + decimalPartToWords(decimalPart)
}
return result
}
// ============================================================================
// Ordinal Functions
// ============================================================================
/**
* Builds ordinal for tens and ones (0-99).
* @param {number} n - Number 0-99
* @returns {string} Ordinal word
*/
function buildOrdinalTensOnes(n) {
if (n === 0) return ''
if (n < 10) return ORDINAL_ONES[n]
if (n < 20) return ORDINAL_TEENS[n - 10]
const ones = n % 10
const tens = Math.trunc(n / 10)
if (ones === 0) {
// Round tens: "עשרים וראשון" (twentieth = twenty and first)
return ORDINAL_TENS[tens] + ' ו' + ORDINAL_ONES[1]
}
// For compound ordinals, only the last part is ordinal
return ORDINAL_TENS[tens] + ' ו' + ORDINAL_ONES[ones]
}
/**
* Converts a non-negative integer to Hebrew ordinal words.
* @param {bigint} n - Non-negative integer to convert
* @returns {string} Hebrew ordinal words
*/
function integerToOrdinal(n) {
if (n === 0n) return ''
if (n === 1n) return ORDINAL_ONES[1]
// Numbers < 100
if (n < 100n) {
return buildOrdinalTensOnes(Number(n))
}
// Numbers < 1000
if (n < 1000n) {
const hundreds = Number(n / 100n)
const remainder = Number(n % 100n)
let result
if (hundreds === 1) {
if (remainder === 0) {
return ORDINAL_HUNDRED
}
result = HUNDREDS[hundreds]
}
else {
if (remainder === 0) {
return HUNDREDS[hundreds]
}
result = HUNDREDS[hundreds]
}
return result + ' ' + buildOrdinalTensOnes(remainder)
}
// Numbers < 1,000,000
if (n < 1_000_000n) {
const thousands = Number(n / 1000n)
const remainder = Number(n % 1000n)
if (remainder === 0) {
if (thousands <= 9) {
return THOUSANDS_SPECIAL[thousands]
}
return buildScaleSegment(thousands, 'ו') + ' ' + SCALE[1]
}
// Cardinal thousands + ordinal remainder
let result
if (thousands <= 9) {
result = THOUSANDS_SPECIAL[thousands]
}
else {
result = buildScaleSegment(thousands, 'ו') + ' ' + SCALE[1]
}
if (remainder < 100) {
return result + ' ' + buildOrdinalTensOnes(remainder)
}
const remHundreds = Math.trunc(remainder / 100)
const remTensOnes = remainder % 100
if (remTensOnes === 0) {
return result + ' ' + HUNDREDS[remHundreds]
}
return result + ' ' + HUNDREDS[remHundreds] + ' ' + buildOrdinalTensOnes(remTensOnes)
}
// Numbers >= 1,000,000
const millions = Number(n / 1_000_000n)
const remainder = n % 1_000_000n
if (remainder === 0n) {
if (millions === 1) {
return SCALE[2]
}
return buildScaleSegment(millions, 'ו') + ' ' + SCALE_PLURAL[2]
}
// Cardinal millions + ordinal remainder
let result
if (millions === 1) {
result = SCALE[2]
}
else {
result = buildScaleSegment(millions, 'ו') + ' ' + SCALE_PLURAL[2]
}
return result + ' ' + integerToOrdinal(remainder)
}
/**
* Converts a numeric value to Hebrew ordinal words.
* @param {number | string | bigint} value - The numeric value to convert
* @returns {string} The ordinal in Hebrew words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a positive integer
* @example
* toOrdinal(1) // 'ראשון'
* toOrdinal(21) // 'עשרים וראשון'
*/
function toOrdinal(value) {
const n = parseOrdinalValue(value)
checkMax(n, ordinalMax)
return integerToOrdinal(n)
}
// ============================================================================
// Currency Functions
// ============================================================================
/**
* Builds segment word for currency (masculine forms).
* @param {number} n - Segment value (0-999)
* @returns {string} Hebrew words for the segment (masculine form)
*/
function buildCurrencySegment(n) {
if (n === 0) return ''
const ones = n % 10
const tens = Math.trunc(n / 10) % 10
const hundreds = Math.trunc(n / 100)
let result = ''
// Hundreds
if (hundreds > 0) {
result = HUNDREDS[hundreds]
}
// Tens and ones
if (tens === 1) {
// Teens (10-19)
const teenWord = TEENS[ones]
if (result) {
result += ' ' + teenWord
}
else {
result = teenWord
}
}
else {
// Tens (20-90)
if (tens >= 2) {
if (result) {
result += ' ' + TENS[tens]
}
else {
result = TENS[tens]
}
}
// Ones - masculine form for currency
if (ones > 0) {
if (result) {
result += ' ו' + ONES_MASC[ones]
}
else {
result = ONES_MASC[ones]
}
}
}
return result
}
/**
* Converts a non-negative integer to Hebrew currency words (masculine).
* @param {bigint} n - Non-negative integer to convert
* @returns {string} Hebrew words (masculine form)
*/
function integerToCurrencyWords(n) {
if (n === 0n) return ZERO
// Fast path: numbers < 1000
if (n < 1000n) {
return buildCurrencySegment(Number(n))
}
// Use general integerToWords but with masculine forms embedded in segment builder
// For simplicity, use the cardinal form which defaults to feminine
// However, for currency we need masculine - this is a simplification
return integerToWords(n, 'ו')
}
/**
* Converts a numeric value to Hebrew New Israeli Shekel currency words.
* @param {number | string | bigint} value - The numeric value to convert
* @returns {string} The currency in Hebrew 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)
}
// Shekels (masculine)
if (dollars > 0n || cents === 0n) {
if (dollars === 1n) {
parts.push(SHEKEL_SINGULAR + ' ' + ONES_MASC[1])
}
else if (dollars === 2n) {
parts.push(ONES_MASC[2] + ' ' + SHEKEL_PLURAL)
}
else {
const shekelWord = integerToCurrencyWords(dollars)
const shekelForm = SHEKEL_PLURAL
parts.push(shekelWord + ' ' + shekelForm)
}
}
// Agorot (feminine)
if (cents > 0n) {
const centNum = Number(cents)
if (centNum === 1) {
parts.push(AGORA_SINGULAR + ' ' + ONES[1])
}
else {
const centWord = integerToWords(cents, 'ו')
parts.push(centWord + ' ' + AGORA_PLURAL)
}
}
return parts.join(' ')
}
// ============================================================================
// Exports
// ============================================================================
export { toCardinal, toOrdinal, toCurrency }