مقاله فنی

ساخت یک میزکار بررسی و پذیرش PDF در دلفی با PDFium Component

یک میزکار (workbench) بررسی و پذیرش (intake) PDF یک برنامه کوچک با یک وظیفه است: نگاه کردن به هر فایل قبل از اینکه به هر چیزی در ادامه مسیر اجازه داده شود آن را لمس کند. برای انجام این کار، باید مجموعه‌ای از قابلیت‌ها را در یک گذر واحد سرهم کند. فایل را باز می‌کند (بدون اعتماد به آن)، می‌خواند که فایل در مورد خودش چه ادعایی دارد، به دنبال محتوایی می‌گردد که ممکن است یک استخراج‌کننده ساده‌لوح را گمراه کند یا حاملی برای حمله باشد، تصمیم می‌گیرد که آیا اصلاً متن قابل استخراجی وجود دارد یا خیر، و سپس بر اساس یافته‌هایش سند را به یک صف هدایت می‌کند. بازرسی را نادیده بگیرید و خرابی‌ها بی‌صدا رخ خواهند داد: یک PDF رمزگذاری‌شده با رمزعبور مالک (owner-password-encrypted) که فرم XFA را دربرمی‌گیرد، در استخراج‌کننده متن به عنوان رشته‌های خالی رد می‌شود، به عنوان یک سند سفید ایندکس می‌شود، و هیچ‌کس متوجه نمی‌شود تا زمانی که کسی در ادامه مسیر به دنبال محتوایی بگردد که هرگز خوانده نشده است. PDFium Component یک نمایشگر سورس‌کد VCL/LCL و کتابخانه بازرسی برای دلفی، C++Builder، و لازاروس است که فراخوانی‌های درون‌نگری (introspection calls) مورد نیاز این میزکار را در معرض نمایش قرار می‌دهد. بخش‌های زیر توضیح می‌دهند که کدام فراخوانی به کدام سؤال پاسخ می‌دهد، و دو جایی را نشان می‌دهد که فراخوانیِ واضح و بدیهی، با اطمینان کامل به شما پاسخی اشتباه می‌دهد

پنج سؤالی که باید قبل از هدایت فایل پاسخ داده شوند

بدون در نظر گرفتن شبکه و نوار تصاویر بندانگشتی، تریاژ پذیرش به پنج سؤال خلاصه می‌شود:

  • آیا اصلاً می‌توان فایل را باز کرد، و تحت چه رمزعبوری؟
  • ادعا می‌کند که چیست: عنوان، نویسنده، تاریخ ایجاد؟
  • آیا محتوای فعال یا پرخطری مانند جاوا اسکریپت، یک فرم XFA، یا فایل‌های جاسازی شده را به همراه دارد؟
  • آیا متن قابل استخراجی وجود دارد، یا یک اسکن است که به سمت OCR می‌رود؟
  • با توجه به همه این‌ها، کدام صف آن را دریافت می‌کند: پردازش مستقیم، بررسی دستی، یا قرنطینه؟

هر سؤال روی یک یا دو فراخوانی PDFium Component مپ می‌شود. دو مورد از این مپ‌شدن‌ها گوشه‌های تیزی دارند که دلیل بیشتر فایل‌های اشتباه هدایت‌شده‌ای هستند که من مجبور بوده‌ام در محیط تولید (production) دیباگ کنم. متادیتا سند در دو مکان مختلف قرار دارد که می‌توانند با هم توافق نداشته باشند، و رمزگذاری لزوماً مانع باز شدن یک سند نمی‌شود

باز کردنِ ارزان: فرم-پر-کنی خاموش، صفر صفحه رندر شده

تریاژ باید ارزان‌ترین باز کردنِ ممکن باشد. تنظیم FormFill := False قبل از Active := True به کامپوننت می‌گوید محیطِ فرم-پر-کنی (form-fill) را به کلی نادیده بگیرد. این کار زمان بارگذاری را کوتاه می‌کند، و (که برای فایل‌هایی با منشأ ناشناخته به همان اندازه مهم است) از مقداردهی اولیه هرگونه جاوا اسکریپت در سطح سند جلوگیری می‌کند. هیچ‌کدام از ویژگی‌های بازرسیِ استفاده شده در زیر نیازی به رندر کردن یک صفحه ندارند، بنابراین یک گذر تریاژ هرگز مجبور نیست حتی یک بیت‌مپ تولید کند

