يجب تدقيق ملف PDF الذي يصل إلى حدود الإنتاج - قائمة انتظار الطباعة (print queue)، أو الأرشيف، أو بوابة تحميل العميل - قبل أن يعرضه أي شيء. قد يحمل الملف إجراء تشغيل (Launch action) موصولاً لبدء برنامج خارجي، أو صورًا خشنة جدًا بحيث لا تنجو من الطباعة، أو قاموس تشفير (encryption dictionary) يمنع مهمة الطباعة ذاتها التي تم إرسالها من أجلها، أو تسمية PDF/A لا يرقى إليها. يُسمى فحص مستند مقابل قواعد مثل هذه قبل دخوله في مسار عمل بالفحص المسبق (preflighting)، وتمنح واجهة برمجة تطبيقات C لـ PDFium لـ Delphi كل ما يلزم لتنفيذ عمليات التحقق مباشرة، دون عرض صفحة واحدة
يبني هذا المقال عمليات التحقق نفسها: أربع فئات تدقيق (audit classes)، كل منها عبارة عن روتين صغير يلحق النتائج (findings) بقائمة نتائج مشتركة. تحصل العناصر التفاعلية (Interactive elements)، ومقاييس الموارد، وحالة الأمان، وعلامات المعايير، جميعها على كود عامل، بما في ذلك الحساب (arithmetic). إذا كان ما تحتاجه هو الآلية (machinery) حول عمليات التحقق - حلقات المجلدات الدفعية (batch folder loops)، وملفات تقارير JSON و HTML، وعزل كل ملف على حدة - فإن مكون PDFium يشحن محرك فحص مسبق جاهزًا، ويغطي مقال الفحص المسبق الدفعي عبر CLI تلك التوصيلات. يتشارك الاثنان عمدًا في مفردات كود خروج (exit-code) واحدة، لذا فإن المدقق (auditor) المكتوب هنا يندرج مباشرة تحت هذا المحرك الدفعي (batch driver)
سجل النتائج (finding record) وعقد كود الخروج (exit-code contract)
تكتب كل عملية تحقق في نوع سجل مسطح واحد (flat record type)، لأن البديل، وهو أن تطبع كل عملية تحقق نصها النثري (prose) الخاص بها، لا يمكن عده أو تصفيته أو وضع حد أدنى له (thresholded) بعد ذلك. أربعة حقول تكفي
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 أبدًا، والذي يمكن تغييره بحرية. يتبع كود خروج العملية (process exit code) نفس العقد المكون من ثلاث قيم (three-value contract) مثل مقال الدفعة (batch article): 0 تعني أن الملف لم ينتج أي نتائج، و 1 تعني وجود نتائج، و 2 تعني أنه تعذر تشغيل التدقيق نفسه لأن الملف فشل في التحليل (parse) أو يطلب كلمة مرور. إبقاء الكود 2 منفصلاً أمر مهم. المجلد الذي يحتوي على عمليات مسح (scans) تالفة هو ماسح ضوئي مكسور في المنبع (upstream)، وليس انهيارًا مفاجئًا في الامتثال (compliance)، وطي الاثنين معًا يرسل شخصًا يطارد المشكلة الخطأ
العناصر التفاعلية: البرامج النصية (scripts)، وأهداف التشغيل (launch targets)، والروابط الخارجية
يصنف PDFium كل إجراء يجده حسب نوع عدد صحيح (integer type)، وتستحق الثوابت من fpdf_doc.h التثبيت بدقة، لأن القيم المنسوخة بشكل خاطئ تجعل الماسح الضوئي أعمى بصمت. التعداد (enumeration) الحقيقي هو PDFACTION_UNSUPPORTED = 0 و PDFACTION_GOTO = 1 و PDFACTION_REMOTEGOTO = 2 و PDFACTION_URI = 3 و PDFACTION_LAUNCH = 4 و PDFACTION_EMBEDDEDGOTO = 5. لاحظ ما هو غائب: لا يوجد عضو JavaScript. البرامج النصية على مستوى المستند (Document-level scripts) ليست إجراءات ارتباط (link actions) ولا تظهر أبدًا من خلال FPDFAction_GetType؛ يتم تعدادها بواسطة مجموعة منفصلة من الاستدعاءات (calls). المدقق الذي يختبر أنواع الإجراءات مقابل ثابت JavaScript متخيل (imagined JavaScript constant) يقوم بالتجميع (compiles) والتشغيل، ولا يجد شيئًا، إلى الأبد
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 split) السياسة. يعد إجراء التشغيل (Launch action) خطأً لأن بدء برنامج تعسفي (arbitrary program) هو أخطر شيء يمكن أن تفعله نقرة في ملف PDF، ولا تحتاجه أي فاتورة. الروابط الخارجية (External URIs) عبارة عن تحذيرات: شائعة في المستندات المشروعة (legitimate documents)، ولكن يجب أن يرى المراجع الهدف دون النقر، لأن نص الارتباط المرئي (visible link text) والوجهة الفعلية لا يجب أن يتفقا. قفزات GoTo داخل المستند هي هيكل (structure)، وليست سلوكًا، وتبقى خارج التقرير تمامًا - الفحص المسبق الذي يدق ناقوس الخطر (cries wolf) على كل إدخال في جدول المحتويات (table-of-contents entry) يدرب الأشخاص على تجاهله. بالنسبة لقراءة أجسام البرامج النصية (script bodies) خلف عدد JavaScript، ولمستويات توقيع MDP واكتشاف XFA، يمر مقال تدقيق المخاطر الأمنية عبر نفس السطح من خلال غلاف الكائن (object wrapper) الخاص بالمكون
مقاييس الموارد: DPI الفعال للصورة
الصورة داخل ملف PDF ليس لها DPI خاص بها. إنها تحتوي على بكسلات (pixels)، وتضع الصفحة تلك البكسلات في مستطيل مُقاس بالنقاط (points)، حيث تساوي 72 نقطة بوصة واحدة. توجد الدقة (Resolution) فقط كنسبة بين الاثنين، وهذا هو السبب في أن نفس الصورة التي تبلغ دقتها 600 في 400 تكون حادة الوضوح كصورة مصغرة (thumbnail) وفوضى ضبابية (blurry mess) كصورة رئيسية (hero) بحجم الصفحة الكاملة. لذلك يحتاج التدقيق إلى كلا الرقمين لكل صورة: أبعاد البكسل المصدر من بيانات الصورة الوصفية (image metadata)، والمستطيل الموضوع من حدود الكائن (object 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;
الحدود هي سياسة، وليست فيزياء: 150 DPI هو الحد الأدنى الذي تتكسر تحته طباعة المكتب (visibly pixelates)، و 300 هو الهدف التجاري المعتاد، وأي شيء أعلى من 600 لا يشتري أي جودة مرئية بينما يضخم حجم الملف، وهذا هو السبب في أنه يبلغ عن انتفاخ إعلامي (informational bloat) بدلاً من عيب (defect). تحذير (caveat) واحد صادق: يُرجع FPDFPageObj_GetBounds المربع المحاذي للمحور (axis-aligned box)، لذلك بالنسبة لصورة تم وضعها مع تدوير (rotation)، فإن الرقم المحسوب يقلل من تقدير الكثافة الحقيقية. يحمل هيكل (struct) FPDF_IMAGEOBJ_METADATA أيضًا حقول horizontal_dpi و vertical_dpi التي يستمدها PDFium من مصفوفة التحويل (transform matrix) الكاملة، ومقارنة النتيجتين طريقة رخيصة لاكتشاف المواضع المستديرة (rotated placements). حساب النقاط إلى البكسل (points-to-pixels arithmetic) نفسه يقود العرض في الاتجاه المعاكس، كما هو موضح في مقال تصدير JPEG
حالة الأمان: التشفير وبتات الأذونات
يحدد تشفير PDF كلمتي مرور بوظائف مختلفة. تتحكم كلمة مرور المستخدم (user password) في فك التشفير: بدونها لن يفتح الملف على الإطلاق، ويُرجع FPDF_LoadDocument قيمة nil مع إبلاغ FPDF_GetLastError بـ FPDF_ERR_PASSWORD. تتحكم كلمة مرور المالك (owner password) في الأذونات: يُفتح الملف المحمي بكلمة مرور مالك فقط بدون بيانات اعتماد (credentials) ولكنه يحمل بتات تقييد (restriction bits) يجب على القارئ المطابق (conforming reader) احترامها. لذلك فإن محاولة التحميل بحد ذاتها هي المسبار الأمني الأول، والتمييز يحدد كود الخروج - ملف كلمة مرور المستخدم لا يمكن تدقيقه (كود 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. يعتمد ما إذا كانت نتيجة معينة مهمة على قرار التوجيه (routing decision). يجب أن يرفض مكتب الطباعة (print bureau) ملف SEC-NOPRINT عند الاستلام، حيث يحصل المرسل (submitter) على رسالة واضحة، بدلاً من رفضه في RIP قبل ثلاث ساعات من الموعد النهائي (deadline). يجب أن يتعامل الأرشيف مع SEC-ENC نفسه كعائق (blocker)، لأن التشفير والحفظ على المدى الطويل لا يختلطان - وهي النقطة التي يوشك التحقق من المعايير على توضيحها رسميًا
علامات المعايير: قراءة مطالبة PDF/A
يعلن ملف عن مطابقة PDF/A في حزمة البيانات الوصفية XMP (XMP metadata packet) الخاصة به، من خلال الخاصية pdfaid:part (من 1 إلى 4) و pdfaid:conformance (حرف المستوى، مثل b للدقة المرئية أو a للوسم الهيكلي الكامل). لا تقدم واجهة برمجة تطبيقات C لـ PDFium أي وصول (accessor) لـ XMP؛ لا يقرأ FPDF_GetMetaText سوى قاموس Info، وهو ليس مكان وجود التعريف (identification). مخرج الهروب (escape hatch) هو قاعدة في المعيار نفسه: تتطلب ISO 19005 تخزين دفق بيانات XMP الوصفية بشكل غير مضغوط، تحديدًا حتى تتمكن الأدوات من العثور عليه بدون محلل PDF كامل. لذلك فإن المسح الضوئي بالبايت الخام (raw byte scan) هو كاشف مطالبات مشروع (legitimate claim detector) - والملف الذي تختبئ مطالبته داخل دفق مضغوط قد انتهك بالفعل المعيار الذي يدعيه
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;
النتيجة التي ينتجها هذا هي إعلامية (informational) عمدًا، لأن المطالبة هي إعلان، وليست خاصية للملف. إدخال XMP هو سطر واحد من XML يمكن لأي منتج كتابته، بما في ذلك المنتج المعطل؛ التوافق (conformance) هو تلبية الملف فعليًا لمئات القواعد حول الخطوط المضمنة (embedded fonts)، واللون المستقل عن الجهاز (device-independent color)، والميزات الممنوعة (forbidden features). إن اكتشاف المطالبة يخبرك فقط بالملفات التي يجب توجيهها إلى التحقق الفعلي، ولا شيء أكثر. يقوم محرك الفحص المسبق (preflight engine) المدمج في المكون بتنفيذ هذا التحقق عبر ملفات تعريف (profiles) PDF/A و PDF/UA و PDF/X، ويوضح مقال الدفعة عبر CLI كيفية توصيله بمسار عمل مع تقارير يمكن للمدقق فتحها لاحقًا
التشغيل مقابل ملف به مشكلة
يربط برنامج التشغيل (driver) عمليات التحقق معًا: الأمان أولاً، لأنه يقرر ما إذا كان التدقيق سيتم تشغيله على الإطلاق، ثم السلوكيات على مستوى المستند ومطالبة المعايير، ثم حلقة صفحة (page loop) للإجراءات والصور
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
كل سطر قابل للتنفيذ بمفرده، ولكن الجمع بينها هو الحكم الحقيقي (real verdict). يدعي هذا الملف أنه PDF/A-2 بينما يحمل قاموس تشفير (encryption dictionary) و JavaScript حيًا، ويحظر PDF/A كلاهما تمامًا - لذا فإن المطالبة خاطئة بشكل يثبت (provably false) قبل تشغيل أي مدقق عميق (deep validator). هذا هو نوع التناقض الذي تظهره قائمة النتائج المسطحة وتخفيه النتيجة المنطقية للنجاح/الفشل (boolean pass/fail)
ما لا يمكن أن يخبرك به هذا التدقيق
الصدق بشأن النطاق هو ما يحافظ على الثقة في أداة الفحص المسبق. يقرأ كل ما سبق ما يعلنه الملف عن نفسه: يقوم PDFium بتحليل البنية (structure)، وهذا التدقيق يجردها. لا يقوم بالتحقق من صحة PDF/A - لا توجد فحوصات لتغطية الصور الرمزية (glyph-coverage checks) مقابل الخطوط المضمنة (embedded fonts)، ولا يوجد تحليل لمساحة الألوان (color space) مقابل أهداف الإخراج (output intents)، ولا أي من قواعد مستوى الجملة (clause-level rules) التي تفصل المطالبة عن التوافق (conformance)؛ لذلك تحتاج إلى مدقق مخصص (dedicated validator) مثل محرك الفحص المسبق للمكون أو veraPDF. بتات الأذونات (Permission bits) هي إعلانات يحترمها القراء المطابقون (conforming readers)، وليست جدرانًا مشفرة (cryptographic walls)، لذلك يصف SEC-NOPRINT النية بدلاً من التنفيذ (enforcement). يغطي مسح الإجراءات (action scan) التوضيحات التفسيرية للارتباطات (link annotations) والبرامج النصية على مستوى المستند (document-level scripts)؛ تحتاج البرامج النصية المدفونة في قواميس أحداث حقل النموذج (form-field event dictionaries) إلى واجهات برمجة تطبيقات النموذج (form APIs) في الأعلى. والفحص الخاص بالتوقيع (signature check)، إذا قمت بتوسيع التدقيق به، يبلغ عن النية المعلنة، وليس التشفير الذي تم التحقق منه - التحقق من سلسلة الشهادات (certificate chain validation) هو وظيفة منفصلة. تدقيق الفحص المسبق (preflight audit) هو مقابلة الاستيعاب (intake interview)، وليس المحاكمة: مهمته هي جعل قرار التوجيه مستنيرًا وسريعًا وقابلاً للتكرار
ملاحظة: واجهات برمجة تطبيقات المستند والصفحة والتعليقات التوضيحية وكائنات الصور المستخدمة في هذا التدقيق، جنبًا إلى جنب مع غلاف Delphi عالي المستوى (high-level Delphi wrapper) ومحرك فحص مسبق كامل للتحقق من المعايير (standards-validation preflight engine)، تُشحن مع مكون PDFium