기술 문서

PDFium VCL로 Delphi에서 구조화된 PDF 텍스트 추출

PDFiumPas는 페이지 텍스트를 문자열이 아니라 구조로 반환합니다. GetStructuredText는 블록을 담은 TPdfStructuredTextPage를 만들어내는데, 각 블록은 줄을 담고, 각 줄은 스타일이 적용된 스팬을 담습니다. 모든 계층에 페이지 좌표계 경계가 있고 원본 문자 인덱스가 보존되어 있어서, 어떤 조각이든 바탕이 되는 텍스트 페이지로 다시 매핑할 수 있습니다

대부분의 코드가 출발점으로 삼는 평면 문자열 추출도 여전히 존재하며 그 목적에 대해서는 여전히 옳습니다. 그것이 더 이상 충분하지 않게 되는 순간은 어떤 단어가 제목이었는지, 어떤 것이 왼쪽 열에 속했는지, 또는 일치한 부분이 페이지의 어디에 실제로 있는지 알아야 할 때입니다

평면 문자열은 왜 대부분의 작업에 잘못된 출력인가?

사람들이 추출된 텍스트에 던지는 질문은 "이 페이지에 어떤 문자가 있는가"인 경우가 거의 없기 때문입니다. 그보다는 "제목이 무엇인가", "이것은 표인가", "이 단락은 4절에 속하는가", "형광펜 표시를 어디에 그려야 하는가"입니다. 문자열 하나는 이 중 어느 것에도 답하지 못하며, 여러분이 그것으로부터 재구성하는 모든 답은 이제 여러분이 떠안게 된 휴리스틱입니다

2단 레이아웃이 이를 구체적으로 보여줍니다. 2단 기사를 문자열로 추출하면, 생성기가 콘텐츠 스트림을 어떻게 작성했는지에 따라 1열 전체 다음에 2열 전체가 나올 수도 있고, 1열 1줄, 2열 1줄, 1열 2줄, 이런 식으로 페이지를 따라 내려갈 수도 있습니다. 둘 다 규격을 준수하는 PDF에서 나올 수 있습니다. 형식 수준에서는 어느 쪽도 틀리지 않았는데, PDF는 문서 개요가 아니라 페이지 위의 표시를 서술하기 때문입니다. 블록 기반 모델은 추출기가 순서 결정을 명시적으로 내리고 어떤 결정을 내렸는지 여러분에게 알려주게 해줍니다

콘텐츠 순서인가 물리적 레이아웃인가?

TPdfStructuredTextOptions.ReadingOrderroContentOrderroPhysicalLayout 중에서 선택하며, 올바른 답은 여러분이 생성기와 기하 정보 중 어느 쪽을 더 신뢰하는지에 달려 있습니다

콘텐츠 순서는 콘텐츠 스트림이 그리는 순서 그대로 텍스트를 반환합니다. 이는 빠르며, 잘 만들어진 생성기가 생성한 문서라면 대개 의도된 읽기 순서입니다. 물리적 레이아웃은 스트림 순서를 무시하고 문자들이 실제로 앉아 있는 위치로부터 순서를 재구성하며, 줄로, 그다음 열로 군집화합니다. 이것이 여러분이 원하는 방식은 스캔 후 OCR된 페이지, 읽기 순서가 아니라 폰트 순서로 텍스트를 내보내는 도구의 출력, 그리고 시각적 결과만이 유일하게 믿을 수 있는 것인 모든 경우입니다

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // 1부터 시작

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // 페일 클로즈드 예산

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

태깅이 기하 정보로는 할 수 없는 무엇을 더해주는가?

의도입니다. IncludeSemantics를 켜면, 태그된 PDF의 블록은 구조 트리에서 가져온 Kind를 담게 되므로, 제목이 제목인 이유는 폰트가 평균보다 컸기 때문이 아니라 생성기가 그렇게 말했기 때문입니다. 이 종류들은 재사용에 중요한 형태를 포괄합니다. cfParagraph, HeadingLevel이 있는 cfHeading, cfListItem, cfTableCell, cfCaption, cfFigure, 그리고 태그되지 않은 경우의 폴백인 cfPlain입니다

Source 필드는 각 분류가 어디서 왔는지를 기록합니다. 구조 트리라면 rosStructure, 추론이라면 rosHeuristic이며, 문서 집합 전체에 걸쳐 추출 파이프라인을 얼마나 신뢰할지 결정할 때 로그로 남길 필드입니다. Figure는 알아둘 가치가 있는 특수한 경우입니다. cfFigure 블록의 텍스트는 어떤 글리프에서도 나오지 않고 대체 설명(alternate description)에서 나오는데, figure에는 그 자체의 문자가 없기 때문입니다. 일치하지 않는 대체 텍스트도 버려지는 대신 여전히 표현되며, 이는 페이지 위에 아무것도 그려지지 않더라도 접근성 감사가 설명이 존재한다는 것을 볼 수 있게 해줍니다. 태깅 모델 자체는 PDF/UA 구조 트리 검증에서 다룹니다

