Teknisk artikel

Fingerprint-timing och ankaroffset i HotXLS-diagram

HotXLS Delphi Component spelar bara upp ett omodifierat Excel-diagram byte för byte när två saker gäller: att diagrammet nåddes via kalkylbladets drawing-relation i stället för ett gissat delnamn, och att modellens 64-bitars fingerprint fångades efter att diagrammodellen hade parsats klart. Version 2.382.0 åtgärdade det första villkoret, version 2.382.3 det andra och började dessutom rundresa ankaroffsetvärdena xdr:colOff och xdr:rowOff, som drawing-skrivaren hade hårdkodat till noll. Båda felen kom ur samma lokala korpusfall, two-charts.xlsx: först såg en strukturell assertion att två diagramdelar blev tre, sedan visade en byte-jämförelse av varje xl/charts/chartN.xml att diagram som ingen hade rört ändå skrevs om — och inget av problemen kastade något undantag eller fick Excel att klaga, vilket är varför de överlevde så länge som de gjorde

Varför kom en arbetsbok med två diagram tillbaka med tre diagramdelar?

För att inläsaren hade en fallback som gissade. När ett kalkylblad saknade drawing-relation i sin .rels-del antog den gamla koden att drawingen låg på det konventionella namnet xl/drawings/drawing{i+1}.xml, där i är arkets position, och kopplade in den delen om den fanns i arkivet. I two-charts.xlsx har första arket varken drawing eller någon .rels-del alls, medan xl/drawings/drawing1.xml visst finns — den tillhör andra arket, som når den via Target="../drawings/drawing1.xml". Ark 1 ärvde alltså ett diagram som det aldrig refererade, chart1.xml parsades två gånger, och sparningen skrev ut arbetsboken med tre diagramdelar i stället för två

Hur HotXLS löser upp kalkylblads-drawingar i two-charts-exemplet: Sheet1 saknar drawing-relation och rels-del medan Sheet2 når xl/drawings/drawing1.xml via ParPartTargets, och fallbacken före 2.382.0 gissade det konventionella namnet utifrån arkets position så chart1.xml parsades två gånger och sparningar skrev tre diagramdelar tills fixen bara laddade drawingar via XlsxRtDrawing
Sheet1 refererade aldrig något diagram, så relationsgrafen är den enda säkra källan till drawing-målet, och ett gissat konventionellt namn gjorde en arbetsbok med två diagram till en sparning med tre delar

Fixen i HotXLS v2.382.0 tog bort gissningen helt. En kalkylblads-drawing laddas nu bara via ParPartTargets[i].Values[XlsxRtDrawing], det mål som är registrerat för drawing-relationstypen på det arket, och ett ark utan en sådan relation får ingen drawing alls. Det är beteendet formatet kräver: elementet <drawing r:id="…"/> i kalkylbladet (ECMA-376 Part 1 §18.3.1.36) är den enda länken mellan ett ark och dess drawing, och delnamn i ett OPC-paket betyder ingenting utöver vad relationsgrafen tilldelar dem. Arkiv som Excel skriver råkar använda de konventionella namnen, och det är vad som lät genvägen passera så länge; genomgången av OPC-relationsuppslagning i HotXLS tar upp varför ett gissat delnamn aldrig är säkert ens när gissningen oftast stämmer

// Före v2.382.0: en saknad drawing-relation föll tillbaka på en gissning
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 tillhöra ett annat ark

// Sedan v2.382.0: relation eller inget alls
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

Vad garanterar diagrammets fingerprint?

Fingerprintet avgör, per diagram, om sparningen kan kopiera originaldelen eller måste generera den på nytt. Vid import, med PreserveUnsupportedParts aktiverat före Open, behåller HotXLS råbyten i UTF-8 för varje diagramdel i FRawChartXml, bygger den typade modellens egen serialisering med BuildChartKnownXml och lagrar serialiseringens längd i FRawChartModelLength och dess hash i FRawChartModelHash. Hashen är FNV-1a över UTF-16-kodenheterna i den genererade XML:en, med den standardmässiga 64-bitars offsetbasen 14695981039346656037 och primtalet 1099511628211. Vid sparning bygger XlsxChartRawModelUnchanged om den kända XML:en och jämför längd och hash; en träff betyder att den typade modellen är exakt vad den var vid import, så inget som programmet kunde ha ändrat har ändrats

