مقاله فنی

پیشروی متن و بازیابی clip با q/Q در رندرکنندهٔ Delphi

رندرکنندهٔ صفحه در HotPDF Delphi Component حالا متن را با حساب کردن جابه‌جایی هر گلایف در text space پیش می‌برد، یعنی tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th همان‌طور که ISO 32000-1 §9.4.4 تعریفش می‌کند، و بعد ماتریس متن را از بخش خطی‌اش با HPDFTranslateTextMatrix جلو می‌برد. بریدن به‌ازای هر فریم q ذخیره و روی Q بازیابی می‌شود، اما یک ناحیهٔ GDI فقط وقتی گرفته می‌شود که آن فریم واقعاً clip را عوض کند. هر دو fix در HotPDF 2.754.0 رسیدند و هر دو از صفحات دنیای واقعی آمدند که با کلمه‌های فروپاشیده رندر می‌شدند یا نواحی clip از Q شان نشت می‌کردند. باگ اول حسابی است که تا وقتی تولیدکننده اندازهٔ فونتش را داخل ماتریس بنویسد درست به نظر می‌رسد. دومی یک fix درستی است که تقریباً شتاب‌گیری رندر موازی را خرجمان کرد و راهی که سرعت را پس گرفتیم، اگر هر device PDF پشتیبانی‌شده از GDI می‌نویسی، ارزش دانستنش را دارد

چرا وقتی یک PDF از Tf 1 استفاده می‌کند متن به یک کلوخه فرومی‌ریزد؟

چون کد پیشروی قدیمی یک فاصلهٔ text space را مستقیم به مؤلفهٔ انتقال Tm اضافه می‌کرد، انگار که text space و user space همیشه مقیاس یکسان دارند. بسیاری از تولیدکننده‌های واقعی اندازهٔ فونت را با Tf روی 1 می‌گذارند و اندازهٔ واقعی را در ماتریس متن حمل می‌کنند. با /F1 1 Tf و 12 0 0 12 72 700 Tm، یک گلایف 500 واحدی در text space به اندازهٔ 0.5 پیش می‌رود که وقتی Tm مقیاسش می‌کند روی صفحه 6 نقطه می‌شود. رندرکنندهٔ قدیمی Tm.e := Tm.e + Adv را اجرا می‌کرد و قلم را 0.5 نقطه جلو می‌برد. هر گلایف یک‌دوازدهم نویسه بعد از قبلی می‌نشست، پس یک خط متن بدنه به‌شکل یک لکهٔ تیره در حاشیهٔ چپ رندر می‌شد در حالی که همان فایل در هر viewer دیگری بی‌نقص به نظر می‌رسید

چرا متن زیر Tf 1 در رندرکنندهٔ HotPDF فرومی‌پاشد: با 12 0 0 12 72 700 Tm یک گلایف 500 واحدی باید 0.5 واحد در text space پیش برود که Tm آن را به 6 نقطه مقیاس می‌کند، در حالی که کد قدیمی 0.5 را مستقیم به Tm.e اضافه می‌کرد و یک خط متن بدنه را به‌شکل لکه‌ای به اندازهٔ یک‌دوازدهم نویسه به‌ازای هر گلایف رندر می‌کرد
تولیدکننده‌هایی که اندازهٔ فونت را در ماتریس متن کدگذاری می‌کنند باعث می‌شدند هر گلایف یک‌دوازدهم نویسه بعد از قبلی بنشیند، نقصی که روی خروجی خود کتابخانه نامرئی بود
// content stream از تولیدکننده‌ای که اندازه را در Tm کدگذاری می‌کند نه Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// پیشروی قدیمی (ساده‌شده): فاصله به Tm.e اضافه می‌شد انگار user space است
Adv := W * FontSize / 1000;                  // 0.5 برای گلایفی 500 واحدی
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th فقط روی عرض
Adv := Adv + CharSpace;                      // Tc با Th مقیاس نمی‌شود
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw اشتباهاً با Tfs مقیاس شده
Tm.e := Tm.e + Adv;                          // از Tm.a و Tm.b و Tm.c و Tm.d چشم‌پوشی می‌کند

// تنظیم TJ قدیمی: بدون Th و باز هم فقط Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

