Project 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.

4 modules · 2 recorded sessionstext-menu UI in the terminalupdated 2026-10-03

Every screen below was recorded under CPython. When this page was built, the EML interpreter replayed each session from the same input and printed the same bytes.

About

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).

Recorded sessions

What the screen shows while someone uses the program. Each typed line appears after its prompt, the way a terminal shows it.

bad-input

interpreter: byte-equal

== 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.
What was typed (30 lines)
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

basic

interpreter: byte-equal

== 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.
What was typed (28 lines)
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

Modules

The program as written, entry module first. Each module transpiles to its own Python file, which is what eml project run executes.

main.eml(entry)

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 (main.py)
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 (bases.py)
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 (roman.py)
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 (text.py)
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

Built on these corpus cases