面向Delphi和C++Builder的HotXLS Excel Library,用纯Object Pascal读写每一个旧版.xls文件背后的Compound File Binary容器。TlxCompoundFile类直接针对一个TStream实现了[MS-CFB] version 3布局——头部、DIFAT、FAT链、MiniFAT和目录树——整条路径上完全不涉及ole32.dll,也不涉及COM IStorage
这听起来像是底层管线活儿,而在过去二十年里,它确实是别人负责维护的管线活儿。每一个接触过.xls文件的Delphi代码库都会去调用StgOpenStorage,拿到一个IStorage,再从里面取出Workbook流。三行代码,好用,没人再多想——直到有一天,同一份代码不得不跑在一个没有Windows的地方
为什么StgOpenStorage在服务器上会失效?
COM结构化存储API恰恰会在现代Delphi代码常见的那些部署形态下失效,而原因和文件格式本身毫无关系。StgOpenStorage是ole32.dll里的一个Win32入口点:它要一个文件系统上的路径,要求调用线程已经初始化了COM,还要求运行在Windows上。路径这个要求最先带来麻烦,因为一个接收上传工作簿的REST端点,手里拿到的字节是在一个缓冲区里,而不是在磁盘上——于是你把缓冲区写成临时文件,打开它,读回来,再删掉它,从此就多了一套要在高负载下小心维护的临时文件生命周期。ILockBytes是文档里给出的逃生舱口,但在一个TMemoryStream之上接一个自定义实现,涉及的COM互操作比大多数团队愿意接受的要多。初始化这个要求带来的麻烦排第二,通常出现在某个没人调用过CoInitialize的服务工作线程里;而平台这个要求,一旦目标是跑在FPC下的Linux、一个容器镜像或者macOS,对话直接就没法继续了。因此HotXLS把基于StgOpenStorage的经典lxOLE路径保留为默认方式,因为它经过了长期实战检验,现有调用方不应该被迫改动;TlxCompoundFile则是为其他所有场景准备的可选替代方案
头部和FAT链到底说明了什么
一个复合文件的前512字节,回答了你在读取任何一个载荷字节之前需要知道的全部结构性问题。[MS-CFB] §2.2把偏移量0处的头部签名固定为八个字节D0 CF 11 E0 A1 B1 1A E1,lxIsCompoundStream正是检查这八个字节,检查完之后会把流位置还原,这样调用方就能做嗅探而不打扰任何东西。接下来还有四个字段决定了整体几何结构:0x1C处的字节序必须是0xFFFE,这同时也充当了一次低成本的第二重签名校验;0x1E处的扇区位移给出扇区大小,计算方式是1 shl SectorShift,所以version 3用位移9得到512字节扇区,version 4用位移12得到4096字节;0x20处的迷你扇区位移是6,也就是迷你扇区为64字节;0x38处的迷你流截止阈值是4096。接下来的地址运算,是最容易出错的地方。扇区0紧接在头部之后开始,所以扇区N的起始字节偏移量是512 + N * SectorSize——注意这里是字面量512,而不是SectorSize。在version 3文件上这两个值恰好相等,这个bug就会永远藏起来;而在version 4文件上,它会悄悄读到错误的扇区,这正是HotXLS把这段逻辑收拢在SidToOffset这一个函数里的原因
一个复合文件本质上是文件内部的一个FAT文件系统,所以读取它意味着遍历由扇区ID组成的链表,其中FAT[n]保存的是紧跟在扇区n之后的那个扇区ID。有三个哨兵值用来终止或标注一条链——ENDOFCHAIN、代表某扇区本身属于FAT的FATSECT,以及代表某扇区是DIFAT扇区的DIFSECT——这三者都读作带符号的负32位整数,这让循环条件保持简单。要找到FAT还需要多一层间接:DIFAT是一个扇区ID数组,记录着FAT扇区都存放在哪里,它的前109个条目就位于头部偏移量0x4C处。TlxCompoundFile遍历这109个条目,遇到第一个负值就停下,再把每个FAT扇区拼接成一个扁平的Integer数组。这意味着在512字节扇区下,109个FAT扇区、每个128个条目,一共可寻址13,952个扇区,也就是说容器要长到大约6.8 MiB以上,DIFAT才需要溢出到自己的链上
之所以还存在第二张分配表,是因为512字节的扇区在存放小流时大部分空间都被浪费掉了。任何低于4096字节截止阈值的流根本不会存放在普通扇区里:它存放在迷你流内部,迷你流本身是挂在根目录条目下的一个普通流,被细分成64字节的迷你扇区,通过一张位于头部偏移量0x3C的并行MiniFAT链接起来。打开一个真实的.xls文件,Workbook流位于普通FAT上,而摘要信息流则位于迷你扇区空间里,这正是为什么一个只覆盖了FAT路径的实现,看起来能正常工作,直到它需要读取文档元数据的那一刻才露馅。目录是第三种结构,也是让整个容器可导航的关键:每个条目恰好128字节,一个512字节扇区能容纳四个,前64字节是UTF-16名称,0x40处是名称的字节长度,0x42处是对象类型(1代表存储、2代表流、5代表根),0x44、0x48和0x4C处是树链接,0x74处是起始扇区,0x78处是32位流大小。那个名称长度字段计算的字节数包含了结尾的空终止符,所以实际字符数是NameLen div 2 - 1,一旦这里差一位,你最后得到的流名字就会变成Workboo
从内存缓冲区中取出Workbook流
TlxCompoundFile.OpenStream把上面这一切都藏在了一次调用背后:给它一个流名称,它返回一个持有完整已具体化字节的TlxCfbStream。整个过程——嗅探、加载、提取——都是针对一个TBytesStream运行的,全程不会碰一下磁盘
uses
Classes, SysUtils, lxCompoundFile;
function ExtractBiffPayload(const Blob: TBytes): TBytes;
var
Src: TBytesStream;
Cfb: TlxCompoundFile;
Wb: TlxCfbStream;
begin
SetLength(Result, 0);
Src:= TBytesStream.Create(Blob);
try
if not lxIsCompoundStream(Src) then
Exit; // not a CFB container at all
Cfb:= TlxCompoundFile.Create;
try
Cfb.LoadFromStream(Src); // header, FAT, directory, MiniFAT
Wb:= Cfb.OpenStream('Workbook'); // BIFF8
if Wb = nil then
Wb:= Cfb.OpenStream('Book'); // BIFF5 / BIFF7
if Wb <> nil then
try
Result:= Wb.Data;
finally
Wb.Free;
end;
finally
Cfb.Free;
end;
finally
Src.Free;
end;
end;
这里有两个细节值得指出。LoadFromStream接受一个AOwnsStream标志,默认为False,所以源流的所有权仍然归调用方所有——这是刻意的设计,因为常见情况是应用程序本来就已经拥有这个流。而OpenStream返回的TlxCfbStream持有自己独立的一份字节拷贝,通过Data、Size、Read、Seek和CopyTo暴露出来。在一个大工作簿上,这份拷贝是实实在在的开销,也是"返回的对象在容器被释放之后仍然有效"这一设计所必须付出的诚实代价。当一个工作簿大到"完整拷贝进内存"这种形状本身就不合适时,面向超大电子表格的流式直读器是更合适的入口
为什么一个加密的XLSX文件看起来像XLS文件?
因为在容器这一层,它确实就是——而这正是掌握这一层带来的实际收益。用十六进制编辑器打开一个加密的.xlsx文件,前八个字节是D0 CF 11 E0 A1 B1 1A E1,与1997年那个年代的.xls文件逐字节完全一致,因为[MS-OFFCRYPTO]加密并不是原地加密ZIP包本身:它把整个包封装进一个CFB容器里,作为一个名为EncryptedPackage的流,旁边还有一个描述加密算法的EncryptionInfo流。所以这个签名标识的是容器,对载荷内容什么也说明不了。要区分一个BIFF工作簿和一个加密的OOXML包,就得读取目录——调用LoadFromStream之后,可以遍历EntryCount和Entries,或者用一对HasStream探测来判断
type
TCfbPayload = (cpUnknown, cpBiffWorkbook, cpEncryptedOoxml);
function ClassifyContainer(AStream: TStream): TCfbPayload;
var
Cfb: TlxCompoundFile;
E: TlxCfbEntry;
I: Integer;
begin
Result:= cpUnknown;
Cfb:= TlxCompoundFile.Create;
try
Cfb.LoadFromStream(AStream);
for I:= 0 to Cfb.EntryCount - 1 do
begin
E:= Cfb.Entries(I);
if E.EntryType <> cfbStream then
Continue;
if E.Name = 'EncryptedPackage' then
Result:= cpEncryptedOoxml
else if (E.Name = 'Workbook') or (E.Name = 'Book') then
Result:= cpBiffWorkbook;
end;
finally
Cfb.Free;
end;
end;
目录名称本身也值得单独提个醒:摘要信息流的名字前面带着一个控制字符0x05,所以按普通显示字符串写的比较逻辑永远匹配不到它们,一条粗心的日志输出也会把它们打印成乱码。这次分类之后的所有工作——推导密钥、校验密码验证器——是另一个独立的问题,在为什么Excel会拒绝一份用错误密码模式加密的工作簿一文中有介绍。容器这一层只能告诉你,你现在站在哪扇门前面
写出一个Excel真正打得开的容器
TlxCompoundFile的写入侧刻意做得比读取侧窄得多,理解这背后的原因能省下一场和规范较真的争论。[MS-CFB]允许的合法容器空间极其庞大:多层存储、正确配平的红黑目录树、迷你流、DIFAT链。Excel实际输出的只是这个空间里很小的一角,读取时能接受的范围略大一些。而HotXLS写出的角落还要更小——只写Excel确实能加载的最小集合。每一个流都放在普通FAT上,不走迷你流路径,这会多耗费一些磁盘空间,但换来的是正确性:一个原本会被Excel打包进五个64字节迷你扇区的300字节摘要流,改成占用一整个512字节扇区,对一个工作簿来说,这点开销比起在写入路径上维护第二张分配表、第二条链遍历逻辑以及支撑它的根条目流所带来的复杂度,根本不算什么。目录条目在根节点下组成一条扁平的兄弟链,每个节点都被着色为黑色,输出顺序是固定的:先占位头部,再写流数据扇区,再写目录扇区,再写FAT扇区,最后回头把只有在最后才能确定的扇区ID重新写入头部。FAT的大小是通过一个短小的不动点循环自行确定的,因为添加FAT扇区本身可能会把扇区总数推高到需要再多一个FAT扇区的地步
procedure SaveAsCompoundFile(const Dest: string; const BiffBytes: TBytes);
var
FS: TFileStream;
Cfb: TlxCompoundFile;
begin
FS:= TFileStream.Create(Dest, fmCreate);
try
Cfb:= TlxCompoundFile.Create;
try
Cfb.CreateNew(FS); // v3 header, 512-byte sectors
Cfb.AddStream('Workbook', BiffBytes);
Cfb.Save; // data -> dir -> FAT -> header
finally
Cfb.Free;
end;
finally
FS.Free;
end;
end;
这套实现的边界在哪里
有三条边界值得直说清楚,因为一个在边界情况上悄悄处理错误的容器读取器,比一个直接报错的读取器更糟糕。TlxCompoundFile只读取头部中常驻的109个DIFAT条目,不会沿着0x44处的DIFAT链继续往后走,这就把一个可读容器的上限限制在了512字节扇区下大约6.8 MiB——这个上限比HotXLS在实际场景中遇到的真实.xls文件要宽裕得多,但终究是一道硬上限,写入器也明确执行同样的限制,而不会去输出一个自己都描述不了的容器。第二,带4096字节扇区的version 4容器在扇区大小运算上是能兼容的,但代码并不是为它调优的,而且64位流大小字段不会被参考:HotXLS只读取偏移量0x78处的低32位,高32位不作处理,这对version 3是正确的,也仅仅对version 3正确。第三,条目查找是在目录列表中按名称做扁平扫描,而不是从某个父存储沿红黑树往下遍历,所以嵌套存储是靠名称碰撞来解析的——一个.xls文件需要的每一个流都位于顶层,这也正是这种更简单设计站得住脚的原因,但如果代码期望按SomeStorage/SomeStream这种路径去寻址,是找不到的
这些都不会改变这个unit存在的意义。掌握容器这一层,把.xls文件的处理变成了普通的Object Pascal代码:可以从一个字节数组解析,可以不依赖文件系统进行测试,可以移植到编译器所面向的任何平台,也不再需要一个COM套间。它同时也淘汰了那些嗅探式的捷径,因为现在识别一个工作簿意味着读取它的目录,而不是只看它的前八个字节——这和不打开整个工作簿就列出工作表名称一文背后遵循的是同一套规范
TlxCompoundFile作为HotXLS Excel Component的一部分提供,适用于Delphi和C++Builder,与构建在它之上的BIFF和OOXML层同属一体;产品页收录了完整的unit参考和受支持的编译器矩阵