Алгоритм чтения символов UTF-8 из текстового файла

Нашёл статью https://habr.com/ru/company/ruvds/blog/551060/, но не понимаю её, поскольку не знаком с С++ (там есть исходник). Также нашёл RFC на русском https://efim360.ru/rfc-3629-utf-8-format-preobrazovaniya-iso-10646/. Мне нужно в программе на Си считать текстовый файл в кодировке UTF-8 и записать каждый символ UTF-8 в переменную. Как это сделать? Подскажите алгоритм. Спасибо.


Ответы (4 шт):

Автор решения: Stanislav Volodarskiy

UTF-8 хорошо разбирается на символы анализом старших битов. Код ниже уязвим к переполнению буфера, это только демонстрация.

Условие ((c & 0x80) == 0 || (c & 0x40) != 0) поверяет начало нового символа в кодировке UTF-8. Новый символ начинается с 0XXXXXXX2 или 11XXXXXX2. Пока не встречен следующий новый символ, складываем байты в буфер. Когда это случилось, печатаем буфер. Между символами печатаем разделитель, чтобы убедиться что разбиение верное:

#include <stdio.h>

int main() {
    int k = 0;
    char buffer[10];
    for (; ; ) {
        int c = getchar();
        if (c == EOF || ((c & 0x80) == 0 || (c & 0x40) != 0)) {
            if (k > 0) {
                printf("_%.*s", k, buffer);
            }
            k = 0;
        }
        if (c == EOF) {
            break;
        }
        buffer[k] = c;
        ++k;
    }
}
$ gcc -std=c11 -pedantic -Wall -Wextra -Werror temp.c

$ echo -e 'Hello!\nПривет!' | ./a.out 
_H_e_l_l_o_!_
_П_р_и_в_е_т_!_
→ Ссылка
Автор решения: AlexGlebe

Если нужен не алгоритм, а просто набор букв в UTF-8 кодировке, то просто считайте простыми функциями чтения из файла. fgetwc Сначала локаль с UTF-8 нужно установить.

// Абвгдей-ка
# include <stdio.h>
# include <wchar.h>
# include <locale.h>
int main  ( )  {
  char  const * const l = setlocale ( LC_ALL  , ""  ) ;
  fwprintf  ( stdout  , L"l = '%s'\n" , l ) ;
  FILE  * const f = fopen ( "fgetwc.c" , "rb" ) ;
  do {
    wint_t const c = fgetwc ( f ) ;
    if ( c == WEOF )
      break ;
    fwprintf  ( stdout  , L"'%lc' " , c ) ;
  } while ( 1 ) ;
  fclose  ( f ) ;
  fputws  ( L"\n" , stdout  ) ;
}

> ./a.out

l = 'ru_RU.UTF-8'
'/' '/' ' ' 'А' 'б' 'в' 'г' 'д' 'е' 'й' '-' 'к' 'а' '
' ...
→ Ссылка
Автор решения: MiniMax

Прямолинейный вариант чтения и вывода - считываем байт, с помощью битовой маски узнаём количество последующих байт (4 варианта) и "досчитываем" их. Для проверки выводим получившийся символ.

Note: В стандарте С11 сказано, что порядок выполнения выражений внутри инициализирующего списка неопределён, поэтому в первом варианте могут быть проблемы из-за неупорядоченного вызова getchar(): например, getchar() по индексу 2 может быть вызван раньше getchar() по индексу 3, соответственно байты будут перепутаны. Во втором варианте (причёсанном) этой проблемы нет.

Выдержка из С11 - "The evaluations of the initialization list expressions are indeterminately sequenced with respect to one another and thus the order in which any side effects occur is unspecified. 152) ... 152) In particular, the evaluation order need not be the same as the order of subobject initialization."

#include <stdio.h>

int main(void) {
    int ch; 
    while((ch = getchar()) != EOF) {
        if ((ch & 0xF0) == 0xF0) {
            printf("%s", (char []){ch, getchar(), getchar(), getchar(), '\0'});
        } else if ((ch & 0xE0) == 0xE0) {
            printf("%s", (char []){ch, getchar(), getchar(), '\0'});
        } else if ((ch & 0xC0) == 0xC0) {
            printf("%s", (char []){ch, getchar(), '\0'});
        } else {
            printf("%c", ch);
        }   
    }   
    puts("");
    return 0;
}

Причёсанный вариант - без дублирования кода, но менее наглядный.

#include <stdio.h>

int main(void) {
    int ch; 
    char str[5];
    int n;
    while((ch = getchar()) != EOF) {
        str[0] = ch;

        if ((ch & 0xF0) == 0xF0) {
            n = 4;
        } else if ((ch & 0xE0) == 0xE0) {
            n = 3;
        } else if ((ch & 0xC0) == 0xC0) {
            n = 2;
        } else {
            n = 1;
        }   

        for(int i = 1; i < n; i++) {
           str[i] = getchar();
        }

        str[n] = '\0';

        printf("%s", str);
    }   

    puts("");
    return 0;
}

