HotPDF는 THPDFDocComparison을 통해 Delphi에서 두 PDF 문서를 비교합니다. 이 클래스는 두 파일의 객체 그래프를 카탈로그부터 바깥쪽으로 순회하고, 요청이 있으면 각 페이지 쌍을 렌더링해 달라진 픽셀까지 측정합니다. 결과는 발견한 모든 차이, 소비한 예산, 비교가 끝까지 완료되었는지를 담은 JSON 리포트입니다. 두 패스 모두 중요한데, 구조적 diff와 시각적 diff는 서로 다른 질문에 답하기 때문입니다
이 기능 뒤에 있는 질문은 대개 릴리스에 관한 질문입니다. 리포트 엔진에 변경 사항이 들어오고, 출력이 다시 생성되면, 누군가는 무엇이 달라졌는지 판단해야 합니다. 두 파일을 나란히 펼쳐 보는 방법은 서너 페이지까지만 통하고 그 이후로는 집중력이 무너집니다. 원시 바이트를 비교하는 방법은 처음부터 아무것도 알려주지 못하는데, 같은 생성기의 두 실행이 만들어내는 바이트가 달라지는 이유는 사람 눈에 보이는 것과는 아무 상관이 없기 때문입니다
PDF는 왜 바이트가 다른데도 시각적으로는 동일할 수 있을까?
독립적으로 생성된 두 PDF가 인쇄 결과는 똑같은데 바이트는 다른 경우가 흔한데, 그 이유는 겉모습이 아니라 구조에 있습니다. 객체 번호는 객체가 실제로 기록되는 순서대로 부여됩니다. 폰트 서브셋은 글리프가 처음 등장하는 순서대로 CID를 할당하므로, 조금 다른 순회 과정에서 만들어진 서브셋은 같은 화면 텍스트에 대해서도 다른 콘텐츠 스트림 바이트를 만들어냅니다. 크로스 레퍼런스 오프셋은 상류의 무언가가 길이를 바꿀 때마다 옮겨집니다
그래서 객체 번호는 문서 간 동일성 판단에 쓸 수 없습니다. 대신 HotPDF는 카탈로그부터 순회하며 딕셔너리는 키의 바이트 순서로, 배열은 인덱스로 펼쳐서 각 스냅샷을 구축하므로, 모든 객체는 그 객체에 도달하는 경로로 이름 붙여집니다. 루트에서부터 순회로 도달할 수 없는 객체는 객체 번호와 세대를 담은 합성 $Unreachable[...] 경로로 대체되며, 이는 고아가 된 콘텐츠를 리포트에서 조용히 사라지게 하지 않고 계속 보이게 해 줍니다
스트림은 복사해서 비교하지 않습니다. 각 스트림은 원래 스트림 위치를 이후 복원하면서 계산되는 점진적 SHA-256 서명 하나를 제공하므로, 100메가바이트짜리 파일 두 개를 비교한다고 해서 200메가바이트를 두 번 실체화해야 하는 것은 아닙니다
한쪽 문서에 삽입이 있을 때 페이지 정렬하기
1페이지와 1페이지, 2페이지와 2페이지 하는 식으로 비교하는 방법은 아무것도 삽입되지 않았을 때만 정확합니다. 표지를 하나 끼워 넣으면 순진한 비교는 모든 페이지가 바뀌었다고 보고하는데, 이는 기술적으로는 맞지만 실무적으로는 쓸모가 없습니다
HotPDF는 diff하기 전에 페이지를 정렬합니다. 추출 가능한 텍스트로 페이지마다 서명을 만들고, 텍스트가 없는 페이지는 구조적 서명으로 대체한 다음, 매칭된 타깃 인덱스에 대해 최장 증가 부분 수열을 계산합니다. 이 부분 수열 안에 있는 페이지는 그저 위치가 밀린 페이지이고, 부분 수열 밖에 있는 페이지는 진짜로 변경된 페이지입니다. 이 구분 덕분에 400페이지짜리 매뉴얼의 diff가 읽을 만해지는데, 리포트는 400페이지가 바뀌었다고 하는 대신 한 페이지가 삽입되었다고 말해 주기 때문입니다
구조적 비교 실행하기
가장 단순한 호출은 로드된 문서 두 개와 모드 하나를 받습니다. cmStructural은 객체 그래프 순회를 수행하고, cmRenderedImage는 픽셀 비교를 수행하며, cmFull은 둘 다 수행합니다. 더 가벼운 모드인 cmPageCount, cmPageText, cmObjectCount는 저렴한 스모크 체크용으로 존재합니다:
uses
HPDFDoc, HPDFDocCompare;
var
DocA, DocB: THotPDF;
Report: AnsiString;
begin
DocA := THotPDF.Create(nil);
DocB := THotPDF.Create(nil);
try
if (DocA.LoadFromFile('baseline.pdf') <= 0) or
(DocB.LoadFromFile('candidate.pdf') <= 0) then
Exit;
Report := THPDFDocComparison.Compare(DocA, DocB, cmStructural);
with TFileStream.Create('diff.json', fmCreate) do
try
WriteBuffer(Report[1], Length(Report));
finally
Free;
end;
finally
DocB.Free;
DocA.Free;
end;
end;
이 리포트는 불리언 하나로는 표현할 수 없는 세 가지 상태를 구분합니다. identical은 무언가 달라졌는지를, comparisonComplete는 순회가 끝까지 완료되었는지를, comparisonBudget은 (완료되지 못했다면) 이를 멈추게 한 한계를 나타냅니다. 예산을 다 써버린 비교는 comparisonComplete=false와 identical=false를 함께 보고하는데, 도중에 멈춘 순회는 동일하다고 주장할 근거가 없기 때문입니다. identical만 읽는 자동화는 결국 예산 정지를 실제 차이로 오인하게 되므로, 세 값을 모두 읽으십시오
순회를 제한하는 한계는 무엇일까?
THPDFStructuralCompareLimits.Default의 기본값은 악의적인 문서가 아니라 실제 문서를 기준으로 정해져 있으며, 의미 있는 예산마다 각자의 상한이 있습니다. 객체 250,000개, 엣지 2,000,000개, 깊이 128, 보고되는 차이 10,000개, 스트림당 64MB와 전체 스트림 바이트 합계 512MB, 값당 1MB, 경로당 4,096바이트입니다. 자신의 코퍼스를 알고 있다면 의도적으로 올리고, 외부에서 들어온 파일을 비교한다면 낮추십시오:
var
Limits: THPDFStructuralCompareLimits;
Options: THPDFRenderedCompareOptions;
begin
Limits := THPDFStructuralCompareLimits.Default;
Limits.MaxDifferences := 200; // fail fast in CI
Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;
Options := THPDFRenderedCompareOptions.Default;
Options.DPI := 150; // default is 72
Options.ColorTolerance := 2; // ignore 1-2 level rounding noise
Options.MinimumSimilarity := 0.9995;
Options.MaxChangedPixelRatio := 0.0005;
Options.GenerateHeatmaps := True; // write overlay images for review
Report := THPDFDocComparison.CompareWithOptions(DocA, DocB, cmFull,
Limits, Options);
end;
렌더링 패스는 비트맵을 할당하기 전에 페이지 크기와 요청된 DPI로부터 픽셀 수를 미리 추정한 다음, 실제 비트맵이 만들어진 뒤 다시 확인하므로, 손상된 페이지 지오메트리가 크기를 속여 예산을 몰래 빠져나갈 수는 없습니다. DPI를 높이면 정밀도와 비용이 제곱으로 늘어납니다. 150 DPI는 72 DPI의 네 배에 해당하는 픽셀이며, 페이지당 및 전체 픽셀 상한이 존재하는 것은 바로 300 DPI로 돌아가는 배치 작업이 그렇지 않으면 스스로 문제를 향해 메모리를 할당해 나가기 때문입니다
얼마나 비슷해야 충분히 비슷한 걸까?
두 페이지는 두 조건을 모두 만족할 때만 비슷하다고 간주됩니다. 변경된 픽셀 비율이 MaxChangedPixelRatio 이하이고 유사도가 MinimumSimilarity 이상이어야 합니다. 임계값이 하나가 아니라 둘인 이유는, 소수의 치명적으로 잘못된 픽셀과 폭넓게 퍼진 작은 색상 변화가 서로 다른 종류의 실패이며 어느 쪽이든 하나의 워크플로에서는 허용 가능하고 다른 워크플로에서는 실격 사유가 될 수 있기 때문입니다. 임계값 검사는 반올림되지 않은 값을 사용하며, JSON의 소수점 여섯 자리는 비교를 정의하기 위해서가 아니라 리포트를 안정적이고 diff 가능하게 유지하기 위해 존재합니다
변경된 픽셀은 픽셀 단위 플러드 필이 아니라 4방향 인접을 갖는 고정 크기 타일을 노드로 사용해 영역으로 그룹화됩니다. 이는 메모리를 제한된 상태로 유지하고 실행 사이에도 영역 목록을 안정적으로 유지해 줍니다. 보존되는 영역 세부 정보를 잘라내는 것은 목록에만 영향을 주며 보고된 영역 개수에는 영향을 주지 않으므로, MaxChangedRegions보다 변경된 영역이 더 많은 페이지도 실제로 몇 개였는지는 그대로 보고합니다
보통의 직관을 뒤집는다는 점에서 명확히 짚어둘 만한 동작이 하나 있습니다. 렌더러 실패, 할당 실패, 오버레이 실패는 결코 조용히 삼켜지지 않습니다. 이런 종류는 모두 renderError나 renderBudget으로 기록되고 renderComparisonComplete=false를 강제하는데, 렌더링에 실패한 페이지는 아무도 비교하지 못한 페이지이며 이를 동일하다고 보고하는 것은 아무것도 보고하지 않는 것보다 더 나쁘기 때문입니다
각 모드는 파이프라인 어디에 어울릴까?
구조적 비교는 무엇이 바뀌었는지에 답하며, 경로와 페이지 인덱스와 관련된 객체 번호를 이름 붙여 실패가 이를 만든 코드를 가리키게 하므로 회귀 테스트 스위트의 올바른 기본값입니다. 렌더링 비교는 누군가가 눈치챌지에 답하며, 승인 절차와 최적화 패스가 정말로 무손실이었는지 검증할 때 필요한 질문입니다
둘을 조합하면 잘 어울립니다. 모든 빌드에서 cmStructural을 실행해 예상치 못한 객체 수준 변경에서 즉시 실패하게 하고, 릴리스 전에는 사람이 오버레이를 살펴볼 여유가 있을 때 히트맵과 함께 cmFull을 실행하십시오. 다른 이유로 이미 페이지 마크업을 내보내고 있는 파이프라인이라면, PDF 페이지를 SVG로 내보내기에서 설명한 텍스트 출력이 사람이 직접 diff할 수 있는 세 번째 관점을 제공하며, 프리플라이트 리포트 자동화의 자동 검사는 두 diff 모드 어느 쪽도 답하려 하지 않는 규격 준수 질문을 다룹니다
비교, 프리플라이트, 렌더링은 같은 로드된 문서 객체 모델을 공유하므로, 파일 하나에 대한 단일 패스가 이 셋 모두에 정보를 제공할 수 있습니다. Delphi와 C++Builder를 위한 전체 기능 목록은 HotPDF Delphi PDF 컴포넌트 페이지에 있습니다