מאמר טכני

כיצד פועלת גרפיקת PDF: זרמי תוכן (Content Streams) ואופרטורים

דף PDF אינו מאחסן פיקסלים, והוא אינו מאחסן עץ של אובייקטי צורה כפי ש-SVG עושה. הוא מאחסן תוכנית. כל קו, עקומה, מילוי, ותמונה הממוקמת על הדף הם התוצאה של ביצוע רצף של אובייקטים (operators) בזרם תוכן, מלמעלה למטה, כנגד מצב גרפי רץ. הבן עובדה אחת זו ורוב ההתנהגות של הפורמט מפסיקה להיות מפתיעה: מדוע מילוי זקוק לאופרטור צביעה נפרד לאחר בניית הנתיב, מדוע צבעים ורוחבי קווים דולפים מצורה אחת לאחרת אלא אם כן אתה סוגר אותם בסוגריים, מדוע אותו קוד ציור יכול לנחות במקומות שונים לחלוטין לאחר שינוי קואורדינטה יחיד. זהו סיור במודל ביצוע זה כפי שהוגדר ב-ISO 32000: האופרטורים שתפגוש כאשר תפתח זרם תוכן, והכללים המחליטים מה מופיע על הדף

זרם התוכן הוא בייטקוד פוסטפיקס (postfix bytecode)

זרם תוכן הוא רצף בתים שטוח של אופרנדים (operands) ואחריהם אופרטורים. אופרנדים באים קודם, האופרטור שצורך אותם בא אחרון, וזה ההפך מקריאה לפונקציה וזהה למכונת מחסנית (stack machine): דחוף (push) את המספרים, ואז הנפק את הפועל. אין קינון (nesting), אין תחביר ביטויים, אין משתנים. קו מתאר של משולש הוא חמש שורות מזה:

100 100 m    % moveto: התחל תת-נתיב חדש ב- (100, 100)
200 200 l    % lineto: הוסף קטע ל- (200, 200)
300 100 l    % lineto: הוסף קטע ל- (300, 100)
h            % closepath: חבר בחזרה להתחלה
S            % stroke: צבע את קו המתאר של הנתיב

האופרטורים הם תמציתיים (terse) בכוונה. דף אמיתי הוא אלפים מאלה, בדרך כלל דחוסים עם FlateDecode. העלות של הקומפקטיות הזו היא שהזרם אינו נושא שום מבנה שאתה יכול לתשאל: מציג אינו יכול לשאול "היכן הכותרת בדף זה", הוא יכול רק להריץ את התוכנית ולראות איזה דיו נוחת איפה. זוהי סיבת השורש לכך שחילוץ טקסט מקובצי PDF אקראיים הוא קשה

נקודת המוצא (origin) היא שמאלית-תחתונה, וציר ה-Y צומח כלפי מעלה

לפני שקואורדינטה כלשהי הגיונית, עליך לדעת היכן נמצא (0, 0). PDF שם את נקודת המוצא בפינה השמאלית-התחתונה של הדף, כאשר ציר ה-X גדל ימינה וציר ה-Y גדל כלפי מעלה, נמדד בנקודות (points) של 72 נקודות לאינץ' (ISO 32000-2 §8.3.2). על דף US Letter הקצה העליון יושב ב- y = 792, לא ב- y = 0. כל מי שמגיע מגרפיקת מסך, שבה נקודת המוצא היא שמאלית-עליונה וציר ה-Y גדל כלפי מטה, מבין את זה הפוך בניסיון הראשון ומצייר את הקו הראשון מחוץ לתחתית הדף. היחידה גם בלתי תלויה במדיה: 72 יחידות הן אינץ' אחד בין אם הדף מרונדר למסך טלפון או למכונת צילום (imagesetter)

רוב ספריות ציור-הדפים יורשות מוסכמה זו ישירות. ב-HotPDF, למשל, TextOut וקריאות הנתיב כולם מודדים מהשמאלית-התחתונה בנקודות, כך שערך הקרוב לגובה הדף שם תוכן בראש הדף:

// HotPDF, Delphi: y נמדד מהקצה התחתון כלפי מעלה, בנקודות
Pdf.CurrentPage.SetLineWidth(2.0);
Pdf.CurrentPage.MoveTo(100, 700);   // קרוב לראש הדף
Pdf.CurrentPage.LineTo(300, 700);
Pdf.CurrentPage.Stroke;             // פולט את אופרטורי moveto/lineto/stroke

רצף הקריאה הזה מהודר (compiles down) בדיוק לאופרטורי m, l ו-S שלמעלה. הספרייה היא קלדנית עבור זרם התוכן, לא יותר, ולדעת מה היא פולטת זה מה שמאפשר לך להסיק על הפלט כאשר צורה נוחתת במקום שלא ציפית

