Technisch artikel

HotXLS grafiekfingerprint en anker-offsets in Delphi

Een workbook met twee grafieken doorloopt een open-en-opslag die niets bewerkt, en aan de andere kant kunnen drie verschillende dingen uitkomen. De ene uitvoer heeft drie chart-parts waar de bron er twee had. De andere heeft er nog steeds twee, maar een bytevergelijking laat zien dat elke xl/charts/chartN.xml is herschreven en de seriekleuren zijn gedraaid. Een derde houdt de chart-XML intact terwijl elke grafiek een paar pixels naar de rasterlijnen is gesprongen. Excel opent alle drie zonder reparatieprompt. Elk symptoom komt uit een beslissing in het laad- en opslagpad: hoe een werkblad zijn tekening vindt, wanneer de changefingerprint van de grafiek wordt genomen, en of de anker-offsets in het drawing-part worden teruggeschreven

Een werkblad vindt zijn tekening via zijn relaties

Het drie-parts-symptoom komt uit een loader die part-namen gokt. In een XLSX-pakket wijst een werkblad naar zijn tekening met een <drawing r:id="…"/>-element (ECMA-376 Part 1 §18.3.1.36), en de r:id lost op via het .rels-part van het blad naar een target als ../drawings/drawing1.xml. Excel nummert tekeningen toevallig voorspelbaar, dus een handgeschreven reader ontwikkelt vaak een fallback: ontbreekt de relatie, probeer dan xl/drawings/drawing{n}.xml voor bladpositie n en gebruik het als het archief het heeft

// Een shortcut die opduikt in handgeschreven XLSX-readers (niet publiceren, dit)
function GuessDrawingPart(Zip: TZipFile; SheetPos: Integer;
  const RelTarget: string): string;
begin
  Result := RelTarget;   // gelezen uit het .rels van het blad, '' indien afwezig
  if Result = '' then
  begin
    Result := 'xl/drawings/drawing' + IntToStr(SheetPos + 1) + '.xml';
    if Zip.IndexOf(Result) < 0 then
      Result := '';
  end;
end;

De gok houdt stand totdat een blad zonder grafieken vooraan staat. In een workbook waarvan het eerste blad geen drawing en helemaal geen .rels-part heeft, terwijl het tweede blad via Target="../drawings/drawing1.xml" bij xl/drawings/drawing1.xml komt, geeft de fallback blad 1 een tekening waarnaar het nooit heeft verwezen. chart1.xml wordt twee keer geparsed, een keer per blad, en de opslag schrijft drie chart-parts. Part-namen in een OPC-pakket dragen geen betekenis buiten wat de relatiegraaf ze toekent, en dat is precies de strekking van de walkthrough over OPC-relatieresolutie in HotXLS

Hoe een werkblad zijn tekening vindt in een workbook met twee grafieken: Sheet1 heeft geen drawing-relatie en geen rels-part terwijl Sheet2 via zijn drawing-relatie bij xl/drawings/drawing1.xml komt, dus een loader die die conventionele naam gokt vanuit de bladpositie parseert chart1.xml twee keer en slaat drie chart-parts op, en daarom laadt HotXLS een tekening alleen via de drawing-relatie van het blad
Sheet1 heeft nooit naar een grafiek verwezen, dus de relatiegraaf is de enige veilige bron voor de drawing-target, en een gegokte conventionele naam maakt van een workbook met twee grafieken een opslag met drie parts

HotXLS Delphi Component pakt de strikte lezing. De tekening van een werkblad wordt alleen geladen vanuit de target van de drawing-relatie van dat blad, en een blad zonder zo'n relatie krijgt geen tekening, welke bestanden er ook in xl/drawings/ staan. Een drawing-part waarnaar niets verwijst, wordt nooit aan een blad gehangen

Wat bepaalt of een onaangeraakte grafiek gekopieerd of herbouwd wordt?

