Artigo Técnico

Delimitando records PivotCache BIFF com o HotXLS em Delphi

O substream PivotCache do BIFF guarda o dataset em cache de uma PivotTable separadamente da view que a exibe, e o HotXLS lê e escreve esse substream inspecionando os corpos dos records em vez de confiar nos números deles. Essa distinção é a história inteira: o mesmo número de record carrega dois layouts de corpo incompatíveis, dependendo de qual writer produziu o arquivo, então o leitor decide o enquadramento pelo primeiro corpo de record que vê

Você encontra essa camada no instante em que uma PivotTable precisa sobreviver a um round trip. Uma view de pivot sem o cache dela é uma casca vazia, e o Excel vai reconstruir o cache a partir do intervalo de origem quando abrir o arquivo, o que funciona bem até o ponto em que o intervalo de origem sumiu, os dados foram colados de uma query, ou a workbook é um fechamento arquivado que não pode mudar quando alguém abrir

Duas estruturas, dois lugares no arquivo

Os dados em cache e a definição do cache vivem em partes diferentes da workbook, e confundi-los é a primeira coisa a acertar. Os records em cache formam um substream próprio, dado em [MS-XLS] §2.1.7.12 como PIVOTCACHE = SXDB SXDBEx *SXFORMULA *FDB *DBB EOF. Note o que está ausente: não há BOF no começo dessa produção

A definição fica nos globals da workbook, como PIVOTCACHEDEFINITION = SXStreamID SXVS [SXSRC] [SXADDLCACHE] (§2.1.7.20.3), posicionada depois dos records de formatação e antes dos records BoundSheet e Country. Então um único cache é descrito em dois lugares separados por centenas de records, e a ligação entre eles é um identificador de stream que precisa bater em três locais ao mesmo tempo

Enquadramento do PivotCache no HotXLS em BIFF8: a PIVOTCACHEDEFINITION com o seu SXStreamID fica nos globals da workbook depois da formatação e antes de BoundSheet, enquanto os records em cache vivem num stream sob o storage _SX_DB_CUR nomeado com hex maiúsculo de quatro dígitos contendo records SXDB, SXDBEx, SXFORMULA, FDB e DBB sem BOF, e o SXStreamID.idStm, o campo idstm do SXDB e o nome do stream precisam bater
Um único cache de pivot é descrito em dois lugares separados por centenas de records, unidos por um identificador de stream que precisa bater nos globals, no cabeçalho do SXDB e no nome do substream ao mesmo tempo

Cada cache pertence a um stream sob _SX_DB_CUR cujo nome é a grafia hexadecimal maiúscula de quatro dígitos do seu identificador. O SXStreamID.idStm, o campo idstm repetido no cabeçalho do SXDB, e esse nome de stream precisam todos casar. Ao alocar um novo identificador, reserve primeiro todo número já lido do arquivo, ou um cache novo pode reivindicar um número que pertence a um cache mais antigo que o leitor ainda não alcançou

Mais um identificador pega as pessoas. O valor iCache numa view de pivot é a posição zero-based do SXStreamID correspondente na sequência global, e não um identificador de cache que você escolhe. Na escrita ele precisa ser mapeado do objeto de cache para a sua posição real de saída, e as views existentes precisam ser renumeradas junto, ou atualizar um cache aponta silenciosamente uma view para outro

var
  Book: TXLSWorkbook;
  Cache: TXLSPivotCache;
  Field: TXLSPivotCacheField;
  V: TXLSPivotCacheValue;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('sales.xls');
    Cache := Book.PivotCaches.Add;
    Cache.SourceRangeSheet := 'Data';
    Cache.SourceFirstRow := 1;  Cache.SourceFirstCol := 1;
    Cache.SourceLastRow := 500; Cache.SourceLastCol := 6;
    Cache.SourceDataType := 1;        // SXVS SHEET, MS-XLS 2.4.317
    Cache.RefreshOnLoad := False;     // confia nos records em cache
    Cache.SaveData := True;

    Field := Cache.AddField('Region', xlpcftString);
    V.ValueType := xlpcftString;
    V.StrValue := 'North';
    Field.FindOrAddItem(V);

    Cache.SetRecordCount(0);          // limpa, e depois dimensiona o grid de records
    Cache.SetRecordCount(500);
    Book.StorePivotCaches;
  finally
    Book.Free;
  end;
end;

O SetRecordCount duplo não é superstição. O RecordCount é uma escrita de propriedade comum que não aloca, e o caminho interno de crescimento só inicializa as linhas recém-adicionadas, então um cache cuja contagem foi definida pelo caminho do cabeçalho pode acabar com um grid de índices de comprimento zero. Escritas em RecordIndices são então descartadas sem erro. Definir a contagem para zero e de volta restabelece o grid, e isso tem de acontecer depois de todo campo ter sido adicionado, porque a largura da linha vem da contagem de campos

Por que um número de record não pode dizer o layout do corpo?

Porque os números de record e os layouts de corpo mudaram em momentos diferentes, então o mapeamento entre eles não é uma função. Um número do conjunto legado só aparece em arquivos de writers mais antigos, o que o torna um sinal confiável numa direção. Outro número é genuinamente ambíguo: aparece tanto em arquivos corretos quanto numa faixa de versões intermediárias que usavam o número novo com o layout de corpo velho

O enquadramento, portanto, tem de ser decidido pelo corpo, e uma vez por substream de cache em vez de por record. O HotXLS trava o dialeto pelo comprimento do primeiro record SXDBB de cada substream. No enquadramento da especificação, um SXDBB guarda exatamente um record de cache, então o comprimento dele é igual a uma largura de linha. No enquadramento empacotado mais antigo, o primeiro record guarda quantas linhas couberem, então para qualquer cache com mais de uma linha ele tem pelo menos duas larguras de linha. A comparação é decisiva sempre que as duas previsões diferem

