Rémtörténet a karakterkódolásokról

Czirkos Zoltán, Dobra Gábor · 2016.08.28.

Ékezetes betűk, szövegek kódolása és megjelenítése a programokban. Javasolt olvasmány azoknak, akik szeretnék a nagy házijukban megoldani a magyar ékezetes szövegek helyes kezelését.

18+

Az ékezetes betűk kódolásával máig gondok vannak. Sokféle szabvány létezik arra, hogy mely ékezetes betűt milyen számkóddal jelölünk, ami azért nehéz ügy, mert ezek a kódtáblázatok általában egymással inkompatibilisek.

A probléma ugyan elméletben megoldott, létezik olyan karakterkódolás, a Unicode, amely a világ (majdnem) összes nyelvének (majdnem) összes írásjelét tartalmazza, mégis rendszeresen találkozunk árvíztûrõ tükörfúrógépekkel (meg ĂĄrvĂ­ztĹąrĹ tükörfúrógépekkel) még nyomtatott szövegekben is. Ennek az oka sokszor a programozók figyelmetlensége. A karakterkódolási szabványok követésével és a programok helyes beállításával ezek a problémák megszüntethetőek. Legtöbbször csak egy-két függvényhívásról van szó!

Ha a rémtörténet nem érdekel, szeretnél jól aludni, és csak azért vagy itt, mert az ékezetes szövegek nem látszanak jól a nagyházidban, akkor lapozz az oldal legaljára, a receptekhez.

1. Az egybájtos karakterkódolások

Az angol nyelvben használt, ékezet nélküli betűkhöz az ASCII kódolás terjedt el, amiről előadáson is volt szó. Egykor voltak más kódolások is, de a ASCII mára gyakorlatilag egyeduralkodóvá vált. A nyugat-európai nyelvekhez (pl. a franciához) használják ennek a Latin-1, vagy más néven ISO8859-1-es kiterjesztését. Ez az ASCII kódolás 128 kódját újabb 96 karakterrel egészíti ki a 160-255 tartományban, így ez már 8 bites. Ebben sajnos nincsen benne a magyar ő és ű. A testvérében, a Latin-2-ben (ISO8859-2) már benne van, így ezzel bármilyen magyar szöveg leírható. Ebben a magyar ű betű helyén a Latin-1-esben û van, az ő helyén pedig õ. Ezért találkozni néha ilyenekkel: árvíztûrõ tükörfúrógép, amikor egy Latin-2 kódolással megadott sztringet Latin-1 kódolásúnak gondol egy program, vagy esetleg egy betűtípus leírófájljában van benne helytelenül, hogy melyik alakzat melyik karaktert jelenti.

A Latin-2-höz hasonló kódolást használ a Windows a szövegfájloknál (Windows-1250). A konzol ablakban meg egy negyediket (IBM-852), amely a Latin-1-2-re egyáltalán nem hasonlít. Ezek a kódolások a lenti képeken láthatóak. (Muszáj volt képként beilleszteni, ugyanis itt, az InfoC site-on használt betűtípus nem tartalmaz minden karaktert, ami a lenti képeken található.)

ISO8859-1 (Latin-1)
ISO8859-2 (Latin-2)
IBM-852

A karakterkódolások közötti inkompatibilitás problémája akkor jelentkezik, amikor a programunkban ékezetes szöveget szeretnénk kiírni. Ha azt mondjuk a Code::Blocksban (Windowson), hogy printf("ő"), a keletkező sztring a 0xF5, 0x00 bájtokból áll: az ő kódja és a lezáró nulla. De a konzolablakban a 0xF5 a paragrafus jel § karakterkódja! Ha beolvasunk egy sztringet, az viszont helyesen fog megjelenni kiíráskor, mivel a programunkban történő beolvasáskor már az IBM-852 szerinti kódok vannak:

C:\...\karakter\bin\Debug> karakter.exe
═rd be, hogy teniszŘt§!
teniszütő
Ezt Ýrtad be: teniszütő

