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

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

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

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

Діаграма протиставлення: малювання операторів водяного знака на кожній PDF-сторінці проти зберігання їх один раз у Form XObject з PDFium
Перемалювання штампа на кожній сторінці дублює його байти через кожен потік вмісту, тоді як 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';   // одна сторінка ілюстрації
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // Захопити сторінку 0 документа-штампа в багаторазовий дескриптор,
    // що належить Dest. Source має бути Active; індекс з відліком від нуля.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not build the stamp XObject');
    // ... розмістіть його, а потім звільніть перед закриттям Stamp (див. нижче) ...

Сигнатура виглядає так: 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
  // Поточна сторінка Dest отримує одну копію XObject.
  PageObj := Dest.InsertFormObjectFromXObject(XObject);
  if PageObj = nil then
    raise Exception.Create('Insert failed on this page');

  // Позиціонувати: зсунути на 200 одиниць вправо, на 500 вгору, у масштабі 70%.
  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;   // закріпити редагування цієї сторінки в її потоці вмісту
  // якщо не Dest.SaveAs(...), тоді ... після завершення кожної сторінки
end;

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

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

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

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

Впорядкована діаграма життєвого циклу штампів сторінок PDFium: захоплення, розміщення, звільнення дескриптора TPdfXObject і закриття документа штампа останнім
Захопіть штамп один раз, розмістіть його на кожній сторінці, звільніть XObject, поки документ штампа ще відчинений, а тоді збережіть і зачиніть джерело останнім

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

Розміщення — це двовимірне афінне перетворення, те саме, яке 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 : горизонтальний і вертикальний масштаб
// b, c : члени зсуву / обертання
// e, f : перенесення (де бере початок координат на сторінці)

Ви можете заповнити ці шість значень вручну, але при складанні їх вручну обертання часто йде не так, оскільки воно змішує всі чотири параметри 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. Захопити художнє зображення один раз. Тут Stamp Active.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not capture the stamp page');
    try
      // 2. Розмістити копію на кожній сторінці Dest. PageNumber з відліком від 1.
      for I := 1 to Dest.PageCount do
      begin
        Dest.PageNumber := I;                // зробити сторінку I поточною
        PageObj := Dest.InsertFormObjectFromXObject(XObject);
        if PageObj = nil then
          Continue;

        M := TPdfMatrix.Create;
        try
          M.Rotate(45);                      // діагональний водяний знак
          M.Translate(150, 100);             // підштовхнути в позицію
          RawM := M.Handle;
          FPDFPageObj_SetMatrix(PageObj, RawM);
        finally
          M.Free;
        end;
        Dest.UpdatePage;                     // закріпити редагування цієї сторінки
      end;
    finally
      XObject.Free;                          // 3. звільнити ПЕРЕД закриттям Stamp
    end;

    // 4. Записати результат, поки Dest ще відкритий.
    if not Dest.SaveAs(AOutput) then
      raise Exception.Create('Could not save ' + AOutput);
  finally
    Stamp.Free;                              // джерело закривається останнім
    Dest.Free;
  end;
end;

Структура блоків try виконує справжню роботу. Внутрішній блок finally звільняє XObject до того, як управління зможе досягти зовнішнього finally, що звільняє Stamp, тому дескриптор завжди звільняється, поки його джерело ще живе, навіть якщо виняток спрацьовує посеред циклу. Правильно налаштуйте цю вкладеність, і правило часу життя потурбується про себе саме

Анатомія FS_MATRIX: шість афінних коефіцієнтів, які PDFium використовує для масштабування, обертання і перенесення штампованого Form XObject на сторінці
Шість чисел відображають координати штампа в простір сторінки, а TPdfMatrix компонує Scale, Rotate і Translate у порядку виклику, щоб посадити діагональний водяний знак

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