מאמר טכני

חותמות דף לשימוש חוזר דרך Form XObjects עם PDFium

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

Form XObject הוא המבנה ש-PDF מספקת כדי למנוע בדיוק את זה. הוא עוטף פיסת תוכן לשימוש חוזר, דף שלם או תבנית קטנה, לאובייקט בעל שם יחיד שניתן לצייר פעמים רבות במיקומים רבים. התוכן חי בקובץ פעם אחת. כל דף שרוצה את החותמת מחזיק בהוראה קצרה שאומרת "צייר XObject N כאן, עם הטרנספורמציה (transform) הזו". סימן מים של מאה עמודים מוסיף אז אובייקט תוכן אחד לקובץ במקום מאה, וזה ההבדל בין מסמך שגדל ליניארית עם ספירת הדפים שלו לבין אחד שלא. סימני מים, חותמות לוגו, תבניות מספרי עמודים, וחותמות רשמיות (seals) הם כולם אותה צורת בעיה, וה-Form XObject הוא הכלי הנכון לכל אחד מהם

מדוע אובייקט מאוחסן אחד מנצח מאה ציורים-מחדש

החיסכון הוא מבני, לא קוסמטי. דף PDF מרונדר על ידי ביצוע זרם התוכן שלו, רצף של מפעילי ציור (drawing operators). כאשר אתם מציירים מחדש חותמת על כל דף, אתם מצרפים (appending) את הרצף המלא של מפעילים עבור החותמת הזו לזרם של כל דף, והבתים משוכפלים כמספר הדפים שיש לכם. Form XObject מעביר את המפעילים הללו לזרם אחד המאוחסן פעם אחת במסמך. ההפניה (reference) שדף בודד שומר היא קטנה: היא דוחפת מטריצת טרנספורמציה, מפעילה את ה-XObject, ומשחזרת מצב. ספירת הדפים כבר אינה מכפילה את עלות עבודת האמנות

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

לכידת דף לתוך XObject

PDFium בונה את האובייקט לשימוש חוזר מדף קיים. המקור הוא דף במסמך כלשהו שפתוח אצלכם, קובץ PDF קטן בן עמוד אחד שאינו מכיל דבר מלבד עבודת האמנות של סימן המים שלכם, או עמוד מסוים של קובץ גדול יותר. CreateXObjectFromPage לוכד את התוכן של עמוד המקור לתוך מאחיז לשימוש חוזר השייך למסמך היעד, זה שאתם מטביעים עליו חותמת

var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
begin
  Dest := TPdf.Create(nil);
  Stamp := TPdf.Create(nil);
  try
    Dest.FileName := 'Report.pdf';
    Dest.Active := True;
    Stamp.FileName := 'Watermark.pdf';   // one page of artwork
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // Capture page 0 of the stamp document into a reusable handle that
    // is owned by Dest. Source must be Active; the index is zero-based.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not build the stamp XObject');
    // ... place it, then free it before closing Stamp (see below) ...

החתימה היא CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject. המתודה מעלה חריגה (raises) אם מסמך המקור אינו Active, והיא מחזירה nil במקום להעלות חריגה כאשר PDFium אינה יכולה לבנות את האובייקט, כך שהבדיקה המפורשת לעיל אינה אופציונלית. המאחיז שחוזר הוא TPdfXObject שבבעלותכם, ושני אילוצי מחזור החיים המוצמדים אליו הם החלק בכל התרגיל הזה שתופס אנשים לא מוכנים, ולכן הם מקבלים חלק משלהם למטה

הצבת החותמת על דף

XObject שנלכד אינו עושה דבר בפני עצמו. כדי לגרום לו להופיע, עליכם להכניס עותק שלו לדף הנוכחי של המסמך, זה שנבחר על ידי מאפיין ה-PageNumber (מבוסס 1), באמצעות InsertFormObjectFromXObject. קריאה זו מחזירה את אובייקט הדף שמתחתיו, שהוא FPDF_PAGEOBJECT, והמאחיז המוחזר הוא הדרך בה אתם ממקמים את ההצבה. ללא טרנספורמציה החותמת נוחתת בנקודת המוצא של קואורדינטות דף המקור, וזה נדיר שזה המקום שבו אתם רוצים אותה

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

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
begin
  // The current page of Dest receives one copy of the XObject.
  PageObj := Dest.InsertFormObjectFromXObject(XObject);
  if PageObj = nil then
    raise Exception.Create('Insert failed on this page');

  // Position it: move 200 units right, 500 up, at 70% scale.
  M := TPdfMatrix.Create;
  try
    M.Scale(0.7, 0.7);
    M.Translate(200, 500);
    RawM := M.Handle;
    if FPDFPageObj_SetMatrix(PageObj, RawM) = 0 then
      raise Exception.Create('Cannot assign the stamp matrix');
  finally
    M.Free;
  end;
  Dest.UpdatePage;   // commit this page's edits to its content stream
  // if not Dest.SaveAs(...) then ... when every page is done.
