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

Επίλυση σχέσεων OPC XLSX σε parsers Delphi

Ένα έγκυρο xlsx δεν χρειάζεται να περιέχει το xl/worksheets/sheet1.xml. Το HotXLS, το native στοιχείο υπολογιστικού φύλλου Excel για Delphi και C++Builder, εντοπίζει κάθε part μέσω του γράφου σχέσεων OPC αντί να μαντεύει ονόματα, γιατί το ISO/IEC 29500-2 εγγυάται μόνο ότι τα parts είναι προσβάσιμα από το _rels/.rels, ποτέ ότι κάθονται σε συμβατικές διαδρομές

Γιατί ο parser μου αποτυγχάνει σε ένα έγκυρο xlsx;

Επειδή τα ονόματα part που απομνημόνευσες είναι σύμβαση ενός παραγωγού, όχι απαίτηση της μορφής. Κάθε διαδρομή που έχεις ποτέ hardcode-άρει, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, είναι αυτό που τυχαίνει να εκπέμπει ο desktop writer του Excel. Ένα συμμορφούμενο πακέτο μπορεί να βάλει το βιβλίο εργασίας στο office/book.xml και το πρώτο φύλλο εργασίας στο xl/custom/data-sheet.xml και να παραμένει νόμιμο SpreadsheetML, αρκεί οι σχέσεις να δείχνουν εκεί. Αυτός είναι ο πιο συνηθισμένος λόγος που ένας σπιτικός αναγνώστης αναφέρει «δεν βρίσκω το sheet1.xml» σε ένα αρχείο που το Excel, το LibreOffice, και τα Numbers ανοίγουν όλα χωρίς παράπονο

Οι παραγωγοί που το κάνουν αυτό δεν είναι εξωτικοί. Οι γεννήτριες αναφορών από την πλευρά του server επαναχρησιμοποιούν ένα πακέτο-πρότυπο και κρατούν την αρχική του διάταξη. Οι ροές εξαγωγής που συγχωνεύουν δύο βιβλία εργασίας αριθμούν εκ νέου φύλλα και αφήνουν κενά, οπότε ένα βιβλίο εργασίας πέντε φύλλων έχει sheet1, sheet2, sheet4, sheet7, και sheet9. Εργαλεία που αφαιρούν ένα φύλλο δεν αριθμούν πάντα εκ νέου τους επιζώντες. Σε κάθε μία από αυτές τις περιπτώσεις η εικασία βασισμένη σε δείκτη xl/worksheets/sheet + IntToStr(i + 1) + .xml διαβάζει σιωπηρά το λάθος φύλλο ή δεν διαβάζει τίποτα, κάτι που είναι χειρότερο από μια εξαίρεση γιατί το βιβλίο εργασίας φορτώνει και οι αριθμοί είναι λάθος. Το ελάχιστο πακέτο παρακάτω ασκεί ολόκληρο το πρόβλημα, και είναι το σχήμα έναντι του οποίου το HotXLS τρέχει regression tests

<!-- _rels/.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
      Target="office/book.xml"/>
</Relationships>

<!-- office/_rels/book.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId42"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
      Target="../xl/custom/data-sheet.xml"/>
</Relationships>

<!-- xl/custom/_rels/data-sheet.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="note7"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
      Target="../notes/review.xml"/>
</Relationships>

Τι εγγυάται πραγματικά το ISO/IEC 29500-2;

Εγγυάται προσβασιμότητα, όχι θέση. Το ISO/IEC 29500-2 είναι το μέρος Open Packaging Conventions του προτύπου, και η ρήτρα σχέσεών του ορίζει ακριβώς ένα σταθερό σημείο εισόδου: το part σχέσεων πακέτου στο _rels/.rels. Από εκεί ακολουθείς τη σχέση της οποίας το Type είναι http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument για να φτάσεις στο part του βιβλίου εργασίας, και κάθε άλλο part ανακαλύπτεται διαβάζοντας το δικό του part σχέσεων και ακολουθώντας τυποποιημένες ακμές προς τα έξω

