Technical Article

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

Una hoja de cálculo contiene una columna de nombres de clientes. Algunos están en chino, algunos en cirílico, unos pocos llevan diéresis alemanas o un acento francés. La exporta a CSV y abre el resultado, y cada carácter está intacto. Exporta el mismo libro de trabajo a RTF para una plantilla de combinación de correspondencia, lo abre en un procesador de textos, y los nombres no ASCII se han reducido a filas de signos de interrogación. Los datos nunca cambiaron. Lo que cambió es el contrato de codificación del formato que escribió, y cada ruta de exportación lleva uno diferente.

Esta es la trampa que atrapa a una librería que parece completamente compatible con Unicode en la superficie. El texto de la celda se mantiene internamente como WideString, por lo que el modelo nunca pierde un carácter. La pérdida ocurre en el límite, en el escritor que tiene que serializar ese texto en un formato con sus propias reglas sobre qué bytes son legales y cómo debe codificarse cualquier cosa fuera del rango legal. Lograr que un escritor sea correcto no evita que envíe otro que mutile el mismo texto. La solución no es un interruptor global. Es una decisión separada y correcta en cada ruta.

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

El formato de texto enriquecido (RTF) es anterior a Unicode y se especificó para sobrevivir a transportes que solo transmiten 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 esa página de códigos debe emitirse como un escape en lugar de como un byte sin procesar. El escape relevante es \u, que lleva una unidad de código de 16 bits con signo seguida de un carácter alternativo ASCII para lectores demasiado antiguos para entender el escape en absoluto.

HotXLS escribe RTF de esta manera. El encabezado del documento se abre declarando la página de códigos, en la forma \ansi\ansicpg1252\uc1, and el escritor en la unidad lxRTF recorre cada cadena emitiendo cualquier carácter por encima de ASCII simple como un escape \u para que el flujo de bytes se mantenga limpio de 7 bits independientemente de lo que la página de códigos declarada pueda contener. Un punto de código como U+4E2D se convierte en la secuencia literal  3?, no en un byte sin procesar que un visor intentaría luego interpretar a través de cualquier página de códigos que asumiera. Sin esa disciplina, cualquier cosa fuera de la página de códigos declarada no tiene una representación de bytes legal, y un escritor que emite el valor sin procesar produce los signos de interrogación que iniciaron este artículo.

El detalle a tener en cuenta es que la página de códigos declarada y los escapes son dos mitades de un mismo contrato. Declarar la página de códigos por sí sola no ayuda al texto que queda fuera de ella. Emitir escapes sin una página de códigos declarada deja los caracteres alternativos ambiguos. Ambos deben ser correctos juntos, razón por la cual un escritor que maneja solo uno de ellos sigue fallando en el primer libro de trabajo multilingüe.

El escape de HTML se trata de algo más que corchetes angulares

La exportación HTML produce un documento de varias hojas cuyos marcos de navegación llevan los nombres de las hojas como texto visible. Esos nombres son cadenas controladas por el autor que pueden contener cualquier carácter, incluidos aquellos significativos para el marcado. Una hoja llamada literalmente Q1 & Q2 <draft> tiene que llegar a la página como entidades de escape, o de lo contrario los corchetes angulares abrirán una etiqueta fantasma y el ampersand iniciará una referencia de entidad que nunca se pretendió. Esto es un escape HTML común, y omitirlo en una etiqueta de marco es el tipo de omisión que supera cualquier prueba construida a partir de nombres de hojas compuestos únicamente por ASCII.

La cuestión de la codificación se sitúa una capa por debajo. Cuando los caracteres no ASCII aterrizan en un contexto que no está garantizado que se sirva como UTF-8, la representación segura es una referencia de carácter numérico, por lo que U+00E9 se escribe como é en lugar de como un byte sin procesar cuyo significado dependa del juego de caracteres de respuesta. La imagen especular de esta regla se aplica en la entrada. Un libro de trabajo leído de vuelta de XLSX lleva cadenas compartidas en las que un carácter ya puede estar almacenado como una entidad XML numérica, y esa entidad tiene que ser decodificada en un carácter completo antes de entrar en el modelo de celda. Decodifíquelo descuidadamente, dividiendo un punto de código en bytes separados, y un solo carácter vuelve a surgir como dos piezas de mojibake que ninguna exportación posterior podrá reparar.

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

Un archivo XLSX es un archivo ZIP, y el archivo almacena un nombre para cada miembro que contiene. ZIP es lo suficientemente antiguo como para que su especificación original no dijera nada sobre la codificación de esos nombres, por lo que un lector que no encuentra ninguna señal asume la página de códigos local del archivo. Esa suposición es incorrecta en el momento en que el nombre de un miembro contiene un carácter no ASCII, lo que sucede con nombres de partes de hojas de cálculo localizados y con medios incrustados cuyos nombres de archivo llevan acentos o escrituras no latinas.

