아무것도 그리지 않는 PDF 렌더러는 대개 드로잉 코드 자체에는 버그가 전혀 없습니다. 델파이와 C++Builder용 HotPDF Component에서는 네 가지 별개의 결함이 페이지를 빈 화면으로 렌더링시키는 동안 모든 로그 라인은 깨끗했습니다: 앞에 슬래시가 붙은 이름 오퍼랜드, 뒤집힌 cm 결합, 그리고 항상 0을 읽는 토큰 인덱스가 그것입니다. 그중 어느 것도 예외를 던지지 않았습니다. 어느 것도 로그를 남기지 않았습니다. 콘텐츠 스트림은 정확히 토큰화되었고, 오퍼레이터 디스패처는 모든 오퍼레이터를 인식했으며, 이미지 XObject는 유효한 비트맵으로 디코딩되었는데, 그 뒤 페이지는 텅 빈 채로 나왔습니다. 이런 조합 — 모든 단계에서 성공을 보고하면서 눈에 보이는 것은 아무것도 만들어내지 않는 파이프라인 — 은 실패가 아니라 조용히 빗나가는 룩업이나 인덱스의 전형적인 신호입니다. 이 글은 그런 결함 계열 하나에 대한 부검이며, 그것이 38개 릴리스 동안 살아남게 만든 테스트 규율에 대한 부검이기도 합니다
PDF 렌더러가 아무것도 그리지 않는 이유는 무엇인가
PDF 렌더러에서 리소스 룩업 실패는 빈 페이지와 구별되지 않기 때문입니다. 콘텐츠 스트림의 이름 오퍼랜드와 리소스 딕셔너리 키는 서로 다른 두 문자열 공간인데, HotPDF는 정규화 없이 이 둘을 비교하고 있었습니다. 토크나이저는 /Im0을 읽고 솔리더스(슬래시)를 그대로 유지합니다. 그것이 토큰 그 자체이기 때문입니다. 로드된 /Resources /XObject 딕셔너리는 그 키를 Im0으로 저장합니다. 파서가 딕셔너리 키를 만들 때 구분자를 제거하기 때문입니다. 따라서 오퍼랜드 이름에 대한 모든 FindValue는 -1을 반환했습니다. 그 파급 범위는 이미지보다 넓었습니다. ISO 32000-1 §8.9는 Do를, §8.4는 gs와 그 /ExtGState 룩업을, §8.6은 cs와 CS를, §8.7.4.3은 sh를 다룹니다. 이 다섯 오퍼레이터 모두 리소스 하위 딕셔너리의 키를 원본 오퍼랜드로 삼았으므로, 다섯 모두 실패했습니다. 이름이 붙은 색 공간은 DeviceGray로 폴백되어, 1 scn이 흰 종이 위에 흰 잉크가 되어 버렸습니다. 이미지 XObject는 아예 칠해지지 않았습니다 — 사실상 비트맵 이미지 경로는 출시된 날부터 한 번도 제대로 작동한 적이 없었습니다. 수정은 오퍼랜드로 키가 지정되는 모든 룩업에 적용되는 유닛 레벨 헬퍼이며, 이는 이 규칙이 다시 흐트러지지 않게 지키는 유일한 방법입니다
// Page content stream, the ordinary image-placement idiom:
// q
// /GS0 gs
// 200 0 0 120 60 400 cm
// /Im0 Do
// Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.
function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
Result := N;
if (Result <> '') and (Result[1] = '/') then
Delete(Result, 1, 1);
end;
// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
Exit;
두 번째로, 관련된 놓친 부분이 한 계층 아래에 있었습니다. 렌더러는 스트림과 딕셔너리에 대해서만 타입 지정된 리졸버를 갖고 있었으므로, 흔히 볼 수 있는 /CS0 5 0 R과 그 끝에 있는 [/Separation ...]처럼 최상위 배열 객체를 가리키는 간접 참조는 둘 다에서 nil로 해석되어 미해결 링크로 폴백되었습니다. 범용 객체 리졸버를 추가하는 것으로 이름 붙은 색 공간과 함수 배열이 한 번에 고쳐졌습니다. 셰이딩 딕셔너리를 다루고 있다면 축형 및 방사형 셰이딩 경로에도 같은 해석 규율이 적용됩니다. 그곳에서는 /Function 항목이 매우 자주 간접 참조입니다
cm 오퍼레이터와 거꾸로 작성된 결합
두 번째 결함은 이미지를 페이지에서 약 십만 픽셀 벗어난 위치에 배치했는데, 이는 이미지를 아예 그리지 않은 것과 정확히 똑같아 보입니다. ISO 32000-1 §8.3.4는 PDF 변환을 행 벡터로 정의하며, cm 오퍼레이터는 자신의 오퍼랜드 행렬 M을 현재 변환 행렬(CTM)에 M × CTM 형태로 결합합니다 — M이 먼저 적용되고 기존 CTM은 그 뒤에 적용됩니다. HotPDF는 행렬을 HPDFMatMul(A, B)를 통해 합성하며, 이는 B를 A보다 먼저 적용합니다. 따라서 올바른 호출은 기존 CTM을 A로 전달해야 합니다. 출시된 코드는 오퍼랜드 행렬을 A로 전달했고, 그 결과 CTM × M이 되었습니다
순서가 뒤바뀐 것은 단일 cm에 대해서는 무해하지만, 표준적인 두 단계 관용구에 대해서는 치명적입니다. 1 0 0 1 x y cm에 이어 w 0 0 h 0 0 cm으로 이미지를 배치하면, 올바른 계단식 적용은 단위 정사각형을 (w, h)로 스케일한 다음 (x, y)로 이동시킵니다. 뒤바뀐 계단식 적용에서는 이동이 먼저 들어가고 스케일이 그것을 곱하므로, 명목상 (60, 400)에 있으면서 200 x 120으로 스케일된 이미지는 (12000, 48000)에 착지합니다. 블릿 상단의 클립 테스트가 이를 거부하고, 블릿은 건너뛰어지며, 그 어디에서도 문제를 보고하지 않습니다
// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.
// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)), GS.CTM);
// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)));
이 사례에서 흥미로운 점은 같은 소스 파일 안에 이미 올바른 순서가 존재했다는 것입니다. Form XObject의 /Matrix 항목도 똑같이 뒤바뀐 합성을 갖고 있었지만, Type 3 글리프 경로와 임베드된 글리프 윤곽선 경로는 둘 다 처음부터 올바른 순서를 사용했습니다. 글리프 배치는 뒤집으면 원점으로 눈에 띄게 무너지기 때문에, 누군가 이미 그것을 고칠 수밖에 없었던 것입니다. 서로 다른 두 관례가 하나의 유닛 안에서 삼십여 개 릴리스 동안 공존했고, 각각은 자신의 함수 안에서는 올바르게 보였으며, 어떤 리뷰어도 그것을 알아채지 못했습니다. 두 호출 지점 어느 쪽도 개별적으로 보면 틀려 보이지 않았기 때문입니다
토큰 인덱스가 하나 어긋나면 어떤 일이 일어나는가
구문적으로는 처리되지만 의미적으로는 죽어 있는 열두 개의 오퍼레이터를 얻게 됩니다. 렌더러의 오퍼랜드 접근자는 NumAt(Back)이며, 이는 Tokens[OpIndex - Back]을 읽습니다. OpIndex는 오퍼레이터 토큰 자신의 인덱스입니다. 따라서 단일 오퍼랜드를 갖는 오퍼레이터는 back 1에서 자신의 숫자를 찾습니다. 그중 열두 개가 NumAt(0)으로 작성되어 있었고, 이는 오퍼레이터 토큰 자체를 읽어 ctOperandNumber 종류 검사에 실패한 뒤 기본값인 0을 반환합니다. 그 목록은 ISO 32000-1 §9.3의 텍스트 상태 오퍼레이터인 Tc, Tw, Tz, TL, Ts, Tr와, §8.4.3의 그래픽 상태 오퍼레이터인 w, J, j, M, ri, i입니다. 문자 및 단어 간격은 아무 효과가 없어졌고, 수평 스케일링은 절대 적용되지 않았으며, 행간이 0으로 남아 T*가 결코 줄을 진행시키지 않았고, 텍스트 라이즈는 아무 일도 하지 않았으며, 렌더 모드는 항상 채우기였고, 모든 문서의 모든 스트로크는 선언된 선 굵기와 무관하게 1픽셀짜리 가는 선으로 나왔습니다. m, rg, Tm 같은 다중 오퍼랜드 오퍼레이터들은 NumAt(1..6)을 사용했고 모두 올바랐으므로, 함수를 훑어보는 리뷰어의 눈에는 그럴듯한 인덱스 산술 벽 안에 열두 개의 잘못된 항목이 파묻혀 있는 모습으로 보였습니다
function NumAt(Back: Integer): Double;
begin
Result := 0;
if (OpIndex - Back >= 0)
and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
Result := Tokens[OpIndex - Back].NumValue;
end;
// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1) // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading := NumAt(1) // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w' then GS.LineWidth := NumAt(1) // previously NumAt(0)
테스트 스위트가 38개 버전 동안 계속 통과한 이유는 무엇인가
단언(assertion)이 완전히 렌더링된 페이지와 부분적으로 렌더링된 페이지를 구별하기에 너무 약했기 때문입니다. 렌더링 스모크 테스트는 출력 비트맵이 완전히 검지 않다거나, 페이지가 비어 있지 않다거나, 이미지 다이제스트가 0이 아니라는 식의 것들을 단언했습니다. 텍스트는 렌더링되고 이미지는 렌더링되지 않는 상황에서도 이들 모두가 성립합니다. 텍스트는 문제없이 그려졌으므로 프레임 버퍼가 균일한 적이 없었고, 다이제스트는 0인 적이 없었으며, 사실상 이미지 파이프라인 전체가 죽은 코드였음에도 스위트는 성공을 보고했습니다. 약한 단언은 그래픽 테스트에서 특히 유혹적입니다. 강한 단언은 부서지기 쉬워 보이기 때문입니다. 안티앨리어싱 에지가 1픽셀 이동했다고 깨지는 테스트를 원하는 사람은 없으므로, 자연스러운 후퇴는 어떤 합리적인 변경으로도 위반될 수 없는 무언가를 단언하는 것입니다 — 그리고 그 후퇴는 결국 어떤 비합리적인 변경으로도 위반될 수 없는 술어에 도달하게 됩니다. 분리 색 공간 테스트는 출력이 검정과 구별 가능하다는 것을 단언했습니다. 흰 바탕에 회색도 이를 통과했고, 흰 바탕에 흰색도 통과했습니다. 그 테스트는 올바른 색이 칠해졌는지를 측정하고 있지 않았습니다. 캔버스에 무엇이든 일어났는지만 측정하고 있었습니다
실제로 실패하는 렌더링 단언은 어떻게 작성하는가
기대하는 색의 픽셀을 기대하는 개수만큼 세고, 위치와 크기는 그 개수로부터 자연스럽게 도출되게 하는 것입니다. 대체 규율은 손으로 만든 최소한의 PDF, 파일당 하나의 시각적 사실, 그리고 특정 RGB 삼중값에서 허용 오차 이내에 몇 개의 픽셀이 들어오는지에 대한 단언입니다. 알려진 오프셋에 배치된 순수 빨간색의 200 x 120 이미지는 대략 24000개의 빨간 픽셀을 만들어내야 합니다. 리소스 룩업이 빗나가면 개수는 0입니다. cm 계단식 적용이 뒤바뀌면 개수는 0입니다. 이미지가 잘못된 색 공간으로 렌더링되면 개수는 0입니다. 숫자 하나가 세 가지 모두를 잡아내며, 허용 오차 대역은 사람들이 애초에 정확한 비교를 꺼리게 만들었던 안티앨리어싱 노이즈를 흡수합니다
function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
X, Y: Integer;
C: TColor;
begin
Result := 0;
for Y := 0 to Bmp.Height - 1 do
for X := 0 to Bmp.Width - 1 do
begin
C := Bmp.Canvas.Pixels[X, Y];
if (Abs(GetRValue(C) - R) <= Tol)
and (Abs(GetGValue(C) - G) <= Tol)
and (Abs(GetBValue(C) - B) <= Tol) then
Inc(Result);
end;
end;
// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
'image XObject was never drawn');
네 개의 스모크 테스트가 이 방식으로 다시 작성되었습니다 — Type 4 틴트 변환, 이미지 Do 배치, 선택적 콘텐츠 가시성 케이스, Tr 스트로크 모드 — 이들을 합쳐 이 결함 계열 전체가 드러났습니다. 그것이 진짜 교훈이며, 이 코드베이스를 넘어서까지 일반화됩니다: 렌더링 파이프라인에서 단언은 색상을 명시해야 합니다. 그보다 부드러운 것은 렌더러가 실행되었는지 확인하는 검사이지, 실제로 그려냈는지 확인하는 검사가 아닙니다. 직접 페이지-투-비트맵 하네스를 구축하고 있다면, 페이지 래스터화 안내서가 여러분의 첫 회귀 테스트에 픽셀 카운트 헬퍼를 붙이기에 자연스러운 자리입니다
솔직한 한계
두 가지 한계는 분명히 밝혀둘 가치가 있습니다. 텍스트 클리핑 렌더 모드 4에서 7까지는 각각의 기본 채우기 또는 스트로크 모드로 그려집니다. 렌더러가 글리프 윤곽선으로부터 누적된 클립 경로를 모델링하지 않기 때문입니다. 텍스트 형태의 클리핑에 의존하는 문서는 그 아래 클리핑된 아트워크 대신 텍스트 자체를 렌더링하게 됩니다. 그리고 여기서 설명한 픽셀 카운트 규율은 스모크 테스트 기법이지 적합성 검증 스위트가 아닙니다 — 특정한 시각적 사실이 프레임 버퍼에 도달했다는 것을 증명할 뿐이며, 이는 출력이 참조 래스터라이저와 일치한다는 것을 증명하는 것보다 훨씬 낮은 기준입니다. 하지만 이는 정확히 이 네 가지 버그가 3년간의 릴리스 동안 넘지 못했던 기준이기도 합니다
여기서 다룬 렌더러는 델파이와 C++Builder용 표준 HotPDF Component의 일부로 제공됩니다. 제품 페이지에는 비트맵 캐시와 백그라운드 프리페치 진입점을 포함한 전체 페이지 렌더링 API 레퍼런스가 있습니다