콘텐츠로 이동

고급 PDF 파서 진단

Artisan 가져오기 경로는 Chrome이 생성한 Portable Document Format(PDF) 파일을 읽고, 한 페이지를 NextPDF 문서로 가져옵니다. 까다로운 입력에서 가져오기가 실패하면 PageImporter::import() 아래에서 파일을 바이트 단위로 읽는 파서 클래스를 살펴봐야 합니다.

이 가이드는 NextPDF\Parser 네임스페이스의 저수준 파서 표면을 문서화합니다: PdfReader, PdfTokenizer, CrossRefParser, StreamDecoder, ResourceCollector, RevisionExtractor, 그리고 값 객체 PdfObjectRevisionXRefTable. 여기에 표시된 모든 심볼은 nextpdf/artisan에 실제로 존재합니다. 이 가이드는 이상화된 표면이 아니라 구현된 그대로의 파서를 문서화합니다.

이 가이드는 설명과 사용법을 함께 다루는 문서로 읽어 주십시오. 각 구성 요소가 어떻게 맞물리는지 설명한 다음, 증분 업데이트 리비전을 검사하는 과정을 안내합니다. 이 계층 위의 가져오기 경계에 대해서는 Artisan 개발자 가이드를 참조하십시오.

파서 표면은 일반 가져오기 경로가 이미 실패했고 그 원인을 좁혀야 할 때에만 사용하십시오. 대표적인 계기는 다음과 같습니다:

  • PageImporter::import()NextPDF\Artisan\Exception\PdfParseException를 던지고, 상호 참조 테이블, 스트림 필터, 페이지 트리 중 무엇이 문제인지 알아야 하는 경우.
  • Chrome 업그레이드로 출력 형식이 바뀌어(전통적인 상호 참조 테이블이 상호 참조 스트림이 되거나 그 반대로) 픽스처가 더 이상 일치하지 않는 경우.
  • Chrome이 생성하지 않은 서드파티 PDF를 받았고 파서가 해당 파일을 읽을 수 있는지 자체를 확인하려는 경우.
  • 증분 업데이트된 문서를 포렌식 분석하고 있으며 리비전별 바이트 범위나 객체 가시성이 필요한 경우.

일반적인 렌더러 통합을 작성하는 경우에는 이 표면이 필요하지 않습니다. 파서는 범용 PDF 라이브러리가 아니라 내부 진단 도구입니다: 암호화된 PDF, 선형화된 힌트 테이블, 또는 충돌하는 객체 재정의가 포함된 증분 업데이트를 지원하지 않습니다.

파서는 단일 책임 클래스로 이루어진 소규모 집합입니다. PdfReader는 진입점이며, 나머지는 이 클래스가 생성하거나 호출하는 협력자입니다.

클래스책임주요 메서드
PdfReader파일 구조를 읽고, 객체를 해석하고, 페이지 트리를 순회합니다.parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerISO 32000-2:2020 §7.2에 따른 어휘 분석 — 이름, 문자열, 숫자, 딕셔너리, 배열, 참조.readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset()
CrossRefParser전통적인 상호 참조 테이블과 상호 참조 스트림을 파싱합니다.parseXRefTable(), parseXRefStream()
StreamDecoder스트림 바이트를 /Filter에 따라 디코딩합니다.decode() (정적)
ResourceCollectorResources 트리를 깊이 순회하여 도달 가능한 모든 간접 객체를 수집합니다.traverse(), getCollected()
RevisionExtractor증분 업데이트된 파일을 리비전별 바이트 범위로 분할합니다.extractRevision() (정적), getRevisionBoundaries() (정적)
PdfObject불변의 파싱된 간접 객체(딕셔너리 및 선택적 스트림).get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTable불변의 리비전별 상호 참조 스냅샷.getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

