中文城市名在歐美語系的 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 下解讀的名稱,到了跑其他代碼頁的機器上就成了另一組位元組
| 位元組 | 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 下以哪種字串型別編譯
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 數值欄位則依宣告的小數位數右對齊。解析同樣不理地區設定,所以從採用小數逗號的機器產生的檔案,到哪裡讀都一樣
讀取其他程式寫出來的資料表
開啟別人寫的資料表,代碼頁的問題就換到讀取的機器上。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 項目名稱,則依它們自己的編碼規則處理