מאמר טכני

סיבוב כפול ובאגי fit-zoom ב-PDFium ב-Delphi

פונקציית FPDF_RenderPageBitmap של רכיב PDFium מקבלת ארגומנט rotate ש-PDFium תמיד מוסיפה מעל איזה סיבוב שהעמוד כבר נושא ברשומת ה-/Rotate שלו עצמו, כך שקריאת הסיבוב השמור של עמוד והזנת אותו ערך בחזרה לתוך קריאת העיבוד מסובבת את העמוד פעמיים. אותה טעות בדיוק מופיעה בחשבון fit-zoom: שינוי גודל תמונה ממוזערת מהרוחב והגובה הבלתי-מסובבים של העמוד מייצר יחס-תמונה שגוי בכל פעם שה-/Rotate הוא 90 או 270 מעלות, משום שהביטמאפ המעובד יוצא עם רוחב וגובה מוחלפים

הכשל קל לזיהוי ברגע שאתה יודע למה לחפש, וקל לפספוס עד אז. אצווה של חשבוניות סרוקות מגיעה עם תמהיל של מקורות פורטרט ולנדסקייפ, מישהו מיישר חצי מהם עם סיבוב 90 מעלות ב-Acrobat לפני ארכוב, ופס התמונות הממוזערות במציג Delphi הבנוי על PDFium מעבד את העמודים הספציפיים האלה על הצד, הפוך, או דחוסים לתוך תיבה בצורה עבור האוריינטציה הלא-נכונה. שום דבר לא זורק חריגה. שום דבר לא רושם שגיאה. הפיקסלים פשוט שגויים, ורק עבור קבוצת המשנה של עמודים שמישהו סובב בדיעבד — בדיוק הסוג של באג ששורד מעבר QA מלא מול PDF בדיקה בלתי-מסובב ואז מופיע בייצור בעמוד 47 של קובץ אמיתי

למה PDFium מסובבת את העמוד פעמיים?

PDFium מיישמת את ערך ה-/Rotate של עמוד עצמו אוטומטית בכל פעם שהיא מעבדת ביטמאפ, ללא קשר למה שנמסר למעבד. הפרמטר rotate של FPDF_RenderPageBitmap, חשוף ב-PDFiumPas כערכי TRotation ‏ro0, ‏ro90, ‏ro180 ו-ro270 על TPdf.RenderPage, ‏TPdf.RenderTile ו-TPdf.RenderPageThumbnail, לא קובע את הזווית שהעמוד אמור להסתיים בה; פרמטר ה-rotate קובע כמה סיבוב נוסף לשכב מעל מה שמילון העמוד כבר מפרט, וזו הסיבה שכל אחת מהפונקציות האלה מגדירה אותו כברירת מחדל ל-ro0

‏TPdf.PageRotation קוראת את אותו ערך /Rotate דרך FPDFPage_GetRotation, וקוד אפליקציה לעיתים קרובות זקוק לו מסיבות שאין להן שום קשר לעיבוד, כמו החלטה איך לפרוס הערה במרחב-עמוד. המלכודת היא שורה אחת: מסירת PageRotation לתוך הארגומנט Rotation של RenderPage, מצפה שהקריאה תנרמל את העמוד לזקוף. עמוד שכבר נשמר עם /Rotate 90 מוצג נכון, מסובב, בכל מציג תואם, כולל PDFium; הוסף ro90 שוב מעל זה והעמוד מתנדנד ל-180 מעלות במקום ה-90 המכוונות, בעוד עמוד ללא סיבוב בכלל מסתובב רבע-סיבוב לא-רצוי ללא סיבה

// Wrong: PageRotation already reflects /Rotate, and PDFium applies
// it automatically on every render -- passing it again as Rotation
// doubles the angle
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, Pdf.PageRotation, []);

// Right: leave Rotation at its ro0 default and let PDFium apply the
// page's own /Rotate exactly once
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, ro0, []);

למה שהפרמטר Rotation בעצם קיים

