기술 문서

수작업으로 최소 PDF 만들기: 필요한 5가지 객체

PDF의 핵심은 일반 텍스트(plain-text) 컨테이너라는 점입니다. 대부분의 파일을 16진수(hex) 편집기로 열어보면 상단은 읽을 수 있습니다: 버전 주석이 나오고 번호가 매겨진 객체(objects)들의 배열, 그다음 작은 인덱스, 그리고 뷰어(리더)에게 어디서부터 시작해야 하는지 알려주는 포인터가 맨 아래에 위치합니다. 압축(compression)을 제거하면 텍스트 편집기에 작동하는 문서를 타이핑해서 뷰어로 열어볼 수 있을 정도로 다가가기 쉬운 포맷입니다. 수작업으로 객체들을 서로 연결해보고 배선(wiring)을 올바르게 할 때까지 파일이 열리지 않는 것을 경험해보면, 어떤 사양(specification)을 수없이 읽는 것보다 한 번 직접 해보는 것이 PDF의 구성 원리를 더 잘 가르쳐줍니다

이 안내글은 실제로 무언가를 렌더링하는 가장 작은 PDF를 빌드합니다. US Letter 크기의 종이 1페이지에 기본 내장 글꼴로 쓰인 "Hello, World!"라는 문구가 전부입니다. 완성된 파일에는 정확히 5개의 객체와 그 주위를 감싸는 몇 줄의 부기(bookkeeping) 작업이 필요합니다. 먼저 객체를 작성한 다음, 리더가 수용할 수 있는 하나의 파일로 이들을 묶어줄 헤더, 상호 참조(cross-reference) 테이블, 그리고 트레일러를 조립할 것입니다

뷰어가 요구하는 5가지 객체

리더(뷰어)는 콘텐츠를 찾기 위해 PDF를 위에서 아래로 스캔하지 않습니다. 트레일러에서 시작하여 문서 카탈로그에 대한 참조를 따르고, 그곳에서부터 연결된 객체 체인(chain)을 거닙니다. 그 체인 상의 모든 객체는 존재해야 하며, 하나라도 누락되면 열기에 실패합니다. 1페이지짜리 문서의 경우 체인은 짧으며, 각 연결 고리는 단 하나의 작업만을 수행합니다:

  • 카탈로그(Catalog)는 루트(root)입니다. 트레일러가 가리키는 객체이며, 여기서 유일하게 요구되는 항목은 페이지 트리에 대한 참조입니다
  • 페이지 모음(Pages)은 페이지 트리 노드입니다. 문서의 페이지들을 나열하고 몇 페이지가 있는지 보고합니다
  • 페이지(Page)는 하나의 물리적 페이지를 설명합니다: 크기(size), 그림을 그릴 때 사용하는 리소스, 그리고 어떤 콘텐츠 스트림이 그것을 그려내는지(paints)에 대한 정보입니다
  • 콘텐츠 스트림(Content stream)은 해당 페이지에 텍스트와 그래픽을 배치하는 후위(postfix) 명령인 그리기(drawing) 연산자들을 보관합니다
  • 글꼴(Font)은 콘텐츠 스트림이 참조하는 서체를 선언합니다. 14개의 표준 글꼴 중 하나를 사용하면 아무것도 내장(embed)할 필요가 없습니다

각 객체에는 번호가 지정되어 있고 주소가 있습니다(addressable). 간접 객체(indirect object)는 N 0 obj ... endobj로 작성되며, 여기서 N은 객체 번호이고 0은 세대(generation) 번호입니다(새로 쓰는 파일에서는 항상 0). 파일 내의 다른 어느 곳에서든 참조를 이용해 그 객체를 가리킵니다: 5 0 R은 "객체 5"를 의미합니다. 이러한 참조가 바로 배선(wiring)입니다. 우리가 지정한 번호 체계에 따르면, 카탈로그는 페이지 트리에 접근하기 위해 2 0 R을 유지하고, 페이지 트리는 다시 해당 페이지에 대한 참조를 유지하는 식입니다. 번호를 하나라도 잘못 지정하면 리더가 아무것도 없는(dangling) 포인터를 따라가게 됩니다

이름, 딕셔너리, 그리고 스트림

