Artículo técnico

Exportación de hojas de cálculo segura con Unicode en Delphi: RTF y HTML

Una hoja de cálculo contiene una columna con nombres de clientes. Algunos están en chino, otros en cirílico, unos cuantos tienen diéresis del alemán o un acento del francés. Si la exporta a CSV y abre el resultado, cada carácter permanecerá intacto. En cambio, si exporta el mismo libro a RTF para una plantilla de combinación de correspondencia y lo abre en un procesador de texto, los nombres que no son ASCII habrán colapsado y se verán como filas de signos de interrogación. Los datos nunca cambiaron. Lo que cambió fue el contrato de codificación del formato en el que se escribió, y cada ruta de exportación conlleva uno distinto

Esta es la trampa que atrapa a una biblioteca que parece estar totalmente preparada para admitir Unicode en la superficie. El texto de las celdas se mantiene internamente como WideString, por lo que el modelo nunca pierde ningún carácter. La pérdida ocurre en el límite, en el escritor que debe serializar ese texto y pasarlo a un formato con reglas propias acerca de qué bytes son válidos y cómo se debe codificar todo lo que quede fuera del rango válido. Entienda correctamente a un escritor y todavía puede enviar otro que destroce el mismo texto. La solución no es un interruptor global. Se trata de una decisión separada y correcta en cada ruta

El RTF es un formato seguro de 7 bits por diseño

El Rich Text Format es anterior a Unicode y se especificó para sobrevivir a los transportes que solo pasan ASCII imprimible. Un documento RTF declara una página de códigos en su encabezado, y cualquier carácter que el escritor no pueda representar en dicha página de códigos debe emitirse como un escape en lugar de como un byte sin procesar. El escape pertinente es \u, que contiene una unidad de código de 16 bits con signo seguida de un carácter ASCII de respaldo destinado a los lectores que son demasiado antiguos para entender el escape en absoluto

HotXLS escribe el RTF de este modo. El encabezado del documento se abre y declara la página de códigos, de la forma \ansi\ansicpg1252\uc1, y el escritor de la unidad lxRTF recorre cada cadena y emite cualquier carácter superior al ASCII simple como un escape \u para que el flujo de bytes se mantenga limpio de 7 bits, independientemente de lo que pueda contener la página de códigos declarada. Un punto de código como U+4E2D se convierte en la secuencia literal \u20013?, y no en un byte sin procesar que un visor trataría de interpretar a través de cualquier página de códigos que se haya imaginado. Sin esa disciplina, todo lo que quede fuera de la página de códigos declarada no tiene representación legal en bytes, y un escritor que emita el valor sin procesar produce los signos de interrogación de los que hablaba este artículo al principio

El detalle a tener en cuenta es que la página de códigos declarada y los escapes son dos mitades de un solo contrato. Declarar solo la página de códigos no ayuda al texto que se encuentra fuera de ella. Emitir escapes sin una página de códigos declarada deja ambiguos a los caracteres de respaldo. Ambos deben ser correctos en conjunto, y ese es el motivo por el cual un escritor que maneja solo uno de ellos seguirá fallando ante el primer libro de trabajo multilingüe

El escape en HTML no se trata solo de paréntesis angulares

La exportación en HTML produce un documento con varias hojas, y los marcos de navegación de dicho documento contienen los nombres de las hojas como texto visible. Esos nombres son cadenas controladas por el autor que pueden incluir cualquier carácter, incluidos los que son importantes para el marcado. Una hoja que literalmente se llame Q1 & Q2 <draft> debe llegar a la página como entidades escapadas, de lo contrario los paréntesis angulares abrirán una etiqueta fantasma y el ampersand iniciará una referencia a una entidad que nunca fue la intención. Esto es el escape HTML ordinario, y omitirlo en la etiqueta de un marco es el tipo de descuido que pasa por alto cualquier prueba construida a partir de nombres de hojas compuestos únicamente por caracteres ASCII

