כדי לצמצם את גודל קובצי ה-PDF בדלפי, ספריית PDF של losLab מספקת שלושה ממשקי API התוקפים את שלושת המקורות הגדולים ביותר לנפיחות: SubsetEmbeddedFonts משכתב כל תוכנית גופן TrueType מוטמעת ומקטין אותה רק לגליפים שהמסמך אכן מרנדר, DownsampleImages דוגם מחדש תמונות רסטר החורגות מ-DPI היעד, ו-NormalizeLZWStreams מחליף את דחיסת ה-LZWDecode המיושנת ב-FlateDecode. כל אחד מהם מחזיר את מספר האובייקטים ששינה, כך שאפס מעיד על כך שהמעבר היה ללא פעולה (no-op) ולא כשל שקט
מדוע קובץ ה-PDF הממוזג שלי גדול יותר מקובצי המקור שלו?
קובץ PDF ממוזג או כזה שנוצר באופן תכנותי הוא בדרך כלל גדול מדי מאחת משלוש סיבות: גופנים מוטמעים במלואם, תמונות שנדגמו הרבה מעבר לרזולוציית התצוגה שלהן, וזרמים שעדיין דחוסים באמצעות מסנן ה-LZW הישן. תקן ISO 32000-1 §9.9 מאפשר למפיק להטמיע את תוכנית הגופן המלאה, ורוב המפיקים עושים בדיוק את זה מכיוון שזהו ברירת המחדל הבטוחה. קובץ FontFile2 מלא של Arial מגיע למאות קילובייטים; הטמיעו אותו בתריסר קובצי מקור, מזגו אותם, ותקבלו תריסר עותקים של קווי מתאר של גליפים עבור תווים שאף אחד לא הקליד. המיזוג עצמו אינו מייצר את הבזבוז, הוא רק מרכז אותו לקובץ יחיד שבו הסכום הכולל הופך לבסוף לגלוי
תמונות הן הגורם המפר השני. סריקה ברוחב 4800 פיקסלים הממוקמת במסגרת של רבע עמוד מעבירה פי 40 יותר נתוני פיקסלים ממה שצינור הדפסה של 300 DPI יכול לנצל. הגורם השלישי שקט יותר: זרמים המסוננים באמצעות LZWDecode. תקן ISO 32000-1 §7.4.4 מגדיר הן LZWDecode והן FlateDecode, ומציין ש-Flate בדרך כלל משיג דחיסה טובה לפחות באותה מידה; בפועל פלט Flate קטן יותר בעקביות על אותם נתונים, ו-LZW שורד בעיקר בקבצים שעברו דרך כלים משנות ה-90 בשלב כלשהו בהיסטוריה שלהם. המשך מאמר זה מלווה את שלושת המעברים של ספריית PDF של losLab הפותרים כל בעיה, ולאחר מכן משלב אותם לצינור עיבוד אחד
יצירת תת-קבוצות של גופנים באמצעות SubsetEmbeddedFonts
SubsetEmbeddedFonts מכווץ כל גופן TrueType מוטמע במסמך טעון לתווים שהמסמך משתמש בהם בפועל, והוא אינו זקוק לארגומנטים מכיוון שהוא מפיק את רשימת השימור מזרמי התוכן עצמם. באופן פנימי, המעבר עובר על זרם התוכן של כל עמוד בעזרת GetTextRuns, אוסף את קודי התווים המופנים תחת כל משאב גופן, בונה רשימת שימור, ומעביר את תוכנית הגופן המקורית למנוע ה-FontSub של Windows (הפונקציה CreateFontPackage) כדי לייצר תת-קבוצה. התוכנית המשוכתבת מחליפה את זרם ה-FontFile2 במקומו, ושם ה-BaseFont מקבל את התגית LOSABC+, מוסכמת ששת האותיות הגדולות וסימן הפלוס המוגדרת בתקן ISO 32000-1 §9.6.4 עבור גופני תת-קבוצה. קידומת זו היא גם מה שהופך את הקריאה לאידמפוטנטית (idempotent): הריצו את המעבר פעמיים, וגופנים שכבר עברו תת-קבוצה יזוהו וידולגו, כך ששילובו במשימת אצווה שעשויה לבקר שוב בקבצים הוא בטוח
var
Lib: TPDFlib;
Fonts: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('merged-report.pdf', '') = 1 then
begin
Fonts := Lib.SubsetEmbeddedFonts;
// Fonts = number of FontFile2 programs rewritten;
// 0 means nothing embedded, or everything already subsetted
Lib.SaveToFile('merged-report-subset.pdf');
end;
finally
Lib.Free;
end;
end;
שני פרטי מימוש ראויים להכרה מכיוון שהם מסבירים את גבולות ה-API. ראשית, המעבר מכוון ל-FontFile2, ולכן הוא מכסה תוכניות TrueType מוטמעות; גופנים המוטמעים כ-Type 1 או כ-CFF אחיד (bare CFF) נותרים ללא שינוי כדי לא לקחת סיכונים. שנית, הוא מסתמך על FontSub, מה שהופך את SubsetEmbeddedFonts לזמין ב-Windows בלבד. נקודה מעודנת יותר מהמימוש: השאלה האם גופן מתאים נקבעת על ידי פענוח בפועל של שרשרת ההפניות FontDescriptor ← FontFile2, ולא על ידי הסתמכות על סימון הטמעה (embedded-flag heuristic), מכיוון שגופנים במסמך טעון מעולם לא עברו את הרישום בצד היצירה שמגדיר דגלים כאלה. אם הזרם המפוענח קיים, הגופן מועמד; אם לא, הוא מדולג ללא שגיאה
הפשרה האמיתית: גופן תת-קבוצה מכיל רק את הגליפים שהיו קיימים בזמן יצירת תת-הקבוצה. אם כלי המשך, או הקוד שלכם, יוסיפו מאוחר יותר טקסט באותו גופן, לכל תו שמחוץ לתת-הקבוצה לא יהיה קו מתאר והוא יתרנדר כגליף חסר. בצעו יצירת תת-קבוצות כשלב האחרון לשינוי התוכן, לעולם לא לפני שלב העריכה. אותה זהירות נדרשת אם אתם מתכננים לשלוף את הגופן מאוחר יותר לשימוש חוזר; המאמר על חילוץ טקסט, תמונות וגופנים עם PDFlibPas מכסה מה תוכנית תת-קבוצה מחולצת יכולה ולא יכולה לתעוד לכם
כיצד DownsampleImages מחליט אילו תמונות לכווץ?
DownsampleImages(MaxDPI, Quality, Filter) דוגם מחדש רק את התמונות שהוא יכול לקבוע בביטחון שהן בעלות דגימת-יתר, תוך שימוש בהערכת DPI שמרנית במכוון. אובייקט XObject של תמונת PDF שומר ממדי פיקסלים אך לא רזולוציה פיזית אמינה, וכל תגית DPI מתמונת המקור כמעט ולא שורדת מחזור של טעינה-עריכה-שמירה. לכן המעבר מעריך SrcDPI = PixelWidth / 8.5, ובפועל שואל: אם תמונה זו הייתה משתרעת על פני כל הרוחב של עמוד Letter, מה הייתה הרזולוציה שלה? רק תמונות שההערכה שלהן חורגת מ-MaxDPI זוכות לטיפול. ההטיה היא מכוונת: תמונה הממוקמת קטנה על הדף היא בעלת DPI אמיתי גבוה יותר מההערכה, ולכן המעבר מופעל בחסר במקום לפגוע בנכס באיכות הדפסה שאינו יכול למדוד
Quality בין 1 ל-100 קובע את איכות הקידוד מחדש של JPEG, בעוד ש-0 שומר על הפלט כ-Flate ללא אובדן נתונים בסגנון PNG; Filter בוחר את ליבת הדגימה מחדש, 0 עבור ממוצע תיבה (box average) ו-1 עבור ביליניארי (bilinear). עבור ניירת משרדית סרוקה, DownsampleImages(150, 75, 1) הוא נקודת התחלה הגיונית; עבור כל דבר שעשוי להיות מודפס מחדש, העלו את MaxDPI ל-300 או דלגו על המעבר לחלוטין. הפחתת הרזולוציה היא השלב היחיד מבין השלושה שיש בו אובדן נתונים (lossy), ולכן הוא שייך להגדרה שהמשתמשים שלכם יכולים לכבות
המרת זרמי LZW ישנים באמצעות NormalizeLZWStreams
NormalizeLZWStreams הוא הניצחון הקל: הוא מפענח ללא אובדן נתונים כל זרם LZWDecode ודוחס אותו מחדש באמצעות FlateDecode, במקום, ומחזיר את מספר הזרמים שהומרו. הוא מטפל הן בערך יחיד של /Filter /LZWDecode והן ב-LZW המופיע בתוך מערך שרשרת מסננים, שבו רק חוליית ה-LZW מוחלפת ויתר השרשרת נשמרת. פרמטרי הניבוי (Predictor, Columns, Colors, BitsPerComponent) נקראים מתוך ה-DecodeParms של הזרם ומועברים למפענח, כך שנתוני תמונה מקודדי-ניבוי עוברים את הסבב בצורה נכונה. מכיוון ששני המסננים הם קודקים מדויקים ברמת הביטים, הבתים המפוענחים זהים לפני ואחרי; רק דחיסת המיכל משתנה, וזו הסיבה שמעבר זה בטוח להרצה ללא תנאי על כל קובץ
במסמך ללא זרמי LZW הקריאה פשוט מחזירה 0 ואינה נוגעת בדבר, מה שסוויטת הבדיקות של הספרייה בודקת במפורש: קובץ שנוצר לאחרונה המכיל Flate בלבד חייב לדווח על אפס המרות. ערובה זו לאי-פעולה (no-op) חשובה כאשר המעבר נמצא בצינור עיבוד המעבד אלפי קבצים הטרוגניים, חלקם משנת 2024 וחלקם משנת 1998
צינור אופטימיזציית הגודל המלא בדלפי
שלושת המעברים משתלבים לפונקציה אחת של טעינה-אופטימיזציה-שמירה, והסדר משפיע פחות ממה שניתן לצפות מכיוון שהם פועלים על סוגי אובייקטים נפרדים: גופנים, אובייקטי תמונה (XObjects) ומסנני זרם. הרצת יצירת תת-קבוצות תחילה היא עדיין הבחירה המסודרת, מכיוון שזהו המעבר שיש לו מגבלת סדר עריכה
function OptimizePDF(const Src, Dst: string): Boolean;
var
Lib: TPDFlib;
Fonts, Images, Streams: Integer;
begin
Result := False;
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile(Src, '') <> 1 then
Exit;
Fonts := Lib.SubsetEmbeddedFonts; // TrueType FontFile2 -> subset
Images := Lib.DownsampleImages(150, 75, 1); // >150 DPI -> JPEG q75, bilinear
Streams := Lib.NormalizeLZWStreams; // LZWDecode -> FlateDecode
Result := Lib.SaveToFile(Dst) = 1;
// Log Fonts/Images/Streams: three zeros mean the file was already lean
finally
Lib.Free;
end;
end;
אמתו את צינור העריכה כפי שהספרייה מאמתת את עצמה: בסבב מלא (round-trip). בדיקות הרגרסיה של גרסה v3.130 יוצרות מסמך, שומרות אותו, טוענות אותו מחדש, מריצות את האופטימיזציה, שומרות שוב, ואז מצהירות (assert) על שלושה דברים: הפלט קטן יותר, הספירות המוחזרות תואמות לציפיות, וטעינה מחדש של הקובץ שעבר אופטימיזציה עדיין מפענחת ומציגה אותו. שחזור לולאת יצירה-אופטימיזציה-טעינה זו מול דגימה של קובצי הייצור שלכם, והשוואת הטקסט המחולץ לפני ואחרי, הם השקעה של שעה אחת שמונעת טעויות אינטגרציה הרבה לפני שלקוח יפתח חשבונית פגומה
// Round-trip check: the optimized file must still load cleanly
Lib := TPDFlib.Create;
try
Assert(Lib.LoadFromFile('merged-report-opt.pdf', '') = 1);
Assert(Lib.GetPageCount > 0);
finally
Lib.Free;
end;
היכן משתלב צינור האופטימיזציה בזרימת עבודה של מיזוג? לאחר המיזוג, ולא במהלך המיזוג. מיזוג תחילה ואופטימיזציה של התוצאה היחידה פירושם שכל גופן מוטמע עובר תת-קבוצה פעם אחת מול איחוד כל התווים שבשימוש, במקום לכל קובץ מקור בנפרד. אם קצב המיזוג הוא צוואר הבקבוק, PDFlibPas מציעה נתיב מהיר ברמת הבתים הנמנע מפענוח אובייקטים מלא, המתואר במאמר על מיזוג PDF מהיר עם הסטת הפניות בתים; ועבור קלטים גדולים מדי מכדי להחזיקם במלואם בזיכרון, מיזוג ופיצול PDF גדולים בגישה ישירה מכסה את נתיב ההזרמה. שניהם משתלבים באופן טבעי עם מעבר אופטימיזציה סופי על הפלט הממוזג
מה ששלושת המעברים לא יעשו
שלישיית האופטימיזציה של ספריית PDF של losLab אינה כוללת במכוון כל דבר המשנה את הסמנטיקה של המסמך. SubsetEmbeddedFonts אינו מאחד גופנים כפולים על פני מקורות ממוזגים לתוכנית אחת, אלא מכווץ כל אחד מהם באופן עצמאי; מניעת כפילויות היא טרנספורמציה שונה ומסוכנת יותר. DownsampleImages ידלג על תמונה שהערכת ה-DPI השמרנית שלה נשארת מתחת לסף גם כאשר בן אנוש יכול לזהות שהיא גדולה מדי למסגרת שלה. ואף אחד מהמעברים אינו נוגע במבנה המסמך, כך שקובץ הנפוח מאלפי אובייקטים יתומים זקוק לשמירה בסגנון שכתוב ולא למעברים אלה ברמת הזרם. בתוך גבולות אלה, השילוב של יצירת תת-קבוצות של גופנים, הפחתת רזולוציה של תמונות ונרמול LZW-ל-Flate מסיר את שלושת המקורות הקלאסיים לנפיחות PDF באמצעות קריאת API צפויה אחת לכל אחד. שלוש הפונקציות מסופקות כחלק מ-losLab PDF Library עבור דלפי, C# ו-VB.NET, לצד ממשקי ה-API למיזוג, חילוץ ורינדור שנדונו לעיל