Τεχνικό Άρθρο

HotXLS Chart fingerprint και EMU anchor offsets στο Delphi

Το HotXLS Delphi Component αναπαράγει ένα ανέπαφο Excel chart byte προς byte μόνο όταν ισχύουν δύο πράγματα: το chart να έχει φτάσει μέσα από το worksheet drawing relationship και όχι από ένα μαντευμένο part name, και το 64-bit model fingerprint να έχει καταγραφεί αφού το chart model τελείωσε το parsing. Η έκδοση 2.382.0 διόρθωσε την πρώτη προϋπόθεση, η 2.382.3 τη δεύτερη, και άρχισε να κάνει round-trip τα μη μηδενικά offsets του anchor xdr:colOff και xdr:rowOff που ο drawing writer σκληροκοδίζωνε στο μηδέν. Και τα δύο ελαττώματα βγήκαν από μία περίπτωση τοπικού corpus, το two-charts.xlsx: πρώτα ένας δομικός έλεγχος είδε δύο chart parts να γίνονται τρία, μετά μια σύγκριση byte κάθε xl/charts/chartN.xml έδειξε charts που δεν τα είχε αγγίξει κανείς να ξαναγράφονται — και κανένα από τα δύο προβλήματα δεν πετούσε exception ούτε προκαλούσε παράπονο από το Excel, γι' αυτό και επιβίωσαν όσο επιβίωσαν

Γιατί ένα βιβλίο εργασίας με δύο charts γύρισε με τρία chart parts;

Γιατί ο loader είχε ένα fallback που μάντευε. Όταν ένα worksheet δεν είχε drawing relationship μέσα στο .rels part του, ο παλιός κώδικας υπέθετε ότι το drawing ζει στο συμβατικό όνομα xl/drawings/drawing{i+1}.xml, όπου i η θέση του sheet, και προσάρταγε αυτό το part αν υπήρχε στο archive. Στο two-charts.xlsx το πρώτο sheet δεν έχει ούτε drawing ούτε καν .rels part, ενώ το xl/drawings/drawing1.xml υπάρχει — ανήκει στο δεύτερο sheet, που το φτάνει μέσα από Target="../drawings/drawing1.xml". Το sheet 1 κληρονόμησε λοιπόν ένα chart που δεν ανέφερε ποτέ, το chart1.xml έγινε parse δύο φορές, και το save έγραψε το βιβλίο εργασίας με τρία chart parts αντί για δύο

Πώς το HotXLS λύνει τα worksheet drawings στο δείγμα two-charts: το Sheet1 δεν έχει ούτε drawing relationship ούτε rels part ενώ το Sheet2 φτάνει το xl/drawings/drawing1.xml μέσω ParPartTargets, και το fallback πριν το 2.382.0 μάντευε αυτό το συμβατικό όνομα από τη θέση του sheet ώστε το chart1.xml να γινόταν parse δύο φορές και τα saves να έγραφαν τρία chart parts μέχρι το fix να φορτώνει drawings μόνο μέσω XlsxRtDrawing
Το Sheet1 δεν ανέφερε ποτέ chart, οπότε το relationship graph είναι η μόνη ασφαλής πηγή για το drawing target, και ένα μαντευμένο συμβατικό όνομα μετέτρεψε ένα βιβλίο εργασίας δύο charts σε save με τρία parts

Το fix στο HotXLS v2.382.0 κατάργησε ολότελα το μάντεψμα. Ένα worksheet drawing φορτώνεται πλέον μόνο μέσω ParPartTargets[i].Values[XlsxRtDrawing], του target που έχει καταγραφεί για τον τύπο drawing relationship πάνω στο sheet, και ένα sheet χωρίς τέτοιο relationship δεν παίρνει καθόλου drawing. Αυτή είναι η συμπεριφορά που απαιτεί η μορφή: το στοιχείο <drawing r:id="…"/> μέσα στο worksheet (ECMA-376 Part 1 §18.3.1.36) είναι ο μόνος σύνδεσμος ανάμεσα σε ένα sheet και το drawing του, και τα part names σε ένα OPC package δεν κουβαλάνε κανένα νόημα πέρα από αυτό που τους αναθέτει το relationship graph. Τα archives που γράφει το Excel τυχαίνει να χρησιμοποιούν τα συμβατικά ονόματα, και αυτό είναι που άφηνε τη συντόμευση να περνάει τόσο καιρό· η ανάλυση του OPC relationship resolution στο HotXLS καλύπτει γιατί το μάντεψμα ενός part name δεν είναι ποτέ ασφαλές ακόμα κι όταν η μαντεψιά συνήθως στέκει

