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

BIFF8 XLUnicodeString в Delphi: cch и fHigh

HotXLS decode-ва 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

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

Защо BIFF8 string се връща празен?

BIFF8 string се връща празен, защото length guard е отхвърлил payload-а, преди read изобщо да се случи, или защото reader-ът е спрял при първия NUL, който е срещнал. И двата path-а са тихи по конструкция. В HotXLS guard-ът обикновено е explicit DataLength check в record handler-а и трябва да се изчислява според encoding-а: 16-bit payload се нуждае от 8 + cch * 2 bytes за SXViewLink body, а 8-bit payload се нуждае от 8 + cch. Приложете wide-character arithmetic към 8-bit record и всеки short name ще fail-не gate-а. NUL behavior е вторият trap, защото TXLSBlob.GetString и TXLSBlob.GetWideString и двата scan-ват 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 стават arbitrary code points. Само over-long read е loud: TXLSBlob.EnsureReadable вдига Blob read exceeds data size, когато request-ът излезе след blob-а. Under-reading няма такъв алармен сигнал

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 трябва сам да носи преобразуването * 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);            // половината текст, без error

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

Convention-ът важи навсякъде, където byte stream се обхожда ръчно. Когато HotXLS stitch-ва long String record ($0207, [MS-XLS] 2.4.268) обратно от Continue records ($003C), wide branch-ът изчислява segCh от segment length и после извиква GetWideString(3, segCh * 2), защото record body започва на 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 NUL-ове. Suffix-ът WithZero означава NUL retention, не character width: вътрешно се изпълнява същият SetString върху PWideChar с Count div SizeOf(WideChar), както при GetWideString, но без terminator scan. One-byte counterpart-ът е TXLSBlob.GetStringWithZero, който връща AnsiString. Никъде в името не е казано кой кой е, а тази ambiguity е струвала реални bug-ове на codebase-а. Конкретното misreading си струва да бъде назовано, защото изглежда толкова правдоподобно: GetWideString needs cch * 2, so GetWideStringWithZero must be the one that takes cch directly. Той действително приема cch без оплакване, връща WideString и compiler-ът е доволен. Връща и половината characters, събрани от грешните byte pairs. Правилният 8-bit path е TXLSBlob.GetString с plain cch byte count, cast-нат до WideString при assignment. HotXLS 2.376.0 поправи точно тази misuse в два chart decoder-а

SXViewLink и per-encoding length gate

SXViewLink ($0858, [MS-XLS] 2.4.316) е най-чистият worked example, защото пакетира и двете asymmetries в един 8-byte header. 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 е capped на 255, защото length field е single byte. HotXLS записва record-а в chart globals преди Units, до PivotChartBits ($0859, [MS-XLS] 2.4.196), когато chart sheet е свързан с PivotTable view; record-level прегледът на тази 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. По-ранна версия gate-ваше и двата encoding-а зад wide-character expression 8 + cch * 2, така че Excel-written 8-bit view name fail-ваше 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 с 2-byte cch на offset 28, fHigh на offset 30 и characters на offset 31; trendline captions, които Excel беше записал с 8-bit characters, се decode-ваха като empty strings. И двете бяха поправени в същия release. А 8-bit case-ът не е legacy curiosity, ограничена до Excel 2.0 до 4.0 files: current Excel все още записва 8-bit BIFF8 payloads, когато всеки character се побира в един byte

Как безопасно да decode-нете нов BIFF8 record в Delphi?

Когато field-ът действително е standard XLUnicodeString, използвайте TXLSBlob.GetBiffString, вместо да hand-roll-вате branch-а. Той чете length field, чете option byte, dispatch-ва към подходящия reader и придвижва cursor-а след body-то. Двата Boolean parameters са частта, която трябва да прочетете внимателно: is8bit описва width-а на length field-а, не width-а на characters, а iswide казва дали изобщо има fHigh option byte. BIFF versions под $0600 нямат нито едното

var
  Offset: LongWord;
begin
  Offset := 6;  // в SXViewLink cch byte започва тук
  // is8bit = length field-ът е широк един 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 разчита на EnsureReadable да вдигне exception, а не на bounds check, който вие контролирате. Затова HotXLS chart decoder-ът gate-ва по DataLength и връща нищо, вместо да throw-не: malformed third-party workbook трябва да ви струва един caption, а не целия document. Tradeoff-ът е съзнателен и точно затова encoding-specific guard-ът трябва да бъде правилен, защото guard-ът е нещото, което превръща лошия read в silence

Още една process детайл, научен по трудния начин в същия release. Assert-вайте върху saved и reopened workbook, а не върху in-memory model-а, който току-що сте построили. Batch-ът 2.376.0 откри и SXEx emitter ([MS-XLS] 2.4.282), който декларираше 24-byte body и записваше само 22, разсинхронизирайки всеки record след PivotTable view, включително worksheet EOF и всеки следващ chart sheet substream. Съществуващите pivot tests не го уловиха, защото всички assert-ваха спрямо memory. String decoding има същото свойство: round trip през file е единственият test, който действително упражнява byte counts

Ако работите с classic XLS internals в Delphi или C++Builder и не искате да поддържате собствен BIFF8 record reader, encoding rules-те по-горе вече са имплементирани и regression-tested в HotXLS Delphi spreadsheet component, който чете и записва XLS и XLSX без Excel или OLE automation