기술 문서

PDF 파일 구조: 보편적 문서 형식이 작동하는 방식

같은 인보이스 PDF를 Windows와 휴대폰에서 열어 보면 줄바꿈과 폰트, 로고가 똑같은 자리에 착지합니다. PDF 파일은 번호를 매긴 객체의 집합에 바이트 오프셋 인덱스를 더한 것이고, PDFium Component 안의 PDFium 엔진 같은 리더는 끝에서부터 엽니다. startxref, xref 테이블, 카탈로그, 페이지 트리, 콘텐츠 스트림, 그다음 픽셀 순입니다

그렇다면 PDF 파일이란 무엇이고, 어디서나 똑같이 보이는 이유는 무엇일까요? Word와 RTF 문서는 텍스트와 스타일을 저장하고 레이아웃을 여는 기계에 맡기므로, 빠진 폰트나 다른 프린터 드라이버가 페이지를 재조판합니다. PDF는 대신 완성된 결과를 저장합니다. 모든 글리프와 줄이 포인트로 잰 좌표 공간에서 절대 위치를 지니고, 폰트 프로그램이 파일 안을 여행하며, 이미지는 자기 색 데이터와 압축 필터를 실어 옵니다. 보는 시점에 배치되는 것은 아무것도 없고, 어떤 운영체제의 어떤 규격 렌더러든 같은 페이지를 칠하는 이유가 여기 있습니다. 형식은 1993년 Acrobat과 함께 PDF 1.0으로 시작해 PostScript 이미징 모델 위에 세워졌고, 리비전마다 아래에서 기술할 기계장치의 한 부분을 더했습니다:

  • PDF 1.1부터 1.4까지는 암호화와 인터랙티브 양식, 논리 구조를 갖춘 디지털 서명, 투명도를 가져왔습니다
  • PDF 1.5는 객체 스트림과 상호 참조 스트림을 더했고, PDF 1.6은 AES 암호화를 더했습니다
  • PDF 1.7은 ISO 32000-1:2008이 되었고, PDF 2.0은 2020년 개정된 ISO 32000-2:2017이 되었습니다
  • PDF/A(ISO 19005), PDF/X(ISO 15930), PDF/UA(ISO 14289)는 보관과 인쇄 생산, 접근성을 위해 그 기반을 제한합니다

리더가 PDF 파일을 열 때 무엇이 일어나는가?

PDF 열기는 파일의 마지막 바이트에서 시작해 비트맵으로 끝나는 조회의 체인입니다. 그 체인의 거의 모든 고리는 ISO 32000-1 §7.3의 기본 객체 타입 중 하나인 딕셔너리이고, 딕셔너리들은 7 0 R(객체 7, 세대 0) 같은 간접 참조로 서로를 가리킵니다. 그래서 파일은 페이지의 나열이 아니라 객체의 그래프처럼 행동합니다. 헤더와 본문, 상호 참조 테이블, 트레일러의 배치는 PDF 파일 구조 개요에 있고, 줄마다 주석을 단 최소 PDF가 obj, endobj, stream이 텍스트 편집기에서 어떻게 보이는지 보여 줍니다. 모든 리더가 따르는 순서는 다음과 같습니다

  1. 파일 끝으로 탐색해 startxref 키워드 뒤에 적힌 바이트 오프셋을 읽습니다
  2. 그 오프셋의 상호 참조 데이터를 적재합니다. 모든 객체 번호를 파일 안의 위치로 매핑하는 데이터입니다
  3. 트레일러를 읽고 /Root를 따라 문서 카탈로그에 갑니다
  4. 카탈로그의 /Pages 항목을 따라 페이지 트리를 내려가 요청된 페이지 객체에 이릅니다
  5. 페이지의 콘텐츠 스트림을 디코딩하고 그 연산자들을 순서대로 해석합니다
  6. 그 연산자들이 /Resources로 이름 짓는 폰트와 이미지, Form XObject를 해석합니다
  7. 경로와 글리프 윤곽, 이미지를 장치 해상도의 픽셀로 래스터화합니다

첫 단계: startxref와 상호 참조 테이블

리더는 PDF를 상단부터 결코 스캔하지 않습니다. 잘 구성된 파일은 startxref 키워드와 48211 같은 십진수, %%EOF로 끝나고, 그 숫자가 상호 참조 데이터의 바이트 오프셋입니다. xref를 향한 순진한 뒤쪽 검색은 startxref 안에서 멈추므로, 완전한 키워드를 찾으십시오. 클래식 테이블은 고정 폭 항목을 씁니다(§7.5.4). 리더가 다른 항목을 파싱하지 않고 어떤 객체 번호의 항목으로든 뛰어갈 수 있습니다. 각 항목의 바이트 배치는 구조 개요가 다룹니다. 테이블 뒤의 트레일러는 /Size, /Root와 선택적으로 /Encrypt, /ID, /Prev를 이름 짓습니다

