Project P046

Text formatter

A paragraph set at a width from 20 to 60: left, right, centred or justified, wrapped greedily as a terminal does, or balanced by dynamic programming so that the right edge is as even as it can be - with the raggedness of both wrappings shown side by side.

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

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

A paragraph set at a width from 20 to 60 characters: left, right, centred or justified, wrapped greedily as a terminal does, or balanced so that the right edge is as even as it can be. The lines are drawn inside a frame of the chosen width, and the raggedness of both wrappings is shown so that they can be compared.

  • main.eml - the menu, the text and the width with their checks, and the framed paragraph on screen
  • layout.eml - the two ways of breaking words into lines, the raggedness, and laying a line out

How each part works:

  • Greedy wrapping is the corpus case word-wrap: a word goes on the current line if it fits, otherwise it starts the next one.
  • Raggedness is the sum, over every line but the last, of the square of the spaces left at its end; squaring makes one very short line cost more than several slightly short ones.
  • Balanced wrapping finds the breaking with the least raggedness by dynamic programming from the end of the paragraph: the cheapest way to break the words from each position on is the cheapest choice of its first line plus the cheapest way to break the rest. On a tie the longer first line wins. At width 31 the sample takes eight lines either way, with raggedness 89 greedily and 51 balanced: balanced wrapping moves "nor" to the next line early on, so that no line is left 8 spaces short as "while it fits; balanced" is in the greedy version.
  • Justified lines spread the spare spaces over the gaps, the leftmost gaps taking one more when they do not divide evenly; the last line and a line of one word stay left-aligned.
  • A word longer than the width gets a line of its own and sticks out of the frame; the program names it.

What is checked: a width from 20 to 60; a typed text of at least one word, ended by a line holding only a dot. An empty width answer cancels.

Sessions: sessions/basic.in sets the sample at the starting width of 36, then at 31 - greedy (raggedness 89), balanced (51), justified, right-aligned and centred. sessions/bad-input.in gives menu choices 0 and x, widths 19, 61 and x, a text with no words, and a text with a 45-letter word, shown left, balanced and justified, before going back to the sample.

Built on the verified corpus case word-wrap (greedy wrapping, checked for lines over the width and for words lost or added).

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
== Text formatter ==
Set a paragraph at a width: left, right, centred, justified, or balanced.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 0
Pick a number from 1 to 9.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> x
Pick a number from 1 to 9.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 3
width (20 to 60)> 19
Type a number from 20 to 60; the width stays 36.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 3
width (20 to 60)> 61
Type a number from 20 to 60; the width stays 36.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 3
width (20 to 60)> x
Type a number from 20 to 60; the width stays 36.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 3
width (20 to 60)> 
Cancelled.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 1
Type the text; it may run over several lines. A line with only a dot ends it.
> .
No words: the text stays as it was.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 1
Type the text; it may run over several lines. A line with only a dot ends it.
> Some words, then a very long one:
> Pneumonoultramicroscopicsilicovolcanoconiosis
> and a few more words after it.
> .
15 words.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 4
Width 36, left, wrapped greedy:
+------------------------------------+
|Some words, then a very long one:   |
|Pneumonoultramicroscopicsilicovolcanoconiosis
|and a few more words after it.      |
+------------------------------------+
3 lines; raggedness 9 (the squares of the spaces left at the end of every line but the last).
The word Pneumonoultramicroscopicsilicovolcanoconiosis is longer than the width, so it sticks out.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 8
Width 36, left, wrapped balanced:
+------------------------------------+
|Some words, then a very long one:   |
|Pneumonoultramicroscopicsilicovolcanoconiosis
|and a few more words after it.      |
+------------------------------------+
3 lines; raggedness 9 (the squares of the spaces left at the end of every line but the last).
Greedy wrapping at this width: 3 lines, raggedness 9.
The word Pneumonoultramicroscopicsilicovolcanoconiosis is longer than the width, so it sticks out.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 7
Width 36, justify, wrapped greedy:
+------------------------------------+
|Some  words,  then  a very long one:|
|Pneumonoultramicroscopicsilicovolcanoconiosis
|and a few more words after it.      |
+------------------------------------+
3 lines; raggedness 9 (the squares of the spaces left at the end of every line but the last).
The word Pneumonoultramicroscopicsilicovolcanoconiosis is longer than the width, so it sticks out.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 2
The sample: 44 words.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 9
Bye.
What was typed (22 lines)
0
x
3
19
3
61
3
x
3

