Toolkit Labs

Toolkit Labs datasets

UNICODE-300 — 300 strings that break naive text handling

UNICODE-300 is a labelled corpus of 300 short strings, 30 in each of ten categories, built from a named inventory of Unicode building blocks: combining marks, ZWJ emoji sequences, codepoints beyond the BMP, variation selectors, bidi controls, zero-width characters, Cyrillic and Greek homoglyphs, NFKC compatibility characters, case-mapping oddities, and whitespace that is not U+0020.

It is for the code that stores, truncates, slugifies, compares or validates text a stranger typed — the place where len(), a database column limit and a browser disagree about how long a string is.

Every property is measured, not asserted

Each case carries codepoint count, UTF-8 byte length, UTF-16 code-unit length, NFC stability, NFKC length, upper and lower lengths, case round-trip stability, and flags for combining marks, zero-width characters, bidi controls, astral codepoints and variation selectors. All of it is computed by Python's unicodedata at build time, on UCD version 15.0.0, which is recorded in kit.json. The category is the construction rule, so a label cannot be wrong; the numbers are re-derivable by you.

categorycaseswhat it breaks
combining30len() counts codepoints, not what a reader sees: truncating here can leave a combining mark attached to the wrong letter, or standing alone.
emoji_zwj30one visible glyph, several codepoints joined by U+200D: a per-character loop, a reverse, or a hard character limit splits it into pieces that render as strangers.
astral30beyond the BMP: one codepoint is two UTF-16 code units, so JavaScript's .length, a UTF-16 column limit and Python's len() disagree about the same string.
variation_selector30an invisible U+FE0F/U+FE0E decides text or emoji presentation; strip it and the glyph changes, keep it and the length is one more than it looks.
bidi_control30invisible direction overrides reorder what is displayed without changing what is stored, so a filename or a name can read differently than it compares.
zero_width30invisible codepoints survive trim(), slip through many alphanumeric checks, and make two identical-looking identifiers unequal.
homoglyph30Cyrillic and Greek letters that look like ASCII. NFKC does NOT fold them, so normalising is not a defence against a lookalike.
nfkc_fold30compatibility characters whose NFKC form has a different length, so validating before normalising and storing after gives two different strings.
case_mapping30case conversion is not length-preserving and not always reversible: upper and lower round-trips change these strings.
whitespace30spaces and line terminators that are not U+0020 or U+000A: splitlines() breaks on some of them, strip() does not remove others.

The free slice, and the checker

Twenty cases — two per category, the two lowest ids — are public, together with the whole checker. No account, no email address, nothing to agree to.

curl -O https://toolkitlabs.org/unicode300/unicode300-free.zip
unzip unicode300-free.zip && cd unicode300-free
python3 check.py --fn controls:ascii_fold --cases open20.jsonl

check.py runs your own function over every case and counts three things as failures: it raised, it returned a non-string, or it returned a string that cannot be encoded as UTF-8. Everything else is reported as an observation per category, because whether stripping a zero-width character is right depends on what your function is for.

What the three control functions do to all 300

Produced by running check.py over the full corpus at build time — the same command, shipped in both kits, so you can reproduce every number here:

functionhard failuresemptiedchangedorphan_combiningzero_width_keptbidi_keptastral_lost
identity000060300
ascii_fold0029400060
truncate8001980603012

identity returns the text unchanged, ascii_fold is the classic NFKD-then-drop-non-ASCII slug step, and truncate8 is a column that holds eight characters. Read the table as what each one did, not as a verdict.

What is in the free download

filebytes
README.md3,170
open20.jsonl14,387
check.py3,840
controls.py578
kit.json390
LICENSE-CC0.txt467

Zipped: 6,907 bytes. Everything in it is CC0 1.0 — public domain, no attribution required, commercial use included, yours whether you buy anything or not.

The paid tier is the other 280 cases

EUR 19 buys the complete 300-case corpus as JSONL, the same corpus as a flat CSV with one column per measured property, and the same checker and control functions. Nothing is encrypted and nothing is time-limited.

Full corpus — EUR 19

Instant download after payment; digital goods, delivered immediately. Payments are processed by Stripe. Prices include VAT where applicable.

Verifying nothing moved

Corpus SHA-256 e3e1c76e836bd259c571967f0210bc7cd1e081b808720b137ae0cafed934b780, over the 300-line JSONL exactly as shipped. kit.json in both downloads carries the same digest and the UCD version, so you can prove the cases were not changed after these numbers were published.

What this is not