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

Ανάγνωση ιδιοτήτων εγγράφου Excel σε Delphi: τρεις δρόμοι

Ζητήστε από μια ροή επεξεργασίας να δρομολογήσει δέκα χιλιάδες υπολογιστικά φύλλα κατά συγγραφέα, εταιρεία ή ημερομηνία τελευταίας τροποποίησης, και το χειρότερο που μπορεί να κάνει είναι να ανοίξει πλήρως κάθε βιβλίο εργασίας. Οι απαντήσεις ταξιδεύουν μέσα στις ιδιότητες εγγράφου του αρχείου, αυτό που ο κόσμος του Office ονομάζει Document Summary Information: το στρώμα μεταδεδομένων που ευρετηριάζει η Αναζήτηση των Windows, βάσει του οποίου ταξινομεί το SharePoint και το οποίο δείχνει το Excel στον διάλογο Properties. Αυτό το στρώμα είναι το πολύ μερικά kilobytes, και βρίσκεται σε καλά τεκμηριωμένη θέση και στις δύο μορφές του Excel. Το κόλπο είναι να το φτάσετε από τη Delphi χωρίς να πληρώσετε για το εκατομμύριο κελιά που δεν χρειάζεστε

Υπάρχουν τρεις πραγματικοί δρόμοι, και διαφέρουν λιγότερο ως προς το τι επιστρέφουν παρά ως προς το τι απαιτούν από το μηχάνημα που τους τρέχει. Το COM automation οδηγεί το ίδιο το Excel και διαβάζει τα πάντα, σε τιμές επιτραπέζιου υπολογιστή. Η μορφή .xls κρατά τις ιδιότητές της σε OLE property-set streams που τα Windows θα αναλύσουν για λογαριασμό σας. Η μορφή .xlsx τις κρατά σε δύο μικρά μέρη XML μέσα σε ένα zip που το RTL της Delphi μπορεί να ανοίξει μόνο του. Ακολουθεί λειτουργικός κώδικας για τον καθένα, με το κόστος δηλωμένο απερίφραστα

Διάγραμμα τριών δρόμων της Delphi προς το Document Summary Information του Excel: COM automation που οδηγεί το ίδιο το Excel, OLE property-set streams για αρχεία xls, και ανάλυση XML docProps του OOXML για πακέτα xlsx
Το COM automation αγοράζει πλήρη κάλυψη με τίμημα ένα αδειοδοτημένο επιτραπέζιο Excel και δευτερόλεπτα ανά αρχείο, ενώ οι δύο εγγενείς ως προς τη μορφή δρόμοι διαβάζουν μόνο τους περιέκτες μεταδεδομένων σε χιλιοστά του δευτερολέπτου. Αυτό που επιστρέφει ο κάθε δρόμος είναι σχεδόν το ίδιο — αυτό που απαιτεί από τη μηχανή δεν είναι

Δρόμος 1: το COM automation διαβάζει τα πάντα, σε τιμές επιτραπέζιου υπολογιστή

Το automation είναι ο μόνος δρόμος με ολική κάλυψη μέσα από ένα μοντέλο αντικειμένων: το τυπικό σύνολο σύνοψης, το εκτεταμένο σύνολο με Company και Manager, και τις ιδιότητες που ορίζει ο χρήστης, όλα προσβάσιμα μέσω των BuiltinDocumentProperties και CustomDocumentProperties. Τα πάντα φτάνουν ως OleVariant, και το API έχει μια συνήθεια που αξίζει να ξέρετε πριν σας δαγκώσει: μια ενσωματωμένη ιδιότητα που δεν ανατέθηκε ποτέ δεν επιστρέφει κενή, εγείρει ένα EOleException τη στιγμή που αγγίζετε το Value. Ο βοηθός παρακάτω το αντιμετωπίζει ως «δεν έχει οριστεί» και όχι ως αποτυχία

uses
  System.SysUtils, System.Variants, System.Win.ComObj;

procedure ReadPropertiesViaCom(const FileName: string);
var
  Excel, Book, Builtin, Custom: OleVariant;
  I: Integer;

  function BuiltinProp(const Name: string): string;
  begin
    try
      Result := VarToStr(Builtin.Item(Name).Value);
    except
      on EOleError do
        Result := '';   // η ιδιότητα υπάρχει αλλά δεν ανατέθηκε ποτέ
    end;
  end;