Ha azonban a konzol ablakhoz kiválasztunk egy olyan betűtípust, amely tartalmazza a megfelelő ékezetes karaktereket (pl. a Lucida Console ilyen), és a parancssorban a program futtatása előtt átváltjuk a karakterkódolást arra, amelyik kódolással a forráskódot is elmentettük, helyesen jelenik meg a szöveg:

C:\...\karakter\bin\Debug> chcp 1250 kódlap váltása
Az aktív kódlap: 1250

C:\...\karakter\bin\Debug> karakter.exe
Írd be, hogy teniszütő!
teniszütő
Ezt írtad be: teniszütő

Szóval most már működnek az ékezeteink.

2. A Unicode kódolás

A többnyelvű szövegek nem írhatóak le a fenti kódolásokkal. Nem csak az a baj, hogy egy cirill vagy japán betűk nem szerepelnek bennük, hanem például még egy latin betűs útikönyvvel is gondban vagyunk! A Latin-1-ben nincs ő, a Latin-2-ben nincs ø, ezért ez a mondat nem írható le egyikkel sem: Dánia fővárosa København.

A '80-as évek vége táján felmerült, hogy létre kellene hozni egy olyan kódtáblát, amely a világ összes nyelvének összes karakterét tartalmazza, mert akkor nem lesz ilyen gond. Ez lett a Unicode. Mivel azonban az összes létező írásjelek jóval többen vannak, mint 256, ebben egy karaktert már nem egy, hanem két bájttal jelölnek (vagy ritkán néggyel). Aminek pedig az a következménye, hogy egy Unicode sztring nem jelenhet meg char[] típusként a C programunkban, mert a char bájtot jelent.

Az egybájtos karakterkódokról a kétbájtos Unicode kódra átalakítani egy szöveget nagyon könnyű; egy 256 elemű tömbben eltárolhatjuk, melyik kódból mi lesz. Az egyes kódolásokhoz (Latin-1, Latin-2 stb.) azonban eltérő táblázatok tartoznak. A visszaalakítás nem ilyen egyszerű, mert bár technikailag könnyen megvalósítható (65536 elemű tömb tárolja a cél kódtábla karaktereit), azonban könnyen előfordulhat, hogy olyan karaktert kell átkódolni, ami a cél kódtáblában nem létezik.

És fölmerül még egy probléma. Egyes számítógéptípusok úgy tárolják a 16 bites számokat – amelyeket két 8 bites bájtként kell elhelyezni a memóriában –, hogy az alsó 8 bitet írják előbb, utána pedig a felső 8 bitet (előbb a kicsi – little endian). Más gépek meg épp fordítva, előre veszi a felső 8 bitet, és utána, a következő memóriacímre pedig az alsó 8 bitet (big endian). Ez egészen addig nem gond, amíg két, egymástól eltérő típusú számítógépnek kommunikálnia nem kell egymással. Viszont ha ezek az Interneten keresztül adatot küldenének egymásnak, vagy szeretnék olvasni az egymás által kiírt fájlokat (pl. Unicode kódolású szövegeket), akkor már figyelni kell arra, hogy ugyanazt a bájtsorrendet használják – különben amit az egyik 0xFCE2-nek mond, azt a másik 0xE2FC-nek fogja értelmezni, és fordítva.

Ezért a 16 bites Unicode kódolású szövegekben el szoktak helyezni egy ún. BOM (byte order mark, bájtsorrend jele) karaktert, amelynek a kódja 0xFEFF. Ha a szöveget olvasó számítógép egy 0xFEFF kódot talál a szövegben, akkor tudja, hogy annak bájtsorrendje megegyezik a sajátjával. Ha azonban egy 0xFFFE számot lát (amely szándékosan semmilyen karakternek nem kódja), akkor tudja, hogy minden számban meg kell cserélnie a felső és alsó nyolc bitet:

/* felteve, hogy 16 bites */
unsigned short bajtcsere(unsigned short kod) {
    return (kod & 0xFF)<<8 | (kod & 0xFF00)>>8;
}