// Πριν το v2.382.0: ένα λείπον drawing relationship κατέληγε σε μάντεψμα
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // μπορεί να ανήκει σε άλλο sheet

// Από το v2.382.0: relationship ή τίποτα
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

Τι εγγυάται το chart fingerprint;

Το fingerprint αποφασίζει, ανά chart, αν το save μπορεί να αντιγράψει το αυθεντικό part ή πρέπει να το ξαναδημιουργήσει. Στο import, με το PreserveUnsupportedParts ενεργοποιημένο πριν το Open, το HotXLS κρατάει τα raw UTF-8 bytes κάθε chart part στο FRawChartXml, χτίζει τη δική του serialization του typed model με το BuildChartKnownXml, και αποθηκεύει το μήκος αυτής της serialization στο FRawChartModelLength και το hash της στο FRawChartModelHash. Το hash είναι FNV-1a πάνω στα UTF-16 code units του παραγόμενου XML, με το καθιερωμένο 64-bit offset basis 14695981039346656037 και prime 1099511628211. Τη στιγμή του save το XlsxChartRawModelUnchanged ξαναχτίζει το known XML και συγκρίνει μήκος και hash· η αντιστοιχία σημαίνει ότι το typed model είναι ακριβώς ό,τι ήταν στο import, οπότε τίποτα από όσα θα μπορούσε να έχει αλλάξει η εφαρμογή δεν έχει αλλάξει

Το HotXLS καταγράφει το chart fingerprint στο import, κρατώντας raw UTF-8 bytes στο FRawChartXml ενώ το BuildChartKnownXml δίνει FRawChartModelLength και ένα hash FNV-1a, και τη στιγμή του save το XlsxChartRawModelUnchanged ξαναχτίζει και συγκρίνει και τις δύο τιμές, ώστε μια αντιστοιχία να αναπαράγει τα αυθεντικά bytes ή να αντιγράφει το συμπιεσμένο entry και μια αναντιστοιχία να πέφτει στο XlsxMergeChartXml
Ένα fingerprint είναι τόσο καλό όσο η στιγμή που το παίρνεις, και η καταγραφή του πριν τελειώσουν όλα τα recovery passes εγγυάται ένα hash που δεν θα ταιριάζει ποτέ ξανά με το ολοκληρωμένο model
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                                  // τίποτα διατηρημένο
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // αναπαραγωγή verbatim
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // δομικό merge
end;

Ο XLSX writer πηγαίνει ένα βήμα παραπέρα από το BuildChartXmlFromKnown. Όταν το model είναι αμετάβλητο και το StrictOOXML είναι κλειστό, δοκιμάζει πρώτα να αντιγράψει το συμπιεσμένο entry κατευθείαν από το source archive στο output κάτω από το νέο part name του chart, ώστε τα bytes να μην καν decode και re-deflate. Μόνο αν εκείνη η αντιγραφή δεν είναι εφικτή πέφτει στο decode-or-merge μονοπάτι. Ο ίδιος ο μηχανισμός — μήκος συν hash, αναπαραγωγή όταν ισούνται, merge όταν όχι — είναι αυτός που περιγράφεται στη σημείωση για το επεξεργασία Excel charts χωρίς απώλεια ChartML. Το άρθρο αυτό αφορά τον τρόπο που σταμάτησε σιωπηλά να δουλεύει

Γιατί όμως κάθε chart έπαιρνε το merge μονοπάτι;

Γιατί το fingerprint καταγραφόταν μια κλήση νωρίτερα. Το chart parsing στο HotXLS είναι ένα SAX πέρασμα πάνω στο chart part ακολουθούμενο από ένα σύνολο recovery passes που τραβάνε λεπτομέρειες από το raw text που οι SAX handlers δεν μοντελοποιούν άμεσα: το XlsxChartParseSeriesFlags διαβάζει κάθε block <c:ser> για το flag <c:smooth> του και τις τιμές srgbClr του marker fill και του marker line, και μετά ανακτά axis crossing modes και στυλ major και minor tick marks για τους category και value axes. Πριν το v2.382.3 η σειρά στο τέλος του ParseChartXml ήταν: κατηγοριοποίησε τις axis groups, χτίσε το known XML, κατέγραψε μήκος και hash, και μόνο τότε εκτέλεσε το XlsxChartParseSeriesFlags. Το fingerprint περιέγραψε επομένως ένα model που ακόμα στερούνταν smooth flags, χρωμάτων marker και tick marks. Τη στιγμή του save το BuildChartKnownXml εκτελούνταν πάνω στο ολοκληρωμένο model, που πια εξέπεμπε <c:smooth val="1"/> και τα ανακτημένα χρώματα marker. Μεγαλύτερο XML, διαφορετικό hash, το XlsxChartRawModelUnchanged γύριζε False, και το chart περνούσε από το XlsxMergeChartXml. Το merge είναι σωστή λειτουργία για ένα chart που κάποιος επεξεργάστηκε, αλλά δεν είναι διατήρηση byte: ξανασειριοποιεί το δέντρο, και ο κανόνας ιδιοκτησίας που αφήνει το typed model να κερδίζει για series, axes και plot groups σημαίνει ότι οι αναδημιουργημένοι κόμβοι αντικαθιστούν τα αυθεντικά. Το ορατό αποτέλεσμα στην εκτέλεση του corpus ήταν drifted χρώματα series σε charts που δεν τα είχε αγγίξει κανείς — κάθε chart σε κάθε διατηρημένο βιβλίο εργασίας, σε κάθε save, χωρίς καμία διάγνωση πουθενά

