技術文章

Delphi 匯出 dBASE、SYLK、DIF 不掉非 ASCII 字元

中文城市名在歐美語系的 PC 上匯出成 .dbf,回來時已經是一排問號,可是那次匯出回報的是成功。原因很單純:dBASE、SYLK 與 DIF 檔案沒有任何地方可以記錄編碼。安全的寫入器會用主機 ANSI 代碼頁,在寫出第一個位元組之前把每個名稱與值拿去比對;HotXLS 則是該代碼頁裝不下就拒絕

主機代碼頁是這三種檔案唯一的編碼依據

這三種格式都來自 PC 的早年:dBASE 出自 Ashton-Tate,SYLK 出自 Microsoft 的 Multiplan 時代,DIF 出自 VisiCalc 的開發商 Software Arts。三者都把文字存成單一位元組,並假設讀取的機器跟寫入的機器用同一個代碼頁——在整個辦公室都跑同一套 DOS 設定的年代,這個假設還算合理。到了今天的 Windows,那個代碼頁就是 GetACP 回傳的值:西歐安裝是 1252,簡體中文是 936,斯拉夫語系是 1251。dBASE IV 與 FoxPro 後來在表頭第 29 個位元組放了語言驅動標記,但 HotXLS 寫出的 dBASE III 佈局把那個位元組留成零,而 SYLK、DIF 和以空格對齊的 .prn 文字連這種表頭欄位都沒有。所以代碼頁是在檔案之外講好的。HotXLS 寫入與讀取都用主機 ANSI 代碼頁,並在寫入前檢查每個字元在該代碼頁裡是否都有對應位元組

格式SaveAs / Open 上的副檔名檔案內宣告的編碼主機代碼頁裝不下的文字
dBASE III 資料表.dbf,需要時另附 .dbt 備忘檔沒有(第 29 位元組保持為零)寫入前就拋出 EXlsDbf,並刪掉剛建立的 .dbf / .dbt
SYLK.slk、.sylk沒有SaveAs 回傳 -1,不建立任何檔案
DIF.dif沒有SaveAs 回傳 -1,不建立任何檔案
格式化文字.prn沒有SaveAs 回傳 -1,不建立任何檔案

dBASE 欄位名稱的十個位元組,得用代碼頁來算

dBASE 資料表裡每一個尺寸上限都是位元組數,多位元組代碼頁就是在這裡出問題。欄位描述區用十一個位元組存名稱——十個給名稱,一個給結尾的 NUL——型別字母緊接在偏移 11,寬度在 16,小數位數在 17。在代碼頁 936 下一個中文字是兩個位元組,所以十個中文字的名稱就是二十個位元組。這些位元組不先量長度就複製進去,會一路壓過 NUL、型別字母和寬度;之後沒有任何讀取器救得回那個描述區,儘管這個名稱通過了十個字元的檢查。就算工作站在 CJK 環境,dBASE 欄位名稱也請保持 ASCII。xBase 工具、ODBC 驅動程式,以及讀 shapefile 屬性表的 GIS 軟體全都靠這些名稱對位,而只能在 936 下解讀的名稱,到了跑其他代碼頁的機器上就成了另一組位元組

Delphi 中 HotXLS dBASE III DBF 欄位描述區佈局:十位元組的名稱欄加 NUL,型別字母在偏移 11,寬度在 16、小數位數在 17;AddField('CITY') 放得進去,但代碼頁 936 下十個中文字的名稱會溢出成二十個位元組
在 32 位元組的 dBASE 欄位描述區裡,通過十個字元檢查的中文欄位名稱,在代碼頁 936 下會溢進 NUL、型別字母與寬度欄位
位元組dBASE III 內容
0版本:0x03;資料表附有 .dbt 備忘檔時為 0x83
1–3最後更新:自 1900 起算的年、月、日
4–7記錄筆數,32 位元小端序
8–11表頭長度與記錄長度,16 位元字組
32 起每個欄位一個 32 位元組描述區,之後是 0x0D;接著是各筆記錄,每筆前面帶刪除旗標

