Čištění textu, který přišel odjinud

Neviditelné znaky, zaměněná interpunkce, konce řádků, normalizační formy a pravidla velikosti písmen, kvůli kterým vložený text zlobí, a pořadí, ve kterém je opravit.

Text, který přišel z PDF, z buňky tabulky, z pole v CMS, z e-mailového klienta nebo z chatovací aplikace, s sebou nese formátovací rozhodnutí toho, co ho vyrobilo, a téměř žádné z těch rozhodnutí není na obrazovce vidět. Výsledkem je řetězec, který vypadá správně, správně se vytiskne a pak bez zjevného důvodu selže při porovnání, hledání, parsování JSON nebo vyhledání v databázi.

Znaky, které nevidíte

Pro Unicode jsou to plnohodnotné znaky. Jen nemají viditelný tvar, nebo mají stejný tvar jako něco jiného.

ZnakKódový bodOdkud obvykle přicházíCo rozbije
Nezlomitelná mezeraU+00A0  v HTML, Word, sazba PDFdělení podle mezery, přesné shody
Úzká nezlomitelná mezeraU+202Ffrancouzská typografie, data z Wordutotéž, jen méně nápadně
Ideografická mezeraU+3000vstupní metody CJKvypadá jako široká mezera
Mezera nulové šířkyU+200Bnápovědy pro zalomení v CMS, kopírovaný text z webuneviditelná a není to bílý znak
Nespojovník / spojovník nulové šířkyU+200C, U+200Dperština, indická písma, sekvence emojihranice slov, počty znaků
Spojovač slovU+2060sázecí nástrojenic viditelného, všechno textové
Měkký spojovníkU+00ADdělení slov ve Wordu, exporty do PDFslovo přestane odpovídat samo sobě
Byte order markU+FEFFprvní bajty souboru v UTF-8první pole prvního řádku
Značka zleva doprava / zprava dolevaU+200E, U+200Fobousměrný textzatoulané značky kolem čísel
Oddělovač řádku a odstavceU+2028, U+2029některé aplikace na Macu a sázecí programydělení na řádky, starší parsery JavaScriptu

Hledání selže proto, že porovnává kódové body, ne tvary. Když vám PDF dalo New U+00A0 York a vy napíšete New York s obyčejnou mezerou U+0020, jsou ty dva řetězce různé a nic vám neřekne proč.

"New York".includes("New York")   false, the gap is U+00A0
"  text​".trim().length      5, the zero-width space survives

Nezlomitelná mezera se pro většinu funkcí trim a pro \s ve většině regexových enginů počítá jako bílý znak, takže na okrajích řetězce zmizí a uprostřed přežije, přesně tam, kde dělení očekává obyčejnou mezeru. Mezera nulové šířky není bílým znakem nikde, takže ji ořezání ani slučování nechají být. Právě proto může Email Extractor z textu zkopírovaného z poštovního klienta nevrátit nic: jediný znak nulové šířky uvnitř adresy zabrání tomu, aby vzor odpovídal.

Hidden Character Detector ukáže, co tam doopravdy je, a Unicode Escape vypíše přesné kódové body jedné hodnoty. Nemažte ale všechno, na co narazíte: spojovník nulové šířky uvnitř sekvence emoji a nespojovník nulové šířky v perštině nebo dévanágarí jsou obsah, ne šum.

Interpunkce, kterou vám změnil textový editor

Automatické opravy nahrazují znaky typografickými už při psaní a tato náhrada odejde s textem i z dokumentu.

Napsali jsteTeď máteKódový bod
'pravou jednoduchou uvozovkuU+2019
" a "levou a pravou dvojitou uvozovkuU+201C, U+201D
- mezi slovypomlčku nebo dlouhou pomlčkuU+2013, U+2014
...výpustkuU+2026
- před číslemznak minus nebo nezlomitelný spojovníkU+2212, U+2011

V próze je to v pořádku, ve všem, co parsuje stroj, je to smrtelné.

