[C++]: Convertir une chaîne MBCS en UTF8 avec du c++ standard

L’autre jour une de mes collègues me dit “j’ai un souci avec mon code lorsqu’un chemin contient un accent français”.

Le code qu’elle utilise pour essayer de corriger le crash commence par une simple affection d’un chemin contenant un accent dans une std::string:

std::string sTest = "C:\\Temp\\TestAccent\\é.txt";

Au début je n’ai pas réagi et en fait ce code est traduit de manière correcte en langage machine,  mais quelque chose me dérange…

En effet il se trouve que par default/chance l’encodage des fichiers sous Visual Studio utilise l’encodage Windows Ansi de l’OS.
Donc sur un OS français( avec un codepage Windows-1252) il n’y aura pas de problèmes mais qu’en sera t’il sur un OS chinois ou vietnamien lorsque le compilateur va parser notre accent é ou même l’afficher ? Je pense que le même fichier code source ouvert sur un OS asiatique par exemple n’affichera pas un é mais plutôt un autre caractère donc ca n’est pas très rassurant pour investiguer un problème d’encodage.

Donc mon premier test a été de créer le fichier en question sur le système de fichier qui lui utilise UTF16 et ensuite d’utiliser l’API Windows MBCS pour lister les fichiers d’un répertoire et de regarder comment est codé cet accent dans la mémoire.

int main()
{
   std::string pattern("C:\\Temp\\TestAccent");

   // First get path with MBCS Windows api
   pattern.append("\\*");
   WIN32_FIND_DATAA data;
   HANDLE hFind;
   if ((hFind = FindFirstFileA(pattern.c_str(), &data)) != INVALID_HANDLE_VALUE) {
      do {
         
         if (data.cFileName[0] == '.') continue; // Skip . and ..
         std::cout << data.cFileName << std::endl;

      } while (FindNextFileA(hFind, &data) != 0);
      FindClose(hFind);
   }

   return 0;
}

Lorsque l’on regarde la mémoire de la variable data.cFileName, on observe la chose suivante:

Donc avec les API Windows MBCS (celles qui se finisse par un A) notre accent é est codé sur 1 octet avec la valeur 0xe9.

Continuons notre exploration et utilisons les fonctions c++ std::filesystem pour voir comment est codé le nom du fichier:

#include <string>
#include <iostream>

#include <filesystem>
namespace fs = std::filesystem;

#define BOOST_AUTO_LINK_SYSTEM 
#include <boost/filesystem.hpp>
namespace bfs = boost::filesystem;


int main()
{
   std::string str, stru8;

   // c++17, std::filesystem
   std::string path("C:\\Temp\\TestAccent");
   for (const auto& entry : fs::directory_iterator(path)) 
   {
      str = entry.path().string();
      const char* pStr = str.c_str();
      stru8 = entry.path().u8string();
      const char* pU8Str = stru8.c_str();
   }
      
   // c++17, boost::filesystem
   for (const auto & entry : bfs::directory_iterator(path))
   {
      str = entry.path().string();
      const char* pStr = str.c_str();

      //u8string() does not exists in boost
   }

   return 0;
}

Si on regarde la mémoire:


std::directory_entry.string() renvoie une chaine MBCS avec é codé sur 1 caractère (0xe9)
std::directory_entry.u8string() renvoie une chaine utf8 avec é codé sur 2 caractères (0xc3 0xa9)
boost n’a pas de methode u8string et encode les chaînes comme la version std c’est à dire en MBCS.

Mais revenons au problème de départ de ma collègue…
Elle utilise une librairie d’abstraction au-dessus de boost::filesystem qui ne fonctionne qu’avec des chemins utf8 donc quand on passe un chemin MBCS cela provoque une exception:

#include <Windows.h>
#include <string>
#include <iostream>

int main()
{
   std::string pattern("C:\\Temp\\TestAccent");
   std::string filepath = pattern;

   // First get path with MBCS Windows api
   pattern.append("\\*");
   WIN32_FIND_DATAA data;
   HANDLE hFind;
   if ((hFind = FindFirstFileA(pattern.c_str(), &data)) != INVALID_HANDLE_VALUE) {
      do {
         std::string fname = data.cFileName;
         if (fname[0] == '.') continue; // Skip . and ..
         filepath = filepath + "\\" + fname;
         std::cout << filepath << std::endl;

      } while (FindNextFileA(hFind, &data) != 0);
      FindClose(hFind);
   }

   // this methods only understands utf8 path 
   // and here filepath holds a MBCS string (C:\Temp\TestAccent\é.txt) => !!!!! EXCEPTION !!!!!
   if (Hal::FileUtils::Exists(filepath))
   {
      //Do something
   }

   return 0;
}

