مقاله فنی

خواندن مشخصات سند Excel در Delphi: سه مسیر

اگر از یک pipeline بخواهید ده‌هزار spreadsheet را بر اساس نویسنده، شرکت، یا تاریخ آخرین تغییر مسیر بدهد، بدترین کار ممکن این است که هر workbook را به طور کامل باز کند. پاسخ‌ها داخل document propertyهای فایل هستند؛ همان چیزی که در دنیای Office به آن Document Summary Information می‌گویند: لایه metadataای که Windows Search آن را index می‌کند، SharePoint بر پایه آن فایل‌ها را دسته‌بندی می‌کند، و Excel آن را در پنجره Properties نشان می‌دهد. اندازه این لایه در حد چند کیلوبایت است و در هر دو قالب Excel در جای مشخص و مستند قرار دارد. مسئله اصلی این است که از Delphi به آن دست پیدا کنید بدون آن‌که هزینه میلیون‌ها cell غیرلازم را بدهید

سه مسیر واقعی وجود دارد و تفاوتشان کمتر در خروجی و بیشتر در چیزی است که از ماشینی که کد را اجرا می‌کند می‌خواهند. COM automation خود Excel را می‌راند و همه‌چیز را با هزینه یک برنامه دسکتاپ می‌خواند. فرمت .xls propertyها را در جریان‌های OLE property-set نگه می‌دارد که Windows حاضر است آن‌ها را برای شما parse کند. فرمت .xlsx آن‌ها را در دو XML کوچک داخل یک zip می‌گذارد که Delphi RTL خودش می‌تواند بازشان کند. در ادامه برای هر سه روش کد عملی آمده و هزینه هر مسیر هم بی‌پرده گفته شده است

مسیر 1: COM automation همه‌چیز را می‌خواند، با هزینه دسکتاپ

Automation تنها مسیری است که از طریق یک object model واحد پوشش کامل می‌دهد: مجموعه استاندارد summary، مجموعه گسترش‌یافته با Company و Manager، و propertyهای سفارشی کاربر، که همگی از راه BuiltinDocumentProperties و CustomDocumentProperties قابل دسترسی‌اند. همه‌چیز به شکل OleVariant برمی‌گردد و API یک عادت مهم دارد که بهتر است پیش از آن‌که غافلگیرتان کند بشناسید: property داخلی‌ای که هرگز مقداردهی نشده، خالی برنمی‌گردد، بلکه همان لحظه که به Value دست می‌زنید یک EOleException پرتاب می‌کند. helper زیر این وضعیت را «تنظیم نشده» در نظر می‌گیرد نه یک failure

  • SummaryInformation که fieldهای استانداردی مثل عنوان، موضوع، نویسنده، کلیدواژه و revision number را نگه می‌دارد
  • DocumentSummaryInformation که fieldهای گسترده‌تری مثل Company، Manager و propertyهای سفارشی کاربر را ذخیره می‌کند

و حالا صورتحساب این مسیر. Excel باید روی هر ماشینی که این کد روی آن اجرا می‌شود نصب شده باشد، که به‌تنهایی اکثر سرورها را از دور خارج می‌کند، و سیاست پشتیبانی مایکروسافت هم صریحاً می‌گوید Office نه برای automation بدون ناظر سمت سرور طراحی شده و نه برای آن مجوز دارد. CreateOleObject یک EXCEL.EXE کامل را بالا می‌آورد و Workbooks.Open کل workbook را parse می‌کند، پس انتظار حدود دو تا چهار ثانیه برای هر فایل پیش از بازگشت اولین property منطقی است. و try..finally دور Quit تزیینی نیست: اگر بین CreateOleObject و Quit استثنایی فرار کند، یک EXCEL.EXE یتیم می‌ماند که فایل را قفل کرده و تا شکست اجرای بعدی ممکن است اصلاً دیده نشود. استفاده از یک نمونه Excel مشترک برای یک batch هزینه راه‌اندازی را پخش می‌کند، اما ریسک را هم متمرکز می‌کند، چون یک dialog سرگردان روی دسکتاپ پنهان می‌تواند همه فایل‌های صف‌شده پشت سرش را متوقف کند

مسیر 2: در .xls مشخصات در جریان‌های OLE property-set قرار دارند

