Teknisk artikkel

HotXLS: diagramfingeravtrykk og ankerforskyvninger i Delphi

HotXLS Delphi Component spiller av et uendret Excel-diagram byte for byte bare når to ting holder: diagrammet ble nådd gjennom tegningens relasjon på regnearkarket og ikke gjennom et gjettet delnavn, og 64-bits fingeravtrykket av modellen ble tatt etter at diagrammodellen var ferdig parset. Versjon 2.382.0 rettet den første betingelsen, versjon 2.382.3 rettet den andre og begynte å kjøre de ikke-null xdr:colOff- og xdr:rowOff-ankerforskyvningene tur-retur, forskyvninger som tegneskriveren hadde hardkodet til null. Begge feilene kom ut av ett lokalt korpus-tilfelle, two-charts.xlsx: først så en strukturell assert at to diagramdeler ble tre, deretter viste en byte-sammenligning av hver xl/charts/chartN.xml at diagrammer ingen hadde rørt fortsatt ble skrevet om — og ingen av problemene kastet unntak eller fikk Excel til å klage, og det er derfor de overlevde så lenge

Hvorfor kom en arbeidsbok med to diagrammer tilbake med tre diagramdeler?

Fordi innleseren hadde en reservevei som gjettet. Når et regnearkark ikke hadde noen tegnerelasjon i sin .rels-del, antok den gamle koden at tegningen lå på det konvensjonelle navnet xl/drawings/drawing{i+1}.xml, der i er arkets posisjon, og festet den delen hvis den fantes i arkivet. I two-charts.xlsx har det første arket ingen tegning og ingen .rels-del i det hele tatt, mens xl/drawings/drawing1.xml finnes — den tilhører det andre arket, som når den gjennom Target="../drawings/drawing1.xml". Ark 1 arvet derfor et diagram det aldri refererte til, chart1.xml ble parset to ganger, og lagringen skrev arbeidsboken ut med tre diagramdeler i stedet for to

Hvordan HotXLS løser opp tegninger for regnearkark i to-diagram-eksemplet: Sheet1 har ingen tegnerelasjon og ingen rels-del, mens Sheet2 når xl/drawings/drawing1.xml gjennom ParPartTargets, og reserveveien før 2.382.0 gjettet det konvensjonelle navnet ut fra arkposisjonen, så chart1.xml ble parset to ganger og lagringer skrev tre diagramdeler helt til rettelsen bare lastet tegninger gjennom XlsxRtDrawing
Sheet1 refererte aldri til noe diagram, så relasjonsgrafen er den eneste trygge kilden til tegningens mål, og et gjettet konvensjonelt navn gjorde en arbeidsbok med to diagrammer om til en lagring med tre deler

Rettelsen i HotXLS v2.382.0 fjernet gjettingen helt. En tegning for et regnearkark lastes nå bare gjennom ParPartTargets[i].Values[XlsxRtDrawing], målet som er registrert for relasjonstypen for tegning på det arket, og et ark uten en slik relasjon får ingen tegning i det hele tatt. Det er oppførselen formatet krever: elementet <drawing r:id="…"/> i regnearkarket (ECMA-376 Part 1 §18.3.1.36) er den eneste koblingen mellom et ark og tegningen, og delnavn i en OPC-pakke betyr ingenting ut over det relasjonsgrafen tildeler dem. Arkiver skrevet av Excel bruker tilfeldigvis de konvensjonelle navnene, og det er det som lot snarveien passere så lenge; gjennomgangen av OPC-relasjonsoppslag i HotXLS dekker hvorfor det aldri er trygt å gjette et delnavn, selv når gjetningen som regel er riktig

// Før v2.382.0: en manglende tegnerelasjon falt tilbake på en gjetning
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 annet ark

// Fra og med v2.382.0: relasjon eller ingenting
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

Hva garanterer diagramfingeravtrykket?

Fingeravtrykket avgjør, per diagram, om lagringen kan kopiere originaldelen eller må generere den på nytt. Ved import, med PreserveUnsupportedParts slått på før Open, beholder HotXLS de rå UTF-8-bytene for hver diagramdel i FRawChartXml, bygger den typede modellens egen serialisering med BuildChartKnownXml, og lagrer lengden på den serialiseringen i FRawChartModelLength og hashen i FRawChartModelHash. Hashen er FNV-1a over UTF-16-kodeenhetene i den genererte XML-en, med standard 64-bits offset-basis 14695981039346656037 og primtall 1099511628211. Ved lagring bygger XlsxChartRawModelUnchanged den kjente XML-en på nytt og sammenligner lengde og hash; et treff betyr at den typede modellen er nøyaktig det den var ved import, så ingenting applikasjonen kunne ha endret, har endret seg

