기술 문서

PDFium Delphi에서 양끝 정렬 텍스트 표 오탐 고치기

PDFium Component 버전 3.117.0은 양끝 정렬 문단을 공백 정렬 표로 보고하지 않습니다. 모든 열 경계가 자기가 나누는 어느 행에도 텍스트가 없는 세로 통로일 것을 요구하고, 격자가 이미 차지한 단어를 건너뛰며, 글리프 박스 중심 거리 대신 세로 겹침으로 셀 텍스트를 조립합니다. 세 변경 모두 ExtractTables와 ExtractDocumentTables 안에 있고 옵션이 필요하지 않습니다

이 작업을 시작한 제보는 화려하지 않았습니다. 표가 하나도 없는 보도자료 페이지가 ExtractTables에서 5x4 공백 표로 돌아왔고, 신뢰도는 기본 MinConfidence 0.5를 여유 있게 넘었으며, 셀에는 평범한 본문 텍스트 조각이 담겨 있었습니다. 입학 서식도 에세이 문단으로 같은 일을 해 3x4와 5x3을 만들어 냈습니다. 두 문서 모두 양끝 정렬이었습니다. 뻔한 대응은 임계값을 조정하는 것이고, 이 릴리스의 유용한 교훈은 조정으로는 고칠 수 없다는 것입니다. 조정 대상인 규칙이 잘못된 질문을 하고 있었기 때문입니다

uses
  PDFium;

// 회귀 점검: 문서의 모든 공백 표를 나열해 산문뿐인 페이지가
// 깨끗한지 확인할 수 있게 함
procedure ReportWhitespaceTables(Pdf: TPdf);
var
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Options := TPdfTableExtractionOptions.Default;   // MinColumnGap 12pt
  Tables := Pdf.ExtractDocumentTables(Options);
  for I := 0 to High(Tables) do
    if Tables[I].DetectionMode = ptdmWhitespace then
      Writeln(Format('page %d: %dx%d whitespace table, confidence %.2f, ' +
        'first cell "%s"',
        [Tables[I].PageNumber, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Tables[I].Cells[0].Text]));
end;

양끝 정렬 텍스트가 표처럼 보이는 이유

양끝 정렬 문단이 표처럼 보이는 이유는 양끝 정렬된 줄이 레이아웃 엔진이 늘린 간격으로 나뉜 단어들의 행이고, 늘어난 간격이 MinColumnGap에 도달하면 감지기가 그것을 열 구분자와 구별할 행 내부적 방법이 없기 때문입니다. PDFium Component의 공백 전략은 단어 박스를 시각적 행으로 묶고, 이전 단어와의 수평 거리가 최소 MinColumnGap(기본 12포인트)인 곳마다 각 행을 단어 그룹으로 나누며, 최소 MinColumns개의 왼쪽 정렬 그룹 앵커가 AlignmentTolerance(3포인트) 이내에서 두 개 이상의 연속 행에 반복되면 표로 인정합니다. 표 감지 개요에 설명된 규칙이 그것이고, 진짜 정렬된 표에는 정확히 맞습니다

이제 이것을 양끝 정렬된 10포인트 산문 스무 줄에 적용해 보겠습니다. 모든 줄이 같은 오른쪽 여백까지 늘어나므로 긴 단어로 끝나는 줄은 내부 공백을 벌리고, 짧은 줄이 몇 개 있는 문단에서는 그중 일부 공백이 12포인트를 넘습니다. 연속한 두 줄이 각각 늘어난 간격 하나씩만 같은 X 위치 3포인트 이내에 두면 2행 2열 후보가 됩니다. 줄이 충분히 많으면 이것은 운이 나쁜 것이 아니라 확실성에 가까워지는 확률이며, 보도자료의 5x4는 그런 간격 네 개가 다섯 줄에서 나란히 맞은 회차였을 뿐입니다

