기술 문서

Delphi에서 PDF 페이지 경계를 넘는 표 연결 감지

PDFium Component 3.117.0은 페이지 경계를 넘어 끊긴 표를, 두 조각이 모두 페이지 가장자리에 닿거나 첫 조각 아래와 둘째 조각 위에 본문 텍스트가 없을 때 연결하며, 반복 머리글과 바닥글은 무시합니다. ExtractDocumentTables는 이 내용 기반 판정을 기존 페이지 여백 테스트의 대안으로 적용하고, 첫 행이 전체 폭을 차지하는 캡션 셀 하나인 다음 페이지 조각은 거부하며, 다음 페이지로 넘어간 한 행을 그 연결 체인의 일부로 유지합니다

표 감지와 추출 글은 연결을 네 개의 엄격한 게이트로 제시하고 그중 하나로 페이지 가장자리에 닿음을 다뤘습니다. 그 설명은 그것이 다룬 릴리스에는 정확했지만, 사람들이 실제로 컴포넌트에 넣는 대부분의 표에는 틀렸습니다. 이 글이 그 정정입니다. 여백 테스트가 감당하지 못하는 문서가 무엇인지, 무엇이 그것을 대체했는지, 그리고 그 수정이 함께 끌고 온 두 가지 부수 사례를 다룹니다

Word 익스포트에서 페이지 여백 테스트가 실패하는 이유

워드 프로세서는 종이 가장자리가 아니라 아래 여백에서 행 배치를 멈추기 때문입니다. 기본 ContinuationMargin이 36포인트일 때 원래 규칙은 앞 조각의 아래 모서리가 페이지 바닥에서 36포인트 이내에 있고 뒤 조각의 위 모서리가 페이지 상단에서 36포인트 이내에 있을 것을 요구했습니다. 기본 1인치 여백으로 Word에서 내보낸 문서는 마지막 행을 페이지 바닥에서 최소 72포인트 위에 두고, 바닥글이 있으면 더 위에 둡니다. 그래서 그 조건은 결코 성립하지 않았습니다. 그런 문서의 긴 표는 모두 ContinuationGroup이 0인 독립 조각으로 돌아왔고, 호출자는 다시 손으로 꿰매야 했습니다. 그 테스트는 그것이 겨냥한 대상 — 페이지를 고정된 콘텐츠 박스까지 채우고 다음 페이지를 맨 위에서 시작하는 레이아웃 엔진이 만든 보고서 — 에는 여전히 맞습니다. 나쁜 규칙이 아니라 불완전한 규칙이며, 그래서 버전 3.117.0이 그것을 교체하지 않고 유지한 채 두 번째 경로를 추가했습니다

내용 기반 판정은 대신 무엇을 확인할까

내용 기반 판정은 페이지 기하가 아니라 각 페이지의 단어 박스를 사용해 두 조각 사이 공간을 표 말고 다른 무언가가 차지하는지 확인합니다. ExtractDocumentTables는 문서를 훑으며 페이지마다 위쪽이 바닥글 밴드 위에 있는 단어들의 가장 낮은 아래 모서리와, 아래쪽이 머리글 밴드 아래에 있는 단어들의 가장 높은 위 모서리를 기록합니다. 두 밴드 모두 ContinuationMargin 포인트 깊이이므로, 같은 옵션이 이제 페이지 가장자리 여유와 반복 머리글 및 바닥글 영역의 높이라는 두 역할을 겸합니다. 앞 조각의 아래 모서리가 그 페이지의 가장 낮은 본문 텍스트와 같거나 아래에 있고 뒤 조각의 위 모서리가 다음 페이지의 가장 높은 본문 텍스트와 같거나 위에 있으면, 각각 AlignmentTolerance 이내에서, 한 쌍이 통과합니다. 쉬운 말로 하면 표가 N페이지의 마지막 내용이고 N+1페이지의 첫 내용이며, 여백 밴드에 있는 페이지 번호나 문서 제목은 세지 않는다는 뜻입니다. 그 제외는 임의적이지 않습니다. ISO 32000-1 §14.8.2.2는 반복 머리글과 바닥글을 쪽 번호 매김 아티팩트, 즉 페이지 나눔 때문에 존재하는 내용으로 분류하며, 태그된 리더가 그것을 건너뛰게 하는 바로 그 발상이 표가 그것을 지나 이어지게 합니다. marked-content 글에서 태그된 파일이 그런 아티팩트를 명시적으로 선언하는 방식을 다룹니다. 여기서는 대부분의 내보낸 표가 태그를 전혀 지니지 않으므로 위치에서 분류를 추론합니다

