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

Ταξινόμηση εξωτερικών συνδέσεων SupBook και XTI σε Delphi

Άνοιξε ένα παλιό xls, αποθήκευσέ το ξανά, και ο τύπος add-in που καλούσε μια καταχωρημένη βιβλιοθήκη ανάλυσης δείχνει τώρα σε μια κενή αναφορά μέσα στο ίδιο το βιβλίο εργασίας. Το HotXLS ανιχνεύει αυτή τη σιωπηλή φθορά σε μια κακή υπόθεση: ότι μια εγγραφή BIFF SupBook είναι είτε self είτε εξωτερικό αρχείο. Το [MS-XLS] ορίζει επτά είδη, όχι δύο

Γιατί ένα αποθηκευμένο βιβλίο εργασίας χάνει τις συνδέσεις add-in του;

Επειδή το τεστ ταξινόμησης ήταν δομικό αντί τυπολογικό. Η παραδοσιακή συντόμευση διαβάζει μια εγγραφή SupBook ($01AE), ελέγχει αν κουβαλά τον δείκτη self, και αν όχι, μεταχειρίζεται όποια συμβολοσειρά ακολουθεί ως URL εγγράφου. Κάθε εγγραφή που δεν είναι ούτε το ένα ούτε το άλλο πέφτει σε έναν προεπιλεγμένο κλάδο, και ο προεπιλεγμένος κλάδος είναι σχεδόν πάντα «αυτό είναι το ίδιο το βιβλίο εργασίας». Μια υποστηρικτική σύνδεση add-in, μια σύνδεση ίδιου φύλλου, μια αχρησιμοποίητη θέση και μια περικομμένη εγγραφή καταλήγουν όλες φορώντας την ίδια λάθος ετικέτα. Τίποτα δεν πετάει εξαίρεση όσο αυτό συμβαίνει: η εγγραφή αναλύθηκε, ο τύπος ξαναμεταγλωττίστηκε, το αρχείο αποθηκεύτηκε χωρίς προειδοποίηση, και το ελάττωμα αναδύεται τρεις εβδομάδες μετά όταν κάποιος προσέχει μια στήλη μηδενικών εκεί που υπήρχε μετατροπή νομίσματος. Το [MS-XLS] §2.4.271 περιγράφει μια εγγραφή που μπορεί να είναι self-αναφορά, αναφορά ίδιου φύλλου, δοχείο συναρτήσεων add-in, εξωτερικό βιβλίο εργασίας με εικονική διαδρομή και πίνακα ονομάτων φύλλων, σύνδεση δεδομένων DDE ή OLE, ή αχρησιμοποίητο placeholder — και μια έβδομη κατάσταση που δεν είναι στην προδιαγραφή αλλά υπάρχει σε πραγματικούς δίσκους, η εγγραφή που δεν αναλύεται. Η λύση δεν είναι μια καλύτερη ευρετική· είναι η άρνηση ύπαρξης ευρετικής καθόλου

Τα επτά είδη που μπορεί να κουβαλά μια εγγραφή SupBook

Το HotXLS δηλώνει την ταξινομία supporting-link ως κλειστή απαρίθμηση στο lxExternSheet.pas, και κάθε κατάντη απόφαση κάνει switch πάνω της. Εννέα τιμές απαρίθμησης καλύπτουν τις επτά κατηγορίες, επειδή η περίπτωση DDE και OLE χρειάζεται μια προσωρινή κατάσταση πριν επιλυθεί:

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // απέτυχε η ανάλυση, ή έμειναν byte στο τέλος
    slkSelf,              // αυτό το βιβλίο εργασίας
    slkSameSheet,         // δείκτης U+0000
    slkAddIn,             // δοχείο συναρτήσεων add-in
    slkExternalWorkbook,  // εικονική διαδρομή + πίνακας ονομάτων φύλλων
    slkDde,               // επιλύεται από τα flags του ExternName
    slkOle,               // επιλύεται από τα flags του ExternName
    slkDdeOrOle,          // ένα από τα δύο, ακόμα άγνωστο ποιο
    slkUnused);           // placeholder ενός μοναδικού κενού

  TXLSFormulaReferenceClass = (
    frcInternal,
    frcExternalWorkbook,
    frcExternalOther,
    frcUnknownOrMalformed);

  TXLSXtiInfo = record
    XtiIndex    : Integer;   // zero-based, όπως αποθηκεύεται στο ExternSheet.rgXTI
    ExternID    : Integer;   // one-based, η εσωτερική σύμβαση
    SupBookIndex: Integer;
    Sheet1Index : Integer;
    Sheet2Index : Integer;
    LinkKind    : TXLSSupportingLinkKind;
  end;

