segunda-feira, 2 de fevereiro de 2015

Detectar o encoding de um arquivo para não corromper ao transformá-lo

Em uma certa aplicação que eu fiz, um programa windows forms deveria fazer upload de um arquivo CSV para uma aplicação WEB para a importação do arquivo.

O problema é que esse sistema funcionava em 8 línguas diferentes, então, obviamente, o sistema deveria suportar vários tipos de caracteres (e encodings) diferentes.

Além disso o CSV era ou montado por um usuário, exportando do excel, ou exportado por um ERP.

Seria impossível impor ao usuário que sempre gerasse um arquivo UTF-8, porque além de não ser obrigado a saber o que é isso, sistemas ERP legados dificilmente exportarão os arquivos nesse formato, e você, em uma empresa pequena, simplesmente não têm como impor que uma empresa como SAP ou Totvs altere seus sistemas para exportar arquivos em UTF-8.

O site da web aceitava o arquivo como vinha, fazia uma conversão forçada para o formato padrão (ANSI ISO 8859-1) e importava o arquivo.

Conversões forçadas são aquelas que você faz sem saber qual é o formato de origem do arquivo.
Se você simplesmente mandar abrir um arquivo do qual não sabe o encoding o .net framework usará o encoding default do computador. Se o arquivo não estiver na codificação padrão ele será lido erroneamente,  parecendo estar corrompido. Qualquer operação com ele, como concatenar informações e escrever em outro arquivo resultará em strings corrompidas, com acentos perdidos.

Se o arquivo for ASCII ou o ANSI no encoding padrão de um computador com windows em inglês ou português a conversão ocorreria sem problemas. Se o arquivo fosse UTF-8 ou qualquer outro codepage ou tivesse caracteres especiais de uma outra linguagem essa conversão forçada corromperia o arquivo.

Como se isso não bastasse, havia um outro fator: ao subir o arquivo direto pelo site o  browser identificava o encoding do arquivo e colocava o  encoding correto no cabeçalho. Ao ler o stream e colocar o resultado em uma string o .net fazia uma conversão "não forçada" para o formato padrão do servidor, pois ele sabia o formato de origem, informado pelo request, e esse tipo de conversão muda o formato e o encoding de um arquivo sem corromper, mantendo os mesmos caracteres especiais e acentuados, apenas com uma outra codificação.

Isso mascarava o problema, pois raramente ocorria uma corrupção de arquivo.

Por outro lado, quando eu tentava fazer o upload com a aplicação windows, eu tentava impor ou assumir um determinado formato e fazer o upload nesse formato. Um pré processamento era realizado antes do upload do arquivo.

Eu precisava que minha aplicação windows detectasse qual era o encoding do arquivo antes de abri-lo, fizesse as operações necessárias, colocasse esse encoding no cabeçalho http do request e fizesse o upload desse arquivo. O servidor, por sua vez, deveria também detectar o encoding do arquivo, fazer uma asserção para comparar com o encoding informado no request e lançar uma exceção se fossem diferentes. Se fossem iguais ele poderia prosseguir com o processamento do arquivo.

Como se faz para detectar o encoding de um arquivo. Como o browser faz isso?

Uma pesquisada no google me fez encontrar a biblioteca chardet 

Foi necessária a instalação do TortoiseSVN  para baixar o source da mesma, que está no google code. Recompilei o source, e nesse post eu disponibilizo tanto o source como o compilado para download.

Essa biblioteca se propõe a analisar n bytes de um arquivo de texto e fazer uma heurística para descobrir que tipo de encoding ele usa, baseando-se em uma estatística dos caracteres encontrados.

Instale o tortoise SVN, clique com o direito na área de trabalho, dê o comando tortoise checkout e coloque o endereço http://chardetsharp.googlecode.com/svn/trunk/ conforme a figura abaixo.


Abra o sln com o visual studio e recompile o mesmo. Agora você pode importar a dll gerada no seu projeto.

Também encontrei a classe EncodingTools no site do code project, que fazia uso de um outro algoritmo para identificar o encoding. Essa classe, podemos dizer, é mais "caseira".