El problema de la codificación se ubica una capa más abajo. Cuando los caracteres que no son ASCII recaen en un contexto en el que no se garantiza que se sirvan como UTF-8, la representación segura es una referencia de carácter numérica, de modo que U+00E9 se escribe como é en lugar de como un byte sin procesar cuyo significado depende del conjunto de caracteres de respuesta. La imagen de espejo de esta regla se aplica a la inversa, es decir, a la entrada. Un libro que se vuelve a leer de XLSX contiene cadenas compartidas en las cuales un carácter puede estar ya almacenado como una entidad XML numérica, y esa entidad debe decodificarse en un carácter completo antes de entrar al modelo de celda. Si la decodifica de forma descuidada, dividiendo un punto de código en bytes separados, un solo carácter resurgirá como dos piezas de mojibake que ninguna exportación posterior podrá reparar

El contenedor XLSX es un archivo ZIP, y un ZIP posee su propia codificación de nombres

Un archivo XLSX es un archivo ZIP, y el archivo guarda un nombre para cada miembro que contiene. El formato ZIP es lo suficientemente antiguo como para que su especificación original no dijera nada sobre la codificación de dichos nombres, por lo que un lector que no encuentre una señal, asume que es la página de códigos local del archivo. Dicha suposición es incorrecta desde el mismo instante en el que el nombre de un miembro contiene un carácter que no es ASCII, cosa que ocurre con los nombres de las partes de las hojas de cálculo traducidas, así como con los medios incrustados cuyos nombres de archivo llevan acentos o letras que no son latinas

La solución se reduce a un solo bit. El bit 11 de propósito general en el encabezado de cada archivo local declara que el nombre del miembro está codificado como UTF-8. HotXLS comprueba exactamente ese bit cuando lee un archivo; prueba los indicadores de propósito general respecto a la máscara $0800, y un lector o escritor que la ignore leerá mal un nombre que una implementación correcta almacenó como UTF-8. Es barato configurar el bit y es barato respetarlo, y es todo lo que distingue a un nombre de miembro que sobrevive al viaje de ida y vuelta de uno que llega dañado antes de que se llegue a analizar el contenido de la hoja de cálculo de forma sintáctica

La conversión de mayúsculas y minúsculas y el escaneo de números ocultan el mismo peligro

La evaluación de fórmulas es donde la seguridad Unicode deja de ser sobre la serialización y pasa a ser sobre la comparación. La función SEARCH es insensible a mayúsculas y minúsculas, lo que significa que tiene que convertir las mayúsculas y minúsculas antes de buscar una subcadena. La forma incorrecta de convertir es a través de la página de códigos ANSI, porque poner el texto no ASCII en mayúsculas de ese modo encamina los caracteres a través de una página de códigos estrecha y daña cualquier elemento externo a ella. La forma correcta de ponerlo en mayúsculas es a través de cadenas anchas, lo cual conserva todo el rango de UTF-16. HotXLS realiza la conversión con WideUpperCase exactamente por este motivo, por lo que la búsqueda de texto acentuado o no latino coincide con los mismos caracteres que se le otorgaron en lugar de una aproximación destrozada por la página de códigos

El tokenizador de fórmulas conlleva una obligación relacionada que no tiene nada que ver con las letras y todo que ver con el lugar donde finaliza un token. La notación científica, como 1E3 o 2.5E-3, es un solo valor numérico literal, y el escáner tiene que reconocer la E, un signo opcional y los dígitos que la siguen como parte del número en lugar de dividir la entrada en un nombre seguido de un número distinto. Un escáner que maneje mal esto convierte una constante perfectamente válida en un error de análisis sintáctico o, peor aún, en una expresión que está mal pero se oculta en silencio. Esto encaja en la misma discusión porque ambos casos consisten en que un lector tome una decisión correcta a nivel de carácter: una acerca de cómo convertir un carácter para poder hacer una comparación, y la otra sobre si un carácter continúa el token en curso

Cómo crear y exportar un libro multilingüe

