기술 문서

Delphi PDF 텍스트 검색과 좌표 반환: PDFlibPas

페이지의 텍스트를 추출하는 일은 문제의 쉬운 절반입니다. 사용자가 검색 상자에 단어를 입력하고, viewer 가 그 위치로 점프해 노란 상자를 그려 주기를 기대하는 순간, 단순한 평면 텍스트 문자열만으로는 줄 수 없는 정보가 필요해집니다. 각 match 가 어느 page 에 있는지, 그리고 PDF 좌표계에서 어떤 rectangle 을 차지하는지입니다. 페이지 전체를 이어 붙인 문자열은 이미 그 geometry 를 잃어버렸습니다. substring 은 찾을 수 있어도, 정확한 위치를 가리킬 수는 없습니다

PDFlibPas 는 Delphi 와 C++Builder 용 native Object Pascal PDF 라이브러리이며, v3.78.0 부터 정확히 이 질문에 답합니다. 기존 text-block extractor 위에 세 개의 query API 가 올라가 있습니다. SearchText 는 page range 를 순회하며 각 hit 의 page 와 axis-aligned rectangle 을 반환하고, EnumPageElements 는 한 page 위의 모든 항목을 나열하며 text block 과 embedded image 를 함께 보여 줍니다. GetTextInAreaEx 는 영역 안의 각 block rectangle 을 문자열 목록으로 평탄화하지 않고 그대로 보고합니다. 이 셋은 write path 를 건드리지 않으며, 라이브러리에 이미 있던 기계 위에 얹힌 순수 read-side 추가 기능입니다

geometry 가 funnel 이 아니라 text-block list 에 남는 이유

가장 자연스러운 생각은 GetPageText 내부 경로를 재사용하는 것입니다. 하지만 그 경로는 일시적인 extraction "funnel" 을 통과해 page 문자열을 만든 다음, 반환 전에 자신을 해제합니다. 결과를 손에 쥘 때쯤이면 block 별 좌표는 이미 사라져 있습니다. 애초에 들고 나올 수 있는 정보가 아니었습니다

좌표는 다른 구조 안에서는 살아남습니다. ExtractPageTextBlocks(3) 는 text-block list handle 을 반환하고, 그 item 들은 각각 여덟 개의 double 로 된 bounding quad, font name, font size, 그리고 block text 를 담고 있습니다. 이 handle 만이 추출 이후에도 geometry 를 붙들고 있는 유일한 장소이기 때문에, 새 query API 들은 모두 funnel 이 아니라 이 구조 위에 세워졌습니다. block list 를 재사용하면 search, enumeration, region query 가 하나의 extraction pass 와 하나의 위치 정의를 공유하게 됩니다

그래서 SearchText 의 형태도 그 제약에서 그대로 나옵니다. 범위 안의 각 page 마다 block list 를 추출하고, 각 block 의 text 를 GetTextBlockText 로 읽어 query 와 비교한 뒤, 일치하는 block 에 대해서는 quad 를 rectangle 로 축약합니다. 반환되는 hit 는 작은 record 하나입니다

type
  TPDFlibSearchHit = record
    Page: Integer;                       // 1-based page of the match
    Left, Top, Right, Bottom: Double;    // axis-aligned hit rectangle
    MatchText: WideString;               // the block text that contained the query
  end;

bound array 는 네 개의 corner 가 아니라 X/Y 교차 배열이다

가장 먼저 발목을 잡는 디테일이 이것입니다. GetTextBlockBound(ListID, Index, BoundIndex)BoundIndex 를 1에서 8까지 받는데, 이 여덟 값은 사람들이 짐작하듯 "corner 1, corner 2, corner 3, corner 4" 가 아닙니다. 형태는 X, Y, X, Y, X, Y, X, Y 입니다. 홀수 index 가 X 좌표이고 짝수 index 가 Y 좌표이며, 총 네 점을 이룹니다. 쌍을 잘못 읽으면 rectangle 은 즉시 엉망이 됩니다

rectangle 대신 quad 가 있는 이유는 rotation 때문입니다. 각도가 들어간 text block 은 실제로 네 점짜리 bounding polygon 을 가지며, 이 여덟 개의 double 이 그것을 정확히 기술합니다. 하지만 highlight-and-jump 용도에서는 대개 기울어진 polygon 이 아니라 upright box 가 필요합니다. 그래서 라이브러리는 네 점을 훑어 X 와 Y 의 최소값과 최대값을 구해 quad 를 axis-aligned rectangle 로 축약합니다. 회전된 text 는 자신을 감싸는 upright box 로 접히고, highlight overlay 에는 바로 그것이 필요합니다

