Artykuł techniczny

Kolacjonowanie skanów dupleksowych w Delphi: scalanie PDF

CollateDocumentsEx w bibliotece PDFlibPas do Delphi scala kilka otwartych dokumentów w jeden przeplatany dokument. Dołącza GroupSize stron z każdego źródła na rundę, przyjmuje listę zakresów stron dla każdego źródła i traktuje zakres malejący, taki jak 3-1, jako odwrócenie tego źródła. Jedno wywołanie zamienia stos przedni i odwrócony stos tylny w kolejność czytania

Scenariusz stojący za tym API jest przyziemny i bardzo częsty. Skaner arkuszowy z torem jednostronnym przepuszcza cały stos zadrukiem w dół, a operator odwraca stos i przepuszcza go ponownie. W efekcie powstają dwa pliki PDF: strony przednie w kolejności, tylne w kolejności odwrotnej. Użytkownik chce jednego pliku: strona 1 przód, strona 1 tył, strona 2 przód i tak dalej. Ten artykuł dotyczy problemu porządkowania oraz pułapki duplikacji zasobów, która się pod nim kryje. Jeśli interesuje Cię surowa przepustowość zwykłej konkatenacji, zobacz szybkie scalanie PDF przez przesunięcia referencji na poziomie bajtów; jeśli wejściowe pliki są zbyt duże, by w całości zmieścić się w pamięci, zobacz scalanie i dzielenie gigabajtowych plików PDF z dostępem bezpośrednim

Skaner produkuje dwa stosy, jeden z nich odwrotnie

Kolacjonowanie to nie scalanie. Scalanie konkatenuje zakresy stron; kolacjonowanie je przeplata, a wzorzec przeplotu jest własnością fizycznego urządzenia, które wygenerowało dane wejściowe. Pomylenie wzorca nie daje pliku „trochę błędnego” — plik staje się nieczytelny: co druga strona należy do innego arkusza. Niemal każdy realny przypadek opisują trzy zmienne: ile źródeł bierze udział w rotacji, ile stron pochodzi z każdego źródła na rundę oraz czy jakieś źródło trzeba czytać od tyłu. CollateDocuments obsługuje pierwsze dwie za pomocą zwykłej tablicy uchwytów dokumentów i liczby całkowitej GroupSize. CollateDocumentsEx dodaje trzecią, przyjmując listę zakresów stron rozdzielonych średnikiem, po jednym segmencie na źródło, gdzie pusty segment oznacza wszystkie strony danego źródła, a zakres malejący je odwraca. Obie funkcje dołączają dane na koniec aktualnie wybranego dokumentu i zwracają 1 przy powodzeniu, 0 przy każdym odrzuceniu

Dlaczego naiwne kolacjonowanie mnoży rozmiar pliku?

Ponieważ mapa importu, która mapuje numery obiektów źródłowych na numery obiektów docelowych, jest odbudowywana przy każdym wywołaniu kopiowania, a wszystko osiągalne z więcej niż jednego fragmentu zostaje zaimportowane raz na fragment. Wewnątrz PDFlibPas TPDFDocument.CopyPagesFromDoc resetuje swoją listę NewIndObjList na początku każdego wywołania. Ta lista to jedyna pamięć kopiarki o tym, co już przeniosła. Wywołaj ją raz z dziesięciostronicowym zakresem, a czcionka współdzielona przez wszystkie dziesięć stron zostanie osadzona raz. Wywołaj ją dziesięć razy po jednej stronie, a ta sama czcionka zostanie osadzona dziesięć razy. Ma to o wiele większe znaczenie dla skanów niż dla dokumentów tekstowych, ponieważ zeskanowana strona to pojedynczy duży obraz XObject, a obiekty współdzielone to te o realnej wadze: osadzony profil ICC, współdzielony łańcuch /DecodeParms, formularz XObject ze stemplem lub znakiem wodnym stosowany do każdego arkusza, czcionka warstwy tekstu OCR. Oczywistym sposobem napisania kolacjonowania rotacyjnego jest pętla po rundach, a ta pętla to dokładnie przypadek patologiczny

// 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;

Dwanaście rund, dwa źródła, dwadzieścia cztery mapy importu. Nic nie ostrzega. Kolejność stron jest poprawna, każda strona się renderuje, a jedynym objawem jest plik kilkukrotnie większy niż suma jego wejść. Przy zadaniu wsadowym na 300 stron mnożnik to nie błąd zaokrąglenia — to różnica między archiwum mieszczącym się w budżecie retencji a takim, które się nie mieści

Zaimportuj raz, potem przeporządkuj drzewo stron

Naprawa polega na rozdzieleniu dwóch kwestii, które naiwna pętla ze sobą zlała. Kopiowanie decyduje, które obiekty istnieją w dokumencie docelowym; porządkowanie decyduje, gdzie strony siedzą w drzewie stron. CollateDocumentsEx kopiuje każde źródło dokładnie raz, w jednym wywołaniu CopyPagesFromDoc z pełnym zakresem tego źródła, więc każde źródło dostaje jedną mapę importu, a współdzielone zasoby są zapisywane raz. Dopiero gdy każde źródło już wylądowało, następuje przeplot, i odbywa się on w całości poprzez TPDFPageTree.MovePage

