Bài viết kỹ thuật

Phân loại liên kết ngoài SupBook và XTI BIFF trong Delphi

Mở một tệp xls cũ, lưu lại lần nữa, và công thức add-in từng gọi vào một thư viện phân tích đã đăng ký giờ lại trỏ tới một tham chiếu rỗng ngay bên trong chính workbook. HotXLS truy nguyên sự hỏng âm thầm đó về một giả định sai: rằng bản ghi BIFF SupBook hoặc là self hoặc là tệp ngoài. [MS-XLS] định nghĩa bảy loại, chứ không phải hai

Vì sao workbook lưu lại lại mất liên kết add-in?

Vì phép kiểm tra phân loại mang tính cấu trúc thay vì theo kiểu. Lối tắt truyền thống đọc một bản ghi SupBook ($01AE), xem nó có mang marker self hay không, và nếu không thì coi chuỗi theo sau là một URL tài liệu. Mọi bản ghi không thuộc hai trường hợp đó đều rơi xuống nhánh mặc định, và nhánh mặc định gần như luôn là "đây chính là workbook này". Một liên kết hỗ trợ add-in, một liên kết same-sheet, một slot không dùng và một bản ghi bị cụt đều đeo cùng một nhãn sai. Không có gì văng ra trong lúc đó: bản ghi đã parse, công thức đã biên dịch lại, tệp đã lưu mà không có cảnh báo, và lỗi chỉ lộ diện ba tuần sau khi ai đó nhận ra một cột toàn số 0 ở chỗ vốn là phép đổi tiền tệ. [MS-XLS] §2.4.271 mô tả một bản ghi có thể là tham chiếu self, tham chiếu same-sheet, container hàm add-in, workbook ngoài với đường dẫn ảo và bảng tên sheet, liên kết dữ liệu DDE hoặc OLE, hoặc một placeholder không dùng — cộng thêm trạng thái thứ bảy không có trong đặc tả nhưng tồn tại trên đĩa thật: bản ghi không parse nổi. Cách sửa không phải một heuristic tốt hơn; là từ chối hẳn mọi heuristic

Bảy loại mà một bản ghi SupBook có thể mang

HotXLS khai báo hệ phân loại supporting-link thành một enumeration đóng trong lxExternSheet.pas, và mọi quyết định phía sau đều switch trên nó. Chín giá trị enumeration phủ bảy nhóm, vì trường hợp DDE và OLE cần một trạng thái tạm trước khi phân giải được:

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // parse thất bại, hoặc còn byte thừa
    slkSelf,              // chính workbook này
    slkSameSheet,         // marker U+0000
    slkAddIn,             // container hàm add-in
    slkExternalWorkbook,  // đường dẫn ảo + bảng tên sheet
    slkDde,               // phân giải từ cờ ExternName
    slkOle,               // phân giải từ cờ ExternName
    slkDdeOrOle,          // một trong hai, chưa biết cái nào
    slkUnused);           // placeholder một dấu cách

  TXLSFormulaReferenceClass = (
    frcInternal,
    frcExternalWorkbook,
    frcExternalOther,
    frcUnknownOrMalformed);

  TXLSXtiInfo = record
    XtiIndex    : Integer;   // zero-based, như lưu trong ExternSheet.rgXTI
    ExternID    : Integer;   // one-based, quy ước nội bộ
    SupBookIndex: Integer;
    Sheet1Index : Integer;
    Sheet2Index : Integer;
    LinkKind    : TXLSSupportingLinkKind;
  end;

Việc điều phối dựa trên sentinel chứ không dựa trên chuỗi. Giá trị trường $0401 đánh dấu bản ghi self. Sheet count bằng một đi kèm $3A01 đánh dấu container add-in. Chỉ giá trị nằm trong khoảng 1 đến $00FF mới nghĩa là theo sau là một đường dẫn ảo đã mã hóa, và chỉ lúc đó HotXLS mới decode một chuỗi. Bất cứ thứ gì ngoài ba hình dạng đó đều giữ nguyên slkUnknown, và một bản ghi mà bảng tên sheet không tiêu thụ đúng phần thân của nó sẽ bị hạ ngược về slkUnknown kể cả khi phần đầu trông hợp lệ

Cây sentinel mà HotXLS dùng để phân loại một bản ghi BIFF SupBook thành bảy loại, chỉ decode chuỗi với giá trị nằm trong khoảng đường dẫn mã hóa và quay về loại unknown thay vì nhánh mặc định
Mỗi loại được tới bằng một sentinel chứ không phải một phép so chuỗi, và bản ghi không khớp hình dạng nào giữ nguyên trạng thái unknown thay vì rơi vào nhánh mặc định nghĩa là workbook này

Vì sao marker same-sheet decode thành một chuỗi rỗng?

