<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P077-text-banner/ | updated: 2026-10-14 -->

# P077 Text banner

Big letters five rows high from a built-in font, wrapped to the screen; ordinary text wrapped greedily to a width and lined up left, centred or right; and a frame whose width comes from the longest line, so its edge closes exactly.

EML-P project `projects/text-banner` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/text-banner` runs it and `eml project verify projects/text-banner` 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: text-banner-builder (https://efficientnewlanguage.org/cases/152-text-banner-builder/).

## Sessions

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

Input:

```text
0
x
1
a,b

2

2
abc
9
81
x
20
middle

3
abc
10
5
0
left
2
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

4
```

Screen:

```text
== Text banner ==
Big letters, wrapped and lined-up text, and a frame that fits.

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 0
Pick a number from 1 to 4.

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> x
Pick a number from 1 to 4.

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 1
text for the banner> a,b
The big letters are A to Z, 0 to 9, space and ! ? . -
text for the banner> 
Cancelled.

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 2
text> 
Cancelled.

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 2
text> abc
width (10 to 80)> 9
Type a whole number from 10 to 80.
width (10 to 80)> 81
Type a whole number from 10 to 80.
width (10 to 80)> x
Type a whole number from 10 to 80.
width (10 to 80)> 20
line up (left, centre or right)> middle
Type left, centre or right.
line up (left, centre or right)> 
Cancelled.

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 3
text> abc
width of the text (10 to 70)> 10
padding (0 to 4)> 5
Type a whole number from 0 to 4.
padding (0 to 4)> 0
line up (left, centre or right)> left
+---+
|abc|
+---+

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 2
text> word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word
Up to 200 characters.
text> 
Cancelled.

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 4
Bye.
```

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

Input:

```text
1
Hello EML 2026!
1
eml-p rocks?
2
The quick brown fox jumps over the lazy dog, again and again.
30
left
2
The quick brown fox jumps over the lazy dog, again and again.
20
centre
2
Some words are longer than a line: internationalisation, for one.
15
right
3
A frame as wide as its longest line, so the right edge closes.
24
2
centre
4
```

Screen:

```text
== Text banner ==
Big letters, wrapped and lined-up text, and a frame that fits.

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 1
text for the banner> Hello EML 2026!
#   # ##### #     #      ###        ##### #   # #
#   # #     #     #     #   #       #     ## ## #
##### ####  #     #     #   #       ####  # # # #
#   # #     #     #     #   #       #     #   # #
#   # ##### ##### #####  ###        ##### #   # #####

 ###   ###   ###   ###    #
#   # #  ## #   # #       #
  ##  # # #   ##  ####    #
 #    ##  #  #    #   #
#####  ###  #####  ###    #

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 1
text for the banner> eml-p rocks?
##### #   # #           ####        ####   ###   #### #   #  ####  ###
#     ## ## #           #   #       #   # #   # #     #  #  #     #   #
####  # # # #     ##### ####        ####  #   # #     ###    ###    ##
#     #   # #           #           #  #  #   # #     #  #      #
##### #   # #####       #           #   #  ###   #### #   # ####    #

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 2
text> The quick brown fox jumps over the lazy dog, again and again.
width (10 to 80)> 30
line up (left, centre or right)> left
+----------------------------+
The quick brown fox jumps over
the lazy dog, again and again.
+----------------------------+
(the rule marks the width, 30 columns)

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 2
text> The quick brown fox jumps over the lazy dog, again and again.
width (10 to 80)> 20
line up (left, centre or right)> centre
+------------------+
The quick brown fox
jumps over the lazy
   dog, again and
       again.
+------------------+
(the rule marks the width, 20 columns)

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 2
text> Some words are longer than a line: internationalisation, for one.
width (10 to 80)> 15
line up (left, centre or right)> right
+-------------+
 Some words are
  longer than a
          line:
internationalis
ation, for one.
+-------------+
(the rule marks the width, 15 columns)

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 3
text> A frame as wide as its longest line, so the right edge closes.
width of the text (10 to 70)> 24
padding (0 to 4)> 2
line up (left, centre or right)> centre
+--------------------------+
|  A frame as wide as its  |
|   longest line, so the   |
|    right edge closes.    |
+--------------------------+

1) big letters  2) wrap and line up  3) in a frame  4) quit
choice> 4
Bye.
```

## Modules

### main.eml

```eml
# P077 text banner: big five-row letters, wrapped to the screen; ordinary
# text wrapped to a width and lined up left, centred or right; and a frame
# round it.
import banner

def trim(s):
    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 number(s):
    if len(s) == 0 or len(s) > 2:
        return -1
    0 => n
    for c in s:
        if not (c >= "0" and c <= "9"):
            return -1
        n * 10 + int(c) => n
    return n

def ask(prompt, low, high):
    while True:
        trim(input(prompt + " (" + str(low) + " to " + str(high) + ")> ")) => a
        if a == "":
            return -1
        number(a) => n
        if n >= low and n <= high:
            return n
        ("Type a whole number from " + str(low) + " to " + str(high) + ".") ^0

def ask_text(prompt):
    while True:
        trim(input(prompt)) => s
        if s == "" or len(s) <= 200:
            return s
        "Up to 200 characters." ^0

def big():
    while True:
        ask_text("text for the banner> ") => s
        if s == "":
            "Cancelled." ^0
            return
        if banner.can_draw(s):
            # 13 letters of 5 columns, a space between them, fit in 80.
            banner.wrap(s, 13) => parts
            for k in [0:len(parts) - 1]:
                if k > 0:
                    "" ^0
                for row in banner.big(parts[k]):
                    row ^0
            return
        "The big letters are A to Z, 0 to 9, space and ! ? . -" ^0

def ask_how():
    while True:
        trim(input("line up (left, centre or right)> ")) => h
        if h == "":
            return ""
        if h == "left" or h == "centre" or h == "right":
            return h
        "Type left, centre or right." ^0

def wrapped():
    ask_text("text> ") => s
    if s == "":
        "Cancelled." ^0
        return
    ask("width", 10, 80) => w
    if w == -1:
        "Cancelled." ^0
        return
    ask_how() => how
    if how == "":
        "Cancelled." ^0
        return
    "+" + "-" * (w - 2) + "+" => ruler
    ruler ^0
    for line in banner.wrap(s, w):
        banner.aligned(line, w, how) ^0
    ruler ^0
    ("(the rule marks the width, " + str(w) + " columns)") ^0

def framed():
    ask_text("text> ") => s
    if s == "":
        "Cancelled." ^0
        return
    ask("width of the text", 10, 70) => w
    if w == -1:
        "Cancelled." ^0
        return
    ask("padding", 0, 4) => pad
    if pad == -1:
        "Cancelled." ^0
        return
    ask_how() => how
    if how == "":
        "Cancelled." ^0
        return
    banner.wrap(s, w) => lines
    0 => widest
    for line in lines:
        if len(line) > widest:
            len(line) => widest
    [] => placed
    for line in lines:
        placed + [banner.aligned(line, widest, how)] => placed
    for row in banner.boxed(placed, pad):
        row ^0

"== Text banner ==" ^0
"Big letters, wrapped and lined-up text, and a frame that fits." ^0
True => running
while running:
    "" ^0
    "1) big letters  2) wrap and line up  3) in a frame  4) quit" ^0
    trim(input("choice> ")) => choice
    if choice == "1":
        big()
    elif choice == "2":
        wrapped()
    elif choice == "3":
        framed()
    elif choice == "4":
        False => running
    else:
        "Pick a number from 1 to 4." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import banner

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 number(s):
    if len(s) == 0 or len(s) > 2:
        return -1
    n = 0
    for c in s:
        if not (c >= "0" and c <= "9"):
            return -1
        n = n * 10 + int(c)
    return n

