مقاله فنی

ممیزی خودکار ریسک و preflight برای PDF با PDFium

یک PDF که به مرز تولید می‌رسد، چه صف چاپ باشد، چه بایگانی، چه درگاه بارگذاری مشتری، باید پیش از آن‌که چیزی آن را render کند ممیزی شود. ممکن است فایل یک Launch action داشته باشد که برای اجرای برنامه‌ای خارجی سیم‌کشی شده است، تصاویری داشته باشد که برای چاپ بیش از حد کم‌کیفیت‌اند، یک encryption dictionary حمل کند که همان کار چاپی را که فایل برایش ارسال شده ممنوع می‌کند، یا یک برچسب PDF/A داشته باشد که واقعاً شرایطش را برآورده نمی‌کند. بازرسی سند در برابر چنین قواعدی پیش از ورود به workflow را preflighting می‌گویند، و PDFium C API هرآنچه Delphi برای پیاده‌سازی مستقیم این بررسی‌ها نیاز دارد در اختیارش می‌گذارد، بدون این‌که حتی یک صفحه render شود

این مقاله خودِ بررسی‌ها را می‌سازد: چهار کلاس ممیزی، که هرکدام یک روال کوچک هستند و یافته‌ها را به یک فهرست نتیجه مشترک اضافه می‌کنند. عناصر تعاملی، سنجه‌های منبع، وضعیت امنیتی و نشانگرهای استاندارد، همگی با کد عملی پوشش داده می‌شوند، از جمله حساب عددی آن‌ها. اگر چیزی که لازم دارید machinery پیرامون این بررسی‌ها است، مثل حلقه‌زدن روی پوشه‌ها در حالت batch، فایل‌های گزارش JSON و HTML، و ایزوله‌سازی per-file، خود PDFium Component یک موتور preflight آماده عرضه می‌کند و مقاله CLI دسته‌ای preflight همین plumbing را توضیح می‌دهد. هر دو عمداً یک واژگان exit code مشترک دارند، بنابراین ممیزی‌ای که اینجا می‌نویسید مستقیم زیر همان driver دسته‌ای جا می‌افتد

رکورد یافته‌ها و قرارداد exit code

هر بررسی در یک نوع رکورد flat می‌نویسد، چون گزینه جایگزین، یعنی این‌که هر بررسی خودش متن آزاد چاپ کند، بعداً قابل شمارش، فیلترکردن یا thresholdگذاری نیست. چهار فیلد کافی است

uses
  System.SysUtils, System.Math, System.IOUtils,
  System.Generics.Collections, pdfium_lib;

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // stable machine key, e.g. 'ACT-LAUNCH'
    Page: Integer;      // 1-based; 0 means document level
    Message: string;    // for humans; free to reword between releases
  end;

  TFindings = TList<TPreflightFinding>;

procedure Add(Findings: TFindings; Severity: TFindingSeverity;
  const Code: string; Page: Integer; const Msg: string);
var
  F: TPreflightFinding;
begin
  F.Severity := Severity;
  F.Code := Code;
  F.Page := Page;
  F.Message := Msg;
  Findings.Add(F);
end;

ابزارهای پایین‌دست روی Code کلید می‌زنند، نه روی متن Message که آزاد است بین نسخه‌ها عوض شود. exit code فرایند هم همان قرارداد سه‌مقداری مقاله batch را دنبال می‌کند: 0 یعنی فایل هیچ یافته‌ای تولید نکرد، 1 یعنی یافته وجود دارد، و 2 یعنی خود ممیزی نتوانست اجرا شود چون فایل parse نشد یا password می‌خواهد. جدا نگه‌داشتن code 2 مهم است. یک پوشه پر از اسکن خراب نشانه خرابی اسکنر بالادستی است، نه فروپاشی ناگهانی انطباق، و قاطی‌کردن این دو کسی را دنبال مسئله اشتباه می‌فرستد