La API pública no le pide que piense en nada de esto. Simplemente se crea el libro con los valores de las celdas de WideString y se llama al punto de entrada de exportación que usted desee. Las decisiones de codificación tienen lugar dentro de cada escritor. El siguiente ejemplo llena una hoja con texto en diferentes idiomas y luego escribe tanto un archivo RTF como uno HTML desde el mismo libro, de modo que las dos rutas se ejecutan con idénticas entradas

uses
  lxHandle;

procedure ExportMultilingualWorkbook;
var
  Book: IXLSWorkbook;
  Sheet: IXLSWorksheet;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Clientes');

    Sheet.Cells[1, 1].Value := 'Nombre';
    Sheet.Cells[1, 2].Value := 'Ciudad';

    // Cell text is held as WideString, so every script survives the model.
    Sheet.Cells[2, 1].Value := '王伟';          // Chino
    Sheet.Cells[2, 2].Value := '北京';
    Sheet.Cells[3, 1].Value := 'Müller';        // Alemán con diéresis
    Sheet.Cells[3, 2].Value := 'Köln';
    Sheet.Cells[4, 1].Value := 'Иванов';        // Cirílico
    Sheet.Cells[4, 2].Value := 'Москва';
    Sheet.Cells[5, 1].Value := 'Désirée';       // Francés con acento
    Sheet.Cells[5, 2].Value := 'Montréal';

    // RTF: the lxRTF writer declares the code page and emits every
    // non-ASCII character as a \u escape, keeping the file 7-bit clean.
    Book.SaveAsRTF('Clientes.rtf');

    // HTML: sheet names are HTML-escaped and non-ASCII text is written
    // so it does not depend on a guessed response charset.
    Book.SaveAsHTML('Clientes.html');
  finally
    Book := nil;
  end;
end;

Ambas llamadas devuelven un estado de Integer y ambas consumen el mismo texto en memoria. No hay nada en el código de llamada que declare una página de códigos o que escape a un carácter, porque es la responsabilidad del escritor que conoce su propio formato. El método SaveAsCSV a nivel de libro, se rige por la misma forma si necesita una exportación delimitada desde el mismo origen idéntico

// Same workbook, a third export path with its own encoding rules.
Book.SaveAsCSV('Clientes.csv');

La seguridad Unicode es algo que se aplica a cada ruta, y no a cada biblioteca

La lección que vale la pena llevarse consigo es que no existe un solo lugar en el que se pueda implementar la seguridad Unicode. El RTF necesita una página de códigos declarada y además escapes en \u. El HTML requiere de entidades de escape para los caracteres importantes de marcado y referencias numéricas allí donde no esté garantizado el conjunto de caracteres, además de una decodificación correcta de las entidades que lleguen en las cadenas compartidas. El contenedor ZIP necesita que el bit 11 de propósito general esté activado para que un nombre de miembro que sea de tipo UTF-8 sea leído como UTF-8. La evaluación de fórmulas requiere de conversiones de mayúsculas y minúsculas para las cadenas anchas y de un tokenizador que mantenga la notación científica unida de una sola pieza. Cada uno de estos casos compone un contrato distinto, y una biblioteca puede satisfacer a uno de ellos y al mismo tiempo violar silenciosamente al otro. Tal es el motivo por el cual una herramienta que se desempeñe correctamente en el CSV, puede sin embargo depararle un RTF lleno de puros signos de interrogación

En el caso de que sus exportaciones se apoyen en formatos delimitados, las contrapartidas entre las mismas se analizan en profundidad en nuestro recorrido por la exportación a CSV, TSV y HTML; y si el origen se trata de un conjunto de resultados en lugar de una hoja construida a mano, los patrones en la exportación a bases de datos en los informes de Delphi hacen juego de forma natural con las reglas de codificación abordadas aquí. Todo ello se suministra como una parte del Componente HotXLS para Delphi y C++Builder, junto a las APIs de lectura, de fórmulas y de formateo tratadas a lo largo y a lo ancho de este blog