Техническая статья

Декодирование BIFF8 XLUnicodeString в Delphi: cch и fHigh

HotXLS декодирует BIFF8 XLUnicodeString, сначала читая cch и флаг fHigh, а затем выбирая reader, соответствующий кодировке: TXLSBlob.GetWideString с числом байт cch * 2, когда fHigh равен 1, и TXLSBlob.GetString, когда fHigh равен 0. Поменяйте их местами — и record выдаст пустую или строку вдвое короче, но никогда не вызовет exception

Именно это делает такой класс ошибок дорогим. Chart открывается, series рисуются правильно, axes на месте, а одна подпись trendline просто пустая. Ничего в log, ничего в exception handler, никакого диалога о повреждённом файле. Файл всё время был исправен: reader запросил неправильное число байт и получил ровно то, что запросил

Почему строка BIFF8 возвращается пустой?

Строка BIFF8 возвращается пустой, потому что length guard отклонил payload ещё до чтения или reader остановился на первом найденном NUL. Оба пути по конструкции работают молча. В HotXLS guard обычно является явной проверкой DataLength в handler записи, и его нужно вычислять отдельно для каждой encoding: 16-битному payload требуется 8 + cch * 2 байт для body SXViewLink, а 8-битному payload — только 8 + cch. Примените wide-character arithmetic к 8-битной записи, и любое короткое имя не пройдёт gate. Поведение NUL — вторая ловушка, потому что TXLSBlob.GetString и TXLSBlob.GetWideString сканируют decoded result на terminator и обрезают его там, возвращая пустую строку, если terminator стоит в позиции один. Если прочитать 16-битный body с половиной числа байт, сохранятся первые cch div 2 символов; если прочитать 8-битный body через wide reader, пары байт образуют произвольные code point. Громко заявляет о себе только over-long read: TXLSBlob.EnsureReadable поднимает Blob read exceeds data size, когда запрос выходит за пределы blob. Для under-reading такого сигнала нет

GetWideString считает байты, а не символы

TXLSBlob.GetWideString(Index, Count) принимает Count в байтах. Внутри он делает SetString над PWideChar с Count div SizeOf(WideChar), поэтому передача количества символов молча вдвое укорачивает строку. В layout BIFF8 длина строки, напротив, выражена в символах. Поэтому каждый 16-битный call site сам должен выполнить преобразование * 2, а каждый 8-битный call site не должен его выполнять. Это та же граница encoding, которая проявляется при обратной записи текста, поэтому полезно прочитать эту статью вместе с материалом о Unicode-safe spreadsheet export в Delphi, если ваш pipeline гоняет строки в обоих направлениях

// 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);    // всё ещё wide reader

Соглашение действует везде, где byte stream обходится вручную. Когда HotXLS собирает длинную String record ($0207, [MS-XLS] 2.4.268) обратно из её Continue records ($003C), wide branch вычисляет segCh по длине сегмента и затем вызывает GetWideString(3, segCh * 2), потому что body record начинается с offset 3, а count по-прежнему задан в байтах. Rich-text reader делает то же с offset 1 в первом Continue segment. [MS-XLS] 2.5.293 гарантирует, что при fHighByte, равном 1, разрыв приходится на границу двухбайтового символа, поэтому bookkeeping частичного символа не нужен, но байтовую арифметику всё равно нужно выполнить правильно

Что на самом деле делает GetWideStringWithZero?

TXLSBlob.GetWideStringWithZero — wide-character reader, сохраняющий встроенные NUL. Суффикс WithZero означает сохранение NUL, а не ширину символа: внутри он выполняет то же SetString над PWideChar с Count div SizeOf(WideChar), что и GetWideString, только без поиска terminator. Однобайтный аналог — TXLSBlob.GetStringWithZero, возвращающий AnsiString. В имени ничего не сказано о том, какой метод какой, и эта неоднозначность уже стоила codebase реальных ошибок. Конкретное неверное чтение стоит назвать, потому что оно выглядит настолько правдоподобно: GetWideString требует cch * 2, значит GetWideStringWithZero должен принимать cch напрямую. Он принимает cch без возражений, возвращает WideString, и compiler доволен. Но он также возвращает половину символов, собранных из неправильных пар байт. Правильный 8-битный путь — TXLSBlob.GetString с обычным числом байт cch, приведённый к WideString в присваивании. HotXLS 2.376.0 исправил именно такое неправильное использование в двух chart decoder