عناصر تعاملی: اسکریپت‌ها، Launch targetها و پیوندهای خارجی

PDFium هر actionی را که پیدا می‌کند با یک نوع عددی دسته‌بندی می‌کند، و ثابت‌های آمده از fpdf_doc.h را باید دقیق میخکوب کرد، چون مقدارهای اشتباه‌کپی‌شده یک اسکنر را بی‌صدا کور می‌کنند. enum واقعی این است: PDFACTION_UNSUPPORTED = 0، PDFACTION_GOTO = 1، PDFACTION_REMOTEGOTO = 2، PDFACTION_URI = 3، PDFACTION_LAUNCH = 4، و PDFACTION_EMBEDDEDGOTO = 5. دقت کنید چه چیزی غایب است: هیچ عضو JavaScriptای در این enum نیست. scriptهای سطح سند link action نیستند و هرگز از مسیر FPDFAction_GetType ظاهر نمی‌شوند؛ آن‌ها با خانواده جداگانه‌ای از فراخوانی‌ها enumerate می‌شوند. ممیزی‌ای که action typeها را با یک ثابت خیالی JavaScript مقایسه می‌کند، کامپایل می‌شود، اجرا می‌شود، و برای همیشه هیچ‌چیز پیدا نمی‌کند

const
  PDFACTION_GOTO         = 1;   // in-document jump: harmless
  PDFACTION_REMOTEGOTO   = 2;   // jump into another local file
  PDFACTION_URI          = 3;   // opens an external URL
  PDFACTION_LAUNCH       = 4;   // starts an external program
  PDFACTION_EMBEDDEDGOTO = 5;   // jump into an embedded file

function ActionTarget(Doc: FPDF_DOCUMENT; Action: FPDF_ACTION;
  AType: ULONG): string;
var
  Buf: array[0..2047] of AnsiChar;
begin
  FillChar(Buf, SizeOf(Buf), 0);
  if AType = PDFACTION_URI then
    FPDFAction_GetURIPath(Doc, Action, @Buf, SizeOf(Buf))
  else
    FPDFAction_GetFilePath(Action, @Buf, SizeOf(Buf));
  Result := string(UTF8String(PAnsiChar(@Buf)));
end;

procedure AuditPageActions(Doc: FPDF_DOCUMENT; Page: FPDF_PAGE;
  PageNo: Integer; Findings: TFindings);
var
  StartPos: Integer;
  Link: FPDF_LINK;
  Action: FPDF_ACTION;
  AType: ULONG;
begin
  StartPos := 0;
  while FPDFLink_Enumerate(Page, @StartPos, @Link) <> 0 do
  begin
    Action := FPDFLink_GetAction(Link);
    if Action = nil then
      Continue;                 // destination-only link, nothing to flag
    AType := FPDFAction_GetType(Action);
    case AType of
      PDFACTION_LAUNCH:
        Add(Findings, fsError, 'ACT-LAUNCH', PageNo,
          'Launch action targets "' + ActionTarget(Doc, Action, AType) + '"');
      PDFACTION_URI:
        Add(Findings, fsWarning, 'ACT-URI', PageNo,
          'link opens ' + ActionTarget(Doc, Action, AType));
      PDFACTION_REMOTEGOTO, PDFACTION_EMBEDDEDGOTO:
        Add(Findings, fsWarning, 'ACT-XFILE', PageNo,
          'cross-file destination "' + ActionTarget(Doc, Action, AType) + '"');
    end;                        // PDFACTION_GOTO stays silent by design
  end;
end;

procedure AuditDocumentBehaviors(Doc: FPDF_DOCUMENT; Findings: TFindings);
var
  N: Integer;
begin
  N := FPDFDoc_GetJavaScriptActionCount(Doc);
  if N > 0 then
    Add(Findings, fsError, 'JS-DOC', 0,
      Format('%d document-level JavaScript action(s) run on open', [N]));
  N := FPDFDoc_GetAttachmentCount(Doc);
  if N > 0 then
    Add(Findings, fsWarning, 'ATT-EMB', 0,
      Format('%d embedded file attachment(s)', [N]));
