مقاله فنی

CompressDocument در HotPDF: زیرمجموعه فونت فشرده در Delphi

THotPDF.CompressDocument در HotPDF یک کلید واحد است که کاری می‌کند BeginDoc کوچک‌ترین PDF بی‌اتلافی را تولید کند که کامپوننت قادر به نوشتنش است: FlateDecode در حداکثر سطح، یک cross-reference stream با object streamها، font subsetting و زیرمجموعه‌های فونت فشرده که گلایف‌های نگه‌داشته‌شده را پشت یک /CIDToGIDMap صریح شماره‌گذاری دوباره می‌کنند. EndDoc بعد تنظیمات خودت را سر جایشان برمی‌گرداند. یک سند آزمایشی سه‌صفحه‌ای با Arial و SimSun از 10.2 MB به 20 KB رسید با رندر کاملاً یکسان

CompressDocument واقعاً چه چیزهایی را روشن می‌کند؟

CompressDocument شش تنظیم نویسنده به‌علاوهٔ سقف object stream را به‌مدت یک سند بازنویسی می‌کند و بعد از آن همه را برمی‌گرداند. در BeginDoc، قبل از اینکه نسخهٔ PDF قطعی شود، HotPDF مقادیر تو را ثبت می‌کند و Compression را روی cmFlateDecode و CompressionLevel را روی clMaximum می‌گذارد، EnableFontSubsetting و CompactFontSubsetting را روشن می‌کند و UseXRefStream به‌همراه UseObjectStreams را فعال می‌کند (ISO 32000-1 §7.5.7 و §7.5.8). object streamها به PDF 1.5 نیاز دارند، پس یک Version قدیمی‌تر وقتی قفل نشده باشد به 1.5 بالا برده می‌شود. PDF/A-1 هر دو ساختار را ممنوع می‌کند، پس یک سند PDF/A-1 جدول cross-reference کلاسیک خودش را نگه می‌دارد و فقط کارهای Flate و فونت را می‌گیرد. تصاویر دقیقاً همان‌طور که embed کرده‌ای رها می‌شوند

نمودار چرخهٔ حیات CompressDocument در HotPDF در Delphi: BeginDoc مقادیر خود نویسنده را ثبت می‌کند، شش تنظیم از جمله Compression و UseObjectStreams را برای یک سند بازنویسی می‌کند و EndDoc هر مقدار قرض‌گرفته را در بیرونی‌ترین finally اش برمی‌گرداند، در حالی که خود property یعنی CompressDocument روی True می‌ماند
شش تنظیم نویسنده و سقف object-stream دقیقاً برای یک سند قرض گرفته می‌شوند و وقتی EndDoc اجرا می‌شود پس داده می‌شوند، پس یک گزارش ناتمام هیچ‌وقت کامپوننت را در حداکثر فشرده‌سازی گیر نمی‌اندازد
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'invoice-2026-1042.pdf';
    Pdf.CompressDocument := True;    // توسط BeginDoc اعمال و توسط EndDoc پس گرفته می‌شود
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-1042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

برگرداندن در بیرونی‌ترین finally مربوط به EndDoc اتفاق می‌افتد، پس یک exception در نیمهٔ راهِ یک گزارش، کامپوننتِ طولانی‌عمر را با فشرده‌سازی حداکثری برای کار بعدی گیر نمی‌اندازد. خود property یعنی CompressDocument روی True می‌ماند؛ فقط شش تنظیمی که قرض گرفته بود برمی‌گردند. با نسخه هم با دقت بیشتری رفتار می‌شود. HotPDF بالا بردن خودش به 1.5 را فقط وقتی برمی‌گرداند که سند هنوز روی 1.5 تمام شود، پس وقتی قابلیت دیگری در طول اجرا فایل را به 1.6 برده (مثلاً یک فونت OpenType embed شده)، نسخهٔ بالاتر می‌ماند، دقیقاً همان‌طور که بدون فشرده‌سازی می‌ماند

چرا زیرمجموعه‌های فونت بدون فشرده‌سازی همچنان بزرگ‌اند؟