Тест

$ gcc -Wall source.c
$ echo -n hello℃℉привет | ./a.out
hello℃℉привет
→ Ссылка
Автор решения: avp

Для начала, из описания UTF-8 в Википедии

UTF-8 (от англ. Unicode Transformation Format, 8-bit — «формат преобразования Юникода, 8-бит») — распространённый стандарт кодирования символов, позволяющий более компактно хранить и передавать символы Юникода, используя переменное количество байт (от 1 до 4)

На основании того, что правильные utf-8 символы занимают до 4-х байт, можно предложить хранить utf-8 символы, извлекаемые из сишных строк, в нуль-терминированных пятерках байт. Это будет удобно для печати.

Другой вариант хранения -- массив переменных типа int (или unsigned int), в каждую из которых упакован utf-8 код символа. Этот вариант предпочтительней с точки зрения как экономии памяти (каждый символ 4 байта), так и эффективности доступа (все символы будут выровнены в памяти).

Вообще, для работы со строками, состоящими из utf-8 символов вполне подходят многие стандартные функции -- strcpy(), strstr() и т.п. Для чтения строк из файла работают fgets() и getline(), котрые всегда читают в память целое число utf-8 символов.

Понятно, что при работе с utf-8 в первую очередь напрягает переменная длина кода. Поэтому пердлагаю вашему вниманию небольшую библиотеку утилит, облегчающих программирование.


Следующая пара функций позволяет быстро определить количество байт, занимаемых utf-8 символом (по битам первого байта utf-8), упакованным в integer перемнную

// 5 high bits in lower byte of `c` is index in len[]
int
utf8_len (int c)
{
  static char len[32] = {[0 ... 15] = 1,  // 0xxxx  ASCII -- 1 byte codes
             [16 ... 23] = 0, // 10xxx  error codes -- use 1 byte for step to next
             [24 ... 27] = 2, // 110xx  2 bytes codes
             3, 3,            // 1110x  3 bytes codes
             4,               // 11110  4 bytes codes
             0};              // 11111  error codes
  return len[(c >> 3) & 0x1f];
}

или расположенного в строке

int
utf8_sym_len (const char *s)
{
  return utf8_len (*s);
}

По старшим пяти битам (они кодируют числа от 0 до 31) первого байта utf-8 символа в строке мы можем легко находить его длину в байтах.

Для чтения из файла по одному utf-8 символу можно предложить вот такой аналог fgetc(), возвращает utf-8 код, упакованный в int:

// returns utf-8 symbol (low utf-8 byte in low bits of result)
// or EOF (if EOF found during reading last file characters, returns EOF and set error)
// if error byte found, then it's position + 1 placed to the `*err`
int
utf8_fgetsym (FILE *in, int *err)
{
  int dummy;   if (!err) err = &dummy;
  *err = 0;

  int c = fgetc(in);
  if (c == EOF)
    return EOF;

  int res = c,
    pos = 0,
    u8len = utf8_len(c);

  if (!u8len)
    goto Err;

  for (pos = 1; pos < u8len; pos++) {
    if ((c = fgetc(in)) == EOF) {
      res = c;
      goto Err;
    }
    res |= (c << (pos * 8));
    if ((c & 0xc0) != 0x80)
      goto Err;
  }

  return res;

 Err:
  *err = pos + 1;
  return res;
}

Для извлечения в char массив (скажем, размером 5 байт) символов utf-8, расположенных в строке (прочитанной, например, fgets()) можно использовать функцию, которая возвращает размер utf-8 символа в байтах для обеспечения прохода по строке в цикле:

// returns step to the next character in `s`
// put utf-8 bytes of `s[]` to `usym[]` (zero terminated)
// if error found in the codes put `"⁇"` (3 bytes) into `usym[]`
//    place to `*err`  wrong byte position + 1 and returns 1
int
utf8_getsym_z (const char *s, char usym[], int *err)
{
  int dummy;   if (!err) err = &dummy;
  *err = 0;

  int pos = 0, u8len = utf8_sym_len(s);
  if (!u8len)
    goto Err;

  // copy u8len bytes from s[] to usym[] and check bytes for error
  usym[pos] = s[pos];
  for (pos = 1; pos < u8len; pos++) {
    if ((s[pos] & 0xc0) != 0x80) // all utf-8 bytes except first must be 10xxx_xxxx
      goto Err;
    else
      usym[pos] = s[pos];
  }
  usym[pos] = 0; // zero terminated array is the C string

  return u8len;

 Err:
    *err = pos + 1;
    strcpy(usym, "⁇"); // this string is 3-bytes utf-8 symbol
    return 1;
}