begin
  Excel := CreateOleObject('Excel.Application');
  try
    Excel.DisplayAlerts := False;
    Book := Excel.Workbooks.Open(FileName, 0, True);   // μόνο για ανάγνωση
    try
      Builtin := Book.BuiltinDocumentProperties;
      Writeln('Author : ', BuiltinProp('Author'));
      Writeln('Title  : ', BuiltinProp('Title'));
      Writeln('Subject: ', BuiltinProp('Subject'));
      Writeln('Company: ', BuiltinProp('Company'));
      Writeln('Manager: ', BuiltinProp('Manager'));

      Custom := Book.CustomDocumentProperties;
      for I := 1 to Custom.Count do
        Writeln(VarToStr(Custom.Item(I).Name), ' = ',
          VarToStr(Custom.Item(I).Value));
    finally
      Book.Close(False);
    end;
  finally
    Excel.Quit;   // φτάστε εδώ σε κάθε διαδρομή, αλλιώς το EXCEL.EXE μένει πίσω
    Excel := Unassigned;
  end;
end;

Τώρα ο λογαριασμός. Το Excel πρέπει να είναι εγκατεστημένο σε κάθε μηχάνημα όπου τρέχει αυτός ο κώδικας, κάτι που από μόνο του αποκλείει τους περισσότερους διακομιστές, και η πολιτική υποστήριξης της Microsoft λέει ρητά ότι το Office ούτε σχεδιάστηκε ούτε αδειοδοτείται για automation χωρίς επιτήρηση από την πλευρά του διακομιστή. Το CreateOleObject εκκινεί ένα πλήρες EXCEL.EXE και το Workbooks.Open αναλύει ολόκληρο το βιβλίο εργασίας, οπότε περιμένετε περίπου δύο έως τέσσερα δευτερόλεπτα ανά αρχείο πριν επιστρέψει η πρώτη ιδιότητα. Και το try..finally γύρω από το Quit δεν είναι διακοσμητικό: μια εξαίρεση που διαφεύγει ανάμεσα στο CreateOleObject και το Quit αφήνει ένα ορφανό EXCEL.EXE να κρατά κλείδωμα στο αρχείο, αόρατο μέχρι να αποτύχει πάνω του η επόμενη εκτέλεση. Η επαναχρησιμοποίηση μίας παρουσίας του Excel σε μια παρτίδα αποσβένει το κόστος εκκίνησης αλλά συγκεντρώνει τον κίνδυνο, επειδή ένας αδέσποτος διάλογος στην κρυφή επιφάνεια εργασίας κολλάει κάθε αρχείο που περιμένει πίσω του

Δρόμος 2: το .xls αποθηκεύει ιδιότητες σε OLE property-set streams

Ένα βιβλίο εργασίας BIFF8 είναι ένα OLE compound file, ένα μινιατούρα σύστημα αρχείων από storages και streams. Τα δεδομένα των κελιών ζουν στο stream Workbook· τα μεταδεδομένα ζουν δίπλα τους σε δύο property-set streams των οποίων τα ονόματα αρχίζουν με τον χαρακτήρα ελέγχου #5: το \005SummaryInformation για τα κλασικά πεδία και το \005DocumentSummaryInformation για τα εκτεταμένα και τα προσαρμοσμένα. Μέσα στο καθένα κάθεται ένα δυαδικό property set στη διάταξη MS-OLEPS, με ενότητες κλειδωμένες σε ένα αναγνωριστικό μορφής (FMTID) και ιδιότητες κλειδωμένες σε ένα ακέραιο property ID. Η ενότητα σύνοψης έχει FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, όπου το PIDSI_TITLE είναι $02 και το PIDSI_AUTHOR είναι $04· τα Company ($0F) και Manager ($0E) ζουν στην ενότητα document-summary, και οι προσαρμοσμένες ιδιότητες σε μια δεύτερη ενότητα πίσω από ένα λεξικό ονομάτων

