-
Notifications
You must be signed in to change notification settings - Fork 4
/
romans.js
144 lines (129 loc) · 4.1 KB
/
romans.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
/* 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
}