PDFium Component의 표 연결에 두 가지 테스트가 필요한 이유: Word의 1인치 여백에서는 페이지 여백 테스트가 레이아웃이 결코 도달하지 않는 36pt 창 안에 조각 모서리를 요구하지만, 내용 기반 테스트는 단어 박스를 비교해 표가 N페이지의 마지막 본문 내용이고 N+1페이지의 첫 내용일 때 연결하며 반복 머리글과 바닥글 밴드는 무시합니다
둘 중 하나가 게이트를 열고 그제서야 나머지 검사가 실행됩니다. 페이지가 인접한지, 뒤 조각에 전체 폭 캡션 행이 없는지, 열 경계가 AlignmentTolerance의 두 배 이내로 일치하는지입니다

두 테스트는 OR로 결합됩니다. 표가 종이 가장자리까지 이어지는 레이아웃 엔진 보고서는 첫 번째를 통과하고, 표가 여백에서 멈추는 Word 익스포트는 두 번째를 통과하며, 둘 다인 문서는 두 번 통과합니다. 둘 중 하나가 성공한 뒤에야 나머지 게이트가 실행되며, 고정된 순서를 따릅니다. 페이지 번호가 인접해야 하고, 뒤 조각이 캡션 행으로 시작하지 않아야 하며, 열 경계가 기본값에서 6포인트인 AlignmentTolerance의 두 배 이내로 일치해야 합니다. 열거형은 TPdfTableContinuation이고 값은 ptcNone, ptcStart, ptcMiddle, ptcEnd입니다. ptcEnd로 표시된 조각이 또 다른 페이지로 이어지면 ptcMiddle로 승격되므로, 세 페이지짜리 표는 페이지 순서대로 start, middle, end로 읽힙니다. 그룹 번호는 1부터 시작하고 0은 연결되지 않음을 뜻하며, ToJson은 같은 정보를 continuation과 continuationGroup 멤버로 내보냅니다. 이어 붙이는 일을 다운스트림 서비스가 한다면 그쪽 형태를 쓰는 편이 좋습니다

uses
  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;
    Options.DetectContinuations := True;     // 기본값이며 명시를 위해 표기
    Options.ContinuationMargin := 54;        // 두 줄 바닥글, 깊이 약 50pt

    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 rows)',
          [Tables[I].PageNumber, Tables[I].RowCount]));
      end;
  finally
    Pdf.Free;
  end;
end;

캡션 행이 두 표가 붙는 것을 어떻게 막을까

첫 행이 모든 열을 가로지르는 셀 하나인 다음 페이지 조각은 이전 표의 나머지가 아니라 새 표로 취급됩니다. 이 규칙이 있는 이유는 내용 기반 판정만으로는 너무 성급하게 연결하기 때문입니다. 그것을 드러낸 사례는 증명서 형식의 서식이었습니다. 1페이지 아래쪽 근처에서 표가 끝나고, 열 너비가 동일한 두 번째 표가 2페이지 위쪽 근처에서 시작하며, 둘 사이에는 바닥글 외에 아무것도 없고 열이 소수점까지 맞습니다. 여백 테스트에서는 둘 다 가장자리에 닿지 않아 만나지 않았지만, 내용 테스트에서는 즉시 연결되어 섹션이 있는 서식이 하나의 엉킨 그리드가 되었습니다. 둘을 가르는 것은 셀 구조에서 보입니다. 두 번째 표는 "RECIPIENT INFORMATION" 같은 섹션 캡션을 전체 폭에 걸친 병합 셀 하나로 배치하며 시작하고, 진짜 연결은 그렇게 하지 않습니다. 캡션은 이미 이전 페이지에서 시작한 표에 속하기 때문입니다. TableStartsWithCaptionRow가 바로 그것을 인코딩합니다. 조각에 열이 최소 두 개 있고 RowIndex = 0, ColumnIndex = 0, ColumnSpan = ColumnCount인 셀이 있으면 참입니다. 이 검사는 뒤 조각에만 실행되므로 자기 캡션 행이 첫 페이지에 있는 표는 영향을 받지 않습니다. 캡션은 N페이지에 있고, 검사되는 것은 N+1페이지 조각뿐입니다