Een fingerprint per grafiek bepaalt het. Als PreserveUnsupportedParts True is op het moment van Open (True is de default), bewaart HotXLS de originele bytes van elk chart-part naast het geparsede grafiekmodel. Vervolgens serialiseert hij het model met zijn eigen chart-writer en registreert twee getallen voor die uitvoer: de lengte, en een 64-bits FNV-1a-hash over zijn UTF-16 code units, met de standaard offset basis 14695981039346656037 en priem 1099511628211. TXLSXChart.HasPreservedXml meldt of een grafiek die originele bytes meedraagt

Op het moment van opslaan wordt het grafiekmodel opnieuw geserialiseerd en worden beide getallen vergeleken. Gelijke lengte en gelijke hash betekenen dat het model exact is wat het bij import was, dus niets dat de toepassing had kunnen veranderen, is veranderd. In dat geval kopieert de writer, met StrictOOXML uit, de gecomprimeerde entry uit het bronarchief rechtstreeks naar de uitvoer onder de nieuwe part-naam van de grafiek; de bytes worden nooit gedecodeerd of opnieuw gedeflate. Wijkt een van beide getallen af, dan wordt de originele XML in plaats daarvan gemerged met het model. Ook het veranderen van de lijnopmaak van een error bar of trendline telt als bewerking, al hoort die opmaak niet bij het geserialiseerde model

HotXLS neemt bij import een grafiekfingerprint af, bewaart de originele bytes van het chart-part en registreert de lengte en de 64-bits FNV-1a-hash van zijn eigen serialisatie van het grafiekmodel, en bouwt en vergelijkt bij het opslaan beide waarden opnieuw, zodat een match de originele bytes replayt of de gecomprimeerde entry kopieert en een mismatch de originele XML met het model merged
Het fingerprint is net zo goed als het moment waarop het wordt genomen, en afnemen voordat elke herstelpass klaar is garandeert een hash die nooit matcht met het voltooide model

De merge is de juiste operatie voor een grafiek die iemand heeft bewerkt. Hij houdt de delen van de originele XML vast die het model niet dekt, laat het model winnen voor series, assen en plotgroepen, en serialiseert het resultaat opnieuw. Wat hij niet kan, is de originele bytes reproduceren. De notitie over Excel-grafieken bewerken zonder ChartML te verliezen behandelt wat de merge vasthoudt; dit artikel gaat over de voorwaarden waaronder een onbewerkte grafiek die merge nooit bereikt

uses System.SysUtils, lxHandleX;

procedure ResaveUntouched(const Src, Dst: string);
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  S, C: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.PreserveUnsupportedParts := True;  // de default; moet gelden vóór Open
    Book.StrictOOXML := False;              // Strict-uitvoer herbouwt elke grafiek
    if Book.Open(Src) <> 1 then
      raise Exception.Create('Cannot open ' + Src);
    for S := 1 to Book.Sheets.Count do      // Sheets[] is 1-based
    begin
      Sheet := Book.Sheets[S];
      for C := 0 to Sheet.Charts.Count - 1 do  // Charts[] is 0-based
        Writeln(Sheet.Name, ' / ', Sheet.Charts[C].Name, ': original kept = ',
          BoolToStr(Sheet.Charts[C].HasPreservedXml, True));
    end;
    if Book.SaveAs(Dst) <> 1 then
      raise Exception.Create('Cannot save ' + Dst);
  finally
    Book.Free;
  end;
end;

Als een grafiek met opzet opnieuw uit het model gebouwd moet worden, bijvoorbeeld om opmaak kwijt te raken die de bron meedroeg, gooit TXLSXChart.ClearPreservedXml de bewaarde bytes weg en wordt alleen die grafiek uit het model geschreven

Waarom zou toch elke grafiek het merge-pad nemen?

Omdat een fingerprint dat een stap te vroeg wordt afgenomen, nooit matcht. Chart-XML wordt zelden in een pass gelezen. Een streaming-parse bouwt de series, assen en plotgroepen, en daarna leest een reeks vervolgpasses details uit de ruwe charttekst die makkelijker te herstellen zijn zodra de series bestaan: de <c:smooth>-vlag op elke <c:ser>, de srgbClr-waarden van marker-vulling en marker-lijn, as-kruisingsmodi, en de major- en minor tick-mark-stijlen. De serieverzameling bestaat simpelweg nog niet terwijl de streaming-pass draait, dus die passes moeten erna komen

