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.
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.
| Call | Removes | Both 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 happened | A | B | What reconciles them |
|---|---|---|---|
| An accent stored two ways | café | café | normalize(NFC) |
| A zero-width space inside a word | paypal | paypal | strip_zero_width_chars |
| A no-break space where a space was expected | total due | total due | collapse_whitespace |
| Letters from another script, drawn the same | раураl | paypal | normalize_confusables |
| Fullwidth forms | abc | abc | 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 = "paypal" # 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")
// Two strings that render identically and do not compare equal.
// cargo add disarm
use disarm::api::{normalize, strip_zero_width_chars, NormalizationForm};
fn main() {
// The account holder typed one of these; the lookup used the other.
let stored = "pay\u{200b}pal"; // a zero-width space inside the word
let typed = "paypal";
assert_ne!(stored, typed, "expected these to differ");
assert_eq!(stored.chars().count(), typed.chars().count() + 1);
// Nothing about the rendered forms says why. The codepoints do.
let diff: Vec<char> = stored.chars().filter(|c| !typed.contains(*c)).collect();
assert_eq!(diff, vec!['\u{200b}']);
// The narrowest call that reconciles them. A preset would also work and
// would change a great deal else besides.
assert_eq!(strip_zero_width_chars(stored), typed);
// The other common cause needs no cleanup at all — just the right form.
assert_ne!("caf\u{e9}", "cafe\u{301}");
assert_eq!(normalize("cafe\u{301}", NormalizationForm::Nfc), "caf\u{e9}");
println!(
"ok: differ by {} codepoint, reconciled by strip_zero_width_chars",
diff.len()
);
}
// Two strings that render identically and do not compare equal.
// npm install disarm
const { stripZeroWidthChars } = require("disarm");
// The account holder typed one of these; the lookup used the other.
const STORED = "paypal"; // a zero-width space inside the word
const TYPED = "paypal";
if (STORED === TYPED) throw new Error("expected these to differ");
if ([...STORED].length !== [...TYPED].length + 1) throw new Error("unexpected lengths");
// Nothing about the rendered forms says why. The codepoints do.
const diff = [...STORED].filter((c) => !TYPED.includes(c));
if (diff.length !== 1 || diff[0] !== "") throw new Error("unexpected difference");
// The narrowest call that reconciles them. A preset would also work and would
// change a great deal else besides.
if (stripZeroWidthChars(STORED) !== TYPED) throw new Error("not reconciled");
// The other common cause needs no cleanup at all — just the right normal form.
if ("café" === "café") throw new Error("expected these to differ");
if ("café".normalize("NFC") !== "café") throw new Error("NFC did not compose");
console.log(`ok: differ by ${diff.length} codepoint, reconciled by strip_zero_width_chars`);
# Two strings that render identically and do not compare equal.
# gem install disarm
require "disarm"
# The account holder typed one of these; the lookup used the other.
STORED = "paypal" # a zero-width space inside the word
TYPED = "paypal"
raise "expected these to differ" if STORED == TYPED
raise "unexpected lengths" unless STORED.length == TYPED.length + 1
# Nothing about the rendered forms says why. The codepoints do.
diff = STORED.chars.reject { |c| TYPED.include?(c) }
raise "unexpected difference" unless diff == [""]
# The narrowest call that reconciles them. A preset would also work and would
# change a great deal else besides.
raise "not reconciled" unless Disarm.strip_zero_width_chars(STORED) == TYPED
# The other common cause needs no cleanup at all — just the right normal form.
raise "expected these to differ" if "café" == "café"
raise "NFC did not compose" unless "café".unicode_normalize(:nfc) == "café"
puts "ok: differ by #{diff.size} codepoint, reconciled by strip_zero_width_chars"
// Two strings that render identically and do not compare equal.
// implementation("dev.disarm:disarm:0.14.1")
import dev.disarm.Disarm;
import java.text.Normalizer;
public class CompareStrings {
public static void main(String[] args) {
// The account holder typed one of these; the lookup used the other.
// Written as a codepoint rather than a backslash-u escape, because javac
// resolves those before it tokenises.
String zwsp = Character.toString(0x200B);
String stored = "pay" + zwsp + "pal";
String typed = "paypal";
// Java's `assert` is disabled unless the JVM is started with -ea.
if (stored.equals(typed)) throw new IllegalStateException("expected these to differ");
if (stored.length() != typed.length() + 1) {
throw new IllegalStateException("unexpected lengths");
}
// Nothing about the rendered forms says why. The codepoints do.
long diff = stored.codePoints().filter(cp -> typed.indexOf(cp) < 0).count();
if (diff != 1) throw new IllegalStateException("unexpected difference");
// The narrowest call that reconciles them. A preset would also work and
// would change a great deal else besides.
if (!Disarm.stripZeroWidthChars(stored).equals(typed)) {
throw new IllegalStateException("not reconciled");
}
// The other common cause needs no cleanup at all — just the right form.
String composed = "café";
String decomposed = "café";
if (composed.equals(decomposed)) throw new IllegalStateException("expected these to differ");
if (!Normalizer.normalize(decomposed, Normalizer.Form.NFC).equals(composed)) {
throw new IllegalStateException("NFC did not compose");
}
System.out.printf("ok: differ by %d codepoint, reconciled by strip_zero_width_chars%n", diff);
}
}
// Two strings that render identically and do not compare equal.
// implementation("dev.disarm:disarm-kotlin:0.14.1")
import dev.disarm.Disarm
import java.text.Normalizer
fun main() {
// The account holder typed one of these; the lookup used the other.
val zwsp = String(Character.toChars(0x200B))
val stored = "pay${zwsp}pal"
val typed = "paypal"
check(stored != typed) { "expected these to differ" }
check(stored.length == typed.length + 1) { "unexpected lengths" }
// Nothing about the rendered forms says why. The codepoints do. Kotlin's
// String.indexOf takes a Char or a String, not the Int codepoint that
// Java's overload accepts, so compare against the string each spells.
val diff = stored.codePoints()
.filter { cp -> !typed.contains(String(Character.toChars(cp))) }
.count()
check(diff == 1L) { "unexpected difference" }
// The narrowest call that reconciles them. A preset would also work and
// would change a great deal else besides.
check(Disarm.stripZeroWidthChars(stored) == typed) { "not reconciled" }
// The other common cause needs no cleanup at all — just the right form.
val composed = "café"
val decomposed = "café"
check(composed != decomposed) { "expected these to differ" }
check(Normalizer.normalize(decomposed, Normalizer.Form.NFC) == composed) { "NFC did not compose" }
println("ok: differ by $diff codepoint, reconciled by strip_zero_width_chars")
}
/* Two strings that render identically and do not compare equal.
*
* Links against a cdylib built from bindings/cabi in the disarm repository.
* The C ABI has no normalization entry point, so the NFC half of the story is
* left to the other bindings; the zero-width case is the one that matters most
* in C anyway, where strings are bytes and nothing normalises anything.
*/
#include <stdio.h>
#include <string.h>
#include "disarm.h"
int main(void) {
/* The account holder typed one of these; the lookup used the other. */
const char *stored = "paypal";
const char *typed = "paypal";
if (strcmp(stored, typed) == 0) {
fprintf(stderr, "expected these to differ\n");
return 1;
}
/* The zero-width space is three bytes in UTF-8. */
if (strlen(stored) != strlen(typed) + 3) {
fprintf(stderr, "unexpected lengths\n");
return 1;
}
/* The narrowest call that reconciles them. */
char *cleaned = disarm_strip_zero_width_chars(stored);
int ok = strcmp(cleaned, typed) == 0;
disarm_string_free(cleaned);
if (!ok) {
fprintf(stderr, "not reconciled\n");
return 1;
}
printf("ok: differ by 1 codepoint, reconciled by strip_zero_width_chars\n");
return 0;
}
Found a pair this gets wrong? The confusables table grew out of exactly that kind of report. Open an issue with it.
Related tools
- Remove invisible characters — when the difference turns out to be something that renders as nothing.
- Normalize Unicode whitespace — when it turns out to be a blank that is not a space.
- Check confusable characters — when it turns out to be a letter from another script.
- Sanitize a filename — the same mismatch, in the place it bites hardest.