기술 문서

Delphi에서 PDFium QuadPoints를 활용한 텍스트 마크업 주석 생성하기

PDFium Component는 TPdf.CreateAnnotation을 통해 형광펜(highlight), 밑줄(underline), 취소선(strikeout), 물결선(squiggly) 등의 텍스트 마크업 주석을 생성합니다. TPdfAnnotation 레코드의 HasAttachmentPoints를 True로 설정하고 사각형 좌표를 나타내는 AttachmentPoints를 기입하면, 컴포넌트는 내부적으로 ISO 32000-1 §12.5.6.10 규격의 QuadPoints 항목을 파일에 기록합니다. API의 외형은 이것이 전부입니다. 본 기사가 작성된 진짜 이유는 내부 로직 단계에서 발생하는 매우 까다로운 실패 현상 때문입니다. 새로 생성한 주석 인스턴스를 대상으로 로우 레벨 PDFium 함수인 FPDFAnnot_SetAttachmentPoints를 호출하면, 에러 코드나 아무런 경고도 없이 언제나 무조건 false만을 반환하며 작동하지 않는 문제가 발생합니다. 이 글은 기존 파일 내의 주석들을 조회하고 파싱해 내는 PDF 주석 판독 및 검토 가이드의 짝꿍 문서로, 동일한 파일 구조를 역방향인 기입 관점에서 추적해 분석해 나갑니다

디버깅 상황은 항상 똑같습니다. 형광펜 주석을 생성한 후 인덱스 0으로 좌표 설정 함수를 호출했는데 반환 값으로 false가 떨어지면 개발자는 자신의 좌표 계산식이 잘못된 것인 줄 알고 헛물켜기 시작합니다. 점들의 행렬을 트랜스포즈해 보고, Y축 방향을 반전시켜 보고, 페이지 좌표계를 디바이스 화면 좌표계로 변환도 해 봅니다. 그러나 좌표 수치 자체가 문제의 원인이 아니었기 때문에 이 조치들은 아무런 해결책이 되지 않습니다. 진짜 문제는 PDFium C API의 독특한 인덱싱 규칙에 있었으며, 원인을 파악하면 단 두 줄의 코드 수정만으로 완벽하게 해결할 수 있습니다

ISO 32000-1 규격에서의 QuadPoints의 정의와 의미

QuadPoints는 n개의 사각형 영역을 묘사하는 8×n개의 수치 배열 구조로, ISO 32000-1 §12.5.6.10 규격상 모든 텍스트 마크업 주석에 필수로 포함되어야 합니다. 개별 사각형 영역은 형광펜이나 밑줄, 취소선이 실질적으로 그려져야 할 특정 단어나 인접한 단어들의 바운딩 영역을 가리킵니다. 주석 개체의 기본 Rect 속성도 존재하지만, 마크업 형태의 주석에서 이 사각형은 단순히 해당 주석 전체가 위치하는 대략적인 범위만을 규정할 뿐이며 화면에 그려지는 실제 실선 그래픽은 이 QuadPoints 데이터에 기초해 렌더링됩니다. 직사각형(rectangle)이 아닌 사각형(quadrilateral) 데이터인 이유는 텍스트가 회전되어 있거나 기울어(sheared) 있을 수 있기 때문입니다. 네 꼭짓점 좌표는 x1 y1 x2 y2 x3 y3 x4 y4와 같이 4개의 독립된 좌표 데이터 쌍으로 저장됩니다

주의할 점은 이 네 꼭짓점을 정의하는 좌표의 순서에 대해 규격서의 문구와 실무 제품들의 판독 동작이 엇갈린다는 사실입니다. ISO 공식 표준 문서 상에는 네 꼭짓점을 시계 반대 방향 순서로 그리도록 기술되어 있으나, 어도비 아크로뱃 리더(Acrobat Reader)의 렌더러는 역사적으로 이 좌표를 언제나 Z자 패턴으로 해석하여 렌더링해 왔습니다. 즉, 윗변의 좌측에서 우측으로, 그리고 밑변의 좌측에서 우측으로 그리는 순서입니다. 전 세계 개발자들이 어도비 제품을 기준으로 문서를 제작해 테스트했기 때문에, PDFium을 포함한 현대의 거의 모든 PDF 뷰어 프로그램들은 이 Z자 패턴 규칙을 따릅니다. 오히려 규격서의 문구를 그대로 기계적으로 해석해 구현한 파일들은 아크로뱃 이외의 뷰어에서 형광펜 줄이 꼬이거나 일그러지는 현상을 겪게 됩니다. PDFium의 FS_QUADPOINTSF 구조체 역시 정확히 이 관례를 적용하고 있습니다. Y축 좌표가 위로 갈수록 증가하는 페이지 좌표계를 기준으로, (x1,y1)은 좌측 상단, (x2,y2)는 우측 상단, (x3,y3)은 좌측 하단, (x4,y4)는 우측 하단 꼭짓점을 지칭합니다. 이 순서에 맞추어 좌표를 기입하십시오. 뷰어 프로그램들이 웬만한 규약 위반은 눈감아 주지만, 이 사각형 꼭짓점 좌표 순서가 꼬이는 순간 그래픽 렌더링을 완전히 포기해 버리기 때문입니다

