기술 문서

바이트 동일 PDF 출력: Delphi에서 재현 가능한 저장

PDFium Component는 재현 가능한 PDF 출력을 단 한 번의 호출로 만듭니다: TPdf.SaveAsReproducible은 같은 콘텐츠를 두 번 저장하면 바이트 동일 파일이 나오도록 문서를 다시 씁니다. 문서 정보 딕셔너리에서 /CreationDate와 /ModDate를 제거하고, 트레일러 /ID를 쓰지 않고, 오브젝트 순서를 안정적으로 유지하며, 바꾼 것을 TPdfReproducibleReport에 보고합니다. 암호화된 파일은 거부되고, 서명된 파일은 명시적 동의가 필요합니다

필요성은 보통 빌드 파이프라인에서 나타납니다. 누군가 생성된 PDF를 리포지터리나 골든 파일 테스트 스위트에 커밋하는데, 어느 페이지도 바뀐 게 없는데도 매 실행마다 diff가 나옵니다. 또는 아카이브가 SHA-256으로 중복을 제거하는데 같은 인보이스의 사본 50장을 조용히 저장합니다. 저장할 때마다 새 시각을 파일에 찍었으니까요. 벽시계를 박아 넣는 PDF 작성기는 데스크톱 앱에는 괜찮지만 바이트를 정체로 취급하는 무엇에게는 적대적입니다

같은 PDF를 두 번 저장하면 왜 다른 바이트가 나올까요?

변하지 않은 문서의 두 번 저장이 다른 이유는 정확히 두 종류의 데이터 때문입니다: 문서 정보 딕셔너리의 타임스탬프와 트레일러의 파일 식별자입니다. 신중한 작성기가 내보내는 나머지 전부, 오브젝트 번호 매기기, 오브젝트 순서, 크로스 레퍼런스 레이아웃은 결정론적으로 만들 수 있고, PDFium Component 전면 재작성 경로는 이미 그렇게 합니다: 오브젝트는 오름차순 오브젝트 번호로 기록되고 xref 테이블이 같은 순서를 따릅니다

타임스탬프가 눈에 띄는 범인입니다. ISO 32000-1 §14.3.3은 /Info 딕셔너리의 /CreationDate와 /ModDate를 정의하고, 네이티브 PDFium 저장은 문서를 직렬화할 때마다 새로운 /ModDate를 씁니다. 파일 식별자가 미묘한 쪽입니다. ISO 32000-1 §14.4는 /ID를 바이트 문자열의 2요소 배열로 기술합니다. 첫 요소는 원본 파일을 식별하고 둘째 요소는 파일이 수정됨에 따라 바뀝니다. 작성기들은 보통 그 문자열들을 파일에 관한 무언가를 해시해서 유도하는데, 그 무언가에 타임스탬프가 찍힌 바이트가 포함되면 식별자는 비결정론을 물려받습니다

Delphi에서 TPdf.SaveAsReproducible 사용하기

TPdf.SaveAsReproducible에는 오버로드가 둘 있습니다. TStream에 쓰는 것과 파일명에 쓰는 것이며, 둘 다 out Report: TPdfReproducibleReport와 기본값이 False인 선택적 AllowSignedDocument 파라미터를 받고 Boolean을 반환합니다. 반환값은 Report.Succeeded를 반영합니다; False일 때 Report.ErrorMessage가 이유를 말합니다. 파일 오버로드는 대상 옆의 임시 파일에 쓰고 성공한 저장 뒤에만 대상을 치환하므로, 실패한 실행이 이전 빌드 출력 자리에 반쯤 쓰인 PDF를 남기지 않습니다

var
  Pdf: TPdf;
  Report: TPdfReproducibleReport;