procedure InspectIncoming(const IncomingPath: string; var Rec: TIntakeRecord);
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := IncomingPath;
    Pdf.FormFill := False;     // no form environment, no JavaScript init
    Pdf.Active := True;        // failure is silent: Active simply stays False

    if not Pdf.Active then
    begin
      Rec.OpenFailed := True;  // damaged file or user-password lock
      Exit;                    // the finally block still runs
    end;

    Rec.PageCount := Pdf.PageCount;
    CollectIdentity(Pdf, IncomingPath, Rec);
    CollectRiskSignals(Pdf, Rec);
  finally
    Pdf.Active := False;
    Pdf.Free;                  // never leak the instance on a malformed file
  end;
end;

بررسی پس از انتساب (assignment) اختیاری نیست، و به دلیلی، یک بررسی است تا کنترل‌کننده استثنا (exception handler). وقتی موتور نمی‌تواند فایل را بارگذاری کند، کامپوننت EPdfError داخلی را می‌بلعد و به جای انتشار آن، Active را روی False باقی می‌گذارد. کدی که منتظر یک استثنا (exception) باشد، با خوشحالی PageCount را از سندی می‌خواند که هرگز باز نشده است. اگر گردش‌کار ردِ فایل (rejection workflow) به متن واقعی خطای موتور نیاز دارد، فایل را در یک آرایه بایت بخوانید و سربارگذاری (overload) LoadDocument را که TBytes می‌گیرد، فراخوانی کنید؛ آن مسیر EPdfError را همراه با پیام بالا می‌آورد (raise)، از جمله در مورد رمزعبور. بلوک try..finally همچنان جایگاه خود را به دست می‌آورد. سرویس‌های پذیرش هفته‌ها بدون مراقبت اجرا می‌شوند، و هیچ استثنای بعدیِ نباید نمونه TPdf را نشت دهد (leak) یا قفلی را نگه دارد که گذر تلاش مجدد روی آن گیر کند

توان عملیاتی (Throughput) به ندرت به گلوگاه تبدیل می‌شود. با غیرفعال بودن فرم-پر-کنی (form fill) و عدم رندرینگ، باز کردنِ تریاژی تحت سلطه I/O است، و یک کارگر مجزا به راحتی می‌تواند چندین فایل در ثانیه را از دیسک محلی بررسی کند. اگر حجم پذیرش زمانی از ظرفیت یک کارگر بیشتر شد، کار را بر اساس فایل تقسیم‌بندی کنید نه بر اساس نوع بررسی. پنج سؤال در یک بار باز کردن مشترک هستند، و تقسیم آنها در فرآیندهای مختلف باعث می‌شود به جای مستهلک کردنِ پرهزینه‌ترین مرحله، آن را چند برابر کنید

متادیتا در دو مکان قرار دارد، و آنها با هم توافق ندارند

استاندارد ISO 32000-1 دو خانه برای متادیتا سند تعریف می‌کند: دیکشنری اطلاعاتِ سند (document information dictionary) (بند 14.3.3) و یک بسته XMP متصل به کاتالوگ (بند 14.3.2). ویژگی‌های Title، Author، Subject، و CreationDate دیکشنری اطلاعات را می‌خوانند، همراه با MetaText[] برای هر کلید دیگری و DecodeDate برای پارس کردن رشته تاریخ D:YYYYMMDD.... مشکل این است که تولیدکنندگان مدرن به طور فزاینده‌ای فقط XMP را می‌نویسند، مسیری که ISO 32000-2 با منسوخ کردن بیشتر کلیدهای دیکشنری اطلاعات در PDF 2.0 آن را رسمی کرده است. این نشانه در یک ابزار پذیرش ملموس است. میزکار شما عنوان خالی نشان می‌دهد در حالی که Adobe Acrobat یک عنوان را نمایش می‌دهد، زیرا آکروبات به عنصر dc:title درون بسته XMP که ویژگی‌های دیکشنری اطلاعات هرگز آن را لمس نمی‌کنند بازگشته (fallback) است

procedure CollectIdentity(Pdf: TPdf; const FilePath: string;
  var Rec: TIntakeRecord);
