技術記事

コードページを持たない DBF、SYLK、DIF への書き出し

西欧の PC で .dbf に書き出した中国語の市名が、成功したはずの書き出しから疑問符で戻ってくることがあります。dBASE、SYLK、DIF のファイルにはエンコーディングを記録する場所がないからです。安全な writer はホストの ANSI コードページを使い、最初の 1 バイトを書く前にすべての名前と値をそのページで検査します。HotXLS はそのページに収まらないものを拒みます

ホストコードページこそ、これらのファイルが持つ唯一のエンコーディング

3 形式とも PC 初期の産物です。dBASE は Ashton-Tate、SYLK は Microsoft の Multiplan 時代、DIF は VisiCalc の Software Arts から来ました。どれもテキストをシングルバイトで格納し、リーダーが writer と同じコードページを共有していると想定します。オフィスの全マシンが同じ DOS 構成で動いていた時代には、筋のいい賭けでした。今日の Windows では、そのページは GetACP が返すものです。西欧インストールで 1252、簡体字中国語で 936、キリルで 1251。dBASE IV と FoxPro は後にテーブルヘッダーのバイト 29 へ言語ドライバの印を置きましたが、HotXLS が書く dBASE III レイアウトではこのバイトは 0 のまま、SYLK、DIF、スペース桁合わせの .prn テキストには、そもそもそれ用のヘッダーフィールドがありません。つまりコードページはファイルの外で合意されます。HotXLS は書き込みと読み込みの両方でホストの ANSI コードページを使い、書く前にすべての文字がそのページ内のバイトを持つかを検査します

形式SaveAs / Open の拡張子ファイル内で宣言されるエンコーディングホストページに収まらないテキストのとき
dBASE III テーブル.dbf。必要なら .dbt メモファイルもなし(バイト 29 は 0 のまま)書き込み前に EXlsDbf を上げ、直後に作った .dbf / .dbt を削除する
SYLK.slk、.sylkなしSaveAs が -1 を返し、ファイルは作らない
DIF.difなしSaveAs が -1 を返し、ファイルは作らない
整形テキスト.prnなしSaveAs が -1 を返し、ファイルは作らない

dBASE のフィールド名 10 バイト、数えるのはコードページで

dBASE テーブルのサイズ上限は、すべてバイト数です。マルチバイトコードページが痛いのはここです。フィールドディスクリプタは名前を 11 バイトに収めます。名前 10 バイト、終端 NUL に 1 バイト。タイプ文字がすぐ後ろのオフセット 11、幅が 16、小数桁数が 17 に座ります。コードページ 936 では漢字 1 文字が 2 バイトなので、10 文字の中国語名は 20 バイトになります。それを測らずにコピーすれば、バイト列は NUL、タイプ文字、幅をまっすぐ踏み抜きます。名前は 10 文字チェックを通っていたのに、そのディスクリプタはもう誰にも読めません。dBASE のフィールド名は、CJK ワークステーションでも ASCII にしましょう。xBase ツール、ODBC ドライバ、shapefile の属性テーブルを読む GIS ソフトは、どれもその名前をキーにします。936 でしか解読できない名前は、別のページで動くマシンの上で別のバイト列に化けます

HotXLS の Delphi での dBASE III DBF フィールドディスクリプタのレイアウト。10 バイトの名前スロットと NUL、オフセット 11 のタイプ文字、16 の幅、17 の小数桁数。AddField('CITY') は収まるが、コードページ 936 での 10 文字の中国語名は 20 バイトを踏み抜く
32 バイトの dBASE フィールドディスクリプタの中で、10 文字チェックを通った中国語のフィールド名は、コードページ 936 の下で NUL、タイプ文字、幅へ溢れます
バイトdBASE III の内容
0バージョン:0x03。テーブルに .dbt メモファイルが付くときは 0x83
1–3最終更新:1900 年からの年、月、日
4–7レコード数。32 ビットのリトルエンディアン
8–11ヘッダー長とレコード長。16 ビットワード
32 以降フィールドごとに 1 つの 32 バイトディスクリプタ、続いて 0x0D。レコードはその後ろに、それぞれ削除フラグを伴って続く

幅もバイトです。文字フィールド(C)の上限は 254 バイト、N の値は宣言どおりの小数桁の右寄せテキスト、D の値は YYYYMMDD 形の 8 文字です。フィールドに収まらない値はアスタリスクの並びになります。dBASE 自身が使う慣例です。長いテキストはメモ(M)フィールドへ行き、.dbt 相手のブロック番号を保持します。512 バイトのヘッダー、続いて 512 バイトのブロック、各メモは 0x1A 0x1A で終わります。.dbf 名での TXLSXWorkbook.SaveAs は、active シートの使用中最初の行をフィールド名に変え、列ごとに C、N、L、D を推定し、混在列は C に落とし、254 文字を超えるテキストは M へ移します。幅をバイトで管理したいときは、TXlsDbfTable でテーブルを自前で組み立て、各 C フィールドを最長値の符号化後の長さでサイズ決めましょう