HotXLS fångar diagram-fingerprintet vid import och behåller råbyten i UTF-8 i FRawChartXml medan BuildChartKnownXml ger FRawChartModelLength och en FNV-1a-hash, och vid sparning bygger XlsxChartRawModelUnchanged om och jämför båda värdena, så en träff spelar upp originalbyten eller kopierar den komprimerade posten och en avvikelse faller vidare till XlsxMergeChartXml
Fingerprintet är bara så bra som ögonblicket det tas, och att fånga det innan alla återställningspass har kört klart garanterar en hash som aldrig mer matchar den färdiga modellen
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                                  // inget bevarat
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // ordagrann uppspelning
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // strukturell sammanslagning
end;

XLSX-skrivaren går ett steg längre än BuildChartXmlFromKnown. När modellen är oförändrad och StrictOOXML är av försöker den först kopiera den komprimerade posten direkt från källarkivet till utdata under diagrammets nya delnamn, så byten behöver inte ens avkodas och deflateras om. Bara om den kopian inte är möjlig faller den vidare till vägen som avkodar eller slår samman. Själva mekanismen — längd plus hash, spela upp vid likhet, slå samman annars — är den som beskrivs i noten om att redigera Excel-diagram utan att tappa ChartML. Den här artikeln handlar om hur den tyst slutade fungera

Varför tog varje diagram ändå sammanslagningsvägen?

För att fingerprintet fångades ett anrop för tidigt. Diagramparsning i HotXLS är en SAX-genomgång av diagramdelen följd av en uppsättning återställningspass som plockar ut detaljer ur råtexten som SAX-hanterarna inte modellerar direkt: XlsxChartParseSeriesFlags läser varje <c:ser>-block efter dess <c:smooth>-flagga och srgbClr-värdena för markörens fyllning och markörlinje, och återställer sedan axelkorsningslägen samt stora och små skalstrecksstilar för kategori- och värdeaxlarna. Före v2.382.3 var ordningen i slutet av ParseChartXml: klassificera axelgrupperna, bygg den kända XML:en, fånga längd och hash, och kör först därefter XlsxChartParseSeriesFlags. Fingerprintet beskrev alltså en modell som fortfarande saknade smooth-flaggor, markörfärger och skalstreck. Vid sparning körde BuildChartKnownXml mot den färdiga modellen, som nu gav <c:smooth val="1"/> och de återställda markörfärgerna. Längre XML, annan hash, XlsxChartRawModelUnchanged returnerade False, och diagrammet gick genom XlsxMergeChartXml. Sammanslagningen är en korrekt operation för ett diagram som någon har redigerat, men den bevarar inte byten: den serialiserar om trädet, och ägarskapsregeln som låter den typade modellen vinna för serier, axlar och plotgrupper gör att de återskapade noderna ersätter originalen. Det synliga resultatet i korpuskörningen var driftande seriefärger på diagram som ingen hade redigerat — varje diagram i varje bevarad arbetsbok, vid varje sparning, utan någon diagnostik någonstans

Reparationen är en enda omordning: XlsxChartParseSeriesFlags kör nu innan den kända XML:en byggs, så att fingerprintet beskriver modellen som den kommer att se ut när programmet först ser den. Lärdomen gäller mer än diagram. Ett fingerprint för ändringsdetektering är bara så bra som ögonblicket det tas, och det säkra ögonblicket är efter att varje pass som kan mutera modellen har kört klart. HotXLS har en andra fångstplats för samma två värden, den baslinje den återetablerar mot utdatafilen efter en lyckad sparning, och den platsen hade alltid kört mot en fullt parsad modell; platsen vid import var undantaget

Vart tog ankaroffsetvärdena vägen?

Rakt ned i en nolla. En twoCellAnchor i drawing-delen fäster ett diagram mellan två celler, och varje hörn bär ett cellindex plus en offset inuti den cellen: from (ECMA-376 Part 1 §20.5.2.5) och to (§20.5.2.32) innehåller vardera col, colOff (§20.5.2.4), row och rowOff. Offsetvärdena är i English Metric Units, 914400 per tum, och Excel skriver icke-nollvärden närhelst ett diagram har placerats eller storleksändrats med musen, vilket gäller de flesta diagram. Det första diagrammet i two-charts.xlsx börjar på rad 0 med en rowOff på 19049 och slutar på kolumn 8, rad 15 med en colOff på 247650 och en rowOff på 66674 — ungefär en kvarts tum in i sista kolumnen. Drawing-parsern i HotXLS hade alltid läst de fyra värdena — bildkoden använde dem — men diagramskrivaren gav <xdr:colOff>0</xdr:colOff> och <xdr:rowOff>0</xdr:rowOff> för varje hörn, vilket snäppte varje diagram till cellrutnätet vid sparning

