losLab PDF Library는 SetDeterministicDocumentID(1)을 호출하면 동일한 입력에 대해 바이트 단위로 동일한 PDF 출력을 만들어낼 수 있습니다. 기본적으로 트레일러 /ID 배열은 벽시계 시각의 MD5 다이제스트이므로, 같은 생성기의 두 실행은 최소한 그 바이트만큼은 다릅니다. 결정론적 모드는 대신 /ID를 안정된 시드에서 유도해서 재현 가능한 빌드를 되찾아줍니다
이 증상은 보통 아무도 찾아보기 전에 CI에서 먼저 나타납니다. 템플릿은 바뀌지 않았고, 입력 레코드도 바뀌지 않았고, 폰트도 바뀌지 않았는데, 생성된 PDF는 파이프라인이 실행될 때마다 다르게 해시됩니다. 빌드 캐시는 절대 적중하지 않습니다. 콘텐츠 주소 지정 스토리지는 야간 빌드마다 새로운 블롭을 쌓아갑니다. 바이트 수준 회귀 diff는 아무도 건드리지 않은 파일에서 켜집니다. 그 diff를 실제 바이트까지 추적해보면 거의 항상 파일 트레일러에 앉아 있는 같은 몇 개의 16진수 숫자입니다
트레일러 ID 배열은 무엇을 위한 것인가
트레일러 /ID는 콘텐츠의 체크섬이 아니라 파일 식별 마커입니다. ISO 32000-1 §14.4는 이를 두 개의 바이트 문자열로 구성된 배열로 정의합니다. 첫 번째 요소는 문서가 생성될 때 배정되어 이후의 모든 편집에서 살아남도록 의도된 영구 식별자이고, 두 번째 요소는 라이터가 파일이 수정될 때마다 새로 고치는 변경 식별자입니다. 둘을 합치면 시스템이 두 파일이 하나의 문서의 리비전인지 아니면 서로 무관한 두 문서인지 결정할 수 있습니다. §7.5.5는 트레일러가 /Encrypt를 담고 있을 때는 반드시 /ID도 담아야 한다고 정해서 이 항목을 사실상 필수로 만듭니다
명세는 값을 어떻게 계산하는지에 대해서는 아무것도 말하지 않습니다. 권장 사항은 현재 시각, 파일 경로, 파일 크기, 문서 정보 딕셔너리 같은 것들의 다이제스트이며, 벽시계 시각이 결과를 유일하게 만드는 재료입니다. 그것이 바로 식별을 위해서는 원하는 속성이면서 동시에 재현성을 파괴하는 속성이며, 그래서 이것이 조용한 동작 변경이 아니라 명시적인 스위치여야 하는 이유입니다
같은 빌드는 왜 매번 다른 PDF를 만드는가
기본 식별자가 생성 순간에서 유도되기 때문입니다. 역사적으로 losLab PDF Library는 현재 타임스탬프의 MD5로 /ID 문자열을 만들었으므로, 1초 간격을 두고 두 번 생성된 문서는 파일 안의 다른 모든 바이트가 동일하더라도 두 개의 다른 영구 식별자를 가집니다. 그 하류 비용은 실제적입니다. 아티팩트를 해시로 키잉하는 빌드 시스템은 PDF 단계를 절대 재사용할 수 없고, 중복 제거 객체 저장소는 문서당 한 사본이 아니라 빌드당 한 사본을 유지하며, 바이너리 diff를 보는 리뷰어는 나머지 diff를 신뢰하기 전에 유일한 변경이 노이즈뿐임을 증명해야 합니다. 결정론적 /ID 생성은 객체 스트림과 상호 참조 스트림 노트에서 설명하는 레이아웃 안정성 작업과 같은 정신으로 그 노이즈를 제거하기 위해 존재합니다
재현 가능한 식별자로 전환하기
결정론적 모드는 문서별로 옵트인이며, 요청하기 전까지는 기존 출력이 바뀌지 않도록 기본값이 꺼져 있습니다. SetDeterministicDocumentID는 0이나 1을 받아들이고, 값이 받아들여지면 1을, 범위를 벗어나면 0을 반환합니다. GetDeterministicDocumentID는 현재 상태를 보고합니다. SetDocumentIDSeed는 다른 모든 것보다 우선하는 명시적인 시드 문자열을 공급하며, 빈 시드를 넘기면 유도된 시드로 되돌아갑니다. GetDocumentFileID는 저장 이후 /ID[0]을 읽어 돌려주므로 이를 로그로 남기거나 그것에 대해 assert할 수 있습니다
var
Lib: TPDFlib;
FileID: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed('invoice-4471-rev3');
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Invoice 4471');
Lib.SaveToFile('invoice.pdf');
FileID := Lib.GetDocumentFileID; // identical on every run
finally
Lib.Free;
end;
end;
새로 고침은 플래그를 켤 때가 아니라 저장 시점에 일어나므로, 문서 빌드 후반에 결정론적 모드를 활성화해도 여전히 효과가 있습니다. 이는 또한 바뀐 시드가 다음 전체 저장에 반영된다는 뜻이기도 합니다. 시드 A를 설정하고 저장한 다음 시드 B를 설정하고 저장하면 두 파일은 다른 식별자를 가지며, 시드 A를 복원하면 원래 값이 복원됩니다. 문서가 청구서 번호, 레코드 리비전, git 커밋 식별자 같은 자연스러운 안정 키를 가지고 있다면 명시적인 시드가 옳은 선택인데, 식별자를 부수적인 메타데이터로부터 분리해주기 때문입니다
여러분이 직접 시드를 제공하지 않으면 시드는 어디서 오는가
명시적인 시드가 없으면 losLab PDF Library는 동일한 재생성 전반에 걸쳐 불변이어야 할 문서 상태로부터 시드를 유도합니다. PDF 버전 헤더, 페이지 수, 문서 정보 딕셔너리의 모든 항목입니다. 문자열과 이름 값은 그대로 취해지고, 다른 객체 타입은 직렬화된 형태로 기여하며, 이 모든 것이 /ID 문자열로 해시됩니다. 중요한 결과는 CreationDate와 ModDate가 정보 딕셔너리의 일부이므로 설계상 시드의 일부라는 점입니다. 두 번의 실행이 같은 식별자를 얻으려면 진짜로 같은 문서 메타데이터를 만들어내야 합니다
Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
Lib.SetInformation(2, 'Quarterly Report'); // Title
Lib.SetInformation(5, 'reporting-service 4.2'); // Creator
Lib.SetInformation(7, 'D:20260101000000Z'); // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z'); // ModDate
Lib.SaveToFile('report.pdf');
키 8로 ModDate를 고정하는 것은 이중의 역할을 하며, 이 부분이 사람들을 걸려 넘어지게 합니다. 결정론적 /ID만으로는 파일을 바이트 단위로 동일하게 만들지 못하는데, 저장 경로가 호출자가 명시적으로 설정하지 않는 한 ModDate에 현재 시각을 찍기 때문입니다. 키 8을 설정하면 그 값이 호출자가 공급한 것으로 표시되고 그 시각 찍기를 억제합니다. 식별자뿐 아니라 재현 가능한 파일 자체를 원한다면, 메타데이터 타임스탬프를 빌드 입력으로 취급하십시오. 소스 레코드나 고정된 기준일에서 유도하고, 절대 Now에서 유도하지 마십시오
ID를 다시 쓰면 왜 암호화된 PDF가 깨지는가
/ID[0]은 암호화된 문서에서는 단순한 메타데이터가 아니라 키 재료이기 때문입니다. ISO 32000-1 §7.6.3.3 알고리즘 2는 파일 식별자의 첫 번째 요소를, 패딩된 비밀번호, /O 값, 허가 비트와 함께 표준 보안 핸들러의 리비전 2부터 4까지의 암호화 키 계산에 투입합니다. 유도된 키는 그다음 리더가 열 때 확인하는 /U 검증 문자열을 만들어내며, 파일 키는 Encrypt를 호출할 때나 암호화된 문서가 로드될 때 유도되고 캐시되며, 둘 다 저장 이전에 일어납니다. 그러므로 저장 도중 식별자를 다시 쓰면 구조적으로는 유효하지만 재오픈 시 /U 검사가 실패하는 파일이 만들어질 것입니다. 미묘한 손상이 아니라 여러분을 포함해 아무도 열 수 없는 문서입니다. 그래서 결정론적 새로 고침은 암호화 상태를 가지지 않은 문서로 제한되며, 암호화된 문서는 결정론적 모드 여부와 무관하게 이미 가지고 있던 /ID를 그대로 유지합니다. 이 설정은 단순히 그 경로에 아무 영향도 미치지 않습니다. 관련된 리비전 처리와 허가 의미론은 PDF 암호화와 허가 감사 안내에서 다룹니다. 암호화 복원 경로는 §14.4가 의도한 대로 정확히 /ID[1], 즉 변경 식별자만 새로 고친다는 점도 유의하십시오
증분 저장은 왜 원래 식별자를 유지하는가
두 번째 경계는 append 모드입니다. 증분 업데이트는 파일의 앞선 모든 바이트를 손대지 않은 채 두고 그 뒤에 새 리비전을 씁니다. §14.4 전반에 걸친 /ID[0]의 영구성은 새 리비전이 이전 리비전과 같은 문서에 속한다는 것을 소비자에게 알려주는 근거입니다. 이를 다시 쓰는 것은 그 연결을 끊고, 파일 안에 이미 있는 리비전들과 모순을 일으키며, 서명 의미론을 방해할 것입니다. 서명은 특정 문서의 특정 리비전의 바이트 범위를 커버하기 때문입니다. 그래서 losLab PDF Library는 전체 저장에서만 결정론적 식별자를 새로 고치고 append 모드 중에는 절대 그러지 않으며, 이는 PDF 증분 업데이트와 스트림에 대한 append 글에서 설명하는 보장을 그대로 유지합니다
식별자 생성을 위한 하나의 관문
losLab PDF Library의 모든 /ID 생성은 이제 NewFileIDString이라는 하나의 내부 루틴을 통과하며, 이것이 결정론적 스위치를 하나의 경로에 붙인 패치가 아니라 신뢰할 수 있는 것으로 만들어줍니다. 빈 문서 생성, 누락된 /ID 배열의 지연 생성, 암호화 지문 복원 경로 모두 이 루틴을 호출하므로, 벽시계 시각이 다시 새어 들어올 수 있는 곳은 정확히 한 군데뿐입니다. 이는 또한 콘텐츠에서 유도된 식별자 같은 미래의 변형이 전체 시리얼라이저에 대한 감사가 아니라 함수 하나에 대한 변경이라는 뜻이기도 합니다
function BuildQuote(const Seed: WideString): AnsiString;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(Seed);
Lib.SetInformation(7, 'D:20260101000000Z');
Lib.SetInformation(8, 'D:20260101000000Z');
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Quote 8812');
Result := Lib.SaveToString;
finally
Lib.Free;
end;
end;
// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
WriteLn('reproducible')
else
WriteLn('nondeterminism leaked into the output');
다른 어디에서든 재현 가능한 출력에 의존하기 전에 이 비교를 테스트 스위트에 넣어두십시오. 새로운 기능이 타임스탬프를 다시 들여오는 순간 크게 실패해줄 것이기 때문입니다. 재현성은 그렇게 하지 않으면 조용히 붕괴하는 속성이며, 인메모리 저장 두 번에 대한 단일 assertion은 모든 빌드에서 실행해도 거의 비용이 들지 않습니다
여기서 보여준 결정론적 식별자 API는 Delphi와 C++Builder용 losLab PDF Library와 함께 전체 문서 정보, 암호화, 증분 저장 레퍼런스와 나란히 제공됩니다