میان‌بُر Tm.e تنها نقص آن بلوک نبود. فاصله‌گذاری کلمه یعنی Tw در واحدهای text space بدون مقیاس بیان می‌شود، اما کد قدیمی آن را در FontSize / 1000 ضرب می‌کرد، پس زیر Tf 12 یک خط justify شده تقریباً همهٔ فاصلهٔ بین کلمه‌هایش را از دست می‌داد. مقیاس‌بندی افقی Th روی عرض گلایف اعمال می‌شد اما نه روی Tc یا Tw، و تنظیم kerning در TJ اصلاً از آن عبور نمی‌کرد. مسیر بدون-رسم که متن نامرئی در حالت رندر 3 — همان جنس لایه‌های متنی OCR — را پیش می‌برد و متن داخل optional content پنهان، یک کپی خصوصی از همان حساب داشت، پس هر چیزی که بعد از یک اجرای نامرئی رسم می‌شد از موقعیت غلط شروع می‌شد. باگ‌های وضعیت متن در یک رندرکننده به‌ندرت بلند شکست می‌خورند: مثل باگ‌های ایندکس عملوند و نام منبع که روزی Tc و Tw و Tz را بدون حتی یک خطا صفر می‌کردند، این‌ها صفحات قابل‌باور روی خروجی خود کتابخانه تولید می‌کردند و فقط روی فایل‌های تولیدکننده‌های دیگر می‌شکستند

ISO 32000-1 §9.4.4 پیشروی گلایف را چطور تعریف می‌کند؟

ISO 32000-1 §9.4.4 پیشروی را کاملاً در text space تعریف می‌کند و آن را به‌عنوان یک ماتریس انتقال روی ماتریس متن اعمال می‌کند، پس جواب این است که اول tx را حساب کنی و بگذاری Tm مقیاس و چرخش و skew را انجام بدهد. برای نوشتار افقی، tx برابر ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th است، که در آن w0 عرض گلایف در هزارم‌های em است، Tj تنظیم TJ است و Th برابر Tz تقسیم بر 100. Tm جدید می‌شود [1 0 0 1 tx 0] × Tm، که در HotPDF همان هلپر HPDFTranslateTextMatrix است: X و Y را از طریق ضرایب ماتریس a و b و c و d اضافه می‌کند به‌جای آنکه مستقیم در e و f بنویسد. طبق §9.3.3، Tw فقط روی کد نویسهٔ تک‌بایتی 32 اعمال می‌شود، پس کدهای CID چندبایتی هرگز روی مسیر افقی فاصله‌گذاری کلمه نمی‌گیرند. همان هلپر حالا Td و TD و T* و operatorهای ' و " و تنظیم‌های TJ و مسیر متن پنهان را می‌راند، یعنی یک تابع واحد مالک قاعده است

پیشروی گلایف طبق ISO 32000-1 9.4.4 در رندرکنندهٔ HotPDF: tx در text space از w0 و Tj و Tfs و Tc و Tw و Th حساب می‌شود و بعد از طریق HPDFTranslateTextMatrix اعمال می‌شود تا جابه‌جایی از ضرایب ماتریس a و b و c و d عبور کند و Td و TD و TJ و مسیر متن پنهان یک قاعده مشترک داشته باشند
اضافه کردن پیشروی مستقیم به Tm.e فقط وقتی کار می‌کند که text space با user space برابر باشد؛ فرستادنش از ضرایب ماتریس، متن مقیاس‌شده و چرخیده و کج را درست نگه می‌دارد
procedure HPDFTranslateTextMatrix(var Matrix: THPDFAffineMatrix; X, Y: Double);
begin
  Matrix.e := Matrix.e + Matrix.a * X + Matrix.c * Y;
  Matrix.f := Matrix.f + Matrix.b * X + Matrix.d * Y;
end;

// پیشروی گلایف افقی، ISO 32000-1 9.4.4
W   := HPDFFontCharWidth(F, Code);
Adv := W * State.Text.FontSize / 1000 + State.Text.CharSpace;
if (Code = 32) and not F.CID2Byte then
  Adv := Adv + State.Text.WordSpace;         // Tw در text space، بدون مقیاس
Adv := Adv * State.Text.HorizScale / 100;    // Th روی کل مجموع اعمال می‌شود
HPDFTranslateTextMatrix(Tm, Adv, 0);

// عنصر عددی TJ: همان فضا، همان Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