1
.
1
Some words, then a very long one:
Pneumonoultramicroscopicsilicovolcanoconiosis
and a few more words after it.
.
4
8
7
2
9

basic

interpreter: byte-equal
== Text formatter ==
Set a paragraph at a width: left, right, centred, justified, or balanced.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 4
Width 36, left, wrapped greedy:
+------------------------------------+
|A line of text is easy to read when |
|it is neither too long nor too      |
|short. Greedy wrapping puts each    |
|word on the current line while it   |
|fits; balanced wrapping looks at the|
|whole paragraph first and evens out |
|the ragged right edge.              |
+------------------------------------+
7 lines; raggedness 63 (the squares of the spaces left at the end of every line but the last).

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 3
width (20 to 60)> 31
The width is now 31.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 4
Width 31, left, wrapped greedy:
+-------------------------------+
|A line of text is easy to read |
|when it is neither too long nor|
|too short. Greedy wrapping puts|
|each word on the current line  |
|while it fits; balanced        |
|wrapping looks at the whole    |
|paragraph first and evens out  |
|the ragged right edge.         |
+-------------------------------+
8 lines; raggedness 89 (the squares of the spaces left at the end of every line but the last).

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 8
Width 31, left, wrapped balanced:
+-------------------------------+
|A line of text is easy to read |
|when it is neither too long    |
|nor too short. Greedy wrapping |
|puts each word on the current  |
|line while it fits; balanced   |
|wrapping looks at the whole    |
|paragraph first and evens out  |
|the ragged right edge.         |
+-------------------------------+
8 lines; raggedness 51 (the squares of the spaces left at the end of every line but the last).
Greedy wrapping at this width: 8 lines, raggedness 89.

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 7
Width 31, justify, wrapped greedy:
+-------------------------------+
|A  line of text is easy to read|
|when it is neither too long nor|
|too short. Greedy wrapping puts|
|each  word  on the current line|
|while    it    fits;   balanced|
|wrapping  looks  at  the  whole|
|paragraph  first  and evens out|
|the ragged right edge.         |
+-------------------------------+
8 lines; raggedness 89 (the squares of the spaces left at the end of every line but the last).

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 5
Width 31, right, wrapped greedy:
+-------------------------------+
| A line of text is easy to read|
|when it is neither too long nor|
|too short. Greedy wrapping puts|
|  each word on the current line|
|        while it fits; balanced|
|    wrapping looks at the whole|
|  paragraph first and evens out|
|         the ragged right edge.|
+-------------------------------+
8 lines; raggedness 89 (the squares of the spaces left at the end of every line but the last).

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 6
Width 31, center, wrapped greedy:
+-------------------------------+
|A line of text is easy to read |
|when it is neither too long nor|
|too short. Greedy wrapping puts|
| each word on the current line |
|    while it fits; balanced    |
|  wrapping looks at the whole  |
| paragraph first and evens out |
|    the ragged right edge.     |
+-------------------------------+
8 lines; raggedness 89 (the squares of the spaces left at the end of every line but the last).

1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit
choice> 9
Bye.
What was typed (9 lines)
4
3
31
4
8
7
5
6
9

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
# P046 text formatter: a paragraph set at a chosen width - left, right,
# centred or justified, wrapped greedily as a terminal does, or balanced so
# that the right edge is as even as it can be. The raggedness of both
# wrappings is shown, so they can be compared.
import layout

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

def lines_text(n):
    if n == 1:
        return "1 line"
    return str(n) + " lines"