Η επισκευή είναι μία αναδιάταξη: το XlsxChartParseSeriesFlags εκτελείται πλέον πριν χτιστεί το known XML, ώστε το fingerprint να περιγράφει το model όπως θα υπάρχει τη στιγμή που η εφαρμογή θα το βλέπει πρώτη φορά. Το μάθημα γενικεύεται πέρα από τα charts. Ένα fingerprint ανίχνευσης αλλαγών είναι τόσο καλό όσο η στιγμή που το παίρνεις, και η ασφαλής στιγμή είναι αφού έχει τελειώσει κάθε πέρασμα που μπορεί να μεταλλάξει το model. Το HotXLS έχει ένα δεύτερο σημείο καταγραφής για τις ίδιες δύο τιμές, το baseline που επανακαθιερώνει απέναντι στο αρχείο εξόδου μετά από ένα επιτυχές save, και εκείνο το σημείο πάντα δούλευε πάνω σε πλήρως parsed model· το σημείο του import ήταν η εξαίρεση

Πού πήγαν τα offsets του anchor;

Σε ένα κυριολεκτικό μηδέν. Ένα twoCellAnchor στο drawing part καρφώνει ένα chart ανάμεσα σε δύο κελιά, και κάθε γωνία κουβαλάει ένα cell index συν ένα offset μέσα στο κελί εκείνο: το from (ECMA-376 Part 1 §20.5.2.5) και το to (§20.5.2.32) κρατούν το καθένα col, colOff (§20.5.2.4), row και rowOff. Τα offsets είναι σε English Metric Units, 914400 ανά ίντσα, και το Excel γράφει μη μηδενικές τιμές κάθε φορά που ένα chart τοποθετήθηκε ή πήρε μέγεθος με το ποντίκι, που είναι τα περισσότερα charts. Το πρώτο chart στο two-charts.xlsx ξεκινά στη γραμμή 0 με rowOff 19049 και τελειώνει στη στήλη 8, γραμμή 15, με colOff 247650 και rowOff 66674 — περίπου ένα τέταρτο της ίντσας μέσα στην τελευταία στήλη. Ο drawing parser στο HotXLS διάβαζε πάντα αυτές τις τέσσερις τιμές — ο κώδικας των εικόνων τις χρησιμοποιούσε — αλλά ο chart writer εξέπεμπε <xdr:colOff>0</xdr:colOff> και <xdr:rowOff>0</xdr:rowOff> για κάθε γωνία, κουμπώνοντας κάθε chart πάνω στο πλέγμα των κελιών στο save