HotXLS tar diagramfingeravtrykket ved import, beholder rå UTF-8-byte i FRawChartXml mens BuildChartKnownXml gir FRawChartModelLength og en FNV-1a-hash, og ved lagring bygger XlsxChartRawModelUnchanged begge verdiene på nytt og sammenligner dem, så et treff spiller av originalbytene eller kopierer den komprimerte oppføringen, og et avvik faller videre til XlsxMergeChartXml
Fingeravtrykket er ikke bedre enn øyeblikket det tas, og å ta det før alle gjenopprettingspassene er ferdige garanterer en hash som aldri mer matcher den ferdige 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                                  // ingenting bevart
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // ordrett avspilling
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // strukturell sammenslåing
end;

XLSX-skriveren går ett steg lenger enn BuildChartXmlFromKnown. Når modellen er uendret og StrictOOXML er av, prøver den først å kopiere den komprimerte oppføringen rett fra kildearkivet og inn i utdataene under diagrammets nye delnavn, så bytene blir ikke engang dekodet og komprimert på nytt. Bare hvis den kopien ikke er mulig, faller den videre til dekod-eller-slå-sammen-stien. Mekanismen selv — lengde pluss hash, avspilling når de er like, sammenslåing når de ikke er det — er den som er beskrevet i notatet om å redigere Excel-diagrammer uten å miste ChartML. Denne artikkelen handler om måten det sluttet å virke på, i det stille

Hvorfor tok hvert diagram sammenslåingsstien likevel?

Fordi fingeravtrykket ble tatt ett kall for tidlig. Diagramparsing i HotXLS er en SAX-gjennomgang av diagramdelen, etterfulgt av en serie gjenopprettingspass som henter detaljer ut av råteksten som SAX-handlerne ikke modellerer direkte: XlsxChartParseSeriesFlags leser hver <c:ser>-blokk for <c:smooth>-flagget og srgbClr-verdiene til markørens fyll og markørlinjen, og gjenoppretter deretter aksenes kryssmoduser og stiler for store og små hakemerker for kategori- og verdiaksene. Før v2.382.3 var rekkefølgen på slutten av ParseChartXml: klassifiser aksegruppene, bygg den kjente XML-en, ta lengde og hash, og kjør først deretter XlsxChartParseSeriesFlags. Fingeravtrykket beskrev derfor en modell som fortsatt manglet smooth-flagg, markørfarger og hakemerker. Ved lagring kjørte BuildChartKnownXml mot den ferdige modellen, som nå skrev ut <c:smooth val="1"/> og de gjenopprettede markørfargene. Lengre XML, annen hash, XlsxChartRawModelUnchanged returnerte False, og diagrammet gikk gjennom XlsxMergeChartXml. Sammenslåingen er en korrekt operasjon for et diagram noen har redigert, men den bevarer ikke bytene: den serialiserer treet på nytt, og eierskapsregelen som lar den typede modellen vinne for serier, akser og plottgrupper, betyr at de regenererte nodene erstatter originalene. Det synlige resultatet i korpuskjøringen var forskjøvede seriefarger på diagrammer ingen hadde redigert — hvert diagram i hver bevarede arbeidsbok, ved hver lagring, helt uten diagnostikk

Reparasjonen er én enkelt omrokkering: XlsxChartParseSeriesFlags kjører nå før den kjente XML-en bygges, så fingeravtrykket beskriver modellen slik den vil være når applikasjonen først ser den. Lærdommen generaliserer ut over diagrammer. Et fingeravtrykk for endringsdeteksjon er ikke bedre enn øyeblikket det tas, og det trygge øyeblikket er etter at alle pass som kan mutere modellen, er ferdige. HotXLS har et andre fangstpunkt for de samme to verdiene, basislinjen den gjenoppretter mot utdatafilen etter en vellykket lagring, og det punktet hadde alltid kjørt mot en fullt parset modell; punktet ved import var den som skilte seg ut

Hvor ble det av ankerforskyvningene?

Ned i en bokstavelig null. En twoCellAnchor i tegnedelen fester et diagram mellom to celler, og hvert hjørne bærer en celleindeks pluss en forskyvning inne i den cellen: from (ECMA-376 Part 1 §20.5.2.5) og to (§20.5.2.32) holder hver col, colOff (§20.5.2.4), row og rowOff. Forskyvningene er i English Metric Units, 914400 per tomme, og Excel skriver ikke-null verdier hver gang et diagram er plassert eller endret størrelse med musen, noe som gjelder de fleste diagrammer. Det første diagrammet i two-charts.xlsx starter på rad 0 med en rowOff på 19049 og slutter på kolonne 8, rad 15 med en colOff på 247650 og en rowOff på 66674 — omtrent en kvart tomme inn i den siste kolonnen. Tegneparseren i HotXLS hadde alltid lest de fire verdiene — bildekoden brukte dem — men diagramskriveren skrev ut <xdr:colOff>0</xdr:colOff> og <xdr:rowOff>0</xdr:rowOff> for hvert hjørne, og snappet dermed hvert diagram til cellerutenettet ved lagring

