Teknisk artikel

HotXLS chart-fingerprint-timing og anchor-offsets i Delphi

HotXLS Delphi Component afspiller et uændret Excel-diagram byte for byte, kun når to ting holder sig: Diagrammet blev nået via regnearkets drawing-relationship frem for et gættet partnavn, og det 64-bit model-fingerprint blev fanget, efter chart-modellen var færdig med at parse. Version 2.382.0 rettede den første betingelse, version 2.382.3 rettede den anden og begyndte at round-trippe de nonzero xdr:colOff- og xdr:rowOff-anchor-offsets, som drawing-writeren havde hardcodet til nul. Begge defekter kom ud af én lokal corpus-case, two-charts.xlsx: Først så en strukturel assertion to chart-parts blive til tre, derefter viste en byte-sammenligning af hver eneste xl/charts/chartN.xml, at diagrammer, ingen havde rørt, stadig blev skrevet om — og intet af det raise-de en exception eller fik Excel til at brokke sig, og det er derfor, de overlevede så længe, som de gjorde

Hvorfor kom en arbejdsbog med to diagrammer tilbage med tre chart-parts?

Fordi loaderen havde en fallback, der gættede. Når et ark ikke havde nogen drawing-relationship i sin .rels-part, gik den gamle kode ud fra, at drawingen lå under det konventionelle navn xl/drawings/drawing{i+1}.xml, hvor i er arkets position, og koblede den part på, hvis den fandtes i arkivet. I two-charts.xlsx har det første ark hverken drawing eller en .rels-part overhovedet, mens xl/drawings/drawing1.xml godt findes — den tilhører det andet ark, som når den via Target="../drawings/drawing1.xml". Ark 1 arvede derfor et diagram, det aldrig refererede, chart1.xml blev parset to gange, og gemmet skrev arbejdsbogen ud med tre chart-parts i stedet for to

Sådan resolver HotXLS regnearks-drawings i two-charts-eksemplet: Sheet1 har ingen drawing-relationship og ingen rels-part, mens Sheet2 når xl/drawings/drawing1.xml via ParPartTargets, og fallbacken før 2.382.0 gættede det konventionelle navn ud fra arkets position, så chart1.xml blev parset to gange, og gem skrev tre chart-parts, indtil fixet kun indlæste drawings via XlsxRtDrawing
Sheet1 refererede aldrig til et diagram, så relationship-grafen er den eneste sikre kilde til drawing-target, og et gættet konventionelt navn forvandlede en arbejdsbog med to diagrammer til et gem med tre parts

Fixet i HotXLS v2.382.0 fjernede gættet helt. En regnearks-drawing indlæses nu udelukkende via ParPartTargets[i].Values[XlsxRtDrawing], den target, der er registreret for drawing-relationship-typen på det pågældende ark, og et ark uden en sådan relationship får slet ingen drawing. Det er den adfærd, formatet kræver: Elementet <drawing r:id="…"/> i regnearket (ECMA-376 Part 1 §18.3.1.36) er den eneste forbindelse mellem et ark og dets drawing, og partnavne i en OPC-pakke betyder intet ud over det, relationship-grafen tildeler dem. Arkiver skrevet af Excel bruger tilfældigvis de konventionelle navne, og det er derfor, genvejen kunne bestå så længe; gennemgangen i OPC relationship resolution i HotXLS dækker, hvorfor det aldrig er sikkert at gætte et partnavn, selv når gættet som regel er rigtigt

// Før v2.382.0: en manglende drawing-relationship faldt tilbage til et gæt
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // kan tilhøre et andet ark

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

Hvad garanterer chart-fingerprintet?

Fingerprintet afgør pr. diagram, om gemningen kan kopiere den originale part eller må generere den forfra. Ved import, med PreserveUnsupportedParts slået til før Open, gemmer HotXLS de rå UTF-8-bytes af hver chart-part i FRawChartXml, bygger den typede models egen serialisering med BuildChartKnownXml og gemmer serialiseringens længde i FRawChartModelLength og dens hash i FRawChartModelHash. Hashen er FNV-1a over den genererede XMLs UTF-16 code units med den sædvanlige 64-bit offset-basis 14695981039346656037 og primtallet 1099511628211. Ved gemning genopbygger XlsxChartRawModelUnchanged den kendte XML og sammenligner længde og hash; et match betyder, at den typede model er præcis, som den var ved import, så intet af det, applikationen kunne have ændret, er ændret

