مقاله فنی

اعتبارسنجی PDFهای فشرده: جریان‌های شیء و XRef

فرض کنید یک اعتبارسنج کوچک نوشته‌اید. فایل PDF را باز می‌کند، به انتهای آن می‌رود، startxref را پیدا می‌کند، offset را می‌خواند، و انتظار دارد روی کلیدواژه xref فرود بیاید و زیر آن یک جدول cross-reference با پهنای ثابت ببیند. از همان جدول، offsetهای اشیاء را جمع می‌کند و بعد رو به عقب می‌گردد تا کلیدواژه trailer را پیدا کند و از آن /Root و /Size را بخواند. این روال روی تمام فایل‌هایی که خودتان برای آزمایش ساخته‌اید عالی کار می‌کند. بعد ناگهان فایلی می‌رسد که نسخه جدیدی از Word یا کتابخانه‌ای با هدف PDF 1.5 آن را تولید کرده است و اعتبارسنج شما اعلام می‌کند فایل خراب است. در جایی که offset اشاره می‌کند هیچ xrefای نیست، هیچ دیکشنری trailerای هم پیدا نمی‌شود، و جدول اشیایی که اعتبارسنج ساخته تقریباً خالی است. فایل معتبر است. این اعتبارسنج است که با عدسی پانزده سال پیش دارد آن را می‌خواند

این رایج‌ترین دلیل شکست یک بررسی PDF در سطح بایت است که بر اساس چیدمان کلاسیک نوشته شده و روی سندهای مدرن اجرا می‌شود. ساختاری که به آن وابسته است، یعنی جدول cross-reference متنی و کلیدواژه trailer، در PDF 1.5 اختیاری شد و خیلی وقت‌ها اصلاً وجود ندارد. دو قابلیت جای آن را گرفتند: جریان cross-reference و جریان فشرده شیء. هر دو در ISO 32000-1 توصیف شده‌اند، و اعتبارسنجی که آن‌ها را نشناسد، یک فایل سالم را به صورت توده‌ای از اشیای گمشده می‌بیند

PDF 1.5 در انتهای فایل چه چیزی را تغییر داد

در ISO 32000-1 بخش 7.5.8 جریان cross-reference را تعریف می‌کند و بخش 7.5.7 جریان شیء از نوع /ObjStm را. این دو با هم به نویسنده فایل اجازه می‌دهند دو ساختاری را که parser کلاسیک بر آن‌ها تکیه داشت کنار بگذارد. یک فایل PDF 1.5 ممکن است اصلاً با جدول xref تمام نشود. به جای آن، شیئی که startxref به آن اشاره می‌کند یک شیء جریانی معمولی است که در دیکشنری‌اش /Type /XRef قرار دارد و همان جریان داده cross-reference را به صورت باینری فشرده نگه می‌دارد. کلیدواژه trailer هم دیگر وجود ندارد، چون trailer اکنون همان دیکشنری خود جریان است. کلیدهایی که parser کلاسیک دنبالش می‌گشت، یعنی /Root، /Size و /ID، همگی داخل همان دیکشنری قرار دارند

تغییر دوم خود اشیاء را جابه‌جا می‌کند. به جای آن‌که هر شیء غیرمستقیم در offset بایتی خودش نوشته شود، نویسنده می‌تواند تعداد زیادی از اشیای کوچک، مانند دیکشنری صفحات، دیکشنری annotationها و درخت ساختار را در یک جریان شیء واحد بسته‌بندی کند و کل ظرف را با Flate فشرده سازد. اشیای منفرد دیگر offset بایتی مستقلی در فایل ندارند. آن‌ها موقعیتی درون یک blob فشرده دارند. اعتبارسنجی که در بایت خام دنبال 1 0 obj می‌گردد هرگز آن‌ها را پیدا نمی‌کند، چون آن متن فقط بعد از inflate شدن ظاهر می‌شود. برای parser کلاسیک، انگار نیمی از سند ناگهان ناپدید شده است

کلیدهای trailer حتی در فایل فشرده هم متن ساده‌اند

بخش اطمینان‌بخش ماجرا این است که برای خواندن trailer یک جریان cross-reference نیازی به inflate کردن هیچ چیزی ندارید. یک شیء جریانی به صورت یک دیکشنری نوشته می‌شود، بعد کلیدواژه stream می‌آید و بعد بایت‌های فشرده. خود دیکشنری متن ساده است. بنابراین وقتی startxref به یک جریان cross-reference اشاره می‌کند، بایت‌هایی که بلافاصله بعد از شماره شیء می‌آیند مانند یک دیکشنری عادی به نظر می‌رسند و /Root، /Size و /ID پیش از رسیدن به کلیدواژه stream و داده Flate کاملاً روشن در همان‌جا قرار گرفته‌اند