스팬은 스타일과 출처를 담는다

TPdfStructuredTextSpan은 자신의 텍스트, 페이지 좌표계 경계, FontName, FontSize, FontWeight, Angle과 함께 SourceStartIndexSourceCharacterCount를 담습니다. 스팬은 스타일이 바뀌는 지점에서 나뉘므로, 굵은 단어 세 개가 있는 문장은 세 개의 스팬이 되며, HTML이나 Markdown에서 강조를 재구성하는 것은 폰트 이름으로부터 추측하는 문제가 아니라 속성을 읽는 문제가 됩니다

두 소스 인덱스 필드는 추출을 단순한 보고가 아니라 하나의 기능으로 바꿔주는 요소입니다. 이들은 페이지의 문자 시퀀스를 다시 가리키므로, 검색에서 일치한 블록을 텍스트에 대한 두 번째의, 순서가 다른 패스 없이도 문자 단위 선택 기하나 형광펜 사각형으로 변환할 수 있습니다. 그 메커니즘은 문자 상자를 이용한 시각적 텍스트 줄 선택에서 설명합니다. Angle 필드는 보이는 것보다 더 중요합니다. 스탬프나 워터마크 안의 회전된 텍스트는 본문 텍스트와 같은 좌표 공간에 놓이며, 각도를 무시하는 파이프라인은 대각선으로 놓인 "DRAFT"를 아무렇지 않게 단락 한가운데로 병합해 버립니다

예산, 그리고 두 가지 품질 카운터

MaxCharacters는 잘라내기 설정이 아니라 페일 클로즈드 예산입니다. 그것을 넘는 페이지는 콘텐츠 일부를 조용히 반환하는 대신 멈춥니다. 신뢰할 수 없는 수집 경로에서는 그것이 여러분이 원하는 동작입니다. 백만 자짜리 페이지는 기계로 생성된 괴물이거나, 여러분의 추출기를 시스템에서 가장 느린 부분으로 만들려는 시도이기 때문입니다

반환된 페이지의 두 카운터가 추출 품질을 직접적으로 서술합니다. UnmappedCharacterCount는 사용 가능한 유니코드 매핑이 없는 문자를 세는데, 이는 /ToUnicode CMap 없이 임베드된 서브셋 폰트의 전형적인 증상입니다. 그런 텍스트는 완벽하게 렌더링되지만 쓸모없는 것으로 추출됩니다. GeometryFailureCount는 바운딩 박스를 결정할 수 없었던 문자를 세는데, 이는 물리적 레이아웃 순서를 저하시킵니다. 둘 다 로그로 남기십시오. 이 숫자들이 꾸준히 0에 가까운 문서 집합은 자신 있게 색인할 수 있으며, 그렇지 않은 집합은 어떤 다운스트림 결과라도 신뢰할 수 있으려면 파이프라인 안의 일부 생성기에 관심이 필요하다는 것을 알려줍니다

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

실제 페이지에서의 성능

물리적 레이아웃 추출은 비용이 큰 모드이며, 구현은 실제로 큰 페이지를 위해 만들어져 있습니다. 문자 순서 정렬은 반복 스캔이 아니라 O(n log n)으로 실행되고, 줄과 스팬 버퍼는 문자마다 재할당하는 대신 기하급수적으로 커지며, 유니코드 텍스트는 문자열 연결이 아니라 버퍼에 구축되고, 인접한 텍스트 객체에 대한 폰트 조회는 캐시됩니다. 이런 조합 덕분에 5,000자짜리 조밀한 페이지도 제곱 시간이 아니라 예측 가능한 시간에 처리됩니다

페이지 수가 많은 작업이라면 가능한 곳에서는 여전히 더 저렴한 모드를 선택할 가치가 있습니다. 신뢰하는 태그된 문서에는 시맨틱을 켠 roContentOrder를 사용하고, 기하 정보만이 유일한 신호인 스캔 자료와 레거시 자료에는 roPhysicalLayout을 아껴 쓰십시오. 단순한 문자열만 필요하다면 PDF 문서에서 텍스트 추출하기에서 설명하는 더 단순한 API가 여전히 더 빠른 경로이며, 텍스트를 마크된 콘텐츠 식별자까지 추적해야 한다면 BDC와 MCID 마크된 콘텐츠 읽고 쓰기에서 그 계층을 다룹니다

블록 모델은 검색 파이프라인이 원하는 것에도 깔끔하게 대응됩니다. 문단들을 거느린 제목은 제목이 있는 청크가 되고, 경계는 인용이 문서가 아니라 페이지 위의 한 위치를 가리킬 수 있게 해줍니다. PDFiumPas는 PDFium 엔진을 감싸는 Delphi와 Lazarus용 컴포넌트로, 예제와 함께 PDFium Delphi 컴포넌트 페이지에 문서화되어 있습니다