Artigo Técnico

Exportar DBF, SYLK e DIF quando o arquivo não tem code page

Nomes de cidades chinesas exportados para .dbf num PC ocidental podem voltar como pontos de interrogação de um export que reportou sucesso, porque arquivos dBASE, SYLK e DIF não têm onde registrar uma codificação. Um writer seguro usa a code page ANSI do host e confere cada nome e cada valor contra ela antes do primeiro byte; o HotXLS recusa o que essa página não puder guardar

A code page do host é a única codificação que esses arquivos têm

Os três formatos vêm dos primeiros anos do PC: dBASE da Ashton-Tate, SYLK da era Multiplan da Microsoft, DIF da Software Arts, a empresa do VisiCalc. Cada um guarda texto como bytes únicos e assume que o reader divide a code page do writer — aposta justa quando toda máquina do escritório rodava o mesmo setup DOS. No Windows de hoje essa página é o que o GetACP retornar: 1252 numa instalação da Europa Ocidental, 936 no chinês simplificado, 1251 no cirílico. dBASE IV e FoxPro depois puseram uma marca de language-driver no byte 29 do header da tabela, mas o layout dBASE III que o HotXLS grava deixa esse byte em zero, e SYLK, DIF e o texto .prn alinhado por espaços não têm campo de header para isso de forma alguma. Então a code page é acordada fora do arquivo. O HotXLS usa a code page ANSI do host na escrita e na leitura, e confere antes de gravar se todo caractere tem um byte nessa página

FormatoExtensão no SaveAs / OpenCodificação declarada no arquivoTexto que a página do host não pode guardar
Tabela dBASE III.dbf, mais um arquivo de memo .dbt quando necessárioNão (byte 29 fica em zero)EXlsDbf é levantada antes de gravar, e o .dbf / .dbt recém-criados são apagados
SYLK.slk, .sylkNãoO SaveAs retorna -1 e não cria arquivo
DIF.difNãoO SaveAs retorna -1 e não cria arquivo
Texto formatado.prnNãoO SaveAs retorna -1 e não cria arquivo

Dez bytes para um nome de field dBASE, contados na code page

Todo limite de tamanho numa tabela dBASE é uma contagem de bytes. É aí que code pages multibyte machucam. Um field descriptor guarda o nome em onze bytes — dez para o nome, um para o NUL terminador dele — e põe a letra de tipo logo atrás, no offset 11, a largura no 16 e a contagem de decimais no 17. Sob a code page 936 um caractere chinês são dois bytes, então um nome chinês de dez caracteres são vinte. Copie esses bytes para dentro sem medi-los e eles atravessam direto o NUL, a letra de tipo e a largura; nenhum reader recupera esse descriptor depois, mesmo que o nome tenha passado numa checagem de dez caracteres. Mantenha nomes de field dBASE em ASCII mesmo numa estação CJK. Ferramentas xBase, drivers ODBC e software de GIS que leem tabelas de atributos de shapefile todos se chaveiam nesses nomes, e um nome que só decodifica sob 936 vira bytes diferentes em cada máquina rodando outra página

Layout de field descriptor DBF dBASE III no Delphi com HotXLS: slot de nome de dez bytes mais NUL, letra de tipo no offset 11, largura no 16 e decimais no 17, onde AddField('CITY') cabe mas um nome chinês de dez caracteres sob a code page 936 ultrapassa vinte bytes
dentro de um field descriptor dBASE de 32 bytes, um nome de field chinês que passa numa checagem de dez caracteres vaza para o NUL, a letra de tipo e a largura sob a code page 936
BytesConteúdo dBASE III
0Versão: 0x03, ou 0x83 quando um arquivo de memo .dbt acompanha a tabela
1–3Última atualização: ano desde 1900, mês, dia
4–7Contagem de records, little-endian de 32 bits
8–11Comprimento do header e comprimento do record, palavras de 16 bits
32 em dianteUm descriptor de 32 bytes por field, depois 0x0D; records seguem, cada um atrás de um deletion flag

Larguras também são bytes. Um field de caracteres (C) tem teto de 254 bytes, valores N são texto alinhado à direita com os decimais declarados, valores D são oito caracteres na forma YYYYMMDD, e um valor largo demais para o field dele vira uma fila de asteriscos, a convenção que o próprio dBASE usa. Texto mais longo vai para um campo memo (M) que guarda um número de bloco no companheiro .dbt: um header de 512 bytes, depois blocos de 512 bytes, cada memo terminado por 0x1A 0x1A. O TXLSXWorkbook.SaveAs com um nome .dbf transforma a primeira linha usada da planilha ativa em nomes de field, infere C, N, L ou D por coluna, cai para C em colunas mistas e move texto com mais de 254 caracteres para M. Quando as larguras precisam ser controladas em bytes, monte a tabela você mesmo com o TXlsDbfTable e dimensione cada field C para o comprimento codificado do maior valor dele