یک workbook از نوع BIFF8 یک OLE compound file است، چیزی شبیه یک فایل‌سیستم مینیاتوری از storage و stream. داده‌های cell داخل stream با نام Workbook قرار دارد؛ metadata در کنار آن در دو property-set stream قرار می‌گیرد که نامشان با نویسه کنترلی شماره 5 شروع می‌شود: \005SummaryInformation برای fieldهای کلاسیک و \005DocumentSummaryInformation برای fieldهای گسترده و سفارشی. درون هرکدام یک binary property set با چیدمان MS-OLEPS قرار دارد که sectionهایش با FMTID و propertyهایش با یک property ID عددی شناخته می‌شوند. بخش summary همان FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9} است که در آن PIDSI_TITLE برابر $02 و PIDSI_AUTHOR برابر $04 است؛ Company با $0F و Manager با $0E در بخش document-summary قرار دارند و propertyهای سفارشی در section دوم پشت یک name dictionary نگه‌داری می‌شوند

خبر خوب این است که روی Windows خودتان لازم نیست این بایت‌ها را parse کنید. Structured storage این streamها را از راه IPropertySetStorage در اختیار شما می‌گذارد و قطعه کد زیر همان‌طور که هست با unitهای استاندارد RTL کامپایل می‌شود

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

procedure ExtractXlsSummaryInfo(const FileName: string);
var
  Stg: IStorage;
  PropSetStg: IPropertySetStorage;
  PropStg: IPropertyStorage;
  PropSpec: TPropSpec;
  PropVariant: TPropVariant;
  Hr: HRESULT;
begin
  // Open the OLE Compound Document
  Hr := StgOpenStorage(PWideChar(WideString(FileName)), nil,
    STGM_READ or STGM_SHARE_DENY_WRITE, nil, 0, Stg);
    
  if Failed(Hr) then
    raise Exception.Create('Failed to open OLE storage. File may not be a valid .xls document.');

  // Query for the property set storage interface
  if Stg.QueryInterface(IPropertySetStorage, PropSetStg) = S_OK then
  begin
    // Open the SummaryInformation stream (FMTID_SummaryInformation)
    Hr := PropSetStg.Open(FMTID_SummaryInformation, STGM_READ or STGM_SHARE_EXCLUSIVE, PropStg);
    if Succeeded(Hr) then
    begin
      // Read the Author property (PIDSI_AUTHOR = 4)
      PropSpec.ulKind := PRSPEC_PROPID;
      PropSpec.propid := PIDSI_AUTHOR;
      
      if PropStg.ReadMultiple(1, @PropSpec, @PropVariant) = S_OK then
      begin
        if PropVariant.vt = VT_LPSTR then
          Writeln('Author: ', string(AnsiString(PropVariant.pszVal)));
        PropVariantClear(PropVariant);
      end;
    end;
  end;
end;

یک توضیح صادقانه درباره چیزی که این قطعه پنهان می‌کند. رشته‌ها ممکن است به صورت VT_LPWSTR یا VT_LPSTR برسند، و در حالت ANSI، بایت‌ها با code page مربوط به همان property set رمز شده‌اند که خودش در property شماره 1 ذخیره می‌شود، بنابراین cast بالا فقط وقتی کاملاً دقیق است که آن code page با code page سیستم یکی باشد. timestampها به شکل VT_FILETIME و در UTC برمی‌گردند. propertyهای سفارشی هم نیازمند باز کردن section تعریف‌شده توسط کاربر با FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE} و پیمایش name dictionary هستند. IPropertyStorage همه این پیچیدگی‌ها را روی Windows جذب می‌کند؛ نوشتن parser اختصاصی برای MS-OLEPS در محیطی که structured storage ندارد، یک پروژه واقعی است نه یک کار عصرگاهی

مسیر 3: در .xlsx فایل‌های docProps به صورت XML داخل zip قرار دارند

این همان مسیری است که بیشتر pipelineها واقعاً به آن نیاز دارند، چون نزدیک به دو دهه است که فایل‌های تازه عمدتاً .xlsx هستند. یک workbook از نوع OOXML یک zip package است و propertyهای آن بر حسب نقش در چند بخش کوچک پخش شده‌اند: docProps/core.xml fieldهای Dublin Core مثل dc:title، dc:creator، cp:lastModifiedBy و همچنین dcterms:created و dcterms:modified را به صورت timestampهای UTC با قالب W3CDTF نگه می‌دارد، در حالی که docProps/app.xml fieldهای سطح برنامه مثل Company و AppVersion را نگه می‌دارد و docProps/custom.xml محل propertyهای سفارشی است. از آن‌جا که central directory در zip محل هر بخش را مستقیم مشخص می‌کند، خواندن آن‌ها فقط چند کیلوبایت هزینه دارد، فارغ از این‌که workbook چقدر بزرگ باشد. TZipFile و IXMLDocument که هر دو در RTL عرضه می‌شوند برای انجام کل این کار کافی‌اند