PDFium Component에서 양끝 정렬 산문이 표로 점수화된 이유 도해: 모든 줄이 같은 여백까지 늘어나므로 줄마다 다른 X에서 단일 간격이 MinColumnGap을 넘고, AlignmentTolerance 이내의 연속 간격 두 개가 오탐 후보를 만들었으며, 이제 통로 테스트가 그것을 거부합니다
진짜 표는 모든 행에서 열 앵커를 반복하지만 양끝 정렬 문단은 줄마다 다른 공백을 늘리며, 그래서 행 수준 조정만으로는 둘을 가를 수 없었습니다

모든 임계값은 한 부류의 문서를 다른 부류와 맞바꿉니다. MinColumnGap을 20포인트로 올리면 조밀한 재무 보고서의 좁은 열을 잃는데, 기본값을 이미 낮춘 이유가 바로 그 경우였습니다. MinRows를 3으로 올리면 진짜 2행 표를 버리면서 긴 문단의 확률만 조금 낮춥니다. AlignmentTolerance를 3포인트 아래로 좁히면 왼쪽 모서리가 그보다 더 흔들리는 OCR 유래 단어 박스가 깨집니다. 행 수준 신호는 정말로 모호하므로, 수정은 행이 스스로 나르지 않는 신호에서 와야 합니다

열 경계를 진짜로 만드는 것

진짜 열 경계는 자기가 나누는 모든 행에 걸쳐 비어 있는 페이지의 세로 띠입니다. 표에는 구조상 모든 열 쌍 사이에 그것이 있습니다. 셀이 공유 X 위치를 기준으로 배치되었기 때문입니다. 양끝 정렬 문단은 줄마다 다른 수평 위치에서 단어 공백을 늘리므로, 한두 줄을 넘는 교집합을 견디는 띠가 없습니다. 이제 PDFium Component가 정확히 그것을 검사합니다. 후보의 단어 그룹이 앵커 열에 배정된 뒤, 인접한 열 쌍마다 두 셀에 모두 내용이 있는 모든 행에서 왼쪽 셀 단어의 가장 오른쪽 모서리부터 오른쪽 셀 단어의 가장 왼쪽 모서리까지의 구간을 취하고, 그 구간들을 행에 걸쳐 교집합하여, 교집합이 MinColumnGap의 0.5배(기본값에서 6포인트)보다 좁으면 후보 전체를 거부합니다

ExtractTables 뒤의 텍스트 없는 통로 테스트 도해: 각 행이 자기 왼쪽 셀의 오른쪽 모서리부터 오른쪽 셀의 왼쪽 모서리까지의 구간을 내놓고, 교집합은 진짜 표에서는 MinColumnGap의 절반보다 넓게 남고 양끝 정렬 텍스트에서는 아무것도 남지 않습니다
진짜 열 경계는 자기가 나누는 모든 행에서 비어 있으므로, 행별 간격을 교집합하면 표에는 공유 띠가 남고 늘어난 산문에는 띠가 전혀 남지 않습니다

세부 두 가지가 중요합니다. 어느 한쪽 셀이 빈 행은 표를 행사하지 않으므로 빈 셀이 있거나 본문보다 열이 적은 머리글이 있는 표도 통과합니다. 그리고 통로 폭은 별도 옵션으로 노출되지 않고 MinColumnGap에서 도출됩니다. 둘이 같은 물리적 대상을 기술하기 때문입니다. 디자이너가 열 사이에 남기는 간격입니다. 로직은 표 API가 아니라 원시 단어 박스 위에서 만든다면 재현할 만큼 작으며, 아래 샘플은 컴포넌트 내부의 검사를 그대로 옮긴 것입니다

uses
  Math, PDFium;

type
  TIndexList = array of Integer;
  TCellIndexes = array of TIndexList;   // Row * ColumnCount + Column

// 인접한 열 쌍 중 하나라도 그것을 쓰는 행들에 걸쳐
// MinColumnGap / 2 이상의 텍스트 없는 세로 통로가 없으면 False 반환
function HasTextFreeCorridors(const Words: TPdfWordBoxes;
  const Cells: TCellIndexes; RowCount, ColumnCount: Integer;
  MinColumnGap: Double): Boolean;
