Technical Article

HotXLS Chart Fingerprint Timing and Anchor Offsets in Delphi

HotXLS Delphi Component replays an unmodified Excel chart byte for byte only when two things hold: the chart was reached through the worksheet drawing relationship rather than a guessed part name, and the 64-bit model fingerprint was captured after the chart model finished parsing. Version 2.382.0 fixed the first condition, version 2.382.3 fixed the second and started round-tripping the nonzero xdr:colOff and xdr:rowOff anchor offsets that the drawing writer had been hard-coding to zero. Both defects came out of one local corpus case, two-charts.xlsx: first a structural assertion saw two chart parts become three, then a byte comparison of every xl/charts/chartN.xml showed charts nobody had touched still being rewritten — and neither problem raised an exception or made Excel complain, which is why they survived as long as they did

Why did a two-chart workbook come back with three chart parts?

Because the loader had a fallback that guessed. When a worksheet had no drawing relationship in its .rels part, the old code assumed the drawing lived at the conventional name xl/drawings/drawing{i+1}.xml, where i is the sheet position, and attached that part if it existed in the archive. In two-charts.xlsx the first sheet has no drawing and no .rels part at all, while xl/drawings/drawing1.xml does exist — it belongs to the second sheet, which reaches it through Target="../drawings/drawing1.xml". Sheet 1 therefore inherited a chart it never referenced, chart1.xml was parsed twice, and the save wrote the workbook out with three chart parts instead of two

How HotXLS resolves worksheet drawings in the two-charts sample: Sheet1 carries no drawing relationship and no rels part while Sheet2 reaches xl/drawings/drawing1.xml through ParPartTargets, and the pre 2.382.0 fallback guessed that conventional name from the sheet position so chart1.xml was parsed twice and saves wrote three chart parts until the fix loaded drawings only through XlsxRtDrawing
Sheet1 never referenced a chart, so the relationship graph is the only safe source for the drawing target, and a guessed conventional name turned a two-chart workbook into a three-part save

The fix in HotXLS v2.382.0 removed the guess entirely. A worksheet drawing is now loaded only through ParPartTargets[i].Values[XlsxRtDrawing], the target recorded for the drawing relationship type on that sheet, and a sheet with no such relationship gets no drawing at all. That is the behavior the format demands: the <drawing r:id="…"/> element in the worksheet (ECMA-376 Part 1 §18.3.1.36) is the only link between a sheet and its drawing, and part names in an OPC package carry no meaning beyond what the relationship graph assigns them. Archives written by Excel happen to use the conventional names, which is what let the shortcut pass for so long; the walkthrough of OPC relationship resolution in HotXLS covers why guessing a part name is never safe even when the guess is usually right

// Before v2.382.0: a missing drawing relationship fell back to a guess
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // may belong to another sheet

// Since v2.382.0: relationship or nothing
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

What does the chart fingerprint guarantee?

The fingerprint decides, per chart, whether the save can copy the original part or must regenerate it. On import, with PreserveUnsupportedParts enabled before Open, HotXLS keeps the raw UTF-8 bytes of each chart part in FRawChartXml, builds the typed model's own serialization with BuildChartKnownXml, and stores that serialization's length in FRawChartModelLength and its hash in FRawChartModelHash. The hash is FNV-1a over the UTF-16 code units of the generated XML, with the standard 64-bit offset basis 14695981039346656037 and prime 1099511628211. At save time XlsxChartRawModelUnchanged rebuilds the known XML and compares length and hash; a match means the typed model is exactly what it was at import, so nothing the application could have changed has changed

HotXLS captures the chart fingerprint on import, keeping raw UTF-8 bytes in FRawChartXml while BuildChartKnownXml yields FRawChartModelLength and an FNV-1a hash, and at save time XlsxChartRawModelUnchanged rebuilds and compares both values, so a match replays the original bytes or copies the compressed entry and a mismatch falls through to XlsxMergeChartXml
The fingerprint is only as good as the moment it is taken, and capturing it before every recovery pass has finished guarantees a hash that never matches the completed model again
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                                  // nothing preserved
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // verbatim replay
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // structural merge
end;

The XLSX writer goes one step further than BuildChartXmlFromKnown. When the model is unchanged and StrictOOXML is off, it first tries to copy the compressed entry straight from the source archive into the output under the chart's new part name, so the bytes are not even decoded and re-deflated. Only if that copy is not possible does it fall through to the decode-or-merge path. The mechanism itself — length plus hash, replay when equal, merge when not — is the one described in the note on editing Excel charts without losing ChartML. This article is about the way it silently stopped working

Why did every chart take the merge path anyway?