HotXLS fanger chart-fingerprintet ved import og gemmer rå UTF-8-bytes i FRawChartXml, mens BuildChartKnownXml giver FRawChartModelLength og en FNV-1a-hash, og ved gemning genopbygger XlsxChartRawModelUnchanged og sammenligner begge værdier, så et match afspiller original-bytes eller kopierer den komprimerede post, og et mismatch falder videre til XlsxMergeChartXml
Et fingerprint er kun så godt som det øjeblik, det tages, og at fange det, før alle recovery-passes er færdige, garanterer en hash, der aldrig igen matcher den færdige model
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                                  // intet bevaret
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // ordret replay
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // strukturel merge
end;

XLSX-writeren går ét skridt videre end BuildChartXmlFromKnown. Når modellen er uændret og StrictOOXML er slået fra, forsøger den først at kopiere den komprimerede post direkte fra kildearkivet til outputtet under diagrammets nye partnavn, så bytes slet ikke bliver dekodet og re-deflatet. Først hvis den kopiering ikke er mulig, falder den videre til decode-eller-merge-vejen. Mekanismen selv — længde plus hash, replay ved lighed, merge ved ulighed — er den, der er beskrevet i noten om at redigere Excel-diagrammer uden at miste ChartML. Denne artikel handler om, hvordan den lydløst holdt op med at virke

Hvorfor endte alle diagrammer alligevel på merge-vejen?

Fordi fingerprintet blev fanget ét kald for tidligt. Chart-parsing i HotXLS er et SAX-pass hen over chart-partens efterfulgt af en række recovery-passes, der trækker detaljer ud af den rå tekst, som SAX-handlerne ikke modellerer direkte: XlsxChartParseSeriesFlags læser hver <c:ser>-blok for dens <c:smooth>-flag og srgbClr-værdierne for marker-fill og marker-linje og genvinder derefter akse-krydsningstilstande samt store og små tick-mark-stile for kategori- og værdiakserne. Før v2.382.3 var rækkefølgen til sidst i ParseChartXml: klassificér aksegrupperne, byg den kendte XML, fang længde og hash, og kør først derefter XlsxChartParseSeriesFlags. Fingerprintet beskrev altså en model, der stadig manglede smooth-flags, marker-farver og tick marks. Ved gemning blev BuildChartKnownXml kørt mod den færdige model, som nu emitterede <c:smooth val="1"/> og de genvundne marker-farver. Længere XML, anderledes hash, XlsxChartRawModelUnchanged returnerede False, og diagrammet gik gennem XlsxMergeChartXml. Mergen er en korrekt operation for et diagram, nogen har redigeret, men den er ikke byte-bevarende: Den reserialiserer træet, og den ejerskabsregel, der lader den typede model vinde for serier, akser og plotgrupper, betyder, at de regenererede noder erstatter originalerne. Det synlige resultat i corpus-kørslen var skæve seriesfarver på diagrammer, ingen havde redigeret — hvert diagram i hver bevaret arbejdsbog, ved hvert gem, uden nogen diagnostik nogen steder

Reparationen er én enkelt omprioritering: XlsxChartParseSeriesFlags kører nu, før den kendte XML bygges, så fingerprintet beskriver modellen, som den vil se ud, når applikationen ser den første gang. Lektien generaliserer ud over diagrammer. Et change-detection-fingerprint er kun så godt som det øjeblik, det tages, og det sikre øjeblik er, efter alle passes, der kan mutere modellen, er færdige. HotXLS har et andet capture-sted for de samme to værdier, basen, den genetablerer mod outputfilen efter et vellykket gem, og det sted havde altid kørt mod en fuldt parset model; import-stedet var det afvigende punkt

Hvor blev anchor-offsetsne af?

Ind i et bogstaveligt nul. En twoCellAnchor i drawing-part'en fastgør et diagram mellem to celler, og hvert hjørne bærer et celleindeks plus en offset inde i den celle: from (ECMA-376 Part 1 §20.5.2.5) og to (§20.5.2.32) holder hver især col, colOff (§20.5.2.4), row og rowOff. Offsetsne er i English Metric Units, 914400 pr. tomme, og Excel skriver nonzero-værdier, hver gang et diagram er placeret eller skalering med musen, hvilket er de fleste diagrammer. Det første diagram i two-charts.xlsx starter ved række 0 med en rowOff på 19049 og slutter ved kolonne 8, række 15 med en colOff på 247650 og en rowOff på 66674 — omkring en kvart tomme inde i den sidste kolonne. Drawing-parseren i HotXLS havde altid læst de fire værdier — billedkoden brugte dem — men chart-writeren emitterede <xdr:colOff>0</xdr:colOff> og <xdr:rowOff>0</xdr:rowOff> for hvert hjørne og snappede hvert diagram til cellegitteret ved gemning

