Articolo tecnico

HotXLS: fingerprint grafici e offset anchor in Delphi

Un workbook con due grafici attraversa un open-and-save che non modifica nulla, e tre cose diverse possono uscire dall'altra parte. Un output ha tre parti chart dove la sorgente ne aveva due. Un altro ne ha ancora due, ma un confronto byte per byte mostra che ogni xl/charts/chartN.xml è stato riscritto e i colori delle serie hanno preso una deriva. Un terzo conserva l'XML dei grafici intatto mentre ogni grafico è saltato di qualche pixel sulle linee della griglia delle celle. Excel apre tutti e tre senza prompt di riparazione. Ogni sintomo viene da una decisione nel percorso di caricamento e salvataggio: come un foglio di lavoro trova il suo drawing, quando viene preso il fingerprint delle modifiche del grafico, e se gli offset di anchor dentro la parte drawing vengono riscritti

Un foglio di lavoro trova il suo drawing attraverso le relazioni

Il sintomo delle tre parti viene da un loader che tira a indovinare i nomi delle parti. In un pacchetto XLSX un foglio di lavoro punta al suo drawing con un elemento <drawing r:id="…"/> (ECMA-376 Part 1 §18.3.1.36), e l'r:id si risolve attraverso la parte .rels del foglio verso un target come ../drawings/drawing1.xml. Excel per abitudine numera i drawing in modo prevedibile, così un reader scritto a mano spesso sviluppa un fallback: quando la relazione manca, prova xl/drawings/drawing{n}.xml per la posizione n del foglio e lo usa se l'archivio ce l'ha

// Una scorciatoia che compare nei reader XLSX scritti a mano (non speditela)
function GuessDrawingPart(Zip: TZipFile; SheetPos: Integer;
  const RelTarget: string): string;
begin
  Result := RelTarget;   // risolta dal .rels del foglio, '' quando assente
  if Result = '' then
  begin
    Result := 'xl/drawings/drawing' + IntToStr(SheetPos + 1) + '.xml';
    if Zip.IndexOf(Result) < 0 then
      Result := '';
  end;
end;

L'indovinello regge finché arriva prima un foglio senza grafici. In un workbook il cui primo foglio non ha né drawing né parte .rels, mentre il secondo foglio raggiunge xl/drawings/drawing1.xml tramite Target="../drawings/drawing1.xml", il fallback consegna al foglio 1 un drawing che non ha mai referenziato. chart1.xml viene analizzato due volte, una per foglio, e il salvataggio scrive tre parti chart. I nomi delle parti in un pacchetto OPC non hanno alcun significato oltre a quello che il grafo delle relazioni assegna loro, che è tutta la tesi della guida sulla risoluzione delle relazioni OPC in HotXLS

Come un foglio di lavoro trova il suo drawing in un workbook con due grafici: Sheet1 non porta né relazione di drawing né parte rels mentre Sheet2 raggiunge xl/drawings/drawing1.xml tramite la sua relazione di drawing, così un loader che tira a indovinare quel nome convenzionale dalla posizione del foglio analizza chart1.xml due volte e salva tre parti chart, ed è per questo che HotXLS carica un drawing solo tramite la relazione di drawing del foglio
Sheet1 non ha mai referenziato un grafico, quindi il grafo delle relazioni è l'unica sorgente sicura per il target del drawing, e un nome convenzionale indovinato trasforma un workbook con due grafici in un salvataggio a tre parti

HotXLS Delphi Component prende la lettura rigorosa. Il drawing di un foglio di lavoro viene caricato solo dal target della relazione di drawing di quel foglio, e un foglio senza relazione non riceve alcun drawing, qualunque file si trovi in xl/drawings/. Una parte drawing che nessuno referenzia non viene mai attaccata a un foglio

Che cosa decide se un grafico intatto viene copiato o ricostruito?

Lo decide un fingerprint per grafico. Quando PreserveUnsupportedParts vale True al momento dell'Open (True è il valore predefinito), HotXLS conserva i byte originali di ogni parte chart accanto al modello di grafico analizzato. Poi serializza il modello con il proprio writer dei grafici e registra due numeri per quell'output: la sua lunghezza e un hash FNV-1a a 64 bit sulle sue unità di codice UTF-16, con l'offset basis standard 14695981039346656037 e il numero primo 1099511628211. TXLSXChart.HasPreservedXml riferisce se un grafico porta quei byte originali

