Five lengths, one correct cut
Truncate text without breaking emoji
👨👩👧👦 is 25 bytes, 11 UTF-16 code units,
7 codepoints, 1 character and 2 terminal columns. Every one of those numbers is
right. Cut at the wrong one and the family becomes one man, or a smaller family,
or half a surrogate pair. Move the slider and watch the four cuts disagree.
The tool
Enter text to measure it.
Nothing is uploaded. The engine runs inside this page.
The same limit, four ways — only one of them disarm's
What the characters are made of
Your text split into grapheme clusters — one box per user-perceived character, with how many codepoints it took to build. The boxes holding more than one codepoint are the ones a naive cut can land inside.
Loading the engine…
Four lengths, all correct
The tool above needs JavaScript. This is the same measurement, written out, so the result is legible without running anything.
Five measures, four cuts. Terminal columns are measured but not cut, because a
column budget is a layout question rather than a slicing one: you fit text to a
width, which means choosing where to wrap or elide, not truncating at the
column-th unit. terminal_width and grapheme_width give
you the number; what you do with it is a decision the library cannot make.
| Text | Bytes | Codepoints | Characters | Columns |
|---|---|---|---|---|
| hello | 5 | 5 | 5 | 5 |
| café | 5 | 4 | 4 | 4 |
| café (decomposed) | 6 | 5 | 4 | 4 |
| 👨👩👧👦 | 25 | 7 | 1 | 2 |
| 🇬🇧 | 8 | 2 | 1 | 2 |
| 👋🏽 | 8 | 2 | 1 | 2 |
| 🏳️🌈 | 14 | 4 | 1 | 2 |
| नमस्ते | 18 | 6 | 3† | 3 |
| 한 (precomposed) | 3 | 1 | 1 | 2 |
| 각 (jamo) | 9 | 3 | 1 | 2 |
| 世界 | 6 | 2 | 2 | 4 |
† नमस्ते
counts three under Unicode 15.1's conjunct rule, which keeps
स्ते together as one cluster. Libraries
built against older tables report four, so a discrepancy against your own stack
is a version difference rather than a disagreement about the text.
Measured against disarm 0.14.1. Columns come from
terminal_width over UAX #11 East Asian Width, with ambiguous-width
characters counted as one. Note the last row: 世界 is the only entry
where the character count and the column count differ for an ordinary reason
rather than an emoji one — two characters, four columns.
Three ways to cut, two of them wrong
Take 👋🏽 hi and keep one character. The waving hand carries a skin
tone modifier, U+1F3FD, which is a separate codepoint sitting inside
the same cluster.
| Cut | Result | What happened |
|---|---|---|
grapheme_truncate(t, 1) | 👋🏽 | Lands on a cluster boundary. The modifier travels with the hand. |
t[:1] by codepoint | 👋 | Keeps the hand and drops the modifier, changing who is depicted. |
bytes[0..1] | � | Ends mid-sequence. The result is not valid UTF-8, and the renderer substitutes U+FFFD. |
The byte case is the one that gets caught, because invalid UTF-8 tends to raise an error somewhere. The codepoint case is the dangerous one: the output is perfectly valid text that says something the author did not write. A flag cut this way loses its partner regional indicator and renders as a bare letter; a family loses its family.
A cut is clean exactly when it falls on a boundary that
grapheme_split reports, which is how this page decides. The rule is
not a heuristic of its own — disarm segments the text, and any cut that is
not one of those prefixes has landed inside a character.
The same thing in your own code
Each code block has been compiled and verified. Provided under the MIT
license to illustrate disarm. The C example counts rather than truncates:
the C ABI exposes disarm_grapheme_len and
disarm_terminal_width but no split or truncate.
disarm on GitHub →
# Count what a reader sees, and cut without splitting it.
# pip install disarm
from disarm import grapheme_len, grapheme_truncate, terminal_width
# Four people joined by three zero-width joiners. Every one of these numbers is
# correct; they answer different questions.
FAMILY = "👨👩👧👦"
assert len(FAMILY.encode()) == 25 # bytes
assert len(FAMILY) == 7 # codepoints — what len() gives you
assert grapheme_len(FAMILY) == 1 # characters — what a reader counts
assert terminal_width(FAMILY) == 2 # columns
# Cutting by codepoints keeps the father and discards his family. Asserting on
# the output is the point: it is valid text that says the wrong thing.
text = FAMILY + "🎉"
assert text[:1] == "👨"
assert grapheme_truncate(text, 1) == FAMILY
# A skin tone modifier is a separate codepoint inside the same cluster, so a
# naive cut silently changes who is depicted.
assert "👋🏽 hi"[:1] == "👋"
assert grapheme_truncate("👋🏽 hi", 1) == "👋🏽"
print(f"ok: {len(FAMILY.encode())} bytes, {len(FAMILY)} codepoints, "
f"{grapheme_len(FAMILY)} character, {terminal_width(FAMILY)} columns")
// Count what a reader sees, and cut without splitting it.
// cargo add disarm
use disarm::api::{grapheme_len, grapheme_truncate, terminal_width};
fn main() {
// Four people joined by three zero-width joiners. Every one of these
// numbers is correct; they answer different questions.
let family = "👨👩👧👦";
assert_eq!(family.len(), 25); // bytes
assert_eq!(family.chars().count(), 7); // codepoints
assert_eq!(grapheme_len(family), 1); // characters
assert_eq!(terminal_width(family, false), 2); // columns
// Cutting by codepoints keeps the father and discards his family.
let text = format!("{family}🎉");
let naive: String = text.chars().take(1).collect();
assert_eq!(naive, "👨");
assert_eq!(grapheme_truncate(&text, 1), family);
// A skin tone modifier is a separate codepoint inside the same cluster.
assert_eq!("👋🏽 hi".chars().take(1).collect::<String>(), "👋");
assert_eq!(grapheme_truncate("👋🏽 hi", 1), "👋🏽");
println!(
"ok: {} bytes, {} codepoints, {} character, {} columns",
family.len(),
family.chars().count(),
grapheme_len(family),
terminal_width(family, false)
);
}
// Count what a reader sees, and cut without splitting it.
// npm install disarm
const { graphemeLen, graphemeTruncate, terminalWidth } = require("disarm");
// Four people joined by three zero-width joiners. Every one of these numbers is
// correct; they answer different questions. Note that JavaScript's own .length
// is a third answer again — UTF-16 code units, not codepoints.
const FAMILY = "👨👩👧👦";
const bytes = Buffer.byteLength(FAMILY, "utf8");
const cps = [...FAMILY].length;
if (bytes !== 25) throw new Error("expected 25 bytes");
if (cps !== 7) throw new Error("expected 7 codepoints");
if (FAMILY.length !== 11) throw new Error("expected 11 UTF-16 code units");
if (graphemeLen(FAMILY) !== 1) throw new Error("expected 1 character");
if (terminalWidth(FAMILY) !== 2) throw new Error("expected 2 columns");
// Cutting by codepoints keeps the father and discards his family.
const text = FAMILY + "🎉";
if ([...text].slice(0, 1).join("") !== "👨") throw new Error("expected a lone father");
if (graphemeTruncate(text, 1) !== FAMILY) throw new Error("expected the whole family");
// A skin tone modifier is a separate codepoint inside the same cluster.
if ([..."👋🏽 hi"].slice(0, 1).join("") !== "👋") throw new Error("expected a bare hand");
if (graphemeTruncate("👋🏽 hi", 1) !== "👋🏽") throw new Error("expected the modifier kept");
console.log(
`ok: ${bytes} bytes, ${cps} codepoints, ${graphemeLen(FAMILY)} character, ${terminalWidth(FAMILY)} columns`,
);
# Count what a reader sees, and cut without splitting it.
# gem install disarm
require "disarm"
# Four people joined by three zero-width joiners. Every one of these numbers is
# correct; they answer different questions.
FAMILY = "👨👩👧👦"
raise "expected 25 bytes" unless FAMILY.bytesize == 25
raise "expected 7 codepoints" unless FAMILY.length == 7
raise "expected 1 character" unless Disarm.grapheme_len(FAMILY) == 1
raise "expected 2 columns" unless Disarm.terminal_width(FAMILY) == 2
# Cutting by codepoints keeps the father and discards his family.
text = FAMILY + "🎉"
raise "expected a lone father" unless text[0, 1] == "👨"
raise "expected the whole family" unless Disarm.grapheme_truncate(text, 1) == FAMILY
# A skin tone modifier is a separate codepoint inside the same cluster.
raise "expected a bare hand" unless "👋🏽 hi"[0, 1] == "👋"
raise "expected the modifier kept" unless Disarm.grapheme_truncate("👋🏽 hi", 1) == "👋🏽"
puts "ok: #{FAMILY.bytesize} bytes, #{FAMILY.length} codepoints, " \
"#{Disarm.grapheme_len(FAMILY)} character, #{Disarm.terminal_width(FAMILY)} columns"
// Count what a reader sees, and cut without splitting it.
// implementation("dev.disarm:disarm:0.14.1")
import dev.disarm.Disarm;
import java.nio.charset.StandardCharsets;
public class CountGraphemes {
public static void main(String[] args) {
// Four people joined by three zero-width joiners. Every one of these
// numbers is correct; they answer different questions. Java's own
// String.length() is a fourth answer: UTF-16 code units.
String family = "👨👩👧👦";
int bytes = family.getBytes(StandardCharsets.UTF_8).length;
int cps = family.codePointCount(0, family.length());
// Java's `assert` is disabled unless the JVM is started with -ea.
if (bytes != 25) throw new IllegalStateException("expected 25 bytes");
if (cps != 7) throw new IllegalStateException("expected 7 codepoints");
if (family.length() != 11) throw new IllegalStateException("expected 11 UTF-16 units");
if (Disarm.graphemeLen(family) != 1) throw new IllegalStateException("expected 1 character");
if (Disarm.terminalWidth(family) != 2) throw new IllegalStateException("expected 2 columns");
// Cutting by codepoints keeps the father and discards his family.
String text = family + "🎉";
String naive = new String(text.codePoints().limit(1).toArray(), 0, 1);
if (!naive.equals("👨")) throw new IllegalStateException("expected a lone father");
if (!Disarm.graphemeTruncate(text, 1).equals(family)) {
throw new IllegalStateException("expected the whole family");
}
// A skin tone modifier is a separate codepoint inside the same cluster.
if (!Disarm.graphemeTruncate("👋🏽 hi", 1).equals("👋🏽")) {
throw new IllegalStateException("expected the modifier kept");
}
System.out.printf("ok: %d bytes, %d codepoints, %d character, %d columns%n",
bytes, cps, Disarm.graphemeLen(family), Disarm.terminalWidth(family));
}
}
// Count what a reader sees, and cut without splitting it.
// implementation("dev.disarm:disarm-kotlin:0.14.1")
import dev.disarm.Disarm
fun main() {
// Four people joined by three zero-width joiners. Every one of these
// numbers is correct; they answer different questions.
val family = "👨👩👧👦"
val bytes = family.toByteArray(Charsets.UTF_8).size
val cps = family.codePointCount(0, family.length)
check(bytes == 25) { "expected 25 bytes" }
check(cps == 7) { "expected 7 codepoints" }
check(family.length == 11) { "expected 11 UTF-16 units" }
check(Disarm.graphemeLen(family) == 1L) { "expected 1 character" }
check(Disarm.terminalWidth(family) == 2L) { "expected 2 columns" }
// Cutting by codepoints keeps the father and discards his family.
val text = family + "🎉"
val naive = String(text.codePoints().limit(1).toArray(), 0, 1)
check(naive == "👨") { "expected a lone father" }
check(Disarm.graphemeTruncate(text, 1) == family) { "expected the whole family" }
// A skin tone modifier is a separate codepoint inside the same cluster.
check(Disarm.graphemeTruncate("👋🏽 hi", 1) == "👋🏽") { "expected the modifier kept" }
println("ok: $bytes bytes, $cps codepoints, ${Disarm.graphemeLen(family)} character, " +
"${Disarm.terminalWidth(family)} columns")
}
/* Count what a reader sees.
*
* The C ABI exposes disarm_grapheme_len and disarm_terminal_width but neither
* grapheme_split nor grapheme_truncate, so this example counts rather than
* truncates. The counting is the argument anyway: four measures, four answers.
*
* Links against a cdylib built from bindings/cabi in the disarm repository.
*/
#include <inttypes.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "disarm.h"
/* Four people joined by three zero-width joiners. */
static const char *FAMILY = "👨👩👧👦";
/* Codepoints are the UTF-8 bytes that are not continuation bytes. */
static uint64_t codepoints(const char *s) {
uint64_t n = 0;
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
if ((*p & 0xC0) != 0x80) n++;
}
return n;
}
int main(void) {
uint64_t bytes = (uint64_t)strlen(FAMILY);
uint64_t cps = codepoints(FAMILY);
uint64_t graphemes = disarm_grapheme_len(FAMILY);
/* false: ambiguous East Asian width counts as one column, which is what a
* modern UTF-8 terminal does. */
uint64_t columns = disarm_terminal_width(FAMILY, false);
if (bytes != 25 || cps != 7 || graphemes != 1 || columns != 2) {
fprintf(stderr, "unexpected measurements\n");
return 1;
}
printf("ok: %" PRIu64 " bytes, %" PRIu64 " codepoints, %" PRIu64 " character,"
" %" PRIu64 " columns\n", bytes, cps, graphemes, columns);
return 0;
}
Where a character limit actually matters
| Case | Use | Why not codepoints |
|---|---|---|
| A post or message limit | grapheme_len | A limit of 280 should mean what a reader counts. One family emoji costing seven of them is indefensible. |
| Username validation | grapheme_len | Sanitize first, then measure, or the limit applies to text you are about to rewrite. |
| A database column | grapheme_truncate | Truncating to fit can split a cluster and store a fragment that never renders correctly again. |
| Preview snippets | grapheme_truncate | The visible failure: a broken emoji in a card or a search result. |
| Monospace layout | terminal_width | Cluster count is not column count. A CJK character is one cluster and two columns. |
The grapheme guide
covers the Text builder forms and the
ambiguous_wide policy for legacy double-width terminals. One
documented limitation is worth repeating: segmentation depends on Unicode tables,
so a brand-new emoji sequence may be split across clusters until those tables are
updated.
Related tools
- Sanitize a filename — the other place a length limit counts bytes and cuts in the wrong place.
- Detect zalgo text — combining marks stacked deep, which are clusters too.
- Remove invisible characters — the zero-width joiners that hold these clusters together, in their hostile use.