<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P021-base-converter/ | updated: 2026-10-03 -->

# P021 Base converter

Convert whole numbers of any length between bases 2 to 36 by long division on their digits, show a number in bases 2, 8, 10 and 16 at once, and turn numbers into Roman numerals and back - refusing a numeral that is not written the standard way and saying how it should be - from a text menu.

EML-P project `projects/base-converter` in the EML language repo: 4 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/base-converter` runs it and `eml project verify projects/base-converter` replays every session under CPython (two hash seeds) and in the interpreter; the site build replays every session in the interpreter again and publishes a session only if its screen matches.

Built on verified corpus cases: base-converter (https://efficientnewlanguage.org/cases/014-base-converter/), base-conversion-roundtrip (https://efficientnewlanguage.org/cases/232-base-conversion-roundtrip/), roman-numeral-converter (https://efficientnewlanguage.org/cases/092-roman-numeral-converter/), roman-to-integer (https://efficientnewlanguage.org/cases/103-roman-to-integer/).

## Sessions

### bad-input - interpreter: byte-equal to the golden

Input:

```text
9
1
1
37
x
8
129
8.5
--5
-

1
2
-0
10
3
0
4000
abc
2026
4
IIII
IC
VV
MMMM
ABC
IV
4

5
```

Screen:

```text

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 9
Pick a number from 1 to 5.

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 1
from base (2-36)> 1
A base is a whole number from 2 to 36, or nothing to cancel.
from base (2-36)> 37
A base is a whole number from 2 to 36, or nothing to cancel.
from base (2-36)> x
A base is a whole number from 2 to 36, or nothing to cancel.
from base (2-36)> 8
number> 129
9 is not a digit in base 8.
number> 8.5
8 is not a digit in base 8.
number> --5
- is not a digit; digits are 0-9, then A-Z for 10-35.
number> -
Type some digits.
number> 
Cancelled.

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 1
from base (2-36)> 2
number> -0
to base (2-36)> 10
0 (base 2) = 0 (base 10)

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 3
number (1-3999)> 0
Type a whole number from 1 to 3999, or nothing to cancel.
number (1-3999)> 4000
Type a whole number from 1 to 3999, or nothing to cancel.
number (1-3999)> abc
Type a whole number from 1 to 3999, or nothing to cancel.
number (1-3999)> 2026
2026 = MMXXVI

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 4
numeral> IIII
IIII is not written the standard way; 4 is IV.
numeral> IC
IC is not written the standard way; 99 is XCIX.
numeral> VV
VV is not written the standard way; 10 is X.
numeral> MMMM
MMMM is not a numeral from I to MMMCMXCIX.
numeral> ABC
Roman numerals use only I, V, X, L, C, D and M.
numeral> IV
IV = 4

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 4
numeral> 
Cancelled.

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 5
Bye.
```

### basic - interpreter: byte-equal to the golden

Input:

```text
1
10
255
16
1
16
ff
2
1
10
123456789012345678901234567890
36
1
10
-42
2
2
10
2026
3
1994
3
3999
4
mcmxciv
4
XLII
5
```

Screen:

```text

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 1
from base (2-36)> 10
number> 255
to base (2-36)> 16
255 (base 10) = FF (base 16)

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 1
from base (2-36)> 16
number> ff
to base (2-36)> 2
FF (base 16) = 11111111 (base 2)

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 1
from base (2-36)> 10
number> 123456789012345678901234567890
to base (2-36)> 36
123456789012345678901234567890 (base 10) = BYW97UM9S91DLZ68TSI (base 36)

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 1
from base (2-36)> 10
number> -42
to base (2-36)> 2
-42 (base 10) = -101010 (base 2)

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 2
from base (2-36)> 10
number> 2026
The number 2026 (base 10) is:
  base 2   11111101010
  base 8   3752
  base 10  2026
  base 16  7EA

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 3
number (1-3999)> 1994
1994 = MCMXCIV

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 3
number (1-3999)> 3999
3999 = MMMCMXCIX

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 4
numeral> mcmxciv
MCMXCIV = 1994

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 4
numeral> XLII
XLII = 42

