<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P046-text-formatter/ | updated: 2026-10-10 -->

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

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

## Sessions

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

Input:

```text
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
```

Screen:

```text
== 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.
```

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

Input:

```text
4
3
31
4
8
7
5
6
9
```

Screen:

```text
== 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.
```

## Modules

### main.eml

```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 of main.eml:

```python
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 of layout.eml:

```python
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
```

## README

# P046 - Text formatter

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