왜 FPDFAnnot_SetAttachmentPoints가 false를 반환할까요?

FPDFAnnot_SetAttachmentPoints가 새 주석에서 무조건 실패하는 이유는 이 함수의 설계 계약 조건이 지정된 특정 인덱스 위치의 사각형을 '교체(replace)'하는 것이기 때문입니다. 완전히 새로 생성된 주석 객체 내부에는 교체 대상인 사각형 슬롯이 애초에 단 한 개도 존재하지 않습니다. 이 함수의 매개변수 구조는 주석 핸들, quad_index, 그리고 꼭짓점 좌표 구조체를 넘겨받습니다. 이때 인덱스 0을 넘기는 것은 '0번째 위치에 새로 만들어라'가 아닌, '이미 존재하는 0번째 사각형의 좌표를 수정하라'는 명령이므로 FPDFAnnot_CountAttachmentPoints 결과가 0인 새 주석에서는 존재하지 않는 영역 수정 에러를 내뿜으며 false를 반환하는 것입니다. 슬롯 영역을 최초 생성하는 함수는 따로 존재합니다. 바로 FPDFAnnot_AppendAttachmentPoints(추가) 함수입니다. FPDFPage_CreateAnnot를 통해 무에서 새로 작성된 모든 주석은 카운트가 항상 0이므로, 최초 삽입 시점에는 반드시 Append 함수를 먼저 가동해야 하며 사후 데이터 수정 시에만 Set 함수를 호출해 대처해야 합니다

이전의 결함은 과거 PDFium Component 내부 설계에서도 목격되었던 문제입니다. v1.79.0 버전 이전까지는 CreateAnnotationSetAnnotation이 공유하는 내부 공용 함수가 FPDFAnnot_SetAttachmentPoints(Annotation, 0, ...)를 항상 하드코딩해서 기동했습니다. 기존 주석을 수정하는 상황에서는 정상 작동하지만 새 주석을 생성하는 순간 무조건 에러가 터졌고, 결국 상위 애플리케이션에 'Cannot set attachment points(주석 좌표를 설정할 수 없습니다)'라는 모호한 EPdfException 에러만을 보고하게 되었던 것입니다. v1.79.1 버전에 탑재된 해결책은 현재 슬롯 개수에 맞게 분기 처리하여 완벽히 해결했습니다

// Inside the component's annotation writer (v1.79.1+):
// a new annotation has no quad slots yet, so Append creates
// the first one; Set only replaces a slot that already exists
if FPDFAnnot_CountAttachmentPoints(Annotation) = 0 then
  Check(FPDFAnnot_AppendAttachmentPoints(Annotation, QuadPoints) <> 0,
    'Cannot set attachment points')
else
  Check(FPDFAnnot_SetAttachmentPoints(Annotation, 0, QuadPoints) <> 0,
    'Cannot set attachment points');

컴포넌트에 노출된 원시 C API 함수를 다이렉트로 기동하는 구조의 프로젝트도 동일합니다. PDFium.pas 내부에서 모든 FPDFAnnot_* C API 함수 진입점들을 제공하므로 직접 호출할 수 있습니다. FPDF_ANNOTATION 핸들을 잡고 사각형 좌표를 기입할 때는 반드시 FPDFAnnot_CountAttachmentPoints를 호출해 현재 개수를 먼저 대조한 뒤 알맞게 호출 함수를 분기 제어하십시오. 만일 검색 창에 'FPDFAnnot_SetAttachmentPoints returns false'를 입력하고 이 문서를 찾아오신 개발자라면, 이 분기 로직 처리가 99% 확률로 확실한 정답이 될 것입니다

TPdf.CreateAnnotation을 사용하여 형광펜 주석 만들기