Аналогичная функцию, возвращает utf-8 символ, упакованный в int (в этом случае для прохода по строке нужно самостоятельно взять размер возвращенного символа):

// returns utf8 packed to the `int type` from utf8  string
// if error utf8 byte found, then it's position + 1 placed to the `*err`
int
utf8_str (const char *s, int *err)
{
  int dummy;   if (!err) err = &dummy;
  *err = 0;

  int pos = 0, u8len = utf8_sym_len(s);
  unsigned int utf8 = (unsigned char)s[0];
  if (!u8len)
    goto Err;
  for (pos = 1; pos < u8len; pos++) {
    utf8 |= ((unsigned char)s[pos] << 8);
    if ((s[pos] & 0xc0) != 0x80) // all utf-8 bytes except first must be 10xxx_xxxx
      goto Err;
  }

  return utf8;

 Err:
  *err = pos + 1;
  return utf8;
}

Эта функция позволяет записать упакованный в int utf-8 символ в строку

// put utf8 symbol to string `s` (termination '\0' not placed)
// returns length of utf8 symbol
// suppose valid utf8
int
utf8_tostr (char *s, unsigned int utf8)
{
  int u8len = utf8_len(utf8);

  s[0] = utf8;
  for (int i = 1; i < u8len; i++) {
    utf8 >>= 8;
    s[i] = utf8;
  }

  return u8len;
}

Для подсчета количества utf-8 символов в строке

// like strlen() for utf8 string
// returns count of utf8 symbols
int
utf8_strlen (const char *s)
{
  int n = 0;

  for (; *s; s++)
    if ((*s & 0xc0) != 0x80)
      n++;

  return n;
}

И наконец, если вспомнить, что utf-8 это один из вариантов кодирования unicode:

// returns unicode (ucs) for `utf8`
// suppose valid utf8
int
utf8_to_ucs (unsigned int utf8)
{
  if ((unsigned)utf8 < 128)
    return utf8;
  
  int u8len = utf8_len(utf8);
  static char mask[5] = {0, 0, 0x1f, 0xf, 0x7};
  int ucs = utf8 & mask[u8len];
  for (int i = 1; i < u8len; i++) {
    ucs <<= 6;
    utf8 >>= 8;
    ucs |= (utf8 & 0x3f);
  }

  return ucs;
}

// suppose valid ucs
int
ucs_to_utf8 (int ucs)
{
  if (ucs < 128)
    return ucs;
  if (ucs < 0x800) 
    return (0xc0 | (ucs >> 6)) |
      ((0x80 | (ucs & 0x3f)) << 8);
  if (ucs < 0x10000)  
    return (0xe0 | (ucs >> 12)) |
      ((0x80 | ((ucs >> 6) & 0x3f)) << 8) |
      ((0x80 | (ucs & 0x3f)) << 16);

  return (0xf0 | (ucs >> 18)) |
    ((0x80 | ((ucs >> 12) & 0x3f)) << 8) |
    ((0x80 | ((ucs >> 6) & 0x3f)) << 16) |
    ((0x80 | (ucs & 0x3f)) << 24);
  
}

Кстати, чуть не забыл о примере чтения файла -)

int
main (int ac, char *av[])
{
  int c, err, rc = 0;
  char sbuf[5];

  while ((c = utf8_fgetsym(stdin, &err)) != EOF) {
    sbuf[utf8_tostr(sbuf, c)] = 0;

    if (err) {
      rc = 1;
      fprintf(stderr, "********** UTF-8 coding error in pos %d c = 0x%08x\n", err - 1, c);
      continue;
    }

    fputs(sbuf, stdout);
  }

  return rc;
}

И его запуск:

avp@avp-desktop:~/avp/hashcode$ gcc ttt.c -Os
avp@avp-desktop:~/avp/hashcode$ echo 'ячсм //  ▓, ▒, ░, ⁇,' | ./a.out
ячсм //  ▓, ▒, ░, ⁇,
avp@avp-desktop:~/avp/hashcode$ 

Надеюсь, код окажется вам хоть в чем-то полезен. Что непонятно -- спрашивайте в комментариях.

P.S.

Тип int в размерах, количестве и т.п. выбран совершенно осознано, поскольку (imho) работа в памяти с текстами (а UTF-8 сделан именно для текстов и размер нормальной книги не превышает мегабайта) размером в пару гигабайт это нонсенс.

Как давно уже говорили умные люди (если правильно помню, это был Дейкстра), если 2 явления различаются на 3 порядка, то для них требуются принципиально разные подходы

→ Ссылка