مقاله فنی

اعتبارسنجی پیش‌انتشار PDF/A در Delphi با PDFium VCL

یک دروازهٔ ingest آرشیو یک batch از فایل‌های «PDF/A-2b» را رد کرد که در هر viewer روی میز به‌خوبی باز می‌شدند. تأمین‌کننده قسم می‌خورد که آن‌ها conformant هستند. نبودند: هر کدام یک action جاوااسکریپت در دل catalog داشتند، از آن چیزهایی که یک نگاه عادی هرگز نمی‌بیند و یک validator کامل PDF/A مثل veraPDF در یک لحظه علامت می‌زند. گیر کار این است که هیچ‌کس نمی‌خواست فقط برای پاسخ دادن به یک سؤال بله یا خیر برای هر فایل، یک toolchain جاوا را به یک service دسته‌ای Delphi وصل کند. این همان شکافی است که ValidatePdfACompliance در PDFium Component پر می‌کند، و ارزش دارد بفهمیم چگونه به یک verdict می‌رسد بدون اینکه هرگز یک content stream را کامل parse کند

چرا خود PDFium نمی‌تواند به این سؤال پاسخ بدهد

اولین چیزی که باید صادقانه بگوییم این است: pdfium.dll bundled هیچ قابلیت PDF/A ندارد. نه ConvertToPDFA، نه OutputIntent writer، نه XMP API در surface عمومی. هر بخش از PDF/A در این library، هم سمت نوشتن و هم سمت بررسی، در pure Pascal داخل FPdfPdfa.pas زندگی می‌کند و با parse سطح بایت به‌علاوهٔ incremental update کار می‌کند. پس وقتی validator را صدا می‌زنید از renderer کرومیوم چیزی نمی‌پرسید؛ دارید یک token scanner Pascal را روی bytes ساختاری فایل اجرا می‌کنید

API عمومی عمداً کوچک است. یک function یک stream را از position 0 می‌خواند و یک record برمی‌گرداند:

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // a set of TPdfAValidationIssue
    function IsCompliant: Boolean;        // True only when level <> unknown/none
  end;                                    // AND Issues is empty

IsCompliant یک قانون حیاتی برای دروازه encode می‌کند: یک فایل فقط وقتی pass است که یک conformance level واقعی detect شده باشد و set issueها خالی باشد. یک parse که موفق می‌شود اما هیچ marker pdfaidی پیدا نمی‌کند به pacNone resolve می‌شود، که صریحاً pass نیست. این همان نقطه‌ای است که batch preflight report CLI از بیرون بیان می‌کند: یک فهرست findings خالی روی یک فایل ناشناس، نشانهٔ سلامت نیست

پاک کردن body streamها پیش از هر token scan

این مهم‌ترین جزئیات پیاده‌سازی است، و همان چیزی که اگر scanner خودتان را بنویسید راحت‌ترین جا برای اشتباه کردن است. detector نقض‌ها را با جست‌وجوی tokenهای نامِ جداشده پیدا می‌کند، چیزهایی مثل /JavaScript, /LZWDecode, /BM. اگر روی bytes خام فایل scan کنید، bodyهای باینری embedded streamها، تصویرهای فشرده، profileهای ICC، برنامه‌های فونت، به‌طور تصادفی توالی بایت‌هایی خواهند داشت که شبیه همین tokenها هستند. شما /AA یا /3D را «پیدا شده» گزارش می‌کنید فقط چون سه بایت داخل یک JPEG اتفاقاً همان حروف را ساخته‌اند. این یک کارخانهٔ false positive است

راه حل PdfStructureBytes است: فایل را طی می‌کند و bytes بین هر keyword stream و endstream را با فاصله پر می‌کند و ساختار dictionary را دست‌نخورده نگه می‌دارد. فقط بعد از آن scan اجرا می‌شود. هر check مربوط به tokenهای نام در validator روی این copy پاک‌شده انجام می‌شود. اگر فقط یک ایده از این مقاله بردارید، همان باشد. همین انضباط در validator PDF/UA هم تکرار می‌شود، که copy خودش را از همین routine نگه می‌دارد چون این دو standard مستقل از هم تغییر می‌کنند

29 issue و معنی هر کدام

