Introducción a Buffer

Introducción a Buffer

Introducción al manejo de datos binarios en JavaScript, ArrayBuffer e introducción a los juegos de caracteres.

·

15 min read

15.1 Datos binarios.

Para poder tratar los flujos TCP, leer y escribir en el sistema de archivos, es necesario tratar con flujos de datos que son puramente binarios. Los datos, como regla general, se presentan internamente como octetos, (8 bits). Un octeto o byte es una pequeña unidad de datos con un valor numérico entre 0 y 255 (256 valores). Los valores numéricos se pueden representar en la notación normal (decimal), pero también se pueden usar otras presentaciones, como la octal (base 8) o la hexadecimal (base 16).

15.2 Búfer. ArrayBuffer en JavaScript.

Un búfer es una región de almacenamiento ubicada en la memoria física que es utilizada para almacenar temporalmente una cierta cantidad de datos, octetos, mientras se mueven de un lugar a otro. En Node.js esta memoria intermedia corresponde a memoria bruta asignada fuera del montón (“heap”) de V8.

En JavaScript para representar un búfer genérico se usa el objeto “ArrayBuffer”, que en definitiva es un área de memoria reservada donde se almacenará un conjunto datos binarios. Esta área es de tamaño fijo y no puede incrementarse ni reducirse dinámicamente. No se puede trabajar directamente con esta área reservada, ya que no existen métodos o funciones que sean capaces de acceder directamente al “ArrayBuffer”, para poder trabajar y acceder a esos datos ubicados en memoria, desde JavaScript puro, es necesario utilizar el objeto “TypedArray” o el objeto “DataView” que funcionan como una vista de los datos contenidos en un “ArrayBuffer”.

Estas matrices tipadas, son similares a un “array” de enteros que no puede redimensionarse. El “tipo” se refiere a una “vista” concreta de ese búfer como pueden ser: números enteros sin signo de 8 bits, números enteros de 32 bits, coma flotante de 64 bits, etc.

  • “Int8Array”: entero de 8-bit con signo
  • “Uint8Array”: entero de 8-bit sin signo
  • “Uint8ClampedArray”: entero de 8-bit sin signo restringido a valores entre 0 y 255
  • “Int16Array”: 2 enteros de 16-bit con signo
  • “Uint16Array”: 2 enteros de 16-bit sin signo
  • “Int32Array”: 4 enteros de 32-bit con signo
  • “Uint32Array”: 4 enteros de 32-bit sin signo
  • “Float32Array”: 4 coma flotante de 32-bit
  • “Float64Array”: 8 coma flotante de 64-bit

Sobre un mismo “ArrayBuffer” podemos establecer una o más vistas de matrices tipadas. Cada una de las matrices trabajará sobre el mismo búfer, y cada una de ellas tratará los datos según el tipo de datos que tienen definidos. Hay que tener en cuenta, cuando se almacena más de un byte, si el entorno es BE (Big-endian) o LE (Little-endian). LE considera el byte menos significativo en primera posición, en BE el byte más significativo se almacena en primera posición.

En el siguiente ejemplo creamos un “ArrayBuffer” de 2 bytes y creamos dos matrices con tipo sobre el mismo búfer de datos, una de enteros de 8 bits sin signo y otra de enteros de 16 bits sin signo.

Codigo disponible en el archivo buffer1.js GitHub

Usamos la vista de un entero 8 bits, para asignar los valores al primer y segundo byte. En el primer octeto guardamos el valor 255 en binario ‘11111111’ en el segundo byte guardamos el valor 128 en binario ‘10000000’.

Cuando se visualiza el valor contenido en el “ArrayBuffer” desde la vista de 16 bits, en un entorno LE, considera que el primer byte es el menos significativo y el segundo byte el más significativo por lo el valor obtenido es el siguiente 1000000011111111 que es 33023 en decimal. En un entorno BE el valor decimal sería 65408 en binario 1111111110000000.

const memoria = new ArrayBuffer(2);
const vista8 = new Uint8Array(memoria);
const vista16 = new Uint16Array(memoria);
vista8[0] = 255; //11111111
vista8[1] = 128; //10000000
console.log(vista8); //Uint8Array [ 255, 128 ]
//LE byte menos significativo 1º
console.log(vista16, vista16[0].toString(2)); //Uint16Array [ 33023 ] '1000000011111111'