یعنی یک اعتبارسنج می‌تواند فقط با parse کردن دیکشنری جریان، سه واقعیتی را که بیش از همه لازم دارد به دست آورد: کاتالوگ کجاست، فایل ادعا می‌کند چند شیء دارد، و شناسه فایل چیست. نه لازم است داده cross-reference را از حالت فشرده خارج کند و نه لازم است ورودی‌های باینری داخل آن را تفسیر کند. چیزی که parser ساده‌لوح را زمین می‌زند خواندن trailer نیست؛ پیدا کردن اشیاء است. این دو مسئله از هم جداشدنی‌اند و حل اولی کم‌هزینه است

جریان‌های شیء: یک header و بعد یک blob از Flate

جریان شیء یک ظرف است. دیکشنری آن شامل /Type /ObjStm است، یک ورودی /N که تعداد اشیای بسته‌بندی‌شده درون آن را می‌دهد، و یک ورودی /First که offset بایتی محل شروع بدنه نخستین شیء را در داده inflate شده مشخص می‌کند. payload فشرده، وقتی inflate شود، با یک header کوچک از /N جفت عدد صحیح آغاز می‌شود. هر جفت شامل یک شماره شیء و offset بدنه آن شیء نسبت به /First است. بعد از header، بدنه‌های اشیاء یکی پس از دیگری آمده‌اند

وقتی بایت‌ها inflate شدند، گسترش دادن این جریان یک کار مکانیکی است. دیکشنری را می‌خوانید تا /N و /First را بگیرید، جریان را با یک decoder برای Flate inflate می‌کنید، روی /N جفت نخست راه می‌روید تا معلوم شود هر شماره شیء در کدام offset قرار دارد، و بعد هر بدنه را طوری بیرون می‌کشید که انگار یک شیء غیرمستقیم عادی بوده است. تنها وابستگی واقعی همان decoder مربوط به Flate است و شما همین حالا هم یکی دارید: Delphi با System.ZLib عرضه می‌شود و Free Pascal با واحد zstream، که هر دو zlib را wrap می‌کنند و بدون هیچ کد شخص ثالثی یک جریان Flate خام را inflate می‌کنند. اگر روالی بنویسید که هر شیء استخراج‌شده را به جدول اشیای اعتبارسنج اضافه کند، بقیه اعتبارسنج، یعنی همان بخشی که /Root را پیمایش می‌کند و درخت صفحات را چک می‌کند، دقیقاً مثل حالت فایل کلاسیک رفتار خواهد کرد

چه چیزهایی را لازم نیست پیاده‌سازی کنید

خیلی راحت می‌توان حجم کار را بیش از حد برآورد کرد. خواندن کلیدهای trailer از یک فایل فشرده نیازی به decode کردن ورودی‌های باینری جریان cross-reference ندارد. جریان cross-reference در بخش 7.5.8 از سه نوع entry استفاده می‌کند و entry نوع 2، همان نوعی که می‌گوید این شیء درون جریان شیء N و در اندیس i زندگی می‌کند، چیزی است که برای ساختن یک نقشه کامل از offsetها باید decode کنید. آن نقشه را وقتی لازم دارید که بخواهید هر شیء دلخواه را بر اساس شماره‌اش resolve کنید. برای خواندن /Root، /Size و /ID که در دیکشنری متنی قرار دارند به آن نیازی ندارید، و برای گسترش جریان‌های شیء هم نیازی نیست چون هر /ObjStm با استفاده از /N و /First محتویات خودش را اعلام می‌کند

همچنین لازم نیست فقط برای به دست آوردن کلیدهای trailer، predictorهای PNG و TIFF را که ممکن است یک جریان cross-reference از طریق /DecodeParms اعمال کند پیاده‌سازی کنید. predictorها ردیف‌های باینری cross-reference را فیلتر می‌کنند تا بهتر فشرده شوند؛ هیچ ارتباطی با دیکشنری پیش از جریان ندارند. بنابراین کوچک‌ترین ارتقایی که یک اعتبارسنج کلاسیک را نسبت به PDF مدرن آگاه می‌کند خیلی کوچک است: وقتی startxref به جای کلیدواژه xref روی یک جریان فرود می‌آید، دیکشنری جریان را برای کلیدهای trailer parse کنید، و هر /ObjStmای را که می‌بینید گسترش دهید تا محتوایش وارد جدول اشیاء شود. decode کردن entryهای نوع 2 و predictorها یک کار بزرگ‌تر و مستقل است که می‌توانید آن را تا زمانی که واقعاً به resolve تصادفی اشیاء نیاز پیدا نکرده‌اید عقب بیندازید

چرا بررسی انطباق باید اول جریان‌ها را گسترش دهد

به محض آن‌که یک بررسی profile را اجرا کنید، این موضوع دیگر نظری نمی‌ماند. یک اعتبارسنج PDF/A یا PDF/X اشیای مشخصی را بازبینی می‌کند: کاتالوگ سند را برای آرایه /OutputIntents، جریان /Metadata را برای یک بسته XMP با شناسه درست، هر توصیفگر فونت را برای فایل فونتِ جاسازی‌شده، و trailer را برای /ID. در یک فایل فشرده، بیشتر این اشیاء داخل جریان‌های شیء هستند. اعتبارسنجی که جریان‌های شیء را گسترش نداده باشد نمی‌تواند کلیدهای کاتالوگ را ببیند، متادیتا را پیدا کند، یا فونت‌ها را فهرست کند. در نتیجه یک سند کاملاً منطبق را به عنوان سندی گزارش می‌کند که output intent ندارد، XMP ندارد، و نیمی از ساختارش گم شده است، فقط چون شواهد مورد نیازش هنوز در blob مربوط به Flate مانده و او هرگز آن را inflate نکرده است

