在西文 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;由扩展名决定,所以两个伴生文件不会被互相错读。字符数据按主机代码页解码。因此一个 DOS 程序在代码页 866 下写出、第 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 条目名的处理,走的是另一套编码规则