Artikel Teknis

Drift Panjang Record BIFF pada XLS Writer Delphi

HotXLS 2.376.0 memperbaiki drift panjang record BIFF pada classic XLS writer-nya: emitter SXEx untuk PivotTable view mendeklarasikan body 24 byte di header, lalu menambahkan 26 byte. BIFF reader mempercayai panjang yang dideklarasikan, sehingga dua byte surplus membuat seluruh bagian setelahnya kehilangan sinkronisasi, dan workbook yang memasangkan PivotTable dengan chart sheet kehilangan chart saat dibuka ulang

Bagian menariknya bukan kata off-by-one. Yang menarik adalah jarak antara kesalahan dan gejala. Tidak ada yang gagal di titik bug. Pivot record diserialisasikan dengan bersih, file ditulis tanpa error, Excel membukanya, dan kerusakan baru muncul ratusan byte setelahnya di substream yang sama sekali tidak berkaitan. Jarak ini khas pada setiap binary format dengan length prefix, dan layak dipahami sebelum Anda menulis emitter lain untuk format semacam itu

Mengapa satu panjang record yang salah menghancurkan seluruh worksheet stream?

BIFF8 workbook stream tidak memiliki framing selain aritmetikanya sendiri. Setiap record adalah header 4-byte yang terdiri dari record id 2 byte dan body length 2 byte, diikuti tepat sejumlah payload byte tersebut ([MS-XLS] 2.1.4). Tidak ada separator, magic byte, checksum, atau resynchronization point. Reader mendarat pada record berikutnya hanya karena record sebelumnya mengatakan kebenaran tentang ukurannya sendiri. Panjang yang dideklarasikan bukan metadata tentang record; ia adalah pointer ke record berikutnya. Jadi lihat apa yang dilakukan dua byte surplus itu. Reader mengonsumsi header SXEx, melewati 24 byte yang dijanjikan header, lalu mendarat dua byte terlalu awal, pada sepasang zero yang tersisa dari body berukuran berlebih. Zero tersebut dibaca sebagai record id $0000, lalu reader berikutnya membaca record id worksheet EOF ($000A) sebagai length record palsu itu, dan dengan patuh melewati sepuluh byte ke apa pun yang datang setelahnya. Sejak saat itu setiap header dibaca pada offset yang salah. Pada workbook yang gagal, hasilnya adalah chart sheet yang _Chart-nya bernilai nil setelah reopen, serta debug dump yang menunjukkan $18AF ditafsirkan sebagai record id. Tidak satu pun nilai tersebut muncul di dekat kode pivot

Emitter dan writer tidak pernah saling memeriksa

Alasan struktural mengapa drift ini mungkin terjadi adalah HotXLS membangun BIFF record sebagai TXLSBlob dengan header dan payload sebagai dua fakta independen. EmitSXEx menulis record id, lalu Blob.AddWord(24) untuk length, kemudian menambahkan body field demi field. Angka 24 tersebut adalah constant yang dihitung manual, tidak pernah diturunkan atau diperiksa terhadap byte yang mengikutinya. Write path juga tidak menutup celah ini: AddRec meneruskan blob ke TXLSBlobList.Append, yang menyalin Data.DataLength byte secara verbatim ke output stream. DataLength adalah jumlah byte sebenarnya, sehingga writer dengan setia mengeluarkan body 26 byte di balik header yang mengklaim 24. Kedua sisi melakukan tepat apa yang diperintahkan, dan tidak ada yang ditugaskan untuk memperhatikan kontradiksi di antara keduanya. HotXLS sudah menghindari masalah ini ketika me-replay payload yang dipertahankan: TXLSWorkbook.StoreDConnBlobs menghitung header length word dari body length aktual, bukan dari literal, dan itulah sebabnya blob replay tidak pernah drift

Apa yang ditetapkan [MS-XLS] 2.4.282 tentang SXEx?

Spesifikasinya tegas tentang ukuran, sehingga perbaikannya mekanis. [MS-XLS] 2.4.282 mendefinisikan body SXEx sebagai grbit 4 byte diikuti sepuluh field 2 byte: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle, dan cchVacateStyle. Empat ditambah dua puluh adalah dua puluh empat. Emitter lama menulis sebelas zero word, padahal spesifikasi mendefinisikan sepuluh, dan pemanggilan AddWord(0) tanpa nama field membuat penghitungan dengan mata saat review sama andalnya dengan yang Anda bayangkan. Preallocation memberi petunjuk bahwa layout sudah dipahami tetapi loop belum: TXLSBlob.Create(28) meminta tepat empat byte header ditambah body 24 byte, namun blob melewati hint itu pada setiap pemanggilan, dan melakukannya diam-diam karena AdjustBufferSize melakukan realloc sesuai kebutuhan. Capacity hint yang langsung dilampaui oleh kode layak diperiksa ulang pada serializer apa pun

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
  // Sepuluh zero word melengkapi body 24 byte sesuai [MS-XLS] 2.4.282
  // Panjang yang dideklarasikan HARUS cocok dengan byte yang ditulis, atau setiap record
  // setelahnya akan salah diparse
  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;

