기술 문서

HotPDF Delphi로 HTML to PDF의 CSS 멀티 컬럼 레이아웃

HotPDF Component는 THPDFHTMLImporter.RenderHTML5가 Delphi에서 HTML을 PDF로 변환할 때 CSS 멀티 컬럼 텍스트를 배치합니다. 페이징 HTML 엔진은 콘텐츠 박스에 대해 column-count와 column-width를 해석하고, 모든 multicol 컨테이너의 마지막 조각을 이분 검색으로 균형 맞추며, 이전 페이지들은 페이지 바닥까지 컬럼별로 채우고, column-fill, column-span: all, column-rule을 지원합니다

이걸 원하는 이유는 지루하지만 실제적입니다. 두 컬럼 제품 소식지, 세 개의 좁은 컬럼에 조판된 약관 부록, 마케팅 팀이 이미 HTML로 설계해 둔 인쇄 뉴스레터처럼 보여야 하는 릴리스 노트 PDF죠. 컬럼 지원이 없으면 임포터는 모든 단락을 하나의 넓은 행에 쌓고, 행 길이는 두 배가 되고, 문서는 로그 파일처럼 읽힙니다. 지원하면 브라우저 미리보기를 몰던 같은 스타일시트가 PDF도 몰고, 어려운 부분은 페이징이 됩니다. 화면에서는 브라우저가 대부분 신경 쓸 일이 없는 부분이죠

HotPDF는 컬럼 수를 어떻게 정할까

HotPDF는 CSS Multi-column Layout Module Level 1 §3.4의 의사 알고리즘과 같은 규칙으로 컬럼 수를 정하며, 패딩과 보더를 뺀 콘텐츠 박스 폭 W에 적용합니다. 선언된 column-count는 그대로 씁니다. column-width만 주어지면 엔진은 최소 한 컬럼의 바닥을 두고 N = Trunc((W + gap) / (column-width + gap))을 계산합니다. 둘 다 있으면 결과는 둘 중 작은 쪽이므로, 선언된 count는 유도된 count를 제한할 수 있어도 늘릴 수는 없습니다

구체적 사례: 483pt 콘텐츠 박스(A4에 56pt 옆 여백, 반올림)에 column-width: 140pt; column-gap: 12pt를 주면 Trunc(495 / 152) = 3컬럼이 나오고 각 컬럼은 (483 - 2 * 12) / 3 = 153pt 폭입니다. 같은 규칙에 column-count: 2를 더하면 두 컬럼으로 떨어집니다. 작은 쪽이 이기기 때문입니다. gap 키워드 normal은 컨테이너 폰트 크기의 1em으로 해석되는데, 이 해석은 파스 시점이 아니라 레이아웃 시점에 일어나므로 같은 규칙 안에서 column-gap 뒤에 선언된 font-size도 올바른 gap을 만들어 냅니다

Delphi에서 HTML to PDF를 위한 HotPDF CSS 멀티 컬럼 수 결정: column-count는 선언대로 쓰이고, column-width는 N = Trunc((W + gap) / (column-width + gap))으로 유도되며, 둘 다 설정되면 작은 쪽이 이기고, 12pt 미만의 컬럼은 단일 컬럼으로 무너짐
엔진은 콘텐츠 박스에 CSS 의사 알고리즘을 적용하고, 유도된 count를 선언된 count로 제한하며, 12pt 바닥 보호 장치를 더해 신뢰할 수 없는 템플릿이 수천 개의 미세 컬럼을 만들지 못하게 합니다

사양에 없는 보호 장치가 하나 있고, 일부러 넣은 것입니다. 컬럼 폭이 계산된 뒤 12pt 미만의 값은 컨테이너를 단일 컬럼으로 되돌립니다. column-count: 9999라고 말하는 스타일시트는, 우연이든 신뢰할 수 없는 템플릿에서 왔든, 그렇지 않으면 음수나 미세 폭의 수천 개 컬럼과 줄마다 글자 하나를 넣으려는 줄 나누기를 만들어 냅니다. 단일 컬럼 폴백은 출력을 읽을 수 있게 유지하고 레이아웃 비용을 한계 안에 묶습니다. 0이나 음수 count는 거기까지 가지도 않습니다. 검증기는 column-count에 auto나 양의 정수만 받고, column-width와 column-gap은 각각 auto나 normal, 또는 포인트나 em 단위의 길이를 받습니다