PDF가 작동하는 방식: 헤더와 객체 본문부터 상호 참조 테이블을 거쳐 트레일러까지 PDF 파일의 세로 해부도
상호 참조 테이블이 객체 번호를 바이트 오프셋으로 바꾸므로, 리더는 나머지를 훑지 않고 어떤 페이지로든 곧장 탐색합니다

모든 항목은 절대 바이트 위치이므로, 줄 끝을 다시 쓰는 편집기처럼 바이트를 미는 무엇이든 인덱스를 잘못된 자리로 남겨 둡니다. 복구 패스를 지닌 리더는 obj 키워드를 스캔해 인덱스를 다시 짓고, 없는 리더는 파일을 거부합니다. 그래서 손상된 PDF가 한 뷰어에서는 열리고 다른 뷰어에서는 실패합니다. startxref 오프셋에 앉은 것이 어느 사례인지 알려 줍니다. 클래식 테이블이라면 xref 키워드, PDF 1.5 상호 참조 스트림이라면 N 0 obj 헤더, 낡은 오프셋이라면 그 외의 무엇입니다. 아래 헬퍼는 평범한 RTL 스트림 위에 지은 여러분 자신의 코드입니다

uses
  System.SysUtils, System.Classes;

// 자작 헬퍼: Offset은 startxref 뒤에 적힌 숫자다.
// 리더가 그 위치에서 발견할 것을 분류한다
function DescribeXrefAt(const FileName: string; Offset: Int64): string;
var
  FS: TFileStream;
  Buf: TBytes;
  Head: string;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if Offset >= FS.Size then
      Exit('offset beyond end of file: the file needs a repair pass');
    FS.Position := Offset;
    SetLength(Buf, 32);
    SetLength(Buf, FS.Read(Buf[0], Length(Buf)));
  finally
    FS.Free;
  end;
  Head := TEncoding.ASCII.GetString(Buf);
  if Head.StartsWith('xref') then
    Result := 'classic cross-reference table'
  else if Head.Contains(' obj') then
    Result := 'cross-reference stream (PDF 1.5 or later)'
  else
    Result := 'stale offset: the file needs a repair pass';
end;

상호 참조 스트림(§7.5.8)은 /Type /XRef와 각 열의 바이트 폭을 주는 /W 배열, 바이트 오프셋이나 객체 스트림(§7.5.7)을 가리키는 이진 행을 지닌 압축 객체입니다. 트레일러 키들이 그 딕셔너리로 옮겨가므로, 파일에는 trailer 키워드가 아예 없습니다. THotPDF에 UseXRefStream이 설정되면 HotPDF는 이 형태로 씁니다. 메일 첨부나 인쇄소 업로드처럼 여러분 손을 떠나는 파일에는 클래식 테이블이 더 안전한 선택으로 남습니다. PDF 1.5 이전 리더가 여전히 그것을 파싱하기 때문입니다. PDF/A-1은 보관용 문서의 이 질문을 종결시킵니다. ISO 19005-1은 PDF 1.4 위에 서고 §6.1.4는 상호 참조 스트림을 써서는 안 된다고 말하므로, HotPDF는 PDF/A-1 프로파일이 활성인 동안 클래식 테이블을 씁니다

48 0 obj
<< /Type /XRef /Size 49 /Root 1 0 R
   /W [1 3 1] /Index [0 49]
   /Filter /FlateDecode /Length 162 >>
stream
...binary rows: 1 + 3 + 1 bytes each...
endstream
endobj
startxref
48211
%%EOF

/Root에서 맞는 페이지까지: 카탈로그와 페이지 트리

트레일러의 /Root는 문서 카탈로그를 가리키고, 카탈로그의 /Pages는 페이지 트리의 루트를 가리킵니다. 중간의 모든 /Pages 노드는 아래 리프 페이지 수의 /Count를 지니므로, 5,000페이지를 찾는 리더는 내려가며 카운트를 빼고 통째로 열지 않은 서브트리를 건너뜁니다. ISO 32000-1 §7.7.3은 균형 트리를 요구하지 않습니다. 항목 10,000개의 /Kids 배열 하나도 합법이고, 검색만 느립니다. 리프만 읽는 코드는 하나의 함정에 빠집니다. /Resources, /MediaBox, /CropBox, /Rotate는 상속 가능하므로(§7.7.3.4), 자체 /MediaBox가 없는 페이지도 여전히 유효합니다. 값이 조상에 살기 때문입니다

