Tekst opschonen die ergens anders vandaan komt
Tekst die uit een PDF, een spreadsheetcel, een CMS-veld, een e-mailprogramma of een chatapp komt, draagt de opmaakkeuzes mee van wat hem geproduceerd heeft, en vrijwel geen van die keuzes is op het scherm te zien. Het resultaat is een string die er goed uitziet, goed afdrukt, en dan zonder duidelijke reden struikelt over een vergelijking, een zoekopdracht, een JSON-parse of een databaselookup.
Tekens die je niet kunt zien
Voor Unicode zijn dit echte tekens. Ze hebben alleen geen zichtbare vorm, of dezelfde vorm als iets anders.
| Teken | Code point | Komt meestal uit | Wat het breekt |
|---|---|---|---|
| Non-breaking space | U+00A0 | in HTML, Word, PDF-opmaak | splitsen op een spatie, exacte overeenkomsten |
| Narrow no-break space | U+202F | Franse typografie, datums uit Word | hetzelfde, minder opvallend |
| Ideographic space | U+3000 | CJK-invoermethoden | ziet eruit als een brede witruimte |
| Zero-width space | U+200B | afbreektips uit een CMS, gekopieerde webtekst | onzichtbaar, en geen witruimte |
| Zero-width non-joiner / joiner | U+200C, U+200D | Perzische en Indiase tekst, emoji-reeksen | woordgrenzen, tekentellingen |
| Word joiner | U+2060 | zetgereedschap | niets zichtbaars, alles tekstueels |
| Soft hyphen | U+00AD | woordafbreking in Word, PDF-exports | een woord komt niet meer met zichzelf overeen |
| Byte order mark | U+FEFF | de eerste bytes van een UTF-8 bestand | het eerste veld van de eerste rij |
| Left-to-right / right-to-left mark | U+200E, U+200F | bidirectionele inhoud | losse tekens rond getallen |
| Line en paragraph separator | U+2028, U+2029 | sommige Mac- en opmaakprogramma's | regels splitsen, oudere JavaScript-parsers |
Een zoekopdracht mislukt omdat een zoekopdracht code points vergelijkt, geen vormen. Als een PDF je New U+00A0 York gaf en jij typt New York met een gewone spatie U+0020, dan zijn de twee strings verschillend en niets vertelt je waarom.
"New York".includes("New York") false, the gap is U+00A0
" text".trim().length 5, the zero-width space survives
Een non-breaking space telt voor de meeste trimfuncties en voor \s in de meeste regex-engines als witruimte, dus hij verdwijnt aan de randen van een string en overleeft in het midden, precies waar een split een gewone spatie verwacht. Een zero-width space is nergens witruimte, dus trimmen en samentrekken laten hem ongemoeid. Daarom kan Email Extractor ook niets teruggeven uit tekst die uit een mailprogramma is gekopieerd: één zero-width teken binnen het adres zorgt ervoor dat het patroon niet meer past.
Hidden Character Detector laat zien wat er werkelijk staat, en Unicode Escape geeft de exacte code points van één waarde. Verwijder alleen niet alles wat je tegenkomt: een zero-width joiner in een emoji-reeks en een zero-width non-joiner in Perzisch of Devanagari zijn inhoud, geen ruis.
Leestekens die een tekstverwerker voor je heeft veranderd
Autocorrectie vervangt tijdens het typen tekens door typografische varianten, en die vervanging gaat met de tekst het document uit.
| Wat je typte | Wat je nu hebt | Code point |
|---|---|---|
' | right single quotation mark | U+2019 |
" en " | left en right double quotation mark | U+201C, U+201D |
- tussen woorden | en dash of em dash | U+2013, U+2014 |
... | horizontal ellipsis | U+2026 |
- voor een getal | minus sign of non-breaking hyphen | U+2212, U+2011 |
Dat is prima in lopende tekst en fataal in alles wat een machine parst.
{ “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
In CSV verloopt de fout stiller. Een parser herkent alleen het rechte dubbele aanhalingsteken als veldbegrenzer, dus een waarde die een tekstverwerker in gekrulde aanhalingstekens heeft gezet wordt als niet aangehaald behandeld; elke komma daarbinnen splitst dan de rij en alle kolommen daarna schuiven op. Een kolom waarvan de negatieve getallen U+2212 gebruiken importeert als tekst, en sommen laten hem stilzwijgend weg.
Find and Replace met een korte vervangingslijst is de oplossing, maar pas hem alleen toe op tekst die richting code, CSV of een sleutel gaat. Hem over lopende tekst halen die je publiceert, vlakt leestekens af die er bewust stonden.
Regeleindes en witruimte aan het eind
Windows-tools sluiten een regel af met CR LF (U+000D U+000A), Unix-tools met alleen LF, en een enkele heel oude Mac-export gebruikt nog alleen CR. De meeste parsers kunnen daarmee overweg. Naïef splitsen niet.
"UK\r\n" split on "\n" -> "UK\r"
"UK\r" === "UK" false
"UK\r".length 3
Twee strings die in een tabel, een log of een diff-weergave identiek ogen, kunnen nog steeds verschillen door een carriage return, een spatie aan het eind of een tab. Wanneer Text Diff een regel als gewijzigd markeert en er geen verschil te zien is, is dat het antwoord. Het is ook wat een losse spatie binnen de aanhalingstekens zet wanneer een geplakte kolom door Quote Lines of Join Lines gaat.
Normaliseer in één doorgang, waarbij je CR LF en een losse CR samen afvangt (\r\n? vervangen door \n), anders verdubbelt een vervanging in twee stappen de lege regels.
Eén letter, twee manieren om hem te schrijven
Unicode staat toe dat een letter met accent één code point is of een basisletter gevolgd door een combineerteken. Beide zijn correct, en ze zijn niet gelijk.
| Vorm | é wordt opgeslagen als | Code units in JavaScript |
|---|---|---|
| NFC (samengesteld) | U+00E9 | 1 |
| NFD (ontleed) | U+0065 U+0301 | 2 |
macOS geeft van oudsher ontlede bestandsnamen terug, en verschillende PDF-generatoren en invoermethoden produceren ontlede tekst, dus een waarde uit de ene bron komt niet overeen met dezelfde waarde die op een toetsenbord is getypt.
"é" === "é" false
"é".normalize("NFC") === "é" true
De gevolgen reiken verder dan gelijkheid. Een sortering die code points vergelijkt zet ontlede vormen naast de kale e en samengestelde vormen ver weg, dus een namenlijst komt in twee klonten terug. Tekentellingen verschillen per vorm, wat uitmaakt voor een limiet van 280 tekens of een VARCHAR(50) kolom, en Text Statistics rapporteert verschillende cijfers voor wat op dezelfde tekst lijkt. Afkappen is erger: een ontlede string op een vaste lengte knippen kan tussen een basisletter en zijn accent snijden, waarna dat accent zich hecht aan wat erop volgt, dus Truncate Text kan op niet-genormaliseerde invoer een zichtbaar verkeerd laatste teken opleveren.
De compatibiliteitsvormen, NFKC en NFKD, gaan verder en vouwen tekens samen die er alleen maar op lijken: de ligatuur U+FB01 wordt fi, de volledige-breedte A U+FF21 wordt A, het microteken U+00B5 wordt de Griekse mu U+03BC, en de superscript ² wordt een gewone 2. Dat is uitstekend voor een zoek- of ontdubbelsleutel en destructief voor alles wat je toont, want x² wordt stilletjes x2.
De werkregel: NFC voor opslag en weergave, NFKC alleen voor sleutels die niemand ooit ziet.
Hoofdletters omzetten is niet één operatie
Naar hoofdletters omzetten is geen afbeelding per teken, en het staat niet los van de locale.
- Het Duitse
ßU+00DF wordt in hoofdlettersSS, dus de string wordt langer en terug naar kleine letters levert niet het origineel op. - De Griekse sigma wordt aan het eind van een woord
ςen eldersσin kleine letters, wat opnieuw de heen-en-terugweg breekt. - Turks en Azerbeidzjaans hebben een puntloze
ıU+0131 en een hoofdletter met puntİU+0130. In die locales wordtIin kleine lettersıeniin hoofdlettersİ.
Die laatste is de klassieke productiebug. Code die een headernaam, een bestandsextensie of een protocolstring naar kleine letters omzet werkt overal, tot hij draait op een machine waarvan de standaardlocale Turks is, waar "FILE" in kleine letters fıle wordt en niet meer overeenkomt met file. In Java en .NET gebruiken de methoden zonder argument de standaardlocale van de machine, dus toUpperCase() en ToUpper() zijn de valkuil; noem een invariante locale voor alles wat machineleesbaar is. Voor vergelijking is case folding (casefold() in Python) beter dan naar kleine letters omzetten, want dat behandelt ß als ss.
Title case kent geen enkele vaste definitie, en daarom levert "elk woord met een hoofdletter" zoiets op als "The Lord Of The Rings" en "IPhone". De meeste stijlgidsen geven het eerste en laatste woord een hoofdletter plus alles behalve lidwoorden, nevenschikkende voegwoorden en korte voorzetsels, houden beide helften van een samenstelling met koppelteken met hoofdletter, en raken acroniemen of namen met interne hoofdletters zoals McDonald, O'Brien of iPhone nooit aan.
Programmeernotaties hebben hun eigen grensprobleem. HTTPResponseCode naar snake case omzetten hangt volledig af van hoe de splitser een reeks hoofdletters behandelt; Case Converter geeft http_response_code, een naïeve variant geeft h_t_t_p_response_code.
Een volgorde die werkt
Elke stap gaat ervan uit dat de vorige is uitgevoerd. In de verkeerde volgorde werken ze elkaar tegen.
- Leg de encoding vast. Mojibake zoals
cafébetekent dat de bytes met de verkeerde encoding zijn gedecodeerd, dus decodeer de oorspronkelijke bytes opnieuw in plaats van de symptomen te repareren. Verwijder een BOM alleen helemaal aan het begin van de tekst. - Normaliseer de regeleindes naar LF in één doorgang.
- Verwijder de opmaaktekens die je als ruis hebt beoordeeld: soft hyphens, zero-width space, word joiner, bidi-markeringen. Doe dit vóór het normaliseren, want NFC kan een basisletter en zijn accent niet samenstellen als er een onzichtbaar teken tussen staat.
- Vervang gelijkende leestekens, als de bestemming code, CSV, JSON of een sleutel is.
- Pas Unicode-normalisatie toe, standaard NFC.
- Pak nu de witruimte aan. Zet de overgebleven exotische spaties om naar U+0020, trek reeksen samen, en trim daarna. Dit vóór stap 3 doen laat dubbele spaties achter overal waar een non-breaking space een gewone tegenkwam, en trimmen vóór stap 2 laat op elke regel een carriage return aan de laatste waarde geplakt.
- Zet hoofdletters als laatste om, met de locale expliciet benoemd.
Bewerkingen op woordniveau horen ook na stap 3. Tekst splitsen met Split Text terwijl er nog soft hyphens in staan geeft opgeblazen tellingen en halve woorden, en hetzelfde geldt voor alles wat op termen scant, waaronder Censor Text.
Als een waarde na dit alles nog steeds weigert overeen te komen, stop dan met ernaar kijken. Druk de lengte af, escape hem naar code points, en vergelijk de twee ge-escapete vormen rechtstreeks; daar is het verschil overduidelijk op een manier die het op het scherm nooit is.