PDF 페이지는 픽셀을 저장하지 않으며, SVG처럼 도형 객체 트리를 저장하지도 않습니다. 대신 프로그램을 저장합니다. 페이지에 나타나는 모든 선, 곡선, 채우기 및 배치된 이미지는 실행 중인 그래픽 상태를 기준으로 콘텐츠 스트림의 연산자 시퀀스를 위에서 아래로 실행한 결과입니다. 이 사실 하나를 이해하면 파일 형식의 대부분의 동작이 놀랍지 않게 다가옵니다: 왜 경로를 만든 후 별도의 페인팅 연산자로 채우기를 해야 하는지, 색상과 선 너비가 블록으로 묶어두지 않으면 어떻게 한 도형에서 다음 도형으로 번지는지, 단일 좌표 변환 후 왜 동일한 그리기 코드가 완전히 다른 곳에 나타나는지 말입니다. 이 글은 ISO 32000에 정의된 실행 모델, 즉 콘텐츠 스트림을 열 때 만나게 되는 연산자와 페이지에 표시되는 내용을 결정하는 규칙에 대한 안내입니다
콘텐츠 스트림은 후위 표기법(postfix) 바이트코드입니다
콘텐츠 스트림은 피연산자(operand) 뒤에 연산자(operator)가 오는 평면적인 바이트 시퀀스입니다. 피연산자가 먼저 오고 이를 소비하는 연산자가 마지막에 오는데, 이는 함수 호출의 역순이며 스택 머신과 동일합니다: 숫자를 먼저 밀어 넣고 동사를 발행합니다. 중첩도, 표현식 구문도, 변수도 없습니다. 삼각형 윤곽선은 다음 5줄로 표현됩니다:
100 100 m % moveto: (100, 100)에서 새 하위 경로(subpath) 시작
200 200 l % lineto: (200, 200)으로 선분 추가
300 100 l % lineto: (300, 100)으로 선분 추가
h % closepath: 시작점으로 다시 연결
S % stroke: 경로 윤곽선 그리기
연산자들은 의도적으로 간결합니다. 실제 페이지는 이러한 코드가 수천 개 있으며, 일반적으로 FlateDecode로 압축됩니다. 이러한 간결함의 대가는 스트림이 질의할 수 있는 구조를 가지지 않는다는 것입니다: 뷰어는 "이 페이지의 제목이 어디에 있습니까?"라고 물을 수 없고, 단지 프로그램을 실행하여 어떤 잉크가 어디에 위치하는지 볼 수 있을 뿐입니다. 이것이 임의의 PDF에서 텍스트 추출이 어려운 근본적인 이유입니다
원점은 왼쪽 하단이며, Y는 위로 증가합니다
어떤 좌표가 의미를 가지려면 (0, 0)이 어디인지 알아야 합니다. PDF는 원점을 페이지의 왼쪽 하단 모서리에 두며, X는 오른쪽으로, Y는 위쪽으로 증가하고, 단위는 1인치당 72포인트(ISO 32000-2 §8.3.2)입니다. US Letter 페이지에서 상단 가장자리는 y = 0이 아니라 y = 792에 위치합니다. 원점이 왼쪽 상단이고 Y가 아래로 증가하는 화면 그래픽에 익숙한 사람이라면 처음에는 이를 거꾸로 이해하여 첫 번째 선을 페이지 하단 밖으로 그리게 될 것입니다. 또한 단위는 매체와 무관합니다: 72단위는 페이지가 전화기 화면에 렌더링되든 이미지세터에 렌더링되든 1인치입니다
대부분의 페이지 그리기 라이브러리는 이 규칙을 직접 상속합니다. 예를 들어, HotPDF에서 TextOut과 경로 호출은 모두 왼쪽 하단에서부터 포인트를 측정하므로, 페이지 높이에 가까운 값을 사용하면 콘텐츠가 맨 위에 놓입니다:
// HotPDF, Delphi: y는 하단 가장자리부터 위쪽으로 포인트 단위로 측정
Pdf.CurrentPage.SetLineWidth(2.0);
Pdf.CurrentPage.MoveTo(100, 700); // 페이지 상단 부근
Pdf.CurrentPage.LineTo(300, 700);
Pdf.CurrentPage.Stroke; // moveto/lineto/stroke 연산자 발생
이 호출 시퀀스는 컴파일되어 정확히 위의 m, l, S 연산자가 됩니다. 라이브러리는 콘텐츠 스트림의 타이피스트일 뿐이며, 라이브러리가 무엇을 발생시키는지 아는 것이 도형이 예상치 못한 곳에 나타났을 때 결과를 추론할 수 있게 해줍니다
경로를 만들고, 그 다음에 페인트합니다
PDF는 경로 구성(path construction)과 경로 페인팅(path painting)을 구분하며, 이 구분은 단지 학구적인 것이 아닙니다. 먼저 시각적으로 아무것도 추가하지 않는 구성 연산자로 도형을 설명한 다음, 누적된 경로로 무엇을 할지 결정하는 단일 페인팅 연산자를 발행합니다. 동일한 삼각형이 윤곽선이 될 수도 있고, 단색 채우기가 될 수도 있으며, 혹은 둘 다 될 수도 있는데, 이는 전적으로 여러분이 마지막에 사용하는 동사에 달려 있습니다
구성 연산자는 몇 개 되지 않습니다. m은 지정된 점에서 새로운 하위 경로를 시작합니다. l은 직선 세그먼트를 추가합니다. c는 두 개의 제어점과 하나의 끝점 등 6개의 피연산자로부터 3차 베지에(Bézier) 곡선을 추가합니다. re는 x, y, 너비, 높이 등 4개의 값으로부터 전체 직사각형을 추가하는 단축키입니다. h는 현재 하위 경로를 시작점으로 되돌려 닫습니다. 이 중 어느 것도 페이지에 잉크를 묻히지 않으며 오직 기하학 정보만 누적합니다
200 250 m % 하위 경로 시작
300 350 400 450 500 250 c % 3차 베지에: 두 개의 제어점, 그 다음 끝점
150 200 re % 150 x 200 직사각형, 독립된 하위 경로로 추가
h % 닫기
원래의 예제에서는 현재 폐기된 y 변형 곡선 연산자를 사용했지만, 실제 사용 시에는 명시적인 3개의 점을 가지는 c 형태를 접하게 되며 이를 사용해야 합니다. 경로가 생성되면 단일 페인팅 연산자가 이를 완성합니다. 어휘는 적지만 모든 페이지의 모든 도형은 다음 중 하나로 끝나기 때문에 외워둘 가치가 있습니다:
S는 현재의 선 너비와 선 색상을 사용하여 경로 윤곽선을 그립니다f는 현재의 채우기 색상과 0이 아닌 와인딩 규칙(nonzero winding rule)을 사용하여 내부를 채웁니다f*는 짝수-홀수 규칙(even-odd rule)을 사용하여 채우며, 이는 자기 교차형(self-intersecting) 도형이나 구멍이 있는 도형에 중요합니다B는 채우기와 윤곽선 그리기를 한 번에 수행합니다;b는 그에 앞서 경로를 먼저 닫습니다n은 아무것도 그리지 않으며, 이는 보이는 표시를 남기지 않고 경로가 잘라내기(clip) 영역이 되는 방식입니다
와인딩 규칙은 사람들이 자주 헷갈리는 부분입니다. Nonzero(f, B)는 테스트 지점에서의 광선(ray) 교차 횟수를 부호 단위로 계산하여 그 값이 0이 아닌 모든 영역을 채우므로, 구멍은 해당 하위 경로가 외부 경로와 반대 방향으로 와인딩되어 있을 때만 비어 있게 됩니다. 짝수-홀수(f*, B*)는 방향에 관계없이 교차할 때마다 상태를 전환합니다. 만약 "도넛" 모양의 도형이 전체가 색칠되어 나온다면 내부 원이 외부 원과 동일한 방향으로 와인딩되어 있는 것이므로 방향을 바꾸거나 짝수-홀수 규칙으로 전환해야 합니다
색상은 매개변수가 아닌 모드(mode)입니다
콘텐츠 스트림의 색상은 지속적입니다. 한 번 색상을 설정하면 다른 색상을 설정하거나 이전 상태를 복원할 때까지 그 색상이 유지되며, 이 때문에 괄호로 묶이지 않은 색상 변경은 그 이후에 그려지는 모든 항목에 조용히 색을 입히게 됩니다. 또한 PDF는 채우기 색상과 선(stroke) 색상을 독립적인 설정으로 유지하며, 채우기에는 소문자 연산자를, 선에는 대문자 연산자를 사용합니다. 장치(device) 색상 공간은 각각의 속기를 가집니다:
0.5 g % DeviceGray 채우기, 중간 회색 (0 = 검정, 1 = 흰색)
0.2 0.6 0.8 rg % DeviceRGB 채우기
0.8 0.2 0.1 RG % DeviceRGB 선 (대문자 = 선)
0.2 0.8 0.0 0.1 k % DeviceCMYK 채우기
DeviceRGB는 화면 출력에 적합하고, DeviceCMYK는 인쇄물 제작에 필요하며, DeviceGray는 단색 콘텐츠를 위한 가장 간결한 선택입니다. 이러한 장치 공간은 편리하지만 보정되지 않았습니다: 동일한 RGB 값이 두 대의 모니터에서 다르게 렌더링될 수 있으며, 이것이 ICC 기반 색상 공간과 PDF/A 출력 의도(output intent)가 해결하고자 하는 문제입니다. 색상에 민감한 작업의 경우 cs와 CS로 보정된 공간을 선택하고 sc와 scn으로 구성 요소를 설정하지만, 일반적인 문서에서는 장치 속기가 이 역할을 담당합니다. 라이브러리는 이러한 것들을 타입화된 호출로 감싸 제공합니다. 예를 들어, HotPDF는 단일 TColor를 받아 일치하는 연산자를 발생시킵니다:
Pdf.CurrentPage.SetRGBFillColor(clRed);
Pdf.CurrentPage.Rectangle(100, 100, 200, 150); // x, y, 너비, 높이
Pdf.CurrentPage.Fill;
Pdf.CurrentPage.SetRGBFillColor(RGB(0, 255, 0));
Pdf.CurrentPage.Circle(150, 400, 50); // x, y, 반경
Pdf.CurrentPage.Fill;
그래픽 상태와 q/Q 스택
경로 자체를 제외한 모든 것은 그래픽 상태(graphics state)에 존재합니다: 현재 변환 행렬, 채우기 및 선 색상, 선 너비, 파선(dash) 패턴, 잘라내기 영역, 알파(투명도). 상태는 전역적이고 변경 가능하므로 지역적인 변경을 가하는 유일한 안전한 방법은 전체 상태를 저장하고 변경하여 그린 후 다시 되돌리는 것입니다. 그것이 q와 Q가 하는 일입니다. q는 현재 상태의 복사본을 스택에 밀어넣고(push), Q는 매칭되는 q 이후의 모든 변경 사항을 폐기하며 꺼냅니다(pop)
q % 전체 그래픽 상태 저장
2 0 0 2 100 100 cm % 변환 행렬 병합: 2배 축척, (100,100)으로 이동
0.8 g % 회색 채우기, 이 블록에 한정됨
% ... 축척이 적용된 회색 콘텐츠 그리기 ...
Q % 복원: 변환 및 색상 복구
균형을 잃은 q와 Q는 수동으로 구축되거나 이어 붙여진 콘텐츠 스트림이 잘못되는 가장 흔한 원인입니다. 매칭되는 Q가 없는 떠돌이 q는 페이지가 끝날 때 스택을 깊게 남기고, 불필요한 Q는 언더플로를 유발합니다. 어느 쪽이든 뷰어는 이전의 잘라내기나 변환을 강제로 유지할 수 있으며, 그로 인해 콘텐츠가 사라지거나 잘못된 위치에 놓이게 됩니다. 경로로 설명할 수 없는 이유로 그래픽이 사라진 경우 가장 먼저 상태 스택을 검토하십시오
CTM은 모든 좌표를 변환합니다
현재 변환 행렬(Current Transformation Matrix, CTM)은 연산자의 숫자와 실제 페이지 사이에 위치합니다. 모든 좌표는 무엇인가 그려지기 전에 CTM에 곱해지므로 행렬을 변경하면 단일 경로 좌표를 건드리지 않고도 모든 후속 드로잉이 나타나는 위치와 방법이 변경됩니다. cm 연산자는 새로운 행렬을 현재 행렬에 병합(concatenate)하며 아핀(affine) 행렬 [a b c d e f]에 매핑되는 6개의 피연산자를 취합니다:
1 0 0 1 100 50 cm % (100, 50)만큼 이동: e와 f가 오프셋 전달
2 0 0 1.5 0 0 cm % x축 2배, y축 1.5배 축척: a와 d는 축척 요소
0.707 0.707 -0.707 0.707 0 0 cm % 45도 회전 (a, b, c, d에 cos/sin 적용)
이와 관련해 사람들은 두 가지 측면에서 실수를 합니다. 첫째, cm은 행렬을 대체하는 것이 아니라 합성(compose)하므로 변환이 누적되며 순서가 중요합니다: 확대 후 이동은 이동 후 확대와 동일하지 않습니다. 둘째, 회전과 축척은 도형의 중심이 아니라 현재 원점을 중심으로 회전(pivot)하므로 어떤 대상을 제자리에서 회전시키려면 원점으로 이동, 회전, 원래 위치로 이동의 과정을 거쳐야 하며 이 모두가 q/Q로 묶여 있어야 합니다. 이 행렬이 이미지를 배치하는 역할을 하기도 하는데, 마지막으로 살펴볼 부분입니다
이미지 및 재사용 가능한 콘텐츠는 XObject입니다
래스터(Raster) 이미지는 콘텐츠 스트림 내에 인라인으로 존재하지 않습니다. 이들은 이미지 XObject로 저장되는데, 너비, 높이, 비트 깊이, 색상 공간, 압축 필터를 설명하는 자체 딕셔너리를 가진 외부 객체이며, 콘텐츠 스트림은 이를 참조할 뿐입니다. JPEG 기반 사진은 다음과 같이 선언됩니다:
/Photo <<
/Type /XObject
/Subtype /Image
/Width 640
/Height 480
/BitsPerComponent 8
/ColorSpace /DeviceRGB
/Filter /DCTDecode % 이미지 데이터는 JPEG 스트림
>>
이미지 XObject는 단위 정사각형(unit square) 안에 그려집니다: 항상 사용자 공간의 (0, 0)에서 (1, 1)까지의 영역을 차지합니다. 사용자는 이미지에 위치나 크기를 전달하지 않습니다. 대신 단위 정사각형이 원하는 직사각형에 매핑되도록 CTM을 설정한 다음 Do를 사용해 이미지를 호출합니다. 그렇기 때문에 이미지를 배치하는 것은 항상 변환 후 호출의 과정을 거치며, 해당 배율이 다음 연산에 영향을 미치지 않도록 상태 저장/복원으로 감싸야 합니다:
q
640 0 0 480 50 300 cm % 단위 정사각형을 (50, 300)의 640x480 크기로 매핑
/Photo Do % 이미지 XObject 칠하기
Q
이 Do 메커니즘은 폼 XObject에도 동일하게 적용되는데, 이는 재사용 가능한 그래픽 청크, 즉 로고나 반복되는 스탬프를 자체 경계 상자(bounding box)가 있는 개별 콘텐츠 스트림으로 저장합니다. 한 번 정의하고 다른 CTM으로 여러 번 호출하면 파일의 바이트는 단 한 번만 나타납니다. 대부분의 라이브러리는 이를 단일 배치 호출 뒤에 숨깁니다: HotPDF는 AddImage로 비트맵을 등록하고 사용자가 행렬을 수동으로 구축하는 대신 명시적인 x, y, 너비, 높이를 취하는 ShowImage를 사용해 비트맵을 배치합니다:
var
Bmp: TBitmap;
ImgIndex: Integer;
begin
Bmp := TBitmap.Create;
try
Bmp.LoadFromFile('logo.bmp');
ImgIndex := Pdf.AddImage(Bmp, icFlate);
// x, y (왼쪽 하단), 너비, 높이, 회전 각도
Pdf.CurrentPage.ShowImage(ImgIndex, 50, 300, 200, 150, 0);
finally
Bmp.Free;
end;
end;
이 한 줄의 코드 아래에서 라이브러리는 이미지 XObject 딕셔너리를 작성하고, CTM을 설정하여 단위 정사각형의 크기와 위치를 지정한 뒤 Do를 호출합니다. 모든 기이한 결과를 설명해주기 때문에 이 아래의 모델을 이해하는 것은 그만한 가치가 있습니다: 늘어난 이미지는 배율이 맞지 않는 CTM의 결과이고, 40페이지에 걸쳐 동일한 로고가 들어간 경우 폼 XObject 하나가 40번 호출된 것이며, 거꾸로 렌더링된 이미지는 손상된 파일이 아니라 행렬의 부호가 뒤집힌 것입니다
결론
그래픽 모델은 구조를 파악하고 나면 매우 간단합니다. 콘텐츠 스트림은 가변 상태를 바탕으로 실행되는 후위 표기법 바이트코드입니다. 좌표는 왼쪽 하단에서 시작하여 CTM을 거칩니다. 경로는 보이지 않게(silently) 생성되며 의도적인 하나의 연산자로 페인팅됩니다. 색상 및 선 설정은 q/Q로 묶어둘 때까지 지속됩니다. 이미지 및 재사용 가능한 그래픽은 단위 정사각형을 변환하여 배치되는 XObject입니다. 거의 모든 혼란스러운 렌더링 결과는 이 5가지 규칙 중 하나로 귀결됩니다. 만약 이러한 그래픽 연산자들이 페이지 딕셔너리나 이들을 가리키는 상호 참조 테이블 등 더 큰 객체 모델 내에서 어떻게 위치하는지 보고 싶다면 PDF 파일 구조에 대한 기술 개요에서 해당 계층을 다루고 있으며, 처음부터 단순한 PDF 만들기에서는 바이트 구성을 끝까지 살펴볼 수 있습니다. 텍스트 그리기는 자체 연산자 제품군과 함정을 지니고 있으며, 이는 자매글인 PDF 텍스트 및 글꼴 처리에서 설명합니다
여기서 살펴본 MoveTo, LineTo, Stroke, Rectangle, Fill, SetRGBFillColor, AddImage, ShowImage와 같은 Delphi 그리기 호출은 사용자를 대신해 이 콘텐츠 스트림 연산자를 발생시키는 Delphi 및 C++Builder용 HotPDF 컴포넌트의 일부입니다