מאמר טכני

בדיקות Preflight וביקורת סיכונים אוטומטיות ב-PDF עם PDFium

קובץ PDF שמגיע לגבול ייצור — תור הדפסה, ארכיון, פורטל העלאות של לקוחות — צריך לעבור ביקורת לפני שמשהו מרנדר אותו. הקובץ עשוי לשאת פעולת Launch שמכוונת להפעיל תוכנית חיצונית, תמונות גסות מכדי לשרוד הדפסה, מילון הצפנה שאוסר בדיוק את משימת ההדפסה שלשמה הוא הוגש, או תווית PDF/A שהוא אינו עומד בה. בדיקת מסמך מול כללים כאלה לפני שהוא נכנס לתהליך עבודה נקראת preflighting (בדיקת קדם-טיסה), וה-C API של PDFium מעניק ל-Delphi את כל הדרוש ליישום הבדיקות ישירות, מבלי לרנדר אפילו עמוד אחד

מאמר זה בונה את הבדיקות עצמן: ארבע מחלקות ביקורת, שכל אחת מהן היא שגרה קטנה המצרפת ממצאים לרשימת תוצאות משותפת. אלמנטים אינטראקטיביים, מדדי משאבים, מצב אבטחה וסמני תקנים, כולם מקבלים קוד עובד, כולל האריתמטיקה. אם מה שאתם צריכים זו המכונות שסביב הבדיקות — לולאות של תיקיות אצווה, קבצי דוחות JSON ו-HTML, בידוד לכל קובץ — ה-PDFium Component מספק מנוע preflight מוכן מראש, ומאמר ה-CLI של preflight באצווה מכסה את הצנרת הזו. השניים חולקים במכוון אוצר מילים אחד של קודי יציאה, כך שמבקר שנכתב כאן נכנס ישירות תחת מנהל האצווה ההוא

רשומת הממצאים וחוזה קוד היציאה

כל בדיקה כותבת לתוך סוג רשומה שטוחה אחת, מכיוון שהחלופה, שבה כל בדיקה מדפיסה את המלל שלה, לא ניתנת לספירה, סינון או קביעת סף לאחר מכן. ארבעה שדות מספיקים

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;

כלים במורד הזרם (Downstream tooling) מסתמכים על ה-Code, לעולם לא על טקסט ה-Message, שהוא חופשי להשתנות. קוד היציאה (exit code) של התהליך פועל לפי אותו חוזה של שלושה ערכים כמו מאמר האצווה: 0 אומר שהקובץ לא ייצר שום ממצאים, 1 אומר שקיימים ממצאים, ו-2 אומר שלא ניתן היה להפעיל את הביקורת עצמה בגלל שהקובץ נכשל בפענוח או דורש סיסמה. שמירה על קוד 2 נפרד היא חשובה. תיקיה של סריקות פגומות היא סורק שבור במעלה הזרם, לא קריסת תאימות פתאומית, ושילוב של השניים ישלח מישהו לרדוף אחרי הבעיה הלא נכונה

אלמנטים אינטראקטיביים: סקריפטים, יעדי הפעלה, קישורים חיצוניים

PDFium מסווג כל פעולה שהוא מוצא לפי סוג מספר שלם, ושווה לקבע במדויק את הקבועים מ-fpdf_doc.h, מכיוון שערכים שהועתקו בצורה שגויה הופכים סורק לעיוור בשקט. המנייה (enumeration) האמיתית היא PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4, ו-PDFACTION_EMBEDDEDGOTO = 5. שימו לב מה חסר: אין כאן איבר JavaScript. סקריפטים ברמת המסמך אינם פעולות של קישורים ולעולם אינם מופיעים דרך FPDFAction_GetType; הם נמנים על ידי משפחה נפרדת של קריאות. מבקר שבודק סוגי פעולות מול קבוע 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;

פיצול החומרה מקודד מדיניות. פעולת Launch היא שגיאה מכיוון שהתחלת תוכנית שרירותית היא הדבר המסוכן ביותר שלחיצה ב-PDF יכולה לעשות, ואף חשבונית לא צריכה זאת. URIs חיצוניים הם אזהרות: נפוצים במסמכים לגיטימיים, אך סוקר צריך לראות את היעד לפני שהוא לוחץ, מכיוון שטקסט הקישור הגלוי והיעד בפועל לא חייבים להתאים. קפיצות GoTo בתוך המסמך הן מבנה, לא התנהגות, ונשארות מחוץ לדוח לחלוטין — preflight שצועק "זאב" על כל כניסה בתוכן העניינים מאמן אנשים להתעלם ממנו. לקריאת גופי הסקריפטים שמאחורי ספירת ה-JavaScript, ועבור רמות חתימת MDP וזיהוי XFA, מאמר ביקורת סיכוני האבטחה עובר על אותו שטח דרך מעטפת האובייקטים של הרכיב