جای‌گذاری گلایف هم باید همان منطق را دنبال می‌کرد. وقتی خطوط دور embed شده در دسترس نیست و رندرکننده به TextOutW مربوط به GDI برمی‌گردد، حالا ماتریس کامل گلایف را از CTM × Tm × rise × مقیاس em می‌سازد، شامل Th، و آن را با SetWorldTransform در حالت GM_ADVANCED داخل یک جفت SaveDC / RestoreDC نصب می‌کند. فونت GDI با ارتفاع ثابت 1000 واحدی ساخته می‌شود و ترنسفورم اندازه‌گیری را انجام می‌دهد، پس متن چرخیده و کج جهتش را نگه می‌دارد به‌جای آنکه ایستاده در یک نقطهٔ مبدأ ترنسفورم‌شده رسم شود. حالت نوشتار عمودی یک عدم‌تقارن عمدی است: فونت WMode 1 روی محور y با متریک عمودی‌اش پیش می‌رود و مقیاس‌بندی افقی روی آن محور اعمال نمی‌شود

q/Q واقعاً چه چیزی را در وضعیت گرافیک PDF ذخیره می‌کند؟

ISO 32000-1 §8.4.2 مسیر برش جاری را بخشی از وضعیت گرافیک فهرست می‌کند، پس Q باید clip را دقیقاً همان‌طور که در q متناظر بود بازیابی کند، نه فقط پارامترهای عددی. HotPDF از قبل پشتهٔ وضعیت گرافیک با CTM و رنگ‌ها و پارامترهای خط و وضعیت متن را نگه می‌داشت، اما GDI clip را در device context نگه می‌دارد، بیرون از آن پشته. پس یک کپی از وضعیت عددی همه چیز را بازیابی می‌کرد جز clip، و clipی که با W n داخل یک بلوک q ... Q نصب می‌شد به بریدن هر عملیات بعدی روی صفحه ادامه می‌داد. Form XObjectها مسیر دومی به همان شکست اضافه کردند، چون §8.10 به فرم یک save و restore ضمنی دور محتوایش می‌دهد و محتوای فرم دنیای واقعی گاهی operatorهای q خودش را نامتوازن رها می‌کند هرچند spec می‌خواهد جفت شوند. رندرکننده حالا قبل از اجرای یک فرم CaptureClipBeforeChange و SaveDC را صدا می‌زند و بعد از تمام شدن فرم هر ناحیهٔ ذخیره‌شدهٔ عمیق‌تر از عمق ورود را دور می‌ریزد و RestoreDC را صدا می‌زند، پس هر HRGN ذخیره‌شده دقیقاً یک مسیر آزادسازی دارد

گرفتن تنبل clip با THPDFSavedClipState

fixی که عرضه شد به‌ازای هر q یک رکورد THPDFSavedClipState ذخیره می‌کند اما بخش پرهزینه را تا اولین باری که فریم clip را عوض می‌کند عقب می‌اندازد. رکورد handle ناحیه و عمق پشته‌ای که به آن تعلق دارد و device contextی که از آن گرفته شده و یک فلگ Captured را نگه می‌دارد. DevPushState فقط عمق و DC را پر می‌کند و آرایهٔ فریم را با دو برابر کردن از 16 بزرگ می‌کند، پس یک content stream پر از q 1 0 0 1 x y cm ... Q هیچ object GDI ای تخصیص نمی‌دهد. operatorهایی که می‌خواهند برش را عوض کنند، یعنی رسم مسیر با یک W یا W* معلق، operator n، پر کردن الگو و ورود به فرم، اول CaptureClipBeforeChange را صدا می‌زنند

گرفتن تنبل clip در GDI در رندرکنندهٔ HotPDF: DevPushState به‌ازای هر q فقط عمق و DC را ثبت می‌کند، CaptureClipBeforeChange ناحیه را درست قبل از اینکه W یا n یا ورود به فرم برش را عوض کند می‌خواند، DevPopState آن را روی Q بازیابی و حذف می‌کند و نسخهٔ مشتاقی که به‌ازای هر q می‌گرفت توان‌عملیاتی موازی را به حدود 1.15 برابر تک‌نخی می‌برد
ساختن یک ناحیهٔ GDI به‌ازای هر q threadهای رندر را گرسنگی می‌داد، پس گرفتن حالا فقط وقتی اتفاق می‌افتد که یک operator بخواهد برش را عوض کند و گیت شتاب‌گیری 1.5 برابری دوباره قبول می‌شود
procedure THPDFPageRenderer.CaptureClipBeforeChange;
var
  Index, ClipResult: Integer;
  Region: HRGN;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index < 0) or FSavedClips[Index].Captured or
     (FSavedClips[Index].StackDepth <> FGSStack.Count) or
     (FSavedClips[Index].DC <> FDC) then Exit;   // از قبل ذخیره شده یا مال ما نیست
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 یعنی اصلاً clip وجود ندارد
  if ClipResult <= 0 then
  begin
    DeleteObject(Region);
    Region := 0;
    if ClipResult < 0 then RaiseLastOSError;
  end;
  FSavedClips[Index].Region := Region;
  FSavedClips[Index].Captured := True;
