UNPKG

romans

Version:

A small, no-dependency lib for converting to and from roman numerals

144 lines (129 loc) 4.2 kB
/* eslint-disable function-call-argument-newline */ /** * A mapping of Roman numeral symbols to their decimal values. * Values are ordered from highest to lowest to ensure proper conversion. * @readonly * @type {Readonly<Record<import('./romans').RomanChar, number>>} */ const roman_map = { M: 1000, CM: 900, D: 500, CD: 400, C: 100, XC: 90, L: 50, XL: 40, X: 10, IX: 9, V: 5, IV: 4, I: 1 } /** * Array of valid Roman numeral symbols and combinations. * @type {ReadonlyArray<import('./romans').RomanChar>} */ const allChars = Object.keys(roman_map) /** * Array of decimal values corresponding to Roman numerals. * @type {ReadonlyArray<number>} */ const allNumerals = Object.values(roman_map) /** * Regular expression pattern for validating Roman numerals. * Ensures proper character combinations and repetition rules. * @type {RegExp} * @readonly */ const romanPattern = /^(M{1,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})|M{0,4}(CM|C?D|D?C{1,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})|M{0,4}(CM|CD|D?C{0,3})(XC|X?L|L?X{1,3})(IX|IV|V?I{0,3})|M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|I?V|V?I{1,3}))$/ /** * Converts a decimal number to its Roman numeral representation. * @param {number} decimal - The decimal number to convert (must be a positive integer < 4000) * @returns {import('./romans').RomanNumeral} The Roman numeral representation * @throws {Error} When input is not a positive integer * @throws {Error} When input is greater than or equal to 4000 * @example * romanize(1994) // Returns 'MCMXCIV' * romanize(2023) // Returns 'MMXXIII' * romanize(0) // Throws Error: requires an unsigned integer * romanize(4000) // Throws Error: requires max value of less than 4000 */ const romanize = (decimal) => { if ( decimal <= 0 || typeof decimal !== 'number' || Math.floor(decimal) !== decimal ) { throw new Error('requires an unsigned integer') } if (decimal >= 4000) { throw new Error('requires max value of less than 4000') } // If input is padded with zeros, remove them decimal = decimal.toString().replace(/^0+/, '') const result = [] for (let i = 0; i < allChars.length; i++) { const count = Math.floor(decimal / allNumerals[i]) if (count > 0) { result.push(allChars[i].repeat(count)) decimal %= allNumerals[i] } if (decimal === 0) break } return /** @type {import('./romans').RomanNumeral} */ (result.join('')) } /** * Converts a Roman numeral string to its decimal representation. * @param {string} romanStr - The Roman numeral string to convert * @returns {import('./romans').ValidDecimal} The decimal value (1-3999) * @throws {Error} When input contains invalid Roman numeral characters * @throws {Error} When input contains valid characters in an invalid sequence * @example * deromanize('MCMXCIV') // Returns 1994 * deromanize('MMXXIII') // Returns 2023 * deromanize('INVALID') // Throws Error: requires valid roman numeral string * deromanize('IC') // Throws Error: requires valid roman numeral string (invalid sequence) */ const deromanize = (romanStr) => { if (typeof romanStr !== 'string' || !romanPattern.test(romanStr)) { throw new Error('requires valid roman numeral string') } /** @type {Record<string, RegExp>} */ const invalidPatterns = { // IIII or more 'I': /I{4,}/, // VV or more 'V': /V{2,}/, // XXXX or more 'X': /X{4,}/, // LL or more 'L': /L{2,}/, // CCCC or more 'C': /C{4,}/, // DD or more 'D': /D{2,}/, // MMMM or more 'M': /M{4,}/ }; for (const [_, pattern] of Object.entries(invalidPatterns)) { if (pattern.test(romanStr)) { throw new Error('requires valid roman numeral string') } } let result = 0 let prevValue = 0 for (let i = romanStr.length - 1; i >= 0; i--) { const currentValue = roman_map[/** @type {import('./romans').SingleRomanChar} */ (romanStr[i])] result += currentValue < prevValue ? -currentValue : currentValue prevValue = currentValue } return /** @type {import('./romans').ValidDecimal} */ (result) } module.exports = { deromanize, romanize, allChars, allNumerals }