기술 문서

Delphi에서 양면 스캔 취합: PDF 인터리브 병합

Delphi PDF 라이브러리 PDFlibPas의 CollateDocumentsEx는 여러 개의 열린 문서를 하나의 인터리브된 문서로 병합합니다. 각 소스에서 라운드마다 GroupSize개의 페이지를 덧붙이며, 소스별 페이지 범위 목록을 받아들이고, 3-1 같은 내림차순 범위를 그 소스의 역순으로 취급합니다. 호출 하나로 앞면 더미와 뒤집힌 뒷면 더미를 읽는 순서로 바꿉니다

이 API 뒤에 있는 시나리오는 평범하고 매우 흔합니다. 단면 경로만 가진 시트급지 스캐너는 전체 더미를 뒷면이 위로 가게 한 번 통과시킨 다음, 조작자가 더미를 뒤집어서 다시 한번 통과시킵니다. 결국 두 개의 PDF가 나옵니다. 순서대로인 앞면들과 역순인 뒷면들입니다. 사용자가 원하는 결과는 하나의 파일, 즉 1페이지 앞면, 1페이지 뒷면, 2페이지 앞면, 이런 식입니다. 이 글은 순서 문제와 그 아래 놓인 리소스 중복 함정을 다룹니다. 관심사가 원시 연결 처리량이라면 바이트 수준 참조 이동을 통한 빠른 PDF 병합을 보십시오. 입력이 너무 커서 메모리에 전혀 담을 수 없다면 다이렉트 액세스로 기가바이트급 PDF 병합·분할하기를 보십시오

스캐너는 두 개의 더미를 만들고, 그중 하나는 뒤집혀 있다

취합(collation)은 병합이 아닙니다. 병합은 페이지 범위를 연결하고, 취합은 그것들을 인터리브하며, 그 인터리브 패턴은 그것을 만들어낸 물리 장치의 속성입니다. 패턴을 잘못 잡으면 파일은 약간 잘못된 게 아니라 읽을 수 없게 됩니다. 두 페이지마다 다른 시트에 속하게 됩니다. 거의 모든 실제 사례를 기술하는 세 가지 변수가 있습니다. 순환에 몇 개의 소스가 있는지, 라운드마다 각 소스에서 몇 페이지가 오는지, 그리고 어떤 소스든 역순으로 읽어야 하는지입니다. CollateDocuments는 문서 핸들의 평범한 배열과 GroupSize 정수로 처음 두 가지를 다룹니다. CollateDocumentsEx는 세미콜론으로 구분된 페이지 범위 목록을 받아들여 세 번째를 더합니다. 소스마다 하나의 세그먼트이며, 빈 세그먼트는 그 소스의 모든 페이지를 의미하고 내림차순 범위는 역순으로 만듭니다. 두 함수 모두 현재 선택된 문서의 끝에 덧붙이며 성공 시 1, 거부 시 0을 반환합니다

순진한 취합은 왜 파일 크기를 배가시키는가

소스 객체 번호를 대상 객체 번호로 매핑하는 임포트 맵이 복사 호출마다 재구축되며, 한 청크보다 많은 청크에서 도달 가능한 것이라면 청크마다 한 번씩 임포트되기 때문입니다. PDFlibPas 내부에서 TPDFDocument.CopyPagesFromDoc은 매 호출 맨 위에서 NewIndObjList를 리셋합니다. 그 목록이 복사기가 이미 무엇을 가져왔는지 기억하는 유일한 방법입니다. 10페이지 범위로 한 번 호출하면 열 페이지 모두가 공유하는 폰트는 한 번 내장됩니다. 페이지 하나씩 열 번 호출하면 같은 폰트가 열 번 내장됩니다. 이는 텍스트 문서보다 스캔에서 훨씬 더 중요한데, 스캔된 페이지는 하나의 커다란 이미지 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는 재배치 전에 상속된 값을 해석해서 페이지 딕셔너리에 써넣으므로, 페이지는 이동을 거치면서도 자신의 속성을 그대로 가지고 갑니다

재정렬 패스는 실제로 무엇을 하는가

insert-at 의미론에 대해 선택 정렬을 실행합니다. 원하는 블록 상대 순서가 먼저 계산됩니다. 소스를 순환하며 각각에서 GroupSize까지의 인덱스를 취하고, 소진된 소스는 건너뛰고, 모든 페이지가 배치될 때까지 반복합니다. 그러면 덧붙여진 블록에 대한 순열이 만들어집니다. 이를 적용하는 것은 까다로운 부분인데, MovePage가 스왑이 아니라 삽입이므로, 모든 이동이 예전 위치와 새 위치 사이의 모든 것을 하나씩 밀어내기 때문입니다

구현은 덧붙여진 각 페이지가 현재 어디에 앉아 있는지 모델링하는 Current 배열을 유지하며, 위치 K에 속할 페이지를 K에서부터 앞으로 스캔하고, 이동을 실행한 다음, 그 이동이 트리에 한 일을 반영하도록 배열 항목들을 밀어냅니다. 이는 배열 연산에서는 O(n제곱)이고 객체 복사에서는 0인데, 이 작업 부하에는 올바른 트레이드오프입니다. 500페이지짜리 취합은 25만 번의 정수 셔플이며, 이미지 데이터는 단 1바이트도 중복되지 않습니다. 내림차순 범위와 반복된 페이지는 이 패스에서 특별한 처리가 필요 없는데, 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로 3페이지 소스와 5페이지 소스에 대해 라운드는 A1 A2 B1 B2, 그다음 A가 거의 소진되면서 A3 B3 B4, 그다음 B5 단독으로 나옵니다. 소진된 소스는 패딩되는 대신 그냥 건너뛰기 때문입니다

롤백, 폼 필드, 그리고 함께 오지 않는 것

모든 인자는 대상이 건드려지기 전에 검증됩니다. 누락된 문서 핸들, 자기 자신을 소스로 나열한 선택된 문서, 1 미만인 GroupSize, 소스 수와 맞지 않는 세그먼트 수, 소스가 갖지 않은 페이지를 지목하는 범위, 이 모든 것은 대상을 그대로 둔 채 0을 반환합니다. 복사 중 실패는 더 까다로운 경우이며, 원시 PageTree.DeletePages가 아니라 공개된 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 제품 페이지에 있습니다