end;

تقسیم severity بیانگر policy است. Launch action یک error است، چون اجرای یک برنامه دلخواه خطرناک‌ترین کاری است که یک click در PDF می‌تواند انجام دهد و هیچ invoiceای به آن نیاز ندارد. URIهای خارجی warning هستند: در اسناد مشروع رایج‌اند، اما بازبین باید مقصد را بدون کلیک‌کردن ببیند، چون متن قابل‌دیدن لینک و مقصد واقعی آن لزوماً یکی نیست. پرش‌های درون‌سندی از نوع GoTo ساختار هستند، نه رفتار، و عمداً اصلاً وارد گزارش نمی‌شوند؛ preflightای که برای هر ورودی table of contents فریاد خطر بکشد، خیلی سریع همه را به نادیده‌گرفتن خودش عادت می‌دهد. برای خواندن بدنه اسکریپت‌های پشت شمارش JavaScript، و برای سطوح signature MDP و تشخیص XFA، مقاله ممیزی ریسک امنیتی همین سطح را از راه wrapper شی‌گرای component دنبال می‌کند

سنجه‌های منبع: effective image DPI

یک تصویر داخل PDF به‌خودی‌خود هیچ DPIای ندارد. چیزی که دارد پیکسل است، و صفحه آن پیکسل‌ها را درون یک مستطیل با واحد point قرار می‌دهد، جایی که هر 72 point برابر با یک inch است. رزولوشن فقط به‌صورت نسبت این دو معنا پیدا می‌کند، و به همین دلیل همان عکس 600 در 400 هم می‌تواند به‌عنوان thumbnail بسیار تیز باشد و هم وقتی تمام صفحه شود کاملاً تار. بنابراین ممیزی برای هر تصویر به هر دو عدد نیاز دارد: ابعاد پیکسلی منبع از metadata تصویر، و مستطیل قرارگیری از bounds شیء

procedure AuditPageImages(Page: FPDF_PAGE; PageNo: Integer;
  Findings: TFindings);
var
  I, ObjCount: Integer;
  Obj: FPDF_PAGEOBJECT;
  Meta: FPDF_IMAGEOBJ_METADATA;
  L, B, R, T: Single;
  WidthPt, HeightPt, DpiX, DpiY, EffDpi: Double;
begin
  ObjCount := FPDFPage_CountObjects(Page);
  for I := 0 to ObjCount - 1 do
  begin
    Obj := FPDFPage_GetObject(Page, I);
    if FPDFPageObj_GetType(Obj) <> FPDF_PAGEOBJ_IMAGE then
      Continue;
    if FPDFImageObj_GetImageMetadata(Obj, Page, @Meta) = 0 then
      Continue;
    if FPDFPageObj_GetBounds(Obj, @L, @B, @R, @T) = 0 then
      Continue;

    WidthPt  := R - L;              // placed size on the page, in points
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 points = 1 inch, so placed inches = points / 72, and
    // effective DPI = source pixels / placed inches.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // the worse axis decides print quality

    if EffDpi < 150.0 then
      Add(Findings, fsWarning, 'IMG-LOWRES', PageNo,
        Format('image %dx%d px placed at %.1fx%.1f pt = %.0f DPI effective',
          [Meta.Width, Meta.Height, WidthPt, HeightPt, EffDpi]))
    else if EffDpi > 600.0 then
      Add(Findings, fsInfo, 'IMG-BLOAT', PageNo,
        Format('image is %.0f DPI at placed size; resampling would ' +
          'shrink the file with no visible loss', [EffDpi]));
  end;
end;

