مقاله فنی

رندرکننده PDF چیزی رسم نمی‌کند: چهار باگ خاموش در Delphi

رندرکننده‌ی PDF که چیزی رسم نمی‌کند معمولاً هیچ باگی در کد رسمش ندارد. در مؤلفه HotPDF برای Delphi و C++Builder، چهار نقص جداگانه باعث می‌شد صفحات خالی رندر شوند در حالی که هر خط لاگ تمیز می‌ماند: عملوندهای نام با یک اسلش پیشرو، یک ترکیب معکوس cm، و یک نمایه توکن که صفر می‌خواند. هیچ‌کدام خطا پرتاب نکردند. هیچ‌کدام لاگ نگرفتند. جریان محتوا درست توکن‌بندی شد، دیسپچر عملگر هر عملگر را تشخیص داد، XObject تصویر به یک بیت‌مپ معتبر رمزگشایی شد، و سپس صفحه خالی درآمد. این ترکیب — پایپ‌لاینی که در هر مرحله موفقیت گزارش می‌کند و هیچ‌چیز مرئی تولید نمی‌کند — امضای یک جست‌وجو یا نمایه‌ای است که بی‌صدا خطا می‌کند نه اینکه شکست بخورد. این تشریح پس از مرگ یک چنین خانواده‌ای است، و همچنین نظم آزمونی که اجازه داد ۳۸ نسخه دوام بیاورد

چرا رندرکننده PDF اصلاً چیزی رسم نمی‌کند؟

چون یک جست‌وجوی منبع ناموفق در یک رندرکننده PDF از یک صفحه خالی غیرقابل‌تمایز است. عملوندهای نام در جریان محتوا و کلیدهای دیکشنری منبع دو فضای رشته‌ای متفاوت‌اند، و HotPDF بدون نرمال‌سازی میان آن‌ها مقایسه می‌کرد. توکن‌ساز /Im0 را می‌خواند و اسلش را نگه می‌دارد، چون توکن همین است؛ دیکشنری بارگذاری‌شده /Resources /XObject کلید را به‌صورت Im0 ذخیره می‌کند، چون تجزیه‌گر هنگام ساخت کلیدهای دیکشنری، جداکننده را حذف می‌کند. بنابراین هر FindValue علیه یک نام عملوند مقدار -1 برمی‌گرداند. شعاع انفجار وسیع‌تر از تصاویر بود. ISO 32000-1 §8.9 مربوط به Do، §8.4 مربوط به gs و جست‌وجوی /ExtGState آن، §8.6 مربوط به cs و CS، و §8.7.4.3 مربوط به sh است. هر پنج عملگر زیردیکشنری منبع خودشان را با عملوند خام کلیدگذاری می‌کردند، پس هر پنج‌تا خطا می‌کردند. فضاهای رنگ نام‌دار به DeviceGray بازمی‌گشتند، که 1 scn را به جوهر سفید روی صفحه سفید تبدیل می‌کرد. XObjectهای تصویر هرگز اصلاً رسم نمی‌شدند — مسیر تصویر بیت‌مپ در عمل از روز اول هرگز کار نکرده بود. رفع مشکل یک تابع کمکی سطح واحد است که در هر جست‌وجوی کلیدگذاری‌شده با عملوند اعمال می‌شود، که تنها راهی است که مانع دوباره منحرف‌شدن قرارداد می‌شود

// Page content stream, the ordinary image-placement idiom:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.

function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
  Result := N;
  if (Result <> '') and (Result[1] = '/') then
    Delete(Result, 1, 1);
end;

// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
  FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
  Exit;

یک خطای دوم و مرتبط یک لایه پایین‌تر بود. رندرکننده فقط برای جریان‌ها و دیکشنری‌ها ریزالورهای تایپ‌شده داشت، پس یک ارجاع غیرمستقیم که به یک آبجکت آرایه سطح‌بالا اشاره می‌کرد — همان /CS0 5 0 R رایج با [/Separation ...] در سوی دیگر — از هر دو مسیر به nil ریزالو می‌شد و به لینک حل‌نشده بازمی‌گشت. افزودن یک ریزالور آبجکت عمومی فضاهای رنگ نام‌دار و آرایه‌های تابع را در یک حرکت رفع کرد. اگر در حال سیم‌کشی دیکشنری‌های شیدینگ هستید، همان نظم ریزالوسیون در مسیر شیدینگ محوری و شعاعی اعمال می‌شود، جایی که مدخل /Function بسیار غالباً غیرمستقیم است

عملگر cm و ترکیبی که برعکس نوشته شده بود

نقص دوم تصاویر را حدود صدهزار پیکسل بیرون از صفحه قرار می‌داد، که دقیقاً شبیه رسم‌نکردن آن‌هاست. ISO 32000-1 §8.3.4 تبدیل‌های PDF را با بردارهای سطری تعریف می‌کند، و عملگر cm ماتریس عملوندش M را روی ماتریس تبدیل جاری به‌صورت M × CTM ترکیب می‌کند — M ابتدا اثر می‌گذارد، سپس CTM موجود. HotPDF ماتریس‌ها را از راه HPDFMatMul(A, B) ترکیب می‌کند، که B را پیش از A اعمال می‌کند. بنابراین فراخوانی درست CTM قدیم را به‌عنوان A منتقل می‌کند. کد ارسال‌شده ماتریس عملوند را به‌عنوان A منتقل می‌کرد، که CTM × M تولید می‌کرد