12 0 obj
<< /Type /Page
   /Parent 3 0 R
   /MediaBox [0 0 612 792]
   /Resources << /Font << /F1 7 0 R >> >>
   /Contents 13 0 R
>>
endobj

미디어 박스는 페이지에 그리는 모든 것의 좌표 공간을 정합니다. 원점은 왼쪽 아래, Y는 위로, 1유닛은 PDF 1.6 /UserUnit이 스케일하지 않는 한 1/72인치입니다. PDFium Component는 번호로 페이지를 청하면 내려가기 전체를 수행합니다. TPdf.PageMetrics는 상속된 박스가 이미 풀린 상태의 포인트 단위 페이지 크기와 페이지 회전을 돌려주므로, 아래 목록은 리프 페이지에 자체 박스가 없는 파일에서도 올바른 크기를 보고합니다

uses
  System.SysUtils, PDFium;

// 페이지 수와 모든 페이지의 크기를 포인트로 출력한다
procedure ListPages(const FileName: string);
const
  Degrees: array[TRotation] of Integer = (0, 90, 180, 270);
var
  Pdf: TPdf;
  I: Integer;
  M: TPdfPageMetrics;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;           // xref 데이터와 트레일러, 카탈로그를 파싱한다
    if not Pdf.Active then        // 실패한 로드는 Active = False로 남긴다
      raise Exception.Create('Cannot open ' + FileName);
    Writeln(Pdf.PageCount, ' pages');
    for I := 1 to Pdf.PageCount do
    begin
      M := Pdf.PageMetrics[I];    // 1 기반이다. 트리를 통해 페이지를 로드한다
      Writeln(Format('Page %d: %.1f x %.1f pt, /Rotate %d',
        [I, M.Width, M.Height, Degrees[M.Rotation]]));
    end;
  finally
    Pdf.Free;
  end;
end;

PDF는 화면에 페이지를 어떻게 그리는가?

페이지의 가시 콘텐츠는 작은 프로그램입니다. 하나 이상의 콘텐츠 스트림에 놓인 후위 연산자들이, 리더가 /Filter가 이름 짓는 디코더, 대개 /FlateDecode를 돌린 뒤 순서대로 해석됩니다. BT가 텍스트 객체를 열고, Tf가 폰트 자원과 크기를 고르며, Td가 텍스트 위치를 옮기고, Tj가 문자열을 칠하고, ET가 객체를 닫습니다. 경로는 m, l, c, re로 지어진 뒤 f로 채워지거나 S로 스트로크됩니다. 그래픽 상태(§8.4)는 그 연산자들이 암묵적으로 읽는 것들을 쥡니다. 변환 행렬과 색, 클리핑 경로 같은 것들입니다. q는 사본을 밀어 넣고 Q는 꺼내므로, 그 쌍 안에서 설정된 변환이 뒤의 콘텐츠로 새는 일은 없습니다

q
1 0 0 1 72 700 cm     % 원점을 (72, 700)으로 옮긴다
BT
  /F1 12 Tf           % 12포인트의 폰트 자원 F1
  0 0 Td
  (Invoice 2026-0417) Tj
ET
0.2 0.2 0.8 RG        % 스트로크 색, q/Q가 범위를 정한다
0 -6 m 140 -6 l S
Q
PDF가 페이지를 그리는 방식: BT, Tf, Td, Tj 연산자가 그들이 칠하는 마크로 이어지는 콘텐츠 스트림 발췌
피연산자가 연산자보다 앞에 착지하고 q/Q 쌍이 상태 변화를 울타리치므로, 지역 변환이 이웃 콘텐츠로 새지 않습니다

/F1 같은 이름은 페이지 /Resources로 들어가는 키입니다. 폰트 딕셔너리는 폰트 디스크립터(§9.8)를 통해 내장 폰트 프로그램으로 이어지고, 이미지는 JPEG나 Flate 데이터가 디코딩되면 Do 하나로 칠해집니다. 래스터화는 마지막에 옵니다. 렌더러는 변환 행렬을 모든 경로와 글리프 윤곽에 적용하고, 포인트를 장치 픽셀로 매핑한 뒤 결과를 안티앨리어싱으로 채웁니다. 그 시점에 페이지는 여전히 벡터 데이터이므로, 400퍼센트 확대는 비트맵을 늘리는 대신 콘텐츠 스트림을 더 큰 스케일로 다시 돌립니다. 목표 해상도는 호출자가 고릅니다. 화면에는 인치당 96 또는 150픽셀, 인쇄에는 300 이상입니다. PDFium Component에서 그 단계는 출력 크기를 픽셀로 받는 TPdf.RenderPage입니다

