Artigo Técnico

Fingerprint e offsets de âncora do HotXLS no Delphi

Uma pasta com dois charts passa por um abrir-e-salvar que não edita nada, e três coisas diferentes podem sair do outro lado. Uma saída tem três partes de chart onde a fonte tinha duas. Outra ainda tem duas, mas uma comparação byte a byte mostra que todo xl/charts/chartN.xml foi reescrito e as cores das séries mudaram. Uma terceira mantém o XML do chart intacto enquanto todo gráfico pulou alguns pixels sobre as linhas de grade. O Excel abre as três sem aviso de reparo. Cada sintoma vem de uma decisão no caminho de carga e salvamento: como uma planilha encontra o drawing dela, quando o fingerprint de alteração do chart é tirado, e se os offsets de âncora dentro da parte de drawing são gravados de volta

Uma planilha encontra o seu drawing pelas relationships

O sintoma das três partes vem de um loader que chuta nomes de parte. Num pacote XLSX uma planilha aponta para o drawing dela com um elemento <drawing r:id="…"/> (ECMA-376 Part 1 §18.3.1.36), e o r:id resolve pela parte .rels da planilha para um alvo como ../drawings/drawing1.xml. O Excel por acaso numera drawings de um jeito previsível, então um leitor escrito à mão costuma criar um fallback: quando a relationship não existe, tentar xl/drawings/drawing{n}.xml para a posição n da planilha e usá-lo se o arquivo tiver

// Um atalho que aparece em leitores XLSX escritos à mão (não coloque isso em produção)
function GuessDrawingPart(Zip: TZipFile; SheetPos: Integer;
  const RelTarget: string): string;
begin
  Result := RelTarget;   // resolvido pelo .rels da planilha, '' quando ausente
  if Result = '' then
  begin
    Result := 'xl/drawings/drawing' + IntToStr(SheetPos + 1) + '.xml';
    if Zip.IndexOf(Result) < 0 then
      Result := '';
  end;
end;

O chute se sustenta até uma planilha sem charts vir primeiro. Numa pasta cuja primeira planilha não tem drawing nem parte .rels alguma, enquanto a segunda planilha alcança xl/drawings/drawing1.xml através de Target="../drawings/drawing1.xml", o fallback entrega à planilha 1 um drawing que ela nunca referenciou. O chart1.xml é parseado duas vezes, uma por planilha, e o salvamento grava três partes de chart. Nomes de parte num pacote OPC não carregam significado além do que o grafo de relationships atribui a eles, que é todo o argumento do walkthrough sobre resolução de relationships OPC no HotXLS

Como uma planilha encontra o drawing dela numa pasta de dois charts: Sheet1 não tem relationship de drawing nem parte rels enquanto Sheet2 alcança xl/drawings/drawing1.xml pela relationship de drawing, então um loader que chuta esse nome convencional pela posição da planilha parseia o chart1.xml duas vezes e salva três partes de chart, e é por isso que o HotXLS só carrega um drawing pela relationship de drawing da planilha
A Sheet1 nunca referenciou um chart, então o grafo de relationships é a única fonte segura para o alvo do drawing, e um nome convencional chutado transforma uma pasta de dois charts num salvamento de três partes

O HotXLS Delphi Component fica com a leitura estrita. O drawing de uma planilha só é carregado do alvo da relationship de drawing daquela planilha, e uma planilha sem uma não ganha drawing, não importa quais arquivos estejam parados em xl/drawings/. Uma parte de drawing que nada referencia nunca é anexada a uma planilha

O que decide se um chart intocado é copiado ou reconstruído?

Um fingerprint por chart decide. Quando PreserveUnsupportedParts está True no momento do Open (True é o padrão), o HotXLS guarda os bytes originais de toda parte de chart ao lado do modelo de chart parseado. Ele então serializa o modelo com o próprio escritor de charts e registra dois números para essa saída: o comprimento dela e um hash FNV-1a de 64 bits sobre as unidades de código UTF-16, com a offset basis padrão 14695981039346656037 e o primo 1099511628211. TXLSXChart.HasPreservedXml informa se um chart está carregando esses bytes originais

Na hora de salvar, o modelo do chart é serializado de novo e os dois números são comparados. Comprimento igual e hash igual significam que o modelo é exatamente o que era na importação, então nada do que a aplicação pudesse ter mudado mudou. Nesse caso, com StrictOOXML desligado, o escritor copia a entrada comprimida do arquivo fonte direto para a saída sob o novo nome de parte do chart; os bytes nunca são decodificados nem re-comprimidos. Se qualquer um dos números difere, o XML original é mesclado com o modelo em vez disso. Mudar a formatação de linha de uma barra de erro ou trendline também conta como edição, mesmo que essa formatação não faça parte do modelo serializado