Al momento del salvataggio il modello viene serializzato di nuovo ed entrambi i numeri vengono confrontati. Lunghezza uguale e hash uguale significano che il modello è esattamente com'era all'importazione, quindi nulla di ciò che l'applicazione poteva cambiare è cambiato. In quel caso, con StrictOOXML disattivato, il writer copia la voce compressa dall'archivio sorgente direttamente nell'output sotto il nuovo nome della parte chart; i byte non vengono mai decodificati né ricompressi. Se uno dei due numeri differisce, l'XML originale viene invece fuso con il modello. Cambiare la formattazione della linea di una barra di errore o di una trendline conta anch'esso come modifica, anche se quella formattazione non fa parte del modello serializzato

HotXLS cattura un fingerprint del grafico all'importazione, conservando i byte della parte chart originale e registrando lunghezza e hash FNV-1a a 64 bit della propria serializzazione del modello del grafico, e al salvataggio ricostruisce e confronta entrambi i valori, così una corrispondenza riproduce i byte originali o copia la voce compressa mentre una differenza fonde l'XML originale con il modello
Il fingerprint vale solo quanto il momento in cui viene preso, e catturarlo prima che ogni passaggio di recupero sia finito garantisce un hash che non corrisponde mai al modello completato

Il merge è l'operazione giusta per un grafico modificato da qualcuno. Conserva le parti dell'XML originale che il modello non copre, dà la precedenza al modello per serie, assi e gruppi di plot, e riserializza il risultato. Quello che non può fare è riprodurre i byte originali. La nota sulla modifica dei grafici Excel senza perdere il ChartML copre ciò che il merge conserva; questo articolo riguarda le condizioni nelle quali un grafico non modificato non ci arriva mai

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;  // il valore predefinito; deve valere prima di Open
    Book.StrictOOXML := False;              // l'output Strict rigenera ogni grafico
    if Book.Open(Src) <> 1 then
      raise Exception.Create('Cannot open ' + Src);
    for S := 1 to Book.Sheets.Count do      // Sheets[] è 1-based
    begin
      Sheet := Book.Sheets[S];
      for C := 0 to Sheet.Charts.Count - 1 do  // Charts[] è 0-based
        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 un grafico deve essere rigenerato dal modello di proposito, per esempio per eliminare formattazioni portate dalla sorgente, TXLSXChart.ClearPreservedXml scarta i byte conservati e quel grafico viene scritto dal solo modello

Perché mai ogni grafico prenderebbe il percorso di merge?

Perché un fingerprint preso un passaggio troppo presto non corrisponde mai. L'XML dei grafici raramente viene letto in un unico passaggio. Un parse in streaming costruisce serie, assi e gruppi di plot, e poi una serie di passaggi successivi legge dettagli dal testo grezzo del grafico che sono più facili da recuperare una volta che le serie esistono: il flag <c:smooth> su ogni <c:ser>, i valori srgbClr del riempimento e della linea del marker, le modalità di intersezione degli assi e gli stili dei tick mark maggiori e minori. La collezione delle serie semplicemente non esiste ancora mentre il passaggio in streaming gira, quindi quei passaggi devono venire dopo

Supponete che il fingerprint venga catturato alla fine del passaggio in streaming, prima che girino i passaggi successivi. Descrive allora un modello senza flag smooth, senza colori dei marker e con tick mark predefiniti. Al momento del salvataggio il serializzatore gira contro il modello completato ed emette <c:smooth val="1"/> più i colori recuperati. L'XML è più lungo, l'hash differisce, e ogni grafico di ogni workbook conservato passa per il merge a ogni salvataggio. Su un grafico a linee con serie smussate e marker personalizzati i nodi <c:ser> rigenerati sostituiscono gli originali, e da lì arriva la deriva dei colori

HotXLS cattura il fingerprint solo dopo che ogni passaggio di recupero è finito, così l'hash descrive il modello così come l'applicazione lo vede la prima volta. Gli stessi due numeri vengono ristabiliti dopo un salvataggio riuscito, questa volta dal file di output, e anche quella seconda cattura parte da un modello completamente analizzato. Un hash di rilevamento delle modifiche deve essere preso dopo l'ultimo passaggio che può ancora mutare il modello. Prenderlo prima produce un controllo che fallisce a ogni salvataggio

Gli offset di anchor vivono nella parte drawing

Il sintomo dell'aggancio alla griglia non c'entra nulla con l'XML dei grafici. La posizione di un grafico sul foglio è memorizzata nella parte drawing, e la forma consueta è xdr:twoCellAnchor (ECMA-376 Part 1 §20.5): un angolo from e un angolo to, ciascuno con col, colOff, row e rowOff. Gli offset sono English Metric Units, 914400 per pollice e 12700 per punto, ed Excel scrive valori non nulli ogniqualvolta un grafico viene trascinato o ridimensionato col mouse. Per un file che dichiara un angolo from a colonna 0, riga 0 con un rowOff di 19049, e un angolo to a colonna 8, riga 15 con un colOff di 247650 e un rowOff di 66674, il grafico inizia 1,5 punti sotto la cima della riga 1 e finisce poco più di un quarto di pollice dentro la colonna I