ترتیب معکوس برای یک cm منفرد بی‌ضرر است و برای اصطلاح دومرحله‌ای استاندارد فاجعه‌بار. یک تصویر را با 1 0 0 1 x y cm و به‌دنبالش w 0 0 h 0 0 cm قرار دهید و آبشار درست مربع واحد را با (w, h) مقیاس می‌کند و سپس آن را با (x, y) جابه‌جا می‌کند. تحت آبشار معکوس، انتقال ابتدا وارد می‌شود و مقیاس آن را ضرب می‌کند، پس تصویری که اسماً در (60, 400) با مقیاس 200 در 120 است در (12000, 48000) فرود می‌آید. آزمون بریدگی در بالای بلیت آن را رد می‌کند، بلیت رد می‌شود، و هیچ‌جا مشکلی گزارش نمی‌شود

// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                    NumAt(3), NumAt(2), NumAt(1)), GS.CTM);

// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                            NumAt(3), NumAt(2), NumAt(1)));

آنچه این مورد را آموزنده می‌کند این است که همان فایل منبع از قبل ترتیب درست را داشت. مدخل /Matrix یک Form XObject همان ترکیب معکوس را داشت، اما مسیر گلیف Type 3 و مسیر خطوط گلیف جاسازی‌شده هر دو از ابتدا درست بودند، چون جای‌گذاری گلیف وقتی معکوس شود به‌طور مرئی به مبدأ فرومی‌ریزد و کسی از قبل مجبور شده بود آن را رفع کند. دو قرارداد برای سه دهه در یک واحد کنار هم زیستند، هرکدام در تابع خودشان درست، و هیچ بازبینی‌کننده‌ای متوجه نشد چون هیچ نقطه فراخوانی به‌تنهایی اشتباه به‌نظر نمی‌رسید

وقتی یک نمایه توکن یک واحد اشتباه باشد چه اتفاقی می‌افتد؟

دوازده عملگر به دست می‌آورید که به‌لحاظ نحوی پردازش می‌شوند و به‌لحاظ معنایی مرده‌اند. دسترسی‌گر عملوند در رندرکننده NumAt(Back) است، که Tokens[OpIndex - Back] را می‌خواند، و OpIndex نمایه خود توکن عملگر است. بنابراین یک عملگر تک‌عملوندی عدد خودش را در back 1 می‌یابد. دوازده‌تا به‌صورت NumAt(0) نوشته شده بودند، که توکن عملگر را می‌خواند، بررسی نوع ctOperandNumber را رد می‌کند، و مقدار پیش‌فرض صفر را برمی‌گرداند. فهرست شامل Tc، Tw، Tz، TL، Ts و Tr از عملگرهای وضعیت متن ISO 32000-1 §9.3، به‌همراه w، J، j، M، ri و i از عملگرهای وضعیت گرافیکی §8.4.3 است. فاصله‌گذاری کاراکتر و کلمه به عملیات‌های بی‌اثر تبدیل شدند، مقیاس‌بندی افقی هرگز اعمال نشد، فاصله خطی صفر ماند پس T* هرگز خط را جلو نبرد، برجستگی متن هیچ کاری نکرد، حالت رندر همیشه پر بود، و هر ضربه قلم در هر سند به‌صورت خط‌مویی یک‌پیکسلی درمی‌آمد صرف‌نظر از عرض خط اعلان‌شده. عملگرهای چندعملوندی مانند m، rg و Tm از NumAt(1..6) استفاده می‌کردند و همه درست بودند، پس بازبینی‌کننده‌ای که تابع را اسکن می‌کرد دیواری از حساب‌گری نمایه‌ای باورپذیر می‌دید که دوازده مدخل اشتباه در آن نهفته بود

function NumAt(Back: Integer): Double;
begin
  Result := 0;
  if (OpIndex - Back >= 0)
    and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
    Result := Tokens[OpIndex - Back].NumValue;
end;

// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1)   // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading   := NumAt(1)   // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w'  then GS.LineWidth      := NumAt(1)   // previously NumAt(0)

چرا مجموعه تست برای ۳۸ نسخه سبز ماند؟