Ανατομία σε Delphi ενός compound file xls τύπου BIFF8, με το stream Workbook δίπλα στα property sets SummaryInformation και DocumentSummaryInformation, και την αλυσίδα πρόσβασης από StgOpenStorageEx σε IPropertySetStorage
Ένα αρχείο xls αποθηκεύει δεδομένα κελιών και ιδιότητες εγγράφου ως αδελφά streams μέσα σε ένα OLE compound file. Τα Windows θα αναλύσουν τα δυαδικά property sets για λογαριασμό σας, ώστε ο κώδικας Delphi να μην αγγίζει ούτε διατάξεις MS-OLEPS ούτε κωδικοσελίδες με το χέρι

Τα καλά νέα είναι ότι στα Windows δεν αναλύετε ποτέ μόνοι σας αυτά τα bytes. Το structured storage εκθέτει τα streams μέσω του IPropertySetStorage, και το παρακάτω μεταγλωττίζεται όπως φαίνεται με τις τυπικές μονάδες του RTL

uses
  System.SysUtils, Winapi.Windows, Winapi.ActiveX, System.Win.ComObj;

const
  FMTID_SummaryInfo: TGUID = '{F29F85E0-4FF9-1068-AB91-08002B27B3D9}';
  PIDSI_TITLE    = $02;
  PIDSI_AUTHOR   = $04;
  STGFMT_STORAGE = 0;

function ReadXlsSummaryString(const FileName: string; PropId: TPropID): string;
var
  Unk: IUnknown;
  Stg: IStorage;
  PropSetStg: IPropertySetStorage;
  PropStg: IPropertyStorage;
  Spec: TPropSpec;
  Value: TPropVariant;
begin
  Result := '';
  OleCheck(StgOpenStorageEx(PWideChar(FileName),
    STGM_READ or STGM_SHARE_DENY_WRITE, STGFMT_STORAGE, 0, nil, nil,
    @IID_IStorage, Unk));
  Stg := Unk as IStorage;
  PropSetStg := Stg as IPropertySetStorage;
  OleCheck(PropSetStg.Open(FMTID_SummaryInfo,
    STGM_READ or STGM_SHARE_EXCLUSIVE, PropStg));
  Spec.ulKind := PRSPEC_PROPID;
  Spec.propid := PropId;
  if PropStg.ReadMultiple(1, @Spec, @Value) = S_OK then  // S_FALSE: δεν υπάρχει
  try
    case Value.vt of
      VT_LPSTR:  Result := string(AnsiString(Value.pszVal));
      VT_LPWSTR: Result := Value.pwszVal;
    end;
  finally
    PropVariantClear(Value);
  end;
end;

// χρήση: Writeln('Author: ', ReadXlsSummaryString('ledger.xls', PIDSI_AUTHOR));

Μια ειλικρινής κουβέντα για όσα κρύβει το απόσπασμα. Οι συμβολοσειρές μπορούν να φτάσουν ως VT_LPWSTR ή ως VT_LPSTR, και στην περίπτωση ANSI τα bytes είναι κωδικοποιημένα στην κωδικοσελίδα του ίδιου του property set, η οποία αποθηκεύεται ως ιδιότητα 1 της ενότητας, οπότε η παραπάνω μετατροπή τύπου είναι ακριβής μόνο όταν αυτή η κωδικοσελίδα ταιριάζει με του συστήματος. Οι χρονοσφραγίδες επιστρέφουν ως VT_FILETIME σε UTC. Οι προσαρμοσμένες ιδιότητες σημαίνουν άνοιγμα της ενότητας που ορίζει ο χρήστης, με FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, και διάσχιση του λεξικού ονομάτων της. Το IPropertyStorage απορροφά όλα αυτά στα Windows· το να γράψετε τον δικό σας αναλυτή MS-OLEPS για ένα περιβάλλον χωρίς structured storage είναι πραγματικό έργο, όχι δουλειά ενός απογεύματος

Δρόμος 3: το .xlsx κρατά τα docProps ως XML μέσα στο zip