این thresholdها policy هستند، نه physics. مقدار 150 DPI کف تقریبی‌ای است که پایین‌تر از آن چاپ اداری به‌شکل دیدنی pixelate می‌شود، 300 هدف رایج چاپ تجاری است، و هر چیزی بالاتر از 600 کیفیت دیداری قابل‌توجهی اضافه نمی‌کند و فقط حجم فایل را باد می‌کند، به همین دلیل به‌عنوان informational bloat گزارش می‌شود نه defect. یک caveat صادقانه هم وجود دارد: FPDFPageObj_GetBounds فقط جعبه axis-aligned را برمی‌گرداند، بنابراین اگر تصویر با rotation قرار گرفته باشد، عدد محاسبه‌شده چگالی واقعی را دست‌کم می‌گیرد. ساختار FPDF_IMAGEOBJ_METADATA همچنین فیلدهای horizontal_dpi و vertical_dpi را هم دارد که PDFium آن‌ها را از transform matrix کامل استخراج می‌کند، و مقایسه آن دو نتیجه راه ارزانی برای تشخیص placementهای چرخیده است. همین حساب point-to-pixel در جهت معکوس، یعنی render کردن، در مقاله خروجی JPEG پوشش داده شده است

وضعیت امنیتی: encryption و permission bitها

رمزنگاری PDF دو password با دو کارکرد متفاوت تعریف می‌کند. user password درِ decryption را باز می‌کند: بدون آن فایل اصلاً باز نمی‌شود و FPDF_LoadDocument مقدار nil برمی‌گرداند، در حالی که FPDF_GetLastError مقدار FPDF_ERR_PASSWORD را گزارش می‌کند. owner password مجوزها را دروازه‌بانی می‌کند: فایلی که فقط owner password دارد بدون credential هم باز می‌شود، اما بیت‌های محدودکننده‌ای حمل می‌کند که reader سازگار باید آن‌ها را رعایت کند. بنابراین تلاش برای بارگذاری خودِ فایل نخستین probe امنیتی است، و همین تمایز exit code را تعیین می‌کند — فایلِ دارای user password عملاً قابل ممیزی نیست و code 2 می‌گیرد، در حالی که فایلِ owner-password-only به‌طور عادی ممیزی می‌شود و فقط finding جمع می‌کند

const
  FPDF_ERR_PASSWORD = 4;

function AuditSecurity(const FileName: string;
  Findings: TFindings): FPDF_DOCUMENT;
var
  Perms: ULONG;
  Revision: Integer;
begin
  Result := FPDF_LoadDocument(PAnsiChar(AnsiString(FileName)), nil);
  if Result = nil then
  begin
    if FPDF_GetLastError() = FPDF_ERR_PASSWORD then
      Add(Findings, fsError, 'SEC-USERPW', 0,
        'user (open) password required; audit cannot proceed')
    else
      Add(Findings, fsError, 'DOC-BROKEN', 0, 'file failed to parse');
    Exit;
  end;

  Revision := FPDF_GetSecurityHandlerRevision(Result);
  if Revision >= 0 then       // -1 means the file is not encrypted
  begin
    // Opened with an empty password yet encrypted: owner-password-only.
    // Anyone may read it, but the permission bits restrict what a
    // conforming reader lets them do. Unencrypted files report all
    // bits set, which is why the revision gate comes first.
    Perms := FPDF_GetDocPermissions(Result);
    Add(Findings, fsInfo, 'SEC-ENC', 0,
      Format('encrypted, security handler revision %d', [Revision]));
    if (Perms and 4) = 0 then      // bit 3: print
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: copy / extract content
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: high-resolution print
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

این maskها از Table 22 در ISO 32000-1 می‌آیند که بیت‌ها را از 1 شماره‌گذاری می‌کند: bit 3 از مقدار /P mask برابر 4 است، bit 5 برابر 16، و bit 12 برابر 2048. این‌که هر finding چقدر مهم است یک تصمیم routing است. یک مرکز چاپ باید فایل دارای SEC-NOPRINT را همان لحظه intake پس بزند، جایی که submitter پیام روشن می‌گیرد، نه این‌که سه ساعت مانده به deadline در RIP این موضوع کشف شود. یک archive باید خود SEC-ENC را blocker بداند، چون encryption و نگه‌داری بلندمدت با هم نمی‌سازند — نکته‌ای که بررسی استاندارد در بخش بعدی آن را به‌صورت رسمی‌تر بیان می‌کند