פרמטר ה-Rotation מרוויח את מקומו ב-API עבור עבודה שונה באמת: הוספת סיבוב תצוגה-בלבד שאין לו שום קשר לאוריינטציה השמורה של עמוד, הסוג שכפתור סרגל-כלים סובב-תצוגה מיישם בלי לגעת בקובץ הבסיסי. ‏TPdfView שומרת על שני הרעיונות כשני מאפיינים נפרדים בדיוק מהסיבה הזו. ‏TPdfView.PageRotation משקפת את ה-/Rotate של העמוד עצמו ויכולה, דרך FPDFPage_SetRotation, לכתוב ערך חדש בחזרה לתוך המסמך; ‏TPdfView.Rotation הוא מאפיין חולף, תצוגה-בלבד, שברירת המחדל שלו ro0 ואף פעם לא נוגע בקובץ. קריאת המאפיין הראשון וכתיבתו לתוך השני היא כל הבאג במשפט אחד

// View-only: rotates what the user sees, changes nothing in the file
procedure TViewerForm.RotateViewClick(Sender: TObject);
begin
  case PdfView.Rotation of
    ro0:   PdfView.Rotation := ro90;
    ro90:  PdfView.Rotation := ro180;
    ro180: PdfView.Rotation := ro270;
    ro270: PdfView.Rotation := ro0;
  end;
end;

// Persistent: rewrites the page's own /Rotate entry in the document
procedure TViewerForm.RotatePageClick(Sender: TObject);
begin
  case PdfView.PageRotation of
    ro0:   PdfView.PageRotation := ro90;
    ro90:  PdfView.PageRotation := ro180;
    ro180: PdfView.PageRotation := ro270;
    ro270: PdfView.PageRotation := ro0;
  end;
end;

למה שינוי גודל ה-fit-zoom נשבר באותו אופן?

שינוי גודל ה-fit-zoom נשבר מסיבת-תמונת-ראי: החישוב מתחיל מזוג המספרים הלא-נכון ולא מהזווית הלא-נכונה. דרך טיפוסית לשנות גודל תיבת תמונה-ממוזערת מבקשת מ-PDFium את הרוחב והגובה של עמוד, משווה את יחס-התמונה ההוא לתיבה הזמינה, ומחשבת את המלבן הגדול ביותר שמתאים בתוכה — מה שעובד בנקיות עבור עמוד בלתי-מסובב. אותו חישוב בשקט נכשל עבור עמוד /Rotate 90 או /Rotate 270 כאשר הרוחב והגובה הגיעו מקריאה שמדווחת את הגודל המהותי, הבלתי-מסובב, של העמוד: עמוד A4 בפורטרט שנושא /Rotate 90 עדיין מדווח בערך 595 על 842 נקודות, אף על פי ש-PDFium מעבדת אותו, נכון, בערך 842 על 595 ברגע שהסיבוב נכנס לתוקף, ותיבת-fit שחושבה מהזוג הבלתי-מסובב מסתיימת בצורה עבור האוריינטציה הלא-נכונה לגמרי

‏FPDF_GetPageSizeByIndex היא דוגמה קונקרטית אחת לקריאה שמדווחת את הגודל המהותי, הבלתי-מסובב, ההוא בעיצוב, מה שהופך אותה לנוחה לסריקת מידות עמוד בלי לטעון כל עמוד ומסוכנת עבור חשבון fit-zoom ששוכח לקחת אותה בחשבון. התיקון נובע ישירות משמת הבעיה: בדוק את הסיבוב של העמוד לפני ביצוע חשבון ה-fit, החלף רוחב וגובה בכל פעם שהסיבוב ההוא 90 או 270 מעלות, חשב את תיבת ה-fit מהזוג המוחלף, ועדיין מסור ro0 לקריאת העיבוד בפועל, משום ש-PDFium נשארת זו שמיישמת את הסיבוב האמיתי

קבלת תמונות ממוזערות נכונות בלי להמציא מחדש את חשבון ה-fit

‏TPdf.RenderPageThumbnail כבר נושאת את התיקון הזה, כך שהנתיב הקצר ביותר לתמונה ממוזערת נכונה הוא לקרוא לה במקום להרכיב מחדש את לוגיקת ה-fit-וסיבוב ידנית. בהינתן אינדקס עמוד מבוסס-1 ורוחב וגובה מקסימליים, ‏RenderPageThumbnail מחשבת תיבת-fit, מתקנת אותה עבור /Rotate של 90 או 270 פנימית, ומחזירה ביטמאפ בבעלות-הקוד-הקורא בלי להפריע לעמוד הנוכחי של המסמך או להפעיל אירוע OnPageChange — מה שחשוב עבור פס תמונות ממוזערות שנבנה לצד מציג חי על אותו מופע TPdf

