مقاله فنی

اعضای تنبل object stream و بازنویسی کامل PDF در Delphi

وقتی HotPDF Delphi Component یک فایل PDF 1.5 را با LoadFromFile بارگذاری می‌کند، objectهای بسته‌بندی‌شده داخل ظرف‌های /Type /ObjStm را parse نمی‌کند. جای زندگی هر عضو فشرده را ثبت می‌کند و فقط وقتی چیزی سراغش را بگیرد parse‌اش می‌کند. همین ثابت تنبل است که زمان بارگذاری را متناسب با چیزی نگه می‌دارد که واقعاً دستش می‌زنی و همین هم دلیل آن است که یک بازنویسی کامل پیش از بیرون رفتن هر byte ای باید یک کار اضافه بکند: هر عضوی که هنوز parse نشده بسط بدهد، چون بازنویسی دارد همان ظرف‌هایی را دور می‌ریزد که آن اعضا در آن‌ها زندگی می‌کنند

نشانه‌ای که این یادداشت را اجباری کرد، توصیفش آسان و اشکال‌زدایی‌اش ناخوشایند است. فایلی را بارگذاری کن که فونت‌ها و فضاهای رنگ و درخت ساختارش در object streamها نشسته‌اند، آن را از جفت generation یعنی BeginDoc و EndDoc بگذران، و خروجی بی‌شکایت باز می‌شود. تعداد صفحه درست است، متن روی صفحه‌هایی که سرسری چک می‌کنی دیده می‌شود. بعد همکارت صفحهٔ 40 را باز می‌کند و متن بدنه با یک فونت جانشین رندر می‌شود، یا فرمان Extract Text جایی که قبلاً یک جانشین ActualText بود garbage برمی‌گرداند. هیچ‌چیز کرش نکرد. نویسنده صرفاً شیئی را سریالایز کرد که هرگز بارگذاری نشده بود و یک شیء بارگذاری‌نشده به‌صورت هیچ‌چیز سریالایز می‌شود

LoadFromFile برای یک object فشرده واقعاً چه چیزی نگه می‌دارد؟

برای هر درایهٔ cross-reference از نوع 2، LoadFromFile یک رکورد کوچک در FCompactObjects نگه می‌دارد: شمارهٔ شیء، شاخص stream میزبان در جدول ظرف‌ها، موقعیت عضو داخل آن stream، و یک اشاره‌گر ParsedObject که با nil شروع می‌شود. خود ظرف پیدا می‌شود، اگر سند رمزنگاری‌شده باشد رمزگشایی می‌شود و inflate می‌شود، اما بدنهٔ اعضا به‌صورت byte رها می‌شود. ISO 32000-1 §7.5.7 چیدمان ظرف را تعریف می‌کند که همین را ممکن می‌کند: یک هدر از جفت‌های شمارهٔ شیء و offset، و بعد از /First بدنهٔ اعضا به هم چسبیده، پس هر عضو را می‌توان بدون دست زدن به همسایه‌هایش برش داد

EnsureCompressedObjectLoaded تنها مسیری است که یک رکورد را به شیء تبدیل می‌کند. رکورد را با شمارهٔ شیء پیدا می‌کند و اگر ParsedObject از قبل مقدار گرفته باشد همان شیء cache‌شده را برمی‌گرداند و یک cache hit می‌شمارد. در غیر این صورت اگر ظرف بیرون انداخته شده بود دوباره بارگذاری‌اش می‌کند، بازهٔ byte عضو را از جدول offset حساب می‌کند، به parser یک نمای zero-copy از آن برش می‌دهد و نتیجه را در رکورد ذخیره می‌کند. از آن به بعد شیء غیرمستقیم است، شمارهٔ شیء واقعی‌اش را حمل می‌کند و مثل هر شیئی که از بدنهٔ فایل parse شده باشد در شاخص objectهای سند ثبت می‌شود. catalog و dictionary اطلاعات و root درخت صفحه و خود صفحه‌ها هنگام بارگذاری از همین مسیر می‌گذرند چون ناوبری به آن‌ها نیاز دارد. فونت‌ها و فضاهای رنگ و dictionaryهای ExtGState و عناصر ساختار این‌طور نیستند و تا وقتی یک رندر صفحه یا یک بازنویسی به آن‌ها دست بزند به‌صورت رکورد می‌مانند