3. Az UTF-8 kódolás

A Unicode kódolás elméletben visszafelé kompatibilis az ASCII kódolással, ugyanis az első 128 karaktere ugyanabban a sorrendben van. Azonban a szövegfájlok mégsem kompatibilisek egymással: a „HELLO” szöveg ASCII kóddal 0x48, 0x45, 0x4C, 0x4C, 0x4F, Unicodeban 0x0048, 0x0045, 0x004C, 0x004C, 0x004F, amiből aztán a használt számítógép típusától függően vagy a bal, vagy a jobb oldali bájtsorozat lesz a fájlban. A BOM-mal együtt ezek így néznek ki:

FE FF 00 48 00 45 00 4C 00 4C 00 4F
FF FE 48 00 45 00 4C 00 4C 00 4F 00

Ezért találták ki az UTF-8 szövegkódolást. Az ilyen szövegekben a Unicode kódszámokat használjuk, azonban mindig 8 bites értékekből építjük fel azt, átalakítva a nagyobb számokat több bájtos sorozatokká. Ha a leírandó kódszám elfér 7 biten (vagyis 0x0000 és 0x007F között van), akkor levágjuk 8 bitre, és úgy tesszük a fájlba. Ha ennél nagyobb, akkor kettő, három, sőt néha még több bájtos sorozattal írjuk le. A bájtok sorrendje azonban az ilyen sorozatokban kötött, és nem függ a számítógép típusától. Az átkódolás az alábbi módon helyezi el a biteket:

TartományUnicodeUTF-8
0x0000-0x007F00000000 0xxxxxxx0xxxxxxx
0x0080-0x07FF00000yyy yyxxxxxx110yyyyy 10xxxxxx
0x0800-0xFFFFzzzzyyyy yyxxxxxx1110zzzz 10yyyyyy 10xxxxxx

A Wikipedia az Euró jelét hozza példának, hogyan néz ki egy karakter UTF-8 kódolása:

  • Az € karakter kódszáma 0x20AC.
  • Ez binárisan 0010000010101100, ami a fenti táblázat alapján a harmadik kategóriába esik. Vagyis három bájton lesz kódolható.
  • Az első bájt viszi az első négy bitet: 11100010. A második a következő hatot: 10000010. Az utolsó a maradékot: 10101100.
  • A kapott bájtok: 0xE2 0x82 0xAC.

Az UTF-8 kódolású sztringek, mivel bájtokból állnak, a C forráskódokban „újra” char[]-ként jelenhetnek meg. Ezeknél azonban a beépített sztringkezelő függvényeket használva elég furcsa dolgokat tapasztalhatunk. Pl. azt, hogy strlen("o") értéke 1, viszont strlen("ő") értéke 2. strlen("€") meg 3. Az ő betűt két bájt kódolja, míg az o betűt csak egy, és ezt az strlen() nem tudja. Továbbá, míg az 'o' a fordító számára érthető, 'ő' és '€' teljesen teljesen értelmezhetetlenek, szintaktikai hibát jelentenek, mivel az aposztrófok között a fordító egy bájtot vár, de a forráskódban kettőt vagy hármat talál. Végleg át kell állítanunk az agyunkat: a char nem karaktert, hanem bájtot jelent! Még jó, hogy a többi függvény, pl. a strcpy() és a strcmp() nagyjából helyesen működik. (Végülis ez volt a célja az UTF-8 megalkotóinak.)

Egy Unicode kódolású szöveget UTF-8 bájtsorozattá alakítani könnyű, néhány bitműveletről van szó:

#include <stdio.h >

typedef unsigned char Bajt;
typedef unsigned short UniKar;

/* Unicode sztringbol UTF-8 sztringet csinal. a bemenet es a
 * kimenet is nullaval terminalt tomb. */