Η αποστολή βασίζεται σε sentinels, όχι σε συμβολοσειρές. Τιμή πεδίου $0401 σηματοδοτεί την εγγραφή self. Πλήθος φύλλων ένα μαζί με $3A01 σηματοδοτεί δοχείο add-in. Μόνο τιμή στο εύρος 1 έως $00FF σημαίνει ότι ακολουθεί κωδικοποιημένη εικονική διαδρομή, και μόνο τότε το HotXLS αποκωδικοποιεί καθόλου συμβολοσειρά. Οτιδήποτε εκτός αυτών των τριών σχημάτων μένει slkUnknown, και εγγραφή της οποίας ο πίνακας ονομάτων φύλλων δεν καταναλώνει ακριβώς το σώμα της εγγραφής υποβαθμίζεται πίσω σε slkUnknown ακόμα κι όταν η κεφαλή έμοιαζε πιθανή

Η σκάλα με sentinels που χρησιμοποιεί το HotXLS για να ταξινομήσει μια εγγραφή BIFF SupBook σε επτά είδη, αποκωδικοποιώντας συμβολοσειρά μόνο για τιμές στο εύρος κωδικοποιημένης διαδρομής και γυρνώντας σε άγνωστο είδος αντί σε προεπιλεγμένο κλάδο που σημαίνει αυτό το βιβλίο εργασίας
Κάθε είδος φτάνεται με sentinel και όχι με τεστ συμβολοσειράς, και εγγραφή που δεν ταιριάζει σε κανένα από τα σχήματα μένει άγνωστη αντί να πέσει σε προεπιλεγμένο κλάδο που σημαίνει αυτό το βιβλίο εργασίας

Γιατί ο δείκτης same-sheet αποκωδικοποιείται ως κενή συμβολοσειρά;

Επειδή ο γενικής χρήσης αναγνώστης συμβολοσειρών BIFF καταστρέφει το byte από το οποίο εξαρτάται η ταξινόμηση. Η υποστηρικτική σύνδεση same-sheet είναι συμβολοσειρά ενός χαρακτήρα του οποίου ο μοναδικός χαρακτήρας είναι U+0000, και το TXLSBlob.GetBiffString την επιστρέφει ως κενό WideString, αδιακρίτως από πραγματικά κενή διαδρομή — που είναι ακριβώς η είσοδος στην οποία μια ευρετική self-αναφορά απαντά «self». Το HotXLS λοιπόν διαβάζει τον ακατέργαστο πρώτο code point από το σώμα της εγγραφής αντί να εμπιστευτεί την αποκωδικοποιημένη τιμή:

StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
  StringOptions := Data.GetByte(StringOffset + 2);
  if (StringOptions and $01) = 0 then
    FirstChar := Data.GetByte(StringOffset + 3)     // συμπιεσμένο, ένα byte
  else
    FirstChar := Data.GetWord(StringOffset + 3);    // φαρδύ, δύο bytes
end;

if FirstChar = 0 then
  FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
  FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
  FKind := slkDdeOrOle
else if FDocUrl <> '' then
  FKind := slkExternalWorkbook;

