기술 문서

Delphi PDF 텍스트 추출: 워드 스페이스와 줄바꿈

HotPDF Delphi Component는 THotPDF.ExtractLoadedPageText에서 워드 스페이스와 줄바꿈을 공백 문자가 아니라 glyph 지오메트리에서 재구성합니다. 글리프 자기 너비 뒤의 간격이 텍스트 높이의 0.15를 넘으면 공백이 들어가고, 텍스트 원점이 쓰기 방향을 가로질러 텍스트 높이의 절반보다 많이 움직일 때만 새 줄이 시작됩니다. v2.768.3부터 페이지 텍스트는 Form XObject를 통해 그려진 텍스트도 포함하고, 보이는 crop 영역 밖의 글리프는 제외합니다. 이 글의 나머지는 각 규칙이 왜 그런 모양인지 설명합니다. 그 모든 규칙은 실전 문서에서 그럴듯하지만 틀린 출력을 내놓던 더 단순한 규칙을 대체한 것입니다

증상은 PDF 텍스트를 검색 인덱스에 먹여 본 사람이라면 낯익습니다. 표지 페이지가 PDFReferenceManualNovember4,1998로 추출되고, 세금 양식이 156줄로 쪼개지고, 대각선 워터마크가 줄당 한 글자로 도착하며, 재단된 교정쇄는 어떤 뷰어도 보여 주지 않는 프린터 slug line으로 시작합니다. 이 파일들 중 깨진 것은 하나도 없습니다. 각자 나이브한 추출기가 잘못 읽는 완전히 합법적인 텍스트 배치 방식을 쓸 뿐입니다

추출된 PDF 텍스트는 왜 워드 스페이스를 잃을까?

추출된 텍스트가 워드 스페이스를 잃는 이유는 PDF가 그것을 담으라 요구받은 적이 없기 때문입니다. 생산자는 공백 문자를 표시해 단어를 구분할 수 있지만, TJ 배열 안의 숫자(ISO 32000-1 §9.4.3)나 새 Td(§9.4.2)로 펜을 옮길 수도 있고, TeX 출력과 상당수 Distiller 파일, 대부분의 양쪽 정렬 레이아웃이 정확히 그렇게 합니다. v2.766.76 이전의 HPDFAssemblePageText는 수직 이동만 봤으므로, 배치로 만들어진 단어 경계는 그저 사라졌습니다. 어셈블러는 이제 이전 글리프의 쓰기 방향을 따라 그 글리프 자기 너비의 끝에서 현재 글리프의 원점까지 거리를 재고, 그 거리가 현재 글리프 box 높이의 0.15를 넘을 때 공백 하나를 넣습니다. box 높이는 유저 공간에서 ascent부터 descent까지 잰 것입니다. 어느 쪽이 이미 공백이면 추가하지 않고, CJK 문자 둘 사이에도 넣지 않습니다. 양쪽 정렬이 표의문자를 벌리는 것은 단어 경계를 뜻하지 않으니까요. glyph 레코드가 같은 지오메트리를 노출하므로, 특정 파일이 의아할 때 그 판단을 재현할 수 있습니다

uses
  SysUtils, HPDFDoc, HPDFContentStream;

procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
  Glyphs: THPDFGlyphArray;
  I: Integer;
  Height, Gap: Double;
begin
  if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
    Exit;
  for I := 1 to High(Glyphs) do
  begin
    // 유저 공간에서 glyph box의 ascent-to-descent 높이
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // 수평 텍스트: 이전 글리프 자기 너비 끝부터의 간격
    Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
    if (Height > 0) and (Gap > 0.15 * Height) then
      Writeln(Format('U+%.4x gap %.2f height %.2f: space',
        [Glyphs[I].Unicode, Gap, Height]));
  end;
end;

펜 위치 대신 글리프 자기 너비에서 재는 이유는?

HotPDF는 GlyphEndX / GlyphEndY에서 워드 갭을 재는데, 글리프 뒤의 펜 위치에는 갭이 아닌 간격이 이미 들어 있기 때문입니다. ISO 32000-1 §9.4.4는 수평 변위를 glyph 너비 × 폰트 크기 + 문자 간격 Tc + 워드 스페이싱 Tw로 정의하고, 전부 Tz로 스케일합니다. BaselineEndX / BaselineEndY는 그 전체 변위를 담고, GlyphEndX / GlyphEndY는 폰트 어드밴스와 Tz만 담습니다. 음의 Tc로 트래킹을 조이고 각 글리프 뒤 TJ 조정으로 그 간격을 되돌려 주는 생산자에게 이 차이는 중요합니다. 펜 위치에서 재면 그 되돌림이 갭처럼 보여서, 중국어 표현 “95后”가 “9 5 后”로 추출되었던 것입니다. 임계값이 Tf 크기가 아니라 glyph box 높이에 묶인 것도 비슷한 이유입니다. Word 내보내기는 흔히 1 Tf를 쓰고 실제 크기를 스케일된 Tm에 실으므로, Tfs는 1이라 말하는 동안 텍스트는 10포인트입니다. Tfs에 묶인 규칙은 같은 페이지의 두 철자를 다르게 다뤘을 것입니다

