HotXLS Excel Library cho Delphi và C++Builder đọc và ghi container Compound File Binary đứng sau mọi tệp .xls đời cũ hoàn toàn bằng Object Pascal thuần. Lớp TlxCompoundFile triển khai trực tiếp bố cục [MS-CFB] phiên bản 3 trên một TStream — header, DIFAT, chuỗi FAT, MiniFAT và cây thư mục — không có ole32.dll và không có COM IStorage ở bất kỳ đâu trong đường xử lý
Nghe có vẻ chỉ là hạ tầng ống nước, và suốt hai mươi năm nó đúng là hạ tầng ống nước do người khác sở hữu. Mọi codebase Delphi từng chạm vào một tệp .xls đều gọi tới StgOpenStorage, nhận về một IStorage, rồi rút stream Workbook ra khỏi đó. Ba dòng, chạy tốt, không ai nghĩ tới nó lần nào nữa — cho tới ngày cùng đoạn mã đó phải chạy ở một nơi không có Windows
Vì sao StgOpenStorage ngừng hoạt động trên máy chủ?
API structured-storage của COM thất bại đúng ở những hình dạng triển khai mà mã Delphi hiện đại đang sống, vì những lý do chẳng liên quan gì tới định dạng tệp cả. StgOpenStorage là một điểm vào Win32 trong ole32.dll: nó muốn một đường dẫn trên một hệ tệp, nó muốn COM đã được khởi tạo trên luồng gọi, và nó muốn chạy trên Windows. Yêu cầu về đường dẫn gây đau đầu trước tiên, vì một endpoint REST nhận một workbook được tải lên có các byte trong một buffer, không phải trên đĩa — nên bạn phải ghi buffer ra một tệp tạm, mở nó, đọc lại, xóa đi, và giờ sở hữu cả một vòng đời tệp tạm dễ sai sót dưới tải nặng. ILockBytes là lối thoát được tài liệu hóa, nhưng nối dây một cài đặt tùy chỉnh lên trên một TMemoryStream là quá nhiều COM interop so với mức hầu hết các nhóm phát triển muốn làm. Yêu cầu khởi tạo cắn tiếp theo, thường trong một luồng worker dịch vụ mà chưa ai gọi CoInitialize, và yêu cầu nền tảng kết thúc cuộc trò chuyện ngay khi đích là Linux dưới FPC, một image container, hay macOS. Vì vậy HotXLS giữ đường xử lý cổ điển lxOLE dựng trên StgOpenStorage làm mặc định, vì nó đã qua thử lửa và những bên gọi hiện có không nên phải thay đổi gì; TlxCompoundFile là lựa chọn thay thế tùy chọn cho tất cả những người khác
Header và các chuỗi FAT thực sự nói gì
512 byte đầu tiên của một tệp compound trả lời mọi câu hỏi cấu trúc bạn cần trước khi đọc một byte payload nào. [MS-CFB] §2.2 cố định chữ ký header tại offset 0 là tám byte D0 CF 11 E0 A1 B1 1A E1, và lxIsCompoundStream kiểm tra đúng điều đó, khôi phục lại vị trí stream sau đó để một bên gọi có thể "ngửi thử" mà không làm xáo trộn gì cả. Bốn trường nữa quyết định hình học: thứ tự byte tại 0x1C phải là 0xFFFE, đóng vai trò như một phép kiểm tra chữ ký thứ hai rẻ tiền; sector shift tại 0x1E cho kích thước sector là 1 shl SectorShift, nên phiên bản 3 dùng shift 9 cho sector 512 byte và phiên bản 4 dùng shift 12 cho 4096 byte; mini sector shift tại 0x20 là 6, khiến mini sector có 64 byte; và ngưỡng cắt mini stream tại 0x38 là 4096. Phép toán địa chỉ theo sau đó là nơi hay sai nhất. Sector 0 bắt đầu ngay sau header, nên sector thứ N bắt đầu tại offset byte 512 + N * SectorSize — chú ý số 512 theo nghĩa đen, không phải SectorSize. Trên một tệp phiên bản 3, hai số đó giống hệt nhau nên lỗi này ẩn mãi mãi; trên một tệp phiên bản 4, nó âm thầm đọc sai sector, đó là lý do HotXLS giữ điều này trong một hàm duy nhất, SidToOffset
Một tệp compound là một hệ tệp FAT bên trong một tệp, nên đọc nó nghĩa là duyệt các danh sách liên kết của các sector ID nơi FAT[n] giữ ID theo sau sector n. Ba giá trị lính canh (sentinel) kết thúc hoặc chú thích một chuỗi — ENDOFCHAIN, FATSECT cho một sector thuộc về chính FAT, và DIFSECT cho một sector DIFAT — và cả ba đọc ra dưới dạng số nguyên có dấu 32-bit âm, giữ cho điều kiện vòng lặp đơn giản. Tìm FAT cần thêm một tầng gián tiếp nữa: DIFAT là mảng các sector ID nói rằng các sector FAT nằm ở đâu, và 109 mục đầu tiên của nó nằm trong header tại offset 0x4C. TlxCompoundFile duyệt qua 109 mục đó, dừng lại ở mục âm đầu tiên, và nối mỗi sector FAT thành một mảng Integer phẳng duy nhất. Đó là 109 sector FAT với 128 mục mỗi sector trên một sector 512 byte, tức 13.952 sector có thể định địa chỉ, tức khoảng 6,8 MiB container trước khi DIFAT phải tràn sang chuỗi của riêng nó
Bảng cấp phát thứ hai tồn tại vì các sector 512 byte lãng phí phần lớn không gian của chúng cho các stream nhỏ. Bất kỳ stream nào dưới ngưỡng cắt 4096 byte hoàn toàn không được lưu trong các sector: nó sống bên trong mini stream, tự nó là một stream thông thường treo trên mục thư mục root, được chia nhỏ thành các mini sector 64 byte và nối chuỗi qua một MiniFAT song song bắt rễ tại offset header 0x3C. Mở một tệp .xls thật và stream Workbook nằm trên FAT thông thường trong khi các stream summary-information nằm xuống trong không gian mini-sector, đó là lý do một cách cài đặt chỉ bao quát đường FAT trông có vẻ hoạt động đúng cho tới khi nó cần metadata tài liệu. Thư mục là cấu trúc thứ ba và là cái khiến container có thể điều hướng được: mỗi mục đúng 128 byte, bốn mục mỗi sector 512 byte, mang một tên UTF-16 trong 64 byte đầu, độ dài byte của nó tại 0x40, kiểu đối tượng tại 0x42 (1 = storage, 2 = stream, 5 = root), các liên kết cây tại 0x44, 0x48 và 0x4C, sector bắt đầu tại 0x74 và kích thước stream 32-bit tại 0x78. Độ dài tên đó đếm byte bao gồm cả null kết thúc, nên số ký tự là NameLen div 2 - 1, và làm sai lệch một đơn vị chính là cách bạn kết thúc với một stream tên Workboo
Rút một stream Workbook ra khỏi một buffer bộ nhớ
TlxCompoundFile.OpenStream giấu toàn bộ những gì ở trên đằng sau một lệnh gọi duy nhất nhận vào một tên stream và trả về một TlxCfbStream giữ các byte đã được vật chất hóa đầy đủ. Toàn bộ chuỗi thao tác — ngửi thử, tải, trích xuất — chạy trên một TBytesStream mà không có gì chạm vào đĩa cả
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;
Hai chi tiết ở đó đáng nêu ra. LoadFromStream nhận một cờ AOwnsStream mặc định là False, nên bên gọi vẫn giữ trách nhiệm với stream nguồn — có chủ đích, vì trường hợp phổ biến là một stream mà ứng dụng đã sở hữu sẵn. Và OpenStream trả về một TlxCfbStream sở hữu bản sao byte của riêng nó, được lộ ra qua Data, Size, Read, Seek và CopyTo. Bản sao đó là một chi phí thật trên một workbook lớn, và đó là cái giá trung thực của một thiết kế mà đối tượng trả về vẫn hợp lệ sau khi container bị giải phóng. Khi một workbook đủ lớn để việc sao chép toàn bộ vào bộ nhớ là hoàn toàn sai hình dạng, bộ đọc trực tiếp dạng streaming cho bảng tính quá khổ là điểm vào tốt hơn
Vì sao một XLSX đã mã hóa lại trông giống một tệp XLS?
Vì nó thực sự là một tệp như vậy, ở mức container — và đây chính là lợi ích thực tiễn của việc sở hữu lớp đó. Mở một .xlsx đã mã hóa trong một trình soạn hex và tám byte đầu tiên là D0 CF 11 E0 A1 B1 1A E1, giống hệt từng byte với một .xls đời 1997, vì mã hóa [MS-OFFCRYPTO] không mã hóa gói ZIP tại chỗ: nó bọc toàn bộ gói bên trong một container CFB dưới dạng một stream tên EncryptedPackage, cạnh một stream EncryptionInfo mô tả thuật toán. Vì vậy chữ ký chỉ nhận dạng container và không nói gì về payload. Phân biệt một workbook BIFF với một gói OOXML đã mã hóa nghĩa là đọc thư mục, mà sau LoadFromStream là một lượt quét qua EntryCount và Entries, hay một cặp phép dò 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;
Tên thư mục đáng một lời cảnh báo riêng: các stream summary-information mang một ký tự điều khiển 0x05 dẫn đầu trong tên của chúng, nên một phép so sánh viết với một chuỗi hiển thị thuần túy sẽ không bao giờ khớp với chúng và một dòng log ngây thơ render chúng ra thành rác. Mọi thứ phía sau bước phân loại này — suy dẫn khóa, kiểm tra bộ xác nhận mật khẩu — là một vấn đề riêng biệt, được trình bày trong ghi chú về vì sao Excel từ chối một workbook được mã hóa bằng sai chế độ cipher. Lớp container chỉ cho bạn biết bạn đang đứng trước cánh cửa nào
Ghi một container mà Excel thực sự sẽ mở được
Phía ghi của TlxCompoundFile cố tình hẹp hơn nhiều so với phía đọc, và hiểu vì sao sẽ giúp bạn tránh một cuộc tranh cãi với đặc tả. [MS-CFB] cho phép một không gian container hợp lệ khổng lồ: storage đa tầng, cây thư mục đỏ-đen cân bằng đúng chuẩn, mini stream, chuỗi DIFAT. Excel phát ra một góc nhỏ của không gian đó và đọc một góc lớn hơn đôi chút. HotXLS ghi ra một góc còn nhỏ hơn nữa — mức tối thiểu mà Excel được chứng minh là tải được. Mọi stream đều nằm trên FAT thông thường không có đường mini-stream, tốn dung lượng đĩa nhưng đổi lại tính đúng đắn: một stream summary 300 byte mà Excel lẽ ra sẽ đóng gói vào năm mini sector 64 byte thì lại chiếm trọn một sector 512 byte đầy đủ, và đối với một workbook đó chỉ là nhiễu so với việc phải duy trì một bảng cấp phát thứ hai, một lượt duyệt chuỗi thứ hai và stream mục root hậu thuẫn nó ở phía ghi. Các mục thư mục tạo thành một chuỗi anh em phẳng dưới root với mọi node tô màu đen, và thứ tự phát ra là cố định: chỗ giữ header, các sector dữ liệu stream, các sector thư mục, các sector FAT, rồi seek ngược lại để ghi lại header với các sector ID chỉ được biết ở cuối cùng. FAT tự định cỡ qua một vòng lặp điểm cố định ngắn, vì việc thêm các sector FAT có thể đẩy số lượng sector lên cao đủ để cần thêm một sector FAT nữa
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;
Việc cài đặt dừng lại ở đâu
Ba ranh giới đáng nói rõ, vì một bộ đọc container âm thầm xử lý sai một trường hợp biên còn tệ hơn cả một bộ đọc báo lỗi. TlxCompoundFile đọc 109 mục DIFAT thường trú trong header và không theo chuỗi DIFAT tại 0x44 vượt quá chúng, giới hạn một container đọc được ở khoảng 6,8 MiB trên các sector 512 byte — thoải mái cao hơn các tệp .xls thật mà HotXLS gặp trong thực tế, nhưng dù sao cũng là một trần cứng, và bộ ghi thực thi rõ ràng cùng giới hạn đó thay vì phát ra một container mà nó không thể mô tả. Thứ hai, các container phiên bản 4 với sector 4096 byte được tiếp nhận nhờ phép toán kích thước sector nhưng không phải là thứ mà đoạn mã được tinh chỉnh cho, và kích thước stream 64-bit không được tham khảo: HotXLS đọc 32 bit thấp tại offset 0x78 và bỏ mặc nửa cao, điều này đúng cho phiên bản 3 và chỉ cho phiên bản 3. Thứ ba, việc tra cứu mục là một lượt quét phẳng theo tên qua danh sách thư mục thay vì một lượt duyệt xuống cây đỏ-đen từ một storage cha, nên các storage lồng nhau được giải quyết bằng xung đột tên chứ không phải bằng đường dẫn — mọi stream mà một tệp .xls cần đều nằm ở mức cao nhất, đó là điều khiến thiết kế đơn giản hơn có thể biện minh được, nhưng đoạn mã mong đợi định địa chỉ SomeStorage/SomeStream sẽ không tìm thấy nó
Không điều gì trong số đó thay đổi mục đích của unit này. Sở hữu lớp container biến việc xử lý .xls thành Object Pascal thông thường: có thể phân tích từ một mảng byte, có thể test không cần hệ tệp, khả chuyển sang bất kỳ nền tảng nào mà trình biên dịch nhắm tới, và không có căn hộ COM (COM apartment) nào cả. Nó cũng loại bỏ các lối tắt ngửi thử, vì nhận dạng một workbook giờ nghĩa là đọc thư mục của nó thay vì tám byte đầu tiên — cùng kỷ luật đứng sau liệt kê tên sheet mà không mở toàn bộ workbook
TlxCompoundFile được cung cấp như một phần của HotXLS Excel Component cho Delphi và C++Builder, cùng với các lớp BIFF và OOXML nằm trên nó; trang sản phẩm có đầy đủ tài liệu tham khảo unit và ma trận trình biên dịch được hỗ trợ