Encontrei uma classe chamada TextFileEncodingDetector no git que não é muito boa, pois se baseia apenas na detecção do BOM (byte order mark) coisa que a Unicode já descontinuou. Mais sobre Unicode e BOM.

Por último eu encontrei uma biblioteca chamada UDE que na minha opinião foi a mais completa e profissional. Tanto a chardet como a UDE são ports do Mozilla Universal Character Detector no entanto a UDE está mais atual. É a biblioteca usada pela mozilla para detecção de caracteres. A UDE também deve ser baixada com o SVN no endereço e recompilada. No arquivo ao final desse post você pode ver todo

Precisava testar essas três bibliotecas tanto com arquivos de teste como com meus arquivos de produção para saber qual trabalhava melhor. Para testar em casos extremos usei tanto arquivos muito pequenos, com caracteres especiais mas sem caracteres suficientes para caracterizar um determinado encoding / linguagem, e testei com arquivos grandes todos em ANSI e que tinham um único caractere em utf-8.

Eu criei uma classe estática com uma única função para facilitar a transformação de um arquivo em um vetor de bytes. Como meus arquivos são pequenos não fiz nenhuma consideração quanto à performance ou quanto à limitar o tamanho do vetor de bytes. Se você for importar arquivos maiores do que 10mb seria prudente levar isso em consideração.


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;

namespace EncodeDetector
{
    public static class TextUtils
    {
        public static byte[] FileToBytes(string filename)
        {
            using(FileStream fs = new FileStream(filename, FileMode.Open, FileAccess.Read))
            {
                byte[] queEuGosto = new byte[fs.Length];
                fs.Read(queEuGosto, 0, (int)fs.Length);
                return queEuGosto;
            }
        }
    }
}

Também criei uma classe chamada VtrEncodingDetector como um wrapper para a biblioteca UDE, com dois objetivos: primeiro, se for detectado que o arquivo tem um BOM, então eu posso identificar o tipo do arquivo lendo apenas 3 ou 4 bytes em vez do arquivo todo. (isso não é confiável, pois o arquivo pode ter uma BOM marcando UTF-8 e estar todo em ANSI). Outro motivo é que eu posso trocar a implementação interna da minha biblioteca assim que eu encontre uma biblioteca de detecção de charset que eu goste mais.
Abaixo o código da classe.


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Mozilla.CharDet;
using Ude;

namespace EncodeDetector
{
    public static class VtrEncodingDetector
    {
        /// 
        /// Retorna encoding do streaming. (UTF7; UTF8; Unicode)
        /// 
        /// Bytes do Arquivo
        /// Encoding Type
        public static Encoding GetEncodingFile(byte[] bytes)
        {
            if (bytes != null && bytes.Length >= 2)
            {
                //verifica esses primeiro pois utf32 pode confundir-se com utf16
                if (bytes[0] == 0xff && bytes[1] == 0xfe && bytes[2] == 0 && bytes[3] == 0)
                    return Encoding.UTF32;

                //utf-32BE  Unicode (UTF-32 Big-Endian)  
                if (bytes[0] == 0 && bytes[1] == 0 && bytes[2] == 0xfe && bytes[3] == 0xff)
                    return Encoding.GetEncoding(12001);

                if (bytes[0] == 255 && bytes[1] == 254)
                    return Encoding.Unicode;

                if (bytes[0] == 0xfe && bytes[1] == 0xff)
                    return Encoding.BigEndianUnicode;


                if (bytes[0] == 239 && bytes[1] == 187 && bytes[2] == 191)
                    return Encoding.UTF8;

                if (bytes[0] == 60 && bytes[1] == 63)
                    return Encoding.ASCII;

                //reconhecimento de utf7 http://pt.wikipedia.org/wiki/Marca_de_ordem_de_byte
                if (bytes[0] == 43 && bytes[1] == 47 && bytes[2] == 118 && (bytes[3] == 56 || bytes[3] == 57 || bytes[3] == 43 || bytes[3] == 47))
                    return Encoding.UTF7;

                //uso da ferramenta UDE para detectar automaticamente o encoding
                try
                {
                    //http://code.google.com/p/ude/
                    CharsetDetector d = new CharsetDetector();
                    d.Feed(bytes, 0, bytes.Length);
                    d.DataEnd();
                    Encoding enc = Encoding.GetEncoding(d.Charset);
                    d.Reset();
                    if (enc != null)
                        return enc;
                    else
                        //por padrão retorna 1252, que é o iso 8859-1, ansi para europa ocidental, codepage padrão para windows em inglês e português
                        //http://msdn.microsoft.com/en-us/library/system.text.encoding.codepage(v=vs.110).aspx
                        //return Encoding.GetEncoding(1252);
                        return Encoding.Default;
                }
                catch
                {
                    //por padrão retorna 1252, que é o iso 8859-1, ansi para europa ocidental, codepage padrão para windows em inglês e português
                    //http://msdn.microsoft.com/en-us/library/system.text.encoding.codepage(v=vs.110).aspx
                    //return Encoding.GetEncoding(1252);
                    return Encoding.Default;
                }
            }
            //por padrão retorna 1252, que é o iso 8859-1, ansi para europa ocidental, codepage padrão para windows em inglês e português
            //http://msdn.microsoft.com/en-us/library/system.text.encoding.codepage(v=vs.110).aspx
            //return Encoding.GetEncoding(1252);
            return Encoding.Default;
        }
    }
}