def ask(prompt, low, high):
    while True:
        a = trim(input(prompt + " (" + str(low) + " to " + str(high) + ")> "))
        if a == "":
            return -1
        n = number(a)
        if n >= low and n <= high:
            return n
        print("Type a whole number from " + str(low) + " to " + str(high) + ".")

def ask_text(prompt):
    while True:
        s = trim(input(prompt))
        if s == "" or len(s) <= 200:
            return s
        print("Up to 200 characters.")

def big():
    while True:
        s = ask_text("text for the banner> ")
        if s == "":
            print("Cancelled.")
            return
        if banner.can_draw(s):
            parts = banner.wrap(s, 13)
            for k in range(0, len(parts)):
                if k > 0:
                    print("")
                for row in banner.big(parts[k]):
                    print(row)
            return
        print("The big letters are A to Z, 0 to 9, space and ! ? . -")

def ask_how():
    while True:
        h = trim(input("line up (left, centre or right)> "))
        if h == "":
            return ""
        if h == "left" or h == "centre" or h == "right":
            return h
        print("Type left, centre or right.")

def wrapped():
    s = ask_text("text> ")
    if s == "":
        print("Cancelled.")
        return
    w = ask("width", 10, 80)
    if w == -1:
        print("Cancelled.")
        return
    how = ask_how()
    if how == "":
        print("Cancelled.")
        return
    ruler = "+" + "-" * (w - 2) + "+"
    print(ruler)
    for line in banner.wrap(s, w):
        print(banner.aligned(line, w, how))
    print(ruler)
    print("(the rule marks the width, " + str(w) + " columns)")

def framed():
    s = ask_text("text> ")
    if s == "":
        print("Cancelled.")
        return
    w = ask("width of the text", 10, 70)
    if w == -1:
        print("Cancelled.")
        return
    pad = ask("padding", 0, 4)
    if pad == -1:
        print("Cancelled.")
        return
    how = ask_how()
    if how == "":
        print("Cancelled.")
        return
    lines = banner.wrap(s, w)
    widest = 0
    for line in lines:
        if len(line) > widest:
            widest = len(line)
    placed = []
    for line in lines:
        placed = placed + [banner.aligned(line, widest, how)]
    for row in banner.boxed(placed, pad):
        print(row)

print("== Text banner ==")
print("Big letters, wrapped and lined-up text, and a frame that fits.")
running = True
while running:
    print("")
    print("1) big letters  2) wrap and line up  3) in a frame  4) quit")
    choice = trim(input("choice> "))
    if choice == "1":
        big()
    elif choice == "2":
        wrapped()
    elif choice == "3":
        framed()
    elif choice == "4":
        running = False
    else:
        print("Pick a number from 1 to 4.")
print("Bye.")
```

### banner.eml

```eml
# P077 text banner - big letters five rows high, text wrapped to a width
# and lined up left, centred or right, and a frame drawn round it whose
# width comes from the longest line, as in the corpus case
# text-banner-builder - so a wrong count shows as a ragged edge.

"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !?.-" => LETTERS
"abcdefghijklmnopqrstuvwxyz" => SMALL

