מאמר טכני

דגימה מחדש אדפטיבית של תמונות PDF ב־Delphi עם PDFiumPas

שבוע אחרי שתכונת הדחיסה יוצאת מגיעות שתי תלונות: לחוזה הסרוק יש עכשיו קווי מתאר של אותיות מדורגים ופרוותיים, והלוגו השקוף בעמוד השער יושב בתוך הילה חיוורת. PDFiumPas עונה על שתיהן במקום אחד. TPdf.OptimizeImages מודד כל תמונה לפני שהוא מקטין אותה, ואז בוחר גרעין דגימה מחדש וצובר צבע בצורה מודעת־אלפא

זה לא היה כך תמיד. לפני v3.100.0 אותה שיטה ביצעה דגימת יתר למטה לכל תמונה שאינה דו־מפלסית בצעד שכן קרוב קבוע — בדיוק האלגוריתם שמייצר את שתי התלונות: הוא דוגם בנקודה פיקסל מקור אחד לכל פיקסל פלט, והוא מתייחס ל־RGB שיושב מתחת לפיקסל שקוף לחלוטין כאילו לקורא אי פעם תהיה אפשרות לראות אותו. שכתוב v3.100.0 מחליף את הנתיב הבודד הזה בחמישה גרעינים, כלל בחירה מדוד ותקציב זיכרון עבודה מפורש

למה דגימה מחדש גורמת לטקסט סרוק להיראות משונן?

כי דגימה בנקודה עונה על השאלה הלא נכונה. כשסריקה של 300 DPI מכוונת מחדש ל־150 DPI, כל פיקסל יעד מייצג בלוק שתיים על שתיים של פיקסלי מקור, והשכן הקרוב שומר אחד מהארבעה ומשליך את השאר. מי מהם שורד תלוי בעיגול, ולכן קצה קו שהיה חלק עם אנטי־קצה (antialiasing) במקור הופך להטלת מטבע לכל פיקסל. התוצאה היא מדרגות האליאסינג הקלאסיות לאורך קצוות הגליפים, ובנוסף מוארה (moiré) באזורי הפוטוהלפתון שבהם הדגימות שהושלכו במקרה נשאו את התבנית. זה חשוב יותר ב־PDF מאשר על המסך כי הנזק הוא קבוע. XObject של תמונה נושא את נתוני הדגימה שלו לצד /Width, /Height ו־/BitsPerComponent (ISO 32000-1 §8.9.5), ודגימה מחדש כותבת מחדש את שלושתם בתוך הקובץ. זום גרוע במציג הוא פריים שאפשר לצייר מחדש, ול־PDFiumPas יש מנגנון נפרד לזה במטמון רינדור וביצועי זום. דגימה מחדש גרועה היא מסמך חדש שאתם מוסרים ללקוח

למה דגימת שכן קרוב הורסת טקסט סרוק ב־PDFiumPas ל־Delphi: כל פיקסל פלט שומר אחד מארבעה פיקסלי מקור ומשליך את השאר, מה שמייצר קצוות גליף משוננים ומוארה, וחמשת גרעיני הדגימה מחדש מחליפים זאת
דגימה בנקודה שומרת פיקסל מקור אחד לכל פיקסל פלט וזורקת את שלושת האחרים, ולכן PDFiumPas מציע עכשיו חמישה גרעינים במקום אחד

איך PDFiumPas מודד פירוט ובוחר גרעין

PDFiumPas מחליט לכל תמונה, לא לכל מסמך. לפני בחירת גרעין הוא מחשב ציון פירוט לומיננס מנורמל מרשת דגימה חסומה: הצעדים האופקי והאנכי הם (Width + 63) div 64 ו־(Height + 63) div 64, כך שסריקה של 12000 פיקסלים ותמונה ממוזערת של 300 פיקסלים עולים שניהם בערך אותה סריקה של 64 על 64. בכל מיקום דגימה הוא מסכם את ההפרש המוחלט אל השכן מימין ואל השכן מלמטה, על פני עד שלושה ערוצים, ואז מחלק במספר הדגימות כפול 255. הציון נופל בטווח 0 עד 1, כאשר גרפיקה עסקית שטוחה יושבת ליד האפס ומרקם צילומי צפוף מטפס