Antes de começar os testes, abra o projeto do UDE. Você verá que na linha  71 do arquivo UniversalDetector.cs existe um field protected chamado charsetProbers. Um array de Probers.
protected CharsetProber[] charsetProbers = new CharsetProber[PROBERS_NUM];



Vá na classe CharsetDetector, arquivo CharsetDetector.cs, que é descendente de UniversalDetector e transforme esse field protegido em uma propertie somente leitura pública. Poderia ser um método Get também.

public CharsetProber[] CharsetProbers { get { return this.charsetProbers; } }

Isso fará com que a lista interna de CharsetProbers seja exposta, permitindo que você navegue por ela. Um prober é uma classe interna da biblioteca responsável por identificar um charset específico. Quando você cria a classe CharsetDetector e passa para ela um vetor de bytes para serem analisados ela cria vários probers para analisar o arquivo e nesse vetor ficam os probers usados, e a porcentagem de confiabilidade de cada um deles. Como existe um prober para cada encoding, o prober com maior confiabilidade será o que providenciará a resposta final da biblioteca.

Em um projeto real essa modificação é totalmente desnecessária. Foi incluída aqui apenas de curiosidade, para nossos testes. Outra modificação é na biblioteca chardet. Ela sempre escreve o encoding detectado no console, mesmo que você não queira. É um processo interno de debug dela, mas atrapalha quando o teste é feito justamente em uma aplicação console. Você pode comentar a linha 255 do arquivo UniversalDetector.cs:
//Console.Out.WriteLine(aCharset);

Comente também a linha 197 do arquivo SingleByteCharsetProber.cs:
//Console.Out.WriteLine("  SBCS: {0:0.000} [{1}]", GetConfidence(), CharSetName);

Agora a biblioteca não vai mais fazer um dump do seu processo interno de detecção, mesmo em modo debug. A biblioteca UDE também tem essas saídas. Procure por console.writeline ou console.out.writeline dentro de todos os métodos DumpStatus e comente.

E Finalmente a classe Program.cs onde faço os testes. Compile o programa e execute-o pela linha de comando, passando como argumento o nome de um dos arquivos da pasta data ou um que você tenha aí. Mandei junto com o exemplo um executável hexeditor.exe para que você veja como a codificação de um caractere acentuado pode mudar dependendo do encoding usado. Para debugar você pode colocar um nome de arquivo fixo, usar parâmetros da linha de comando, usar testes unitários ou usar um FileOpenDialog. Aproveite também os vários arquivos de testes que vêm com os testes unitários do UDE.

//program


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using href.Utils;
using KlerksSoft;
using Mozilla.CharDet;
using Ude;

