Bài viết kỹ thuật

Font index BIFF8 bỏ qua 4: rich text runs với HotXLS Delphi

HotXLS đánh số mọi tham chiếu font BIFF8 đúng như [MS-XLS] §2.5.129 FontIndex định nghĩa: giá trị 0 tới 3 tính từ 0, giá trị trên 4 tính từ 1, và 4 không bao giờ xuất hiện, nên FONT record thứ năm là ifnt 5 và ifnt hợp lệ lớn nhất bằng số FONT record. Từ HotXLS 2.384.4, XF writer, XF reader, các run rich text string và migration run giữa các workbook đều theo luật đó, còn 2.384.5 và 2.384.6 mở rộng sang các run của comment và text box, kể cả qua copy và chèn dòng

Luật này trông như một lỗi đánh máy cho tới khi bạn đụng vào nó. Ai đó mở một workbook với tám FONT record, thấy một XF trỏ vào font 8, và kết luận writer đã sinh chỉ mục ngoài miền. Cái suy luận chính xác đó được ship trong HotXLS 2.384.1 như một “fix”, và nó biến một implementation đúng thành một thứ mà mọi font tùy biến trong file mở bằng Excel đều rơi lệch một slot sớm hơn. Phần thú vị không phải bản thân off-by-one, mà là có bao nhiêu chỗ trong một thư viện BIFF8 mang cùng quy ước đó, và một font binding sống sót qua một lần save rồi gãy ở lần thứ hai thế nào. Nếu bạn đã từng vật lộn với các quirks về độ dài và encoding trong giải mã cch và fHigh của BIFF8 XLUnicodeString, thì đây là cùng một họ bug: file thì ổn, phép tính mới là thứ có vấn đề

Luật FontIndex của [MS-XLS] thực sự nói gì?

[MS-XLS] §2.5.129 nói một FontIndex dưới 4 là vị trí record tính từ 0, một FontIndex trên 4 là vị trí record tính từ 1, và giá trị 4 cấm dùng. Cùng kiểu FontIndex đó được dùng bởi các XF record, các formatting run của SST và các formatting run của TXO, nên một luật bị đọc sai làm hỏng cả ba. Bằng chứng dễ tái hiện với các file do Excel soạn: SOLVSAMP.XLS kèm theo Office có 19 FONT record và ifnt XF lớn nhất là 19, một workbook 43 record dừng đúng ở 43, và một file lưu bởi Excel 16 với 30 FONT record trỏ các ô Courier New vào ifnt 22, tức record thứ 22. Chẳng file nào trong số đó chứa một giá trị 4. Nếu bạn cần tự phân tích cách map các chỉ mục trong một công cụ diagnostic, phép chuyển đổi chỉ là hai hàm ngắn

// [MS-XLS] 2.5.129 FontIndex: 0..3 tính từ 0, > 4 tính từ 1, 4 không hợp lệ
function FontIndexToRecordNo(Ifnt: Word): Integer;  // FONT record đánh số từ 1
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 không được xuất hiện
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
Ánh xạ FontIndex của HotXLS theo MS-XLS 2.5.129, nơi ifnt 0 tới 3 là vị trí FONT record tính từ 0, ifnt 5 trở lên tính từ 1 và giá trị 4 không bao giờ xuất hiện, cùng phép chuyển FontIndexToRecordNo và bằng chứng từ các workbook do Excel soạn như SOLVSAMP.XLS
FONT record thứ năm là ifnt 5 chứ không phải 4 — một workbook 19 record dừng ở ifnt 19, và chẳng file nào do Excel soạn lưu giá trị cấm nằm giữa

Bên trong HotXLS, cùng luật đó nằm ở hai chỗ soi gương. TXLSFontList.GetSaveIndex nhận vị trí đánh số từ 1 của một font trong danh sách được tham chiếu và chỉ giảm các vị trí 1 tới 4, nên vị trí 5 được ghi thành ifnt 5. TXLSReader.ParseXF làm phép ngược lại khi nạp: ifnt từ 5 trở lên bị giảm về một slot tính từ 0 của danh sách font, còn những gì dưới đó giữ nguyên. Remap rich-run của SST và CountRichRunFontRefs áp cùng phép chuyển ifnt >= 5, và đó mới là điểm mấu chốt: một quy ước, mọi consumer

// TXLSFontList.GetSaveIndex (phía writer)
Result := inherited GetSaveIndex(Index);   // vị trí referred đánh số từ 1
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 thành 0..3, 5+ giữ nguyên

// TXLSReader.ParseXF (phía reader)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 là slot 4 của danh sách font

Vì sao một bản “fix” zero-based làm trôi mọi font tùy biến một bậc?

