박사과정 원서 접수를 준비하면서 PDF 편집이 필요했다.
해야 할 일은 단순했다. 여러 PDF를 병합하고, 페이지 순서를 바꾸고, 일부 페이지만 빼는 정도였다.
그런데 온라인 PDF 편집기는 이 단순한 작업을 지나치게 복잡하게 만들었다. 파일을 올리면 로그인 화면이 나오고, 저장하려는 순간 결제 안내가 나오고, 무료로 보이는 기능도 실제로는 제약이 많았다.
이 경험에서 출발해 PDF 편집기를 직접 만들어 보기로 했다. 완전히 새로운 발상은 아니었다. 나는 AI Agent의 RAG 파이프라인을 만들면서 PDF 파싱을 꽤 오래 다뤘고, 문서를 chunk 단위로 가공하는 Contextifier를 만들면서 PDF 내부 구조를 계속 만졌다. 텍스트 추출이 잘 되지 않는 PDF, 좌표가 뒤섞인 PDF, 페이지는 정상으로 보이는데 실제 텍스트 순서는 엉망인 PDF를 다루다 보면 PDF가 일반적인 문서 포맷과 전혀 다르게 움직인다는 점을 자연스럽게 알게 된다.
그래서 Edit2me를 만들기 시작했다. 목표는 거창한 디자인 툴이 아니라, PDF를 병합하고 페이지를 재배치하고 필요한 부분만 조립하는 작업을 내 손에 맞게 처리하는 것이다. 이 시리즈는 그 과정의 기록이다. 1편에서는 PDF 편집기를 만들기 전에 반드시 알아야 하는 PDF의 내부 구조를 정리한다.

