Technical Article

Спуск полос N-up и изменение порядка страниц с помощью PDFium

Объединение и разделение файлов являются первыми операциями со страницами, к которым обычно обращаются разработчики, и они решают множество задач. Однако они покрывают не все сценарии. Существует отдельный класс задач по изменению структуры страниц без изменения самих файлов: размещение четырех слайдов на одном листе для раздаточного материала, перетаскивание страницы из конца документа в начало или извлечение страниц 3, 7 и 12 в короткую выдержку без изменения остальных данных. PDFium предлагает для этого три метода, каждый из которых работает иначе, чем привычные операции объединения и разделения. В этой статье рассматривается их принцип работы, точки вывода результатов и одна важная деталь владения ресурсами, которая могла приводить к сбоям на практике

Этими методами являются ImportNPagesToOne для спуска полос N-up, MovePages для изменения порядка страниц на месте и ImportPagesByIndex для извлечения подмножества страниц. Объединение соединяет документы последовательно, при этом итоговое количество страниц равно сумме исходных. Разделение создает несколько выходных файлов из одного входного. Описанные три операции занимают промежуточное положение: одна меняет количество исходных страниц на одном листе, вторая изменяет порядок страниц внутри одного документа, а третья копирует выбранный набор страниц в другой документ. Понимание этой разницы избавляет от необходимости реализовывать сложный цикл объединения и удаления, когда достаточно одного вызова

Что на самом деле делает спуск полос N-up

Спуск полос представляет собой полиграфический термин, означающий размещение нескольких исходных страниц на одном большом листе так, чтобы после печати и фальцовки страницы шли в правильном порядке. Простыми примерами служат раздаточные материалы с компоновкой по 2 страницы на лист, тетради буклетов по 4 страницы или контактные листы с десятком эскизов. PDFium управляет геометрией этого процесса с помощью одного вызова

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

Параметры NumX и NumY задают сетку. Значение 2, 1 размещает две исходные страницы рядом, 2, 2 компонует четыре страницы в четыре квадранта, а 4, 3 создает двенадцатистраничный контактный лист. PDFium последовательно считывает исходные страницы, масштабирует каждую под размер ячейки и заполняет сетку слева направо и сверху вниз, создавая новый выходной лист по мере заполнения текущей сетки. Исходные страницы при этом не изменяются. На выходе вы получаете новый документ, страницы которого состоят из нескольких объединенных макетов

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

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

Два наиболее распространенных формата страниц полезно запомнить. Размер US Letter составляет 612 на 792 пункта (8.5 дюймов умножить на 72 равно 612, а 11 дюймов умножить на 72 равно 792). Формат A4 имеет примерный размер 595 на 842 пункта, исходя из его размеров 210 на 297 миллиметров. В заголовке самой привязки четко указано это правило, а библиотека поставляет константу 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, а не логическое значение. Возвращаемое значение представляет собой совершенно новый дескриптор документа, выделенный отдельно от источника, и вызывающая сторона становится его владельцем. Исходный объект 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 для загрузки, рендеринга и редактирования документов