מדדי משאבים: DPI אפקטיבי של תמונה

לתמונה בתוך PDF אין DPI משלה. יש לה פיקסלים, והעמוד מציב את הפיקסלים הללו לתוך מלבן הנמדד בנקודות (points), שבו 72 נקודות מהוות אינץ'. רזולוציה קיימת רק כיחס של השניים, וזו הסיבה שאותה תמונה של 600 על 400 חדה כתער כתמונה ממוזערת ועיסה מטושטשת כתמונת גיבור על עמוד שלם. הביקורת צריכה אפוא את שני המספרים עבור כל תמונה: ממדי פיקסל המקור מהמטא-נתונים של התמונה, והמלבן המוצב מגבולות האובייקט

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;

הספים הם מדיניות, לא פיזיקה: 150 DPI הוא רצפה שמתחתיה הדפסה משרדית מתפקסלת לעין, 300 הוא יעד מסחרי רגיל, וכל דבר מעל 600 לא קונה שום איכות נראית לעין תוך ניפוח גודל הקובץ, וזו הסיבה שזה מדווח כניפוח מידעני (informational bloat) ולא כפגם. אזהרה כנה אחת: FPDFPageObj_GetBounds מחזיר את התיבה מיושרת הצירים, כך שעבור תמונה שהוצבה עם סיבוב הנתון המחושב מעריך בחסר את הצפיפות האמיתית. מבנה ה-FPDF_IMAGEOBJ_METADATA נושא גם שדות horizontal_dpi ו-vertical_dpi ש-PDFium גוזר מתוך מטריצת הטרנספורמציה המלאה, והשוואת שתי התוצאות היא דרך זולה לזהות הצבות מסובבות. אותה אריתמטיקה של נקודות לפיקסלים מניעה את הרינדור בכיוון ההפוך, כפי שמכוסה במאמר הייצוא ל-JPEG

מצב אבטחה: הצפנה וסיביות הרשאה

הצפנת PDF מגדירה שתי סיסמאות עם תפקידים שונים. סיסמת המשתמש (user password) שולטת בפענוח: בלעדיה הקובץ לא ייפתח בכלל, ו-FPDF_LoadDocument מחזיר nil כאשר FPDF_GetLastError מדווח על FPDF_ERR_PASSWORD. סיסמת הבעלים (owner password) שולטת בהרשאות: קובץ המוגן רק על ידי סיסמת בעלים נפתח ללא הרשאות אולם נושא סיביות הגבלה שקורא תואם חייב לכבד. ניסיון הטעינה עצמו הוא לכן גשוש האבטחה הראשון, וההבחנה קובעת את קוד היציאה — קובץ עם סיסמת משתמש אינו ניתן לביקורת (קוד 2), בעוד שקובץ עם סיסמת בעלים עובר ביקורת כרגיל ורק צובר ממצאים

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;

המסכות (masks) מגיעות מטבלה 22 של ISO 32000-1, אשר ממספרת ביטים החל מ-1: ביט 3 של הערך /P הוא מסכה 4, ביט 5 הוא 16, ביט 12 הוא 2048. האם לממצא מסוים יש משמעות זו החלטת ניתוב. לשכת הדפסה צריכה לדחות קובץ SEC-NOPRINT בקבלה, שם המגיש מקבל הודעה ברורה, ולא ב-RIP שלוש שעות לפני הדדליין. ארכיון צריך להתייחס ל-SEC-ENC עצמו כחוסם, שכן הצפנה ושימור לטווח ארוך לא הולכים יחד — נקודה שבדיקת התקנים עומדת לציין באופן רשמי

סמני תקנים: קריאת הצהרת PDF/A

