De HotXLS Delphi Component schrijft een onaangeraakte Excel-grafiek alleen byte voor byte terug als aan twee voorwaarden is voldaan: de grafiek is bereikt via de tekenrelatie van het werkblad en niet via een gegokte onderdeelnaam, en de 64-bits modelvingerafdruk is vastgelegd nadat het grafiekmodel klaar was met parsen. Versie 2.382.0 herstelde de eerste voorwaarde, versie 2.382.3 de tweede, en die laatste begon ook de niet-nul xdr:colOff- en xdr:rowOff-anker-offsets rond te schrijven die de tekenauteur altijd op nul had gehardcodeerd. Beide defecten kwamen uit één lokaal corpusgeval, two-charts.xlsx: eerst zag een structuurcontrole twee grafiekonderdelen in drie veranderen, daarna toonde een bytevergelijking van elk xl/charts/chartN.xml dat grafieken die niemand had aangeraakt nog steeds werden herschreven — en geen van beide problemen gooide een exception of liet Excel klagen, en daarom hebben ze zo lang overleefd
Waarom kwam een workbook met twee grafieken terug met drie grafiekonderdelen?
Omdat de loader een fallback had die gokte. Als een werkblad geen tekenrelatie in zijn .rels-onderdeel had, nam de oude code aan dat de tekening onder de conventionele naam xl/drawings/drawing{i+1}.xml stond, waarbij i de positie van het blad is, en hing dat onderdeel eraan als het in het archief bestond. In two-charts.xlsx heeft het eerste blad geen tekening en helemaal geen .rels-onderdeel, terwijl xl/drawings/drawing1.xml wel bestaat — het hoort bij het tweede blad, dat het bereikt via Target="../drawings/drawing1.xml". Blad 1 erfde daardoor een grafiek waarnaar het nooit verwees, chart1.xml werd twee keer geparseerd, en bij het opslaan schreef de workbook drie grafiekonderdelen weg in plaats van twee
De fix in HotXLS v2.382.0 haalde de gok volledig weg. Een werkbladtekening wordt nu alleen geladen via ParPartTargets[i].Values[XlsxRtDrawing], het doel dat voor het tekenrelatietype op dat blad is vastgelegd, en een blad zonder die relatie krijgt helemaal geen tekening. Dat is het gedrag dat het formaat eist: het element <drawing r:id="…"/> in het werkblad (ECMA-376 Part 1 §18.3.1.36) is de enige schakel tussen een blad en zijn tekening, en onderdeelnamen in een OPC-pakket hebben geen betekenis buiten wat de relatietekening ze toekent. Archieven die Excel schrijft gebruiken toevallig de conventionele namen, en dat is wat de sluipweg zo lang door de mazen liet glippen; de uiteenzetting over OPC-relatieresolutie in HotXLS behandelt waarom het gokken van een onderdeelnaam nooit veilig is, zelfs als de gok meestal klopt
// Vóór v2.382.0: een ontbrekende tekenrelatie viel terug op een gok
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 bij een ander blad horen
// Sinds v2.382.0: relatie of niets
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
LoadDrawing(zip, drawingName);
Wat garandeert de grafiekfingerprint?
De fingerprint bepaalt per grafiek of het opslaan het originele onderdeel kan kopiëren of het opnieuw moet genereren. Bij het importeren, met PreserveUnsupportedParts aan vóór Open, bewaart HotXLS de ruwe UTF-8-bytes van elk grafiekonderdeel in FRawChartXml, bouwt met BuildChartKnownXml de eigen serialisatie van het getypeerde model, en legt de lengte van die serialisatie vast in FRawChartModelLength en de hash in FRawChartModelHash. De hash is FNV-1a over de UTF-16-code-eenheden van de gegenereerde XML, met de standaard 64-bits offsetbasis 14695981039346656037 en priemgetal 1099511628211. Bij het opslaan bouwt XlsxChartRawModelUnchanged de bekende XML opnieuw op en vergelijkt lengte en hash; een match betekent dat het getypeerde model precies is wat het bij het importeren was, dus is er niets veranderd dat de applicatie had kunnen veranderen
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 // niets bewaard
else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
Result := XlsxDecodeChartUtf8(Chart.FRawChartXml) // letterlijke terugspeel
else
Result := XlsxMergeChartXml(
XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // structurele merge
end;
De XLSX-writer gaat een stap verder dan BuildChartXmlFromKnown. Wanneer het model onveranderd is en StrictOOXML uit staat, probeert hij eerst het gecomprimeerde item rechtstreeks uit het bronarchief naar de uitvoer te kopiëren onder de nieuwe onderdeelnaam van de grafiek, zodat de bytes niet eens worden gedecodeerd en opnieuw gedeflateerd. Alleen als die kopie niet mogelijk is, valt hij door naar het decodeer-of-merge-pad. Het mechanisme zelf — lengte plus hash, terugspeel bij gelijkheid, merge bij verschil — is hetzelfde als beschreven in de notitie over Excel-grafieken bewerken zonder ChartML te verliezen. Dit artikel gaat over de manier waarop het er stilzwijgend mee ophield te werken
Waarom nam elke grafiek toch het merge-pad?
Omdat de fingerprint één aanroep te vroeg werd vastgelegd. Het parsen van grafieken is in HotXLS een SAX-ronde over het grafiekonderdeel, gevolgd door een reeks herstelrondes die details uit de ruwe tekst halen die de SAX-handlers niet rechtstreeks modelleren: XlsxChartParseSeriesFlags leest elk <c:ser>-blok voor zijn <c:smooth>-flag en de srgbClr-waarden van de markervulling en de markerlijn, en herstelt daarna de as-kruisingsmodi en de stijlen van de grote en kleine maatstreepjes voor de categorie- en waardeas. Vóór v2.382.3 was de volgorde aan het eind van ParseChartXml: de asgroepen classificeren, de bekende XML bouwen, lengte en hash vastleggen, en pas daarna XlsxChartParseSeriesFlags draaien. De fingerprint beschreef dus een model dat de smooth-flags, markerkleuren en maatstreepjes nog miste. Bij het opslaan liep BuildChartKnownXml tegen het voltooide model, dat nu <c:smooth val="1"/> en de herstelde markerkleuren uitschreef. Langere XML, andere hash, XlsxChartRawModelUnchanged gaf False terug, en de grafiek ging door XlsxMergeChartXml. De merge is een correcte bewerking voor een grafiek die iemand heeft bewerkt, maar het is geen bytebehoudende: hij serialiseert de boom opnieuw, en de eigendomsregel die het getypeerde model laat winnen voor reeksen, assen en plotgroepen betekent dat de opnieuw gegenereerde knopen de originelen vervangen. Het zichtbare resultaat in de corpusrun waren verschoven reekskleuren op grafieken die niemand had bewerkt — elke grafiek in elke bewaarde workbook, bij elke opslag, zonder enige diagnostiek
De reparatie is één enkele herordening: XlsxChartParseSeriesFlags draait nu voordat de bekende XML wordt gebouwd, zodat de fingerprint het model beschrijft zoals het zal bestaan wanneer de applicatie het voor het eerst ziet. De les gaat verder dan grafieken. Een fingerprint voor wijzigingsdetectie is alleen zo goed als het moment waarop hij wordt genomen, en dat veilige moment is nadat elke ronde die het model kan muteren klaar is. HotXLS heeft een tweede plek waar dezelfde twee waarden worden vastgelegd: de basislijn die na een geslaagde opslag opnieuw tegen het uitvoerbestand wordt gezet, en die plek liep altijd al tegen een volledig geparseerd model; de plek bij het importeren was de uitzondering
Waar bleven de anker-offsets?
Regelrecht in een letterlijke nul. Een twoCellAnchor in het tekeningonderdeel klemt een grafiek tussen twee cellen, en elke hoek draagt een celindex plus een offset binnen die cel: from (ECMA-376 Part 1 §20.5.2.5) en to (§20.5.2.32) bevatten elk col, colOff (§20.5.2.4), row en rowOff. De offsets staan in English Metric Units, 914400 per inch, en Excel schrijft niet-nul waarden zodra een grafiek met de muis is geplaatst of in grootte is gewijzigd, en dat geldt voor de meeste grafieken. De eerste grafiek in two-charts.xlsx begint op rij 0 met een rowOff van 19049 en eindigt op kolom 8, rij 15 met een colOff van 247650 en een rowOff van 66674 — ongeveer een kwart inch in de laatste kolom. De tekeningparser in HotXLS las die vier waarden altijd al — de afbeeldingscode gebruikte ze — maar de grafiekschrijver zette <xdr:colOff>0</xdr:colOff> en <xdr:rowOff>0</xdr:rowOff> voor elke hoek, waardoor elke grafiek bij het opslaan naar het celraster sprong
// Sinds v2.382.3 speelt de ankerschrijver de geïmporteerde EMU-offsets terug
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 draagt nu FFromColOff, FFromRowOff, FToColOff en FToRowOff, gevuld vanuit de tekeningparser en meegekopieerd met de rest van de ankerstatus wanneer een grafiek wordt toegewezen. Ze zijn bewust private: het publieke anker-oppervlak blijft de vier celcoördinaten FromRow, FromCol, ToRow en ToCol, en een grafiek die vanuit Delphi-code wordt aangemaakt landt zoals voorheen op celgrenzen. De offsets bestaan om een round trip trouw te maken, niet om subcelpositionering als feature bloot te leggen. Let op dat deze fix losstaat van de fingerprint: het anker zit in het tekeningonderdeel, niet in het grafiekonderdeel, dus een grafiek waarvan de ChartML perfect terugspeelde zou zonder deze fix alsnog naar het raster zijn gesprongen. De eenheidsconversies achter die EMU-waarden staan in de notitie over HotXLS-afbeeldingsgeometrie en EMU-schaling
Hoe bewijs je dat een grafiek ongewijzigd terugkomt?
Door bytes te vergelijken, niet door het resultaat in Excel te openen. Excel repareert en normaliseert zo veel bij het laden dat een verschoven grafiek er prima uitziet tot een analist merkt dat de markerkleur is veranderd. De corpustest die beide defecten ving, doet drie dingen na een open-en-opslag zonder wijzigingen: hij loopt de relaties van werkblad, tekening en grafiek langs en faalt op elke dubbele, verweesde of losse grafiekverwijzing; hij vergelijkt een signatuur van grafiektype, reeksformules en ankermeetkunde tussen origineel en uitvoer; en voor two-charts.xlsx leest hij elk xl/charts/chartN.xml uit beide archieven en eist identieke bytes. Diezelfde controle is in Delphi makkelijk te schrijven met de RTL-klasse 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); // gooit een fout als het onderdeel verdwenen is
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;
Drie voorwaarden maken die vergelijking zinvol, en elk ervan faalt geruisloos als je ze vergeet. PreserveUnsupportedParts moet True zijn vóór Open, anders worden er geen ruwe bytes vastgelegd en wordt elke grafiek uit het model opnieuw opgebouwd. StrictOOXML moet False zijn, want de strikte modus forceert regeneratie by design. En de applicatie mag de grafiek tussen openen en opslaan niet aanraken — eigenschappen lezen is prima, maar elke setter die het getypeerde model wijzigt, klapt de fingerprint om en stuurt de grafiek het merge-pad op, wat correct gedrag is en niet waar deze test voor dient. Grafiekonderdelen worden bij het opslaan bovendien hernummerd vanuit een workbook-brede teller, dus een workbook waarvan de blad- of grafiekvolgorde is veranderd, zet identieke bytes onder een andere chartN.xml-naam; de corpuscontrole volgt daarom relaties en niet namen
Beide fixes zijn uitgebracht in HotXLS 2.382.0 en 2.382.3 en zijn op Win32 en Win64 tegen het lokale corpus geverifieerd, waarbij de opnieuw opgeslagen grafiekvoorbeelden ook via een onafhankelijke officesuite naar PDF zijn gerenderd en pagina voor pagina met de originelen zijn vergeleken. HotXLS leest, bewerkt en schrijft XLSX-grafieken vanuit native Delphi- en C++Builder-code zonder enige Excel-installatie, en juist daarom is dit niveau van trouw een verantwoordelijkheid van de library — de HotXLS Delphi spreadsheet component-pagina heeft de featurelijst en een proefdownload