مقاله فنی

اندازه‌گیری متن PDF برای چیدمان و word wrap در Delphi

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

این همان مسئله کلاسیک چیدمان است. برای اینکه یک پاراگراف را داخل یک ستون wrap کنید باید کلمه به کلمه بدانید هر خطِ نامزد چه مقدار فضای افقی می‌گیرد و باید این را قبل از ترسیم هر چیز بدانید. word wrap در اصل یک حلقه اندازه‌گیری است که دور یک فراخوانی ترسیم پیچیده شده و bindingی که فقط ترسیم می‌کند فقط نیمه دوم این ماجرا را به شما می‌دهد. پشتیبانی اندازه‌گیری متن در PDFium Component این خلأ را با دو تابع MeasureText و MeasureTextWidth پر می‌کند که گستره رندرشده یک رشته را گزارش می‌دهند، بدون اینکه هیچ علامتی روی هیچ صفحه‌ای بگذارند

چرا اندازه‌گیری یک class helper است، نه یک متد تازه روی TPdf

پشتیبانی اندازه‌گیری به شکل یک Delphi class helper برای TPdf ارائه می‌شود که در unit خودش زندگی می‌کند، نه به صورت متدهای تازه‌ای که مستقیم به کلاس TPdf پیچ شده باشند. class helper یک قابلیت زبانی است که به شما اجازه می‌دهد از بیرونِ اعلان یک نوع، متدهایی را به آن اضافه کنید. همین که آن unit در scope باشد، متدهای جدید دقیقاً مثل اعضای خود کلاس فراخوانی می‌شوند، بنابراین یک helper method به شکل Pdf.MeasureTextWidth(...) خوانده می‌شود و نیازی به ساختن یا عبور دادن یک شیء جداگانه نیست

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

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // the helper unit; brings MeasureText into scope on TPdf

// With the unit in scope the methods read as members of TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W and H are now the rendered width and height in PDF user units
end;

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

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

این توالی شامل چهار فراخوانی PDFium است. FPDFPageObj_NewTextObj بر اساس نام قلم و اندازه، یک text object برای سند می‌سازد. FPDFText_SetText رشته‌ای را که آن شیء حمل می‌کند تنظیم می‌کند. FPDFPageObj_GetBounds جعبه مرزی شیء را بازمی‌خواند. FPDFPageObj_Destroy شیء را آزاد می‌کند. نکته مهم این است که در این توالی هیچ فراخوانی‌ای به API درج در صفحه دست نمی‌زند. شیء به صورت کاملاً مجزا ساخته، پرسیده و نابود می‌شود، بنابراین وقتی تابع برمی‌گردد سند هیچ تغییری نکرده است. این فقط یک probe یک‌بارمصرف است که خروجی‌اش همان چهار عدد جعبه مرزی است

این مقاوم‌ترین راه انجام کار است، چون PDFium یک advance width سرراست برای تک‌تک glyphها ارائه نمی‌کند تا خودتان آن‌ها را جمع بزنید. متریک glyphها به برنامه قلم، encoding و نحوه بارگذاری face در PDFium وابسته است و هیچ فراخوانی عمومی‌ای وجود ندارد که advance هر نویسه در یک رشته را به شما بدهد. در مقابل، جعبه مرزی یک text object واقعی توسط همان سازوکاری محاسبه می‌شود که هنگام ترسیم، glyphها را می‌چیند، بنابراین به جای یک تقریب، گستره واقعی رندر را بازتاب می‌دهد. ساختن یک شیء موقتی و خواندن bounds آن قابل‌اعتمادترین اندازه‌گیری‌ای است که کتابخانه می‌تواند بدهد

// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
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);   // probe discarded, page untouched
  end;
end;

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

جعبه مرزی با چهار لبه برمی‌گردد: چپ، پایین، راست و بالا، و دو بُعد با تفریق از آن‌ها به دست می‌آیند. عرض برابر است با راست منهای چپ و ارتفاع برابر است با بالا منهای پایین. هر دو در واحدهای کاربری PDF بیان می‌شوند که در آن هر واحد یک‌هفتادودوم اینچ است؛ همان فضای مختصاتی‌ای که متن را روی صفحه در آن جای‌گذاری می‌کنید. در این مرحله نه خبری از واحد مخفی دستگاه هست و نه از پیکسل. عرض 36 یعنی نیم اینچ روی صفحه، فارغ از اینکه بعداً با چه وضوحی رندر شود

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

