Bài viết kỹ thuật

Lệch độ dài BIFF record trong XLS writer Delphi

HotXLS 2.376.0 đã sửa lỗi lệch độ dài BIFF trong XLS writer cổ điển: SXEx emitter cho PivotTable view khai báo body dài 24 byte trong header rồi append 26 byte. BIFF reader tin vào độ dài đã khai báo, nên hai byte thừa làm desynchronize mọi thứ phía sau, còn workbook ghép PivotTable với chart sheet sẽ mất chart khi mở lại

Điểm đáng chú ý không phải một word off-by-one. Mà là khoảng cách giữa lỗi và triệu chứng. Không có gì fail tại vị trí bug. Pivot record serialize sạch, file ghi không lỗi, Excel mở được, còn hỏng hóc chỉ lộ ra hàng trăm byte sau trong một substream hoàn toàn không liên quan. Khoảng cách đó là đặc trưng của mọi binary format có length prefix, và đáng hiểu trước khi bạn viết thêm emitter cho một format như vậy

Vì sao một record length sai phá hủy cả worksheet stream?

BIFF8 workbook stream không có framing nào ngoài arithmetic của chính nó. Mọi record là header 4 byte gồm record id (2 byte) và body length (2 byte), theo sau bởi đúng số payload byte đó ([MS-XLS] 2.1.4). Không có separator, magic byte, checksum hay resynchronization point. Reader chỉ đến được record kế tiếp vì record trước nói thật về kích thước của chính nó. Độ dài khai báo không phải metadata về record; nó là pointer tới record tiếp theo. Hãy trace hai byte thừa đã làm gì. Reader consume SXEx header, skip 24 byte mà header hứa, rồi dừng sớm hai byte trên một cặp zero còn sót từ body quá lớn. Nó đọc các zero đó như record id $0000, sau đó đọc worksheet EOF record id ($000A) tiếp theo như length của phantom record ấy và ngoan ngoãn skip mười byte vào phần kế. Từ đó mọi header đều được đọc ở offset sai. Trong workbook lỗi, việc này tạo chart sheet có _Chart bằng nil sau khi reopen và debug dump cho thấy $18AF bị hiểu là record id. Không value nào trong đó xuất hiện gần pivot code

Emitter và writer không bao giờ đối chiếu với nhau

Lý do cấu trúc khiến drift có thể xảy ra là HotXLS dựng một BIFF record dưới dạng TXLSBlob, nơi header và payload là hai fact độc lập. EmitSXEx ghi record id, sau đó Blob.AddWord(24) cho length, rồi append body theo từng field. Con số 24 được đếm bằng tay, không derive từ byte theo sau và cũng không được check với chúng. Write path cũng không lấp khoảng trống: AddRec forward blob tới TXLSBlobList.Append, method này copy nguyên văn Data.DataLength byte vào output stream. DataLength là byte count thật, nên writer trung thành emit 26 byte body sau header tuyên bố 24. Cả hai nửa đều làm đúng điều được bảo, còn mâu thuẫn giữa chúng không thuộc trách nhiệm của ai để phát hiện. HotXLS vốn đã tránh việc này khi replay payload được giữ lại: TXLSWorkbook.StoreDConnBlobs tính header length word từ body length thật thay vì literal, chính vì vậy blob replay chưa từng drift

[MS-XLS] 2.4.282 chốt gì về SXEx?

Spec không mơ hồ về size, nên bản sửa trở thành cơ học. [MS-XLS] 2.4.282 định nghĩa SXEx body là grbit 4 byte theo sau bởi mười field 2 byte: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStylecchVacateStyle. Bốn cộng hai mươi là hai mươi tư. Emitter cũ ghi mười một zero word trong khi spec định nghĩa mười, còn các lời gọi AddWord(0) vô danh không mang tên field, nên đếm bằng mắt khi review đáng tin đúng như vẻ ngoài. Preallocation là manh mối: TXLSBlob.Create(28) xin đúng bốn byte header cộng body 24 byte, nhưng blob vượt hint đó ở mọi call và vượt một cách im lặng vì AdjustBufferSize reallocate khi cần. Capacity hint mà code lập tức vượt qua đáng được xem lại trong mọi serializer

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // header 4 byte + body 24 byte
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Mười zero word hoàn tất body 24 byte theo [MS-XLS] 2.4.282
  // Length đã khai báo PHẢI khớp byte đã ghi, nếu không mọi record
  // sau record này sẽ bị parse sai
  Blob.AddWord(0);               // csxformat
  Blob.AddWord(0);               // cchErrorString
  Blob.AddWord(0);               // cchNullString
  Blob.AddWord(0);               // cchTag
  Blob.AddWord(0);               // csxselect
  Blob.AddWord(0);               // crwPage
  Blob.AddWord(0);               // ccolPage
  Blob.AddWord(0);               // cchPageFieldStyle
  Blob.AddWord(0);               // cchTableStyle
  Blob.AddWord(0);               // cchVacateStyle
  AddRec(DataList, Blob);
  Result := 1;
end;

Vì sao lỗi này sống sót qua cả PivotTable test suite?

Vì pivot test hiện có chưa từng round-trip qua file. Chúng dựng workbook, assert trên in-memory model rồi dừng, mà in-memory assertion không thể thấy length mismatch chỉ tồn tại trong serialized byte stream. Record set được nói tới trong việc ghi BIFF8 PivotTable record từ Delphi được test tốt theo chuẩn đó nhưng vẫn ship một emitter làm hỏng stream. Defect còn cần feature thứ hai mới nhìn thấy: pivoted worksheet đứng một mình vẫn reopen được, vì corruption chạy ra cuối substream mà không ai inspect. Chỉ tổ hợp PivotTable và chart sheet, nơi chart sheet và drawing chiếm substream đứng sau worksheet, mới biến misalignment im lặng thành object bị mất nhìn thấy được