یک زیرمجموعهٔ کلاسیک TrueType outlineهایی را که هرگز نمی‌کشی می‌اندازد ولی هر glyph ID را سر جای خودش نگه می‌دارد، و همین شماره‌گذاری است که سنگینش می‌کند. content stream نشان می‌دهد CIDهایی که برابر GIDهای اصلی‌اند، پس زیرمجموعه مجبور است برای هر جایگاه تا بالاترین گلایفی که نگه می‌دارد، خالی یا غیرخالی، یک offset برای loca و یک درایه برای hmtx نگه دارد. برای یک فونت لاتین این سربار نویز است. برای یک فونت CJK مثل SimSun که ایدئوگرام‌هایش عمیق در یک جدول گلایف خیلی بزرگ نشسته‌اند، دو نویسهٔ چینی جدول‌هایی را به دنبال خود می‌کشند که برای کل فونت اندازه گرفته شده‌اند. قواعد بستن زیرمجموعهٔ فونت برای گلایف‌های شکل‌یافته تصمیم می‌گیرند کدام گلایف‌ها زنده می‌مانند؛ فشرده‌سازی دربارهٔ این است که بازمانده‌ها چقدر خرج دارند

CompactFontSubsetting گلایف‌های نگه‌داشته‌شده را در یک بازهٔ متراکم شروع از صفر شماره‌گذاری دوباره می‌کند و روی CIDFont یک stream برای /CIDToGIDMap می‌نویسد، که ISO 32000-1 §9.7.4.2 آن را جدولی از GIDهای دوبایتی ایندکس‌شده با CID تعریف می‌کند. کل ترفند همین جدول است. content streamها و آرایهٔ عرض /W و CMap مربوط به ToUnicode همه CIDهای اصلی را نگه می‌دارند، پس هیچ چیز نوشته‌شده لازم نیست عوض شود؛ فقط جست‌وجو از CID به گلایف به داخل map منتقل می‌شود. در تستی که این قابلیت را برانگیخت، SimSun با دو نویسه از 24.8 KB دادهٔ فونت به 3.1 KB رسید

مقایسهٔ یک زیرمجموعهٔ فونت اسپارس در HotPDF که درایه‌های loca و hmtx را برای هر glyph ID اصلی تا بالاترین GID نگه‌داشته‌شده نگه می‌دارد، با خروجی CompactFontSubsetting که گلایف‌های نگه‌داشته‌شده را به‌شکل متراکم از صفر شماره‌گذاری دوباره می‌کند و CIDها را از طریق یک stream از نوع CIDToGIDMap نگاشت می‌کند در حالی که content streamها و /W و ToUnicode دست‌نخورده می‌مانند
شماره‌گذاری دوباره هزینه را از برنامهٔ فونت به یک map stream کوچک منتقل می‌کند؛ دو نویسهٔ SimSun از 24.8 KB به 3.1 KB رسیدند بدون اینکه بایتی از محتوای از قبل نوشته‌شده لمس شود

فشرده‌سازی مرزهای محکمی دارد و به‌جای شکست خوردن، آرام تنزل می‌کند. HotPDF زیرمجموعه‌های فشرده را فقط برای فونت‌های Type 0 از جنس TrueType می‌سازد، هم آن‌هایی که از طریق SetFont با subsetting روشن تنظیم شده‌اند و هم فونتی که از طریق RegisterUnicodeTTF ثبت شده. یک فونت سادهٔ TrueType گلایف‌هایش را از طریق cmap داخل برنامهٔ فونت پیدا می‌کند که شماره‌گذاری دوباره می‌شکندش، پس زیرمجموعهٔ اسپارس را نگه می‌دارد. فونت‌های OpenType-CFF هم مسیر فشرده ندارند. یک build فشرده که شکست بخورد به زیرمجموعهٔ اسپارس برمی‌گردد به‌جای raise کردن. property به‌صورت پیش‌فرض خاموش است، پس خروجی‌های موجود بایت‌به‌بایت یکسان می‌مانند، در حالی که زیر PDF/A فونت یونیکد ثبت‌شده همیشه یک زیرمجموعهٔ فشرده می‌گیرد

