기술 문서

PDFium을 사용한 N-up 임포지션 및 페이지 재배열

병합과 분할은 모든 사람이 가장 먼저 찾는 두 가지 페이지 작업이며, 많은 부분을 다룹니다. 하지만 모든 것을 다루지는 않습니다. 전체 파일을 이동하는 대신 페이지를 재배열하는 별도의 작업군이 있습니다: 유인물을 위해 4개의 슬라이드를 한 장에 배치하거나, 문서의 뒤쪽에 있는 페이지를 앞으로 끌어오거나, 나머지 부분을 건드리지 않고 3, 7, 12페이지를 짧은 발췌본으로 추출하는 작업입니다. PDFium은 정확히 이 작업을 위한 세 가지 메서드를 제공하며, 각각은 이미 알고 있는 병합 및 분할과는 다르게 작동합니다. 이 글에서는 이들이 수행하는 작업, 출력 지점이 위치한 곳, 그리고 실제 현장에서 크래시를 유발했던 하나의 소유권 세부 사항에 대해 살펴봅니다

이 세 가지는 N-up 임포지션을 위한 ImportNPagesToOne, 내부 페이지 재배열을 위한 MovePages, 하위 집합 추출을 위한 ImportPagesByIndex입니다. 병합은 문서를 끝에서 끝으로 연결하며 입력 페이지 수의 합과 동일한 페이지 수를 남깁니다. 분할은 하나의 입력에서 여러 출력 파일을 씁니다. 여기에 있는 세 가지 작업은 그 사이에 위치합니다: 하나는 시트를 공유하는 원본 페이지 수를 변경하고, 다른 하나는 단일 문서 내의 순서를 변경하며, 마지막 하나는 선택된 일부 페이지만 다른 문서로 복사합니다. 각각의 차이점을 알면 단 한 번의 호출로 처리할 수 있는 곳에서 병합 후 삭제와 같은 번거로운 작업을 강제로 수행하지 않아도 됩니다

N-up 임포지션의 실제 기능

임포지션(Imposition)은 인쇄 및 접지 후 올바른 순서로 읽을 수 있도록 여러 원본 페이지를 더 큰 한 장의 시트에 배열하는 인쇄 전 처리(prepress) 용어입니다. 일상적으로 사용되는 형태로는 2-up 유인물, 4-up 소책자 접지 단위(signature), 또는 한 페이지에 십여 개의 썸네일을 맞추는 밀착 인화(contact sheet) 등이 있습니다. PDFium은 한 번의 호출로 이러한 기하학적 배치를 처리합니다:

function ImportNPagesToOne(
  OutputWidth, OutputHeight: Single;
  NumX, NumY               : Cardinal): TPdf;

NumXNumY는 그리드를 정의합니다. 2, 1 값은 두 개의 원본 페이지를 나란히 배치하고, 2, 2는 네 페이지를 사분면 레이아웃으로 채우며, 4, 3은 12-up 밀착 인화를 생성합니다. PDFium은 원본 페이지를 순서대로 읽고, 각 페이지를 셀에 맞게 축소하며, 왼쪽에서 오른쪽으로, 위에서 아래로 그리드를 채우고, 현재 그리드가 꽉 찰 때마다 새로운 출력 시트를 시작합니다. 원본 페이지는 수정되지 않습니다. 반환되는 것은 합성된 페이지들로 구성된 새 문서입니다

출력 크기는 픽셀이 아닌 포인트 단위입니다

OutputWidthOutputHeight는 PDF 사용자 단위(user units)이며, 하나의 PDF 사용자 단위는 1포인트, 즉 1인치의 72분의 1입니다. 이 단위는 출력 시트의 물리적 크기를 선언하며 화면 픽셀이나 렌더링 DPI와는 아무런 관련이 없습니다. 비트맵에 익숙한 개발자가 픽셀 수를 입력했다가 우표만 한 크기나 광고판만 한 크기의 시트를 얻게 되기 때문에, 이는 임포지션에서 오류를 범하는 가장 흔한 원인입니다

