기술 문서

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

PDFium Component의 표 추출은 버전 3.117.0부터 얇은 채워진 사각형을 표 규칙선으로 취급합니다. 기본값으로 켜져 있는 DetectFilledRulings가 활성화되면, MaxRulingThickness(3포인트)보다 두껍지 않은 축 정렬 채워진 상자는 긴 축을 따라 규칙선 하나가 되고, 더 큰 채워진 상자는 네 모서리를 내놓으며, 모든 규칙선 좌표는 그리드가 조립되기 전에 RulingSnapTolerance(4포인트) 이내로 스냅됩니다. 그래서 Word와 Google Docs, 브라우저에서 내보낸 표가 조각으로 공백 감지에 떨어지는 대신 완전한 그리드로 격자 감지기에 도달합니다

표 감지와 추출에 관한 앞선 글은 격자 감지가 그려진 선을 사용하고 각 스트로크 경로 세그먼트가 페이지 좌표로 변환된다고 했습니다. 그 문장은 맞았지만 불완전했습니다. 실제 샘플 문서 13개 집합에서 경로 객체를 세어 보니 그중 9개에는 스트로크 경로가 전혀 없는데도 각 페이지에 두께 0.5~1포인트의 채워진 사각형이 수백 개 있었습니다. 스트로크 전용 감지기는 아무것도 보지 못했고, 모든 페이지가 공백 감지로 떨어졌으며, 출력은 표가 아니라 작은 조각의 흩어짐이었습니다. 3.116.4에서 추가된 compact-columns 프리셋이 조각 수준에서 그것을 누그러뜨렸지만, 근본 원인은 감지기가 잘못된 페인팅 연산자를 읽고 있던 것이었습니다

Word가 내보낸 표에 스트로크 선이 없는 이유

워드 프로세서는 테두리를 선으로 생각하지 않고 폭을 가진 상자로 생각하며, 그 상자를 채우기로 칠합니다. ISO 32000-1 §8.5.2.1은 re 연산자를 사각형 서브패스를 덧붙이는 것으로, §8.5.3은 페인팅 연산자를 나눕니다. S는 현재 선 폭으로 경로를 스트로크하고, f는 내부를 채웁니다. 0.5포인트 셀 테두리는 x y w 0.5 re f로 나오고, 선 폭과 이음, 대시 패턴을 포함한 스트로크 기계는 전혀 돌지 않습니다. 셀 음영도 더 큰 상자를 쓴 같은 구성입니다. m, l, S로 그린 스트로크 그리드가 원래 감지기가 기대한 것이고, 오피스 애플리케이션에서 내보낸 것 중 그것을 만들어 내는 것은 거의 없습니다

% 워드 프로세서 익스포트의 셀 테두리 하나: 높이 0.5pt의 채워진 상자
72 700 468 0.5 re f
% 셀 음영: 셀 크기의 채워진 상자
72 676 117 24 re f
% 원래 감지기가 겨냥해 작성된 스트로크 그리드 선
72 700 m 540 700 l S

FPDFPath_GetDrawMode에 스트로크 플래그가 설정되었는지만 묻는 감지기에게 두 채워진 상자는 모두 보이지 않습니다. 그러면 셀 안의 단어가 공백 감지에 도달하는데, 6포인트 간격으로 나뉜 열이 기본 MinColumnGap 12포인트 아래에 있고, 돌아오는 것은 MinRows를 통과할 만큼 충분히 정렬된 행의 일부뿐입니다. 그것이 조각 동작이며, 파라미터를 아무리 조정해도 작성자가 그린 그리드가 되지 않습니다

PDFium Component는 채워진 상자를 어떻게 규칙선으로 바꿀까

TableCollectObjectRulings는 모든 경로 객체를 서브패스 단위로 검사합니다. 그리기 모드는 FPDFPath_GetDrawMode에서 옵니다. DetectFilledRulings가 켜져 있고 채우기 모드가 none이 아니면 경로가 채워진 것으로 셉니다. 각 점은 객체 행렬을 거쳐 변환되어 수집되고, 서브패스마다 MaxSubpathPoints(8)까지 받으며, 곡선 세그먼트가 있으면 그 서브패스가 곡선으로 표시됩니다. 서브패스가 닫히거나 새 MoveTo가 시작되면 FlushSubpath가 그것이 무엇이었는지 판정합니다. 곡선 서브패스는 버려지고, 점들이 적어도 한 축에서 경계 상자 모서리 PointTolerance(0.05포인트) 이내에 모두 놓이지 않는 닫힌 다각형도 버려집니다. 삼각형이나 갈매기 모양, 둥근 탭은 결코 규칙선이 되지 않으며, 그래서 장식 그림이 그리드에 들어오지 않습니다

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

