HotPDF는 좌표 대신 선언적 트리로부터 페이지가 나뉜 문서를 만들 수 있습니다. 섹션, 스택, 텍스트, 목록, 표로 THPDFDOMDocument를 조립해 THPDFDOMRenderer에 넘기면, 렌더러가 측정하고 페이지를 나누고 페이지 장식을 그리며, 요청이 있으면 결과물을 접근 가능하게 만드는 PDF/UA 구조 트리까지 내보냅니다. 레이아웃 코드는 y 좌표를 단 한 번도 계산하지 않습니다
좌표 기반 리포트 생성기를 유지보수해 본 사람이라면 이것이 왜 중요한지 알 것입니다. 첫 버전은 잘 동작합니다. 그러다 고객 주소가 세 줄로 늘어나고, 표에 행이 추가되고, 현지화된 제목이 줄바꿈되면, 그 아래의 모든 y 위치가 틀어집니다. 이런 문제들은 비즈니스 로직 곳곳에 흩어진 수작업 페이지 나눔 검사로 누적되고, 2년 뒤에 도착한 태그 PDF 요구 사항은 단락이 무엇인지도 모르는 코드에 나중에 끼워 넣을 수가 없습니다
트리는 무엇을 소유하며, 왜 소유권이 엄격할까?
DOM은 모든 계층에서 단일 소유권을 강제합니다. 문서는 섹션을 소유하고, 섹션은 본문·헤더·푸터를 소유하며, 스택과 컨테이너와 표는 자신의 자식을 소유합니다. 재사용은 Clone이나 등록된 팩토리를 통해서만 일어나며, 같은 객체를 두 부모에 붙이는 방식으로는 절대 일어나지 않습니다. 이 규칙은 형식적인 절차가 아닙니다. 트리에 두 번 등장하는 컴포넌트는 서로 다른 제약 조건으로 두 번 측정되고 해제 시점에는 두 번 해제될 것입니다
호출하는 코드 입장에서 이 규칙의 실질적인 결과는, 헬퍼가 새 인스턴스를 반환한다는 점입니다. RegisterComponent로 팩토리를 등록하고 CreateComponent를 호출하면 매번 새로운 컴포넌트를 만들어내는 이름 붙은 레시피를 얻게 되며, 서명란이나 법적 고지 푸터처럼 반복되는 장식이 트리에 들어가는 방식이 바로 이것입니다
uses
HPDFDoc, HPDFLayoutDOM;
var
Doc: THPDFDOMDocument;
Section: THPDFDOMSection;
Table: THPDFDOMTable;
Row: THPDFDOMTableRow;
I: Integer;
begin
Doc := THPDFDOMDocument.Create;
Doc.GenerateStructure := True; // emit the PDF/UA structure tree
Doc.Language := 'en-US';
Section := Doc.AddSection;
Section.PageWidth := 595; // A4 in points
Section.PageHeight := 842;
Section.MarginLeft := 56;
Section.MarginTop := 56;
Section.MarginRight := 56;
Section.MarginBottom := 56;
Section.Style.FontName := 'Helvetica';
Section.Style.FontSize := 10;
Section.Body.AddHeading('Annual maintenance report', 1);
Section.Body.AddText('Every asset inspected during the reporting ' +
'period is listed below, grouped by site.');
Section.Body.AddSpacer(12);
Table := THPDFDOMTable.Create('assets');
Table.AddColumn(3); // weights, not absolute widths
Table.AddColumn(1);
Table.AddColumn(1);
Table.RepeatHeaders := True;
Row := Table.AddRow(18, True); // header row
Row[0].Text := 'Asset';
Row[1].Text := 'Last service';
Row[2].Text := 'Status';
for I := 0 to High(Assets) do
begin
Row := Table.AddRow(16);
Row[0].Text := Assets[I].Name;
Row[1].Text := Assets[I].ServiceDate;
Row[2].Text := Assets[I].Status;
end;
Section.Body.Add(Table);
end;
페이지 나눔은 어떻게 제곱 비용을 피할까?
트리를 순진하게 페이지로 나누는 방법은 들어가지 못한 부분을 복제해 다음 페이지로 옮기는 것입니다. 행이 만 개인 표에서는 남은 행을 페이지마다 한 번씩 복제하게 되어, 선형이어야 할 문서가 제곱 비용의 문서로 바뀌어 버립니다
HotPDF는 대신 좁게 분할합니다. 최상위 렌더러는 본문 자식들을 인덱스 순으로 순회하며 섹션이나 본문 전체를 복제하는 일이 없습니다. 페이지 경계에 실제로 걸쳐 있는 중첩된 스택과 컨테이너만 영향을 받은 서브트리가 복제되며, 무거운 두 리프 타입은 복사본이 아니라 커서를 가지고 다닙니다. 텍스트 연속은 아직 갚아야 할 원본 문자 범위를 저장하고, 표 연속은 아직 배치하지 않은 행 슬라이스를 저장합니다. 긴 문서는 계속 선형을 유지하고, 긴 단락은 한 번 나뉘든 다섯 번 나뉘든 비용이 같습니다
측정은 부작용에 대해 정직하게 유지됩니다. THPDFLayoutElement.Measure는 그리기 부작용이 없어야 하며, 실제 배치는 항상 THotPDF.PlaceLayoutElement라는 중앙 루틴을 거치는데, 이 루틴은 배치된 조각을 다시 측정하고 오버플로 소유권을 설정하며 진단 정보를 기록합니다. DOM 렌더러는 새 페이지 정책, 페이지 장식, 간격, 연속 항목의 수명만 결정합니다
표 헤더 규칙은 어떻게 무한 문서를 막을까?
페이지마다 표 헤더를 반복하는 것은 간단해 보이지만 두 가지 실패 모드를 숨기고 있습니다. HotPDF는 헤더 행이 연속된 행의 첫 묶음에만 나타나야 하고, 첫 분할이 모든 헤더 행에 더해 최소 하나의 본문 행까지 담을 수 있어야 한다고 요구합니다. 두 번째 규칙이 없다면, 남은 공간보다 헤더가 더 큰 경우 헤더만 담긴 페이지가 만들어지고, 그다음도 똑같은 페이지가 이어지며, 이것이 영원히 반복됩니다
이어지는 페이지에서 다시 그려진 헤더는 콘텐츠가 아니라 아티팩트로 표시되는데, 이는 접근성과 텍스트 추출 양쪽 모두에 맞는 정답입니다. 원래 헤더 행은 논리적인 표 구조 안에 정확히 한 번만 남습니다. 이를 건너뛰면 스크린 리더는 데이터 중간에서 열 제목을 다시 읽어주고, 텍스트 추출기는 본문 행 사이에 중복된 헤더 행을 끼워 넣게 됩니다
연속 깊이에도 방어적인 상한이 있는데, 커스텀 컴포넌트가 Split을 구현하면서 언제나 동등한 꼬리를 반환하도록 만들 수도 있기 때문입니다. 렌더러는 꼬리를 분리한 뒤, 다음 페이지를 시작하기 전에 이 한계를 검사하며, 현재 반복은 자신의 finally 블록 안에서 꼬리를 해제하므로, 오작동하는 서드파티 컴포넌트는 디스크를 채우는 대신 진단 가능한 오류로 실패합니다
하나의 논리 요소, 여러 페이지 조각
자동 태깅은 페이지 나눔 모델과 구조 모델이 서로 합의해야 하는 지점입니다. 두 페이지에 걸쳐 나뉜 단락은 하나의 논리적 단락이므로 구조 요소도 하나로 남아야 합니다. 하지만 마킹된 콘텐츠 식별자는 페이지마다 따로이므로, 보이는 조각마다 그것이 나타나는 페이지에서 각자의 MCID가 필요합니다
HotPDF는 하나의 구조 요소를 유지하면서 조각마다 그 요소의 /K 배열에 마킹된 콘텐츠 참조를 덧붙이는 방식으로 이를 해결하며, /Pg와 /MCID 쌍이 페이지와 식별자를 지정합니다. 그 MCID에 대한 ParentTree 슬롯은 같은 요소를 다시 가리킵니다. 이것이 바로 ISO 14289가 기대하는 방식이며, 연속 클론이 일반 클론과 구별되는 이유이기도 합니다. 일반적인 Clone은 새로운 논리적 콘텐츠를 뜻하며 새로운 의미론적 정체성을 얻지만, 내부의 연속 클론은 자신이 이어가는 컴포넌트의 정체성을 그대로 물려받습니다
요소 재사용은 컴포넌트 포인터로 정렬된 의미론적 정체성 인덱스를 이진 비교로 검색해 처리하므로, 큰 트리에서도 조회가 로그 시간에 이루어집니다. 이 인덱스는 소유하지 않는 참조만 담고 있으며, 구조 객체 자체의 수명은 PDF 객체 그래프에 속한 채로 남습니다
렌더러가 사전에 강제하는 구조 규칙
GenerateStructure가 켜져 있으면, PDF/UA 규칙 여러 개가 파일이 완성된 뒤가 아니라 트리가 렌더링되는 동안 확인됩니다. 제목은 레벨 1에서 시작해야 하며 레벨을 건너뛸 수 없습니다. LI는 L 안에만 등장할 수 있고, Lbl과 LBody는 LI 안에만 등장할 수 있습니다. TR은 표에 속하고, TH와 TD는 행에 속합니다. 대체 텍스트가 없는 그림은 PDF/UA 모드에서 거부됩니다
일찍 거부하는 것은 이 설계의 의도적인 선택입니다. 문서가 만들어진 뒤 대체 텍스트 누락을 보고하는 검증기는 만 건의 명세서 배치를 다시 만들어야 한다는 사실만 알려줄 뿐이지만, 컴포넌트를 거부하는 렌더러는 데이터가 아직 유효한 범위 안에 있는 동안 어느 컴포넌트인지를 알려줍니다. 규격 준수 검증은 여전히 파이프라인의 별도 단계로 남아 있어야 하며, 그 메커니즘은 PDF/A, PDF/X, PDF/UA 검증에서 다룹니다
var
Pdf: THotPDF;
Renderer: THPDFDOMRenderer;
Stats: THPDFDOMRenderStatistics;
begin
Pdf := THotPDF.Create(nil);
Renderer := THPDFDOMRenderer.Create;
try
Pdf.FileName := 'maintenance-report.pdf';
Pdf.BeginDoc;
Stats := Renderer.Render(Doc, Pdf);
Pdf.EndDoc;
Writeln(Format('%d page(s), %d placement(s), %d split(s)',
[Stats.PageCount, Stats.PlacementCount, Stats.SplitCount]));
Writeln(Format('structure elements=%d marked content=%d artifacts=%d',
[Stats.StructureElementCount, Stats.MarkedContentCount,
Stats.ArtifactCount]));
Writeln(Format('deepest continuation chain: %d',
[Stats.MaximumContinuationDepth]));
finally
Renderer.Free;
Doc.Free;
Pdf.Free;
end;
end;
이 통계 레코드는 처음 보기보다 유용합니다. 템플릿 변경 이후 SplitCount가 급격히 늘어난다는 것은 대개 어떤 컴포넌트가 자신의 컨테이너보다 더 크게 측정되기 시작했다는 뜻입니다. MaximumContinuationDepth가 서서히 늘어나는 것은 Split이 페이지마다 진행하는 양이 너무 적은 컴포넌트가 있다는 조기 경고입니다. 그리고 ArtifactCount를 연속 페이지 개수와 비교해 보면 반복된 헤더가 정말로 아티팩트로 태깅되었는지 확인할 수 있습니다
DOM은 직접 API 옆 어디에 자리할까?
DOM은 직접 그리기를 대체하지 않으며, 같은 페이지 객체 위에 자리합니다. 렌더러가 배치하는 모든 것은 THotPDF에 대한 직접 호출과 섞어 쓸 수 있으며, 이는 정확한 위치에 손으로 배치한 서명 이미지처럼 손수 위치를 정한 요소 하나가 필요한 리포트에서 중요해집니다. 페이지 닫기는 여전히 AddPage와 EndDoc의 통제 아래 있으므로, 즉시 플러시 모드에서는 완성된 페이지를 메모리에 붙잡아 두지 않으며 상주 메모리는 현재의 연속 항목, 폰트 리소스, 일반적인 문서 객체 그래프에 의해서만 결정됩니다
콘텐츠가 데이터 주도이고 레이아웃이 규칙 주도일 때는 DOM을 선택하고, 고정된 아트워크에는 직접 그리기를 유지하십시오. 지금 겪는 문제가 특히 표 페이지 나눔이라면, PDF에서 표 생성하기의 더 좁은 접근 방식을 먼저 읽어볼 가치가 있으며, 정렬 같은 텍스트 수준 동작은 텍스트 정렬에서 설명합니다
선언적 레이아웃, 자동 태깅, 직접 그리기 API는 Delphi와 C++Builder용 같은 컴포넌트 안에서 함께 제공됩니다. 전체 기능 목록은 HotPDF Delphi PDF 컴포넌트 페이지에 있습니다