PDF 파일은 객체 본문, cross-reference, trailer를 중심으로 해석된다. 출처: dPDF
PDF는 문서라기보다 렌더링 명령 묶음에 가깝다
PDF를 처음 다룰 때 가장 먼저 버려야 하는 관점은 "PDF 안에 문단과 제목과 표가 구조적으로 들어 있다"는 기대다. 일부 PDF는 태그 정보를 포함해 접근성 구조를 갖지만, 일반적인 PDF 렌더링의 핵심은 페이지 위에 무엇을 어디에 그릴지 적어 둔 명령 목록이다.
PDF는 고정 레이아웃 문서 형식이다. PDF Reference 1.7에서 설명하는 PDF의 페이지 콘텐츠는 그래픽 객체, 텍스트 객체, 이미지 객체, 경로 그리기 명령 같은 연산자들로 구성된다. 뷰어는 이 명령을 해석해 페이지를 화면이나 프린터 위에 그린다.
이 차이가 중요하다. HTML은 보통 DOM 트리와 CSS 레이아웃을 통해 문서를 재배치할 수 있지만, PDF는 이미 배치가 끝난 결과에 가깝다. 텍스트도 "첫 번째 문단의 세 번째 문장"처럼 저장되는 것이 아니라, 특정 좌표에 특정 글리프를 그리는 식으로 저장될 수 있다. 그래서 PDF에서 텍스트를 뽑아 RAG에 넣는 작업은 단순한 문자열 읽기가 아니라 좌표, 폰트, 인코딩, 그리기 순서를 복원하는 작업이 된다.
PDF 파일의 큰 뼈대
PDF 파일은 대체로 네 부분으로 이해할 수 있다.
- Header: PDF 버전을 알리는 시작부
- Body: 객체들이 저장되는 본문
- Cross-reference table 또는 cross-reference stream: 객체 위치 인덱스
- Trailer: 문서 루트와 xref 위치를 찾기 위한 마무리 정보
가장 단순한 PDF의 형태는 다음과 비슷하다.
%PDF-1.7
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 595 842] /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT
/F1 24 Tf
100 700 Td
(Hello PDF) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000010 00000 n
...
trailer
<< /Root 1 0 R /Size 5 >>
startxref
...
%%EOF
실제 PDF는 이보다 훨씬 복잡하다. 객체 스트림, 압축된 콘텐츠 스트림, 암호화, 증분 업데이트, 선형화, 폰트 서브셋, 이미지 XObject 등이 섞인다. 그래도 기본 골격은 크게 바뀌지 않는다. PDF 뷰어는 trailer에서 Root를 찾고, Catalog에서 Pages 트리를 찾고, Page 객체에서 Contents를 따라가며 페이지를 렌더링한다.
Header: 파일의 시작점
PDF 파일은 보통 다음과 같은 헤더로 시작한다.
%PDF-1.7
이 한 줄은 PDF 버전을 나타낸다. PDF 1.7은 이후 ISO 32000-1로 표준화되었다. 최신 PDF 2.0은 ISO 32000-2에 해당한다.
헤더만 보고 PDF의 모든 기능을 판단하면 안 된다. PDF에는 문서 Catalog의 /Version 항목으로 버전을 덮어쓰는 경우도 있고, 실제 파일이 특정 버전의 기능을 일부만 쓰는 경우도 있다. 편집기를 만들 때 헤더는 시작점일 뿐이고, 실제 기능 판단은 객체 구조를 따라가며 해야 한다.
Body: 모든 것은 객체로 저장된다
PDF의 본문은 객체들의 집합이다. 객체는 번호와 generation 번호를 가진다.
12 0 obj
<< /Type /Page /Parent 2 0 R /Contents 15 0 R >>
endobj
여기서 12 0 obj는 객체 번호 12, generation 번호 0인 간접 객체를 뜻한다. 다른 객체는 12 0 R 형식으로 이 객체를 참조할 수 있다. PDF 내부 구조는 사실상 객체 그래프다.
PDF 객체의 기본 타입은 다음과 같다.
- Boolean:
true,false - Number:
123,3.14 - String:
(Hello)또는<48656C6C6F> - Name:
/Type,/Page,/Contents - Array:
[1 2 3] - Dictionary:
<< /Key /Value >> - Stream: dictionary 뒤에 붙는 바이트 스트림
- Null:
null - Indirect object:
1 0 obj ... endobj - Indirect reference:
1 0 R
PDF 편집기는 이 객체 그래프를 깨지 않으면서 필요한 객체를 추가하거나 바꿔야 한다. 페이지 순서를 바꾸는 작업도 실제로는 /Pages 트리의 /Kids 배열과 /Count 값을 조정하는 일에 가깝다. PDF를 병합하는 작업은 서로 다른 파일의 객체 번호 충돌을 피하면서 Page 객체와 Resource 객체를 새 문서에 재배치하는 일이다.
Dictionary: PDF 객체의 핵심 자료 구조
PDF에서 의미 있는 대부분의 구조는 dictionary로 표현된다. dictionary는 <<와 >> 사이에 key-value 쌍을 넣는 구조다.
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 595 842]
/Resources << /Font << /F1 10 0 R >> >>
/Contents 15 0 R
>>
/Type /Page는 이 객체가 Page 객체임을 나타낸다. /MediaBox는 페이지 크기를 나타내고, /Resources는 페이지에서 사용할 폰트, 이미지, 그래픽 상태 같은 자원을 가리킨다. /Contents는 실제 렌더링 명령이 들어 있는 콘텐츠 스트림을 가리킨다.
이 구조 때문에 PDF 편집은 단순 문자열 치환으로 처리하기 어렵다. /Contents 15 0 R만 보고 15번 객체를 수정하면 될 것처럼 보이지만, 실제로는 /Contents [15 0 R 16 0 R]처럼 여러 스트림을 배열로 가질 수 있다. 또한 /Resources는 Page 객체에 직접 있을 수도 있고 상위 Pages 노드에서 상속될 수도 있다.
Stream: 이미지, 폰트, 콘텐츠 명령이 들어가는 곳
Stream은 PDF에서 큰 데이터를 담는 방식이다. 이미지 바이트, 폰트 프로그램, 페이지 콘텐츠 명령, 색상 프로파일 등이 stream으로 저장된다.
15 0 obj
<< /Length 67 /Filter /FlateDecode >>
stream
...compressed bytes...
endstream
endobj
/Length는 stream 데이터 길이를 나타낸다. /Filter /FlateDecode는 stream 데이터가 zlib/deflate 계열 압축으로 저장되어 있음을 뜻한다. 따라서 PDF 콘텐츠를 읽으려면 stream dictionary를 읽고, Filter를 해석하고, 압축을 푼 뒤, 그 안의 연산자들을 다시 파싱해야 한다.
편집기 관점에서 stream은 가장 조심해야 하는 영역이다. 텍스트를 조금 고쳤다고 해서 문자열 길이만 바꾸면 되는 것이 아니다. 압축을 풀고 명령을 수정하고 다시 압축한 뒤 /Length를 갱신해야 한다. 여러 Filter가 체인으로 걸려 있거나 predictor가 쓰인 이미지 stream이면 처리는 더 복잡해진다.

