מאמר טכני

רינדור עמודי PDF ל-JPEG ב-Delphi עם PDFium Component

רינדור עמוד PDF ל-JPEG הן שתי פעולות שאנשים נוטים להריץ יחד ואז לדבג בנפרד. תחילה אתה מרסטר (rasterize) את העמוד למפת-סיביות (bitmap) ברזולוציה שאתה בוחר. לאחר מכן אתה מוסר את מפת-הסיביות הזו למקודד JPEG ובוחר איכות. PDFium Component מחזיק בחצי הראשון דרך RenderPage; החצי השני הוא VCL פשוט, TJPEGImage מתוך Vcl.Imaging.jpeg. התפר ביניהם הוא המקום בו חיות ההחלטות המעניינות, משום שהרזולוציה שאתה בוחר בצד הרינדור והאיכות שאתה בוחר בצד הקידוד מתקזזות אחת מול השנייה ומול גודל הקובץ בדרכים שקל לטעות בהן

הדבר שצריך להפנים לפני כל קוד: לעמוד PDF אין פיקסלים. הוא מתואר בנקודות (points), כאשר נקודה אחת היא 1/72 אינץ', והעמוד הוא ציור וקטורי הנמדד בנקודות אלו. כאשר אתה מבקש מ-PDFium לרנדר, אתה בוחר על כמה פיקסלים להקרין את הציור הזה, והבחירה הזו היא ה-DPI. אם תטעה בחשבון, או שתרנדר תמונה ממוזערת מטושטשת כשאתה רצית מאסטר להדפסה, או שתקצה מפת-סיביות של 200 מגה-פיקסל למשהו שנועד להיות תצוגה מקדימה של 120 פיקסלים

מ-DPI לממדי פיקסלים

RenderPage רוצה Width ו-Height בפיקסלים שלמים, לא DPI. לכן העבודה הראשונה היא המרה. עמוד מדווח על גודלו בנקודות דרך PageWidth ו-PageHeight (שניהם Double), וההמרה היא אותה אחת שכל מרסטר (rasterizer) משתמש בה: פיקסלים שווים לנקודות כפול DPI יעד חלקי 72. עמוד US Letter הוא 612 על 792 נקודות. ב-150 DPI זה הופך ל-1275 על 1650 פיקסלים; ב-72 DPI זה נשאר 612 על 792, פיקסל אחד לכל נקודה, שזה המקרה שאנשים שוכחים שהוא פשוט הזהות (identity)

// Pdf.PageNumber חייב כבר להצביע על העמוד שאתה רוצה.
PixelW := Round(Pdf.PageWidth  * Dpi / 72);
PixelH := Round(Pdf.PageHeight * Dpi / 72);
Bitmap := Pdf.RenderPage(0, 0, PixelW, PixelH, ro0, [], clWhite);
// ... השתמש ב-Bitmap ...
Bitmap.Free;   // הצורה-הפונקציונלית של RenderPage מוסרת לך את הבעלות

שני פרטים בארבע השורות האלו קובעים האם הקוד נכון. הראשון הוא שהצורה הפונקציונלית של RenderPage מחזירה TBitmap שאתה הבעלים שלו. PDFium הקצה אותו והלך משם; אם אינך עושה לו Free בכל איטרציה, אצווה על פני כמה מאות עמודים דולפת כמה מאות מפות-סיביות והתהליך מתנפח עד שמשהו קורס. השני הוא הארגומנט Color, clWhite כאן. עמודי PDF מצוירים בדרך כלל מתוך הנחה של מצע לבן אטום, ועמוד עם שקיפות המרונדר על צבע הרקע הלא נכון מייצר קצוות בוציים או הילות כהות תועות. לבן הוא בררת המחדל הנכונה עבור כמעט כל מסמך; הפרמטר קיים למקרה הנדיר שבו הוא לא

ה-0, 0 הם ההיסטים (offsets) של Left ו-Top אל תוך העמוד, במרחב הקואורדינטות המותאם (scaled), ואתה משאיר אותם באפס אלא אם כן אתה חותך (cropping). ה-ro0 הוא סיבוב (rotation): השאר אותו באפס ו-PDFium יכבד כל סיבוב שהעמוד כבר מצהיר עליו ברשומת ה-/Rotate שלו, כך שעמוד שנוצר לרוחב (landscape) יוצא לרוחב מבלי שתעשה דבר

קידוד מפת-הסיביות כ-JPEG

ברגע שמפת-הסיביות קיימת, JPEG הוא החלק הקל, וזה Delphi טהור. TJPEGImage.Assign מעתיק את מפת-הסיביות פנימה, CompressionQuality מגדיר את האיכות בסולם של 1 עד 100, ו-SaveToFile כותב את הקובץ. כלל הסידור היחיד הוא שיש להגדיר את האיכות לפני שאתה שומר, משום שהיא מכוונת את הקידוד ש-SaveToFile מפעיל