== Base converter ==
1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit
choice> 5
Bye.
```

## Modules

### main.eml

```eml
# P021 base converter: whole numbers between bases 2 to 36, a number in the
# common bases at once, and Roman numerals both ways.
import bases
import roman
import text

60 => longest

def ask_base(prompt):
    # A base from 2 to 36, asked again until one is typed; -1 cancels.
    while True:
        text.trim(input(prompt)) => answer
        if answer == "":
            return 0 - 1
        text.number(answer) => b
        if b >= 2 and b <= 36:
            return b
        "A base is a whole number from 2 to 36, or nothing to cancel." ^0

def parse_number(s, base):
    # [negative, digits, ""] for a whole number written in base (a minus sign
    # in front allowed), or [False, [], what is wrong].
    text.upper(s) => s
    False => negative
    if len(s) > 0 and s[0] == "-":
        True => negative
        s[1:len(s)] => s
    if s == "":
        return [False, [], "Type some digits."]
    if len(s) > longest:
        return [False, [], "At most " + str(longest) + " digits."]
    [] => digits
    for c in s:
        bases.digit_value(c) => v
        if v == 0 - 1:
            return [False, [], c + " is not a digit; digits are 0-9, then A-Z for 10-35."]
        if v >= base:
            return [False, [], c + " is not a digit in base " + str(base) + "."]
        digits + [v] => digits
    bases.strip(digits) => digits
    if digits == [0]:
        False => negative
    return [negative, digits, ""]

def ask_number(base):
    # A number in base, asked again until one is typed; None cancels.
    while True:
        text.trim(input("number> ")) => answer
        if answer == "":
            return None
        parse_number(answer, base) => r
        if r[2] == "":
            return r
        r[2] ^0

def shown(n, digits):
    # A number with its sign, from its sign and digit values.
    if n[0]:
        return "-" + bases.written(digits)
    return bases.written(digits)

True => running
while running:
    "" ^0
    "== Base converter ==" ^0
    "1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit" ^0
    text.trim(input("choice> ")) => choice
    if choice == "1" or choice == "2":
        ask_base("from base (2-36)> ") => base
        None => n
        if base != 0 - 1:
            ask_number(base) => n
        0 - 1 => target
        if n != None and choice == "1":
            ask_base("to base (2-36)> ") => target
        if n == None or (choice == "1" and target == 0 - 1):
            "Cancelled." ^0
        elif choice == "1":
            (shown(n, n[1]) + " (base " + str(base) + ") = " + shown(n, bases.convert(n[1], base, target)) + " (base " + str(target) + ")") ^0
        else:
            ("The number " + shown(n, n[1]) + " (base " + str(base) + ") is:") ^0
            for b in [2, 8, 10, 16]:
                ("  " + ("%-9s" % ("base " + str(b))) + shown(n, bases.convert(n[1], base, b))) ^0
    elif choice == "3":
        while True:
            text.trim(input("number (1-3999)> ")) => answer
            text.number(answer) => v
            if answer == "" or (v >= 1 and v <= 3999):
                break
            "Type a whole number from 1 to 3999, or nothing to cancel." ^0
        if answer == "":
            "Cancelled." ^0
        else:
            (str(v) + " = " + roman.to_roman(v)) ^0
    elif choice == "4":
        while True:
            text.upper(text.trim(input("numeral> "))) => answer
            if answer == "":
                break
            roman.from_roman(answer) => r
            if r[1] == "":
                break
            r[1] ^0
        if answer == "":
            "Cancelled." ^0
        else:
            (answer + " = " + str(r[0])) ^0
    elif choice == "5":
        False => running
    else:
        "Pick a number from 1 to 5." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import bases
import roman
import text
longest = 60

