Word 익스포트에서 세 페이지에 걸친 표는 종이 바닥에서 72포인트보다 가까워지지 않습니다. 1인치 여백이 72포인트이고 워드 프로세서는 여백에서 행 배치를 멈추기 때문입니다. 조각들이 페이지 가장자리 36포인트 안에 있을 때만 이어 붙이는 연결기의 눈에는 열이 같은 무관한 표 세 개로 보입니다. 효과를 내는 질문은 다른 것입니다. 한 조각의 끝과 다음 조각의 시작 사이에 반복 머리글이나 바닥글 말고 다른 무언가가 놓여 있는가?
이 질문은 자체 함정 두 개를 데려옵니다. 양식이 1페이지 아래에서 한 표를 끝내고 2페이지 위에서 같은 열 폭을 가진 다른 표를 열 수 있습니다. 그리고 격자 표가 한 행을 나눔 너머로 밀어 넣을 수도 있는데, 두 행이 있어야 표로 인정하는 감지기에게는 너무 짧습니다
ExtractDocumentTables의 두 가지 가장자리 테스트
PDFium Component는 표 감지와 추출 글에서 다룬 페이지별 ExtractTables의 문서 수준 대응물인 TPdf.ExtractDocumentTables에서 연결을 표시합니다. TPdfTableExtractionOptions.Default에 그렇게 되어 있듯 DetectContinuations가 켜져 있으면 이 메서드는 모든 페이지를 추출하고, 표를 페이지 번호순, 그다음 각 페이지 안에서는 위에서 아래로 정렬한 뒤 그 목록의 이웃 쌍 하나하나를 검사합니다. 비교는 이웃 사이에서만 이루어집니다. 따라서 연결 후보는 언제나 N페이지의 가장 아래 표와 N+1페이지의 가장 위 표의 짝이고, N페이지의 긴 표 아래에 놓인 작은 표는 그 짝에서 긴 표의 자리를 차지합니다. 짧은 비고 표가 긴 표 아래에 있을 때는 페이지 번호와 ColumnCount로 여러분 코드에서 직접 그 조각들을 연결하세요
첫 번째 테스트는 순수하게 기하학적입니다. 앞 조각의 아래 모서리는 페이지 바닥에서 ContinuationMargin포인트 이내여야 하고, 뒤 조각의 위 모서리는 다음 페이지 상단에서 같은 거리 이내여야 합니다. 기본값은 36포인트, 반 인치입니다. 고정 콘텐츠 박스를 채우는 레이아웃 엔진의 보고서는 이 테스트를 쉽게 통과합니다. 1인치 여백의 Word 익스포트는 양쪽에서 최소 36포인트씩 벗어나고, 통과할 때까지 여백을 넓히는 접근도 잘못입니다. 같은 숫자가 두 번째 테스트의 머리글·바닥글 밴드도 정하기 때문입니다
워드 프로세서 출력을 처리하는 것은 두 번째 테스트, 내용 기반 테스트입니다. 둘은 OR로 결합합니다. 하나만 열어도 게이트가 열립니다
두 조각 사이에서 무엇이 텍스트로 치나?
페이지의 모든 단어는 반복 머리글 밴드나 바닥글 밴드 안에 완전히 들어 있지 않는 한 본문 텍스트로 치고, 두 밴드 모두 ContinuationMargin포인트 깊이입니다. ExtractDocumentTables는 페이지마다 위가 바닥글 밴드 위에 있는 단어들 가운데 가장 낮은 아래 모서리와, 아래가 머리글 밴드 아래에 있는 단어들 가운데 가장 높은 위 모서리를 기록하는데, 표 자신의 셀 텍스트도 여기에 포함됩니다. 표가 N페이지의 마지막 본문 내용이면 그 아래 모서리는 가장 낮은 단어와 같거나 그보다 아래에 있고, 기본값이 3포인트인 AlignmentTolerance 이내에서 테스트가 통과합니다. 뒤 조각은 N+1페이지 상단에서 거울 조건을 만족해야 합니다
밴드를 무시하는 데는 규격상 근거가 있습니다. ISO 32000-1 §14.8.2.2는 반복 머리글, 바닥글, 페이지 번호를 쪽 매김 아티팩트, 즉 문서를 페이지로 잘랐기 때문에 존재하는 내용으로 다룹니다. marked-content 글에서 설명하듯 태그된 파일은 이들을 /Artifact로 선언할 수 있지만, 표 이어 붙이기가 가장 절실히 필요한 익스포트에는 태그가 드물어 위치가 태그를 대신합니다. 기본 밴드에서는 글리프 상단이 아래 모서리에서 30포인트 위에서 끝나는 페이지 번호가 무시됩니다. 40포인트까지 내려오는 면책 문구 줄은 본문 텍스트이고 연결을 막습니다
텍스트 레이어가 없는 페이지에서는 기록된 극값이 페이지 경계로 폴백하고, 내용 기반 테스트는 자동으로 통과하며, 남은 게이트들이 판가름합니다. 이미지나 벡터 윤곽으로 그린 바닥글도 같은 이유로 보이지 않으므로 연결을 막는 일이 없습니다
가장자리 테스트를 통과하는 것은 게이트를 열 뿐입니다. 페이지가 연속이어야 하고, 뒤 조각이 캡션 행으로 시작하지 않아야 하며, 열 경계가 기본값 기준 6포인트인 AlignmentTolerance의 두 배 이내에서 맞아야 합니다. 이 모든 조건이 성립하면 앞 조각은 새 그룹 번호를 얻어 ptcStart가 되거나 이미 체인을 닫았다면 ptcEnd에서 ptcMiddle로 옮겨가고, 뒤 조각은 같은 그룹의 ptcEnd가 됩니다. 세 페이지에 걸친 표는 start, middle, end로 읽힙니다. 그룹 번호는 1부터 시작하고, ptcNone인 그룹 0은 표가 홀로 섰다는 뜻입니다. ToJson은 같은 데이터를 "continuationGroup"과 "continuation"(none, start, middle 또는 end)로 기록하므로 JSON을 받는 서비스가 자체적으로 이어 붙일 수 있습니다
아래 목록은 두 줄짜리 바닥글이 대략 50포인트 깊이인 Word 익스포트를 가정하므로, 두 줄 모두 밴드 안에 들어오도록 여백을 54로 올립니다
uses
System.SysUtils, PDFium;
var
Pdf: TPdf;
Options: TPdfTableExtractionOptions;
Tables: TPdfTables;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'itinerary-from-word.pdf';
Pdf.LoadDocument;
Options := TPdfTableExtractionOptions.Default; // DetectContinuations = True
Options.ContinuationMargin := 54; // 바닥글을 약 50 pt 깊이로 가정
Tables := Pdf.ExtractDocumentTables(Options);
for I := 0 to High(Tables) do
case Tables[I].Continuation of
ptcStart:
Writeln(Format('group %d starts on page %d, %d rows',
[Tables[I].ContinuationGroup, Tables[I].PageNumber,
Tables[I].RowCount]));
ptcMiddle, ptcEnd:
Writeln(Format('group %d continues on page %d, %d rows',
[Tables[I].ContinuationGroup, Tables[I].PageNumber,
Tables[I].RowCount]));
else
Writeln(Format('standalone table on page %d, %d x %d',
[Tables[I].PageNumber, Tables[I].RowCount,
Tables[I].ColumnCount]));
end;
finally
Pdf.Free;
end;
end.
전체 폭 첫 행이 두 표를 갈라 놓는 방법
첫 행이 모든 열을 가로지르는 셀 하나인 뒤 조각은 가장자리 테스트가 통과하고 열이 일치하더라도 새 표로 보고됩니다. 양식이 바로 내용 기반 테스트가 과하게 나아갈 수 있는 곳입니다. 한 표가 1페이지 아래쪽에서 끝나고 다음 절이 2페이지에서 같은 열 폭으로 열리면 모든 기하 검사가 통과합니다. 차이는 두 번째 표의 여는 행입니다. RECIPIENT INFORMATION 같은 절 캡션이 전체 폭에 걸친 병합 셀 하나로 놓여 있습니다. 진짜 연결은 그렇게 열지 않습니다. 표의 캡션은 표가 시작하는 페이지에 머물기 때문입니다
이 규칙은 뒤 조각의 셀만 읽습니다. 그 조각에는 최소 두 개의 열과 RowIndex = 0, ColumnIndex = 0, ColumnSpan = ColumnCount인 셀이 필요합니다. 앞 조각은 결코 검사하지 않으므로 자기 캡션이 N페이지 상단에 있는 표는 N+1페이지의 연결과 평소처럼 이어집니다
뒤따르는 열 비교는 열 개수 일치보다 엄격합니다. 각 조각의 경계 위치를 셀 사각형에서 다시 만들되, 병합 셀 아래 가려진 경계는 양쪽의 가장 가까운 알려진 경계 사이를 균등하게 나눠 채웁니다. 그다음 모든 경계 쌍을 비교하고 6포인트를 넘는 차이는 연결을 거부합니다. 비율이 다른 두 4열 표는 갈라진 채로 유지됩니다. 생성기가 페이지 사이에서 한 열을 8포인트 옮긴 진짜 연결도 마찬가지입니다. 이를 받아들이려고 AlignmentTolerance를 올리면 감지에 쓰이는 클러스터링도 느슨해집니다
페이지 나눔을 넘는 격자 한 행
한 행을 다음 페이지로 민 격자 표는 체인으로 이어질 때는 그 행을 유지하고, 그렇지 못할 때는 잃습니다. 기본 MinRows 2는 길 잃은 두 선이 표로 보고되는 것을 막습니다. 나눔 너머로 밀려간 행은 다릅니다. 진짜 데이터이며 버려지면 이어 붙인 표가 한 행 모자라게 되는데 그 간극을 설명해 줄 조각도 남지 않습니다
문서 스캔은 이것을 세 단계로 처리합니다. DetectContinuations와 DetectRuledTables가 모두 켜져 있으면 격자 감지기가 행 하한을 1로 낮춘 채 모든 페이지에서 돕니다. 그다음 연결 표시가 전체 결과 위에서 이루어집니다. 마지막으로 체인에 들어가지 못했고 호출자의 MinRows보다 행이 적은 모든 표가 제거됩니다. 평범한 페이지 한가운데의 한 행 격자는 이 필터를 지나며 사라지는데, 이것이 바로 MinRows의 존재 이유입니다
MinRows는 2로 두세요. 직접 1로 바꾸면 마지막 필터가 꺼져 모든 페이지의 한 행 격자가 전부 돌아옵니다
이 예외는 격자 표에만 적용됩니다. 공백 전략은 표를 알아보는 데 정렬된 두 행이 필요하므로 MinRows가 무엇을 말하든 내부 하한 2를 유지하고, ExtractTables는 MinRows = 1을 받아들이지만 격자 감지만이 이를 존중합니다. 한 행을 넘긴 비격자 표에는 페이지의 단어 박스나 구조화 텍스트 블록과 읽기 순서에서 설명하는 블록이 그 행을 직접 복원할 위치를 줍니다
체인을 하나의 표로 되돌려 꿰기
체인은 연속된 채로 돌아옵니다. 링크는 정렬된 배열에서 이웃 사이에만 만들어지므로 ptcStart 조각 바로 뒤에 그 ptcMiddle과 ptcEnd 조각이 따라오고, ptcNone이나 ptcStart에서 리셋하고 ptcNone이나 ptcEnd에서 기록하는 루프는 그룹 번호로 무언가를 찾아볼 필요가 전혀 없습니다
반복 머리글 행이 까다로운 부분입니다. Word는 모든 페이지에서 머리글을 반복할 수 있고 다른 생성기는 그러지 않습니다. TPdfTableCell의 IsHeader 플래그로는 판단할 수 없습니다. 위치, 즉 RowIndex < HeaderRowCount로 조각마다 따로 매겨지기 때문입니다. 연결의 0번 행은 머리글을 반복하든 데이터를 싣든 머리글로 표시되고, 앞 절의 한 행 넘침도 표시됩니다. 아래 루틴은 각 연결의 첫 행 텍스트를 체인의 첫 행과 비교해 정확히 일치할 때만 버립니다
또한 ToCsv 출력을 줄로 나누는 대신 Cells에서 행을 만듭니다. 셀 안에서 텍스트가 줄바꿈되는 셀은 보이는 줄 사이에 CR LF를 실어 돌아오고, ToCsv는 그 나눔을 따옴표 필드 안에 유지하므로 CSV 텍스트의 첫 줄을 지우면 두 줄 머리글 셀이 반토막 납니다
// 읽기 측 도우미(라이브러리의 일부 아님): 각 체인을 탭으로 구분된
// 하나의 파일로 다시 만들고 뒤 페이지에서 반복된 머리글은 버림
function RowText(const T: TPdfTable; Row: Integer): string;
var
Parts: array of string;
I: Integer;
begin
SetLength(Parts, T.ColumnCount);
for I := 0 to High(T.Cells) do
if (T.Cells[I].RowIndex = Row) and (T.Cells[I].ColumnIndex >= 0) and
(T.Cells[I].ColumnIndex < T.ColumnCount) then
Parts[T.Cells[I].ColumnIndex] :=
StringReplace(string(T.Cells[I].Text), #13#10, ' ', [rfReplaceAll]);
Result := '';
for I := 0 to High(Parts) do
begin
if I > 0 then
Result := Result + #9;
Result := Result + Parts[I];
end;
end;
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
I, R, FirstRow: Integer;
Header: string;
Lines: TStringList;
begin
Lines := TStringList.Create;
try
for I := 0 to High(Tables) do
begin
FirstRow := 0;
if Tables[I].Continuation in [ptcNone, ptcStart] then
begin
Lines.Clear;
Header := RowText(Tables[I], 0);
end
else if (Trim(Header) <> '') and (RowText(Tables[I], 0) = Header) then
FirstRow := 1; // 이 페이지에서 반복된 머리글
for R := FirstRow to Tables[I].RowCount - 1 do
Lines.Add(RowText(Tables[I], R));
if Tables[I].Continuation in [ptcNone, ptcEnd] then
Lines.SaveToFile(Format('%s\table%d-page%d.tsv',
[Folder, I, Tables[I].PageNumber]));
end;
finally
Lines.Free;
end;
end;
정확 비교가 옳은 선택입니다. 워드 프로세서가 반복한 머리글은 문자 단위로 일치하지만, 퍼지 비교는 머리글과 닮은 데이터 행을 언젠가 삼켜 버립니다. 출력에서 길 잃은 추가 머리글은 쉽게 발견되지만 빠진 데이터 행은 그렇지 않습니다
추측 대신 바닥글 밴드 측정하기
ContinuationMargin는 숫자 하나로 세 가지 일을 합니다. 첫 테스트의 페이지 가장자리 여유, 두 번째 테스트의 바닥글 밴드 깊이와 머리글 밴드 깊이입니다. 밴드보다 깊은 바닥글은 윗줄을 본문 영역에 남기고, 앞 조각이 텍스트로 이어지는 것처럼 보이며, 체인은 그 페이지에서 끊어집니다. 너무 깊게 잡은 밴드는 진짜 내용을 삼키기 시작합니다. 아래 여백 바로 위의 짧은 마무리 줄이 밴드 안으로 떨어지고, 그 줄 위의 표는 다음 페이지를 여는 무엇이든과 이어질 수 있습니다
문서 자체가 이 숫자를 줄 수 있습니다. TPdf.PageWordBoxes는 현재 페이지의 모든 단어를 Top이 Bottom보다 큰 페이지 공간 Rect로 돌려주므로, 본문 여백 아래에 놓인 단어들 가운데 가장 높은 Top이 바닥글 깊이입니다. 아래 코드는 레이아웃의 아래 여백 바로 아래 값, 1인치 페이지 기준 70을 취하고, 바닥글 없는 문서를 위해 36포인트를 하한으로 유지합니다
// 읽기 측 도우미(라이브러리의 일부 아님): 어떤 페이지에서든
// BodyMargin 아래서 발견되는 가장 높은 단어 상단, 즉 바닥글 영역의 깊이
function MeasureFooterDepth(Pdf: TPdf; BodyMargin: Double): Double;
var
P, I: Integer;
Words: TPdfWordBoxes;
begin
Result := 0;
for P := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := P;
Words := Pdf.PageWordBoxes;
for I := 0 to High(Words) do
if (Words[I].Rect.Bottom < BodyMargin) and
(Words[I].Rect.Top > Result) then
Result := Words[I].Rect.Top;
end;
end;
// Max에는 System.Math 필요
Options := TPdfTableExtractionOptions.Default;
Options.ContinuationMargin := Max(36.0, MeasureFooterDepth(Pdf, 70) + 2);
Tables := Pdf.ExtractDocumentTables(Options);
밴드 깊이는 양쪽 가장자리에서 같으므로 깊은 반복 머리글도 페이지 상단에 대해 재고 더 큰 값을 쓰세요. 여유는 두어 포인트 안으로 유지합니다. 포인트 하나 추가는 내용 기반 테스트가 더 못 보게 되는 본문 텍스트 한 포인트입니다
한 레이아웃은 구조상 캡션 규칙을 무력화합니다. 모든 연결 조각의 첫 행으로 병합된 "continued" 배너를 쓰는 생성기입니다. 그런 조각은 각각 새 표로 보고되므로 연속된 페이지 번호, 같은 ColumnCount, 0번 행의 배너 텍스트로 여러분 코드에서 직접 연결하세요
연결 표시와 캡션 규칙, 한 행 예외는 모두 ExtractDocumentTables 안에서 돌고, 이 경로는 Delphi와 C++Builder, Lazarus 빌드에서 똑같이 동작합니다. 표 추출 옵션 전체 목록은 Delphi용 PDFium Component 페이지에 있습니다