Technical Article

Спуск смуг N-up та зміна порядку сторінок з PDFium

Об'єднання та розділення - це дві операції зі сторінками, які зазвичай використовують у першу чергу, і вони покривають багато завдань. Але вони вирішують не все. Існує окрема родина завдань, які переставляють сторінки, а не переміщують цілі файли: розмістити чотири слайди на одному аркуші для роздаткового матеріалу, перетягнути сторінку з кінця документа на початок або вилучити сторінки 3, 7 і 12 у невелику вибірку, не торкаючись решти. PDFium пропонує три методи саме для цього, і кожен із них поводиться інакше, ніж уже знайомі вам об'єднання та розділення. У цій статті розглядається, що вони роблять, де знаходяться точки виведення, а також одна деталь передачі власності, яка спричинила збій на практиці.

Ці три методи: ImportNPagesToOne для спуску смуг N-up, MovePages для зміни порядку сторінок на місці та ImportPagesByIndex для вилучення вибірки. Об'єднання складає документи один за одним і робить загальну кількість сторінок рівною сумі вхідних файлів. Розділення записує кілька вихідних файлів з одного вхідного. Описані тут три операції займають проміжне положення: одна з них змінює кількість вихідних сторінок на одному аркуші, інша змінює порядок сторінок у межах одного документа, а третя копіює вибрані сторінки в інший документ. Розуміння цієї різниці позбавить вас від необхідності виконувати складні маніпуляції з об'єднанням та видаленням, коли достатньо одного виклику.

Що насправді робить спуск смуг N-up

Спуск смуг (imposition) - це поліграфічний термін для розташування кількох вихідних сторінок на одному більшому аркуші так, щоб надрукований і складений результат читався в правильному порядку. Повсякденна версія - це роздатковий матеріал 2-up, зошит для брошури 4-up або контрольний лист, на якому розміщується десяток ескізів. PDFium обробляє цю геометрію за допомогою одного виклику:

function ImportNPagesToOne(
  OutputWidth, OutputHeight: Single;
  NumX, NumY               : Cardinal): TPdf;

Параметри NumX та NumY описують сітку. Значення 2, 1 розміщує дві вихідні сторінки поруч; 2, 2 упаковує чотири сторінки в квадрантний макет; 4, 3 створює макет із 12 сторінок. PDFium послідовно читає вихідні сторінки, масштабує кожну з них відповідно до її комірки та заповнює сітку зліва направо, зверху вниз, починаючи новий вихідний аркуш щоразу, коли поточна сітка заповнена. Вихідні сторінки не змінюються. Ви отримуєте новий документ, сторінки якого є композитними.

Розмір виведення вказується в пунктах, а не в пікселях

Параметри OutputWidth та OutputHeight - це користувацькі одиниці PDF, причому одна одиниця PDF дорівнює одному пункту, що становить 1/72 дюйма. Ця одиниця визначає фізичний розмір вихідного аркуша і не має жодного відношення до екранних пікселів або роздільної здатності відтворення (DPI). Це найпоширеніше місце, де виникають помилки під час спуску смуг, оскільки розробник, який звик до растрових зображень, вказує кількість пікселів і отримує аркуш розміром із поштову марку або рекламний щит.

Числа, які варто запам'ятати, - це два розміри сторінок, які ви використовуватимете найчастіше. Формат US Letter має розмір 612 на 792 пункти, оскільки 8.5 дюйма помножити на 72 дорівнює 612, а 11 дюймів помножити на 72 дорівнює 792. Формат A4 має розмір приблизно 595 на 842 пункти, виходячи з його габаритів 210 на 297 міліметрів. Заголовок самої прив'язки чітко вказує це правило: одна одиниця дорівнює 1/72 дюйма, а модуль містить константу PointsPerInch із значенням 72, якщо ви віддаєте перевагу обчисленню розміру в дюймах у коді, а не використанню констант.

const
  LetterW = 612.0;   // 8.5 in * 72
  LetterH = 792.0;   // 11  in * 72
var
  Source, Composite: TPdf;
begin
  Source := TPdf.Create(nil);
  Composite := nil;
  try
    Source.FileName := 'slides.pdf';
    Source.Active := True;

    // Four source pages per Letter sheet, 2 by 2 grid.
    Composite := Source.ImportNPagesToOne(LetterW, LetterH, 2, 2);
    if Composite = nil then
      raise Exception.Create('PDFium rejected the imposition arguments');

    Composite.SaveAs('slides-4up.pdf');
  finally
    Composite.Free;   // see the next section: this is mandatory
    Source.Free;
  end;
end;

Повернутий дескриптор ви маєте звільнити самостійно

Прочитайте сигнатуру ще раз. Метод ImportNPagesToOne повертає об'єкт TPdf, а не Boolean. Цей повернутий результат є абсолютно новим дескриптором документа, виділеним окремо від джерела, і викликач володіє ним. Вихідний об'єкт TPdf, для якого ви викликали метод, залишається незмінним і продовжує володіти власним дескриптором; композитний документ є другим, незалежним об'єктом. Якщо ви дозволите повернутому TPdf вийти за межі області видимості без звільнення, ви отримаєте витік усього документа PDFium.