살아남는 것은 축 정렬 사각형이고 경계 상자로 분류됩니다. 폭이 MaxRulingThickness 이하이고 높이가 그보다 크면 가로 중앙에 세로 규칙선 하나가 상자를 아래에서 위까지 걸쳐 생기고, 반대 경우에는 가로 규칙선 하나가 생깁니다. 두 치수가 모두 임계값을 넘으면 음영 셀이고, 그 상자는 모서리마다 하나씩 네 개의 규칙선을 내놓습니다. 두 치수가 모두 임계값 이하이면 아무것도 내놓지 않으므로 2포인트 정사각형 글머리 기호가 선으로 오인되지 않습니다. 스트로크 경로는 AddLine을 거치는 예전 경로를 타서 축 정렬 세그먼트마다 규칙선 하나가 되므로, S로 그린 그리드는 예전과 똑같이 처리되고, 채우기와 스트로크를 모두로 칠한 경로는 병합 패스가 합쳐 버릴 겹치는 조각들을 만들어 냅니다

uses
  PDFium;

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

    Options := TPdfTableExtractionOptions.Default;
    // 3.117.0의 기본값이며 명시를 위해 나열
    Options.DetectFilledRulings := True;     // 얇은 채워진 상자가 규칙선이 됨
    Options.MaxRulingThickness := 3.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';
      Writeln(Format('%dx%d %s, confidence %.2f',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence]));
    end;
  finally
    Pdf.Free;
  end;
end;

음영 셀 표에 RulingSnapTolerance가 하는 일

RulingSnapTolerance가 음영만으로 만들어진 표를 하나의 그리드로 이어지게 합니다. 어떤 익스포트는 테두리를 전혀 그리지 않습니다. 모든 셀이 자기 색의 채워진 상자이고, 이웃 상자 사이는 1~3포인트의 흰 간격입니다. 각 상자는 네 모서리 규칙선을 내놓지만, 한 셀의 오른쪽 모서리와 다음 셀의 왼쪽 모서리는 2포인트 떨어져 있고, 연결성 검사는 기본값 1포인트인 RulingTolerance를 씁니다. 스냅이 없으면 모든 셀이 규칙선 네 개짜리 자기 연결 컴포넌트를 이루고, 어느 컴포넌트도 MinRows에 닿지 않아 페이지가 아무것도 보고하지 않습니다. TableSnapRulings는 등장하는 모든 X 좌표(각 세로 규칙선의 위치에 각 가로 규칙선의 시작과 끝을 더한 것)와 모든 Y 좌표를 모아, 각 목록을 정렬하고, 이웃과의 차이가 허용치 이하인 값을 연쇄로 묶어 클러스터링한 뒤, 각 클러스터를 평균으로 대체하고, 모든 위치와 시작, 끝을 가장 가까운 클러스터 중심으로 옮깁니다. 간격의 양쪽이 같은 선이 되어 연결성이 성립합니다

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

스냅은 TableMergeRulings 앞에서 실행되는데, 그 함수는 규칙선을 정렬하고 RulingTolerance 이내에서 닿거나 겹치는 공선 조각들을 이어 붙이며, 둘 다 TableDetectRuled가 데이터를 보기 전에 실행됩니다. 그래서 쌍별 연결성 검사가 셀별 조각 수가 아니라 그리드 선 수에 비례합니다. 스트로크 그리드에서는 이미 같았던 좌표가 자기 자신으로 스냅되므로 이 패스들이 무해합니다. 염두에 둘 것은 연쇄 클러스터링에 자체 폭 제한이 없다는 점입니다. 각각 3포인트씩 떨어진 좌표 연속은 하나의 중심으로 접힙니다. 기본값 4포인트에서는 글자 하나보다 좁은 열에만 영향을 주지만, 문서에 반드시 분리되어야 하는 실제 3포인트 간격이 있다면 허용치를 낮추거나 0으로 두어 스냅을 끄십시오

// 격자 전략만 분리해 한 페이지에서 각 설정이 보는 것을 비교
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;

// Word 익스포트는 보통 0, N, 그다음 N보다 적은 값을 보고함:
// 스트로크 전용은 아무것도 못 보고, 스냅이 음영 셀을 연결하며,
// 스냅을 끄면 음영 셀이 각자 섬으로 남음
Writeln(CountRuledTables(Pdf, False, 4.0));
Writeln(CountRuledTables(Pdf, True, 4.0));
Writeln(CountRuledTables(Pdf, True, 0.0));

form XObject 안의 규칙선

