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

Комплектація дуплексних сканів у Delphi: злиття PDF з чергуванням

CollateDocumentsEx у бібліотеці 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 у пошуках сторінки, яка має бути на 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