مقاله فنی

اندازه‌گیری متن PDF برای چیدمان (Layout) و شکستن خطوط (Word Wrap) در دلفی

فراخوانی‌ای که متن را روی صفحه PDF قرار می‌دهد سرراست است. شما به AddText یک رشته (string)، یک فونت، یک اندازه، و یک موقعیت می‌دهید، و گلیف‌ها (glyphs) ظاهر می‌شوند. اما کاری که انجام نمی‌دهد این است که به شما بگوید پس از ترسیم شدن این رشته، عرض آن چقدر خواهد بود، و همچنین یک رشته طولانی را به چندین خط نمی‌شکند (word wrap). یک فراخوانی تکی، یک رشته متصل (run) از متن را در یک موقعیت نقاشی می‌کند. اگر این رشته عریض‌تر از ستونی باشد که قصد داشتید در آن جای بگیرد، به سادگی از لبه آن بیرون می‌زند، و هیچ‌چیز در فراخوانیِ ترسیم به شما هشدار نمی‌دهد. لحظه‌ای که به جای یک برچسب تکی به یک پاراگراف نیاز پیدا می‌کنید، قطعه گمشده همان عرضِ رشته در فونت و اندازه انتخاب‌شده است، که باید قبل از متعهد شدن (commit) به رسم آن روی صفحه، اندازه‌گیری شود

این یک مشکل کلاسیک در چیدمان (layout) است. برای جا دادن یک پاراگراف در یک ستون، باید کلمه به کلمه بدانید که هر خط کاندیدا چقدر فضای افقی اشغال خواهد کرد، و باید این را پیش از ترسیم هر چیزی بدانید. شکستن خطوط (word wrap) یک حلقه اندازه‌گیری است که حول یک فراخوانیِ ترسیم پیچیده شده است، و بایندینگی که فقط کار ترسیم را انجام می‌دهد تنها نیمه دوم کار را به شما می‌سپارد. قابلیت اندازه‌گیری متن در کامپوننت PDFium این شکاف را با دو تابع MeasureText و MeasureTextWidth پر می‌کند، توابعی که وسعتِ رندرشدهِ یک رشته را بدون قرار دادن هیچ اثری روی صفحه، گزارش می‌دهند

چرا اندازه‌گیری یک کلاس هلپر (class helper) است و نه یک متد جدید در TPdf

قابلیت اندازه‌گیری به عنوان یک کلاس هلپرِ دلفی برای TPdf در یک یونیتِ مجزای خود قرار دارد، و نه به عنوان متدهای جدیدی که به کلاس TPdf پیچ شده باشند. کلاس هلپر یک ویژگیِ زبان برنامه‌نویسی است که به شما اجازه می‌دهد متدها را از بیرونِ اعلان (declaration) یک نوعِ موجود، به آن متصل کنید. زمانی که این یونیت وارد حوزه دید (scope) شود، متدهای جدید دقیقاً به گونه‌ای فراخوانی می‌شوند که گویی متعلق به همان کلاس هستند، بنابراین یک متد هلپر به شکل Pdf.MeasureTextWidth(...) خوانده می‌شود، بدون آنکه نیاز باشد شیء جداگانه‌ای ساخته شده یا به اطراف پاس داده شود

دلیل لایه‌بندی به این شکل، مسئله تفکیک (separation) است. نوعِ هسته‌ایِ TPdf همان‌گونه که هست باقی می‌ماند، هیچ فیلدی به آن اضافه نمی‌شود و هیچ امضای (signature) موجودی تغییر نمی‌کند، بنابراین پروژه‌ای که هرگز نیازی به چیدمان ندارد، کد اندازه‌گیری را نیز هرگز با خود حمل نمی‌کند. پروژه‌ای که به آن نیاز دارد، یک یونیت به عبارت uses خود اضافه می‌کند و متدها روشن می‌شوند. این قابلیت، به صورت اختیاری (opt-in) در سطحِ دانه‌بندی (granularity) یک یونیتِ تکی درمی‌آید، که تمیزترین راه برای گسترشِ نوعی است که شما مالک آن نیستید یا نمی‌خواهید تغییری در آن ایجاد کنید

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // یونیت هلپر؛ که MeasureText را به روی TPdf وارد میدان دید می‌کند

// زمانی که یونیت در میدان دید باشد، متدها به عنوان اعضای TPdf خوانده می‌شوند:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // اکنون W و H برابر عرض و ارتفاع رندرشده در واحدهای کاربر (user units)ِ PDF هستند
end;

اندازه‌گیری بدون دست زدن به صفحه

اندازه‌گیری باید عاری از عوارض جانبی (side effects) باشد. باید عرض را گزارش دهد بدون آنکه چیزی از خود به جای بگذارد، زیرا شما هنگام تصمیم‌گیری در مورد یک چیدمان، آن را بارها فراخوانی می‌کنید و صفحه باید دقیقاً همان‌گونه به نظر برسد که اگر هرگز اندازه‌گیری نمی‌کردید به نظر می‌رسید. تکنیکی که این امر را ممکن می‌سازد، ساختن یک شیء متنی، پرسیدنِ اندازه آن، و دور انداختنش پیش از آن است که به صفحه‌ای متصل شود

