<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P138-text-truncation/ | updated: 2026-10-17 -->

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

EML-P project `projects/text-truncation` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/text-truncation` runs it and `eml project verify projects/text-truncation` 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-truncate-ellipsis (https://efficientnewlanguage.org/cases/213-text-truncate-ellipsis/), whitespace-normalization-loss (https://efficientnewlanguage.org/cases/245-whitespace-normalization-loss/).

## Sessions

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

Input:

```text
0
x
1

1
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
1
abc

1
abc
61
1
abc
x
1
abc
0
3

3
99
3
0
5
```

Screen:

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

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

Input:

```text
1
Quarterly report: sales
12
1
Q3
10
1
Release notes
2
2
3
14
3
30
4
5
```

Screen:

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

## Modules

### main.eml

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

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

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

## README

# P138 - Text truncation

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-ups
- `text.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).