라이브러리가 Append 및 Set 호출 분기 처리를 내부에서 알아서 조율해 주므로, 개발자가 할 일은 TPdf.CreateAnnotation 함수를 실행하기 전에 주석 설정용 레코드 변수 필드들만 차례대로 채워 기입하면 끝납니다. 아래의 예제는 A4 문서 페이지를 생성하고 200×20 포인트 크기의 가상 텍스트 영역 상에 반투명한 노란색 형광펜 줄을 가볍게 떨어뜨리는 구성입니다. 네 꼭짓점 좌표는 앞서 학습한 Z자 패턴 정렬을 엄격히 준수해 기입하였고, Rectangle 속성 역시 이 꼭짓점 영역을 완벽하게 감싸 안는 경계 상자로 설정해 줍니다. 그래야 사용자가 형광펜 선 위를 마우스로 더블클릭하거나 터치해 도구 팝업을 띄울 때 오작동하지 않고 정렬이 일치하게 작동합니다

var
  Pdf: TPdf;
  A: TPdfAnnotation;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(0, 595, 842);

    FillChar(A, SizeOf(A), 0);
    A.Subtype := anHighlight;
    A.HasColor := True;
    A.Color := clYellow;
    A.ColorAlpha := $80;                     // 50% opacity
    A.HasAttachmentPoints := True;
    A.AttachmentPoints[1].X := 50;  A.AttachmentPoints[1].Y := 700; // top-left
    A.AttachmentPoints[2].X := 250; A.AttachmentPoints[2].Y := 700; // top-right
    A.AttachmentPoints[3].X := 50;  A.AttachmentPoints[3].Y := 680; // bottom-left
    A.AttachmentPoints[4].X := 250; A.AttachmentPoints[4].Y := 680; // bottom-right
    A.Rectangle.Left := 50;  A.Rectangle.Top := 700;
    A.Rectangle.Right := 250; A.Rectangle.Bottom := 680;
    A.ContentsText := 'Highlighted region';
    Pdf.CreateAnnotation(A);

    Pdf.SaveAs('highlighted.pdf');
  finally
    Pdf.Free;
  end;
end;

주석 유형의 변환은 속성 값 한 줄만 바꾸면 됩니다. 밑줄(anUnderline), 취소선(anStrikeout), 물결선(anSquiggly)은 모두 동일한 레코드 구성 양식과 사각형 꼭짓점 좌표를 동일하게 사용합니다. ISO 32000-1 표준 규격상 이 4가지 주석들은 시각 효과 장식을 빼면 모두 완전히 동일한 동일 주석 군(family)에 속하기 때문입니다. 반면 텍스트 마크업 형식이 아닌 주석들(네모 anSquare, 동그라미 anCircle, 말풍선 anText 등)은 꼭짓점 좌표가 불필요하며 오직 Rectangle 경계 영역 정보만을 기준으로 작동하므로, HasAttachmentPoints 값을 False로 그냥 기입하고 저장하면 됩니다

왜 AttachmentPoints[0] 코드가 Delphi에서는 빌드되지만 FPC에서는 컴파일 에러를 일으킬까요?

사각형 꼭짓점 타입인 TQuadrilateralPoint는 1부터 인덱싱이 시작하는 array [1..4] of TPdfPoint로 선언되어 있습니다. 0-based 인덱스 습관을 가진 대다수의 개발자들이 실수하기 쉬운 지점입니다. 코드 상에 A.AttachmentPoints[0]를 작성하고 Delphi의 기본 컴파일러(dcc32)로 빌드를 누르면 아무런 경고도 없이 빌드가 완료됩니다. 델파이 컴파일러는 기본 설정 상 배열 인덱스 범위 초과 검사(range checking)를 수행하지 않기 때문입니다. 결국 실행 시점에 이 0번 인덱스 기입 코드는 사각형 데이터 메모리 앞쪽에 맞닿아 있는 TPdfAnnotation의 다른 엉뚱한 필드 메모리 블록을 짓밟고 변조시키는 치명적인 침묵의 훼손(silent corruption) 버그를 일으키게 됩니다. 형광펜의 꼭짓점 하나가 이상한 좌표로 날아가 꼬이거나, 인근의 엉뚱한 속성값 하나가 깨져서 오작동하는데 에러 로그조차 발생하지 않는 끔찍한 현상이 발생합니다. Free Pascal 컴파일러 환경으로 Lazarus 포팅 작업을 하던 중, 다행히 FPC 컴파일러가 상수 인덱스 범위 초과 검사를 빌드 시점에 기습 감지해 에러를 보고하면서 이 허점을 잡을 수 있었습니다. 이 배열 범위 인덱싱 실수와 앞서 설명한 C API의 Set/Append 호출 오판 현상은 그렇게 디버깅 단계를 거쳐 한 번에 완벽하게 교정되었습니다