Si modificamos el valor desde la vista de 16 evidentemente también cambia los valores para la vista de 8. Si asignamos el valor 65408 en binario 1111111110000000 se puede observar cómo ha cambiado la vista de 8, al ser un entorno LE el byte menos significativo “10000000” (128 decimal) se coloca en primera posición el más significativo en segunda posición “11111111” (255 en decimal). Si asignamos cualquier otro valor desde la vista de 16, por ejemplo 1000 pasaría lo mismo.

vista16[0] = 65408; //1111111110000000
console.log(vista16); //Uint16Array [ 65408 ]
console.log(vista8, vista8[0].toString(2),vista8[1].toString(2)); 
//Uint8Array [ 128, 255 ] '10000000' '11111111'

vista16[0] = 1000; //0000001111101000
console.log(vista16); //Uint16Array [ 1000 ]
console.log(vista8, vista8[0].toString(2),vista8[1].toString(2)); 
//Uint8Array [ 232, 3 ] '11101000' '11'

15.3 Codificación y juego de caracteres.

Como se ha explicado anteriormente cada octeto de información contiene un valor numérico. Para poder representar los caracteres, hay que establecer una correspondencia entre el valor numérico del octeto y un carácter en función de alguna tabla de mapeo (codificación). Un repertorio de caracteres especifica una colección de caracteres, como "a", "!" Y "ä". Los códigos de carácter son códigos numéricos definidos para los caracteres de un repertorio. Una codificación de caracteres define cómo las secuencias de códigos numéricos son asignadas a las secuencias de octetos.

Codificación de caracteres. Estándares.

ASCII 7 bits

Una de las primeras codificaciones en aparecer fue ASCII (American Standard Code for Information Interchange). La codificación, ASCII usa 7 bits por lo que dispone de 128 valores posibles que van desde el 0000000 a 1111111, cada número de código se presenta como un octeto con el mismo valor y representa un carácter. ASCII tiene espacio suficiente para todas las minúsculas y mayúsculas de las letras latinas y para cada dígito numérico, signos de puntuación comunes, espacios y otros caracteres de control. Empieza en el código 32 (asignado al espacio en blanco) y termina en el 126 (asignado a el carácter tilde ~). Las posiciones del 0 a 31 y 127 están reservadas para códigos de control. La mayoría de los códigos de caracteres actualmente en uso contienen ASCII como su subconjunto en algún sentido, los octetos que contienen valores del 128 al 255 no se usan en ASCII.

Codificaciones de 8 bits

Mediante 8 bits se puede representar hasta el número 255 (256 valores), por lo que se puede tener un conjunto de caracteres más amplios, ASCII solo asigna hasta 127, los otros valores del 128 a 255 son de repuesto, aprovechando estos valores de repuesto aparecieron diferentes códigos de caracteres que fueron creados para adaptarse a los diferentes idiomas. Estos código de caracteres son una extensión del ASCII, los más importantes son los pertenecientes a la familia ISO 8859 y el juego de caracteres de Windows. Los códigos ISO 8859 amplían el repertorio ASCII de diferentes maneras con diferentes caracteres especiales utilizados en diferentes idiomas y culturas. Las posiciones del código 0 - 127 contienen el mismo carácter que en ASCII, las posiciones 128 - 159 no se usan (reservadas para los caracteres de control), y las posiciones 160 - 255 son la parte variable, utilizada de manera diferente en diferentes miembros de la familia ISO 8859. ISO 8859-1 alias “Latín 1” contiene varios caracteres acentuados y otras letras necesarias para escribir idiomas de Europa occidental y algunos caracteres especiales. El juego de caracteres de Windows, es similar a la familia ISO 8859, la principal diferencia es que algunas de las posiciones del rango 128 a 159 se asignan a caracteres imprimibles, como las comillas dobles, comillas simples etc., en el rango de 160-255 se mantienen las mismas asignaciones de caracteres que en ISO 8859 aunque no siempre, existiendo así diferentes páginas de código (CP), que difieren del estándar ISO 8859 correspondiente.

ISO 10646, Unicode y UTF-8.

Las anteriores codificaciones de caracteres eran limitadas y no podían contener suficientes caracteres para abarcar todos los idiomas del mundo, además entraban en conflicto, pues dos codificaciones podrían usar el mismo número o código para dos caracteres diferentes, o usar números diferentes para el mismo carácter. Si se quiere codificar todos los caracteres del mundo asignando a cada uno un código único no es suficiente con los 256 valores que se pueden almacenar en un byte, para su codificación es necesario un código multibyte.

