Artigo Técnico

Por que salvar XLS recalcula fórmulas em silêncio no Delphi

O HotXLS, biblioteca Excel nativa para Delphi e C++Builder, salva uma pasta de trabalho .xls BIFF8 clássica com cache primeiro: TXLSWorksheet.WriteFormula pede a TXLSWorkbook.TryGetCachedFormulaValue o valor que o Excel guardou ao lado de cada fórmula e só chama o avaliador quando esse cache está ausente ou invalidado. Uma pasta de trabalho que você abriu e nunca tocou salva os mesmos números de volta, e resultados novos exigem uma chamada explícita de Recalculate em vez de serem efeito colateral escondido do SaveAs

O bug que forçou esse contrato a aparecer era embaraçosamente pequeno. Um arquivo de corpus chamado nested-subtotals.xls guarda um total geral em R2C4 cujo valor em cache é 37. Abra no HotXLS, peça TryGetCachedFormulaValue para a célula, receba 37. Salve sem mudar uma única célula, abra a cópia salva, faça a mesma pergunta, receba 67. Nada na API tinha sido chamado para calcular coisa alguma, e ainda assim um número no arquivo tinha se movido exatamente 30 — e 30 por acaso é a soma dos dois subtotais de grupo, 10 e 20, que ficam dentro do intervalo coberto pelo total geral

Por que salvar um arquivo XLS muda o valor de uma fórmula?

Dois defeitos independentes precisavam se alinhar para aquele 37 virar 67, e corrigir só um deles teria escondido o outro. O primeiro era estrutural: o writer clássico recalculava toda fórmula em todo save. O segundo era uma checagem de tipo que nunca podia ser verdadeira para uma fórmula carregada do disco, o que fazia o avaliador contar células SUBTOTAL aninhadas duas vezes. O arquivo de corpus foi simplesmente a primeira entrada em que uma recalculada na hora do save deu resposta diferente do Excel e alguém comparou as duas. O defeito estrutural é fácil de enunciar: antes da v2.382.3, TXLSWorksheet.WriteFormula e sua irmã de fórmulas compartilhadas WriteFormulaWithTExp obtinham o campo FormulaValue de oito bytes de todo registro Formula chamando TXLSWorkbook.GetFormulaValue, que é o avaliador. O cache que ParseFormula tinha decodificado com cuidado do arquivo de origem na carga nunca era consultado na saída. Na prática, cada save era um recálculo completo com a API de recálculo no nível da pasta de trabalho contornada, então nada que você pudesse configurar na pasta de trabalho o teria impedido. Qualquer ponto em que o avaliador do HotXLS discordasse do Excel, fosse uma função legitimamente não suportada ou um bug simples, virava mudança silenciosa de dados no save

O segundo defeito morava no callback de subtotais aninhados que o avaliador usa. O Excel define toda forma de SUBTOTAL como ignorando células cuja própria fórmula é outro SUBTOTAL, então a calculadora em lxCalc.pas arma FIgnoreSubtotalCells durante a agregação e pergunta à pasta de trabalho, por TXLSWorkbook.GetClassicIsSubtotalCell, se cada célula do intervalo é uma delas. Esse callback buscava o texto da fórmula como Variant e o testava com VarType(f) = varOleStr. O texto volta de GetUnCompiledFormula como String do Delphi, e um String atribuído a um Variant é varUString, nunca varOleStr. O predicado era falso para toda célula em todo arquivo carregado, os subtotais de grupo eram somados ao total geral uma segunda vez, e num save que recalculava tudo, 10 + 20 + 7 virou 67

// HotXLS 2.381 e anteriores: um Variant de fórmula criado a partir de um String
// é varUString, então esta comparação nunca dava certo
Result := (VarType(f) = varOleStr) and
  (SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
   SameText(Copy(f, 1, 10), '=SUBTOTAL('));

// HotXLS 2.382.0: VarIsStr aceita varString, varOleStr e varUString,
// e AGGREGATE é excluído dos subtotais que o contêm, como o Excel faz
if VarIsStr(f) then
  Result := SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
    SameText(Copy(f, 1, 10), '=SUBTOTAL(') or
    SameText(Copy(f, 1, 10), 'AGGREGATE(') or
    SameText(Copy(f, 1, 11), '=AGGREGATE(');