begin
  Pdf:= TPdf.Create(nil);
  try
    Pdf.FileName:= 'invoice-2026-10.pdf';
    Pdf.Active:= True;
    if Pdf.SaveAsReproducible('build\invoice-2026-10.pdf', Report) then
      Writeln(Format('%d -> %d bytes, %d objects kept, %d pruned, ' +
        '%d timestamps stripped', [Report.SourceByteCount,
        Report.OutputByteCount, Report.KeptObjectCount,
        Report.PrunedObjectCount, Report.StrippedTimestampCount]))
    else
      Writeln('Reproducible save failed: ', Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

리포트 필드들은 CI에서 로그로 남길 가치가 있습니다. StrippedTimestampCount는 /Info가 두 날짜를 담은 문서에서 보통 2이고, /Info 딕셔너리가 아예 없는 문서에서 0입니다. PrunedObjectCount는 네이티브 저장에는 있었지만 카탈로그나 /Info 딕셔너리에서 도달할 수 없었던 오브젝트를 셉니다; 재현 가능 재작성은 그것들을 떨어뜨리므로, 출력이 평범한 저장과 같은 크기이거나 더 작을 것으로 예상하세요

타임스탬프 제거만으로는 부족했던 이유

첫 구현은 두 타임스탬프를 모두 제거했는데도 서로 다른 파일 둘을 만들어 냈습니다. 페이지는 일치했고 /Info 딕셔너리는 일치했는데, 트레일러가 /ID의 둘째 요소에서 달랐습니다. 근본 원인은 순서였습니다: 재작성은 먼저 PDFium에 네이티브 저장을 요청하고 그 바이트를 후처리하는데, 트레일러 빌더가 네이티브 출력을 해시해서 /ID를 계산했던 것입니다. 그 네이티브 출력에는 여전히 새로운 /ModDate가 담겨 있었으므로, 해시와 따라서 식별자는 타임스탬프 자체가 최종 파일에 닿지도 않았는데도 매 실행마다 바뀌었습니다

재현 가능한 빌드를 위한 일반 규칙의 좋은 예입니다: 비결정론적 값을 무언가가 먼저 유도했다면 제거만으로는 부족합니다. 해법은 /ID를 아예 운반하지 않는 것이었습니다. TPdf.SaveAsReproducible은 /Size, /Root, 그리고 소스에 있을 경우 /Info만 담은 트레일러를 씁니다. 트레일러에 시간 의존적인 것이 남지 않으면, 같은 문서의 연속된 두 저장은 바이트 단위로 동일하게 비교되고, 동작 검증도 정확히 그런 식으로 이뤄집니다

function SavesAreIdentical(Pdf: TPdf): Boolean;
var
  A, B: TMemoryStream;
  RA, RB: TPdfReproducibleReport;
begin
  A:= TMemoryStream.Create;
  B:= TMemoryStream.Create;
  try
    Result:= Pdf.SaveAsReproducible(A, RA) and
      Pdf.SaveAsReproducible(B, RB) and
      (A.Size = B.Size) and CompareMem(A.Memory, B.Memory, A.Size);
  finally
    B.Free;
    A.Free;
  end;
end;

문자열을 망가뜨리지 않고 딕셔너리 키 제거하기

타임스탬프는 텍스트 검색이 아니라 /Info 오브젝트 본문의 토큰 인지 스캔으로 제거됩니다. /ModDate를 향한 순진한 바이트 치환은 우연히 그 텍스트를 담고 있는 /Subject나 /Keywords 문자열까지 때리고, 리터럴 문자열에서 바이트를 잘라 내면 크게 실패하는 대신 메타데이터를 조용히 망가뜨립니다. PDFium Component는 먼저 본문의 스캔 사본에서 리터럴 문자열과 주석을 무력화한 다음, 진짜 이름 토큰이 시작하는 자리의 매치만 받아들이고, PDF 오브젝트 문법으로 그 키의 값이 끝나는 곳을 찾아 키-값 쌍 전체를 건너뜁니다

실용적 결과는 /Info의 다른 모든 엔트리, 제목, 저자, 생산자, 커스텀 키가 손대지 않고 살아남는다는 것입니다. 날짜로 정의된 두 키만 제거되고, 그것도 트레일러의 /Info 엔트리가 실제로 가리키는 딕셔너리에서만입니다

암호화·서명 PDF에서 /ID 생략의 대가는 무엇인가요?

/ID 생략은 평범한 PDF 1.x 출력에는 안전하지만, 암호화를 배제하고 서명에 일어나는 일을 바꿉니다. ISO 32000-1에서 트레일러 /ID는 /Encrypt 엔트리가 있지 않은 한 선택 사항입니다. 표준 보안 핸들러가 첫 식별자 문자열을 암호화 키에 섞기 때문입니다. /ID 없는 파일은 올바르게 암호화된 파일일 수 없으므로, TPdf.SaveAsReproducible은 시도하지 않습니다: 트레일러가 /Encrypt를 달고 있으면 False를 반환하고 Report.ErrorMessage가 문서가 암호화되어 있다고 말합니다. 파이프라인이 허용한다면 먼저 복호화하거나, 암호화된 산출물을 바이트 동일성 검사에서 빼 두세요. PDF 2.0을 겨냥한다면 주의하세요. ISO 32000-2는 규칙을 강화해 모든 파일에 /ID를 요구하므로, 재현 가능 출력은 PDF 1.x 대상에 가장 자연스럽게 맞습니다; Delphi에서의 정확한 PDF 버전 준수성 글이 컴포넌트가 버전 대상을 다루는 방식을 다룹니다

서명된 문서는 다른 문제입니다. TPdf.SaveAsReproducible은 전면 비증분 재작성이므로 바이트 오프셋이 움직이고 기존 서명의 /ByteRange는 더 이상 서명된 바이트를 커버하지 않습니다. 따라서 서명된 파일의 재현 가능 출력은 안정적인 바이트를 얻는 대가로 기존 서명의 유효성을 포기하는 의사 결정이고, 그래서 AllowSignedDocument = True를 넘기지 않는 한 메서드는 SignatureCount가 0보다 클 때 EPdfError를 냅니다

if Pdf.SignatureCount > 0 then
  // 명시적 옵트인: 재작성은 바이트 오프셋을 옮기므로 기존
  // 서명은 이후 검증에 실패할 것으로 예상해야 합니다
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);

