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
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
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
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:
PreserveUnsupportedPartsprecisa estar True antes doOpen; com ele desligado nenhum byte original é guardado e todo chart é escrito do modeloStrictOOXMLprecisa 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
OpeneSaveAspode 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.xmldiferente; 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