קובץ מצהיר על תאימות PDF/A בחבילת המטא-נתונים XMP שלו, דרך המאפיין pdfaid:part (1 עד 4) ו-pdfaid:conformance (אות הרמה, כגון b לנאמנות חזותית או a לתיוג מבני מלא). ה-C API של PDFium אינו מציע מנגנון גישה ל-XMP; FPDF_GetMetaText קורא רק את מילון ה-Info, שאינו המקום בו מזהה זה יושב. פתח המילוט הוא כלל בתקן עצמו: ISO 19005 דורש שזרם המטא-נתונים XMP יאוחסן לא דחוס, בדיוק כדי שכלים יוכלו למצוא אותו ללא מנתח (parser) PDF מלא. סריקת בתים גולמית היא לכן גלאי הצהרה לגיטימי — וקובץ שההצהרה שלו מסתתרת בתוך זרם דחוס כבר הפר את התקן שהוא מצהיר עליו

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;

הממצא שזה מייצר הוא מידעני במכוון, מכיוון שהצהרה היא הכרזה, לא מאפיין של הקובץ. ערך ה-XMP הוא שורה אחת של XML שכל מפיק יכול לכתוב, כולל אחד שבור; תאימות היא שהקובץ למעשה עומד במאות כללים לגבי גופנים מוטמעים, צבע בלתי תלוי-במכשיר (device-independent), ותכונות אסורות. זיהוי ההצהרה אומר לכם אילו קבצים לנתב לאימות אמיתי, ותו לא. מנוע ה-preflight המובנה של הרכיב מבצע את האימות הזה לאורך פרופילי PDF/A, PDF/UA, ו-PDF/X, ומאמר ה-CLI של האצווה מראה כיצד לחווט אותו לתוך צינור נתונים עם דוחות שמבקר יכול לפתוח מאוחר יותר

הרצה מול קובץ בעייתי

מנהל ההתקן (driver) מחבר את הבדיקות יחד: אבטחה קודם כל, כי זה קובע אם הביקורת רצה בכלל, לאחר מכן התנהגויות ברמת המסמך והצהרת התקנים, ולאחר מכן לולאת עמוד עבור פעולות ותמונות

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;

מול עלון שחזר מסוכנות חיצונית, הפלט נראה כך

> 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

כל שורה ניתנת לפעולה בפני עצמה, אך השילוב הוא פסק הדין האמיתי. קובץ זה מצהיר על PDF/A-2 בזמן שהוא נושא מילון הצפנה ו-JavaScript חי, ו-PDF/A אוסר על שניהם על הסף — ולכן ההצהרה הוכחה כשקרית לפני שכל מאמת מעמיק רץ. זה סוג הסתירה שרשימת ממצאים שטוחה מעלה על פני השטח ושתוצאת בוליאנית של עובר/נכשל מסתירה

מה הביקורת הזו לא יכולה להגיד לכם

כנות לגבי ההיקף היא מה ששומר על האמון בכלי preflight. כל האמור לעיל קורא את מה שהקובץ מצהיר על עצמו: PDFium מנתח מבנה, והביקורת הזו ממלאה אותו. היא אינה מבצעת אימות PDF/A — אין בדיקות כיסוי גליפים מול גופנים מוטמעים, אין ניתוח מרחב צבע מול כוונות פלט (output intents), אין כללי רמת-סעיף שמפרידים בין הצהרה לבין תאימות; לשם כך אתם צריכים מאמת ייעודי כגון מנוע ה-preflight של הרכיב או veraPDF. סיביות הרשאה הן הצהרות שקוראים תואמים מכבדים, לא חומות קריפטוגרפיות, כך ש-SEC-NOPRINT מתאר כוונה ולא אכיפה. סריקת הפעולות מכסה הערות קישור וסקריפטים ברמת המסמך; סקריפטים הקבורים במילוני אירועים של שדות טופס דורשים את ה-APIs של טפסים בנוסף. ובדיקת חתימה, אם תרחיבו את הביקורת עם אחת, מדווחת על כוונה מוצהרת, לא על קריפטוגרפיה מאומתת — אימות שרשרת תעודות היא עבודה נפרדת. ביקורת preflight היא ראיון הקבלה, לא המשפט: התפקיד שלה הוא להפוך את החלטת הניתוב למודעת, מהירה, וניתנת לשחזור

הערה: ממשקי ה-API של האובייקטים של מסמך, עמוד, הערה (annotation) ותמונה המשמשים לאורך ביקורת זו, יחד עם מעטפת Delphi ברמה גבוהה ומנוע preflight מלא לאימות תקנים, נשלחים עם PDFium Component