def ask_base(prompt):
    while True:
        answer = text.trim(input(prompt))
        if answer == "":
            return 0 - 1
        b = text.number(answer)
        if b >= 2 and b <= 36:
            return b
        print("A base is a whole number from 2 to 36, or nothing to cancel.")

def parse_number(s, base):
    s = text.upper(s)
    negative = False
    if len(s) > 0 and s[0] == "-":
        negative = True
        s = s[1:len(s)]
    if s == "":
        return [False, [], "Type some digits."]
    if len(s) > longest:
        return [False, [], "At most " + str(longest) + " digits."]
    digits = []
    for c in s:
        v = bases.digit_value(c)
        if v == 0 - 1:
            return [False, [], c + " is not a digit; digits are 0-9, then A-Z for 10-35."]
        if v >= base:
            return [False, [], c + " is not a digit in base " + str(base) + "."]
        digits = digits + [v]
    digits = bases.strip(digits)
    if digits == [0]:
        negative = False
    return [negative, digits, ""]

def ask_number(base):
    while True:
        answer = text.trim(input("number> "))
        if answer == "":
            return None
        r = parse_number(answer, base)
        if r[2] == "":
            return r
        print(r[2])

def shown(n, digits):
    if n[0]:
        return "-" + bases.written(digits)
    return bases.written(digits)

running = True
while running:
    print("")
    print("== Base converter ==")
    print("1) convert a number  2) in bases 2, 8, 10, 16  3) to Roman  4) from Roman  5) quit")
    choice = text.trim(input("choice> "))
    if choice == "1" or choice == "2":
        base = ask_base("from base (2-36)> ")
        n = None
        if base != 0 - 1:
            n = ask_number(base)
        target = 0 - 1
        if n != None and choice == "1":
            target = ask_base("to base (2-36)> ")
        if n == None or choice == "1" and target == 0 - 1:
            print("Cancelled.")
        elif choice == "1":
            print(shown(n, n[1]) + " (base " + str(base) + ") = " + shown(n, bases.convert(n[1], base, target)) + " (base " + str(target) + ")")
        else:
            print("The number " + shown(n, n[1]) + " (base " + str(base) + ") is:")
            for b in [2, 8, 10, 16]:
                print("  " + "%-9s" % ("base " + str(b)) + shown(n, bases.convert(n[1], base, b)))
    elif choice == "3":
        while True:
            answer = text.trim(input("number (1-3999)> "))
            v = text.number(answer)
            if answer == "" or v >= 1 and v <= 3999:
                break
            print("Type a whole number from 1 to 3999, or nothing to cancel.")
        if answer == "":
            print("Cancelled.")
        else:
            print(str(v) + " = " + roman.to_roman(v))
    elif choice == "4":
        while True:
            answer = text.upper(text.trim(input("numeral> ")))
            if answer == "":
                break
            r = roman.from_roman(answer)
            if r[1] == "":
                break
            print(r[1])
        if answer == "":
            print("Cancelled.")
        else:
            print(answer + " = " + str(r[0]))
    elif choice == "5":
        running = False
    else:
        print("Pick a number from 1 to 5.")
print("Bye.")
```

### bases.eml

```eml
# P021 base converter - whole numbers between bases 2 to 36. A number is kept
# as its list of digit values, most significant first, and converted by long
# division done by hand: divide the whole list by the new base, keep the
# remainder as the next digit, and repeat until nothing is left. Every step
# uses small numbers only, so numbers of any length come out exact.

"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" => digit_chars

def digit_value(c):
    # The value of digit c (0-9, A-Z), or -1.
    for i in [0:35]:
        if digit_chars[i] == c:
            return i
    return 0 - 1

def divide(digits, base, by):
    # [quotient digits, remainder] of the number digits (in base) divided by
    # the small number by; the quotient has no leading zeros.
    [] => q
    0 => carry
    for d in digits:
        carry * base + d => current
        int((current - current % by) / by) => part
        current % by => carry
        if len(q) > 0 or part > 0:
            q + [part] => q
    return [q, carry]

