CI에서 생성된 PDF 테스트하기
한눈에 보기
섹션 제목: “한눈에 보기”이 레시피는 NextPDF로 PDF를 생성하고 자신의 출력을 테스트 아래에 두고 싶은 애플리케이션 개발자를 위한 것입니다. 그것은 엔진 자체의 테스트 규율의 소비자 측입니다: NextPDF를 다시 테스트하는 것이 아니라, 문서가 여전히 마땅히 말해야 할 것을 말하고 여전히 보이던 대로 보이는지 단언합니다.
두 가지 단언 스타일이 거의 모든 것을 다룹니다.
- 시맨틱 단언은 추출된 텍스트에 대한 것입니다 — 생성하고, 유니코드 텍스트를 복구하고, 그것이 기대하는 문자열을 포함하는지 단언합니다. 이는 레이아웃 조정과 글꼴 변경에서 살아남습니다.
- 골든(스냅샷) 단언은 바이트에 대한 것입니다 — 재빌드가 바이트 단위로 동일하도록
DeterministicSettings를 고정한 다음, 새 바이트를 커밋된 참조 파일과 비교합니다. 이는 의도하지 않은 모든 변경을 잡습니다.
콘텐츠 정확성에는 시맨틱 단언을, 회귀 감지선으로는 골든 단언을 사용하십시오. 러너가 워크스테이션과 동일한 바이트를 생성하면, 둘 다 CI에서 변경 없이 실행됩니다.
composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3바이트 비교가 아니라 추출된 텍스트에 단언하기
섹션 제목: “바이트 비교가 아니라 추출된 텍스트에 단언하기”두 PDF의 원시 바이트 비교는 취약합니다: 새 타임스탬프, 다시 서브셋된 글꼴, 또는 재정렬된 객체 모두가 리더가 보는 것을 바꾸지 않으면서 바이트를 바꿉니다. 대신 콘텐츠에 단언하십시오.
NextPDF 코어는 생산자이므로, 먼저 텍스트를 추출 가능하게 만드십시오. 이는 하나가 아니라 두 가지 별개의 메커니즘입니다. 텍스트 추출은 글리프 코드를 유니코드로 다시 매핑하는 올바른 /ToUnicode CMap(ISO 32000-2 §9.10.2)에 의존합니다 — 엔진이 임베드된 글꼴에 대해 그것을 내보내므로, 추출기가 원시 글리프 인덱스가 아니라 실제 문자를 복구합니다. 태그된 PDF는 별개입니다: enableTaggedPdf()와 setLanguage()는 읽기 순서와 접근성을 기록하는 구조 트리를 추가하며, 이는 /ToUnicode CMap을 만드는 것이 아닙니다. 콘텐츠를 쓰기 전에 둘 다 활성화하십시오: 깔끔한 텍스트 복구를 위한 CMap, 읽기 순서를 위한 태깅. 생산자 세부 사항은 추출 가능한 텍스트 콘텐츠 생성하기를 참조하십시오. 그런 다음 텍스트를 복구하고 그것에 단언하십시오.
페이지 수와 구조적 사실의 경우, Inspect 모듈의 Quick 깊이는 Spectrum 사이드카가 없을 때 인프로세스로 실행되는 순수 PHP 폴백을 가집니다 — CI 러너에서 편리하지만, 그것은 저하된 스캔입니다. 그것은 INSPECT-FALLBACK-001 “정확도가 제한될 수 있음” 이슈를 플래그하고, 전체 객체 트리 파싱이 아니라 원시 바이트에 대한 거친 /Type /Page 정규식에서 페이지 수를 도출합니다. Spectrum 사이드카가 구성되어 있으면, Quick 깊이조차 그것을 사용합니다 — InspectDepth는 사이드카가 수행하는 분석량을 제어하므로, Quick은 본질적으로 사이드카 없는 것이 아닙니다.
<?php
declare(strict_types=1);
use NextPDF\Inspect\Inspector;use NextPDF\Inspect\InspectConfig;
$result = (new Inspector())->inspect($pdfBytes, InspectConfig::quick());
// With no sidecar injected, Quick depth takes the in-process PHP fallback:// a degraded scan (page count from a regex) that flags INSPECT-FALLBACK-001.// If a Spectrum sidecar is available, Inspector uses it even at Quick depth.$pageCount = $result->pageCount; // int (regex-derived in the fallback)$version = $result->pdfVersion; // e.g. "2.0"$encrypted = $result->isEncrypted; // boolInspector::inspect()는 불변 InspectResult를 반환합니다. 전체 텍스트 복구의 경우, 바이트에 대해 다운스트림 추출기(pdftotext, 또는 Standard 깊이의 Inspect Spectrum 사이드카)를 실행하고 그 출력에 단언하십시오 — 생산자의 정확한 바이트가 아니라 복구된 텍스트에 단언하십시오.
골든 스냅샷을 위해 출력을 바이트 단위로 동일하게 만들기
섹션 제목: “골든 스냅샷을 위해 출력을 바이트 단위로 동일하게 만들기”골든 테스트는 재빌드가 동일한 바이트를 생성할 때만 작동합니다. PDF에는 비결정성의 두 가지 내장 원천이 있습니다: 날짜 필드(CreationDate / ModDate)와 트레일러의 파일 식별자(ISO 32000-2 §7.5.5)입니다. NextPDF는 둘 다 DeterministicSettings를 통해 제거하며, 이는 테스트 해킹이 아니라 일급 구성 값입니다.
DeterministicSettings는 고정된 DateTimeImmutable과 32자 16진수 fileIdSeed를 받습니다. 그것을 Config에 전달한 다음, 그 구성에서 문서를 빌드하십시오. 결정적 프로파일이 고정되면(고정된 타임스탬프와 /ID), 동일한 입력이 동일하게 고정된 도구 체인에서 실행 전반에 바이트 단위로 동일한 출력을 산출합니다 — PHP 패치, 확장 및 압축 라이브러리 버전, 그리고 글꼴 파일이 모두 일정하게 유지됩니다. 이 중 어느 것이라도 다른 머신 전반에서는 바이트가 여전히 갈라질 수 있습니다. 거기서는 텍스트 추출 단언을 선호하고 골든 스냅샷은 고정된 환경을 위해 남겨 두십시오.
<?php
declare(strict_types=1);
use DateTimeImmutable;use NextPDF\Core\Config;use NextPDF\Core\Document;use NextPDF\Core\DeterministicSettings;
function buildInvoice(int $invoiceId): string{ $config = new Config( deterministic: new DeterministicSettings( timestamp: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), fileIdSeed: '00000000000000000000000000000000', // exactly 32 hex chars ), );
$document = Document::createStandalone($config); $document->setLanguage('en'); $document->enableTaggedPdf('en'); // structure tree for reading order; /ToUnicode is emitted separately $document->addPage(); $document->setFont('helvetica', '', 12); $document->multiCell(0, 7, "Invoice #{$invoiceId}");
return $document->getPdfData();}fileIdSeed는 정확히 32개의 16진수 문자여야 하며, 그렇지 않으면 생성자가 InvalidConfigException을 던집니다. 이미 Config를 보유하고 있다면, 그것을 다시 빌드하는 대신 $config->withDeterministic($settings)로 결정적 복사본을 도출할 수 있습니다.
두 단언 스타일을 위한 PHPUnit 테스트
섹션 제목: “두 단언 스타일을 위한 PHPUnit 테스트”이 테스트 클래스는 동일한 빌더에 대해 시맨틱 단언과 골든 단언을 행사합니다. 골든 파일은 한 번 생성되고, 사람이 검토하고, 커밋됩니다. 그 후 테스트는 모든 바이트 변경에서 실패합니다.
<?php
declare(strict_types=1);
namespace App\Tests\Pdf;
use PHPUnit\Framework\TestCase;
use function App\Pdf\buildInvoice; // the deterministic builder above
final class InvoicePdfTest extends TestCase{ private const GOLDEN = __DIR__ . '/__snapshots__/invoice-42.pdf';
public function testInvoiceTextIsPresent(): void { $pdf = buildInvoice(42);
// Recover text with an external extractor (installed in CI, see below). $text = self::extractText($pdf);
self::assertStringContainsString('Invoice #42', $text); }
public function testInvoiceBytesMatchGolden(): void { $pdf = buildInvoice(42);
// First run: write the golden, then review and commit it by hand. if (! \is_file(self::GOLDEN)) { \file_put_contents(self::GOLDEN, $pdf); self::markTestIncomplete('Golden file created — review and commit it.'); }
self::assertSame( \file_get_contents(self::GOLDEN), $pdf, 'Generated PDF bytes drifted from the committed golden snapshot.', ); }
private static function extractText(string $pdf): string { // tempnam() creates a zero-byte file; track it so the finally block // removes both it and the .pdf path, leaking neither. $tmp = \tempnam(\sys_get_temp_dir(), 'pdf'); $tmpPdf = $tmp . '.pdf'; try { \file_put_contents($tmpPdf, $pdf);
// Run pdftotext via proc_open so we can read the exit code AND // stderr. shell_exec() returns "" on a missing/failed binary, which // would silently turn a broken runner into a passing assertion — // the opposite of a reliable CI test. pdftotext writes UTF-8 to "-" // (stdout). Requires poppler-utils on the runner (see workflow). $descriptors = [ 1 => ['pipe', 'w'], // stdout 2 => ['pipe', 'w'], // stderr ]; $process = \proc_open( ['pdftotext', $tmpPdf, '-'], $descriptors, $pipes, );
if (! \is_resource($process)) { throw new \RuntimeException( 'Could not start pdftotext. Install poppler-utils on the runner.', ); }
$text = \stream_get_contents($pipes[1]); $stderr = \stream_get_contents($pipes[2]); \fclose($pipes[1]); \fclose($pipes[2]); $exitCode = \proc_close($process);
if ($exitCode !== 0) { throw new \RuntimeException(\sprintf( 'pdftotext failed (exit %d): %s. Is poppler-utils installed on the runner?', $exitCode, \trim((string) $stderr) !== '' ? \trim((string) $stderr) : '(no stderr)', )); }
return (string) $text; } finally { // Remove both the original tempnam() file and the .pdf we wrote. @\unlink($tmp); @\unlink($tmpPdf); } }}바이트 단언은 buildInvoice()가 DeterministicSettings를 고정하기 때문에만 의미가 있습니다. 그것 없이는 CreationDate만으로도 매 실행에서 골든 테스트가 실패합니다.
CI가 동일한 바이트를 생성하도록 글꼴 고정하기
섹션 제목: “CI가 동일한 바이트를 생성하도록 글꼴 고정하기”바이트 단위로 동일한 출력은 모든 머신에서 동일한 글꼴 바이트가 서브셋되는 것에 의존합니다. 러너에서 워크스테이션과 다르게 해석되는 글꼴은 임베드된 서브셋을 바꾸고 골든 테스트를 깨뜨립니다 — DeterministicSettings가 고정되어 있어도 그렇습니다.
두 가지 규칙이 글꼴을 안정적으로 유지합니다.
- 특정 서체가 필요 없는 골든 테스트에는 Base 14 표준 글꼴(예를 들어
helvetica)을 사용하십시오. 그것들은 커스텀 글꼴 바이트 임베딩을 피합니다 — 안정적인 내장 메트릭에 의존하지만, 정확히 렌더링된 모양은 여전히 뷰어의 글꼴 치환에 의존할 수 있습니다. - 커스텀 글꼴은 저장소에 벤더링하고 NextPDF가 그것을 명시적으로 가리키게 하십시오. 머신마다 다른 시스템 글꼴 경로에 의존하지 마십시오.
Config(fontsDirectory: ...)를 설정하거나 커밋된 디렉터리로addFontDirectory()를 호출하십시오.
<?php
declare(strict_types=1);
use NextPDF\Core\Config;use NextPDF\Core\Document;
$config = new Config(fontsDirectory: __DIR__ . '/fonts'); // committed to the repo$document = Document::createStandalone($config);$document->addFontDirectory(__DIR__ . '/fonts'); // or add it imperatively$document->addPage();$document->setFont('dejavusans', '', 12); // resolved from the repo골든 테스트를 위해 OS 패키지 관리자에서 글꼴을 설치하지 마십시오: 배포판 글꼴 패키지는 버전과 힌팅이 다르므로, 러너 업그레이드가 조용히 바이트를 바꿉니다. 벤더링된 글꼴 디렉터리는 그 변수를 제거합니다.
GitHub Actions 워크플로
섹션 제목: “GitHub Actions 워크플로”이 워크플로는 NextPDF가 필요로 하는 확장과 함께 PHP를 설치하고, 시맨틱 단언을 위한 텍스트 추출기를 설치하고, PHPUnit을 실행합니다. php-version: "8.4" 줄은 PHP 마이너 버전(8.4)을 고정하며, 패치는 아닙니다 — setup-php는 그것을 최신 가용 8.4.x로 해석합니다. 바이트 수준 재현성을 위해, 러너 이미지 업그레이드가 골든 스냅샷 아래에서 PHP 빌드를 이동시킬 수 없도록 지원하는 구체적 패치(예를 들어 php-version: "8.4.8")를 고정하십시오.
name: PDF tests
on: [push, pull_request]
jobs: test: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4
- name: Set up PHP uses: shivammathur/setup-php@v2 with: php-version: "8.4" extensions: curl, gd, intl, mbstring, openssl, zlib coverage: none
- name: Install text extractor for PDF assertions run: sudo apt-get update && sudo apt-get install -y poppler-utils
- name: Install dependencies run: composer install --no-interaction --no-progress --prefer-dist
- name: Run the test suite run: vendor/bin/phpunit --testsuite=pdfpoppler-utils는 텍스트 단언을 위한 pdftotext를 제공합니다. 확장 목록은 NextPDF 코어가 강제로 요구하는 것과 일치합니다: curl, gd, intl, mbstring, openssl, zlib는 각각 네트워킹, 래스터 이미지 처리, 국제화된 텍스트와 정렬, 멀티바이트 텍스트, 암호화/서명을 위한 암호학, 그리고 스트림 압축을 다룹니다. 모두 설치하십시오 — 코어의 composer.json이 각각을 요구하므로, 누락된 확장은 단일 기능이 아니라 composer install을 실패시킵니다. 이후 단언 단계가 HTML이나 XML 출력을 파싱한다면, 그 단계를 위해 dom을 추가하십시오. 그것은 코어 요구 사항이 아닙니다. 글꼴이 저장소에 벤더링되어 있으므로, 글꼴 패키지 설치가 필요 없습니다 — 그것이 러너의 바이트를 사용자의 것과 동일하게 유지하는 것입니다.
엣지 케이스 및 주의 사항
섹션 제목: “엣지 케이스 및 주의 사항”- 골든 테스트는
DeterministicSettings가 필요합니다. 고정된 타임스탬프와fileIdSeed가 없으면CreationDate,ModDate, 트레일러 파일 식별자가 매 실행에서 바뀌고 바이트 단언이 결코 통과하지 않습니다. fileIdSeed는 정확히 32개의 16진수 문자입니다. 다른 길이나 16진수가 아닌 문자는 생성 시InvalidConfigException을 던집니다.- 글꼴은 바이트의 일부입니다. 러너의 다른 글꼴 버전은 글리프를 다시 서브셋하고 골든 테스트를 실패시킵니다. 글꼴을 벤더링하거나 Base 14를 사용하십시오.
- 코어는
extractText()를 실어 보내지 않습니다. 단언을 위한 텍스트 복구는 소비자 작업입니다:pdftotext나 Inspect Spectrum 사이드카를 사용하십시오. 생산자의 일은 올바른/ToUnicodeCMap(임베드된 글꼴에 대해 자동)을 내보내 추출기가 실제 유니코드를 복구하게 하는 것입니다.enableTaggedPdf()는 그 위에 구조 트리를 추가하지만, 그것이 CMap을 생산하는 것은 아닙니다. - Inspect Quick 깊이는 사이드카가 없을 때 인프로세스 PHP 폴백을 가집니다(정확도 제한 —
INSPECT-FALLBACK-001을 플래그). Standard와 Full은 항상 사이드카가 필요합니다. 사이드카 없는 CI의 경우, Quick 폴백은 페이지 수, 버전, 암호화 플래그를 제공합니다 — 그 결과를 근사치로 취급하고 콘텐츠 정확성에는 추출된 텍스트에 의존하십시오. - 골든을 의도적으로 재생성하십시오. 변경이 의도된 것일 때, 스냅샷을 삭제하고, 다시 실행하여 새것을 쓰고, 커밋하기 전에 차이를 검토하십시오. CI에서 골든을 절대 자동으로 덮어쓰지 마십시오.
두 단언 스타일 모두 저렴합니다. 골든 비교는 한 번의 빌드 더하기 문자열 비교입니다. 시맨틱 경로는 문서당 하나의 프로세스 외부 pdftotext 호출을 추가합니다. 그것들을 텍스트에 실제로 단언하는 문서로 유지하십시오. Inspect Quick PHP 폴백(사이드카 없음)은 바이트의 단일 패스 스캔이므로, 테스트에 무시할 만한 시간을 추가합니다. 사이드카가 구성되면, Quick 깊이는 대신 하나의 사이드카 왕복을 만듭니다.
보안 참고 사항
섹션 제목: “보안 참고 사항”- 추출된 텍스트를 기계 판독 가능한 것으로 취급하십시오: 기밀성 제어로서 비밀이 바이트에서 없음을 결코 단언하지 마십시오. 태그된 텍스트는 파일을 가진 누구나 읽을 수 있습니다. 기밀성을 위해서는 암호화하십시오.
- 추출기를 위한 임시 파일 경로를
tempnam()으로 빌드하고 정리하십시오. 테스트 픽스처를 예측 가능한 공유 경로로 전달하지 마십시오. - 도구와 액션 버전을 고정하십시오(
8.4마이너만이 아니라8.4.8같은 구체적 PHP 패치, 배포판을 통한poppler-utils, 액션 SHA 또는 태그) — 공급망 범프가 골든 바이트나 도구 체인을 조용히 바꿀 수 없도록.
적합성
섹션 제목: “적합성”이 가이드는 규범적 표준 주장을 하지 않습니다. 그것이 의존하는 결정성은 ISO 32000-2에서 명명된 두 비결정적 필드 — 트레일러 파일 식별자(/ID, §7.5.5)와 문서 정보 날짜 필드(CreationDate / ModDate, 트레일러와는 별개 위치인 문서 정보 딕셔너리에 담김) — 를 DeterministicSettings를 통해 제거하는 것입니다. 텍스트 단언은 엔진이 임베드된 글꼴에 대해 내보내는 /ToUnicode CMap(§9.10.2)에 의존합니다. enableTaggedPdf()는 구조 트리를 별개로 추가하며 그 CMap을 만들지 않습니다. 보여진 모든 NextPDF 호출은 검증된 공개 API입니다.