نشانگرهای استاندارد: خواندن یک ادعای PDF/A

یک فایل تطابق PDF/A را از راه بسته metadata مبتنی بر XMP خودش اعلام می‌کند؛ با ویژگی pdfaid:part برای شماره بخش 1 تا 4 و pdfaid:conformance برای حرف سطح، مثل b برای وفاداری دیداری یا a برای tagging ساختاری کامل. PDFium C API هیچ accessorای برای XMP ندارد؛ FPDF_GetMetaText فقط Info dictionary را می‌خواند، و شناسایی PDF/A آنجا نگهداری نمی‌شود. راه فرار را خود استاندارد فراهم می‌کند: ISO 19005 الزام می‌کند stream مربوط به XMP metadata بدون فشرده‌سازی ذخیره شود تا ابزارها بدون داشتن یک parser کامل PDF بتوانند آن را پیدا کنند. بنابراین یک byte scan خام روشی مشروع برای تشخیص claim است — و فایلی که claimش را در stream فشرده پنهان کند از قبل همان استانداردی را که ادعا می‌کند نقض کرده است

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // empty = no PDF/A claim present
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // XMP identification schema
  if P = 0 then
    Exit;
  // Handles both <pdfaid:part>2</pdfaid:part> and pdfaid:part="2":
  // take the first digit after the property name.
  Limit := Min(P + 32, Length(S));
  Inc(P, Length('pdfaid:part'));
  while (P <= Limit) and not (S[P] in ['1'..'4']) do
    Inc(P);
  if P <= Limit then
    Result := 'PDF/A-' + Char(S[P]);
end;

findingای که این روال تولید می‌کند عمداً informational است، چون claim فقط یک declaration است، نه یک ویژگی ذاتی فایل. ورودی XMP یک خط XML است که هر producerای، حتی producer خراب، می‌تواند بنویسد؛ conformance یعنی این‌که فایل واقعاً صدها قاعده مربوط به فونت‌های تعبیه‌شده، رنگ مستقل از دستگاه و قابلیت‌های ممنوع را رعایت کند. تشخیص claim فقط به شما می‌گوید کدام فایل‌ها را باید برای validation واقعی route کنید و نه بیشتر. موتور preflight داخلی component این validation را برای profileهای PDF/A، PDF/UA و PDF/X انجام می‌دهد، و مقاله CLI دسته‌ای نشان می‌دهد چگونه آن را درون یک pipeline همراه با reportهایی که auditor بعداً بتواند باز کند، سیم‌کشی کنید

یک اجرا روی فایل مسئله‌دار

driver همه بررسی‌ها را به هم می‌دوزد: اول security، چون تعیین می‌کند ممیزی اصلاً اجرا می‌شود یا نه، بعد behaviorهای سطح سند و claim استاندارد، و بعد یک حلقه صفحه‌ای برای actionها و imageها

function AuditFile(const FileName: string; Findings: TFindings): Integer;
var
  Doc: FPDF_DOCUMENT;
  Page: FPDF_PAGE;
  I: Integer;
  Claim: string;
