Il componente HotXLS per Delphi riscrive byte per byte un grafico Excel non modificato solo quando valgono due condizioni: il grafico è stato raggiunto tramite la relazione di drawing del foglio di lavoro e non tramite un nome di parte indovinato, e il fingerprint a 64 bit del modello è stato catturato dopo che il modello del grafico ha finito di essere analizzato. La versione 2.382.0 ha corretto la prima condizione, la 2.382.3 la seconda, e da lì ha iniziato a riportare nel round trip gli offset di ancoraggio non nulli xdr:colOff e xdr:rowOff che il writer del drawing azzerava sempre. Entrambi i difetti sono emersi da un unico caso del corpus locale, two-charts.xlsx: prima un'asserzione strutturale ha visto due parti chart diventare tre, poi un confronto byte per byte di ogni xl/charts/chartN.xml ha mostrato che i grafici che nessuno aveva toccato venivano comunque riscritti — e nessuno dei due problemi sollevava eccezioni o faceva lamentare Excel, ed è per questo che sono sopravvissuti così a lungo
Perché un workbook con due grafici tornava con tre parti chart?
Perché il loader aveva un fallback che tirava a indovinare. Quando un foglio di lavoro non aveva alcuna relazione di drawing nella sua parte .rels, il vecchio codice presumeva che il drawing stesse al nome convenzionale xl/drawings/drawing{i+1}.xml, dove i è la posizione del foglio, e agganciava quella parte se esisteva nell'archivio. In two-charts.xlsx il primo foglio non ha né drawing né parte .rels, mentre xl/drawings/drawing1.xml esiste davvero: appartiene al secondo foglio, che lo raggiunge tramite Target="../drawings/drawing1.xml". Il foglio 1 si è quindi ritrovato un grafico che non aveva mai referenziato, chart1.xml veniva analizzato due volte, e il salvataggio scriveva il workbook con tre parti chart invece di due
La correzione nella v2.382.0 di HotXLS ha eliminato del tutto l'indovinello. Ora un drawing di foglio di lavoro viene caricato solo tramite ParPartTargets[i].Values[XlsxRtDrawing], il target registrato per il tipo di relazione drawing su quel foglio, e un foglio senza quella relazione non ottiene alcun drawing. È il comportamento che il formato pretende: l'elemento <drawing r:id="…"/> nel foglio di lavoro (ECMA-376 Part 1 §18.3.1.36) è l'unico collegamento tra un foglio e il suo drawing, e i nomi delle parti in un pacchetto OPC non hanno alcun significato oltre a quello che assegna loro il grafo delle relazioni. Gli archivi scritti da Excel usano per caso i nomi convenzionali, ed è ciò che ha permesso alla scorciatoia di passare inosservata così a lungo; la panoramica su come HotXLS risolve le relazioni OPC spiega perché indovinare il nome di una parte non sia mai sicuro, anche quando l'intuizione è quasi sempre giusta
// Prima della v2.382.0: una relazione di drawing mancante ripiegava su un indovinello
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
LoadDrawing(zip, drawingName); // potrebbe appartenere a un altro foglio
// Dalla v2.382.0: o c'è la relazione, o niente
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
LoadDrawing(zip, drawingName);
Che cosa garantisce il fingerprint di un grafico?
Il fingerprint decide, grafico per grafico, se il salvataggio può copiare la parte originale o deve rigenerarla. In importazione, con PreserveUnsupportedParts attivo prima di Open, HotXLS conserva i byte UTF-8 grezzi di ogni parte chart in FRawChartXml, costruisce la serializzazione del modello tipizzato con BuildChartKnownXml, e memorizza la lunghezza di quella serializzazione in FRawChartModelLength e il suo hash in FRawChartModelHash. L'hash è FNV-1a sulle unità di codice UTF-16 dell'XML generato, con la base di offset standard a 64 bit 14695981039346656037 e il primo 1099511628211. Al momento del salvataggio XlsxChartRawModelUnchanged ricostruisce l'XML noto e confronta lunghezza e hash; una corrispondenza significa che il modello tipizzato è esattamente quello che era in importazione, quindi nulla di ciò che l'applicazione poteva cambiare è cambiato
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 // nulla di conservato
else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
Result := XlsxDecodeChartUtf8(Chart.FRawChartXml) // riproduzione letterale
else
Result := XlsxMergeChartXml(
XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // merge strutturale
end;
Il writer XLSX fa un passo in più rispetto a BuildChartXmlFromKnown. Quando il modello è invariato e StrictOOXML è disattivato, prova prima a copiare direttamente la voce compressa dall'archivio sorgente all'output sotto il nuovo nome di parte del grafico, così i byte non vengono nemmeno decodificati e ricompressi. Solo se quella copia non è possibile ricade sul percorso decodifica-o-merge. Il meccanismo in sé — lunghezza più hash, riproduzione quando coincidono, merge quando no — è quello descritto nella nota su modificare i grafici Excel senza perdere il ChartML. Questo articolo parla del modo in cui ha smesso di funzionare in silenzio
Perché ogni grafico finiva comunque nel percorso di merge?
Perché il fingerprint veniva catturato una chiamata troppo presto. L'analisi dei grafici in HotXLS è un passaggio SAX sulla parte chart seguito da una serie di passaggi di recupero che estraggono dal testo grezzo dettagli che gli handler SAX non modellano direttamente: XlsxChartParseSeriesFlags legge ogni blocco <c:ser> per il suo flag <c:smooth> e per i valori srgbClr del riempimento e della linea del marker, poi recupera le modalità di incrocio degli assi e gli stili dei tick major e minor per l'asse delle categorie e quello dei valori. Prima della v2.382.3 l'ordine alla fine di ParseChartXml era: classificare i gruppi di assi, costruire l'XML noto, catturare lunghezza e hash, e solo dopo eseguire XlsxChartParseSeriesFlags. Il fingerprint descriveva quindi un modello a cui mancavano ancora i flag smooth, i colori dei marker e i tick. Al momento del salvataggio BuildChartKnownXml girava sul modello completato, che ora emetteva <c:smooth val="1"/> e i colori dei marker recuperati. XML più lungo, hash diverso, XlsxChartRawModelUnchanged restituiva False, e il grafico passava da XlsxMergeChartXml. Il merge è un'operazione corretta per un grafico che qualcuno ha modificato, ma non è un'operazione che preserva i byte: riserializza l'albero, e la regola di proprietà che lascia vincere il modello tipizzato su serie, assi e gruppi di plot fa sì che i nodi rigenerati sostituiscano gli originali. Il risultato visibile nell'esecuzione sul corpus erano colori delle serie alterati su grafici che nessuno aveva modificato — ogni grafico in ogni workbook conservato, a ogni salvataggio, senza alcuna diagnostica da nessuna parte
La riparazione è un semplice riordino: ora XlsxChartParseSeriesFlags gira prima che venga costruito l'XML noto, così il fingerprint descrive il modello come sarà quando l'applicazione lo vede per la prima volta. La lezione vale oltre i grafici. Un fingerprint di rilevamento delle modifiche vale solo quanto il momento in cui viene preso, e il momento sicuro è dopo che ogni passaggio in grado di mutare il modello è terminato. HotXLS ha un secondo punto di cattura per gli stessi due valori, la baseline che ristabilisce rispetto al file di output dopo un salvataggio riuscito, e quel punto ha sempre girato su un modello completamente analizzato; quello in importazione era l'anomalia
Dove sono finiti gli offset di ancoraggio?
In uno zero letterale. Un twoCellAnchor nella parte drawing fissa un grafico tra due celle, e ogni angolo porta un indice di cella più un offset dentro quella cella: from (ECMA-376 Part 1 §20.5.2.5) e to (§20.5.2.32) contengono ciascuno col, colOff (§20.5.2.4), row e rowOff. Gli offset sono in English Metric Units, 914400 per pollice, e Excel scrive valori non nulli ogni volta che un grafico è stato posizionato o ridimensionato col mouse, cioè quasi sempre. Il primo grafico in two-charts.xlsx inizia alla riga 0 con un rowOff di 19049 e finisce alla colonna 8, riga 15, con un colOff di 247650 e un rowOff di 66674 — circa un quarto di pollice dentro l'ultima colonna. Il parser del drawing in HotXLS aveva sempre letto quei quattro valori — il codice delle immagini li usava — ma il writer dei grafici emetteva <xdr:colOff>0</xdr:colOff> e <xdr:rowOff>0</xdr:rowOff> per ogni angolo, agganciando ogni grafico alla griglia delle celle al salvataggio
// Dalla v2.382.3 il writer degli anchor riproduce gli offset EMU importati
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>' + ...
Ora TXLSXChart porta FFromColOff, FFromRowOff, FToColOff e FToRowOff, riempiti dal parser del drawing e copiati insieme al resto dello stato dell'anchor quando un grafico viene assegnato. Sono deliberatamente privati: la superficie pubblica dell'anchor resta quella delle quattro coordinate di cella FromRow, FromCol, ToRow e ToCol, e un grafico creato da codice Delphi finisce sui bordi delle celle come prima. Gli offset esistono per rendere fedele il round trip, non per esporre il posizionamento dentro la cella come funzionalità. Va notato che questa correzione è indipendente dal fingerprint: l'anchor vive nella parte drawing, non in quella chart, quindi un grafico il cui ChartML veniva riprodotto perfettamente sarebbe comunque saltato alla griglia senza di essa. Le conversioni di unità dietro quei valori EMU sono trattate nella nota su geometria delle immagini e scaling EMU in HotXLS
Come si dimostra che un grafico torna invariato dal round trip?
Confrontando i byte, non aprendo il risultato in Excel. Excel ripara e normalizza così tanto al caricamento che un grafico alterato sembra a posto finché un analista non si accorge che il colore del marker è cambiato. Il test sul corpus che ha scoperto entrambi i difetti fa tre cose dopo un'apertura e un salvataggio senza modifiche: percorre le relazioni di foglio di lavoro, drawing e chart e fallisce su qualsiasi riferimento a un grafico duplicato, orfano o pendente; confronta una firma di tipo di grafico, formule delle serie e geometria dell'anchor tra originale e output; e per two-charts.xlsx legge ogni xl/charts/chartN.xml da entrambi gli archivi e pretende byte identici. Lo stesso controllo è facile da scrivere in Delphi con la TZipFile della 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); // solleva un'eccezione se la parte è sparita
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;
Tre condizioni rendono significativo quel confronto, e ognuna fallisce in silenzio se la dimentichi. PreserveUnsupportedParts deve essere True prima di Open, altrimenti non viene catturato alcun byte grezzo e ogni grafico viene ricostruito dal modello. StrictOOXML deve essere False, perché la modalità strict forza la rigenerazione per progetto. E l'applicazione non deve toccare il grafico tra apertura e salvataggio: leggere le proprietà va bene, ma qualsiasi setter che cambia il modello tipizzato ribalta il fingerprint e manda il grafico sul percorso di merge, il che è un comportamento corretto e non è ciò per cui serve questo test. Le parti chart vengono anche rinumerate da un contatore a livello di workbook al salvataggio, quindi un workbook la cui sequenza di fogli o di grafici è cambiata collocherà byte identici sotto un nome chartN.xml diverso; per questo il checker del corpus segue le relazioni invece dei nomi
Entrambe le correzioni sono uscite in HotXLS 2.382.0 e 2.382.3 e sono verificate su Win32 e Win64 contro il corpus locale, con i campioni di grafici risalvati anche renderizzati in PDF tramite una suite office indipendente e confrontati pagina per pagina con gli originali. HotXLS legge, modifica e scrive grafici XLSX da codice nativo Delphi e C++Builder senza alcuna installazione di Excel, ed è questo che rende una fedeltà di questo livello una responsabilità della libreria — la pagina del componente spreadsheet HotXLS per Delphi ha l'elenco delle funzionalità e una versione di prova