HotPDF Component는 THPDFHeadlessDocument.SaveFDFDifferencesToFile를 통해 Delphi에서 FDF /Differences 스트림을 적용합니다. 이 메서드는 정확한 바이트 길이와 SHA-256으로 식별한 기준 리비전과 적재된 PDF가 여전히 일치하는지 검사하고, 누적 리비전 바이트를 정확히 한 번 이어붙이며, 결과를 암호화 권한과 DocMDP/FieldMDP 승인에 통과시키고, 원자적으로 발행합니다. 같은 제출을 반복하면 바이트 단위 no-op입니다
이 기능이 존재하는 워크플로는 화려하지 않지만 흔합니다. 계약서나 검사 양식을 PDF로 보내면 누군가 자기 뷰어에서 작성하고 서명하고, 돌아오는 것은 PDF가 아니라 FDF 파일입니다. 필드 값은 /Fields에 실리지만, 서명된 리비전 자체, 즉 CMS 서명이 실제로 커버하는 바이트는 /Differences에 실립니다. 병합을 잘못하면 서명을 깨거나, 더 나쁘게는 어떤 서명도 커버하지 않는 필드 값을 받아들이게 됩니다
FDF /Differences 스트림은 실제로 무엇을 실어 나를까
FDF /Differences 스트림은 대상 PDF의 raw 증분 업데이트 바이트를 실지, FDF 수준의 이력이 아닙니다. ISO 32000-1 §12.7.7.3, 표 243은 FDF 딕셔너리의 Differences 항목을 열었던 이후 원본 PDF에 가해진 증분 변경으로 정의하며, §7.5.6의 PDF 증분 업데이트와 같은 형태입니다. 새 객체와 교체된 객체, 크로스 레퍼런스 섹션, /Prev가 기본 파일을 가리키는 트레일러입니다
FDF 컨테이너 자체는 정반대입니다. §12.7.7.1은 FDF 파일에 대한 증분 업데이트를 금지하므로, FDF는 단일 트레일러, 세대 0 객체만, 유일한 객체 번호만 갖습니다. HotPDF Component는 FDF 파서에서 정확히 이것을 강제하고 /Differences를 편하게 만들려고 느슨해지지 않습니다. 증분 데이터는 엄격히 비증분 컨테이너의 한 스트림 안에서 살고, 이 두 층을 분리해 두는 것이 일의 절반입니다
스트림의 두 성질이 이후 모든 설계 결정을 좌우합니다:
- 스트림은 누적입니다. 대상 PDF가 열린 이후 추가된 모든 바이트를 담으므로, 같은 세션의 두 번째 제출은 이미 커밋한 리비전을 반복하고 그 뒤에 새 것을 더합니다
- 같은 FDF의 바깥
/Fields와/Annots항목이/Differences와 일치한다는 보장이 없으므로, 쉽게 읽을 수 있는 필드 값은 서명이 커버하는 데이터가 아닙니다
두 번째 항목은 뼈 있게 말할 가치가 있습니다. 임포트 코드가 /Fields에서 /V를 읽고 같은 파일에 서명이 들어 있었다는 이유로 그 결과를 서명된 것으로 치부한다면, 위조 채널을 만든 겁니다. HotPDF Component는 경로를 의도적으로 분리해 둡니다. FDF 어노테이션 임포터는 /Differences를 즉시 거부하고, 전용 Differences 전송만 이를 다룹니다
이어붙이기를 명시적 기준 리비전에 묶기
SaveFDFDifferencesToFile는 FDF가 어느 리비전에 속하는지 추측하기를 거부합니다. 호출자는 양식을 연 완전한 PDF 리비전의 정확한 길이인 BaseBytes와 64자리 16진수 SHA-256인 BaseSHA256을 넘깁니다. 메서드는 적재된 문서의 그 접두부를 다시 해싱해 불일치면 "The loaded PDF does not match the explicitly selected base"로 거부하고, 접두부가 startxref가 실제 크로스 레퍼런스 데이터를 가리키는 진짜 리비전 끝점에서 끝나는지 검사합니다
실용 규칙은 양식을 보낼 때 두 숫자를 기록하는 것이지 FDF가 돌아올 때가 아닙니다. HPDFCrypt의 SHA256Init / SHA256Update / SHA256Final 루틴 위의 작은 헬퍼면 충분합니다:
uses
SysUtils, Classes, HPDFCrypt;
function PrefixSHA256Hex(const FileName: string; PrefixBytes: Int64): AnsiString;
var
Stream: TFileStream;
Ctx: TSHA256Context;
Digest: TSHA256Digest;
Buffer: array[0..65535] of Byte;
Remaining: Int64;
Count, I: Integer;
begin
Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
if (PrefixBytes <= 0) or (PrefixBytes > Stream.Size) then
raise EArgumentException.Create('Base length is outside the file');
SHA256Init(Ctx);
Remaining := PrefixBytes;
while Remaining > 0 do
begin
Count := SizeOf(Buffer);
if Remaining < Count then
Count := Integer(Remaining);
Stream.ReadBuffer(Buffer, Count);
SHA256Update(Ctx, Buffer, Count);
Dec(Remaining, Count);
end;
SHA256Final(Ctx, Digest);
finally
Stream.Free;
end;
Result := '';
for I := 0 to High(Digest) do
Result := Result + AnsiString(LowerCase(IntToHex(Digest[I], 2)));
end;
// 전송 시점: 나가는 파일 전체가 기준 리비전입니다
// BaseLength := 'out\contract-0042.pdf'의 바이트 크기
// BaseHash := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);
적재된 문서가 정확히 기본일 필요는 없습니다. 기본에 이전 제출에서 이미 커밋한 리비전을 더한 것일 수 있으며, 누적 스트림이 다룰 만하게 유지되는 방식이 이것입니다. BaseBytes는 계속 원래 접두부를 가리키고, 그 뒤는 전부 이전 이어붙이기 바이트로 취급됩니다
SaveFDFDifferencesToFile로 Differences 적용하기
THPDFHeadlessDocument.SaveFDFDifferencesToFile는 출력 파일 이름, FDF 바이트, 기준 길이와 해시, 선택적인 내장 FDF 선택, THPDFFDFPackageOptions 한도 레코드를 받고 THPDFHeadlessFDFDifferencesResult를 반환합니다. 내부에서는 FDF를 THPDFFDFPackage로 열어 DecodeDifferences를 직접 호출하므로, 미리 디코딩된 스트림이 아니라 FDF 파일을 넘기면 됩니다. 빈 Selection은 최상위 FDF를 뜻하고, 비어 있지 않으면 먼저 내장된 자식 FDF로 들어갑니다
uses
SysUtils, IOUtils, HPDFHeadlessDocument, HPDFHeadlessCMS, HPDFFDFPackage;
procedure CommitReturnedForm(const CurrentPdf, NextPdf, FdfFile: string;
BaseLength: Int64; const BaseHash: AnsiString);
var
Doc: THPDFHeadlessDocument;
Policy: THPDFHeadlessCMSOptions;
Selection: THPDFFDFChildSelections;
Applied: THPDFHeadlessFDFDifferencesResult;
begin
Doc := THPDFHeadlessDocument.Create;
try
Policy := THPDFHeadlessCMSOptions.Default;
Policy.TrustedCAFile := 'trust\issuing-ca.pem';
Doc.SetSignaturePolicyOptions(Policy);
Doc.LoadFromFile(CurrentPdf);
Selection := nil; // 최상위 FDF, 내장 자식 없음
Applied := Doc.SaveFDFDifferencesToFile(NextPdf,
TFile.ReadAllBytes(FdfFile), BaseLength, BaseHash,
Selection, THPDFFDFPackageOptions.Default);
Writeln(Format('base %d, prior %d, total %d, added %d, no-op %s',
[Applied.BaseBytes, Applied.PriorAppendBytes, Applied.TotalAppendBytes,
Applied.AddedBytes, BoolToStr(Applied.NoOp, True)]));
finally
Doc.Free;
end;
end;
검사 순서가 호출 자체보다 중요합니다. 기본 해시가 통과한 뒤, 메서드는 이미 커밋된 이어붙이기 바이트, 즉 BaseBytes부터 적재된 파일 끝까지를 디코딩된 페이로드의 시작과 비교합니다. 이미 커밋한 것보다 짧은 제출은 "A cumulative submission cannot remove committed append bytes"로 실패하고, 어긋나는 제출은 "The cumulative Differences conflict with the committed revision"으로 실패합니다. 남는 접미부만 새 것이고, 그 접미부만 AddedBytes에 들어갑니다
조립된 후보는 대상 디렉터리의 배타적 임시 파일에 기록되고, 플러시되고, 독립적인 리비전 원천으로 다시 열려, 출력을 교체하기 전에 검증됩니다. 파싱, 취소, 정책, 예산, 발행 실패는 적재된 문서와 기존 출력 파일을 모두 그대로 둡니다. 두 전제 조건이 사람들을 일찍 걸러 줍니다. 출력이 적재된 소스를 별칭으로 삼아서는 안 되고, 문서에 저장되지 않은 편집이 있어서는 안 됩니다. 불투명한 이어붙이기는 그 편집들을 조용히 버리기 때문입니다("Differences를 적용하기 전에 대기 중인 문서 편집을 발행하십시오")
반복 적용이 왜 바이트 단위 no-op일까
같은 누적 FDF의 반복 적용이 no-op인 이유는 페이로드의 모든 바이트가 이미 파일 안에 있기 때문입니다. 페이로드가 이전 이어붙이기 바이트와 정확히 같으면 SaveFDFDifferencesToFile는 NoOp = True와 AddedBytes = 0을 보고하고, 발행하는 출력은 현재 PDF와 동일합니다. 이것은 "추가 전용"보다 강한 보장이며, 네트워크 타임아웃 뒤의 재시도나 죽은 워커를 안전하게 만드는 것이 바로 이것입니다
// Rev 1은 이미 contract-0042-r1.pdf에 커밋됨, 같은 FDF가 두 번 도착
// (Doc, Again, Fdf, Selection, BaseLength, BaseHash는 위에서 선언)
Doc := THPDFHeadlessDocument.Create;
try
Doc.LoadFromFile('store\contract-0042-r1.pdf');
Again := Doc.SaveFDFDifferencesToFile('store\contract-0042-r1-retry.pdf',
Fdf, BaseLength, BaseHash, Selection, THPDFFDFPackageOptions.Default);
// Again.NoOp = True, Again.AddedBytes = 0,
// 그리고 재시도 파일은 contract-0042-r1.pdf와 바이트 단위로 동일
finally
Doc.Free;
end;
수용 테스트도 그에 맞게 쓰십시오. 재시도 출력과 현재 리비전 사이의 전체 바이트 동일성을 단언하십시오. 파일이 줄어들지 않았다는 것만이 아니라. 추가 전용 동작만 확인하는 테스트는 같은 리비전을 두 번 이어붙이는 구현도 쾌히 통과시키는데, 그 결과는 나중에 DocMDP 감사가 설명해야 하는 중복 업데이트 섹션을 가진, 그럴듯해 보이는 PDF입니다
리비전이 발행되기 전에 어떤 승인 검사가 도는가
발행 전에 후보 PDF는 아이덴티티, 서명, 권한 검사를 통과해야 하며, 어느 것도 바깥 FDF 필드를 신뢰하지 않습니다. 트레일러 /ID의 첫 원소는 변하지 않고 살아남아야 하고, 적재된 문서에 이미 있는 모든 서명 필드는 동일한 서명 딕셔너리와 같은 서명 값 아이덴티티로 여전히 존재해야 합니다. 기존 서명을 빼버리거나 교체하거나 다시 쓰는 Differences 페이로드는 hdSignatureModificationUnsupported로 실패합니다
다음은 권한 부여 층입니다. 암호화된 문서에서 비어 있지 않은 이어붙이기의 경우 변경된 객체는 역할별로 분류되고(카탈로그와 페이지, 어노테이션, AcroForm 필드, appearance 스트림) 인증된 사용자 권한 비트와 대조됩니다. 어노테이션을 편집하는 리비전에는 어노테이션 권한이 필요한 식입니다. 그다음 후보는 HotPDF Component가 다른 곳에서 쓰는 것과 같은 인증과 필드 잠금 집행을 거칩니다. DocMDP 인증 서명(ISO 32000-1 §12.8.2.2)은 이후 리비전이 어떤 종류의 변경을 할 수 있는지 제한하고, FieldMDP 잠금(§12.8.2.4)은 이름이 지정된 필드를 보호합니다. HotPDF의 DocMDP 및 FieldMDP 리비전 분석이 그 권한들이 리비전별로 어떻게 분류되는지 다룹니다
오류 처리는 "이 FDF는 형태가 잘못됐다"와 "이 리비전은 허용되지 않는다"를 나눠야 합니다. 두 번째 사례는 보통 사람이 필요하니까요:
try
Applied := Doc.SaveFDFDifferencesToFile(NextPdf, Fdf, BaseLength, BaseHash,
Selection, THPDFFDFPackageOptions.Default);
except
on E: EHPDFFDFPackageError do
QuarantineFdf(FdfFile, 'No usable Differences stream: ' + E.Message);
on E: EHPDFHeadlessDocumentError do
case E.Code of
hdPermissionDenied, hdSignatureModificationUnsupported:
EscalateForReview(FdfFile, E.Message);
hdLimitExceeded:
QuarantineFdf(FdfFile, 'Over budget: ' + E.Message);
else
QuarantineFdf(FdfFile, E.Message); // 베이스 불일치, 충돌, 잘못된 엔드포인트
end;
end;
이 연산이 절대 하지 않는 것도 검사하는 것만큼 중요합니다. SaveFDFDifferencesToFile는 FDF /F 파일 명세를 가져오지 않으므로, FDF 안에 이름이 적힌 PDF가 병합을 디스크나 네트워크의 다른 파일로 돌릴 수 없고, JavaScript나 액션을 실행하지도 않습니다. 모든 입력은 명시적으로 도착합니다. 적재된 기본, FDF 바이트, 여러분이 구성한 신뢰 자료입니다
Windows 트랜잭션과 C ABI fdf.differences.apply
같은 전송을 헤드리스 코드를 직접 쓰지 않고도 쓸 수 있습니다. THPDFWindowsFormDataTransaction.ApplyFDFDifferences는 FDF 바이트, 기준 길이, 기준 SHA-256, 선택, 패키지 옵션, 비밀번호를 받아 SaveFDFDifferencesToFile에 위임하고, 트랜잭션의 바이트 회계, 롤백, 취소, 같은 핸들 재시도 동작을 더합니다. Delphi가 아닌 호출자에게는 C ABI가 JSON 연산 fdf.differences.apply를 노출하며, base가 길이와 해시를 실립니다:
{ "schemaVersion": 1, "type": "fdf.differences.apply", "format": "fdf",
"base": { "bytes": 673, "sha256": "..." },
"signaturePolicy": { "trustedCAPEM": "..." } }
JSON 응답은 baseBytes, priorAppendBytes, totalAppendBytes, addedBytes, noop을 보고하며, THPDFHeadlessFDFDifferencesResult와 같은 필드입니다. 이 전송의 발표된 수용 게이트는 Linux FPC 3.3.1, Windows Delphi 32비트와 64비트, Windows FPC 32비트와 64비트에서 돌고, 각 보안 타깃은 1,244개의 네이티브 검사와 382개의 콜백 명령으로 21개 사례를 커버합니다
알아 둘 가치가 있는 한계와 픽스처 함정
Differences 전송은 의도적으로 좁으며, 몇몇 가장자리는 실제 테스트 스위트를 물어 뜯은 적이 있습니다:
- 기본은 완전한 PDF 리비전이어야 합니다. 파일 중간에 걸리는 길이나
startxref가 실제 크로스 레퍼런스 데이터를 가리키지 않는 길이는 바이트 하나 복사되기 전에 거부됩니다 - 서명 픽스처에는 간접
/Lock이 필요합니다. Windows 적재 문서 권한 리더는/Lock이 인라인 딕셔너리인 서명 필드를 "Signature field /Lock must be an indirect reference"로 거부합니다. 일부 서명 경로는 인라인 형태를 용인하지만, 잠금 딕셔너리는 자기 객체로 쓰십시오 - 벌거벗은
openssl req -x509로 만든 테스트 CA에는CA:TRUE와keyCertSign이 없어서, 체인 검증이 라이브러리 버그처럼 보이지만 아닌 인증서 검증 오류로 실패합니다 - 바깥
/Fields값은 이 호출이 병합하지 않습니다. 문서에 그 값들도 원한다면 정보성인지 명시적으로 정하고, 서명된 것으로 제시하지 마십시오 - FDF 자체는 비증분으로 남습니다. 트레일러가 여럿이거나 세대가 0이 아니거나 객체 번호가 중복되는 FDF 파일은 더 풍부한 FDF가 아니라 형태가 잘못된 FDF입니다
서명된 리비전이 그림에 없는 FDF와 XFDF의 평범한 값 쪽은, HotPDF의 다중 선택 FDF 및 XFDF 왕복과 Delphi에서의 XFDF 어노테이션 임포트와 익스포트가 더 나은 출발점입니다
FAQ
FDF 파일 대신 디코딩된 Differences 스트림을 넘겨도 되나요?
아니요. SaveFDFDifferencesToFile는 넘겨진 FDF를 THPDFFDFPackage로 파싱하고, 선택적인 내장 선택을 적용하며, DecodeDifferences를 내부적으로 호출합니다. raw 리비전 바이트를 들여다보거나 보관하고 싶으면 THPDFFDFPackage.DecodeDifferences를 단독으로 쓰되, 적용 호출에는 FDF 파일을 넘기십시오
클라이언트가 두 번째, 더 큰 누적 FDF를 보내면 어떻게 되나요?
첫 커밋된 리비전이 이미 들어 있는 PDF를 적재하고 같은 BaseBytes와 BaseSHA256을 유지하십시오. 메서드는 새 페이로드가 이미 커밋된 바이트로 정확히 시작하는지 검증하고, 새 접미부만 이어붙여 AddedBytes로 보고한 뒤, 결과에 전체 권한과 DocMDP/FieldMDP 승인을 돌립니다
적용 성공은 /Fields의 필드 값이 서명됐다는 뜻인가요?
아니요. ISO 32000-1은 하나의 FDF 안에서 /Fields, /Annots, /Differences가 일치할 것을 요구하지 않고, HotPDF Component도 이를 조정하지 않습니다. 서명된 것은 이어붙인 리비전 안의 CMS 서명들이 커버하는 것이므로, 서명된 값은 FDF 래퍼가 아니라 병합된 PDF에서 읽으십시오
FDF Differences 전송, 헤드리스 서명 정책 엔진, Windows 폼 데이터 트랜잭션은 모두 HotPDF Component에 들어 있으며 Delphi, C++Builder, Free Pascal을 지원합니다