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