Πρόσεξε τον κλάδο συμπιεσμένου-έναντι-φαρδύ. Το byte επιλογών κάθεται σε σταθερή μετατόπιση από τη κεφαλή της συμβολοσειράς και ο πρώτος code point είναι ένα ή δύο bytes ανάλογα με το bit 0, οπότε η άνευ όρων ανάγνωσή του ως byte δουλεύει στα περισσότερα αρχεία και αποτυγχάνει σε αυτά που έγραψαν localized εκδόσεις — η χειρότερη δυνατή κατανομή για bug. Το placeholder αχρησιμοποίητης θέσης πιάνεται με τον ίδιο τρόπο, από το κυριολεκτικό payload ενός κενού, και η περίπτωση DDE ή OLE από τον διαχωριστή U+0003 ενσωματωμένο στην κωδικοποιημένη διαδρομή

Γιατί το HotXLS διαβάζει τον ακατέργαστο πρώτο code point από το σώμα εγγραφής BIFF SupBook αντί για την αποκωδικοποιημένη συμβολοσειρά, επειδή ο γενικής χρήσης αναγνώστης συμβολοσειρών μετατρέπει τον δείκτη same-sheet U+0000 σε κενή τιμή
Ο δείκτης same-sheet είναι συμβολοσειρά ενός χαρακτήρα του οποίου ο χαρακτήρας είναι U+0000, οπότε ο γενικός αναγνώστης συμβολοσειρών τον διπλώνει σε κενή τιμή και μόνο ο ακατέργαστος code point στη μετατόπιση του byte επιλογών τον κρατά

Γιατί DDE και OLE δεν χωρίζονται τη στιγμή του SupBook;

Επειδή η εγγραφή SupBook δεν κουβαλά τα διακριτικά bits. Σου λέει ότι η σύνδεση είναι ένα από τα δύο· τα flags fOle και fOleLink που αποφασίζουν ποιο μένουν στην εγγραφή ExternName ($0023) που φτάνει αργότερα στη ροή. Το HotXLS καταγράφει slkDdeOrOle τη στιγμή της ανάλυσης και το στενεύει στο ParseExternalName, και αν δεν έρθει ποτέ ExternName το είδος μένει προσωρινό για πάντα — που είναι σωστό, γιατί το αρχείο πραγματικά δεν το λέει. Κάθε καταναλωτής κατάντη μεταχειρίζεται αυτή την προσωρινή τιμή ως πραγματική τιμή και όχι ως ελλιπή, οπότε κανένας καλών δεν χρειάζεται να επινοήσει tiebreak. Το να μαντέψεις «πιθανόν DDE» εδώ θα αγόραζε μια πιο τακτοποιημένη απαρίθμηση και μια κατηγορία λάθος απαντήσεων που κανείς δεν θα μπορούσε να ιχνηλατήσει πίσω:

if FKind = slkDdeOrOle then
begin
  if Data.DataLength < 2 then
    Exit;
  Flags := Data.GetWord(0);
  if (Flags and $0010) <> 0 then
    FKind := slkOle
  else if (Flags and $0008) <> 0 then
    FKind := slkDde;
end;

Τα ευρετήρια XTI είναι zero-based στον δίσκο και one-based στο εσωτερικό

