מאמר טכני

מדידת טקסט ב-PDF עבור פריסה וגלישת מילים ב-Delphi

הקריאה ששמה טקסט על עמוד PDF היא פשוטה. אתה נותן ל-AddText מחרוזת, גופן, גודל ומיקום, והגליפים (glyphs) מופיעים. מה שהיא לא עושה זה לומר לך עד כמה המחרוזת תהיה רחבה ברגע שהיא תצויר, והיא לא שוברת מחרוזת ארוכה על פני מספר שורות. קריאה בודדת מציירת רצף (run) אחד של טקסט במיקום אחד. אם הרצף רחב יותר מהעמודה שאליה התכוונת שייכנס, הוא פשוט חורג מעבר לקצה, ושום דבר בקריאת הציור אינו מזהיר אותך. ברגע שאתה רוצה פסקה ולא תווית (label) בודדת, החלק החסר הוא רוחב המחרוזת בגופן ובגודל שנבחרו, הנמדד לפני שאתה מקבע אותה לעמוד

זוהי בעיית הפריסה הקלאסית. כדי לגלוש (wrap) פסקה אל תוך עמודה עליך לדעת, מילה אחר מילה, כמה מרחב אופקי כל שורה פוטנציאלית תיקח, ועליך לדעת זאת לפני שאתה מצייר דבר. גלישת מילים (Word wrap) היא לולאת מדידה העוטפת קריאת ציור, וחיבור שרק מצייר נותן לך את החצי השני. התמיכה במדידת טקסט ב-PDFium component סוגרת את הפער הזה בעזרת שתי פונקציות, MeasureText ו-MeasureTextWidth, המדווחות על ההיקף המרונדר של מחרוזת מבלי לשים סימן על שום עמוד

מדוע המדידה היא class helper, ולא מתודה חדשה ב-TPdf

התמיכה במדידה מגיעה בתור class helper ב-Delphi עבור TPdf, החי ביחידה (unit) משלו, במקום כמתודות חדשות המחוברות למחלקת TPdf. Class helper הוא תכונת שפה המאפשרת לך לצרף מתודות לטיפוס קיים מחוץ להכרזה (declaration) שלו. ברגע שהיחידה נמצאת בהיקף (in scope), המתודות החדשות נקראות בדיוק כאילו הן שייכות למחלקה, כך שמתודת-העזר נקראת כ-Pdf.MeasureTextWidth(...) ללא אובייקט נפרד שיש לבנות או להעביר

הסיבה לשכבות בדרך זו היא הפרדה. טיפוס הליבה TPdf נשאר כפי שהוא, מבלי שנוסף שדה ומבלי שנגעו בחתימה (signature) קיימת, כך שפרויקט שלעולם אינו צריך פריסה לעולם אינו נושא את קוד המדידה. פרויקט שכן צריך זאת מוסיף יחידה אחת לסעיף uses והמתודות נדלקות (light up). יכולת הופכת לאופציונלית (opt-in) בגרנולריות של יחידה בודדת, שזו הדרך הנקייה ביותר להרחיב טיפוס שאינו בבעלותך או שאינך רוצה להפריע לו

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;

מדידה מבלי לגעת בעמוד

המדידה חייבת להיות נקייה מתופעות לוואי. היא חייבת לדווח על רוחב מבלי להשאיר שום דבר מאחור, משום שאתה קורא לה פעמים רבות בזמן קבלת החלטות על פריסה והעמוד חייב להיראות בדיוק כפי שהיה נראה אילו מעולם לא היית מודד כלל. הטכניקה שמאפשרת זאת היא לבנות אובייקט טקסט, לבקש את הגודל שלו, ולזרוק אותו לפני שהוא אי פעם מחובר לעמוד

הרצף הוא ארבע קריאות PDFium. FPDFPageObj_NewTextObj יוצרת אובייקט טקסט אל מול המסמך, בהינתן שם הגופן והגודל. FPDFText_SetText מגדירה את המחרוזת שאותו אובייקט נושא. FPDFPageObj_GetBounds קוראת בחזרה את התיבה התוחמת (bounding box) של האובייקט. FPDFPageObj_Destroy משחררת את האובייקט. באופן מכריע, שום דבר ברצף הזה אינו קורא ל-API של הוספה לעמוד (page-insertion). האובייקט נוצר, מתושאל (queried), ונהרס בבידוד, כך שהמסמך אינו משתנה כאשר הפונקציה חוזרת. זוהי גשושית חד-פעמית (throwaway probe) שהפלט היחיד שלה הוא ארבעת המספרים של התיבה התוחמת שלה

זוהי הדרך החסונה לעשות זאת מכיוון ש-PDFium אינה חושפת רוחב התקדמות (advance width) נוח לכל גליף שהיית יכול לסכם בעצמך. מדדי גליפים (Glyph metrics) תלויים בתוכנית הגופן, בקידוד (encoding), ובאופן שבו PDFium טוענת את הגופן, ואין קריאה ציבורית שמוסרת לך את ההתקדמות של כל תו במחרוזת. התיבה התוחמת של אובייקט טקסט אמיתי, לעומת זאת, מחושבת על ידי אותו המנגנון שהיה פורס את הגליפים לשם ציור, כך שהיא משקפת את ההיקף המרונדר בפועל ולא קירוב (approximation). בניית אובייקט חד-פעמי (disposable) אחד וקריאת הגבולות שלו היא המדידה האמינה ביותר שהספרייה יכולה לתת