개발 시 반드시 지켜야 할 두 가지 올바른 코딩 습관이 도출됩니다. 꼭짓점 인덱스는 위의 샘플 소스처럼 항상 1번부터 4번까지 기입하십시오. 그리고 주석 제어 모듈을 빌드할 때는 안전을 위해 소스 선두에 {$R+} 플래그를 기입하여 Delphi 컴파일러가 배열 인덱스 범위 초과 검사를 반드시 수행하도록 제어해 빌드하십시오. 일반 dcc32 컴파일러가 빌드를 무사 통과시켰다는 사실은 소스 상에 인덱스가 안전하다는 증거가 아닙니다. 단지 그 위치의 메모리가 밟혔을 때 운 좋게 프로세스 즉시 크래시(crash) 충돌만 피했을 뿐이라는 위험 신호입니다

실제 페이지 상의 텍스트로부터 사각형 꼭짓점 좌표를 추출하는 방법

데모 소스 상에서 고정 상수로 좌표를 하드코딩하는 것은 간단하지만, 실무 상업용 뷰어 모듈을 구현할 때는 텍스트의 글자 모양(glyphs)을 마우스 드래그한 좌표나 검색어가 일치하는 위치 정보를 정확히 파악해 마크업 좌표를 계산해야 합니다. 이는 PDFium Component를 사용한 PDF 텍스트 추출 가이드에서 안내하는 텍스트 영역 지오메트리 해석 엔진을 연계 가동해 구합니다. 이 가이드의 해석 API들은 문자의 글자 좌표 정보를 QuadPoints가 사용하는 페이지 좌표계와 완벽히 일치하는 직사각형 경계 상자(bounding boxes) 정보로 제공하므로, 검색어가 시작하는 첫 번째 글자의 좌측 끝 좌표와 마지막 글자의 우측 끝 좌표 및 해당 행의 위아래 높이 범위를 대조해 사각형 네 꼭짓점 좌표를 오차 없이 정밀하게 연산할 수 있습니다. 만일 소스 코드 상에서 문서를 무에서 새로 작성 중이라 실제 텍스트가 시트에 그려지기 전의 정확한 줄 바꿈 라인 크기와 위치 계산 결과를 미리 계산해야 하는 경우라면, 텍스트 크기 정밀 측정 및 줄 바꿈 처리 가이드 기사를 함께 참고하십시오

여기서도 의도된 기술 사양 경계를 투명하게 밝힙니다. 컴포넌트의 TPdfAnnotation 레코드 내부에는 단 하나의 TQuadrilateralPoint 정보만 수록되므로, CreateAnnotation 호출 1회당 1개의 형광펜 사각형 선만 출력 파일에 기입됩니다. 만일 사용자가 3개 행에 걸쳐 있는 드래그 영역을 한 번에 형광펜 처리하려는 경우 규격서 §12.5.6.10 규정에 따라 3개의 사각형 영역 데이터를 개별 등록해야 하며, 다음과 같은 두 가지 우회 기법이 제공됩니다. 가장 단순한 접근법은 각 행마다 개별적인 주석 개체를 생성해 3번 기입하는 것입니다. 가장 간단하고 직관적이며 모든 PDF 뷰어 프로그램에서 잘 작동합니다. 다른 압축형 처리 방식은 단 하나의 주석 개체 내에 3개의 사각형 정보를 기입하는 것입니다. 컴포넌트를 통해 1번째 사각형을 포함한 기본 주석을 생성해 등록한 뒤, 컴포넌트 하단에 매핑되어 제공되는 원시 C API 함수인 FPDFAnnot_AppendAttachmentPoints를 개발자가 소스 상에서 수동으로 2회 연속 추가 호출하여 2~3번째 사각형 데이터를 직접 주입하는 방식입니다. Append 함수는 없는 슬롯을 새로 확장해 채워주므로 정상 작동합니다. 즉, 0-based 인덱스 방식의 0번 슬롯이 비어 있는 상황과 달리 Append로 2, 3번째 슬롯을 만든 뒤 Set을 호출하는 식의 접근은 정상 지원되지 않습니다

주석 기입 처리를 완수한 후에는 단순히 결과 에러 코드 확인에만 의존하지 마시고, 배포 전 어도비 아크로뱃 리더 등의 상용 뷰어에 파일을 로드하여 형광펜 주석이 글자 폰트 위 좌표에 엇갈림 없이 정확히 안착했는지, 투명도가 원하는 농도로 적용되었는지, 다시 다른 이름으로 저장했다 열어도 주석이 무사히 살아남는지 눈으로 최종 검증을 완수하십시오. 본 기사에 예시해 드린 지능형 Append/Set 자동 분기 장치 및 사각형 꼭짓점 데이터 제어 엔진은 모두 추가 비용 없이 Delphi, C++Builder, Lazarus 개발 환경을 일체형 지원하는 PDFium Component의 표준 배포 규격에 탑재되어 무상으로 지원됩니다