disarm

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

8 bytes / codepoints / characters, depending who you ask

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

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.

TextBytes CodepointsCharacters Columns
hello5555
café5444
café (decomposed)6544
👨‍👩‍👧‍👦25712
🇬🇧8212
👋🏽8212
🏳️‍🌈14412
नमस्ते18633
(precomposed)3112
(jamo)9312
世界6224

नमस्ते 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.

CutResultWhat 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")

Where a character limit actually matters

CaseUseWhy not codepoints
A post or message limitgrapheme_len A limit of 280 should mean what a reader counts. One family emoji costing seven of them is indefensible.
Username validationgrapheme_len Sanitize first, then measure, or the limit applies to text you are about to rewrite.
A database columngrapheme_truncate Truncating to fit can split a cluster and store a fragment that never renders correctly again.
Preview snippetsgrapheme_truncate The visible failure: a broken emoji in a card or a search result.
Monospace layoutterminal_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