원시 PDF 바이트로 \NextPDF\Parser\PdfReader를 생성한 다음, 다른 메서드를 호출하기 전에 반드시 parse()를 호출하십시오. parse()%PDF- 헤더를 확인하고, 파일 끝부분에서 startxref를 찾은 뒤, /Prev 링크를 따라 상호 참조 체인을 순회합니다.

리더는 parse() 이후 다음 세 가지 메서드 그룹을 노출합니다:

  • 객체 접근. getObject(int $objNum)PdfObject를 반환하며, Type 2 항목(객체 스트림 내부에 저장된 객체)을 투명하게 해석합니다. getObjectNumbers()는 free가 아닌 모든 객체 번호의 정렬된 list<int>를 반환합니다. resolveRef(mixed $value)는 단일 간접 참조를 따라가며, 직접 값은 변경하지 않고 그대로 반환합니다.
  • 페이지 접근. getPage(int $pageIndex)는 카탈로그를 해석하고 /Pages를 순회하여 0부터 시작하는 인덱스의 페이지를 반환합니다. getPageContentStream(), getPageResources(), getPageMediaBox()PageImporter가 필요로 하는 부분을 추출합니다. collectPageResources()는 페이지의 Resources 및 Contents에서 도달 가능한 모든 객체의 array<int, PdfObject>를 반환합니다.
  • 리비전 접근. getRevisionCount()는 증분 리비전의 개수를 반환합니다(단일 리비전 파일은 1을 반환). getRevisionXRef(int $index)는 하나의 RevisionXRefTable을 반환합니다(인덱스 0이 가장 최근). getRevisions()는 전체 list<RevisionXRefTable>를 반환합니다.

PdfTokenizer는 바이트 스트림을 읽습니다. 직접 생성하는 일은 드뭅니다 — PdfReaderCrossRefParser가 각자의 인스턴스를 소유합니다 — 그러나 잘못된 토큰에서 파싱이 실패할 때 검사할 계층입니다. 진단에서는 두 가지 동작이 중요합니다:

  • 보안 한도는 구성이 아니라 상수입니다. 토크나이저는 리터럴 문자열 중첩, 딕셔너리 및 배열 중첩, 키워드 길이, 배열 요소 개수에 상한을 둡니다. 한도를 초과하면 메시지에 한도 이름이 명시된 PdfParseException을(를) 던집니다. 이러한 한도 중 하나를 건드리는 조작된 입력은 파서 버그가 아니라 의도대로 동작하는 방어입니다.
  • readValue()는 디스패처입니다. 다음 바이트를 검사하여 readName(), readLiteralString(), readHexString(), readArray(), readDictionary(), 또는 number/reference 리더에 위임합니다. 간접 참조 N G R은 배열 형태 ['type' => 'ref', 'num' => N, 'gen' => G]로 반환됩니다. 이 형태를 PdfObject::getRef()PdfReader::resolveRef()가 인식합니다.

CrossRefParser는 Chrome이 내보낼 수 있는 두 형식을 모두 파싱합니다:

  • parseXRefTable()는 전통적인 xref 테이블(PDF 1.x 스타일)을 읽습니다: 서브섹션 헤더 뒤에 고정 폭 20 바이트 항목이 오며, 이어서 trailer 딕셔너리가 옵니다.
  • parseXRefStream()는 상호 참조 스트림(PDF 2.0, ISO 32000-2:2020 §7.5.8)을 읽습니다: /Type /XRef가 있는 간접 객체, /W 필드 폭 배열, 그리고 항목의 이진 스트림.

둘 다 동일한 형태를 반환합니다: array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. PdfReader::parse()는 상호 참조 오프셋의 네 바이트를 들여다보고 어느 것을 호출할지 결정합니다: xref이면 테이블 파서를 선택하고, 그 외에는 스트림 객체로 취급합니다. 두 파서 모두 서브섹션당 100 만 항목 상한을 강제하여, 그렇지 않으면 파서를 과도하게 실행하게 만들 위조된 개수를 거부합니다.