end;

שני פרטי משק-בית (housekeeping) הופכים זאת לבטוח. ראשית, לאחר ההכנסה, אובייקט הדף שייך לדף, לא ל-XObject. שחרור (freeing) ה-XObject מאוחר יותר אינו מבטל את ההצבות שכבר ביצעתם. זה מה שמאפשר לסדר ה-צור-הצב-שחרר (create-place-free) המתואר להלן לעבוד. שנית, הכנסה ומיקום משנים רק את רשימת האובייקטים של הדף בזיכרון; UpdatePage הוא מה שמעביר בסריאליזציה (serialises) את הרשימה הזו חזרה לזרם התוכן של הדף, כך שדף שתערכו מבלי לקרוא לו נשמר כאילו החותמת מעולם לא הוצבה

כלל מחזור החיים של המאחיז (handle) שנושך אנשים

שני אילוצים מכתיבים את המאחיז של XObject, והתעלמות מכל אחד מהם יוצרת כשל שנראה לא קשור לסיבה שלו. ראשית, מסמך המקור חייב להיות פעיל ברגע שאתם קוראים ל-CreateXObjectFromPage. הלכידה קוראת את התוכן של דף המקור מתוך מסמך המקור החי, כך שמסמך זה והדף שלו צריכים להיות פתוחים ותקפים בעת בניית המאחיז. שנית, וזה האחד שמפתיע אנשים, יש לשחרר (free) את המאחיז לפני שסוגרים את דף המקור, ובפועל לפני שאתם סוגרים או משחררים את מסמך המקור ממנו הוא בא

הסיבה היא שה-XObject הוא הפניה (reference) לתוך מבנה שמסמך המקור עדיין מחזיק בבעלותו. זה אינו עותק מנותק העומד בפני עצמו שתוכלו לשאת אתכם לאחר שהמקור נעלם. אם תסגרו את המקור קודם, המאחיז יישאר מצביע לתוכן שנהרס, כך ששחרורו מאוחר יותר, או כל שימוש אחר בו, יפעל על זיכרון שכבר אינו תקף. התסמין הוא הקלאסי למאחיז מתנדנד (dangling handle): הפרת גישה (access violation) בכיבוי, או השחתה (corruption) לסירוגין שזזה ממקום למקום בהתאם לסדר ההקצאה, עם מחסנית המצביעה על קוד ניקוי (cleanup) במקום על השורה שגרמה לבעיה בפועל. התיקון הוא סידור נכון, לא קוד הגנתי. בנו את ה-XObject, הכניסו אותו לכל דף שזקוק לו, שחררו את ה-XObject, ורק אז סגרו את מסמך המקור. ההורס (destructor) של TPdfXObject משחרר עבורכם את מאחיז PDFium שמתחתיו, כך ששחרור העטיפה בזמן הנכון הוא כל האחריות שלכם

המטריצה, ומה פירוש ששת המספרים שלה

ההצבה היא טרנספורמציה אפינית (affine transform) דו-ממדית, אותה אחת ש-PDF משתמשת בה בכל מקום למיקום תוכן (ISO 32000-1, סעיף 8.3.4). אלו שישה מספרים, הכתובים a, b, c, d, e, f, ו-PDFium חושפת אותם כרשומת FS_MATRIX. הם ממפים נקודה ממרחב האובייקט עצמו אל מרחב הדף:

// x' = a*x + c*y + e
// y' = b*x + d*y + f
//
// a, d : horizontal and vertical scale
// b, c : the shear / rotation terms
// e, f : translation (where the origin lands on the page)