Delphi에서 ExtractLoadedPageText의 HotPDF 워드 스페이스 규칙: 이전 글리프의 GlyphEndX에서 다음 글리프의 BaselineStartX까지 거리가 ascent-to-descent box 높이의 0.15를 넘을 때만 공백을 넣습니다. BaselineEndX의 펜 위치에는 Tc, Tw, Tz가 이미 들어 있어 양쪽 정렬 트래킹 되돌림이 9 5 后 같은 가짜 갭으로 변하기 때문입니다
단어가 어디서 끊기는지 결정하는 것은 공백 문자가 아니라 지오메트리입니다. glyph 레코드가 같은 측정값을 노출하므로 의아한 파일의 판단을 재현할 수 있습니다

이 규칙에는 정직한 가장자리가 있습니다. 아주 느슨한 트래킹, 그러니까 Tc만으로 글자 사이가 텍스트 높이의 0.15를 넘게 열리는 헤딩은 글자마다 공백을 사이에 두고 추출됩니다. 페이지 모양은 그렇지만 인덱싱하고 싶었던 것은 아닐 겁니다. 한 베이스라인 위에 순서 없이 그려진 조각들은 음의 갭을 만들어 공백 없이 붙습니다. 둘 다 본문에서는 흔치 않고, 테스트 코퍼스에서 이 변경은 레퍼런스 추출기 대비 28페이지에서 워드 매치를 올렸으며 낮춘 페이지는 없었습니다

HotPDF는 추출 텍스트에서 언제 새 줄을 시작할까?

v2.766.79부터 새 줄은 이전 글리프 원점에서 현재 글리프 원점으로의 이동을 이전 쓰기 방향의 법선에 투영한 값이 두 글리프 중 더 큰 box 높이의 절반을 넘을 때 시작됩니다. 예전 규칙은 날 Y 이동을 Tfs의 절반과 비교했는데, 두 방향에서 실패했습니다. 1 Tf와 스케일된 Tm에서는 임계값이 반 유닛으로 줄어, 텍스트 rise 0.4로 올린 윗첨자나 평범한 베이스라인 떨림이 줄을 끊었습니다. 규칙은 X를 아예 무시했으므로, 회전된 Tm 아래의 텍스트는 글리프마다 페이지를 계단처럼 내려가 줄당 한 글자로 나왔습니다. 방향 법선에 투영하면 회전된 런이 수평 런처럼 동작하고, 두 높이 중 큰 것을 취하면 같은 베이스라인을 공유할 때 큰 샘플 단어와 그 작은 캡션이 한 줄에 남습니다. 위에서 말한 세금 양식에서 줄 수는 156에서 97로 떨어졌습니다. 쓰기 모드 1(§9.7.4.3)의 수직 텍스트는 별도 경로를 따릅니다. 그 글리프들은 열로 묶이고 오른쪽에서 왼쪽으로, 위에서 아래로 읽히며, 열이 바뀔 때마다 줄바꿈이 옵니다

Delphi에서 HotPDF ExtractLoadedPageText가 줄바꿈을 판단하는 방식: glyph 원점 사이의 이동을 쓰기 방향의 법선에 투영해 더 큰 box 높이의 절반과 비교하므로, 1 Tf 폰트 아래 작은 텍스트 rise로 올린 윗첨자와 회전된 Tm 아래 페이지를 계단처럼 내려가는 텍스트가 더 이상 줄당 한 글자로 쪼개지지 않습니다
투영은 회전된 런을 수평 런처럼 동작하게 하고, 두 box 높이 중 큰 것을 취하면 큰 샘플 단어와 그 작은 캡션이 한 줄에 남습니다

ExtractLoadedPageText는 어떤 텍스트를 넣고 어떤 텍스트를 뺄까?

ExtractLoadedPageText는 뷰어가 보여 주는 텍스트를 반환합니다. v2.766.80부터 보이는 글리프만 대상으로 하며, box 중심이 GetLoadedPageVisibleBox 밖에 떨어지는 글리프는 모두 버립니다. 이것은 MediaBox로 잘린 CropBox입니다(§14.11.2). 그러면 slug line과 재단 영역 밖에 텍스트로 놓인 다른 프린터 마크가 제거됩니다. ExtractLoadedPageGlyphs는 의도적으로 페이지 콘텐츠 스트림의 모든 글리프를 계속 반환하므로, 필요할 때 그 자료를 여전히 찾을 수 있습니다. 필터는 box 검사지 가시성 검사가 아닙니다. 클리핑 경로에 가려지거나 흰색으로 그려지거나 이미지에 덮인 텍스트도 여전히 추출됩니다