StreamDecoder::decode(string $data, string|array $filter)는 정적 메서드이며, 하나의 필터 또는 체인으로 연결된 필터 목록을 적용합니다. Chrome의 printToPDF가 내보내는 필터만 정확히 지원합니다:

  • FlateDecode (zlib, raw-deflate 폴백 포함)
  • ASCIIHexDecode
  • ASCII85Decode

그 밖의 필터 이름이 들어오면 Unsupported stream filter 메시지와 함께 PdfParseException를 던집니다. 디코더는 압축 해제 폭탄 위험을 제한하기 위해 압축 해제된 출력을 16 MiB로 제한합니다; 출력이 이 크기를 초과하면 제한 없이 할당하지 않고 예외를 던집니다. PdfReader가 스트림을 읽는 중 디코딩이 예외를 던지면, 원시 스트림 바이트로 폴백하여 하나의 잘못된 필터가 전체 파싱을 중단시키지 않도록 합니다.

ResourceCollector — 깊은 리소스 순회

섹션 제목: “ResourceCollector — 깊은 리소스 순회”

ResourceCollectorPdfReader로 생성되며 PdfReader::collectPageResources()를 통해 호출됩니다. traverse() 메서드는 값을 재귀적으로 순회하고, 모든 ['type' => 'ref'] 참조를 getObject()를 통해 따라가며, 해석된 각 객체를 객체 번호를 키로 하는 array<int, PdfObject>에 한 번씩 기록합니다. 재귀 깊이에 상한을 두며, 해석할 수 없는 참조는 조용히 건너뜁니다. 따라서 하나의 매달린 참조는 하드 실패 대신 부분 수집을 산출합니다.

RevisionExtractor — 증분 업데이트와 리비전

섹션 제목: “RevisionExtractor — 증분 업데이트와 리비전”

생성된 뒤 서명, 주석 추가 또는 다른 방식으로 편집된 PDF는 증분 업데이트를 담고 있습니다: 각 편집은 새로운 상호 참조 섹션과 트레일러를 추가하며 %%EOF 마커로 끝납니다. RevisionExtractor는 파싱이 끝난 PdfReader 위에서만 정적 메서드로 동작합니다:

  • extractRevision(string $pdfData, PdfReader $reader, int $revision)는 요청된 리비전의 %%EOF 경계에서 잘라낸 파일을 반환합니다. 리비전 0(가장 최근)은 파일 전체를 반환하며, 더 높은 인덱스는 점점 더 오래된 스냅샷을 반환합니다.
  • getRevisionBoundaries(string $pdfData, PdfReader $reader)는 각 리비전이 기여한 바이트 범위를 기술하는 list<array{revision, startByte, endByte, sizeBytes}>를 반환합니다.

이 격리는 의도된 설계입니다: 더 오래된 리비전을 추출하면 그 시점까지 보이는 객체만 노출되며, 이는 이후 리비전이 이전 객체를 재정의하는 하이브리드 상호 참조 공격을 차단합니다.

이 절차는 Chrome이 생성한 뒤 편집되었을 수 있는 PDF의 리비전 기록을 검사합니다. 이 예제는 프로덕션에 적합한 형태입니다: strict 타입을 선언하고, 완전한 타입 힌트를 사용하며, 입력을 검증하고, 가장 구체적인 예외를 잡습니다.

  1. PDF 바이트를 메모리로 읽고, 리더를 생성하기 전에 빈 입력을 거부합니다.
  2. 먼저 \NextPDF\Parser\PdfReader를 생성하고 parse()를 호출합니다.
  3. 그다음 getRevisionCount()를 읽습니다. 값이 1이면 증분 업데이트가 없는 단일 리비전 파일을 의미합니다.
  4. 각 리비전에 대해 해당 RevisionXRefTable를 읽고 getActiveObjectCount(), hasRootUpdate(), getSize()를 검사합니다.
  5. 리비전별 바이트 범위를 RevisionExtractor::getRevisionBoundaries()로 계산합니다.
  6. 파서가 발생시키는 가장 구체적인 예외인 PdfParseException를 잡아 진단 메시지를 표면화합니다.
