← X character counter

49 conformance vectors for X’s weighted length

Open test data for anyone building a character counter for X — including the cases almost everybody gets wrong. Take them, run them against your own code, and tell us if we are the ones who are wrong.

X does not count characters. It assigns each character a weight and allows 280 weight per post. Latin letters, digits and common punctuation weigh 1; nearly everything else — CJK, Cyrillic, Arabic, Devanagari, every emoji — weighs 2. Every URL costs a flat 23 whatever its real length.

That much is widely known and still not enough. The rule is grapheme clusters, not code points: a complete emoji is one unit at weight 2 however many code points it is built from. A four-person family emoji is seven code points joined by zero-width joiners. Counting code points and weighting them gives 11. X charges 2.

We shipped that bug. Our counter over-counted joined emoji for weeks, and the test we had comparing our browser copy against our server copy passed every single run — because both copies were wrong in the same way. Two implementations agreeing with each other is evidence of consistency, not of correctness.

Which is the reason this page exists. Every counter in this category claims accuracy and none of them publish anything you can check. So here is ours, in a form that can falsify it.

Get the data

curl -s https://postsiete.com/tools/x-vectors.json | jq '.vectors | length'
# 49

Served as JSON with Access-Control-Allow-Origin: *, so a browser-based test suite can fetch it directly. MIT licensed. Each vector carries a why giving the arithmetic, so a disagreement points at a specific claim rather than at a number.

const { vectors } = await (await fetch(
  'https://postsiete.com/tools/x-vectors.json'
)).json();

for (const v of vectors) {
  const got = yourWeightedLength(v.text);
  if (got !== v.expected) {
    console.log(v.name, 'expected', v.expected, 'got', got, '—', v.why);
  }
}

How to falsify these

The expected values are derived from X’s published rules and stated as arithmetic — they are not snapshots of any implementation, ours included. That matters: a suite generated from an implementation can only ever prove that the implementation agrees with itself.

If you believe a vector is wrong, the fastest disproof is to post the string to X and see whether it is accepted at the length claimed. We would rather hear it than be wrong — tell us.

The vectors

basic · 7

Plain text in the light ranges. If these fail, nothing else matters.

NameInputWeightWhy
ascii-hellohello5Five Latin letters, all weight 1.
ascii-sentenceThe·quick·brown·fox·jumps·over·the·lazy·dog.4444 characters, every one inside the first light range.
newlines-countone\ntwo\nthree13Line feeds are U+000A — inside the light range, and they are charged, not ignored.
latin-accents-precomposedcafé4U+00E9 is a single code point below 0x1100, so 1 like the rest.
cyrillicпривет6Cyrillic sits at U+0400–U+04FF, inside the first light range. Weight 1, which surprises people who assume "non-Latin means 2".
hebrewשלום4U+05D0–U+05EA, inside the first light range.
thaiสวัสดี6Thai is U+0E00–U+0E7F, inside the light range. Six code points, six units — note this is more than the four grapheme clusters a reader sees.

heavy · 4

Characters outside the light ranges, which cost 2. CJK is the common case and the reason a 200-character Japanese post does not fit.

NameInputWeightWhy
japaneseこんにちは10Five kana at U+3053 upward, all above 0x10FF, so 2 each.
chinese你好世界8Four CJK ideographs, 2 each.
korean안녕하세요10Five precomposed Hangul syllables at U+AC00 upward, 2 each.
mixed-latin-cjkhello·こんにちは165 Latin (5) + 1 space (1) + 5 kana at 2 (10) = 16.

emoji · 9

The one-emoji-one-unit rule. This is where most implementations are wrong, and the failure is always in the same direction: too high.

NameInputWeightWhy
emoji-simple😀2One emoji, weight 2. Also the case everyone already passes.
emoji-with-variation-selector❤️2U+2764 plus U+FE0F. Two code points, ONE emoji, so 2 — not 1+1 and not 2+1.
emoji-skin-tone👍🏽2Thumbs up plus a skin-tone modifier is one emoji at 2, not two emoji at 4.
emoji-flag🇨🇴2Two regional indicators forming one flag: 2, not 4. A per-code-point counter reports 4.
emoji-zwj-mechanic🧑ZWJ🔧2Person + ZWJ + wrench = one emoji at 2. Per code point it is 200+100+200 = 5 after dividing by scale, and 5 is the number reported in twitter-text#369.
emoji-zwj-family👨ZWJ👩ZWJ👧ZWJ👦2Four people joined by three ZWJs — seven code points, one emoji, 2. Per code point it is (200*4 + 100*3)/100 = 11. If you get 11, you fell through to the per-code-point path. If you get 7, you counted code points and forgot the weights. If you get 11 in Python, `len()` gave you 7 and something else went wrong too.
emoji-two-adjacent😀😀4Two separate emoji, 2 each. Guards against a cluster walker that swallows neighbours.
emoji-in-proseship·it·🚀10"ship it " is 8 at weight 1, plus one emoji at 2.
emoji-keycap1️⃣2Digit + U+FE0F + U+20E3 is one keycap emoji at 2, not three units.

