n2words
Version:
Convert numbers to words in 70+ languages with zero dependencies. Supports BigInt, decimals, and browser/Node.js environments.
401 lines (338 loc) • 12 kB
JavaScript
/**
* Vietnamese (Vietnam) language converter
*
* CLDR: vi-VN | Vietnamese as used in Vietnam
*
* Vietnamese-specific rules:
* - Special pronunciation: "lăm" for 5 in tens position, "mốt" for final 1
* - "Lẻ" (odd/extra) marker when tens place is zero after hundreds/scales
* - Short scale system with Vietnamese words (nghìn, triệu, tỷ)
*/
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)
// ============================================================================
// Base vocabulary for building lookup tables
const ONES = ['không', 'một', 'hai', 'ba', 'bốn', 'năm', 'sáu', 'bảy', 'tám', 'chín']
// Scale words indexed by scale level (0 = units, 1 = thousands, etc.)
// Vietnamese composes large numbers by cycling nghìn/triệu/tỷ and appending
// another tỷ every three groups. The recursion past tỷ tỷ (10^18) is rarely used
// and not firmly fixed, so the table stops at that scale word rather than invent
// more; the ceiling then falls where the next one would be needed (see below).
const SCALES = [
'', 'nghìn', 'triệu', 'tỷ', 'nghìn tỷ', 'triệu tỷ', 'tỷ tỷ',
]
// 3-digit grouping with SCALES[0] = '' (units); the highest scale word is
// tỷ tỷ at index 6 (10^18), so values from 10^(3 * SCALES.length) up have no scale word.
export const cardinalMax = western(SCALES.length - 1)
export const ordinalMax = western(SCALES.length - 1)
export const currencyMax = western(SCALES.length - 1)
const HUNDRED = 'trăm'
const ZERO = 'không'
const NEGATIVE = 'âm'
const DECIMAL_SEP = 'phẩy'
const LE = 'lẻ' // "odd/extra" marker for gaps
// ============================================================================
// Ordinal Vocabulary
// ============================================================================
const ORDINAL_PREFIX = 'thứ'
// First is special: "thứ nhất" (not "thứ một")
const ORDINAL_ONE = 'nhất'
// ============================================================================
// Currency Vocabulary (Vietnamese Dong)
// ============================================================================
const DONG = 'đồng'
// Special forms
const MOT_FINAL = 'mốt' // 1 in tens position (21, 31, etc.)
const LAM = 'lăm' // 5 in tens position (25, 35, etc.)
// ============================================================================
// Segment Building
// ============================================================================
/**
* Builds word for 0-99 with special forms (mốt, lăm).
* @param {number} n - Integer in range 0-99
* @returns {string} Vietnamese words
*/
function buildBelowHundred(n) {
if (n === 0) return ONES[0]
if (n < 10) return ONES[n]
// Teens: 10-19
if (n < 20) {
const ones = n - 10
if (ones === 0) return 'mười'
if (ones === 5) return 'mười lăm'
return 'mười ' + ONES[ones]
}
// 20-99
const ones = n % 10
const tens = Math.trunc(n / 10)
const tensWord = ONES[tens] + ' mươi'
if (ones === 0) return tensWord
if (ones === 1) return tensWord + ' ' + MOT_FINAL
if (ones === 5) return tensWord + ' ' + LAM
return tensWord + ' ' + ONES[ones]
}
/**
* Builds segment word for 0-999.
* @param {number} n - Integer in range 0-999
* @returns {string} Vietnamese words
*/
function buildSegment(n) {
if (n === 0) return ''
const hundreds = Math.trunc(n / 100)
const remainder = n % 100
let result = ''
if (hundreds > 0) {
result = ONES[hundreds] + ' ' + HUNDRED
}
if (remainder > 0) {
if (remainder < 10) {
// Single digit after hundreds needs "lẻ"
if (result) {
result += ' ' + LE + ' '
// Use "năm" not "lăm" after lẻ
result += remainder === 5 ? 'năm' : ONES[remainder]
}
else {
result = ONES[remainder]
}
}
else {
// 10-99 after hundreds
if (result) result += ' '
result += buildBelowHundred(remainder)
}
}
return result
}
/**
* Builds "lẻ" prefixed word for small remainders (1-99) after scale words.
* @param {number} n - Integer in range 0-99
* @returns {string} Vietnamese words
*/
function buildLeSegment(n) {
if (n === 0) return ''
if (n < 10) {
// Use "năm" not "lăm" after lẻ
return LE + ' ' + (n === 5 ? 'năm' : ONES[n])
}
return LE + ' ' + buildBelowHundred(n)
}
// ============================================================================
// Conversion Functions
// ============================================================================
/**
* Converts a non-negative integer to Vietnamese words.
* @param {bigint} n - Non-negative integer to convert
* @returns {string} Vietnamese words
*/
function integerToWords(n) {
if (n === 0n) return ZERO
// Fast path: numbers < 100
if (n < 100n) {
return buildBelowHundred(Number(n))
}
// Fast path: numbers < 1000
if (n < 1000n) {
return buildSegment(Number(n))
}
// Fast path: numbers < 1,000,000 (thousands)
if (n < 1_000_000n) {
const thousands = Number(n / 1000n)
const remainder = Number(n % 1000n)
const thousandsWords = buildSegment(thousands) + ' ' + SCALES[1]
if (remainder === 0) {
return thousandsWords
}
// Check if remainder needs "lẻ" marker (< 100)
if (remainder < 100) {
return thousandsWords + ' ' + buildLeSegment(remainder)
}
return thousandsWords + ' ' + buildSegment(remainder)
}
// 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} Vietnamese words
*/
function buildLargeNumberWords(n) {
const numStr = n.toString()
const len = numStr.length
// Build segments of 3 digits from right to left
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) {
const words = buildSegment(segment)
if (words) {
if (scaleIndex > 0) {
parts.push(words + ' ' + SCALES[scaleIndex])
}
else {
parts.push(words)
}
}
}
scaleIndex--
}
// Join with "lẻ" logic for small remainders
const partsLen = parts.length
if (partsLen === 0) return ZERO
if (partsLen === 1) return parts[0]
// Check if final segment needs "lẻ" marker (remainder <= 99 after scale word)
const lastSegment = segments[segments.length - 1]
if (lastSegment > 0 && lastSegment <= 99) {
// Last segment is small (no hundreds), needs "lẻ" after scale word
let result = parts[0]
for (let i = 1; i < partsLen - 1; i++) {
result += ' ' + parts[i]
}
return result + ' ' + buildLeSegment(lastSegment)
}
// Join with spaces
let result = parts[0]
for (let i = 1; i < partsLen; i++) {
result += ' ' + parts[i]
}
return result
}
/**
* Converts decimal digits to Vietnamese words.
* @param {string} decimalPart - Decimal digits (without the point)
* @returns {string} Vietnamese 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 Vietnamese 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 Vietnamese words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a valid number format
* @example
* toCardinal(42) // 'bốn mươi hai'
* toCardinal(101) // 'một trăm lẻ một'
* toCardinal(1000000) // 'một triệu'
*/
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: toOrdinal(value)
// ============================================================================
/**
* Converts a non-negative integer to Vietnamese ordinal words.
*
* Vietnamese ordinals use "thứ" prefix + cardinal number.
* Special case: "thứ nhất" for 1st (not "thứ một").
* @param {bigint} n - Positive integer to convert
* @returns {string} Vietnamese ordinal words
*/
function integerToOrdinal(n) {
// Special case: 1st is "thứ nhất"
if (n === 1n) {
return ORDINAL_PREFIX + ' ' + ORDINAL_ONE
}
// All others: "thứ" + cardinal
return ORDINAL_PREFIX + ' ' + integerToWords(n)
}
/**
* Converts a numeric value to Vietnamese ordinal words.
* @param {number | string | bigint} value - The numeric value to convert (positive integer)
* @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) // 'thứ nhất'
* toOrdinal(2) // 'thứ hai'
* toOrdinal(10) // 'thứ mười'
*/
function toOrdinal(value) {
const integerPart = parseOrdinalValue(value)
// Ordinals are built from the cardinal speller, so they share its ceiling.
checkMax(integerPart, ordinalMax)
return integerToOrdinal(integerPart)
}
// ============================================================================
// CURRENCY: toCurrency(value)
// ============================================================================
/**
* Converts a numeric value to Vietnamese currency words (Dong).
*
* Vietnamese Dong has no subunit in modern usage (xu are historical).
* Amounts are rounded to whole đồng.
* @param {number | string | bigint} value - The currency amount to convert
* @returns {string} The amount in Vietnamese 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) // 'bốn mươi hai đồng'
* toCurrency(1000) // 'một nghìn đồng'
* toCurrency(-5) // 'âm năm đồng'
*/
function toCurrency(value) {
const { isNegative, dollars: dong } = parseCurrencyValue(value)
checkMax(dong, currencyMax)
let result = ''
if (isNegative) {
result = NEGATIVE + ' '
}
result += integerToWords(dong)
result += ' ' + DONG
return result
}
// ============================================================================
// Public API
// ============================================================================
export { toCardinal, toOrdinal, toCurrency }