Technical Article

PDFium을 사용한 Form XObject 기반 재사용 가능 페이지 스탬프

문서의 모든 페이지에 워터마크나 로고를 스탬핑하는 것은 아주 간단한 작업처럼 보이지만, 작업이 완료된 파일의 용량을 검사하는 순간 사정이 달라집니다. 가장 직관적인 해결책은 각 페이지를 돌면서 동일한 텍스트나 이미지 요소를 매번 새로 그리는 것입니다. 시각적으로는 동일한 결과를 내지만, 이 방식은 데이터 낭비를 기하급수적으로 축적시킵니다. 100페이지 분량의 보고서 페이지마다 대각선 방향으로 'DRAFT' 워터마크를 개별적으로 그리면 동일한 그래픽 경로 및 텍스트 데이터가 100개 복제되어 저장되며, 최종 파일은 이 불필요한 데이터를 모두 수반하게 됩니다.

Form XObject는 PDF 규격이 이러한 비효율성을 해소하기 위해 고안한 구조입니다. 이는 반복적으로 사용될 문서 페이지 조각이나 그래픽 서식을 단일 명명 객체로 패키징하여 여러 페이지의 다양한 좌표에 반복 렌더링되도록 돕습니다. 실제 그래픽 정보는 아카이브 파일 내에 단 한 번만 저장됩니다. 스탬프가 필요한 각 페이지는 'XObject N을 해당 좌표 변환 규칙에 따라 렌더링하라'는 짧은 렌더링 지시문만을 소유하게 됩니다. 100페이지 분량 of 스탬프 작업을 진행하더라도 100배의 용량 폭증 대신 단 하나의 리소스 오브젝트만 추가되므로, 문서 전체 용량이 페이지 수에 비례하여 기하급수적으로 늘어나는 위험을 방지합니다. 워터마크, 로고 이미지 스탬프, 서식 레이아웃 템플릿, 각종 인장 직인 렌더링은 모두 동일한 성격의 문제이며, Form XObject는 이를 처리하는 최적의 솔루션입니다.

한 번 객체로 저장하는 것이 백 번 다시 그리는 것보다 효율적인 이유

이러한 효율성은 단순히 표면적 개선이 아니라 구조적인 개선입니다. PDF 페이지는 연속된 드로잉 지시어들로 구성된 콘텐츠 스트림을 실행하여 화면을 렌더링합니다. 페이지마다 동일한 스탬프를 반복해서 그리는 방식은 해당 그리기에 사용된 긴 지시어 세트 전체를 매 페이지의 스트림 데이터 하단에 매번 가산하므로 파일 크기를 불필요하게 키우는 원인이 됩니다. 반면 Form XObject는 그리기 지시어들을 문서 내에 한 번만 정의되는 단일 독립 스트림으로 이식합니다. 각 페이지는 변환 행렬 정보를 적용하고 해당 XObject의 출력만을 링크한 뒤 본래 상태를 복원하는 가벼운 링크 정보만을 유지하게 됩니다. 결과적으로 페이지 수가 늘어나도 그래픽 리소스 추가로 인한 용량 부담이 배제됩니다.

이는 복잡한 그래픽이 포함된 스탬프를 다룰 때 더욱 중요합니다. 수백 개의 세밀한 선이나 로고 비트맵이 포함된 정밀 인장은 데이터 저장 공간을 많이 차지합니다. 이 리소스를 한 번만 저장하고 링크 형식으로 교차 호출하면, 고용량 리소스 정보는 한 번만 가산되고 페이지당 처리 비용은 몇 바이트 수준의 호출문으로 처리됩니다. 사용자가 화면을 보거나 인쇄물로 출력하는 결과물은 100번 새로 그린 방식과 완벽하게 일치합니다. 리더 프로그램에서는 시각적 구분이 불가능하며, 오직 파일 용량 격차만이 유의미하게 남게 됩니다.

페이지를 XObject로 캡처하기

