Artículo técnico

DBF, SYLK y DIF: exportar sin una code page declarada

Nombres de ciudades chinas exportados a .dbf en una PC occidental pueden volver como signos de interrogación desde una exportación que reportó éxito, porque los archivos dBASE, SYLK y DIF no tienen dónde registrar una codificación. Un escritor seguro usa la code page ANSI del host y verifica cada nombre y valor contra ella antes del primer byte; HotXLS rechaza lo que esa página no pueda contener

La code page del host es la única codificación que tienen estos archivos

Los tres formatos vienen de los primeros años de la PC: dBASE de Ashton-Tate, SYLK de la era Multiplan de Microsoft, DIF de Software Arts, la empresa de VisiCalc. Cada uno guarda texto como bytes individuales y asume que el lector comparte la code page del escritor — una apuesta razonable cuando todas las máquinas de la oficina corrían el mismo DOS. En Windows de hoy esa página es la que devuelva GetACP: 1252 en una instalación de Europa occidental, 936 en chino simplificado, 1251 en cirílico. dBASE IV y FoxPro pusieron después una marca de language-driver en el byte 29 del header de la tabla, pero el layout dBASE III que HotXLS escribe deja ese byte en cero, y SYLK, DIF y el texto .prn alineado con espacios no tienen ningún campo de header para ello. Así que la code page se acuerda fuera del archivo. HotXLS usa la code page ANSI del host al escribir y al leer, y verifica antes de escribir que cada carácter tenga un byte en esa página

FormatoExtensión en SaveAs / OpenCodificación declarada en el archivoTexto que la página del host no puede contener
Tabla dBASE III.dbf, más un archivo memo .dbt cuando hace faltaNo (el byte 29 queda en cero)EXlsDbf se lanza antes de escribir, y el .dbf / .dbt recién creados se borran
SYLK.slk, .sylkNoSaveAs devuelve -1 y no crea archivo
DIF.difNoSaveAs devuelve -1 y no crea archivo
Texto formateado.prnNoSaveAs devuelve -1 y no crea archivo

Diez bytes para un nombre de campo dBASE, contados en la code page

Todo límite de tamaño en una tabla dBASE es un conteo de bytes, y ahí es donde duelen las code pages multibyte. Un descriptor de campo guarda el nombre en once bytes — diez para el nombre y uno para su NUL de cierre — y pone la letra de tipo justo detrás, en el offset 11, el ancho en 16 y el conteo de decimales en 17. Bajo la code page 936 un carácter chino son dos bytes, así que un nombre chino de diez caracteres son veinte. Copie esos bytes sin medirlos y atraviesan de largo el NUL, la letra de tipo y el ancho; ningún lector puede recuperar ese descriptor después, aunque el nombre haya pasado una verificación de diez caracteres. Mantenga los nombres de campo dBASE en ASCII incluso en una estación de trabajo CJK: las herramientas xBase, los drivers ODBC y el software GIS que lee tablas de atributos de shapefiles dependen de esos nombres, y un nombre que solo decodifica bajo 936 se convierte en bytes distintos en cada máquina que corra otra página

HotXLS layout del descriptor de campo DBF dBASE III en Delphi: ranura de nombre de diez bytes más NUL, letra de tipo en el offset 11, ancho en 16 y decimales en 17, donde AddField('CITY') cabe pero un nombre chino de diez caracteres bajo la code page 936 desborda veinte bytes
Dentro de un descriptor de campo dBASE de 32 bytes, un nombre de campo chino que pasa una verificación de diez caracteres se derrama sobre el NUL, la letra de tipo y el ancho bajo la code page 936
BytesContenido dBASE III
0Versión: 0x03, u 0x83 cuando un archivo memo .dbt acompaña a la tabla
1–3Última actualización: año desde 1900, mes, día
4–7Conteo de registros, little-endian de 32 bits
8–11Longitud del header y del registro, palabras de 16 bits
32 en adelanteUn descriptor de 32 bytes por campo, luego 0x0D; los registros siguen, cada uno detrás de un flag de borrado

