HotXLS는 먼저 cch와 fHigh flag를 읽은 뒤 encoding에 맞는 reader를 선택해 BIFF8 XLUnicodeString을 decode합니다. fHigh가 1이면 byte count cch * 2인 TXLSBlob.GetWideString을 사용하고 fHigh가 0이면 TXLSBlob.GetString을 사용합니다. 둘을 반대로 짝지으면 record는 empty 또는 half-length string을 내놓을 뿐 exception은 내지 않습니다
이것이 이런 종류의 bug를 비싸게 만듭니다. chart는 열리고 series도 올바르게 그려지며 axis도 맞는데 trendline caption 하나만 비어 있습니다. log에도 exception handler에도 corrupt-file dialog에도 아무것도 없습니다. file은 처음부터 정상이었습니다. reader가 잘못된 byte 수를 요청했고 요청한 그대로 받았을 뿐입니다
BIFF8 string이 empty로 돌아오는 이유
read가 일어나기도 전에 length guard가 payload를 거부했거나 reader가 처음 만난 NUL에서 멈췄기 때문입니다. 두 경로 모두 설계상 조용합니다. HotXLS에서는 보통 record handler의 명시적인 DataLength check이며 encoding별로 계산해야 합니다. 16-bit payload는 SXViewLink body에 8 + cch * 2 byte가 필요하지만 8-bit payload는 8 + cch만 필요합니다. wide-character arithmetic을 8-bit record에 적용하면 짧은 이름이 전부 gate에서 실패합니다. NUL behavior가 두 번째 trap입니다. TXLSBlob.GetString과 TXLSBlob.GetWideString은 decoded result에서 terminator를 scan해 그 지점에서 잘라내며 terminator가 position one에 놓이면 empty string을 반환합니다. 16-bit body를 절반의 byte count로 읽으면 처음 cch div 2 character만 남고 8-bit body를 wide reader로 읽으면 byte pair가 임의의 code point를 만듭니다. 유일하게 loud한 것은 over-long read입니다. request가 blob을 넘으면 TXLSBlob.EnsureReadable이 Blob read exceeds data size를 raise합니다. under-read에는 그런 alarm이 없습니다
GetWideString은 character가 아니라 byte를 세기
TXLSBlob.GetWideString(Index, Count)의 Count는 byte 단위입니다. 내부적으로 PWideChar에 대해 Count div SizeOf(WideChar)를 사용해 SetString하므로 character count를 넘기면 string이 조용히 절반으로 줄어듭니다. 한편 BIFF8 record layout은 string length를 character로 표현합니다. 따라서 모든 16-bit call site는 * 2 conversion을 직접 들고 있어야 하고 모든 8-bit call site는 이를 들고 있지 않아야 합니다. 이 encoding boundary는 다시 text를 write할 때도 나타나며 pipeline이 양방향으로 string을 옮긴다면 Delphi의 Unicode-safe spreadsheet export와 함께 읽을 가치가 있습니다
// 16-bit XLUnicodeStringNoCch: cch 글자, cch * 2 byte
Name := Data.GetWideString(Start, cch * 2); // 올바른 reader
Name := Data.GetWideString(Start, cch); // 텍스트가 절반이지만 오류 없음
// 8-bit XLUnicodeStringNoCch: cch 글자, cch byte
Name := WideString(Data.GetString(Start, cch)); // 올바른 reader
Name := Data.GetWideStringWithZero(Start, cch); // 여전히 wide reader
byte stream을 손으로 순회하는 곳에서는 이 convention이 어디서나 유지됩니다. HotXLS가 long String record ($0207, [MS-XLS] 2.4.268)를 Continue record ($003C)에서 다시 이어 붙일 때 wide branch는 segment length에서 segCh를 계산하고 GetWideString(3, segCh * 2)를 호출합니다. record body가 offset 3에서 시작하고 count가 여전히 byte이기 때문입니다. rich-text reader도 첫 Continue segment의 offset 1에서 같은 일을 합니다. [MS-XLS] 2.5.293은 fHighByte가 1일 때 break가 double-byte character boundary에 놓인다고 보장하므로 partial-character bookkeeping은 필요 없지만 byte arithmetic을 올바르게 하는 일은 여전히 여러분 몫입니다
GetWideStringWithZero가 실제로 하는 일
TXLSBlob.GetWideStringWithZero는 embedded NUL을 유지하는 wide-character reader입니다. WithZero suffix는 character width가 아니라 NUL retention을 표시합니다. 내부에서는 terminator scan만 빼고 GetWideString와 마찬가지로 PWideChar에 Count div SizeOf(WideChar)를 사용한 SetString을 실행합니다. one-byte counterpart는 TXLSBlob.GetStringWithZero이며 AnsiString을 반환합니다. 이름만으로는 어느 쪽인지 드러나지 않고 이 모호함이 codebase에 실제 bug를 만들었습니다. 그럴듯해 보이는 잘못된 해석을 이름 붙여 보겠습니다. GetWideString은 cch * 2가 필요하니 GetWideStringWithZero는 cch를 직접 받는 것이라고 생각하는 것입니다. 실제로 complaint 없이 cch를 받고 WideString도 반환하며 compiler도 만족합니다. 하지만 character의 절반을 잘못된 byte pair로 조립해 반환합니다. 올바른 8-bit path는 일반 cch byte count로 TXLSBlob.GetString을 호출한 뒤 assignment에서 WideString으로 cast하는 것입니다. HotXLS 2.376.0은 두 chart decoder에서 바로 이 misuse를 수정했습니다
SXViewLink와 encoding별 length gate
SXViewLink ($0858, [MS-XLS] 2.4.316)는 두 asymmetry를 하나의 8-byte header에 함께 넣으므로 가장 깔끔한 worked example입니다. layout은 rt(2), unused(2), reserved(2), cch(1), fHigh(1) 뒤에 XLUnicodeStringNoCch body가 옵니다. fHigh = 1이면 cch * 2 byte의 UTF-16이고 fHigh = 0이면 cch byte의 one-byte character이며 cch는 length field가 single byte이므로 255로 제한됩니다. HotXLS는 chart sheet가 PivotTable view에 link될 때 Units 옆 chart globals에 PivotChartBits ($0859, [MS-XLS] 2.4.196)와 함께 이 record를 씁니다. 해당 machinery의 record-level 관점은 Delphi에서 BIFF8 PivotTable record를 쓰는 방법에서 다룹니다
// SXViewLink ([MS-XLS] 2.4.316): rt(2) unused(2) reserved(2) cch(1)
// 그 다음 XLUnicodeStringNoCch - fHigh(1) 뒤에 character가 옵니다
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;
두 branch는 장식이 아닙니다. 예전 version은 두 encoding을 모두 wide-character expression 8 + cch * 2 아래에서 gate했으므로 Excel이 쓴 8-bit view name은 guard에 실패하고 decoder가 empty PivotSourceName을 반환했으며 IsPivotChart는 false로 남았습니다. pivot link이 진단 한 번 없이 model에서 사라졌습니다. 같은 실수는 Trendline decoder ($2050, [MS-XLS] 2.4.328)에도 있었으며 numeric payload 28 byte 뒤 offset 28의 2-byte cch, offset 30의 fHigh, offset 31의 character가 뒤따릅니다. Excel이 8-bit character로 쓴 trendline caption은 empty string으로 decode되었습니다. 둘 다 같은 release에서 수정되었습니다. 그리고 8-bit case는 Excel 2.0부터 4.0 file에 갇힌 legacy curiosity가 아닙니다. current Excel도 모든 character가 one byte에 들어갈 때 여전히 8-bit BIFF8 payload를 씁니다
Delphi에서 새 BIFF8 record를 안전하게 decode하는 방법
field가 standard XLUnicodeString이라면 직접 branch를 만들지 말고 TXLSBlob.GetBiffString을 사용하세요. length field를 읽고 option byte를 읽은 뒤 일치하는 reader로 dispatch하고 body 뒤로 cursor를 이동합니다. 두 Boolean parameter가 주의해서 읽어야 할 부분입니다. is8bit은 character width가 아니라 length field의 width를 설명하고 iswide는 fHigh option byte가 아예 존재하는지를 말합니다. $0600보다 낮은 BIFF version에는 둘 다 없습니다
var
Offset: LongWord;
begin
Offset := 6; // SXViewLink에서 cch byte가 여기서 시작합니다
// is8bit = length field가 1 byte 폭입니다
// iswide = length field 뒤에 fHigh option byte가 옵니다
Name := Data.GetBiffString(Offset, True, True);
// Offset은 이제 string body 다음 첫 byte를 가리킵니다
handler가 잘린 input이나 hostile input에서도 살아남아야 할 때에는 hand-rolled branch도 여전히 가치가 있습니다. GetBiffString은 caller가 제어하는 bounds check 대신 EnsureReadable이 raise하기를 기대하기 때문입니다. 그래서 HotXLS chart decoder는 DataLength를 gate하고 throw하지 않고 아무것도 반환하지 않습니다. malformed third-party workbook의 비용이 document 전체가 아니라 caption 하나가 되도록 하는 선택입니다. trade-off는 의도적이며 encoding별 guard가 정확해야 하는 이유도 같습니다. bad read를 silence로 바꾸는 것이 바로 guard입니다
마지막으로 같은 release에서 어렵게 배운 process 항목이 하나 있습니다. in-memory model이 아니라 save한 뒤 reopen한 workbook을 assert하세요. 2.376.0 batch는 SXEx emitter([MS-XLS] 2.4.282)가 24-byte body라고 선언하면서 22 byte만 써 PivotTable view 뒤의 모든 record, worksheet EOF와 그 뒤에 오는 chart sheet substream까지 misalign하는 문제도 찾아냈습니다. 기존 pivot test가 이를 잡지 못한 이유는 모두 memory에 대해 assert했기 때문입니다. string decoding도 같은 성격을 가지며 실제로 byte count를 exercise하는 test는 file을 통과하는 round trip뿐입니다
Delphi나 C++Builder에서 classic XLS internals를 다루면서 BIFF8 record reader를 직접 유지하고 싶지 않다면 위 encoding rule은 HotXLS Delphi spreadsheet component에 이미 구현되고 regression-tested되어 있습니다. Excel이나 OLE automation 없이 XLS와 XLSX를 읽고 씁니다