TPdfAValidationIssue یک قرارداد مستند است. ordinals قفل شده‌اند چون testهای DUnitX، demoها، و لایهٔ report همگی به آن‌ها وابسته‌اند، بنابراین یافته‌های تازه فقط به انتهای فهرست افزوده می‌شوند. تا v1.63.0، 29 عضو وجود دارد. آن‌ها در چند خانواده قرار می‌گیرند:

  • Metadata و identity: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3)، pvaiMissingXmpDates
  • رنگ و خروجی: pvaiMissingOutputIntent, pvaiMissingIccProfile، و pvaiMixedDeviceColorSpaces وقتی هم DeviceRGB و هم DeviceCMYK ظاهر شوند (6.2.3.3)
  • ممنوعیت‌های سخت برای هر part: pvaiEncryptionPresent (یک /Encrypt دیکشنری به‌طور کامل ممنوع است)، pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation
  • فونت‌ها: pvaiFontNotEmbedded و سخت‌گیرانه‌ترِ pvaiUnembeddedFont، به‌علاوهٔ pvaiUnicodeMappingMissing برای یک ادعای Level U بدون /ToUnicode
  • تگ‌گذاری: pvaiLevelAStructureMissing وقتی یک ادعای conformance=A هیچ ساختار تگ‌گذاری‌شده‌ای ندارد

