קובץ 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