A v2.382.0 entregou a correção de VarIsStr e, já que estava na mesma função, ensinou ao callback que células AGGREGATE também são excluídas dos subtotais que as contêm. Só isso fez a asserção do corpus passar, porque o 37 recalculado agora batia com o 37 carregado. Não tornou a biblioteca honesta: o save continuava recalculando, e o teste só estava verde porque o avaliador por acaso concordava com o Excel naquele arquivo específico. As regras de quais células SUBTOTAL e AGGREGATE pulam, incluindo linhas ocultas, estão no artigo sobre linhas ocultas em SUBTOTAL e AGGREGATE; o que importa aqui é que nenhum avaliador deveria ter voto num arquivo que você não pediu para ele calcular

O que o Excel garante sobre valores em cache no save?

O Excel trata um save como um instantâneo, não como um evento de cálculo. O valor gravado no campo FormulaValue de um registro Formula ([MS-XLS] §2.4.127, layout na §2.5.133) é o que a célula exibe no momento, que em modo de cálculo manual pode estar desatualizado há anos, e o Excel ainda assim o grava fielmente. O recálculo é uma operação separada, com gatilho próprio. O HotXLS agora segue a mesma regra nos saves clássicos: WriteFormula e WriteFormulaWithTExp chamam TryGetCachedFormulaValue primeiro, usam CacheInfo.Value quando o estado é xlfcsLoaded ou xlfcsCalculated, e só caem em GetFormulaValue para xlfcsMissing e xlfcsInvalidated. A metade de leitura desse contrato, incluindo o que cada estado significa e por que um branco ou um False em cache ainda conta como valor, está descrita em ler valores de fórmula em cache do Excel no Delphi sem recálculo

A decisão de cache primeiro que todo save XLS clássico faz no HotXLS: WriteFormula e WriteFormulaWithTExp chamam TryGetCachedFormulaValue, um estado xlfcsLoaded ou xlfcsCalculated grava CacheInfo.Value verbatim, xlfcsMissing ou xlfcsInvalidated cai no avaliador GetFormulaValue, e uma falha do avaliador grava um payload zero com fAlwaysCalc ligado para que o Excel recalcule na abertura
Uma fórmula atribuída na sessão chega sem cache e uma fórmula substituída é invalidada, então as duas ainda são avaliadas na hora do save e uma pasta de trabalho gerada abre com números, enquanto arquivos que você abriu e nunca tocou mantêm os valores que o Excel guardou

O caminho de fallback é mantido de propósito, e não removido. Uma fórmula que você atribuiu nesta sessão por Cells[Row, Col].Formula chega sem cache, e uma fórmula que você substituiu numa célula carregada é marcada como xlfcsInvalidated por _SetCompiledFormula; as duas são avaliadas na hora do save exatamente como antes, então uma pasta de trabalho gerada continua abrindo no Excel com números nela. Quando nem o avaliador consegue produzir um valor, o writer emite um payload zero e liga fAlwaysCalc (bit 0 do grbit da §2.4.127) para que o Excel recalcule a célula na abertura em vez de confiar no placeholder

procedure RoundTripWithoutRecalc(const Source, Target: string);
var
  Book: TXLSWorkbook;
  Before, After: TXLSFormulaCacheInfo;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open(Source);
    // planilha, linha e coluna baseadas em 1: R2C4 na primeira planilha
    if not Book.TryGetCachedFormulaValue(1, 2, 4, Before) then
      raise Exception.Create('R2C4 carries no usable cache');
    Book.SaveAs(Target);        // nenhum avaliador envolvido para células com cache
  finally
    Book.Free;
  end;

  Book := TXLSWorkbook.Create;
  try
    Book.Open(Target);
    Book.TryGetCachedFormulaValue(1, 2, 4, After);
    // Before.Value = After.Value = 37 em nested-subtotals.xls
    // Um save que recalculasse teria gravado 67 aqui
  finally
    Book.Free;
  end;
end;

Onde a raiz de uma fórmula compartilhada do BIFF guarda seu valor em cache?

