PDFlibPas 비주얼 비교는 두 PDF를 같은 DPI로 렌더링해서 리더가 실제로 보는 것을 diff합니다. TPDFlib.CompareRenderedPagesWith는 한 페이지 쌍을 비교하여 변경된 픽셀, 영역, 마스크, 오버레이를 반환합니다. TPDFlib.CompareDocumentVisualsWith와 TPDFlib.CompareFileVisuals는 문서 전체를 페이지 단위로 페어링하고, 삽입 및 삭제된 페이지를 감지하며, 모든 것을 JSON으로 보고합니다
쓰임새는 비주얼 회귀 테스트입니다. 누군가 인보이스 템플릿을 고치고, 폰트 패키지를 올리고, 렌더링 엔진을 갈아끼웁니다. 그때 질문은 바이트가 바뀌었는지(언제나 바뀝니다)가 아니라 고객 중 누구라도 차이를 보게 되는지입니다. 구조적 diff는 다른 질문에 답합니다. 두 파일이 모든 콘텐츠 스트림에서 달라도 페이지는 똑같이 그릴 수 있고, 오퍼랜드 하나만 밀려도 텍스트 추출기가 지적할 만한 건 하나도 건드리지 않은 채 합계가 3포인트 움직일 수 있습니다. "겉모습이 같다"를 검사할 수 있는 유일한 층은 픽셀입니다
비주얼 PDF diff는 실제로 무엇을 비교하나요?
PDFlibPas는 가공되지 않은 버퍼 내용이 아니라 흰 페이지 위에 보이는 결과를 비교합니다. 모든 페이지는 RenderPageToString을 통해 비압축 BMP로 렌더링되고, 탑다운 비트맵으로 디코드되며, 알파를 가진 픽셀은 채널을 비교하기 전에 흰색 위에 합성됩니다. 따라서 화면에서 똑같아 보이는 RGB 비트맵과 RGBA 비트맵은 동일한 것으로 비교되는데, 양쪽이 서로 다른 렌더 경로에서 왔을 때 딱 원하는 동작입니다
BMP 디코더에는 알아 둘 만한 의도된 쿼크가 하나 있습니다. 32비트 BI_RGB 비트맵은 픽셀당 네 번째 바이트를 가지지만, 포맷은 그 바이트를 정의하지 않습니다: 0으로 채우는 작성기도, 255로 채우는 작성기도, 찌꺼기를 넣는 작성기도 있습니다. PLDecodeVisualBMP는 그 바이트를 무시하고 색을 유지합니다. 그 바이트를 불투명도로 취급하면 0을 쓰는 렌더러가 페이지의 모든 픽셀을 지워 버릴 수 있고, diff는 기꺼이 텅 빈 동일한 문서 둘을 보고할 겁니다. 하위 수준 API에 직접 만든 비트맵을 넣을 때는 여러분이 포맷을 vpfRGBA나 vpfBGRA로 선언할 때만 알파가 존중됩니다
픽셀은 빨강, 초록, 파랑 채널 중 하나라도 ColorTolerance보다 크게 다르면 변경된 것으로 간주됩니다. 허용 오차 0은 비트 단위 일치를 뜻합니다. 실전에서는 한 자릿수 값이면 바뀐 글리프를 숨기지 않으면서 렌더러 빌드 사이의 앤티앨리어싱 지터를 흡수합니다. 실제 텍스트 변경은 채널을 그보다 훨씬 크게 움직이기 때문입니다. 숫자는 감으로 정하지 말고 문제없는 것으로 확인된 베이스라인 실행에서 뽑으세요
CompareRenderedPagesWith로 한 페이지 쌍 비교하기
TPDFlib.CompareRenderedPagesWith는 어느 페이지끼리 짝인지 이미 알고 있고 상세 정보가 필요할 때 쓰는 도구입니다: 바운딩 박스, 연결된 변경 영역, 선택적 픽셀별 마스크, 선택적 붉은색 오버레이가 그 대상입니다. 옵션 레코드와 결과 타입은 PDFlibVisualCompare 유닛에 있고, PLDefaultVisualCompareOptions는 허용 오차 0, 32픽셀 타일, 무시 영역 없음, 마스크와 오버레이 없음을 기본으로 줍니다
uses
PDFlibrary, PDFlibVisualCompare;
var
Baseline, Candidate: TPDFlib;
Options: TPDFVisualCompareOptions;
Comparison: TPDFVisualCompareResult;
ErrorText: WideString;
begin
Baseline := TPDFlib.Create;
Candidate := TPDFlib.Create;
try
if (Baseline.LoadFromFile('invoice-v1.pdf', '') = 1) and
(Candidate.LoadFromFile('invoice-v2.pdf', '') = 1) then
begin
Options := PLDefaultVisualCompareOptions;
Options.ColorTolerance := 8;
Options.IncludeOverlay := True;
// 인쇄 타임스탬프를 마스킹합니다; 픽셀 단위는 아래 DPI 기준
SetLength(Options.IgnoreRects, 1);
Options.IgnoreRects[0].Left := 600;
Options.IgnoreRects[0].Top := 20;
Options.IgnoreRects[0].Right := 790;
Options.IgnoreRects[0].Bottom := 50;
if Baseline.CompareRenderedPagesWith(Candidate, 1, 1, 96, Options,
Comparison, ErrorText) then
begin
if Comparison.ChangedPixels > 0 then
Writeln(PLVisualCompareResultToJSON(Comparison));
end
else
Writeln('Compare failed: ', ErrorText);
end;
finally
Candidate.Free;
Baseline.Free;
end;
end;
여기서 사람을 물어 뜯는 세부 사항이 셋 있습니다. 무시 사각형은 렌더링된 픽셀 단위이고 원점이 좌상단이며 반열린 구간이므로, Right와 Bottom은 배타적이고 DPI를 바꾸면 같은 사각형이 페이지의 다른 부분을 덮습니다. 겹치는 무시 사각형은 한 번만 계산되고, 무시된 픽셀은 사라지는 게 아니라 IgnoredPixels에 나타나므로 ChangedRatio가 정직하게 유지됩니다. 비교 대상 픽셀로만 나누기 때문입니다. 마지막으로, 단일 페이지 호출은 양쪽 렌더링에 같은 크기를 요구합니다; 크기가 바뀐 페이지는 늘여서 맞추는 대신 에러로 실패합니다
영역은 타일 패스에서 나옵니다: 변경된 픽셀들이 TileSize 정사각형에 담기고, 인접한 타일들이 자체 경계와 픽셀 수를 가진 하나의 영역으로 병합됩니다. 타일이 클수록 영역이 더 적고 거칠어지는데, 보통 테스트 리포트가 원하는 것은 그쪽입니다. 오버레이는 두 번째 페이지의 RGB 복사본에 변경된 픽셀을 붉게 칠한 것으로, Comparison.Overlay에 생 바이트로 반환됩니다; CI 아티팩트용으로 PNG로 쓰는 것은 라이브러리가 아니라 여러분의 코드 몫입니다
문서 수준 페이지 페어링은 삽입된 페이지를 어떻게 처리하나요?
TPDFlib.CompareDocumentVisualsWith는 정확한 픽셀 지문 위의 최장 공통 부분 수열로 페이지를 페어링하므로, 한 페이지가 삽입되었다고 이후 모든 페이지가 가짜 변경으로 변하지 않습니다. 페이지 1을 페이지 1에, 페이지 2를 페이지 2에 대응시키는 순진한 루프는 템플릿에 표지 시트 하나만 붙어도 무용지물입니다: 삽입 지점 이후의 모든 페이지가 한 칸씩 밀려서 리포트가 위에서 아래까지 빨갛게 불붙습니다
각 페이지는 렌더링된 뒤 픽셀 폭, 높이, 보이는 픽셀의 SHA-256 다이제스트로 이루어진 키로 축약됩니다. 크기가 겉보기보다 중요합니다: 같은 면적의 전부 하얀 세로 페이지와 전부 하얀 가로 페이지는 바이트 단위로 동일한 픽셀 버퍼를 가지며, 키에 크기가 없다면 서로를 앵커로 삼아 버릴 겁니다. 양쪽에서 동일한 키는 앵커가 되고, LCS가 그중 가장 긴 순서 체인을 고르며, 연속한 두 앵커 사이의 페이지들이 순서대로 짝지어집니다. 한쪽 갭이 더 많은 페이지를 담고 있으면 잉여분은 added 또는 removed로 보고됩니다
var
Options: TPDFVisualCompareOptions;
Report: WideString;
begin
// 앞서와 같이 렌더 설정을 맞춰 Baseline과 Candidate를 로드한 상태
Options := PLDefaultVisualCompareOptions;
Options.ColorTolerance := 8;
Options.TileSize := 64;
Report := Baseline.CompareDocumentVisualsWith(Candidate, 96, Options);
if Baseline.LastErrorCode <> 0 then
Writeln('Visual diff failed: ', Report)
else
Writeln(Report);
end;
앵커는 비트 단위 정확이며, 이는 실수가 아니라 설계 선택입니다. ColorTolerance는 짝지어진 페이지의 비교에만 적용되고 지문에는 절대 적용되지 않습니다. 앤티앨리어싱 노이즈뿐인 페이지는 앵커가 되지 않지만 여전히 갭에 들어가 상대편과 순서대로 짝지어지고, 그다음 허용 오차 아래에서 unchanged로 비교됩니다. 퍼지 앵커는 더 관대하지만 훨씬 예측 불가능해집니다. 근사 중복 페이지들(빈 구분 페이지, 반복되는 약관 페이지)이 서로 맞아붙기 시작할 테니까요
이 정렬이 무엇인지는 분명히 합시다. JSON이 일부러 exact-raster-lcs-gap-order라고 부르는 데는 이유가 있습니다: 래스터 정렬이지 의미적 페이지 인식이 아닙니다. 갭에 재작성된 페이지와 삽입된 페이지가 함께 들어 있으면, 순서대로 짝짓기 과정에서 재작성본이 삽입본과 비교되고 원본이 removed로 보고될 수 있습니다. 동일한 페이지로 가득한 문서도 마찬가지로 애매합니다. 판정에 페어링이 중요하다면 카운터만 보지 말고 쌍 자체를 확인하세요
JSON 리포트 읽기
문서 리포트는 테스트 러너에서 단정할 수 있는 고정된 형태를 가집니다. 성공 시 "status":"complete", "complete":true, 정렬 이름, firstPageCount, secondPageCount, 그리고 카운터들 changedPages, addedPages, removedPages, unchangedPages를 담습니다. pairs 배열은 이어서 모든 행을 firstPage, secondPage(없는 쪽은 0), 그리고 unchanged, changed, size_changed, added, removed 중 하나인 kind와 함께 나열합니다
changed와 unchanged 행은 width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox(변화가 없으면 null), 그리고 bounds와 changedPixels로 이루어진 regions 리스트를 담은 comparison 오브젝트를 내장합니다. size_changed 행은 비교를 전혀 담지 않습니다; firstWidth, firstHeight, secondWidth, secondHeight를 보고하고 changedPages에 포함됩니다. 크기가 바뀐 페이지를 늘여서 비교하면 무의미한 변경 픽셀의 벽이 생기므로, 라이브러리는 기하 변화만 보고하고 거기서 멈춥니다
문서 리포트에는 마스크나 오버레이가 절대 담기지 않습니다. CompareDocumentVisualsWith는 옵션이 요구하더라도 IncludeMask와 IncludeOverlay를 강제로 끕니다. 페이지 수가 늘어도 메모리가 평평하게 유지되도록, 모든 쌍에 대해 페이지 전체 미리보기를 들고 있지 않게 하기 위해서입니다. 리포트로 관심 페이지를 찾은 뒤, 그 페이지들에만 CompareRenderedPagesWith를 호출해 오버레이를 받으세요. 실패는 error 문자열과 함께 "status":"error", "complete":false로 돌아오며, LastErrorCode가 401로 설정됩니다
CompareFileVisuals로 파일과 비교하기
TPDFlib.CompareFileVisuals는 CI용 한 번 호출 형태입니다: 다른 PDF를 로드하고 현재 렌더러 설정을 그 문서에 복사한 뒤, 같은 문서 JSON을 반환합니다. 시그니처는 CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance)이며, 허용 오차는 0에서 255 사이여야 하고 DPI는 유한한 양수여야 합니다
var
Lib: TPDFlib;
Report: WideString;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('baseline\statement.pdf', '') = 1 then
begin
Report := Lib.CompareFileVisuals('build\statement.pdf', '', 96, 8);
Writeln(Report);
end;
finally
Lib.Free;
end;
end;
두 번째 파일을 직접 로드하는 대신 이 호출을 선호할 이유는 렌더러 상속입니다. 로드된 문서는 현재 인스턴스의 렌더 스케일, 크롭 타입, 그래픽 설정을 물려받고, 컴파일에 포함되어 있다면 Cairo 또는 PDFium 엔진 선택도 물려받습니다. CompareDocumentVisualsWith를 위해 TPDFlib 인스턴스 둘을 손으로 만든다면 양쪽을 똑같이 설정하는 것은 여러분 책임이고, 불일치는 모든 페이지가 변경된 것으로 나타납니다. 무시 영역도 받을 수 없습니다; 필요하면 타입이 있는 API로 가세요
한도, 비용, 그리고 구조적 diff의 자리
PDFlibPas는 작업량에 명시적 한도를 두어 악의적이거나 거대한 입력이 메모리를 고갈하는 대신 에러로 실패하게 합니다. 렌더링된 페이지는 64 Mi픽셀을 넘을 수 없고, 타일 격자는 4 Mi 타일로 제한되며, 페이지 정렬은 두 페이지 수의 합이 8192를 넘거나 LCS 행렬이 4 Mi 셀을 넘는 입력을 거부합니다. 문서 비교는 또한 모든 페이지를 두 번 렌더링합니다. 지문용 한 번, 페어 비교용 한 번이므로 150 DPI 이상에서는 그 비용을 예산에 넣으세요. 대부분의 회귀 스위트는 72에서 96 DPI만으로도 훨씬 적은 시간에 레이아웃과 텍스트 변경을 잡아 냅니다
비주얼 diff와 구조적 diff는 서로 다른 질문에 답하고, 진지한 파이프라인은 보통 둘 다 원합니다. 픽셀 비교는 리더가 뭔가 눈치챌지 알려 줍니다. 서명된 문서가 페이지 밖에 렌더링되는 주석을 얻었는지, 증분 업데이트가 폼 필드 값을 같은 보이는 텍스트로 조용히 바꿨는지는 알려 줄 수 없습니다. 그 층은 PDF 서명 후 무엇이 바뀌었는지 분류하기에서 리비전 수준 diff를 다룹니다. 그리고 렌더러 선택이 픽셀을 결정하므로, 베이스라인을 한 엔진에 고정하기 전에 Delphi에서의 멀티 엔진 PDF 렌더링을 읽어 볼 가치가 있습니다
FAQ
PDFlibPas 비주얼 비교는 크기가 바뀐 페이지를 늘여서 비교하나요?
아니요. CompareRenderedPagesWith는 픽셀 크기가 다른 페이지에 대해 에러를 반환하고, 문서 리포트는 그런 쌍을 비교하는 대신 양쪽 크기를 담아 size_changed로 표시합니다
근사 동일 페이지를 앵커로 쓰지 않는 이유는 무엇인가요?
앵커는 보이는 픽셀과 페이지 크기의 정확한 SHA-256 지문을 쓰므로 정렬이 예측 가능하게 유지됩니다. 허용 오차는 그 뒤, 짝지어진 페이지를 비교할 때 적용되므로 노이즈뿐인 페이지도 결국 unchanged로 끝납니다
문서의 모든 페이지에 대한 오버레이 이미지를 받을 수 있나요?
문서 호출에서는 안 됩니다. 메모리를 평평하게 유지하려고 일부러 마스크와 오버레이를 생략합니다. JSON으로 변경된 페이지를 찾은 뒤, 그 페이지들에 대해 IncludeOverlay를 넣어 CompareRenderedPagesWith를 호출하세요
비주얼 비교, 페이지 페어링, 그리고 렌더링 및 문서 API의 나머지는 Delphi용 PDFlibPas PDF 라이브러리와 함께 제공됩니다