Donc cela semble simple il suffit de convertir une chaine MBCS (encodé avec le codepage de l’OS) en UTF8.
J’ai donc commencé à regarder comment faire en c++ standard c’est à dire sans utiliser directement de fonctions Windows et après quelques recherches je me suis tourné vers boost::locale::conv avec le code suivant:

std::string utf8_string = boost::locale::conv::to_utf<char>(filepath, "HowCanIKnowWhatToPutHere");

Le premier problème est de savoir quoi passer comme nom d’encodage, intuitivement je sais que ca doit etre quelque chose du genre 1252 mais quoi exactement ?
Le plus simple est d’aller voir dans les sources de boost et en debuggant on tombe sur le code suivant localisé dans le fichier src/encoding/wconv_codepage.ipp:

windows_encoding all_windows_encodings[] = {
        { "big5",       950, 0 },
        { "cp1250",     1250, 0 },
        { "cp1251",     1251, 0 },
        { "cp1252",     1252, 0 },
        { "cp1253",     1253, 0 },
        { "cp1254",     1254, 0 },
        { "cp1255",     1255, 0 },
        { "cp1256",     1256, 0 },
        { "cp1257",     1257, 0 },
        { "cp874",      874, 0 },
        { "cp932",      932, 0 },
        { "cp936",      936, 0 },
        { "eucjp",      20932, 0 },
        { "euckr",      51949, 0 },
        { "gb18030",    54936, 0 },
        { "gb2312",     20936, 0 },
        { "gbk",        936, 0 },
        { "iso2022jp",  50220, 0 },
        { "iso2022kr",  50225, 0 },
        { "iso88591",   28591, 0 },
        { "iso885913",  28603, 0 },
        { "iso885915",  28605, 0 },
        { "iso88592",   28592, 0 },
        { "iso88593",   28593, 0 },
        { "iso88594",   28594, 0 },
        { "iso88595",   28595, 0 },
        { "iso88596",   28596, 0 },
        { "iso88597",   28597, 0 },
        { "iso88598",   28598, 0 },
        { "iso88599",   28599, 0 },
        { "koi8r",      20866, 0 },
        { "koi8u",      21866, 0 },
        { "ms936",      936, 0 },
        { "shiftjis",   932, 0 },
        { "sjis",       932, 0 },
        { "usascii",    20127, 0 },
        { "utf8",       65001, 0 },
        { "windows1250",        1250, 0 },
        { "windows1251",        1251, 0 },
        { "windows1252",        1252, 0 },
        { "windows1253",        1253, 0 },
        { "windows1254",        1254, 0 },
        { "windows1255",        1255, 0 },
        { "windows1256",        1256, 0 },
        { "windows1257",        1257, 0 },
        { "windows874",         874, 0 },
        { "windows932",         932, 0 },
        { "windows936",         936, 0 },
    };

Première déception ce tableau est codé en dur mais par contre on voit que je peux passer soit cp1252 soit windows1252.
Ok je teste ca fonctionne mais je ne peux pas laisser une chainé codée en dure comme ca car va fonctionner sur un Windows en français mais pas forcement ailleurs.
Deuxième déception je réalise qu’il n’est pas possible de convertir simplement une chaîne MBCS car :
– Microsoft ne fournit pas d’API qui fasse le lien entre le codepage et une chaine. Pour être exact il existe les API GetACP() et GetLocaleInfo() :

   char strCodePage[10];	
   UINT codePage;

   if (GetLocaleInfoA(GetSystemDefaultLCID(), LOCALE_IDEFAULTANSICODEPAGE,
      strCodePage, sizeof(strCodePage) / sizeof(TCHAR)) > 0) 
   {
      // ANSI code page id
      // strCodePage = "1252" on a French OS
      codePage = atoi(strCodePage);
   }

GetLocaleInfo retourne une chaîne mais ca n’est pas la même (dans mon cas la chaine contient “1252”) que celle utilisée par boost dans son tableau…

Dans un monde parfait boost devrait ajouter une entrée dans son tableau (appelons là “mbcs” ou “CP_ACP”) et lorsque l’on passe cette chaîne
cela appelerait GetACP() pour récupérer la valeur du codepage pour la conversion et voici à quoi ressemblerait le code:

#include <windows.h>
#include <string>
#include <iostream>

#include <boost/locale.hpp>

//========================================================================================
// BIG WARNING: CODE BELOW DOES NOT WORK 
// AND ONLY SHOW HOW IT SHOULD BE IN A PERFECT WORLD
//========================================================================================
std::string mbcs_to_utf8(const std::string& sMbcs)
{
   // I REPEAT CODE BELOW CANNOT WORK
   return boost::locale::conv::to_utf<char>(sMbcs, "CP_ACP");
   // I REPEAT CODE ABOVE CANNOT WORK
}

Conclusion de cet article, je peux me tromper mais il semble qu’il ne soit pas possible de convertir simplement une chaîne mbcs en utf8.
Donc la solution est d’abord de convertir en UTF16 en utilisant une API Windows puis de convertir en utf8 en utilisant du pure c++ cette fois ci

// The line below should be put inside your cmake/solution as a global define
#define _SILENCE_ALL_CXX17_DEPRECATION_WARNINGS 
#include <windows.h>
#include <string>
#include <locale>
#include <codecvt>

/////////////////////////////////////////////////////////////////////////
// Function below converts a mbcs string into utf8 (using standard c++ and Windows API)
/////////////////////////////////////////////////////////////////////////
std::string mbcs_to_utf8(const std::string& sMbcs)
{
   //Convert mbcs string(codepage) to wide string (UTF16)
   int count = ::MultiByteToWideChar(CP_ACP, 0, sMbcs.c_str(), -1, nullptr, 0);
   std::wstring ws(count, L'\0');
   ::MultiByteToWideChar(CP_ACP, 0, sMbcs.c_str(), -1, &ws[0], count);

   //Convert wstring to utf8
   std::wstring_convert<std::codecvt_utf8<wchar_t>> myconv;
   return myconv.to_bytes(ws);
}

Ou si vous préférez une version qui utilise l’Api Windows puisqu’en c++ standard ca n’est pas possible:

#include <windows.h>
#include <string>

/////////////////////////////////////////////////////////////////////////
// Function below converts a mbcs string into utf8 (using Windows API)
/////////////////////////////////////////////////////////////////////////
std::string mbcs_to_utf8(const std::string& sMbcs)
{
   //Convert mbcs string(codepage) to wide string (UTF16)
   int count = ::MultiByteToWideChar(CP_ACP, 0, sMbcs.c_str(), -1, NULL, 0);
   std::wstring ws(count, L'\0');
   ::MultiByteToWideChar(CP_ACP, 0, sMbcs.c_str(), -1, &ws[0], count);
   
   //Convert wstring to utf8
   std::string s(count, '\0');
   ::WideCharToMultiByte(CP_UTF8, 0, ws.c_str(), -1, &s[0], count, NULL, NULL);
   
   return s;
}

Cet article me fait penser qu’il est dangereux de mélanger des API qui utilisent des chaines UTF8 avec d’autres qui utilisent des chaines MBCS car assez rapidement on va se retrouver avec des problèmes d’incompatibilités et des crash.
Pour ne rien arranger le comité de normalisation du C++ a décidé que les api de conversion utilisant codecvt sont depréciés sans rien proposé en attendant, cela ne veut pas dire qu’il ne faut pas les utiliser mais ca ajoute encore de la complexité sous Windows car il faut supprimer les warnings.

Sous Windows lorsque l’on utilise des std::string on devrait réserver UTF8 pour échanger des données entre le binaire et le monde extérieure.
Si j’en ai le courage cela fera l’objet d’un autre article intitulé: Pourquoi l’utilisation d’UTF8 à l’intérieur d’un programme Windows est dangereuse tant que Microsoft n’aura pas normalisé les APIs.

Pour faire un peu de teasing puisque le C++20 à introduit le type char8_t, rien n’empêche de rêver et Microsoft pourrait ajouter a sa libc des versions prenant des const char8_t* ce qui permettrait d’utiliser de l’utf8 a tous les niveaux à condition de recompiler toutes les librairies externes utilisées dans vos projets:

// TO BE ABLE TO USE UTF8 ON WINDOWS, MICROSOFT
// COULD ADD libc functions taking char8_t
// AND ON LINUX/MACOS char8_t would be an alias to char
FILE* fopen(char8_t const* _FileName, char8_t const* _Mode);
int printf(char8_t const* const _Format, ...);

[C++]: Convertir une chaîne MBCS en UTF8 avec du c++ standard
Mot clé :