def convert(digits, base, new_base):
    # The digits of the same number in new_base.
    if len(digits) == 0:
        return [0]
    digits => rest
    [] => out
    while len(rest) > 0:
        divide(rest, base, new_base) => r
        [r[1]] + out => out
        r[0] => rest
    return out

def strip(digits):
    # digits without leading zeros (but at least one digit).
    0 => i
    while i < len(digits) - 1 and digits[i] == 0:
        i + 1 => i
    return digits[i:len(digits)]

def written(digits):
    "" => out
    for d in digits:
        out + digit_chars[d] => out
    return out
```

Python projection of bases.eml:

```python
digit_chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"

def digit_value(c):
    for i in range(0, 36):
        if digit_chars[i] == c:
            return i
    return 0 - 1

def divide(digits, base, by):
    q = []
    carry = 0
    for d in digits:
        current = carry * base + d
        part = int((current - current % by) / by)
        carry = current % by
        if len(q) > 0 or part > 0:
            q = q + [part]
    return [q, carry]

def convert(digits, base, new_base):
    if len(digits) == 0:
        return [0]
    rest = digits
    out = []
    while len(rest) > 0:
        r = divide(rest, base, new_base)
        out = [r[1]] + out
        rest = r[0]
    return out

def strip(digits):
    i = 0
    while i < len(digits) - 1 and digits[i] == 0:
        i = i + 1
    return digits[i:len(digits)]

def written(digits):
    out = ""
    for d in digits:
        out = out + digit_chars[d]
    return out
```

### roman.eml

```eml
# P021 base converter - Roman numerals from 1 to 3999. Writing one walks a
# table from the largest value down, the subtractive pairs (CM, CD, XC, XL,
# IX, IV) being entries of the table. Reading one adds each symbol's value,
# except that a symbol smaller than the one after it is subtracted. Reading
# alone would accept IIII or IC, so a numeral counts only if writing its value
# again gives exactly the same letters.

