בקש מצינור לנתב עשרת אלפים גיליונות אלקטרוניים לפי מחבר, חברה או תאריך שינוי אחרון, והדבר הגרוע ביותר שהוא יכול לעשות הוא לפתוח כל חוברת עבודה במלואה. התשובות רוכבות במאפייני המסמך של הקובץ, מה שעולם Office מכנה Document Summary Information: שכבת המטא-נתונים ש-Windows Search מאנדקס, ש-SharePoint מתייק לפיה, ושאותה Excel מציג בתיבת הדו-שיח Properties. השכבה הזו היא קילובייטים לכל היותר, והיא חיה במקום מתועד היטב בשני פורמטי Excel. הטריק הוא להגיע אליה מ-Delphi בלי לשלם על מיליון התאים שאינך צריך
יש שלושה מסלולים אמיתיים, והם נבדלים פחות במה שהם מחזירים ויותר במה שהם דורשים מהמכונה שמריצה אותם. אוטומציית COM מפעילה את Excel עצמו וקוראת הכול, במחירי שולחן עבודה. פורמט ה-.xls שומר את המאפיינים שלו בזרמי property-set של OLE ש-Windows יפרסר עבורך. פורמט ה-.xlsx שומר אותם בשני חלקי XML קטנים בתוך קובץ zip שה-RTL של Delphi יכול לפתוח בעצמו. קוד עובד לכל אחד מהם מובא בהמשך, עם העלויות מוצהרות בפירוש
מסלול 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) חיים במקטע סיכום המסמך, והמאפיינים המותאמים במקטע שני מאחורי מילון שמות
החדשות הטובות הן שב-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 הנשלח, עושים את כל העבודה
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