UNPKG

n2words

Version:

Convert numbers to words in 70+ languages with zero dependencies. Supports BigInt, decimals, and browser/Node.js environments.

610 lines (509 loc) 18.9 kB
/** * Polish (Poland) language converter * * CLDR: pl-PL | Polish as used in Poland * * Polish-specific rules: * - Three-form pluralization: 1 = singular, 2-4 = few, 5+ = many * - Gender agreement (masculine/feminine for numbers < 1000) * - Omit "jeden" before scale words (tysiąc, milion, etc.) * - Irregular hundreds: dwieście, trzysta, czterysta, pięćset... * - Long scale with -ard forms: miliard, biliard, tryliard */ 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 (module-level constants) // ============================================================================ const ONES_MASC = ['', 'jeden', 'dwa', 'trzy', 'cztery', 'pięć', 'sześć', 'siedem', 'osiem', 'dziewięć'] const ONES_FEM = ['', 'jedna', 'dwie', 'trzy', 'cztery', 'pięć', 'sześć', 'siedem', 'osiem', 'dziewięć'] const TEENS = ['dziesięć', 'jedenaście', 'dwanaście', 'trzynaście', 'czternaście', 'piętnaście', 'szesnaście', 'siedemnaście', 'osiemnaście', 'dziewiętnaście'] const TENS = ['', '', 'dwadzieścia', 'trzydzieści', 'czterdzieści', 'pięćdziesiąt', 'sześćdziesiąt', 'siedemdziesiąt', 'osiemdziesiąt', 'dziewięćdziesiąt'] // Irregular hundreds const HUNDREDS = ['', 'sto', 'dwieście', 'trzysta', 'czterysta', 'pięćset', 'sześćset', 'siedemset', 'osiemset', 'dziewięćset'] // Scale words: [singular, few (2-4), many (5+)] /** @type {Record<number, string[]>} */ const PLURAL_FORMS = { 1: ['tysiąc', 'tysiące', 'tysięcy'], 2: ['milion', 'miliony', 'milionów'], 3: ['miliard', 'miliardy', 'miliardów'], 4: ['bilion', 'biliony', 'bilionów'], 5: ['biliard', 'biliardy', 'biliardów'], 6: ['trylion', 'tryliony', 'trylionów'], 7: ['tryliard', 'tryliardy', 'tryliardów'], 8: ['kwadrylion', 'kwadryliony', 'kwadrylionów'], 9: ['kwaryliard', 'kwadryliardy', 'kwadryliardów'], 10: ['kwintylion', 'kwintyliony', 'kwintylionów'], } const ZERO = 'zero' const NEGATIVE = 'minus' const DECIMAL_SEP = 'przecinek' // ============================================================================ // Ordinal Vocabulary // ============================================================================ // Polish ordinals (masculine nominative singular) // 1-9: special forms const ORDINAL_ONES = ['', 'pierwszy', 'drugi', 'trzeci', 'czwarty', 'piąty', 'szósty', 'siódmy', 'ósmy', 'dziewiąty'] // Teens ordinals const ORDINAL_TEENS = ['dziesiąty', 'jedenasty', 'dwunasty', 'trzynasty', 'czternasty', 'piętnasty', 'szesnasty', 'siedemnasty', 'osiemnasty', 'dziewiętnasty'] // Tens ordinals (for exact tens) const ORDINAL_TENS = ['', '', 'dwudziesty', 'trzydziesty', 'czterdziesty', 'pięćdziesiąty', 'sześćdziesiąty', 'siedemdziesiąty', 'osiemdziesiąty', 'dziewięćdziesiąty'] // Hundreds ordinals (for exact hundreds) const ORDINAL_HUNDREDS = ['', 'setny', 'dwusetny', 'trzechsetny', 'czterechsetny', 'pięćsetny', 'sześćsetny', 'siedemsetny', 'osiemsetny', 'dziewięćsetny'] // Scale ordinals (1000, million, etc.) const ORDINAL_SCALES = ['tysięczny', 'milionowy', 'miliardowy', 'bilionowy', 'biliardowy', 'trylionowy', 'tryliardowy'] // ============================================================================ // Currency Vocabulary (Polish Złoty) // ============================================================================ // Złoty forms: [singular, few (2-4), many (5+)] const ZLOTY_FORMS = ['złoty', 'złote', 'złotych'] // Grosz forms: [singular, few (2-4), many (5+)] const GROSZ_FORMS = ['grosz', 'grosze', 'groszy'] // ============================================================================ // Segment Building // ============================================================================ /** * Builds segment word for 0-999 (masculine form). * @param {number} n - Segment value * @returns {string} Polish word */ function buildSegment(n) { if (n === 0) return '' const ones = n % 10 const tens = Math.trunc(n / 10) % 10 const hundreds = Math.trunc(n / 100) const parts = [] // Hundreds if (hundreds > 0) { parts.push(HUNDREDS[hundreds]) } // Tens and ones if (tens === 1) { // Teens (10-19) parts.push(TEENS[ones]) } else { if (tens >= 2) { parts.push(TENS[tens]) } if (ones > 0) { parts.push(ONES_MASC[ones]) } } return parts.join(' ') } /** * Builds segment word for 0-999 (feminine form - only differs in ones). * @param {number} n - Segment value * @returns {string} Polish word */ function buildSegmentFeminine(n) { if (n === 0) return '' const ones = n % 10 const tens = Math.trunc(n / 10) % 10 const hundreds = Math.trunc(n / 100) const parts = [] // Hundreds if (hundreds > 0) { parts.push(HUNDREDS[hundreds]) } // Tens and ones - feminine for ones only if (tens === 1) { parts.push(TEENS[ones]) } else { if (tens >= 2) { parts.push(TENS[tens]) } if (ones > 0) { parts.push(ONES_FEM[ones]) } } return parts.join(' ') } // ============================================================================ // Helper Functions // ============================================================================ /** * Polish pluralization: 1 = singular, 2-4 = few, else = many. * Special case: 11-19 always use many form. * @param {bigint} n - Number to pluralize * @param {string[]} forms - [singular, few, many] * @returns {string} Correct plural form */ function pluralize(n, forms) { if (n === 1n) { return forms[0] } const lastDigit = n % 10n const lastTwoDigits = n % 100n // Teens (11-19) always use many form // 2-4 use few form (but not 12-14) if (lastDigit >= 2n && lastDigit <= 4n && (lastTwoDigits < 10n || lastTwoDigits > 20n)) { return forms[1] } return forms[2] } // ============================================================================ // Conversion Functions // ============================================================================ /** * Converts a non-negative integer to Polish words. * @param {bigint} n - Non-negative integer to convert * @param {string} gender - Gender for numbers < 1000 ('masculine' or 'feminine') * @returns {string} Polish words */ function integerToWords(n, gender) { if (n === 0n) return ZERO // Fast path: numbers < 1000 if (n < 1000n) { return gender === 'feminine' ? buildSegmentFeminine(Number(n)) : 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 scaleWord = pluralize(BigInt(thousands), PLURAL_FORMS[1]) let result if (thousands === 1) { // Omit "jeden" before tysiąc result = scaleWord } else { result = buildSegment(thousands) + ' ' + scaleWord } if (remainder > 0) { result += ' ' + buildSegment(remainder) } return result } // For numbers >= 1,000,000, use scale decomposition return buildLargeNumberWords(n) } /** * Builds words for numbers >= 1,000,000. * Uses BigInt division for faster segment extraction. * @param {bigint} n - Number >= 1,000,000 * @returns {string} Polish words */ function buildLargeNumberWords(n) { // Extract segments using BigInt division (faster than string slicing) // Segments stored least-significant first (index 0 = ones, 1 = thousands, etc.) const segmentValues = [] let temp = n while (temp > 0n) { segmentValues.push(temp % 1000n) temp = temp / 1000n } // Build result string directly let result = '' for (let i = segmentValues.length - 1; i >= 0; i--) { const segment = segmentValues[i] if (segment === 0n) continue const segmentWord = buildSegment(Number(segment)) if (result) result += ' ' if (i === 0) { // Units segment result += segmentWord } else { // Scale word needed const forms = PLURAL_FORMS[i] if (forms) { const scaleWord = pluralize(segment, forms) if (segment === 1n) { // Omit "jeden" before scale words result += scaleWord } else { result += segmentWord + ' ' + scaleWord } } } } return result } /** * Converts decimal digits to Polish words. * @param {string} decimalPart - Decimal digits (without the point) * @param {string} gender - Gender for numbers < 1000 ('masculine' or 'feminine') * @returns {string} Polish words for decimal part */ function decimalPartToWords(decimalPart, gender) { 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), gender) } return result } // Supported magnitude ceilings (checked at the public entry points). PLURAL_FORMS // is keyed by scale level (1 = thousands, …), units separate, so cardinal/ // currency reach 10^((maxScaleKey + 1) * 3) = 10^33; the shorter ORDINAL_SCALES // bounds ordinals at 10^((length + 1) * 3) = 10^24. (Past the cardinal ceiling // the fraction would spell silently-wrong, so the decimal is guarded too.) // Derive from the max numeric key (robust to gaps / non-scale keys). const MAX_SCALE_KEY = Math.max(...Object.keys(PLURAL_FORMS).map(Number)) export const cardinalMax = western(MAX_SCALE_KEY) export const ordinalMax = western(ORDINAL_SCALES.length) export const currencyMax = western(MAX_SCALE_KEY) /** * @typedef {object} CardinalOptions * @property {('masculine'|'feminine')} [gender] - Gender for numbers < 1000 */ /** @type {Required<CardinalOptions>} */ export const cardinalDefaults = { gender: 'masculine' } /** @type {{ gender: ReadonlyArray<Required<CardinalOptions>['gender']> }} */ export const cardinalValues = { gender: ['masculine', 'feminine'] } /** * Converts a numeric value to Polish 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 * @param {CardinalOptions} [options] - Conversion options * @returns {string} The number in Polish words * @throws {TypeError} If value is not a valid numeric type * @throws {Error} If value is not a valid number format * @example * toCardinal(1) // 'jeden' * toCardinal(1, { gender: 'feminine' }) // 'jedna' * toCardinal(1000) // 'tysiąc' * toCardinal(2000) // 'dwa tysiące' */ function toCardinal(value, options) { 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) // Apply option defaults const { gender } = resolveOptions(options, cardinalDefaults, cardinalValues) let result = '' if (isNegative) { result = NEGATIVE + ' ' } result += integerToWords(integerPart, gender) if (decimalPart) { result += ' ' + DECIMAL_SEP + ' ' + decimalPartToWords(decimalPart, gender) } return result } // ============================================================================ // ORDINAL: toOrdinal(value) // ============================================================================ /** * Builds ordinal for a 0-99 segment when it's the final (ordinal) part. * Returns ordinal form: "pierwszy", "dwudziesty pierwszy", etc. * @param {number} n - Number 0-99 * @returns {string} Ordinal words */ function buildOrdinalTensOnes(n) { if (n === 0) return '' const onesDigit = n % 10 const tensDigit = Math.trunc(n / 10) if (tensDigit === 0) { // Single digit: pierwszy, drugi, etc. return ORDINAL_ONES[onesDigit] } if (tensDigit === 1) { // Teens: dziesiąty, jedenasty, etc. return ORDINAL_TEENS[onesDigit] } // Tens >= 20 if (onesDigit === 0) { // Round tens: dwudziesty, trzydziesty, etc. return ORDINAL_TENS[tensDigit] } // Compound: dwudziesty pierwszy, trzydziesty drugi, etc. return ORDINAL_TENS[tensDigit] + ' ' + ORDINAL_ONES[onesDigit] } /** * Converts a positive integer to Polish ordinal words (masculine nominative). * * In Polish ordinals, only the LAST component becomes ordinal. * E.g., 121 = "sto dwudziesty pierwszy" (one hundred twenty-first) * @param {bigint} n - Positive integer to convert * @returns {string} Ordinal Polish words */ function integerToOrdinal(n) { // Fast path: numbers < 100 if (n < 100n) { return buildOrdinalTensOnes(Number(n)) } // Fast path: numbers < 1000 if (n < 1000n) { const num = Number(n) const hundredsDigit = Math.trunc(num / 100) const remainder = num % 100 if (remainder === 0) { // Exact hundreds: setny, dwusetny, etc. return ORDINAL_HUNDREDS[hundredsDigit] } // Has remainder: cardinal hundreds + ordinal remainder return HUNDREDS[hundredsDigit] + ' ' + buildOrdinalTensOnes(remainder) } // Fast path: numbers < 1,000,000 if (n < 1_000_000n) { const thousands = Number(n / 1000n) const remainder = Number(n % 1000n) if (remainder === 0) { // Exact thousands: tysięczny, etc. if (thousands === 1) { return ORDINAL_SCALES[0] } // For larger thousands, use cardinal + tysięczny return buildSegment(thousands) + ' ' + ORDINAL_SCALES[0] } // Has remainder: cardinal thousands + ordinal remainder const scaleWord = pluralize(BigInt(thousands), PLURAL_FORMS[1]) const thousandsWord = thousands === 1 ? '' : buildSegment(thousands) + ' ' return thousandsWord + scaleWord + ' ' + integerToOrdinal(BigInt(remainder)) } // For numbers >= 1,000,000, use scale decomposition return buildLargeOrdinal(n) } /** * Builds ordinal words for numbers >= 1,000,000. * All segments except the final one are cardinal; final segment is ordinal. * @param {bigint} n - Number >= 1,000,000 * @returns {string} Ordinal Polish words */ function buildLargeOrdinal(n) { const numStr = n.toString() const len = numStr.length // Extract segments (most-significant first) 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 } // Find the last non-zero segment let lastNonZeroIdx = segments.length - 1 while (lastNonZeroIdx >= 0 && segments[lastNonZeroIdx] === 0) { lastNonZeroIdx-- } const parts = [] let scaleIndex = segments.length - 1 for (let i = 0; i < segments.length; i++) { const segment = segments[i] if (segment !== 0) { const isLastNonZero = (i === lastNonZeroIdx) if (scaleIndex === 0) { // Units position (no scale) if (isLastNonZero) { parts.push(integerToOrdinal(BigInt(segment))) } else { parts.push(buildSegment(segment)) } } else { // Has scale word if (isLastNonZero) { // This scale position is the final ordinal if (segment === 1) { parts.push(ORDINAL_SCALES[scaleIndex - 1]) } else { // Use cardinal segment + ordinal scale parts.push(buildSegment(segment) + ' ' + ORDINAL_SCALES[scaleIndex - 1]) } } else { // Not the final segment: use cardinal const forms = PLURAL_FORMS[scaleIndex] const scaleWord = forms ? pluralize(BigInt(segment), forms) : '' if (segment === 1) { parts.push(scaleWord) } else { parts.push(buildSegment(segment) + ' ' + scaleWord) } } } } scaleIndex-- } return parts.join(' ') } /** * Converts a numeric value to Polish ordinal words (masculine nominative). * @param {number | string | bigint} value - The numeric value to convert (must be a positive integer) * @returns {string} The number as ordinal words (e.g., "pierwszy", "czterdziesty drugi") * @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) // 'pierwszy' * toOrdinal(2) // 'drugi' * toOrdinal(3) // 'trzeci' * toOrdinal(21) // 'dwudziesty pierwszy' * toOrdinal(42) // 'czterdziesty drugi' * toOrdinal(100) // 'setny' * toOrdinal(1000) // 'tysięczny' */ function toOrdinal(value) { const integerPart = parseOrdinalValue(value) checkMax(integerPart, ordinalMax) return integerToOrdinal(integerPart) } // ============================================================================ // CURRENCY: toCurrency(value) // ============================================================================ /** * Converts a numeric value to Polish currency words (Polish Złoty). * @param {number | string | bigint} value - The currency amount to convert * @returns {string} The amount in Polish 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) // 'czterdzieści dwa złote' * toCurrency(1) // 'jeden złoty' * toCurrency(1.50) // 'jeden złoty pięćdziesiąt groszy' * toCurrency(-5) // 'minus pięć złotych' */ function toCurrency(value) { const { isNegative, dollars: zloty, cents: grosze } = parseCurrencyValue(value) checkMax(zloty, currencyMax) let result = '' if (isNegative) { result = NEGATIVE + ' ' } // Złoty part (masculine) if (zloty > 0n || grosze === 0n) { result += integerToWords(zloty, 'masculine') result += ' ' + pluralize(zloty, ZLOTY_FORMS) } // Grosze part (masculine) if (grosze > 0n) { if (zloty > 0n) { result += ' ' } result += integerToWords(grosze, 'masculine') result += ' ' + pluralize(grosze, GROSZ_FORMS) } return result } // ============================================================================ // Public API // ============================================================================ export { toCardinal, toOrdinal, toCurrency }