begin
  Rec.Title := Pdf.Title;             // Info dictionary value
  Rec.Author := Pdf.Author;
  Rec.CreatedAt := Pdf.CreationDate;  // raw PDF date string ("D:2026...")

  // An empty Info title does not mean the document is untitled. The
  // component does not expose the XMP packet, so probe the raw file
  // bytes for the dc:title element before trusting the blank.
  if (Rec.Title = '') and FileContainsText(FilePath, 'dc:title') then
    Include(Rec.Flags, ifTitleInXmpOnly);
end;

حتی کاوشگرِ ساده‌لوحانه زیررشته (substring probe) در بالا هم ارزش خود را ثابت می‌کند: "متادیتا وجود دارد، اما نه در جایی که ابزارهای قدیمی (legacy) نگاه می‌کنند" یک واقعیتِ مرتبط با هدایت فایل، برای هر خط لوله بایگانی (archive pipeline) است که بر اساس عنوان یا نویسنده ایندکس می‌کند. اگر ایندکس بعدی شما فقط دیکشنری اطلاعات را می‌خواند، فایل‌هایی که با این روش پرچم‌گذاری شده‌اند بی‌صدا غیرقابل جستجو می‌شوند

فایل‌های رمزگذاری‌شده‌ای که به هر حال باز می‌شوند

یک سند رمزگذاری‌شده لزوماً در باز شدن شکست نمی‌خورد. کنترل‌کننده امنیتی استاندارد (ISO 32000-1 بند 7.6.3) یک رمزعبور کاربر (user password) را که برای باز کردن سند لازم است، از یک رمزعبور مالک (owner password) متمایز می‌کند که صرفاً مجوزهایی مانند چاپ و کپی را محدود می‌کند. بخش بزرگی از اسناد تجاری "محافظت شده" با رمزعبور مالک و یک رمزعبور کاربر خالی رمزگذاری شده‌اند. آنها بدون درخواستِ (prompt) رمز باز می‌شوند، کاملاً رمزگشایی می‌شوند، و به نمایشگرهایی متکی هستند که داوطلبانه پرچم‌های مجوز را رعایت می‌کنند. این خط‌مشی است، نه محافظت، و وضعیت‌های پذیرش شما باید تفاوت را منعکس کنند

تشخیص رمزگذاری پس از باز شدن موفقیت‌آمیز، یک فراخوانی موتور به علاوه یک روش جایگزین (fallback) نیاز دارد. FPDF_GetSecurityHandlerRevision(Pdf.Document) برای فایل‌های محافظت‌نشده -1 برمی‌گرداند و برای بقیه ویرایش (revision) هندلر (handler) را، و اینکه Pdf.Permissions چیزی غیر از ماسک $FFFFFFFF که تمام بیت‌هایش تنظیم شده را برگرداند، سیگنالِ تأییدکننده آن است. برای فایل‌هایی که واقعاً با رمزعبور کاربر قفل شده‌اند، Password را پیش از تنظیم Active := True اختصاص دهید؛ اگر باز کردن همچنان شکست خورد، فایل را به حالت مسدود (blocked) هدایت کنید که درخواست اعتبارنامه (credentials) از فرستنده را از طریق یک کانال امن انجام می‌دهد، نه اینکه کورکورانه دوباره تلاش کند. و در برابر وسوسه تلقی کردنِ "رمزگذاری شده" به عنوان قرنطینه خودکار مقاومت کنید. در اکثر صنایع پر-سند، فایل‌های رمزگذاری-شده-اما-قابل-باز-شدن مورد عادی هستند، نه موارد مشکوک

محتوای فعال: جاوا اسکریپت، XFA، و فایل‌های جاسازی شده

سه یافته همیشه باید به تصمیم‌گیریِ هدایت (routing decision) برسند. اول، جاوا اسکریپت: رویداد OnUnsupportedFeature ویژگی‌های ساختاری مانند XFA یا محتوای سه بعدی را همانطور که موتور با آنها روبرو می‌شود گزارش می‌کند، اما جاوا اسکریپت را تشخیص نمی‌دهد. در عوض JavaScriptActionCount را بررسی کنید و نتیجه غیر-صفر را به عنوان محتوای فعال تلقی کنید. دوم، XFA: وقتی FormType مقدار ftXfaFull را برمی‌گرداند، صفحات قابل مشاهده اغلب چیزی بیش از رندرِ قالب XFA نیستند، و استخراج متداولِ متن، متن‌های قالبی (boilerplate) را به جای مقادیر پر شده می‌بیند. سوم، فایل‌های پیوست (attachments): یک PDF یک فرمت ظرف (container) است، و AttachmentCount به شما می‌گوید که آیا این فایل مسافرانی را به همراه دارد یا خیر