Vì bộ đọc chuỗi BIFF đa năng phá hủy chính byte mà phép phân loại phụ thuộc vào. Liên kết supporting same-sheet là một chuỗi một ký tự mà ký tự duy nhất đó là U+0000, và TXLSBlob.GetBiffString trả nó về như một WideString rỗng, không thể phân biệt với một đường dẫn rỗng thật — chính là input mà một heuristic tham chiếu self trả lời "self". Vì vậy HotXLS đọc thẳng code point đầu tiên từ phần thân bản ghi thay vì tin vào giá trị đã decode:

StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
  StringOptions := Data.GetByte(StringOffset + 2);
  if (StringOptions and $01) = 0 then
    FirstChar := Data.GetByte(StringOffset + 3)     // nén, một byte
  else
    FirstChar := Data.GetWord(StringOffset + 3);    // wide, hai byte
end;

if FirstChar = 0 then
  FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
  FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
  FKind := slkDdeOrOle
else if FDocUrl <> '' then
  FKind := slkExternalWorkbook;

Chú ý nhánh nén-so-với-wide. Byte tùy chọn nằm ở offset cố định tính từ header chuỗi và code point đầu tiên dài một hay hai byte tùy bit 0, nên đọc nó vô điều kiện như một byte chạy đúng trên đa số tệp và hỏng trên các tệp do bản build bản địa hóa ghi ra — phân bố tệ nhất có thể cho một bug. Placeholder không dùng bị bắt theo cùng cách, qua payload literal một dấu cách, còn trường hợp DDE hay OLE qua dấu phân tách U+0003 nhúng trong đường dẫn đã mã hóa

Vì sao HotXLS đọc thẳng code point đầu tiên từ phần thân bản ghi BIFF SupBook thay vì chuỗi đã decode, vì bộ đọc chuỗi đa năng biến marker same-sheet U+0000 thành một giá trị rỗng
Marker same-sheet là một chuỗi một ký tự mà ký tự đó là U+0000, nên bộ đọc chuỗi đa năng gộp nó thành giá trị rỗng và chỉ code point thẳng tại offset của byte tùy chọn mới giữ lại được nó

Vì sao DDE và OLE không thể tách ngay lúc đọc SupBook?

Vì bản ghi SupBook không mang các bit phân biệt. Nó chỉ nói rằng liên kết thuộc một trong hai; các cờ fOlefOleLink quyết định là cái nào nằm trong bản ghi ExternName ($0023) tới sau trong stream. HotXLS ghi nhận slkDdeOrOle lúc parse và thu hẹp nó trong ParseExternalName, và nếu không có ExternName nào tới thì loại này giữ trạng thái tạm mãi — điều đó đúng, vì tệp thật sự không nói. Mọi consumer phía sau coi giá trị tạm đó là một giá trị thật chứ không phải một giá trị thiếu, nên không caller nào phải tự nghĩ ra cách phân xử. Đoán "chắc là DDE" ở đây đổi lấy một enumeration gọn hơn và một lớp câu trả lời sai mà không ai truy được về nguồn:

if FKind = slkDdeOrOle then
begin
  if Data.DataLength < 2 then
    Exit;
  Flags := Data.GetWord(0);
  if (Flags and $0010) <> 0 then
    FKind := slkOle
  else if (Flags and $0008) <> 0 then
    FKind := slkDde;
end;

Chỉ số XTI zero-based trên đĩa và one-based bên trong

HotXLS thực hiện phép chuyển off-by-one đúng một lần, tại điểm một token đi vào cây cú pháp nội bộ, và không ở đâu khác. PtgNameX.ixti ([MS-XLS] §2.5.198.85) là chỉ số zero-based vào mảng rgXTI của bản ghi ExternSheet ($0017, §2.4.106), trong khi quy ước ExternID nội bộ của thư viện là one-based với số 0 dành cho "không có sheet ngoài". Đường đọc BIFF8 làm FExternID := wValue + 1 khi decode token tNameX và đường ghi phát StoreExternID - 1, giữ nguyên khung nhìn token thô và ngữ nghĩa trên đĩa. Lỡ cái này thì bắt lỗi cực khó: defined name ngoài phân giải sang mục kề bên, và trong một tệp chỉ có một mục XTI, chỉ số 0 thành 1, trượt, và cái tên suy tàn âm thầm. Một regression chỉ chạy lại văn bản công thức đã biên dịch sẽ không bao giờ thấy nó, vì việc biên dịch lại không đụng tới chỉ số trên đĩa — chính cái bẫy khiến defined names phủ qua sheet và workbook đáng được kiểm thử với byte stream thật. Phân giải bị chặn ở cả hai đầu: TlxExternSheetSheet.TryResolveXti trả False cho chỉ số âm hoặc mục thiếu, TXLSSupBook.TryGetKind trả False cho chỉ số SupBook ngoài mảng, và ClassifyXti sau đó ánh xạ slkSelfslkSameSheet sang frcInternal, slkExternalWorkbook sang frcExternalWorkbook, còn slkAddIn, slkDde, slkOleslkDdeOrOle sang frcExternalOther. Mọi thứ còn lại, kể cả mọi đường ngoài phạm vi, đều rơi vào frcUnknownOrMalformed

