기술 문서

PDFium Delphi에서 채워진 사각형 테두리 규칙선 처리

Word나 Google Docs에서 내보낸 표는 한 페이지에 테두리 조각 수백 개를 지니면서도 스트로크 선 하나 없을 수 있습니다. qpdf --qdf로 페이지를 풀어 보면 content stream이 re f 쌍의 나열로 읽히는데, 각각은 반 포인트 높이의 상자를 묘사합니다. 스트로크 세그먼트만 찾는 표 감지기는 아무것도 못 보고, 단어를 그 사이 공백으로 줄 세우는 fallback으로 미끄러져, 작성자가 깔끔한 그리드 하나를 그려 둔 곳에서 두세 행짜리 조각들을 돌려줍니다. 테두리는 전부 있습니다. 채우기로 칠해져 있을 뿐이므로, 감지기는 채워진 사각형을 규칙선으로 읽고 거의 닿는 모서리를 공유 그리드 선으로 끌어당겨야 합니다

Word 표에 스트로크 선이 없는 이유

워드 프로세서는 셀 테두리를 폭을 지닌 상자로 다루고 그 상자를 fill 연산자로 칠합니다. ISO 32000-1 §8.5.2.1은 re를 닫힌 사각형 subpath를 추가하는 것으로 정의하고, §8.5.3은 칠하기 연산자를 갈라 놓습니다. S는 현재 선 폭으로 path를 stroke하고, f는 내부를 채웁니다. 0.5포인트 테두리는 x y w 0.5 re f로 나오며 stroke 장치(선 폭, join, dash 패턴)는 아예 돌지 않습니다. 셀 음영은 더 큰 상자를 쓴 같은 구성입니다. 선 지향 감지기가 기대하는 stroke 형태, 즉 m, l, S는 오피스 export가 거의 쓰지 않는 형태입니다:

% one cell border from a word-processor export: a 0.5 pt tall filled box
72 700 468 0.5 re f
% 셀 음영: 셀 크기의 채워진 상자
72 676 117 24 re f
% 스트로크 전용 감지기가 찾는 스트로크 그리드 선
72 700 m 540 700 l S

raw PDFium API 위에 직접 감지기를 쓴다면 첫 버전은 보통 FPDFPath_GetDrawMode를 호출해 stroke 플래그가 설정된 path만 남기고 지나갑니다. 위의 채워진 상자 둘은 그 검사에 둘 다 실패합니다. 셀 안의 단어들은 whitespace 감지에 도달하고, 다닥다닥 붙은 export에서는 열이 겨우 6포인트쯤 떨어져 있습니다. PDFium Component의 기본 MinColumnGap은 12포인트라 그 열들은 후보조차 되지 않죠. 열이 잡힐 때까지 MinColumnGap을 낮추면 양끝 정렬 단락의 늘어난 단어 간격까지 후보가 되고, 작성자가 그린 그리드는 결코 돌아오지 않습니다. 그리드를 정의하는 정보가 채워진 상자 안에 살기 때문입니다

PDFium Component가 채워진 상자를 규칙선으로 읽는 방법

PDFium Component는 페이지의 모든 path 객체에서, subpath 단위로 표 지오메트리를 읽고, TPdfTableExtractionOptions.DetectFilledRulings의 기본값이 True이므로 fill 모드가 none이 아닌 path를 stroked path와 함께 규칙선 후보로 검사합니다. 뒤따르는 필터는 의도적으로 엄격합니다. 채워진 subpath는 점이 넷에서 여덟 사이이고, curve segment가 없고, 모든 점이 subpath bounding box의 모서리 위에 0.05포인트 이내로 놓일 때만 자격을 얻습니다. re로 그린 사각형은 통과하고, 모서리 하나를 반복한 점 다섯 개로 그린 것도 통과합니다. 삼각형, 갈매기형, L자 외곽선, 둥근 탭은 실패하고 아무것도 내놓지 않습니다

PDFium Component에서 TableCollectObjectRulings가 닫힌 서브패스를 Delphi의 표 규칙선으로 바꾸는 방식: FlushSubpath가 곡선 외곽선과 경계 상자 모서리를 벗어난 다각형을 버리고, MaxRulingThickness가 얇은 상자를 긴 축마다 규칙선 하나로 나누며, 음영 셀은 네 모서리 규칙선을 내놓고, 아주 작은 정사각형은 배제됩니다
닫힌 서브패스는 축 정렬일 때만 살아남고, 그다음 경계 상자가 그것이 규칙선 하나인지, 음영 셀의 네 모서리인지, 아무것도 아닌지를 결정합니다