סולם הבחירה רץ אז בסדר קבוע. אם ResampleFilter הוא כל דבר פרט ל־pirfAdaptive, המסנן ההוא משמש כמות שהוא. אחרת: תוכן בן סיבית אחת מקבל pirfBilevel; ContentClass של piccLineArt מקבל pirfBox; גורם קנה מידה של 4 ומעלה מקבל גם הוא pirfBox, כי בהקטנה כזאת ממוצע שטח הוא גם התשובה הזולה ביותר וגם הנכונה ביותר; piccPhoto, ציון פירוט של 0.08 ומעלה, או PreferredQuality של 0.9 ומעלה מקבלים pirfLanczos עם הגרעין התלת־אונתי שלו; קנה מידה של 2 ומעלה או איכות של 0.7 ומעלה מקבלים pirfBicubic ברדיוס 2; כל השאר מקבל pirfBilinear. מכיוון ש־TPdfImageOptimizeOptions.Default קובע את PreferredQuality ל־0.85, הרצה בברירת מחדל לעולם לא חוזרת ל־bilinear אלא אם ההקטנה עדינה והתוכן שטוח

איך PDFiumPas בוחר גרעין דגימה מחדש ב־Delphi: סריקה חסומה של 64 על 64 מייצרת ציון פירוט מנורמל, ואז סולם תנאים קבוע מנתב כל תמונה אל המסנן bilevel, box, Lanczos, bicubic או bilinear
ציון הפירוט עולה אותו דבר על סריקה של 12000 פיקסלים ועל תמונה ממוזערת, והסולם שתחתיו עוצר בתנאי ההתאמה הראשון
uses
  PDFium;

