مقاله فنی

مهرهای صفحه قابل استفاده مجدد با Form XObject در PDFium

زدن یک watermark یا logo روی تک‌تک صفحه‌های یک سند در نگاه اول کاری پنج‌دقیقه‌ای به نظر می‌رسد، تا وقتی که خروجی را در یک ابزار بررسی اندازه فایل باز کنید. راه بدیهی این است که صفحه‌ها را پیمایش کنید و روی هر صفحه همان text object یا image object را دوباره بسازید. از نظر بصری جواب می‌دهد، اما اتلافی دارد که مدام انباشته می‌شود. یک watermark مورب با متن «DRAFT» که مستقیم روی یک گزارش صدصفحه‌ای کشیده شده، یعنی صد نسخه از همان داده‌های path و text در content streamها، و فایل ذخیره‌شده همه آن‌ها را با خود حمل می‌کند

Form XObject همان سازوکاری است که PDF دقیقاً برای اجتناب از همین وضعیت فراهم کرده است. این سازوکار یک تکه محتوای قابل استفاده مجدد، چه یک صفحه کامل و چه یک template کوچک، را داخل یک شیء نام‌دار واحد می‌پیچد که می‌توان آن را بارها و در موقعیت‌های مختلف ترسیم کرد. خود محتوا فقط یک بار در فایل زندگی می‌کند. هر صفحه‌ای که آن مهر را می‌خواهد، فقط یک دستور کوتاه نگه می‌دارد که می‌گوید «XObject شماره N را با این transform اینجا بکش». به این ترتیب watermark یک سند صدصفحه‌ای فقط یک content object به فایل اضافه می‌کند، نه صدتا، و همین تفاوت میان سندی است که همراه با تعداد صفحه‌هایش خطی رشد می‌کند و سندی که چنین رشدی ندارد. watermark، مهر logo، template شماره صفحه و seal همگی یک شکل از مسئله هستند و Form XObject برای همه آن‌ها ابزار درست است

چرا یک شیء ذخیره‌شده از صد بار بازترسیم بهتر است

این صرفه‌جویی ساختاری است، نه تزئینی. یک صفحه PDF با اجرای content stream خودش رندر می‌شود، یعنی دنباله‌ای از عملگرهای ترسیم. وقتی مهر را برای هر صفحه دوباره می‌کشید، دنباله کامل این عملگرها را به stream هر صفحه اضافه می‌کنید و همان بایت‌ها به تعداد صفحه‌ها تکرار می‌شوند. Form XObject این عملگرها را به یک stream واحد منتقل می‌کند که فقط یک بار در سند ذخیره می‌شود. ارجاعی که هر صفحه نگه می‌دارد کوچک است: یک ماتریس تبدیل را push می‌کند، XObject را فراخوانی می‌کند و state را برمی‌گرداند. تعداد صفحه‌ها دیگر هزینه نگه‌داری artwork را چندبرابر نمی‌کند

این موضوع وقتی مهم‌تر می‌شود که خود مهر سنگین باشد. یک مهر برداری با صدها segment در path، یا یک bitmap لوگو، از نظر ذخیره‌سازی پرهزینه است. اگر یک‌بار ذخیره و بعد بارها referenced شود، بخش سنگین فقط یک‌بار پرداخت می‌شود و سربار هر صفحه فقط چند بایتِ invocation است. خروجی بصری روی صفحه دقیقاً همان چیزی است که با بازترسیم مستقیم می‌گرفتید و همین نکته اصل ماجراست. خواننده تفاوتی نمی‌بیند، اما اندازه فایل کاملاً تفاوت را نشان می‌دهد

گرفتن یک صفحه و تبدیل آن به XObject

PDFium این شیء قابل استفاده مجدد را از یک صفحه موجود می‌سازد. منبع می‌تواند صفحه‌ای از یک سند باز باشد، یک PDF یک‌صفحه‌ای کوچک که جز artwork مربوط به watermark چیزی در آن نیست، یا یک صفحه مشخص از یک فایل بزرگ‌تر. CreateXObjectFromPage محتوای آن صفحه منبع را به یک handle قابل استفاده مجدد تبدیل می‌کند که متعلق به سند مقصد است؛ همان سندی که می‌خواهید روی آن مهر بزنید

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) ...