Αυτός είναι ο δρόμος που χρειάζονται πραγματικά οι περισσότερες ροές επεξεργασίας, αφού τα νέα αρχεία είναι .xlsx εδώ και σχεδόν δύο δεκαετίες. Ένα βιβλίο εργασίας OOXML είναι ένα πακέτο zip, και οι ιδιότητές του είναι μοιρασμένες σε μικρά μέρη ανά σκοπό: το docProps/core.xml κρατά τα πεδία Dublin Core, τα dc:title, dc:creator, cp:lastModifiedBy, συν τα dcterms:created και dcterms:modified ως χρονοσφραγίδες W3CDTF σε UTC, ενώ το docProps/app.xml κρατά πεδία σε επίπεδο εφαρμογής όπως τα Company και AppVersion, και το docProps/custom.xml κρατά τις προσαρμοσμένες ιδιότητες. Επειδή ο κεντρικός κατάλογος του zip εντοπίζει κάθε μέρος απευθείας, η ανάγνωσή τους κοστίζει μερικά kilobytes ανεξάρτητα από το πόσο μεγάλο είναι το βιβλίο εργασίας. Τα TZipFile και IXMLDocument, και τα δύο μέσα στο RTL που έρχεται μαζί, κάνουν όλη τη δουλειά

Delphi: διάταξη ενός πακέτου zip xlsx που δείχνει τα μέλη XML docProps core, app και custom δίπλα στα μέρη των φύλλων εργασίας, με τους κανόνες παραγωγής για τον έλεγχο προαιρετικών μερών και την αντιστοίχιση namespaces
Τα δεδομένα των φύλλων κυριαρχούν σε ένα πακέτο xlsx, ωστόσο τα μεταδεδομένα κάθονται δίπλα τους σε τρία μικρά προαιρετικά μέλη. Η τυχαία προσπέλαση μέσω του κεντρικού καταλόγου του zip κρατά την ανάγνωση ανάλογη με τις ιδιότητες, όχι με το βιβλίο εργασίας
uses
  System.SysUtils, System.Classes, System.Zip, Xml.XMLDoc, Xml.XMLIntf;

const
  NsDC    = 'http://purl.org/dc/elements/1.1/';
  NsTerms = 'http://purl.org/dc/terms/';
  NsCore  = 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties';
  NsApp   = 'http://schemas.openxmlformats.org/officeDocument/2006/extended-properties';

function PartToXml(Zip: TZipFile; const PartName: string): IXMLDocument;
var
  Bytes: TBytes;
begin
  Zip.Read(PartName, Bytes);
  Result := LoadXMLData(TEncoding.UTF8.GetString(Bytes));
end;

function Field(const Doc: IXMLDocument; const LocalName, Ns: string): string;
var
  Node: IXMLNode;
begin
  Node := Doc.DocumentElement.ChildNodes.FindNode(LocalName, Ns);
  if Node <> nil then
    Result := Node.Text
  else
    Result := '';
end;

procedure ReadXlsxProperties(const FileName: string);
var
  Zip: TZipFile;
  Doc: IXMLDocument;
begin
  Zip := TZipFile.Create;
  try
    Zip.Open(FileName, zmRead);
    if Zip.IndexOf('docProps/core.xml') >= 0 then
    begin
      Doc := PartToXml(Zip, 'docProps/core.xml');
      Writeln('Title   : ', Field(Doc, 'title', NsDC));
      Writeln('Creator : ', Field(Doc, 'creator', NsDC));
      Writeln('Modifier: ', Field(Doc, 'lastModifiedBy', NsCore));
      Writeln('Modified: ', Field(Doc, 'modified', NsTerms));  // W3CDTF, UTC
    end;
    if Zip.IndexOf('docProps/app.xml') >= 0 then
    begin
      Doc := PartToXml(Zip, 'docProps/app.xml');
      Writeln('Company : ', Field(Doc, 'Company', NsApp));
      Writeln('App     : ', Field(Doc, 'Application', NsApp), ' ',
        Field(Doc, 'AppVersion', NsApp));
    end;
  finally
    Zip.Free;
  end;
end;