Los anchos también son bytes. Un campo de caracteres (C) se topa en 254 bytes, los valores N son texto alineado a la derecha con los decimales declarados, los valores D son ocho caracteres en forma YYYYMMDD, y un valor demasiado ancho para su campo se vuelve una fila de asteriscos, la convención que el propio dBASE usa. El texto más largo va a un campo memo (M) que guarda un número de bloque hacia el compañero .dbt: un header de 512 bytes, luego bloques de 512 bytes, cada memo terminado por 0x1A 0x1A. TXLSXWorkbook.SaveAs con un nombre .dbf convierte la primera fila usada de la hoja activa en nombres de campo, infiere C, N, L o D por columna, recurre a C para columnas mixtas y manda a M el texto de más de 254 caracteres. Cuando los anchos deben controlarse en bytes, construya la tabla usted mismo con TXlsDbfTable y dimensione cada campo C para la longitud codificada de su valor más largo

uses
  Windows, SysUtils, Variants, lxDbf;

procedure WriteCustomerTable(const FileName: string);
var
  Table: TXlsDbfTable;
  Row: Integer;
begin
  Table := TXlsDbfTable.Create;
  try
    // Nombres ASCII de a lo más diez bytes; los anchos son bytes en la code page del host
    Table.AddField('CUSTNO', 'N', 6, 0);
    Table.AddField('CITY', 'C', 40, 0);
    Table.AddField('BALANCE', 'N', 12, 2);
    Table.AddField('OPENED', 'D', 8, 0);

    Row := Table.AddRow;
    Table.SetValue(Row, 0, 1001);
    Table.SetValue(Row, 1, WideString(#$5317#$4EAC#$5E02));  // 北京市, 6 bytes en CP936
    Table.SetValue(Row, 2, 1234.5);
    Table.SetValue(Row, 3, EncodeDate(2026, 11, 12));

    try
      XlsWriteDbfFile(FileName, Table);
    except
      on E: EXlsDbf do
        // Esperado en un host 1252: el mensaje nombra el campo "CITY" y la fila 1,
        // y no queda ningún .dbf en el disco
        raise Exception.CreateFmt('DBF export refused under code page %d: %s',
          [GetACP, E.Message]);
    end;
  finally
    Table.Free;
  end;
end;

Esperado: en un host 936 la tabla se escribe y se reabre con los cuatro campos. En un host 1252 XlsWriteDbfFile lanza EXlsDbf nombrando el campo y la fila base uno. XlsWriteDbfStream convierte cada nombre de campo y cada valor C o M a la página del host y de vuelta, compara y mide cada nombre contra sus diez bytes antes de que salga el header, así que una tabla rechazada deja el stream intacto. La forma de archivo va un paso más allá y borra el .dbf, y cualquier .dbt, que ya hubiera creado

¿Cómo probar si una cadena sobrevive a la code page del host?

Conviértala a AnsiString, convierta eso de vuelta a WideString y compare con el original. Un carácter que la página no tiene vuelve como reemplazo, normalmente ?, así que la comparación falla precisamente cuando la exportación perdería datos. Los escritores usan la misma conversión, de modo que la verificación y el escritor no pueden discrepar sobre la code page. Guarde también la longitud en bytes: es el número que se confronta contra una ranura de nombre de diez bytes o un ancho de campo

uses
  Variants;

// True cuando cada carácter de V sobrevive a la code page ANSI del host;
// ByteLen es la longitud codificada para comparar con el ancho de un campo
function FitsHostCodePage(const V: Variant; out ByteLen: Integer): Boolean;
var
  W: WideString;
  A: AnsiString;
begin
  W := VarToWideStr(V);   // VarToStr puede convertir antes de que esta verificación vea el texto
  A := AnsiString(W);
  ByteLen := Length(A);
  Result := WideString(A) = W;
end;

El comentario sobre VarToWideStr trae la lección de verdad. En Delphi 2007 y anteriores string es AnsiString en todas partes, y bajo Free Pascal la unidad Variants se compila con el tipo string propio de la RTL, AnsiString, sin importar el modo de su unidad. En esos compiladores VarToStr ya puso un ? donde iba cada carácter chino. Un guard construido sobre AnsiString(VarToStr(V)) compara entonces signos de interrogación con signos de interrogación y acepta todo. TStringList tiene allí el mismo tipo de problema: asigne texto wide a su propiedad Text para partir líneas y cada carácter pasa por la code page del host de camino adentro. XlsParseSlk, XlsParseDifText y XlsParsePrnText toman entrada WideString y conservan cada uno de sus caracteres en cualquier host. Si también compila con Lazarus, vea con qué tipo de string compilan las unidades de HotXLS bajo Free Pascal

HotXLS verificación de ida y vuelta de la code page ANSI del host para export DBF, SYLK, DIF y PRN en Delphi: AnsiString y de vuelta a WideString conserva el texto chino en un host 936 pero produce signos de interrogación en 1252, rechazado con EXlsDbf o SaveAs devolviendo -1
Convertir cada valor a la code page del host y de vuelta expone los caracteres perdidos antes de que un byte llegue al disco, siempre que el texto se haya leído con VarToWideStr y no con VarToStr

Registros SYLK y DIF, línea por línea

SYLK es un registro por línea, con campos separados por punto y coma. HotXLS escribe un header ID;PHotXLS, un record C;Yfila;Xcol;Kvalor por celda y un terminador E. El texto va entre comillas con las comillas incrustadas dobladas, lo que además protege un punto y coma dentro de un valor, y el lector acepta también el escape ;; que algunos productores usan en su lugar. No hay Boolean: los escritores propios del formato mandan 1 y 0, así que eso es lo que escribe SaveAs; los errores salen como texto tal como #N/A, y las fechas como números seriales. Esas dos letras de la cabecera son como Excel reconoce SYLK — y por qué confunde un CSV cuya primera celda dice ID con un archivo SYLK. La construcción y el parseo trabajan ambas sobre WideString, así que el viaje de ida y vuelta en memoria de abajo da la misma respuesta en cualquier host

uses
  SysUtils, lxSlk;

function SylkRoundTripsInMemory: Boolean;
var
  Cells, Back: TXLSSlkCells;
  Text: WideString;
  I: Integer;
begin
  SetLength(Cells, 3);
  Cells[0].Row := 1; Cells[0].Col := 1; Cells[0].Kind := slkText;
  Cells[0].Text := WideString(#$4E0A#$6D77'; Pudong');   // 上海; Pudong
  Cells[1].Row := 1; Cells[1].Col := 2; Cells[1].Kind := slkText;
  Cells[1].Text := 'Pipe 12" steel';
  Cells[2].Row := 2; Cells[2].Col := 1; Cells[2].Kind := slkNumber;
  Cells[2].Number := 1234.5;

  Text := XlsBuildSlk(Cells);           // K"上海; Pudong", K"Pipe 12"" steel", K1234.5
  Result := XlsParseSlk(Text, Back) = Length(Cells);
  for I := 0 to High(Cells) do
    if Result then
      Result := (Back[I].Row = Cells[I].Row) and (Back[I].Col = Cells[I].Col) and
        (Back[I].Kind = Cells[I].Kind) and (Back[I].Text = Cells[I].Text) and
        ((Cells[I].Kind <> slkNumber) or (Back[I].Number = Cells[I].Number));
end;

DIF es más verborreico. Cuatro grupos de header, TABLE, VECTORS (conteo de columnas), TUPLES (conteo de filas) y DATA, ocupan tres líneas cada uno; después cada dato ocupa dos, una línea tipo,número y una línea que contiene la cadena entre comillas o un indicador de valor. Una fila abre con -1,0 y BOT, y -1,0 con EOD cierra los datos. Un número es 0,valor más V, un lógico es 0,1 más TRUE o 0,0 más FALSE, y una cadena es 1,0 más su texto entre comillas. Al leer, HotXLS mapea los indicadores NA y ERROR a #N/A y #VALUE! y trata NULL como vacío. Una hoja llamada Orders cuya única fila contiene Beijing, 1234.5 y True se exporta así:

TABLE
0,1
"Orders"
VECTORS
0,3
""
TUPLES
0,1
""
DATA
0,0
""
-1,0
BOT
1,0
"Beijing"
0,1234.5
V
0,1
TRUE
-1,0
EOD

Una coma decimal rompe los tres formatos

La code page no es el único ajuste de la máquina que se cuela en estos archivos. En una estación con configuración regional alemana o francesa, FloatToStr convierte 1234.5 en 1234,5. En DIF la línea del dato se vuelve 0,1234,5 y pierde su forma tipo,valor. En SYLK, K1234,5 no es un número. En un campo N de dBASE, los lectores xBase esperan un punto. HotXLS escribe y lee los tres con TFormatSettings.Invariant: SYLK recibe el token más corto que se relee en el mismo double, DIF un FloatToStr invariante con .0 añadido a los enteros, y los campos numéricos dBASE sus decimales declarados, alineados a la derecha. El parseo también ignora el locale, así que un archivo de una máquina con coma decimal se lee igual en todas partes

HotXLS export SYLK y DIF en Delphi: registros C;Y;X;K con header ID;PHotXLS y Boolean como 1, pares de items DIF tipo,número entre BOT y EOD, y TFormatSettings.Invariant manteniendo 1234.5 intacto donde una coma decimal rompe DIF, SYLK y dBASE
Escribir la fila de Orders como registros SYLK y pares de items DIF muestra dónde corrompería cada formato un locale con coma decimal, y por qué HotXLS formatea números con ajustes invariantes

Leer tablas que otros programas escribieron

Abrir la tabla de otro pone la pregunta de la code page en la máquina que lee. XlsReadDbfFile, y TXLSXWorkbook.Open sobre un nombre .dbf, aceptan los bytes de versión 0x03, 0x83, 0x8B, 0x30, 0x31 y 0x32, cubriendo tablas dBASE III y IV y Visual FoxPro, incluidos los tipos binarios de FoxPro I (entero de 32 bits), B (double), Y (currency escalado por 10,000) y T (día juliano más milisegundos). El texto memo viene de un .dbt junto a la tabla, o de un .fpt con el layout de bloques de FoxPro cuando no hay .dbt; la extensión decide, así que ningún compañero se confunde con el otro. Los datos de caracteres se decodifican en la página del host. Una tabla dBASE III escrita por un programa DOS bajo la code page 866, con el byte 29 en cero, muestra por tanto las letras equivocadas en una máquina 1252. Cuando usted conoce al productor, corra la importación donde su code page sea la página del sistema. Los archivos SYLK, DIF y .prn siguen la misma regla, porque Open decodifica sus bytes en la página del host antes de que el parser de texto wide los vea

¿Qué formato debería llevar texto no ASCII entre locales?

Ninguno de estos cuatro, pudiendo elegir. Úselos cuando el sistema receptor insiste — una pantalla de importación de un ERP, una aplicación xBase, un pipeline GIS — y cuando los datos son ASCII o ambos extremos comparten code page. Todo lo demás debería viajar en un formato que declare su codificación: XLSX, SpreadsheetML 2003 o CSV, que HotXLS escribe como UTF-8 con marca de orden de bytes (la exportación CSV y TSV tiene sus propias trampas de transporte). Como SaveAs elige el formato por la extensión, su código tiene que manejar ambos estilos de rechazo desde la primera tabla: una excepción para .dbf, un resultado de -1 para los formatos de texto

uses
  SysUtils, lxHandleX, lxDbf;

function SaveForLegacyImport(const BaseName: string): string;
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Orders');   // la primera hoja añadida queda activa
    Sheet.Cells[1, 1].Value := 'CUSTNO';
    Sheet.Cells[1, 2].Value := 'CITY';
    Sheet.Cells[2, 1].Value := 1001;
    Sheet.Cells[2, 2].Value := WideString(#$5317#$4EAC);  // 北京

    Result := BaseName + '.slk';
    if Book.SaveAs(Result) <> 1 then
    begin
      // La code page activa no puede contener el texto: no se creó ningún .slk
      Result := BaseName + '.xlsx';
      Book.SaveAs(Result);
    end;

    try
      Book.SaveAs(BaseName + '.dbf');
    except
      on E: EXlsDbf do
        Result := Result + ' (no .dbf: ' + E.Message + ')';
    end;
  finally
    Book.Free;
  end;
end;

En un host 936 esta función produce un .slk y un .dbf que ambos contienen 北京; en un host 1252 produce un .xlsx y reporta por qué se rechazó el .dbf. Los nombres de entradas RTF, HTML y ZIP en el componente de hojas de cálculo HotXLS para Delphi siguen reglas de codificación propias