uses
  Windows, SysUtils, Variants, lxDbf;

procedure WriteCustomerTable(const FileName: string);
var
  Table: TXlsDbfTable;
  Row: Integer;
begin
  Table := TXlsDbfTable.Create;
  try
    // Nomes ASCII de no máximo dez bytes; larguras são bytes na code page do 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 na 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 num host 1252: a mensagem nomeia o field "CITY" e a linha 1,
        // e nenhum .dbf fica no disco
        raise Exception.CreateFmt('DBF export refused under code page %d: %s',
          [GetACP, E.Message]);
    end;
  finally
    Table.Free;
  end;
end;

Esperado: num host 936 a tabela é gravada e reabre com os quatro fields. Num host 1252 o XlsWriteDbfFile levanta EXlsDbf nomeando o field e a linha de base um. O XlsWriteDbfStream converte cada nome de field e cada valor C ou M para a página do host e de volta, compara e mede cada nome contra os dez bytes dele antes de o header sair, então uma tabela recusada deixa o stream intocado. A forma arquivo vai um passo além e apaga o .dbf, e qualquer .dbt, que já tinha criado

Como testar se uma string sobrevive à code page do host?

Converta para AnsiString, converta de volta para WideString e compare com o original. Um caractere que a página não tem volta como um substituto, normalmente ?, então a comparação falha exatamente quando o export perderia dados. Os writers usam a mesma conversão, então a checagem e o writer não podem divergir sobre a code page. Guarde também o comprimento em bytes: é o número para confrontar com um slot de nome de dez bytes ou uma largura de field

uses
  Variants;

// True quando todo caractere de V sobrevive à code page ANSI do host;
// ByteLen é o comprimento codificado a comparar com uma largura de field
function FitsHostCodePage(const V: Variant; out ByteLen: Integer): Boolean;
var
  W: WideString;
  A: AnsiString;
begin
  W := VarToWideStr(V);   // VarToStr pode converter antes de esta checagem ver o texto
  A := AnsiString(W);
  ByteLen := Length(A);
  Result := WideString(A) = W;
end;

O comentário no VarToWideStr carrega a lição real. No Delphi 2007 e anteriores o string é AnsiString em toda parte, e sob Free Pascal a unit Variants é compilada com o tipo string próprio da RTL, AnsiString, não importa o modo da sua unit. Nesses compiladores o VarToStr já pôs um ? onde cada caractere chinês estava. Uma guarda construída sobre AnsiString(VarToStr(V)) então compara interrogações com interrogações e aceita tudo. O TStringList tem o mesmo formato de problema ali: atribua texto wide à propriedade Text dele para partir linhas e todo caractere passa pela code page do host a caminho de dentro. O XlsParseSlk, o XlsParseDifText e o XlsParsePrnText recebem WideString e mantêm todo caractere dele em qualquer host. Se você também compila com Lazarus, veja que tipo de string as units do HotXLS compilam sob Free Pascal

Conferência de ida e volta na code page ANSI do host com HotXLS para export DBF, SYLK, DIF e PRN no Delphi: AnsiString e de volta a WideString mantém texto chinês num host 936 mas rende interrogações no 1252, recusado com EXlsDbf ou SaveAs retornando -1
converter cada valor para a code page do host e de volta expõe caracteres perdidos antes de qualquer byte chegar ao disco, desde que o texto tenha sido lido com VarToWideStr e não com VarToStr

Records SYLK e DIF, linha por linha