Con este objetivo nacieron paralelamente la norma ISO 10646 y el estándar Unicode (consorcio Unicode), que definen el Conjunto de Caracteres Universales, (UCS), que contiene los caracteres necesarios para representar prácticamente todos los idiomas conocidos, también cubre una gran cantidad de símbolos gráficos, tipográficos, matemáticos y científicos. UCS es un superconjunto de todos los demás estándares de conjunto de caracteres. UCS y Unicode al necesitar codificación multibyte abarca dos cosas: un conjunto de caracteres y un conjunto de codificaciones.

  • Un conjunto de caracteres:

Tablas de códigos que asignan números enteros a los caracteres. UCS le asigna a cada carácter un nombre o “punto de código” que consiste en un número hexadecimal que representa el valor UCS o Unicode y que suele estar precedido por la cadena "U +". Los caracteres UCS que van desde “U+0000” a “U+007F” son idénticos a los de US-ASCII y el rango de “U+0000” a “U+00FF” es idéntico al ISO 8859-1 (Latin-1). Además proporciona códigos para signos diacríticos y permite que ciertas secuencias de caracteres también se pueden representar como un solo carácter, llamado carácter pre compuesto (o compuesto, o carácter descomponible). Por ejemplo, el carácter "ñ" puede codificarse como el único punto de código U+00F1 "ñ" o como una secuencia compuesta del carácter base U+006E "n" seguido del carácter no espaciador U+0303 “~”.

Los caracteres más comúnmente utilizados, incluidos todos los que se encuentran en los principales estándares de codificación anteriores, se han colocado en un primer plano (0x0000 a 0xFFFD), 64K (2^16^ ) puntos de código, que se llama plano multilingüe básico (BMP) o Plano 0.

  • Un conjunto de codificaciones:

Para poder codificar todos los caracteres de todos los idiomas utilizando varios bytes, es decir, asignar una secuencia de bytes, “valor de código”, a cada carácter o punto de código, existen varias alternativas.

Las dos codificaciones más obvias almacenan los caracteres Unicode como una secuencia de bytes de ancho fijo de 2 o 4 bytes. Los términos oficiales para estas codificaciones son UCS-2 y UCS-4, respectivamente. A menos que se especifique lo contrario, el byte más significativo es el primero (Big endian). Un archivo ASCII se puede transformar en un archivo UCS-2 simplemente insertando un byte “0x00” delante de cada byte ASCII. Si queremos tener un archivo UCS-4, tenemos que insertar tres bytes de “0x00” antes de cada byte ASCII.

Para poder usar un ancho variable se creó el formato de transformación Unicode (UTF) o "formato de transformación UCS” que mediante un mapeo algorítmico asigna a cada punto de código Unicode una secuencia de bytes única. Debido a que el punto de código tiene 21 bits, y a que los ordenadores transfieren datos en múltiplos de 8 bits hay tres posibles modos de expresar Unicode:

  • Usando una unidad de código de 32 bits, cada carácter se representa con 4 bytes (UTF-32).
  • Usando una o dos unidades de código de 16 bits, cada carácter se representa con 2 bytes como mínimo o 4 como máximo (UTF-16).
  • Usando de una a cuatro unidades de código de 8 bits cada carácter se representa con 1 byte como mínimo pudiendo llegar a 4 bytes como máximo (UTF-8).

UCS-4, UTF-32, UCS-2, UTF-16 en cierta medida son ineficientes. Las computadoras intercambian muchas cadenas, y una gran mayoría de esas cadenas solo usan caracteres ASCII que se pueden almacenar con un solo byte, ocho bits. Es extremadamente ineficiente usar 4 bytes para almacenar un carácter ASCII. Además estos formatos de codificación al usar más de un byte, han de hacer frente al problema de la “endianidad” ya que pueden almacenarse en memoria con el byte más significativo (MSB) primero (BE) o el último (LE), lo que puede provocar que cuando se intercambian datos, los bytes que aparecen en el orden "correcto" en el sistema de envío, pueden aparecer como desordenados en el sistema receptor. En algunos casos hay que usar una firma que define el orden de los bytes y el formulario de codificación, (marca de orden de byte BOM), que consiste en el código de carácter U+FEFF al comienzo de una secuencia de datos.

  • UTF-8

UTF-8 es una codificación de longitud variable en la que cada punto de código UCS se codifica utilizando 1, 2, 3 o 4 bytes según sea necesario por lo tanto es más eficiente para caracteres ASCII, por otro lado no presenta el problema de “endianidad” al tener siempre el mismo orden de bytes.