این توالی از چهار فراخوانیِ PDFium تشکیل شده است. FPDFPageObj_NewTextObj با دریافت نام و اندازه فونت، یک شیء متنی در برابر سند ایجاد می‌کند. FPDFText_SetText رشته‌ای را که آن شیء با خود حمل می‌کند، تنظیم می‌نماید. FPDFPageObj_GetBounds جعبه محصورکننده (bounding box) شیء را پس می‌خواند. FPDFPageObj_Destroy شیء را آزاد می‌کند. از همه مهم‌تر اینکه در هیچ‌کجای این توالی، APIای مربوط به درج-در-صفحه (page-insertion) فراخوانی نمی‌شود. این شیء به صورت منزوی ایجاد، کوئری و نابود می‌شود، بنابراین زمانی که تابع بازمی‌گردد، سند بدون تغییر باقی می‌ماند. این یک کاوشگرِ دورانداختنی (throwaway probe) است که تنها خروجی‌اش چهار عددِ مربوط به جعبه محصورکننده آن است

این یک راهِ مقاوم برای انجام کار است زیرا PDFium یک عرض پیشروی (advance width) مناسب برای هر گلیف در اختیار شما قرار نمی‌دهد تا بتوانید خودتان آن را جمع ببندید. معیارهای مربوط به گلیف‌ها (Glyph metrics) به برنامه فونت، به رمزگذاری (encoding)، و به نحوه بارگذاری فیس (face) توسط PDFium بستگی دارند، و هیچ فراخوانی عمومی‌ای وجود ندارد که میزانِ پیشرویِ هر کاراکتر در یک رشته را در اختیار شما قرار دهد. از سوی دیگر، جعبه محصورکننده‌ی یک شیء متنی واقعی، توسط همان ماشینی محاسبه می‌شود که گلیف‌ها را برای ترسیم در کنار هم می‌چیند، بنابراین بازتاب‌دهنده‌ی وسعتِ واقعیِ رندرشده است نه یک تقریب. ساختن یک شیء یک‌بارمصرف و خواندن مرزهای آن، قابل‌اعتمادترین اندازه‌گیری‌ای است که این کتابخانه می‌تواند به شما بدهد

// نمای کلی MeasureText، بیان‌شده از طریق فراخوانی‌های تاییدشده PDFium.
// یک شیء متنی ساخته، اندازه‌گیری، و تخریب می‌شود؛ هیچ صفحه‌ای درگیر نمی‌شود.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // کاوشگر دور انداخته شد، صفحه دست‌نخورده ماند
  end;
end;

مختصات و واحدهای نتیجه

جعبه محصورکننده در قالب چهار لبه یعنی چپ، پایین، راست، و بالا بازمی‌گردد، و دو بعدِ اصلی از طریق تفریق به دست می‌آیند. عرض برابر است با راست منهای چپ و ارتفاع برابر است با بالا منهای پایین. هر دو در قالب واحدهای کاربر در PDF (PDF user units) بیان می‌شوند، جایی که یک واحد معادل یک هفتاد و دوم از یک اینچ است، یعنی دقیقاً همان فضای مختصاتی که شما متن را در آن روی صفحه قرار می‌دهید. در این مرحله هیچ واحد پنهان مربوط به دستگاه و هیچ پیکسلی درگیر نیست. عرضی برابر با 36 به معنای نیم اینچ از صفحه است، فارغ از اینکه وضوح (resolution) نهاییِ رندر کردن چقدر باشد

محور عمودی در همان جهتی حرکت می‌کند که PDF آن را تعریف کرده است، به طوری که Y به سمت بالا افزایش می‌یابد، و به همین دلیل است که ارتفاع برابر است با بالا منهای پایین، نه برعکس. این جزئیات زمانی اهمیت پیدا می‌کند که شما در حال پیش بردن یک نشانگر (cursor) به سمت پایینِ یک ستون هستید. شما ارتفاع یک خط را اندازه می‌گیرید، سپس برای یافتن خط بعدی، آن را از خط پایه (baseline) فعلی کم می‌کنید، زیرا حرکت به سمت پایین صفحه به معنای حرکت به سوی مقادیرِ کوچکترِ Y است. اگر مقصد شما به جای کاغذ یک صفحه‌نمایش باشد، واحدهای کاربر را با توجه به وضوح نمایشگر به پیکسل‌های دستگاه تبدیل می‌کنید: یک مقدار به واحد کاربر ضربدر DPI و تقسیم بر 72 پیکسل را به شما می‌دهد، بنابراین می‌توانید پیش از تصمیم‌گیری درباره محل شکستن خطوط، عرض یک ستون را که به پوینت (points) تنظیم کرده‌اید با رشته‌ی اندازه‌گیری‌شده مطابقت دهید

