Технічна стаття

Багаторазові штампи сторінок за допомогою Form XObjects з PDFium

Штампування водяного знака або логотипа на кожній сторінці документа виглядає як п'ятихвилинна робота, доки ви не відкриєте результат у інспекторі розміру файлу. Очевидний підхід полягає в тому, щоб пройтися сторінками і на кожній знову побудувати ті самі текстові або графічні об'єкти. Це працює візуально, але є надмірно марнотратним, і ця марнотратність накопичується. Діагональний водяний знак «DRAFT», намальований безпосередньо у звіті на сто сторінок, — це сто копій одного й того ж шляху та текстових даних, що знаходяться в потоках вмісту (content streams), і збережений файл містить їх усі

Form XObject — це конструкція, яку надає PDF, щоб уникнути саме цього. Вона обгортає частину багаторазового вмісту, цілу сторінку або невеликий шаблон, у єдиний іменований об'єкт, який можна намалювати багато разів у багатьох позиціях. Вміст існує у файлі один раз. Кожна сторінка, яка потребує штампа, містить коротку інструкцію, що каже: «намалюй XObject N тут, із цим перетворенням». Таким чином, водяний знак на сто сторінок додає до файлу один об'єкт вмісту, а не сто, і в цьому полягає різниця між документом, який зростає лінійно зі збільшенням кількості сторінок, і тим, який цього не робить. Водяні знаки, штампи логотипів, шаблони номерів сторінок і печатки — усе це проблеми однакового характеру, і Form XObject є правильним інструментом для кожної з них

Чому один збережений об'єкт кращий за сто перемалювань

Економія є структурною, а не косметичною. Сторінка PDF рендериться шляхом виконання її потоку вмісту (content stream) — послідовності операторів малювання. Коли ви перемальовуєте штамп для кожної сторінки, ви додаєте повну послідовність операторів для цього штампа до потоку кожної сторінки, і байти дублюються стільки разів, скільки у вас сторінок. Form XObject переносить ці оператори в один потік, що зберігається в документі один раз. Посилання, яке зберігає окрема сторінка, невелике: воно додає матрицю трансформації, викликає 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. Метод викликає виняток, якщо вихідний документ не є 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;

Дві деталі внутрішньої організації роблять це безпечним. По-перше, після вставлення об'єкт сторінки належить сторінці, а не XObject. Звільнення XObject пізніше не робить недійсними ті розміщення, які ви вже зробили. Саме це дозволяє працювати порядку створення-розміщення-звільнення, описаному нижче. По-друге, вставлення та позиціонування змінюють лише список об'єктів сторінки в пам'яті; UpdatePage — це те, що серіалізує цей список назад у потік вмісту сторінки, тому сторінка, яку ви редагуєте без його виклику, зберігається так, ніби штамп ніколи не був розміщений

Правило часу життя дескриптора, яке збиває людей з пантелику

Дескриптор XObject підпорядковується двом обмеженням, і ігнорування будь-якого з них призводить до збою, який виглядає не пов'язаним зі своєю причиною. По-перше, вихідний документ має бути активним у момент виклику CreateXObjectFromPage. Захоплення зчитує вміст вихідної сторінки з живого вихідного документа, тому цей документ і його сторінка мають бути відкритими та дійсними під час створення дескриптора. По-друге, і це те, що дивує людей, дескриптор має бути звільнений до закриття вихідної сторінки, а на практиці — до того, як ви закриєте або звільните вихідний документ, з якого він походить

Причина полягає в тому, що XObject — це посилання на структуру, якою все ще володіє вихідний документ. Це не відокремлена, автономна копія, яку можна носити з собою після зникнення джерела. Якщо спочатку закрити джерело, дескриптор залишиться вказувати на зруйнований вміст, тому його подальше звільнення або будь-яке інше використання працюватиме з пам'яттю, яка більше не є дійсною. Симптом є класичним для завислого дескриптора (dangling handle): порушення прав доступу під час завершення роботи, або періодичне пошкодження, яке переміщується залежно від порядку виділення пам'яті, зі стеком, який вказує на код очищення, а не на рядок, що фактично викликав проблему. Вирішення полягає у правильному порядку, а не в захисному кодуванні. Створіть XObject, вставте його на кожну сторінку, яка цього потребує, звільніть XObject і лише потім закрийте вихідний документ. Деструктор TPdfXObject звільняє базовий дескриптор PDFium для вас, тому звільнення обгортки в потрібний час — це вся ваша відповідальність

Матриця та що означають її шість чисел

Розміщення — це двовимірне афінне перетворення, те саме, яке 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 складає загальні операції за вас і пост-множить у процесі, тому Translate, Scale і Rotate об'єднуються в ланцюг у тому порядку, в якому ви їх викликаєте. Діагональний водяний знак — це обертання з подальшим переміщенням (translate) для його повторного центрування; кутовий логотип — це масштабування з подальшим переміщенням. Коли матриця готова, скопіюйте її сире значення, властивість Handle типу FS_MATRIX, у локальну змінну і передайте її в FPDFPageObj_SetMatrix; імпорт оголошує матрицю як параметр var, тому властивість не можна передати йому безпосередньо, і в разі невдачі його результат дорівнює 0. Низькорівнева функція FPDFPageObj_Transform, яка приймає шість значень безпосередньо як числа з подвійною точністю (doubles), доступна, якщо ви віддаєте перевагу передачі чисел, а не створенню обгортки

Штампування кожної сторінки в правильному порядку

Повний шаблон збирає всі частини разом із дотриманням порядку, якого вимагає правило часу життя. Відкрийте обидва документи, захопіть штамп один раз, пройдіться цільовими сторінками, встановлюючи по черзі PageNumber (де нумерація починається з 1) і вставляючи та позиціонуючи копію, фіксуючи кожну сторінку за допомогою 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, тому дескриптор завжди звільняється, поки його джерело ще живе, навіть якщо виняток спрацьовує посеред циклу. Правильно налаштуйте цю вкладеність, і правило часу життя потурбується про себе саме

Штампування — це один із куточків ширшого набору інструментів для створення та редагування вмісту сторінки. Якщо ваш штамп є зображенням, а не захопленою сторінкою, стаття конвертація зображень у PDF-документи за допомогою PDFium охоплює те, як спочатку помістити це растрове зображення в документ. А коли те, що ви хочете нести разом із видимим штампом, є файлом, а не чорнилом на сторінці, стаття робота з PDF-вкладеннями в Delphi демонструє сторону вбудованих файлів. Усе це постачається з PDFium Component для Delphi та C++Builder, поруч з API для рендерингу, редагування та документів, які розглядаються в інших публікаціях цього блогу