ترتیب اهمیت دارد. گسترش باید پیش از اجرای بررسی‌ها رخ دهد، نه هم‌زمان با آن‌ها، چون هر بررسی فرض می‌کند می‌تواند به یک شیء بر اساس شماره‌اش برسد. اگر یک بررسی profile را مستقیم روی اسکن بایت خام سوار کنید، همان نابینایی parser کلاسیک را به ارث می‌برد و روی همان فایل‌های مدرنی که بیش از همه احتمال دارد درست‌ساخت باشند، false violation تولید می‌کند، چون آن فایل‌ها از toolchainهایی آمده‌اند که به اندازه کافی جدید بوده‌اند تا از همان ابتدا جریان cross-reference بنویسند

سپردن parse کردن به PDFium

PDFium Component در فرآیند بارگذاری سند، جریان‌های cross-reference و جریان‌های شیء را parse می‌کند و این عملی‌ترین راه برای پرهیز از دست‌نویس کردن مرحله inflate و expand است. وقتی یک فایل را با مؤلفه TPdf بار می‌کنید، اشیایی که در ظرف‌های /ObjStm بسته‌بندی شده‌اند از پیش resolve شده‌اند و نقاط ورود اعتبارسنج سند کاملاً گسترش‌یافته را می‌بینند. ValidatePdfA یک رکورد TPdfAValidationResult برمی‌گرداند که فیلد Conformance آن یک مقدار از نوع TPdfAConformance مانند pac1b یا pacNone است، فیلد Issues آن مجموعه‌ای از مشکلات مشخص کشف‌شده را نگه می‌دارد، و متد IsCompliant آن فقط وقتی true است که هم یک سطح انطباق تشخیص داده شده باشد و هم مجموعه مشکلات خالی باشد. چون اشیاء هنگام بارگذاری گسترش یافته‌اند، یک آرایه /OutputIntents یا یک فونت جاسازی‌شده که داخل جریان شیء بوده پیدا می‌شود، نه این‌که به عنوان مورد مفقود گزارش شود

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

همین منطق برای ValidatePdfX هم برقرار است که یک TPdfXValidationResult با همان شکل بازمی‌گرداند. نکته اساسی در مسیر دادن کار به PDFium این است که رفع فشردگی ساختاریِ توضیح‌داده‌شده در بالا یک‌بار و آن هم درون loader به درستی انجام می‌شود، بنابراین کد اعتبارسنج شما هرگز تفاوت بین فایل کلاسیک و فایل کاملاً فشرده را نمی‌بیند. هر دو به صورت یک مجموعه اشیای parse شده به اعتبارسنج می‌رسند

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', Ord(R.Conformance))
    else
      Writeln('Not conformant; issue count = ', SizeOf(R.Issues));
  finally
    Pdf.Free;
  end;
end;

اگر بایت‌ها از قبل در حافظه باشند و نه روی دیسک، همان توالی بارگذاری و سپس اعتبارسنجی از طریق overload مربوط به LoadDocument(const Data: TBytes) هم کار می‌کند. این overload محتوای خام فایل را می‌گیرد و جریان‌های cross-reference و شیء آن را دقیقاً مانند مسیر مبتنی بر نام فایل parse می‌کند. نکته‌ای که باید از این بحث برای یک اعتبارسنج دست‌نویس بردارید خودِ قاعده ساختاری است، نه API: کلیدهای trailer را به صورت متن ساده از دیکشنری جریان بخوانید، پیش از پیمایش سند هر /ObjStmای را با یک decoder مربوط به Flate گسترش دهید، و decode کردن entryهای باینری cross-reference را یک کار بزرگ‌تر و اختیاری در نظر بگیرید

وقتی ساختار گسترش پیدا کرد، اعتبارسنج می‌تواند بقیه جریان کار را روی همان ساختار جلو ببرد. برای یک harness خط فرمانیِ preflight که انطباق را در مجموعه‌ای از ورودی‌های یک پوشه گزارش می‌کند، به آموزش ما درباره ساخت یک CLI گزارش preflight به صورت batch مراجعه کنید. وقتی اعتبارسنجی دروازه‌ای پیش از شکستن یک سند بزرگ به چند بخش است، تکنیک‌های مطرح‌شده در راهنمای ما برای تقسیم سندهای PDF به چند فایل به‌طور طبیعی با الگوی load-and-check نشان‌داده‌شده در اینجا جفت می‌شوند. هر دو بر سطح بارگذاری و اعتبارسنجی PDFium Component برای Delphi و C++Builder بنا شده‌اند