Delphi에서 멀티 컬럼 HTML 섹션을 PDF로 렌더링하기

멀티 컬럼 출력에는 임포터의 스위치가 필요 없습니다. THPDFHTMLImporter는 그려 넣을 THotPDF 문서와 함께 만들어지고, BeginDoc은 이미 실행된 상태여야 하며, 컬럼 속성은 정상 캐스케이드를 타고 옵니다. 마크업 안이거나 RenderHTML5의 두 번째 인자로 넘긴 저자 스타일시트 안에서입니다

uses
  SysUtils, HPDFDoc, HPDFHTMLImport;

procedure BuildBulletin(const FileName: string);
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Html, Css: WideString;
begin
  Pdf := THotPDF.Create(nil);
  Importer := THPDFHTMLImporter.Create(Pdf);
  try
    Pdf.FileName := FileName;
    Pdf.BeginDoc;
    Importer.Margin := 56;
    Importer.BaseFontName := 'Arial';
    Importer.BaseFontSize := 10;

    Css := 'section.news { column-width: 140pt; column-gap: 12pt; ' +
           '  column-rule: 0.5pt solid #999999 } ' +
           'section.news h2 { column-span: all }';

    Html := '<section class="news">' +
            '<p>Release 4.2 ships the new export pipeline...</p>' +
            '<p>Licensing changes take effect on 1 March...</p>' +
            '<h2>Known issues</h2>' +
            '<p>Import of legacy archives &amp; templates...</p>' +
            '</section>';

    if not Importer.RenderHTML5(Html, Css) then
      raise Exception.Create('Nothing was emitted');
    Pdf.EndDoc;
  finally
    Importer.Free;
    Pdf.Free;
  end;
end;

마지막 단락의 이중 이스케이프에 주목하십시오. Pascal 문자열 안의 &amp;는 앰퍼샌드의 HTML 엔티티이며, 임포터가 단일 문자로 되돌려 디코딩합니다. 캐스케이드의 나머지, 예산, 리소스 콜백은 HotPDF HTML5 paged-media 임포트 개요에서 설명한 것과 정확히 같게 동작합니다. 컬럼은 같은 프로필 안의 레이아웃 모드 하나일 뿐입니다

이분 검색으로 컬럼 균형 맞추기

HotPDF는 탐욕적 채우기가 남은 모든 줄을 여전히 배치하는 가장 작은 컬럼 높이를 찾아 컬럼 균형을 맞춥니다. 탐욕 패스는 단순합니다. 줄들을 컬럼 하나에 다음 줄이 후보 높이를 넘을 때까지 담고, 컬럼 둘에서 계속하는 식입니다. 검색은 가장 높은 단일 줄을 하한으로, 모든 줄 높이의 합을 상한으로 시작해 매 라운드 구간을 반으로 줄이고, 한계가 0.05pt 안에 들면 멈춥니다

Delphi에서 HotPDF 멀티 컬럼 균형: 가장 높은 단일 줄과 모든 줄 높이의 합 사이 이분 검색이 모든 줄을 배치하는 탐욕 채우기를 가진 가장 작은 컬럼 높이를 찾으며, 한계는 0.05pt 안에서 수렴
모든 줄이 맞는다는 술어는 컬럼 높이에 대해 단조이므로, 구간을 반으로 줄이면 가장 짧은 균형 밴드에 수렴하고 마지막 탐욕 채우기는 고아 줄 없이 모든 줄을 소비합니다

이분 검색은 술어가 단조일 때만 통하는데, 여기서 그렇습니다. 더 높은 컬럼은 더 낮은 컬럼보다 적은 줄을 담을 수 없으므로, "모든 줄이 맞는다"는 높이가 자라면서 정확히 한 번 거짓에서 참으로 뒤집힙니다. 이 성질이 수렴을 보장하고, 찾은 높이에서의 마지막 탐욕 채우기가 가짜 추가 페이지를 부를 고아 줄을 남기지 않고 모든 줄을 소비함을 보장합니다. 결과의 한 디테일도 설명됩니다. 균형 밴드는 줄 상자가 허락하는 만큼 짧을 뿐 반드시 동일한 컬럼이 아니며, 줄은 나눌 수 없고 마지막 컬럼은 다른 것보다 한두 줄 높게 끝날 수 있습니다