[
    [" ### ", "#   #", "#####", "#   #", "#   #"],
    ["#### ", "#   #", "#### ", "#   #", "#### "],
    [" ####", "#    ", "#    ", "#    ", " ####"],
    ["#### ", "#   #", "#   #", "#   #", "#### "],
    ["#####", "#    ", "#### ", "#    ", "#####"],
    ["#####", "#    ", "#### ", "#    ", "#    "],
    [" ####", "#    ", "#  ##", "#   #", " ####"],
    ["#   #", "#   #", "#####", "#   #", "#   #"],
    ["#####", "  #  ", "  #  ", "  #  ", "#####"],
    ["#####", "   # ", "   # ", "#  # ", " ##  "],
    ["#   #", "#  # ", "###  ", "#  # ", "#   #"],
    ["#    ", "#    ", "#    ", "#    ", "#####"],
    ["#   #", "## ##", "# # #", "#   #", "#   #"],
    ["#   #", "##  #", "# # #", "#  ##", "#   #"],
    [" ### ", "#   #", "#   #", "#   #", " ### "],
    ["#### ", "#   #", "#### ", "#    ", "#    "],
    [" ### ", "#   #", "# # #", "#  # ", " ## #"],
    ["#### ", "#   #", "#### ", "#  # ", "#   #"],
    [" ####", "#    ", " ### ", "    #", "#### "],
    ["#####", "  #  ", "  #  ", "  #  ", "  #  "],
    ["#   #", "#   #", "#   #", "#   #", " ### "],
    ["#   #", "#   #", "#   #", " # # ", "  #  "],
    ["#   #", "#   #", "# # #", "## ##", "#   #"],
    ["#   #", " # # ", "  #  ", " # # ", "#   #"],
    ["#   #", " # # ", "  #  ", "  #  ", "  #  "],
    ["#####", "   # ", "  #  ", " #   ", "#####"],
    [" ### ", "#  ##", "# # #", "##  #", " ### "],
    ["  #  ", " ##  ", "  #  ", "  #  ", " ### "],
    [" ### ", "#   #", "  ## ", " #   ", "#####"],
    ["#### ", "    #", " ### ", "    #", "#### "],
    ["#   #", "#   #", "#####", "    #", "    #"],
    ["#####", "#    ", "#### ", "    #", "#### "],
    [" ### ", "#    ", "#### ", "#   #", " ### "],
    ["#####", "    #", "   # ", "  #  ", "  #  "],
    [" ### ", "#   #", " ### ", "#   #", " ### "],
    [" ### ", "#   #", " ####", "    #", " ### "],
    ["     ", "     ", "     ", "     ", "     "],
    ["  #  ", "  #  ", "  #  ", "     ", "  #  "],
    [" ### ", "#   #", "  ## ", "     ", "  #  "],
    ["     ", "     ", "     ", "     ", "  #  "],
    ["     ", "     ", "#####", "     ", "     "],
] => GLYPHS

def index_in(s, c):
    for i in [0:len(s) - 1]:
        if s[i] == c:
            return i
    return -1

def upper(c):
    index_in(SMALL, c) => i
    if i >= 0:
        return LETTERS[i]
    return c

def can_draw(text):
    for c in text:
        if index_in(LETTERS, upper(c)) == -1:
            return False
    return True

def trim_right(s):
    len(s) => j
    while j > 0 and s[j - 1] == " ":
        j - 1 => j
    return s[0:j]

def big(text):
    # Five rows: each letter's glyph, one space between letters, and no
    # spaces left at the ends of the rows.
    ["", "", "", "", ""] => rows
    for k in [0:len(text) - 1]:
        GLYPHS[index_in(LETTERS, upper(text[k]))] => g
        for r in [0:4]:
            if k > 0:
                rows[r] + " " => rows[r]
            rows[r] + g[r] => rows[r]
    for r in [0:4]:
        trim_right(rows[r]) => rows[r]
    return rows

def words(s):
    [] => out
    "" => w
    for c in s + " ":
        if c == " ":
            if w != "":
                out + [w] => out
            "" => w
        else:
            w + c => w
    return out

def wrap(text, width):
    # Greedy wrapping: as many words as fit on each line, one space between
    # them; a word longer than the width is cut into pieces that fit.
    [] => lines
    "" => line
    for w in words(text):
        while len(w) > width:
            if line != "":
                lines + [line] => lines
                "" => line
            lines + [w[0:width]] => lines
            w[width:len(w)] => w
        if line == "":
            w => line
        elif len(line) + 1 + len(w) <= width:
            line + " " + w => line
        else:
            lines + [line] => lines
            w => line
    if line != "":
        lines + [line] => lines
    return lines

