Technischer Artikel

HotXLS-Chart-Fingerprint und Anker-Offsets in Delphi

Die HotXLS Delphi Component spielt ein unverändertes Excel-Chart nur dann Byte für Byte zurück, wenn zwei Dinge gelten: Das Chart wurde über die Worksheet-Drawing-Beziehung erreicht statt über einen geratenen Part-Namen, und der 64-Bit-Modell-Fingerprint wurde erst genommen, nachdem das Chart-Modell mit dem Parsen fertig war. Version 2.382.0 hat die erste Bedingung repariert, Version 2.382.3 die zweite – und begann zusätzlich, die von null verschiedenen xdr:colOff- und xdr:rowOff-Anker-Offsets zurückzuspielen, die der Drawing-Writer bisher stur auf null hartkodiert hatte. Beide Defekte stammten aus einem einzigen lokalen Korpus-Fall, two-charts.xlsx: Zuerst sah eine strukturelle Assertion zwei Chart-Parts zu dreien anwachsen, dann zeigte ein Byte-Vergleich jeder einzelnen xl/charts/chartN.xml, dass Charts, die niemand angefasst hatte, trotzdem neu geschrieben wurden – und keines der beiden Probleme warf eine Exception oder ließ Excel meckern, weshalb sie so lange überleben konnten

Warum kam eine Mappe mit zwei Charts mit drei Chart-Parts zurück?

Weil der Loader einen Fallback hatte, der riet. Hatte ein Worksheet keine Drawing-Beziehung in seinem .rels-Part, nahm der alte Code an, das Drawing liege unter dem konventionellen Namen xl/drawings/drawing{i+1}.xml, wobei i die Sheet-Position ist, und hängte diesen Part an, wenn er im Archiv existierte. In two-charts.xlsx hat das erste Sheet weder ein Drawing noch überhaupt ein .rels-Part, während es xl/drawings/drawing1.xml durchaus gibt – es gehört zum zweiten Sheet, das es über Target="../drawings/drawing1.xml" erreicht. Sheet 1 erbte also ein Chart, auf das es nie verwiesen hatte, chart1.xml wurde zweimal geparst, und das Speichern schrieb die Mappe mit drei Chart-Parts statt zweien raus

Wie HotXLS die Worksheet-Drawings im two-charts-Beispiel auflöst: Sheet1 trägt weder eine Drawing-Beziehung noch ein rels-Part, während Sheet2 xl/drawings/drawing1.xml über ParPartTargets erreicht, und der Fallback vor 2.382.0 riet jenen konventionellen Namen aus der Sheet-Position, sodass chart1.xml zweimal geparst wurde und Saves drei Chart-Parts schrieben, bis der Fix Drawings nur noch über XlsxRtDrawing lud
Sheet1 hat nie auf ein Chart verwiesen, daher ist der Beziehungsgraph die einzige sichere Quelle für das Drawing-Ziel, und ein geratener konventioneller Name machte aus einer Mappe mit zwei Charts einen Drei-Part-Save

Der Fix in HotXLS v2.382.0 hat den Ratemechanismus komplett entfernt. Ein Worksheet-Drawing wird jetzt ausschließlich über ParPartTargets[i].Values[XlsxRtDrawing] geladen – das für den Drawing-Beziehungstyp auf diesem Sheet eingetragene Ziel –, und ein Sheet ohne eine solche Beziehung bekommt schlicht kein Drawing. Genau das verlangt das Format: Das Element <drawing r:id="…"/> im Worksheet (ECMA-376 Part 1 §18.3.1.36) ist der einzige Link zwischen einem Sheet und seinem Drawing, und Part-Namen in einem OPC-Paket tragen keine Bedeutung über das hinaus, was der Beziehungsgraph ihnen zuweist. Archive, die Excel schreibt, benutzen zufällig die konventionellen Namen, weshalb der Shortcut so lange durchging; der Rundgang durch die OPC-Beziehungsauflösung in HotXLS erklärt, warum es nie sicher ist, einen Part-Namen zu raten, selbst wenn der Rat meistens stimmt