Δύο ακόμα κανόνες από το ίδιο πρότυπο κάνουν την πραγματική δουλειά. Η ρήτρα ονοματοδοσίας part καθορίζει πού κατοικεί ένα part σχέσεων: για ένα part στο <folder>/<name>, οι σχέσεις του είναι στο <folder>/_rels/<name>.rels, και για ένα part στη ρίζα του πακέτου ο φάκελος είναι απλά _rels/. Η ρήτρα σήμανσης σχέσεων δηλώνει ότι το Target είναι μια αναφορά URI που επιλύεται έναντι του URI του part πηγής, με τη συνήθη έννοια του RFC 3986, εκτός αν το TargetMode="External" το σημαδεύει ως δείχνον έξω από το πακέτο. Η επίλυση σχετική με την πηγή είναι το βήμα που όλοι παραλείπουν, και είναι ο λόγος που το ίδιο κυριολεκτικό ../notes/review.xml σημαίνει κάτι μέσα στο xl/custom/_rels/data-sheet.xml.rels και κάτι εντελώς διαφορετικό μέσα σε ένα αρχείο rels έναν φάκελο πιο βαθιά. Μια τελευταία πτυχή κάθεται ανάμεσα στο λογικό μοντέλο και τα bytes στον δίσκο: τα ονόματα part στο λογικό μοντέλο είναι απόλυτα και ξεκινούν με πλάγια κάθετο, αλλά η ρήτρα φυσικής αντιστοίχισης ZIP αφαιρεί αυτή την κάθετο όταν μετατρέπει ένα όνομα part σε όνομα στοιχείου ZIP, οπότε ένας resolver που το ξεχνά αναζητά το /xl/sharedStrings.xml στο αρχείο και δεν βρίσκει τίποτα

Μέσα στην XlsxResolveRelationshipTarget

Το HotXLS συγκεντρώνει ολόκληρο τον κανόνα επίλυσης σε μία συνάρτηση, την XlsxResolveRelationshipTarget, δηλωμένη στο lxHandleX.pas ως function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Παίρνει το όνομα στοιχείου ZIP του part πηγής και το ακατέργαστο χαρακτηριστικό Target, και επιστρέφει ένα όνομα στοιχείου ZIP χωρίς κάθετο στην αρχή, έτοιμο να δοθεί απευθείας στο αρχείο. Το πέρασμα ενός κενού OwnerPartName επιλύει έναντι της ρίζας του πακέτου, που είναι ακριβώς αυτό που χρειάζεται το part σχέσεων πακέτου. Η σειρά των λειτουργιών μετράει περισσότερο από τα μεμονωμένα βήματα: οι ανάστροφες κάθετοι κανονικοποιούνται πρώτα σε πλάγιες κάθετους, γιατί κάποιοι παραγωγοί γράφουν διαχωριστικά στυλ Windows στο Target· κάθε fragment που εισάγεται από # κόβεται πριν από τον χειρισμό διαδρομής, οπότε το ../charts/chart1.xml#Sheet1 επιλύεται σε όνομα part αντί σε ανύπαρκτη καταχώρηση αρχείου· μόνο τότε η συνάρτηση διαχωρίζει το απόλυτο από το σχετικό

