병합(Merge)과 분할(Split)은 많은 개발자가 가장 먼저 접하는 페이지 제어 작업이며 다양한 요구사항을 충족합니다. 그러나 이 둘이 모든 요구를 만족시키지는 못합니다. 파일 전체를 조작하는 대신 페이지 배열을 변경하는 다음과 같은 작업군이 별도로 존재합니다. 즉, 인쇄용 유인물을 만들기 위해 네 개의 슬라이드를 한 장에 배치하거나, 문서 뒷부분의 특정 페이지를 맨 앞으로 드래그하거나, 다른 페이지는 건드리지 않고 3, 7, 12페이지만 모아 짧은 요약본을 만드는 것 등입니다. PDFium은 정확히 이 작업을 지원하는 세 가지 메서드를 제공하며, 각각 기존의 병합 및 분할과는 다르게 작동합니다. 본 아티클에서는 해당 메서드들의 역할과 데이터 출력 시점, 그리고 예기치 않은 시스템 종료를 유발할 수 있는 소유권 설정에 대해 자세히 다룹니다.
N-up 임포지션의 실제 동작 방식
임포지션(Imposition, 면배치)은 인쇄 및 제본 후 올바른 순서로 정렬되도록 여러 원본 페이지를 하나의 큰 인쇄판에 배치하는 인쇄 사전 작업 용어입니다. 일상적인 예로는 2페이지를 한 장에 모아 찍는 유인물, 4페이지로 구성된 소책자 시그니처, 혹은 한 페이지에 수십 개의 미리보기 이미지를 배열하는 밀착 인쇄(contact sheet) 등이 있습니다. PDFium은 단 한 번의 호출로 이러한 기하학적 계산을 처리합니다.
function ImportNPagesToOne(
OutputWidth, OutputHeight: Single;
NumX, NumY : Cardinal): TPdf;
NumX와 NumY는 그리드를 정의합니다. 2, 1은 두 개의 원본 페이지를 좌우로 배치하고, 2, 2는 네 페이지를 4등분 레이아웃으로 모으며, 4, 3은 12페이지짜리 인쇄 시트를 구성합니다. PDFium은 원본 페이지들을 순서대로 읽고, 각 페이지를 셀 크기에 맞춰 축소한 후, 그리드를 오른쪽에서 오른쪽, 위에서 아래 방향으로 채워 나갑니다. 그리드가 가득 차면 새 페이지에 작성을 시작합니다. 원본 페이지들은 전혀 수정되지 않으며, 여러 페이지가 합성된 형태의 새 문서 객체가 반환됩니다.
출력 크기 단위는 픽셀이 아닌 포인트입니다
OutputWidth 및 OutputHeight는 PDF 사용자 단위(user unit)이며, 1 단위는 1/72인치 크기의 1포인트(point)입니다. 이 수치는 인쇄용 시트의 물리적인 치수를 나타내며, 화면 픽셀이나 렌더링 DPI와는 아무런 관계가 없습니다. 임포지션을 설정할 때 가장 자주 일어나는 실수가 바로 이 부분입니다. 비트맵 데이터 처리에 익숙한 개발자가 픽셀 수치 단위로 값을 입력하여, 우표나 빌보드 광고판만 한 크기의 비정상적인 문서를 생성하게 되기 때문입니다.
가장 자주 사용하는 대표적인 규격 문서 크기 두 가지를 기억해 두면 유용합니다. US Letter는 8.5인치에 72를 곱한 612포인트와 11인치에 72를 곱한 792포인트로 구성됩니다. A4는 210mm x 297mm 물리 크기 기준으로 약 595포인트 x 842포인트입니다. 바인딩 헤더에 명시된 규칙대로 1 단위는 1/72인치를 의미하며, 코드 내에서 상수 값을 직접 쓰는 대신 인치 기준으로 치수를 계산하고 싶다면 제공되는 72 값의 PointsPerInch 상수를 활용할 수 있습니다.
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은 Boolean이 아닌 TPdf 객체를 반환합니다. 이 반환 값은 원본 문서와 격리되어 새롭게 메모리가 할당된 문서 핸들이며, 호출자가 그 관리 권한을 가집니다. 호출 시 사용된 원본 TPdf 객체는 변경되지 않고 자체 핸들을 유지합니다. 즉, 합성 결과물은 별개의 독립적인 두 번째 객체입니다. 반환된 TPdf를 메모리에서 해제하지 않고 스코프 밖으로 소멸시키면 PDFium 문서가 누수됩니다.
더욱 치명적인 실수는 반대 상황에서 일어납니다. 내부적으로 이 메서드는 FPDF_ImportNPagesToOne을 호출하여 PDFium으로부터 새로운 FPDF_DOCUMENT 핸들을 할당받은 뒤, 이 원본 핸들을 반환할 TPdf 래퍼 내부에 래핑하여 핸들의 생명주기가 래퍼의 파괴 시점과 연동되도록 설계합니다. 따라서 핸들의 소유권은 단 하나만 존재하며, 반환된 객체를 Free를 통해 소멸시킬 때에만 닫혀야 합니다. 예외 발생 시 래퍼 객체도 해제하고 래핑되어 있던 원본 핸들에 대해서도 FPDF_CloseDocument를 명시적으로 재호출하면, 동일한 PDFium 문서가 두 번 닫힙니다. 이것이 이중 해제(double-free) 현상이며, 예전에 실제로 발생했던 심각한 버그 형태입니다. 예방 규칙은 간단합니다. 메서드가 전달한 TPdf 객체만 소멸시키고, 래퍼 내부의 가상 핸들에 직접 접근하여 해제 함수를 호출하지 마십시오.
여기에 수반되는 두 가지 세부 조치 사항이 있습니다. 첫째, 그리드 행렬 수치에 0이 주입되거나 메모리 할당에 실패하면 PDFium이 인수를 거부하고 nil을 반환하므로, 데이터 참조 전 nil 여부 유효성 검사가 먼저 수행되어야 합니다. 둘째, 위의 예시처럼 반환될 변수를 try 전에 nil로 미리 초기화하고 finally 절에서 해제하여, 중간 단계 오류 시 유효하지 않은 포인터 해제 오류나 메모리 누수를 원천 방지해야 합니다.
재작성 없이 페이지 순서 변경하기
임포지션은 새 문서를 빌드하는 과정인 반면, 페이지 재배열은 단일 문서를 그 자리에서 변경하는 것입니다. MovePages는 지정한 페이지 묶음을 현재 위치에서 들어 올려 대상 위치로 이동시키며, 이동된 영역을 제외한 나머지 모든 페이지들을 밀어내어 전체 페이지 수를 동일하게 유지합니다.
function MovePages(
const PageIndices: array of Integer;
DestPageIndex : Integer): Boolean;
인덱스는 0부터 시작합니다. 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는 원본 문서와 0 기반의 인덱스 배열을 인수로 받아, 복사 대상 문서의 원하는 오프셋 위치에 페이지들을 주입합니다.
function ImportPagesByIndex(
Source : TPdf;
const PageIndices: array of Integer;
InsertAt : Integer= 0): Boolean;
대상 타겟 문서 객체에서 이 메서드를 호출하고 원본 소스 문서 객체를 첫 번째 인수로 전달합니다. PageIndices는 복제할 소스 페이지 목록을 원하는 순서대로 정의하며, InsertAt은 삽입될 위치의 0 기반 타겟 오프셋입니다. 예를 들어 0을 지정하면 기존 문서 내용의 맨 앞에 삽입되며, 빈 배열 전달 시 전체 페이지를 그대로 복제하므로 문서 전체 복사본을 만드는 것과 같아집니다. 잘못된 원본 인덱스가 전달되면 False가 반환됩니다.
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 문서를 복수 파일로 분할하기 아티클에서 안내합니다. 본 문서에서 설명한 면배치(imposition) 및 페이지 재배열 기법은 본 블로그에서 소개하는 다양한 문서 로드, 렌더링 및 편집 API와 함께 Delphi 및 C++Builder용 PDFium Component 제품에 통합되어 제공됩니다.