살아남은 사각형은 bounding box를 MaxRulingThickness(기본 3포인트)와 비교해 분류됩니다. 폭이 임계값 이하이고 높이가 그보다 크면 상자의 수평 중앙에서 상자 전체 높이를 달리는 세로 규칙선 하나가 나오고, 거울 상황에서는 가로 규칙선 하나가 나옵니다. 두 치수가 모두 임계값을 넘으면 shaded cell로 취급되어 모서리마다 하나씩 규칙선 넷을 내놓습니다. 두 치수가 모두 임계값 이하면 아무것도 내놓지 않으므로 목록의 2포인트 정사각형 불릿이 표 격자의 토막으로 변하는 일은 없습니다. fill 색은 이 판정에 아무 역할도 하지 않습니다. 흰 페이지 위의 흰 상자는 검은 상자와 같은 규칙선을 내는데, 그래서 표 크기만 한 배경 패널이 바깥 프레임으로 나타날 수 있습니다

stroked path는 자기 경로를 유지합니다. 양 끝점이 한 축에서 RulingTolerance(기본 1포인트) 이하로 다른 모든 직선 segment는 다른 축 방향 규칙선이 되고, curve segment는 건너뛰며, 닫힌 subpath의 마감 segment도 셉니다. fill과 stroke를 모두로 칠한 path는 양쪽 경로로 모두 기여하고 겹치는 조각은 병합 단계가 나중에 합칩니다. stroked rounded rectangle은 똑바른 변의 규칙선은 내지만 모서리의 arc만 떨어집니다. 추출 호출 자체는 특별한 설정이 필요 없습니다:

uses
  System.SysUtils, PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I, J, WithText: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // 1 기반

    Options := TPdfTableExtractionOptions.Default;
    // 채워진 규칙선 필드들, 기본값 그대로 나열
    Options.DetectFilledRulings := True;
    Options.MaxRulingThickness := 3.0;       // 포인트; 0보다 커야 합니다
    Options.RulingSnapTolerance := 4.0;      // 포인트; 0이면 스냅을 끕니다
    Options.IncludeFormXObjects := True;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
    begin
      if Tables[I].DetectionMode = ptdmRuled then
        Mode := 'ruled'
      else
        Mode := 'whitespace';
      WithText := 0;
      for J := 0 to High(Tables[I].Cells) do
        if Tables[I].Cells[J].Text <> '' then
          Inc(WithText);
      Writeln(Format('%dx%d %s, confidence %.2f, %d of %d cells hold text',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence, WithText, Length(Tables[I].Cells)]));
    end;
  finally
    Pdf.Free;
  end;
end;

MaxRulingThickness를 0으로 두는 것은 기능을 끄는 방법이 아닙니다. ExtractTables는 지오메트리 옵션을 미리 검증해서 임계값이 0 이하이면 "Table extraction geometry options are invalid" 메시지의 EPdfError를 일으킵니다. stroke-only 동작을 원하면 DetectFilledRulings를 False로 두세요. 오피스 제품군이나 브라우저 인쇄 대화상자에서 나온 것이라면 켜 두세요. 엄격한 사각형 필터가 비용을 낮게 유지하는 장치이고, 장식 fill은 거의 통과하지 못합니다

RulingSnapTolerance는 음영 셀 표에 무엇을 해 주는가

RulingSnapTolerance는 음영만으로 만든 표가 하나의 그리드로 이어지게 하는 장치입니다. 어떤 export는 테두리를 전혀 그리지 않습니다. 각 셀이 채워진 상자이고 이웃과 1~3포인트의 흰 gutter로 갈라져 있죠. 각 상자는 모서리 규칙선 넷을 내놓지만, 한 셀의 오른쪽 모서리와 다음 셀의 왼쪽 모서리는 예컨대 2포인트 떨어져 있고, 두 규칙선이 같은 그리드에 속하는지 판정하는 검사는 RulingTolerance인 1포인트만 허용합니다. 스냅이 없으면 모든 셀이 1x1 그리드를 묘사하는 규칙선 넷의 섬이 되고, MinColumns나 MinRows(둘 다 기본 2)에 닿는 것이 없어, 명백히 표가 있는 페이지에서 ruled pass는 표 없음을 보고합니다

PDFium Component에서 RulingSnapTolerance가 Delphi의 음영 셀 표를 연결하는 방식: 이웃 셀이 2pt 간격을 남기고 그 모서리 규칙선이 1pt RulingTolerance를 넘어서 있어서, 스냅이 두 X 값을 231.0 클러스터로 묶어 두 셀이 하나의 그리드 선을 공유하게 됩니다
스냅은 병합 전, 그리고 격자 감지기 전에 실행되므로 흰 간격의 양쪽이 하나의 선이 되고 모든 셀이 규칙선 네 개짜리 섬이기를 그칩니다