בנה את הנתיב, ואז צבע אותו

PDF מפריד בניית נתיב מצביעת נתיב, וההפרדה אינה פדנטיות (pedantry). ראשית אתה מתאר צורה עם אופרטורי בנייה שאינם מוסיפים שום דבר נראה לעין, ואז מנפיק אופרטור צביעה יחיד שמחליט מה לעשות עם הנתיב המצטבר. אותו משולש יכול להיות קו מתאר, מילוי מלא, או שניהם, בתלות אך ורק בפועל איתו אתה מסיים

אופרטורי הבנייה הם מעטים. m מתחיל תת-נתיב חדש בנקודה. l מוסיף קטע ישר. c מוסיף עקומת בזייה קובית (cubic Bezier curve) משישה אופרנדים, שתי נקודות בקרה ונקודת קצה. re הוא קיצור דרך שמוסיף מלבן שלם מרביעיית x, y, רוחב, גובה. h סוגר את תת-הנתיב הנוכחי בחזרה להתחלתו. אף אחד מהם לא שם דיו על הדף; הם רק צוברים גיאומטריה

200 250 m                    % מתחיל את תת-הנתיב
300 350 400 450 500 250 c    % עקומת בזייה קובית: שתי נקודות בקרה, ואז נקודת קצה
150 200 re                   % מלבן של 150 x 200, שנוסף כתת-נתיב משלו
h                            % סגור

הדוגמה המקורית השתמשה בווריאנט ה-y המיושן כעת של אופרטור העקומה; c עם שלוש הנקודות המפורשות שלו היא הצורה שתראה בפועל וזו שכדאי להושיט אליה יד. ברגע שהנתיב קיים, אופרטור צביעה אחד מסיים אותו. אוצר המילים קטן ושווה שינון, מכיוון שכל צורה בכל דף מסתיימת באחד מאלה:

  • S צובע (strokes) את קו המתאר של הנתיב באמצעות רוחב הקו וצבע הקו הנוכחיים
  • f ממלא את הפנים באמצעות צבע המילוי הנוכחי וכלל הליפוף השונה מאפס (nonzero winding rule)
  • f* ממלא באמצעות כלל הזוגי-אי-זוגי (even-odd rule), מה שחשוב עבור צורות החותכות את עצמן וצורות עם חורים
  • B ממלא ואז מצייר קו בפעולה אחת; b סוגר את הנתיב קודם
  • n לא צובע כלום, וכך נתיב הופך לאזור חיתוך (clip region) מבלי להשאיר סימן נראה לעין

כלל הליפוף (winding rule) הוא החלק שאנשים טועים בו. שונה מאפס (nonzero, f, B) סופר את החיתוכים המסומנים (signed crossings) של קרן מנקודת הבדיקה וממלא בכל מקום שהספירה אינה אפס, כך שחור נשאר ריק רק אם תת-הנתיב שלו מתלפף הפוך לחיצוני. זוגי-אי-זוגי (even-odd, f*, B*) מתחלף בכל חיתוך ללא קשר לכיוון. אם צורת "סופגנייה" יוצאת מלאה, העיגול הפנימי מלופף באותו כיוון כמו החיצוני, ואתה או הופך אותו או עובר לזוגי-אי-זוגי

צבע הוא מצב (mode), לא פרמטר

צבע בזרם תוכן הוא דביק. אתה מגדיר צבע והוא נשאר מוגדר עד שתגדיר אחר או תשחזר מצב מוקדם יותר, וזו הסיבה ששינוי צבע ללא סוגריים צובע בשקט כל דבר שמצויר אחריו. PDF גם שומר את צבע המילוי וצבע הקו (stroke color) כשתי הגדרות עצמאיות, עם אופרטורים באותיות קטנות למילוי ואותיות גדולות לקו. למרחבי צבעי המכשיר (device color spaces) יש קיצורי דרך משלהם:

0.5 g                % מילוי DeviceGray, אפור בינוני (0 = שחור, 1 = לבן)
0.2 0.6 0.8 rg       % מילוי DeviceRGB
0.8 0.2 0.1 RG       % קו DeviceRGB (אותיות גדולות = stroke)
0.2 0.8 0.0 0.1 k    % מילוי DeviceCMYK

DeviceRGB מתאים לפלט מסך, DeviceCMYK הוא מה שהפקת דפוס מצפה לו, ו-DeviceGray היא הבחירה הקטנה ביותר עבור תוכן מונוכרום. מרחבי המכשיר נוחים אך אינם מכוילים (uncalibrated): אותה שלשת RGB יכולה להירנדר שונה על שני צגים, שזו הבעיה שמרחבי צבע מבוססי ICC וכוונות פלט של PDF/A קיימים כדי לפתור. עבור עבודה קריטית-לצבע אתה בוחר מרחב מכויל עם cs ו-CS וקובע רכיבים עם sc ו-scn, אך עבור מסמכים רגילים קיצורי הדרך של המכשיר נושאים בעומס. ספרייה עוטפת אלה בקריאות מוקלדות. HotPDF, למשל, לוקחת TColor יחיד ופולטת את האופרטורים התואמים:

Pdf.CurrentPage.SetRGBFillColor(clRed);
Pdf.CurrentPage.Rectangle(100, 100, 200, 150);  // x, y, רוחב, גובה
Pdf.CurrentPage.Fill;

Pdf.CurrentPage.SetRGBFillColor(RGB(0, 255, 0));
Pdf.CurrentPage.Circle(150, 400, 50);           // x, y, רדיוס
Pdf.CurrentPage.Fill;

המצב הגרפי (graphics state) ומחסנית ה-q/Q

כל מה שאינו הנתיב עצמו חי במצב הגרפי (graphics state): מטריצת הטרנספורמציה הנוכחית, צבעי מילוי וקו, רוחב קו, תבנית מקווקוות (dash pattern), אזור חיתוך, אלפא. המצב הוא גלובלי וניתן לשינוי (mutable), כך שהדרך הבטוחה היחידה לבצע שינוי מקומי היא לשמור את הכל, לשנות אותו, לצייר, ולגלגל בחזרה (roll it back). זה מה ש-q ו-Q עושים. q דוחף עותק של המצב הנוכחי למחסנית (stack); Q מקפיץ (pops) אותו, וזורק כל שינוי שנעשה מאז ה-q התואם

q                    % שמור את המצב הגרפי כולו
2 0 0 2 100 100 cm   % שרשר טרנספורמציה: קנה מידה פי 2, תרגם ל- (100,100)
0.8 g                % מילוי אפור, בטווח (scoped) בלוק זה
% ... צייר תוכן אפור שעבר שינוי קנה מידה ...
Q                    % שחזר: טרנספורמציה וצבע חוזרים לקדמותם

q ו-Q לא מאוזנים הם דרך נפוצה בה זרם תוכן שנבנה ידנית או נתפר משתבש. q תועה ללא Q תואם משאיר את המחסנית עמוקה כאשר הדף מסתיים; Q נוסף גורם לגלישה כלפי מטה (underflows). כך או כך מציג עשוי לשמור על אזור חיתוך או טרנספורמציה ישנים בתוקף, ותוכן נעלם או נוחת במקום הלא נכון. כאשר גרפיקה נעלמת ללא סיבה שהנתיב יכול להסביר, בדוק (audit) את מחסנית המצב קודם

ה-CTM מבצע טרנספורמציה על כל קואורדינטה

מטריצת הטרנספורמציה הנוכחית יושבת בין המספרים באופרטורים שלך לבין הדף בפועל. כל קואורדינטה מוכפלת ב-CTM לפני שכל דבר מצויר, ולכן שינוי המטריצה משנה היכן וכיצד כל הציורים הבאים יופיעו מבלי לגעת בקואורדינטת נתיב אחת. האופרטור cm משרשר מטריצה חדשה על הנוכחית, לוקח שישה אופרנדים הממפים למטריצה האפינית [a b c d e f]:

1 0 0 1 100 50 cm        % תרגם (translate) ב- (100, 50): e ו-f נושאים את הסטייה (offset)
2 0 0 1.5 0 0 cm         % שנה קנה מידה x פי 2, y פי 1.5: a ו-d הם גורמי שינוי קנה המידה
0.707 0.707 -0.707 0.707 0 0 cm   % סובב 45 מעלות (cos/sin ב-a, b, c, d)

שני דברים מכשילים אנשים. ראשית, cm מרכיב (composes) ולא מחליף, כך שטרנספורמציות מצטברות והסדר קובע: שינוי קנה מידה ואז תרגום (translating) אינו זהה לתרגום ואז שינוי קנה מידה. שנית, סיבוב (rotation) ושינוי קנה מידה סובבים (pivot) סביב נקודת המוצא הנוכחית, לא מרכז הצורה שלך, כך שכדי לסובב משהו במקום אתה מתרגם אותו לנקודת המוצא, מסובב, ואז מתרגם בחזרה, כולם עטופים ב-q/Q. אותה מטריצה זו היא שקובעת את המיקום של תמונות, החתיכה האחרונה ששווה לראות

תמונות ותוכן רב-פעמי הם XObjects

תמונות רסטר (Raster images) אינן חיות בתוך-השורה (inline) בזרם התוכן. הן מאוחסנות כ-image XObjects, אובייקטים חיצוניים עם מילון משלהם המתאר רוחב, גובה, עומק סיביות, מרחב צבע, ומסנן דחיסה, וזרם התוכן רק מפנה אליהם. תמונה מגובה JPEG מכריזה על עצמה כך:

/Photo <<
  /Type /XObject
  /Subtype /Image
  /Width 640
  /Height 480
  /BitsPerComponent 8
  /ColorSpace /DeviceRGB
  /Filter /DCTDecode        % נתוני התמונה הם זרם JPEG
>>

XObject תמונה מצייר לתוך ריבוע היחידה (unit square): הוא תמיד תופס את האזור שבין (0, 0) ל- (1, 1) במרחב המשתמש. אתה לא מעביר לו מיקום או גודל. במקום זאת אתה מגדיר את ה-CTM כך שריבוע היחידה ימופה למלבן שאתה רוצה, ואז מפעיל (invoke) אותו עם Do. זו הסיבה שהצבת תמונה היא תמיד טרנספורמציה ואחריה הפעלה, עטופות בשמירה/שחזור (save/restore) כדי שקנה המידה לא ידלוף לפעולה הבאה:

q
640 0 0 480 50 300 cm    % ממפה את ריבוע היחידה לתיבה בגודל 640x480 במיקום (50, 300)
/Photo Do                % צבע את ה-XObject של התמונה
Q

אותו מנגנון Do מניע גם XObjects של טופס (form XObjects), המחזיקים נתח רב-פעמי של גרפיקה, לוגו או חותמת חוזרת, כזרם תוכן משלהם עם תיבה תוחמת. הגדר אותו פעם אחת, הפעל אותו פעמים רבות עם CTM שונה, והבתים מופיעים בקובץ פעם אחת בלבד. רוב הספריות מסתירות זאת מאחורי קריאת מיקום יחידה: HotPDF רושם מפת סיביות (bitmap) עם AddImage וממקם אותה עם ShowImage, לוקח x, y, רוחב וגובה מפורשים במקום לבקש ממך לבנות את המטריצה ביד:

var
  Bmp: TBitmap;
  ImgIndex: Integer;
begin
  Bmp := TBitmap.Create;
  try
    Bmp.LoadFromFile('logo.bmp');
    ImgIndex := Pdf.AddImage(Bmp, icFlate);
    // x, y (שמאלית-תחתונה), רוחב, גובה, זווית סיבוב
    Pdf.CurrentPage.ShowImage(ImgIndex, 50, 300, 200, 150, 0);
  finally
    Bmp.Free;
  end;
end;

מתחת לשורה אחת זו הספרייה כותבת את מילון ה-XObject של התמונה, מגדירה את ה-CTM לגודל ולמיקום ריבוע היחידה, ופולטת Do. המודל שמתחת הוא זה ששווה להכיר, מכיוון שהוא מסביר כל תוצאה מוזרה: תמונה מתוחה היא CTM עם גורמי קנה מידה שאינם מתאימים, לוגו זהה על ארבעים עמודים הוא form XObject אחד שהופעל ארבעים פעם, ותמונה שמרונדרת הפוך (upside down) היא היפוך סימן (sign flip) במטריצה, לא קובץ פגום

לאן זה מוביל

המודל הגרפי הוא קטן ברגע שאתה רואה את הצורה שלו. זרם תוכן הוא בייטקוד פוסטפיקס הרץ נגד מצב שניתן לשינוי (mutable state); קואורדינטות מתחילות בשמאלית-התחתונה ועוברות דרך ה-CTM; נתיבים נבנים בשקט ונצבעים עם אופרטור מכוון אחד; הגדרות צבע וקו נמשכות עד שתסגור אותן בסוגריים עם q/Q; תמונות וגרפיקה רב-פעמית הם XObjects הממוקמים על ידי ביצוע טרנספורמציה על ריבוע יחידה. כמעט כל תוצאת רינדור מבלבלת מצטמצמת לאחד מחמשת הכללים הללו. אם ברצונך לראות כיצד אופרטורים גרפיים אלה יושבים בתוך מודל האובייקטים הגדול יותר, מילוני הדפים וטבלת ההפניה המקושרת שמצביעים אליהם, הסקירה הטכנית של מבנה קובץ PDF מכסה את השכבה הזו, ובניית PDF פשוט מאפס הולכת דרך הבתים מקצה לקצה. ציור טקסט חי במשפחת אופרטורים משלו ויש לו מלכודות משלו, המכוסות במאמר המלווה על טיפול בטקסט וגופנים ב-PDF

קריאות הציור של Delphi המוצגות כאן, MoveTo, LineTo, Stroke, Rectangle, Fill, SetRGBFillColor, AddImage ו-ShowImage, הן חלק מ-HotPDF Component עבור Delphi ו-C++Builder, שפולט אופרטורים אלה של זרם תוכן עבורך