Artículo técnico

DBF, SYLK y DIF desde Delphi sin perder texto no ASCII

Nombres de ciudades chinas exportados a .dbf en un PC occidental pueden volver como interrogaciones desde una exportación que reportó éxito, porque los ficheros dBASE, SYLK y DIF no tienen dónde anotar una codificación. Un writer seguro usa la página de códigos ANSI del host y comprueba cada nombre y valor contra ella antes del primer byte; HotXLS rechaza lo que esa página no pueda contener

La página de códigos del host es la única codificación que tienen estos ficheros

Los tres formatos vienen de los primeros años del PC: dBASE de Ashton-Tate, SYLK de la era Multiplan de Microsoft, DIF de Software Arts, la empresa de VisiCalc. Cada uno guarda el texto en bytes sueltos y asume que el lector comparte la página de códigos del writer — una apuesta razonable cuando todas las máquinas de la oficina corrían el mismo DOS. En Windows hoy esa página es lo 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 de la cabecera de la tabla, pero el layout dBASE III que escribe HotXLS deja ese byte a cero, y SYLK, DIF y el texto .prn alineado con espacios no tienen ningún campo de cabecera para ella. Así que la página de códigos se acuerda fuera del fichero. HotXLS usa la página ANSI del host al escribir y al leer, y comprueba antes de escribir que cada carácter tenga un byte en esa página

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

Diez bytes para un nombre de campo dBASE, contados en la página de códigos

Todos los límites de tamaño de una tabla dBASE son conteos de bytes. Ahí es donde duelen las páginas de códigos multibyte. Un descriptor de campo guarda el nombre en once bytes — diez para el nombre y uno para su NUL terminador — y pone la letra de tipo justo detrás, en el offset 11, el ancho en el 16 y el conteo de decimales en el 17. Bajo la página 936 un carácter chino son dos bytes, así que un nombre chino de diez caracteres son veinte. Copia 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 pasara una comprobación de diez caracteres. Mantén los nombres de campo dBASE en ASCII incluso en una estación CJK: las herramientas xBase, los drivers ODBC y el software GIS que lee tablas de atributos de shapefile se anclan todos en esos nombres, y un nombre que solo decodifica bajo 936 se convierte en bytes distintos en cada máquina con otra página

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

Los anchos también son bytes. Un campo de caracteres (C) se queda en 254 bytes como máximo, 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 convierte en una fila de asteriscos, la convención que usa el propio dBASE. El texto más largo va a un campo memo (M) que guarda un número de bloque en el compañero .dbt: una cabecera 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 las columnas mixtas y mueve el texto de más de 254 caracteres a M. Cuando los anchos tienen que controlarse en bytes, construye la tabla tú mismo con TXlsDbfTable y dimensiona cada campo C por 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 diez bytes como máximo; los anchos son bytes en la página de códigos 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 disco
        raise Exception.CreateFmt('DBF export refused under code page %d: %s',
          [GetACP, E.Message]);
    end;
  finally
    Table.Free;
  end;
end;

Lo esperado: en un host 936 la tabla se escribe y 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 la cabecera, así que una tabla rechazada deja el stream intacto. La forma de fichero va un paso más allá y borra el .dbf, y cualquier .dbt, que ya hubiera creado

¿Cómo compruebas si una cadena sobrevive a la página de códigos del host?

Conviértela a AnsiString, convierte eso de vuelta a WideString y compara con el original. Un carácter que la página no tiene vuelve como un reemplazo, normalmente ?, así que la comparación falla exactamente cuando la exportación perdería datos. Los writers usan la misma conversión, así que la comprobación y el writer no pueden discrepar sobre la página de códigos. Guarda también la longitud en bytes: es el número que contrastar con un hueco de nombre de diez bytes o con un ancho de campo

uses
  Variants;

// True cuando cada carácter de V sobrevive a la página ANSI del host;
// ByteLen es la longitud codificada para comparar con un ancho de 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 comprobación vea el texto
  A := AnsiString(W);
  ByteLen := Length(A);
  Result := WideString(A) = W;
end;