가장 많이 사용될 두 가지 페이지 크기의 수치는 외워두는 것이 좋습니다. US Letter 사이즈는 8.5인치 곱하기 72가 612이고 11인치 곱하기 72가 792이므로 612 x 792포인트입니다. A4 사이즈는 210 x 297 밀리미터 크기에서 계산되어 대략 595 x 842포인트입니다. 바인딩의 자체 헤더에는 하나의 단위가 1인치의 72분의 1이라는 규칙이 명확히 명시되어 있으며, 코드에서 직접 리터럴 값을 적는 대신 인치로부터 크기를 계산하고 싶다면 제공되는 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;

반환된 핸들의 메모리 해제는 사용자의 책임입니다

함수 서명(signature)을 다시 읽어보세요. ImportNPagesToOne은 Boolean이 아닌 TPdf를 반환합니다. 이 반환 값은 원본과 별도로 할당된 완전히 새로운 문서 핸들이며, 호출자가 그 소유권을 가집니다. 메서드를 호출한 원본 TPdf는 그대로 유지되며 자신의 핸들을 여전히 소유하고 있고, 합성 문서는 두 번째 독립적인 객체입니다. 반환된 TPdf를 메모리 해제하지 않고 범위를 벗어나게 두면 PDFium 문서 전체에 누수(leak)가 발생합니다

이보다 더 위험한 실수는 반대 방향에서 발생합니다. 내부적으로 이 메서드는 FPDF_ImportNPagesToOne을 통해 PDFium에 새로운 FPDF_DOCUMENT를 요청한 다음, 래퍼(wrapper)의 수명이 핸들의 수명을 제어하도록 반환된 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를 반환합니다

이 지점이 분할 작업과의 차이가 두드러지는 곳입니다. 분할은 개별 파일들을 작성하며, 한 번의 작업으로 디스크에 여러 개의 출력을 생성합니다. ImportPagesByIndex는 그 반대 형태의 작업을 수행합니다: 선택된 페이지들을 메모리의 단일 대상 문서로 모아둔 다음, 나중에 한 번만 저장합니다. "3, 7, 12페이지를 하나의 짧은 PDF로 만들어줘"와 같은 작업을 할 때 이것이 가장 직접적인 방법이며, 내부적으로 FPDF_ImportPagesByIndex를 래핑합니다

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을 설정하고 ActiveTrue로 전환하여 원본을 열고, 작업을 수행한 다음 SaveAs로 저장하고 소유한 리소스를 해제합니다. 주의해야 할 단 한 가지 분기점은 어떤 호출이 새 문서를 할당하느냐 하는 것입니다. MovePages는 이미 들고 있는 문서를 수정하므로 해제해야 할 객체는 하나입니다. ImportPagesByIndex는 직접 생성한 대상에 쓰기 작업을 수행하므로, 자신이 열어둔 원본과 대상 모두를 해제합니다. ImportNPagesToOne은 유일한 예외로, 새 문서가 직접 생성한 객체가 아닌 메서드의 반환 값이기 때문이며, 이것이 호출자가 소유한 별도의 핸들이라는 사실을 잊어버리는 것이 메모리 누수나 이중 해제 오류를 발생시키는 원인입니다. 결과를 nil로 초기화하고, 호출 후에 상태를 검사하며 단일 경로를 통해 해제하세요

실제 해야 할 작업이 페이지를 재배열하는 것이 아니라 전체 파일을 결합하는 것이라면 PDFium을 사용하여 여러 PDF 파일을 단일 문서로 병합하기를 참조하세요. 반대로 한 문서를 여러 파일로 나누는 것이라면 PDFium을 사용하여 PDF 문서를 여러 파일로 분할하기를 참조하세요. 여기에 설명된 임포지션 및 재배열 메서드는 이 블로그의 다른 곳에서 다루는 로드, 렌더링 및 편집 API와 함께 Delphi 및 C++Builder용 PDFium 컴포넌트의 일부로 제공됩니다