begin
  Doc := AuditSecurity(FileName, Findings);
  if Doc = nil then
    Exit(2);                        // audit failure, not a verdict
  try
    AuditDocumentBehaviors(Doc, Findings);
    Claim := PdfAClaim(FileName);
    if Claim <> '' then
      Add(Findings, fsInfo, 'STD-PDFA', 0,
        Claim + ' conformance claimed (declaration only, not validated)');
    for I := 0 to FPDF_GetPageCount(Doc) - 1 do
    begin
      Page := FPDF_LoadPage(Doc, I);
      if Page = nil then
      begin
        Add(Findings, fsError, 'PAGE-BROKEN', I + 1, 'page failed to parse');
        Continue;
      end;
      try
        AuditPageActions(Doc, Page, I + 1, Findings);
        AuditPageImages(Page, I + 1, Findings);
      finally
        FPDF_ClosePage(Page);
      end;
    end;
  finally
    FPDF_CloseDocument(Doc);
  end;
  if Findings.Count > 0 then
    Result := 1
  else
    Result := 0;
end;

روی یک brochure که از یک آژانس بیرونی برگشته، خروجی چیزی شبیه این است

> preflight_audit brochure_final.pdf
brochure_final.pdf: 5 finding(s)
  [ERROR]   ACT-LAUNCH   page 3   Launch action targets "..\tools\setup.exe"
  [ERROR]   JS-DOC       doc      2 document-level JavaScript action(s) run on open
  [WARNING] IMG-LOWRES   page 7   image 412x287 px placed at 396.0x275.8 pt = 75 DPI effective
  [WARNING] SEC-NOPRINT  doc      printing is not permitted
  [INFO]    STD-PDFA     doc      PDF/A-2 conformance claimed (declaration only, not validated)
exit code 1

هر خط به‌تنهایی actionable است، اما verdict واقعی در ترکیب آن‌هاست. این فایل در حالی ادعای PDF/A-2 می‌کند که هم encryption dictionary دارد و هم JavaScript زنده، و PDF/A هر دو را صریحاً ممنوع می‌کند — بنابراین پیش از آن‌که هر validator عمیق‌تری اجرا شود، دروغ‌بودن claim از قبل ثابت شده است. این همان نوع تناقضی است که یک فهرست flat از findingها آشکار می‌کند و یک pass/fail بولی آن را پنهان می‌سازد

این ممیزی چه چیزهایی را نمی‌تواند به شما بگوید

صداقت درباره scope همان چیزی است که یک ابزار preflight را قابل اعتماد نگه می‌دارد. همه چیزهایی که در بالا آمد آنچه را می‌خواند که فایل درباره خودش اعلام می‌کند: PDFium ساختار را parse می‌کند و این ممیزی آن را inventory می‌کند. این ممیزی validation واقعی PDF/A انجام نمی‌دهد — نه بررسی پوشش گلیف در برابر فونت‌های embedشده، نه تحلیل فضای رنگ در برابر output intentها، و نه هیچ‌کدام از قواعد clause-levelای که claim را از conformance جدا می‌کنند؛ برای آن به یک validator اختصاصی مانند موتور preflight component یا veraPDF نیاز دارید. permission bitها declarationهایی هستند که readerهای سازگار رعایتشان می‌کنند، نه دیوارهای رمزنگارانه، بنابراین SEC-NOPRINT نیت را توصیف می‌کند نه enforcement را. اسکن actionها annotationهای پیوندی و اسکریپت‌های سطح سند را پوشش می‌دهد؛ scriptهایی که در event dictionaryهای fieldهای فرم پنهان شده‌اند، به APIهای فرم هم نیاز دارند. و اگر ممیزی را با بررسی امضا گسترش دهید، آن هم intent اعلام‌شده را گزارش می‌کند نه رمزنگاریِ verifyشده را — اعتبارسنجی زنجیره گواهی یک کار جداگانه است. ممیزی preflight مصاحبه intake است، نه دادگاه: وظیفه‌اش این است که تصمیم routing را آگاهانه، سریع و تکرارپذیر کند

نکته: APIهای سند، صفحه، annotation و image object که در سراسر این ممیزی استفاده شدند، همراه با یک wrapper سطح‌بالای Delphi و یک موتور کامل preflight برای اعتبارسنجی استانداردها، همگی با PDFium Component عرضه می‌شوند