Більш небезпечна помилка полягає у зворотному. Усередині метод запитує у PDFium новий дескриптор FPDF_DOCUMENT за допомогою FPDF_ImportNPagesToOne, а потім обгортає цей необроблений дескриптор у повернутий TPdf, щоб час життя обгортки керував часом життя дескриптора. З цього моменту існує рівно один власник дескриптора і рівно одне місце, де він має бути закритий: при виклику Free для повернутого об'єкта. Недбала обробка помилок, яка одночасно звільняє обгортку та викликає FPDF_CloseDocument для отриманого необробленого дескриптора, закриває один і той самий документ PDFium двічі. Це подвійне звільнення, і це саме та помилка, з якою зіткнувся один із викликачів. Правило для її запобігання просте: закривайте документ лише на одному шляху виконання, звільняючи об'єкт TPdf, який передав вам метод, і ніколи не намагайтеся закрити дескриптор в обхід обгортки, яка його вже прийняла.

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

Зміна порядку сторінок без їхнього перезапису

Зміна порядку змінює один документ на місці. Метод MovePages вилучає набір сторінок з їхніх поточних позицій і розміщує їх у місці призначення, зсуваючи все інше навколо переміщеного блоку так, щоб кількість сторінок залишалася незмінною:

function MovePages(
  const PageIndices: array of Integer;
  DestPageIndex    : Integer): Boolean;

Індекси починаються з нуля. Параметр PageIndices перелічує сторінки для переміщення в тому порядку, у якому они мають розташуватися, а DestPageIndex - це індекс, на який потрапляє перша переміщена сторінка після завершення операції. Оскільки PDFium змінює розташування сторінок без копіювання та повторного стиснення їхнього вмісту, ця операція є швидкою та без втрат якості: об'єкти сторінок зберігають свої потоки, ресурси та точність представлення. Це саме той виклик, який забезпечує перетягування сторінок на панелі перегляду ескізів, коли користувач перетягує ескіз у новий слот, а ви фіксуєте новий порядок одним викликом. Метод повертає False, якщо індекс виходить за межі діапазону, тому перевіряйте результат виконання замість того, щоб вважати зміну порядку успішною за замовчуванням.

var
  Doc: TPdf;
begin
  Doc := TPdf.Create(nil);
  try
    Doc.FileName := 'report.pdf';
    Doc.Active := True;

    // Move the last page (index 4 in a 5-page file) to the very front.
    if not Doc.MovePages([4], 0) then
      raise Exception.Create('MovePages rejected the index');

    Doc.SaveAs('report-reordered.pdf');
  finally
    Doc.Free;
  end;
end;

Вилучення вибірки за індексом

Третя операція копіює визначений набір сторінок з одного документа в інший. Метод ImportPagesByIndex приймає вихідний документ та масив індексів (починаючи з нуля) і вставляє ці сторінки в цільовий документ у вибрану позицію:

function ImportPagesByIndex(
  Source           : TPdf;
  const PageIndices: array of Integer;
  InsertAt         : Integer= 0): Boolean;

Ви викликаєте цей метод для цільового документа та передаєте вихідний документ як перший аргумент. Параметр PageIndices визначає сторінки джерела для вилучення в потрібному порядку; InsertAt - це слот у цільовому документі (починаючи з нуля), куди буде вставлено першу імпортовану сторінку, тобто значення 0 розміщує їх перед наявною першою сторінкою, а інші варіанти додають їх до поточної кількості сторінок. Порожній масив імпортує кожну сторінку, що робить цей виклик повним копіюванням, якщо це необхідно. Метод повертає False, якщо будь-який індекс у джерелі виходить за межі діапазону.

Тут важливий контраст із розділенням. Розділення записує окремі файли на диску в результаті однієї операції. Метод ImportPagesByIndex виконує протилежну задачу: він збирає вибраний набір сторінок в один цільовий документ у пам'яті, який ви потім зберігаєте один раз. Коли задача звучить як "надайте мені сторінки 3, 7 та 12 у вигляді одного короткого PDF", це найкоротший шлях, який усередині викликає функцію FPDF_ImportPagesByIndex.

var
  Source, Excerpt: TPdf;
begin
  Source := TPdf.Create(nil);
  Excerpt := TPdf.Create(nil);
  try
    Source.FileName := 'manual.pdf';
    Source.Active := True;
    Excerpt.CreateDocument;   // start an empty target

    // Pull pages 3, 7 and 12 (zero-based 2, 6, 11) into the excerpt.
    if not Excerpt.ImportPagesByIndex(Source, [2, 6, 11], 0) then
      raise Exception.Create('A requested page index is out of range');

    Excerpt.SaveAs('manual-excerpt.pdf');
  finally
    Excerpt.Free;
    Source.Free;
  end;
end;

Охайне об'єднання всього разом

Загальний процес є однаковим для всіх трьох методів: відкрити джерело, встановивши FileName та змінивши Active на True, виконати операцію, зберегти за допомогою SaveAs та звільнити об'єкти, якими ви володієте. Єдина гілка коду, яка вимагає особливої уваги, - це визначення того, які виклики виділяють пам'ять для нового документа. Метод MovePages змінює документ, який ви вже тримаєте, тому потрібно звільнити один об'єкт. ImportPagesByIndex записує дані в цільовий об'єкт, який ви створили самі, тому ви звільняєте і джерело, і відкриту ціль. Метод ImportNPagesToOne є винятком, оскільки новий документ є результатам виконання методу, а не чимось, що ви створили самостійно, і забування про те, що це окремий дескриптор у власності викликача, призводить як до витоку пам'яті, так і до подвійного звільнення. Ініціалізуйте результат значенням nil, перевіряйте його після виклику та звільняйте лише на одному шляху виконання.

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