Το HotXLS εκτελεί τη μετατροπή off-by-one ακριβώς μία φορά, στο σημείο που ένα token μπαίνει στο εσωτερικό δέντρο σύνταξης, και πουθενά αλλού. Το PtgNameX.ixti ([MS-XLS] §2.5.198.85) είναι ευρετήριο zero-based στον πίνακα rgXTI της εγγραφής ExternSheet ($0017, §2.4.106), ενώ η εσωτερική σύμβαση ExternID της βιβλιοθήκης είναι one-based με το μηδέν δεσμευμένο για «κανένα εξωτερικό φύλλο». Η διαδρομή ανάγνωσης BIFF8 κάνει FExternID := wValue + 1 όταν αποκωδικοποιεί token tNameX και η διαδρομή εγγραφής εκπέμπει StoreExternID - 1, αφήνοντας την ακατέργαστη όψη token και τη σημασιολογία στον δίσκο ανέγγιχτες. Να το κάνεις λάθος είναι ασυνήθιστα δύσκολο να πιαστεί: εξωτερικά καθορισμένα ονόματα επιλύονται στη γειτονική καταχώρηση, και σε αρχείο με μία καταχώρηση XTI το ευρετήριο 0 γίνεται 1, χάνει, και το όνομα υποβαθμίζεται σιωπηλά. Ένα regression που εξασκεί μόνο επαναμεταγλωττισμένο κείμενο τύπου δεν το βλέπει ποτέ, επειδή η επαναμεταγλώττιση δεν αγγίζει καθόλου το ευρετήριο του δίσκου — η ίδια παγίδα που κάνει τα καθορισμένα ονόματα που εκτείνονται σε φύλλα και βιβλία εργασίας να αξίζουν τεστ σε πραγματικά streams bytes. Η επίλυση είναι φραγμένη στα δύο άκρα: το TlxExternSheetSheet.TryResolveXti επιστρέφει False για αρνητικό ευρετήριο ή ελλιπή καταχώρηση, το TXLSSupBook.TryGetKind επιστρέφει False για ευρετήριο SupBook εκτός πίνακα, και το ClassifyXti μετά αντιστοιχίζει slkSelf και slkSameSheet σε frcInternal, slkExternalWorkbook σε frcExternalWorkbook, και slkAddIn, slkDde, slkOle και slkDdeOrOle σε frcExternalOther. Ό,τι άλλο, κάθε διαδρομή εκτός εύρους συμπεριλαμβανομένης, προσγειώνεται στο frcUnknownOrMalformed

Το HotXLS μετατρέπει το zero-based ευρετήριο XTI ενός token BIFF PtgNameX στο one-based εσωτερικό του ExternID σε ένα μοναδικό σημείο, με φραγμένη επίλυση στα δύο άκρα και τον χάρτη ταξινόμησης που τον καταναλώνει
Το off-by-one μεταξύ του zero-based ευρετηρίου δίσκου και του one-based εσωτερικού ExternID εφαρμόζεται μία φορά, καθώς ένα token μπαίνει στο δέντρο σύνταξης, και κάθε άλυτο ευρετήριο προσγειώνεται στην κατηγορία malformed

Ταξινομώντας έναν τύπο πριν τον παγώσεις

Το TXLSCompiledFormula.ClassifyReferences σαρώνει απευθείας το διατηρημένο stream tokens BIFF αντί να απομεταφράσει τον τύπο και να ψάξει για αγκύλες. Το κυνήγι αγκυλών σε κείμενο τύπου είναι ευρετική κειμένου ντυμένη παλτό parser: ταιριάζει literals συμβολοσειρών, ταιριάζει δομημένες αναφορές, και χάνει τελείως τα εξωτερικά καθορισμένα ονόματα, αφού αυτά δεν κουβαλούν αγκύλες σε απομεταφρασμένη μορφή. Η σάρωση tokens κοιτάζει μόνο PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3d και PtgAreaErr3d, με επιστροφή σε διάσχιση δέντρου σύνταξης όταν δεν επιβιώνει stream BIFF. Η συγχώνευση είναι σκόπιμα απαισιόδοξη — η σταθερή προτεραιότητα είναι frcUnknownOrMalformed, μετά frcExternalWorkbook, μετά frcExternalOther, μετά frcInternal — οπότε ένα μοναδικό δυσανάγνωστο token δηλητηριάζει όλο τον τύπο. Για εξωτερικό καθορισμένο όνομα το ευρετήριο ονόματος επικυρώνεται κι αυτό: one-based, στο εύρος, και υποστηριζόμενο από διατηρημένη εγγραφή ExternName