// Vor v2.382.0: eine fehlende Drawing-Beziehung fiel auf einen Ratename-Fallback zurück
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // kann zu einem anderen Sheet gehören

// Seit v2.382.0: Beziehung oder nichts
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

Was garantiert der Chart-Fingerprint?

Der Fingerprint entscheidet pro Chart, ob der Save den Original-Part kopieren oder ihn neu generieren kann. Beim Import – mit aktiviertem PreserveUnsupportedParts vor dem Open – bewahrt HotXLS die rohen UTF-8-Bytes jedes Chart-Parts in FRawChartXml, baut die eigene Serialisierung des typisierten Modells mit BuildChartKnownXml und legt die Länge dieser Serialisierung in FRawChartModelLength sowie ihren Hash in FRawChartModelHash ab. Der Hash ist FNV-1a über die UTF-16-Code-Units des generierten XML, mit der 64-Bit-Standard-Offset-Basis 14695981039346656037 und der Primzahl 1099511628211. Zur Save-Zeit baut XlsxChartRawModelUnchanged das Known-XML neu und vergleicht Länge und Hash; eine Übereinstimmung bedeutet, dass das typisierte Modell exakt das vom Import ist, also nichts geändert hat, was die Anwendung hätte ändern können

HotXLS nimmt den Chart-Fingerprint beim Import auf und bewahrt rohe UTF-8-Bytes in FRawChartXml, während BuildChartKnownXml FRawChartModelLength und einen FNV-1a-Hash liefert; zur Save-Zeit baut XlsxChartRawModelUnchanged beide Werte neu und vergleicht sie, sodass eine Übereinstimmung die Original-Bytes zurückspielt oder den komprimierten Eintrag kopiert und eine Abweichung an XlsxMergeChartXml durchfällt
Ein Fingerprint ist nur so gut wie der Moment seiner Aufnahme, und ihn zu nehmen, bevor jeder Recovery-Pass fertig ist, garantiert einen Hash, der mit dem fertigen Modell nie wieder übereinstimmt
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                                  // nichts erhalten
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // wortgetreue Wiedergabe
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // struktureller Merge
end;

Der XLSX-Writer geht noch einen Schritt über BuildChartXmlFromKnown hinaus. Ist das Modell unverändert und StrictOOXML aus, versucht er zuerst, den komprimierten Eintrag direkt aus dem Quellarchiv unter dem neuen Part-Namen des Charts in die Ausgabe zu kopieren, sodass die Bytes nicht einmal dekodiert und neu gepackt werden. Nur wenn diese Kopie nicht möglich ist, fällt er auf den Decode-or-Merge-Pfad zurück. Der Mechanismus selbst – Länge plus Hash, Wiedergabe bei Gleichheit, Merge bei Ungleichheit – ist der aus dem Beitrag zu Excel-Charts bearbeiten, ohne ChartML zu verlieren. Dieser Artikel handelt davon, wie er stillschweigend aufgehört hat zu funktionieren

Warum ist dennoch jedes Chart über den Merge-Pfad gegangen?