אתם יכולים למלא את ששת הערכים האלה באופן ידני, אבל הרכבה ידנית שלהם היא המקום שבו סיבוב משתבש, מכיוון שסיבוב מערבב את כל הארבעה מתוך a, b, c, d יחד. העטיפה TPdfMatrix, מתוך יחידת FPdfMatrix, מרכיבה את הפעולות הנפוצות עבורכם ומכפילה-לאחר-מכן (post-multiplies) תוך כדי כך, כך ש-Translate, Scale ו-Rotate משתרשרים בסדר שבו אתם קוראים להם. סימן מים אלכסוני הוא סיבוב ואחריו הזזה (translate) למרכוז מחדש; לוגו בפינה הוא שינוי קנה מידה (scale) ואחריו הזזה. כשהמטריצה מוכנה, העתיקו את הערך הגולמי שלה, מאפיין Handle מסוג FS_MATRIX, לתוך משתנה מקומי והעבירו אותו ל-FPDFPageObj_SetMatrix; הייבוא (import) מכריז על המטריצה כעל פרמטר var, לכן לא ניתן להעביר לה מאפיין ישירות, והתוצאה שלה היא 0 במקרה של כישלון. הרמה הנמוכה יותר של FPDFPageObj_Transform, שלוקחת את ששת הערכים ישירות ככפולים (doubles), זמינה כאשר אתם מעדיפים להעביר מספרים במקום לבנות עטיפה

הטבעת כל דף, בסדר הנכון

התבנית המלאה מרכיבה את החלקים יחד בסדר שכלל מחזור החיים דורש. פתחו את שני המסמכים, לכדו את החותמת פעם אחת, עברו על דפי היעד על ידי קביעת ה-PageNumber מבוסס ה-1 בתורו והכנסה בתוספת מיקום של עותק, שמירת כל דף (commit) עם UpdatePage, לאחר מכן שחרור ה-XObject, אז שמירה עם SaveAs, ותנו למסמך המקור להיסגר אחרון

procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
  I: Integer;
begin
  Dest := TPdf.Create(nil);
  Stamp := TPdf.Create(nil);
  try
    Dest.FileName := ASource;
    Dest.Active := True;
    Stamp.FileName := AStamp;
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // 1. Capture the artwork once. Stamp is Active here.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not capture the stamp page');
    try
      // 2. Place a copy on every page of Dest. PageNumber is 1-based.
      for I := 1 to Dest.PageCount do
      begin
        Dest.PageNumber := I;                // make page I current
        PageObj := Dest.InsertFormObjectFromXObject(XObject);
        if PageObj = nil then
          Continue;

        M := TPdfMatrix.Create;
        try
          M.Rotate(45);                      // diagonal watermark
          M.Translate(150, 100);             // nudge into position
          RawM := M.Handle;
          FPDFPageObj_SetMatrix(PageObj, RawM);
        finally
          M.Free;
        end;
        Dest.UpdatePage;                     // commit this page's edits
      end;
    finally
      XObject.Free;                          // 3. free BEFORE Stamp closes
    end;

    // 4. Write the result while Dest is still open.
    if not Dest.SaveAs(AOutput) then
      raise Exception.Create('Could not save ' + AOutput);
  finally
    Stamp.Free;                              // source closes last
    Dest.Free;
  end;
end;

הצורה של בלוקי ה-try היא שעושה את העבודה האמיתית. ה-finally הפנימי משחרר את ה-XObject לפני שהשליטה יכולה להגיע ל-finally החיצוני שמשחרר את Stamp, כך שהמאחיז תמיד משוחרר בעוד המקור שלו עדיין חי, גם אם חריגה מופעלת באמצע הלולאה. דייקו בקינון (nesting) הזה וכלל מחזור החיים ידאג לעצמו

הטבעת חותמת היא פינה אחת מתוך ערכת כלים גדולה יותר לבנייה ועריכה של תוכן דף. אם החותמת שלכם היא תמונה בפני עצמה ולא דף שנלכד, המרת תמונות למסמכי PDF בעזרת PDFium מכסה את הכנסת מפת הסיביות הזו למסמך קודם כל. וכאשר הדבר שאתם רוצים לשאת לצד החותמת הגלויה הוא קובץ במקום דיו על הדף, עבודה עם קבצים מצורפים ל-PDF ב-Delphi מראה את צד הקובץ המוטמע. כל זה מסופק עם רכיב PDFium עבור Delphi ו-C++Builder, לצד ה-APIs של רינדור, עריכה ומסמכים המכוסים במקומות אחרים בבלוג זה