기술 문서

Delphi에서 PDF 콘텐츠를 반응형 HTML로 리플로우하기

PDFium Component는 BuildReflowDocument를 사용해 고정 레이아웃 PDF를 리플로우 가능한 시맨틱 모델로 바꾸고, 그 모델을 ToHtml로 독립형 HTML로 내보냅니다. 제목은 제목으로, 목록 항목은 목록 항목으로 남고, 페이지에서 감지된 표는 헤더 셀과 스팬이 보존된 실제 표 마크업으로 나옵니다. 결과물의 어디에도 외부 스크립트나 스타일시트를 참조하지 않습니다

이 기능을 원하는 이유는 PDF 페이지가 위치가 고정된 글리프 집합이며, 이는 휴대폰 화면, 스크린 리더, 검색 색인에는 정확히 맞지 않는 형태이기 때문입니다. 일반 텍스트만 추출해 이를 해결하려는 시도는 모두 문서를 읽을 수 있게 만들었던 구조를 잃어버리고, 페이지를 이미지로 변환해 해결하려는 시도는 모두 텍스트 자체를 완전히 잃어버립니다. 리플로우 모델은 둘 다를 유지합니다. 단어와 그 단어들 사이의 관계 모두를 말입니다

시맨틱 정보는 어디에서 올까?

모든 것은 이 컴포넌트에서 텍스트와 시맨틱의 유일한 원천인 GetStructuredText에서 시작됩니다. PDF가 ISO 32000-1 14.7절에서 정의하는 태그 PDF처럼 구조 트리를 담고 있으면, 모델은 생성기가 기록해 둔 논리적 계층을 따릅니다. 그렇지 않을 때, 그리고 실제로 대부분의 PDF는 그렇지 않은데, 모델은 읽기 순서를 위해 이미 계산해 둔 물리적 레이아웃 순서로 후퇴합니다

이 선택은 하나의 확고한 경계를 지켜줍니다. 기존 파서가 이미 답할 수 있는 질문에 답하려고 두 번째 PDF 파서나 두 번째 렌더링 엔진을 도입하지 않습니다. 그 아래에 있는 읽기 순서 메커니즘은 구조화된 텍스트 블록과 읽기 순서에서 설명하며, 리플로우 모델은 그 위에 얹힌 시맨틱 레이어이지 그것을 대체하는 것이 아닙니다

각 노드는 자신의 정보가 어디서 왔는지 기록하므로, 소비자는 문서가 선언한 제목과 레이아웃 휴리스틱이 추론한 제목을 구별할 수 있습니다. 신뢰도에 민감한 파이프라인은 모든 노드를 똑같이 권위 있는 것으로 취급하는 대신 이 필드를 읽어야 합니다

평평한 트리, 그리고 왜 객체 트리가 아닐까

이 모델은 전위 순회로 펼쳐진 평평한 트리입니다. 재귀적인 레코드나 소유권을 가진 객체 그래프 대신, 각 노드가 ParentIndexDepth를 가진 배열 하나입니다. 페이지, 제목, 단락, 목록, 목록 항목, 그림, 캡션, 표, 행, 셀 모두가 그 하나의 선형 배열 안에 살고 있습니다

여기서 두 가지 이점이 따라옵니다. 소비자는 재귀 없이 순서대로 배열을 스트리밍할 수 있으므로, HTML이나 Markdown, 트리 뷰를 만드는 작업이 단순한 반복문이 됩니다. 그리고 이 레이아웃은 재귀적인 관리 타입을 ABI 경계 너머로 다루는 방식이 서로 다른 Delphi, C++Builder, Free Pascal 사이에서도 이식성을 유지합니다. 동적 배열의 재귀적 레코드는 어디서나 컴파일되지만 각자 미묘하게 다르게 동작하는 바로 그런 구성 요소입니다

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfReflowOptions;
  Doc: TPdfReflowDocument;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.LoadDocument;

    Options := TPdfReflowOptions.Default;
    Options.FullDocument := True;
    Options.DetectTables := True;
    Options.IncludeCss := True;          // inline style block, no external file
    Options.MaxNodes := 200000;          // fail-closed budget
    Options.MaxCharacters := 4000000;

    Doc := Pdf.BuildReflowDocument(Options);

    for I := 0 to High(Doc.Nodes) do
      case Doc.Nodes[I].Kind of
        prnkHeading:
          Writeln(Format('%sH%d: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
            Doc.Nodes[I].HeadingLevel, Doc.Nodes[I].Text]));
        prnkParagraph:
          Writeln(Format('%sp: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
            Copy(Doc.Nodes[I].Text, 1, 60)]));
        prnkTable:
          Writeln(Format('table on page %d', [Doc.Nodes[I].PageNumber]));
      end;

    Writeln(Format('%d node(s), %d table(s), %d character(s)',
      [Length(Doc.Nodes), Doc.TableCount, Doc.CharacterCount]));
  finally
    Pdf.Free;
  end;
end;

표는 어떻게 두 번 나오지 않게 막을까?

표 감지는 페이지의 구조화된 텍스트가 이미 수집된 뒤에 실행되는데, 이는 뻔한 위험을 만듭니다. 같은 셀 콘텐츠가 텍스트 블록과 감지된 표 양쪽에 존재하는 것입니다. 둘 다 내보내면 모든 표 뒤에 그 내용이 별도의 단락으로 다시 이어지는 HTML이 만들어집니다

