技術文章

Delphi 解碼 BIFF8 XLUnicodeString:cch 與 fHigh

HotXLS 解碼 BIFF8 XLUnicodeString 時,會先讀取 cchfHigh 旗標,再選擇與編碼匹配的讀取器:fHigh 為 1 時使用位元組數為 cch * 2TXLSBlob.GetWideStringfHigh 為 0 時使用 TXLSBlob.GetString。把它們配反後,記錄只會產生空字串或半長字串,從來不會擲出例外

這正是這類 bug 代價高昂的原因。圖表可以開啟,序列正確繪製,座標軸也沒有問題,卻有一條趨勢線標題悄悄變成空白。日誌裡沒有任何內容,例外處理器也沒有任何內容,更不會彈出檔案損壞對話方塊。檔案從頭到尾都是好的;讀取器只是要求了錯誤的位元組數,然後準確地取得它要求的內容

BIFF8 字串為什麼會回傳空值

BIFF8 字串回傳空值,可能是長度保護在讀取前拒絕了載荷,也可能是讀取器在遇到第一個 NUL 時停止。兩條路徑從設計上都是靜默的。在 HotXLS 中,保護通常是記錄處理器中的明確 DataLength 檢查,而且必須按編碼計算:16 位元載荷的 SXViewLink 主體需要 8 + cch * 2 個位元組,8 位元載荷只需要 8 + cch。把寬字元算術套用到 8 位元記錄上,會讓每個短名稱都無法通過門檻。NUL 行為是第二個陷阱,因為 TXLSBlob.GetStringTXLSBlob.GetWideString 都會在解碼結果中掃描終止符並在那裡截斷;終止符位於第一個位置時,就會回傳空字串。用一半位元組數讀取 16 位元主體,會保留前 cch div 2 個字元;透過寬讀取器讀取 8 位元主體,則會把位元組對組成任意碼位。只有過長讀取會大聲失敗:要求超過 blob 時,TXLSBlob.EnsureReadable 會擲出 Blob read exceeds data size。少讀沒有這樣的警報

GetWideString 計算的是位元組,而不是字元

TXLSBlob.GetWideString(Index, Count)Count 單位是位元組。內部會對 PWideChar 呼叫 SetString,長度為 Count div SizeOf(WideChar),因此傳入字元數會靜默讓字串減半。另一方面,BIFF8 記錄版面用字元表示字串長度。因此每個 16 位元呼叫點都必須自行攜帶 * 2 轉換,每個 8 位元呼叫點則不能帶它。這是寫回文字時也會遇到的同一編碼邊界;如果你的流程需要雙向搬移字串,值得和Delphi 中安全處理 Unicode 的試算表匯出一起閱讀

// 16 位元 XLUnicodeStringNoCch:cch 個字元,cch * 2 個位元組
Name := Data.GetWideString(Start, cch * 2);        // 正確
Name := Data.GetWideString(Start, cch);            // 文字減半,不報錯

// 8 位元 XLUnicodeStringNoCch:cch 個字元,cch 個位元組
Name := WideString(Data.GetString(Start, cch));    // 正確
Name := Data.GetWideStringWithZero(Start, cch);    // 仍然是寬字元讀取器

只要是手工遍歷位元組串流,這個慣例都成立。HotXLS 將長 String 記錄($0207,[MS-XLS] 2.4.268)從 Continue 記錄($003C)重新拼接時,寬字元分支根據段長度計算 segCh,再呼叫 GetWideString(3, segCh * 2),因為記錄主體從偏移 3 開始,而計數單位仍然是位元組。富文字讀取器在第一個 Continue 區段從偏移 1 開始時也做同樣處理。[MS-XLS] 2.5.293 保證當 fHighByte 為 1 時,斷點會落在雙位元組字元邊界上,因此不需要記錄半個字元,但位元組算術仍然要由你正確完成

GetWideStringWithZero 到底做什麼

TXLSBlob.GetWideStringWithZero 是一個保留嵌入 NUL 的寬字元讀取器。WithZero 後綴標記的是保留 NUL,而不是字元寬度:內部同樣會對 PWideChar 呼叫 SetString,長度為 Count div SizeOf(WideChar),但不會像 GetWideString 那樣掃描終止符。對應的一位元組方法是 TXLSBlob.GetStringWithZero,它回傳 AnsiString。名稱本身沒有說明二者分別是什麼,這種歧義已經在程式庫中造成過真實 bug。這個具體誤讀值得點名,因為它看起來非常合理:GetWideString 需要 cch * 2,所以 GetWideStringWithZero 一定直接接受 cch。它確實會不報錯地接受 cch,也確實回傳 WideString,編譯器完全滿意;但它也會回傳一半的字元,而且是由錯誤位元組對拼出來的字元。正確的 8 位元路徑是使用普通 cch 位元組計數呼叫 TXLSBlob.GetString,在賦值時轉換為 WideString。HotXLS 2.376.0 正是在兩個圖表解碼器中修復了這種誤用

SXViewLink 與按編碼區分的長度門檻