signature این متد چنین است: CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject. این متد اگر سند منبع Active نباشد استثنا پرتاب می‌کند و اگر PDFium نتواند شیء را بسازد به جای استثنا، nil برمی‌گرداند، بنابراین بررسی صریح بالا اختیاری نیست. handleی که برمی‌گردد یک TPdfXObject است که مالک آن شما هستید و همین handle دو قید مهم مربوط به طول عمر دارد که همان بخش غافلگیرکننده کل ماجرا هستند، برای همین پایین‌تر بخش جداگانه خودشان را دارند

قرار دادن مهر روی یک صفحه

XObjectی که capture شده به خودی خود کاری انجام نمی‌دهد. برای اینکه ظاهر شود باید با InsertFormObjectFromXObject یک نسخه از آن را روی صفحه فعلی سند، یعنی صفحه‌ای که با ویژگی مبتنی بر 1 یعنی PageNumber انتخاب شده، درج کنید. این فراخوانی page object زیرین یعنی FPDF_PAGEOBJECT را برمی‌گرداند و همین handle راهی است که placement را با آن تعیین می‌کنید. اگر هیچ transformی ندهید، مهر در مبدأ مختصات صفحه منبع خودش می‌نشیند که به‌ندرت جای دلخواه شماست

از آنجا که InsertFormObjectFromXObject در هر فراخوانی فقط یک نسخه درج می‌کند و هر بار یک page object تازه به شما برمی‌گرداند، می‌توانید همان XObject را چند بار روی یک صفحه با transformهای متفاوت بکشید و محتوای ذخیره‌شده هنوز هم فقط یک بار در فایل حساب می‌شود. یک لوگوی گوشه صفحه و یک watermark کم‌رنگِ تمام‌صفحه می‌توانند هر دو از همان شیء captureشده ساخته شوند

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 این روند را امن می‌کند. اول اینکه وقتی object روی صفحه درج شد، دیگر متعلق به صفحه است، نه به خود XObject. آزاد کردن XObject در ادامه placementهایی را که قبلاً انجام داده‌اید نامعتبر نمی‌کند و همین باعث می‌شود ترتیب «بساز، قرار بده، آزاد کن» که در ادامه می‌آید درست کار کند. دوم اینکه درج و position دادن فقط فهرست objectهای صفحه را در حافظه تغییر می‌دهد؛ UpdatePage چیزی است که این فهرست را دوباره در content stream صفحه serialize می‌کند، بنابراین صفحه‌ای که آن را ویرایش کرده‌اید اما UpdatePage را صدا نزده‌اید، هنگام ذخیره طوری رفتار می‌کند که انگار مهر هرگز قرار داده نشده است

قانون طول عمر handle که معمولاً آدم‌ها را گیر می‌اندازد

دو قید رفتار این handle مربوط به XObject را کنترل می‌کنند و نادیده گرفتن هرکدام شکستی می‌سازد که در ظاهر هیچ ربطی به علت اصلی‌اش ندارد. اول اینکه در لحظه‌ای که CreateXObjectFromPage را صدا می‌زنید، سند منبع باید active باشد. عملیات capture محتوای صفحه منبع را از سند زنده منبع می‌خواند، پس آن سند و صفحه‌اش باید هنگام ساخت handle باز و معتبر باشند. دوم، و این همان بخشی است که بیشتر غافلگیر می‌کند، این handle باید پیش از بسته شدن صفحه منبع آزاد شود و در عمل پیش از آنکه سند منبعی را که از آن آمده می‌بندید یا آزاد می‌کنید

