HotXLS Delphi Component prehrá nedotknutý Excel graf bajt po bajte len vtedy, keď platia dve veci: graf sa dosiahol cez drawing relationship pracovného hárku a nie cez hádané meno partu, a 64-bitový odtlačok modelu sa zachytil až po tom, čo parser dokončil parsovanie modelu grafu. Verzia 2.382.0 opravila prvú podmienku, verzia 2.382.3 druhú a začala round-tripovať nenulové offsety kotvy xdr:colOff a xdr:rowOff, ktoré drawing writer doteraz natvrdo nuloval. Oba defekty vzišli z jedného lokálneho corpus prípadu, two-charts.xlsx: najprv štrukturálna assertion videla, ako sa dva chart party zmenili na tri, potom bajtové porovnanie každého xl/charts/chartN.xml ukázalo, že grafy, ktorých sa nikto nedotkol, sa stále prepisujú — a ani jeden problém nevyhodil výnimku ani nepobúril Excel, a práve preto prežili tak dlho
Prečo sa z workbooku s dvoma grafmi vrátili tri chart party?
Pretože loader mal fallback, ktorý hádal. Keď hárok nemal vo svojej .rels časti žiadny drawing relationship, starý kód predpokladal, že drawing žije na konvenčnom mene xl/drawings/drawing{i+1}.xml, kde i je pozícia hárku, a tú časť pripojil, ak v archíve existovala. V two-charts.xlsx prvý hárok nemá drawing ani žiadnu .rels časť, kým xl/drawings/drawing1.xml existuje — patrí druhému hárku, ktorý sa k nemu dostane cez Target="../drawings/drawing1.xml". Sheet 1 tak zdedil graf, na ktorý nikdy neodkazoval, chart1.xml sa parsoval dvakrát a uloženie zapísalo workbook s tromi chart partmi namiesto dvoch
Oprava v HotXLS v2.382.0 hádanie úplne odstránila. Drawing pracovného hárku sa teraz načítava len cez ParPartTargets[i].Values[XlsxRtDrawing], teda cieľ zaznamenaný pre typ drawing relationship na danom hárku, a hárok bez takéhoto relationship nedostane žiadny drawing. To je správanie, ktoré formát vyžaduje: element <drawing r:id="…"/> v hárku (ECMA-376 Part 1 §18.3.1.36) je jediné spojenie medzi hárok a jeho drawingom a mená partov v OPC package nenesú nič viac než to, čo im priradí relationship graf. Archívy zapísané Excelom náhodou používajú konvenčné mená, a práve preto tá skratka tak dlho prechádzala; rozbor OPC relationship resolution v HotXLS vysvetľuje, prečo hádanie mena partu nie je nikdy bezpečné, aj keď je odhad zvyčajne správny
// Pred v2.382.0: chýbajúci drawing relationship padol na hádanie
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
LoadDrawing(zip, drawingName); // môže patriť inému hárku
// Od v2.382.0: relationship alebo nič
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
LoadDrawing(zip, drawingName);
Čo zaručuje odtlačok modelu grafu?
Odtlačok rozhoduje pre každý graf o tom, či uloženie môže skopírovať pôvodnú časť alebo ju musí regenerovať. Pri importe, s PreserveUnsupportedParts zapnutým pred Open, HotXLS drží surové UTF-8 bajty každého chart partu v FRawChartXml, stavia vlastnú serializáciu typovaného modelu cez BuildChartKnownXml a dĺžku tejto serializácie ukladá do FRawChartModelLength a jej hash do FRawChartModelHash. Hash je FNV-1a cez UTF-16 code units vygenerovaného XML, so štandardnou 64-bitovou offset basis 14695981039346656037 a prvočíslom 1099511628211. Pri ukladaní XlsxChartRawModelUnchanged znova postaví známe XML a porovná dĺžku aj hash; zhoda znamená, že typovaný model je presne ten, čo bol pri importe, takže sa nezmenilo nič, čo by aplikácia mohla zmeniť
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 // nič sa nezachovalo
else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
Result := XlsxDecodeChartUtf8(Chart.FRawChartXml) // doslovné prehranie
else
Result := XlsxMergeChartXml(
XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // štrukturálne zlúčenie
end;
XLSX writer ide o krok ďalej než BuildChartXmlFromKnown. Keď je model nezmenený a StrictOOXML je vypnutý, najprv skúsi skopírovať komprimovaný záznam priamo zo zdrojového archívu do výstupu pod novým menom chart partu, takže bajty sa ani nedekódujú a znova nedeflujú. Až keď tá kópia nie je možná, prepadne na decode-or-merge cestu. Samotný mechanizmus — dĺžka plus hash, prehranie pri zhode, zlúčenie pri nezhode — je ten, ktorý opisuje poznámka o úprave Excel grafov bez straty ChartML. Tento článok je o tom, ako prestal ticho fungovať
Prečo aj tak každý graf skončil na merge ceste?
Pretože odtlačok sa zachytil o jedno volanie priskoro. Parsovanie grafu v HotXLS je SAX prechod cez chart part nasledovaný sadou recovery passov, ktoré ťahajú zo surového textu detaily, ktoré SAX handlery priamo nemodelujú: XlsxChartParseSeriesFlags číta každý <c:ser> blok kvôli jeho <c:smooth> flagu a hodnotám srgbClr marker fillu a marker line, a potom obnovuje axis crossing modes a štýly major a minor tick-markov pre category a value osi. Pred v2.382.3 bolo poradie na konci ParseChartXml takéto: klasifikuj axis groups, postav známe XML, zachyť dĺžku a hash, a až potom spusť XlsxChartParseSeriesFlags. Odtlačok teda opisoval model, ktorému ešte chýbali smooth flagy, farby markerov a tick marky. Pri ukladaní sa BuildChartKnownXml spustil proti dokončenému modelu, ktorý teraz emitoval <c:smooth val="1"/> a obnovené farby markerov. Dlhšie XML, iný hash, XlsxChartRawModelUnchanged vrátil False a graf šiel cez XlsxMergeChartXml. Merge je správna operácia pre graf, ktorý niekto upravoval, ale nie je to operácia zachovávajúca bajty: reserializuje strom a pravidlo vlastníctva, ktoré necháva typovaný model vyhrať pre series, osi a plot groups, znamená, že regenerované nody nahradia originály. Viditeľným výsledkom v corpus behu boli posunuté farby sérií na grafoch, ktoré nikto neupravoval — každý graf v každom zachovávanom workbooku, pri každom uložení, bez akéhokoľvek diagnosticu
Oprava je jedno presunuté poradie: XlsxChartParseSeriesFlags teraz beží pred tým, než sa postaví známe XML, takže odtlačok opisuje model tak, ako bude existovať, keď ho aplikácia prvýkrát uvidí. Lekcia platí aj mimo grafov. Odtlačok na detekciu zmien je len taký dobrý, ako moment, v ktorom sa zachytí, a bezpečný moment je až po skončení všetkých passov, ktoré môžu model meniť. HotXLS má druhé miesto zachytenia tých istých dvoch hodnôt, baseline, ktorú po úspešnom uložení znova nastaví proti výstupnému súboru, a to vždy bežalo proti úplne naparsovanému modelu; importné miesto bolo to zvláštne
Kam zmizli offsety kotvy?
Do doslovnej nuly. twoCellAnchor v drawing parte pripína graf medzi dve bunky a každý roh nesie index bunky plus offset vnútri tej bunky: from (ECMA-376 Part 1 §20.5.2.5) a to (§20.5.2.32) držia col, colOff (§20.5.2.4), row a rowOff. Offsety sú v English Metric Units, 914400 na palec, a Excel zapisuje nenulové hodnoty vždy, keď bol graf umiestnený alebo zmenený myšou, čo je väčšina grafov. Prvý graf v two-charts.xlsx začína na riadku 0 s rowOff 19049 a končí na stĺpci 8, riadku 15 s colOff 247650 a rowOff 66674 — približne štvrť palca do posledného stĺpca. Drawing parser v HotXLS tie štyri hodnoty vždy čítal — image kód ich používal — ale chart writer emitoval <xdr:colOff>0</xdr:colOff> a <xdr:rowOff>0</xdr:rowOff> pre každý roh, takže pri ukladaní každý graf pricvakol na mriežku buniek
// Od v2.382.3 writer kotvy prehráva importované EMU offsety
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 teraz nesie FFromColOff, FFromRowOff, FToColOff a FToRowOff, naplnené z drawing parsera a kopírované spolu s ostatným stavom kotvy, keď sa graf priraďuje. Sú zámerne privátne: verejný anchor surface sú stále štyri súradnice buniek FromRow, FromCol, ToRow a ToCol a graf vytvorený z Delphi kódu pristane na hraniciach buniek ako predtým. Offsety existujú preto, aby bol round trip verný, nie preto, aby odhalili sub-cell pozicionovanie ako funkciu. Všimnite si, že táto oprava je nezávislá od odtlačku: kotva žije v drawing parte, nie v chart parte, takže graf, ktorého ChartML sa prehral dokonale, by bez nej aj tak skočil na mriežku. Konverzie jednotiek za tými EMU hodnotami pokrýva poznámka o image geometry a EMU skalovaní v HotXLS
Ako dokážete, že graf prejde round-tripom nezmenený?
Porovnaním bajtov, nie otvorením výsledku v Exceli. Excel pri načítaní toľko vecí opravuje a normalizuje, že posunutý graf vyzerá dobre až do chvíle, keď si analytik všimne, že sa zmenila farba markeru. Corpus test, ktorý oba defekty chytil, robí po otvorení a uložení bez úprav tri veci: prejde worksheet, drawing a chart relationships a zlyhá na každej duplicitnej, osirelej alebo visiacej chart referencii; porovná signatúru typu grafu, formúl sérií a geometrie kotvy medzi originálom a výstupom; a pre two-charts.xlsx prečíta každý xl/charts/chartN.xml z oboch archívov a vyžaduje identické bajty. Rovnaká kontrola sa dá ľahko napísať v Delphi s RTL 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); // vyhodí výnimku, ak časť zmizla
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;
Tieto tri podmienky robia to porovnanie zmysluplným a každá z nich ticho zlyhá, ak sa na ňu zabudne. PreserveUnsupportedParts musí byť True pred Open, inak sa nezachytia žiadne surové bajty a každý graf sa prestavia z modelu. StrictOOXML musí byť False, pretože strict mód regeneráciu vynucuje už z princípu. A aplikácia sa medzi otvorením a uložením nesmie grafu dotknúť — čítanie vlastností je v poriadku, ale každý setter, ktorý zmení typovaný model, prepne odtlačok a pošle graf na merge cestu, čo je správne správanie a nie to, na čo je tento test. Chart party sa pri ukladaní aj prečíslujú z workbook-globálneho počítadla, takže workbook, ktorému sa zmenilo poradie hárkov alebo grafov, umiestni identické bajty pod iné meno chartN.xml; corpus checker preto sleduje relationships, nie mená
Obe opravy vyšli v HotXLS 2.382.0 a 2.382.3 a sú overené na Win32 aj Win64 proti lokálnemu corpusu, pričom preuložené chart vzorky sa navyše renderovali cez nezávislý office balík do PDF a porovnali stránku po stránke s originálmi. HotXLS číta, upravuje a zapisuje XLSX grafy z natívneho Delphi a C++Builder kódu bez akejkoľvek inštalácie Excelu, a práve preto je táto úroveň vernosti zodpovednosťou knižnice — stránka HotXLS Delphi spreadsheet component má zoznam funkcií a trial na stiahnutie