SXViewLink($0858,[MS-XLS] 2.4.316)是最清楚的工作範例,因為它在一個 8 位元組標頭中同時包含了兩種不對稱性。版面是 rt(2)、unused(2)、reserved(2)、cch(1)、fHigh(1),後面跟著 XLUnicodeStringNoCch 主體:fHigh = 1 表示 cch * 2 個 UTF-16 位元組,fHigh = 0 表示 cch 個單位元組字元,cch 的上限是 255,因為長度欄位只有一個位元組。當圖表工作表連結到 PivotTable 檢視時,HotXLS 會在 Units 之前、圖表全域記錄中寫入該記錄,緊鄰 PivotChartBits($0859,[MS-XLS] 2.4.196);在 Delphi 中寫入 BIFF8 PivotTable 記錄的文章介紹了這套記錄層級機制

// SXViewLink([MS-XLS] 2.4.316):rt(2) unused(2) reserved(2) cch(1)
// 然後是 XLUnicodeStringNoCch:fHigh(1) 後跟字元
PivCch := Item.FData.GetByte(6);
if (PivCch > 0) and (Item.FData.GetByte(7) <> 0) and
   (Item.FData.DataLength >= LongWord(8 + PivCch * 2)) then
begin
  Result.PivotSourceName := Item.FData.GetWideString(8, PivCch * 2);
  Result.IsPivotChart := True;
end
else if (PivCch > 0) and (Item.FData.GetByte(7) = 0) and
   (Item.FData.DataLength >= LongWord(8 + PivCch)) then
begin
  Result.PivotSourceName := WideString(Item.FData.GetString(8, PivCch));
  Result.IsPivotChart := True;
end;

兩個分支不是裝飾。早期版本讓兩種編碼都透過寬字元表達式 8 + cch * 2 進行門控,因此 Excel 寫出的 8 位元檢視名稱無法通過保護,解碼器回傳空的 PivotSourceNameIsPivotChart 也保持為 false。Pivot 連結沒有任何診斷就從模型中消失了。完全相同的錯誤還位於 Trendline 解碼器($2050,[MS-XLS] 2.4.328)中;名稱欄位在 28 位元組數值載荷之後,偏移 28 處有 2 位元組 cch,偏移 30 處有 fHigh,字元從偏移 31 開始。Excel 用 8 位元字元寫入的趨勢線標題同樣會解碼為空。兩個問題在同一個版本中修復。而 8 位元情況並不只是存在於Excel 2.0 到 4.0 檔案中的遺留趣聞:只要所有字元都能用一個位元組表示,目前 Excel 仍然會寫出 8 位元 BIFF8 載荷

如何在 Delphi 中安全解碼新的 BIFF8 記錄

欄位確實是標準 XLUnicodeString 時,應使用 TXLSBlob.GetBiffString,而不是手寫分支。它讀取長度欄位和選項位元組,分派到匹配的讀取器,並將游標推進到主體內容之後。兩個 Boolean 參數是需要仔細閱讀的部分:is8bit 描述的是長度欄位的寬度,不是字元寬度;iswide 表示長度欄位後是否存在 fHigh 選項位元組。低於 $0600 的 BIFF 版本兩者都沒有

var
  Offset: LongWord;
begin
  Offset := 6;  // 在 SXViewLink 中 cch 位元組從這裡開始
  // is8bit = 長度欄位寬度為一個位元組
  // iswide = 長度欄位後跟隨 fHigh 選項位元組
  Name := Data.GetBiffString(Offset, True, True);
  // Offset 現在指向字串主體之後的第一個位元組

當處理器必須承受截斷或惡意輸入時,手寫分支仍然有存在價值,因為 GetBiffString 依賴 EnsureReadable 擲出例外,而不是使用你能控制的邊界檢查。這就是 HotXLS 圖表解碼器檢查 DataLength 並回傳空結果而不是擲出例外的原因:格式錯誤的第三方活頁簿應該只犧牲一個標題,而不是犧牲整個文件。這項取捨是有意的,也正是編碼專屬保護必須正確的原因,因為保護負責把錯誤讀取轉化為靜默結果

同一版本還帶來最後一個流程教訓。應當對儲存並重新開啟的活頁簿斷言,而不是對剛建構的記憶體模型斷言。2.376.0 批次還發現了一個 SXEx 發射器([MS-XLS] 2.4.282)宣告主體長度 24 位元組卻只寫了 22 位元組,導致 PivotTable 檢視之後的每筆記錄錯位,包括工作表 EOF 和後面的圖表工作表子串流。現有 Pivot 測試始終沒有發現它,因為全部斷言都針對記憶體。字串解碼具有相同性質:只有透過檔案往返,才能真正測試位元組計數

如果你在 Delphi 或 C++Builder 中處理經典 XLS 內部結構,又不想自行維護 BIFF8 記錄讀取器,上述編碼規則已經在HotXLS Delphi 試算表元件中實作並經過回歸測試,無需 Excel 或任何 OLE 自動化即可讀寫 XLS 和 XLSX