Artigo Técnico

Fingerprint e offsets de âncora do HotXLS no Delphi

O HotXLS Delphi Component reproduz um gráfico do Excel sem modificações byte a byte só quando duas coisas valem: o gráfico foi alcançado pelo relacionamento de drawing da worksheet, e não por um nome de part adivinhado, e o fingerprint de 64 bits do modelo foi capturado depois que o modelo do gráfico terminou de ser analisado. A versão 2.382.0 corrigiu a primeira condição, a 2.382.3 corrigiu a segunda e passou a fazer round-trip dos offsets de âncora xdr:colOff e xdr:rowOff não zerados que o writer de drawing vinha gravando como zero fixo. Os dois defeitos saíram de um único caso do corpus local, two-charts.xlsx: primeiro uma asserção estrutural viu duas parts de gráfico virarem três, depois uma comparação byte a byte de cada xl/charts/chartN.xml mostrou gráficos que ninguém tinha tocado ainda sendo reescritos — e nenhum dos dois problemas levantava exceção nem fazia o Excel reclamar, e é por isso que sobreviveram tanto tempo

Por que uma pasta de trabalho com dois gráficos voltou com três parts de gráfico?

Porque o loader tinha um fallback que adivinhava. Quando uma worksheet não tinha relacionamento de drawing na sua part .rels, o código antigo assumia que o drawing morava no nome convencional xl/drawings/drawing{i+1}.xml, onde i é a posição da planilha, e anexava essa part se ela existisse no arquivo. Em two-charts.xlsx, a primeira planilha não tem drawing nem part .rels nenhuma, enquanto xl/drawings/drawing1.xml existe — pertence à segunda planilha, que chega nele por Target="../drawings/drawing1.xml". A planilha 1 herdou então um gráfico que nunca referenciou, chart1.xml foi analisado duas vezes, e o save gravou a pasta de trabalho com três parts de gráfico em vez de duas

Como o HotXLS resolve drawings de worksheet na amostra two-charts: a Sheet1 não carrega relacionamento de drawing nem part rels, enquanto a Sheet2 alcança xl/drawings/drawing1.xml por ParPartTargets, e o fallback anterior à 2.382.0 adivinhava aquele nome convencional a partir da posição da planilha, então chart1.xml era analisado duas vezes e os saves gravavam três parts de gráfico até a correção carregar drawings só por XlsxRtDrawing
A Sheet1 nunca referenciou um gráfico, então o grafo de relacionamentos é a única fonte segura para o alvo do drawing, e um nome convencional adivinhado transformou uma pasta de trabalho de dois gráficos num save de três parts

A correção no HotXLS v2.382.0 removeu o palpite por completo. Um drawing de worksheet agora só é carregado por ParPartTargets[i].Values[XlsxRtDrawing], o alvo registrado para o tipo de relacionamento de drawing naquela planilha, e uma planilha sem esse relacionamento não ganha drawing nenhum. É o comportamento que o formato exige: o elemento <drawing r:id="…"/> na worksheet (ECMA-376 Part 1 §18.3.1.36) é o único elo entre uma planilha e o drawing dela, e nomes de part num pacote OPC não carregam significado nenhum além do que o grafo de relacionamentos lhes atribui. Arquivos escritos pelo Excel por acaso usam os nomes convencionais, e é isso que deixou o atalho passar por tanto tempo; o passo a passo sobre resolução de relacionamentos OPC no HotXLS mostra por que adivinhar um nome de part nunca é seguro, mesmo quando o palpite costuma acertar

// Antes da v2.382.0: um relacionamento de drawing ausente caía num palpite
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // pode pertencer a outra planilha

// Desde a v2.382.0: relacionamento ou nada
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

O que o fingerprint do gráfico garante?

O fingerprint decide, gráfico a gráfico, se o save pode copiar a part original ou precisa regenerá-la. Na importação, com PreserveUnsupportedParts habilitado antes do Open, o HotXLS guarda os bytes UTF-8 crus de cada part de gráfico em FRawChartXml, monta a serialização do próprio modelo tipado com BuildChartKnownXml, e registra o comprimento dessa serialização em FRawChartModelLength e o hash dela em FRawChartModelHash. O hash é FNV-1a sobre as code units UTF-16 do XML gerado, com a base de offset padrão de 64 bits 14695981039346656037 e o primo 1099511628211. Na hora do save, XlsxChartRawModelUnchanged reconstrói o XML conhecido e compara comprimento e hash; bater significa que o modelo tipado é exatamente o que era na importação, então nada do que a aplicação poderia ter mudado mudou