Anatomien af xdr:twoCellAnchor-hjørnerne for det første diagram i HotXLS-eksemplet: from holder col 0 og rowOff 19049, mens to holder col 8, colOff 247650 og rowOff 66674 i EMU med 914400 pr. tomme, og writeren, der emitterede nul-offsets, snappede diagrammer til gitteret, indtil FFromColOff, FToColOff og deres søskende afspillede de importerede værdier
Ankeret bor i drawing-part'en og ikke i chart-part'en, så denne reparation er uafhængig af fingerprint-fixet, og begge skulle shippe, før arbejdsbogen virkelig round-trippede
// Siden v2.382.3 afspiller anchor-writeren de importerede 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 bærer nu FFromColOff, FFromRowOff, FToColOff og FToRowOff, udfyldt fra drawing-parseren og kopieret med sammen med den øvrige anchor-tilstand, når et diagram tildeles. De er bevidst private: Den offentlige anchor-overflade er stadig de fire cellekoordinater FromRow, FromCol, ToRow og ToCol, og et diagram oprettet fra Delphi-kode lander på cellegrænser som før. Offsetsne findes for at gøre en round trip trofast, ikke for at eksponere sub-celle-positionering som en feature. Bemærk, at dette fix er uafhængigt af fingerprintet: Ankeret bor i drawing-part'en, ikke i chart-part'en, så et diagram, hvis ChartML blev afspillet perfekt, ville stadig være sprunget til gitteret uden det. Enhedskonverterne bag disse EMU-værdier er dækket i noten om HotXLS image geometry og EMU-skalering

Hvordan beviser du, at et diagram round-tripper uændret?

Ved at sammenligne bytes, ikke ved at åbne resultatet i Excel. Excel reparerer og normaliserer så meget ved indlæsning, at et diagram med skæve farver ser fint ud, lige til en analytiker opdager, at marker-farven har skiftet. Corpus-testen, der fangede begge defekter, gør tre ting efter en open-and-save uden redigeringer: Den går regnearks-, drawing- og chart-relationshiperne igennem og fejler ved enhver duplikeret, forældreløs eller dinglende chart-reference; den sammenligner en signatur af chart-type, serieformler og anchor-geometri mellem original og output; og for two-charts.xlsx læser den hver xl/charts/chartN.xml fra begge arkiver og kræver identiske bytes. Det samme tjek er nemt at skrive i Delphi med RTL'ens 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, hvis parten er forsvundet
        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 betingelser gør den sammenligning meningsfuld, og hver af dem fejler lydløst, hvis man glemmer den. PreserveUnsupportedParts skal være True før Open, ellers fanges ingen rå bytes, og hvert diagram genopbygges fra modellen. StrictOOXML skal være False, fordi strict mode bevidst tvinger regeneration frem. Og applikationen må ikke røre diagrammet mellem open og save — det er fint at læse properties, men enhver setter, der ændrer den typede model, vender fingerprintet og sender diagrammet ned ad merge-vejen, hvilket er korrekt adfærd og ikke, hvad denne test er til. Chart-parts nummereres desuden om fra en workbook-dækkende tæller ved gemning, så en arbejdsbog, hvis ark- eller diagramrækkefølge har skiftet, placerer identiske bytes under et andet chartN.xml-navn; corpus-checkeren følger relationships frem for navne af den grund

Begge fixes shippede i HotXLS 2.382.0 og 2.382.3 og er verificeret på Win32 og Win64 mod det lokale korpus, hvor chart-eksemplerne desuden er gemt igen og renderet gennem en uafhængig office suite til PDF og sammenlignet side for side med originalerne. HotXLS læser, redigerer og skriver XLSX-diagrammer fra native Delphi- og C++Builder-kode uden nogen Excel-installation involveret, og det er det, der gør dette niveau af fidelity til bibliotekets ansvar — siden HotXLS Delphi spreadsheet-komponent har featurelisten og en trial-download