url · 8

The flat 23. The WEIGHT is canonical; which substrings are links is where honest implementations differ — see not-a-url.

NameInputWeightWhy
url-https-shorthttps://x.com23Flat 23 regardless of length — 13 characters charged as 23. Links can cost MORE than they are long.
url-https-longhttps://example.com/a/very/long/path?with=query&and=more#fragment23Same flat 23. 64 characters charged as 23.
url-www-no-schemewww.example.com23A www. host with no scheme is still linkified.
url-bare-domainexample.com23THE ONE THAT UNDERCOUNTS. No scheme, no www., still a link, still 23. A matcher requiring http(s):// or www. reports 11 and tells the author a post fits that the API will reject.
url-bare-domain-with-pathexample.com/pricing23The path is part of the link, so still one 23 rather than 23 plus the path.
url-in-proseread·this:·https://example.com·now38"read this: " is 11 characters at weight 1, the link is a flat 23, and " now" is 4. 11 + 23 + 4 = 38. The text either side of a link must keep its own weighting.
url-two-linkshttps://a.com·https://b.com4723 + 1 space + 23 = 47.
url-adjacent-to-cjkこんにちは·https://example.com345 kana at 2 (10) + 1 space + 23 = 34. Checks that the link split does not lose the weighting of the text around it.

not-a-url · 6

Text that must NOT be charged 23. Dotted tokens are everywhere in ordinary prose, and a spurious link is a worse error than a missed one.

NameInputWeightWhy
not-a-url-ege.g.·this·is·prose18"e.g." is not a link. Charging it 23 would report 37 for an 18-unit sentence and send an author rewriting copy that fits.
not-a-url-filenamesee·index.js·for·details24.js is not in the TLD list, so this is 24 plain characters. A counter matching any dotted token charges 23 for the filename.
not-a-url-versionversion·2.78.0·shipped22A version string is not a domain. The leading-digit guard is what stops this.
not-a-url-sentence-endIt·is·done.Then·we·ship.24A missing space after a full stop must not manufacture a domain out of two words.
not-a-url-almost-tldexample.coma12.coma is not a TLD, so this is twelve ordinary characters — which means "example.com" costs 23 and "example.coma" costs 12, and ADDING a character made the post cheaper. Surprising, correct, and the clearest demonstration that link detection is decided on the whole token rather than incrementally.
not-a-url-ellipsiswait...what11Consecutive dots are not a hostname.

normalisation · 2

The same visible string must cost the same however it was encoded.

NameInputWeightWhy
nfc-decomposed-accentcafé4"cafe" + U+0301 combining acute normalises under NFC to "café", four code points at weight 1. Without normalisation this is 5, so the same visible word costs differently depending on which editor produced it.
nfc-korean-decomposed가2Two conjoining jamo normalise under NFC to the single syllable 가 (U+AC00), which is above 0x10FF and so costs 2. Unnormalised, the jamo are also above 0x10FF and would cost 4.

boundary · 8

The exact edges of the published ranges. Off-by-one lives here.

NameInputWeightWhy
boundary-last-light-char1U+10FF is the last code point of the first light range. Inclusive, so 1.
boundary-first-heavy-char2U+1100 is one past the range. 2. Together with the previous case this pins the edge in both directions.
boundary-general-punctuation-start 1U+2000 opens the second light range.
boundary-zwj-aloneZWJ1A bare ZWJ is the last code point of the second range, weight 1. This is the value that makes the family emoji 11 rather than 14 when counted wrongly, so it is worth pinning on its own.
boundary-just-past-second-range2U+200E, the left-to-right mark, is one past the second range and costs 2. Invisible and expensive.
boundary-en-dash1U+2013 is inside 0x2010–0x201F. Dashes and smart quotes are cheap; that is the point of the third range.
boundary-prime1U+2032 opens the fourth range, 0x2032–0x2037.
boundary-just-past-fourth-range2One past the last light range.

structural · 5

Empty strings, whitespace, and the degenerate inputs that produce a crash rather than a wrong number.

NameInputWeightWhy
empty(empty)0Nothing costs nothing.
single-space·1U+0020 is inside 0x0000–0x10FF, so 1.
exactly-280-asciiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa280The limit itself. Fits — the comparison is <=, not <.
over-by-oneaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa281One over. The two cases together pin which way the boundary falls.
cjk-140-fills-280漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢280140 ideographs at 2 each is exactly the limit — the arithmetic behind "a Japanese post gets half as many characters".

Sources