컬럼이 페이지 나눔을 가로지르면 어떻게 되나

현재 페이지에 맞지 않는 컬럼들은 페이지 바닥까지 차례로 채워지고, 마지막 페이지 조각만 균형이 맞춰집니다. 이것은 CSS Multi-column Layout Level 1 §7.1이 조각화된 문맥의 column-fill: balance에 규정하는 동작이고, 인쇄 뉴스레터의 생김새이기도 합니다. 마지막 페이지를 제외한 모든 페이지는 꽉 찬 컬럼이고, 마지막에서는 남은 텍스트가 한 컬럼만 높고 다른 컬럼은 빈 채로 두는 대신 고르게 갈라집니다

각 페이지 조각은 같은 조각내기를 독립적으로 거칩니다. 남은 줄이 가용 높이의 N컬럼에 맞으면 조각은 균형이 맞춰지고, 그렇지 않으면 모든 컬럼이 페이지 바닥까지 차고 루프가 새 페이지를 시작합니다. 엔진이 break-inside: avoid 판단과 범위 측정에 쓰는 컨테이너 전체의 자연 높이는 무제한 용량의 균형 밴드 높이이므로, 균형 잡힌 블록으로 새 페이지에 맞을 컨테이너는 잘리지 않고 통째로 그곳으로 옮겨집니다. 단일 줄이 페이지 전체보다 높으면 엔진은 제약을 완화해 그 줄을 어쨌든 페이지 맨 위에 놓고, 주제 multi-column으로 hdkPaginationConstraint 진단을 보고합니다

Delphi에서 HTML to PDF를 위한 HotPDF 멀티 컬럼 페이징: 마지막 페이지 이전 조각들은 모든 컬럼을 페이지 바닥까지 채우고, 마지막 조각은 컬럼에 걸쳐 균형이 맞춰지며, column-span all은 컨테이너를 전폭 요소 주변의 따로 균형 맞춘 밴드들로 갈라냄
multicol 컨테이너의 마지막 조각만 균형이 맞춰집니다. 이전 페이지들은 차례로 채우고, 각 조각은 균형, 규칙, 페이징을 독립적으로 반복합니다

column-fill: auto와 고정 높이 박스

column-fill: auto와 확정된 height에서 HotPDF는 컬럼을 순서대로 채우고, 각 컬럼 높이는 남은 페이지 공간과 컨테이너 높이에서 패딩과 보더를 뺀 값 중 작은 쪽입니다. 최솟값은 보이는 것보다 중요합니다. 남은 페이지 공간만 쓰면 페이지에 공간이 넉넉할 때 첫 컬럼이 모든 줄을 삼켜 버립니다. 확정 높이가 없으면 column-fill: auto는 브라우저처럼 균형 맞추기로 폴백합니다. 제약 없는 multicol 컨테이너는 채울 컬럼 높이가 없기 때문입니다. 첫 구현이 이 폴백을 놓쳤고, 27개 회귀 검사 중 하나가 잡아 냈습니다

column-span: all과 중첩 단락

HotPDF는 CSS Multi-column Layout Level 1 §6.1을 따라 멀티 컬럼 컨테이너의 직계 자식에 대한 column-span: all을 지원합니다. span을 건 자식은 컨테이너를 세그먼트로 갈라냅니다. 그 앞 자식들은 하나의 컬럼 밴드를 이루고, span 요소는 전체 폭에 걸쳐 렌더되며, 뒤 자식들은 새 밴드를 시작합니다. 각 밴드는 자체적으로 균형이 맞춰지고 채워지고 선이 그려지고 페이징되므로, 세 컬럼 섹션 한가운데의 제목은 사이에 전폭 제목을 둔 두 개의 균형 밴드를 만들어 냅니다. 사람들이 목업에 실제로 그리는 레이아웃입니다