Anatomia degli angoli xdr:twoCellAnchor per un grafico d'esempio: from contiene col 0 e rowOff 19049 mentre to contiene col 8, colOff 247650 e rowOff 66674 in EMU di 914400 per pollice, e poiché un writer che emette offset a zero aggancia i grafici alla griglia, HotXLS riscrive gli offset importati al salvataggio
L'anchor vive nella parte drawing e non nella parte chart, quindi il replay degli offset è indipendente dal fingerprint, e un round trip fedele ha bisogno di entrambi

Un writer che legge quei quattro numeri e poi emette <xdr:colOff>0</xdr:colOff> per ogni angolo sposta ogni grafico sulle linee della griglia. La parte chart può riprodursi byte per byte e il grafico si sposta comunque, perché i due vivono in parti diverse. HotXLS conserva il tipo di anchor caricato (twoCellAnchor, oneCellAnchor o absoluteAnchor, esposto come PhysicalAnchorKind) insieme ai suoi valori EMU, e li riscrive invariati. Gli offset si leggono come FromOffsetXEMU, FromOffsetYEMU, ToOffsetXEMU e ToOffsetYEMU, e le conversioni di unità alle spalle sono sviluppate nella nota sulla geometria delle immagini e lo scaling EMU di 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]));
  // Sostituisce l'intero frame in una chiamata: righe e colonne sono 1-based,
  // gli offset sono EMU dentro la cella, l'ultimo argomento è il 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;

Usate SetTwoCellAnchor quando l'intenzione è spostare o ridimensionare. Assegnare FromRow, FromCol, ToRow o ToCol su un grafico a due celle cambia solo quell'indice di cella e lascia gli offset dove sono, che è il comportamento giusto per una piccola correzione e quello sbagliato per piazzare un grafico da zero. SetTwoCellAnchor valida il frame nel suo complesso e solleva ERangeError quando l'angolo finale precede quello iniziale o gli offset escono dall'intervallo EMU di Excel. Un grafico creato con AddChart parte con offset a zero, quindi sta esattamente sui bordi delle celle finché non gliene date di propri

Dimostrare il round trip con un confronto byte per byte

Aprire l'output in Excel dimostra poco qui. Excel normalizza abbastanza al caricamento che un grafico riserializzato sembra identico finché qualcuno non nota che il colore di un marker è cambiato, e un aggancio alla griglia di pochi punti è facile da perdere sullo schermo. Il controllo che vale la pena automatizzare confronta le parti chart byte per byte dopo un open-and-save senza modifiche, e il TZipFile della RTL è tutto ciò che serve

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;

Alcune condizioni decidono se una differenza in quel controllo significa qualcosa:

  • PreserveUnsupportedParts deve valere True prima dell'Open; con esso disattivato nessun byte originale viene conservato e ogni grafico viene scritto dal modello
  • StrictOOXML deve valere False, perché l'output Strict rigenera volutamente i grafici. Lasciatelo disattivato in ogni pipeline che promette che grafici intatti sopravvivono a un salvataggio
  • Un pacchetto sorgente scritto con namespace Strict OOXML viene convertito ai namespace transitional in un salvataggio transitional, quindi i byte dei suoi grafici differiscono anche quando tutto il resto è invariato
  • Il codice fra Open e SaveAs può leggere liberamente le proprietà dei grafici, ma qualsiasi setter che cambia il modello cambia il fingerprint e manda quel grafico nel merge, il che è corretto e resta fuori da ciò che questo test misura
  • Le parti chart vengono rinumerate da un contatore a livello di workbook al salvataggio, quindi un workbook il cui ordine di fogli o grafici è cambiato mette byte identici sotto un nome chartN.xml diverso; un checker per quel caso segue le relazioni dei drawing invece di confrontare i nomi

Gli anchor richiedono un confronto per valore. La parte drawing viene scritta da zero attorno ai grafici che contiene, quindi i suoi byte sono la cosa sbagliata da confrontare; riaprire entrambi i file e confrontare FromOffsetXEMU fino a ToOffsetYEMU grafico per grafico becca un writer che trasforma 247650 in 0. Un controllo strutturale che percorre le relazioni fra foglio di lavoro, drawing e chart e fallisce su qualsiasi riferimento chart duplicato, orfano o pendente becca il caso delle tre parti prima ancora che il confronto byte per byte parta

Tutto questo gira in codice nativo Delphi e C++Builder senza installazione di Excel sulla macchina, quindi il workbook che torna da un job batch è l'unica prova di ciò che è successo ai suoi grafici. Licenze, l'API completa dei grafici e una build di prova sono sulla pagina del componente foglio di calcolo Delphi HotXLS