Ανατομία των γωνιών xdr:twoCellAnchor για το πρώτο chart του δείγματος HotXLS: το from κρατά col 0 και rowOff 19049 ενώ το to κρατά col 8, colOff 247650 και rowOff 66674 σε EMU των 914400 ανά ίντσα, και ο writer που εξέπεμπε μηδενικά offsets κούμπωνε τα charts στο πλέγμα μέχρι τα FFromColOff, FToColOff και τα αδέρφια τους να αναπαράγουν τις imported τιμές
Το anchor ζει στο drawing part και όχι στο chart part, οπότε αυτή η επισκευή είναι ανεξάρτητη από το fix του fingerprint, και τα δύο έπρεπε να κυκλοφορήσουν πριν το βιβλίο εργασίας κάνει αληθινά round-trip
// Από το v2.382.3 ο anchor writer αναπαράγει τα imported EMU offsets
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 κουβαλάει πλέον FFromColOff, FFromRowOff, FToColOff και FToRowOff, γεμάτα από τον drawing parser και αντιγραμμένα μαζί με την υπόλοιπη anchor κατάσταση όταν ένα chart γίνεται assign. Είναι σκόπιμα private: η public επιφάνεια του anchor παραμένουν οι τέσσερις συντεταγμένες κελιών FromRow, FromCol, ToRow και ToCol, και ένα chart που δημιουργείται από Delphi κώδικα προσγειώνεται σε όρια κελιών όπως πριν. Τα offsets υπάρχουν για να γίνει το round trip πιστό, όχι για να εκτεθεί η τοποθέτηση εντός κελιού ως feature. Σημειώστε ότι αυτό το fix είναι ανεξάρτητο από το fingerprint: το anchor ζει στο drawing part, όχι στο chart part, οπότε ένα chart του οποίου το ChartML αναπαραγόταν τέλεια θα συνέχιζε να πηδάει στο πλέγμα χωρίς αυτό. Οι μετατροπές μονάδων πίσω από αυτές τις EMU τιμές καλύπτονται στη σημείωση για το HotXLS image geometry και EMU scaling

Πώς αποδεικνύεις ότι ένα chart κάνει round-trip αναλλοίωτο;

Συγκρίνοντας bytes, όχι ανοίγοντας το αποτέλεσμα στο Excel. Το Excel επιδιορθώνει και κανονικοποιεί τόσα πράγματα στο load που ένα drifted chart φαίνεται μια χαρά μέχρι μια αναλύτρια να προσέξει ότι άλλαξε το χρώμα του marker. Το corpus test που έπιασε και τα δύο ελαττώματα κάνει τρία πράγματα μετά από ένα open-and-save χωρίς καμία επεξεργασία: διασχίζει τις σχέσεις worksheet, drawing και chart και αποτυγχάνει σε οποιαδήποτε διπλή, ορφανή ή κρεμάμενη chart αναφορά· συγκρίνει ένα signature του chart τύπου, των series formulas και της anchor γεωμετρίας ανάμεσα σε original και output· και για το two-charts.xlsx διαβάζει κάθε xl/charts/chartN.xml και από τα δύο archives και απαιτεί πανομοιότυπα bytes. Ο ίδιος έλεγχος γράφεται εύκολα σε Delphi με το 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);   // πετάει exception αν το part εξαφανίστηκε
        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;

Τρεις προϋποθέσεις κάνουν νόημα αυτή τη σύγκριση, και κάθε μία αποτυγχάνει σιωπηλά αν ξεχαστεί. Το PreserveUnsupportedParts πρέπει να είναι True πριν το Open, αλλιώς δεν καταγράφονται raw bytes και κάθε chart ξαναχτίζεται από το model. Το StrictOOXML πρέπει να είναι False, γιατί το strict mode επιβάλλει αναδημιουργία εκ σχεδίου. Και η εφαρμογή δεν πρέπει να αγγίξει το chart ανάμεσα σε open και save — το διάβασμα properties είναι μια χαρά, αλλά οποιοδήποτε setter αλλάζει το typed model αναποδογυρίζει το fingerprint και στέλνει το chart στο merge μονοπάτι, που είναι σωστή συμπεριφορά και δεν είναι αυτό που τεστάρει το test. Επιπλέον, τα chart parts αριθμούνται εκ νέου από έναν μετρητή όλου του βιβλίου εργασίας στο save, οπότε ένα βιβλίο εργασίας του οποίου η σειρά sheets ή charts άλλαξε θα τοποθετήσει πανομοιότυπα bytes κάτω από διαφορετικό όνομα chartN.xml· ο corpus checker ακολουθεί relationships αντί για ονόματα γι' αυτόν ακριβώς τον λόγο

Και τα δύο fixes κυκλοφόρησαν στα HotXLS 2.382.0 και 2.382.3 και επαληθεύτηκαν σε Win32 και Win64 πάνω στο τοπικό corpus, με τα resaved chart δείγματα να αποδίδονται επιπλέον σε PDF μέσω μιας ανεξάρτητης office σουίτας και να συγκρίνονται σελίδα σελίδα με τα πρωτότυπα. Το HotXLS διαβάζει, επεξεργάζεται και γράφει XLSX charts από native Delphi και C++Builder κώδικα χωρίς καμία εγκατάσταση Excel, και αυτό κάνει αυτό το επίπεδο πιστότητας ευθύνη μιας βιβλιοθήκης — η σελίδα του HotXLS Delphi spreadsheet component έχει τη λίστα features και ένα trial download