Stel dat het fingerprint wordt afgenomen aan het einde van de streaming-pass, voordat de vervolgpasses draaien. Het beschrijft dan een model zonder smooth-vlaggen, zonder marker-kleuren en met default tick marks. Bij het opslaan draait de serializer tegen het voltooide model en emitteert <c:smooth val="1"/> plus de herstelde kleuren. De XML is langer, de hash wijkt af, en elke grafiek in elk bewaard workbook gaat bij elke opslag door de merge. Bij een lijngrafiek met gladde series en eigen markers vervangen de opnieuw opgebouwde <c:ser>-nodes de originelen, en daar komt de kleurdrift vandaan

HotXLS neemt het fingerprint pas af nadat elke herstelpass is afgerond, dus de hash beschrijft het model zoals de toepassing het eerst ziet. Dezelfde twee getallen worden na een geslaagde opslag opnieuw vastgesteld, nu uit het uitvoerbestand, en die tweede afname start ook vanuit een volledig geparsed model. Een change-detection-hash moet worden genomen na de laatste pass die het model nog kan muteren. Eerder afnemen levert een check op die bij elke opslag faalt

Anker-offsets staan in het drawing-part

Het rastersnap-symptoom heeft niets met chart-XML te maken. Waar een grafiek op het blad staat, staat in het drawing-part, en de gebruikelijke vorm is xdr:twoCellAnchor (ECMA-376 Part 1 §20.5): een from-hoek en een to-hoek, elk met col, colOff, row en rowOff. De offsets zijn English Metric Units, 914400 per inch en 12700 per point, en Excel schrijft niet-nulwaarden zodra een grafiek met de muis wordt gesleept of van formaat verandert. Voor een bestand dat een from-hoek op kolom 0, rij 0 met een rowOff van 19049 declareert, en een to-hoek op kolom 8, rij 15 met een colOff van 247650 en een rowOff van 66674, begint de grafiek 1,5 point onder de bovenkant van rij 1 en eindigt hij iets meer dan een kwart inch in kolom I

Anatomie van de xdr:twoCellAnchor-hoeken voor een voorbeeldgrafiek: from houdt col 0 en rowOff 19049 vast terwijl to col 8, colOff 247650 en rowOff 66674 in EMU van 914400 per inch houdt, en omdat een writer die nul-offsets emitteert grafieken op het raster snapt, schrijft HotXLS de geïmporteerde offsets bij het opslaan terug
Het anker staat in het drawing-part in plaats van het chart-part, dus offset-replay is onafhankelijk van het fingerprint, en een getrouwe round trip heeft beide nodig

Een writer die die vier getallen leest en daarna voor elke hoek <xdr:colOff>0</xdr:colOff> emitteert, zet elke grafiek op de rasterlijnen. Het chart-part kan byte voor byte worden gereplayd en de grafiek verschuift toch, omdat de twee in verschillende parts zitten. HotXLS houdt de ankersoort vast die hij laadde (twoCellAnchor, oneCellAnchor of absoluteAnchor, beschikbaar als PhysicalAnchorKind) samen met zijn EMU-waarden, en schrijft die onveranderd terug. De offsets zijn leesbaar als FromOffsetXEMU, FromOffsetYEMU, ToOffsetXEMU en ToOffsetYEMU, en de eenheidsconversies erachter worden doorgerekend in de notitie over HotXLS-beeldgeometrie en EMU-schaling

procedure NudgeChart(Chart: TXLSXChart);
const
  EmuPerPoint = 12700;