Catalog에서 Pages 트리와 Page 객체로 이어지는 구조. 출처: Skia Graphics Engine
Cross-reference: 객체 위치를 찾기 위한 인덱스
PDF 뷰어가 파일 전체를 처음부터 끝까지 순서대로 파싱한다면 큰 PDF를 여는 속도가 느려진다. PDF는 객체 번호를 파일 오프셋에 매핑하기 위해 cross-reference 구조를 둔다.
전통적인 xref table은 다음과 같은 형태다.
xref
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000131 00000 n
0000000220 00000 n
0000000348 00000 n
각 줄은 객체의 바이트 오프셋, generation 번호, 사용 여부를 나타낸다. n은 사용 중인 객체, f는 free object를 뜻한다. trailer의 startxref는 xref가 시작되는 위치를 가리킨다.
PDF 1.5 이후에는 xref stream도 사용할 수 있다. xref stream은 cross-reference 정보를 일반 stream 객체처럼 저장한다. qpdf 문서는 object stream과 xref stream이 PDF 1.5에서 도입된 구조라고 설명한다. 이 구조가 나오면 단순히 xref 문자열을 찾아 table을 읽는 방식만으로는 충분하지 않다.
Trailer: Root를 찾기 위한 마지막 단서
Trailer는 PDF 파서가 문서 구조의 출발점을 찾기 위한 정보를 담는다.
trailer
<<
/Size 6
/Root 1 0 R
/Info 5 0 R
>>
startxref
492
%%EOF
/Root는 Catalog 객체를 가리킨다. Catalog는 문서 전체의 루트 객체이고, 여기서 Pages 트리, AcroForm, outlines, metadata 같은 구조로 이어진다. /Size는 cross-reference 섹션에서 관리하는 객체 수와 관련된다.
편집기를 만들 때 trailer를 제대로 갱신하지 않으면 PDF 뷰어가 문서를 열지 못하거나 일부 객체를 찾지 못한다. 특히 기존 파일을 직접 수정하는 방식보다 새 파일을 재작성하는 방식이 구현상 더 안전한 이유가 여기에 있다. 객체를 새로 쓰고 xref와 trailer를 다시 만드는 편이 예측 가능하다.
Catalog와 Pages 트리
PDF 문서의 시작점은 Catalog다.
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
Catalog의 /Pages는 Pages 트리의 루트 노드를 가리킨다. Pages 트리는 문서의 페이지들을 계층적으로 관리한다.
2 0 obj
<<
/Type /Pages
/Kids [3 0 R 4 0 R]
/Count 2
>>
endobj
/Kids 배열에는 Page 객체 또는 하위 Pages 객체가 들어간다. /Count는 해당 노드 아래에 있는 총 페이지 수다. 페이지가 많을 때 모든 Page 객체를 하나의 배열에 넣지 않고 트리로 관리할 수 있다.
페이지 순서 변경은 이 구조를 이해하면 단순해진다. 페이지의 시각적 내용 자체를 건드리지 않고 /Kids 배열의 순서만 바꾸면 된다. 병합도 마찬가지다. 여러 PDF에서 Page 객체를 가져와 하나의 Pages 트리에 연결하면 된다. 다만 이때 Page 객체가 참조하는 Resources, Contents, Annots, MediaBox, CropBox 같은 의존 객체를 함께 가져와야 한다.
Page 객체: 한 페이지의 메타데이터
Page 객체는 한 페이지를 렌더링하는 데 필요한 정보를 담는다.
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 595 842]
/CropBox [0 0 595 842]
/Resources 6 0 R
/Contents 7 0 R
>>
endobj
중요한 항목은 다음과 같다.
/Parent: 상위 Pages 노드/MediaBox: 페이지의 기본 크기/CropBox: 화면에 표시하거나 출력할 영역/Rotate: 페이지 회전값/Resources: 폰트, 이미지, XObject, 색상 공간 같은 자원/Contents: 페이지 콘텐츠 스트림/Annots: 주석, 링크, 폼 필드 같은 annotation
PDF 병합을 구현할 때 Page 객체만 복사하면 안 된다. /Contents가 가리키는 stream, /Resources가 가리키는 폰트와 이미지, /Annots가 가리키는 주석 객체까지 따라가야 한다. 객체 그래프 복사가 필요한 이유다.
Contents: 페이지를 그리는 명령어 목록
Page 객체의 /Contents는 페이지 위에 무엇을 그릴지 나타내는 콘텐츠 스트림이다. 압축을 풀면 다음과 같은 명령어들이 나온다.
q
1 0 0 1 0 0 cm
BT
/F1 12 Tf
72 720 Td
(Hello PDF) Tj
ET
Q
PDF 콘텐츠 스트림은 후위 표기법에 가깝다. 피연산자가 먼저 나오고, 연산자가 뒤에 온다. 72 720 Td는 텍스트 위치를 이동시키는 명령이고, (Hello PDF) Tj는 문자열을 표시하는 명령이다.
자주 보게 되는 연산자는 다음과 같다.
q: 그래픽 상태 저장Q: 그래픽 상태 복원cm: 현재 변환 행렬 변경BT: 텍스트 객체 시작ET: 텍스트 객체 종료Tf: 텍스트 폰트와 크기 설정Td: 텍스트 위치 이동Tm: 텍스트 행렬 설정Tj: 문자열 표시TJ: 간격 조정 배열을 사용한 문자열 표시Do: XObject 그리기re: 사각형 경로 추가S: 선 그리기f: 채우기
여기서 중요한 점은 텍스트 시작과 종료가 ST와 ET가 아니라 BT와 ET라는 점이다. BT는 Begin Text, ET는 End Text다. PDF 파싱을 하다 보면 stream과 endstream, BT와 ET가 자주 같이 등장하기 때문에 용어가 섞이기 쉽다.
텍스트 추출이 어려운 이유
PDF에서 보이는 텍스트와 실제 저장된 텍스트는 다를 수 있다. 콘텐츠 스트림에는 문자열이 들어 있지만, 그 문자열의 바이트가 곧바로 유니코드 텍스트를 의미하지 않을 수 있다.
예를 들어 다음 명령은 단순해 보인다.
BT
/F13 10 Tf
100 600 Td
<001200130014> Tj
ET
<001200130014>는 사람이 읽을 수 있는 문자열이 아니다. 이 값은 특정 폰트의 character code일 수 있다. 실제 유니코드로 바꾸려면 폰트 dictionary, Encoding, ToUnicode CMap 등을 따라가야 한다. ToUnicode CMap이 없으면 텍스트 추출 품질은 급격히 떨어질 수 있다.
또 다른 문제는 텍스트 순서다. 화면에는 한 문장처럼 보이더라도 PDF 내부에서는 글자가 여러 번 나뉘어 그려질 수 있다.
[(Hel) 20 (lo) -10 ( PDF)] TJ
TJ는 문자열과 간격 조정값을 함께 담는 배열을 사용한다. 이 구조는 자간 조정에는 유용하지만, 텍스트 추출기 입장에서는 문자열을 다시 이어 붙여야 하는 부담이 된다.
표와 다단 문서에서는 문제가 더 커진다. PDF는 텍스트의 논리적 읽기 순서를 보장하지 않는다. 렌더링 순서가 읽기 순서와 다를 수 있고, 좌표 기준으로 재정렬해야 하는 경우가 많다. RAG용 문서 파싱에서 레이아웃 분석이 필요한 이유가 여기에 있다.
좌표계와 변환 행렬
PDF 페이지의 기본 좌표계는 일반적으로 왼쪽 아래를 원점으로 한다. A4 크기 페이지의 /MediaBox [0 0 595 842]는 좌하단이 (0, 0), 우상단이 (595, 842)인 좌표계를 뜻한다. 단위는 point이며 1 point는 1/72 inch다.
하지만 실제 콘텐츠는 단순 좌표만으로 해석되지 않는다. PDF에는 현재 변환 행렬(Current Transformation Matrix, CTM)이 있고, cm 연산자로 좌표계를 이동, 확대, 회전, 기울임 처리할 수 있다.
1 0 0 1 100 200 cm
이 명령은 좌표계를 x축 100, y축 200만큼 이동시키는 변환에 해당한다. 이미지나 텍스트가 어디에 그려지는지 계산하려면 현재 그래픽 상태의 CTM과 텍스트 행렬을 함께 추적해야 한다.
그래서 PDF 편집에서 페이지 회전, crop, 이미지 삽입을 구현할 때는 단순히 좌표 숫자를 넣는 것으로 끝나지 않는다. 현재 페이지의 /Rotate, /MediaBox, /CropBox, CTM을 함께 고려해야 한다.

