הקריאה ששמה טקסט על דף PDF היא פשוטה. אתם נותנים ל-AddText מחרוזת, גופן, גודל ומיקום, והגליפים מופיעים. מה שהיא לא עושה זה לומר לכם כמה רחבה תהיה המחרוזת לאחר שתצויר, והיא לא שוברת מחרוזת ארוכה על פני מספר שורות. קריאה בודדת מציירת ריצת (run) טקסט אחת במיקום אחד. אם הריצה רחבה יותר מהעמודה שאליה התכוונתם שהיא תיכנס, היא פשוט חורגת מהקצה, ושום דבר בקריאת הציור לא מזהיר אתכם. ברגע שתרצו פסקה במקום תווית בודדת, החלק החסר הוא רוחב המחרוזת בגופן ובגודל שנבחרו, הנמדד לפני שאתם מתחייבים לציירו על הדף
זוהי בעיית הפריסה הקלאסית. כדי לגלוש פסקה לתוך עמודה, אתם צריכים לדעת, מילה אחר מילה, כמה רווח אופקי תיקח כל שורה מועמדת, ואתם צריכים לדעת את זה לפני שמציירים משהו. גלישת מילים (word wrap) היא לולאת מדידה העוטפת קריאת ציור, וקישור שרק מצייר נותן לכם את החצי השני. תמיכת מדידת הטקסט ברכיב ה-PDFium סוגרת את הפער הזה עם שתי פונקציות, MeasureText ו-MeasureTextWidth, המדווחות על ההיקף המרונדר של מחרוזת מבלי לשים אף סימן על שום דף
מדוע המדידה היא class helper, ולא מתודה חדשה ב-TPdf
תמיכת המדידה מגיעה בתור class helper של Delphi עבור TPdf, החיה ביחידה (unit) משלה, ולא כמתודות חדשות שהוברגו לתוך כיתת TPdf. class helper הוא מאפיין של השפה המאפשר לכם לחבר מתודות לסוג קיים מחוץ להכרזה עליו. ברגע שהיחידה נמצאת בטווח (scope), המתודות החדשות נקראות בדיוק כאילו היו שייכות לכיתה, ולכן מתודת עזר נקראת כ-Pdf.MeasureTextWidth(...) ללא אובייקט נפרד לבנות או להעביר
הסיבה לשכבות בדרך זו היא הפרדה. סוג ה-TPdf המרכזי נשאר כפי שהוא, ללא הוספת שדה וללא נגיעה באף חתימה קיימת, כך שפרויקט שלעולם אינו זקוק לפריסה לעולם אינו נושא את קוד המדידה. פרויקט שכן זקוק לו מוסיף יחידה אחת לסעיף uses והמתודות נדלקות. יכולת הופכת לאופציונלית (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 של הוספה לדף. האובייקט נוצר, מתשאל, ונהרס בבידוד, כך שהמסמך ללא שינוי כאשר הפונקציה חוזרת. זוהי גשושית חד-פעמית שהפלט היחיד שלה הוא ארבעת המספרים של התיבה התוחמת שלה
זוהי הדרך החסונה לעשות זאת מכיוון ש-PDFium אינה חושפת רוחב קידום נוח לכל גליף שתוכלו לסכם בעצמכם. מדדי גליף תלויים בתוכנית הגופן, בקידוד, ובאופן שבו PDFium טוען את הגופן, ואין קריאה ציבורית שמוסרת לכם את הקידום של כל תו במחרוזת. התיבה התוחמת של אובייקט טקסט אמיתי, לעומת זאת, מחושבת על ידי אותו מנגנון שהיה פורס את הגליפים לציור, ולכן היא משקפת את ההיקף המרונדר בפועל במקום קירוב. בניית אובייקט חד-פעמי אחד וקריאת גבולותיו היא המדידה האמינה ביותר שהספרייה יכולה לתת
// 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 עולה כלפי מעלה, ולכן גובה הוא עליון פחות תחתון ולא ההפך. פרט זה חשוב כאשר אתם מקדמים סמן במורד עמודה. אתם מודדים גובה של שורה, ואז מחסרים אותו מקו הבסיס (baseline) הנוכחי כדי למצוא את הבא אחריו, מכיוון שתנועה למטה בדף פירושה תנועה לעבר Y קטן יותר. אם היעד שלכם הוא מסך ולא נייר, אתם ממירים יחידות משתמש לפיקסלים של התקן עם רזולוציית התצוגה: ערך ביחידות משתמש מוכפל ב-DPI ומחולק ב-72 נותן פיקסלים, כך שניתן להתאים רוחב עמודה שקבעתם בנקודות לריצה נמדדת לפני שאתם מחליטים היכן תתרחש השבירה
מה קורה בקלט מנוון (degenerate)
הפונקציות נכתבות כדי להיכשל בשקט. אם אין מסמך פתוח, או אם לא ניתן ליצור את אובייקט הטקסט, התוצאה היא היקף אפס במקום העלאת חריגה (exception). הרוחב והגובה מאותחלים לאפס בחלק העליון ונדרסים רק לאחר שתיבה תוחמת נקראה בחזרה בהצלחה. מחרוזת ריקה, מסמך חסר, גופן שהספרייה אינה יכולה לפתור לאובייקט - כל אלה מחזירים אפס במקום לזרוק חריגה
בחירה זו שומרת על לולאת מדידה פשוטה, שכן לולאה שרצה על אלפי מילים אינה המקום לטיפול בחריגות בכל איטרציה. העלות היא שהמתקשר נושא באחריות הבדיקה. רוחב אפס הוא סמן (sentinel), לא עובדה על הטקסט, כך שקוד שמחלק ברוחב מדוד או מניח ערך חיובי חייב להישמר מפני אפס לפני שהוא סומך עליו. התייחסו לאפס כ-"לא ניתן היה למדוד" והחוזה ברור; התעלמו מזה וקלט מנוון יהפוך בשקט לפריסה עם עמודה של גליפים חופפים
גלישת מילים חמדנית (greedy) הבנויה על המדידה
עם פונקציית רוחב ביד, גלישת מילים היא לולאה חמדנית קצרה. אתם מפצלים את הפסקה למילים, שומרים על שורה נוכחית, ועבור כל מילה אתם מודדים מה הייתה השורה אם הייתם מצרפים אליה את המילה הזו. כל עוד שורת הניסיון עדיין מתאימה לרוחב העמודה אתם ממשיכים להוסיף; כאשר היא תחרוג אתם מרוקנים (flush) את השורה הנוכחית עם AddText ומתחילים חדשה עם המילה שלא התאימה. ההצטברות נעשית כולה עם 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;
הלולאה מודדת את שורת הניסיון במקום למדוד כל מילה ולסכם, מכיוון שרוחב שורה אינו סכום רוחב מילותיה. רווחים בין מילים תורמים, וריצה נמדדת לוכדת זאת ישירות. הכלל החמדן (greedy), התאם כמה שיותר מילים כפי שהעמודה מאפשרת ושבור באחרונה שמתאימה, הוא אותו כלל שממלא את הפער בין AddText גולמי לפסקה אמיתית. קריאת הציור מעולם לא הייתה החלק הקשה. המדידה שחייבת להקדים לה היא כן, וזה בדיוק מה שה-helper מספק
היכן זה משתלב
מדידה היא השכבה שבין יצירת תוכן לבין הרינדור שלו, ולכן היא משתלבת בטבעיות עם שאר תהליך העבודה (workflow) של יצירת מסמכים מאפס. אם אתם מרכיבים דפים וממקמים טקסט מלכתחילה, עבודת התשתית נמצאת ב-יצירת מסמכי PDF מאפס בעזרת רכיב PDFium ב-Delphi, שם AddText והגדרת דף מכוסים במלואם. כאשר הגופן שאתם מודדים חשוב לא פחות מהמחרוזת, מכיוון שהמדדים תלויים בגופן, ניתוח מאפייני גופן ב-PDF בעזרת רכיב PDFium ב-Delphi מראה כיצד הספרייה מדווחת על מידע הגופן שמניע את התיבות התוחמות הללו. שניהם נבנים על אותו קישור, רכיב PDFium עבור Delphi ו-Lazarus, שבו עזר (helper) המדידה מסופק לצד ה-APIs של המסמך, הדף והטקסט המתוארים לאורך בלוג זה