Mengapa masalah ini lolos dari seluruh test suite PivotTable?

Karena test pivot yang ada tidak pernah melakukan round-trip melalui file. Test membangun workbook, melakukan assertion terhadap in-memory model, lalu berhenti, sementara assertion in-memory tidak dapat melihat length mismatch yang hanya ada dalam serialized byte stream. Record set yang dibahas dalam menulis BIFF8 PivotTable record dari Delphi diuji dengan baik menurut standar itu, tetapi tetap mengirim emitter yang merusak stream. Defect ini juga membutuhkan feature kedua agar terlihat: worksheet berisi pivot yang kemudian tidak diikuti banyak hal masih dapat dibuka ulang, karena corruption berjalan sampai ujung substream yang tidak diperiksa siapa pun. Hanya kombinasi PivotTable dan chart sheet, ketika chart sheet dan drawing menempati substream setelah worksheet, yang mengubah misalignment diam-diam menjadi object yang terlihat hilang

// PivotChartRoundTripThroughLinkRecords, diringkas
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 terjadi di sini
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Sebelum perbaikan, Wb.Sheets[3]._Chart bernilai nil pada baris itu karena reader sudah kehilangan batas substream jauh sebelum mencapai chart BOF. Assertion yang akhirnya menangkap bug serialisasi pivot adalah assertion tentang chart

Bagaimana membaca kembali BIFF stream yang misaligned sampai record pertama yang salah?

Telusuri rantai header dan cetak, karena BIFF stream yang desynchronized mengumumkan dirinya secara struktural jauh sebelum datanya tampak salah. Mulai dari substream BOF ($0809), baca id dan length, maju empat ditambah length, lalu ulangi. Selama stream masih aligned, Anda mendarat pada record id yang masuk akal dan rantai berakhir tepat pada EOF ($000A). Setelah drift, Anda mendapatkan id yang tidak ada, length yang melewati buffer, atau rantai yang berjalan melewati tempat EOF seharusnya berada

// Telusuri BIFF record stream dan berhenti pada header pertama yang tidak mungkin valid
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)^;
    // Zero id tidak pernah menjadi record legal, dan body yang melewati
    // buffer membuktikan chain sudah drift di bagian upstream
    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;

Kemudian baca output secara terbalik, dan pegang satu aturan: record pertama yang gagal diparse hampir tidak pernah menjadi pelakunya. Ia adalah korban. Pelakunya adalah record tepat sebelumnya, yaitu record terakhir yang diparse tanpa keluhan, karena record yang berbohong tentang panjangnya sendiri selalu berhasil diparse. Dalam kasus ini traversal berhenti pada phantom record $0000, dan record sebelumnya adalah SXEx. Bandingkan declared length record itu dengan field list dalam spesifikasi, byte demi byte, lalu aritmetikanya akan cocok atau tidak. Jika traversal bahkan tidak mencapai first record yang masuk akal, masalahnya berada satu layer lebih rendah, di OLE2 compound file yang menampung Workbook stream, dan sebanyak apa pun record-level dump tidak akan membantu

Emitter yang tidak dapat berbohong tentang panjangnya sendiri

Perbaikan yang tahan lama bukan constant yang benar, melainkan menghilangkan kesempatan untuk menulis constant yang salah. Sisihkan length word, emit body, lalu patch header dari byte count yang benar-benar dihasilkan. HotXLS menyediakan apa yang diperlukan: TXLSBlob.DataLength memberi offset saat ini dan SetWord menulis kembali ke posisi yang sudah diterbitkan

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // ingat posisi length word
  Blob.AddWord(0);             // placeholder, di-patch oleh EndRecord
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;

Jujurlah tentang batas jaminan itu. Assertion menyeluruh bahwa emitted byte sama dengan 2 + 2 + declared hanya berlaku untuk record yang muat di bawah limit BIFF8 sebesar 8224 payload byte. Body yang lebih besar secara sah mendeklarasikan 8224 di header dan berlanjut dalam record $003C Continue, persis seperti yang dilakukan pivot cache dan connection writer HotXLS untuk payload besar, sehingga invariant-nya bersyarat: di bawah limit, panjang emitted blob harus sama dengan declared length ditambah empat, sedangkan di atasnya splitter-lah yang memiliki aritmetika. Encode perbedaan itu di helper, bukan di comment. Penalaran yang sama berpindah ke setiap tag-length-value format, bukan hanya BIFF. Emitter yang mendeklarasikan size sebelum mengetahuinya membuat klaim yang tidak dapat diperiksa code dan tidak dapat dihitung reviewer, dan semuanya bekerja sampai feature kedua mendarat setelah feature pertama

BIFF8 writer, pivot record emitter, dan chart substream yang dibahas di sini tersedia sebagai bagian dari HotXLS Delphi spreadsheet component untuk Delphi dan C++Builder, yang membaca dan menulis XLS, XLSX, dan ODS tanpa Excel terpasang