procedure CollectRiskSignals(Pdf: TPdf; var Rec: TIntakeRecord);
var
  i, PageNo: Integer;
  Ext: string;
begin
  Rec.IsEncrypted := Assigned(FPDF_GetSecurityHandlerRevision) and
    (FPDF_GetSecurityHandlerRevision(Pdf.Document) <> -1);
  Rec.HasForms := Pdf.FormType <> ftNone;
  Rec.IsXfa := Pdf.FormType = ftXfaFull;
  Rec.HasJavaScript := Pdf.JavaScriptActionCount > 0;

  // AnnotationCount is a per-page property; walk the pages to total
  // it. Loading a page object renders nothing, so this stays cheap.
  Rec.Annotations := 0;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    Inc(Rec.Annotations, Pdf.AnnotationCount);
  end;

  Rec.Attachments := Pdf.AttachmentCount;

  for i := 0 to Rec.Attachments - 1 do
  begin
    Ext := LowerCase(ExtractFileExt(string(Pdf.AttachmentName[i])));
    if (Ext = '.exe') or (Ext = '.js') or (Ext = '.vbs') or (Ext = '.dll') then
      Include(Rec.Flags, ifDangerousAttachment);
  end;
end;

دو جزئیات در آن حلقه سزاوار توجه هستند. نام فایلِ پیوست از داخل سند می‌آید، بنابراین هرگز قبل از پاک‌سازی (sanitizing)، از آن دوباره به عنوان مسیر خروجی استفاده نکنید؛ نامی جاسازی شده مانند ..\..\start.exe یک پیمایش مسیر (path traversal) است که منتظر یک فراخوانیِ ذخیره بی‌دقت مانده است. و یک لیست مسدود (blocklist)ِ پسوندها یک زنگ خطر (tripwire) است، نه یک تضمین. وظیفه آن اجبار به تصمیم‌گیری انسانی است، نه تأییدِ پاک بودن فایل

تبدیل سیگنال‌ها به وضعیت‌های هدایت (routing states)

یک مدل وضعیتِ (state model) کارا، به وضعیت‌های کمتری نسبت به انتظار اکثر تیم‌ها نیاز دارد: آماده (ready) (بدون مانع، متن موجود است)، بررسی (review) (باز شدن موفق بود اما چیزی نیاز به توجه چشم دارد، مانند یک فرم XFA، جاوا اسکریپت، یک لایه متنی خالی، یا عنوانی که فقط در XMP است)، مسدود (blocked) (رمزعبور کاربر لازم است)، و آسیب‌دیده (damaged) (باز شدن شکست خورد). شواهد را در کنار وضعیت ثبت کنید. هش (hash) فایل، تعداد صفحات، پرچم‌های دقیق، و پیام خطای موتور برای فایل‌های آسیب‌دیده همگی اهمیت دارند، زیرا کسی که در مورد تصمیم‌گیریِ هدایت سؤال می‌کند، هفته‌ها بعد، در مقابل فایلی این کار را انجام می‌دهد که ممکن است از آن زمان تاکنون جایگزین شده یا تغییر یافته باشد

وقتی اپراتوری نیاز دارد به یک فایل قرنطینه شده نگاه کند، آن را به نمایشگر پیش‌فرضِ shell ندهید. آن را در داخل یک پنل سخت‌افزاری‌شده (hardened) با اسکریپت‌نویسی و کنترل پیوند غیرفعال‌شده رندر کنید، رویکردی که در ساخت یک سطح پیش‌نمایش امن PDF در دلفی توضیح داده شده است. و اگر پذیرشِ شما بایگانی‌ای را تغذیه می‌کند که نیازمندی‌های انطباق (conformance) دارد، گذر تریاژ محل طبیعی برای برنامه‌ریزی بررسی‌های عمیق‌تر است؛ اعتبارسنجیِ دسته‌ای پیش از پرواز (batch preflight validation) بر اساس پروفایل‌های PDF/A و PDF/UA دقیقاً از همان جایی شروع می‌شود که این بازرسی متوقف می‌شود

صفحه محصولِ این کامپوننت به لایسنس، API کامل بازرسی، و دموهای همراه می‌پردازد، از جمله یک بازرسِ سند با سبک پذیرش: کامپوننت PDFium