var
  Wb   : TXLSWorkbook;
  Sheet: TXLSWorksheet;
  i    : Integer;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('quarterly.xls');
    for i := 1 to Wb.Sheets.Count do        // Το Sheets βασίζεται στο ένα
    begin
      Sheet := Wb.Sheets[i];
      // Παγώνει ΜΟΝΟ τύπους ταξινομημένους frcExternalWorkbook;
      // εσωτερικές, add-in, DDE/OLE και κακοσχηματισμένες αναφορές μένουν τύποι
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

Η παράμετρος OnlyExternal είναι εκεί που η ταξινομία πληρώνει το κόστος της. Το πάγωμα ενός τύπου είναι μη αναστρέψιμο, άρα η λειτουργία πρέπει να αποδείξει ότι μια αναφορά είναι εξωτερικό βιβλίο εργασίας και όχι απλώς να την υποψιαστεί. Οι κλήσεις add-in επιβιώνουν, οι συνδέσεις DDE και OLE επιβιώνουν, και ό,τι δεν μπόρεσε ο parser να καταλάβει πλήρως επιβιώνει, επειδή το ασφαλές αποτέλεσμα της αβεβαιότητας είναι να μην αλλάξει τίποτα. Η ίδια πειθαρχία κυβερνά το rebinding τύπων που αντιγράφονται μεταξύ βιβλίων εργασίας, όπου μια κακοταξινομημένη αναφορά επαναδένεται σε λάθος βιβλίο αντί να αποτύχει δυνατά

Οι εγγραφές που δεν αναλύονται ξαναγράφονται ανέγγιχτες

Το HotXLS κρατά το αρχικό payload SupBook και το επανεκπέμπει byte-προς-byte όταν η εγγραφή δεν επεξεργάστηκε ποτέ. Αποτυχία ανάλυσης θέτει slkUnknown και καθαρίζει την παράγωγη κατάσταση, αλλά το καταγεγραμμένο σώμα μένει στο FRawData και η διαδρομή αποθήκευσης το προτιμά από κάθε ανακατασκευή όσο το στοιχείο δεν είναι dirty και δεν είναι η εγγραφή self. Η εναλλακτική — η κανονικοποίηση μιας μη αναλυμένης εγγραφής σε self-αναφορά ώστε ο writer να έχει κάτι καλά σχηματισμένο να εκπέμψει — μετατρέπει μια εγγραφή που δεν κατάλαβες σε εγγραφή που είναι οριστικά λάθος. Η αρχή είναι το ίδιο συμβόλαιο που εφαρμόζεται στα VBA projects και τις εξωτερικές αναφορές τους σε έναν κύκλο load-and-save, και είναι η διαφορά μεταξύ βιβλιοθήκης που κάνει round-trip πραγματικά αρχεία και βιβλιοθήκης που κάνει round-trip τα αρχεία που τυχαίνει να περιέχει η σουίτα τεστ της. Ένα βιβλίο εργασίας που πέρασε από δεκαπέντε χρόνια εκδόσεων Excel, μια γεννήτρια αναφορών και δύο εργαλεία μετάπτωσης θα περιέχει εγγραφές που δεν σχεδίασε κανένας εν ζωή. Γράψέ τες πίσω όπως τις βρήκες

Η ταξινόμηση των εγγραφών SupBook και XTI βάσει τύπου κυκλοφόρησε στο HotXLS 2.361.2 έως 2.361.4, μαζί με φραγμένη επίλυση XTI και τη πιο ασφαλή διαδρομή ConvertFormulasToValues που περιγράφεται εδώ. Αν συντηρείς κώδικα Delphi ή C++Builder που διαβάζει legacy xls αρχεία με κλήσεις add-in, συνδέσεις DDE ή OLE, ή εξωτερικά καθορισμένα ονόματα, το HotXLS Delphi spreadsheet component χειρίζεται όλη την ταξινομία εγγενώς, χωρίς εγκατάσταση Excel και χωρίς OLE automation στο μηχάνημα που δουλεύει