구문(syntax)의 세 가지 요소가 거의 모든 것을 전달합니다. 이름(name)은 슬래시로 시작합니다: /Type, /Page, /F0. 이름은 대소문자를 구분하는 식별자(identifiers)이지 문자열(strings)이 아니며, PDF는 딕셔너리(dictionary) 키와 객체가 무엇인지 태그하는 데 이들을 사용합니다. 딕셔너리(dictionary)는 모든 키(key)가 이름(name)인 상태로 이중 꺾쇠괄호 안에 래핑된 키-값 쌍의 세트입니다: << /Type /Page /MediaBox [0 0 612 792] >>. 값(Values)은 숫자, 이름, 대괄호로 묶인 배열, 참조 또는 중첩된 딕셔너리가 될 수 있습니다. 대부분의 PDF 객체는 딕셔너리입니다

스트림(stream)은 딕셔너리 뒤에 streamendstream 키워드 사이의 바이트 블록이 이어지는 구조입니다. 그곳이 바로 페이지-그리기(page-drawing) 연산자들이 존재하는 공간이며, 실제 파일에서는 압축된 이미지와 포함(embed)된 글꼴 또한 이곳에 위치합니다. 스트림 딕셔너리는 이 바이트들을 설명합니다. 프로덕션 파일의 경우 정확한 바이트 수를 알려주는 /Length 항목을 반드시 지녀야 하며, 데이터가 압축되었을 때는 주로 /FlateDecode와 같은 /Filter도 갖추어야 합니다. 손으로 바이트를 세는 일은 이 실습에서 교육적인 보상(payoff)도 없고 하나라도 틀리면 파일이 고장 날 확률이 매우 높기 때문에, 우리는 /Length를 채우는 작업을 도구에 의지할 것입니다

객체 작성하기

다음은 순서대로 정렬된 5개의 객체입니다. 콘텐츠 스트림을 읽기 전에 기억해야 할 좌표(coordinate) 세부 사항이 있습니다: PDF는 1포인트가 1/72인치인 단위(points)를 기준으로 페이지의 왼쪽 아래 모서리에서부터 측정하며, Y축은 위로 갈수록 증가합니다. US Letter 페이지의 크기는 612 x 792 포인트이므로 50 700은 아래쪽이 아닌 왼쪽 상단 근처에 위치합니다

1 0 obj
<< /Type /Catalog
   /Pages 2 0 R
>>
endobj

2 0 obj
<< /Type /Pages
   /Kids [3 0 R]
   /Count 1
>>
endobj

3 0 obj
<< /Type /Page
   /Parent 2 0 R
   /MediaBox [0 0 612 792]
   /Resources << /Font << /F0 4 0 R >> >>
   /Contents 5 0 R
>>
endobj

4 0 obj
<< /Type /Font
   /Subtype /Type1
   /BaseFont /Helvetica
>>
endobj

5 0 obj
<< /Length 44 >>
stream
BT
/F0 36 Tf
50 700 Td
(Hello, World!) Tj
ET
endstream
endobj

참조를 읽어보면 그 구조가 확연히 드러납니다. 객체 1(카탈로그)은 /Pages 항목을 객체 2로 가리킵니다. 객체 2(페이지 트리)는 /Kids에 객체 3을 나열하고 /Count 1을 선언합니다. 객체 3(페이지)은 /Parent가 다시 객체 2를 가리키게 하며(트리와 페이지는 서로를 참조해야 함), /MediaBox로 자신의 크기를 정하고 /Resources 내에서 로컬 이름 /F0로 글꼴을 노출하며 /Contents로 객체 5의 이름을 지정합니다. 객체 4는 글꼴(font)입니다: /BaseFont /Helvetica는 규격을 준수하는 모든 리더가 이미 보유하고 있는 14개의 표준 서체 중 하나를 선택하므로 내장할 필요가 없습니다. 객체 5는 콘텐츠 스트림입니다

콘텐츠 스트림이 실제로 뜻하는 바

스트림의 본문은 PDF의 페이지 설명(page-description) 언어로 쓰인 작은 프로그램이며, 피연산자(operands)가 먼저 오고 이를 소비(consume)하는 연산자가 뒤따르는 후위 표기법(postfix)을 사용합니다. 5줄의 코드가 이 역할을 수행합니다. BTET는 텍스트 객체를 열고 닫습니다; 텍스트의 위치를 정하거나 보여주는 모든 것은 이 둘 사이에 있어야 합니다. /F0 36 Tf는 현재 글꼴을 /F0이라는 이름의 리소스로 설정하고 36포인트로 맞춥니다(Tf는 "텍스트 글꼴 및 크기 설정"입니다). 50 700 Td는 텍스트 위치를 페이지 좌표에서 (50, 700)으로 이동시킵니다. (Hello, World!) Tj는 문자열을 표시하며, PDF에서는 문자열을 괄호 안의 리터럴 텍스트로 작성하고 Tj를 이용해 현재 위치에 그립니다(paint). BT/ET를 생략하면 엄격한 뷰어(리더)는 텍스트 연산자를 거부하며, Tj 이전에 글꼴 설정을 잊어버리면 그릴 때 사용할 현재 글꼴이 없게 됩니다