La solución es un solo bit. El bit 11 de propósito general en cada encabezado de archivo local declara que el nombre del miembro está codificado como UTF-8. HotXLS comprueba exactamente ese bit cuando lee un archivo, probando las banderas de propósito general contra la máscara $0800, y un lector o escritor que lo ignore leerá incorrectamente un nombre que una implementación correcta almacenó como UTF-8. El bit es fácil de establecer y de respetar, y es toda la diferencia entre un nombre de miembro que sobrevive al viaje de ida y vuelta y uno que llega dañado antes de que se analice siquiera el contenido de la hoja de cálculo.

El plegado de mayúsculas y minúsculas y el escaneo de números esconden el mismo peligro

La evaluación de fórmulas es donde la seguridad Unicode deja de tratarse de serialización y pasa a tratarse de comparación. La función SEARCH no distingue entre mayúsculas y minúsculas, lo que significa que tiene que plegar las mayúsculas antes de buscar una subcadena. La forma incorrecta de plegar es a través de la página de códigos ANSI, porque pasar a mayúsculas texto no ASCII de esa manera encamina los caracteres a través de una página de códigos estrecha y daña cualquier cosa fuera de ella. La forma correcta es el plegado a mayúsculas de cadenas anchas, que conserva todo el rango UTF-16. HotXLS realiza el plegado con WideUpperCase exactamente por esta razón, por lo que una búsqueda de texto con acentos o no latín coincide con los mismos caracteres que recibió en lugar de una aproximación de los mismos alterada por la página de códigos.

El tokenizador de fórmulas lleva una obligación relacionada que no tiene nada que ver con letras y sí con dónde termina un token. La notación científica como 1E3 o 2.5E-3 es un único literal numérico, y el escáner tiene que reconocer la E, un signo opcional y los siguientes dígitos como parte del número en lugar de dividir la entrada en un nombre seguido de un número separado. Un escáner que maneje mal esto convierte una constante perfectamente válida en un error de análisis o, peor aún, en una expresión silenciosamente incorrecta. Pertenece a la misma discusión porque ambos casos tratan sobre un lector que toma una decisión correcta a nivel de carácter: uno sobre cómo plegar un carácter para la comparación, el otro sobre si un carácter continúa el token actual.

Construir y exportar un libro de trabajo multilingüe

La API pública no le pide que piense en nada de esto. Usted construye el libro de trabajo a partir de valores de celda WideString y llama al punto de entrada de exportación que desee. Las decisiones de codificación ocurren dentro de cada escritor. El siguiente ejemplo siembra una hoja con texto en varias escrituras y luego escribe un archivo RTF y uno HTML desde el mismo libro de trabajo, de modo que las dos rutas se ejecutan contra una entrada idéntica.

uses
  lxHandle;

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

    Sheet.Cells[1, 1].Value := 'Name';
    Sheet.Cells[1, 2].Value := 'City';

    // Cell text is held as WideString, so every script survives the model.
    Sheet.Cells[2, 1].Value := '王伟';          // Chinese
    Sheet.Cells[2, 2].Value := '北京';
    Sheet.Cells[3, 1].Value := 'Müller';        // German umlaut
    Sheet.Cells[3, 2].Value := 'Köln';
    Sheet.Cells[4, 1].Value := 'Иванов';        // Cyrillic
    Sheet.Cells[4, 2].Value := 'Москва';
    Sheet.Cells[5, 1].Value := 'Désirée';       // French accents
    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('Customers.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('Customers.html');
  finally
    Book := nil;
  end;
end;

Ambas llamadas devuelven un estado Integer, y ambas consumen el mismo texto en memoria. Nada en el código de llamada declara una página de códigos ni escapa un carácter, porque la responsabilidad recae en el escritor que conoce su propio formato. El método SaveAsCSV a nivel de libro de trabajo sigue la misma forma si necesita una exportación delimitada desde el mismo origen.

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

La seguridad Unicode es por ruta, no por librería

La lección que vale la pena llevarse es que no hay un único lugar para ser seguro con Unicode. RTF necesita una página de códigos declarada más escapes \u. HTML necesita escape de entidades para caracteres significativos de marcado y referencias numéricas donde el juego de caracteres no está garantizado, además de una decodificación correcta de las entidades que llegan en cadenas compartidas. El contenedor ZIP necesita que el bit 11 de propósito general esté establecido para que un nombre de miembro UTF-8 se lea como UTF-8. La evaluación de fórmulas necesita el plegado de mayúsculas de cadenas anchas y un tokenizador que mantenga la notación científica en una sola pieza. Cada uno de estos es un contrato diferente, y una librería puede satisfacer uno mientras viola silenciosamente otro. Esa es la razón por la que una herramienta que maneja bien CSV puede seguir entregándole un RTF lleno de signos de interrogación.

Si sus exportaciones se apoyan en los formatos delimitados, los compromisos entre ellos se cubren en nuestro tutorial sobre exportación a CSV, TSV y HTML, y cuando el origen es un conjunto de resultados en lugar de una hoja creada a mano, los patrones en exportación de bases de datos para informes de Delphi se emparejan naturalmente con las reglas de codificación descritas aquí. Todo esto se incluye como parte del componente HotXLS para Delphi y C++Builder, junto con las API de lectura, fórmula y formato cubiertas en otras partes de este blog.