[[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
 [50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"]] => table

def to_roman(n):
    # The Roman numeral for 1 <= n <= 3999.
    "" => out
    for row in table:
        while n >= row[0]:
            out + row[1] => out
            n - row[0] => n
    return out

def symbol_value(c):
    for row in table:
        if row[1] == c:
            return row[0]
    return 0

def read_value(s):
    # The value of s by the add-or-subtract rule, or -1 if a letter is not a
    # Roman symbol.
    0 => total
    for i in [0:len(s) - 1]:
        symbol_value(s[i]) => v
        if v == 0:
            return 0 - 1
        if i + 1 < len(s) and v < symbol_value(s[i + 1]):
            total - v => total
        else:
            total + v => total
    return total

def from_roman(s):
    # [value, ""] for a numeral written the standard way, otherwise
    # [0, what is wrong].
    read_value(s) => v
    if v == 0 - 1:
        return [0, "Roman numerals use only I, V, X, L, C, D and M."]
    if v < 1 or v > 3999:
        return [0, s + " is not a numeral from I to MMMCMXCIX."]
    if to_roman(v) != s:
        return [0, s + " is not written the standard way; " + str(v) + " is " + to_roman(v) + "."]
    return [v, ""]
```

Python projection of roman.eml:

```python
table = [[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"], [50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"]]

def to_roman(n):
    out = ""
    for row in table:
        while n >= row[0]:
            out = out + row[1]
            n = n - row[0]
    return out

def symbol_value(c):
    for row in table:
        if row[1] == c:
            return row[0]
    return 0

def read_value(s):
    total = 0
    for i in range(0, len(s)):
        v = symbol_value(s[i])
        if v == 0:
            return 0 - 1
        if i + 1 < len(s) and v < symbol_value(s[i + 1]):
            total = total - v
        else:
            total = total + v
    return total

def from_roman(s):
    v = read_value(s)
    if v == 0 - 1:
        return [0, "Roman numerals use only I, V, X, L, C, D and M."]
    if v < 1 or v > 3999:
        return [0, s + " is not a numeral from I to MMMCMXCIX."]
    if to_roman(v) != s:
        return [0, s + " is not written the standard way; " + str(v) + " is " + to_roman(v) + "."]
    return [v, ""]
```

### text.eml

```eml
# P021 base converter - reading what is typed. The interpreter that checks
# every session does not run string methods yet, so the text handling is
# written out here.

def trim(s):
    # s without the spaces at either end.
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def upper(s):
    # s with a-z turned into A-Z; everything else as it was.
    "abcdefghijklmnopqrstuvwxyz" => lower_letters
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ" => upper_letters
    "" => out
    for c in s:
        c => d
        for i in [0:25]:
            if lower_letters[i] == c:
                upper_letters[i] => d
        out + d => out
    return out

def number(s):
    # The value of s if it is digits only (at least one), otherwise -1.
    if s == "":
        return 0 - 1
    0 => n
    for c in s:
        if not (c in "0123456789"):
            return 0 - 1
        n * 10 + int(c) => n
    return n
```

Python projection of text.eml:

```python
def trim(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def upper(s):
    lower_letters = "abcdefghijklmnopqrstuvwxyz"
    upper_letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    out = ""
    for c in s:
        d = c
        for i in range(0, 26):
            if lower_letters[i] == c:
                d = upper_letters[i]
        out = out + d
    return out

def number(s):
    if s == "":
        return 0 - 1
    n = 0
    for c in s:
        if not c in "0123456789":
            return 0 - 1
        n = n * 10 + int(c)
    return n
```

## README

# P021 - Base converter

Converts whole numbers between any two bases from 2 to 36, shows a number in
bases 2, 8, 10 and 16 at once, and turns numbers into Roman numerals and
back. A text menu.

- `main.eml` - the menu and its questions, with their checks
- `bases.eml` - digits and long division by hand
- `roman.eml` - writing and reading Roman numerals, and the standard-form
  check
- `text.eml` - trimming, capitals and whole numbers

A number is kept as its list of digit values, most significant first, and
converted by long division done by hand: divide the whole list by the new
base, keep the remainder as the next digit, and repeat until nothing is left.
Every step uses small numbers only, so a number of up to 60 digits comes out
exact - 123456789012345678901234567890 is BYW97UM9S91DLZ68TSI in base 36.
Digits are 0-9 and then A-Z for 10 to 35, in either case; a minus sign in
front is kept.

Roman numerals run from I to MMMCMXCIX (1 to 3999). Writing one walks a table
from the largest value down, with the subtractive pairs (CM, CD, XC, XL, IX,
IV) as entries of the table. Reading one adds each symbol's value, except
that a symbol smaller than the one after it is subtracted. That rule alone
would also accept IIII, IC or VV, so a numeral counts only if writing its
value again gives exactly the same letters - and when it does not, the screen
says how the value is written.

What is checked: a base is a whole number from 2 to 36; a number has only
digits of its base (at most 60, with a minus sign allowed); a number for
Roman numerals is from 1 to 3999; a numeral uses only I, V, X, L, C, D and M,
stays in range and is written the standard way. Anything else asks again;
nothing cancels.

Sessions: `sessions/basic.in` converts 255 to base 16, FF to base 2, a
30-digit number to base 36 and -42 to base 2, shows 2026 in the four common
bases, writes 1994 and 3999 as Roman numerals and reads mcmxciv and XLII;
`sessions/bad-input.in` types bases out of range or not numbers, digits that
do not belong to the base, a point, a doubled or lone minus sign, -0, numbers
outside 1 to 3999, and numerals written the wrong way (IIII, IC, VV), too
large (MMMM) or with other letters.

Built on the verified corpus cases `base-converter` (digits by repeated
division and remainder), `base-conversion-roundtrip` (writing a number in a
base and reading it back), `roman-numeral-converter` (the table walk) and
`roman-to-integer` (the add-or-subtract reading rule).