PDFium Component의 캡션 행 게이트: 진짜 연결은 데이터 셀로 시작해 같은 ContinuationGroup에 합류하지만, 0번 행이 RowIndex 0, ColumnIndex 0, ColumnSpan이 ColumnCount인 병합 셀 하나를 담은 뒤 조각은 연결로 거부되고 새 표로 보고됩니다
검사는 뒤 조각에만 닿으므로 자기 캡션 행이 첫 페이지에 있는 표는 영향을 받지 않고, 게이트는 두 가장자리 테스트 중 하나가 이미 그 쌍을 연결한 뒤에 실행됩니다

뒤따르는 열 비교인 TablesHaveMatchingColumns는 열 개수가 같음보다 엄격합니다. 각 조각의 경계 위치를 셀 사각형에서 다시 만들고, 병합 셀이 가리는 경계를 보간하며, 어떤 경계라도 허용치를 넘어 어긋나면 그 쌍을 거부합니다. 그래서 비율이 다른 4열 표 두 개는 다른 모든 것이 맞아떨어져도 서로 떨어져 있습니다

다음 페이지로 넘어간 한 행은 어떻게 될까

한 행을 다음 페이지로 넘기는 격자 표는 이제 감지되어 연결됩니다. 단, 연결 체인에 들어가는 경우이고, 홀로 있으면 버려집니다. 기본 MinRows 2는 길 잃은 선 두 개가 표로 보고되지 않게 하려는 것이지만, 나눔 때문에 밀려난 마지막 행은 진짜 행이고 2라는 단단한 하한이 그것을 조용히 버렸으며, 표의 나머지는 완전해 보였지만 사실 완전하지 않았습니다. 문서 수준 스캔은 세 단계로 처리합니다. DetectContinuations와 DetectRuledTables가 모두 설정되면 페이지별 패스가 행 하한을 잠시 1로 낮춰 격자 감지기를 돌립니다. 그래서 ExtractTables는 격자 표에 대해 MinRows 1을 받아들이고, 공백 감지는 내부 하한 2를 유지합니다. 연결 표시는 전체 결과에 대해 이루어집니다. 그다음 호출자의 MinRows보다 짧으면서 어느 체인에도 속하지 않는 모든 표가 제거됩니다. 한 행짜리 조각이 살아남는 이유는 연결되었기 때문이며, 평범한 페이지 한가운데의 한 행짜리 격자는 예전과 똑같이 걸러집니다

PDFium Component가 페이지 나눔을 넘어간 격자 행을 유지하는 방법: DetectContinuations와 DetectRuledTables가 설정되면 페이지별 격자 패스가 행 하한 1로 실행되고, 연결 표시가 전체 결과에 이루어지며, MinRows보다 짧으면서 모든 체인 밖에 있는 조각만 제거됩니다
넘어간 행은 그 체인이 연결해 주기 때문에 살아남고, 평범한 페이지의 독립된 한 행짜리 격자는 예전과 똑같이 걸러지며, 공백으로 감지한 표는 그런 구제 없이 두 행 하한을 유지합니다
// 각 체인을 하나의 CSV로 다시 만들고 반복된 머리글 행은 버림
// 연결 조각에서
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R: Integer;
  Lines: TStringList;
  Csv: TStringList;
