Text truncation
Fitting text into a column with an ellipsis against the one-liner s[:w-3] + ..., titles that come out the same when cut, and three whitespace tidy-ups that each settle in one pass and merge different amounts.
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
Fitting text into a column with an ellipsis, and tidying whitespace.
main.eml- the menu, one text at a width both ways, every width counted, titles that come out the same, and three whitespace tidy-upstext.eml- fitting a text, the one-liner, the survey over widths, collisions, the tidy-ups, and the sample values
How each part works:
- Fitting: the text if it fits; otherwise as much of it as leaves room for "...", or dots only when even one character will not go. It is never wider than the width and always a prefix of the text with dots.
- As in the corpus case
text-truncate-ellipsis, the one-liner s[:w - 3] + "..." is wrong three ways, all silently: it cuts text that already fits, a width below 3 makes the stop negative so it counts from the right end, and it comes out wider than the width. Over the 8 titles at every width from 0 to 30 it is too wide 24 times and cuts a fitting title 109 times; fitting does neither. - Cut short, different titles come out the same: at width 14 the 8 titles show as 4 labels.
- As in the corpus case
whitespace-normalization-loss, trim, collapse (trim and one space inside) and strip all each settle after one pass. They differ in what they merge: 10 values become 8, 7 and 4. Strip all makes AB 12, AB12 and A B12 one value; whether those were different is a fact about the field. A blank value is something before a trim and nothing after it.
What is checked: menu choices 1 to 5; a text of up to 60 characters and a width from 0 to 60. An empty answer cancels.
Sessions: sessions/basic.in gives: - one text each: a title at width 12, Q3 at 10 (the one-liner cuts it), and Release notes at 2 (the stop counts from the right); - every width, titles at widths 14 and 30, and the whitespace tidy-ups.
sessions/bad-input.in gives: - menu choices 0 and x; - texts: a cancel and 61 characters, then abc with a cancel, 61 and x for the width, and abc at width 0; - titles: a cancel, 99, and width 0, where every title is one label.
Built on the verified corpus cases text-truncate-ellipsis (the one-line truncation and its three silent bugs) and whitespace-normalization-loss (three idempotent tidy-ups that merge different amounts).
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 truncation ==
Fitting text into a column with an ellipsis, and tidying whitespace.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 0
Pick a number from 1 to 5.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> x
Pick a number from 1 to 5.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 1
text (up to 60 characters)>
Cancelled.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 1
text (up to 60 characters)> xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Up to 60 characters.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 1
text (up to 60 characters)> abc
width (0 to 60)>
Cancelled.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 1
text (up to 60 characters)> abc
width (0 to 60)> 61
Type a whole number from 0 to 60.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 1
text (up to 60 characters)> abc
width (0 to 60)> x
Type a whole number from 0 to 60.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 1
text (up to 60 characters)> abc
width (0 to 60)> 0
fit: [] 0 characters
s[:w-3]+...: [...] 3 characters
The one-liner is 3 wider than the width.
With a width below 3 the stop -3 counts from the right end of the text.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 3
width (0 to 60)>
Cancelled.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 3
width (0 to 60)> 99
Type a whole number from 0 to 60.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 3
width (0 to 60)> 0
At width 0, titles that come out the same:
[] from [Quarterly report: sales], [Quarterly report: staff], [Quarterly review], [Release notes 4.1], [Release notes 4.2], [Board meeting minutes], [Board meeting agenda], [Q3]
8 titles show as 1 label.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 5
Bye.
What was typed (25 lines)
0
x
1
1
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
1
abc
1
abc
61
1
abc
x
1
abc
0
3
3
99
3
0
5
basic
interpreter: byte-equal== Text truncation ==
Fitting text into a column with an ellipsis, and tidying whitespace.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 1
text (up to 60 characters)> Quarterly report: sales
width (0 to 60)> 12
fit: [Quarterly...] 12 characters
s[:w-3]+...: [Quarterly...] 12 characters
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 1
text (up to 60 characters)> Q3
width (0 to 60)> 10
fit: [Q3] 2 characters
s[:w-3]+...: [Q3...] 5 characters
The text fits in 10, and the one-liner cut it anyway.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 1
text (up to 60 characters)> Release notes
width (0 to 60)> 2
fit: [..] 2 characters
s[:w-3]+...: [Release note...] 15 characters
The one-liner is 13 wider than the width.
With a width below 3 the stop -1 counts from the right end of the text.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 2
The 8 titles at every width from 0 to 30, 248 cases:
s[:w-3]+...: wider than the width 24 times, a fitting title cut 109 times
fit: wider than the width 0 times, a fitting title cut 0 times
A slice never complains about its bounds, so nothing about the one-liner fails loudly.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 3
width (0 to 60)> 14
At width 14, titles that come out the same:
[Quarterly r...] from [Quarterly report: sales], [Quarterly report: staff], [Quarterly review]
[Release not...] from [Release notes 4.1], [Release notes 4.2]
[Board meeti...] from [Board meeting minutes], [Board meeting agenda]
8 titles show as 4 different labels.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 3
width (0 to 60)> 30
At width 30 every title stays different.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 4
Values: [Ann Lee] [ Ann Lee] [Ann Lee] [Ann Lee ] [AB 12] [AB12] [A B12] [555 1234 567] [5551 234 567] [ ]
as they are: 10 different
trim: 8 different; a second pass changes nothing for 10 of 10
collapse: 7 different; a second pass changes nothing for 10 of 10
strip all: 4 different; a second pass changes nothing for 10 of 10
strip all makes [AB 12], [AB12] and [A B12] one value, and the two phone numbers, grouped differently, one: whether those were different is a fact about the field, not the string.
The blank value [ ] is something before a trim and nothing after: a required-field check gives opposite answers on either side of it.
1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit
choice> 5
Bye.
What was typed (16 lines)
1
Quarterly report: sales
12
1
Q3
10
1
Release notes
2
2
3
14
3
30
4
5
Modules
The program as written, entry module first. Each module transpiles to its own Python file, which is what eml project run executes.
main.eml(entry)
eml# P138 text truncation: one text at a width both ways, every width
# counted, titles that come out the same, and three whitespace tidy-ups.
import text
def plain(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 pad(s, n):
s => out
while len(out) < n:
out + " " => out
return out
def lpad(s, n):
s => out
while len(out) < n:
" " + out => out
return out
def ask_width(prompt):
plain(input(prompt)) => t
if t == "":
"Cancelled." ^0
return 0 - 1
if len(t) > 2:
"Type a whole number from 0 to 60." ^0
return 0 - 1
for c in t:
if c < "0" or c > "9":
"Type a whole number from 0 to 60." ^0
return 0 - 1
if int(t) > 60:
"Type a whole number from 0 to 60." ^0
return 0 - 1
return int(t)
def one():
input("text (up to 60 characters)> ") => s
if s == "":
"Cancelled." ^0
return
if len(s) > 60:
"Up to 60 characters." ^0
return
ask_width("width (0 to 60)> ") => w
if w < 0:
return
text.fit(s, w) => a
text.naive(s, w) => b
(" fit: [" + a + "] " + str(len(a)) + " characters") ^0
(" s[:w-3]+...: [" + b + "] " + str(len(b)) + " characters") ^0
if len(s) <= w and b != s:
(" The text fits in " + str(w) + ", and the one-liner cut it anyway.") ^0
if len(b) > w:
(" The one-liner is " + str(len(b) - w) + " wider than the width.") ^0
if w < 3 and len(s) > w:
(" With a width below 3 the stop " + str(w - 3) + " counts from the right end of the text.") ^0
def every():
text.survey(text.titles(), 30) => r
("The " + str(len(text.titles())) + " titles at every width from 0 to 30, " + str(r[0]) + " cases:") ^0
(" s[:w-3]+...: wider than the width " + str(r[1]) + " times, a fitting title cut " + str(r[2]) + " times") ^0
(" fit: wider than the width " + str(r[3]) + " times, a fitting title cut " + str(r[4]) + " times") ^0
"A slice never complains about its bounds, so nothing about the one-liner fails loudly." ^0
def same():
ask_width("width (0 to 60)> ") => w
if w < 0:
return
text.collisions(text.titles(), w) => cs
if len(cs) == 0:
("At width " + str(w) + " every title stays different.") ^0
return
0 => lost
("At width " + str(w) + ", titles that come out the same:") ^0
for c in cs:
"" => names
for s in c[1]:
if names != "":
names + ", " => names
names + "[" + s + "]" => names
(" [" + c[0] + "] from " + names) ^0
lost + len(c[1]) - 1 => lost
len(text.titles()) - lost => left
if left == 1:
(str(len(text.titles())) + " titles show as 1 label.") ^0
else:
(str(len(text.titles())) + " titles show as " + str(left) + " different labels.") ^0
def tidy():
text.values() => vs
"" => shown
for v in vs:
if shown != "":
shown + " " => shown
shown + "[" + v + "]" => shown
("Values: " + shown) ^0
(" as they are: " + str(text.distinct(vs)) + " different") ^0
for how in ["trim", "collapse", "strip all"]:
[] => out
0 => settled
for v in vs:
text.tidy(how, v) => t
out + [t] => out
if text.tidy(how, t) == t:
settled + 1 => settled
(" " + pad(how + ":", 11) + str(text.distinct(out)) + " different; a second pass changes nothing for " + str(settled) + " of " + str(len(vs))) ^0
" strip all makes [AB 12], [AB12] and [A B12] one value, and the two phone numbers, grouped differently, one: whether those were different is a fact about the field, not the string." ^0
"The blank value [ ] is something before a trim and nothing after: a required-field check gives opposite answers on either side of it." ^0
"== Text truncation ==" ^0
"Fitting text into a column with an ellipsis, and tidying whitespace." ^0
True => running
while running:
"" ^0
"1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit" ^0
plain(input("choice> ")) => choice
if choice == "1":
one()
elif choice == "2":
every()
elif choice == "3":
same()
elif choice == "4":
tidy()
elif choice == "5":
False => running
else:
"Pick a number from 1 to 5." ^0
"Bye." ^0
Python projection (main.py)
import text
def plain(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 pad(s, n):
out = s
while len(out) < n:
out = out + " "
return out
def lpad(s, n):
out = s
while len(out) < n:
out = " " + out
return out
def ask_width(prompt):
t = plain(input(prompt))
if t == "":
print("Cancelled.")
return 0 - 1
if len(t) > 2:
print("Type a whole number from 0 to 60.")
return 0 - 1
for c in t:
if c < "0" or c > "9":
print("Type a whole number from 0 to 60.")
return 0 - 1
if int(t) > 60:
print("Type a whole number from 0 to 60.")
return 0 - 1
return int(t)
def one():
s = input("text (up to 60 characters)> ")
if s == "":
print("Cancelled.")
return
if len(s) > 60:
print("Up to 60 characters.")
return
w = ask_width("width (0 to 60)> ")
if w < 0:
return
a = text.fit(s, w)
b = text.naive(s, w)
print(" fit: [" + a + "] " + str(len(a)) + " characters")
print(" s[:w-3]+...: [" + b + "] " + str(len(b)) + " characters")
if len(s) <= w and b != s:
print(" The text fits in " + str(w) + ", and the one-liner cut it anyway.")
if len(b) > w:
print(" The one-liner is " + str(len(b) - w) + " wider than the width.")
if w < 3 and len(s) > w:
print(" With a width below 3 the stop " + str(w - 3) + " counts from the right end of the text.")
def every():
r = text.survey(text.titles(), 30)
print("The " + str(len(text.titles())) + " titles at every width from 0 to 30, " + str(r[0]) + " cases:")
print(" s[:w-3]+...: wider than the width " + str(r[1]) + " times, a fitting title cut " + str(r[2]) + " times")
print(" fit: wider than the width " + str(r[3]) + " times, a fitting title cut " + str(r[4]) + " times")
print("A slice never complains about its bounds, so nothing about the one-liner fails loudly.")
def same():
w = ask_width("width (0 to 60)> ")
if w < 0:
return
cs = text.collisions(text.titles(), w)
if len(cs) == 0:
print("At width " + str(w) + " every title stays different.")
return
lost = 0
print("At width " + str(w) + ", titles that come out the same:")
for c in cs:
names = ""
for s in c[1]:
if names != "":
names = names + ", "
names = names + "[" + s + "]"
print(" [" + c[0] + "] from " + names)
lost = lost + len(c[1]) - 1
left = len(text.titles()) - lost
if left == 1:
print(str(len(text.titles())) + " titles show as 1 label.")
else:
print(str(len(text.titles())) + " titles show as " + str(left) + " different labels.")
def tidy():
vs = text.values()
shown = ""
for v in vs:
if shown != "":
shown = shown + " "
shown = shown + "[" + v + "]"
print("Values: " + shown)
print(" as they are: " + str(text.distinct(vs)) + " different")
for how in ["trim", "collapse", "strip all"]:
out = []
settled = 0
for v in vs:
t = text.tidy(how, v)
out = out + [t]
if text.tidy(how, t) == t:
settled = settled + 1
print(" " + pad(how + ":", 11) + str(text.distinct(out)) + " different; a second pass changes nothing for " + str(settled) + " of " + str(len(vs)))
print(" strip all makes [AB 12], [AB12] and [A B12] one value, and the two phone numbers, grouped differently, one: whether those were different is a fact about the field, not the string.")
print("The blank value [ ] is something before a trim and nothing after: a required-field check gives opposite answers on either side of it.")
print("== Text truncation ==")
print("Fitting text into a column with an ellipsis, and tidying whitespace.")
running = True
while running:
print("")
print("1) one text 2) every width 3) titles that come out the same 4) whitespace 5) quit")
choice = plain(input("choice> "))
if choice == "1":
one()
elif choice == "2":
every()
elif choice == "3":
same()
elif choice == "4":
tidy()
elif choice == "5":
running = False
else:
print("Pick a number from 1 to 5.")
print("Bye.")
text.eml
eml# P138 text truncation - fitting text into a column with an ellipsis, and
# tidying whitespace. As in the corpus case text-truncate-ellipsis, the
# one-line truncation s[:width - 3] + "..." is wrong three ways, and all
# silently: it cuts text that already fits, a width below 3 makes the stop
# negative and cuts from the right end, and it can come out wider than the
# width. As in whitespace-normalization-loss, three tidy-ups all settle
# after one pass, and differ in how many different values they merge.
def fit(s, width):
# at most width characters: the text if it fits, else as much of it as
# leaves room for "...", or dots only when even one character will not go
if len(s) <= width:
return s
if width <= 3:
return "..."[0:width]
return s[0:width - 3] + "..."
def naive(s, width):
return s[0:width - 3] + "..."
def titles():
return ["Quarterly report: sales", "Quarterly report: staff", "Quarterly review", "Release notes 4.1", "Release notes 4.2", "Board meeting minutes", "Board meeting agenda", "Q3"]
def survey(texts, top):
# over every width 0 to top: [cases, naive too wide, naive cut text that
# fit, fit too wide, fit changed text that fit]
0 => cases
0 => wide
0 => cut
0 => fwide
0 => fcut
for w in [0:top]:
for s in texts:
cases + 1 => cases
naive(s, w) => a
fit(s, w) => b
if len(a) > w:
wide + 1 => wide
if len(s) <= w and a != s:
cut + 1 => cut
if len(b) > w:
fwide + 1 => fwide
if len(s) <= w and b != s:
fcut + 1 => fcut
return [cases, wide, cut, fwide, fcut]
def collisions(texts, width):
# groups of different texts that come out the same at this width
{} => by
[] => order
for s in texts:
fit(s, width) => k
if k in by:
by[k] + [s] => by[k]
else:
[s] => by[k]
order + [k] => order
[] => out
for k in order:
if len(by[k]) > 1:
out + [[k, by[k]]] => out
return out
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 collapse(s):
# trimmed, and every run of spaces inside made one space
"" => out
False => gap
for c in trim(s):
if c == " ":
True => gap
else:
if gap:
out + " " => out
False => gap
out + c => out
return out
def strip_all(s):
"" => out
for c in s:
if c != " ":
out + c => out
return out
def tidy(how, s):
if how == "trim":
return trim(s)
if how == "collapse":
return collapse(s)
return strip_all(s)
def values():
return ["Ann Lee", " Ann Lee", "Ann Lee", "Ann Lee ", "AB 12", "AB12", "A B12", "555 1234 567", "5551 234 567", " "]
def distinct(xs):
{} => seen
for x in xs:
1 => seen[x]
return len(seen)
Python projection (text.py)
def fit(s, width):
if len(s) <= width:
return s
if width <= 3:
return "..."[0:width]
return s[0:width - 3] + "..."
def naive(s, width):
return s[0:width - 3] + "..."
def titles():
return ["Quarterly report: sales", "Quarterly report: staff", "Quarterly review", "Release notes 4.1", "Release notes 4.2", "Board meeting minutes", "Board meeting agenda", "Q3"]
def survey(texts, top):
cases = 0
wide = 0
cut = 0
fwide = 0
fcut = 0
for w in range(0, top+1):
for s in texts:
cases = cases + 1
a = naive(s, w)
b = fit(s, w)
if len(a) > w:
wide = wide + 1
if len(s) <= w and a != s:
cut = cut + 1
if len(b) > w:
fwide = fwide + 1
if len(s) <= w and b != s:
fcut = fcut + 1
return [cases, wide, cut, fwide, fcut]
def collisions(texts, width):
by = {}
order = []
for s in texts:
k = fit(s, width)
if k in by:
by[k] = by[k] + [s]
else:
by[k] = [s]
order = order + [k]
out = []
for k in order:
if len(by[k]) > 1:
out = out + [[k, by[k]]]
return out
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 collapse(s):
out = ""
gap = False
for c in trim(s):
if c == " ":
gap = True
else:
if gap:
out = out + " "
gap = False
out = out + c
return out
def strip_all(s):
out = ""
for c in s:
if c != " ":
out = out + c
return out
def tidy(how, s):
if how == "trim":
return trim(s)
if how == "collapse":
return collapse(s)
return strip_all(s)
def values():
return ["Ann Lee", " Ann Lee", "Ann Lee", "Ann Lee ", "AB 12", "AB12", "A B12", "555 1234 567", "5551 234 567", " "]
def distinct(xs):
seen = {}
for x in xs:
seen[x] = 1
return len(seen)