void unicode_2_utf8(UniKar const *be, Bajt *ki) {
    int pb, pk;

    pk = 0;
    for (pb = 0; be[pb] != 0x0000; ++pb) {
        /* 00000000 0xxxxxxx    0x0000-0x007F   0xxxxxxx */
        if (be[pb] <= 0x007F)
            ki[pk++] = be[pb];
        else
            /* 00000yyy yyxxxxxx    0x0080-0x07FF   110yyyyy 10xxxxxx */
            if (be[pb] <= 0x07FF) {
                ki[pk++] = 0xC0 | be[pb] >> 6;   /* 0xC0 = 11000000 */
                ki[pk++] = 0x80 | (be[pb] & 0x3F); /* 0x80 = 10000000, 0x3F = 00111111 */
            }
        /* zzzzyyyy yyxxxxxx    0x0800-0xFFFF   1110zzzz 10yyyyyy 10xxxxxx */
            else {
                ki[pk++] = 0xE0 | be[pb] >> 12;  /* 0xE0 = 11100000 */
                ki[pk++] = 0x80 | ((be[pb] >> 6) & 0x3F);
                ki[pk++] = 0x80 | (be[pb] & 0x3F);
            }
    }

    ki[pk] = 0;
}

int main(void) {
    UniKar arvizturo[] = { 0x00E1, 'r', 'v', 0x00ED, 'z', 't',
                           0x0171, 'r', 0x0151, ' ', 0x263A, ' ', 0x20AC, 0x0000
                         };
    unsigned char arvizturo_utf8[30];

    unicode_2_utf8(arvizturo, arvizturo_utf8);
    printf("arvizturo szmajli, es euro: %s\n", arvizturo_utf8);

    return 0;
}

A visszaalakítás ugyanilyen egyszerű. A programot elindítva ennek kell megjelennie: árvíztűrő ☺ €. Linuxon egyből ez fog megjelenni (ezek UTF-8 kódolást használnak szinte mindenhol), a Windowsokon meg a fenti módon a kódlapot át kell állítani, csak most a chcp 65001 paranccsal, ahogy a hatodik előadás szmájlis példaprogramjához is kellett.

4. Ékezetek: receptek

Az alábbi kódrészletek a nagyházikban szabadon használhatóak, a forrás megjelölése mellett.

UTF-8 BOM karakter a Windows szövegfájljaiban

Az UTF-8 kódolás gyakorlatilag a Unicode karakterek kódjait használja, azoknak egy kényelmesebb ábrázolási módja. Azt azonban nem köti meg az UTF-8 szabvány, hogy a Unicode fájlok elején lévő BOM-ot tartalmaznia kell-e egy UTF-8 fájlnak. Mivel a bájtok sorrendje kötött, teljesen felesleges jelezni a bájtsorrendet, így a legtöbb program nem használ UTF-8 kódolás esetén BOM-ot. Néhány Windows-os program (pl. Notepad) ennek ellenére elhelyezi, ezzel számos problémát okozva: sok program (pl. Internet Explorer...) erre nincs felkészítve, hiszen értelmetlen.

A BOM kódja 0xFEFF, ami a 0x0800-0xFFFF tartományba esik, így UTF-8 reprezentációja három bájtos: EF BB BF. Pl. az „árvíztűrő” szöveg egy fájl elején:

C3 A1 72 76 C3 AD 7A 74 C5 B1 72 C5 91          (UTF-8)      
EF BB BF C3 A1 72 76 C3 AD 7A 74 C5 B1 72 C5 91 (UTF-8 + BOM)

Ha ilyet látunk a fájl elején, egyszerűen dobjuk el az első három bájtot.

char buf[3];
fscanf(fp, "%c%c%c", buf+0, buf+1, buf+2);
if (strncmp(buf, "\xEF\xBB\xBF", 3)!=0) /* ha nem bom-mal kezdődik a fájl */
    fseek(fp, 0, SEEK_SET);             /* vissza az elejére */
/* ... a fájl kezelése ... */

Windows parancssor (konzol ablak) és fájlok