begin
  Csv := TStringList.Create;
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      if Tables[I].Continuation in [ptcNone, ptcStart] then
        Csv.Clear;
      Lines.Text := string(Tables[I].ToCsv);
      if (Tables[I].Continuation in [ptcMiddle, ptcEnd]) and
         (Lines.Count > 1) and (Tables[I].RowCount > 1) then
        Lines.Delete(0);            // 워드 프로세서가 반복한 머리글
      for R := 0 to Lines.Count - 1 do
        Csv.Add(Lines[R]);
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Csv.SaveToFile(Format('%s\page%d-group%d.csv',
          [Folder, Tables[I].PageNumber, Tables[I].ContinuationGroup]));
    end;
  finally
    Lines.Free;
    Csv.Free;
  end;
end;

그 루틴에서 의도적인 세부가 두 가지입니다. 한 행짜리 넘침은 결코 잘리지 않습니다. RowCount 가드가 그것을 지키기 때문입니다. 그리고 각 페이지에서 머리글 행을 반복하는 워드 프로세서는 첫 줄이 다시 머리글인 조각을 만들므로, middle과 end 조각에서 0번 줄을 버리는 것은 그 경우에는 맞고 머리글을 반복하지 않는 생성기에는 틀립니다. 폴더 전체에 루틴을 돌리기 전에 문서 하나로 확인하십시오

규칙이 여전히 멈추는 지점

내용 기반 판정은 그것이 읽는 텍스트 레이어만큼만 정확합니다. 텍스트가 전혀 없는 스캔 페이지에서는 기록된 본문 텍스트 극값이 페이지 경계로 폴백하고, 사이에 아무것도 없음 조건이 공허하게 성립하며, 캡션 행과 열 게이트만 남습니다. 그런 페이지의 격자 표도 빈 골격으로는 여전히 찾아지므로 체인은 올바르게 연결될 수 있지만, 주변 텍스트에 대해서는 실제로 검증된 것이 없습니다. 그것이 중요하면 텍스트 레이어를 먼저 추가하십시오. 텍스트가 아니라 이미지로 렌더링된 바닥글은 밴드 로직에 보이지 않으며 같은 이유로 무해합니다

밴드는 숫자 하나입니다. ContinuationMargin보다 깊은 바닥글은 아래쪽 줄들을 본문 영역 안에 남겨 앞 조각이 텍스트로 이어지는 것처럼 보이게 하고 연결을 막습니다. 첫 예제처럼 옵션을 실제 밴드 깊이로 올리십시오. 너무 많이 올리면 페이지 아래쪽의 짧은 맺음 문단이 밴드로 미끄러져 무시되고, 표가 그 뒤에 오는 무엇이든 연결됩니다. 캡션 규칙에는 거울상 실패도 있습니다. 모든 연결 조각의 첫 행에 병합된 continued 배너를 쓰는 생성기는 그 조각들이 새 표로 거부되게 만들며, 오늘로서 유일한 대처는 아무것도 풀지 않은 채 직접 ContinuationGroup으로 꿰매는 것입니다. 규칙에 스위치가 없기 때문입니다

공백으로 감지한 표에는 한 행짜리 구제가 전혀 적용되지 않습니다. 공백 전략은 정렬된 두 행이 있어야 표를 보므로, 격자 없는 표가 한 행을 넘기면 그 행만큼 여전히 짧게 보고됩니다. 그런 상황을 만나면 구조화된 텍스트 블록과 읽기 순서 뒤의 단어 박스가 그것을 복구할 원시 위치를 줍니다. 이 작업을 이끈 샘플 집합인 워드 프로세서와 브라우저 익스포트 열세 개에서, 진짜 여러 페이지 표가 있는 다섯 문서가 모두 단일 체인으로 연결되었고 이전에 붙어 버렸던 증명서 서식은 떨어져 있었습니다. 이것이 이 릴리스를 재는 기준이지 모든 레이아웃에 대한 약속이 아닙니다

연결 표시와 캡션 규칙, 한 행 패스는 모두 Delphi와 C++Builder, Lazarus 빌드가 공유하는 문서 수준 경로에 있습니다. 전체 표 추출 API는 PDFium Component for Delphi 페이지에 설명되어 있습니다