Bản viết lại theo kiểu zero-based trong HotXLS 2.384.1 làm trôi mọi font tùy biến vì nó đọc một chỉ mục tính từ 1 như thể tính từ 0, rồi sửa bốn call site cho khớp cách đọc sai đó: GetSaveIndex, ParseXF, remap run của SST và migration run giữa các workbook trong Sheets.AddCopy. Round trip của HotXLS vẫn nhìn tưởng ổn, vì writer và reader thống nhất với nhau. Excel thì không. Một file do 2.384.1 ghi đặt font tùy biến đầu tiên ở ifnt 4, mà Excel hiểu là font mặc định, và mọi font tùy biến sau đó lệch một record sớm; mở một file Excel thì đi theo hướng ngược lại, binding mỗi font trễ một record

Regression HotXLS 2.384.1 khi GetSaveIndex và ParseXF đọc các giá trị FontIndex tính từ 1 như tính từ 0, ghi font tùy biến đầu tiên thành ifnt 4 cấm mà Excel phân giải thành font mặc định, và mọi font sau đó rơi một record sớm trong khi round trip vẫn pass
Writer và reader thống nhất với nhau trong cùng một cách đọc sai, nên test save-rồi-mở-lại vẫn xanh trong khi mọi font khi mở bằng Excel đều lệch một slot — khi một quy ước nằm ở bảy chỗ mà bạn sửa bốn chỗ, hãy nghi ngờ thay đổi của mình trước

Dấu hiệu lẽ ra chặn đứng thay đổi đó đã nằm ngay trong cùng codebase. CountRichRunFontRefs, remap FONTX và FBI của chart, và danh sách font của style engine không bao giờ bị đụng tới và vẫn dùng skip-4, nên thư viện tự mâu thuẫn ngay khoảnh khắc 2.384.1 ra đời, và chỉ nhờ trùng hợp là các font rich-text thường cũng được một XF nào đó tham chiếu nên mâu thuẫn được che giấu. Khi một quy ước xuất hiện ở bảy chỗ mà bạn đang sửa bốn chỗ, hãy nghi ngờ thay đổi của mình trước khi nghi ngờ ba chỗ còn lại. Version 2.384.4 khôi phục đánh số theo spec ở cả bốn chỗ, và test regression cũ, thứ từng assert ifnt < FontCount và vì thế mã hóa luôn cách đọc sai, được thay bằng các test map mọi ifnt được ghi ngược về tên FONT record qua công thức của spec. Vẫn còn một giới hạn đáng thành thật: các file được lưu bởi 2.384.1 tới 2.384.3 với năm font trở lên mang các chỉ mục đã trôi mà reader không thể phân biệt với dữ liệu hợp lệ, nên cách chữa duy nhất là sinh lại chúng

Vì sao font run của comment chỉ gãy ở lần save thứ hai?

Comment run và text box run gãy ở lần save thứ hai vì HotXLS giữ N-1 FONT record đầu tiên vô điều kiện và chỉ bỏ record cuối khi không XF nào tham chiếu, trong khi các TXO formatting run ([MS-XLS] §2.4.329) được ghi lại từng byte mà không đánh số lại. Các file .xls do Excel soạn luôn kết thúc bằng một font đuôi không được tham chiếu (một DengXian 9pt trên hệ thống locale Trung Quốc), nên ở lần save đầu, font chỉ được một comment run dùng chưa bao giờ là record cuối, và chẳng gì dịch chuyển nhìn thấy được. Nhưng lần save đầu đã bỏ font đuôi đó và đưa font chỉ-dùng-cho-comment lên vị trí cuối. Lần save thứ hai rồi vứt nó vì coi như không được tham chiếu, ifnt của run trỏ vượt quá cuối, và Excel rơi về font mặc định; nếu workbook giữa chừng có thêm một font mới, run lặng lẽ binding vào font đó, và trong quá trình test điều này biến một text box run có style thành Arial. Các file nhiều comment kiểu như mô tả trong dựng workflow review comment và hyperlink đúng là chỗ cái bẫy này cắn, vì chúng liên tục được mở, chú thích và lưu

Bảng font của HotXLS qua hai lần save, trong đó FONT record đuôi không được tham chiếu mà Excel luôn ghi bị bỏ trước tiên, font chỉ-dùng-cho-comment trở thành cuối rồi bị vứt vì các TXO formatting run được ghi lại mà không đếm tham chiếu, cho tới khi CountRichRunFontRefs sửa bộ lọc sống sót trong 2.384.5
Lần save đầu nhìn có vẻ sạch vì font đuôi nhận lấy mất mát, còn font comment chỉ biến mất ở lần hai — hãy map mỗi ifnt về tên FONT record qua các lần save thay vì tin một test trong bộ nhớ

HotXLS 2.384.5 coi TXO run ngang hàng SST run. CountRichRunFontRefs giờ đi qua mọi TMSOShapeTextBox trên mọi worksheet, chuyển ifnt skip-4 của mỗi run thành slot, và đếm nó như một tham chiếu, nên một font chỉ-dùng-cho-run sống sót qua bộ lọc save. Bảng slot-to-save-index thu được đi vào FontRunRemap của mỗi drawing, và TMSOShapeTextBox.Store viết lại các chỉ mục run trên một bản sao riêng của các byte run thô, để nguyên TxOLastRun vì nó không mang font nào. Với code ứng dụng, hợp đồng rất đơn giản: TXLSComment.TextRuns.FontIndex và TXLSTextBox.TextRuns.FontIndex dùng đánh số của file, bỏ qua 4, đúng như khi đọc; chỉ mục run tính từ 1 và CharIndex là offset ký tự nơi run bắt đầu. Sau một lần save, con số được lưu có thể khác cái bạn đặt, nhưng nó vẫn trỏ đúng font đó