{ “name”: “Ada” }      unexpected token, only U+0022 is a JSON string quote
git commit -m “fix”    the shell sees three words, not one quoted argument
WHERE name = ‘Ada’     SQL syntax error near ‘
10\u201320                  not a number range, U+2013 is not a hyphen

V CSV je selhání tišší. Parser uznává jako oddělovač pole jen rovnou dvojitou uvozovku, takže hodnota, kterou textový editor obalil kudrnatými uvozovkami, se považuje za neuvozenou; každá čárka uvnitř ní pak řádek rozdělí a všechny sloupce za ní se posunou. Sloupec, jehož záporná čísla používají U+2212, se naimportuje jako text a součty ho tiše vynechají.

Řešením je Find and Replace s krátkým seznamem náhrad, ale používejte ho jen na text, který míří do kódu, CSV nebo klíče. Když ho pustíte na prózu, kterou publikujete, zplošťí interpunkci, která tam byla záměrně.

Konce řádků a bílé znaky na konci

Nástroje na Windows ukončují řádek dvojicí CR LF (U+000D U+000A), unixové nástroje samotným LF a pár velmi starých exportů z Macu stále používá samotné CR. Většina parserů si poradí. Naivní dělení ne.

"UK\r\n" split on "\n"  ->  "UK\r"
"UK\r" === "UK"             false
"UK\r".length               3

Dva řetězce, které se v tabulce, logu nebo v zobrazení rozdílů vykreslí stejně, se pořád mohou lišit o návrat vozíku, mezeru na konci nebo tabulátor. Když Text Diff označí řádek jako změněný a žádná změna není vidět, je to právě tohle. Je to také to, co vloží zatoulanou mezeru dovnitř uvozovek, když vložený sloupec projde nástrojem Quote Lines nebo Join Lines.

Normalizujte jedním průchodem, kdy se CR LF i osamocené CR zachytí najednou (\r\n? nahrazeno za \n), jinak dvoukroková náhrada zdvojí prázdné řádky.

Jedno písmeno, dva způsoby zápisu

Unicode dovoluje, aby písmeno s diakritikou bylo jediný kódový bod, nebo základní písmeno následované kombinační značkou. Obojí je správně a nerovnají se.

Formaé je uloženo jakoKódových jednotek v JavaScriptu
NFC (složená)U+00E91
NFD (rozložená)U+0065 U+03012

macOS historicky vydává rozložené názvy souborů a několik generátorů PDF i vstupních metod produkuje rozložený text, takže hodnota z jednoho zdroje se neshoduje s toutéž hodnotou napsanou na klávesnici.

"é" === "é"                  false
"é".normalize("NFC") === "é" true

Následky sahají za pouhou rovnost. Řazení, které porovnává kódové body, umístí rozložené tvary vedle prostého e a složené tvary daleko od nich, takže se seznam jmen vrátí ve dvou shlucích. Počty znaků se mezi oběma tvary liší, což hraje roli u limitu 280 znaků nebo u sloupce VARCHAR(50), a Text Statistics hlásí u zdánlivě stejného textu různá čísla. Zkracování je horší: rozříznutí rozloženého řetězce na pevné délce může vést mezi základním písmenem a jeho diakritikou, takže se ta diakritika připojí k tomu, co následuje, a Truncate Text tak na nenormalizovaném vstupu může vyrobit viditelně chybný poslední znak.

Kompatibilní tvary NFKC a NFKD jdou dál a slučují znaky, které se jiným jen podobají: ligatura U+FB01 se stane fi, v plné šířce U+FF21 se stane A, znak mikro U+00B5 se stane řeckým mí U+03BC a horní index ² se stane obyčejnou 2. To je skvělé pro klíč k hledání nebo k odstranění duplicit a ničivé pro cokoli, co zobrazujete, protože se tiše promění v x2.

Praktické pravidlo: NFC pro ukládání a zobrazení, NFKC jen pro klíče, které nikdo nikdy nevidí.

Převod velikosti písmen není jedna operace