거기까지 가려면 덜 눈에 띄는 문제부터 고쳐야 했습니다. <div style="column-count:2"> 같은 컨테이너에 <p> 자식들이 여럿이면 예전에는 모든 단락이 단일로 쌓인 컬럼에 렌더됐습니다. 각 단락이 자기 텍스트 블록으로 배치되고 부모의 컬럼 선언이 적용되지 않았기 때문입니다. HotPDF는 이제 중첩된 블록 자식들의 텍스트를 컨테이너용 하나의 흐름으로 모읍니다. 수집기는 그 과정에서 모든 블록 경계에 hard break를 넣어야 합니다. 건너뛰면 한 단락의 마지막 단어와 다음 단락의 첫 단어가 같은 줄에서 이어 붙는데, 테스트 텍스트가 우연히 문장부호로 끝나면 놓치기 쉬운 버그입니다

그 병합 흐름에는 의도적인 범위가 있습니다. 서브트리 전체가 평범한 텍스트 흐름일 때만 쓰입니다. 폼 컨트롤, 이미지, 표, 목록, <hr>을 어디든 담고 있는 컨테이너는 평범한 블록 경로를 유지하며 단일 컬럼으로 렌더됩니다. 병합 텍스트 수집기는 그 지오메트리를 표현할 수 없기 때문입니다. 특히 표는 자체 페이징 규칙이 있는데, HotPDF의 rowspan 처리와 반복 표 헤더 글이 다루며, 컬럼 밴드에 섞으면 그 보장이 깨집니다

column rule이 가는 곳과 테스트 방법

HotPDF는 column-rule을 각 gap의 중선, 즉 컬럼 오른쪽 모서리에서 gap의 절반 지점에, 모든 페이지 조각에서 밴드 위에서 아래까지 그리며, CSS Multi-column Layout Level 1 §4가 설명하는 그대로입니다. 폭 기본값은 1pt입니다. 색은 선언되지 않았거나 currentColor일 때 요소 텍스트 색이 기본입니다. 스타일 키워드 없는 column-rule 단축 표기는 solid로 취급되는데, 이는 border 단축 표기를 흔히 읽는 방식과 맞춘 것이고, none과 hidden은 규칙을 억제합니다

컬럼 지오메트리는 PDF를 파싱하지 않고도 관측할 수 있습니다. THPDFHTMLImporter의 OnLineLayout 이벤트는 그려지는 줄마다 THPDFHTMLLineLayout 레코드와 함께 한 번 발화하고, 엔진이 각 컬럼을 따로 그리므로 각 줄의 X 필드는 자기 컬럼의 왼쪽 모서리입니다. 인접 컬럼은 정확히 컬럼 폭 더하기 gap만큼 차이 나므로, 회귀 테스트에서 12pt gap과 20pt gap을 구별하기 쉽습니다

type
  TColumnProbe = class
  public
    Lines: TStringList;
    constructor Create;
    destructor Destroy; override;
    procedure LineDrawn(Sender: TObject; const Line: THPDFHTMLLineLayout);
  end;

constructor TColumnProbe.Create;
begin
  inherited Create;
  Lines := TStringList.Create;
end;

destructor TColumnProbe.Destroy;
begin
  Lines.Free;
  inherited;
end;

procedure TColumnProbe.LineDrawn(Sender: TObject;
  const Line: THPDFHTMLLineLayout);
begin
  // PageIndex는 0부터 시작, X는 그 줄의 컬럼 왼쪽 모서리
  Lines.Add(Format('page=%d x=%.1f y=%.1f %s',
    [Line.PageIndex, Line.X, Line.Y, string(Line.Text)]));
end;

// 연결, RenderHTML5 전에:
//   Probe := TColumnProbe.Create;
//   Importer.OnLineLayout := Probe.LineDrawn;

이 이벤트로 테스트를 쓰며 얻은 실용 참고 두 가지입니다. PageIndex는 0부터 시작하며, 손으로 쓴 단언이 가장 먼저 틀리는 부분입니다. 그리고 X는 정렬 이동을 포함하므로, 가운데나 오른쪽 정렬 텍스트의 컬럼 위치 비교는 왼쪽 정렬 픽스처에서 하십시오. 규칙 자체는 이벤트에 나오지 않습니다. 위치를 단언하려면 압축되지 않은 콘텐츠 스트림에서 수직 m과 l 연산자를 스캔하십시오

알아 둘 가치가 있는 한계와 함정