PDFium은 기존 페이지로부터 재사용 가능한 객체를 생성합니다. 오픈된 다른 문서의 페이지나 워터마크 그래픽만 담겨 있는 단일 페이지 사본 PDF, 혹은 특정 고용량 문서의 일부분을 소스로 삼을 수 있습니다. CreateXObjectFromPage는 대상 페이지 리소스를 추출하여 스탬프가 렌더링될 대상 문서가 소유하는 재사용 가능한 핸들 객체로 캡처(capture)합니다.

var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
begin
  Dest := TPdf.Create;
  Stamp := TPdf.Create;
  try
    Dest.LoadFromFile('Report.pdf');
    Stamp.LoadFromFile('Watermark.pdf');   // one page of artwork

    // Capture page 0 of the stamp document into a reusable handle that
    // is owned by Dest. Source must be active; the index is zero-based.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not build the stamp XObject');
    // ... place it, then free it before closing Stamp (see below) ...

API 시그니처는 CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject입니다. 이 메서드는 에러 발생 시 예외를 호출하는 대신 nil을 반환하므로, 반환값 검증 로직은 반드시 구현되어야 합니다. 반환된 핸들은 개발자 코드 영역에서 소유하는 TPdfXObject 인스턴스이며, 이 핸들과 연계되어 작동하는 두 가지 리소스 생명 주기(lifetime) 제약 사항은 구현 시 자주 범하는 오류 영역이므로 다음 섹션에서 별도로 설명합니다.

페이지에 스탬프 배치

캡처된 XObject 자체만으로는 화면에 노출되지 않습니다. 이를 실제 렌더링하려면 InsertFormObjectFromXObject를 실행하여 대상 문서의 현재 활성 페이지에 복사본을 삽입해야 합니다. 이 함수는 하위 페이지 개체 핸들인 FPDF_PAGEOBJECT를 반환하며, 이 핸들 속성을 제어하여 렌더링 좌표를 결정합니다. 위치 변환 연산(transform)을 생략하면 스탬프 그래픽은 소스 문서의 원점 좌표에 맞물려 출력되므로, 대부분의 시나리오에서는 적절한 좌표 이동 처리를 필요로 합니다.

InsertFormObjectFromXObject 메서드는 실행 시마다 개별 복사본을 삽입하고 독자적인 페이지 개체 핸들을 반환하므로, 하나의 XObject를 다른 행렬 변환식과 연계하여 단일 페이지의 여러 좌표에 복사 렌더링할 수 있으며, 이 모든 조각들의 원본 그래픽 데이터는 파일 내에 여전히 단 하나만 가산됩니다. 즉, 문서 귀퉁이 로고 이미지 스탬프와 전체 페이지에 깔리는 반투명 워터마크 출력을 단 하나의 캡처된 공통 오브젝트로부터 함께 구현할 수 있습니다.

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
begin
  // The current page of Dest receives one copy of the XObject.
  PageObj := Dest.InsertFormObjectFromXObject(XObject);
  if PageObj = nil then
    raise Exception.Create('Insert failed on this page');

  // Position it: move 200 units right, 500 up, at 70% scale.
  M := TPdfMatrix.Create;
  try
    M.Scale(0.7, 0.7);
    M.Translate(200, 500);
    FPDFPageObj_SetMatrix(PageObj, M.Handle);
  finally
    M.Free;
  end;
  // Dest.SaveLoadedDocument(...) when every page is done.
end;

소유권 이전 메커니즘 덕분에 리소스 정리가 대단히 간단하고 안전합니다. 삽입 연산이 성공적으로 수행되면, 페이지 개체의 소유권은 해당 페이지로 넘어가며 생성 기점이 된 XObject 정보와는 무관하게 동작합니다. 따라서 렌더링 후 XObject 핸들을 해제(Free)하더라도 이미 삽입 배치된 페이지 콘텐츠 그래픽은 소실되지 않고 안전하게 보존됩니다. 이것이 아래 설명할 '생성-배치-해제' 순차 프로세스가 문제없이 작동할 수 있는 근거입니다.