Weil der Fingerprint einen Aufruf zu früh genommen wurde. Das Chart-Parsing in HotXLS ist ein SAX-Lauf über den Chart-Part, gefolgt von einer Reihe von Recovery-Pässen, die Details aus dem Rohtext ziehen, die die SAX-Handler nicht direkt modellieren: XlsxChartParseSeriesFlags liest jeden <c:ser>-Block nach seinem <c:smooth>-Flag und den srgbClr-Werten von Marker-Füllung und Marker-Linie aus und holt danach die Achsenschnitt-Modi sowie die Major- und Minor-Tick-Mark-Styles für Kategorie- und Werteachsen. Vor v2.382.3 lautete die Reihenfolge am Ende von ParseChartXml: Achsengruppen klassifizieren, Known-XML bauen, Länge und Hash nehmen – und erst dann XlsxChartParseSeriesFlags laufen lassen. Der Fingerprint beschrieb also ein Modell, dem noch Smooth-Flags, Marker-Farben und Tick-Marks fehlten. Zur Save-Zeit lief BuildChartKnownXml gegen das fertige Modell, das jetzt <c:smooth val="1"/> und die wiederhergestellten Marker-Farben ausspuckte. Längereres XML, anderer Hash, XlsxChartRawModelUnchanged gab False zurück, und das Chart ging durch XlsxMergeChartXml. Der Merge ist für ein Chart, das jemand bearbeitet hat, eine korrekte Operation, aber keine byteerhaltende: Er serialisiert den Baum neu, und die Ownership-Regel, die dem typisierten Modell bei Serien, Achsen und Plot-Gruppen den Vorrang gibt, bedeutet, dass die neu generierten Knoten die Originale ersetzen. Das sichtbare Ergebnis im Korpus-Lauf waren gedriftete Serienfarben auf Charts, die niemand angefasst hatte – jedes Chart in jeder bewahrten Mappe, bei jedem Save, ohne jede Diagnostik irgendwo

Die Reparatur ist eine einzige Umordnung: XlsxChartParseSeriesFlags läuft jetzt, bevor das Known-XML gebaut wird, sodass der Fingerprint das Modell so beschreibt, wie es existieren wird, wenn die Anwendung es zum ersten Mal sieht. Die Lehre verallgemeinert sich über Charts hinaus. Ein Change-Detection-Fingerprint ist nur so gut wie der Moment seiner Aufnahme, und der sichere Moment ist, nachdem jeder Pass, der das Modell verändern kann, abgeschlossen ist. HotXLS hat eine zweite Aufnahmestelle für dieselben beiden Werte – die Baseline, die es nach einem erfolgreichen Save gegen die Ausgabedatei neu setzt –, und diese Stelle lief immer gegen ein vollständig geparstes Modell; die Import-Stelle war der Ausreißer

Wo sind die Anker-Offsets geblieben?

In ein buchstäbliches Null. Ein twoCellAnchor im Drawing-Part nagelt ein Chart zwischen zwei Zellen fest, und jede Ecke trägt einen Zellindex plus einen Offset innerhalb dieser Zelle: from (ECMA-376 Part 1 §20.5.2.5) und to (§20.5.2.32) halten jeweils col, colOff (§20.5.2.4), row und rowOff. Die Offsets sind in English Metric Units, 914400 pro Zoll, und Excel schreibt Werte ungleich null, wann immer ein Chart per Maus platziert oder in der Größe verändert wurde – was auf die meisten Charts zutrifft. Das erste Chart in two-charts.xlsx beginnt in Zeile 0 mit einem rowOff von 19049 und endet in Spalte 8, Zeile 15 mit einem colOff von 247650 und einem rowOff von 66674 – etwa ein Viertelzoll in die letzte Spalte hinein. Der Drawing-Parser in HotXLS hatte diese vier Werte schon immer gelesen – der Bild-Code benutzte sie –, aber der Chart-Writer schrieb <xdr:colOff>0</xdr:colOff> und <xdr:rowOff>0</xdr:rowOff> für jede Ecke und riss jedes Chart beim Save aufs Zellraster

Anatomie der xdr:twoCellAnchor-Ecken für das erste Chart des HotXLS-Beispiels: from hält col 0 und rowOff 19049, während to col 8, colOff 247650 und rowOff 66674 in EMU zu 914400 pro Zoll hält, und der Writer, der Null-Offsets schrieb, riss Charts aufs Raster, bis FFromColOff, FToColOff und ihre Geschwister die importierten Werte zurückspielten
Der Anker lebt im Drawing-Part statt im Chart-Part, daher ist diese Reparatur unabhängig vom Fingerprint-Fix, und beide mussten ausgeliefert sein, bevor die Mappe wirklich unverändert zurückkam
// Seit v2.382.3 gibt der Anker-Writer die importierten EMU-Offsets zurück
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>' + ...