var
  Pdf: TPDFlib;
  Hits: array[0..255] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract.pdf', '');
    // Search pages 1 to 10, case-insensitive, substring match.
    Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
          [Hits[I].Page, Hits[I].Left, Hits[I].Top,
           Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
  finally
    Pdf.Free;
  end;
end;

이 rectangle 은 page 왼쪽 아래를 원점으로 하는 PDF user-space point 좌표계에 있습니다. drawing 이나 annotation 호출에 넘기는 것과 동일한 좌표계입니다. 이것은 의도된 선택입니다. search hit 에서 받은 rectangle 을 좌표 변환 없이 highlight annotation 이나 "여기로 스크롤" 명령에 바로 넘길 수 있기 때문입니다

대소문자, whole word, 그리고 CJK 가 다른 이유

두 번째 인자는 TPDFlibSearchOptions 집합이며, 여기에는 soCaseSensitivesoWholeWord 가 들어갑니다. 빈 집합 [] 이 가장 흔한 경우이며, case-insensitive substring search 를 뜻합니다. soCaseSensitive 를 추가하면 Indemnityindemnity 를 구분하게 되고, soWholeWord 를 추가하면 signsignature 안에서 일치하지 않게 됩니다. 둘을 함께 써도 됩니다

whole-word matching 은 무엇을 word boundary 로 볼지에 대한 정의가 필요하며, 여기서는 그 규칙이 설계상 ASCII 중심이라는 점을 분명히 말해 둘 가치가 있습니다. 문자는 ASCII letter, ASCII digit, 또는 underscore 일 때 word 일부로 간주됩니다. 익숙한 [A-Za-z0-9_] 클래스입니다. match 바로 앞과 뒤 문자가 not word character 이거나, match 가 block 경계에 닿아 있을 때만 whole-word 로 인정됩니다

비라틴 문자권에서는 그 결과가 달라집니다. 한자, 가나, 그리고 다른 비 ASCII 문자는 이 클래스 밖에 있으므로, 그 주변 경계는 전부 non-word edge 로 읽힙니다. 실제로는 CJK 텍스트에서 whole-word search 가 거의 모든 위치를 word boundary 로 취급하게 되어, 플래그가 사실상 substring matching 처럼 동작합니다. 이것은 버그가 아니라 문서화된 한계이며, 기능이 모델링된 대상의 동작과도 같습니다. 말뭉치가 주로 CJK 라면 whole-word 모드가 전문 tokenizer 수준의 분절을 제공하지는 않습니다. 그 한계를 전제로 설계해야 합니다

구현 세부 하나도 짚어 둘 만합니다. case-insensitive 비교는 UpperCaseWideString 에 적용하며, AnsiUpperCase 는 쓰지 않습니다. Ansi 쪽 함수는 AnsiString 을 반환하므로, 나머지 경로가 사용하는 WideString 과 맞지 않고, 더 나쁘게는 active code page 밖 문자를 lossy 하게 접어 버릴 수 있습니다. 입력도 Unicode, 출력도 Unicode 로 끝까지 가는 것이 맞습니다

라이브러리 전체가 공유하는 page range parser

세 번째 인자는 "1,3,5-9" 같은 page range 문자열입니다. 여기서 쓰는 parser 는 특별한 것이 아닙니다. PLParsePageRangeListPrintPages 와 page-copy 루틴을 떠받치고 있으며, 여기서도 그대로 쓰이므로 올바르게 인쇄되는 range 는 올바르게 검색됩니다. range 문자열이 비어 있으면 "모든 page" 를 뜻하는 sentinel 이고, 이 경우 SearchText 가 전체 목록을 직접 만듭니다

범위는 비용과 직결됩니다. 천 page 문서에서 열 page 구간만 검색하면, 추출도 열 page 에 대해서만 수행됩니다. 루프는 range 가 가리킨 page 만 선택하고 block 을 추출하기 때문입니다. 특정 clause 가 appendix 에 있다는 것을 이미 안다면, range 에 그렇게 적고 나머지 파일은 건너뛰는 편이 낫습니다

search 와 enumeration 은 내부적으로 selected page 를 바꾸며 순회하므로, 둘 다 호출 시점의 selected page 를 저장했다가 finally 블록에서 복원합니다. page 를 만들던 중간에 SearchText 를 호출해도, 함수가 끝나고 나면 page 선택 상태는 원래대로 돌아옵니다. 이런 save-and-restore 계약은 없을 때만 눈에 띄는 종류의 것이고, 그렇기 때문에 있는 것입니다

페이지 전체를 열거하기: text 와 image 를 하나의 목록에서

search 가 "이 단어가 어디 있나"에 답한다면, 다른 반쪽은 "이 page 에 도대체 무엇이 있나"입니다. 그것이 EnumPageElements 입니다. 반환값은 통합 목록 하나이며, 각 element 는 Kind 필드로 text block 또는 embedded image 중 하나임이 구분됩니다

type
  TPDFlibPageElementKind = (ekText, ekImage);

  TPDFlibPageElement = record
    Kind: TPDFlibPageElementKind;
    Page: Integer;
    Left, Top, Right, Bottom: Double;
    Text: WideString;        // ekText
    FontName: WideString;    // ekText
    FontSize: Double;        // ekText
    ImageID: Integer;        // ekImage; usable with SelectImage / GetImageID
  end;

text element 는 같은 ExtractPageTextBlocks pass 에서 오므로 rectangle, font name, size 가 이미 채워진 상태로 도착합니다. image element 는 page 의 embedded image list 를 FindImagesGetImageID 로 훑어 얻어 오며, 여기에 담긴 ImageID 는 이후 SelectImage 에 넘겨 더 자세히 조사할 handle 입니다. 둘을 하나의 array 에 담기 때문에 page 를 한 번만 걸어도 그 위의 모든 항목을 볼 수 있습니다

var
  Pdf: TPDFlib;
  Elems: array[0..511] of TPDFlibPageElement;
  Total, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf', '');
    Total := Pdf.EnumPageElements(1, Elems);
    for I := 0 to Total - 1 do
      if I <= High(Elems) then
        if Elems[I].Kind = ekText then
          WriteLn(Format('text  %s/%.1f  "%s"',
            [Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
        else
          WriteLn(Format('image id=%d', [Elems[I].ImageID]));
  finally
    Pdf.Free;
  end;
end;

여기에는 라이브러리 전반의 관례를 그대로 따르는 counting rule 이 있고, 이를 지키지 않으면 초기화되지 않은 memory 를 읽게 됩니다. 반환값은 element 수이며, 이것은 넘겨 준 array 크기보다 클 수 있습니다. 함수는 들어갈 만큼만 slot 을 채우고, 나머지는 계속 세어 나갑니다. signature enumeration 과 같은 방식입니다. 따라서 루프는 항상 반환된 count 와 High(array) 중 더 작은 값으로 clamp 해야 하며, count 까지 무조건 도는 것은 잘못입니다. 위 예제가 I <= High(...) 를 넣은 이유가 그것입니다. 반환값이 buffer 를 넘치면 더 큰 array 를 준비해서 다시 호출하면 됩니다

라이브러리의 더 저수준 text-block 호출을 써 본 적이 있다면, 이것은 그 위에 얹힌 typed 하고 geometry-aware 한 레이어라고 보면 됩니다. 기반 extraction 자체는 PDFlibPas로 PDF 텍스트, 이미지, 폰트 추출하기 에서 설명하는 것과 같습니다. 반대로 목표가 "이 텍스트가 어디 있나"가 아니라 "이 문서가 보조 기술을 위해 어떻게 구조화돼 있나"라면, 물리적인 block layout 대신 논리적 reading order 를 드러내는 tagged-PDF structure tree 가 병행되는 read-side 이야기입니다

이미 위치를 알고 있을 때의 region query

때로는 search term 자체가 없고 rectangle 만 있을 때가 있습니다. 폼 템플릿이 invoice number 를 늘 오른쪽 위에 놓거나, 스캔된 레이아웃이 표 전용 band 를 고정으로 두는 경우입니다. GetTextInAreaEx 는 그런 경우를 위한 API 입니다. 이것은 GetTextInArea 의 bounds-carrying 대응물입니다. 기존 호출이 영역의 문자열 목록만 준다면, 새 호출은 걸러진 각 block 의 rectangle 과 text 를 함께 돌려주므로, 상자 안에 무엇이 있는지뿐 아니라 그 안에서 각 줄이 어디에 놓이는지도 알 수 있습니다

var
  Pdf: TPDFlib;
  Hits: array[0..63] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf', '');
    Pdf.SelectPage(1);
    // Left, Top, Width, Height in PDF points on the selected page.
    Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Hits[I].MatchText);
  finally
    Pdf.Free;
  end;
end;

두 가지는 분명히 해 둘 필요가 있습니다. GetTextInAreaEx 는 현재 selected page 에서 동작하므로 먼저 SelectPage 를 호출해야 합니다. SearchText 와 달리 range 를 받지 않습니다. 또 block 은 query rectangle 과 intersects 할 때 유지됩니다. 완전히 포함될 때만 남는 것이 아니므로, 경계를 걸치는 줄도 결과에 들어옵니다. 손으로 그린 선택 상자에서는 보통 이것이 원하는 동작이지만, 엄격한 containment 가 필요하다면 이제 rectangle 을 받았으니 호출자 쪽에서 후처리하면 됩니다

실전에 쓰는 방법

세 호출을 관통하는 공통점은 geometry 가 더 이상 사후 복원 대상이 아니라는 점입니다. search hit 는 자신의 page 와 box 를 알고 있고, page element 는 자신의 rectangle 과, text 라면 font 정보까지 알고 있습니다. region query 는 각 줄이 어디에 놓였는지도 알려 줍니다. 이 정도면 public API 아래로 내려가지 않고도 진짜 find-and-highlight 기능, click-to-locate index, 또는 layout-aware extractor 를 만들 수 있습니다

이 query API 들은 PDFlibPas Delphi PDF Library 의 일부로 제공되며, 그 기반이 되는 전체 text-block extraction 레이어와 Delphi 및 C++Builder 용 read-side introspection surface 의 나머지 기능도 함께 포함합니다