מאמר טכני

קריאת מאפייני מסמך של Excel ב-Delphi: שלושה מסלולים

בקש מצינור לנתב עשרת אלפים גיליונות אלקטרוניים לפי מחבר, חברה או תאריך שינוי אחרון, והדבר הגרוע ביותר שהוא יכול לעשות הוא לפתוח כל חוברת עבודה במלואה. התשובות רוכבות במאפייני המסמך של הקובץ, מה שעולם Office מכנה Document Summary Information: שכבת המטא-נתונים ש-Windows Search מאנדקס, ש-SharePoint מתייק לפיה, ושאותה Excel מציג בתיבת הדו-שיח Properties. השכבה הזו היא קילובייטים לכל היותר, והיא חיה במקום מתועד היטב בשני פורמטי Excel. הטריק הוא להגיע אליה מ-Delphi בלי לשלם על מיליון התאים שאינך צריך

יש שלושה מסלולים אמיתיים, והם נבדלים פחות במה שהם מחזירים ויותר במה שהם דורשים מהמכונה שמריצה אותם. אוטומציית COM מפעילה את Excel עצמו וקוראת הכול, במחירי שולחן עבודה. פורמט ה-.xls שומר את המאפיינים שלו בזרמי property-set של OLE ש-Windows יפרסר עבורך. פורמט ה-.xlsx שומר אותם בשני חלקי XML קטנים בתוך קובץ zip שה-RTL של Delphi יכול לפתוח בעצמו. קוד עובד לכל אחד מהם מובא בהמשך, עם העלויות מוצהרות בפירוש

תרשים של שלושה מסלולי Delphi אל Document Summary Information של Excel: אוטומציית COM המפעילה את Excel עצמו, זרמי property-set של OLE עבור קובצי xls, ופרסור XML של docProps ב-OOXML עבור חבילות xlsx
אוטומציית COM קונה כיסוי מלא במחיר של Excel שולחני מורשה ושניות לכל קובץ, בעוד שני המסלולים הילידיים לפורמט קוראים רק את מכלי המטא-נתונים באלפיות שנייה. מה שכל מסלול מחזיר כמעט זהה — מה שהוא דורש ממכונת המארח אינו כזה

מסלול 1: אוטומציית COM קוראת הכול, במחירי שולחן עבודה

האוטומציה היא המסלול היחיד עם כיסוי מלא דרך מודל אובייקטים אחד: קבוצת הסיכום הסטנדרטית, הקבוצה המורחבת עם Company ו-Manager, ומאפיינים מותאמים שהוגדרו על ידי המשתמש, כולם נגישים דרך BuiltinDocumentProperties ו-CustomDocumentProperties. הכול מגיע כ-OleVariant, ולממשק יש הרגל אחד ששווה להכיר לפני שהוא נושך: מאפיין מובנה שמעולם לא הוקצה לו ערך אינו חוזר ריק, הוא מעורר 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 אינו מתוכנן ואינו מורשה לאוטומציה בצד השרת ללא השגחה. CreateOleObject משיקה EXCEL.EXE מלא ו-Workbooks.Open מפרסרת את כל חוברת העבודה, ולכן צפה לכשתיים עד ארבע שניות לכל קובץ לפני שהמאפיין הראשון חוזר. וה-try..finally סביב Quit אינו קישוט: חריגה שנמלטת בין CreateOleObject ל-Quit משאירה EXCEL.EXE יתום שמחזיק נעילה על הקובץ, בלתי נראה עד שההרצה הבאה נכשלת מולו. שימוש חוזר במופע Excel אחד לאורך אצווה מפחית את עלות ההפעלה אך מרכז את הסיכון, מכיוון שתיבת דו-שיח תועה אחת על שולחן העבודה המוסתר תוקעת כל קובץ שממתין בתור מאחוריה

מסלול 2: קובצי .xls מאחסנים מאפיינים בזרמי property-set של OLE

חוברת עבודה בפורמט BIFF8 היא קובץ מורכב של OLE, מערכת קבצים מיניאטורית של אחסונים וזרמים. נתוני התאים חיים בזרם Workbook; המטא-נתונים חיים לצדו בשני זרמי property-set ששמותיהם מתחילים בתו הבקרה #5: \005SummaryInformation עבור השדות הקלאסיים ו-\005DocumentSummaryInformation עבור המורחבים והמותאמים. בתוך כל אחד יושבת קבוצת מאפיינים בינרית בפריסת MS-OLEPS, עם מקטעים הממופתחים לפי מזהה פורמט (FMTID) ומאפיינים הממופתחים לפי מזהה מאפיין שלם. מקטע הסיכום הוא FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, שבו PIDSI_TITLE הוא $02 ו-PIDSI_AUTHOR הוא $04; Company ($0F) ו-Manager ($0E) חיים במקטע סיכום המסמך, והמאפיינים המותאמים במקטע שני מאחורי מילון שמות

