Технічна стаття

Декодування BIFF8 XLUnicodeString у Delphi: cch і fHigh

HotXLS декодує BIFF8 XLUnicodeString, спочатку читаючи cch і flag fHigh, а потім вибираючи reader, який відповідає encoding: TXLSBlob.GetWideString із byte count cch * 2, коли fHigh дорівнює 1, і TXLSBlob.GetString, коли fHigh дорівнює 0. Поміняйте їх місцями — і record дасть empty або half-length string, але ніколи exception

Саме це робить такий class bug дорогим. Chart відкривається, series малюються правильно, axes правильні, а один trendline caption просто порожній. Нічого в log, нічого в exception handler, жодного corrupt-file dialog. File весь час був справний; reader запросив неправильну кількість bytes і отримав рівно те, що запросив

Чому BIFF8 string повертається порожнім?

BIFF8 string повертається empty, бо length guard відхилив payload ще до read або reader зупинився на першому NUL, який знайшов. Обидва paths за конструкцією silent. У HotXLS guard зазвичай є explicit DataLength check у record handler, і його треба рахувати per encoding: 16-bit payload потребує 8 + cch * 2 bytes для SXViewLink body, а 8-bit payload — лише 8 + cch. Застосуйте wide-character arithmetic до 8-bit record — і кожен короткий name не пройде gate. NUL behavior — друга trap, бо TXLSBlob.GetString та TXLSBlob.GetWideString обидва сканують decoded result на terminator і truncate-ять там, повертаючи empty string, коли terminator стоїть на position one. Прочитайте 16-bit body із half byte count — і збережете перші cch div 2 characters; прочитайте 8-bit body через wide reader — і byte pairs утворять довільні code points. Голосною є лише over-long read: TXLSBlob.EnsureReadable піднімає Blob read exceeds data size, коли запит виходить за межі blob. Under-reading такого alarm не має

GetWideString рахує bytes, а не characters

TXLSBlob.GetWideString(Index, Count) приймає Count у bytes. Усередині він виконує SetString над PWideChar із Count div SizeOf(WideChar), тому передавання character count тихо вдвічі скорочує string. BIFF8 record layouts, тим часом, задають string length у characters. Кожен 16-bit call site тому має сам нести conversion * 2, а кожен 8-bit call site — не нести його. Це та сама encoding boundary, яка проявляється під час повторного запису text, а не лише читання, і про неї варто прочитати разом із Unicode-safe spreadsheet export у Delphi, якщо ваш pipeline ганяє strings в обидва боки

// 16-bit XLUnicodeStringNoCch: cch символів, cch * 2 bytes
Name := Data.GetWideString(Start, cch * 2);        // правильно
Name := Data.GetWideString(Start, cch);            // половина text, без error

// 8-bit XLUnicodeStringNoCch: cch символів, cch bytes
Name := WideString(Data.GetString(Start, cch));    // правильно
Name := Data.GetWideStringWithZero(Start, cch);    // усе ще wide reader

Convention діє всюди, де byte stream проходиться вручну. Коли HotXLS зшиває long String record ($0207, [MS-XLS] 2.4.268) назад із його Continue records ($003C), wide branch обчислює segCh із segment length, а потім викликає GetWideString(3, segCh * 2), бо body record починається на offset 3, а count усе ще у bytes. Rich-text reader робить те саме з offset 1 на першому Continue segment. [MS-XLS] 2.5.293 гарантує, що break лежить на double-byte character boundary, коли fHighByte дорівнює 1, тому partial-character bookkeeping не потрібен, але byte arithmetic вам усе одно треба зробити правильно

Що насправді робить GetWideStringWithZero?

TXLSBlob.GetWideStringWithZero — wide-character reader, який зберігає embedded NULs. Suffix WithZero позначає NUL retention, а не character width: усередині він виконує те саме SetString над PWideChar із Count div SizeOf(WideChar), що й GetWideString, але без terminator scan. One-byte counterpart — TXLSBlob.GetStringWithZero, який повертає AnsiString. З імені не видно, який із них який, і ця ambiguity уже коштувала codebase реальних bugs. Варто назвати конкретне неправильне читання, бо воно виглядає правдоподібно: GetWideString потребує cch * 2, отже GetWideStringWithZero, мабуть, той, хто приймає cch напряму. Він справді приймає cch без complaint, справді повертає WideString, і compiler задоволений. Але також повертає половину characters, зібраних із неправильних byte pairs. Правильний 8-bit path — TXLSBlob.GetString зі звичайним cch byte count, cast у WideString під час assignment. HotXLS 2.376.0 виправив саме таке misuse у двох chart decoders

