UNPKG

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
/** * 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 }