이를 해결하는 규칙은 기하학적입니다. 감지된 표가 텍스트 블록 면적의 절반 이상을 차지하면, 표 노드는 그 블록과 합류하는 대신 대체합니다. 행 안의 셀 색인은 버킷에 세어 담는 방식으로 만들어지므로, 모델을 구축하는 비용은 각 행마다 모든 셀을 다시 훑는 대신 셀과 행의 개수에 비례한 선형 비용으로 유지됩니다. 한 페이지에 수백 개의 셀이 있을 수 있는 재무 문서에서는 이 점이 중요합니다

감지된 구조는 자신이 감지 결과임을 솔직하게 드러냅니다. 눈금선이 있는 표는 순전히 여백만으로 정렬된 표보다 더 신뢰성 있게 인식되며, 노드의 신뢰도가 이를 반영합니다. 틀린 표라도 표가 없는 것보다 나은 콘텐츠라면 감지를 켜 두고, 틀린 표가 더 나쁜 아카이브 변환이라면 신뢰도로 걸러내십시오

독립형을 유지하는 HTML 내보내기

ToHtml은 이미 만들어진 모델을 순회할 뿐 PDFium을 다시 방문하지 않으므로, 두 번 내보내도 추가 비용이 없고 같은 모델에서 다른 결과가 나올 수도 없습니다. 텍스트와 속성 값은 일관되게 이스케이프되고, 제목 레벨은 HTML이 실제로 정의하는 h1부터 h6 범위로 잘리며, 헤더 셀과 RowSpan, ColumnSpan은 쓰인 그대로 통과합니다

선택적인 CSS는 순수한 인라인 스타일 블록입니다. 어떤 종류의 스크립트도, 웹 폰트도, 외부 리소스도 없으며, 이것이 이 결과물을 이메일, 도움말 뷰어, 샌드박스 처리된 브라우저 컨트롤에 안전하게 임베드할 수 있게 해 줍니다:

var
  Html: WideString;
  Stream: TFileStream;
  Bytes: TBytes;
begin
  Options := TPdfReflowOptions.Default;
  Options.FullDocument := True;
  Options.IncludeCss := True;
  Options.IncludePageSections := True;   // keep page boundaries visible
  Options.PreserveLineBreaks := False;   // let the browser wrap paragraphs

  Html := Pdf.BuildReflowDocument(Options).ToHtml;

  Bytes := TEncoding.UTF8.GetBytes(string(Html));
  Stream := TFileStream.Create('report.html', fmCreate);
  try
    if Length(Bytes) > 0 then
      Stream.WriteBuffer(Bytes[0], Length(Bytes));
  finally
    Stream.Free;
  end;
end;

PreserveLineBreaks는 가장 곰곰이 생각해 볼 가치가 있는 옵션입니다. PDF의 줄바꿈은 고정된 페이지 너비를 위해 내려진 조판 결정이므로, 좁은 화면에서 이를 그대로 유지하면 리플로우가 애초에 해결하려던 바로 그 문제가 재현됩니다. 시, 코드 목록, 주소에는 줄바꿈을 보존하고, 산문에는 버리십시오

예산, 취소, 페이지 상태

문자, 노드, 표, 셀에는 각자 상한이 있으며, 각각은 할당 이후가 아니라 이전에 확인되므로 손상되었거나 악의적인 문서도 다른 무언가를 소진시키는 대신 깔끔하게 실패합니다. 취소 토큰은 페이지, 블록, 표, 행, 셀 경계에서 확인되므로, 천 페이지짜리 문서를 취소한 스캔도 반응성을 유지합니다

GUI 애플리케이션에 특히 중요한 동작이 하나 있습니다. 문서 전체 스캔은 활성 페이지를 복원하는 범위 안에서 실행되므로, 성공이든 예산 실패든 취소든 호출자의 현재 페이지는 그대로 남습니다. 340페이지를 보는 도중 내보내기를 시작한 뷰어는 이후에도 여전히 340페이지에 머물러 있습니다

리플로우가 잘 맞는 곳, 그리고 맞지 않는 곳

리플로우 출력은 검색 색인, 접근성을 고려한 읽기 뷰, 모바일 표시, 콘텐츠 마이그레이션에는 훌륭한 입력값입니다. 충실도를 보존하는 컨버터는 아니며, 절대 위치, 정확한 폰트, 벡터 아트워크, 정밀한 페이지 지오메트리는 설계상 이 기능의 목적 밖입니다. 페이지가 똑같이 보여야 하는 작업에는 렌더링을 쓰고, 페이지가 다른 곳에서 읽을 수 있어야 하는 작업에는 리플로우를 쓰십시오

보조 기술을 위해서는 리플로우 모델이 접근 가능한 리더 만들기에서 설명한 읽기 기능과 짝을 이루며, 실제 구조 트리를 갖춘 문서는 눈에 띄게 더 나은 모델을 만들어내는데, 이는 상류에서의 태깅 검증을 PDF/UA 구조 트리 검증에서 설명한 대로 하는 것이 좋은 이유이기도 합니다

리플로우, 구조화된 텍스트, 태깅 검증, 렌더링은 Delphi, C++Builder, Lazarus에서 하나의 문서 객체를 공유합니다. 전체 API는 Delphi용 PDFium Component 페이지에서 설명합니다