O HotXLS captura um fingerprint de chart na importação, guardando os bytes originais da parte de chart e registrando o comprimento e o hash FNV-1a de 64 bits da serialização própria do modelo do chart, e na hora de salvar reconstrói e compara os dois valores, de modo que uma coincidência repete os bytes originais ou copia a entrada comprimida e uma divergência mescla o XML original com o modelo
O fingerprint vale tanto quanto o momento em que é tirado, e capturá-lo antes que todas as passadas de recuperação terminem garante um hash que nunca coincide com o modelo completo

A mesclagem é a operação certa para um chart que alguém editou. Ela mantém as partes do XML original que o modelo não cobre, deixa o modelo vencer em séries, eixos e grupos de plot, e resserializa o resultado. O que ela não consegue fazer é reproduzir os bytes originais. A nota sobre editar charts do Excel sem perder ChartML cobre o que a mesclagem mantém; este artigo trata das condições em que um chart sem edições nunca chega até ela

uses System.SysUtils, lxHandleX;

procedure ResaveUntouched(const Src, Dst: string);
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  S, C: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.PreserveUnsupportedParts := True;  // o padrão; precisa valer antes do Open
    Book.StrictOOXML := False;              // saída Strict regenera todo chart
    if Book.Open(Src) <> 1 then
      raise Exception.Create('Cannot open ' + Src);
    for S := 1 to Book.Sheets.Count do      // Sheets[] é base 1
    begin
      Sheet := Book.Sheets[S];
      for C := 0 to Sheet.Charts.Count - 1 do  // Charts[] é base 0
        Writeln(Sheet.Name, ' / ', Sheet.Charts[C].Name, ': original kept = ',
          BoolToStr(Sheet.Charts[C].HasPreservedXml, True));
    end;
    if Book.SaveAs(Dst) <> 1 then
      raise Exception.Create('Cannot save ' + Dst);
  finally
    Book.Free;
  end;
end;

Quando um chart deve ser regenerado do modelo de propósito, por exemplo para remover formatação que a fonte carregava, TXLSXChart.ClearPreservedXml descarta os bytes guardados e esse chart é escrito só do modelo

Por que todo chart tomaria o caminho de merge de qualquer jeito?

Porque um fingerprint tirado um passo cedo demais nunca coincide. O XML de chart raramente é lido numa única passada. Um parse em stream constrói as séries, os eixos e os grupos de plot, e depois um conjunto de passadas de complemento lê detalhes do texto bruto do chart que ficam mais fáceis de recuperar quando as séries já existem: a flag <c:smooth> de cada <c:ser>, os valores srgbClr do preenchimento e da linha do marker, os modos de cruzamento de eixo, e os estilos de tick mark maior e menor. A coleção de séries simplesmente ainda não existe enquanto a passada em stream roda, então essas passadas precisam vir depois

Suponha que o fingerprint é capturado no fim da passada em stream, antes das passadas de complemento rodarem. Ele então descreve um modelo sem flags de smooth, sem cores de marker e com tick marks padrão. Na hora de salvar, o serializador roda contra o modelo completo e emite <c:smooth val="1"/> mais as cores recuperadas. O XML fica maior, o hash difere, e todo chart de toda pasta preservada passa pela mesclagem em todo salvamento. Num chart de linha com séries suavizadas e markers customizados, os nós <c:ser> regenerados substituem os originais, e é daí que vem a mudança de cor

O HotXLS captura o fingerprint só depois que toda passada de recuperação terminou, então o hash descreve o modelo como a aplicação o vê pela primeira vez. Os mesmos dois números são re-estabelecidos depois de um salvamento bem-sucedido, desta vez a partir do arquivo de saída, e essa segunda captura também parte de um modelo completamente parseado. Um hash de detecção de mudança precisa ser tirado depois da última passada que ainda possa mutar o modelo. Tirá-lo antes dá uma verificação que falha em todo salvamento

Os offsets de âncora moram na parte de drawing

O sintoma de encaixe na grade não tem nada a ver com o XML do chart. Onde um chart fica na planilha está guardado na parte de drawing, e a forma usual é xdr:twoCellAnchor (ECMA-376 Part 1 §20.5): um canto from e um canto to, cada um com col, colOff, row e rowOff. Os offsets estão em English Metric Units, 914400 por polegada e 12700 por ponto, e o Excel grava valores não nulos sempre que um chart é arrastado ou redimensionado com o mouse. Para um arquivo que declara um canto from na coluna 0, linha 0 com um rowOff de 19049, e um canto to na coluna 8, linha 15 com um colOff de 247650 e um rowOff de 66674, o chart começa a 1,5 ponto abaixo do topo da linha 1 e termina um pouco mais de um quarto de polegada dentro da coluna I

Anatomia dos cantos do xdr:twoCellAnchor para um chart de exemplo: from guarda col 0 e rowOff 19049 enquanto to guarda col 8, colOff 247650 e rowOff 66674 em EMU de 914400 por polegada, e como um escritor que emite offsets zero encaixa os charts na grade, o HotXLS grava os offsets importados de volta ao salvar
A âncora mora na parte de drawing e não na parte de chart, então a repetição de offsets é independente do fingerprint, e um round trip fiel precisa dos dois