// PageW, PageH are a page's own (unrotated) dimensions in points, for
// example from FPDF_GetPageSizeByIndex, which reports size before
// /Rotate is applied
function FitBox(PageW, PageH: Double; Rotation: TRotation;
  MaxW, MaxH: Integer; out FitW, FitH: Integer): Boolean;
var
  PgW, PgH, Swap: Integer;
begin
  PgW := Round(PageW);
  PgH := Round(PageH);
  if PgW < 1 then PgW := 1;
  if PgH < 1 then PgH := 1;

  if Rotation in [ro90, ro270] then
  begin
    Swap := PgW;
    PgW := PgH;
    PgH := Swap;
  end;

  Result := (MaxW > 0) and (MaxH > 0);
  if not Result then
    Exit;

  if PgW * MaxH > PgH * MaxW then
  begin
    FitW := MaxW;
    FitH := (MaxW * PgH) div PgW;
  end
  else
  begin
    FitH := MaxH;
    FitW := (MaxH * PgW) div PgH;
  end;
end;

עוזר ה-FitBox שווה לשמור בכל מקרה, משום ש-RenderPageThumbnail מכסה רק את מקרה הביטמאפ-הבודד. רשת תמונות-ממוזערות מותאמת אישית, פס תצוגה-מקדימה-להדפסה, או תיבת דו-שיח בוחר-עמוד שפורסת כמה עמודים מול תיבות עצמאיות זקוקה לאותו חשבון fit מודע-סיבוב בלי בהכרח לרצות ביטמאפ טרי לכל אריח, ומצבי הזום fit-page ו-fit-width של TPdfView עצמה נשענים על אותו רעיון בדיוק פנימית, בוחרים בין הרוחב והגובה של עמוד עבור חשבון יחס-הזום בהתבסס על הסיבוב הנוכחי של התצוגה לפני שהם משווים אותו מול שטח הלקוח הזמין. אם ביצועי זום וגלילה בסוג הזה של מציג הם הבעיה הבאה ברשימה, החלק הנלווה על מטמון עיבוד וזום חלק במציג Delphi מבוסס-PDFium ממשיך בדיוק היכן ששינוי-גודל נכון מפסיק

זיהוי סיבוב כפול לפני שלקוח עושה זאת

לסיבוב כפול יש חתימה חזותית אמינה אחת: עמוד שסובב 90 מעלות בכניסה יוצא נראה מסובב 180 יחסית לשאר המסמך, לא 90, משום שה-ro90 הנוסף נערם מעל ה-ro90 של העמוד עצמו במקום להחליף אותו. תבנית בדיקה שבנויה רק מעמודי /Rotate 0 לעולם לא תתפוס את זה, שכן הוספת ro0 ל-ro0 עדיין ro0 והבאג נשאר בלתי-נראה; תבנית זקוקה לפחות לעמוד אחד שנשמר עם /Rotate 90 ואחד עם /Rotate 270 לפני שנתיב קוד של תמונה-ממוזערת או fit-zoom אפשר לבטוח בו

צינור העמוד-לביטמאפ הבסיסי המכוסה בעיבוד עמודי PDF ל-JPEG עם רכיב PDFium כבר מעבד עמודים מסובבים נכון ללא שום קוד מקרה-מיוחד, בדיוק משום שהוא משאיר את Rotation בברירת המחדל ro0 שלו ונותן ל-PDFium ליישם /Rotate בעצמה. באג הסיבוב-הכפול מופיע רק ברגע שקוד אפליקציה מתחיל לקרוא את PageRotation בחזרה ולהזין אותו לאיזשהו מקום שהוא לא שייך אליו

קריאות העיבוד מודעות-הסיבוב ושינוי-גודל התמונה הממוזערת המתוארים כאן הם חלק מרכיב PDFium עבור Delphi ו-C++Builder, לצד שאר ה-API של עיבוד, תצוגה, וחילוץ-טקסט הבנוי על אותן מחלקות TPdf ו-TPdfView