// Normalization core, as implemented in lxHandleX.pas.
combined := StringReplace(Target, '\', '/', [rfReplaceAll]);
p := Pos('#', combined);
if p > 0 then
  combined := Copy(combined, 1, p - 1);
if (combined <> '') and (combined[1] = '/') then
  Delete(combined, 1, 1)              // package-absolute: strip the slash only
else
begin
  p := LastDelimiter('/', String(OwnerPartName));
  if p > 0 then
    baseName := Copy(OwnerPartName, 1, p)
  else
    baseName := '';
  combined := baseName + combined;    // relative to the source part folder
end;

source.StrictDelimiter := True;       // '/' only, no quote or space handling
source.Delimiter := '/';
source.DelimitedText := String(combined);
for i := 0 to source.Count - 1 do
begin
  segment := WideString(source[i]);
  if (segment = '') or (segment = '.') then
    Continue;                         // empty and dot segments vanish
  if segment = '..' then
  begin
    if parts.Count > 0 then
      parts.Delete(parts.Count - 1);  // pop, and never below the root
  end
  else
    parts.Add(String(segment));
end;

Ο βρόχος τμημάτων είναι μια απλή διάτρεξη στοίβας: κενά τμήματα και . απορρίπτονται, το .. κάνει pop ένα επίπεδο, και ένα .. που θα διέφευγε από τη ρίζα του πακέτου απορροφάται αντί να παράγει αρνητικό δείκτη ή όνομα που αρχίζει με ../. Η ανάθεση StrictDelimiter := True δεν είναι διακοσμητική. Χωρίς αυτήν μια Delphi TStringList αντιμετωπίζει τα κενά ως διαχωριστικά και τιμά χαρακτήρες εισαγωγικών, κάτι που παραμορφώνει κάθε όνομα part που περιέχει κενό, και ονόματα part με κενά είναι νόμιμα

Ακολουθώντας τον γράφο: βιβλίο εργασίας, φύλλο εργασίας, σχέδιο

Το HotXLS διατρέχει τρία επίπεδα part σχέσεων στο μονοπάτι TXLSXWorkbook.Open. Το επίπεδο πακέτου χειρίζεται η XlsxFindOfficeDocumentPart, που διαβάζει το _rels/.rels και επιστρέφει τον στόχο officeDocument. Το επίπεδο βιβλίου εργασίας διαβάζει το part σχέσεων του βιβλίου εργασίας και χτίζει δύο χάρτες ταυτόχρονα: έναν χάρτη αναγνωριστικών για αναζητήσεις r:id και έναν χάρτη τύπων για μοναδικά (singleton) parts. Τα επίπεδα φύλλου εργασίας και σχεδίου επαναλαμβάνουν το μοτίβο με τις ParseWorksheetRelsXml και ParseDrawingRelsXml, η καθεμία περνώντας το δικό της όνομα part ως βάση επίλυσης ώστε ένα σχέδιο που αναφέρεται στο ../media/image3.png να προσγειώνεται στο σωστό blob

// Tier 1: the only fixed name in the whole format.
WorkbookPartName := XlsxFindOfficeDocumentPart(zip);
if WorkbookPartName = '' then
  WorkbookPartName := 'xl/workbook.xml';        // legacy fallback
if not zip.Exists(WorkbookPartName) then
  Exit;

// Tier 2: <folder>/_rels/<name>.rels for the workbook part itself.
relsName := XlsxRelationshipPartName(WorkbookPartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParsePartRelationshipsXml(relsStream, WorkbookPartName,
      WorkbookTargetById, WorkbookTargetsByType);
  finally
    relsStream.Free;
  end;
end;

// Typed singletons resolve by relationship type URI.
PartName := WorkbookTargetsByType.Values[XlsxRtSharedStrings];
if PartName = '' then
  PartName := 'xl/sharedStrings.xml';

Τα φύλλα συγκεκριμένα πρέπει να περνούν από τον χάρτη αναγνωριστικών, όχι τον χάρτη τύπων. Τα στοιχεία <sheet> στο part του βιβλίου εργασίας φέρουν χαρακτηριστικά r:id, και αυτό το αναγνωριστικό είναι το μόνο πράγμα που δένει ένα όνομα φύλλου με ένα part. Το HotXLS συλλέγει αυτά τα αναγνωριστικά κατά τη διάρκεια της ParseWorkbookXml και επιλύει το καθένα έναντι του χάρτη σχέσεων του βιβλίου εργασίας, υποχωρώντας στο συμβατικό αριθμημένο όνομα μόνο όταν το αναγνωριστικό απουσιάζει ή δεν επιλύεται

// Tier 2b: r:id -> worksheet part, per sheet, in workbook order.
PartName := '';
if (i < SheetRelIds.Count) and (SheetRelIds[i] <> '') then
  PartName := WorkbookTargetById.Values[SheetRelIds[i]];
if PartName = '' then
  PartName := 'xl/worksheets/sheet' + IntToStr(i + 1) + '.xml';
SheetPartNames.Add(String(PartName));

// Tier 3: each worksheet resolves its own satellites against its own name.
relsName := XlsxRelationshipPartName(PartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParseWorksheetRelsXml(relsStream, PartName,
      FParRels[i], ParTableTargets[i], ParPartTargets[i]);
  finally
    relsStream.Free;
  end;
end;

Οτιδήποτε βρίσκεται μετά βασίζεται στον ίδιο μηχανισμό. Οι κοινές συμβολοσειρές, τα στυλ, το θέμα, το έργο VBA κάτω από τον τύπο με χώρο ονομάτων Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, οι εξωτερικοί σύνδεσμοι, το part προσώπου εμβέλειας βιβλίου εργασίας, τα παλαιά σχόλια, τα νηματοποιημένα σχόλια, το σχέδιο VML που φέρει τη γεωμετρία μπαλονιού σχολίου, τα σχέδια, οι εικόνες, τα γραφήματα, οι πίνακες, και τα PivotTables φτάνουν όλα στα bytes τους μέσω επιλυμένων στόχων. Το part θέματος συγκεκριμένα πρέπει να εντοπίζεται σωστά αλλιώς ένα round-trip σιωπηρά αντικαθιστά την παλέτα εταιρικής ταυτότητας ενός πελάτη με το τυπικό θέμα Office, ένας από τους τρόπους αποτυχίας που καλύπτονται στις σημειώσεις για το αζημίωτο round-trip XLSX θέματος, extLst, και calcChain. Η ανάγνωση σχέσεων είναι επίσης ο λόγος που η φόρτωση σταδιοποιείται όπως είναι: όλη η πρόσβαση αρχείου γίνεται σε ένα νήμα πριν αναλυθεί το XML φύλλου εργασίας, γιατί η κατάσταση inflate ενός αρχείου ZIP δεν είναι thread-safe, ένας περιορισμός που εξηγείται στο άρθρο για την παράλληλη ανάλυση XLSX και τον κατανεμητή μνήμης

Γιατί ένα διπλότυπο rId σπάει τη δρομολόγηση βασισμένη σε τύπο;

Γιατί μια μεταγενέστερη κακοσχηματισμένη καταχώρηση μπορεί να αντικαταστήσει μια προγενέστερη έγκυρη και να καταλάβει την αναζήτηση. Τα αναγνωριστικά σχέσεων υποτίθεται ότι είναι μοναδικά μέσα σε ένα part σχέσεων, αλλά κακοσχηματισμένα πακέτα τα επαναχρησιμοποιούν, και μια αφελής ανάθεση Values[Id] := είναι last-write-wins. Αν το rId3 πρώτα δείχνει σε ένα πραγματικό φύλλο εργασίας και ένα δεύτερο rId3 δείχνει σε έναν μη υποστηριζόμενο ή κενό στόχο, το last-write-wins χάνει το φύλλο εργασίας. Η ParsePartRelationshipsXml επομένως εφαρμόζει έναν κανόνα first-wins με δύο συνθήκες: ο επιλυμένος στόχος πρέπει να είναι μη κενός, και το αναγνωριστικό δεν πρέπει να είναι ήδη παρόν. Και οι δύο συνθήκες μαζί είναι αυτό που το κάνει ασφαλές, γιατί ο έλεγχος μη κενού εμποδίζει μια σχέση με ελλείπον Target από το να διεκδικήσει τη θέση πριν φτάσει μια χρησιμοποιήσιμη

if (TargetById <> nil) and (Id <> '') and (resolvedTarget <> '') and
  (TargetById.IndexOfName(String(Id)) < 0) then
  TargetById.Values[String(Id)] := String(resolvedTarget);
if (TargetsByType <> nil) and (relType <> '') and (resolvedTarget <> '') then
  TargetsByType.Add(String(relType + '=' + resolvedTarget));

Πρόσεξε τη σκόπιμη ασυμμετρία σε αυτό το απόσπασμα. Ο χάρτης αναγνωριστικών είναι ένας πραγματικός χάρτης με φύλακα first-wins, ενώ η συλλογή τύπων είναι μια λίστα μόνο-για-προσάρτηση από ζεύγη type=target. Αυτή η διάκριση είναι κρίσιμη: ένα βιβλίο εργασίας έχει ακριβώς μία σχέση κοινών συμβολοσειρών αλλά πολλές σχέσεις φύλλου εργασίας και εξωτερικού συνδέσμου, οπότε η αναζήτηση τύπου μέσω Values[] επιστρέφει την πρώτη αντιστοιχία για singletons, και πολύτιμοι τύποι όπως το externalLink απαριθμούνται διατρέχοντας τη λίστα

Πού σταματά η παρακολούθηση σχέσεων

Τα ειλικρινή όρια μετράνε περισσότερο από μια καθαρή ιστορία. Το HotXLS υποχωρεί σε συμβατικά ονόματα όποτε μια σχέση απουσιάζει, οπότε ένα πακέτο με κατεστραμμένο ή ελλείπον part σχέσεων εξακολουθεί να ανοίγει αν τυχαίνει να ακολουθεί τη διάταξη του Excel· αυτή η υποχώρηση είναι χαρακτηριστικό συμβατότητας, όχι δεύτερη πηγή αλήθειας, και μπορεί να κρύψει ένα bug παραγωγού κατά τη δοκιμή. Αξίζει να γνωρίζεις τρία ακόμα όρια. Στόχοι σημαδεμένοι με TargetMode="External" αποθηκεύονται αυτούσιοι αντί να επιλύονται, κάτι που είναι σωστό για υπερσυνδέσμους και για τη σχέση externalLinkPath που φέρει ένα απομακρυσμένο URL βιβλίου εργασίας, αλλά σημαίνει ότι η τιμή που παίρνεις πίσω είναι ό,τι έγραψε ο παραγωγός. Τα parts γραφημάτων που ανακαλύπτονται μέσω ενός part σχέσεων σχεδίου ζευγαρώνονται με άγκυρες σχεδίου βάσει θέσης αντί βάσει αναγνωριστικού, οπότε μια ασυνήθιστη σειρά άγκυρας μπορεί να αποδιοργανώσει δεσμεύσεις γραφημάτων. Και ο streaming direct reader στο lxDirectRead.pas κρατά τον δικό του ελαφρύτερο χειρισμό διαδρομής με κλειδί το xl/, οπότε ο πλήρης resolver που περιγράφεται εδώ διέπει τα σημεία εισόδου TXLSXWorkbook.Open και GetSheetNames, όχι το μονοπάτι σάρωσης χαμηλής εκχώρησης που τεκμηριώνεται στο άρθρο για τον streaming direct reader για Delphi

Αν το χτίζεις αυτό μόνος σου, η συντομότερη σωστή περίληψη είναι: ποτέ μην κατασκευάζεις ένα όνομα part, πάντα να επιλύεις ένα. Διάβασε το _rels/.rels, ακολούθησε το officeDocument, επίλυσε κάθε Target έναντι του part που το δήλωσε, και δρομολόγησε τα φύλλα βάσει r:id. Αν προτιμάς να το έχεις ήδη δοκιμασμένο έναντι μετονομασμένων parts, μη συνεχούς αρίθμησης φύλλων, και διπλότυπων αναγνωριστικών σχέσεων, ο resolver που περιγράφεται εδώ διατίθεται στο στοιχείο υπολογιστικού φύλλου Delphi HotXLS, μαζί με τη μηχανική round-trip που κρατά ανέπαφα τα parts που δεν αναλύει