Um escritor que lê esses quatro números e depois emite <xdr:colOff>0</xdr:colOff> para cada canto move cada chart para as linhas de grade. A parte de chart pode repetir byte a byte e o chart ainda se move, porque os dois moram em partes diferentes. O HotXLS guarda o tipo de âncora que carregou (twoCellAnchor, oneCellAnchor ou absoluteAnchor, exposto como PhysicalAnchorKind) junto com os valores EMU dele, e os grava de volta sem alteração. Os offsets são legíveis como FromOffsetXEMU, FromOffsetYEMU, ToOffsetXEMU e ToOffsetYEMU, e as conversões de unidade por trás deles são trabalhadas na nota sobre geometria de imagem e escala EMU do HotXLS

procedure NudgeChart(Chart: TXLSXChart);
const
  EmuPerPoint = 12700;
begin
  if Chart.PhysicalAnchorKind <> xlsxAnchorTwoCell then
    Exit;
  Writeln(Format('from R%dC%d +(%d, %d) EMU  to R%dC%d +(%d, %d) EMU',
    [Chart.FromRow, Chart.FromCol, Chart.FromOffsetXEMU, Chart.FromOffsetYEMU,
     Chart.ToRow, Chart.ToCol, Chart.ToOffsetXEMU, Chart.ToOffsetYEMU]));
  // Substitui o frame inteiro numa única chamada: linhas e colunas são base 1,
  // offsets são EMU dentro da célula, o último argumento é o comportamento editAs
  Chart.SetTwoCellAnchor(Chart.FromRow, Chart.FromCol, Chart.ToRow, Chart.ToCol,
    Chart.FromOffsetXEMU + 6 * EmuPerPoint, Chart.FromOffsetYEMU,
    Chart.ToOffsetXEMU + 6 * EmuPerPoint, Chart.ToOffsetYEMU, Chart.AnchorType);
end;

Use SetTwoCellAnchor quando a intenção é mover ou redimensionar. Atribuir FromRow, FromCol, ToRow ou ToCol num chart de duas células muda só aquele índice de célula e deixa os offsets onde estavam, o que é o comportamento certo para um ajuste pequeno e o errado para posicionar um chart do zero. SetTwoCellAnchor valida o frame como um todo e levanta ERangeError quando o canto final precede o inicial ou os offsets caem fora da faixa EMU do Excel. Um chart criado com AddChart começa com offsets zero, então fica exatamente sobre as bordas de célula até você dar valores a elas

Provando o round trip com uma comparação byte a byte

Abrir a saída no Excel prova pouca coisa aqui. O Excel normaliza o bastante na carga que um chart resserializado parece idêntico até alguém notar que a cor de um marker mudou, e um encaixe na grade de alguns pontos é fácil de perder na tela. A verificação que vale automatizar compara as partes de chart byte a byte depois de um abrir-e-salvar sem edições, e o TZipFile da RTL é tudo de que ela precisa

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;

Algumas condições decidem se uma divergência nessa verificação significa algo:

  • PreserveUnsupportedParts precisa estar True antes do Open; com ele desligado nenhum byte original é guardado e todo chart é escrito do modelo
  • StrictOOXML precisa estar False, já que a saída Strict regenera charts por design. Deixe-o desligado em qualquer pipeline que prometa que charts sem edições sobrevivem ao salvamento
  • Um pacote fonte escrito com namespaces Strict OOXML é convertido para namespaces transicionais num salvamento transicional, então os bytes de chart dele diferem mesmo que nada mais tenha mudado
  • O código entre Open e SaveAs pode ler propriedades de chart livremente, mas qualquer setter que mude o modelo muda o fingerprint e manda esse chart pela mesclagem, o que é correto e está fora do que este teste mede
  • As partes de chart são renumeradas de um contador da pasta inteira ao salvar, então uma pasta cuja ordem de planilhas ou charts mudou põe bytes idênticos sob um nome chartN.xml diferente; um verificador para esse caso segue as relationships de drawing em vez de comparar nomes

Âncoras precisam de comparação por valor. A parte de drawing é escrita do zero em volta dos charts que ela contém, então os bytes dela são a coisa errada para diff; reabrir os dois arquivos e comparar de FromOffsetXEMU a ToOffsetYEMU chart por chart pega um escritor que transforma 247650 em 0. Uma verificação estrutural que percorre as relationships de worksheet, drawing e chart e falha com qualquer referência de chart duplicada, órfã ou pendente pega o caso das três partes antes mesmo da comparação byte a byte rodar

Tudo isso roda em código Delphi e C++Builder nativo sem instalação do Excel na máquina, então a pasta que volta de um job em lote é a única evidência do que aconteceu com os charts dela. Licenciamento, a API completa de charts e um build de teste estão na página do HotXLS Delphi spreadsheet component