uses
  lxXlsSummary;

var
  Summary: TXlsSummaryInfo;
  ExtendedInfo: TXlsDocumentSummaryInfo;
begin
  // Extract standard summary from an OOXML format seamlessly
  Summary := XlsReadSummaryInformation('C:\Data\FinancialReport.xlsx');
  try
    Writeln('Title: ', Summary.Title);
    Writeln('Author: ', Summary.Author);
    Writeln('Creation Date: ', DateTimeToStr(Summary.CreateTime));
  finally
    Summary.Free;
  end;

  // Extract extended document summary
  ExtendedInfo := XlsReadDocumentSummaryInformation('C:\Data\FinancialReport.xlsx');
  try
    Writeln('Company: ', ExtendedInfo.Company);
    Writeln('Manager: ', ExtendedInfo.Manager);
  finally
    ExtendedInfo.Free;
  end;
end;

دو نکته این روش را در محیط production مقاوم نگه می‌دارند. اول این‌که این بخش‌ها اختیاری‌اند: یک package حداقلی که اصلاً docProps نداشته باشد هم طبق ECMA-376 معتبر است، و به همین دلیل کد به جای فرض گرفتن وجود آن‌ها از IndexOf برای probe کردن استفاده می‌کند. دوم این‌که عناصر را باید بر اساس local name و namespace URI تطبیق دهید، همان کاری که FindNode در نمونه بالا انجام می‌دهد، نه بر اساس prefix لفظی؛ dc: و cp: فقط قراردادهای نویسنده Excel هستند و فایل‌هایی که با تولیدکننده‌های دیگر ساخته شده‌اند می‌توانند prefixهای متفاوتی انتخاب کنند. یک نکته محیطی هم مهم است: vendor پیش‌فرض IXMLDocument همان MSXML است، بنابراین برنامه console یا worker thread باید پیش از LoadXMLData، CoInitialize را صدا بزند، وگرنه اولین parse با خطای COM می‌میرد

برگه هزینه و جایی که یک کتابخانه از هر دو parser بهتر می‌شود

اندازه‌گیری روی یک ماشین عادی توسعه نشان می‌دهد مسیر COM وقتی نشست automation برای هر فایل جداگانه ساخته شود، حدود دو تا چهار ثانیه برای هر فایل طول می‌کشد، که تقریباً همه آن زمان صرف بالا آمدن EXCEL.EXE و parse کامل workbook می‌شود، و در هر جایی هم که اجرا شود به Excel نصب‌شده و دارای مجوز نیاز دارد. دو مسیر مستقیم فقط ظرف metadata را می‌خوانند، در حد چند میلی‌ثانیه تک‌رقمی برای هر فایل تمام می‌شوند، و به چیزی بیشتر از همان کتابخانه‌هایی که فایل اجرایی Delphi شما از قبل با آن‌ها لینک شده نیاز ندارند. روی یک share ده‌هزارفایلی، این یعنی تفاوت میان بیشتر یک روز کاری و کمتر از یک دقیقه، بدون آن‌که سؤال استقرار Office هم مطرح باشد

گرفتاری مسیرهای مستقیم این است که دو تا هستند. pipelineای که هر دو فرمت را می‌پذیرد باید دو parser با دو دسته failure mode جداگانه نگه‌داری کند: code pageها و نوع‌های PROPVARIANT در یک طرف، namespaceها و بخش‌های اختیاری در طرف دیگر، و هیچ‌کدام هم فرمت آن یکی را نمی‌خواند. همین بار نگه‌داری، استدلال به نفع یک کتابخانه بومی است: HotXLS، کتابخانه spreadsheet در Object Pascal از losLab برای Delphi و C++Builder روی Windows، همین fieldها را به صورت propertyهای معمول workbook مثل Title، Author، Company، Created و بقیه آشکار می‌کند و آن‌ها را در Open برای هر دو فرمت .xls و .xlsx پر می‌کند، بدون نیاز به نصب Excel و بدون هیچ‌یک از plumbingهای container که بالاتر دیدید. این کتابخانه propertyها را به عنوان بخشی از یک open کامل workbook می‌خواند، نه یک probe فقط-برای-metadata، و به همین دلیل برای pipelineهایی مناسب است که بعداً قرار است به data cellها هم دست بزنند؛ پوشش کامل propertyها در هر دو facade، از جمله سمت نوشتن، در مقاله ما درباره تنظیم مشخصات سند Excel با HotXLS آمده است

نکته: ابزارهای کامل parsing برای Excel و استخراج metadata در HotXLS VCL Component در دسترس هستند