No próprio registro Formula dela, como qualquer outra célula de fórmula, e é exatamente isso que fez da célula raiz de um grupo compartilhado o único ponto em que o save com cache primeiro ainda perdia. Uma fórmula compartilhada em BIFF8 é guardada como um registro ShrFmla ([MS-XLS] §2.4.260) que vem depois do registro Formula da célula superior esquerda, e toda célula membro, incluindo a raiz, carrega um rgce formado por um único token PtgExp (§2.5.198): o primeiro byte da expressão analisada é $01, seguido da linha e da coluna da célula raiz. As células seguidoras são autossuficientes — o HotXLS lê o FormulaValue de cada uma e resolve a expressão consultando a fórmula compilada da raiz. A célula raiz é diferente, porque quando seu registro Formula é analisado a expressão ainda não existe; ela chega um registro depois

É nesse vão de um registro que o cache se perdeu. TXLSReader.ParseFormula decodifica o valor em cache e, ao ver um PtgExp cujas coordenadas são iguais às da própria célula, lembra a célula em FSharedFormulaRow e FSharedFormulaCol e publica o cache para a célula. Quando o registro ShrFmla ($04BC) chega, ParseSharedFormula compila a expressão e a instala com _SetCompiledFormula, e _SetCompiledFormula faz o que precisa fazer em qualquer mudança de fórmula: limpa FCachedFormulaValue e volta o estado para xlfcsMissing. O 37 carregado da raiz era portanto jogado fora antes que alguém pudesse lê-lo, TryGetCachedFormulaValue reportava a raiz como sem cache, e o writer de cache primeiro obedientemente caía no avaliador justamente para a célula que todo mundo estava olhando. O registro Array (§2.4.4) tem a mesma ordem e tinha o mesmo buraco

A correção na v2.382.3 adiciona um terceiro campo, FSharedFormulaCachedValue, ao lado das coordenadas pendentes da raiz. ParseFormula guarda ali o cache decodificado quando reconhece uma raiz, e tanto ParseSharedFormula quanto ParseArrayFormula o reproduzem por _SetCellCachedFormulaValue logo depois de instalar a expressão compilada, e então zeram o depósito para Unassigned. A variante String do cache não é afetada por nada disso porque seu payload chega num registro String separado e é roteada por coordenadas de célula, não por ordem de registro. Se você trabalha com o lado OOXML do mesmo conceito, o artigo sobre expansão do si em fórmulas compartilhadas do XLSX explica por que o formato de pacote não tem problema equivalente de ordem, mas tem as próprias armadilhas de expansão

Por que a célula raiz de uma fórmula compartilhada do BIFF perdeu seu 37 em cache no HotXLS: o registro Formula carrega um token PtgExp e o cache decodificado, a expressão ShrFmla chega um registro depois, e instalá-la por _SetCompiledFormula zerava o estado para xlfcsMissing até a versão 2.382.3 começar a guardar FSharedFormulaCachedValue e reproduzi-lo por _SetCellCachedFormulaValue
O registro Array tinha o mesmo vão de um registro e ParseArrayFormula reproduz o depósito do mesmo jeito, enquanto a variante String do cache é roteada por coordenadas de célula e nunca dependeu da ordem dos registros

Por que as seguidoras de uma fórmula compartilhada precisam de deslocamento relativo?

Porque a expressão guardada em ShrFmla é escrita em relação à célula raiz, e uma seguidora que a reutiliza verbatim avalia as referências da raiz em vez das próprias. O reader antigo instalava Value.GetCopy() em cada seguidora, uma cópia profunda sem deslocamento, então um grupo com raiz em B1 e =A1*3 dava =A1*3 para toda seguidora também. O save com cache primeiro na verdade mascarava isso nos arquivos carregados, já que as seguidoras tinham o próprio FormulaValue e nunca precisavam da expressão para salvar corretamente; o problema aparecia no momento em que algo recalculasse. O reader agora instala TXLSCompiledFormula.GetCopy(row - srow, col - scol), que percorre a árvore de sintaxe e desloca toda referência relativa pela distância da seguidora até a raiz, então a seguidora em B2 tem um =A2*3 de verdade

