Unicode dla programistów - notatki
Unicode to jeden z tematów, o których od dawna chciałam napisać: zebrać w jedno miejsce odnośniki czy definicje, których zawsze gdzieś szukam, trochę uporządkować swoją wiedzę, a przy okazji przybliżyć zagadnienie tym osobom, które chciałyby się na ten temat dowiedzieć więcej.
W tym wpisie opiszę krótko czym jest Unicode, przedstawię klika kluczowych pojęć i przedstawię na przykładach jak wygląda obsługa Unicode w dwóch językach programowania: w Javie i w JavaScript.
Zapraszam.
Unicode - standard
Unicode to standard kodowania, reprezentacji i obsługi tekstu dla większości systemów pisma (patrz: unicode faq. Dokumentacja do ostatnio wydanej wersji (w dniu 2021-05-24 jest to Unicode 13.0) standardu znajduje się pod tym odnośnikiem:
http://www.unicode.org/versions/latest/
Cztery części standardu
Standard składa się z czterech części:
- Specyfikacji dostępnej w postaci pojedynczego pdf-a (14 MB)
- Tabel ze znakami. Dostępnych jest kilka tabel (dla różnych potrzeb):
- pełna, aktualizowana na bieżąco lista znaków podzielona - dla wygodniejszego przeglądania - na ponad 150 skryptów i na płaszczyzny (planes) oraz bloki (podział ze względu na zakresy wartości), wraz z indeksem pozwalającym na przeszukiwanie znaków po nazwie
- w wersji 13.0.0: tabela znaków z zaznaczonymi zmianami dodanymi w tej wersji (“delta”)
- cały zbiór znaków, dostępny w momencie publikacji wersji 13.0.0 standardu
Standard nie tylko zawiera same znaki, lecz również szczegółowe informacje dotyczące zasad normalizacji (niezbędnej ze względu na to, że wiele abstrakcyjnych znaków może być reprezentowanych na więcej niż jeden sposób, tj. może zostać poprawnie zakodowanych jako różne sekwencje code-pointów), dekompozycji, zasad uporządkowania w ramach “collation”, renderowania czy wyświetlania tekstów “dwukierunkowych” (np. w tekstach wielojęzycznych) w których pojawiają się zarówno fragmenty “od lewej do prawej” jak i “od prawej do lewej”.
Liczba znaków w standardzie
Każda kolejna wersja włącza w standard nowe znaki (bądź całe ich zakresy). W bieżącej wersji jest ich już prawie 144 tysiące:
Wersja | Rok publikacji | Liczba znaków |
---|---|---|
1.0 | 1991 | 7_161 |
9.0 | 2016 | 128_172 |
13.0 | 2021 | 143_859 |
Definicje
Unicode określa unikalny numer dla każdego znaku, niezależny od platformy sprzętowej, systemu operacyjnego, używanego programu czy języka.
Unicode jest więc uniwersalnym zestawiem znaków z większości systemów pisma, a każdemu ze znaków przypisuje unikalny numer (tzw. code point).
Code Point
Code point to wartość z przestrzeni wartości Unicode (od U+0000 do U+10FFFF). Nie wszyskie code pointy są przypisane do znaków.
Code pointy to liczby z zakresu od U+0000 do U+10FFFF. Zapis U+hex składa się z prefiksu U+ oraz szesnastkowej wartości hex oznaczającej indeks - położenie - code pointu w wielkiej, unikodowej tablicy znaków.
Oto przykłady: code point, znak reprezentowany przez ten code point oraz jego pełna nazwa:
Code point | znak | nazwa |
---|---|---|
00105 | ą | A WITH OGONEK, LATIN SMALL LETTER |
003B4 | δ | GREEK SMALL LETTER DELTA |
1F7E9 | 🟩 | LARGE GREEN SQUARE |
1F385 | 🎅 | FATHER CHRISTMAS |
1f49D | 💝 | HEART WITH RIBBON |
Nie wszystkie code pointy mają przypisane jakieś znaki. W zakresie od U+0000 do U+10FFF jest dostępnych 1_141_112 wartości, a przypisanych znaków (posiadających znaczenie, widocznych jakp glyph lub widoczna spacja) jest 143_696.
Każdy code point należy do jednej z Ogólnych Kategorii: może to być m.in. litera, znak, liczba, znak przestankowy, symbol, separator.
Typy code pointów
W standardzie istnieje siedem typów code pointów: Graphic, Format, Control, Private-Use, Surrogate, Noncharacter, Reserved (patrz definicja D10a).
Pary surogatów
- code pointy w zakresie U+D800 - U+DBFF (1024 code pointy) są nazwane high surogate code points,
- code pointy w zakresie U+DC00 - U+DFFF (1024 code pointy) są nazwane low surogate code ponts
Następujące po sobie high-surogate a następnie low-surogate tworzą parę surogatów w kodowaniu UTF-16 i reprezentują code-pointy większe niż U+FFFF. Powyższe code pointy nie mogą być stosowane w innych kontekstach.
Noncharacters
Istnieje mały zbiór znkaów, które nazwane zostaly “nieznakami” (“noncharacters”) i choć aplikacje mogą ich używać, jeśli chcą, to nie powinny być używane. Są to:
- code pointy z zakresu U+FDD0 - U_FDEF oraz
- wszystkie code pointy konczące sie wartoscią FFFE lub FFFF (tj.: U+FFFF, U+1FFFE, … U+10FFFE, U+10FFFF)
Prywatne code pointy
Istnieją zakresy code pointów, które są uznawane za przypisane do znaków, jednak nie mają żadnej narzuconej przez Unicode interpretacji i można ich używać przy założeniu, że zarówno nadawca, jak i odbiorca porozumieli się do do ich znaczenia. Należą do nich:
- Private Use Area: U+E000–U+F8FF (6_400 znaków),
- Supplementary Private Use Area-A: U+F0000–U+FFFFD (65_534 znaków),
- Supplementary Private Use Area-B: U+100000–U+10FFFD (65_534 znaków).
Znaki formatowania
Te nie mają widocznego kształu, ale mogą mieć wpływ na sąsiadujące znaki Np. U+200C (ZERO WIDTH NON-JOINER) czy U+200D (ZERO WIDTH JOINTER) może być użyty do zmiany kształtu (np. do usunięcia lub wprowadzeniu ligatury).
Zarezerwowane code pointy
Tu znajdują się te code pointy, które są dostępne do użycia, lecz jeszcze nie przypisane (w Unicode 13.0.0 jest ich 830_606).
Znak abstrakcyjny - abstract character
Znak abstrakcyjny lub znak jest jednostką informacji używaną do organizowania, kontroli bądź reprezentacji danych teksowych.
Znaki są w Unicode pewną abstrakcją - każdy z nich posiada określoną nazwę, np. LATIN SMALL LETTER U. Wyrenderowana forma tego znaku (tzw. glyph) wygląda tak: a.
Nie wszystkie znaki abstrakcyjne są zakodowane jako pojedyncze code pointy; niektóre znaki są kodowane w Unicode jako sekwencja dwóch lub kilku code pointów. Na przykład, występujący w języku łacińskim znak małej litery i z ogonkiem, kropką u góry i akcentem jest reprezentowana przez sekwencję U+012F, U+0307, U+0301. Są to tak zwane Unicode Named Sequences, a ich lista jest dostępna pod adresem https://unicode.org/Public/UNIDATA/NamedSequences.txt
Płaszczyzna (plane)
Płaszczyzna to zakres 65_536 (10_00016) następujących po sobie code pointów: od U+n0000 do U+nFFFF, gdzie n przyjmuje wartości od 016 do 1016)
Płaszczyzny dzielą code pointy na 17 równych grup:
-
Płaszczyzna 0 zawiera code pointy od U+00000 do U+0FFFF (Basic Multilingial Plane)
-
Płaszczyzna 1 zawiera code pointy od U+10000 do U+1FFFF (Supplementary Multilingual Plane)
-
Płaszczyzna 2 zawiera code pointy od U+20000 do U+2FFFF (Supplementary Ideographic Plane)
-
…
-
Płaszczyzna 15 zawiera code pointy od U+F0000 do U+FFFFF (Supplementaty Private Use Area A)
-
Płaszczyzna 16 zawiera code pointy od U+100000 do U+10FFFF (Supplementaty Private Use Area B)
Plane 0 - płaszczyzna 0 - jest szczególna, to Basic Multilingial Plane (BMP) zawierająca większość znaków ze współczesnych języków (podstawowe łacińskie, cyrylicę, znaki greckie) oraz wiele symboli.
Płaszczyzny od 1 do 16 to płaszczyzny astralne (astral planes), a code pointy w tych zakresach to astral code points. Znaki znajdujące się w zakresie U+10000 do U+10FFFF nazywane są znakami dodatkowymi (suplementary characters).
Co ciekawe, to właśnie w “płaszczyznach astralnych” znajdują się wszystkie zabawne emoji (które młodsze pokolenie nazywa “emotkami”).
Emoji
Oto link do listy Emoji.
Przegląd kategorii
Symbole
Jednym z najbardziej ekscytujących zastosowań Unicode stało się używanie Emojis. Nazwa wzięła się ze słowa “emotion”, bo początkowo emojis służyły wyrażaniu emocji osoby piszącej i ograniczały się do “buziek” uśmiechniętych lub zasmuconych.
Teraz emojis to dużo, dużo więcej. Zawierają znaki i sekwencje, dzięki którym kodowane są kotki (😺, 😽), serduszka (💖, 💔, 💌) i minki(😓, 🥱, 😳), symbole (💥, 💣, 💦), potwory (👹, 👻, 👽, 🤖), znaki czynione dłonią (🤏, 🖖), części ciała (🫁, (ups, po tym jak wstawiłam mózg, zepsuł mi się nvim…)
###Twarze
Kodowanie obrazków przedstawiające twarze różnych osób ma bardzo ciekawe aspekty: zostały utworzone takie znaki emoji, które nie sugerują płci, są “gender neural”, stąd obok znaków “man: curly hair” 👨🦱 czy “woman: curly hair” 👩🦱 znajdziemy też “person: curly hair” 🧑🦱; emoji reprezentujące ludzkie ciało mają również różne wersje reprezentujące różne odcienie koloru skóry.
Gesty
Na przykład: face-palm :) 🤦
Zawody
Są też przeróżne zawody, również w wariantach wielu płci: cook 🧑🍳, woman cook 👩🍳 and man cook 👨🍳.
I można by tak bez końca: rodziny, rośliny, zwierzęta, warzywa, pieczywo, potrawy, słodycze, napoje, nakrycia stołowe, budowle, środki transportu, zegary, fazy księżyca, pogoda, sport, święta, ubrania, torby, buty, instrumenty…
O - znalazłam nawet szczoteczkę do zębów…🪥 - to jeden z nowododanych znaków., podobnie jak mężczyzn karmiący dziecko 👨🍼.
Tutaj są wszystkie nowo dodane emoji
Kodowanie emoji
Ponieważ emoji leżą poza Basic Multilingual Plane, wartości ich code pointów są liczbami szesnastkowymi o ponad czterech cyfrach. Do ich zakodowania nie wystarczą dwa bajty. Oznacza to, że nie “zmieszczą się” w jednym znaku UTF-16 i muszą być zakodowane jako ciąg znaków.
Na przykład, symbol pieska 🐶 (U+1F436), choć jego kod złożony jest z tylko jednego code pointa, jest kodowany jako para surogatów \uD83D\uDC36.
Niektóre emoji złożone są nie z jednego, lecz z kilku code pointów. Na przykład ów mężczyzna karmiący dziecko składa się z z aż trzech: U+1F468 U+200D U+1F37C - pierwszy to mężczyzna (👨), ostatni to butelka(🍼).
Inny przykład:👩❤️💋👨 - “kiss: woman, man” ma kod złożony z ośmiu code pointów! (patrz index)
Jednostka kodowa - code unit
W pamięci komputera nie znajdują się abstrakcyjne znaki ani code pointy. Komputer musi więc mieć jakąś fizyczny, ściśle określony sposób zapisywania znaków. Służy do tego pojęcie code unit.
Code unit to minimalna sekwencja bitów, jaka może zostać użyta do reprezentacji jednostki zakodowanego tekstu. Standard Unicode używa 8-bitowych code unitów w kodowaniu UTF-8, 16-bitowych code pointów w kodowaniu UTF-16 i 32-bitowych code unitów w kodowaniu UTF-32.
To właśnie kodowanie znaków (character encoding) pozwala przekształcić abstrakcyjne code pointy (liczby) w fizyczne byty (code units, czyli np. dwubajtowe chary).
Najbardziej popuralne sposoby kodowania to UTF-8, UTF-16 oraz UTF-32.
UTF-8
UTF-8 (8-bit Unicode Transformation Format) to kodowanie znaków zmiennej długości umożliwiające zakodowanie wszystkich poprawnych code pointów w Unicide przy użyciu od jednego do czterech ośmiobitowych bajtów.
UTF-8 to kodowanie domyślne w Linuksie, a także domyślne kodowanie w Internecie.
Pierwsze 128 znaków Unicode odpowiada dokładnie znakom w kodzie ASCII i jest zakodowanych w UTF-8 przy użyciu jednego bajtu o takiej samej wartości jak wartość kodu ASCII. Dlatego też poprawny tekst w ASCII jest również poprawnym tekstem Unicode zakodowanym przy użyciu UTF-8.
UTF-16
UTF-16 (16-bit Unicode Transformation Format) jest kolejnym popularnym standardem kodowania Unicode. To również kodowanie zmiennej długości. Code pointy są kodowane przy użyciu jednego lub dwóch code-unitów - w tym przypadku code unit jest szesnastobitowy). Każdy code point zajmuje więc conajmniej 2 i co najwyżej 4 bajty.
Jest to równiez kodowanie używane w Windowsie (do plików tekstowych czy w edytorach tekstu), a także kodowanie używane “wewnętrznie” w najważniejszych językach programowania: w Javie i w JavaScripcie.
Normalizacja napisów
Dość często, zwykle w kontekście porównywania, sortowania czy dopasowywania unikodowych napisów do wzorca (regex) pojawia się problem niejednoznaczności zapisu tego samego (semantycznie) znaku: widzimy ten sam grafem (graficzną reprezentację), widzimy pewien “znak”, jednak to może być on w rzeczywistości kodowany na więcej niż jeden sposób.
- Przykład 1: w przypadku znaków posiadających akcent istnieje więcej niż jedna reprezentacja: znak
é
może być wyrażony jako U+00E9 albo jako kombinacja zwykłej literye
(U+0065) i znaku COMBINING ACUTE ACCENT (U+0301) - Przykład 2: znak
ñ
ma reprezentację w postaci jednego code pointa U+00F1 lub w postaci kombinacji literyn
i znaku~
(U+0303). - Przykład 3:
à
może być zakodowane jako U+0061 (a
) a nasętpnie U+0300 (GRAVE ACCENT). Może być również dowodany jako jeden code point, to jest U+00E0 (A WITH GRAVE ACCENT)
Znak U+0310 (acute accent) oraz znak U+0300 (grave accent) to tzw. znaki łączące (combining marks). W Unicode znak może być reprezentowany przez code point nie będący combining mark oraz następujący po nim ciąg code pointów, które są combining marks.
Unicode dopuszcza taką wieloznaczność z powodów głównie historycznych - pewne pradawne (legacy) zestawy znaków posiadały już mapowanie jeden-do-jeden (znak na code unit) dla znaków z “upiększeniami” (akcentami, ogonkami itp.), więc Unicode Consortium uznało, że warto również takie mapowanie zachować w standardzie.
Rozwiązaniem problemu niejednoznaczności reprezentacji jest normalizacja (zdefiniowana w załączniku Unicode Standard ANnex #15).
Sposoby normalizacji tekstu w Javie i Javascript opiszę w akapitach poświęconych tym językom (patrz niżej).
Języki programowania
Kodowanie wewnętrzne a kodowanie domyślne
Wewnętrznie używane kodowanie to kodowanie używane przez JVM (w przypadku Javy) lub środowisko uruchomieniowe JavaScript (nodejs, deno). Trzeba je odróżnić od np. domyślnego kodowania znaków w programie napisanym w danym języku programowania. Na przykład w Javie napisy (obiekty typu String) są na heapie zakodowane właśnie jako UTF-16 (to właśnie “użytek wewnętrzny”; ta wewnętrzna reprezentacja została “zoptymalizowana” w Javie 9, patrz JEP 254: Compact Strings), jednak metoda String.getBytes() zwraca bajty zakodowane przy pomocy tzw. domyślnego zestawu znaków (default charset), którym jest - w przypadku Javy - UTF-8.
Java
Kodowanie źródeł
Źródła Javy mogą być zakodowane w dowolnym unikodowym kodowaniu, np. UTF-8 czy UTF-16. Zgodnie ze specyfikacją: Chapter 3 Lexical Structure:
Programs are written using the Unicode character set.
Unicode Escapes
W źródłach można używać unikodowych sekwencji unikowych (Uniocode escapes) postaci \uXXXX (patrz: Section 3.2 Lexical Translations):
Unicode escape of the form \uxxxx, where xxxx is a hexadecimal value, represents the UTF-16 code unit whose encoding is xxxx.
Unicode escapes w źródłach są czasami źródłem ciekawych pytań dotyczących Javy (czy Java może uruchamiać komentarze), warto więc czasem zwrócić więszką uwagę na ukryte sekwencje unikowe.
JLS określa sposób przekształcenia kodu źródłowego w kodowaniu Unicode na źródło w kodowaniu ASCII (dzięki czemu źródła mogą być przetwarzane przez narzędzia operujące jedynie na zestawie znaków ASCII; tę skonwertowaną postać rozumie również kompilator) i jest to sposób odwracalny, dzięki czemu można również uzyskać ponownie tekst zakodowany kodowaniem Unicode.
API
Warto przyjrzeć się bliżej, jakim API dysponuje programista, który chce np. używać znaków rozszerzonych. W Javie 5 rozszerzono w tym celu klasę Character
: “nowe” API to konwertery między typem char a wartościami code pointów oraz metody weryfikujące poprawność lub mapujące chary na code pointy.
Metody konwertujące w klasie Character
Metoda | Opis |
---|---|
toChars(int codePoint, char[] dst, int dstIndex) |
konweruje podany unikodowy code point do reprezentacji UTF-16 i umieszcza go w tablicy dst pod podanym indeksem |
toCodePoint(char high, char low) |
konwertuje parę surogatów do wartości code pointa reprezentującego znak pomocniczy |
codePointAt(char[] a, int index) , codePointAt(char[] a, int index, int limit) , codePointAt(CharSequence seq, int index) |
zwracają unikodowy code point na podanym indeksie. Druga metoda nakłada ograniczenie na indeks, trzecia metoda bierze jako parametr CharSequence. |
codePointBefore(char[] a, int index) , codePointBefore(char[] a, int index, int start) , codePointBefore(CharSequence seq, int index) , codePointBefore(char[], int, int) |
metody zwrcają code point przed podanym indeksem |
charCount(int codePoint) |
zwraca wartość 1 jeśli code point może być zareprezentowny jako pojedynczy char, a wartość 2 jeśli podano znak pomocniczy, który wymaga dwóch bajtów |
Weryfikacja i mapowanie
Ponieważ metody operujące na typie podstwowym char
(takie jak np. isLowerCase(char)
czy isDigit(char)
okazały się niewystarczające, dodano metody operujące na typie int. Te nowe metody nie sprawdzają poprawności przekazanej wartości, dlatego dodano również:
Metoda | Opis |
---|---|
isValidCodePont(int) |
metodę sprawdzającą poprawność wartości typu int czyli w przedziale 0x0000 do 0x10FFFF |
isSupplementaryCodePoint(int) |
metodę sprawdzającą, czy wartość jest znakiem pomocniczym czyli w przedziale od 0x10000 do 0x10FFFF włącznie |
isHighSurrogate(char) |
metodę sprawdzającą, czy znak jest poprawnym high surrogate czyli od \uD800 do \uDBFF |
isLowSurrogate(char) |
metodę sprawdzającą, czy znak jest poprawnym Low surrogate czyli od \uDC00 do \uDFFF |
codePointCount(CharSequence, int, int) , codePointCount(char[], int, int) |
metodę zwracającą liczbę code pointów w sekwencji znaków lub tablicy charów |
Metody w klasie String
Do obsługi Unicode pojawił się w klasach String, StringBuilder czy StringBuffer nowe konstruktory i metody działające na znakach rozszerzonych: codePointAt(int)
, codePontBefore(int)
, codePointCount(int beginIdx, int endIdx)
, appendCodePoint(int)
czy offsetByCodePonts(int index, int codePointOffset)
.
Przykład
To przykładowy programik, w którym rozkładam napis na code pointy i znaki, badam ich długość:
|
|
A to przykładowe wyjście z programu dla napisu “👨🍼”:
|
|
Normalizacja w Javie
Do normalizacji napisów w programie napisanym w Javie można użyć klasy java.text.Normalizer, a listę formatów/typów normalizacji można znależć w enumie java.text.Normalizer.Form
W3C rekomenduje użycie normalizacji NFC, ponieważ większość starych kodowań znaków również używa “skomponowanych” wariantów znaków i nie koduje oddzielnie combibing marks.
Tekst warto normalizować tylko wówczas, gdy istnieje taka potrzeba. Można łatwo sprwdzić, czy tekst jest znormalizowany - służy do tego metoda isNormalized()
. Jeśli zwróci ona false
, należy użyć metody normalize()
, która normalizuje wartości char
zgodnie z podanym typem normalizacji:
|
|
Gotowy program demonstrujący użycie różnych formatów (typów) normalizacji znajduje się na stronie Oracle: NormSample.java
JavaScript
Kodowanie plików źródłowych
Przeglądarki domyślnie zakładają, że źródła programu w JavaScript są zapisane w lokalnym kodowaniu, dlatego, aby uniknąć nieprzewidzianego zachowania, należy wyspecyfikować kodowanie źródeł JS. Jak to zrobić?
Jest kilka sposobów:
- ustawić BOM jako pierwszy znak w pliku (nie zalecane dla UTF-8)
- jeśli plik jest ściągany z sieci, można ustawić nagłówek
Content-Type
, np.Content-Type: application/javascript; charset=utf-8
- w przypadku braku nagłówka sprawdzany jest atrybut
charset
w taguscript
:
|
|
- w przypadku jego braku przyjmuje się charset z tagu meta:
|
|
Kodowane wewnętrzne
Silnik JavaScript po wczytaniu źródła JS konwertuje je wewnętrznie na UTF-16, zgodnie ze standardem ECMAScript:
When a String contains actual textual data, each element is considered to be a single UTF-16 code unit.
Używanie Unicode w napisie
Podobnie jak w Javie, literał napisowy może zawierać sekwecję w formacie \uXXXX:
|
|
Normalizacja w JavaScript
Normalizacja to proces pozbywania się niejednoznaczności w reprezentacji napisu.
ES6/ES2015 wprowadziła do metod Stringa funkcję normalize()
:
|
|
Zakończenie
Temat Unicode jest ogromny. W tym artykule przedstawiłam podstawowe definicje związane z Unicodem i bardzo zachęcam do sięgnięcia do źródeł, w szczególności do kanonicznego już tekstu Joela Spolsky’ego.
Myślę, że warto mieć podstawowe pojęcie o tym, czym Unicode jest oraz poczytać o różnych sposobach kodowania znaków. Tekst, znaki, kodowanie - to nasze, programistów, “tworzywo”, którym posługujemy się na co dzień i dobrze jest choć trochę znać materię, z którą się pracuje.
Źródła:
- Wikipedia: Unicode
- The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)
- What should every JS developer know about Unicode
- Java Unicode basics
- Oracle tutorial about Unicode
- Unicode in JavaScript
- java.text.Normalizer - Javadoc javase 16
- String.prototype.normalize() - MDN