Δύο λεπτομέρειες κρατούν αυτό το πράγμα ανθεκτικό στην παραγωγή. Πρώτον, τα μέρη είναι προαιρετικά: ένα ελάχιστο πακέτο χωρίς καθόλου docProps είναι απολύτως έγκυρο κατά το ECMA-376, γι αυτό ο κώδικας ελέγχει με IndexOf αντί να υποθέτει. Δεύτερον, αντιστοιχίστε τα στοιχεία κατά τοπικό όνομα και URI namespace, όπως κάνει παραπάνω το FindNode, ποτέ κατά κυριολεκτικό πρόθεμα· τα dc: και cp: είναι συμβάσεις του writer του Excel, και τα αρχεία που παράγουν άλλες γεννήτριες είναι ελεύθερα να διαλέξουν διαφορετικά προθέματα. Μια σημείωση για το περιβάλλον: ο προεπιλεγμένος πάροχος του IXMLDocument είναι το MSXML, οπότε μια εφαρμογή κονσόλας ή ένα νήμα εργασίας πρέπει να καλέσει CoInitialize πριν από το LoadXMLData, αλλιώς η πρώτη ανάλυση πεθαίνει με σφάλμα COM

Ο πίνακας κόστους, και πότε μια βιβλιοθήκη νικά και τους δύο αναλυτές

Μετρημένος σε ένα συνηθισμένο μηχάνημα προγραμματιστή, ο δρόμος του COM προσγειώνεται περίπου στα δύο έως τέσσερα δευτερόλεπτα ανά αρχείο όταν η συνεδρία automation δημιουργείται ανά αρχείο, σχεδόν όλο αυτό εκκίνηση του EXCEL.EXE συν πλήρης ανάλυση του βιβλίου εργασίας, και απαιτεί εγκατεστημένο, αδειοδοτημένο Excel όπου κι αν τρέχει. Οι δύο άμεσοι δρόμοι διαβάζουν μόνο τους περιέκτες μεταδεδομένων, τελειώνουν σε μονοψήφια χιλιοστά του δευτερολέπτου ανά αρχείο, και δεν χρειάζονται τίποτα εγκατεστημένο πέρα από όσα ήδη συνδέει ένα εκτελέσιμο της Delphi. Σε ένα κοινόχρηστο φάκελο δέκα χιλιάδων αρχείων, αυτή είναι η διαφορά ανάμεσα στο μεγαλύτερο μέρος μιας εργάσιμης ημέρας και σε λιγότερο από ένα λεπτό, χωρίς κανένα ζήτημα ανάπτυξης του Office

Η παγίδα με τους άμεσους δρόμους είναι ότι είναι δύο. Μια ροή επεξεργασίας που δέχεται και τις δύο μορφές συντηρεί δύο αναλυτές με δύο ξένους μεταξύ τους τρόπους αστοχίας, κωδικοσελίδες και τύπους PROPVARIANT από τη μία πλευρά, namespaces και προαιρετικά μέρη από την άλλη, και κανένας δεν διαβάζει τη μορφή του άλλου. Αυτό το φορτίο συντήρησης είναι το επιχείρημα υπέρ μιας εγγενούς βιβλιοθήκης: το HotXLS, η βιβλιοθήκη υπολογιστικών φύλλων της losLab σε Object Pascal για Delphi και C++Builder στα Windows, εκθέτει τα ίδια πεδία ως απλές ιδιότητες βιβλίου εργασίας, Title, Author, Company, Created και τα υπόλοιπα, συμπληρωμένα από το Open τόσο για .xls όσο και για .xlsx, χωρίς εγκατάσταση Excel και χωρίς τίποτα από τις παραπάνω σωληνώσεις περιεκτών. Διαβάζει ιδιότητες ως μέρος ενός πλήρους ανοίγματος βιβλίου εργασίας και όχι ως έλεγχο μόνο μεταδεδομένων, οπότε ταιριάζει σε ροές που ούτως ή άλλως θα αγγίξουν στη συνέχεια τα δεδομένα των κελιών· η πλήρης επιφάνεια ιδιοτήτων και στις δύο όψεις, συμπεριλαμβανομένης της πλευράς εγγραφής, καλύπτεται στο άρθρο μας για τον ορισμό ιδιοτήτων εγγράφου Excel με το HotXLS

Σημείωση: πλήρη εργαλεία ανάλυσης Excel και εξαγωγής μεταδεδομένων είναι διαθέσιμα στο HotXLS Delphi VCL Component