// PivotChartRoundTripThroughLinkRecords, bản rút gọn
Wb.Sheets.Add.Name := 'Report';
Wb.Sheets[2].AddPivotTable('Data!A1:B3', 2, 2, 'SalesPivot');
Wb.Sheets.AddChartSheet('PivotView', TXLSChartType(2), '', '', '',
  Series, No3D, PivotInfo);
Assert.AreEqual(1, Wb.SaveAs(TempPath));
Wb.Free;
Wb := TXLSWorkbook.Create;
Wb.Open(TempPath);                       // misparse xảy ra ở đây
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Trước bản sửa, Wb.Sheets[3]._Chart là nil tại dòng đó vì reader đã mất substream boundary từ rất lâu trước khi tới chart BOF. Assertion cuối cùng bắt được lỗi serialization của pivot lại là assertion về chart

Cách đọc ngược BIFF stream bị lệch về record sai đầu tiên

Hãy đi theo chuỗi header và in nó ra, vì BIFF stream desynchronize sẽ tự báo hiệu bằng cấu trúc rất lâu trước khi data trông sai. Bắt đầu ở substream BOF ($0809), đọc id và length, tiến lên bốn cộng length rồi lặp lại. Khi stream còn align, bạn chạm các record id hợp lệ và chain kết thúc đúng ở EOF ($000A). Khi bắt đầu drift, bạn nhận id không tồn tại, length vượt buffer hoặc chain đi thẳng qua nơi EOF đáng lẽ phải nằm

// Đi qua BIFF record stream và dừng ở header đầu tiên không thể hợp lệ
procedure ScanRecords(Buf: PByte; Size: LongWord);
var
  Pos: LongWord;
  Id, Len: Word;
begin
  Pos := 0;
  while Pos + 4 <= Size do
  begin
    Id  := PWord(Buf + Pos)^;
    Len := PWord(Buf + Pos + 2)^;
    // Id zero không bao giờ là record hợp lệ, còn body vượt quá
    // buffer chứng minh chain đã drift ở đâu đó phía trước
    if (Id = 0) or (Pos + 4 + LongWord(Len) > Size) then
    begin
      WriteLn(Format('desync at %d: id=$%.4x len=%d', [Pos, Id, Len]));
      Break;
    end;
    WriteLn(Format('%6d  id=$%.4x  len=%d', [Pos, Id, Len]));
    if Id = $000A then
      WriteLn('-- EOF, substream ends cleanly --');
    Inc(Pos, 4 + LongWord(Len));
  end;
end;

Sau đó đọc output theo hướng ngược lại và giữ một quy tắc: record đầu tiên fail parse gần như không bao giờ là thủ phạm. Nó là nạn nhân. Thủ phạm là record ngay trước đó, record cuối cùng parse không phàn nàn, vì record nói dối về length của chính nó luôn parse thành công. Trong trường hợp này walk dừng ở phantom record $0000, còn record trước là SXEx. So sánh length đã khai báo của record đó với field list trong spec, từng byte một, arithmetic sẽ tự cho biết có cộng đủ hay không. Nếu walk thậm chí không tới được record đầu tiên hợp lý, vấn đề nằm ở layer thấp hơn, trong OLE2 compound file chứa Workbook stream, và dump record bao nhiêu cũng không giúp được

Emitter không thể nói dối về length của chính nó

Bản sửa bền vững không phải một constant đúng mà là xóa cơ hội ghi constant sai. Reserve length word, emit body rồi patch header từ byte count thực sự tạo ra. HotXLS cung cấp đúng primitive cần dùng: TXLSBlob.DataLength cho offset hiện tại và SetWord ghi lại vào vị trí đã emit

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // nhớ vị trí length word
  Blob.AddWord(0);             // placeholder, EndRecord sẽ patch
end;

procedure EndRecord(Blob: TXLSBlob; LenPos: LongWord);
var
  Body: LongWord;
begin
  Body := Blob.DataLength - LenPos - SizeOf(Word);
  if Body > 8224 then
    raise Exception.Create('BIFF body exceeds 8224 bytes, split with Continue');
  Blob.SetWord(Word(Body), LenPos);
end;

Cần trung thực về nơi guarantee dừng lại. Assertion chung rằng emitted byte bằng 2 + 2 + declared chỉ đúng với record nằm dưới giới hạn BIFF8 là 8224 payload byte. Body quá lớn hợp lệ sẽ khai báo 8224 trong header rồi tiếp tục bằng record $003C Continue, đúng cách HotXLS pivot cache và connection writer xử lý payload lớn, nên invariant là có điều kiện: dưới limit emitted blob length phải bằng declared length cộng bốn, trên limit splitter sở hữu arithmetic. Hãy encode phân biệt đó trong helper thay vì comment. Lý luận tương tự chuyển sang mọi tag-length-value format, không chỉ BIFF. Emitter khai báo size trước khi biết mình đã ghi bao nhiêu đang đưa ra một claim code không thể check và reviewer không thể đếm, hoạt động cho tới khi feature thứ hai nằm sau feature đầu tiên

BIFF8 writer, pivot record emitter và chart substream được nói tới ở đây là một phần của HotXLS Delphi spreadsheet component cho Delphi và C++Builder, đọc và ghi XLS, XLSX cùng ODS mà không cần Excel cài đặt