Porządkowanie tekstu, który przyszedł skądinąd

Niewidoczne znaki, podmieniona interpunkcja, znaki końca wiersza, formy normalizacji i reguły wielkości liter, przez które wklejony tekst zachowuje się nieprzewidywalnie, oraz kolejność, w jakiej to naprawiać.

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.

ZnakPunkt kodowyZwykle przychodzi zCo psuje
Spacja niełamliwaU+00A0  w HTML, Word, układ strony PDFdzielenie po spacji, dokładne dopasowania
Wąska spacja niełamliwaU+202Ftypografia francuska, daty z Wordato samo, tylko mniej oczywiście
Spacja ideograficznaU+3000metody wprowadzania CJKwygląda jak szeroka przerwa
Spacja zerowej szerokościU+200Bpodpowiedzi łamania wiersza w CMS, tekst kopiowany ze stronniewidoczna i nie jest znakiem odstępu
Łącznik i rozłącznik zerowej szerokościU+200C, U+200Dteksty perskie i indyjskie, sekwencje emojigranice wyrazów, liczenie znaków
Word joinerU+2060narzędzia do składunic widocznego, wszystko tekstowe
Łącznik miękkiU+00ADdzielenie wyrazów w Wordzie, eksporty PDFwyraz przestaje pasować sam do siebie
Znacznik kolejności bajtówU+FEFFpierwsze bajty pliku UTF-8pierwsze pole pierwszego wiersza
Znacznik kierunku pismaU+200E, U+200Ftreści dwukierunkowezabłąkane znaczniki wokół liczb
Separator wiersza i akapituU+2028, U+2029niektóre aplikacje Mac i programy do składudzielenie 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.

WpisanoMasz terazPunkt kodowy
'prawy apostrof drukarskiU+2019
" i "lewy i prawy cudzysłów podwójnyU+201C, U+201D
- między wyrazamipółpauza albo pauzaU+2013, U+2014
...wielokropekU+2026
- przed liczbąznak minus albo łącznik niełamliwyU+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 jakoJednostek kodowych w JavaScript
NFC (złożona)U+00E91
NFD (rozłożona)U+0065 U+03012

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 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ż 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ę na SS, 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 regionalnych I staje się małym ı, a i wielkim İ.

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ą.

  1. 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.
  2. Ujednolić znaki końca wiersza do LF w jednym przebiegu.
  3. 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.
  4. Podmień interpunkcję udającą inne znaki, jeśli celem jest kod, plik CSV, JSON albo klucz.
  5. Zastosuj normalizację Unicode, domyślnie NFC.
  6. 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.
  7. 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.