O HotXLS descodifica uma XLUnicodeString BIFF8 lendo primeiro cch e o sinalizador fHigh e escolhendo depois o leitor correspondente à codificação: TXLSBlob.GetWideString com uma contagem de bytes de cch * 2 quando fHigh é 1 e TXLSBlob.GetString quando fHigh é 0. Emparelhe-os ao contrário e o registo devolve uma string vazia ou com metade do comprimento, nunca uma exceção
É isso que torna esta classe de bug dispendiosa. Um gráfico abre, as séries são desenhadas corretamente, os eixos estão certos e uma legenda de trendline fica simplesmente vazia. Nada no log, nada no handler de exceções, nenhuma caixa de diálogo de ficheiro corrompido. O ficheiro esteve correto o tempo todo; o reader pediu o número errado de bytes e recebeu exatamente o que pediu
Porque é que uma string BIFF8 volta vazia?
Uma string BIFF8 volta vazia porque uma guarda de comprimento rejeitou o payload antes de a leitura sequer acontecer ou porque o reader parou no primeiro NUL que encontrou. Ambos os percursos são silenciosos por construção. No HotXLS, a guarda é normalmente uma verificação explícita de DataLength no handler do registo e tem de ser calculada por codificação: um payload de 16 bits precisa de 8 + cch * 2 bytes para um corpo SXViewLink, mas um payload de 8 bits precisa apenas de 8 + cch. Aplique a aritmética de carateres largos a um registo de 8 bits e todos os nomes curtos falham a guarda. O comportamento NUL é a segunda armadilha, porque TXLSBlob.GetString e TXLSBlob.GetWideString procuram ambos um terminador no resultado descodificado e truncam aí, devolvendo uma string vazia quando o terminador fica na posição um. Leia um corpo de 16 bits com metade da contagem de bytes e conserva os primeiros cch div 2 carateres; leia um corpo de 8 bits através do reader wide e os pares de bytes formam pontos de código arbitrários. Só uma leitura longa é ruidosa: TXLSBlob.EnsureReadable levanta Blob read exceeds data size quando o pedido ultrapassa o tamanho do blob. Uma leitura curta não tem esse alarme
GetWideString conta bytes, não carateres
TXLSBlob.GetWideString(Index, Count) recebe Count em bytes. Internamente faz um SetString sobre um PWideChar com Count div SizeOf(WideChar), pelo que passar uma contagem de carateres reduz silenciosamente a string para metade. Entretanto, os layouts dos registos BIFF8 exprimem o comprimento das strings em carateres. Cada local de chamada de 16 bits tem, portanto, de transportar a conversão * 2 por si próprio e cada local de chamada de 8 bits não a pode transportar. É a mesma fronteira de codificação que surge quando se escreve texto novamente, em vez de o ler, e vale a pena ler isto em conjunto com a exportação de folhas de cálculo Unicode-safe em Delphi se o seu pipeline mover strings nas duas direções
// XLUnicodeStringNoCch de 16 bits: cch carateres, cch * 2 bytes
Name := Data.GetWideString(Start, cch * 2); // correto
Name := Data.GetWideString(Start, cch); // metade do texto, sem erro
// XLUnicodeStringNoCch de 8 bits: cch carateres, cch bytes
Name := WideString(Data.GetString(Start, cch)); // correto
Name := Data.GetWideStringWithZero(Start, cch); // continua a ser um reader wide
A convenção mantém-se em todo o lado onde o stream de bytes é percorrido à mão. Quando o HotXLS recompõe um registo String longo ($0207, [MS-XLS] 2.4.268) a partir dos seus registos Continue ($003C), o ramo wide calcula segCh a partir do comprimento do segmento e chama depois GetWideString(3, segCh * 2), porque o corpo do registo começa no offset 3 e a contagem continua a ser em bytes. O reader de rich text faz a mesma coisa a partir do offset 1 no primeiro segmento Continue. [MS-XLS] 2.5.293 garante que a quebra fica numa fronteira de carateres de dois bytes quando fHighByte é 1, pelo que não é necessária contabilidade de carateres parciais, mas a aritmética de bytes continua a ser sua responsabilidade
O que faz realmente GetWideStringWithZero?
TXLSBlob.GetWideStringWithZero é um reader de carateres largos que conserva NULs incorporados. O sufixo WithZero assinala a retenção de NUL, não a largura dos carateres: internamente executa o mesmo SetString sobre um PWideChar com Count div SizeOf(WideChar) que GetWideString, menos a pesquisa do terminador. O equivalente de um byte é TXLSBlob.GetStringWithZero, que devolve uma AnsiString. Nada no nome diz qual é qual e essa ambiguidade já custou bugs reais a este codebase. Vale a pena nomear a leitura errada específica porque parece muito plausível: GetWideString precisa de cch * 2, portanto GetWideStringWithZero deve ser o que recebe cch diretamente. Recebe de facto cch sem queixar-se, devolve de facto uma WideString e o compilador fica satisfeito. Também devolve metade dos carateres, montados a partir dos pares de bytes errados. O percurso correto de 8 bits é TXLSBlob.GetString com uma contagem simples de bytes cch, convertido para WideString no momento da atribuição. O HotXLS 2.376.0 corrigiu exatamente esse uso indevido em dois decoders de gráficos
SXViewLink e a guarda de comprimento por codificação
SXViewLink ($0858, [MS-XLS] 2.4.316) é o exemplo trabalhado mais claro, porque reúne as duas assimetrias num cabeçalho de oito bytes. O layout é rt(2), unused(2), reserved(2), cch(1), fHigh(1), seguido de um corpo XLUnicodeStringNoCch: fHigh = 1 significa cch * 2 bytes de UTF-16, fHigh = 0 significa cch bytes de carateres de um byte e cch está limitado a 255 porque o campo de comprimento tem um único byte. O HotXLS escreve o registo nos chart globals antes de Units, ao lado de PivotChartBits ($0859, [MS-XLS] 2.4.196), quando uma chart sheet liga a uma vista PivotTable; a visão ao nível dos registos dessa maquinaria está coberta em escrever registos PivotTable BIFF8 em Delphi
// SXViewLink ([MS-XLS] 2.4.316): rt(2) unused(2) reserved(2) cch(1)
// depois uma XLUnicodeStringNoCch - fHigh(1) seguido dos carateres
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;
Os dois ramos não são cosméticos. Uma versão anterior aplicava a ambos os formatos a guarda de expressão de carateres largos 8 + cch * 2, pelo que um nome de vista de 8 bits escrito pelo Excel falhava a guarda e o decoder devolvia um PivotSourceName vazio com IsPivotChart ainda a false. A ligação pivot desaparecia do modelo sem um único diagnóstico. O erro idêntico estava no decoder de Trendline ($2050, [MS-XLS] 2.4.328), onde o campo de nome segue 28 bytes de payload numérico com um cch de dois bytes no offset 28, fHigh no offset 30 e os carateres no offset 31; as legendas de trendline que o Excel tinha escrito com carateres de 8 bits eram descodificadas como strings vazias. Ambos foram corrigidos na mesma release. E o caso de 8 bits não é uma curiosidade antiga confinada a ficheiros Excel 2.0 a 4.0: o Excel atual continua a escrever payloads BIFF8 de 8 bits sempre que todos os carateres cabem num byte
Como descodificar com segurança um novo registo BIFF8 em Delphi?
Quando o campo é realmente uma XLUnicodeString normalizada, use TXLSBlob.GetBiffString em vez de escrever o ramo à mão. Ele lê o campo de comprimento, lê o byte de opções, encaminha para o reader correspondente e avança o cursor para além do corpo. Os dois parâmetros Boolean são a parte que tem de ler com atenção: is8bit descreve a largura do campo de comprimento, não a largura dos carateres, e iswide diz se existe de todo um byte de opções fHigh. As versões BIFF abaixo de $0600 não têm nenhum dos dois
var
Offset: LongWord;
begin
Offset := 6; // em SXViewLink, o byte cch começa aqui
// is8bit = o campo de comprimento tem a largura de um byte
// iswide = um byte de opções fHigh segue o campo de comprimento
Name := Data.GetBiffString(Offset, True, True);
// Offset aponta agora para o primeiro byte depois do corpo da string
Os ramos escritos à mão continuam a justificar-se quando o handler tem de sobreviver a entrada truncada ou hostil, porque GetBiffString se apoia em EnsureReadable para levantar uma exceção, em vez de usar um bounds check que possa controlar. É por isso que o decoder de gráficos HotXLS verifica DataLength e não devolve nada em vez de lançar uma exceção: um workbook de terceiros malformado deve custar-lhe uma legenda, não o documento inteiro. O compromisso é deliberado e é precisamente por isso que a guarda específica da codificação tem de estar correta, uma vez que é a guarda que transforma uma leitura errada em silêncio
Uma última parte do processo, aprendida da forma difícil na mesma release. Faça asserções sobre um workbook guardado e reaberto, não sobre o modelo em memória que acabou de construir. O batch 2.376.0 também revelou um emitter SXEx ([MS-XLS] 2.4.282) que declarava um corpo de 24 bytes e escrevia apenas 22, desalinhando todos os registos depois da vista PivotTable, incluindo o EOF da worksheet e qualquer substream de chart sheet que se seguisse. Os testes pivot existentes nunca o detetaram porque todos faziam asserções sobre memória. A descodificação de strings tem a mesma propriedade: um round-trip através do ficheiro é o único teste que exercita realmente as contagens de bytes
Se trabalha com internals XLS clássicos em Delphi ou C++Builder e prefere não manter um reader de registos BIFF8 próprio, as regras de codificação acima já estão implementadas e cobertas por regressão no componente de folhas de cálculo HotXLS para Delphi, que lê e escreve XLS e XLSX sem Excel nem qualquer automação OLE