شش عضو جدیدتر که در ordinalهای 24 تا 29 اضافه شدند، موارد ظریفی را پوشش می‌دهند که بازبین‌ها واقعاً روی آن‌ها گیر می‌کنند: pvaiTrappedTrue (یک /Trapped /True در Info dictionary، یک «false friend» است چون مقدار باید False یا Unknown باشد)، pvaiForbiddenActionSubtype (Sound یا Movie که به‌عنوان action استفاده می‌شود، نه فقط annotation)، pvaiTransparentColorSpace (یک blend mode غیر از Normal یا یک /CA//ca که برابر با 1.0 نیست)، pvaiAnnotationDictViolation, pvaiUnembeddedFont و pvaiMixedDeviceColorSpaces

گیت‌گذاری آگاه از part: A-1 سخت‌گیر است، A-2 و A-3 انعطاف‌پذیرترند

PDF/A یک کتاب‌قانون واحد نیست. سه چیزی که PDF/A-1 ممنوع می‌کند از PDF/A-2 به بعد صراحتاً مجازند: شفافیت (یک /Transparency group یا یک /SMask, 6.4)، محتوای اختیاری (/OCProperties, 6.1.13)، و فایل‌های جاسازی‌شده (/EmbeddedFiles یا /EF, 6.1.11). یک validator ساده‌لوح که هر سه را برای هر فایل علامت بزند، اسناد کاملاً معتبر PDF/A-2 را یکجا رد خواهد کرد

پس validator شمارهٔ part را از markerِ pdfaid از طریق PdfAPartOf می‌خواند و آن بررسی‌ها را پشت PartNo = 1 می‌گذارد. بررسی‌های blend-mode و annotation-alpha برای مسائل جدیدِ شفافیت نیز به‌طور مشابه فقط برای part-1 هستند:

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // only /Normal or /Compatible allowed
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

یک پیش‌فرض محتاطانه هم شایان ذکر است: وقتی اصلاً هیچ markerِ pdfaidی وجود ندارد، part به‌عنوان 1، یعنی سخت‌گیرانه‌ترین حالت، در نظر گرفته می‌شود. منطق این است که یک فایل ناشناس باید با سخت‌ترین rules سنجیده شود، نه این‌که بی‌قید و شرط عبور کند. JavaScript، actionهای ممنوع، LZW، XFA، NeedAppearances، annotationهای ممنوع و fontهای جاسازی‌نشده برای هر part ممنوع می‌مانند، پس آن بررسی‌ها هیچ‌وقت پشت gate نمی‌روند

گسترش object streamها تا چیزی پنهان نماند

PDF 1.5 جریان cross-reference و object stream (/Type /ObjStm) را معرفی کرد، و آن‌ها برای یک byte scanner ساده یک نقطهٔ کور ایجاد می‌کنند. یک catalog، یک OutputIntent، یک action dictionary، هر چیزی که خودش stream نباشد، می‌تواند درون ObjStm به‌صورت Flate-compressed قرار بگیرد. ساختار خام را scan کنید و هیچ‌کدام را نمی‌بینید، اما در عوض یک فایل تمیز گزارش می‌کنید که اصلاً تمیز نیست

PdfExpandObjectStreams این شکاف را پر می‌کند. پیش از اجرای هر بررسی، validator Data := PdfExpandObjectStreams(Data) را انجام می‌دهد. این routine هر ObjStm را پیدا می‌کند، /N و /First header آن را می‌خواند تا شماره و offsetهای objectهای درونش را بگیرد، body را با PdfInflate (zlib در RTL، System.ZLib در Delphi و zstream در FPC)، و هر object درون آن را به‌صورت یک N 0 obj ... endobj در انتهای یک copy از bytes اضافه می‌کند. سپس بررسی‌های token موجود آن objectها را بدون هیچ تغییری در منطقشان پیدا می‌کنند

دو قید این کار را به‌جای شکننده بودن، تمیز نگه می‌دارند. stream objectها، Metadata، ICC profile و font programها نمی‌توانند در یک object stream زندگی کنند؛ فقط dictionaryهای non-stream می‌توانند، پس گسترش فقط با dictionaryها سر و کار دارد و objectهای افزوده هیچ stream keywordی برای برهم‌زدن passِ body-stripping ندارند. و چون محتوای افزوده بعد از %%EOF می‌آید، startxref جست‌وجوی معکوس از اعتبارسنجی object streamها و cross-reference streamها همچنان trailer اصلی را پیدا می‌کند. خود trailerِ cross-reference stream پیش‌تر، در v1.49.3، با خواندن Root، Size و ID مستقیماً از دیکشنری plaintext xref-stream مدیریت شده بود؛ موضوعی که در مقالهٔ همراه دربارهٔ

محدودیت‌های صادقانهٔ یک checker در سطح byte

این یک preflight tool است، نه یک validator دارای certification، و مرزها واقعی‌اند. embed شدن font یک heuristic شمارشی است و درست درآوردنش به یک اصلاح مهم نیاز داشت. بررسی اصلی از PdfCountName('/FontDescriptor')، اما هر فونت دو /FontDescriptor token contribute می‌کند، یکی reference از font dictionary و یکی /Type در خود descriptor object، پس شمارش 2N در برابر N برنامهٔ جاسازی‌شده بود و test همیشه true می‌شد. راه‌حل PdfCountDescriptorRefs این است که فقط /FontDescriptor N G R شکل reference را، یکی برای هر font، بشمارد و pvaiUnembeddedFont فقط وقتی بالا ببرد که برنامه‌های جاسازی‌شده واقعاً کمتر باشند:

K := PdfCountDescriptorRefs(Struct);                 // one per font dict
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

حتی پس از اصلاح هم این روش سرانگشتی است: یک document ترکیبی که در آن هر descriptor اتفاقاً یک FontFile داشته باشد، هنوز می‌تواند یک font ناسازگارِ تکی را از زیر دست رد کند. گسترش object streamها یک side effect شناخته‌شده هم دارد: default resourceهای استاندارد-14 را که یک AcroForm /DR حمل می‌کند، مثل /Helv، آشکار می‌کند و این heuristic هم با دقت آن‌ها را به‌عنوان not embedded گزارش می‌کند، هرچند veraPDF آن‌ها را می‌پذیرد چون در عمل هرگز برای render شدن استفاده نمی‌شوند. بررسی‌های در سطح operatorِ content-stream (6.2.10) کاملاً خارج از scope هستند، چون به جای یک byte scan به content parsing کامل نیاز دارند. validator را به‌عنوان یک gate اول سریع و بدون dependency در نظر بگیرید که تخلف‌هایی را می‌گیرد که marker injection نمی‌تواند درستشان کند، و validator کامل را برای certification نهایی نگه دارید

این نیمهٔ بررسیِ داستان است. سمت نوشتنِ مکمل، جایی که SaveAsPdfA XMP، OutputIntent و sRGB ICC profile را inject می‌کند و به‌صراحت یک requestِ Level A را که هیچ tagged structureای ندارد، پایین‌تر می‌آورد، بر همان machinery در سطح byte تکیه دارد. هر دو نیمه در PDFium Component for Delphi، یک بستهٔ VCL واحد روی یک پیاده‌سازی pure-Pascal از PDF/A با بدون نیاز به نصب runtime خارجی