寬度也是位元組。字元欄位(C)上限 254 位元組,N 值是以宣告小數位數呈現的右對齊文字,D 值則是 YYYYMMDD 形式的八個字元,至於超出欄位寬度的值會變成一排星號,這是 dBASE 自己的慣例。較長的文字放進備忘(M)欄位,欄位裡存的是指向 .dbt 夥伴檔的區塊編號:512 位元組的表頭,接著一連串 512 位元組區塊,每段備忘以 0x1A 0x1A 結束。TXLSXWorkbook.SaveAs 搭配 .dbf 副檔名時,會把作用中工作表第一個使用列變成欄位名稱,逐欄推斷 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
    // 最多十個位元組的 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 主機上資料表寫得出來,重新開啟時四個欄位都在。在 1252 主機上,XlsWriteDbfFile 會拋出 EXlsDbf,訊息裡指明欄位與從 1 起算的列號。XlsWriteDbfStream 會把每個欄位名稱、每個 C 或 M 值轉到主機代碼頁再轉回來比對,並在表頭送出之前量過每個名稱是否符合十位元組上限,所以被拒絕的資料表不會在資料流上留下任何東西。檔案版更進一步,會把它已經建立的 .dbf 以及任何 .dbt 一併刪掉

怎麼測試一個字串撐不撐得過主機代碼頁?

把它轉成 AnsiString,再轉回 WideString,然後跟原字串比對。代碼頁裝不下的字元會變成替換字元,通常是 ?,所以比對失敗的位置正好就是匯出會弄丟資料的位置。寫入器用的也是同一套轉換,因此檢查與寫入對代碼頁的認定不會互相矛盾。別忘了連位元組長度一起留下:要拿來跟十位元組的名稱欄位或欄位寬度相比的就是這個數字

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 在那邊也有同樣形狀的毛病:把寬字元文字指定給它的 Text 屬性來切行,每個字元在進去的路上就都過了一次主機代碼頁。XlsParseSlk、XlsParseDifText 與 XlsParsePrnText 收的是 WideString 輸入,在任何主機上都把每個字元原樣留住。如果您也用 Lazarus 建置,請看HotXLS 各單元在 Free Pascal 下以哪種字串型別編譯

Delphi 中 HotXLS 對 DBF、SYLK、DIF 與 PRN 匯出的主機 ANSI 代碼頁來回檢查:轉成 AnsiString 再轉回 WideString,在 936 主機上中文原樣保留,在 1252 上變成問號,最後以 EXlsDbf 或 SaveAs 回傳 -1 拒絕
先把每個值轉到主機代碼頁再轉回來,就能在任何位元組落地之前看出哪些字元會丟;前提是文字用 VarToWideStr 讀進來,而不是 VarToStr

SYLK 與 DIF 的記錄,一行一行看

SYLK 是一行一筆記錄,欄位之間用分號隔開。HotXLS 寫出 ID;PHotXLS 表頭,每個儲存格一筆 C;Yrow;Xcol;Kvalue 記錄,最後是 E 結尾。文字用引號包起來,內部的引號重複一次,這同時也保護了值裡出現的分號;讀取端另外接受某些產生器使用的 ;; 跳脫。這裡沒有布林值,格式自己的寫入器送的是 1 與 0,所以 SaveAs 就照這樣寫;錯誤以 #N/A 這類文字送出,日期送序列數。開頭那兩個字母就是 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 囉嗦得多。四組表頭 TABLE、VECTORS(欄數)、TUPLES(列數)與 DATA,各佔三行;接下來每個資料項目佔兩行,一行是type,number,另一行放引號字串或值指示符。一列以 -1,0 加 BOT 開頭,-1,0 加 EOD 結束資料。數字是 0,value 配 V,邏輯值是 0,1 配 TRUE 或 0,0 配 FALSE,字串是 1,0 配它的引號文字。讀取時,HotXLS 把 NA 與 ERROR 指示符對應到 #N/A 與 #VALUE!,並把 NULL 當成空白。一張叫 Orders 的工作表,唯一一列放著 Beijing、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

