Техническая статья

Подшивка дуплексных сканов в Delphi: слияние PDF-страниц

CollateDocumentsEx в PDF-библиотеке PDFlibPas для Delphi объединяет несколько открытых документов в один документ с чередованием страниц. Функция за каждый проход добавляет GroupSize страниц из каждого источника, принимает список диапазонов страниц по источникам и трактует убывающий диапазон вроде 3-1 как разворот этого источника в обратном порядке. Один вызов превращает стопку лицевых страниц и перевёрнутую стопку обратных страниц в правильный порядок чтения

Сценарий, стоящий за этим API, обыден и крайне распространён. Планшетный сканер с однопроходным механизмом протягивает всю стопку лицом вниз, затем оператор переворачивает стопку и прогоняет её ещё раз. В итоге получаются два PDF: лицевые страницы по порядку и обратные страницы в обратном порядке. Пользователю нужен один файл: страница 1 лицевая, страница 1 обратная, страница 2 лицевая и так далее. Эта статья посвящена задаче упорядочивания и скрытой за ней ловушке дублирования ресурсов. Если вас интересует чистая пропускная способность конкатенации, смотрите быстрое слияние PDF со сдвигом ссылок на уровне байтов; если входные файлы слишком велики, чтобы поместиться в память целиком, смотрите слияние и разбиение гигабайтных PDF с прямым доступом

Сканер выдаёт две стопки, одна из которых перевёрнута

Подшивка — это не слияние. Слияние конкатенирует диапазоны страниц; подшивка чередует их, и схема чередования зависит от физического устройства, породившего входные данные. Ошибётесь со схемой — и файл будет не слегка неправильным, а нечитаемым: каждая вторая страница принадлежит другому листу. Почти любой реальный случай описывается тремя переменными: сколько источников участвует в ротации, сколько страниц берётся из каждого источника за один проход и нужно ли какой-то источник читать в обратном порядке. CollateDocuments покрывает первые две переменные обычным массивом дескрипторов документов и целым числом GroupSize. CollateDocumentsEx добавляет третью, принимая список диапазонов страниц через точку с запятой, по одному сегменту на источник, где пустой сегмент означает все страницы этого источника, а убывающий диапазон разворачивает его. Обе функции дописывают страницы в конец текущего выбранного документа и возвращают 1 при успехе, 0 при любом отказе

Почему наивная подшивка умножает размер файла?

Потому что карта импорта, сопоставляющая номера объектов источника номерам объектов в целевом документе, перестраивается при каждом вызове копирования, и всё, что достижимо более чем из одного фрагмента, импортируется заново для каждого фрагмента. Внутри PDFlibPas метод TPDFDocument.CopyPagesFromDoc сбрасывает свой NewIndObjList в начале каждого вызова. Этот список — единственная память копировщика о том, что уже было перенесено. Вызовите его один раз с диапазоном в десять страниц — и общий для всех десяти страниц шрифт будет встроен один раз. Вызовите его десять раз по одной странице — и тот же шрифт будет встроен десять раз. Для сканов это значимо гораздо сильнее, чем для текстовых документов, потому что скан-страница — это единственный крупный объект изображения XObject, а общие объекты как раз и несут реальный вес: встроенный ICC-профиль, общая цепочка /DecodeParms, форма-штамп или водяной знак XObject, применяемый к каждому листу, шрифт слоя распознанного текста OCR. Очевидный способ написать циклическую подшивку — это цикл по проходам, и именно этот цикл оказывается патологическим случаем

// Do not do this. Each CopyPageRanges call rebuilds the import map,
// so anything the two sources share internally is imported once per
// round instead of once per source.
var
  RoundIndex: Integer;
begin
  for RoundIndex := 1 to 12 do
  begin
    PDF.CopyPageRanges(Fronts, IntToStr(RoundIndex));
    PDF.CopyPageRanges(Backs, IntToStr(13 - RoundIndex));
  end;
end;

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

Импортировать один раз, затем переупорядочить дерево страниц

Решение состоит в разделении двух задач, которые наивный цикл смешал воедино. Копирование определяет, какие объекты существуют в целевом документе; упорядочивание определяет, где страницы находятся в дереве страниц. CollateDocumentsEx копирует каждый источник ровно один раз, единым вызовом CopyPagesFromDoc с полным диапазоном этого источника, поэтому на каждый источник приходится одна карта импорта, а общие ресурсы записываются один раз. И только после того как все источники размещены, происходит чередование, причём целиком через TPDFPageTree.MovePage

Перемещение страниц в важном здесь смысле бесплатно. ISO 32000-1 §7.7.3 определяет дерево страниц как сбалансированную структуру узловых словарей, чьи массивы /Kids содержат косвенные ссылки, а /Count хранит суммарное число листьев в каждом узле. Перемещение страницы означает удаление одной косвенной ссылки из одного массива /Kids, вставку её в другой, корректировку обоих значений /Count и перепривязку /Parent страницы. Ни один поток содержимого не затрагивается, ни один ресурс не дублируется, ни один объект не создаётся. Объект страницы сохраняет свой номер, и именно поэтому номера объектов остаются стабильными так же, как это описано в статье о замене страниц с сохранением номеров объектов. Есть ещё одна деталь, которую наивное перемещение страницы делает неправильно, а MovePage — правильно. ISO 32000-1 §7.7.3.4 позволяет наследовать /Resources, /MediaBox, /CropBox и /Rotate от предка вместо явного указания на самой странице. Страница, наследующая ресурсы от узла A и затем перемещённая под узел B, молча наследует нечто другое, а то и вовсе ничего. Поэтому MovePage разрешает унаследованное значение и записывает его в словарь страницы перед перемещением, так что страница переносит с собой собственные атрибуты