def show(words, width, how, wrap):
    if len(words) == 0:
        "There is no text yet." ^0
        return 0
    "greedy" => name
    if wrap == "balanced":
        layout.balanced(words, width) => lines
        "balanced" => name
    else:
        layout.greedy(words, width) => lines
    ("Width " + str(width) + ", " + how + ", wrapped " + name + ":") ^0
    ("+" + "-" * width + "+") ^0
    [] => long
    for k in [0:len(lines) - 1]:
        layout.laid_out(lines[k], width, how, k == len(lines) - 1) => text
        if len(text) > width:
            ("|" + text) ^0
            long + [lines[k][0]] => long
        else:
            ("|" + text + " " * (width - len(text)) + "|") ^0
    ("+" + "-" * width + "+") ^0
    (lines_text(len(lines)) + "; raggedness " + str(layout.raggedness(lines, width)) + " (the squares of the spaces left at the end of every line but the last).") ^0
    if wrap == "balanced":
        layout.greedy(words, width) => g
        ("Greedy wrapping at this width: " + lines_text(len(g)) + ", raggedness " + str(layout.raggedness(g, width)) + ".") ^0
    for w in long:
        ("The word " + w + " is longer than the width, so it sticks out.") ^0
    return 0

def read_text():
    "Type the text; it may run over several lines. A line with only a dot ends it." ^0
    "" => text
    while True:
        input("> ") => line
        if trim(line) == ".":
            return layout.words_of(text)
        text + " " + line => text

"== Text formatter ==" ^0
"Set a paragraph at a width: left, right, centred, justified, or balanced." ^0
"A line of text is easy to read when it is neither too long nor too short. Greedy wrapping puts each word on the current line while it fits; balanced wrapping looks at the whole paragraph first and evens out the ragged right edge." => sample
layout.words_of(sample) => words
36 => width
True => running
while running:
    "" ^0
    "1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit" ^0
    trim(input("choice> ")) => choice
    if choice == "1":
        read_text() => typed
        if len(typed) == 0:
            "No words: the text stays as it was." ^0
        else:
            typed => words
            (str(len(words)) + " words.") ^0
    elif choice == "2":
        layout.words_of(sample) => words
        ("The sample: " + str(len(words)) + " words.") ^0
    elif choice == "3":
        trim(input("width (20 to 60)> ")) => answer
        number(answer) => w
        if answer == "":
            "Cancelled." ^0
        elif w >= 20 and w <= 60:
            w => width
            ("The width is now " + str(width) + ".") ^0
        else:
            ("Type a number from 20 to 60; the width stays " + str(width) + ".") ^0
    elif choice == "4":
        show(words, width, "left", "greedy")
    elif choice == "5":
        show(words, width, "right", "greedy")
    elif choice == "6":
        show(words, width, "center", "greedy")
    elif choice == "7":
        show(words, width, "justify", "greedy")
    elif choice == "8":
        show(words, width, "left", "balanced")
    elif choice == "9":
        False => running
    else:
        "Pick a number from 1 to 9." ^0
"Bye." ^0
Python projection (main.py)
import layout

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

def lines_text(n):
    if n == 1:
        return "1 line"
    return str(n) + " lines"

def show(words, width, how, wrap):
    if len(words) == 0:
        print("There is no text yet.")
        return 0
    name = "greedy"
    if wrap == "balanced":
        lines = layout.balanced(words, width)
        name = "balanced"
    else:
        lines = layout.greedy(words, width)
    print("Width " + str(width) + ", " + how + ", wrapped " + name + ":")
    print("+" + "-" * width + "+")
    long = []
    for k in range(0, len(lines)):
        text = layout.laid_out(lines[k], width, how, k == len(lines) - 1)
        if len(text) > width:
            print("|" + text)
            long = long + [lines[k][0]]
        else:
            print("|" + text + " " * (width - len(text)) + "|")
    print("+" + "-" * width + "+")
    print(lines_text(len(lines)) + "; raggedness " + str(layout.raggedness(lines, width)) + " (the squares of the spaces left at the end of every line but the last).")
    if wrap == "balanced":
        g = layout.greedy(words, width)
        print("Greedy wrapping at this width: " + lines_text(len(g)) + ", raggedness " + str(layout.raggedness(g, width)) + ".")
    for w in long:
        print("The word " + w + " is longer than the width, so it sticks out.")
    return 0