نحوهٔ نگه داشتن یک عضو فشرده در HotPDF Delphi Component پیش از parse شدنش: رکورد FCompactObjects شمارهٔ شیء و شاخص ظرف و شاخص عضو و یک اشاره‌گر ParsedObject تهی را نگه می‌دارد، در حالی که EnsureCompressedObjectLoaded یک رکورد را از طریق cache hit و بارگذاری دوبارهٔ ظرف و برش با جدول offset و parse بی‌کپی به یک شیء ثبت‌شده تبدیل می‌کند
LoadFromFile بدنهٔ اعضای /ObjStm را به‌صورت byte رها می‌کند و فقط وقتی خواننده‌ای سراغشان را بگیرد parse می‌کند، پس زمان بارگذاری با آنچه دست می‌زنی حرکت می‌کند — catalog و درخت صفحه زود می‌آیند در حالی که فونت‌ها و فضاهای رنگ و عناصر ساختار به‌صورت رکورد می‌مانند

می‌توانی این را از بیرون تماشا کنی. GetLoadedObjectStreamCacheInfo گزارش می‌دهد چند ظرف وجود دارد، چند عضو فهرست شده و چند تای آن‌ها تا حالا parse شده‌اند:

var
  Pdf: THotPDF;
  Info: THPDFObjectStreamCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('tagged-report.pdf');
    if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
      Writeln(Format('%d containers, %d members indexed, %d parsed so far',
        [Info.ContainerCount, Info.IndexedObjectCount,
         Info.MaterializedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

روی یک فایل سنگین از نظر ساختار، عدد سوم بلافاصله بعد از بارگذاری کسر کوچکی از عدد دوم است. همین شکاف تمام نکتهٔ بارگذاری تنبل است و همین مجموعه دقیقاً همان objectهایی است که یک بازنویسی کامل باید برگردد سراغشان

چرا بازنویسی کامل فونت‌هایی را می‌اندازد که یک ذخیرهٔ افزایشی نگه می‌دارد؟

یک بازنویسی کامل ظرف‌های /ObjStm و /XRef فایل مبدأ را دور می‌ریزد و گراف شیء را از صفر سریالایز می‌کند، پس هر عضوی که ParsedObject اش هنوز nil باشد دیگر نماینده‌ای در خروجی ندارد. یک به‌روزرسانی افزایشی هرگز این مشکل را ندارد، چون objectهای جدید را بعد از byteهای اصلی ضمیمه می‌کند و ظرف‌های قدیمی را سر جای خودشان رها می‌کند تا بخش cross-reference قبلی به آن‌ها آدرس بدهد. تفاوت در نحوهٔ برخورد این دو حالت با فونت‌ها نیست. در این است که آیا ظرف‌های اصلی زنده می‌مانند تا viewer بعدی بخواندشان یا نه

fix در SaveToStream نشسته، همان سریالایزری که EndDoc چه FileName را ست کنی و چه OutputStream به راه می‌اندازد. پیش از آنکه به هر شاخهٔ نویسنده اعزام کند، FCompactObjects را می‌پیماید و روی هر درایه EnsureCompressedObjectLoaded را صدا می‌زند. اگر عضوی بارگذاری نشود، ذخیره raise می‌کند به‌جای ادامه دادن، چون بازنویسی‌ای که بی‌صدا یک dictionary فونت را می‌اندازد از بازنویسی‌ای که می‌ایستد بدتر است. این بسط باید در همان سطح بنشیند، بالای شاخه‌های classic و packed و linearized و بالای هرس کردن streamهای ساختاری بارگذاری‌شده در مسیر linearized. نسخهٔ قبلی اعضا را فقط داخل SaveLoadedDocument بسط می‌داد، که واژگان سند-بارگذاری‌شده را پوشش می‌داد و واژگان generation را یکسره جا می‌انداخت. مسیر LoadFromFile و بعد BeginDoc و ویرایش‌های صفحه و EndDoc مستقیم به نویسنده می‌رفت با هر عضو دست‌نخورده‌ای که هنوز parse نشده بود

جای بسط بازنویسی کامل در HotPDF: SaveToStream پیش از اعزام به نویسندهٔ classic یا packed یا linearized هر درایهٔ FCompactObjects را از EnsureCompressedObjectLoaded می‌گذراند، پس هم واژگان SaveLoadedDocument و هم واژگان LoadFromFile به‌علاوهٔ BeginDoc و EndDoc objectهای کاملاً parse‌شده را سریالایز می‌کنند نه رکوردهای nil
یک به‌روزرسانی افزایشی بعد از byteهای اصلی ضمیمه می‌کند و ظرف‌های قدیمی را خواندنی نگه می‌دارد، اما یک بازنویسی کامل دورشان می‌ریزد — یک پاس بسط بالای هر شاخهٔ نویسنده همان چیزی است که نمی‌گذارد فونت یا عنصر ساختار بارگذاری‌نشده به‌صورت هیچ‌چیز سریالایز شود
// هر دو واژگان بازنویسی حالا اعضای compact را پیش از اجرای هر نویسنده‌ای بسط می‌دهند.
// مسیر سند بارگذاری‌شده:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// مسیر generation روی یک فایل بارگذاری‌شده:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc;   // اول SaveToStream هر درایهٔ FCompactObjects را مادی می‌کند

اعضای cache‌شده هر چه با آن‌ها کرده‌ای را نگه می‌دارند. شیئی که پیش از ذخیره parse و ویرایش و کثیف علامت خورده باشد با ویرایش‌هایش از cache برگردانده می‌شود و عضوی که پاکش کرده‌ای حالت پاک‌شدنش را در ذخیره‌های پشت‌سرهم نگه می‌دارد. پاس بسط بنا به ساختار خودتوان است: فقط خانه‌های nil را پر می‌کند

چرا بررسی پیکسلی روی سه صفحه مورد ActualText را نمی‌بیند

عناصر ساختار همان جایی هستند که این باگ بیشتر از همه پنهان می‌ماند. یک درایهٔ ActualText روی یک توالی محتوای علامت‌خورده، که در ISO 32000-1 §14.9.4 تعریف شده، glyphها را برای استخراج و دسترس‌پذیری جانشین می‌کند اما روی رندر اثری ندارد. اگر آن عنصر ساختار در یک object stream زندگی کند و بازنویسی از دستش بدهد، صفحه هنوز درست رسم می‌شود، اولین و وسطی و آخرین صفحه پیکسل‌به‌پیکسل با مبدأ مقایسه می‌شوند و رگرسیون فقط وقتی رو می‌شود که کسی استخراج متن یا یک screen reader را اجرا کند. تست بازنویسی‌ای که فقط صفحه‌ها را رندر می‌کند برای PDF تگ‌دار تست بازنویسی نیست. متن استخراج‌شده و درخت ساختار را هم diff کن

یک رمز عبور کاربر خالی چگونه بارگذاری را عوض می‌کند؟

رمز عبور کاربر خالی باز هم به معنای رمزنگاری‌شده بودن فایل است و object streamها در چنین فایلی تا وقتی کلید فایل بازیابی نشود ciphertext اند. ISO 32000-1 §7.6.3.4 الگوریتم 2 آن کلید را از رمز عبور و درایهٔ /O و /P و اولین شناسهٔ سند مشتق می‌کند و HotPDF باید آن را روی رشتهٔ خالی اجرا کند پیش از آنکه پاس نوع-2 بتواند یک ظرف را inflate کند. همین است که BeginDoc روی یک سند رمزنگاری‌شدهٔ بارگذاری‌شده پیش از هر چیز DecryptLoadedDocument را با یک رمز عبور خالی صدا می‌زند: گراف شیء باید پیش از آنکه بازنویسی بتواند شروع شود احراز هویت و رمزگشایی شود، بی‌توجه به اینکه caller قصد محافظت از خروجی را دارد یا نه. رمزنگاری خروجی تصمیمی جداست که تنظیمات محافظت caller آن را هدایت می‌کند و BeginDoc آن تنظیمات را بعد از پاس رمزگشایی برمی‌گرداند تا یک ورودی رمزنگاری‌شده بی‌صدا به یک خروجی رمزنگاری‌شده تبدیل نشود

سیاست ظرف‌ها پیش از امتحان شدن هر رمز عبوری از dictionary /Encrypt خوانده می‌شود. برای /V برابر 1 و 2 هر stream با کلید فایل رمزنگاری می‌شود. برای crypt filterها، HotPDF مقدار /StmF را از طریق /CF resolve می‌کند: یک filter از نوع Identity یا یک /CFM برابر None یعنی ظرف‌های متن‌روشن، در حالی که V2 و AESV2 یعنی رمزنگاری‌شده. پاسخ در FReloadObjectStreamsEncrypted می‌نشیند و برای یک مورد خاص مهم است. وقتی ظرف‌ها متن‌روشن باشند اما رشته‌ها نباشند، اعضا رشته‌های رمزنگاری‌شده‌ای حمل می‌کنند که باید یکی‌یکی رمزگشایی شوند، پس MaterializeMembersOfPlaintextObjectStreams هر عضو compact را پیش از پاس رمزگشایی به‌ازای-شیء بسط می‌دهد. وقتی سیاست هنوز معلوم نباشد هیچ کاری نمی‌کند و وقتی خود ظرف‌ها رمزنگاری‌شده بودند هم هیچ کاری نمی‌کند، چون اعضای یک ظرف رمزنگاری‌شده از قبل با آن رمزگشایی شده‌اند و هرگز نباید دو بار رمزگشایی شوند

وقتی یک ظرف رمزگشایی نمی‌شود چه اتفاقی می‌افتد؟

ظرفی که در رمزگشایی شکست بخورد قرنطینه می‌شود، نه اینکه کشنده باشد. پاس نوع-2 یک درایهٔ THPDFObjStmQuarantineInfo در FObjStmQuarantine ثبت می‌کند با شمارهٔ شیء ظرف، یک THPDFObjStmQuarantineReason، یک رشتهٔ تشخیصی و فهرست شماره‌های شیء اعضایی که cross-reference به آن ظرف هدایتشان کرده بود. دلیل osqrDecryptFailed برای چهار وضعیت متمایز مطرح می‌شود: هیچ crypt filterی resolve نشد، رمزگشایی AES-256 یا AES-GCM استثنا داد، رمزگشایی قدیمی RC4 یا AES-128 استثنا داد، یا اصلاً کلید فایل قابل‌استفاده‌ای وجود ندارد. ظرف‌های مستقل به بارگذاری ادامه می‌دهند، پس سندی با یک ظرف آسیب‌دیده باز هم باز می‌شود و هر صفحه‌ای که به آن ظرف وابسته نیست را هم رندر می‌کند

نحوهٔ کار قرنطینهٔ رمزگشایی در HotPDF روی یک PDF بارگذاری‌شده: ظرفی که رمزگشایی‌اش استثنا می‌دهد به‌صورت یک THPDFObjStmQuarantineInfo با دلیل osqrDecryptFailed و شماره‌های شیء اعضایش ثبت می‌شود، ظرف‌های مستقل به بارگذاری ادامه می‌دهند و BeginDoc پیش از آنکه بازنویسی بتواند موفقیت گزارش کند روی اولین درایهٔ ناموفق raise می‌کند
رکوردهای قرنطینه از fallback مربوط به parser جان سالم به در می‌برند و BeginDoc آن‌ها را با نام بررسی می‌کند نه با پرچم رمزنگاری، پس سندی با یک ظرف آسیب‌دیده باز هم باز می‌شود در حالی که مسیر بازنویسی به‌جای نوشتن objectهای خالی می‌ایستد

فهرست قرنطینه از fallback مربوط به parser جان سالم به در می‌برد. اگر بارگذاری اولیهٔ cross-reference شکست بخورد و HotPDF جدول objectها را با اسکن فایل از نو بسازد، ممکن است پرچم رمزنگاری از آن تلاش اول در آن بازسازی دوام نیاورد، اما رکوردهای قرنطینه دوام می‌آورند. همین است که BeginDoc فهرست قرنطینه را بررسی می‌کند نه پرچم رمزنگاری را: روی یک سند بارگذاری‌شده FObjStmQuarantine را می‌پیماید و روی اولین درایهٔ osqrDecryptFailed raise می‌کند، ظرف را نام می‌برد و درخواست بارگذاری مجدد با یک رمز عبور معتبر می‌کند. بازنویسی‌ای که از آن نقطه بگذرد، اعضایی را که آن ظرف قرار بود نگه دارد به‌صورت objectهای خالی می‌نویسد و موفقیت گزارش می‌کند. خودت هم می‌توانی همین بررسی را زودتر و با سیاست خودت از طریق اکسسورهای عمومی اجرا کنی:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // رمز عبور کاربر خالی
  for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
    if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
       (Info.Reason = osqrDecryptFailed) then
      raise Exception.CreateFmt(
        'Object stream %d is unreadable (%s); %d members unresolved',
        [Info.ContainerObjNum, String(Info.Diagnostic),
         Length(Info.MemberObjNums)]);
  // از اینجا بازنویسی ایمن است
end;

بقیهٔ دلایل قرنطینه شکست‌های غیررمزنگاری‌ای را پوشش می‌دهند: ظرفی که stream نیست، یک dictionary غایب، یک /N یا /First نامعتبر، اندازهٔ streamی بیرون از بازهٔ پذیرفته‌شده، شکست در دی‌کامپرس، یک /First که به بعد از داده اشاره می‌کند، یا بدنهٔ عضوی که decode شد اما parse نشد. ارزش دارد این‌ها هنگام ingest لاگ شوند، چون هر یک دقیقاً همان اعضایی را نام می‌برد که در ادامهٔ کار کم خواهی داشت

چرا یک بازنویسی به توکن عددی اصلی نیاز دارد؟

HotPDF هر شیء عددی را به‌صورت یک Single ذخیره می‌کند و یک Single نمی‌تواند متن مبدأ یک عدد حقیقی را بازتولید کند. ISO 32000-1 §7.3.3 اجازه می‌دهد یک نویسنده برای همان مقدار 0.750000 یا .75 یا 0.75 بیرون بدهد و هیچ‌کدام از این‌ها از یک رفت‌وبرگشت از میان 24 بیت دودویی و یک formatter عمومی دست‌نخورده بیرون نمی‌آید. بدتر، مقداری مثل 0.7 اصلاً در Single قابل‌نمایش نیست؛ به نزدیک‌ترین float parse می‌شود و قالب‌بندی دوبارهٔ آن float بسته به حلقهٔ ارقام می‌تواند 0.69999999 یا همسایهٔ گردشده‌اش را تولید کند. روی یک رنگ پرکننده یا یک ثابت شفافیت /CA، این یک واحد اختلاف در یک کانال 8 بیتی است، که برای رد شدن از یک مقایسهٔ پیکسلی با مبدأ کافی است و روی مرزهای gradient هم برای دیده شدن

THPDFNumericObject.RememberSourceToken این را برای حالت ویرایش‌نشده حل می‌کند. parser بلافاصله بعد از نسبت دادن Value آن را با توکن خام صدا می‌زند؛ این متد فقط توکن‌هایی را می‌پذیرد که از رقم ساخته شده‌اند، حداکثر یک نقطهٔ اعشار دارند و یک علامت ابتدایی اختیاری، و توکن را همراه مقداری که به آن مربوط بود در FSourceValue ذخیره می‌کند. پراپرتی SourceToken متن ذخیره‌شده را فقط تا وقتی Value هنوز با FSourceValue برابر است برمی‌گرداند. عدد را عوض کن و توکن بخار می‌شود، پس یک مقدار ویرایش‌شده همیشه از مسیر قالب‌بندی موجود می‌گذرد و هرگز متن کهنه بیرون نمی‌دهد. SaveNumericObject اول SourceToken را بررسی می‌کند و اگر موجود باشد مو‌به‌مو می‌نویسدش، و بعد فقط برای عددهایی که در حافظه ساخته یا ویرایش شده‌اند به شاخه‌های عدد صحیح و ارجاع فضای رنگ و کسری می‌افتد

این ثابت کوچک است و ارزش دارد ساده گفته شود: عددی که دستش نزده‌ای با همان byteهایی نوشته می‌شود که با آن‌ها خوانده شده و عددی که دستش زده‌ای با formatter خودِ HotPDF نوشته می‌شود. اعضای compact هم مثل objectهای بدنه از این بهره می‌برند، چون EnsureCompressedObjectLoaded همان parser را روی برش عضو اجرا می‌کند. خود قالب‌بندی عدد و استقلالش از locale پروسه در مقالهٔ قالب‌بندی عدد PDF مستقل از locale در HotPDF پوشش داده شده است

آزمودن یک مسیر بازنویسی در برابر object streamها

سه بررسی هر شکستی که بالا توصیف شد را می‌گیرند و هیچ‌کدام به Acrobat نیاز ندارند. اول، بعد از ذخیره IndexedObjectCount را با MaterializedObjectCount مقایسه کن؛ روی یک بازنویسی کامل باید برابر باشند و هر شکافی یعنی عضوی که انداخته شده. دوم، متن را استخراج کن و درخت ساختار را روی هر دو فایل فهرست کن، نه اینکه فقط رندرشان کنی، تا یک ActualText از‌دست‌رفته یا یک عنصر ساختار از‌دست‌رفته به‌صورت diff رو شود. سوم، خروجی را با یک نمونهٔ تازه بارگذاری کن و assertion کن که GetLoadedQuarantinedObjStmCount صفر است، که این هم اثبات می‌کند نویسنده ظرفی تولید نکرده که خواننده نتواند بازش کند. ترکیب‌های crypt filter که FReloadObjectStreamsEncrypted را تعیین می‌کنند در مقالهٔ سیاست‌های StmF و StrF و EFF چیده شده‌اند. سمت نویسندهٔ این داستان، یعنی اینکه چگونه object stream تولید کنیم و کِی یک به‌روزرسانی افزایشی را به بازنویسی ترجیح بدهیم، در راهنمای object streamها و به‌روزرسانی‌های افزایشی آمده است

بارگذاری تنبل اعضا و پاس بسط پیش-نویسنده و قرنطینهٔ رمزگشایی و حفظ توکن مبدأ همه در HotPDF Delphi Component برای Delphi و C++Builder عرضه می‌شوند. اگر می‌خواهی GetLoadedObjectStreamCacheInfo و اکسسورهای قرنطینه را در برابر خط لولهٔ ingest خودت ردگیری کنی، صفحهٔ محصول لینک مرجع API را دارد