UTF-8 tiene las siguientes propiedades:

  • Los caracteres UCS U+0000 a U+007F (ASCII) están codificados simplemente como 1 byte 0x00 a 0x7F (compatibilidad ASCII).
  • Todos los caracteres de UCS > U+007F (127 decimal) están codificados como una secuencia de varios bytes, cada uno de los cuales tiene el bit más significativo establecido. Por lo tanto, ningún byte ASCII (0x00-0x7F) puede aparecer como parte de ningún otro carácter.

A continuación se muestra las reglas del algoritmo para la codificación utf-8, partiendo de su punto de código se emplearán 1, 2, 3 o 4 bytes. Las posiciones de los bits “xxx…” se llenan con los bits del número del código de carácter en representación binaria, teniendo en cuenta que el extremo derecho de los bits x será el bit menos significativo.

U+0000 - U+007F (0-127): 0xxxxxxx (1byte) U+0080 - U+07FF (128-2047): 110xxxxx 10xxxxxx (2bytes) U+0800 - U+FFFF (2048-65535): 1110xxxx 10xxxxxx 10xxxxxx (3bytes) U+10000 - U+1FFFFF (65536-2097151): 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx (4bytes)

Si el primer bit de un byte es un "0", los 7 bits restantes del byte contienen uno de los 128 caracteres originales ASCII de 7 bits.

El primer byte de una secuencia multibyte que representa un carácter no ASCII siempre está en el rango de 0xC0 (192) a 0xFD (253) e indica de cuántos bytes está compuesta la secuencia en función de los bits con valor “1”.

Todos los bytes adicionales en una secuencia multibyte están en el rango de 0x80 (128) a 0xBF (191).

Por ejemplo el carácter “ñ” tendría el código número 241 (decimal) que es F1 en hexadecimal y en binario 11110001, su código Unicode por lo tanto sería U00F1, para su codificación se usaran 2 bytes, “110xxxxx 10xxxxxx”, sustituyendo las x por el valor de su código, 241, en binario el resultado sería 11000011 10110001 equivalente a c3b1 en hexadecimal, y a [195, 177] en decimal.

Como hemos mencionado antes, también podríamos representar la letra “ñ” como una secuencia compuesta del carácter base U+006E "n" seguido del carácter no espaciador U+0303“~” que pertenece al bloque marcas diacríticas que van desde la U+0300 a U+036F. Para representarlo necesitaríamos 3 bytes:

  • 1 byte para la letra “n” que se corresponde con el condigo número 110 en decimal que es 6E hexadecimal y en binario 01101110
  • 2 bytes para la marca diacrítica “~” que se corresponde con el código número 771 en decimal que se corresponde con 0x303 en hexadecimal y en binario 1100000011 si lo pasamos a la codificación UTF8 110xxxxx 10xxxxxx tendríamos 2 bytes con los valores 11001100 10000011 que equivalen a cc83 en hexadecimal y a [204, 131] en decimal.

A continuación, usaremos JavaScript para ejemplificar lo explicado y relacionarlo con los ArrayBuffer, usaremos el objeto incorporado TextDecoder que nos permite leer el texto de un conjunto de datos binarios y convertirlo en un dato de tipo string de JavaScript, dados un búfer y la codificación, si no se especifica la codificación por defecto será utf-8. También usaremos el método TextEncoder que toma un String como una entrada y devuelve una secuencia de bytes con codificación UTF-8 siempre devuelve un tipo Uint8Array. A modo de curiosidad también usaremos el método codePointAt(pos)de String, que nos permite obtener el punto de código del valor Unicode del carácter que está situado en una posición (pos) determina de la cadena. También conviene señalar que en JavaScript, los literales de cadena se pueden expresar mediante su respectivo punto de código Unicode en codificación UTF-16 para ello se ha de utilizar la secuencia de escape Unicode, la sintaxis general es\uXXXX, donde X denota cuatro dígitos hexadecimales.

Codigo disponible en el archivo EncodingArrayBuffer.js GitHub

const encoder = new TextEncoder()
const decoder = new TextDecoder()

console.log('ñ'.codePointAt(0));//241
//dec=241 hex=F1 bin=11110001 => utf-8 (11000011 10110001 -> c3b1 -> 2bytes [195, 177]
const str1u8 = new Uint8Array([195,177]);//Generamos un búfer con los datos oportunos.
console.log(str1u8)//[195,177]
let str1 = decoder.decode(str1u8)//decodificamos el bufer
console.log(str1)//ñ
console.log(str1.length);//1
console.log(str1.codePointAt());//241