examples/inspect-revisions.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use NextPDF\Parser\RevisionExtractor;
use NextPDF\Parser\RevisionXRefTable;
/**
* Inspect the incremental-update history of a PDF file.
*
* @return list<array{revision: int, activeObjects: int, rootUpdate: bool, size: int, startByte: int, endByte: int, sizeBytes: int}>
*
* @throws PdfParseException If the file is not a readable PDF.
*/
function inspectRevisions(string $path): array
{
$pdfData = \file_get_contents($path);
if ($pdfData === false || $pdfData === '') {
throw new PdfParseException("Cannot read PDF bytes from path: {$path}");
}
$reader = new PdfReader($pdfData);
$reader->parse();
$boundaries = RevisionExtractor::getRevisionBoundaries($pdfData, $reader);
$report = [];
foreach ($reader->getRevisions() as $table) {
\assert($table instanceof RevisionXRefTable);
$index = $table->index;
$boundary = $boundaries[$index];
$report[] = [
'revision' => $index,
'activeObjects' => $table->getActiveObjectCount(),
'rootUpdate' => $table->hasRootUpdate(),
'size' => $table->getSize(),
'startByte' => $boundary['startByte'],
'endByte' => $boundary['endByte'],
'sizeBytes' => $boundary['sizeBytes'],
];
}
return $report;
}

리더는 리비전을 가장 최신(index0)부터 가장 오래된 순으로 정렬합니다. 더 오래된 스냅샷 하나를 독립적인 바이트로 추출하려면 — 예를 들어 편집이 무엇을 변경했는지 diff하려면 — 추출기를 직접 호출합니다:

examples/extract-revision.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use NextPDF\Parser\RevisionExtractor;
/**
* Extract one revision of a PDF as standalone bytes.
*
* @throws PdfParseException If the file is unreadable or the revision index is out of range.
*/
function extractRevision(string $pdfData, int $revision): string
{
if ($pdfData === '') {
throw new PdfParseException('Empty PDF input');
}
$reader = new PdfReader($pdfData);
$reader->parse();
// Throws PdfParseException with an "out of range" message for an invalid index.
return RevisionExtractor::extractRevision($pdfData, $reader, $revision);
}

모든 파서 실패는 NextPDF\Artisan\Exception\PdfParseException로 표면화됩니다. 메시지는 원인을 좁히는 데 도움이 됩니다. 아래 표를 사용해 메시지 조각을 해당 메시지를 발생시킨 단계에 매핑하십시오.

메시지 조각단계의미
missing %PDF- headerPdfReader::parse()바이트가 PDF가 아니거나, 입력이 앞부분에서 잘렸습니다.
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()파일 끝부분이 손상되었거나 상호 참조 포인터가 범위를 벗어났습니다.
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()전통적인 상호 참조 테이블이 잘못되었습니다.
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()상호 참조 스트림에 필수 딕셔너리 항목이 없습니다.
exceeds limit of (xref 또는 객체 스트림 개수)CrossRefParser / PdfReader위조된 개수가 서비스 거부 가드에 걸렸습니다.
Unsupported stream filterStreamDecoder::decode()스트림이 지원되는 FlateDecode / ASCIIHexDecode / ASCII85Decode 집합 밖의 필터를 사용합니다.
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoder압축된 데이터가 유효하지 않거나 16 MiB 상한을 넘어서 확장됩니다.
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizer조작되었거나 비정상적인 구조가 토크나이저 한도에 걸렸습니다.
Page index ... not found / out of range in subtreePdfReader::getPage()요청된 페이지 인덱스가 페이지 트리에 존재하지 않습니다.
Revision index ... out of rangePdfReader / RevisionExtractor리비전 인덱스가 0부터 getRevisionCount() - 1 범위를 벗어났습니다.

