아무것도 바꾸지 않는 PDF 저장도 문서 메타데이터를 두 가지 방식으로 망가뜨릴 수 있다. /CreationDate와 /ModDate가 같은 문자열 객체를 참조할 때, 그 객체에 써 넣는 자동 ModDate 갱신이 둘 다 다시 써 버린다. 그리고 원래 /Metadata 스트림을 읽기 전에 XMP 객체가 만들어지면 기본 패킷이 원본을 대체한다. PDF Library for Delphi는 공유 객체를 변경하는 대신 딕셔너리 참조를 교체하고, lazy XMP 초기화 전에 기존 패킷을 먼저 확보한다
설정은 PDF 라이브러리가 수행하는 작업 중 가장 볼 것 없는 조작이다. 파일을 읽고, 새 이름으로 저장하고, 그 사이에 아무것도 건드리지 않는다. 페이지는 전후로 똑같이 렌더링되고 콘텐츠 스트림 해시도 일치한다. 그런 검사를 전부 통과한 파일이 여전히, 어떤 렌더러도 보여 주지 않는 두 곳에서 틀려 있을 수 있다. 두 함정 모두 실제 편집이라면 반드시 거치는 read-modify-write 경로에 자리 잡고 있으므로 저장만으로도 발동하고, 두 파일의 비시각적 의미를 비교하는 독립 파서가 하나 더 있어야만 드러난다
PDF를 저장하면 CreationDate가 왜 바뀔까요?
문서 정보 딕셔너리는 두 키가 하나의 간접 문자열 객체를 참조해도 되고, 키가 아니라 객체를 갱신하는 writer는 둘 다 바꿔 버리기 때문이다. ISO 32000-1 §7.3.10은 어떤 딕셔너리 값이든 간접 참조가 될 수 있게 허용하고, §14.3.3 표 317 어디에도 /CreationDate 아래의 값이 /ModDate 아래의 값과 다른 객체여야 한다고 쓰여 있지 않다. 생성 시점에 같은 타임스탬프를 두 번 기록한 프로듀서는 두 키를 하나의 2728 0 R로 가리켜도 전혀 문제가 없으며, 실제 세계의 파일이 정확히 그렇게 되어 있다
방아쇠는 자동 수정 날짜다. UserModDate가 설정되어 있지 않으면 SaveToFile은 쓰기 전에 현재 시각으로 SetInfo('ModDate', ...)를 호출하는데, 이 호출이 SetRawInfo에 도달한다. 단순하게 짠 SetRawInfo는 키 아래의 객체를 찾아 그것이 TPDFString이면 SetTo를 호출한다. 이는 키가 현재 해석되는 객체에 대한 제자리 쓰기이므로, 그 객체가 공유 중이었다면 /CreationDate까지 저장 시각을 보고한다. 문서는 여전히 열리고, 인쇄되고, 픽셀 단위로 이전과 똑같이 렌더링되므로 시각적 회귀 스위트는 눈 하나 깜빡하지 않고 통과한다
var
Lib: TPDFlib;
Before, After: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('design.pdf', '');
Before := Lib.GetInformation(7); // 7 = CreationDate, 8 = ModDate
Lib.SaveToFile('design-resaved.pdf');
Lib.LoadFromFile('design-resaved.pdf', '');
After := Lib.GetInformation(7);
if Before <> After then
Log('a save that changed nothing rewrote CreationDate');
finally
Lib.Free;
end;
end;
TPDFDocument.SetRawInfo의 규칙은 작고 그 뒤의 원칙은 일반적이다. 딕셔너리 항목을 갱신하면 그 항목의 참조를 교체하지, 우연히 해석된 객체를 교체하는 것이 아니다. 코드는 기존 TPDFStringMode를 읽어 hex 문자열은 hex로, 리터럴 문자열은 리터럴로 유지한 다음 FStructure.NewString(Value, StringMode)로 만든 새 문자열을 키 아래에 추가한다. 두 가지 세부 사항이 더 중요하다. 스트림 값을 가진 항목은 예전 스트림을 비우지 않고 교체한다. 그 스트림을 아직 가리키는 다른 키들의 값을 SetTo('')가 비워 버릴 수 있기 때문이다. 그리고 대체된 객체는 삭제하지 않는다. 구조가 그 객체를 소유하고 있고 다른 참조가 여전히 필요로 할 수 있기 때문이다
// 단순한 방식: 키가 현재 해석되는 객체를 그대로 변경한다
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// 올바른 방식: 표현 방식은 유지하고 이 키의 참조만 교체한다
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
이에 대한 테스트는 우연히 객체를 공유하는 파일에 기대기보다 별칭 상황을 일부러 만들어야 한다. 두 날짜 키가 참조하는 hex 문자열 하나, /Title과 /Subject가 공유하는 직접 문자열 하나, /Author와 /Keywords가 공유하는 스트림 하나를 두는 식으로. 각 쌍에서 한 키를 갱신한 뒤에도 다른 키는 원래 값을 읽어야 하고 갱신된 문자열은 여전히 hex여야 한다. SetInformation의 공개 레퍼런스는 이 보장을 한 문장으로 적어 둔다. 다른 필드가 같은 객체를 참조하더라도 Info 필드를 갱신하면 그 필드만 교체된다
기존 XMP 패킷이 기본값으로 대체되는 이유는?
두 줄의 순서 때문이다. TPDFDocument.GetMetadata에는 빠른 경로가 있다. XMP 필드가 이미 할당되어 있으면 XMP.SaveToString을 반환한다. 카탈로그의 /Metadata 스트림을 디코딩하는 대신 말이다. XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);로 쓴 lazy 초기화는 읽기에 자연스럽지만 틀렸다. GetMetadata가 실행되는 시점에는 XMP가 이미 할당되어 있으므로, 로드되는 "원본"은 한 줄 전에 만들어진 객체가 직렬화한 기본 패킷이다. dc:creator와 커스텀 네임스페이스, 표준 식별 정보를 담은 원래 패킷은 객체에 도달하지 못하고 저장할 때 덮어써진다. 똑같은 자동 수정 날짜만으로 발동하기 충분하다. SetInfo가 xmp:ModifyDate를 /ModDate와 맞춰 두려고 Info 딕셔너리를 만지기 전에 XMP를 초기화하기 때문이다. 이 함정이 숨어 있는 자리도 보라. 첫 번째 함정에서 쓰던 Info 딕셔너리 비교는 통과한다. /Author와 /Title은 /Info에서 손대지 않았기 때문이다. 바뀐 것은 XMP 트리뿐이고, 그 트리를 파싱해 비교하는 검사만이 알아챈다
// 잘못된 방식: GetMetadata가 바로 앞 줄에서 만든 객체를 직렬화합니다
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// 올바른 방식: /Metadata 스트림을 먼저 확보한 뒤 생성해서 로드합니다
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
PDF Library for Delphi는 이 순서를 한 곳에 모아 둔다. TPDFDocument.EnsureXMP는 Source := GetMetadata를 TPDFlibXMP.Create보다 먼저 확보하고, 문서 안의 모든 lazy 초기화가 이것을 거친다. SetInfo, SetXMPInformation, GetXMPInformation, PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR, PDF/UA 모드 설정자, 메타데이터 복구 경로가 그 대상이다. SetXMPProperty 같은 공개 진입점도 EnsureXMP를 거치고 GetXMPProperty는 GetDocumentMetadata를 통해 읽으므로, 전체 표면이 하나의 초기화 순서를 공유한다. 세 줄짜리 순서의 올바른 한 벌이 오늘 우연히 서로 맞아떨어지는 열 벌보다 낫다
같은 경로에서 찾은 두 가지 작은 함정
Windows의 XMP 직렬화기는 플랫폼 XML 라이터를 쓰는데, 이것이 패킷에 있어서는 안 되는 XML 선언을 내보낸다. 이를 지우는 눈에 띄는 방법, 곧 <?xpacket에 도달할 때까지 문자를 삭제하는 방법은 올바른 입력에서 실패한다. ISO 16684-1 §7.3.2는 xpacket 래퍼를 선택 사항으로 두므로 <x:xmpmeta> 요소만 쓰는 프로듀서도 규격 안에 있고, 그런 패킷에서는 이 루프가 멀쩡한 문서 전체를 지워 버린다. 직렬화기는 선언의 닫는 ?>를 찾아 그것만 제거한다. 베낄 만한 보존 검사는 두 번 돈다. 래퍼가 있는 상태로 한 번, 잘라낸 상태로 한 번이며, 커스텀 네임스페이스 마커와 원래 저자가 SetInfo, GetMetadata, SaveToString과 재로드를 거쳐 살아남는다고 단언한다. 두 번째 함정은 전처리기 심볼이다. NOVCL은 Free Pascal 빌드에서 설정되지만 XMP 백엔드는 프레임워크가 아니라 운영 체제로 결정된다. PDFlibXMP.pas는 NO_XMP를 OS_WINDOWS가 없을 때만 정의하기 때문이다. SetInfo의 Info-to-XMP 동기화를 NOVCL로 가드하면 Windows Lazarus 빌드는 동작하는 XMP 객체와, 그것을 갱신하는 일을 조용히 건너뛰는 SetInfo를 함께 갖게 된다. 가드는 NO_XMP이므로 Windows용 Free Pascal 애플리케이션도 Delphi와 같은 동기화를 얻는다
그대로 통과시키는 저장에서 원래 ModDate를 유지하려면?
KeepModDate를 TPDFlibSaveOptions에서 설정하고 SaveToFileOptions로 저장하면 됩니다. 이 옵션은 호출이 진행되는 동안 UserModDate를 설정하고, 그러면 SaveToFile이 자동 타임스탬프를 건너뜁니다. XMP 객체를 lazy 초기화하는 단계도 바로 그 단계입니다. 메타데이터를 한 번도 건드리지 않았고 준수 모드도 켜지 않은 문서는 Info 딕셔너리와 /Metadata 스트림을 읽어 들인 그대로 유지합니다. SetInformation(8, ...)을 호출하면 영구적으로 같은 효과가 납니다. 수정 날짜를 직접 설정하면 그것이 사용자 제어 값으로 표시되기 때문입니다
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // 자동 /ModDate 없음, lazy XMP 초기화 없음
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
이것이 무엇을 사 주는지는 솔직하게 보아야 한다. KeepModDate는 출력이 입력과 같은 리비전을 기술해야 하는 통과 단계에는 맞는 선택이고, 실제로 콘텐츠를 편집하는 작업에는 틀린 선택이다. §14.3.3은 /ModDate가 가장 최근 수정을 반영하기를 기대하기 때문이다. 올바른 참조 처리의 대용품도 아니다. 이 옵션은 자동 갱신을 발동하는 그 한 번의 쓰기를 피할 뿐이다. 공유 객체를 변경하는 대신 참조를 교체하는 것, 그리고 초기화 전에 패킷을 확보하는 것이 평범한 저장을 안전하게 만들고, 이 옵션은 의도적인 no-op을 정직하게 만든다
저장이 ModDate 말고는 아무것도 바꾸지 않았다는 걸 어떻게 확인할까요?
픽셀로도, 스트림 해시로도 안 된다. 두 함정 모두 모든 페이지와 모든 콘텐츠 스트림을 바이트 단위로 동일하게 남겨 두기 때문이다. 이들을 잡는 검사는 테스트 대상 라이브러리와 코드를 공유하지 않는 독립 파서가 원본 파일과 저장된 파일에서 각각 뜨는 비시각적 의미 스냅샷과, 그에 이어지는 구조 비교다. 스냅샷에는 /ModDate를 뺀 Info 딕셔너리, 각 북마크를 객체 번호가 아니라 페이지 번호로 해석한 아웃라인 트리, 같은 방식으로 해석한 명명된 목적지와 링크 대상, 폼 필드 값, 해시로 담은 첨부 파일 바이트, 그리고 텍스트가 아니라 트리로 파싱한 XMP 패킷이 들어간다. 객체 번호는 일부러 빼 두었다. 전체 재작성은 모든 것에 번호를 다시 매기므로 객체 번호를 키로 삼는 비교는 잡음만 보고할 수 있기 때문이다
제외 항목은 포함 항목만큼 중요하다. /ModDate, xmp:ModifyDate, xmp:MetadataDate는 바뀌는 것이 정상이므로 비교 전에 뺀다. 원본에 XMP가 아예 없던 파일이 패킷을 얻었다고 해서 감점되지도 않는다. 이 검사가 주장하지 않는 것도 똑같이 분명하다. 기존 패킷을 보존했다는 사실은 그 패킷이 스키마에 유효한지, 문서가 PDF/UA나 어느 PDF/A 파트를 만족하는지에 대해 아무것도 말하지 않는다. 그것들은 각자 도구가 필요한 별개의 질문이고, "메타데이터가 살아남았다"와 "메타데이터가 준수한다"를 뒤섞는 것이야말로 공유 날짜 재작성이 오랫동안 숨어 있을 수 있는 길이다
이 규칙들보다 아래 계층에서 작업한다면, 저장이 객체를 어떻게 다시 쓰는지는 증분 업데이트와 append 전용 저장에서 다룬다. 공유 객체가 그냥 있던 자리에 남는 유일한 저장 모드다. 낡거나 다시 쓰인 날짜가 리더를 잘못 인도하는 또 다른 자리는 수정 레벨과 리비전 비교다. 같은 Info와 XMP 쌍을 복구 쪽에서 보는 관점, 곧 두 절반을 보존하는 데 그치지 않고 서로 맞추는 작업은 PDF/A 변환과 메타데이터 복구에 있다
PDF Library for Delphi는 Delphi, C++Builder, Lazarus용 네이티브 Pascal PDF 라이브러리이고, 여기서 설명한 read-modify-write 경로는 여러분 프로세스의 모든 편집이 거쳐 가는 바로 그 경로입니다. 그래서 위의 보장은 하루에 한 번 저장하든 천 번 저장하든 똑같이 적용됩니다. 지원하는 컴파일러와 플랫폼은 PDF Library for Delphi 제품 페이지를 참고하십시오