기술 문서

HotPDF의 rowspan 그리드와 반복되는 표 헤더

HotPDF는 HTML 테이블을 HTML5 paged-media 프로파일을 통해 렌더링하며, rowspan과 colspan에 실제 점유 그리드를 쓰고, 문자 수 추정이 아니라 실측한 행 높이를 쓰고, 이어지는 모든 페이지에 헤더 행을 반복합니다. 헤더 반복을 거절하는 상황이 두 가지 있는데, 이것을 미리 알아 두는 편이 나중에 복제된 셀을 디버깅하는 것보다 쌉니다

이것을 강제하는 문서 부류는 리포팅 팀이 결국 배송하게 되는 그것입니다. 진실의 원천(source of truth)이 HTML이고 테이블이 네 페이지에 걸치며 헤더가 그 모든 페이지에서 읽혀야 하는 인보이스나 컴플라이언스 리포트. 진짜 테이블 레이아웃 미만의 무엇이든 독자가 즉시 알아채는 두 실패를 낳습니다. 1페이지에 한 번만 등장하는 헤더와 문자 수로 높이를 어림잡은 행들입니다

테이블 기능이 HTML 렌더러로 옮겨진 이유는?

대안은 리치 텍스트를 잃고, 리치 텍스트야말로 콘텐츠가 애초에 HTML인 이유이기 때문입니다. 눈에 보이는 계획은 재사용처럼 생겼습니다. HotPDF에는 제대로 된 그리드를 갖춘 레이아웃 DOM 테이블 오브젝트가 이미 있으니, HTML 파서를 여기에 브리지하면 spanning을 공짜로 얻는다는 것이죠. 문제는 그 테이블 오브젝트가 무엇으로 그리는가입니다. 셀은 텍스트와 스타일 하나를 담고 그리기 경로는 플레인 텍스트 출력을 내보냅니다. 그래서 HTML에 폰트와 색 이상으로 실제로 담긴 것들, 링크, 위첨자, 인라인 크기 변경, 런별 색은 페이지에 닿기 전에 사라집니다

실제 문서와 부딪혀도 살아남는 방향은 그 반대입니다. 테이블 엔진의 기능들, 즉 점유 그리드, 실측, 헤더 반복, 열 가중치를 HTML 렌더러 쪽으로 옮기고, 리치 텍스트 렌더링은 이미 동작하는 자리에 두는 것입니다. 브리지보다 큰 변경이지만, 하이퍼링크가 테이블 셀 안에서 하이퍼링크로 남게 만드는 변경이기도 합니다

union-find 없이 rowspan 처리하기

스팬 셀은 원자적인 행 그룹을 만들지만, 그 그룹에 대한 폐쇄에는 범용 서로소 집합(disjoint-set) 구조가 필요 없습니다. 점유가 항상 연속된 구간이기 때문입니다. K행에서 시작하는 rowspan="3" 셀은 K부터 K+2까지의 행을 점유하고 그 이외는 아무것도 점유하지 않으므로, 그룹 정보는 행별 종료 마커로 환원됩니다

알고리즘은 의도가 두 줄입니다. K에서 시작해 E에서 끝나는 스팬 셀을 배치할 때 GroupEnd[K] := Max(GroupEnd[K], E)를 기록합니다. 그다음 행을 역방향으로 한 번 걸으며 G[R] := G[G[R]]을 적용하면, 각 행의 종료 지점이 겹치는 스팬을 타고 뒤로 전파되어 추이적 폐쇄가 한 번의 패스로 나옵니다. 얻는 것은 모든 행에 대해 자신과 같은 페이지에 남아 있어야 하는 마지막 행인데, 이것이 바로 페이지 분할 단계가 어디서 끊길 수 있는지 판단하는 데 필요한 정보입니다

높이 분배가 나머지 절반입니다. 스팬 셀이 자신이 덮는 행들이 현재 제공하는 것보다 더 많은 세로 공간을 필요로 하면, 잉여분은 행들에 고르게 뿌려지지 않고 스팬의 마지막 행으로 갑니다. 평범한 행 높이가 확정된 뒤에 스팬 셀을 처리하고, 각 스팬의 마지막 행을 덧붙여 채우세요. 잉여분을 고르게 나누는 쪽이 더 공정해 보이지만 눈에 띄게 잘못된 출력을 냅니다. 세 줄 위의 무관한 셀이 우연히 컸다는 이유만으로 한 줄짜리 짧은 셀들만 담은 행들이 부풀어 오릅니다