스냅 단계는 판에 나오는 모든 X 좌표, 즉 각 세로 규칙선의 위치와 각 가로 규칙선의 시작과 끝을 모으고, Y 좌표도 같은 식으로 모읍니다. 각 목록은 정렬된 뒤 이웃 차이가 tolerance를 넘는 지점마다 클러스터로 갈라지고, 각 클러스터는 평균으로 대체되며, 모든 위치와 시작, 끝은 가장 가까운 중심으로 이동합니다. 230.0과 232.0의 모서리는 둘 다 231.0이 되고, 두 셀은 한 선을 공유하며, 가로 규칙선은 함께 늘어나 모서리가 만납니다

RulingTolerance 이내에서 닿거나 겹치는 공선 조각들은 단일 선으로 merge됩니다. 그제서야 grid detector가 돌며 규칙선을 둘씩 비교합니다. 720개의 테두리 조각으로 도착한 30행 표는 그 비교에 몇십 개의 선으로 도달합니다

stroked 그리드에서는 이 단계가 아무것도 바꾸지 않습니다. 이미 같은 좌표는 자기 자신으로 스냅되니까요. 문제는 연쇄에 자체 폭 제한이 없다는 점입니다. 각각 3포인트씩 떨어진 값들, 100, 103, 106, 109는 바깥값이 9포인트 떨어져 있어도 하나의 중심 104.5로 접힙니다. 4포인트 기본값에서는 글자 하나보다 좁은 열에서만 이가므로 오피스 출력은 4포인트 기본값을 유지해도 됩니다. 형태가 일부러 좁은 열(tick box, 한 칸에 숫자 하나씩 넣는 계좌번호 필드)을 지닌 양식이라면 1~2로 낮추고, 스냅을 끄려면 0으로 두세요. ruled 전략을 세 설정으로 단독 실행하면 페이지가 어느 메커니즘에 의존하는지 드러납니다:

// Ruled strategy only, so whitespace detection cannot mask the result
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
  SnapTolerance: Double): Integer;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.DetectWhitespaceTables := False;
  Options.DetectFilledRulings := FilledRulings;
  Options.RulingSnapTolerance := SnapTolerance;
  Result := Length(Pdf.ExtractTables(Options));
end;

// 2pt 간격의 음영 셀로만 그린 표에서 기대되는 값:
//   stroke only            -> 0  (스트로크 지오메트리가 전혀 없음)
//   filled boxes, snap 4   -> 1  (간격 양쪽 모서리가 한 선을 공유)
//   filled boxes, snap 0   -> 0  (모든 셀이 1x1 섬으로 남음)
Writeln('stroke only:          ', CountRuledTables(Pdf, False, 4.0));
Writeln('filled boxes, snap 4: ', CountRuledTables(Pdf, True, 4.0));
Writeln('filled boxes, snap 0: ', CountRuledTables(Pdf, True, 0.0));

셋째 값이 둘째보다 낮게 나오면 그 페이지는 스냅에 의존하는 페이지이고, tolerance를 낮추기 전에 눈으로 확인할 대상입니다

form XObject 안의 규칙선

페이지 레이아웃 도구는 표나 페이지 본문 전체를 form XObject로 감싸 Do로 칠하는 일이 잦습니다. ISO 32000-1 §8.10.1은 form이 칠해질 때 form 행렬이 현재 변환 행렬과 연결된다고 규정하므로, form 안의 사각형은 form 공간에 적히고 두 번 이상의 변환을 거쳐야 페이지 공간에 도착합니다. 기본값인 IncludeFormXObjects가 설정되면 PDFium Component는 각 form 객체 안으로 걸어 들어가 객체 행렬을 부모의 행렬과 결합하고, 결합된 행렬을 모든 자식에게 내려 보냅니다. 번역된 form 안에서 72 700 468 0.5 re f로 그린 테두리가 뷰어가 보여 주는 자리에 착지하는 이유입니다. 이 순회는 여덟 겹의 중첩 form에서 멈춥니다

PDFium Component에서 Delphi의 form XObject 안 규칙선 구조: 72 700 468 0.5 re f로 작성된 얇은 사각형이 form 공간에 살고, form 행렬과 부모 변환 행렬이 올바른 순서로 결합된 뒤에야 페이지 위쪽에 착지합니다
사각형은 form 공간에서 작성되며, 이동 항을 제자리에 유지하는 순서로 행렬이 곱해진 뒤에야 페이지 위쪽에 도달합니다

그 결합의 순서가 중요합니다. form 안의 점은 먼저 객체 행렬을, 그다음 부모 행렬을 통과합니다. 피연산자를 바꾸면 이동 항이 잘못된 공간에서 적용되어, 페이지 위쪽에 있어야 할 규칙선이 원점 근처나 페이지 밖으로 갑니다. 같은 함정은 행렬을 직접 만들 때마다 나타나며, 행렬 prepend와 append 글이 회전 피벗으로 그 함정을 다룹니다