def read_text():
    print("Type the text; it may run over several lines. A line with only a dot ends it.")
    text = ""
    while True:
        line = input("> ")
        if trim(line) == ".":
            return layout.words_of(text)
        text = text + " " + line

print("== Text formatter ==")
print("Set a paragraph at a width: left, right, centred, justified, or balanced.")
sample = "A line of text is easy to read when it is neither too long nor too short. Greedy wrapping puts each word on the current line while it fits; balanced wrapping looks at the whole paragraph first and evens out the ragged right edge."
words = layout.words_of(sample)
width = 36
running = True
while running:
    print("")
    print("1) text  2) sample  3) width  4) left  5) right  6) center  7) justify  8) balanced  9) quit")
    choice = trim(input("choice> "))
    if choice == "1":
        typed = read_text()
        if len(typed) == 0:
            print("No words: the text stays as it was.")
        else:
            words = typed
            print(str(len(words)) + " words.")
    elif choice == "2":
        words = layout.words_of(sample)
        print("The sample: " + str(len(words)) + " words.")
    elif choice == "3":
        answer = trim(input("width (20 to 60)> "))
        w = number(answer)
        if answer == "":
            print("Cancelled.")
        elif w >= 20 and w <= 60:
            width = w
            print("The width is now " + str(width) + ".")
        else:
            print("Type a number from 20 to 60; the width stays " + str(width) + ".")
    elif choice == "4":
        show(words, width, "left", "greedy")
    elif choice == "5":
        show(words, width, "right", "greedy")
    elif choice == "6":
        show(words, width, "center", "greedy")
    elif choice == "7":
        show(words, width, "justify", "greedy")
    elif choice == "8":
        show(words, width, "left", "balanced")
    elif choice == "9":
        running = False
    else:
        print("Pick a number from 1 to 9.")
print("Bye.")

layout.eml

eml
# P046 text formatter - breaking words into lines and laying the lines out.
# A breaking is a list of lines, each a list of words.

def words_of(text):
    # Words are runs of anything but spaces.
    [] => out
    "" => word
    for c in text + " ":
        if c == " ":
            if word != "":
                out + [word] => out
            "" => word
        else:
            word + c => word
    return out

def length(line):
    # The length of the words joined by single spaces.
    0 => n
    for w in line:
        n + len(w) => n
    return n + len(line) - 1

def greedy(words, width):
    # The corpus case word-wrap: a word goes on the current line if it fits,
    # otherwise it starts the next one. A word longer than the width gets a
    # line of its own.
    [] => lines
    [] => line
    for w in words:
        if len(line) == 0:
            [w] => line
        elif length(line) + 1 + len(w) <= width:
            line + [w] => line
        else:
            lines + [line] => lines
            [w] => line
    if len(line) > 0:
        lines + [line] => lines
    return lines

def gap_cost(words, i, j, width):
    # The cost of a line holding words i to j (not the last line): the square
    # of the spaces left at its end; -1 if it does not fit. A single word too
    # long for the width costs nothing - no breaking can help it.
    length(words[i:j + 1]) => n
    if n > width:
        if i == j:
            return 0
        return -1
    return (width - n) * (width - n)