Przenoszenie stron jest „darmowe” w sensie, który się tu liczy. ISO 32000-1 §7.7.3 definiuje drzewo stron jako zrównoważoną strukturę słowników węzłów, których tablice /Kids zawierają referencje pośrednie, a /Count niesie sumę liści na każdym węźle. Przeniesienie strony oznacza usunięcie jednej referencji pośredniej z jednej tablicy /Kids, wstawienie jej do innej, dostosowanie obu wartości /Count i przekierowanie /Parent strony. Żaden strumień treści nie jest dotykany, żaden zasób nie jest duplikowany, żaden obiekt nie jest tworzony. Obiekt strony zachowuje swój numer obiektu, co jest też powodem, dla którego numery obiektów pozostają stabilne w taki sam sposób, jak w zamianie stron zachowującej numery obiektów. Jest jeszcze jeden szczegół, który naiwne przenoszenie strony psuje, a MovePage nie. ISO 32000-1 §7.7.3.4 pozwala, by /Resources, /MediaBox, /CropBox i /Rotate były dziedziczone od węzła nadrzędnego zamiast być podane bezpośrednio na stronie. Strona dziedzicząca zasoby z węzła A, po przeniesieniu pod węzeł B, cicho odziedziczy coś innego, albo nic. MovePage dlatego rozwiązuje wartość dziedziczoną i zapisuje ją do słownika strony przed przeniesieniem, tak by strona przenosiła ze sobą własne atrybuty

Co właściwie robi przebieg przeporządkowujący?

Wykonuje sortowanie przez wybieranie względem semantyki wstawiania. Najpierw obliczana jest pożądana kolejność względem bloku: przechodzimy przez źródła w rotacji, bierzemy do GroupSize indeksów z każdego, pomijamy źródło wyczerpane, powtarzamy, aż każda strona zostanie umieszczona. Daje to permutację na dołączonym bloku. Zastosowanie jej to trudniejsza część, bo MovePage to wstawienie, nie zamiana, więc każde przeniesienie przesuwa o jeden wszystko pomiędzy starą a nową pozycją

Implementacja utrzymuje tablicę Current, modelującą, gdzie aktualnie siedzi każda dołączona strona, przeszukuje od pozycji K w przód strony, która ma tam trafić, wykonuje przeniesienie, po czym przesuwa wpisy tablicy tak, by odzwierciedlały to, co przeniesienie zrobiło z drzewem. To O(n kwadrat) operacji na tablicy i zero kopii obiektów, co jest właściwym kompromisem dla tego obciążenia: kolacjonowanie 500 stron to ćwierć miliona przetasowań liczb całkowitych i ani jeden bajt zduplikowanych danych obrazu. Zakresy malejące i powtórzone strony nie wymagają w tym przebiegu żadnej specjalnej obsługi, ponieważ PLParsePageRangeList jest wywoływane z wyłączonym sortowaniem i dozwolonymi duplikatami, więc żądana kolejność przechodzi przez parsowanie nietknięta

Zakresy odwrócone i scalenie dupleksu jednym wywołaniem

Gdy odwrócenie jest wyrażone jako zakres, przypadek dwuprzebiegowego skanera płaskiego zwija się do jednego wywołania. Strony przednie chcą naturalnej kolejności, a tylne chcą 12-1, a pusty pierwszy segment przed średnikiem mówi, że pierwsze źródło wnosi wszystkie swoje strony

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;

Warto jawnie wskazać dwa zachowania w tym fragmencie. Skolacjonowane strony są dołączane do wybranego dokumentu, więc dokument utworzony przez NewDocument wnosi przed nimi swoją początkową pustą stronę i należy ją usunąć, jeśli jej nie chcemy. A źródła mogą być nierówne: przy GroupSize równym 2, dla źródła trzy- i pięciostronicowego, rundy wyjdą jako A1 A2 B1 B2, potem A3 B3 B4, gdy A jest już niemal wyczerpane, potem samo B5, ponieważ wyczerpane źródło jest po prostu pomijane, a nie dopełniane

Wycofanie, pola formularza i to, co się nie przenosi

Każdy argument jest walidowany przed dotknięciem dokumentu docelowego. Brakujący uchwyt dokumentu, wybrany dokument wymieniony jako własne źródło, GroupSize poniżej jedności, liczba segmentów niezgadzająca się z liczbą źródeł, zakres wskazujący stronę, której źródło nie ma: wszystkie te przypadki zwracają 0, a dokument docelowy pozostaje niezmieniony. Awaria podczas kopiowania to trudniejszy przypadek i jest obsługiwana przez publiczne DeletePages, a nie surowe PageTree.DeletePages. Powód jest konkretny. Kopiowanie działa z włączonym MergeFormData, więc pola formularza źródła zostały już dołączone do tablicy /AcroForm /Fields dokumentu docelowego, zanim kolejne źródło zawiedzie. Usunięcie stron na poziomie drzewa stron pozbawiłoby stron z polami widgetowymi i zostawiłoby wiszące referencje pól; ścieżka publiczna odłącza referencje pól, konspektu i wątków artykułów wraz ze stronami

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]));

Bądź uczciwy wobec swoich użytkowników co do granic tej funkcji. Kolacjonowanie przenosi strony, ich adnotacje i pola formularza, i scala listę pól AcroForm, tablicę kolejności obliczeń oraz słownik zasobów domyślnych. Nie przenosi zakładek źródłowych: drzewo konspektu zeskanowanego stosu przedniego jest niemal zawsze puste, więc w przypadku dupleksu nic się nie traci, ale jeśli kolacjonujesz dwa autorskie dokumenty, ich konspekty zostają w tyle i nawigację trzeba odbudować samodzielnie. Nazwane miejsca docelowe, które istniały tylko w katalogu źródłowym, są w tej samej sytuacji. Zaplanuj to, zanim obiecasz klientowi bezstratne kolacjonowanie

PDFlibPas dostarcza funkcje kolacjonowania razem z resztą swojej powierzchni do składania stron, więc przepływ pracy skanera, ekstrakcja oparta na zakresach i ścieżki dla dużych plików mieszczą się razem w jednym komponencie w Delphi i C++Builder. Pełna dokumentacja API i wersja próbna są dostępne na stronie produktu losLab Delphi PDF library