예외를 잡으면 메시지와 소스 경로를 로깅한 다음, 다시 던지거나 정의된 오류를 반환하십시오. 조용히 삼키지 마십시오: 빈 catch 블록은 파서가 어렵게 제공한 유일한 정보를 숨깁니다.

examples/parse-with-diagnostics.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use Psr\Log\LoggerInterface;
/**
* Parse a PDF, logging the precise parser-stage message on failure.
*
* @throws PdfParseException Rethrown after logging so the caller can decide policy.
*/
function parseWithDiagnostics(string $pdfData, LoggerInterface $logger): PdfReader
{
if ($pdfData === '') {
throw new PdfParseException('Empty PDF input');
}
$reader = new PdfReader($pdfData);
try {
$reader->parse();
} catch (PdfParseException $exception) {
$logger->error('PDF parse failed', [
'reason' => $exception->getMessage(),
'bytes' => \strlen($pdfData),
]);
throw $exception;
}
return $reader;
}
  • 항상 parse()를 먼저 호출하십시오. PdfReader의 모든 접근자는 상호 참조 체인이 로드되어 있다고 가정합니다. getObject() 또는 getPage()parse() 이전에 호출하면 쓸모 있는 것을 반환하지 않습니다.
  • 파서를 읽기 전용이며 Chrome 형태로 다루십시오. Chrome의 printToPDF가 내보내는 PDF 구문의 부분 집합을 대상으로 합니다. 암호화된 PDF, 선형화된 힌트 테이블, 충돌하는 증분 업데이트는 설계상 범위를 벗어납니다. 이를 범용 PDF 복구 도구로 확장하지 마십시오.
  • 보안 한도를 그대로 유지하십시오. 중첩, 키워드 길이, 배열 크기, 상호 참조 개수, 압축 해제 상한은 적대적 입력에 대한 리소스 사용을 제한하기 위해 존재합니다. 한도에서 비롯된 PdfParseException은 조작된 파일에 대한 올바른 결과입니다; 그런 파일을 받아들이기 위해 한도를 올리면 공격 표면이 넓어집니다.
  • 기본값은 페이지 0입니다. getPage()와(과) PageImporter::import()은(는) 첫 번째 페이지를 기본값으로 합니다. 워크플로가 의도적으로 필요로 할 때에만 다른 인덱스를 선택하십시오.
  • 리더를 생성하기 전에 입력을 검증하십시오. 위 예제처럼 비어 있거나 읽을 수 없는 바이트를 일찍 거부하여, 명확한 애플리케이션 수준 오류가 어떤 파서 예외보다 먼저 발생하도록 하십시오.
  • PdfParseException 예외를 잡고, 절대 맨 \Exception은 잡지 마십시오. 이 타입은 파서가 발생시키는 유일하고 구체적인 타입입니다; 이 타입을 잡으면 관련 없는 실패가 가려지는 것을 막을 수 있습니다.
  • Artisan 개발자 가이드 — 파서 위의 가져오기 경계로서, ChromeHtmlRenderer, PageImporter, 그리고 아키텍처 계층화를 포함합니다.
  • Artisan API 참조 — 패키지의 공개 표면을 설명하는 게시된 메서드 표.
  • Artisan 문제 해결 — 렌더러 및 가져오기 실패에 대한 증상 우선 안내.
  • Chrome 렌더러 설정 — 이 파서가 읽는 PDF를 생성하는 렌더러를 구성합니다.
  • ISO 32000-2:2020 §7.5(파일 구조, 상호 참조, 증분 업데이트) 및 §7.2(어휘 규약) — 토크나이저와 상호 참조 파서가 구현하는 명세입니다. 권위 있는 바이트 수준 형식은 게시된 표준을 참조하십시오.