uses
  System.SysUtils, Vcl.Graphics, PDFium;

// FileName의 페이지 1을 Dpi 픽셀/인치로 렌더링해 BMP로 저장한다
procedure RenderFirstPage(const FileName: string; Dpi: Integer);
var
  Pdf: TPdf;
  Bmp: TBitmap;
  W, H: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    if not Pdf.Active then
      raise Exception.Create('Cannot open ' + FileName);
    Pdf.PageNumber := 1;        // 1 기반이다
    // PageWidth와 PageHeight는 포인트 단위다(1/72인치)
    W := Round(Pdf.PageWidth * Dpi / 72);
    H := Round(Pdf.PageHeight * Dpi / 72);
    Bmp := Pdf.RenderPage(0, 0, W, H, ro0, [reAnnotations]);
    try
      Bmp.SaveToFile(ChangeFileExt(FileName, '-p1.bmp'));
    finally
      Bmp.Free;                 // 함수 형태는 새 비트맵을 돌려준다
    end;
  finally
    Pdf.Free;
  end;
end;

페이지는 멀쩡해 보이는데 복사-붙여넣기는 왜 엉터리를 돌려주는가?

복사-붙여넣기가 실패하는 것은 파일이 어떤 글리프를 그릴지는 기록하지만 그것이 어떤 문자를 대표하는지는 기록하지 않을 때입니다. 렌더링은 내장 폰트 프로그램의 글리프 모양만 필요로 합니다. 추출은 Unicode를 필요로 하는데, 그것은 표준 인코딩이나 폰트 딕셔너리의 /ToUnicode CMap(§9.10.3)에서 옵니다. /Encoding /Identity-H인 합성 폰트는 날 글리프 ID를 콘텐츠 스트림에 쓰고, ToUnicode 맵이 없으면 그 ID들은 어떤 문자와도 정의된 관계가 없습니다. 화면은 완벽해 보이는 동안 클립보드는 기호로 채워집니다. 서브셋 폰트가 이런 일을 더 잦게 만듭니다. 문서가 쓰는 글리프만 간직하고, /BaseFont /AAAAAA+ArialMT 같은 이름의 여섯 글자 접두로 쉽게 알아볼 수 있으니까요(§9.6.4). PDF 폰트 처리 가이드가 CJK 폰트와 깨진 추출을 깊이 다룹니다

증분 업데이트와 서명 바이트 범위

서명은 PDF가 편집되는 추가 전용 방식에 의존합니다. 증분 업데이트(§7.5.6)는 마지막 %%EOF 뒤에 객체와 새 상호 참조 섹션을 더하고, 구조 개요가 /Prev 체인과 지워진 콘텐츠가 이전 리비전에 살아남는 이유를 설명합니다. 서명 딕셔너리의 /ByteRange는 [0 a b c]로 쓰이며 CMS 서명을 지닌 /Contents 16진수 문자열을 제외한 모든 것을 해시합니다. 서명된 리비전은 바이트 b + c에서 끝나고, 검증기는 파일을 그곳에서 잘라 그것을 다시 지읍니다. 이후 리비전은 그 지점 뒤에 착지하는 반면, 전면 재작성이나 선형화기, 최적화 패스는 서명자가 해시한 바이트를 움직여 기존 서명 전부를 한꺼번에 무효로 만듭니다

PDF 증분 업데이트가 작동하는 방식: 쌓인 저장 세대가 덧붙은 객체와 새로 고친 트레일러, 디지털 서명이 덮는 바이트 범위를 보여 준다
저장은 다시 쓰는 대신 덧붙이므로, 검증기는 서명 뒤의 모든 변화를 드러내는 동안 허가된 편집은 서명을 살려 둡니다

상호 참조 데이터 파싱과 페이지 트리 걷기부터 렌더링과 텍스트 추출까지 이 파이프라인 전체가 필요한 Delphi나 Lazarus 애플리케이션에게 Delphi와 Lazarus용 PDFium Component는 위 목록에서 쓴 TPdf 문서 컴포넌트와 화면 표시용 TPdfView 뷰어 컨트롤로 그것을 감쌉니다