let string1 = '\u00F1';
console.log(string1);//ñ
console.log(string1===str1);//true

const vista = encoder.encode(str1)
console.log(vista);//[195,177]
const ene='n'
const diacritica='̃'
console.log(ene.codePointAt(0));//110
//dec=110 hex=6E bin=01101110 => utf-8 -> 01101110 -> 6E -> 1 byte [110]
console.log(diacritica.codePointAt(0));//771
//dec=771 hex=0x303 bin=1100000011 => utf-8 (11001100 10000011-> cc83 -> 2 bytes [204, 131].
const u8 = new Uint8Array([110,204,131]);//Generamos un búfer con los datos oportunos.
console.log(u8)//[110,204,131]
let str2 = decoder.decode(u8)//decodificamos el bufer
console.log(str2)//ñ
console.log(str2.length);//2 hemos usado dos caracteres
//Hallamos el punto de código de cada caracter.
console.log(str2.codePointAt(0));//110
console.log(str2.codePointAt(1));//771

let string2 = '\u006E\u0303';
console.log(string2);//ñ
console.log(string2===str2);//true

const vista2 = encoder.encode(str2)
console.log(vista2);//[110,204,131]

Notas adicionales

Como hemos comprobado antes para el caso del carácter “ñ” un punto de código, o una secuencia de puntos de código, pueden representar el mismo carácter abstracto, esta información hay que tenerle en cuenta a la hora de comparar cadenas y de operar con ellas, debido a que los puntos de código son diferentes por lo que la comparación de cadenas no los tratará como iguales, aunque visualmente lo parezcan, debido a que la cantidad de puntos de código en cada versión es diferente por lo que las cadenas tienen diferentes longitudes. Lo podemos comprobar en el siguiente código.

//comparamos las dos cadenas string1='\u00F1' y string2='\u006E\u0303'
console.log(string1 === string2); //false
console.log(string1.length);      // 1
console.log(string2.length);      // 2

Para resolver el anterior problema se puede usar el método normalize() de string, que permite convertir una cadena en una forma normalizada común para todas las secuencias de puntos de código que representan los mismos caracteres.

Se puede usar la normalización basada en la equivalencia canónica: dos secuencias de puntos de código tienen equivalencia canónica si representan los mismos caracteres abstractos y siempre deben tener la misma apariencia visual y comportamiento. Para ello, podemos usar string.normalize([forma]) donde el argumento “forma” puede ser entre otros:

  • “NFC”: Normalization Form Canonical Composition. Forma canónica compuesta
  • “NFD”: Normalization Form Canonical Decomposition. Forma canónica descompuesta

Para producir una forma de la cadena que será la misma para todas las cadenas canónicamente equivalentes. Apliquemos las normalizaciones a las dos representaciones del carácter “ñ”

//Convertimos string1 '\u00F1' a su forma descompuesta
string1 = string1.normalize('NFD');//ñ
console.log(string1 === string2); // true
console.log(string1.length);      // 2
console.log(string2.length);      // 2
console.log(string1.codePointAt(0).toString(16)); //6E
console.log(string1.codePointAt(1).toString(16)); //303

//Convertimos string2 y string1 \u006E\u0303 a sus formas compuesta
string1 = string1.normalize('NFC');//ñ
string2 = string2.normalize('NFC');//ñ
console.log(string1 === string2);// true
console.log(string1.length);// 1
console.log(string2.length);//1
console.log(string2.codePointAt(0).toString(16)); //f1
console.log(string1.codePointAt(0).toString(16)); //f1

Por ejemplo si quisiéramos obtener una cadena libre de marcas diacríticas, tildes, diéresis podríamos normalizarla a su forma descompuesta y a continuación eliminar las marcas diacríticas.

let cadenaDiacriticos ='áéíóúñüÁÉÍÓÚnaeiou'
console.log(cadenaDiacriticos.length)//18
//normalizamos forma descopmuesta y quitamos marcas diacríticas que van desde la `U+0300` a `U+036F`
cadenaDiacriticos=cadenaDiacriticos.normalize('NFD')
console.log(cadenaDiacriticos.length)//30
cadenaDiacriticos=cadenaDiacriticos.replace(/[\u0300-\u036f]/g,"");//aeiounuAEIOUnaeiou
console.log(cadenaDiacriticos)////aeiounuAEIOUnaeiou
console.log(cadenaDiacriticos.length)//18