Anatomi för xdr:twoCellAnchor-hörnen i det första diagrammet i HotXLS-exemplet: from innehåller col 0 och rowOff 19049 medan to innehåller col 8, colOff 247650 och rowOff 66674 i EMU om 914400 per tum, och skrivaren som gav nollor som offset snäppte diagram till rutnätet tills FFromColOff, FToColOff och deras syskon spelade upp de importerade värdena
Ankaret bor i drawing-delen och inte i diagramdelen, så den här fixen är oberoende av fingerprint-fixen, och båda måste släppas innan arbetsboken verkligen rundreste felfritt
// Sedan v2.382.3 spelar ankarskrivaren upp de importerade EMU-offsetvärdena
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är nu FFromColOff, FFromRowOff, FToColOff och FToRowOff, fyllda från drawing-parsern och kopierade tillsammans med övrigt ankarstate när ett diagram tilldelas. De är medvetet privata: den publika ankarytan är fortfarande de fyra cellkoordinaterna FromRow, FromCol, ToRow och ToCol, och ett diagram som skapas från Delphi-kod landar på cellgränser som förut. Offsetvärdena finns för att göra en rundresa trogen, inte för att exponera positionering inom en cell som en funktion. Notera att den här fixen är oberoende av fingerprintet: ankaret bor i drawing-delen, inte i diagramdelen, så ett diagram vars ChartML spelades upp perfekt skulle ändå ha hoppat till rutnätet utan den. Enhetsomvandlingarna bakom de EMU-värdena tas upp i noten om HotXLS bildgeometri och EMU-skalning

Hur bevisar du att ett diagram rundreser oförändrat?

Genom att jämföra byten, inte genom att öppna resultatet i Excel. Excel reparerar och normaliserar så mycket vid inläsning att ett driftat diagram ser fint ut ända tills en analytiker märker att markörfärgen har ändrats. Korpusstestet som fångade båda felen gör tre saker efter en öppna-och-spara utan redigeringar: det går igenom relationerna för kalkylblad, drawing och diagram och faller på varje dubblerad, föräldralös eller dinglande diagramreferens; det jämför en signatur av diagramtyp, serieformler och ankargeometri mellan original och utdata; och för two-charts.xlsx läser det varje xl/charts/chartN.xml från båda arkiven och kräver identiska byten. Samma kontroll är enkel att skriva 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);   // kastar om delen har försvunnit
        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 villkor gör jämförelsen meningsfull, och vart och ett av dem faller tyst om det glöms bort. PreserveUnsupportedParts måste vara True före Open, annars fångas inga råbyten och varje diagram byggs om från modellen. StrictOOXML måste vara False, eftersom strikt läge tvingar fram regenerering by design. Och programmet får inte röra diagrammet mellan öppna och spara — att läsa egenskaper går bra, men varje setter som ändrar den typade modellen vänder fingerprintet och skickar diagrammet ned på sammanslagningsvägen, vilket är korrekt beteende och inte vad testet är till för. Diagramdelar numreras dessutom om från en arbetsboksövergripande räknare vid sparning, så en arbetsbok vars arkordning eller diagramordning har ändrats placerar identiska byten under ett annat chartN.xml-namn; korpuskontrollen följer därför relationer i stället för namn

Båda fixarna släpptes i HotXLS 2.382.0 och 2.382.3 och är verifierade på Win32 och Win64 mot den lokala korpusen, där de omsparade diagrapexemplen dessutom renderades genom en oberoende kontorssvit till PDF och jämfördes sida för sida mot originalen. HotXLS läser, redigerar och skriver XLSX-diagram från nativ Delphi- och C++Builder-kod utan någon Excel-installation, vilket är det som gör den här nivån av trohet till ett biblioteksansvar — HotXLS Delphi spreadsheet component-sidan har funktionslistan och en testversion