흔히 실수를 범하는 핸들 수명 관리 규칙

XObject 핸들을 제어할 때 명심해야 할 두 가지 주요 제약이 있으며, 이를 어기면 원인 규명이 어려운 엉뚱한 예외 오류가 발생합니다. 첫째, CreateXObjectFromPage를 실행할 때 근원 소스 문서가 반드시 활성(Active) 상태여야 합니다. 캡처 모듈은 메모리에 로드되어 있는 소스 문서 데이터를 읽어 그래픽 정보를 추출하므로, 핸들 구성 연산이 종료되기 전에 소스 문서나 해당 페이지 정보가 닫히면 안 됩니다. 둘째, 실무에서 실수하기 가장 쉬운 부분인데, 추출된 XObject 핸들은 소스 페이지를 닫기 전에, 다시 말해 해당 개체를 소싱한 소스 문서를 메모리에서 제거하기 전에 먼저 소멸(Free)되어야 합니다.

그 이유는 생성된 XObject가 근본 소스 문서가 점유하고 있는 메모리 구조에 대한 참조 값을 들고 있기 때문입니다. 이는 소스 파일이 제거된 후에도 독립적으로 유지되는 완전 분리된 복제 사본이 아닙니다. 소스 문서를 먼저 해제해 버리면 XObject 핸들은 이미 소멸된 해제 메모리 영역(dangling handle)을 바라보게 되어, 이후 이 핸들을 정리하거나 제어하려 할 때 유효하지 않은 잘못된 메모리 주소 오류를 발생시킵니다. 대표적인 증상으로는 애플리케이션 종료 시의 액세스 위반(access violation) 에러나 메모리 할당 패턴에 따라 무작위로 위치가 변하는 원인 미상의 메모리 훼손 등이 있으며, 콜 스택 정보 역시 실제 원인 코드 라인이 아닌 최하단 시스템 리소스 해제 코드 영역을 가리키는 경우가 많습니다. 해결책은 복잡한 방어 코드가 아닌 순서의 통제입니다: 즉 XObject를 생성하고, 필요한 모든 페이지에 배치한 후, XObject를 즉시 해제(Free)하고, 그 이후에 비로소 소스 문서를 닫는 것입니다. TPdfXObject 소멸자는 하위 PDFium 핸들을 자동으로 관리해 주므로, 개발자는 wrapper 인스턴스의 적절한 소멸 타이밍만 통제하면 됩니다.

행렬(Matrix)과 6개 숫자가 의미하는 것

위치 배치는 PDF 규격 전반에서 콘텐츠의 정렬을 결정할 때 사용되는 2D 아핀 변환(affine transform, ISO 32000-1 section 8.3.4)을 기반으로 합니다. a, b, c, d, e, f로 정의되는 6개의 실숫값으로 구성되며, PDFium은 이를 FS_MATRIX 레코드로 처리합니다. 이 수치들은 개체 로컬 좌표계의 특정 점을 페이지 좌표계로 투영합니다:

// x' = a*x + c*y + e
// y' = b*x + d*y + f
//
// a, d : horizontal and vertical scale
// b, c : the shear / rotation terms
// e, f : translation (where the origin lands on the page)