Převod na velká písmena není mapování znak po znaku a není nezávislý na lokalizaci.

  • Německé ß U+00DF se na velká písmena převede jako SS, takže se řetězec prodlouží a cesta zpět na malá písmena původní hodnotu nevrátí.
  • Řecká sigma se na konci slova zmenší na ς a jinde na σ, což opět rozbije cestu tam a zpět.
  • Turečtina a ázerbájdžánština mají ı bez tečky U+0131 a velké İ s tečkou U+0130. V těchto lokalizacích se I zmenší na ı a i zvětší na İ.

Ten poslední případ je klasická produkční chyba. Kód, který převádí na malá písmena název hlavičky, příponu souboru nebo řetězec protokolu, funguje všude, dokud neběží na stroji s turečtinou jako výchozí lokalizací, kde se "FILE" zmenší na fıle a přestane odpovídat file. V Javě a .NET používají bezparametrové metody výchozí lokalizaci stroje, takže toUpperCase() a ToUpper() jsou past; u čehokoli strojově čitelného uveďte invariantní lokalizaci. Pro porovnávání je case folding (casefold() v Pythonu) lepší než převod na malá písmena, protože zachází s ß jako se ss.

Title case nemá jedinou definici, a proto „velké písmeno u každého slova“ vyrobí „The Lord Of The Rings“ a „IPhone“. Většina redakčních příruček píše velké písmeno u prvního a posledního slova a u všeho kromě členů, souřadicích spojek a krátkých předložek, u složenin se spojovníkem nechává velké obě části a nikdy nesahá na zkratky ani na jména s velkým písmenem uvnitř, jako McDonald, O'Brien nebo iPhone.

Programátorské konvence zápisu mají vlastní problém s hranicemi. Převod HTTPResponseCode na snake case zcela závisí na tom, jak dělič naloží se sérií velkých písmen; Case Converter dá http_response_code, naivní dělič dá h_t_t_p_response_code.

Pořadí, které funguje

Každý krok předpokládá, že proběhl ten předchozí. Ve špatném pořadí si navzájem překážejí.

  1. Vyřešte kódování. Mojibake jako café znamená, že se bajty dekódovaly ve špatném kódování, takže původní bajty dekódujte znovu, místo abyste látali příznaky. BOM odstraňte jen na úplném začátku textu.
  2. Sjednoťte konce řádků na LF jedním průchodem.
  3. Odstraňte formátovací znaky, které jste vyhodnotili jako šum: měkké spojovníky, mezery nulové šířky, spojovače slov, obousměrné značky. Udělejte to před normalizací, protože NFC nedokáže složit základní písmeno s jeho diakritikou přes neviditelný znak, který sedí mezi nimi.
  4. Nahraďte zaměnitelnou interpunkci, pokud je cílem kód, CSV, JSON nebo klíč.
  5. Aplikujte normalizaci Unicode, ve výchozím stavu NFC.
  6. Teď se pusťte do bílých znaků. Zbylé exotické mezery převeďte na U+0020, sloučte jejich série a pak ořežte okraje. Když to uděláte před krokem 3, zůstanou dvojité mezery všude tam, kde se nezlomitelná mezera potkala s obyčejnou, a ořezání před krokem 2 nechá návrat vozíku přilepený k poslední hodnotě na každém řádku.
  7. Velikost písmen převádějte až nakonec, s výslovně uvedenou lokalizací.

Operace na úrovni slov patří rovněž až za krok 3. Dělení textu nástrojem Split Text, dokud v něm zůstávají měkké spojovníky, dá nafouknuté počty a půlky slov, a totéž platí pro cokoli, co prochází text a hledá výrazy, včetně Censor Text.

Když se hodnota po tom všem pořád odmítá shodovat, přestaňte se na ni dívat. Vypište její délku, převeďte ji na kódové body a porovnejte obě escapované podoby přímo; tam je rozdíl zřejmý způsobem, jakým na obrazovce nikdy nebude.