دلیلش این است که XObject ارجاعی به ساختاری است که هنوز تحت مالکیت سند منبع قرار دارد. این یک نسخه مستقل و خودبسنده نیست که بعد از ناپدید شدن منبع بتوانید آن را با خود حمل کنید. اگر اول منبع را ببندید، handle به محتوایی اشاره می‌کند که دیگر برچیده شده است، بنابراین آزاد کردن آن بعداً یا هر استفاده دیگری از آن روی حافظه‌ای انجام می‌شود که دیگر معتبر نیست. نشانه‌اش همان الگوی کلاسیک یک handle آویزان است: access violation هنگام shutdown یا خرابی‌های ناپایداری که بسته به ترتیب تخصیص جابه‌جا می‌شوند و stack آن‌ها به جای خط اصلی خطا، به کد cleanup اشاره می‌کند. راه‌حل دفاعی‌نویسی نیست، بلکه ترتیب درست است. XObject را بسازید، روی هر صفحه‌ای که به آن نیاز دارد درج کنید، XObject را آزاد کنید و فقط بعد از آن سند منبع را ببندید. destructor مربوط به TPdfXObject خودش handle زیرین PDFium را آزاد می‌کند، پس تمام مسئولیت شما این است که wrapper را در زمان درست آزاد کنید

ماتریس و معنای آن شش عدد

placement یک تبدیل آفین دوبعدی است؛ همان تبدیلی که PDF در همه‌جا برای جای‌گذاری محتوا استفاده می‌کند، مطابق ISO 32000-1 بخش 8.3.4. این تبدیل از شش عدد تشکیل می‌شود که به صورت a, b, c, d, e, f نوشته می‌شوند و PDFium آن‌ها را در recordی به نام 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)

می‌توانید این شش مقدار را دستی پر کنید، اما همین‌جا است که چرخش معمولاً خراب می‌شود، چون rotation هر چهار مقدار a, b, c, d را با هم درگیر می‌کند. wrapperی به نام TPdfMatrix از unit FPdfMatrix عملیات رایج را برایتان compose می‌کند و به صورت post-multiply پیش می‌رود، بنابراین Translate، Scale و Rotate به همان ترتیبی که صدا زده‌اید به هم زنجیر می‌شوند. یک watermark مورب یعنی یک rotate و بعد یک translate برای مرکز کردن، و یک لوگوی گوشه صفحه یعنی یک scale و بعد یک translate. وقتی ماتریس آماده شد، مقدار خام آن یعنی propertyای به نام Handle از نوع FS_MATRIX را در یک متغیر محلی کپی کنید و همان را به FPDFPageObj_SetMatrix بدهید؛ اعلان import، ماتریس را به صورت پارامتر var می‌گیرد و نمی‌توان property را مستقیماً به آن داد، ضمن اینکه در صورت شکست مقدار 0 برمی‌گرداند. اگر ترجیح می‌دهید به جای wrapper فقط عدد بدهید، تابع سطح‌پایین‌تر FPDFPageObj_Transform هم وجود دارد که همین شش مقدار را مستقیم به صورت double می‌پذیرد

مهر زدن روی همه صفحه‌ها، با ترتیب درست

الگوی کامل همه قطعات را با همان ترتیبی کنار هم می‌گذارد که قانون طول عمر لازم دارد. هر دو سند را باز کنید، artwork را یک بار capture کنید، با تنظیم PageNumber مبتنی بر 1 روی تک‌تک صفحه‌های مقصد حرکت کنید و هر بار یک نسخه از XObject را درج و position کنید، هر صفحه را با UpdatePage commit کنید، بعد 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 را آزاد می‌کند، بنابراین handle همیشه در حالی رها می‌شود که منبعش هنوز زنده است، حتی اگر وسط حلقه استثنایی رخ دهد. اگر این nesting را درست بچینید، قانون طول عمر خودش از خودش مراقبت می‌کند

مهر زدن فقط یکی از گوشه‌های یک جعبه‌ابزار بزرگ‌تر برای ساختن و ویرایش محتوای صفحه است. اگر خود مهر شما یک تصویر باشد نه یک صفحه captureشده، تبدیل تصاویر به سندهای PDF با PDFium نشان می‌دهد اول چطور آن bitmap را وارد سند کنید. و اگر چیزی که می‌خواهید در کنار مهر قابل‌دیدن حمل کنید یک فایل باشد نه جوهر روی صفحه، کار با پیوست‌های PDF در Delphi سمت embedded file را پوشش می‌دهد. همه این قابلیت‌ها همراه با PDFium Component برای Delphi و C++Builder عرضه می‌شوند، در کنار APIهای رندر، ویرایش و سند که در بخش‌های دیگر این وبلاگ پوشش داده شده‌اند