disarm

Two strings, one difference

Why don't these two strings match?

The lookup missed. The uniqueness check accepted a duplicate. Two filenames collided that are not the same name. You have both strings, they look identical, and whatever separates them does not render. Paste them here to see which codepoints differ — and, more usefully, which one call makes them equal.

The tool

They diverge in 3 positions.

1 character is in A and not in B, and 2 are in B and not in A. A is 4 codepoints and 5 bytes; B is 5 and 6.

Where they differ

The two strings overlaid. Text they share is printed plainly; a badge marks a codepoint that belongs to only one of them, A or B. This is the part neither string can show you on its own.

cafAéU+00E9BeU+0065B◌́U+0301

What makes them equal

Every one of these was applied to both strings and the results compared, so this is what disarm does rather than what it is documented to do. The first row is the narrowest change that works, which is usually the one you want: the presets below it also fix this, and a good deal else besides.

CallRemovesBoth become
normalize(NFC) composed and decomposed spellings of the same letters café
normalize(NFKC) compatibility forms — fullwidth, ligatures, superscripts café
normalize_confusables lookalike characters from another script café
strip_obfuscation a preset: the deliberate hiding techniques together cafe
normalize_user_input a preset, for text arriving from a form café
canonicalize a preset: the general-purpose cleanup café
canonicalize_strict a preset: the same, less forgiving café
security_clean a preset, for text crossing a trust boundary café
search_key a preset: the key you would index on cafe

Running disarm 0.14.1, compiled to WebAssembly. Your text is never uploaded — the engine is loaded into this page and runs on your machine.

Five ways two strings differ invisibly

The tool above needs JavaScript. These are the cases it exists for, written out, so the result is legible without running anything.

What happenedAB What reconciles them
An accent stored two ways cafécafé normalize(NFC)
A zero-width space inside a word pay​palpaypal strip_zero_width_chars
A no-break space where a space was expected total duetotal due collapse_whitespace
Letters from another script, drawn the same раураlpaypal normalize_confusables
Fullwidth forms abcabc normalize(NFKC)

Measured against disarm 0.14.1. Every row was produced by applying the named function to both strings and comparing the results, which is also how the tool decides — it does not reason about what ought to work.

Take the narrowest answer

Several functions usually reconcile any given pair, and the tool lists them all. The first is the narrowest, and it is normally the one to use. A preset such as security_clean will fix a no-break space, but it will also strip invisibles, fold confusables and normalise the text, and if all you needed was collapse_whitespace then the rest is change you did not ask for and will not notice until it removes something you wanted.

The exception is a comparison key. If you are building the value a database indexes on, breadth is the point: you want every spelling of the same thing to collapse to one key, and a preset is doing that deliberately. Reach for the narrow function when you are repairing a value, and for the preset when you are deriving a key.

The same thing in your own code

Each code block has been compiled and verified in CI. Provided under the MIT license to illustrate disarm. disarm on GitHub →

# Two strings that render identically and do not compare equal.
#   pip install disarm
import unicodedata
from disarm import strip_zero_width_chars

# The account holder typed one of these; the lookup used the other.
STORED = "pay​pal"   # a zero-width space inside the word
TYPED = "paypal"

assert STORED != TYPED, "expected these to differ"
assert len(STORED) == len(TYPED) + 1

# Nothing about the rendered forms says why. The codepoints do.
diff = [c for c in STORED if c not in TYPED]
assert diff == ["​"], diff

# The narrowest call that reconciles them. A preset would also work and would
# change a great deal else besides.
assert strip_zero_width_chars(STORED) == TYPED

# The other common cause needs no disarm at all — just the right normal form.
assert "café" != "café"
assert unicodedata.normalize("NFC", "café") == "café"

print(f"ok: differ by {len(diff)} codepoint, reconciled by strip_zero_width_chars")

Found a pair this gets wrong? The confusables table grew out of exactly that kind of report. Open an issue with it.

Related tools