viernes, 30 de enero de 2015

Conversión de Charset/Encoding en Java

Introducción


Un problema bastante común para los programadores, especialmente para los hispanohablantes, es la codificación de caracteres (Character encoding en inglés).

Un juego de caracteres (en inglés Character set o más comúnmente charset) es la definición de un conjunto de caracteres determinado identificándolos con un número.

Un charset puede tener uno o varios encodings. Un encoding determina la representación de cada carácter mediante secuencias de bytes (1 o más bytes). Es decir, el mismo carácter puede tener distintas representaciones en encodings diferentes, o visto desde el otro lado, una misma secuencia de bytes puede representar caracteres diferentes en un encoding u otro.

El manejo de encodings suele ser peor que un dolor de muelas, sobre todo cuando se necesita realizar comparaciones de Strings que provienen de diferentes fuentes: entrada de usuario, base de datos, código fuente, etc. Para obtener resultados correctos, hay que asegurarse de que cada String se construye a partir del encoding adecuado.

Problemas


Los problemas suelen aparecer con caracteres no ASCII como las vocales acentuadas. Por ejemplo, la cadena aáeéiíoóuú se representaría así:


Encoding / Bytes000102030405060708091011121314151617181920212223
ISO-8859-161e165e969ed6ff375fa0a
UTF-861c3a165c3a969c3ad6fc3b375c3ba0a
UTF-16fffe6100e1006500e9006900ed006f00f3007500fa000a00


Se puede observar que una misma secuencia de caracteres da lugar a 3 secuencias de bytes diferentes.

Las secuencias de bytes probablemente no sean compatibles entre sí: es decir, no se puede leer una secuencia codificada en UTF-8 como si lo estuviese ISO-8859-1 o en UTF-16. En el ejemplo, al usar un editor ISO-8859-1 con la secuencia en UTF-8 se vería aáeéiíoóuú y al hacer lo inverso se vería a�e�i�o�u.

Puede ocurrir que secuencias válidas en un encoding sean inválidas en otro. Por ejemplo, la secuencia hexadecimal C1C1 representa la cadena ÁÁ en ISO-8859-1 pero es una secuencia inválida en UTF-8.

También puede pasar que haya caracteres de un charset que no se puedan representar en otro: por ejemplo, el charset ISO-8859-15 tiene el símbolo del Euro €, por lo que no se podrían convertir con exactitud textos con dicho símbolo al charset ISO-8859-1 que no lo tiene.

Strings en Java


En Java, los caracteres y secuencias de caracteres (String, StringBuilder, etc) se representan en una de las versiones del encoding UTF-16 de Unicode. Eso significa que da igual desde que fuente se haya creado el carácter, internamente se almacena en UTF-16.

Los constructores de las clases String, InputStreamReader y OutputStreamWriter entre otras permiten indicar el charset (Charset), de modo que se puede indicar como interpretar las secuencias de bytes para convertirlas en caracteres y viceversa. Por defecto se utiliza Charset.defaultCharset(), un charset obtenido de la configuración del sistema operativo para el usuario (entorno) que arrancó la máquina virtual Java.

En el fondo, para realizar la conversión entre caracteres y secuencias de bytes al leer o escribir, se utilizan las siguientes clases:

  • CharsetEncoder: permite obtener la representación como secuencia de bytes de una secuencia de caracteres en determinado charset.
  • CharsetDecoder: permite interpretar una secuencia de bytes como una secuencia de caracteres en un determinado charset.
Dichas clases proporcionan el mayor control sobre el proceso de conversión, permitiendo especificar por ejemplo el comportamiento ante secuencias de bytes inválidas o caracteres no representables.

Por el contrario, otras alternativas tienen restricciones que se deben tener en cuenta. Por ejemplo, en la documentación oficial de la clase String:

Ejemplos


He realizado una implementación simple del comando iconv. El código se puede ver en el siguiente enlace:

El modo de uso es el siguiente:

$ java -jar Project.jar input.file input-charset ouput-charset

Por ejemplo:

$ java -jar Project.jar utf8.txt utf8 iso-8859-1

Si no se invoca con 3 argumentos, se muestra la lista de charsets disponible y se indica el modo de uso correcto.

El código incluye la función:

public static String doConversion(
    String input,
    String inputCharsetName,
    String outputCharsetName
) throws IOException;