uses
  Windows, SysUtils, Variants, lxDbf;

procedure WriteCustomerTable(const FileName: string);
var
  Table: TXlsDbfTable;
  Row: Integer;
begin
  Table := TXlsDbfTable.Create;
  try
    // 最大 10 バイトの ASCII 名。幅はホストコードページでのバイト数
    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));  // 北京市。CP936 では 6 バイト
    Table.SetValue(Row, 2, 1234.5);
    Table.SetValue(Row, 3, EncodeDate(2026, 11, 12));

    try
      XlsWriteDbfFile(FileName, Table);
    except
      on E: EXlsDbf do
        // 1252 ホストでの期待結果:メッセージはフィールド "CITY" と行 1 を
        // 名指し、ディスクに .dbf は残らない
        raise Exception.CreateFmt('DBF export refused under code page %d: %s',
          [GetACP, E.Message]);
    end;
  finally
    Table.Free;
  end;
end;

期待結果:936 のホストではテーブルが書かれ、4 フィールドすべてが再オープンできます。1252 のホストでは XlsWriteDbfFile がフィールド名と 1 始まりの行番号を添えて EXlsDbf を上げます。XlsWriteDbfStream はすべてのフィールド名とすべての C、M の値をホストページへ変換して戻し、比較し、ヘッダーが出ていく前に各名前を 10 バイトで測ります。拒まれたテーブルはストリームを無傷のままにします。ファイル形態はさらに一歩進んで、すでに作ってしまった .dbf と、あれば .dbt を削除します

文字列がホストコードページを生き延びるかの試し方

AnsiString に変換し、それを WideString に戻し、元と比べます。ページにない文字は置き換え、たいてい ? で戻ってくるので、比較が失敗するのは、書き出しがデータを失うときにちょうど一致します。writer も同じ変換を使うので、検査と writer がコードページについて食い違うことはありません。バイト長も取っておきましょう。10 バイトの名前スロットやフィールド幅と突き合わせる数字です

uses
  Variants;

// V のすべての文字がホスト ANSI コードページを生き延びるとき True。
// ByteLen はフィールド幅と比較する符号化後の長さ
function FitsHostCodePage(const V: Variant; out ByteLen: Integer): Boolean;
var
  W: WideString;
  A: AnsiString;
begin
  W := VarToWideStr(V);   // VarToStr はこの検査がテキストを見る前に変換してしまうことがある
  A := AnsiString(W);
  ByteLen := Length(A);
  Result := WideString(A) = W;
end;

VarToWideStr へのコメントに本題が載っています。Delphi 2007 以前では string はどこも AnsiString で、Free Pascal の下では、あなたのユニットのモードが何であれ、Variants ユニットは RTL 自身の string 型、AnsiString でコンパイルされます。そうしたコンパイラでは VarToStr が、漢字のあった場所にすでに ? を置いています。AnsiString(VarToStr(V)) の上に組んだガードは、疑問符と疑問符を比較してすべてを受け入れます。TStringList にも同じ形の問題があります。行分割のために wide テキストを Text プロパティへ代入すると、入る途中で全文字がホストコードページを通ります。XlsParseSlk、XlsParseDifText、XlsParsePrnText は WideString を取り、どのホストでもその全文字を保持します。Lazarus でもビルドするなら、Free Pascal の下で HotXLS の各ユニットがどの文字列型でコンパイルされるかを参照してください

HotXLS の Delphi での DBF、SYLK、DIF、PRN 書き出しにおけるホスト ANSI コードページの往復検査。AnsiString から WideString への復帰は 936 ホストでは中国語テキストを保ち、1252 では疑問符になり、EXlsDbf か SaveAs の -1 で拒まれる
各値をホストコードページへ変換して戻せば、失われた文字が 1 バイトもディスクに届く前に露見します。テキストが VarToStr でなく VarToWideStr で読まれていればの話です

SYLK と DIF のレコード、行ごとに