Что на самом деле делает проход переупорядочивания?

Он выполняет сортировку выбором относительно семантики вставки. Сначала вычисляется желаемый порядок относительно блока: источники обходятся по ротации, из каждого берётся до GroupSize индексов, исчерпанный источник пропускается, и так до тех пор, пока не будут размещены все страницы. Это даёт перестановку добавленного блока. Применить её — самое неудобное, потому что MovePage — это вставка, а не обмен, поэтому каждое перемещение сдвигает на единицу всё между старой и новой позицией

Реализация хранит массив Current, моделирующий, где сейчас находится каждая добавленная страница, сканирует вперёд от позиции K в поисках страницы, которая должна там оказаться, выполняет перемещение, а затем сдвигает элементы массива, отражая то, что перемещение сделало с деревом. По операциям с массивом это O(n в квадрате), а по копированию объектов — ноль, и это правильный компромисс для такой нагрузки: подшивка на 500 страниц — это четверть миллиона перестановок целых чисел и ни одного продублированного байта данных изображения. Убывающие диапазоны и повторяющиеся страницы не требуют особой обработки в этом проходе, потому что PLParsePageRangeList вызывается с отключённой сортировкой и разрешёнными дубликатами, так что запрошенный порядок доходит до разбора нетронутым

Обратные диапазоны и дуплексное слияние одним вызовом

Когда разворот выражается диапазоном, случай двойного прохода планшетного сканера сворачивается в один вызов. Лицевые страницы хотят естественный порядок, а обратные хотят 12-1, и пустой первый сегмент перед точкой с запятой означает, что первый источник отдаёт все свои страницы

var
  PDF: TPDFlib;
  Target, Fronts, Backs: Integer;
begin
  PDF := TPDFlib.Create;
  try
    Target := PDF.NewDocument;
    if PDF.LoadFromFile('fronts.pdf', '') <> 1 then
      Exit;
    Fronts := PDF.SelectedDocument;
    if PDF.LoadFromFile('backs.pdf', '') <> 1 then
      Exit;
    Backs := PDF.SelectedDocument;
    PDF.SelectDocument(Target);
    // fronts 1..12 in order, backs scanned in reverse: F1 B12 F2 B11 ...
    if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 1 then
      PDF.SaveToFile('duplex.pdf');
  finally
    PDF.Free;
  end;
end;

В этом фрагменте стоит явно отметить два поведения. Подшитые страницы добавляются в выбранный документ, поэтому документ, созданный через NewDocument, добавляет впереди них свою исходную пустую страницу, и если она не нужна, её следует удалить. А источники могут быть неравны по объёму: при GroupSize равном 2, при трёхстраничном и пятистраничном источниках, проходы дадут A1 A2 B1 B2, затем A3 B3 B4 при почти исчерпанном A, затем B5 в одиночку, потому что исчерпанный источник просто пропускается, а не дополняется заглушками

Откат, поля форм и то, что не переносится

Каждый аргумент проверяется до того, как затронут целевой документ. Отсутствующий дескриптор документа, выбранный документ, указанный в качестве собственного источника, GroupSize меньше единицы, число сегментов, не совпадающее с числом источников, диапазон, называющий страницу, которой в источнике нет: всё это возвращает 0 с неизменённым целевым документом. Сбой во время копирования — случай посложнее, и он обрабатывается через публичный DeletePages, а не через низкоуровневый PageTree.DeletePages. Причина конкретна. Копирование выполняется с включённым MergeFormData, поэтому поля форм источника уже добавлены в массив /AcroForm /Fields целевого документа к моменту, когда более поздний источник даёт сбой. Удаление страниц на уровне дерева страниц лишило бы страницы виджетов и оставило бы висячие ссылки на эти поля; публичный путь отвязывает ссылки на поля, оглавление и цепочки статей вместе со страницами

if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 0 then
  // Nothing was appended and the target is byte-identical to before.
  // 412 is the copy failure; 0 means the arguments were rejected
  // during validation, before any page was touched.
  Log(Format('collate rejected, LastErrorCode=%d', [PDF.LastErrorCode]));

Будьте честны с пользователями насчёт границ. Подшивка переносит страницы, их аннотации и их поля форм, объединяет список полей AcroForm, массив порядка вычисления и словарь ресурсов по умолчанию. Она не переносит закладки источника: дерево оглавления стопки отсканированных лицевых страниц почти всегда пусто, поэтому в дуплексном случае ничего не теряется, но если вы подшиваете два авторских документа, их оглавления останутся позади, и навигацию придётся собирать заново вручную. Именованные назначения, существовавшие только в каталоге источника, оказываются в том же положении. Учтите это до того, как пообещаете клиенту безупречную подшивку

PDFlibPas поставляет функции подшивки вместе с остальной поверхностью сборки страниц, поэтому сценарий сканера, извлечение по диапазонам и работа с большими файлами живут за одним компонентом для Delphi и C++Builder. Полный справочник API и пробная сборка доступны на странице продукта losLab Delphi PDF library