def aligned(line, width, how):
    # The line placed in the width: "left", "centre" (an odd space left
    # over goes on the right) or "right". Nothing is added after the text.
    0 => left
    if how == "right":
        width - len(line) => left
    elif how == "centre":
        int((width - len(line) - (width - len(line)) % 2) / 2) => left
    return " " * left + line

def boxed(lines, pad):
    # The corpus case's frame: as wide as the longest line plus the
    # padding on both sides, so the right edge closes exactly.
    0 => widest
    for line in lines:
        if len(line) > widest:
            len(line) => widest
    widest + pad * 2 => inner
    ["+" + "-" * inner + "+"] => out
    for line in lines:
        out + ["|" + " " * pad + line + " " * (inner - pad - len(line)) + "|"] => out
    return out + ["+" + "-" * inner + "+"]
```

Python projection of banner.eml:

```python
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !?.-"
SMALL = "abcdefghijklmnopqrstuvwxyz"
GLYPHS = [[" ### ", "#   #", "#####", "#   #", "#   #"], ["#### ", "#   #", "#### ", "#   #", "#### "], [" ####", "#    ", "#    ", "#    ", " ####"], ["#### ", "#   #", "#   #", "#   #", "#### "], ["#####", "#    ", "#### ", "#    ", "#####"], ["#####", "#    ", "#### ", "#    ", "#    "], [" ####", "#    ", "#  ##", "#   #", " ####"], ["#   #", "#   #", "#####", "#   #", "#   #"], ["#####", "  #  ", "  #  ", "  #  ", "#####"], ["#####", "   # ", "   # ", "#  # ", " ##  "], ["#   #", "#  # ", "###  ", "#  # ", "#   #"], ["#    ", "#    ", "#    ", "#    ", "#####"], ["#   #", "## ##", "# # #", "#   #", "#   #"], ["#   #", "##  #", "# # #", "#  ##", "#   #"], [" ### ", "#   #", "#   #", "#   #", " ### "], ["#### ", "#   #", "#### ", "#    ", "#    "], [" ### ", "#   #", "# # #", "#  # ", " ## #"], ["#### ", "#   #", "#### ", "#  # ", "#   #"], [" ####", "#    ", " ### ", "    #", "#### "], ["#####", "  #  ", "  #  ", "  #  ", "  #  "], ["#   #", "#   #", "#   #", "#   #", " ### "], ["#   #", "#   #", "#   #", " # # ", "  #  "], ["#   #", "#   #", "# # #", "## ##", "#   #"], ["#   #", " # # ", "  #  ", " # # ", "#   #"], ["#   #", " # # ", "  #  ", "  #  ", "  #  "], ["#####", "   # ", "  #  ", " #   ", "#####"], [" ### ", "#  ##", "# # #", "##  #", " ### "], ["  #  ", " ##  ", "  #  ", "  #  ", " ### "], [" ### ", "#   #", "  ## ", " #   ", "#####"], ["#### ", "    #", " ### ", "    #", "#### "], ["#   #", "#   #", "#####", "    #", "    #"], ["#####", "#    ", "#### ", "    #", "#### "], [" ### ", "#    ", "#### ", "#   #", " ### "], ["#####", "    #", "   # ", "  #  ", "  #  "], [" ### ", "#   #", " ### ", "#   #", " ### "], [" ### ", "#   #", " ####", "    #", " ### "], ["     ", "     ", "     ", "     ", "     "], ["  #  ", "  #  ", "  #  ", "     ", "  #  "], [" ### ", "#   #", "  ## ", "     ", "  #  "], ["     ", "     ", "     ", "     ", "  #  "], ["     ", "     ", "#####", "     ", "     "]]

def index_in(s, c):
    for i in range(0, len(s)):
        if s[i] == c:
            return i
    return -1

def upper(c):
    i = index_in(SMALL, c)
    if i >= 0:
        return LETTERS[i]
    return c