SYLK は 1 行に 1 レコード、フィールドはセミコロン区切りです。HotXLS は ID;PHotXLS ヘッダー、セルごとに C;Y行;X列;K値 のレコード、そして E の終端子を書きます。テキストは引用符で囲み、埋め込まれた引用符は二重化します。値の中のセミコロンもこれで守られ、リーダーは一部の作成者が代わりに使う ;; エスケープも受け付けます。Boolean はありません。形式自身の writer が 1 と 0 を送るので、SaveAs もそう書きます。エラーは #N/A のようなテキストで出て行き、日付はシリアル値です。先頭のあの 2 文字は Excel が SYLK を認識する方法です。そして最初のセルが ID の CSV を SYLK ファイルと見違える理由でもあります。組み立てとパースはどちらも WideString で働くので、下のメモリ内往復はどのホストでも同じ答えを出します

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 はおしゃべりです。ヘッダーは 4 グループ、TABLE、VECTORS(列数)、TUPLES(行数)、DATA で、各 3 行使います。データ項目はその後 2 行ずつ。型,数 の行と、引用符付き文字列か値指示子の 1 行です。行は -1,0 と BOT で開き、-1,0 と EOD がデータを閉じます。数値は 0,値 に V、論理値は 0,1 に TRUE か 0,0 に FALSE、文字列は 1,0 に引用符付きテキストです。読み込みでは、HotXLS は NA と ERROR の指示子を #N/A と #VALUE! に写像し、NULL は空として扱います。Orders という名のシートで唯一の行が北京、1234.5、True を持つときの書き出しは:

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

小数点コンマは 3 形式すべてを壊す

これらのファイルに染み出す機械設定はコードページだけではありません。ドイツ語やフランス語の地域設定のワークステーションでは、FloatToStr が 1234.5 を 1234,5 に変えます。DIF では項目行が 0,1234,5 になり、型,値 の形を失います。SYLK では K1234,5 は数値ではありません。dBASE の N フィールドでは、xBase リーダーはポイントを期待します。HotXLS は 3 形式の書き込みと読み込みを TFormatSettings.Invariant で行います。SYLK は同じ double に読み戻せる最短トークン、DIF は整数に .0 を付けた不変の FloatToStr、dBASE の数値フィールドは宣言どおりの小数桁の右寄せです。パースもロケールを無視するので、小数点コンマのマシンが作ったファイルもどこで読んでも同じになります

HotXLS の Delphi での SYLK と DIF の書き出し。ID;PHotXLS ヘッダー付きの C;Y;X;K レコードと Boolean は 1、BOT と EOD の間の DIF の型と数の項目ペア、TFormatSettings.Invariant が小数点コンマが DIF、SYLK、dBASE を壊す場所で 1234.5 を無傷に保つ
Orders の行を SYLK レコードと DIF 項目ペアで書くと、小数点コンマのロケールが各形式を壊す位置と、HotXLS が不変設定で数値を整形する理由が見えてきます

他のプログラムが書いたテーブルを読む

他人のテーブルを開くと、コードページの問題は読み込み側のマシンに移ります。XlsReadDbfFile と、.dbf 名での TXLSXWorkbook.Open は、バージョンバイト 0x03、0x83、0x8B、0x30、0x31、0x32 を受け付けます。dBASE III と IV、Visual FoxPro のテーブルを覆い、FoxPro のバイナリ型 I(32 ビット整数)、B(double)、Y(10,000 倍の通貨)、T(ユリウス日+ミリ秒)も含みます。メモテキストはテーブルの脇の .dbt から、.dbt がなければ FoxPro のブロックレイアウトの .fpt から来ます。拡張子が決めるので、どちらの相手ももう一方として誤読されません。文字データはホストページでデコードされます。したがって、DOS プログラムがコードページ 866 で書き、バイト 29 を 0 のままにした dBASE III テーブルは、1252 のマシンで間違った文字を表示します。作成者が分かっているなら、そのコードページがシステムページになっている場所でインポートを実行しましょう。SYLK、DIF、.prn のファイルも同じ規則に従います。wide テキストのパーサが目にする前に、Open がホストページでバイトをデコードするからです

ロケール間で非 ASCII テキストを運ぶべき形式はどれか

選べるなら、この 4 つのどれでもありません。使うのは受け側のシステムが強く求めるとき——ERP のインポート画面、xBase アプリケーション、GIS のパイプライン——で、データが ASCII か、両端がコードページを共有するときです。それ以外は、エンコーディングを宣言する形式で運ぶべきです。XLSX、SpreadsheetML 2003、あるいは HotXLS が BOM 付き UTF-8 で書く CSV(CSV と TSV の書き出しには輸送上の落とし穴が専用にあります)。SaveAs は拡張子から形式を選ぶので、最初の表の 2 通りの拒否をコード側で両方扱うことになります。.dbf は例外、テキスト形式は -1 です

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');   // 最初に追加したシートが active になる
    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
      // active のコードページではテキストを収められない。.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;

936 のホストでは、この関数は北京を収めた .slk と .dbf を作ります。1252 のホストでは .xlsx を作り、.dbf が拒まれた理由を報告します。HotXLS Delphi spreadsheet component での RTF、HTML、ZIP エントリ名には、それぞれ専用のエンコーディング規則があります