PDF의 좌표 계산은 페이지 좌표계와 변환 행렬을 함께 추적하는 문제다. 출처: Math Insight
Resources: 콘텐츠 스트림의 이름표 사전
콘텐츠 스트림 안에서는 /F1, /Im0 같은 이름이 등장한다.
BT
/F1 12 Tf
(Hello) Tj
ET
/Im0 Do
이 이름들은 Page의 /Resources dictionary에서 실제 객체로 연결된다.
<<
/Font << /F1 10 0 R >>
/XObject << /Im0 11 0 R >>
>>
/F1은 10번 폰트 객체를 뜻하고, /Im0는 11번 이미지 XObject를 뜻한다. 콘텐츠 스트림만 복사하고 Resources를 복사하지 않으면 뷰어는 /F1이나 /Im0가 무엇인지 알 수 없다.
PDF 병합에서 리소스 이름 충돌도 중요하다. 두 PDF가 모두 /F1이라는 이름을 쓰더라도 실제 폰트 객체는 다를 수 있다. 페이지 단위로 Resources가 분리되어 있으면 문제가 적지만, 페이지를 합치거나 콘텐츠 스트림을 한 페이지에 병합할 때는 이름 충돌을 해결해야 한다.
Image XObject: PDF 안의 이미지는 별도 객체다
PDF에서 이미지는 보통 Image XObject로 저장된다.
11 0 obj
<<
/Type /XObject
/Subtype /Image
/Width 600
/Height 400
/ColorSpace /DeviceRGB
/BitsPerComponent 8
/Filter /DCTDecode
/Length 12345
>>
stream
...jpeg bytes...
endstream
endobj
콘텐츠 스트림에서는 다음처럼 이미지를 그린다.
q
600 0 0 400 0 0 cm
/Im0 Do
Q
이미지 자체는 XObject stream에 있고, 페이지에는 그 이미지를 어느 크기와 위치로 그릴지 명령만 들어간다. 따라서 이미지 기반 PDF는 텍스트 추출이 거의 되지 않는다. 스캔본 PDF가 RAG 파이프라인에서 OCR을 필요로 하는 이유다.
Font와 ToUnicode
PDF 텍스트 처리에서 가장 까다로운 부분은 폰트다. 폰트 객체는 glyph를 어떻게 그릴지에 대한 정보뿐 아니라 character code를 glyph와 유니코드로 어떻게 매핑할지도 일부 담당한다.
간단한 폰트 dictionary는 다음과 비슷하다.
10 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
하지만 실제 PDF에서는 CIDFont, Type0 Font, embedded font subset, ToUnicode CMap이 자주 등장한다.
<<
/Type /Font
/Subtype /Type0
/BaseFont /ABCDEE+NotoSansCJKkr-Regular
/Encoding /Identity-H
/DescendantFonts [21 0 R]
/ToUnicode 22 0 R
>>
/BaseFont /ABCDEE+...처럼 앞에 붙은 임의 접두어는 폰트 서브셋을 나타내는 경우가 많다. 문서에 실제로 쓰인 glyph만 포함해 파일 크기를 줄이는 방식이다.
텍스트 추출기는 ToUnicode CMap을 사용해 character code를 유니코드로 바꾼다. ToUnicode가 없거나 부정확하면 화면에는 정상적으로 보이는 텍스트도 추출 결과에서는 깨질 수 있다. PDF가 사람이 보기에는 안정적이지만 기계가 읽기에는 까다로운 이유다.
Annotation과 AcroForm
PDF에는 페이지 위에 보이는 정적 콘텐츠 외에도 annotation이 있다. 링크, 주석, 하이라이트, 파일 첨부, 폼 필드 위젯 등이 annotation으로 표현된다.
Page 객체는 /Annots 배열을 통해 annotation 객체들을 참조할 수 있다.
<<
/Type /Page
/Annots [30 0 R 31 0 R]
>>
입력 가능한 PDF 폼은 AcroForm 구조와 연결된다. Catalog에는 /AcroForm 항목이 있을 수 있고, 각 필드는 위젯 annotation과 연결된다.
PDF 편집기가 단순 병합만 한다면 annotation을 무시해도 겉보기에는 큰 문제가 없어 보일 수 있다. 하지만 링크가 사라지거나 입력 필드가 깨지는 문제가 생길 수 있다. 원본 PDF의 동작까지 보존하려면 annotation과 AcroForm 객체도 함께 다뤄야 한다.
Incremental update: PDF는 뒤에 덧붙여 수정할 수 있다
PDF는 기존 파일을 직접 고쳐 쓰지 않고, 변경된 객체와 새 xref, 새 trailer를 파일 뒤에 덧붙이는 incremental update 방식을 지원한다. 이 방식은 전자서명, 변경 이력, 빠른 저장에 중요하다.
구조적으로는 다음과 같다.
기존 PDF 본문
기존 xref
기존 trailer
새 객체들
새 xref
새 trailer with /Prev
새 startxref
%%EOF
새 trailer의 /Prev는 이전 xref 위치를 가리킨다. 파서는 마지막 startxref에서 시작해 이전 xref를 따라가며 전체 객체 상태를 구성할 수 있다.
이 구조는 PDF 편집기를 만들 때 양면성이 있다. 기존 파일을 보존하면서 변경분만 추가할 수 있다는 장점이 있지만, 파서 입장에서는 같은 객체 번호가 여러 번 등장할 수 있다는 뜻이기도 하다. 가장 마지막 revision의 객체가 유효한 객체다. 단순히 파일 앞에서 처음 만난 객체를 쓰면 잘못된 결과가 나올 수 있다.
Linearized PDF와 Object Stream
웹에서 빠르게 열리는 PDF에는 linearization이 적용될 수 있다. Linearized PDF는 첫 페이지를 빨리 보여주기 위해 파일 구조를 재배치한 형태다. Library of Congress의 PDF 설명은 PDF가 랜덤 접근과 점진적 렌더링을 지원하는 구조를 가질 수 있다고 정리한다.
PDF 1.5부터는 object stream이 도입되었다. object stream은 여러 작은 객체를 하나의 stream 안에 압축해 저장한다. 파일 크기를 줄이는 데 유리하지만, 파서 구현은 더 어려워진다. 객체가 12 0 obj ... endobj 형태로 파일 본문에 직접 드러나지 않고, object stream 내부에 들어갈 수 있기 때문이다.
따라서 현실적인 PDF 파서는 다음 경우를 모두 고려해야 한다.
- 전통적인 xref table
- xref stream
- object stream 내부 객체
- incremental update로 덮어쓴 객체
- 압축된 content stream
- 암호화된 PDF
- 손상되었지만 뷰어가 복구해 여는 PDF
직접 PDF 편집기를 만들 때 처음부터 모든 케이스를 완벽히 처리하려고 하면 범위가 커진다. 그래서 1차 목표를 페이지 병합과 순서 변경으로 잡는 것이 합리적이다. 이 작업은 콘텐츠 스트림 내부 편집보다 Pages 트리와 객체 복사에 초점이 있다.
PDF 병합은 무엇을 하는 작업인가
PDF 병합은 표면적으로는 파일 A 뒤에 파일 B를 붙이는 일이다. 내부적으로는 다음 단계에 가깝다.
- 입력 PDF 각각의 xref와 trailer를 읽는다.
- Root Catalog에서 Pages 트리를 찾는다.
- 모든 Page 객체를 순서대로 펼친다.
- 각 Page 객체가 참조하는 의존 객체를 추적한다.
- 새 PDF에서 사용할 객체 번호를 다시 부여한다.
- 새 Pages 트리를 만든다.
- 새 Catalog, xref, trailer를 쓴다.
여기서 핵심은 객체 번호 재매핑이다. 두 PDF가 모두 10 0 obj를 가지고 있을 수 있다. 새 PDF에 둘을 그대로 넣으면 충돌이 발생한다. 따라서 입력 객체 (파일 A, 10 0)과 (파일 B, 10 0)를 서로 다른 새 객체 번호로 매핑해야 한다.
개념적으로는 다음과 같다.
A: 10 0 obj -> new: 15 0 obj
B: 10 0 obj -> new: 42 0 obj
이때 객체 내부의 참조도 모두 갱신해야 한다.
/Resources 10 0 R
위 참조가 새 문서에서는 /Resources 15 0 R 또는 /Resources 42 0 R이 되어야 한다. 객체 그래프를 복사하면서 indirect reference를 재작성하는 로직이 필요하다.
페이지 순서 변경은 무엇을 하는 작업인가
페이지 순서 변경은 병합보다 단순하다. 같은 문서 안의 Page 객체 순서를 다시 배열하면 된다.
예를 들어 기존 Pages 노드가 다음과 같다고 하자.
<<
/Type /Pages
/Kids [3 0 R 4 0 R 5 0 R]
/Count 3
>>
페이지 순서를 3, 1, 2로 바꾸면 다음처럼 만들 수 있다.
<<
/Type /Pages
/Kids [5 0 R 3 0 R 4 0 R]
/Count 3
>>
물론 실제 구현에서는 Pages 트리가 여러 단계일 수 있으므로 전체 페이지 목록을 flatten한 뒤 새 Pages 트리를 재구성하는 방식이 더 단순할 수 있다. 이 방식은 구현이 명확하고, /Count 값을 다시 계산하기 쉽다.
왜 PDF를 이미지 렌더링처럼 이해해야 하는가
PDF를 이미지 파일이라고 부를 수는 없다. PDF는 벡터 그래픽, 텍스트, 이미지, 폼, 주석, 메타데이터를 포함하는 복합 문서 포맷이다. 다만 편집기를 만드는 관점에서는 "이미 렌더링 위치가 확정된 그리기 명령 묶음"으로 이해하는 편이 안전하다.
이 관점은 세 가지 판단을 가능하게 한다.
첫째, 텍스트 편집을 쉽게 보지 않게 된다. PDF의 텍스트는 논리 문단이 아니라 glyph 배치일 수 있다. 글자 하나를 수정하면 폰트 인코딩, 문자열 길이, 위치, 줄바꿈, 자간, ToUnicode까지 영향을 받을 수 있다.
둘째, 페이지 단위 편집이 상대적으로 안정적이라는 점을 이해하게 된다. 병합, 분할, 순서 변경은 콘텐츠 내부를 재해석하지 않고 Page 객체와 의존 객체를 재구성하는 방식으로 처리할 수 있다.
셋째, RAG 파싱과 PDF 편집이 서로 연결된다는 점이 보인다. RAG에서는 PDF의 렌더링 명령을 읽기 순서와 의미 단위로 복원해야 하고, PDF 편집에서는 그 명령과 객체 그래프를 깨지 않으면서 다시 조립해야 한다. 둘 다 PDF를 단순 파일이 아니라 구조화된 객체 그래프로 다뤄야 한다.
Edit2me 1편에서 잡는 구현 범위
Edit2me의 초기 범위는 다음처럼 잡는 것이 적절하다.
- 여러 PDF 입력 받기
- 각 PDF의 페이지 목록 추출
- 페이지 썸네일 또는 순서 정보 표시
- 사용자가 페이지 순서 변경
- 선택된 순서대로 새 PDF 생성
- 객체 번호 재매핑
- Pages 트리 재구성
- xref와 trailer 재작성
반대로 다음 기능은 이후 단계로 미루는 편이 좋다.
- PDF 내부 텍스트 직접 수정
- 폰트 재작성
- 복잡한 AcroForm 보존
- 전자서명 보존
- 암호화 PDF 편집
- 손상 PDF 복구
- OCR 기반 텍스트 삽입
범위를 이렇게 나누면 구현 난이도가 명확해진다. 1편은 PDF 구조를 이해하는 단계이고, 2편에서는 파서와 객체 모델을 어떻게 만들지 정리할 수 있다. 3편에서는 실제 병합과 페이지 재배치, UI 또는 API 구성까지 이어갈 수 있다.
정리
PDF는 겉으로는 문서지만 내부적으로는 객체 그래프와 렌더링 명령의 조합이다. Header, Body, xref, trailer가 파일의 기본 골격을 만들고, Catalog와 Pages 트리가 문서의 페이지 구조를 만든다. Page 객체는 Contents와 Resources를 통해 실제 렌더링에 필요한 명령과 자원을 연결한다.
텍스트는 BT와 ET 사이의 텍스트 객체 안에서 Tj, TJ 같은 연산자로 그려진다. 그러나 폰트 인코딩과 ToUnicode CMap, 좌표계, 렌더링 순서 때문에 텍스트 추출은 단순하지 않다. 이미지 역시 Image XObject로 저장되고, 콘텐츠 스트림에서는 Do 연산자로 호출된다.
PDF 편집기를 직접 만든다면 처음부터 텍스트 편집을 목표로 잡기보다 페이지 단위 조작부터 시작하는 편이 안정적이다. 병합과 순서 변경은 PDF의 객체 그래프를 복사하고 Pages 트리를 재구성하는 문제로 환원할 수 있다. 이 구조를 이해하면 온라인 편집기에 기대지 않고도 필요한 기능을 직접 구현할 수 있는 출발점이 생긴다.