Porządkowanie tekstu, który przyszedł skądinąd
Tekst, który przyszedł z pliku PDF, z komórki arkusza, z pola w systemie CMS, z programu pocztowego albo z komunikatora, niesie z sobą decyzje formatujące tego, co go wyprodukowało, a prawie żadna z tych decyzji nie jest widoczna na ekranie. Efektem jest ciąg znaków, który wygląda poprawnie, drukuje się poprawnie, a potem bez widocznego powodu przewraca porównanie, wyszukiwanie, parsowanie JSON albo odczyt z bazy danych.
Znaki, których nie widać
Dla Unicode to prawdziwe znaki. Po prostu nie mają widocznego kształtu albo mają ten sam kształt co coś innego.
| Znak | Punkt kodowy | Zwykle przychodzi z | Co psuje |
|---|---|---|---|
| Spacja niełamliwa | U+00A0 | w HTML, Word, układ strony PDF | dzielenie po spacji, dokładne dopasowania |
| Wąska spacja niełamliwa | U+202F | typografia francuska, daty z Worda | to samo, tylko mniej oczywiście |
| Spacja ideograficzna | U+3000 | metody wprowadzania CJK | wygląda jak szeroka przerwa |
| Spacja zerowej szerokości | U+200B | podpowiedzi łamania wiersza w CMS, tekst kopiowany ze stron | niewidoczna i nie jest znakiem odstępu |
| Łącznik i rozłącznik zerowej szerokości | U+200C, U+200D | teksty perskie i indyjskie, sekwencje emoji | granice wyrazów, liczenie znaków |
| Word joiner | U+2060 | narzędzia do składu | nic widocznego, wszystko tekstowe |
| Łącznik miękki | U+00AD | dzielenie wyrazów w Wordzie, eksporty PDF | wyraz przestaje pasować sam do siebie |
| Znacznik kolejności bajtów | U+FEFF | pierwsze bajty pliku UTF-8 | pierwsze pole pierwszego wiersza |
| Znacznik kierunku pisma | U+200E, U+200F | treści dwukierunkowe | zabłąkane znaczniki wokół liczb |
| Separator wiersza i akapitu | U+2028, U+2029 | niektóre aplikacje Mac i programy do składu | dzielenie na wiersze, starsze parsery JavaScript |
Wyszukiwanie zawodzi dlatego, że porównuje punkty kodowe, a nie kształty. Jeśli PDF dał ci New U+00A0 York, a ty wpisujesz New York ze zwykłą spacją U+0020, to są dwa różne ciągi znaków i nic ci nie powie dlaczego.
"New York".includes("New York") false, the gap is U+00A0
" text".trim().length 5, the zero-width space survives
Spacja niełamliwa liczy się jako znak odstępu dla większości funkcji przycinających i dla \s w większości silników wyrażeń regularnych, więc znika na brzegach ciągu, a przeżywa w środku, dokładnie tam, gdzie dzielenie spodziewa się zwykłej spacji. Spacja zerowej szerokości nie jest nigdzie znakiem odstępu, więc przycinanie i zwijanie zostawiają ją nietkniętą. To także powód, dla którego ekstraktor adresów e-mail potrafi nic nie zwrócić z tekstu skopiowanego z programu pocztowego: jeden znak zerowej szerokości wewnątrz adresu wystarczy, by wzorzec przestał pasować.
Detektor ukrytych znaków pokazuje, co tam faktycznie jest, a narzędzie Unicode Escape podaje dokładne punkty kodowe pojedynczej wartości. Nie usuwaj jednak wszystkiego, co zobaczysz: łącznik zerowej szerokości wewnątrz sekwencji emoji oraz rozłącznik zerowej szerokości w piśmie perskim czy dewanagari to treść, a nie szum.
Interpunkcja, którą zmienił za ciebie edytor tekstu
Autokorekta podmienia znaki typograficzne w trakcie pisania, a podmiana wychodzi razem z tekstem poza dokument.
| Wpisano | Masz teraz | Punkt kodowy |
|---|---|---|
' | prawy apostrof drukarski | U+2019 |
" i " | lewy i prawy cudzysłów podwójny | U+201C, U+201D |
- między wyrazami | półpauza albo pauza | U+2013, U+2014 |
... | wielokropek | U+2026 |
- przed liczbą | znak minus albo łącznik niełamliwy | U+2212, U+2011 |
W prozie jest to w porządku, a we wszystkim, co parsuje maszyna, jest zabójcze.
{ “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
W plikach CSV awaria jest cichsza. Parser rozpoznaje jako ogranicznik pola wyłącznie prosty cudzysłów podwójny, więc wartość, którą edytor tekstu owinął cudzysłowami drukarskimi, jest traktowana jako nieujęta w cudzysłów; każdy przecinek w jej wnętrzu rozbija wtedy wiersz, a wszystkie kolejne kolumny się przesuwają. Kolumna, w której liczby ujemne używają U+2212, importuje się jako tekst, a sumy po cichu ją pomijają.
Rozwiązaniem jest narzędzie znajdź i zamień z krótką listą podstawień, ale stosuj je tylko do tekstu, który idzie do kodu, pliku CSV albo do klucza. Puszczenie go po prozie, którą publikujesz, spłaszcza interpunkcję, która była zamierzona.
Znaki końca wiersza i odstępy na końcu
Narzędzia windowsowe kończą wiersz sekwencją CR LF (U+000D U+000A), narzędzia uniksowe samym LF, a kilka bardzo starych eksportów z Maca wciąż używa samego CR. Większość parserów sobie radzi. Naiwne dzielenie nie.
"UK\r\n" split on "\n" -> "UK\r"
"UK\r" === "UK" false
"UK\r".length 3
Dwa ciągi znaków, które wyświetlają się identycznie w tabeli, w logu albo w widoku różnic, wciąż mogą różnić się powrotem karetki, spacją na końcu albo tabulatorem. Kiedy porównywarka tekstu oznacza wiersz jako zmieniony, a żadnej zmiany nie widać, to właśnie jest odpowiedź. To także powód, dla którego zabłąkana spacja ląduje wewnątrz cudzysłowów, gdy wklejona kolumna przechodzi przez cytowanie wierszy albo łączenie wierszy.
Normalizuj w jednym przebiegu, dopasowując CR LF i samotny CR jednocześnie (\r\n? zamienione na \n), bo inaczej dwuetapowe podstawienie podwoi puste wiersze.
Jedna litera, dwa sposoby zapisu
Unicode pozwala, żeby litera z akcentem była pojedynczym punktem kodowym albo literą podstawową, po której następuje znak łączący. Oba zapisy są poprawne i nie są sobie równe.
| Forma | é jest zapisane jako | Jednostek kodowych w JavaScript |
|---|---|---|
| NFC (złożona) | U+00E9 | 1 |
| NFD (rozłożona) | U+0065 U+0301 | 2 |
macOS od dawna wydaje nazwy plików w postaci rozłożonej, a kilka generatorów PDF i metod wprowadzania produkuje tekst rozłożony, więc wartość z jednego źródła nie będzie pasować do tej samej wartości wpisanej z klawiatury.
"é" === "é" false
"é".normalize("NFC") === "é" true
Skutki uboczne sięgają dalej niż równość. Sortowanie porównujące punkty kodowe stawia formy rozłożone obok zwykłego e, a formy złożone daleko od nich, więc lista nazwisk wraca w dwóch skupiskach. Liczba znaków różni się między formami, co ma znaczenie przy limicie 280 znaków albo kolumnie VARCHAR(50), a statystyki tekstu podają różne liczby dla czegoś, co wygląda na ten sam tekst. Gorzej jest z przycinaniem: cięcie ciągu rozłożonego na stałej długości może trafić między literę podstawową a jej akcent, przez co ten akcent doczepi się do tego, co następuje dalej, więc przycinanie tekstu na nieznormalizowanym wejściu potrafi wyprodukować widocznie błędny ostatni znak.
Formy zgodnościowe, NFKC i NFKD, idą dalej i składają znaki, które jedynie przypominają inne: ligatura U+FB01 staje się fi, pełnoszerokie A U+FF21 staje się A, znak mikro U+00B5 staje się greckim mi U+03BC, a indeks górny ² staje się zwykłą 2. Jest to znakomite dla klucza wyszukiwania albo deduplikacji, a niszczące dla czegokolwiek, co wyświetlasz, ponieważ x² po cichu zamienia się w x2.
Reguła robocza brzmi tak: NFC do przechowywania i wyświetlania, NFKC wyłącznie do kluczy, których nikt nigdy nie ogląda.
Zmiana wielkości liter to nie jedna operacja
Zamiana na wielkie litery nie jest odwzorowaniem znak po znaku i nie jest niezależna od ustawień regionalnych.
- Niemieckie
ßU+00DF zamienia się naSS, więc ciąg się wydłuża, a powrót do małych liter nie oddaje oryginału. - Greckie sigma zamienia się na małe
ςna końcu wyrazu, aσw innych miejscach, co również psuje podróż w obie strony. - Turecki i azerski mają
ıbez kropki U+0131 oraz wielkieİz kropką U+0130. W tych ustawieniach regionalnychIstaje się małymı, aiwielkimİ.
To ostatnie jest klasycznym błędem produkcyjnym. Kod, który zamienia na małe litery nazwę nagłówka, rozszerzenie pliku albo nazwę protokołu, działa wszędzie do chwili, gdy uruchomi się na maszynie o tureckich ustawieniach regionalnych, gdzie "FILE" zamienia się na fıle i przestaje pasować do file. W Javie i .NET metody bezargumentowe korzystają z domyślnych ustawień regionalnych maszyny, więc toUpperCase() i ToUpper() są pułapką; dla czegokolwiek czytanego maszynowo wskaż wprost ustawienia niezmienne. Do porównywania lepsze od zamiany na małe litery jest składanie wielkości liter (casefold() w Pythonie), ponieważ obsługuje ß jako ss.
Zapis tytułowy nie ma jednej definicji i dlatego "wielka litera w każdym wyrazie" produkuje "The Lord Of The Rings" oraz "IPhone". Większość przewodników stylistycznych zapisuje wielką literą pierwszy i ostatni wyraz oraz wszystko poza rodzajnikami, spójnikami współrzędnymi i krótkimi przyimkami, utrzymuje wielkie litery w obu członach złożenia z łącznikiem i nigdy nie rusza skrótowców ani nazw z wielką literą w środku, takich jak McDonald, O'Brien czy iPhone.
Konwencje zapisu w programowaniu mają własny problem z granicami. Zamiana HTTPResponseCode na snake case zależy całkowicie od tego, jak dzielący traktuje ciąg wielkich liter; konwerter wielkości liter daje http_response_code, naiwny daje h_t_t_p_response_code.
Kolejność, która działa
Każdy krok zakłada, że poprzedni już się wykonał. W złej kolejności kroki walczą ze sobą.
- Ustal kodowanie. Krzaki takie jak
caféoznaczają, że bajty zdekodowano w niewłaściwym kodowaniu, więc zdekoduj oryginalne bajty ponownie, zamiast łatać objawy. Znacznik BOM usuwaj wyłącznie na samym początku tekstu. - Ujednolić znaki końca wiersza do LF w jednym przebiegu.
- Usuń znaki formatujące, które uznałeś za szum: łączniki miękkie, spacje zerowej szerokości, word joiner, znaczniki dwukierunkowe. Zrób to przed normalizacją, ponieważ NFC nie potrafi złożyć litery podstawowej z jej akcentem ponad niewidocznym znakiem, który siedzi między nimi.
- Podmień interpunkcję udającą inne znaki, jeśli celem jest kod, plik CSV, JSON albo klucz.
- Zastosuj normalizację Unicode, domyślnie NFC.
- Dopiero teraz zajmij się odstępami. Zamień pozostałe egzotyczne spacje na U+0020, zwiń ciągi spacji, a potem przytnij brzegi. Robienie tego przed krokiem 3 zostawia podwójne spacje wszędzie tam, gdzie spacja niełamliwa spotkała się ze zwykłą, a przycinanie przed krokiem 2 zostawia powrót karetki przyklejony do ostatniej wartości w każdym wierszu.
- Wielkość liter zmieniaj na końcu, wskazując ustawienia regionalne wprost.
Operacje na poziomie wyrazów też należą do etapu po kroku 3. Dzielenie tekstu narzędziem do dzielenia w czasie, gdy w tekście wciąż siedzą łączniki miękkie, daje zawyżone liczby i połówki wyrazów, a to samo dotyczy wszystkiego, co skanuje tekst w poszukiwaniu haseł, w tym cenzurowania tekstu.
Kiedy wartość mimo tego wszystkiego wciąż nie chce się dopasować, przestań na nią patrzeć. Wypisz jej długość, zamień ją na punkty kodowe i porównaj wprost obie postaci ze znakami ucieczki; różnica jest tam oczywista w sposób, w jaki nigdy nie jest na ekranie.