در ورودی‌های واگرا چه رخ می‌دهد

این توابع طوری نوشته شده‌اند که آرام و بی‌سر‌وصدا شکست بخورند. اگر هیچ سندی باز نباشد یا text object قابل ساختن نباشد، نتیجه به جای پرتاب استثنا یک گستره صفر است. عرض و ارتفاع در ابتدای تابع صفر می‌شوند و فقط وقتی بازنویسی می‌شوند که جعبه مرزی با موفقیت خوانده شده باشد. رشته خالی، سندِ غایب یا قلمی که کتابخانه نتواند آن را به یک شیء تبدیل کند، همه صفر برمی‌گردانند و استثنایی پرتاب نمی‌کنند

این انتخاب حلقه اندازه‌گیری را ساده نگه می‌دارد، چون حلقه‌ای که روی هزاران واژه اجرا می‌شود جای مناسبی برای مدیریت استثنا در هر تکرار نیست. هزینه این انتخاب به عهده caller می‌افتد. عرض صفر یک sentinel است، نه یک واقعیت درباره متن، بنابراین کدی که بر عرض اندازه‌گیری‌شده تقسیم می‌کند یا مقدار مثبت را مفروض می‌گیرد باید پیش از اعتماد کردن، صفر را کنترل کند. اگر صفر را به معنای «قابل اندازه‌گیری نبود» تفسیر کنید قرارداد روشن است؛ اگر نادیده‌اش بگیرید، یک ورودی واگرا بی‌سروصدا به ستونی از glyphهای هم‌پوشان تبدیل می‌شود

یک word wrap حریصانه بر پایه همین اندازه‌گیری

وقتی یک تابع عرض در اختیار دارید، word wrap فقط یک حلقه کوتاهِ حریصانه است. پاراگراف را به واژه‌ها تقسیم می‌کنید، یک خط جاری نگه می‌دارید و برای هر واژه، می‌سنجید اگر آن واژه را به خط اضافه کنید چه خواهد شد. تا وقتی خط آزمایشی هنوز در عرض ستون جا می‌شود، واژه‌ها را اضافه می‌کنید؛ وقتی در آستانه سرریز قرار گرفت، خط فعلی را با AddText flush می‌کنید و خط تازه را با همان واژه‌ای شروع می‌کنید که جا نشد. تمام این انباشت با MeasureTextWidth انجام می‌شود و تنها چیزی که به صفحه راه پیدا می‌کند خطی است که از قبل مطمئن شده‌اید جا می‌شود

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<string>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := string(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Measure the candidate line before drawing anything.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(Line, Font, FontSize, X, Y);   // flush the line that fit
      Y    := Y - LineHeight;                    // Y decreases going down
      Line := Words[I];                          // overflowing word starts next line
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(Line, Font, FontSize, X, Y);      // flush the final line
end;

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

این قابلیت کجا قرار می‌گیرد

اندازه‌گیری لایه میان تولید محتوا و رندر آن است، بنابراین به‌طور طبیعی با بقیه جریان‌کار ساخت سند از صفر جفت می‌شود. اگر در وهله اول در حال سرهم کردن صفحه‌ها و قراردادن متن هستید، پایه کار در ساخت سندهای PDF از صفر با PDFium Component در Delphi آمده که در آن AddText و تنظیم صفحه به‌طور کامل پوشش داده شده‌اند. وقتی قلمی که اندازه می‌گیرید به اندازه خود رشته مهم است، چون متریک‌ها به face بستگی دارند، تحلیل ویژگی‌های قلم PDF با PDFium Component در Delphi نشان می‌دهد کتابخانه چگونه اطلاعات قلمی را گزارش می‌کند که همین bounding boxها را تعیین می‌کند. هر دو بر پایه همان binding بنا شده‌اند: PDFium Component برای Delphi و Lazarus که helper اندازه‌گیری را در کنار APIهای سند، صفحه و متنِ توضیح‌داده‌شده در سراسر این وبلاگ ارائه می‌کند