SXViewLink і per-encoding length gate

SXViewLink ($0858, [MS-XLS] 2.4.316) — найчистіший worked example, бо в одному eight-byte header поєднує обидві asymmetries. Layout: rt(2), unused(2), reserved(2), cch(1), fHigh(1), потім XLUnicodeStringNoCch body: fHigh = 1 означає cch * 2 bytes UTF-16, fHigh = 0 — cch bytes one-byte characters, а cch обмежений 255, бо length field — один byte. HotXLS записує record у chart globals перед Units, поруч із PivotChartBits ($0859, [MS-XLS] 2.4.196), коли chart sheet посилається на PivotTable view; record-level view цієї machinery описаний у записі BIFF8 PivotTable records у Delphi

// SXViewLink ([MS-XLS] 2.4.316): rt(2) unused(2) reserved(2) cch(1)
// потім XLUnicodeStringNoCch — fHigh(1), за ним characters
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;

Дві branches не cosmetic. Попередня version gated обидва encodings через wide-character expression 8 + cch * 2, тому Excel-written 8-bit view name не проходив guard, і decoder повертав empty PivotSourceName із IsPivotChart, залишеним false. Pivot link зникав із model без жодного diagnostic. Та сама mistake сиділа в Trendline decoder ($2050, [MS-XLS] 2.4.328), де name field іде після 28 bytes numeric payload, із two-byte cch на offset 28, fHigh на offset 30 і characters на offset 31; trendline captions, записані Excel з 8-bit characters, декодувалися як empty strings. Обидва fixes увійшли до того самого release. І 8-bit case — не legacy curiosity, обмежена files Excel 2.0–4.0: current Excel досі записує 8-bit BIFF8 payloads, коли кожен character вміщується в один byte

Як безпечно декодувати новий BIFF8 record у Delphi?

Якщо field справді є standard XLUnicodeString, використовуйте TXLSBlob.GetBiffString замість hand-rolled branch. Він читає length field, читає option byte, dispatch-ить до відповідного reader і пересуває cursor за body. Два Boolean parameters — частина, яку треба читати уважно: is8bit описує width length field, а не width characters, а iswide каже, чи взагалі є option byte fHigh. BIFF versions нижче $0600 не мають ні того, ні іншого

var
  Offset: LongWord;
begin
  Offset := 6;  // у SXViewLink byte cch починається тут
  // is8bit = length field має width one byte
  // iswide = за length field іде fHigh option byte
  Name := Data.GetBiffString(Offset, True, True);
  // Offset тепер вказує на перший byte після string body

Hand-rolled branches все ще доречні, коли handler має переживати truncated або hostile input, бо GetBiffString покладається на exception від EnsureReadable, а не на bounds check, який контролюєте ви. Ось чому HotXLS chart decoder перевіряє DataLength і повертає nothing замість throw: malformed third-party workbook має коштувати одного caption, а не всього document. Trade-off навмисний, і саме тому encoding-specific guard має бути правильним, бо guard перетворює bad read на silence

Остання process detail, вивчена hard way у тому самому release. Робіть assertions на saved і reopened workbook, а не на in-memory model, який щойно побудували. Batch 2.376.0 також виявив SXEx emitter ([MS-XLS] 2.4.282), який оголошував 24-byte body, а записував лише 22, misalign-ив кожен record після PivotTable view, включно з worksheet EOF і будь-яким chart sheet substream далі. Existing pivot tests цього не зловили, бо всі assert-или memory. String decoding має ту саму властивість: лише round trip через file справді exercise-ить byte counts

Якщо ви працюєте з classic XLS internals у Delphi або C++Builder і не хочете підтримувати власний BIFF8 record reader, encoding rules вище вже реалізовані й regression-tested у HotXLS Delphi spreadsheet component, який читає й записує XLS та XLSX без Excel або будь-якої OLE automation