def balanced(words, width):
    # The breaking with the least raggedness - the sum, over every line but
    # the last, of the square of the spaces left at its end - by dynamic
    # programming from the end: best[i] is the least cost of breaking words
    # i onwards. On a tie the longer first line wins.
    len(words) => n
    [0] * (n + 1) => best
    [n] * (n + 1) => cut
    n - 1 => i
    while i >= 0:
        -1 => least
        i => j
        True => fitting
        while j < n and fitting:
            if j == n - 1 and (length(words[i:n]) <= width or i == j):
                0 => c
            else:
                gap_cost(words, i, j, width) => c
                if c != -1:
                    c + best[j + 1] => c
            if c == -1:
                False => fitting
            elif least == -1 or c <= least:
                c => least
                j + 1 => cut[i]
            j + 1 => j
        least => best[i]
        i - 1 => i
    [] => lines
    0 => i
    while i < n:
        lines + [words[i:cut[i]]] => lines
        cut[i] => i
    return lines

def raggedness(lines, width):
    0 => total
    for k in [0:len(lines) - 2]:
        width - length(lines[k]) => left
        if left > 0:
            total + left * left => total
    return total

def laid_out(line, width, how, last):
    # One line as text, without the spaces after it: "left", "right",
    # "center" or "justify" (the last line and a one-word line stay left).
    length(line) => n
    "" => plain
    for w in line:
        if plain != "":
            plain + " " => plain
        plain + w => plain
    if how == "right" and n < width:
        return " " * (width - n) + plain
    if how == "center" and n < width:
        return " " * int((width - n) / 2) + plain
    if how == "justify" and not last and len(line) > 1 and n < width:
        width - n => extra
        len(line) - 1 => gaps
        int(extra / gaps) => each
        extra % gaps => more
        line[0] => out
        for k in [1:len(line) - 1]:
            1 + each => spaces
            if k <= more:
                spaces + 1 => spaces
            out + " " * spaces + line[k] => out
        return out
    return plain
Python projection (layout.py)
def words_of(text):
    out = []
    word = ""
    for c in text + " ":
        if c == " ":
            if word != "":
                out = out + [word]
            word = ""
        else:
            word = word + c
    return out

def length(line):
    n = 0
    for w in line:
        n = n + len(w)
    return n + len(line) - 1

def greedy(words, width):
    lines = []
    line = []
    for w in words:
        if len(line) == 0:
            line = [w]
        elif length(line) + 1 + len(w) <= width:
            line = line + [w]
        else:
            lines = lines + [line]
            line = [w]
    if len(line) > 0:
        lines = lines + [line]
    return lines

def gap_cost(words, i, j, width):
    n = length(words[i:j + 1])
    if n > width:
        if i == j:
            return 0
        return -1
    return (width - n) * (width - n)

def balanced(words, width):
    n = len(words)
    best = [0] * (n + 1)
    cut = [n] * (n + 1)
    i = n - 1
    while i >= 0:
        least = -1
        j = i
        fitting = True
        while j < n and fitting:
            if j == n - 1 and (length(words[i:n]) <= width or i == j):
                c = 0
            else:
                c = gap_cost(words, i, j, width)
                if c != -1:
                    c = c + best[j + 1]
            if c == -1:
                fitting = False
            elif least == -1 or c <= least:
                least = c
                cut[i] = j + 1
            j = j + 1
        best[i] = least
        i = i - 1
    lines = []
    i = 0
    while i < n:
        lines = lines + [words[i:cut[i]]]
        i = cut[i]
    return lines

def raggedness(lines, width):
    total = 0
    for k in range(0, len(lines) - 2+1):
        left = width - length(lines[k])
        if left > 0:
            total = total + left * left
    return total

def laid_out(line, width, how, last):
    n = length(line)
    plain = ""
    for w in line:
        if plain != "":
            plain = plain + " "
        plain = plain + w
    if how == "right" and n < width:
        return " " * (width - n) + plain
    if how == "center" and n < width:
        return " " * int((width - n) / 2) + plain
    if how == "justify" and not last and len(line) > 1 and n < width:
        extra = width - n
        gaps = len(line) - 1
        each = int(extra / gaps)
        more = extra % gaps
        out = line[0]
        for k in range(1, len(line)):
            spaces = 1 + each
            if k <= more:
                spaces = spaces + 1
            out = out + " " * spaces + line[k]
        return out
    return plain

Built on these corpus cases