// 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 (user units), שבהן יחידה אחת היא החלק השבעים-ושניים של אינץ' (1/72 אינץ'), אותו מרחב קואורדינטות שבו אתה ממקם טקסט על העמוד. אין שום יחידת מכשיר נסתרת ושום פיקסל שמעורב בשלב זה. רוחב של 36 משמעותו חצי אינץ' של עמוד, תהא רזולוציית הרינדור הסופית אשר תהא

הציר האנכי רץ כפי ש-PDF מגדירה אותו, כשה-Y גדל כלפי מעלה, וזו הסיבה שהגובה הוא עליון פחות תחתון ולא להפך. פרט זה משמעותי כאשר אתה מקדם סמן במורד עמודה. אתה מודד את גובהה של שורה, ואז מחסר אותו מקו הבסיס (baseline) הנוכחי כדי למצוא את השורה הבאה, משום שתנועה במורד העמוד משמעותה תנועה לכיוון Y קטן יותר. אם היעד שלך הוא מסך ולא נייר, אתה ממיר יחידות משתמש לפיקסלי מכשיר בעזרת רזולוציית התצוגה: ערך ביחידות משתמש המוכפל ב-DPI ומחולק ב-72 נותן פיקסלים, כך שרוחב עמודה שקבעת בנקודות (points) יכול להיות מושווה כנגד רצף שנמדד לפני שאתה מחליט היכן תיפול השבירה (break)

מה קורה בקלט מנוון (degenerate input)

הפונקציות נכתבות כדי להיכשל בשקט. אם אין מסמך פתוח, או אם לא ניתן ליצור את אובייקט הטקסט, התוצאה היא היקף (extent) אפס במקום זריקת חריגה (exception). הרוחב והגובה מאותחלים לאפס בראש הפונקציה ונכתבים-מחדש (overwritten) רק לאחר שתיבה תוחמת נִקְרְאָה בחזרה בהצלחה. מחרוזת ריקה, מסמך חסר, גופן שהספרייה לא יכולה לפענח לכדי אובייקט, כל אלו מחזירים אפס במקום לזרוק חריגה

בחירה זו שומרת על לולאת מדידה פשוטה, משום שלולאה שרצה על פני אלפי מילים היא לא המקום לטיפול בחריגות (exception handling) בכל איטרציה. העלות היא שהקורא (caller) נושא באחריות לבדיקה. רוחב אפס הוא שומר (sentinel), לא עובדה על הטקסט, כך שקוד שמחלק ברוחב מדוד או מניח ערך חיובי חייב להתגונן מפני אפס לפני שהוא סומך עליו. התייחס לאפס כ"לא ניתן היה למדוד" והחוזה (contract) ברור; התעלם ממנו וקלט מנוון הופך בשקט לפריסה (layout) עם עמודה של גליפים חופפים (overlapping glyphs)

גלישת מילים חמדנית (greedy) הבנויה על המדידה

כשיש פונקציית רוחב ביד, גלישת מילים היא לולאה חמדנית (greedy) קצרה. אתה מפצל את הפסקה למילים, שומר שורה נוכחית, ועבור כל מילה אתה מודד מה תהיה השורה אם היית מצרף את אותה מילה. כל עוד שורת הניסיון (trial line) עדיין מתאימה לרוחב העמודה, אתה ממשיך להוסיף; כאשר היא תחרוג (overflow), אתה שוטף (flush) את השורה הנוכחית בעזרת AddText ומתחיל אחת חדשה עם המילה שלא התאימה. ההצטברות (accumulation) נעשית במלואה עם 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];
    // Measure the candidate line before drawing anything.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(X, Y, Font, FontSize, Line);   // 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(X, Y, Font, FontSize, Line);      // flush the final line
end;

הלולאה מודדת את שורת הניסיון במקום למדוד כל מילה ולסכם, מכיוון שהרוחב של שורה אינו סכום הרוחבים של המילים שלה. רווחים בין המילים תורמים גם הם, ורצף נמדד לוכד זאת ישירות. הכלל החמדני, להתאים כמה שיותר מילים שהעמודה מאפשרת ולשבור באחרונה שמתאימה, הוא אותו הכלל שממלא את הפער בין קריאת AddText גולמית לבין פסקה אמיתית. קריאת הציור מעולם לא הייתה החלק הקשה. המדידה שחייבת להקדים אותה היא, וזה בדיוק מה שה-helper מספק

כיצד זה משתלב (Where this fits)

מדידה היא השכבה שבין יצירת תוכן (generating content) לבין רינדור שלו, ולכן היא משתלבת בטבעיות עם שאר זרימת העבודה (workflow) של יצירת מסמכים מאפס. אם אתה מרכיב עמודים וממקם טקסט מלכתחילה, עבודת התשתית נמצאת ביצירת מסמכי PDF מאפס עם PDFium component ב-Delphi, היכן ש-AddText והגדרת עמוד (page setup) מכוסים במלואם. כאשר הגופן שאתה מודד חשוב לא פחות מהמחרוזת, משום שמדדים תלויים בגופן, ניתוח מאפייני גופני PDF עם PDFium component ב-Delphi מראה כיצד הספרייה מדווחת על מידע הגופן שמניע את התיבות התוחמות (bounding boxes) ההן. שניהם נבנים על גבי אותו חיבור, הרכיב PDFium Component עבור Delphi ו-Lazarus, שם ה-measurement helper מסופק לצד ממשקי ה-API של המסמך, העמוד והטקסט המתוארים לאורך בלוג זה