스트림 딕셔너리 내의 /Length 44streamendstream 사이의 바이트 수(count)이며, 정확하게 일치해야 합니다. 특히 여러분의 텍스트 편집기가 줄 바꿈 문자(line endings)를 LF로 쓰느냐 CRLF로 쓰느냐에 따라 합계가 달라지기 때문에 수동으로 개행문자를 세는 것보다 이 값은 도구에 맡기는 것이 현명합니다

헤더, xref, 그리고 트레일러

객체들이 바로 콘텐츠입니다. 그리고 세 가지 구조적인 조각들이 이들을 완전한 파일로 탈바꿈시킵니다. 첫 번째는 포맷(format)과 버전을 명명하는 가장 첫 줄의 헤더입니다:

%PDF-1.7

%는 PDF 구문에서 주석(comment)의 시작을 알리지만, 리더(뷰어)는 이 특별한 주석을 포맷 서명(signature)으로 취급하고 여기서 버전을 읽어 들입니다. 실제(real) 작성기(writer)는 곧바로 하이-비트 바이트(high-bit bytes)로 구성된 두 번째 주석 줄을 뒤따르게 하는데, 이는 파일 전송 도구들에게 이 파일이 바이너리(binary)이며 텍스트처럼 훼손되어서는 안 된다는 힌트를 주기 위함입니다

파일의 끝에는 무작위 액세스(random access)를 가능하게 해주는 인덱스인 상호 참조(cross-reference) 테이블이 옵니다. 이것은 파일의 시작부터 모든 객체의 바이트 오프셋(offset)을 기록하므로, 리더는 객체 1과 2를 먼저 파싱하지 않고 곧장 객체 3을 탐색할 수 있습니다. 이 테이블의 구조는 엄격합니다: 항목(entries)은 고정된 폭(fixed-width)을 가지며, 줄 바꿈 문자를 포함해 각각 20바이트로 이루어져 있습니다. 10자리 오프셋, 5자리 세대(generation), 키워드(사용 중인 객체는 n, 사용 가능한 공간은 f), 그리고 2바이트 종결 문자(terminator) 형식으로 포맷됩니다. 6개의 항목(객체 0은 항상 free-list의 시작입니다)에 대한 올바른 테이블은 다음과 같은 형태를 띱니다:

xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000235 00000 n
0000000308 00000 n
trailer
<< /Size 6
   /Root 1 0 R
>>
startxref
408
%%EOF

이러한 오프셋들은 PDF를 수작업으로 작성할 때 가장 깨지기 쉬운 부분입니다. 각 오프셋은 해당되는 N 0 obj가 시작하는 정확한 바이트 위치이며, 그 윗부분 어느 곳에든 문자 하나를 추가하는 순간 모든 오프셋이 어긋나게 됩니다. 트레일러(trailer)는 리더(뷰어)가 가장 먼저, 그리고 가장 마지막으로 사용하는 진입점입니다: /Root 1 0 R은 카탈로그를 지정하고, /Size 6은 객체의 개수를 명시하며, startxref 408xref라는 단어 자체의 바이트 오프셋을 알려줍니다. 리더는 파일을 열고 끝으로 뛰어넘은 뒤 startxref를 읽고, 상호 참조 테이블을 탐색하여 거기서부터 카탈로그와 그 아래의 모든 것에 도달합니다. %%EOF는 마지막 바이트를 표시합니다

도구가 바이트 개수를 알아서 수정하도록 놔두기

위의 오프셋들은 설명을 위한 예시일 뿐입니다; 파일의 정확한 바이트 배치에 의존하기 때문에 여러분이 타이핑을 끝낼 때쯤이면 실제로는 값이 틀려져 있을 것입니다. 이를 다시 계산하는 대신, 임시 값(placeholder values)으로 구조를 작성하고 유틸리티가 상호 참조 테이블과 스트림 길이를 재구축하도록 맡기십시오. 무료이자 크로스 플랫폼을 지원하는 pdftk는 이를 한 번에 수행합니다:

pdftk hello-draft.pdf output hello.pdf

이 도구는 객체를 파싱(parses)하고, 모든 바이트 오프셋을 재계산하며, 올바른 /Length 값을 채워 넣고, 유효한 xref 테이블과 트레일러를 작성한 다음 hello.pdf를 생성(emits)합니다. 어느 뷰어에서나 이를 열어보면 상단 부근에 36포인트 크기의 Helvetica 글꼴로 적힌 "Hello, World!"가 있는 1페이지 분량의 문서를 확인할 수 있습니다. Qpdf도 같은 역할을 수행하며, 많은 뷰어들 또한 즉석에서(on the fly) 다소 잘못 형성된 파일을 수정(repair)해줍니다. 여기서 도구에 의지하라는 요점은 게으름을 피우라는 뜻이 아닙니다; 오프셋 산술 연산(arithmetic)은 개념적인 내용(conceptual content)은 전혀 없으면서 가장 높은 오류율을 지닌 이 포맷의 한 부분일 뿐이므로, 이를 자동화하면 여러분은 계속해서 구조 그 자체를 배우는 데에만 전념할 수 있습니다

이것이 실제 문서로 확장되는 원리

100페이지짜리 보고서라 하더라도 방금 여러분이 구축한 모양(shape)을 바꾸지는 않습니다. 카탈로그는 여전히 루트(root)에 자리 잡고, 페이지 트리는 여전히 페이지들을 수집하며, 각 페이지는 변함없이 자체 리소스와 콘텐츠 스트림을 가리킵니다. 커지는 것은 뼈대(spine)가 아니라 폭(breadth)입니다: 뷰어가 전체 하위 트리(subtrees)를 건너뛸 수 있도록 페이지 트리가 분기(branches)되고, 콘텐츠 스트림은 5개 대신 수백 개의 연산자를 포함하며, 글꼴은 폭 테이블(width tables) 및 인코딩과 함께 자체 스트림 객체로 포함(embedded)되고, 이미지는 이미지에 특화된 필터가 적용된 스트림으로 도달합니다. 최신 파일들은 또한 많은 객체들을 압축된(compressed) 객체 스트림에 집어넣고 평범한 xref 테이블을 상호 참조 스트림으로 대체하는 경향이 있는데, 이것이 바로 실제 PDF를 텍스트 편집기에서 열었을 때 대개 이진 데이터(binary)의 벽을 마주하게 되는 이유입니다. 기저(underneath)의 모델은 여러분이 수작업으로 만든 파일과 동일합니다. 더 넓은 객체 그래프, 그리고 더 큰 문서 전체에서 카탈로그, 페이지 트리, 리소스 딕셔너리가 어떻게 연관되어 있는지에 대해 다루는 PDF 문서 구조 심층 탐구는 여기서 끝난 내용에 이어 설명을 시작하며, 파일 구조 개요는 증분 업데이트(incremental updates)와 트레일러가 여러 리비전(revisions)에 걸쳐 어떻게 체인으로 연결되는지를 다룹니다

수작업에서 라이브러리로

객체를 손수 타이핑하는 것은 학습을 위한 훈련일 뿐, 프로덕션 기법이 아닙니다. 실제 글꼴(real fonts), 줄 바꿈 된 텍스트, 이미지, 혹은 너무 뻔하지 않은(trivial) 수준 이상의 페이지가 필요한 바로 그 순간부터 pdftk가 땜질해 주었던 바이트 부기(byte bookkeeping)가 주요 작업이 되며, 여러분은 이 골칫거리를 감당해줄 라이브러리를 원하게 됩니다. 똑같은 5개의 객체가 작성되지만, 라이브러리가 여러분을 대신해 매번 오프셋 계산을 하고 글꼴 및 리소스 딕셔너리를 관리하며 단 1바이트를 추적하지 않아도 콘텐츠 스트림을 압축해줍니다. Delphi와 C++Builder용 HotPDF 컴포넌트는 이 전체 파일을 몇 가지 호출(calls)로 줄여 줍니다: 문서를 설정하고 BeginDoc, SetFont, 그리고 TextOut을 호출하여 동일한 인사말을 배치한 후 EndDoc을 통해 올바른 카탈로그, 페이지 트리, xref 및 트레일러를 작성합니다. 그 이면에 깔린 객체들을 이해하는 것이야말로, 문서가 기대한 대로 렌더링 되지 않을 때 결과물의 원인을 추론할 수 있게 해주는 힘입니다