uses
  Vcl.Graphics, Vcl.Imaging.jpeg, PDFium;

procedure SavePageAsJpeg(Pdf: TPdf; PageNumber, Dpi, Quality: Integer;
  const FileName: string);
var
  Bitmap: TBitmap;
  Jpeg: TJPEGImage;
begin
  Pdf.PageNumber := PageNumber;
  Bitmap := Pdf.RenderPage(0, 0,
    Round(Pdf.PageWidth  * Dpi / 72),
    Round(Pdf.PageHeight * Dpi / 72),
    ro0, [], clWhite);
  try
    Jpeg := TJPEGImage.Create;
    try
      Jpeg.Assign(Bitmap);
      Jpeg.CompressionQuality := Quality;   // 1..100
      Jpeg.SaveToFile(FileName);
    finally
      Jpeg.Free;
    end;
  finally
    Bitmap.Free;
  end;
end;

ה-try/finally המקונן הזה נראה טרחני (fussy) עבור עזר (helper) של עמוד-אחד, והוא בדיוק נכון עבור אצווה (batch). הבלוק הפנימי משחרר את המקודד, הבלוק החיצוני משחרר את מפת-הסיביות, וכל אחד מהם שמופעל על חריגה עדיין משחרר את מה שבבעלותו. קפל אותם לאחד, וחריגה במהלך הקידוד עלולה לנטוש את מפת-הסיביות. לאורך ריצה ארוכה זהו ההבדל בין ממיר שמסיים לכזה שמת בעמוד 300 עם קובץ פגום ודיאלוג חוסר-זיכרון

בחירת DPI ואיכות יחד

שני הכפתורים (knobs) אינם בלתי תלויים במטרת הפלט, והטעות הנפוצה היא להגביר את שניהם מתוך זהירות. תמונה ממוזערת לאינטרנט שרונדרה ב-300 DPI ונשמרה באיכות 95 היא של כמה מאות קילובייטים שמתחזה לתמונה של 120 פיקסלים; הדפדפן זורק כמעט את כולה בעת הקטנת קנה המידה (downscale). התאם את הרזולוציה לפיקסלים שהפלט באמת זקוק להם, ואז בחר איכות ששורדת את דחיסת איבוד-הנתונים (lossy) של JPEG ללא ארטיפקטים (artifacts) גלויים לעין

פלטDPIאיכות JPEG
תמונה ממוזערת ברשימה7260-70
תצוגה מקדימה על-מסך96-15080-85
צפייה בפרטים-גבוהים200-30085-95
מאסטר להדפסה300-60090-100

איכות JPEG שווה מילת אזהרה בפני עצמה. זו אינה חוגה ליניארית. הקפיצה מ-70 ל-85 קונה שיפור חזותי אמיתי תמורת גדילה צנועה בקובץ; הקפיצה מ-95 ל-100 מכפילה בערך את הקובץ עבור הבדל שכמעט איש אינו יכול לראות, כי איכות 100 עדיין אינה חסרת-אובדן (lossless), היא פשוט מפסיקה לזרוק הרבה. עבור עמודים עתירי-טקסט, הדחיסה מבוססת-הבלוקים של JPEG מורחת את הקצוות החדים של גליפים לכדי צלצול (ringing) חלש, וזו הסיבה שאיכות מתחת לכ-80 הופכת טקסט לבעל מראה-סרוק על פלט שהיה אמור להיות חד (crisp). אם העמודים הם לרוב טקסט ואתה יכול לשנות פורמטים, PNG מרנדר את הטקסט הזה ללא הצלצול; JPEG מרוויח את מקומו בתוכן צילומי ומעורב שבו הדחיסה שלו באמת קטנה יותר

תמונות ממוזערות מהירות יותר וקטנות יותר

כאשר המטרה היא תמונה ממוזערת ולא שעתוק (reproduction) נאמן, אתה יכול לומר למרנדר (renderer) לעשות פחות עבודה. הפרמטר Options לוקח סט של דגלי TRenderOption, וכמה מהם סוחרים בנאמנות תמורת מהירות בדיוק בדרך שתצוגה מקדימה קטנה רוצה. reGrayscale משמיט צבע, מה שגם מרנדר מהר יותר וגם מייצר מפת-סיביות קטנה יותר לקידוד. reNoSmoothImage ו-reNoSmoothPath מדלגים על החלקת קצוות (anti-aliasing) שגם ככה בלתי נראית בקנה מידה של תמונה ממוזערת

function RenderThumbnail(Pdf: TPdf; PageNumber, MaxW, MaxH: Integer): TBitmap;
var
  Scale: Double;