El comentario sobre VarToWideStr guarda 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, use tu unidad el modo que use. En esos compiladores VarToStr ya ha puesto un ? donde iba cada carácter chino. Un guard construido sobre AnsiString(VarToStr(V)) compara entonces interrogaciones con interrogaciones y acepta todo. TStringList tiene allí el mismo tipo de problema: asigna texto wide a su propiedad Text para partir líneas y cada carácter pasa por la página de códigos del host al entrar. XlsParseSlk, XlsParseDifText y XlsParsePrnText toman entrada WideString y conservan todos sus caracteres en cualquier host. Si también compilas con Lazarus, mira con qué tipo de string compilan las unidades de HotXLS bajo Free Pascal

Comprobación de ida y vuelta por la página ANSI del host de HotXLS para exportación DBF, SYLK, DIF y PRN en Delphi: AnsiString y de vuelta a WideString conserva el texto chino en un host 936 pero produce interrogaciones en 1252, rechazado con EXlsDbf o SaveAs devolviendo -1
Convertir cada valor a la página de códigos del host y de vuelta saca a la luz los caracteres perdidos antes de que cualquier byte llegue al disco, siempre que el texto se leyera con VarToWideStr y no con VarToStr

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

SYLK es un registro por línea, con campos separados por punto y coma. HotXLS escribe una cabecera ID;PHotXLS, un registro C;Yfila;Xcol;Kvalor por celda y un terminador E. El texto va entre comillas con las comillas incrustadas dobladas, lo que también protege un punto y coma dentro de un valor, y el lector acepta el escape ;; que usan algunos productores en su lugar. No hay Boolean. Los writers del propio formato mandan 1 y 0, así que es lo que escribe SaveAs; los errores salen como texto del estilo #N/A y las fechas como números de serie. Esas dos letras de la cabecera son cómo Excel reconoce SYLK — y por qué confunde un CSV cuya primera celda dice ID con un fichero SYLK. La construcción y el parseo trabajan ambas sobre WideString, así que el round trip 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 verboso. Cuatro grupos de cabecera, TABLE, VECTORS (conteo de columnas), TUPLES (conteo de filas) y DATA, ocupan tres líneas cada uno; cada item de datos ocupa después dos, una línea tipo,número y una línea con 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 0,1 más TRUE o 0,0 más FALSE, una cadena 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 blanco. Una hoja llamada Orders cuya única fila lleva 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 página de códigos no es el único ajuste de máquina que se cuela en estos ficheros. En una estación con ajustes regionales alemanes o franceses, FloatToStr convierte 1234.5 en 1234,5. En DIF la línea de item 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 lee de vuelta como 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 fichero venido de una máquina con coma decimal se lee igual en todas partes

Exportación SYLK y DIF de HotXLS en Delphi: registros C;Y;X;K con cabecera ID;PHotXLS y Boolean como 1, pares de item tipo,número de DIF 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 item DIF muestra dónde corrompería cada formato un locale con coma decimal, y por qué HotXLS formatea los números con ajustes invariantes

Leer tablas que escribieron otros programas

Abrir la tabla de otro pone la pregunta de la página de códigos en la máquina que lee. XlsReadDbfFile, y TXLSXWorkbook.Open con un nombre .dbf, aceptan los bytes de versión 0x03, 0x83, 0x8B, 0x30, 0x31 y 0x32, cubriendo tablas dBASE III y IV y de Visual FoxPro, incluidos los tipos binarios de FoxPro I (entero de 32-bit), B (double), Y (moneda escalada 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 ninguno de los dos compañeros se lee por error como 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 página 866, con el byte 29 a cero, muestra por tanto las letras equivocadas en una máquina 1252. Cuando conoces al productor, ejecuta el import donde su página de códigos sea la del sistema. SYLK, DIF y los ficheros .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, teniendo elección. Úsalos cuando el sistema receptor insista — una pantalla de import de ERP, una aplicación xBase, un pipeline GIS — y cuando los datos sean ASCII o ambos extremos compartan página de códigos. 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 propios escollos de transporte). Como SaveAs elige el formato por la extensión, tu código tiene que manejar los dos 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 pasa a ser la 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 página de códigos 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 llevan ambos 北京; en un host 1252 produce un .xlsx e informa de 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