procedure ShrinkScannedPdf(const InputFile, OutputFile: string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InputFile;
    // ברירות מחדל: TargetDpi 150, MinDpiRatio 1.5, PreserveBilevel True,
    // MinDimension 8, pirfAdaptive, piccAuto, איכות 0.85, תקציב 64 MiB.
    Options := TPdfImageOptimizeOptions.Default;
    Options.TargetDpi := 150;
    Options.MinDpiRatio := 1.5;
    Options.ContentClass := piccAuto;
    Options.PreferredQuality := 0.85;
    if Pdf.OptimizeImages(Options, Report) and (Report.OptimizedCount > 0) then
      Pdf.SaveAs(OutputFile);
  finally
    Pdf.Free;
  end;
end;

תמונה נוגעת רק כאשר הגדול מבין ה־DPI של המיקום האופקי והאנכי שלה, מחולק ב־TargetDpi, מגיע אל MinDpiRatio. השומר הזה קיים כדי שתמונה של 160 DPI המכוונת ליעד של 150 DPI לא תקודד מחדש עבור רווח של שישה אחוזים שעולה דור אחד של איכות. תמונות מתחת ל־MinDimension בכל ציר שהוא, 8 כברירת מחדל, מדולגות בתור אייקונים או קווים

למה לוגואים שקופים מקבלים שוליים לבנים?

כי הצבע מתחת לפיקסל שקוף לחלוטין שרירותי, וממוצע משוקלל פשוט נותן לו זכות הצבעה. מייצאים לוגו מכלי עיצוב והשוליים הבלתי נראים הם לעיתים קרובות לבנים, או שחורים, או כל מה שהיה על הקנבס; ערוץ האלפא מסתיר אותם, וסכום ישר על טביעת הרגל של הגרעין מערבב אותם במהירות בחזרה אל הקצה הנראה. PDFiumPas נמנע מזה בכך שהוא צובר דגימות BGRA בצורה טרומית (premultiplied) ומבטל את ההכפלה הטרומית רק בפיקסל היעד

באופן קונקרטי, כל דגימה תורמת מוסיפה channel * alpha * weight אל צובר הצבע, alpha * weight אל צובר האלפא, ו־weight אל סכום המשקלים. אז צבע היעד מחולק בצובר האלפא ולא בסכום המשקלים, וזה הצעד שחשוב: חלוקה בסכום המשקלים הייתה גוררת את הצבע אל הפיקסלים הבלתי נראים, ואילו חלוקה באלפא הצבורה משחזרת את הצבע שהדגימות הנראות באמת הסכימו עליו. האלפא של היעד היא גודל נפרד, 255 * AlphaSum / WeightSum. פורמטים ללא אלפא מחלקים בסכום המשקלים כרגיל, בית הריפוד של יעד FPDFBitmap_BGRx נכתב כקבוע 255, וכל ערוץ נחתך לטווח 0 עד 255 לפני שמירתו. האלפא הזאת בדרך כלל מקורה ברשומת מסכה רכה במילון התמונה (ISO 32000-1 §11.4), ש־PDFium כבר הרכיב אל חוצץ ה־BGRA שהמדגם המחדש מקבל

איך PDFiumPas מסיר את ההילה הלבנה מתמונות PDF שקופות ב־Delphi: דגימות נצברות בצורה טרומית, וצבע היעד מחולק באלפא הצבורה במקום בסכום המשקלים כך שפיקסלים בלתי נראים אינם יכולים להצביע
חלוקת הצבע הטרומי באלפא הצבורה משחזרת את מה שהדגימות הנראות הסכימו עליו, ואילו חלוקה בסכום המשקלים גוררת את הקצה אל הפיקסלים הבלתי נראים
// הצורה של לולאת הצבירה הפנימית, לכל דגימת מקור תורמת
if SrcFormat = FPDFBitmap_BGRA then
  Alpha := PByte(PAnsiChar(Pixel) + 3)^ / 255
else
  Alpha := 1;
for Channel := 0 to Min(BytesPerPixel, 3) - 1 do
  Accumulated[Channel] := Accumulated[Channel] +
    PByte(PAnsiChar(Pixel) + Channel)^ * Alpha * Weight;
AlphaSum := AlphaSum + Alpha * Weight;
WeightSum := WeightSum + Weight;

// ... ובפיקסל היעד, ביטול ההכפלה הטרומית מול סכום האלפא
if SrcFormat = FPDFBitmap_BGRA then
begin
  if Abs(AlphaSum) > 1E-12 then
    ValueSum := Accumulated[Channel] / AlphaSum
  else
    ValueSum := 0;
end
else
  ValueSum := Accumulated[Channel] / WeightSum;

לשמור אמנות קו בת 1 סיבית מחוץ לאזור האפור

כל גרעין רציף שמיושם על סריקה דו־מפלסית מייצר אפור, ואפור הוא בדיוק מה שתמונה בסגנון פקס אסור לה להכיל. לכן PDFiumPas משאיר תמונות בנות סיבית אחת במנוחה כברירת מחדל: PreserveBilevel הוא True ב־TPdfImageOptimizeOptions.Default, ותמונות כאלה נוחתות ב־SkippedCount ללא פגע. מגדירים אותו ל־False ונתיב ה־pirfBilevel נכנס לפעולה במקום גרעין מחליק. הוא הולך על מלבן המקור המדויק שמכסה כל פיקסל יעד, ממצע לומיננס עם המשקלים 0.114, 0.587 ו־0.299 בסדר הזיכרון BGR, וחותך את התוצאה בסף 127.5 ל־0 או 255 שטוחים. לא ניתן לכתוב שום דבר ביניים, ולכן הקצוות נשארים חדים ולא נוצרת הילה אפורה סביב קווים דקים; ערוץ האלפא של מקור BGRA ממוצע כרגיל, ויעד BGRx מקבל את הקבוע 255. אם אתם צריכים את הפיקסלים עצמם ולא מסמך קטן יותר, חילוץ תמונות ממסמכי PDF הוא הנתיב הנפרד

מה קורה כשתמונה חורגת מתקציב זיכרון העבודה?

היא נשארת בדיוק כפי שהייתה, והיא נספרת. MaxWorkingBytes ברירת המחדל שלו 64 MiB והוא נאכף פעמיים. לפני יצירת מפת הסיביות של היעד, PDFiumPas דוחה את התמונה אם רוחב כפול גובה כפול בתים לפיקסל חורג מהתקציב. אחרי ש־FPDFBitmap_CreateEx מצליח הוא בודק שוב לפי המכפלה האמיתית של המרווח (stride) בגובה, כי ריפוד שורות יכול לדחוף הקצאה מעבר למגבלה שאותה מכפלה נאיבית עברה. כל דחייה הורסת את היעד ולא מחזירה דבר. היו ברורים לגבי הידרדרות שזה מרמז עליה: תמונה שחורגת מהתקציב אינה נדגמת מחדש באיכות נמוכה יותר, והיא אינה מפוצלת לאריחים. המקור נשאר במסמך, BudgetExceededCount ו־SkippedCount עולים שניהם, ולכן הרצה יכולה לדווח על הצלחה בזמן שהמסמך אופטמו רק חלקית. זו התנהגות fail-safe מכוונת, אבל המשמעות היא שהדוח אינו קריאה אופציונלית. קיים גם מצב כשל נפרד: תמונות שאת המפה הסיבית שלהן PDFium לא מסוגל לייצר כלל, כמו CMYK, JPX, JBIG2 או מקורות ממוסכים, מעלות את FailedCount במקום וגם נשארות ללא פגע

procedure OptimizeBatch(const Files: array of string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
  I: Integer;
begin
  Options := TPdfImageOptimizeOptions.Default;
  Options.PreserveBilevel := False;              // הצבעת שטח דו-מפלסית
  Options.ContentClass := piccPhoto;             // כפיית Lanczos לקבוצות צילומים
  Options.MaxWorkingBytes := 256 * 1024 * 1024;  // מרווח לסריקות גדולות
  Pdf := TPdf.Create(nil);
  try
    for I := Low(Files) to High(Files) do
    begin
      Pdf.FileName := Files[I];
      if not Pdf.OptimizeImages(Options, Report) then
      begin
        WriteLn('optimize failed: ', Report.ErrorMessage);
        Continue;
      end;
      if Report.BudgetExceededCount > 0 then
        WriteLn(Files[I], ': ', Report.BudgetExceededCount,
          ' image(s) over budget and kept at full size');
      if Report.FailedCount > 0 then
        WriteLn(Files[I], ': ', Report.FailedCount,
          ' image(s) could not be decoded to a bitmap');
      if Report.OptimizedCount > 0 then
        Pdf.SaveAs(ChangeFileExt(Files[I], '.opt.pdf'));
    end;
  finally
    Pdf.Free;
  end;
end;

לקרוא את הדוח לפני שמשחררים את הקובץ

TPdfImageOptimizeReport נבנה כדי לאבחן אותו, לא רק כדי לרשום אותו. לצד OptimizedCount, SkippedCount ו־FailedCount הוא חושף מונה אחד לכל גרעין, כך ש־BoxFilterCount, BilinearFilterCount, BicubicFilterCount, LanczosFilterCount ו־BilevelFilterCount מספרים לכם למה הכלל האדפטיבי הגיע באמת לגבי אוסף התמונות שלכם. תוצאה שכולה box פירושה שההקטנות היו תלולות או שהתוכן סווג כאמנות קו; תוצאה שכולה Lanczos על מסמך שהאמנתם שהוא אמנות קו היא סימן שכדאי לקבוע את ContentClass במפורש. AverageDetailScore הוא המספר להשוואה מול סף ה־0.08 של Lanczos בכיול PreferredQuality, ו־PeakWorkingBytes מראה כמה מ־MaxWorkingBytes ההרצה באמת צרכה. אפשרויות לא תקפות נכשלות בקול ולא בשקט: TargetDpi שאינו חיובי, MinDpiRatio מתחת ל־1, PreferredQuality מחוץ לטווח 0 עד 1, או MaxWorkingBytes שאינו חיובי מרימים EPdfError לפני שעמוד כלשהו נוגע. וכן, OptimizeImages עורך רק את המסמך בזיכרון; כל עמוד ששונה מחויב עם FPDFPage_GenerateContent, ואחרי זה אתם עדיין קוראים בעצמכם ל־SaveAs. כדי לראות בעין מה השתנה, הציגו את המסמכים לפני ואחרי כמפות סיביות כמתואר בהמרת עמודי PDF לתמונות JPEG והשוו אותם בזום מלא

דגימה מחדש אדפטיבית היא מאותן תכונות שבלתי נראות כשהן עובדות ומייצרות פניות תמיכה כשהן לא, ולכן המדידה, הטיפול באלפא ותקציב הזיכרון היו חייבים לנחות יחד ולא כשלושה שיפורים נפרדים. אם אתם שוקלים את זה למוצר ב־Delphi, C++Builder או Lazarus, כל משטח ה־API ופרטי הרישוי נמצאים בעמוד המוצר PDFiumPas Delphi PDFium Component