para transformar una String desde un charset a otro. He explicado que en memoria una String siempre está en UTF-16, por lo que la verdadera utilidad de esta función es ser un esqueleto para tratar los posibles problemas de conversión que se pueden presentar al pasar una String desde un charset origen (es decir, se está seguro de que todos los caracteres que la componen son representables en dicho charset) a otro charset destino según se configuren:

Para hacer pruebas, es recomendable utilizar 2 de los charsets cuya presencia debe estar garantizada en cualquier implementación de la plataforma Java (ver java.nio.charset.StandardCharsets): ISO-8859-1 y UTF-8. Y utilizar cadenas de caracteres cuya representación varíe de un charset a otro, como por ejemplo, las vocales acentuadas: áéíóú.

Enlaces


Enlaces de interés relacionados con este artículo:


(Actualizado 31/01/2015)

Source code: CharsetTest

Código fuente del programa CharsetTest

Ver la entrada correspondiente en el siguiente enlace:


Conversión de Charset/Encoding en Java


package pruebas;

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintStream;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.CoderResult;
import java.nio.charset.CodingErrorAction;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;


public class CharsetTest {

    public static void printAvailableCharsets(PrintStream out, boolean aShowAliases) {
        Map<String, Charset> charsetMap= Charset.availableCharsets();
        Set<String> charsetNames= charsetMap.keySet();
        Iterator<String> charsetIt= charsetNames.iterator();

        while (charsetIt.hasNext() == true) {
            String csName= charsetIt.next();
            out.print(csName);

            if (aShowAliases == true) {
                Charset charset= charsetMap.get(csName);
                Set<String> aliases= charset.aliases();
                Iterator aliasIter= aliases.iterator();

                if (aliasIter.hasNext() == true) {
                    out.print(": ");

                    while (aliasIter.hasNext() == true) {
                        out.print( aliasIter.next() );

                        if (aliasIter.hasNext() == true) {
                            out.print(", ");
                        }
                    }
                }
            }

            out.println();
        }
    }
    //------------------------------------------------------------------------------------------

    public static String doConversion(
        String input,
        Charset inputCharset,
        Charset outputCharset
    ) throws IOException {
        CharsetEncoder inEncoder= inputCharset.newEncoder();
        //Se configura que se provoque error ante cualquier problema de conversión.
        inEncoder= inEncoder.onMalformedInput( CodingErrorAction.REPORT );
        inEncoder= inEncoder.onUnmappableCharacter( CodingErrorAction.REPORT );

        CharsetDecoder outDecoder= outputCharset.newDecoder();
        //Se configura que se provoque error ante cualquier problema de conversión.
        outDecoder= outDecoder.onMalformedInput( CodingErrorAction.REPORT );
        outDecoder= outDecoder.onUnmappableCharacter( CodingErrorAction.REPORT );

        CharBuffer charBuffer= CharBuffer.wrap(input);
        ByteBuffer byteBuffer= inEncoder.encode(charBuffer);

        charBuffer= outDecoder.decode(byteBuffer);
        String output= charBuffer.toString();

        return output;
    }
    //------------------------------------------------------------------------------------------

    /**
     * Example:
     * System.err.println( CharsetTest.doConversion("áéíóú",  "utf8", "ISO-8859-15") );
     */
    public static String doConversion(
        String input,
        String inputCharsetName,
        String outputCharsetName
    ) throws IOException {
        Charset inputCharset;
        Charset outputCharset;

        try {
            inputCharset= Charset.forName(inputCharsetName);
            outputCharset= Charset.forName(outputCharsetName);
        }
        catch (Exception ex) {
            String msg= String.format("%s: %s", ex.getClass().getSimpleName(), ex.getLocalizedMessage());
            RuntimeException rte= new RuntimeException(msg, ex);
            throw rte;
        }

        String output= CharsetTest.doConversion(input, inputCharset, outputCharset);

        return output;
    }
    //------------------------------------------------------------------------------------------

    public static IOException wrapCharacterCodingException(
        CharacterCodingException ex,
        int consumedChars,
        int consumedBytes
    ) {
        String msg= String.format("After sucessfully reading %s characters (%s bytes): %s: %s"
            , ex.getClass().getSimpleName()
            , consumedChars
            , consumedBytes
            , ex.getLocalizedMessage()
        );
        IOException tmp= new IOException(msg, ex);

        return tmp;
    }
    //------------------------------------------------------------------------------------------

