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