기본 규칙선 예산이 16384인 이유

기본 MaxRulingSegments 16384는 셀별 테두리 export가 만들어 내는 조각 수를 반영합니다. stroked로 그린 30행 6열 표는 선 segment 38개, 가로 31개와 세로 7개면 됩니다. 같은 표를 채워진 상자로 export하면 셀마다 테두리 조각 넷, 병합 전 720개가 되고, 모든 셀에 음영을 입히면 셀마다 모서리 규칙선 넷이 더해져 모두 1440개입니다. 그런 표 둘이 한 페이지에 있으면 이미 3000에 육박합니다

예산 초과는 오류이고 오류로 표면에 올라옵니다. ExtractTables는 "Table ruling-segment budget exceeded" 메시지의 EPdfError를 일으키고, 부분 그리드를 반환하지 않으며, 그 페이지의 whitespace pass도 돌지 않습니다. 조작된 페이지가 수백만 개의 작은 상자를 쥐고 있을 수 있는 신뢰할 수 없는 입력에는 빡빡한 예산이 합리적입니다. 그때 호출자는 "이 예산엔 너무 복잡함"과 "이 페이지에 표 없음"을 구분해야 합니다:

function TryExtractTables(Pdf: TPdf; Budget: Integer;
  out Tables: TPdfTables): Boolean;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.MaxRulingSegments := Budget;     // 기본값 16384
  try
    Tables := Pdf.ExtractTables(Options);
    Result := True;
  except
    on E: EPdfError do
    begin
      // LogWarning은 직접 작성한 로깅 루틴이지 라이브러리 일부가 아닙니다
      LogWarning(Format('Page %d: %s', [Pdf.PageNumber, E.Message]));
      Tables := nil;
      Result := False;                     // 예산 초과 또는 잘못된 옵션
    end;
  end;
end;

// False 결과는 "판단 보류"를 뜻하며 빈 배열과는 다릅니다
if not TryExtractTables(Pdf, 4096, Tables) then
  QueueForReview(Pdf.FileName, Pdf.PageNumber);  // 이것도 직접 작성한 루틴입니다

같은 예외 타입이 단어 예산과 잘못된 옵션도 커버하므로, 검토 대기열에서는 기록된 메시지가 이들을 구분하는 열쇠입니다

ruled 경로가 바통을 넘기는 지점

ruled 감지가 먼저, whitespace 감지가 두 번째로 돌고, 규칙 두 개가 서로를 밟지 않게 지켜 줍니다. ruled pass가 이미 보고한 그리드 안에 중심이 들어가는 단어들은 whitespace 감지가 시작되기 전에 제거되므로, 표가 두 번 보고되는 일은 없습니다. whitespace 열 경계는 또한 자신이 갈라 놓는 모든 행을 가로지르는 텍스트 없는 수직 통로여야 하며, 이 규칙이 양끝 정렬 단락이 표로 점수 받는 것을 막습니다. whitespace 표 오탐 글이 이 규칙을 다룹니다. 채워진 사각형 읽기는 오피스 export 표를 whitespace 경로에서 ruled 경로로 옮기는 장치입니다

규칙선은 path 객체에서 오고 텍스트는 텍스트 페이지에서 오므로, 텍스트 레이어가 없는 페이지도 모든 셀이 빈 그리드 골격을 내놓습니다. ruled 그리드의 confidence는 0.88에 텍스트를 지닌 셀 비율의 0.12배를 더한 값이라, 빈 골격은 정확히 0.88을 보고하고 첫 목록의 셀 카운트는 텍스트가 있는 셀 0개를 보여 줍니다. 그 조합이 OCR을 먼저 돌리라는 신호이며, OCR로 검색 가능한 텍스트 레이어 추가하기가 그 방법을 설명합니다. 곡선이나 비사각형 외곽선을 지닌 채워진 도형은 아무것도 기여하지 않으므로, 셀이 채워진 rounded rectangle로 그린 표는 whitespace 감지로 떨어집니다. 테두리도 음영도 없는 표는 표 감지와 추출 글이 설명하는 whitespace 전략의 영역이고, 그것조차 부족하면 구조화된 텍스트와 읽기 순서의 단어 상자와 블록이 한 문서 군집을 위해 쓰인 reader의 원재료가 됩니다

컴포넌트와 함께 배포되는 TableExtractionLab 데모에는 DetectFilledRulings 체크박스가 있어서, 코드 한 줄 없이 export를 읽어 들이고 그리드가 나타났다 사라지는 걸 볼 수 있습니다. 옵션, 레코드 타입, 추출 API의 나머지는 PDFium Component for Delphi 제품 페이지에 정리되어 있습니다