var
  Col, Row, I, LeftCell, RightCell, Supported: Integer;
  CorridorLeft, CorridorRight, RowLeft, RowRight: Double;
begin
  for Col := 0 to ColumnCount - 2 do
  begin
    CorridorLeft := -MaxDouble;
    CorridorRight := MaxDouble;
    Supported := 0;
    for Row := 0 to RowCount - 1 do
    begin
      LeftCell := Row * ColumnCount + Col;
      RightCell := LeftCell + 1;
      if (Length(Cells[LeftCell]) = 0) or (Length(Cells[RightCell]) = 0) then
        Continue;                             // 빈 셀은 표를 행사하지 않음
      RowLeft := -MaxDouble;
      RowRight := MaxDouble;
      for I in Cells[LeftCell] do
        RowLeft := Max(RowLeft, Words[I].Rect.Right);
      for I in Cells[RightCell] do
        RowRight := Min(RowRight, Words[I].Rect.Left);
      CorridorLeft := Max(CorridorLeft, RowLeft);
      CorridorRight := Min(CorridorRight, RowRight);
      Inc(Supported);
    end;
    if (Supported > 0) and
       (CorridorRight - CorridorLeft < MinColumnGap * 0.5) then
      Exit(False);
  end;
  Result := True;
end;

격자 표가 왜 두 번 추출되었을까

격자 표가 두 번 추출된 이유는 공백 패스가 페이지의 모든 단어를 보았고, 그중에는 격자 패스가 이미 그리드에 넣은 단어도 있었기 때문입니다. 깨끗한 격자 표는 구조상 완벽하게 정렬된 공백 표이기도 합니다. 후보의 경계가 기존 표의 절반 이상을 덮으면 거부하는 겹침 검사가 이미 있었지만, 표의 아래쪽 행들과 그 아래 정렬된 텍스트 몇 줄을 합친 후보는 그 비율 아래로 떨어져 이웃으로 번지는 두 번째의 약간 더 큰 표로 살아남을 수 있었습니다. 이제 ExtractTables는 공백 패스가 실행되기 전에 그 단어들을 제거합니다. 단어의 중심점이 격자 패스가 만든 어느 표의 경계 안에 있으면 버리며, 완전 포함이 아니라 중심을 쓰는 이유는 테두리를 0.1포인트만 걸친 단어도 시각적으로 속한 표를 따르게 하기 위해서입니다. 그러면 공백 전략은 자유 단어만 다루므로, 격자 표 바로 아래 붙어 있는 작은 격자 없는 표도 위 그리드와 융합되지 않고 자기 자격으로 감지됩니다

Purpose of Request가 왜 of Purpose Request로 나왔을까

단어 순서가 뒤바뀐 이유는 PDFium Component가 만드는 단어 박스가 글리프 경계 상자의 합집합인데, of에는 하강부가 없고 Purpose와 Request:에는 있기 때문입니다. FPDFText_GetCharBox는 글리프 잉크의 빡빡한 박스를 페이지 공간으로 돌려주지, 폰트의 상승부와 하강부까지 채운 박스를 돌려주지 않으며, 단어 박스는 그 문자 박스들의 합집합입니다. 그래서 하강부가 없는 단어는 더 짧고 세로 중심이 더 위에 있으며, 문제의 서식에서는 2~3포인트 차이였습니다. 예전 셀 텍스트 루틴은 단어를 먼저 중심 Y로 정렬하고(같은 줄 판정에 1포인트 허용치) 그다음 왼쪽 모서리로 정렬했는데, of가 허용치를 벗어나 자기만의 줄로 위에 정렬되어 먼저 나왔습니다