SXViewLink и length gate по encoding

SXViewLink ($0858, [MS-XLS] 2.4.316) — самый наглядный пример, потому что он складывает обе асимметрии в один восьмибайтный header. Layout таков: rt(2), unused(2), reserved(2), cch(1), fHigh(1), затем body XLUnicodeStringNoCch: fHigh = 1 означает cch * 2 байт UTF-16, fHigh = 0 означает cch байт однобайтных символов, а cch ограничен 255, поскольку поле длины занимает один байт. HotXLS записывает record в chart globals перед Units, рядом с PivotChartBits ($0859, [MS-XLS] 2.4.196), когда chart sheet связывается с PivotTable view; record-level сторона этой механики разобрана в статье о записи BIFF8 PivotTable records в Delphi

// 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;

Две ветви не косметика. В ранней версии обе encoding проверялись широким выражением 8 + cch * 2, поэтому записанное Excel 8-битное имя view не проходило guard, и decoder возвращал пустой PivotSourceName с оставшимся false IsPivotChart. Pivot link исчезал из model без единой диагностики. Та же ошибка находилась в Trendline decoder ($2050, [MS-XLS] 2.4.328), где поле имени следует за 28 байтами numeric payload, двухбайтный cch находится на offset 28, fHigh на offset 30, а символы начинаются с offset 31; trendline captions, записанные Excel однобайтными символами, декодировались в пустые строки. Обе ошибки исправили в одном release. И 8-битный случай — не legacy-курьёз, ограниченный файлами Excel 2.0–4.0: современный Excel по-прежнему пишет 8-битные BIFF8 payload всякий раз, когда каждый символ помещается в один байт

Как безопасно декодировать новую BIFF8 record в Delphi?

Если поле действительно является стандартной XLUnicodeString, используйте TXLSBlob.GetBiffString вместо ручной ветки. Он читает поле длины, читает option byte, направляет выполнение в соответствующий reader и продвигает cursor за body. Два Boolean-параметра нужно читать внимательно: is8bit описывает ширину поля длины, а не ширину символов, а iswide сообщает, присутствует ли вообще option byte fHigh. В BIFF versions ниже $0600 нет ни того, ни другого

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

Ручные ветки всё ещё оправданы, когда handler должен переживать обрезанный или враждебный input, потому что GetBiffString полагается на exception от EnsureReadable, а не на bounds check, который контролируете вы. Поэтому chart decoder HotXLS проверяет DataLength и возвращает ничего вместо exception: malformed workbook стороннего производителя должен стоить одной caption, а не всего документа. Компромисс намерен, и именно поэтому encoding-specific guard должен быть правильным: guard превращает bad read в молчание

Последняя деталь процесса была усвоена тем же release и дорогой ценой. Проверяйте сохранённую и заново открытую workbook, а не in-memory model, которую только что построили. Пакет 2.376.0 также обнаружил emitter SXEx ([MS-XLS] 2.4.282), который объявлял body длиной 24 байта и записывал только 22, смещая каждую запись после PivotTable view, включая worksheet EOF и следующий chart sheet substream. Существующие pivot tests это не поймали, потому что все проверяли memory. Для string decoding действует то же правило: только round trip через файл действительно упражняет подсчёт байт

Если вы работаете с внутренностями классического XLS в Delphi или C++Builder и не хотите поддерживать собственный BIFF8 record reader, описанные выше encoding rules уже реализованы и покрыты regression tests в табличном компоненте HotXLS для Delphi, который читает и записывает XLS и XLSX без Excel и любой OLE automation