O HotXLS Delphi Component só volta a gravar um gráfico Excel não modificado byte a byte quando se verificam duas condições: o gráfico foi alcançado através da relação de drawing da folha de cálculo e não por um nome de parte adivinhado, e o fingerprint de 64 bits do modelo foi capturado depois de o modelo do gráfico terminar de ser analisado. A versão 2.382.0 corrigiu a primeira condição, a versão 2.382.3 corrigiu a segunda e começou a fazer round-trip dos deslocamentos de âncora xdr:colOff e xdr:rowOff não nulos que o writer de drawings tinha vindo a fixar a zero. Os dois defeitos saíram de um único caso do corpus local, two-charts.xlsx: primeiro uma asserção estrutural viu duas partes de gráfico tornarem-se três, depois uma comparação byte a byte de todos os xl/charts/chartN.xml mostrou gráficos que ninguém tinha tocado a serem reescritos — e nenhum dos problemas lançou uma exceção nem fez o Excel queixar-se, e é por isso que sobreviveram tanto tempo
Porque é que um livro com dois gráficos voltou com três partes de gráfico?
Porque o loader tinha um fallback que adivinhava. Quando uma folha de cálculo não tinha relação de drawing na sua parte .rels, o código antigo assumia que o drawing estava no nome convencional xl/drawings/drawing{i+1}.xml, em que i é a posição da folha, e anexava essa parte se ela existisse no arquivo. Em two-charts.xlsx a primeira folha não tem drawing nem parte .rels nenhuma, enquanto xl/drawings/drawing1.xml existe de facto — pertence à segunda folha, que lá chega através de Target="../drawings/drawing1.xml". A folha 1 herdou assim um gráfico que nunca referenciou, o chart1.xml foi analisado duas vezes, e a gravação escreveu o livro com três partes de gráfico em vez de duas
A correção do HotXLS v2.382.0 eliminou a adivinhação por completo. O drawing de uma folha passa a ser carregado apenas através de ParPartTargets[i].Values[XlsxRtDrawing], o destino registado para o tipo de relação de drawing nessa folha, e uma folha sem essa relação não recebe drawing nenhum. É o comportamento que o formato exige: o elemento <drawing r:id="…"/> na folha de cálculo (ECMA-376 Parte 1 §18.3.1.36) é o único elo entre uma folha e o seu drawing, e os nomes das partes num pacote OPC não têm qualquer significado além daquele que o grafo de relações lhes atribui. Os arquivos escritos pelo Excel usam por acaso os nomes convencionais, e foi isso que deixou o atalho passar tanto tempo; o percurso sobre a resolução de relações OPC no HotXLS explica porque é que adivinhar o nome de uma parte nunca é seguro, mesmo quando a adivinhação costuma acertar
// Antes da v2.382.0: uma relação de drawing em falta 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 folha
// Desde a v2.382.0: relação ou nada
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
LoadDrawing(zip, drawingName);
O que garante afinal o fingerprint do gráfico?
O fingerprint decide, gráfico a gráfico, se a gravação pode copiar a parte original ou se tem de a regenerar. Na importação, com PreserveUnsupportedParts ativado antes do Open, o HotXLS guarda os bytes UTF-8 em bruto de cada parte de gráfico em FRawChartXml, constrói a serialização do próprio modelo tipado com BuildChartKnownXml e guarda o comprimento dessa serialização em FRawChartModelLength e o seu hash em FRawChartModelHash. O hash é FNV-1a sobre as unidades de código UTF-16 do XML gerado, com a base de deslocamento de 64 bits habitual 14695981039346656037 e o primo 1099511628211. No momento de gravar, o XlsxChartRawModelUnchanged reconstrói o XML conhecido e compara o comprimento e o hash; uma correspondência significa que o modelo tipado está exatamente como estava na importação, ou seja, nada do que a aplicação poderia ter mudado mudou
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) // repetição literal
else
Result := XlsxMergeChartXml(
XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // merge estrutural
end;
O writer de XLSX vai um passo mais longe do que o BuildChartXmlFromKnown. Quando o modelo não mudou e o StrictOOXML está desligado, tenta primeiro copiar a entrada comprimida diretamente do arquivo de origem para a saída, sob o novo nome de parte do gráfico, de modo que os bytes nem sequer são descodificados e recomprimidos. Só se essa cópia não for possível é que cai no caminho de descodificar-ou-fazer-merge. O mecanismo em si — comprimento mais hash, repetir quando são iguais, fazer merge quando não são — é o descrito na nota sobre editar gráficos Excel sem perder o ChartML. Este artigo é sobre a forma como ele deixou de funcionar em silêncio
Porque é que todos os gráficos acabavam no caminho do merge?
Porque o fingerprint era capturado uma chamada demasiado cedo. A análise de gráficos no HotXLS é uma passagem SAX sobre a parte do gráfico seguida de um conjunto de passagens de recuperação que retiram do texto em bruto pormenores que os handlers SAX não modelam diretamente: o XlsxChartParseSeriesFlags lê cada bloco <c:ser> para obter o seu flag <c:smooth> e os valores srgbClr do preenchimento e da linha do marcador, e depois recupera os modos de cruzamento dos eixos e os estilos das marcas principais e secundárias para os eixos de categorias e de valores. Antes da v2.382.3, a ordem no fim do ParseChartXml era: classificar os grupos de eixos, construir o XML conhecido, capturar comprimento e hash, e só depois correr o XlsxChartParseSeriesFlags. O fingerprint descrevia por isso um modelo que ainda não tinha os flags smooth, as cores dos marcadores e as marcas dos eixos. Na gravação, o BuildChartKnownXml corria sobre o modelo completo, que já emitia <c:smooth val="1"/> e as cores de marcador recuperadas. XML mais longo, hash diferente, o XlsxChartRawModelUnchanged devolvia False e o gráfico seguia pelo XlsxMergeChartXml. O merge é uma operação correta para um gráfico que alguém editou, mas não é uma operação que preserve os bytes: reserializa a árvore, e a regra de posse que deixa o modelo tipado ganhar para séries, eixos e grupos de plotagem faz com que os nós regenerados substituam os originais. O resultado visível na corrida do corpus foi a deriva das cores de série em gráficos que ninguém tinha editado — todos os gráficos de todos os livros preservados, em todas as gravações, sem qualquer diagnóstico em lado nenhum
A reparação é uma simples reordenação: o XlsxChartParseSeriesFlags passa a correr antes de o XML conhecido ser construído, de modo que o fingerprint descreve o modelo tal como ele existirá quando a aplicação o vir pela primeira vez. A lição generaliza-se para além dos gráficos. Um fingerprint de deteção de mudanças vale tanto quanto o momento em que é capturado, e o momento seguro é depois de ter terminado toda a passagem capaz de alterar o modelo. O HotXLS tem um segundo local de captura para estes dois valores, a linha de base que volta a estabelecer contra o ficheiro de saída depois de uma gravação bem-sucedida, e esse local sempre correu sobre um modelo totalmente analisado; o local da importação é que era o caso estranho
Onde é que foram parar os deslocamentos das âncoras?
Para um zero literal. Uma twoCellAnchor na parte do drawing fixa um gráfico entre duas células, e cada canto traz um índice de célula mais um deslocamento dentro dessa célula: from (ECMA-376 Parte 1 §20.5.2.5) e to (§20.5.2.32) contêm ambos col, colOff (§20.5.2.4), row e rowOff. Os deslocamentos estão em English Metric Units, 914400 por polegada, e o Excel escreve valores não nulos sempre que um gráfico foi colocado ou redimensionado com o rato, o que acontece na maioria dos gráficos. O primeiro gráfico de 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 drawings do HotXLS sempre tinha lido estes quatro valores — o código das imagens usava-os — mas o writer de gráficos emitia <xdr:colOff>0</xdr:colOff> e <xdr:rowOff>0</xdr:rowOff> em todos os cantos, encaixando cada gráfico na grelha de células ao gravar
// Desde a v2.382.3 o writer de âncoras repete os deslocamentos 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>' + ...
O TXLSXChart passa a transportar FFromColOff, FFromRowOff, FToColOff e FToRowOff, preenchidos a partir do parser de drawings e copiados juntamente com o resto do estado da âncora quando um gráfico é atribuído. São deliberadamente privados: a superfície pública da âncora continua a ser as quatro coordenadas de célula FromRow, FromCol, ToRow e ToCol, e um gráfico criado a partir de código Delphi pousa nos limites das células como antes. Os deslocamentos existem para tornar o round-trip fiel, não para expor o posicionamento sub-célula como funcionalidade. Repare que esta correção é independente do fingerprint: a âncora vive na parte do drawing, não na parte do gráfico, pelo que um gráfico cujo ChartML fosse repetido na perfeição ainda assim saltaria para a grelha sem ela. As conversões de unidades por trás desses valores EMU são tratadas na nota sobre geometria de imagens e escala EMU no HotXLS
Como é que se prova que um gráfico faz round-trip sem alterações?
Comparando bytes, não abrindo o resultado no Excel. O Excel repara e normaliza tanta coisa ao carregar que um gráfico com deriva parece bem até ao momento em que um analista repara que a cor do marcador mudou. O teste de corpus que apanhou os dois defeitos faz três coisas depois de abrir e gravar sem edições: percorre as relações de folha, drawing e gráfico e falha perante qualquer referência de gráfico duplicada, órfã ou pendente; compara uma assinatura de tipo de gráfico, fórmulas de série e geometria da âncora entre original e saída; e, para o two-charts.xlsx, lê cada xl/charts/chartN.xml dos dois arquivos e exige bytes idênticos. A mesma verificação é 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 parte desapareceu
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 delas falha em silêncio se for esquecida. O PreserveUnsupportedParts tem de estar a True antes do Open, ou não são capturados bytes em bruto e todos os gráficos são reconstruídos a partir do modelo. O StrictOOXML tem de estar a False, porque o modo strict força a regeneração por design. E a aplicação não pode tocar no gráfico entre abrir e gravar — ler propriedades não faz mal, mas qualquer setter que altere o modelo tipado muda o fingerprint e envia o gráfico pelo caminho do merge, o que é o comportamento correto e não é para isto que serve este teste. As partes de gráfico também são renumeradas a partir de um contador do livro inteiro ao gravar, pelo que um livro cuja ordem de folhas ou de gráficos tenha mudado vai colocar bytes idênticos sob um nome chartN.xml diferente; é por isso que o verificador do corpus segue as relações e não os nomes
As duas correções saíram no HotXLS 2.382.0 e 2.382.3 e estão verificadas em Win32 e Win64 contra o corpus local, com os exemplos de gráficos regravados também renderizados em PDF através de uma suite de escritório independente e comparados página a página com os originais. O HotXLS lê, edita e escreve gráficos XLSX a partir de código Delphi e C++Builder nativo, sem qualquer instalação do Excel envolvida, e é isso que faz desta fidelidade uma responsabilidade da biblioteca — a página do componente de folhas de cálculo Delphi HotXLS tem a lista de funcionalidades e um download de avaliação