var
  Pdf: THotPDF;
  Glyphs: THPDFGlyphArray;
  PageText: UnicodeString;
  L, B, R, T: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('trimmed-proof.pdf');
    if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
      Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
    // 페이지 콘텐츠 스트림의 모든 글리프, slug line 포함
    if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
      Writeln(Length(Glyphs), ' glyphs in the page content stream');
    // 페이지가 보여 주는 것만, Form XObject 텍스트를 끼워 넣어
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

Form XObject를 통해 그려진 텍스트는 v2.768.3부터 페이지 텍스트의 일부입니다. 헤더, 스탬프, 워터마크는 아주 흔히 폼 안에 살고, 변경 전에는 일부 표준 문서가 글자의 30에서 35퍼센트를 잃었습니다. THotPDF.InterpretContentWithForms는 각 Do를 그 시점의 유효 CTM과 함께 기록하고, 폼을 자기 /Matrix × 그 CTM으로 해석하며(§8.10.1), 폼의 글리프를 Do 위치에 끼워 넣으면서 중첩 폼으로 재귀합니다. 자체 /Resources가 없는 폼은 §7.8.3이 허용하듯 자신을 그리는 스트림의 것을 빌립니다. 폼 글리프는 TokenIndex = -1을 달고 다니며, ExtractLoadedPageGlyphs는 여전히 페이지 스트림 글리프만 반환합니다. 검색, 바꾸기, 레닥션이 변경을 TokenIndex로 되쓰는데 폼 글리프가 섞이면 엉뚱한 바이트를 편집할 테니까요. 알아 둘 만한 두 가지 단순화가 있습니다. 폼 텍스트는 폼의 /BBox로 클립되지 않고, 재귀는 사이클 탐지가 아니라 12수준에서 멈추므로, 자기 자신을 그리는 잘못된 폼은 그 상한에 닿을 때까지 텍스트를 반복합니다

Delphi에서 PDF 페이지 텍스트를 추출할 때 HotPDF가 포함하는 글리프: ExtractLoadedPageText는 box 중심이 GetLoadedPageVisibleBox, 그러니까 MediaBox로 잘린 CropBox 안에 떨어지는 글리프만 유지해 프린터 slug line이 사라지고, InterpretContentWithForms는 각 Do 위치에 TokenIndex를 -1로 두고 Form XObject 글리프를 끼워 넣으며, glyph 수준 API는 여전히 전부 반환합니다
glyph 중심의 box 검사는 가시성 검사가 아닙니다. 흰 텍스트, 클립된 텍스트, 덮인 텍스트도 여전히 나오고, 폼 텍스트는 v2.768.3부터 포함됩니다

Q 연산자 뒤의 텍스트는 왜 깨진 걸로 디코딩됐을까?

v2.766.73 전에는 Q 뒤의 텍스트가 잘못 디코딩될 수 있었습니다. 추출기가 q에서 CTM만 저장했기 때문입니다. 텍스트 상태 파라미터, 그러니까 폰트, 크기, Tc, Tw, Tz, TL, 렌더링 모드, rise는 그래픽 상태에 속합니다(§9.3.1). 그러므로 Q는 스택의 나머지 전부와 함께 그것들을 복원해야 합니다(§8.4.2). 어느 업계 리포트는 q … Q 안에서 2바이트 Identity-H 폰트를 선택한 다음 자체 Tf 없이 1바이트 WinAnsi 텍스트를 보여 주었습니다. 추출기는 안쪽 폰트를 유지한 채 목차의 리더와 “Adobe”라는 단어를 2바이트 코드로 읽었고, 페이지 글자의 15%를 버렸습니다. 인터프리터의 q/Q 스택은 이제 전체 텍스트 상태를 담습니다. 여기서 기술한 추출 규칙은 모든 페이지에 적용되므로, 문서 전체가 한 번의 호출로 파일로 갈 수 있습니다

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // 빈 범위 = 모든 페이지, 페이지 사이 form feed, UTF-8 BOM
    Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
    Writeln(Pages, ' pages extracted');
  finally
    Output.Free;
  end;
end;

어떤 HotPDF 텍스트 API를 써야 할까?

ExtractLoadedPageText는 콘텐츠 스트림 순서를 유지하며, 검색과 인덱싱에는 그것이 옳은 디폴트입니다. 그 아래의 디코딩 체인은 HotPDF로 불러온 PDF에서 텍스트 추출하기에서 다뤄집니다. 저작 순서가 중요한 태그 문서에는 구조 순서 텍스트 추출이 지오메트리에서 추측하는 대신 구조 트리를 걷고, 테이블에 갇힌 데이터에는 페이지 경계를 넘는 타입 테이블 추출이 줄이 아니라 셀을 반환합니다. 전체 API 레퍼런스와 트라이얼 다운로드는 HotPDF Delphi PDF Component 제품 페이지에 있습니다