Egy magyar nyelvűre állított Windowson a legtöbb program Windows-1250 karakterkódolást használ, ami nagyjából kompatibilis a Latin-2-vel. Ezért érdemes a forráskódot is így elmenteni (a Code::Blocks alapbeállítás szerint ezt teszi), és a szövegfájlokban is ezt használni. A parancssori ablakot pedig úgy beállítani, hogy a Lucida Console, vagy egy másik, Unicode-kompatibilis betűtípust használjon. (Az ablak ikonjára klikk, tulajdonságok, stb.)

A konzol ablak kódlapjának beállítása megtehető a programból is, két függvényhívással: SetConsoleCP(1250) és SetConsoleOutputCP(1250). Az egyik a bemeneti kódlapot állítja be, a másik pedig a kimeneti kódlapot. (Hogy miért tér el a beolvasáskor (scanf) és kiíráskor (print) használt karakterkódolás (!) a Windowsban, miért kell ezeket külön beállítani, egy örök rejtély a világ számára.) Vigyázat, ezek nem szabványos függvényhívások! Illik őket #ifdef-ek közé tenni, hogy maradjanak hatástalanok, ha más operációs rendszeren fordítja valaki a programot:

#include <stdio.h>
#include <string.h>
#if defined(WIN32) || defined(_WIN32)
    #include <windows.h>
#endif

int main(void) {
    char s[100];

#if defined(WIN32) || defined(_WIN32)
    SetConsoleCP(1250);
    SetConsoleOutputCP(1250);
#endif
    printf("Írd be, hogy teniszütő!\n");
    gets(s);
    printf("Ezt írtad be: %s.", s);

    return 0;
}

Ha minden jól van beállítva, ennek működnie kell. A két függvényhívást elég a program elején egyszer megtenni (praktikusan a main() elején valamikor), többször már nem kell.

Linux parancssor és fájlok

Ez könnyű. A legtöbb Linux UTF-8 kódolást használ a parancssori ablakokban és a fájlokban is, úgyhogy semmi extra teendő nincsen, rögtön működnek az ékezetes betűt használó programok. Egy dologra kell figyelni, hogy az UTF-8-ban karakter≠bájt! Mivel az ékezetes betűk kettő, egyéb karakterek akár három bájttal lehetnek kódolva, a sztringek indexei elcsúsznak, és hosszaik nem egyeznek meg az strlen() által adottakkal. Például strlen("teniszütő") értéke 11. Ez 9 karakter, 11 bájt hosszú sztring, 12 bájtnyi memóriafoglalás. (Az utf8_strlen() függvény megírása házi feladat.)

Konverzió: Latin-2-ből Unicodeba

Ha egy Windowson egy Latin-2 kódolású fájlból beolvasott szöveget kell megjeleníteni az SDL-lel, akkor ilyen irányú átalakítást kell csinálni. Az alábbi függvénnyel oldható meg:

typedef unsigned char Latin2Kar;
typedef unsigned short UniKar;

void latin2_2_unicode(Latin2Kar *be, UniKar *ki) {
    unsigned short tabla[128] = {
        /* A 0x80-0xFF karakterek Unicode megfeleloje */
        0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
        0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, 0x90, 0x91, 0x92, 0x93,
        0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0x9B, 0x9C, 0x9D,
        0x9E, 0x9F, 0xA0, 0x104, 0x2D8, 0x141, 0xA4, 0x13D, 0x15A,
        0xA7, 0xA8, 0x160, 0x15E, 0x164, 0x179, 0xAD, 0x17D, 0x17B,
        0xB0, 0x105, 0x2DB, 0x142, 0xB4, 0x13E, 0x15B, 0x2C7, 0xB8,
        0x161, 0x15F, 0x165, 0x17A, 0x2DD, 0x17E, 0x17C, 0x154,
        0xC1, 0xC2, 0x102, 0xC4, 0x139, 0x106, 0xC7, 0x10C, 0xC9,
        0x118, 0xCB, 0x11A, 0xCD, 0xCE, 0x10E, 0x110, 0x143, 0x147,
        0xD3, 0xD4, 0x150, 0xD6, 0xD7, 0x158, 0x16E, 0xDA, 0x170,
        0xDC, 0xDD, 0x162, 0xDF, 0x155, 0xE1, 0xE2, 0x103, 0xE4,
        0x13A, 0x107, 0xE7, 0x10D, 0xE9, 0x119, 0xEB, 0x11B, 0xED,
        0xEE, 0x10F, 0x111, 0x144, 0x148, 0xF3, 0xF4, 0x151, 0xF6,
        0xF7, 0x159, 0x16F, 0xFA, 0x171, 0xFC, 0xFD, 0x163, 0x2D9
    };

    int i, j;
    j = 0;
    /* vegig a sztringen */
    for (i = 0; be[i] != 0; ++i) {
        if (be[i] < 128) /* ascii? */
            ki[j++] = be[i];
        else
            ki[j++] = tabla[be[i] - 128];
    }
    /* itt is nulla a lezaro nulla */
    ki[j] = 0x0000;
}