end;

procedure THPDFPageRenderer.DevPopState;
var
  Index: Integer;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index >= 0) and (FSavedClips[Index].StackDepth = FGSStack.Count) then
  begin
    if FSavedClips[Index].Captured and (FSavedClips[Index].DC = FDC) then
      SelectClipRgn(FDC, FSavedClips[Index].Region);  // Region برابر 0 برش را حذف می‌کند
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

هزینهٔ اندازه‌گیری‌شدهٔ نسخهٔ مشتاق دلیل وجود این طراحی است. اولین پیاده‌سازی درست به‌ازای هر q یک ناحیهٔ GDI می‌ساخت و می‌خواند و در صفحه‌هایی که بیشترشان ترنسفورم‌های عددی بودند، threadهای رندر وقتشان را صرف رقابت سر objectهای ناحیهٔ GDI می‌کردند به‌جای رستر کردن. پایپ‌لاین رندر موازی از سود انتظاری‌اش به حدود 1.13 تا 1.20 برابر توان‌عملیاتی تک‌نخی افت کرد و گیت شتاب 1.5 برابری در سویت بنچمارک را رد نکرد. با گرفتن تنبل و ظرفیت فریم بازیافت‌شده، همان بنچمارک گیت اصلی 1.5 برابری را دوباره قبول می‌شود. antialiasing گلایف کوچک TrueType در همان انتشار رسید و مظنون بدیهی بود، اما رگرسیون به تخصیص ناحیه برمی‌گشت، که یادآور خوبی است که قبل از سرزنش تازه‌ترین قابلیت، اندازه بگیر

مرزهای این رویکرد کجاست؟

clip ذخیره‌شده یک ناحیهٔ GDI در پیکسل‌های device است، پس برای بیت‌مپی که رندر می‌شود دقیق و برای هر مقصد دیگری بی‌معناست. به همین دلیل هر فریم device context خودش را ثبت می‌کند و DevPopState وقتی DC عوض شده بازیابی را رد می‌کند، مثلاً وقتی یک گروه شفافیت در بیت‌مپ لایهٔ خودش رندر می‌شود. برگرداندن صفر از GetClipRgn نتیجه‌ای مشروع است به معنای نبود clip و بازیابی آن با SelectClipRgn(FDC, 0) همان کاری است که درست، یک clipی که در q متناظر وجود نداشت را حذف می‌کند. در سمت متن، fix این را درست می‌کند که هر گلایف کجا می‌رود اما عرض نمی‌سازد: اگر فونتی آرایهٔ /Widths اش را جا انداخته باشد و برنامهٔ embed شده در دسترس نباشد، پیشروی همچنان فقط به اندازهٔ fallback عرض خوب است. وقتی این حوزه را رگرسیون‌تست می‌کنی، دست‌کم یک fixture با Tf 1 و یک Tm مقیاس‌شده، یکی با Tz و Tw غیرصفر، و یکی با clipی داخل q ... Q و محتوایی بیرون آن نگه دار، چون هیچ‌کدام در اسنادی که خود کتابخانه تولید می‌کند ظاهر نمی‌شوند

اگر رندرکننده را از کد اپلیکیشن می‌رانی، در الگوی فراخوانی که در رندر کردن یک صفحهٔ PDF به بیت‌مپ توصیف شد هیچ چیز عوض نمی‌شود و صفحه‌هایی که قبلاً خطوط لکه‌دار یا محتوای بریده نشان می‌دادند روی 2.754.0 و بعدش به‌سادگی درست رندر می‌شوند. جزئیات مربوط به کامپوننت و نسخه‌های پشتیبانی‌شدهٔ Delphi و C++Builder و لایسنس در صفحهٔ محصول HotPDF Delphi PDF Component است