    public static void doConversion(
        InputStream inputStream,
        Charset inputCharset,
        OutputStream outputStream,
        Charset outputCharset
    ) throws IOException {
        CharsetDecoder inDecoder= inputCharset.newDecoder();
        //Se configura que se provoque error ante cualquier problema de conversión.
        inDecoder= inDecoder.onMalformedInput( CodingErrorAction.REPORT );
        inDecoder= inDecoder.onUnmappableCharacter( CodingErrorAction.REPORT );

        CharsetEncoder outEncoder= outputCharset.newEncoder();
        //Se configura que se provoque error ante cualquier problema de conversión.
        outEncoder= outEncoder.onMalformedInput( CodingErrorAction.REPORT );
        outEncoder= outEncoder.onUnmappableCharacter( CodingErrorAction.REPORT );

        InputStreamReader isR= new InputStreamReader(inputStream, inDecoder);
        //Se utiliza un buffer de 1 byte para poder tener precisión al contar los bytes cuando se produce un error.
        //De no ser así, una sóla llamada a read() puede leer varios bytes.
        BufferedReader inBR= new BufferedReader(isR, 1);

        //Se convierte de character en character.
        //The maximum number of bytes per character is 4 according to RFC3629 which limited the character table to U+10FFFF.
        ByteBuffer byteBuffer= ByteBuffer.allocate(4);
        CharBuffer charBuffer= CharBuffer.allocate(1);
        byte[] byteArray= new byte[4];

        int consumedChars= 0;
        int consumedBytes= 0;
        int r;

        do {
            try {
                //El CharsetDecoder puede lanzar excepciones.
                r= inBR.read();
            }
            catch (CharacterCodingException ex) {
                IOException tmp= CharsetTest.wrapCharacterCodingException(ex, consumedChars, consumedBytes);
                throw tmp;
            }

            if (r != -1) {
                //Para escribir a partir del comienzo.
                charBuffer.put(0, (char) r);
                //Para leer a partir del comienzo.
                charBuffer.rewind();

                //Para escribir a partir del comienzo.
                byteBuffer.rewind();
                CoderResult cr= outEncoder.encode(charBuffer, byteBuffer, false);
                if (cr.isError() == true) {
                    try {
                        cr.throwException();
                    }
                    catch (CharacterCodingException ex) {
                        IOException tmp= CharsetTest.wrapCharacterCodingException(ex, consumedChars, consumedBytes);
                        throw tmp;
                    }
                }

                //MUY IMPORTANTE: hay que salvaguardar position() antes del rewind().
                int outByteCount=  byteBuffer.position();
                //Para leer a partir del comienzo.
                byteBuffer.rewind();
                byteBuffer.get(byteArray, 0, outByteCount);

                outputStream.write(byteArray, 0, outByteCount);
                consumedChars++;
                consumedBytes= consumedBytes + outByteCount;
            }
        } while (r != -1);

        outputStream.flush();
    }
    //------------------------------------------------------------------------------------------

    public static void main(String[] args) {
        InputStream fis= null;
        PrintStream err= System.err;

        try {
            if (args.length != 3) {
                CharsetTest.printAvailableCharsets(err, true);
                err.println();

                throw new RuntimeException("Usage: intputFileName inputCharsetName outputCharsetName");
            }

            String intputFileName= args[0];
            String inputCharsetName= args[1];
            String outputCharsetName= args[2];

            Charset inputCharset;
            Charset outputCharset;
            try {
                inputCharset= Charset.forName(inputCharsetName);
                outputCharset= Charset.forName(outputCharsetName);
            }
            catch (Exception ex) {
                String msg= String.format("%s: %s", ex.getClass().getSimpleName(), ex.getLocalizedMessage());
                RuntimeException rte= new RuntimeException(msg, ex);
                throw rte;
            }

            try {
                fis= new FileInputStream(intputFileName);
            }
            catch (FileNotFoundException ex) {
                String msg= String.format("%s: %s", ex.getClass().getSimpleName(), ex.getLocalizedMessage());
                RuntimeException rte= new RuntimeException(msg, ex);
                throw rte;
            }

            PrintStream outPS= System.out;

            CharsetTest.doConversion(fis, inputCharset, outPS, outputCharset);
        }
        catch (Exception ex) {
            err.println( ex.getMessage() );
            System.exit(1);
        }
        finally {
            if (fis != null) {
                try {
                    fis.close();
                }
                catch (IOException ex) {
                    Logger.getLogger(CharsetTest.class.getName()).log(Level.SEVERE, null, ex);
                    //Se silencia el error.
                }
            }
        }
    }
    //------------------------------------------------------------------------------------------

}