Seguidoras de fórmula compartilhada precisam de deslocamento relativo no HotXLS: um grupo com raiz em B1 e =A1*3 sobre as entradas 2, 4 e 6 instalava Value.GetCopy verbatim, então B2 recalculava A1*3 e mostrava 6 onde o Excel mostra 12, enquanto o GetCopy deslocado pelo offset da seguidora faz B2 ter =A2*3 e B3 ter =A3*3
O save com cache primeiro mascarava o bug nos arquivos carregados porque cada seguidora carregava o próprio valor em cache, então só um Recalculate explícito podia revelá-lo, e a regressão semeia os caches errados 999 e 888 que precisam sobreviver a um save

O teste de regressão que fixa os dois comportamentos vale a leitura porque se recusa a deixar uma coincidência passar. Ele monta uma pasta de trabalho com =A1*3 e =A2*3 sobre as entradas 2 e 4, e então injeta os caches deliberadamente errados 999 e 888 por _SetCellCachedFormulaValue, uma vez com UseSharedFormulas ligado e outra desligado. Depois de um save e uma recarga, as duas células ainda precisam reportar 999 e 888 — prova de que o save não tocou nem no cache da raiz nem no da seguidora. Só depois de um Recalculate explícito elas devem virar 6 e 12, prova de que a expressão deslocada da seguidora está correta. Um teste que semeasse os valores verdadeiros teria passado no writer antigo também, e é justamente esse o motivo de semear os errados

var
  Book: TXLSWorkbook;
  Info: TXLSFormulaCacheInfo;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open('quarterly-model.xls');
    Book.Sheets[1].Cells[1, 1].Value := 5;   // muda uma entrada

    // Caches carregados de fórmulas dependentes NÃO são invalidados por uma
    // edição de literal, então um SaveAs comum manteria os números antigos.
    // Peça um recálculo quando você realmente quiser resultados novos:
    Book.Recalculate;

    if Book.TryGetCachedFormulaValue(1, 1, 2, Info) then
      Writeln('B1 now ', VarToStr(Info.Value),
        ', state ordinal ', Ord(Info.State));   // xlfcsCalculated
    Book.SaveAs('quarterly-model-updated.xls');
  finally
    Book.Free;
  end;
end;

O que o contrato de cache primeiro não faz por você

O save com cache primeiro preserva o que foi carregado; ele não acompanha se o que foi carregado ainda é verdade. Mudar um literal do qual uma fórmula depende marca o grafo de dependências como sujo para o avaliador, mas deixa o cache xlfcsLoaded da célula dependente no lugar, e o writer clássico vai gravar esse valor velho sem reclamar, a menos que você chame Recalculate ou leia o Value da célula primeiro, o que a calcula e move o estado para xlfcsCalculated. É o mesmo trade-off que o Excel faz no modo de cálculo manual, e é o certo para um pipeline que abre arquivos de terceiros, edita alguns rótulos e salva — mas significa que uma pasta de trabalho que edita entradas precisa assumir explicitamente o próprio passo de recálculo. A política RecalcBeforeSave do writer de XLSX não muda com esse trabalho e tem o próprio modo manual, que preserva caches no mesmo espírito. Dois limites menores decorrem disso: o caminho de cache primeiro só ajuda células cujo estado é xlfcsLoaded ou xlfcsCalculated; um gerador que escreve fórmulas e nunca as avalia continua pagando uma avaliação por célula na hora do save, exatamente como antes. E a correção de subtotais aninhados corrige quais células o avaliador pula, não toda função que o avaliador implementa — um arquivo cujas fórmulas o HotXLS não consegue calcular de forma idêntica ao Excel agora é seguro para fazer round-trip intocado, mas um Recalculate deliberado nesse arquivo ainda vai produzir a resposta da biblioteca e não a do Excel, e você deveria comparar as duas antes de confiar num save recalculado

Saves clássicos com cache primeiro, os caches restaurados das raízes de fórmulas compartilhadas e de matriz, o deslocamento de referências relativas das seguidoras compartilhadas e as regras corrigidas de aninhamento de SUBTOTAL e AGGREGATE vêm todos no HotXLS Delphi Spreadsheet Component padrão para Delphi e C++Builder, sem dependência do Excel nem de nenhum servidor de automação OLE; a página do produto traz a referência completa da API para os pontos de entrada de pasta de trabalho, leitura de cache e recálculo usados aqui