기술 문서

Delphi에서 HotPDF를 사용하여 로드된 PDF로부터 텍스트 추출하기

HotPDF Component는 두 가지 메서드 호출을 통해 Delphi에서 로드된 모든 PDF로부터 유니코드 텍스트를 추출할 수 있습니다. ExtractLoadedPageText는 페이지의 읽기 흐름 순서대로 텍스트를 반환하며, ExtractLoadedPageTextLayout(v2.263.0 버전에서 추가됨)은 페이지의 시각적 배치를 일반 텍스트 형태로 재구성하여 다단(column), 들여쓰기, 표 정렬 상태를 고스란히 출력 파일에 보존합니다. 두 방식 모두 HotPDF로 만들지 않은 외부 생성 문서에서도 원활히 작동합니다. 실제로 비즈니스 환경에서 필요한 것은 바로 이러한 경우입니다. 즉, 고객이 이메일로 보내온 인보이스, 스캔 대행업체에서 보내온 보고서, 혹은 출처를 알 수 없는 소프트웨어가 생성해 낸 계약서 파일 등입니다

PDF는 일반 텍스트 파일과 다른 구조로 텍스트를 저장하기 때문에, 두 함수 내부에서는 단순한 인터페이스 이면의 복잡한 메커니즘이 작동합니다. 이 기사에서는 두 가지 텍스트 추출 모드를 살펴보고, 내부적으로 작동하는 세 가지 핵심 요소인 CMap 리더, 콘텐츠 스트림 인터프리터, 그리고 글꼴 디코드 대체 처리 체인(fallback chain)의 내부 동작 방식을 설명합니다. 매핑 과정을 이해해야 깨진 출력이 발생했을 때 단순히 포기하지 않고 근본 원인을 파악해 대처할 수 있기 때문입니다

왜 텍스트 추출이 단순히 파일에서 문자열을 읽는 것보다 더 어려울까요?

PDF 콘텐츠 스트림은 글자(character)가 아니라 문자 코드(character code)를 기록합니다. Tj 및 TJ 연산자(ISO 32000-1 §9.4.3)는 앞서 선택된 Tf 글꼴에 의존하는 바이트 스트링을 전달합니다. 예컨대 0x41 바이트는 WinAnsi 인코딩에서는 문자 'A'일 수 있지만, 서브셋(subset) 글꼴에서는 임의의 글자 모양을 가리키거나 한중일(CJK) 복합 글꼴에서는 2바이트 CID 데이터의 일부분일 수 있습니다. ISO 32000-1 §9.10은 텍스트 추출을 정확히 이러한 디코딩 문제로 정의합니다. 즉, 글꼴 사전이 제공하는 정보를 기반으로 각 코드를 다시 유니코드로 역매핑하는 과정입니다. 그러나 표준 문서에는 표준 규격을 준수하는 파일일지라도 매핑 역변환을 위한 충분한 정보를 반드시 제공할 필요는 없다고 명시되어 있습니다

이 마지막 조항이야말로 PDF 파일에서 복사하여 붙여넣기를 했을 때 텍스트가 마구 깨지는 현상의 원인을 잘 설명해 줍니다. /ToUnicode 테이블이 없는 서브셋 글꼴을 내장하여 생성된 PDF 파일은 시각적으로는 완벽하게 렌더링되지만, 텍스트를 추출하면 알아볼 수 없는 글자들로 나타납니다. 코드에서 글자 모양으로의 매핑 정보는 존재하지만 유니코드로 변환하기 위한 역매핑 정보가 누락되었기 때문입니다. 따라서 정상적인 텍스트 추출 API라면 다양한 대체 처리 체인을 동원해 복구를 최우선으로 시도해야 하며, 이때 중요한 것은 이 대체 체인이 얼마나 깊이 있고 체계적으로 작동하는가입니다

ExtractLoadedPageText를 통한 읽기 흐름 순서의 텍스트 추출