def can_draw(text):
    for c in text:
        if index_in(LETTERS, upper(c)) == -1:
            return False
    return True

def trim_right(s):
    j = len(s)
    while j > 0 and s[j - 1] == " ":
        j = j - 1
    return s[0:j]

def big(text):
    rows = ["", "", "", "", ""]
    for k in range(0, len(text)):
        g = GLYPHS[index_in(LETTERS, upper(text[k]))]
        for r in range(0, 5):
            if k > 0:
                rows[r] = rows[r] + " "
            rows[r] = rows[r] + g[r]
    for r in range(0, 5):
        rows[r] = trim_right(rows[r])
    return rows

def words(s):
    out = []
    w = ""
    for c in s + " ":
        if c == " ":
            if w != "":
                out = out + [w]
            w = ""
        else:
            w = w + c
    return out

def wrap(text, width):
    lines = []
    line = ""
    for w in words(text):
        while len(w) > width:
            if line != "":
                lines = lines + [line]
                line = ""
            lines = lines + [w[0:width]]
            w = w[width:len(w)]
        if line == "":
            line = w
        elif len(line) + 1 + len(w) <= width:
            line = line + " " + w
        else:
            lines = lines + [line]
            line = w
    if line != "":
        lines = lines + [line]
    return lines

def aligned(line, width, how):
    left = 0
    if how == "right":
        left = width - len(line)
    elif how == "centre":
        left = int((width - len(line) - (width - len(line)) % 2) / 2)
    return " " * left + line

def boxed(lines, pad):
    widest = 0
    for line in lines:
        if len(line) > widest:
            widest = len(line)
    inner = widest + pad * 2
    out = ["+" + "-" * inner + "+"]
    for line in lines:
        out = out + ["|" + " " * pad + line + " " * (inner - pad - len(line)) + "|"]
    return out + ["+" + "-" * inner + "+"]
```

## README

# P077 - Text banner

Big letters five rows high, wrapped to fit the screen. Ordinary text
wrapped to a width and lined up left, centred or right. And a frame round
it that fits the text exactly.

- `main.eml` - the menu, reading text and widths, and printing
- `banner.eml` - the 5 x 5 font, big letters, wrapping, lining up and
  framing

How each part works:

- Big letters: a font of 41 glyphs (A to Z, 0 to 9, space and ! ? . -),
  each five rows of five columns, with no two alike. Small letters are
  drawn as capitals. A banner line is the glyphs side by side with one
  space between, and no spaces left at the ends of the rows. 13 letters
  fit in 80 columns, so longer text is wrapped first.
- Wrapping is greedy: as many words as fit on each line, one space
  between them. A word longer than the width is cut into pieces that
  fit.
- Lining up: right puts the spaces in front; centre splits them, with an
  odd one going on the right. Nothing is added after the text.
- The frame, as in the corpus case `text-banner-builder`, is as wide as
  the longest line plus the padding on both sides, worked out from the
  text rather than a fixed number. A wrong count would show as a ragged
  right edge.

What is checked: menu choices 1 to 4; text of up to 200 characters, and
for big letters only the characters the font has; a width of 10 to 80
(10 to 70 inside a frame); padding of 0 to 4; left, centre or right. An
empty answer cancels.

Sessions: `sessions/basic.in` gives:
- "Hello EML 2026!" in big letters on two lines, and "eml-p rocks?" in
  capitals;
- one sentence wrapped to 30 on the left, and to 20 centred;
- a sentence with "internationalisation," cut to fit 15, on the right;
- a sentence in a frame, 24 wide with padding 2, centred.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- "a,b", which the font cannot draw, and cancels;
- widths 9, 81 and x, the alignment "middle", padding 5, then "abc" in
  the smallest frame;
- a text over 200 characters.

Built on the verified corpus case `text-banner-builder` (sequence
repetition, and a frame drawn to a width taken from the longest line).