var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  I: Integer;
  Ifnt: Word;
begin
  Book := TXLSWorkbook.Create;
  if Book.Open('review-notes.xls') <> 1 then
    raise Exception.Create('Cannot open review-notes.xls');
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  if Note <> nil then
    for I := 1 to Note.TextRuns.Count do
    begin
      Ifnt := Note.TextRuns.FontIndex[I];   // đánh số của file, bỏ qua 4
      if Ifnt = 4 then
        raise Exception.CreateFmt('Run %d uses invalid ifnt 4', [I]);
      Writeln(Format('run %d at char %d: ifnt %d = FONT record #%d',
        [I, Note.TextRuns.CharIndex[I], Ifnt, FontIndexToRecordNo(Ifnt)]));
    end;
end;

Copy, chèn dòng và migration run giữa các workbook

Từ HotXLS 2.384.6, mọi đường copy của classic engine đều giữ được các comment formatting run, vì Range.Copy, CopyRange, Sheets.AddCopy và các phép dịch ô sau Range.Insert và Range.Delete đều đi qua TXLSRange.CopyCell, mà CopyCell trước đây chỉ copy văn bản comment và tác giả. Một phép shift là một copy cộng một clear, nên chèn một dòng ngay trên một note hai run để lại nó với 0 run và một font. Bản sửa copy từng run và chuyển font của nó qua TXLSWorkbook.MigrateRunFontIndex, thứ chuyển chỉ mục skip-4 thành slot, migrate font theo giá trị vào bảng font đích, rồi chuyển ngược về đánh số của file; migration rich-text SST trong Sheets.AddCopy giờ gọi cùng hàm đó thay vì mang theo bản copy riêng của phép tính. Hai ca biên đi kèm: một paste tại chỗ nơi nguồn và đích là cùng comment không được xóa run của nó trước khi đọc, và Sheets.AddCopy giờ chạy thêm một lượt cho các comment gắn vào ô không có cell record nào được lưu, thứ trước đây bị bỏ qua hoàn toàn. Phía bảng font của copy giữa các workbook theo cùng logic theo-giá-trị như phía formula đã trình bày trong copy giữa các workbook và rebinding formula. Trên engine XLSX, các đường copy đã clone run theo giá trị từ trước; khoảng hổng nằm ngay trong phần comment, nơi reader bỏ qua rFont, strike, u và vertAlign còn writer không bao giờ emit u hay vertAlign, nên các run giờ sống sót save và mở lại một cách đối xứng

Bạn nên test font index trong file BIFF8 thế nào?

Hãy test font index bằng cách save rồi mở lại, lý tưởng là qua nhiều hơn một thế hệ, và bằng cách map mỗi ifnt ngược về một FONT record thay vì assert một miền số. Mọi bug trong câu chuyện này đều pass một test trong bộ nhớ: regression 2.384.1 sống trong một cặp writer và reader khớp nhau, sự trôi của TXO cần hai lần save với một thay đổi bảng font ở giữa, còn các comment run mất trên XLSX chỉ lộ diện sau khi mở lại. Một harness hữu ích mở một sample do Excel soạn, save nó hai lần qua HotXLS, thêm hay bớt một font giữa các lần save, rồi kiểm tra vị trí run cộng với, ở cấp byte, các tên font đứng sau mỗi ifnt. Đừng so sánh các giá trị FontIndex trước và sau một lần save, vì đánh số lại là điều hợp lệ

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // do Excel soạn, C2 có hai run
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  RunCount := Note.TextRuns.Count;
  SecondRunAt := Note.TextRuns.CharIndex[2];

  Book.Sheets[1].Range['C2', 'C2'].Copy(Book.Sheets[1].Range['E5', 'E5']);
  Book.Sheets[1].Range['C1', 'C1'].Insert(xlShiftDown);   // C2 dời xuống C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // mở lại, đừng tin bộ nhớ
  Assert(Book.Open(OutFile) = 1);
  Note := Book.Sheets[1].Range['C3', 'C3'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
  Assert(Note.TextRuns.CharIndex[2] = SecondRunAt);
  Note := Book.Sheets[1].Range['E5', 'E5'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
end;

Nếu bạn đọc và ghi XLS cổ điển từ Delphi hay C++Builder mà không muốn chạy theo xem consumer font nào trong số nhiều consumer của thư viện còn thống nhất với [MS-XLS] §2.5.129, thì đánh số skip-4, đánh số lại run khi save và migration run theo giá trị mô tả trong bài đã nằm sẵn trong HotXLS Delphi spreadsheet component, thứ đọc và ghi XLS và XLSX mà không cần Excel hay OLE automation