2행에서 시작하는 rowspan 3 셀이 2행부터 4행까지 하나의 원자적 사각형으로 점유하는 HotPDF HTML 테이블 그리드와, 한 번의 역방향 순회가 만들어 내는 행별 그룹 종료 값 G[R]. 2, 3, 4행이 같은 페이지로 묶이는 모습이 보인다
스팬 점유는 항상 연속된 구간이므로, 행별 종료 마커와 역방향 순회 한 번이 union-find를 대체하고 페이지 분할에 끊길 수 있는 위치를 정확히 알려 줍니다
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Stats: THPDFHTMLImportStatistics;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'audit-report.pdf';
    Pdf.BeginDoc;
    Importer := THPDFHTMLImporter.Create(Pdf);
    try
      Importer.Margin := 48;
      Importer.BaseFontName := 'Arial';
      Importer.BaseFontSize := 10;
      Importer.MaxDOMNodes := 200000;
      Importer.MaxLayoutOperations := 2000000;
      if Importer.RenderHTML5(SourceHtml, PrintStyleSheet) then
      begin
        Stats := Importer.Statistics;
        Writeln('tables ', Stats.TableCount,
                '  page breaks ', Stats.PageBreakCount);
      end;
    finally
      Importer.Free;
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

RenderHTML5는 두 번째 인수로 선택적인 저자 스타일 시트를 받는데, 인쇄 규칙이 들어갈 자리는 바로 여기입니다. 화면용 스타일 시트를 넣지 마세요. 프로파일은 버전 관리되고, HTML5ProfileMilestones는 현재 빌드가 구현한 기능 그룹, 즉 himParserCascade, himPagedLayout, himTablesForms, himBoundedResources를 보고하므로, 애플리케이션은 프로덕션에서 빠진 기능을 발견하는 대신 의도적으로 열화할 수 있습니다

측정과 그리기는 정확히 일치해야 합니다

행 높이는 줄바꿈된 줄을 측정하는 코드가 그것을 그리는 코드와 같은 규칙으로 줄바꿈할 때만 올바릅니다. 당연하게 들리지만 테두리와 내용이 맞지 않는 테이블의 단연 최다 원인입니다. HotPDF는 그리디 라인 카운터로 측정하는데, 이 카운터는 리치 텍스트 출력 경로의 줄바꿈 의미론과 세 가지 구체적 측면에서 일치해야 합니다. 공백에서만 끊고, 단어를 절대 쪼개지 않으며, 열보다 넓은 단어는 자기 전용 줄을 얻습니다

두 번째 요건은 폰트입니다. 측정은 셀 자신의 폰트로 실행되어야 합니다. 너비 함수를 호출하기 전에 실제 이름, 스타일 세트, 크기로 SetFont를 거친 폰트 말이고, 우연히 활성화되어 있던 아무 폰트가 아니어야 합니다. 볼드 텍스트는 같은 크기에서 정상체보다 십퍼센트 이상 넓은 경우가 흔한데, 세 줄 셀을 네 줄 셀로 바꾸기에 충분합니다. 헤더 셀은 볼드이고 본문 셀은 아닌 테이블을 단일 폰트로 측정하면, 독자가 가장 먼저 보는 행들에서 정확히 틀립니다

이것을 제대로 하면 테스트에서 무엇을 단언할 수 있는지가 달라집니다. 정확한 측정의 관측 가능한 효과는 글리프 개수가 아니라 줄 간격입니다. 한 줄짜리 행은 대략 20포인트인 반면 같은 콘텐츠에 대한 문자 수 추정은 두 줄과 대략 35를 예측합니다. 행들 사이의 수직 거리를 단언하세요. 그리고 PDF 사용자 공간에서는 Y가 위로 증가하므로, 본문 행 위에 앉은 헤더는 헤더의 Y 값이 더 크다는 뜻입니다. 화면 좌표 직관이 적는 것과 반대입니다

HotPDF는 언제 헤더 반복을 거부하는가?