HotXLS chuyển chỉ số XTI zero-based của token BIFF PtgNameX thành ExternID one-based nội bộ tại một điểm duy nhất, với phân giải có chặn ở hai đầu và bảng phân loại tiêu thụ nó
Phép off-by-one giữa chỉ số zero-based trên đĩa và ExternID one-based nội bộ được áp một lần, khi token đi vào cây cú pháp, và mọi chỉ số không phân giải được đều rơi vào lớp malformed

Phân loại công thức trước khi đóng băng nó

TXLSCompiledFormula.ClassifyReferences quét thẳng stream token BIFF được giữ lại thay vì decompile công thức rồi săn dấu ngoặc vuông. Săn ngoặc trong văn bản công thức là một heuristic văn bản mặc áo parser: nó khớp string literal, khớp structured reference, và bỏ sót hoàn toàn defined name ngoài, vì những cái đó không mang ngoặc ở dạng decompiled. Phép quét token chỉ nhìn PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3dPtgAreaErr3d, quay về walk cây cú pháp khi không còn stream BIFF nào. Việc gộp được cố ý bi quan — độ ưu tiên cố định là frcUnknownOrMalformed, rồi frcExternalWorkbook, rồi frcExternalOther, rồi frcInternal — nên chỉ một token đọc không nổi cũng đầu độc cả công thức. Với defined name ngoài, chỉ số tên cũng được kiểm tra: one-based, trong phạm vi, và có bản ghi ExternName được giữ lại chống lưng

var
  Wb   : TXLSWorkbook;
  Sheet: TXLSWorksheet;
  i    : Integer;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('quarterly.xls');
    for i := 1 to Wb.Sheets.Count do        // Sheets là one-based
    begin
      Sheet := Wb.Sheets[i];
      // chỉ đóng băng công thức được phân loại frcExternalWorkbook;
      // tham chiếu nội bộ, add-in, DDE/OLE và malformed giữ nguyên là công thức
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

Tham số OnlyExternal là nơi hệ phân loại trả lại giá trị của nó. Đóng băng một công thức là không thể đảo ngược, nên thao tác này phải chứng minh được một tham chiếu là workbook ngoài chứ không chỉ nghi ngờ. Lời gọi add-in sống sót, liên kết DDE và OLE sống sót, và bất cứ thứ gì parser chưa hiểu trọn vẹn đều sống sót, vì kết quả an toàn của sự mơ hồ là không đổi gì cả. Kỷ luật tương tự chi phối rebinding công thức copy giữa các workbook, nơi một tham chiếu phân loại sai sẽ rebind sang nhầm cuốn thay vì hỏng ầm ĩ

Bản ghi không parse nổi được ghi trả nguyên vẹn

HotXLS giữ payload SupBook gốc và phát lại nó từng byte khi bản ghi chưa từng bị sửa. Một lần parse thất bại đặt slkUnknown và xóa trạng thái suy ra, nhưng phần thân đã bắt giữ vẫn nằm trong FRawData và đường lưu ưu tiên nó hơn mọi bản dựng lại miễn là item không dirty và không phải bản ghi self. Phương án thay thế — chuẩn hóa một bản ghi không parse nổi thành tham chiếu self để writer có thứ gì đó well-formed để phát — biến một bản ghi bạn không hiểu thành một bản ghi chắc chắn sai. Nguyên tắc đó chính là cùng một hợp đồng áp cho VBA project và các tham chiếu ngoài của nó qua một vòng load-and-save, và đó là ranh giới giữa một thư viện round-trip được tệp đời thực và một thư viện chỉ round-trip được những tệp mà bộ test của nó tình cờ chứa. Một workbook đi qua mười lăm năm các phiên bản Excel, một trình sinh báo cáo và hai công cụ di trú sẽ chứa những bản ghi mà không ai còn sống ngày nay từng thiết kế. Hãy ghi trả chúng như bạn tìm thấy

Phân loại SupBook và XTI theo kiểu có cấu trúc phát hành trong HotXLS 2.361.2 tới 2.361.4, cùng phân giải XTI có chặn và đường ConvertFormulasToValues an toàn hơn được mô tả ở đây. Nếu bạn duy trì code Delphi hoặc C++Builder đọc tệp xls legacy chứa lời gọi add-in, liên kết DDE hoặc OLE, hay defined name ngoài, HotXLS Delphi spreadsheet component xử trọn hệ phân loại này bản địa, không cần cài Excel và không OLE automation trên máy làm việc