몇 가지 동작은 근사이며, 레이아웃 리뷰 뒤보다 전에 아는 편이 낫습니다. 병합된 컬럼 흐름 안의 단락 간격은 각 블록 경계에서 컨테이너 행 높이의 한 줄로 근사되므로, 컬럼 밴드 안의 단락별 마진은 정확히 재현되지 않습니다. column-span: all이 컨테이너를 갈라놓을 때 각 세그먼트는 컨테이너 패딩과 배경 박스를 반복하는데, 세그먼트를 시각적으로 이어지게 하지만 엄밀한 CSS 의미의 단일 박스는 아닙니다. none과 hidden 이외의 모든 column-rule-style 값은 검증을 통과하지만, 규칙은 solid 선으로 그려집니다

지원하지 않는 값이 조용히 실패하지는 않습니다. StrictCSS를 True로 하면 모든 진단이 EHPDFHTMLUnsupportedCSS 예외로 변하고, OnDiagnostic은 배치 작업이 진단을 기록하고 계속 가게 합니다. strict 모드는 페이징 진단에도 적용되므로, 페이지보다 높은 줄이 있는 문서는 StrictCSS에서 제약을 조용히 완화하는 대신 예외를 던집니다

procedure TReportJob.HtmlDiagnostic(Sender: TObject;
  const Diagnostic: THPDFHTMLDiagnostic);
begin
  case Diagnostic.Kind of
    hdkUnsupportedProperty, hdkUnsupportedValue:
      FLog.Add('CSS ' + Diagnostic.Subject + ': ' + Diagnostic.MessageText);
    hdkPaginationConstraint:
      FLog.Add('Layout relaxed (' + Diagnostic.Subject + '): ' +
        Diagnostic.MessageText);
  else
    FLog.Add(Diagnostic.MessageText);
  end;
end;

// 템플릿 작성: 엔진이 무시할 모든 것에 즉시 실패
Importer.StrictCSS := FValidatingTemplate;
Importer.OnDiagnostic := HtmlDiagnostic;

strict 모드는 템플릿을 쓰는 동안의 올바른 설정입니다. column-span: both 같은 오타가 제목이 컬럼 안으로 흘러드는 걸 조용히 허용하는 대신 즉시 거부되니까요. 프로덕션에서는 OnDiagnostic으로 기록하는 편이 보통 더 나은 절충입니다. 고객이 준 HTML의 별난 선언 하나가 문서 전체의 값을 치르게 해서는 안 되니까요. 동일한 진단(같은 kind, subject, value)은 기본값이 256항목인 MaxDiagnostics 목록에 들어 있는 동안 한 번만 보고되므로, 캐스케이드가 수백 개 요소에 대해 방문하는 선언이 로그를 범람시키지 않습니다

FAQ

HotPDF는 columns 단축 표기를 지원하나요?

네. columns 단축 표기는 양의 정수 count 하나와 컬럼 길이 하나를 순서 무관하게 최대 하나씩 받고, auto는 두 longhand를 리셋합니다. 해석된 값은 개별 속성과 같은 count와 폭 규칙을 따릅니다

멀티 컬럼 컨테이너에 이미지나 표를 담을 수 있나요?

담을 수는 있지만 그러면 HotPDF는 그 컨테이너를 단일 컬럼으로 렌더링합니다. 컬럼 흐름은 모든 자손이 평범한 텍스트 흐름일 때만 적용됩니다. 이미지, 표, 목록, 폼 컨트롤은 자기만의 박스 지오메트리와 페이징 규칙이 필요하기 때문입니다

마지막 페이지는 균형 잡힌 컬럼인데 이전 페이지는 꽉 찬 이유는요?

페이징 미디어에서 column-fill: balance에 대한 CSS 규칙이 그렇습니다. 마지막 조각만 균형이 맞춰집니다. 이전 페이지들은 각 컬럼을 페이지 바닥까지 채워 문서 중간에 공간이 낭비되지 않게 합니다

멀티 컬럼 레이아웃, HTML5 paged-media 프로필, HTML to PDF 파이프라인의 나머지는 Delphi 및 C++Builder용 HotPDF Component의 일부이며, 직접 PDF 드로잉, 폼, 암호화, 텍스트 추출도 함께 다룹니다