אנטומיה ב-Delphi של קובץ xls מורכב בפורמט BIFF8 המציבה את זרם Workbook לצד קבוצות המאפיינים SummaryInformation ו-DocumentSummaryInformation, עם שרשרת הגישה מ-StgOpenStorageEx אל IPropertySetStorage
קובץ xls מאחסן נתוני תאים ומאפייני מסמך כזרמים אחים בקובץ מורכב של OLE. Windows יפרסר עבורך את קבוצות המאפיינים הבינריות, ולכן קוד Delphi אינו נוגע ביד לא בפריסות MS-OLEPS ולא בדפי קוד

החדשות הטובות הן שב-Windows אתה לעולם אינך מפרסר את הבתים האלה בעצמך. אחסון מובנה חושף את הזרמים דרך 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 הבתים מקודדים בדף הקוד של קבוצת המאפיינים עצמה, השמור בעצמו כמאפיין 1 של המקטע, ולכן ההמרה שלמעלה מדויקת רק כאשר דף הקוד הזה תואם לזה של המערכת. חותמות זמן חוזרות כ-VT_FILETIME ב-UTC. מאפיינים מותאמים משמעם פתיחת המקטע שהוגדר על ידי המשתמש, FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, ומעבר על מילון השמות שלו. IPropertyStorage סופג את כל זה ב-Windows; כתיבת מפרסר MS-OLEPS משלך עבור סביבה ללא אחסון מובנה היא פרויקט של ממש, לא אחר צהריים

מסלול 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 מאתרת כל חלק ישירות, קריאתם עולה כמה קילובייטים בלי קשר לגודל חוברת העבודה. TZipFile ו-IXMLDocument, שניהם ב-RTL הנשלח, עושים את כל העבודה

Delphi: פריסה של חבילת zip בפורמט xlsx המציגה את איברי ה-XML של docProps core, app ו-custom לצד חלקי גיליונות העבודה, עם כללי הייצור לבדיקת חלקים אופציונליים ולהתאמת מרחבי שמות
נתוני גיליונות העבודה שולטים בחבילת 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 במקום להניח. שנית, התאם אלמנטים לפי שם מקומי וכתובת מרחב שמות, כפי ש-FindNode עושה למעלה, לעולם לא לפי קידומת ליטרלית; dc: ו-cp: הן מוסכמות של הכותב של Excel, וקבצים שהופקו על ידי מחוללים אחרים חופשיים לבחור קידומות אחרות. הערה סביבתית אחת: ספק ברירת המחדל של IXMLDocument הוא MSXML, ולכן יישום קונסולה או תהליכון עובד חייב לקרוא ל-CoInitialize לפני LoadXMLData, אחרת הפרסור הראשון מת עם שגיאת COM

גיליון העלויות, ומתי ספרייה מנצחת את שני המפרסרים

במדידה על מכונת פיתוח רגילה, מסלול ה-COM נוחת על כשתיים עד ארבע שניות לכל קובץ כאשר מפגש האוטומציה נוצר לכל קובץ, כמעט כולן הפעלת EXCEL.EXE בתוספת פרסור מלא של חוברת העבודה, והוא דורש Excel מותקן ומורשה בכל מקום שבו הוא רץ. שני המסלולים הישירים קוראים רק את מכלי המטא-נתונים, מסתיימים באלפיות שנייה בודדות לכל קובץ, ואינם זקוקים לדבר מותקן מעבר למה שקובץ הרצה של Delphi כבר מקשר פנימה. על פני שיתוף של עשרת אלפים קבצים, זהו ההבדל בין רוב יום עבודה לבין פחות מדקה, בלי שאלת פריסה של Office נלווית

המלכוד עם המסלולים הישירים הוא שיש שניים מהם. צינור שמקבל את שני הפורמטים מתחזק שני מפרסרים עם שני אופני כשל זרים זה לזה, דפי קוד וטיפוסי PROPVARIANT בצד אחד, מרחבי שמות וחלקים אופציונליים בצד השני, ואף אחד מהם אינו קורא את הפורמט של השני. עומס התחזוקה הזה הוא הנימוק לספרייה ילידית: HotXLS, ספריית הגיליונות האלקטרוניים של losLab ב-Object Pascal עבור Delphi ו-C++Builder ב-Windows, חושפת את אותם שדות כמאפייני חוברת עבודה פשוטים, Title, Author, Company, Created והשאר, המאוכלסים על ידי Open עבור .xls ועבור .xlsx כאחד, בלי התקנת Excel ובלי שום צנרת מכלים מזו שלמעלה. היא קוראת מאפיינים כחלק מפתיחה מלאה של חוברת עבודה ולא כבדיקת מטא-נתונים בלבד, ולכן היא מתאימה לצינורות שממשיכים ממילא לגעת בנתוני התאים; משטח המאפיינים המלא בשתי החזיתות, כולל צד הכתיבה, מכוסה במאמר שלנו על קביעת מאפייני מסמך של Excel עם HotXLS

הערה: כלים מלאים לפרסור Excel ולחילוץ מטא-נתונים זמינים ברכיב HotXLS Delphi VCL