검색 색인 생성, 키워드 검색 또는 분석 파이프라인에 텍스트 데이터를 입력하려는 경우 ExtractLoadedPageText 함수를 사용하는 것이 적합합니다. 이 함수의 시그니처는 function ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): boolean 형태입니다. 페이지 인덱스는 0부터 시작하고 결과값은 Delphi 고유의 UnicodeString 형식으로 리턴되며, 페이지에 읽기 가능한 콘텐츠 스트림이 없는 경우 예외를 발생시키는 대신 False를 반환합니다

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText, AllText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('invoice.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        AllText := AllText + PageText + #13#10;
    // AllText now holds the reading-flow text of the document
  finally
    Pdf.Free;
  end;
end;

출력 텍스트의 줄 바꿈은 다음과 같은 단순하고 합리적인 판별 기준을 통해 수행됩니다. 콘텐츠 스트림 내에서 Td 또는 T* 명령이 실행되어 문자의 세로 시작 위치 좌표가 현재 글꼴 크기의 절반 이상 이동하면 줄 바꿈을 삽입합니다. 디코더가 올바르게 해석하지 못한 문자는 누락되는 대신 공백 문자(space)로 대체되므로, 일부 문자가 해석되지 않더라도 단어 경계는 그대로 유지됩니다. 다만 이 모드는 다단 페이지의 논리적 읽기 순서 레이아웃까지 추적하여 처리하지는 않습니다. 2단 구조의 페이지는 콘텐츠 스트림에 기록된 물리적 순서대로 추출되며, 이는 보통 시각적 배치 순서와 일치하지만 예외가 존재할 수 있습니다

레이아웃 보존형 텍스트 추출을 사용해야 하는 상황

표, 양식, 소스 코드 목록처럼 문자의 배치 위치가 중요한 의미를 가지는 문서나, 텍스트 비교(diff), 패턴 검색(grep), 열 기준 데이터 해석이 필요한 경우에는 ExtractLoadedPageTextLayout 함수를 호출하는 것이 적절합니다. 단순히 문자를 일렬로 추출하는 대신 각 문자를 베이스라인(baseline)별로 묶어 가로 좌표 순으로 정렬한 뒤, 글꼴 크기와 문자의 평균 가로 폭을 기반으로 계산된 가상의 격자판 위에 가로세로 여백을 그대로 재현해 줍니다. 동일 베이스라인 상의 텍스트 묶음 사이의 넓은 간격은 공백(space) 문자들로 채워지고, 줄 사이의 큰 간격은 빈 줄(blank line)로 복원되어 원본 레이아웃의 시각적 형태를 그대로 텍스트 상에 구현합니다

var
  Grid: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
  // Columns, indentation and table alignment survive as
  // spaces and blank lines on a character grid
end;

두 추출 모드는 내부 유니코드 디코딩 알고리즘을 완전히 공유하며 단지 디코딩된 문자를 배열하는 방식만 다르므로, 어떤 방식을 선택하든 텍스트 복원 품질에는 영향을 주지 않습니다. 텍스트 내용 자체가 중요하다면 ExtractLoadedPageText를, 표나 열 배치 레이아웃 구조가 중요하다면 ExtractLoadedPageTextLayout을 선택해 대처해 보십시오. 다단 읽기 순서 감지는 두 방식 모두 지원하지 않습니다. 2단 페이지를 레이아웃 보존형 격자 텍스트로 복원하면 두 열이 시각적 형태 그대로 좌우로 나란히 표시되는데, 이는 텍스트 비교나 행 분석 작업에는 최적이지만 모바일 화면 등에서 텍스트를 재배치하여 읽어야 하는 자동 줄 바꿈 용도로는 적절하지 않습니다

HotPDF는 문자 코드를 유니코드로 어떻게 디코딩하나요?

HotPDF Component는 우선순위가 정의된 단계별 대체 처리 체인(fallback chain)에 따라 문자 코드를 유니코드로 변환합니다. 가장 먼저 글꼴에 내장된 /ToUnicode CMap을 확인하고, 그 다음으로 /Encoding 항목(스트림 또는 지정된 CMap 이름)을 확인하며, 한중일(CJK) 복합 글꼴의 경우 Adobe-GB1, Adobe-CNS1, Adobe-Japan1, Adobe-KR 등의 표준 CMap 컬렉션 파일을 분석하고, 최종적으로 단순 글꼴에 대해 라이브러리 내장 WinAnsi 및 MacRoman 인코딩 테이블을 대조합니다. 특정 단계에서 매핑 정보를 찾지 못하면 에러를 유발하는 대신 조용히 다음 복구 경로로 이행하며, 모든 경로를 거쳐도 확인되지 않는 코드는 0으로 처리되어 상위 호출 프로그램에서 누락율을 손쉽게 카운트할 수 있도록 돕습니다

글꼴 사전에 내장된 /ToUnicode CMap(ISO 32000-1 §9.10.3)이 최우선 순위를 가집니다. 이는 문서 생성 프로그램이 검색 및 텍스트 추출을 돕기 위해 의도적으로 수록해 둔 매핑 데이터이기 때문입니다. Adobe 표준 CMap 경로는 글꼴 정보를 내장하지 않고 UniGB-UTF16-H와 같이 미리 정의된 표준 CMap 리소스를 호출하여 사용하는 아시아권 문서(CJK)를 해석할 때 매우 중요합니다. HotPDF는 이러한 리소스 파일들을 resources\CMap 디렉토리에 함께 제공하며, 실행 시 실행 파일 경로를 기준해 자동으로 리소스를 찾아낸 후 파싱 완료된 CMap 정보를 프로세스 메모리에 캐싱해 둡니다. 참고로 이 파일들 중 가장 용량이 큰 Adobe-GB1 파일은 소스 텍스트 크기만 약 2 MB에 달하므로 매 페이지마다 디스크에서 새로 분석하여 파싱하지 않도록 설계되었습니다. 해당 리소스 폴더가 누락된 경우 디코더는 조용히 디스크 기반 표준 CMap 단계를 건너뛰고 내장 테이블 및 기본 인코딩 정보만으로 변환을 진행합니다. 이는 글꼴 수치 계산과 글자 모양의 매핑 차이에 관한 글로, 작성 시점에 동일한 코드 매핑 경계를 분석하는 HotPDF를 활용한 복합 스크립트 텍스트 셰이핑(shaping) 안내서와 대칭 구조를 이룹니다

주의해야 할 두 가지 CMap 구문 분석 함정

CMap 파일은 얼핏 보기에 구문 분석이 간단해 보이지만 의외로 까다로운 부분이 많으며, 직접 파서를 개발할 때 실패하기 쉬운 대표적인 두 가지 예외 사항이 존재합니다. 첫째, 섹션 키워드보다 문자 레코드 수 정보가 '앞에' 기술된다는 점입니다. 즉, beginbfchar 2가 아니라 2 beginbfchar와 같이 표기됩니다. 키워드 뒤에 개수 수치가 나올 것이라 가정한 설계는 앞부분의 숫자 2를 의미 없는 토큰으로 소비한 뒤 모든 데이터 섹션에 데이터가 없는 것으로 오판하게 됩니다. HotPDF의 리더가 채택한 가장 유연하고 안전한 해법은, 선행 선언된 개수 정보를 무시하고 짝을 이루는 endbfchar / endbfrange 키워드가 등장할 때까지 반복하여 분석을 수행하는 것입니다. 이 방식을 사용하면 수치 오차가 표기된 예외적인 실제 문서 파일들도 무사히 구문 분석할 수 있습니다

두 번째 함정은 bfcharbfrange의 목적지 문자열이 정수형 상수가 아니라 UTF-16BE 인코딩 방식의 문자열이라는 점입니다. 예컨대 매핑 타겟인 <D83DDE00>은 U+1F600 코드 포인트를 의미하며, 상위 및 하위 대리코드 쌍(surrogate pair)을 하나의 온전한 문자로 재조합해야 합니다. 이 4바이트 데이터를 단순 빅엔디안 정수로 처리해 버리면, 기본 다국어 평면(BMP)을 벗어나는 모든 유니코드 범위에 대해 올바르지 않은 쓰레기 값이 나오게 됩니다. 최근 PDF 문서 내에 이모지(emoji)가 자주 사용되므로 대리코드 쌍 재조합 처리를 건너뛰는 디코더는 실제 업무 환경의 파일들을 파싱하지 못하고 실패하게 됩니다. HotPDF는 16진수 문자열을 원시 바이트로 먼저 파싱한 후 UTF-16BE 코드 단위들을 올바르게 재결합하므로, 합자(ligature) 매핑 시 발생하는 멀티 문자 매핑 규칙까지 완벽히 지원합니다

ExtractLoadedPageGlyphs를 활용한 원시 글자 모양 수준 접근

앞선 두 가지 텍스트 추출 기능은 모두 ExtractLoadedPageGlyphs를 기반으로 작동하며, 내부 자료 구조인 THPDFGlyphArray 배열은 사용자의 커스텀 코드에서도 그대로 활용할 수 있습니다. 개별 THPDFGlyphRecord 구조체에는 디코딩된 유니코드 포인트는 물론 원시 문자 코드, 바이트 폭(CMap의 codespacerange 기준에 따라 1, 2 또는 4바이트), 활성화된 글꼴 리소스 정보 및 폰트 크기, 사용자 좌표계 공간 기준 X/Y 축 시작 좌표, 가로 문자 폭(advance) 등이 저장되어 제공됩니다. 이 정보를 응용하면 콘텐츠 스트림을 로우 레벨로 직접 분석하지 않고도 단어 경계 감지, 특정 단어의 화면 강조 표시(highlighting) 또는 사용자 정의 레이아웃 알고리즘 등을 손쉽게 제작할 수 있습니다

var
  Glyphs: THPDFGlyphArray;
  I, Unresolved: Integer;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
  begin
    Unresolved := 0;
    for I := 0 to High(Glyphs) do
      if Glyphs[I].Unicode = 0 then
        Inc(Unresolved);
    if Unresolved > 0 then
      ShowMessageFmt('%d of %d glyphs have no Unicode mapping',
        [Unresolved, Length(Glyphs)]);
  end;
end;

위 코드와 같이 Unicode = 0인 레코드들의 비율을 집계하는 것은, 텍스트 데이터의 사후 처리를 진행하기 전에 해당 문서의 추출 품질 신뢰도를 정확히 정량화하는 가장 확실한 방법입니다. 또한 각 글자 모양(glyph) 정보는 콘텐츠 스트림 내의 원본 피연산자 문자 코드 위치와 정교하게 엮여 있으므로, HotPDF가 제공하는 로드된 문서 내 텍스트 검색 및 치환 기능도 이러한 기초 설계를 기반으로 안정적으로 구현될 수 있었습니다

텍스트 추출이 불가능한 PDF 파일들의 특성

어떤 파일들은 어떠한 추출 파서를 사용하더라도 텍스트 복원이 불가능하며, 이러한 한계를 감지하여 예외 처리하는 설계가 필요합니다. 가장 대표적인 경우가 이미지 기반 스캔 문서입니다. 페이지 전체가 하나의 거대한 래스터 이미지 객체로 구성되어 있어 텍스트 드로잉 연산자가 아예 누락되었으므로 빈 문자열이 반환되는 것이 올바른 동작입니다. 이 경우 광학 문자 인식(OCR)을 적용해야 하며, 로드된 PDF에서 페이지 이미지 추출하기 기사를 참고해 이미지 데이터를 추출하는 파이프라인 단계를 먼저 구현해야 합니다. 두 번째 난관은 /ToUnicode 테이블이 누락된 서브셋 글꼴입니다. 인코딩 매핑 테이블 및 표준 CMap 경로를 통해서도 유니코드 변환에 실패하면, 해당 문자들은 0으로 해석되어 최종 텍스트 추출 시 단순 공백(space)으로 채워집니다. 암호화된 PDF의 경우 LoadFromFile 호출 시 정확한 암호를 함께 전달하여 문서를 로드하면, 내부 스트림 분석기가 텍스트를 디코딩하기 전에 스트림이 정상 복호화되므로 텍스트 추출이 가능해집니다

한 가지 협소한 구조적 예외 사항을 덧붙이자면, 유니코드 디코드 체인은 HotPDF의 기본 Flate 복원 함수를 통해 CMap 스트림 및 페이지 콘텐츠 스트림을 읽어들입니다. 따라서 특수한 타 압축 필터를 사용해 기록된 ToUnicode 데이터의 경우, 에러를 유발하는 대신 조용히 다음 디코드 대안 경로로 예외 처리됩니다. 실제로 지난 20여 년간 생성된 대부분의 PDF는 FlateDecode 방식을 준수하므로 에러 걱정 없이 사용 가능하며, 무소음 대체 처리 규칙 설계에 따라 복잡한 오작동 없이 파일이 허용하는 범위 내의 최선의 복원 결과를 제공합니다. 폰트 사전 정보를 찾아내는 것과 동일한 원시 객체 검색 원리는 로드된 문서의 메타데이터 편집에도 기여하므로, 입력 파일을 열어 텍스트 추출, 속성 검증, 주석 어노테이션 추가 등의 작업을 한 번에 안전하게 묶어 처리할 수 있습니다

텍스트 추출, 레이아웃 보존 텍스트 출력, 문자 수준의 원시 좌표 정보 접근 및 이를 기반으로 설계된 검색/치환 기능은 모두 Delphi 및 C++Builder용 표준 HotPDF Component가 자체적으로 제공하는 구성 사양입니다. 별도의 외부 DLL이나 운영체제의 텍스트 엔진 종속성 없이, 독특한 외산 PDF가 접수되더라도 Object Pascal 소스 단에서 브레이크포인트를 걸어 정교하게 디버깅하고 제어해 볼 수 있습니다