Anatomi av xdr:twoCellAnchor-hjørnene for det første diagrammet i HotXLS-eksemplet: from holder col 0 og rowOff 19049 mens to holder col 8, colOff 247650 og rowOff 66674 i EMU med 914400 per tomme, og skriveren som skrev ut null-forskyvninger snappet diagrammer til rutenettet helt til FFromColOff, FToColOff og søsknene deres spilte av de importerte verdiene
Ankeret bor i tegnedelen og ikke i diagramdelen, så denne reparasjonen er uavhengig av fingeravtrykk-rettelsen, og begge måtte ut før arbeidsboken virkelig gikk tur-retur
// Fra og med v2.382.3 spiller ankerskriveren av de importerte EMU-forskyvningene
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 nå FFromColOff, FFromRowOff, FToColOff og FToRowOff, fylt fra tegneparseren og kopiert sammen med den andre anker-tilstanden når et diagram tilordnes. De er bevisst private: den offentlige ankerflaten er fortsatt de fire cellekoordinatene FromRow, FromCol, ToRow og ToCol, og et diagram opprettet fra Delphi-kode lander på cellegrenser som før. Forskyvningene finnes for å gjøre en tur-retur trofast, ikke for å eksponere posisjonering under cellenivå som en funksjon. Merk at denne rettelsen er uavhengig av fingeravtrykket: ankeret bor i tegnedelen, ikke i diagramdelen, så et diagram der ChartML-en ble spilt av perfekt, ville likevel ha hoppet til rutenettet uten den. Enhetskonverteringene bak disse EMU-verdiene er dekket i notatet om HotXLS-bildegeometri og EMU-skalering

Hvordan beviser du at et diagram går tur-retur uendret?

Ved å sammenligne byte, ikke ved å åpne resultatet i Excel. Excel reparerer og normaliserer så mye ved innlasting at et forskjøvet diagram ser fint ut helt til en analytiker legger merke til at markørfargen har endret seg. Korpustesten som fanget begge feilene, gjør tre ting etter en åpne-og-lagre uten redigeringer: den går gjennom relasjonene for regnearkark, tegning og diagram og feiler på enhver duplikat, foreldreløs eller hengende diagramreferanse; den sammenligner en signatur av diagramtype, serieformler og ankergeometri mellom original og utdata; og for two-charts.xlsx leser den hver xl/charts/chartN.xml fra begge arkivene og krever identiske byte. Den samme sjekken er lett å skrive i Delphi med RTL-en 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);   // kaster unntak hvis delen er borte
        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 gjør den sammenligningen meningsfull, og hver av dem feiler stille hvis den glemmes. PreserveUnsupportedParts må være True før Open, ellers fanges ingen rå byte, og hvert diagram bygges på nytt fra modellen. StrictOOXML må være False, fordi strict-modus tvinger regenerering av design. Og applikasjonen må ikke røre diagrammet mellom åpning og lagring — å lese egenskaper er greit, men enhver setter som endrer den typede modellen, vender fingeravtrykket og sender diagrammet ned sammenslåingsstien, noe som er korrekt oppførsel og ikke det denne testen er ute etter. Diagramdeler nummereres også på nytt fra en teller som gjelder hele arbeidsboken ved lagring, så en arbeidsbok der arkrekkefølgen eller diagramrekkefølgen er endret, vil plassere identiske byte under et annet chartN.xml-navn; korpuskontrollen følger relasjoner og ikke navn av den grunnen

Begge rettelsene ble levert i HotXLS 2.382.0 og 2.382.3 og er verifisert på Win32 og Win64 mot det lokale korpuset, der de gjenlagrede diagrameksemplene også er rendret gjennom en uavhengig kontorpakke til PDF og sammenlignet side for side mot originalene. HotXLS leser, redigerer og skriver XLSX-diagrammer fra innebygd Delphi- og C++Builder-kode uten at noen Excel-installasjon er involvert, og det er det som gjør dette nivået av troskap til et bibliotekansvar — siden for HotXLS Delphi-regnearkkomponenten har funksjonslisten og en prøveversjon