O HotXLS captura o fingerprint do gráfico na importação, guardando os bytes UTF-8 crus em FRawChartXml enquanto BuildChartKnownXml produz FRawChartModelLength e um hash FNV-1a, e na hora do save XlsxChartRawModelUnchanged reconstrói e compara os dois valores, de modo que um casamento reproduz os bytes originais ou copia a entrada comprimida e uma divergência cai em XlsxMergeChartXml
O fingerprint só vale tanto quanto o momento em que é tirado, e capturá-lo antes que cada passo de recuperação tenha terminado garante um hash que nunca mais vai bater com o modelo completo
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
  const KnownXml: WideString): Boolean;
begin
  Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
    (Length(KnownXml) = Chart.FRawChartModelLength) and
    (XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;

function BuildChartXmlFromKnown(Chart: TXLSXChart;
  const KnownXml: WideString): WideString;
begin
  if Chart.FRawChartXml = '' then
    Result := KnownXml                                  // nada preservado
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // replay verbatim
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // merge estrutural
end;

O writer de XLSX vai um passo além do BuildChartXmlFromKnown. Quando o modelo está inalterado e o StrictOOXML está desligado, ele primeiro tenta copiar a entrada comprimida direto do arquivo de origem para a saída sob o novo nome de part do gráfico, então os bytes nem são decodificados e recomprimidos. Só quando essa cópia não é possível é que ele cai no caminho de decodificar-ou-mesclar. O mecanismo em si — comprimento mais hash, replay quando bate, merge quando não — é o descrito na nota sobre editar gráficos do Excel sem perder o ChartML. Este artigo é sobre o jeito como ele parou de funcionar em silêncio

Por que todo gráfico acabava no caminho de merge mesmo assim?

Porque o fingerprint era capturado uma chamada cedo demais. A análise de gráficos no HotXLS é uma passagem SAX pela part do gráfico seguida de um conjunto de passagens de recuperação que extraem do texto cru detalhes que os handlers SAX não modelam diretamente: XlsxChartParseSeriesFlags lê cada bloco <c:ser> atrás da sua flag <c:smooth> e dos valores srgbClr do preenchimento e da linha do marcador, e depois recupera modos de cruzamento de eixo e estilos de marca de escala maior e menor para os eixos de categoria e de valor. Antes da v2.382.3 a ordem no fim de ParseChartXml era: classificar os grupos de eixo, montar o XML conhecido, capturar comprimento e hash, e só então rodar XlsxChartParseSeriesFlags. O fingerprint descrevia então um modelo que ainda não tinha as flags smooth, as cores de marcador e as marcas de escala. Na hora do save, BuildChartKnownXml rodava contra o modelo completo, que agora emitia <c:smooth val="1"/> e as cores de marcador recuperadas. XML mais longo, hash diferente, XlsxChartRawModelUnchanged devolvia False, e o gráfico ia pelo XlsxMergeChartXml. O merge é uma operação correta para um gráfico que alguém editou, mas não é uma que preserva bytes: ele reserializa a árvore, e a regra de posse que deixa o modelo tipado vencer para séries, eixos e grupos de plotagem faz com que os nós regenerados substituam os originais. O resultado visível na rodada do corpus foram cores de série deslocadas em gráficos que ninguém tinha editado — todo gráfico em toda pasta de trabalho preservada, em todo save, sem nenhum diagnóstico em lugar nenhum

O reparo é uma única reordenação: XlsxChartParseSeriesFlags agora roda antes de o XML conhecido ser montado, então o fingerprint descreve o modelo como ele vai existir quando a aplicação o vir pela primeira vez. A lição vale além de gráficos. Um fingerprint de detecção de mudança só vale tanto quanto o momento em que é tirado, e o momento seguro é depois que toda passagem capaz de alterar o modelo terminou. O HotXLS tem um segundo ponto de captura para os mesmos dois valores, a linha de base que ele restabelece contra o arquivo de saída depois de um save bem-sucedido, e esse ponto sempre rodou contra um modelo totalmente analisado; o ponto da importação era a exceção

Para onde foram os offsets de âncora?

Para dentro de um zero literal. Um twoCellAnchor na part de drawing prende um gráfico entre duas células, e cada canto carrega um índice de célula mais um offset dentro dessa célula: from (ECMA-376 Part 1 §20.5.2.5) e to (§20.5.2.32) guardam cada um col, colOff (§20.5.2.4), row e rowOff. Os offsets estão em English Metric Units, 914400 por polegada, e o Excel grava valores não zerados sempre que um gráfico foi posicionado ou redimensionado com o mouse, o que vale para a maioria deles. O primeiro gráfico em two-charts.xlsx começa na linha 0 com um rowOff de 19049 e termina na coluna 8, linha 15, com um colOff de 247650 e um rowOff de 66674 — cerca de um quarto de polegada dentro da última coluna. O parser de drawing do HotXLS sempre leu esses quatro valores — o código de imagem os usava — mas o writer de gráficos emitia <xdr:colOff>0</xdr:colOff> e <xdr:rowOff>0</xdr:rowOff> para cada canto, encaixando cada gráfico na grade de células no save

Anatomia dos cantos xdr:twoCellAnchor do primeiro gráfico da amostra HotXLS: from guarda col 0 e rowOff 19049, enquanto to guarda col 8, colOff 247650 e rowOff 66674 em EMU de 914400 por polegada, e o writer que emitia offsets zerados encaixava os gráficos na grade até FFromColOff, FToColOff e seus irmãos reproduzirem os valores importados
A âncora mora na part de drawing e não na part do gráfico, então este reparo é independente da correção do fingerprint, e os dois tiveram que sair antes de a pasta de trabalho realmente fazer round-trip
// Desde a v2.382.3 o writer de âncora reproduz os offsets EMU importados
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
  IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
  '<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...

TXLSXChart agora carrega FFromColOff, FFromRowOff, FToColOff e FToRowOff, preenchidos pelo parser de drawing e copiados junto com o resto do estado de âncora quando um gráfico é atribuído. Eles são deliberadamente privados: a superfície pública de âncora continua sendo as quatro coordenadas de célula FromRow, FromCol, ToRow e ToCol, e um gráfico criado a partir de código Delphi cai em limites de célula como antes. Os offsets existem para tornar um round trip fiel, não para expor posicionamento dentro da célula como recurso. Vale notar que esta correção é independente do fingerprint: a âncora mora na part de drawing, não na part do gráfico, então um gráfico cujo ChartML fosse reproduzido perfeitamente ainda teria pulado para a grade sem ela. As conversões de unidade por trás desses valores EMU estão na nota sobre geometria de imagem e escala EMU no HotXLS

Como provar que um gráfico faz round-trip sem mudanças?

Comparando bytes, não abrindo o resultado no Excel. O Excel repara e normaliza tanta coisa ao carregar que um gráfico deslocado parece certo até o momento em que um analista percebe que a cor do marcador mudou. O teste de corpus que pegou os dois defeitos faz três coisas depois de um abrir-e-salvar sem edições: percorre os relacionamentos de worksheet, drawing e gráfico e falha em qualquer referência de gráfico duplicada, órfã ou pendurada; compara uma assinatura de tipo de gráfico, fórmulas de série e geometria de âncora entre original e saída; e, para two-charts.xlsx, lê cada xl/charts/chartN.xml dos dois arquivos e exige bytes idênticos. A mesma checagem é fácil de escrever em Delphi com o TZipFile da RTL

uses System.Zip, System.SysUtils;

function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
  Src, Dst: TZipFile;
  Name: string;
  A, B: TBytes;
begin
  Result := True;
  Src := TZipFile.Create;
  Dst := TZipFile.Create;
  try
    Src.Open(Original, zmRead);
    Dst.Open(Resaved, zmRead);
    for Name in Src.FileNames do
      if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
      begin
        Src.Read(Name, A);
        Dst.Read(Name, B);   // levanta exceção se a part sumiu
        if (Length(A) <> Length(B)) or
           ((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
        begin
          Writeln('changed: ', Name);
          Result := False;
        end;
      end;
  finally
    Dst.Free;
    Src.Free;
  end;
end;

Três condições tornam essa comparação significativa, e cada uma falha em silêncio se for esquecida. PreserveUnsupportedParts precisa estar True antes do Open, senão nenhum byte cru é capturado e todo gráfico é reconstruído a partir do modelo. StrictOOXML precisa estar False, porque o modo strict força regeneração por projeto. E a aplicação não pode tocar no gráfico entre o open e o save — ler propriedades tudo bem, mas qualquer setter que mexa no modelo tipado vira o fingerprint e manda o gráfico para o caminho de merge, o que é comportamento correto e não é para isso que este teste serve. Parts de gráfico também são renumeradas a partir de um contador do workbook inteiro no save, então uma pasta de trabalho cuja ordem de planilhas ou de gráficos mudou vai colocar bytes idênticos sob um nome chartN.xml diferente; o verificador de corpus segue relacionamentos em vez de nomes justamente por isso

As duas correções saíram no HotXLS 2.382.0 e 2.382.3 e são verificadas em Win32 e Win64 contra o corpus local, com as amostras de gráfico regravadas também renderizadas para PDF por um pacote de escritório independente e comparadas página a página com os originais. O HotXLS lê, edita e grava gráficos XLSX a partir de código Delphi e C++Builder nativo, sem instalação do Excel envolvida, e é isso que faz esse nível de fidelidade ser responsabilidade da biblioteca — a página do componente HotXLS Delphi Spreadsheet traz a lista de recursos e um download de avaliação