TXLSXChart trägt jetzt FFromColOff, FFromRowOff, FToColOff und FToRowOff, gefüllt aus dem Drawing-Parser und zusammen mit dem übrigen Anker-Zustand kopiert, wenn ein Chart zugewiesen wird. Sie sind bewusst privat: Die öffentliche Anker-Oberfläche bleibt bei den vier Zellkoordinaten FromRow, FromCol, ToRow und ToCol, und ein aus Delphi-Code erzeugtes Chart landet wie bisher auf Zellgrenzen. Die Offsets existieren, damit ein Round Trip treu ist, nicht um Sub-Zell-Positionierung als Feature zu exportieren. Beachten Sie, dass dieser Fix unabhängig vom Fingerprint ist: Der Anker lebt im Drawing-Part, nicht im Chart-Part, sodass ein Chart, dessen ChartML perfekt zurückgespielt wurde, ohne ihn trotzdem aufs Raster gesprungen wäre. Die Einheitenumrechnungen hinter diesen EMU-Werten behandelt der Beitrag zu HotXLS-Bildgeometrie und EMU-Skalierung

Wie weisen Sie nach, dass ein Chart unverändert round-tript?

Durch einen Byte-Vergleich, nicht indem Sie das Ergebnis in Excel öffnen. Excel repariert und normalisiert beim Laden so viel, dass ein gedriftetes Chart tadellos aussieht, bis ein Analyst merkt, dass sich die Marker-Farbe geändert hat. Der Korpus-Test, der beide Defekte fing, tut nach einem Open-and-Save ohne jede Bearbeitung drei Dinge: Er läuft die Worksheet-, Drawing- und Chart-Beziehungen durch und schlägt bei jeder duplizierten, verwaisten oder hängenden Chart-Referenz fehl; er vergleicht eine Signatur aus Chart-Typ, Serienformeln und Anker-Geometrie zwischen Original und Ausgabe; und für two-charts.xlsx liest er jede xl/charts/chartN.xml aus beiden Archiven und verlangt identische Bytes. Dieselbe Prüfung lässt sich in Delphi mit dem RTL-TZipFile leicht schreiben

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);   // wirft eine Exception, wenn der Part fehlt
        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;

Drei Bedingungen machen diesen Vergleich aussagekräftig, und jede schlägt still fehl, wenn man sie vergisst. PreserveUnsupportedParts muss vor dem Open True sein, sonst werden keine Rohbytes aufgenommen und jedes Chart aus dem Modell neu gebaut. StrictOOXML muss False sein, denn der Strict-Modus erzwingt die Regeneration ausdrücklich by Design. Und die Anwendung darf das Chart zwischen Open und Save nicht anfassen – Properties lesen ist fein, aber jeder Setter, der das typisierte Modell ändert, kippt den Fingerprint und schickt das Chart auf den Merge-Pfad, was korrektes Verhalten ist und nicht das Ziel dieses Tests. Chart-Parts werden beim Save außerdem aus einem arbeitsmappenweiten Zähler neu nummeriert, sodass eine Mappe mit geänderter Sheet- oder Chart-Reihenfolge identische Bytes unter einem anderen chartN.xml-Namen ablegt; aus diesem Grund folgt der Korpus-Checker den Beziehungen statt den Namen

Beide Fixes erschienen in HotXLS 2.382.0 und 2.382.3 und sind auf Win32 und Win64 gegen das lokale Korpus verifiziert, wobei die neu gespeicherten Chart-Samples zusätzlich durch eine unabhängige Office-Suite nach PDF gerendert und Seite für Seite gegen die Originale verglichen wurden. HotXLS liest, bearbeitet und schreibt XLSX-Charts aus nativem Delphi- und C++Builder-Code ohne jede Excel-Installation – genau das macht diese Stufe an Fidelity zu einer Bibliotheksverantwortung; die Seite zur HotXLS-Delphi-Tabellenkomponente hat die Feature-Liste und einen Trial-Download