چه اتفاقی برای ورودی‌های نامعتبر (degenerate input) می‌افتد

این توابع به گونه‌ای نوشته شده‌اند که در سکوت شکست بخورند (fail quietly). اگر سندی باز نباشد، یا اگر نتوان شیء متنی را ایجاد کرد، نتیجه به جای آنکه یک استثنا (exception) صادر کند، یک وسعت صفر خواهد بود. عرض و ارتفاع در ابتدا صفر تنظیم می‌شوند و تنها زمانی بازنویسی می‌گردند که یک جعبه محصورکننده با موفقیت پس خوانده شده باشد. یک رشته خالی، یک سند ناموجود، فونتی که کتابخانه نتواند آن را به یک شیء تبدیل (resolve) کند، هر یک از این‌ها به جای صدورِ خطا، صفر برمی‌گردانند

این انتخاب یک حلقه اندازه‌گیری را ساده نگه می‌دارد، زیرا حلقه‌ای که روی هزاران کلمه اجرا می‌شود، جایی برای مدیریت استثناها (exception handling) در هر تکرارِ خود نیست. هزینه این کار بر دوش فراخوان‌دهنده است که باید این بررسی را انجام دهد. عرضِ صفر یک نشانگر (sentinel) است، نه واقعیتی درباره آن متن، بنابراین کدی که عرضی را تقسیم می‌کند یا فرض را بر مقدار مثبت می‌گذارد، باید پیش از اعتماد کردن به آن در برابر صفر گارد بگیرد. با صفر به عنوان "اندازه‌گیری ناموفق بود" برخورد کنید و قرارداد روشن است؛ آن را نادیده بگیرید و یک ورودی نامعتبر در سکوت تبدیل به چیدمانی با یک ستون از گلیف‌های روی هم افتاده (overlapping) می‌شود

یک کلمه‌شکن (word wrap) حریصانه مبتنی بر اندازه‌گیری

با در دست داشتن یک تابعِ عرض، شکستن کلمات تبدیل به یک حلقه کوتاه حریصانه (greedy loop) می‌شود. شما پاراگراف را به کلمات تقسیم می‌کنید، یک خط فعلی را نگه می‌دارید، و برای هر کلمه اندازه می‌گیرید که اگر آن کلمه را به خط الحاق کنید، خط به چه شکل درمی‌آید. تا زمانی که خط آزمایشی همچنان در عرض ستون جا بگیرد به افزودن کلمات ادامه می‌دهید؛ و زمانی که می‌خواهد سرریز (overflow) شود، خط فعلی را با AddText ثبت کرده و یک خط جدید با کلمه‌ای که جا نشده بود شروع می‌کنید. انباشت کلمات کاملاً با MeasureTextWidth انجام می‌شود، و تنها چیزی که به صفحه می‌رسد خطی است که از قبل جا گرفتنِ آن را تایید کرده‌اید

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<WideString>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := WideString(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // خط کاندیدا را پیش از رسم هر چیزی اندازه بگیرید.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(X, Y, Font, FontSize, Line);   // خطی که جا شده بود را ثبت کنید
      Y    := Y - LineHeight;                    // Y با حرکت به پایین کاهش می‌یابد
      Line := Words[I];                          // کلمه سرریز شده خط بعدی را شروع می‌کند
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(X, Y, Font, FontSize, Line);      // خط نهایی را ثبت کنید
end;

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

جایگاه این قابلیت

اندازه‌گیری، لایه‌ای است بین تولید محتوا و رندر کردن آن، بنابراین به طور طبیعی با بقیه قسمت‌های یک گردش کارِ (workflow) ساخت سند از پایه همخوانی دارد. اگر در وهله اول در حال کنار هم قرار دادن صفحات و چیدمان متن هستید، پایه‌ریزیِ آن در مقاله ایجاد اسناد PDF از پایه با کامپوننت PDFium در دلفی، جایی که AddText و تنظیمات صفحه به طور کامل پوشش داده شده‌اند، بیان شده است. زمانی که فونتی که در حال اندازه‌گیریِ آن هستید به اندازه خودِ رشته اهمیت دارد (زیرا معیارها به فیس فونت بستگی دارند)، مقاله آنالیز ویژگی‌های فونت PDF با کامپوننت PDFium در دلفی نشان می‌دهد که چگونه کتابخانه اطلاعات فونت را که باعث شکل‌گیریِ این جعبه‌های محصورکننده می‌شود، گزارش می‌دهد. هر دوی این‌ها بر روی همان بایندینگ، یعنی کامپوننت PDFium برای دلفی (Delphi) و لازاروس (Lazarus) ساخته شده‌اند، جایی که هلپرِ اندازه‌گیری در کنار APIهای سند، صفحه، و متنی که در سراسر این وبلاگ توصیف شده‌اند، عرضه می‌گردد