이것은 PDFium의 별난 점이라기보다 PDF가 텍스트를 배치하는 방식의 결과입니다. ISO 32000-1 §9.2.2와 §9.4.4는 글리프 배치를 텍스트 공간에서 기준선을 따른 수평 변위로 정의하며, 파일이 지닌 유일한 세로 메트릭은 폰트별입니다. §9.8.1의 폰트 디스크립터에 있는 Ascent, Descent, FontBBox 항목입니다. 파일 어디에도 두 글리프가 같은 줄을 공유한다고 쓰여 있지 않습니다. 그것은 기하에서 추론해야 하며, 선택 하이라이트를 제대로 보이게 하는 빡빡한 글리프 박스는 — PDFium char box로 텍스트 줄 선택하기에 설명된 것처럼 — 중심 거리 비교에는 잘못된 입력입니다

버전 3.117.0의 수정은 질문을 중심이 얼마나 떨어져 있는가에서 박스가 세로로 얼마나 겹치는가로 바꿉니다. 셀 텍스트는 먼저 그 셀의 단어들을 시각적 줄로 묶고, 단어는 줄의 누적 경계와의 세로 겹침이 두 높이 중 작은 것의 25퍼센트 이상일 때 그 줄에 합류하며, 그다음 각 줄을 왼쪽 모서리로 삽입 정렬하고, 줄들을 줄바꿈으로 이어 붙여 조립됩니다. Purpose와 of는 x-높이 전체에 걸쳐 겹치며, 이는 더 짧은 박스의 25퍼센트를 훨씬 넘으므로 의도대로 같은 줄에 놓여 X로 정렬됩니다

Purpose of Request 재정렬 수정 도해: FPDFText_GetCharBox의 빡빡한 글리프 박스가 하강부 없는 of에 더 높은 중심을 주어 예전 1pt 중심 Y 허용치가 그것을 자기 줄로 정렬했고, 25퍼센트 세로 겹침 규칙이 그것을 기준선 위에 붙들어 단어 순서를 되돌립니다
중심 Y는 잉크가 지닌 상승부와 하강부에 따라 움직이지만, 한 기준선 위의 두 박스는 높이가 어떻든 공유 x-높이에 걸쳐 겹칩니다

중심 거리가 아니라 겹침으로 텍스트 줄 묶기

이 버그에서 가져갈 규칙은 일반적입니다. 세로 중심을 고정 허용치와 비교해 같은 줄을 판정하는 PDF 텍스트 레이아웃 코드는 실제 폰트에서 실패하며, 그 실패는 조용합니다. 아무것도 오류를 내지 않고 단어가 그냥 잘못된 순서로 나옵니다. 하강부가 섞인 경우는 가장 약한 방아쇠입니다. 12포인트 굵은 라벨 옆의 10포인트 값, 위첨자 각주 표시, 대체 폰트에서 그린 통화 기호, 단어마다 높이가 흔들리는 OCR 단어 박스는 모두, 12포인트 행간의 10포인트 텍스트에서 인접 줄을 여전히 분리하는 어떤 허용치보다도 중심을 더 많이 움직입니다. 겹침 비율은 크기 불변입니다. 한 기준선 위의 두 박스는 상승부와 하강부가 어떻든 공유 x-높이에 걸쳐 겹치고, 인접 줄의 두 박스는 전혀 겹치지 않습니다

같은 규칙은 표 추출 밖에서도 적용하기 쉽습니다. TPdf.PageWordBoxes는 현재 페이지의 모든 단어를 페이지 공간 사각형과 함께 돌려주므로, 페이지를 시각적 줄로 묶는 것은 짧은 루프입니다

uses
  Math, PDFium;

function SameVisualLine(const A, B: TPdfRectangle): Boolean;
var
  Overlap, MinHeight: Double;
begin
  Overlap := Min(A.Top, B.Top) - Max(A.Bottom, B.Bottom);
  MinHeight := Min(A.Top - A.Bottom, B.Top - B.Bottom);
  Result := (MinHeight > 0) and (Overlap >= MinHeight * 0.25);
end;

procedure GroupPageIntoLines(Pdf: TPdf; out Lines: TArray<TPdfWordBoxes>);
var
  Words: TPdfWordBoxes;
  Bounds: TArray<TPdfRectangle>;   // 줄별 누적 합집합
  I, J, Found: Integer;