小數逗號會一次弄壞三種格式

代碼頁不是唯一會滲進這些檔案的機器設定。在德語或法語地區設定的工作站上,FloatToStr 會把 1234.5 變成 1234,5。在 DIF 裡,項目行就成了 0,1234,5,原本的type,value 形狀消失。在 SYLK 裡,K1234,5 不是數字。在 dBASE 的 N 欄位裡,xBase 讀取器期待的是小數點。HotXLS 讀寫這三種格式都用 TFormatSettings.Invariant:SYLK 取的是能讀回同一個 double 的最短 token,DIF 用 invariant 的 FloatToStr 並在整數後補上 .0,dBASE 數值欄位則依宣告的小數位數右對齊。解析同樣不理地區設定,所以從採用小數逗號的機器產生的檔案,到哪裡讀都一樣

Delphi 中 HotXLS 的 SYLK 與 DIF 匯出:C;Y;X;K 記錄配上 ID;PHotXLS 表頭、布林值寫成 1,DIF 的 type,number 項目對夾在 BOT 與 EOD 之間,TFormatSettings.Invariant 讓 1234.5 原樣保留,而小數逗號會弄壞 DIF、SYLK 與 dBASE
把 Orders 那一列寫成 SYLK 記錄與 DIF 項目對,就能看出小數逗號地區設定會在哪裡弄壞各個格式,也說明了 HotXLS 為何一律用 invariant 設定格式化數字

讀取其他程式寫出來的資料表

開啟別人寫的資料表,代碼頁的問題就換到讀取的機器上。XlsReadDbfFile,以及對 .dbf 檔名呼叫的 TXLSXWorkbook.Open,接受版本位元組 0x03、0x83、0x8B、0x30、0x31 與 0x32,涵蓋 dBASE III、dBASE IV 與 Visual FoxPro 資料表,包含 FoxPro 的二進位型別 I(32 位元整數)、B(double)、Y(以 10,000 縮放的貨幣)與 T(儒略日加毫秒)。備忘文字來自資料表旁的 .dbt;沒有 .dbt 時則從採用 FoxPro 區塊佈局的 .fpt 讀取。靠副檔名判斷,兩個夥伴檔不會互相誤讀。字元資料在主機代碼頁裡解碼。於是一支在代碼頁 866 下由 DOS 程式寫出、第 29 位元組留成零的 dBASE III 資料表,在 1252 機器上就會顯示錯誤的字母。知道生產者是何方時,就到它的代碼頁正好是系統頁的機器上做匯入。SYLK、DIF 與 .prn 檔案遵循同樣的規則,因為 Open 會在寬字元解析器看到內容之前,先用主機代碼頁解碼這些位元組

跨語系傳遞非 ASCII 文字該用哪種格式?

可以選的話,這四種都不要用。真的碰到接收端堅持才用——ERP 的匯入畫面、xBase 應用程式、GIS 管線——而且資料本身是 ASCII,或者兩端共用同一個代碼頁。其他情況都該走有宣告編碼的格式:XLSX、SpreadsheetML 2003,或 CSV(HotXLS 以帶 BOM 的 UTF-8 寫出,CSV 與 TSV 匯出本身另有搬運上的坑)。由於 SaveAs 是依副檔名選格式,您的程式碼得同時處理第一張表列的兩種拒絕方式:.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');   // 第一個加入的工作表會成為作用中工作表
    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
      // 作用中代碼頁裝不下這些文字:沒有建立 .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 試算表元件裡的 RTF、HTML 與 ZIP 項目名稱,則依它們自己的編碼規則處理