begin
  if Chart.PhysicalAnchorKind <> xlsxAnchorTwoCell then
    Exit;
  Writeln(Format('from R%dC%d +(%d, %d) EMU  to R%dC%d +(%d, %d) EMU',
    [Chart.FromRow, Chart.FromCol, Chart.FromOffsetXEMU, Chart.FromOffsetYEMU,
     Chart.ToRow, Chart.ToCol, Chart.ToOffsetXEMU, Chart.ToOffsetYEMU]));
  // Vervang het hele kader in één aanroep: rijen en kolommen zijn 1-based,
  // offsets zijn EMU binnen de cel, het laatste argument is het editAs-gedrag
  Chart.SetTwoCellAnchor(Chart.FromRow, Chart.FromCol, Chart.ToRow, Chart.ToCol,
    Chart.FromOffsetXEMU + 6 * EmuPerPoint, Chart.FromOffsetYEMU,
    Chart.ToOffsetXEMU + 6 * EmuPerPoint, Chart.ToOffsetYEMU, Chart.AnchorType);
end;

Gebruik SetTwoCellAnchor als de bedoeling is te verplaatsen of van formaat te veranderen. Het toewijzen van FromRow, FromCol, ToRow of ToCol op een tweecelligenkaart verandert alleen die celindex en laat de offsets staan waar ze stonden, wat voor een kleine correctie het juiste gedrag is en voor het plaatsen van een grafiek vanaf nul het verkeerde. SetTwoCellAnchor valideert het kader als geheel en geeft een ERangeError als de eindhoek voor de beginhoek ligt of de offsets buiten het Excel-EMU-bereik vallen. Een grafiek die met AddChart is aangemaakt begint met nul-offsets, dus hij staat exact op celgrenzen totdat je ze een waarde geeft

De round trip bewijzen met een bytevergelijking

De uitvoer in Excel openen bewijst hier weinig. Excel normaliseert bij het laden genoeg om een opnieuw geserialiseerde grafiek identiek te laten lijken totdat iemand doorheeft dat een marker-kleur is veranderd, en een rastersprong van een paar points is op het scherm makkelijk te missen. De check die automatisering waard is, vergelijkt de chart-parts byte voor byte na een open-en-opslag zonder bewerkingen, en de TZipFile uit de RTL is alles wat dat nodig heeft

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;

Een paar voorwaarden bepalen of een mismatch in die check iets betekent:

  • PreserveUnsupportedParts moet True zijn voor Open; staat hij uit, dan worden geen originele bytes bewaard en wordt elke grafiek uit het model geschreven
  • StrictOOXML moet False zijn, omdat Strict-uitvoer per ontwerp grafieken herbouwt. Laat hem uit in elke pijplijn die belooft dat onbewerkte grafieken een opslag overleven
  • Een bronpakket geschreven met Strict OOXML-naamruimten wordt bij een transitional-opslag geconverteerd naar transitional naamruimten, dus zijn chart-bytes wijken af zelfs als verder niets veranderde
  • De code tussen Open en SaveAs mag grafiekeigenschappen vrij lezen, maar elke setter die het model verandert verandert het fingerprint en stuurt die grafiek door de merge, wat correct is en buiten wat deze test meet valt
  • Chart-parts worden bij het opslaan hernummerd vanuit een workbook-brede teller, dus een workbook waarvan de blad- of grafiekvolgorde veranderde zet identieke bytes onder een andere chartN.xml-naam; een checker voor dat geval volgt de drawing-relaties in plaats van namen te matchen

Ankers vergelijken op waarde. Het drawing-part wordt vers geschreven rond de grafieken die het bevat, dus zijn bytes zijn het verkeerde object om te diffen; beide bestanden opnieuw openen en FromOffsetXEMU tot en met ToOffsetYEMU grafiek voor grafiek vergelijken vangt een writer die 247650 in 0 verandert. Een structurele check die werkblad-, drawing- en chart-relaties afloopt en faalt bij elke gedupliceerde, wees- of dangling chart-verwijzing vangt het drie-parts-geval voordat de bytevergelijking überhaupt draait

Al dit draait in native Delphi- en C++Builder-code zonder Excel-installatie op de machine, dus het workbook dat uit een batchjob terugkomt is het enige bewijs van wat er met zijn grafieken is gebeurd. Licenties, de volledige grafiek-API en een trial-build staan op de HotXLS Delphi-spreadsheetcomponent-pagina