begin
  Words := Pdf.PageWordBoxes;
  Lines := nil;
  Bounds := nil;
  for I := 0 to High(Words) do
  begin
    Found := -1;
    for J := High(Lines) downto 0 do
      if SameVisualLine(Bounds[J], Words[I].Rect) then
      begin
        Found := J;
        Break;
      end;
    if Found < 0 then
    begin
      SetLength(Lines, Length(Lines) + 1);
      SetLength(Bounds, Length(Bounds) + 1);
      Found := High(Lines);
      Bounds[Found] := Words[I].Rect;
    end;
    SetLength(Lines[Found], Length(Lines[Found]) + 1);
    Lines[Found][High(Lines[Found])] := Words[I];
    Bounds[Found].Left := Min(Bounds[Found].Left, Words[I].Rect.Left);
    Bounds[Found].Right := Max(Bounds[Found].Right, Words[I].Rect.Right);
    Bounds[Found].Top := Max(Bounds[Found].Top, Words[I].Rect.Top);
    Bounds[Found].Bottom := Min(Bounds[Found].Bottom, Words[I].Rect.Bottom);
  end;
  // 읽기 전에 각 줄을 Rect.Left로 정렬할 것; PageWordBoxes는
  // 콘텐츠 스트림 순서로 단어를 돌려주며, 그것이 시각적 순서라는 보장은 없음
end;

기존 호출자에게 달라지는 것과 한계

그 스니펫의 요점은 루프가 아니라 술어입니다. 간단한 덤프 이상이 필요하면 블록과 줄, 읽기 순서 소스를 이미 지닌 구조화된 텍스트 모델에서 시작하십시오. 읽기 순서가 있는 구조화된 PDF 텍스트 추출에서 다룹니다. 기존 표 호출자는 옵션을 건드리지 않고 세 가지 교정을 모두 받습니다. 통로 임계값은 MinColumnGap의 절반으로 고정되고, 공백 전략은 MinRows를 1로 설정해도(이제 격자 전략이 그것을 받아들입니다) 2행 하한을 유지하며, 격자 우선 단어 필터링은 두 전략이 모두 켜져 있을 때 무조건 적용됩니다. 릴리스에 사용한 문서 13개 샘플 집합에서 공백 패스는 이전에 격자 표 9개와 함께 조각 및 오탐 34개를 돌려주었습니다. 릴리스 이후에는 아무것도 돌려주지 않고 격자 표 수는 41개로 올랐습니다. 다만 그 상승의 대부분은 같은 릴리스가 격자 감지기에 채워진 사각형으로 그린 테두리를 읽도록 가르친 데서 오며, 그것은 별개의 이야기입니다

정직한 한계입니다. 통로 테스트가 무언가를 거부하려면 경계 양쪽에 내용이 있는 행이 최소 하나 필요하므로, 두 개의 늘어난 간격이 우연히 서로 6포인트 이내에 떨어진 2행 후보는 여전히 통과합니다. 예전의 거의 확실성과 비교하면 좁은 우연이지만, 진짜 2행 표가 없는 산문 위주 문서는 MinRows를 3으로 설정해 그것을 닫을 수 있습니다. 왼쪽 정렬된 들쭉날쭉한 텍스트는 애초에 문제가 아니었고 영향을 받지 않습니다. 그리고 PDF에는 여전히 표 객체가 없습니다. ISO 32000-1 §14.8.4.3이 Table 구조 요소를 정의하지만 Tagged PDF만 그것을 지니므로, 그 밖의 모든 것에서 그리드는 기하로부터의 추론으로 남고, 각 TPdfTable의 신뢰도 값이 있는 이유는 추론에는 점수가 필요하기 때문입니다

표 추출과 구조화된 텍스트, 단어 박스는 모두 Delphi와 C++Builder, Lazarus의 같은 페이지 모델에서 읽습니다. TPdfTableExtractionOptions와 함께 배포되는 TableExtractionLab 데모를 포함한 전체 API는 PDFium Component for Delphi 페이지에 설명되어 있습니다