چون ادعاها آن‌قدر ضعیف بودند که نتوانند یک صفحه رندرشده را از یک صفحه نیمه‌رندرشده تشخیص دهند. تست‌های دود رندرینگ چیزهایی مثل «بیت‌مپ خروجی کاملاً سیاه نیست» یا «صفحه خالی نیست» یا «دایجست تصویر غیرصفر است» را ادعا می‌کردند. هرکدام از این‌ها وقتی متن رندر شود و تصاویر نه، برقرار است. متن خوب رسم می‌شد، پس بافر فریم هرگز یکنواخت نبود، دایجست هرگز صفر نبود، و مجموعه تست موفقیت گزارش می‌کرد در حالی که کل پایپ‌لاین تصویر در عمل کد مرده بود. ادعاهای ضعیف دقیقاً برای گرافیک وسوسه‌انگیزند. هیچ‌کس تستی نمی‌خواهد که وقتی یک لبه ضدآلیاسینگ یک پیکسل جابه‌جا می‌شود شکست بخورد، پس عقب‌نشینی طبیعی این است که چیزی را ادعا کنید که هیچ تغییر معقولی نمی‌تواند نقضش کند — و آن عقب‌نشینی شما را روی ادعاهایی می‌نشاند که هیچ تغییر نامعقولی هم نمی‌تواند نقضشان کند. یک تست فضای رنگ Separation ادعا می‌کرد خروجی از سیاه قابل‌تمایز است؛ خاکستری روی سفید آن را قبول می‌کرد، و همین‌طور سفید روی سفید. تست این را نمی‌سنجید که آیا رنگ درست رسم شده یا نه. این می‌سنجید که آیا اصلاً روی بوم اتفاقی افتاده یا نه

چگونه یک ادعای رندرینگ بنویسید که واقعاً شکست بخورد؟

پیکسل‌های رنگ موردانتظار را با مقدار موردانتظار بشمارید، و بگذارید موقعیت و اندازه از آن شمارش بیرون بیایند. نظم جایگزین یک PDF مینیمال دست‌ساز است، یک واقعیت بصری برای هر فایل، و یک ادعا درباره این‌که چند پیکسل با تلورانسی از یک سه‌گانه RGB مشخص فرود می‌آیند. یک تصویر 200 در 120 قرمز خالص که در آفستی معلوم قرار گرفته باید حدود 24000 پیکسل قرمز تولید کند. اگر جست‌وجوی منبع خطا کند، شمارش صفر است. اگر آبشار cm معکوس باشد، شمارش صفر است. اگر تصویر در فضای رنگ اشتباه رندر شود، شمارش صفر است. یک عدد هر سه‌تا را می‌گیرد، و باند تلورانس نویز ضدآلیاسینگ را جذب می‌کند که ابتدا مردم را از مقایسه دقیق منصرف می‌کرد

function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
  X, Y: Integer;
  C: TColor;
begin
  Result := 0;
  for Y := 0 to Bmp.Height - 1 do
    for X := 0 to Bmp.Width - 1 do
    begin
      C := Bmp.Canvas.Pixels[X, Y];
      if (Abs(GetRValue(C) - R) <= Tol)
        and (Abs(GetGValue(C) - G) <= Tol)
        and (Abs(GetBValue(C) - B) <= Tol) then
        Inc(Result);
    end;
end;

// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
  'image XObject was never drawn');

چهار تست دود به این شکل بازنویسی شد — یک تبدیل رنگ Type 4، یک قرارگیری Do تصویر، یک مورد دیده‌پذیری محتوای اختیاری، و یک حالت ضربه‌قلم Tr — و در کنار هم کل خانواده را آشکار کردند. این درس واقعی است، و فراتر از این پایگاه کد تعمیم می‌یابد: در یک پایپ‌لاین رندرینگ، ادعا باید رنگ را نام ببرد. هرچیز نرم‌تر از این فقط تأیید می‌کند رندرکننده اجرا شده است، نه اینکه چیزی رسم کرده. اگر در حال ساختن هارنس صفحه-به-بیت‌مپ خودتان هستید، راهنمای رستریزاسیون صفحه جای طبیعی برای پیچاندن یک کمک‌کننده شمارش پیکسل به اولین رگرسیون شماست

مرزهای صادقانه

دو محدودیت ارزش گفتن صریح دارند. حالت‌های رندر بریدگی متن ۴ تا ۷ به‌عنوان حالت پرکردن یا ضربه‌قلم پایه‌شان رسم می‌شوند، چون رندرکننده مسیرهای بریدگی انباشته از خطوط گلیف را مدل نمی‌کند؛ اسنادی که به بریدگی شکل‌گرفته از متن متکی‌اند متن را رسم می‌کنند نه اثر هنری بریده‌شده زیرش. و نظم شمارش پیکسل که در اینجا شرح داده شد یک تکنیک تست دود است، نه یک مجموعه انطباق — این ثابت می‌کند که یک واقعیت بصری مشخص به بافر فریم رسیده، که استاندارد بسیار پایین‌تری از اثبات تطابق خروجی با یک رستریزکننده مرجع است. هرچند، این دقیقاً همان استانداردی است که این چهار باگ برای سه سال انتشار نتوانستند از آن عبور کنند

رندرکننده‌ای که در اینجا بحث شد به‌عنوان بخشی از مؤلفه HotPDF استاندارد برای Delphi و C++Builder ارائه می‌شود؛ صفحه محصول مرجع کامل API رندرینگ صفحه، شامل کش بیت‌مپ و نقاط ورودی پیش‌واکشی پس‌زمینه را در بر دارد