Trava de enquadramento SXDBB do HotXLS: um número de record carrega dois layouts de corpo incompatíveis, então o leitor compara o comprimento do primeiro record SXDBB com a largura da linha, uma largura de linha trava o dialeto da especificação enquanto duas ou mais larguras travam o enquadramento empacotado legado, empates ficam com a leitura da especificação, e o dialeto trava uma vez por substream de cache, não por record
Números de record não podem decidir o layout do corpo porque os dois mudaram em momentos diferentes, então o HotXLS trava o dialeto uma vez por substream pelo comprimento do primeiro SXDBB e fica com a leitura da especificação no empate

Quando elas não diferem, o leitor fica com a leitura da especificação, pelo princípio de que arquivos escritos pelo Excel superam em número arquivos escritos por um build intermediário. Esse ponto cego é estreito por construção e, quando ocorre, o próprio arquivo continua a ser reproduzido byte a byte. Só os índices tipados expostos aos callers são afetados

A largura do índice vive em outro record

O SXDBB (§2.4.276) carrega um índice por campo de cache cujo flag de valores distintos está ligado, em ordem de campos, e a largura de cada índice é decidida em outro lugar: o record de campo SXFDB correspondente (§2.4.283) declara um flag de itens curtos, e esse flag diz se o índice ocupa dois bytes ou um. Dois records, um contrato implícito, e uma única frase na especificação conectando os dois

Esse acoplamento é exatamente onde uma codificação caseira erra. Um writer anterior do HotXLS empacotava cada campo no número mínimo de bits, completando até a fronteira de byte entre linhas, o que é defensável isoladamente e contradiz diretamente a largura que o mesmo writer tinha acabado de declarar no SXFDB. Um campo com três valores distintos era descrito com um byte de largura num record e ocupava dois bits no outro. O conserto não foi corrigir a aritmética, mas extrair a decisão de largura para uma função que os dois emissores chamam, para que os dois records não possam mais se separar. É a mesma classe de defeito descrita em drift de declaração de comprimento de records BIFF, em que um tamanho declarado e um corpo real se separam

A consequência de não ler esses records de forma alguma vale ser explicitada, porque é fácil subestimá-la. Quando o leitor pulava os índices de records, todo cache carregado de um arquivo reportava índice zero para todo campo de toda linha, o que significa que toda linha apontava para o primeiro valor de cada campo. Não é apenas introspecção reduzida: o caminho de avaliação de pivot e o caminho de preenchimento de cache para célula ambos consomem esse grid. E um teste de round-trip não detecta, porque um cache ainda em replay bruto é escrito de volta a partir dos bytes originais

// Flags de proveniência dizem o que você está segurando e o que pode ser reescrito
if Cache.FromRawBlobs then
begin
  Writeln('stream id        : ', IntToHex(Cache.StreamId, 4));
  Writeln('legacy framing   : ', Cache.RawFramingIsLegacy);
  Writeln('own storage      : ', Cache.RawHasStorageStream);
  Writeln('model complete   : ', Cache.RawModelIsComplete);
  // Reemitir só é lossless quando todo record tem um modelo aqui
  if Cache.CanUpgradeFraming then
    Writeln('safe to rewrite with the current emitters');
end;

Quando reescrever um cache é lossless?

Só quando três condições valem juntas, e o CanUpgradeFraming é a única propriedade que responde à pergunta. O cache precisa estar ainda em replay bruto, o substream precisa estar num dos enquadramentos que esta biblioteca já escreveu incorretamente, e o leitor precisa ter construído um modelo tipado completo de todo record nele. Um cache que o Excel escreveu nunca se qualifica, porque o substream dele carrega records para os quais o HotXLS não tem modelo, e reemitir a partir do modelo os descartaria

O teste de completude é mais estrito do que parece à primeira vista. Um record que o leitor manteve só como bytes opacos marca o modelo como incompleto. Também marca uma contagem declarada de records de fórmula que o emissor não consegue reproduzir, porque reemitir reescreveria uma declaração de vários records de fórmula como uma declaração de nenhum, e um valor no arquivo que não pode ser reproduzido equivale a um record que não pode ser reproduzido

O conservadorismo deliberado atravessa o writer também. Os índices são clamped para a faixa legal em vez de codificados como um sentinela fora de banda, porque a especificação define um índice para a sequência de valores distintos e nada mais, e uma célula vazia é ela própria um valor nessa sequência. Um corpo de record de cache que exceda o teto de records do BIFF não é escrito de forma alguma, o que exigiria milhares de campos de cache e é inalcançável dentro do limite de colunas do BIFF8 de qualquer forma; o fallback é que o Excel atualiza a partir do intervalo de origem, que é comportamento definido, não um arquivo corrompido

Datas carregam a última dependência entre records. A conversão de serial para data depende do sistema de datas da workbook, e o emissor de records não consegue ver a workbook, então a escolha da data base é passada como um parâmetro que tem o sistema 1900 como padrão e é fornecida pelo caminho de save no nível da workbook. Sob o sistema 1900, o número serial é o valor diretamente; o sistema 1904 difere em 1462 dias. O tratamento mais amplo de seriais de data está em seriais de data, o sistema 1904 e formatos numéricos

Se você está trabalhando na camada de view e não na de cache, os records que descrevem o pivot visível estão cobertos em o conjunto de records PivotTable do BIFF8, e o comportamento do lado do cálculo em campos calculados, itens calculados e refresh. As três camadas vêm no componente de planilha HotXLS para Delphi, que é o que torna possível carregar uma workbook legada, inspecionar o que o cache dela realmente contém, e decidir se reescrevê-lo é seguro antes de fazer