namespace EncodeDetector
{
    class Program
    {
        static void Main(string[] args)
        {
            if (args.Count() == 0)
            {
                Console.WriteLine("Informe um arquivo");
                return;
            }

            //limpa o console
            Console.Clear();
            Console.WriteLine("Econding detectado para o arquivo: " + args[0]);
            Console.WriteLine("");

            //crio o vetor de bytes com o conteudo do arquivo
            byte[] queEuGosto = TextUtils.FileToBytes(args[0]);


            #region encoding tools
            //Usando encoding tools
            //fonte:http://www.codeproject.com/KB/recipes/DetectEncoding.aspx
            Encoding enc1 = EncodingTools.DetectInputCodepage(queEuGosto);
            if (enc1 != null)
                Console.WriteLine("Encoding Tools\t-->\t" + enc1.EncodingName + "/" + enc1.BodyName + "/" + enc1.CodePage);
            #endregion


            #region TextFileEncodingDetector
            //fonte: https://gist.github.com/TaoK/945127
            Encoding enc2 = TextFileEncodingDetector.DetectTextByteArrayEncoding(queEuGosto);
            if (enc2 != null)
                Console.WriteLine("TextFileEncodingDetector\t-->\t" + enc2.EncodingName + "/" + enc2.BodyName + "/" + enc2.CodePage);
            #endregion


            #region chardet
            //fonte: http://code.google.com/p/chardetsharp/
            var d = new UniversalDetector();
            d.HandleData(queEuGosto);
            d.DataEnd();            
            Encoding enc3 = Encoding.GetEncoding( d.DetectedCharsetName);
            if (enc3 != null)
                Console.WriteLine("CharDet\t-->\t" + enc3.EncodingName + "/" + enc3.BodyName + "/" + enc3.CodePage);
            #endregion


            #region UDE via VtrEncodingDetector wrapper
            //usando um wrapper para UDE
            Encoding enc4 = VtrEncodingDetector.GetEncodingFile(queEuGosto);
            if (enc4 != null)
                Console.WriteLine("VtrEncodingDetector\t-->\t" + enc4.EncodingName + "/" + enc4.BodyName + "/" + enc4.CodePage);
            #endregion


            #region UDE direto
            //uando UDE direto
            //fonte: http://code.google.com/p/ude/
            CharsetDetector cdet = new CharsetDetector();
            cdet.Feed(queEuGosto, 0, queEuGosto.Length);
            cdet.DataEnd();
            Encoding enc5 = Encoding.GetEncoding(cdet.Charset);
            if (enc5 != null)
            {
                Console.WriteLine("UDE\t-->\t" + enc5.EncodingName + "/" + enc5.BodyName + "/" + enc5.CodePage + " Com confiabilidade de " + (cdet.Confidence * 100).ToString("0.0") + "%");
                foreach (var x in cdet.CharsetProbers)
                {
                    if (x != null)
                        Console.WriteLine("\t- " + x.GetCharsetName() + "\t" + (x.GetConfidence() * 100).ToString("0.00"));
                }
            }
            cdet.Reset();
            #endregion 


            //só para podermos ver o resultado
            //Console.ReadLine();
        }
    }
}



No arquivo desse exemplo enpacotei junto o source e a versão compilada da UDE e da chardet. Há também alguns arquivos de texto em encodings diferentes para teste.
Divirta-se!

Um comentário:

Postagens populares

Marcadores

delphi (60) C# (31) poo (21) Lazarus (19) Site aos Pedaços (15) sql (13) Reflexões (10) .Net (9) Humor (9) javascript (9) ASp.Net (8) api (8) Básico (6) Programação (6) ms sql server (5) Web (4) banco de dados (4) HTML (3) PHP (3) Python (3) design patterns (3) jQuery (3) livros (3) metaprogramação (3) Ajax (2) Debug (2) Dicas Básicas Windows (2) Pascal (2) games (2) linguagem (2) música (2) singleton (2) tecnologia (2) Anime (1) Api do Windows (1) Assembly (1) Eventos (1) Experts (1) GNU (1) Inglês (1) JSON (1) SO (1) datas (1) developers (1) dicas (1) easter egg (1) firebird (1) interfaces (1) introspecção (1) memo (1) oracle (1) reflexão (1)