Because the fingerprint was captured one call too early. Chart parsing in HotXLS is a SAX pass over the chart part followed by a set of recovery passes that pull details out of the raw text which the SAX handlers do not model directly: XlsxChartParseSeriesFlags reads each <c:ser> block for its <c:smooth> flag and the srgbClr values of the marker fill and marker line, then recovers axis crossing modes and major and minor tick-mark styles for the category and value axes. Before v2.382.3 the order at the end of ParseChartXml was: classify the axis groups, build the known XML, capture length and hash, and only then run XlsxChartParseSeriesFlags. The fingerprint therefore described a model that still lacked smooth flags, marker colors and tick marks. At save time BuildChartKnownXml ran against the completed model, which now emitted <c:smooth val="1"/> and the recovered marker colors. Longer XML, different hash, XlsxChartRawModelUnchanged returned False, and the chart went through XlsxMergeChartXml. The merge is a correct operation for a chart somebody edited, but it is not a byte-preserving one: it reserializes the tree, and the ownership rule that lets the typed model win for series, axes and plot groups means the regenerated nodes replace the originals. The visible result in the corpus run was drifted series colors on charts nobody had edited — every chart in every preserved workbook, on every save, with no diagnostic anywhere

The repair is a single reordering: XlsxChartParseSeriesFlags now runs before the known XML is built, so the fingerprint describes the model as it will exist when the application first sees it. The lesson generalizes beyond charts. A change-detection fingerprint is only as good as the moment it is taken, and the safe moment is after every pass that can mutate the model has finished. HotXLS has a second capture site for the same two values, the baseline it re-establishes against the output file after a successful save, and that site had always run against a fully parsed model; the import-time site was the odd one out

Where did the anchor offsets go?

Into a literal zero. A twoCellAnchor in the drawing part pins a chart between two cells, and each corner carries a cell index plus an offset inside that cell: from (ECMA-376 Part 1 §20.5.2.5) and to (§20.5.2.32) each hold col, colOff (§20.5.2.4), row and rowOff. The offsets are in English Metric Units, 914400 to the inch, and Excel writes nonzero values whenever a chart was placed or resized with the mouse, which is most charts. The first chart in two-charts.xlsx starts at row 0 with a rowOff of 19049 and ends at column 8, row 15 with a colOff of 247650 and a rowOff of 66674 — about a quarter inch into the last column. The drawing parser in HotXLS had always read those four values — the image code used them — but the chart writer emitted <xdr:colOff>0</xdr:colOff> and <xdr:rowOff>0</xdr:rowOff> for every corner, snapping each chart to the cell grid on save

Anatomy of the xdr:twoCellAnchor corners for the first chart of the HotXLS sample: from holds col 0 and rowOff 19049 while to holds col 8, colOff 247650 and rowOff 66674 in EMU of 914400 per inch, and the writer that emitted zero offsets snapped charts to the grid until FFromColOff, FToColOff and their siblings replayed the imported values
The anchor lives in the drawing part rather than the chart part, so this repair is independent of the fingerprint fix, and both had to ship before the workbook truly round-tripped
// Since v2.382.3 the anchor writer replays the imported EMU offsets
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 now carries FFromColOff, FFromRowOff, FToColOff and FToRowOff, filled from the drawing parser and copied along with the other anchor state when a chart is assigned. They are deliberately private: the public anchor surface is still the four cell coordinates FromRow, FromCol, ToRow and ToCol, and a chart created from Delphi code lands on cell boundaries as before. The offsets exist to make a round trip faithful, not to expose sub-cell positioning as a feature. Note that this fix is independent of the fingerprint: the anchor lives in the drawing part, not the chart part, so a chart whose ChartML replayed perfectly would still have jumped to the grid without it. The unit conversions behind those EMU values are covered in the note on HotXLS image geometry and EMU scaling

How do you prove a chart round-trips unchanged?

By comparing bytes, not by opening the result in Excel. Excel repairs and normalizes so much on load that a drifted chart looks fine right up until an analyst notices the marker color changed. The corpus test that caught both defects does three things after an open-and-save with no edits: it walks worksheet, drawing and chart relationships and fails on any duplicate, orphaned or dangling chart reference; it compares a signature of chart type, series formulas and anchor geometry between original and output; and for two-charts.xlsx it reads each xl/charts/chartN.xml from both archives and requires identical bytes. The same check is easy to write in Delphi with the RTL TZipFile

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);   // raises if the part vanished
        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;

Three conditions make that comparison meaningful, and each one fails quietly if forgotten. PreserveUnsupportedParts must be True before Open, or no raw bytes are captured and every chart is rebuilt from the model. StrictOOXML must be False, because strict mode forces regeneration by design. And the application must not touch the chart between open and save — reading properties is fine, but any setter that changes the typed model flips the fingerprint and sends the chart down the merge path, which is correct behavior and not what this test is for. Chart parts are also renumbered from a workbook-wide counter on save, so a workbook whose sheet order or chart order changed will place identical bytes under a different chartN.xml name; the corpus checker follows relationships rather than names for that reason

Both fixes shipped in HotXLS 2.382.0 and 2.382.3 and are verified on Win32 and Win64 against the local corpus, with the resaved chart samples also rendered through an independent office suite to PDF and compared page by page against the originals. HotXLS reads, edits and writes XLSX charts from native Delphi and C++Builder code with no Excel installation involved, which is what makes this level of fidelity a library responsibility — the HotXLS Delphi spreadsheet component page has the feature list and a trial download