begin
  Pdf.PageNumber := PageNumber;
  // התאם את העמוד בתוך MaxW x MaxH תוך שמירה על יחס רוחב-גובה.
  Scale := Min(MaxW / Pdf.PageWidth, MaxH / Pdf.PageHeight);
  Result := Pdf.RenderPage(0, 0,
    Round(Pdf.PageWidth  * Scale),
    Round(Pdf.PageHeight * Scale),
    ro0, [reGrayscale, reNoSmoothImage], clWhite);
end;

מקרה התמונה הממוזערת מציג גם את הדרך הנקייה יותר לחשוב על קביעת גודל (sizing). במקום לעבור דרך DPI, חשב פקטור קנה-מידה יחיד שמתאים את העמוד לתוך תיבה תוחמת (bounding box) ושומר על יחס הרוחב-גובה, שזה מה שה-Min משני היחסים עושה. עמוד לרוחב (portrait) ועמוד לרוחב (landscape) שניהם מסיימים בתוך אותה תיבה ללא עיוות, ואתה לעולם לא צריך להסיק איזה DPI מתאים ל-"התאם ב-200 על 280." אזהרה אחת לגבי reGrayscale: הוא ממיר תוכן תמונת רסטר (raster) לאפור, אבל מילויים וקטוריים וטקסט שומרים על ערכי הצבע שלהם במנוע, כך שעמוד שהוא ברובו אמנות וקטורית עלול לחזור פחות מונוכרום ממה ששם הדגל מרמז. לקבלת תוצאה אמיתית בגווני אפור מלאים, המרת מפת-הסיביות המרונדרת בעזרת GrayscalePdfBitmap היא הנתיב האמין

אצוות מסמך שלם

להרכיב את זה יחד למסמך מלא זו לולאה על PageCount, כאשר PageNumber מוזז עמוד אחד בכל פעם. העמודים מבוססי-1: עמוד אחד הוא PageNumber := 1, והלולאה רצה עד PageCount כולל, לא PageCount - 1. הדבר הנוסף שהאצווה חייבת לכבד הוא חוזה הטעינה-השקטה (silent-load). הגדרת Active := True לעולם אינה מעלה חריגה (raises) על קובץ פגום או סיסמה שגויה; היא פשוט משאירה את Active במצב False. בדוק זאת לפני שאתה מרנדר עמוד בודד, או שה-RenderPage הראשון יעבוד מול מסמך שמעולם לא נפתח

procedure ExportAllPages(const PdfPath, OutDir: string; Dpi, Quality: Integer);
var
  Pdf: TPdf;
  I, Digits: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := PdfPath;
    Pdf.Active := True;
    if not Pdf.Active then
      raise Exception.Create('Could not open ' + PdfPath);

    Digits := Length(IntToStr(Pdf.PageCount));   // רפד באפסים כך שהקבצים ימוינו נכון
    for I := 1 to Pdf.PageCount do
      SavePageAsJpeg(Pdf, I, Dpi, Quality,
        Format('%s\page_%.*d.jpg', [OutDir, Digits, I]));
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

הריפוד-באפסים (zero-padding) דרך Digits הוא דבר קטן שחוסך אחר-צהריים מאוחר יותר. קרא לקבצים בשמות page_1.jpg עד page_10.jpg וכל כלי שממיין אותם כמחרוזות שם את page_10 מיד אחרי page_1, ומשבש את הסדר. ריפוד לרוחב של מספר העמוד הגבוה ביותר, כך שמסמך בן 300 עמודים יניב page_001.jpg, שומר על סדר לקסיקלי וסדר עמודים זהים בכל מקום במורד הזרם (downstream)

עבור מסמכים גדולים מספיק כדי שההמרה תיקח זמן ניכר, הרץ אותה מחוץ ל-UI thread או שאב הודעות (pump messages) בין העמודים כך שהאפליקציה תישאר רספונסיבית, ותן למשתמש דרך לעצור. אם אתה מרנדר עמודים גדולים מאוד ורוצה ביטול שנושך באמצע העמוד ולא רק בין העמודים, ל-PDFium Component יש נתיב רינדור פרוגרסיבי (progressive render) עם אסימון ביטול (cancellation token); זהו מנגנון כבד יותר ממה שרוב ייצואי האצווה צריכים, אבל הוא שם כשהעמוד הבודד ב-600 DPI איטי מספיק כדי לחסום

זיווג (pairing) אחרון שכדאי להכיר. ריסטור (Rasterizing) של עמוד משליך את שכבת הטקסט שלו: ה-JPEG הוא פיקסלים, והמילים שבו כבר אינן ניתנות לבחירה או לחיפוש. כאשר אתה זקוק גם לתמונה וגם לטקסט שמתחתיה, רנדר עבור התמונה ומשוך את הטקסט בנפרד, מה שהמאמר המלווה בנושא חילוץ טקסט ממסמכי PDF עם PDFium Component מכסה. העמסות ה-RenderPage (overloads) ואפשרויות הרינדור המוצגות כאן הן חלק מ-PDFium Component עבור Delphi ו-C++Builder