두 경우 모두, 계속 진행했다면 눈에 띄게 잘못된 출력을 낳을 사례들입니다. 첫 번째는 헤더 블록 안에 헤더를 넘어 본문 행까지 뻗는 스팬 셀이 있는 경우입니다. 헤더를 반복하면 그 셀 콘텐츠가 더 이상 속하지 않는 위치에 두 번째로 그려지므로, 헤더는 한 번만 그려지고 테이블은 헤더 없이 계속됩니다. 두 번째는 사용 가능한 페이지 높이의 90퍼센트보다 큰 헤더로, 반복하면 데이터 공간이 거의 남지 않아 테이블이 앞으로 진행하지 못합니다

페이지 나누기를 넘어 HTML 테이블 헤더를 반복하는 HotPDF 판단 흐름: rowspan이 본문 행으로 넘어가는 헤더는 한 번만 그려지고, 사용 가능한 페이지 높이의 90퍼센트보다 큰 헤더도 한 번만 그려지며, 그 외 모든 헤더는 이어지는 페이지마다 반복된다
두 거부는 의도적입니다: 본문 스팬 셀을 소유한 헤더나 페이지 대부분을 채우는 헤더를 반복하면 콘텐츠가 더 이상 속하지 않는 곳에 그려지거나 데이터 공간이 남지 않습니다

두 거부 모두 설계상 의도적이고 조용한데, 대안이 더 나쁘기 때문입니다. 헤더가 반복되지 않는데 반복될 것으로 기대했다면, 엔진을 의심하기 전에 마크업에서 thead 경계를 넘는 rowspan을 확인하세요. 놀람의 대부분은 이 마크업 패턴 하나에서 나옵니다

// 열 가중치는 마크업에서 나오므로, 인쇄 스타일 시트가 그것을 제어할
// 자리입니다. 폭은 픽셀이 아니라 가중치로 취급됩니다
const
  PrintStyleSheet =
    'table { width: 100%; }' +
    'thead th { font-weight: bold; background: #eee; }' +
    'td.amount { text-align: right; }';

// 본문으로 넘어가는 rowspan을 실은 헤더 행은 헤더 반복을 억제합니다.
// 스팬은 한 섹션 안에 유지하세요:
//   <thead><tr><th rowspan="2">Item</th>...</tr></thead>  ok
//   <tr><th rowspan="3">Item</th>...  tbody로 넘어감, 반복 없음

열 폭은 절대 측정값이 아니라 가중치처럼 동작하며, 콘텐츠가 저자의 추정과 어긋날 때 테이블을 쓸 수 있게 유지하는 것은 바로 이 동작입니다. 30퍼센트로 선언된 열은 가용 폭의 대략 30퍼센트를 얻지만, 분배는 각 열이 실제로 필요로 하는 최소 폭을 존중하므로, 분리 불가능한 긴 토큰을 담은 좁은 열이 테이블 박스를 넘쳐 흐르지 않습니다

문서 파이프라인에서의 위치

테이블 작업은 더 넓은 paged-media 프로파일 안에 자리하며, HTML5 paged-media 임포트 경로에서 기술한 페이지 분할 규칙, 리소스 예산, CSS 처리는 테이블을 담은 문서에도 그대로 적용됩니다. 데이터가 HTML로 시작하지 않는다면 PDF에 테이블 직접 만들기의 직접 구성 경로가 파싱 계층을 완전히 우회하고 API로 같은 그리드 동작을 줍니다. 그리고 행 높이가 궁극적으로 줄이 어디서 끊기는지에 달려 있으므로, 텍스트 저스티피케이션과 줄바꿈의 측정 논의는 빽빽한 표 출력을 튜닝하는 사람의 짝꿍 글입니다

여기서 재사용 가능한 교훈은 테이블에 관한 것이 전혀 아닙니다. 새 서브시스템이 오래된 서브시스템이 이미 갖고 있는 기능을 필요로 할 때, 둘 중 누가 재구현하기 가장 어려운 것을 소유하는지 물어보세요. 그리드 산술은 수십 줄이고 쉽게 이동합니다. 인라인 링크, 위첨자, 런별 스타일을 갖춘 리치 텍스트 렌더링은 그렇지 않으므로, 그리드가 움직이고 텍스트가 남았습니다. HotPDF는 HotPDF Delphi PDF 컴포넌트의 일부로 두 경로를 모두 배송하므로, HTML 입력과 직접 구성 사이의 선택은 라이브러리가 아니라 프로젝트의 결정입니다