Konverzió: Unicode-ból UTF-8-ba

Ha egy SDL-en, billentyűzetről beolvasott sztringet kell fájlba írni, vagy más, meglévő UTF-8 sztringekbe beilleszteni:

typedef unsigned char Bajt;
typedef unsigned short UniKar;

void unicode_2_utf8(UniKar const *be, Bajt *ki) {
    int pb, pk;
    pk = 0;
    for (pb = 0; be[pb] != 0x0000; ++pb) {
        /* 00000000 0xxxxxxx    0x0000-0x007F   0xxxxxxx */
        if (be[pb] <= 0x007F)
            ki[pk++] = be[pb];
        else
            /* 00000yyy yyxxxxxx    0x0080-0x07FF   110yyyyy 10xxxxxx */
            if (be[pb] <= 0x07FF) {
                ki[pk++] = 0xC0 | be[pb] >> 6;   /* 0xC0 = 11000000 */
                ki[pk++] = 0x80 | (be[pb] & 0x3F); /* 0x80 = 10000000, 0x3F = 00111111 */
            }
        /* zzzzyyyy yyxxxxxx    0x0800-0xFFFF   1110zzzz 10yyyyyy 10xxxxxx */
            else {
                ki[pk++] = 0xE0 | be[pb] >> 12;  /* 0xE0 = 11100000 */
                ki[pk++] = 0x80 | ((be[pb] >> 6) & 0x3F);
                ki[pk++] = 0x80 | (be[pb] & 0x3F);
            }
    }

    ki[pk] = 0;
}

Konverzió: UTF-8-ból Unicode-ba

Ha UTF-8 sztringek vagy fájlok karaktereit kellene egyesével látni:

/* UTF-8 bajtsorozatbol allitja elo az Unicode sztringet. Mindketto nullaval terminalt.
* A bemeneti UTF - 8 bajtsorozatnak helyesnek kell lennie! * /
void utf8_2_unicode(Bajt const *be, UniKar *ki) {
    int pb, pk;
    pk = 0;
    for (pb = 0; be[pb] != 0; ++pb) {
        if (be[pb] < 0x80) {
            /* 00000000 0xxxxxxx    0x0000-0x007F    0xxxxxxx */
            ki[pk++] = be[pb];
        }
        else if (be[pb] >> 5 == 6) { /* 0x6 = 110 bin */
            /* 00000yyy yyxxxxxx    0x0080-0x07FF    110yyyyy 10xxxxxx */
            ki[pk++] = (be[pb] & 0x1f) << 6 | (be[pb + 1] & 0x3f);
            pb += 1;            /* ket bajtot hasznaltunk */
        }
        else {
            /* zzzzyyyy yyxxxxxx    0x0800-0xFFFF   1110zzzz 10yyyyyy 10xxxxxx */
            ki[pk++] = (be[pb] & 0x0f) << 12 | (be[pb + 1] & 0x3f) << 6 | (be[pb + 2] & 0x3f);
            pb += 2;            /* harom bajtot hasznaltunk */
        }
    }
    ki[pk] = 0x0000;
}