이 6개의 숫자를 직접 작성할 수도 있지만 수동으로 회전 연산식을 구성하는 과정에서 자주 연산 실수가 발생합니다. 회전 변환 과정에서 a, b, c, d의 네 수치 변수가 복합적으로 상호 간섭하기 때문입니다. TPdfMatrix wrapper는 일반적인 이동(Translate), 배율(Scale), 회전(Rotate) 연산식을 호출 순서대로 내부 행렬 곱셈을 통해 자동으로 계산하여 결합해 줍니다. 사선 방향 워터마크는 회전(rotate) 후 위치 중심 보정을 위한 이동(translate) 연산의 조합이며, 가장자리 코너 로고 배치는 배율 조정(scale) 후 이동(translate)의 조합입니다. 행렬 연산이 끝나면 그 원시 레코드 값을 FPDFPageObj_SetMatrix(PageObj, M.Handle) 메서드로 페이지 개체에 할당하며, M.Handle은 내부 FS_MATRIX 자료구조와 매핑됩니다. wrapper 오브젝트를 빌드하지 않고 수치로 직접 전달하려는 경우에는 6개 실수를 double 형식 인수로 수용하는 하위 FPDFPageObj_Transform API를 직접 제어할 수도 있습니다.

올바른 순서로 모든 페이지에 스탬프 적용

안전한 설계 구조는 리소스 생명 주기를 엄격히 준수하도록 다음과 같이 조율됩니다: 두 파일을 열고, 스탬프용 그래픽을 메모리에 한 번 캡처한 후, 대상 페이지 루프를 돌며 타겟 페이지를 활성화해 복사본을 삽입 정렬하고, 페이지 루프 종료 즉시 XObject 리소스를 해제(Free)하며, 이후 대상 문서를 파일로 저장하고, 마지막에 근원 소스 문서를 닫는 것입니다.

procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  i: Integer;
begin
  Dest := TPdf.Create;
  Stamp := TPdf.Create;
  try
    Dest.LoadFromFile(ASource);
    Stamp.LoadFromFile(AStamp);

    // 1. Capture the artwork once. Stamp is active here.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not capture the stamp page');
    try
      // 2. Place a copy on every page of Dest.
      for i := 0 to Dest.PageCount - 1 do
      begin
        Dest.CurrentPageIndex := i;          // make page i current
        PageObj := Dest.InsertFormObjectFromXObject(XObject);
        if PageObj = nil then
          Continue;

        M := TPdfMatrix.Create;
        try
          M.Rotate(45);                      // diagonal watermark
          M.Translate(150, 100);             // nudge into position
          FPDFPageObj_SetMatrix(PageObj, M.Handle);
        finally
          M.Free;
        end;
      end;
    finally
      XObject.Free;                          // 3. free BEFORE Stamp closes
    end;

    // 4. Write the result while Dest is still open.
    Dest.SaveLoadedDocument(AOutput);
  finally
    Stamp.Free;                              // source closes last
    Dest.Free;
  end;
end;

예외 제어 블록(try-finally)의 중첩 구조가 안전성을 담보하는 핵심 장치입니다. 내부 finally 절은 외부 finally가 소스 문서 Stamp를 해제하기 전에 먼저 호출되므로, 루프 실행 도중 어떠한 프로그램 예외 에러가 발생하더라도 XObject 정보는 원본 데이터가 메모리에 살아 있는 안전한 상태에서 먼저 해제됩니다. 이 중첩 예외 구조를 구현하면 소멸 순서 규칙이 자동으로 준수됩니다. (사용 중인 컴포넌트 빌드가 제공하는 페이지 선택 명령어 형식을 적용하십시오. 루프 처리 바디 자체는 동일합니다.)

스탬핑 기능은 페이지 그래픽 요소를 설계하고 편집하는 방대한 PDF 도구 모음의 일부분입니다. 삽입하려는 스탬프가 페이지 사본이 아닌 원시 비트맵 이미지 파일 형태인 경우, 이미지 정보를 문서 내에 먼저 이식하는 과정은 PDFium을 사용하여 이미지 파일을 PDF로 변환하는 방법 가이드를 참조하십시오. 잉크 정보가 아닌 문서 파일 자체를 결과물 내에 함께 수반해 내보내려면 Delphi에서 PDF 첨부 파일 제어하기를 통해 파일 임베딩을 구성할 수 있습니다. 이 모든 기능은 렌더링, 문서 해체 편집 API와 함께 Delphi 및 C++Builder용 PDFium 컴포넌트로 제공됩니다.