페이지 레이아웃 도구는 표나 페이지 본문 전체를 form XObject로 감싸 Do로 칠하는 일이 잦습니다. ISO 32000-1 §8.10.1은 form이 칠해질 때 form 행렬이 현재 변환 행렬과 결합된다고 규정하므로, form 안의 사각형은 form 공간에 살고 두 번 이상의 변환을 거쳐야 페이지에 놓입니다. TableCollectObjectRulings는 IncludeFormXObjects가 설정되면 form 객체로 재귀합니다. 객체 행렬을 읽어 TableMultiplyMatrix로 부모 행렬과 결합하는데, 이 함수의 인수 순서는 첫 번째 행렬로 매핑한 뒤 두 번째로 매핑한다는 뜻이며, FPDFFormObj_CountObjects와 FPDFFormObj_GetObject로 자식을 열거하며 결합된 행렬을 내려 보냅니다. MaxFormDepth(8)보다 깊은 중첩은 조용히 건너뛰는데, 이는 실제 익스포트가 접근할 한계가 아니라 병적인 파일에 대한 방어입니다. 곱셈 순서가 중요한 이유는 행렬 prepend와 append에서 다룬 것과 같습니다. 피연산자를 바꾸면 이동 항이 움직여서, 페이지 위쪽에 놓여야 할 규칙선이 원점에 놓입니다

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

규칙선 예산이 왜 네 배가 되었을까

기본 MaxRulingSegments가 3.117.0에서 4096에서 16384로 올랐습니다. 셀별 테두리가 스트로크 그리드 선보다 훨씬 많은 수로 들어오기 때문입니다. 스트로크로 그린 30행 6열 표는 선 세그먼트 38개입니다. 같은 표를 채워진 상자로 내보내면 셀마다 최대 네 개 테두리로 병합 전 720조각이고, 음영 셀이 있는 서식은 그 두 배입니다. 그런 표가 한 페이지에 둘이면 예전 예산이 바닥났을 것입니다. 예산은 TableAppendRuling에서 Check를 통해 강제되며, Table ruling-segment budget exceeded 메시지와 함께 EPdfError를 던집니다. 성능이 떨어진 결과도, 부분 그리드도 없고, 공백 패스도 실행되지 않습니다. 신뢰할 수 없는 입력을 위해 더 빡빡한 예산을 직접 설정한다면, 빈 결과를 표 없음으로 읽지 말고 예외를 잡아 판단하십시오

Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := 2048;        // 신뢰할 수 없는 입력을 위해 의도적으로 빡빡하게
try
  Tables := Pdf.ExtractTables(Options);
except
  on E: EPdfError do
  begin
    Log(E.Message);                       // 'Table ruling-segment budget exceeded'
    Options.MaxRulingSegments := 16384;   // 3.117.0 기본값
    Tables := Pdf.ExtractTables(Options);
  end;
end;

측정 결과와 접근이 멈추는 지점

같은 샘플 문서 13개에서 추출 결과가 표 43개 — 그중 격자 9개, 공백 조각이나 오탐 34개 — 에서 격자 표 41개, 공백 오탐 0개로 바뀌었습니다. 그 정리의 일부는 3.117.0의 동반 변경 두 가지 덕분입니다. 격자에 이미 귀속된 단어는 공백 감지가 실행되기 전에 제거되어 표가 두 번 보고되지 않고, 공백 열 경계는 이제 그것이 나누는 모든 행을 가로지르는 텍스트 없는 통로여야 하며, 그래서 양끝 정렬 문단이 5x4 표로 점수를 받지 않게 되었습니다. 채워진 사각형 리더가 표 자체를 조각 열에서 격자 열로 옮긴 것입니다

경계는 분명히 밝힐 가치가 있습니다. 텍스트 레이어가 없는 페이지는 셀마다 비어 있는 그리드 골격을 그대로 내놓습니다. 규칙선은 기하에서 오고 텍스트는 텍스트 페이지에서 오기 때문입니다. 스캔 페이지는 OCR이 먼저 필요합니다. 곡선이나 둥근 모서리, 비사각형 외곽선을 가진 채워진 도형은 완전히 버려지므로, 테두리를 둥근 사각형 외곽선으로 그린 표는 예전처럼 공백 감지가 필요합니다. 테두리도 음영도 없는 표는 이 모든 것에 영향받지 않고 표 추출 글에 설명된 공백 전략의 영역으로 남습니다. 그것조차 부족하면 구조화된 텍스트와 읽기 순서의 단어 박스와 블록이 도메인 특화 리더의 원재료입니다. 컴포넌트와 함께 배포되는 TableExtractionLab 데모가 옵션 패널에 DetectFilledRulings를 노출하므로, 주어진 익스포트가 그것을 켜고 끌 때 어떻게 보이는지 확인하는 가장 빠른 방법입니다. 전체 API는 PDFium Component for Delphi 페이지에 설명되어 있습니다