Pdf.EnableFontSubsetting := True;
Pdf.CompactFontSubsetting := True;   // بدون CompressDocument هم قابل استفاده است
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('SimSun', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, WideString('Total: '#$4E2D#$6587));
Pdf.EndDoc;

نویسندهٔ بسته‌بندی‌شده چطور ساختار فایل را می‌فشارد؟

وقتی فونت‌ها و streamها کوچک شدند، dictionaryها و دادهٔ cross-reference بزرگ‌ترین هزینهٔ باقی‌مانده می‌شوند، پس نویسندهٔ object-stream پشت CompressDocument آن‌ها را هم می‌تراشد. راهنمای object streamها و به‌روزرسانی‌های افزایشی خود فرمت کانتینر را پوشش می‌دهد؛ مسیر فشرده‌سازی چهار بهبود روی آن اضافه می‌کند:

  • سینتکس فشرده طبق ISO 32000-1 §7.2.2: فاصله فقط بین دو توکنی نوشته می‌شود که وگرنه به‌شکل نویسه‌های عادی به هم می‌چسبیدند، پس /Type /Page می‌شود /Type/Page
  • فیلدهای cross-reference stream هر عرضی را می‌گیرند که §7.5.8.2 اجازه می‌دهد، پس فایلی زیر 16 MB هر offset را در 3 بایت ذخیره می‌کند به‌جای 4
  • تا 250 شیء در هر object stream جا می‌گیرند به‌جای 100 تای معمول، مگر اینکه سقف خودت را از طریق ConfigureAdaptiveObjectStreamPacking تنظیم کنی
  • وقتی فایل رمزنگاری نشده، Catalog و dictionary مربوط به Info هم داخل object streamها بسته‌بندی می‌شوند؛ خروجی رمزنگاری‌شده آن‌ها را در سطح بالا نگه می‌دارد

سینتکس فشرده با دامی همراه بود که اگر نویسنده را گسترش بدهی ارزش دانستن دارد. امضا بعد از نوشته شدن فایل با جست‌وجوی بایت‌ها برای placeholderهای literal یعنی /ByteRange ( و /Contents < پر می‌شود، و املا فشرده آن‌ها را به /ByteRange( و /Contents< تبدیل می‌کرد که جست‌وجو هرگز پیدا نمی‌کرد. dictionaryهای امضا (Type Sig یا DocTimeStamp با FT Sig) و dictionary رمزنگاری بنابراین چیدمان فاصله‌دار را نگه می‌دارند. یک نقص مرتبط بیلدهای قبل از v2.766.41 را تحت تأثیر می‌گذاشت: هر ذخیره با object stream، از جمله CompressDocument، با دو خط هدر %PDF- شروع می‌شد، پس اگر یک validator سخت‌گیر خروجی‌ات را پرچم زد ارتقا بده

می‌شود PDF ای را که از قبل بارگذاری شده فشرده کرد؟

بله، از طریق overload گزینه‌ها یعنی CompressLoadedDocument(Options, Info) که همان مراحل بی‌اتلاف را روی یک فایل موجود اجرا می‌کند. با THPDFLoadedDocumentCompressionOptions.Default منابع بلااستفادهٔ صفحه را حذف می‌کند، فونت‌ها و فرم‌های یکسان را ادغام می‌کند، فونت‌های embed شده را با زیرمجموعه‌های فشرده زیرمجموعه‌سازی می‌کند، streamهای بدون فیلتر و Flate و LZW و ASCII و RunLength را وقتی نتیجه کوچک‌تر است با Flate دوباره فشرده می‌کند، و کاری می‌کند ذخیرهٔ بعدی از object stream استفاده کند. HighRatioFlate به‌صورت پیش‌فرض خاموش است و object streamها برای PDF/A-1 و ذخیره‌های افزایشی رد می‌شوند. overload بدون پارامتر یعنی CompressLoadedDocument فراخوانی قدیمی‌تر و محدودتر است که فقط streamهای فشرده‌نشده را با Flate فشرده می‌کند

جریان CompressLoadedDocument در HotPDF در Delphi: فراخوانی منابع بلااستفادهٔ صفحه را حذف می‌کند، فونت‌ها و فرم‌های یکسان را ادغام می‌کند، فونت‌های embed شده را با زیرمجموعه‌های فشرده زیرمجموعه می‌کند، streamها را فقط وقتی نتیجه کوچک‌تر است با Flate دوباره فشرده می‌کند و object streamها را برای ذخیرهٔ بعدی روشن می‌کند، در حالی که فیلدهای امضا باعث RefusedBySignaturePolicy می‌شوند و فایل را دست‌نخورده جا می‌گذارند
هر مرحله بایت‌هایی را بازنویسی می‌کند که یک امضا رویشان پوشش دارد، پس کل سند رد می‌شود مگر اینکه صریحاً بی‌اعتبار شدن را اجازه بدهی؛ Info.BytesSaved بعد فقط کارهای منبع و فونت و stream را جمع می‌زند
var
  Doc: THotPDF;
  Options: THPDFLoadedDocumentCompressionOptions;
  Info: THPDFLoadedDocumentCompressionInfo;
begin
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    Doc.LoadFromFile('quarterly-report.pdf');
    Options := THPDFLoadedDocumentCompressionOptions.Default;
    Doc.CompressLoadedDocument(Options, Info);
    if Info.RefusedBySignaturePolicy then
      Writeln(Format('Left untouched: %d signature fields', [Info.SignatureCount]))
    else
    begin
      Writeln(Format('Compact fonts: %d, stream bytes saved: %d',
        [Info.Fonts.CompactSubsetFontCount, Info.BytesSaved]));
      Doc.SaveLoadedDocument('quarterly-report-compact.pdf');
    end;
  finally
    Doc.Free;
  end;
end;

دو مرز روی مسیر بارگذاری‌شده مهم‌اند. هر مرحله بایت‌هایی را بازنویسی می‌کند که یک امضا پوشش‌شان می‌دهد، پس سندی که فیلد امضا دارد به‌صورت یکجا رد می‌شود: فراخوانی 0 برمی‌گرداند، RefusedBySignaturePolicy را ست می‌کند و هیچ چیزی را عوض نمی‌کند، مگر اینکه AllowSignatureInvalidation را ست کنی، بعد از آن Info.SignaturesInvalidated به تو می‌گوید چه چیزی را فدا کردی. فشرده‌سازی اینجا هم محافظه‌کارانه‌تر از مسیر تولید است. HotPDF فقط برنامه‌های فونتی را فشرده می‌کند که منحصراً توسط فونت‌های CIDFontType2 با /CIDToGIDMap از نوع Identity استفاده شده‌اند، جایی که CID برابر GID است، و برنامه‌هایی که map stream موجود یا یک /CIDSet یا جدول‌های گلایف رنگی مثل COLR و sbix و CBDT و SVG دارند را رد می‌کند، چون بازسازی فشرده لایه‌های رنگی را می‌انداخت. دقت کن که Info.BytesSaved فقط مراحل منبع و فونت و stream را جمع می‌زند؛ سود object-stream موقع نوشتن فایل خودش را نشان می‌دهد

در عمل چه نتایجی باید انتظار داشته باشی؟

سودها دنبال این می‌روند که چه سهمی از فایل ساختار فشرده‌نشده و دادهٔ فونت بزرگ‌تر از اندازه است، نه اینکه چند صفحه دارد. نمونهٔ سه‌صفحه‌ای Arial و SimSun وقتی با CompressDocument تولید شد از 10.2 MB به 20 KB کوچک شد و وقتی نسخهٔ اصلی فشرده‌نشده بارگذاری و از CompressLoadedDocument گذرانده شد از 10.2 MB به 19.8 KB، هر دو با رندر یکسان. PDF ای که از قبل فشرده به‌سختی تکان می‌خورد: در مجموعهٔ رگرسیون، چنین فایل‌هایی در بازهٔ -0.07% تا +0.06% اندازهٔ اصلی‌شان ذخیره شدند. فایل‌های پر از عکس سود کمی می‌برند، چون هیچ‌کدام از دو مسیر دادهٔ تصویر را لمس نمی‌کنند

اگر هر شب همان گزارش‌های CJK را تولید می‌کنی، زیرمجموعه‌های فشرده را با کش دائمی زیرمجموعهٔ فونت روی دیسک جفت کن تا کار subsetting در هر اجرا تکرار نشود، و خروجی‌های فشرده را بر اساس محتوای شیء diff کن نه بر اساس بایت، چون یک فیلد عوض‌شده یک object stream کامل را دوباره Flate می‌کند. مرجع کامل propertyها و recordها در صفحهٔ محصول HotPDF Delphi PDF component است