서명된 원본이 진실의 기록이라면 그대로 두고 재현 가능 출력은 CI에서 diff하는 정규화된 렌딩션 같은 파생 사본에만 쓰세요. 이미 있는 것을 건드리지 않고 보호된 파일에 덧붙이는 것은 암호화된 PDF를 Delphi에서 조용히 패치할 수 없는 이유를 참고하세요

바이트 동일성을 믿기 전에 알아 둘 경계

바이트 동일성은 같은 컴포넌트 빌드가 저장한 같은 문서에 대해 성립하고, 메서드가 손대는 것을 정확히 커버합니다. TPdf.SaveAsReproducible은 두 /Info 날짜만 벗깁니다; 자체 xmp:CreateDate나 xmp:ModifyDate를 가진 XMP 메타데이터 스트림은 문서가 이미 들고 있는 바이트 그대로 운반됩니다. 그것은 저장을 거쳐도 안정적이지만, XMP와 /Info 날짜가 더 이상 같은 것을 기술하지 않는다는 뜻이고, 둘의 일치를 요구하는 PDF/A 프로파일에서는 중요합니다; PDF/A Info와 XMP 메타데이터 등가성 글이 그 규칙들을 설명합니다. 다른 PDFium 바이너리나 컴포넌트 버전은 오브젝트를 다르게 직렬화할 수도 있으므로, 기계를 넘어 해시를 비교하는 파이프라인에서는 둘 다 고정하세요

그 한계 안에서 이 메서드는 CI diff, 골든 파일 테스트, 콘텐츠 주소 아카이브가 필요로 하는 것을 줍니다: 같은 입력, 같은 바이트. TPdf.SaveAsReproducible과 TPdfReproducibleReport는 저장, 최적화, 준수성 API의 나머지와 함께 Delphi 및 C++Builder용 PDFium Component에 들어 있습니다

FAQ

TPdf.SaveAsReproducible은 페이지 콘텐츠를 바꾸나요?

아니요. /Info 딕셔너리에서 /CreationDate와 /ModDate 키를 제거하고, 트레일러 /ID를 생략하며, 카탈로그나 /Info에서 도달 불가능한 오브젝트를 떨어뜨립니다; 페이지 콘텐츠는 변경 없이 기록됩니다

암호화된 PDF에서 재현 가능한 출력을 만들 수 있나요?

아니요. 암호화는 재현 가능 출력이 생략하는 트레일러 /ID에 의존하므로, 메서드는 암호화된 소스에 대해 설명이 붙은 ErrorMessage와 함께 False를 반환합니다

서명된 PDF에 AllowSignedDocument가 필요한 이유는 무엇인가요?

재현 가능 저장은 바이트 오프셋을 옮기는 전면 재작성이므로, 기존 서명이 자기 서명 바이트 범위와 더 이상 맞지 않게 됩니다. AllowSignedDocument = True를 넘기는 것은 그 트레이드오프를 받아들인다는 확인입니다