SYLK é um record por linha, campos separados por ponto e vírgula. O HotXLS grava um header ID;PHotXLS, um record C;Ylinha;Xcoluna;Kvalor por célula e um terminador E. Texto vai entre aspas com aspas embutidas dobradas, o que também protege um ponto e vírgula dentro de um valor, e o reader aceita o escape ;; que alguns produtores usam em vez disso. Não existe Boolean. Os writers do próprio formato mandam 1 e 0, então é isso que o SaveAs grava; erros saem como texto como #N/A, datas como números de série. Aquelas duas letras no topo são como o Excel reconhece SYLK — e por que ele confunde um CSV cuja primeira célula lê ID com um arquivo SYLK. Construção e parse ambos trabalham sobre WideString, então a ida e volta em memória abaixo dá a mesma resposta em qualquer 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 é mais falante. Quatro grupos de header, TABLE, VECTORS (contagem de colunas), TUPLES (contagem de linhas) e DATA, tomam três linhas cada; cada item de dado toma depois duas, uma linha tipo,número e uma linha guardando a string entre aspas ou um indicador de valor. Uma linha abre com -1,0 e BOT, e -1,0 com EOD fecha o dado. Um número é 0,valor mais V, um lógico 0,1 mais TRUE ou 0,0 mais FALSE, uma string 1,0 mais o texto dela entre aspas. Na leitura, o HotXLS mapeia os indicadores NA e ERROR para #N/A e #VALUE! e trata NULL como vazio. Uma planilha chamada Orders cuja única linha guarda Beijing, 1234.5 e True exporta como:

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

Uma vírgula decimal quebra os três formatos

A code page não é a única configuração de máquina que vaza para esses arquivos. Numa estação com configurações regionais alemãs ou francesas, o FloatToStr transforma 1234.5 em 1234,5. No DIF a linha de item vira 0,1234,5 e perde a forma tipo,valor. No SYLK, K1234,5 não é número. Num field N dBASE, readers xBase esperam um ponto. O HotXLS grava e lê os três com TFormatSettings.Invariant: o SYLK recebe o token mais curto que lê de volta para o mesmo double, o DIF um FloatToStr invariante com .0 anexado aos inteiros, os fields numéricos dBASE os decimais declarados deles, alinhados à direita. O parse ignora o locale também, então um arquivo de uma máquina de vírgula decimal lê igual em toda parte

Export SYLK e DIF no Delphi com HotXLS: records C;Y;X;K com header ID;PHotXLS e Boolean como 1, pares de item tipo,número do DIF entre BOT e EOD, e TFormatSettings.Invariant mantendo 1234.5 intacto onde vírgula decimal quebra DIF, SYLK e dBASE
gravar a linha Orders como records SYLK e pares de item DIF mostra onde um locale de vírgula decimal corromperia cada formato, e por que o HotXLS formata números com configurações invariantes

Lendo tabelas que outros programas gravaram

Abrir a tabela de outra pessoa põe a pergunta da code page na máquina que lê. O XlsReadDbfFile, e o TXLSXWorkbook.Open num nome .dbf, aceitam bytes de versão 0x03, 0x83, 0x8B, 0x30, 0x31 e 0x32, cobrindo tabelas dBASE III e IV e Visual FoxPro, incluindo os tipos binários do FoxPro I (inteiro de 32 bits), B (double), Y (currency escalada por 10.000) e T (dia juliano mais milissegundos). Texto de memo vem de um .dbt ao lado da tabela, ou de um .fpt com o layout de blocos do FoxPro quando não existe .dbt; a extensão decide, então nenhum companheiro é lido como o outro. Dados de caracteres são decodificados na página do host. Uma tabela dBASE III gravada por um programa DOS sob a code page 866, com o byte 29 em zero, portanto mostra as letras erradas numa máquina 1252. Quando você conhece o produtor, rode o import onde a code page dele é a página do sistema. Arquivos SYLK, DIF e .prn seguem a mesma regra, porque o Open decodifica os bytes deles na página do host antes de o parser de texto wide vê-los

Que formato deve carregar texto não ASCII entre locales?

Nenhum desses quatro, havendo escolha. Use-os quando o sistema receptor insistir — uma tela de import de ERP, uma aplicação xBase, um pipeline de GIS — e quando o dado é ASCII ou os dois lados dividem uma code page. Todo o resto deve viajar num formato que declara a codificação própria: XLSX, SpreadsheetML 2003, ou CSV, que o HotXLS grava como UTF-8 com byte order mark (o export CSV e TSV tem armadilhas de transporte próprias). Como o SaveAs escolhe o formato pela extensão, o seu código precisa tratar os dois estilos de recusa desde a primeira tabela: exceção para .dbf, resultado -1 para os 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');   // a primeira planilha adicionada vira a ativa
    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
      // A code page ativa não pode guardar o texto: nenhum .slk foi criado
      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;

Num host 936 esta função produz um .slk e um .dbf que ambos guardam 北京; num host 1252 ela produz um .xlsx e reporta por que o .dbf foi recusado. Nomes de entrada RTF, HTML e ZIP no componente de planilhas Delphi HotXLS seguem regras de codificação próprias