기술 문서

변경 없는 PDF 저장이 Info와 XMP 메타데이터를 깨뜨리는 이유

PDF Library for Delphi v3.539.18과 v3.539.20은 아무것도 바꾸지 않은 PDF 저장이 문서 메타데이터를 손상시킬 수 있는 두 가지 경로를 고칩니다. /CreationDate와 /ModDate가 같은 문자열 객체를 참조할 때 자동 ModDate 갱신이 둘 다 다시 썼고, 원래 /Metadata 스트림을 읽기 전에 XMP 객체가 만들어지면 기본 패킷이 원본을 대체했습니다. 수정은 공유 객체를 변경하는 대신 딕셔너리 참조를 교체하고, lazy XMP 초기화 전에 기존 패킷을 먼저 확보합니다

이 설정은 PDF 라이브러리가 할 수 있는 가장 재미없는 작업입니다. 파일을 읽고, 새 이름으로 저장하고, 그 사이에 아무것도 건드리지 않습니다. 페이지는 전후가 똑같이 렌더링됐습니다. 콘텐츠 스트림 해시도 일치했습니다. 우리가 가진 모든 검사를 통과했는데도 두 군데가 여전히 잘못되어 있었고, 그 두 곳은 어떤 렌더러도 보여 주지 않는 자리였습니다. 두 결함 모두 실제 편집이라면 반드시 거쳐 가는 read-modify-write 경로에 있었기 때문에 저장만 해도 발동했고, 두 번째 독립 파서가 두 파일의 비시각적 의미를 비교하고 나서야 발견됐습니다

PDF를 저장하면 CreationDate가 왜 바뀔까요?

문서 정보 딕셔너리가 두 키에서 하나의 간접 문자열 객체를 참조해도 되는데, 라이브러리가 키가 아니라 객체를 갱신하고 있었기 때문입니다. ISO 32000-1 §7.3.10은 어떤 딕셔너리 값이든 간접 참조가 될 수 있게 하고, §14.3.3 표 317 어디에도 /CreationDate 아래의 값이 /ModDate 아래의 값과 다른 객체여야 한다고 하지 않습니다. 생성 시점에 같은 타임스탬프를 두 번 기록한 프로듀서는 두 키를 하나의 2728 0 R로 가리켜도 전혀 합법이며, 우리 로컬 코퍼스의 CJK 설계 문서가 정확히 그렇게 되어 있었습니다

방아쇠는 자동 수정 날짜입니다. UserModDate가 설정되지 않으면 SaveToFile은 쓰기 전에 현재 시각으로 SetInfo('ModDate', ...)를 호출하고, 이것이 SetRawInfo에 도달합니다. 예전 SetRawInfo는 키 아래의 객체를 찾아 그것이 TPDFString이면 SetTo를 호출했습니다. 이는 키가 현재 해석되는 객체에 대한 제자리 쓰기이고, 그 객체가 공유되고 있으면 /CreationDate까지 저장 시각을 보고하게 됩니다. 문서는 여전히 열리고 인쇄되고 픽셀 단위로 이전과 똑같이 렌더링되므로, 시각적 회귀 스위트는 눈 하나 깜빡하지 않고 통과합니다

PDFlibPas의 공유 Info 문자열 변경: /CreationDate와 /ModDate가 하나의 문자열 객체 2728 0 R을 합법적으로 참조하고, 예전 SetRawInfo는 키가 해석되는 객체에 SetTo를 호출해 두 날짜를 저장 시각으로 다시 썼으며, 새 SetRawInfo는 hex 문자열 모드를 유지한 채 키 아래에 새 문자열을 추가합니다
딕셔너리 항목을 갱신할 때 이제는 그 항목의 참조를 교체할 뿐 공유 객체를 변경하지 않으므로, 자동 ModDate 쓰기 한 번이 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));

Tests\SharedInfoSemantics.inc의 회귀 테스트는 코퍼스 파일에 기대지 않고 별칭 상황을 직접 만듭니다. 두 날짜 키가 참조하는 hex 문자열 하나, /Title과 /Subject가 공유하는 직접 문자열 하나, /Author와 /Keywords가 공유하는 스트림 하나를 준비합니다. 각 쌍에서 한 키를 갱신한 뒤에도 다른 키는 원래 값을 그대로 읽어야 하고, 갱신된 문자열은 여전히 hex여야 합니다. SetInformation의 공개 레퍼런스는 이제 그 보장을 한 문장으로 명시합니다. 다른 필드가 같은 객체를 참조하더라도 Info 필드를 갱신하면 그 필드만 바뀝니다

기존 XMP 패킷이 기본값으로 대체되는 이유는?

두 줄의 순서 때문입니다. TPDFDocument.GetMetadata에는 빠른 경로가 있습니다. XMP 필드가 이미 할당되어 있으면 카탈로그의 /Metadata 스트림을 디코딩하는 대신 XMP.SaveToString을 반환합니다. 여러 호출 지점이 XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);로 lazy 초기화를 했는데, 읽기에는 자연스럽지만 틀렸습니다. GetMetadata가 실행되는 시점에는 XMP가 이미 할당되어 있으므로, 로드되는 "원본"은 한 줄 전에 만들어진 객체가 직렬화한 기본 패킷입니다. dc:creator와 커스텀 네임스페이스, 표준 식별 정보를 담은 원래 패킷은 객체에 도달하지 못하고 저장할 때 덮어써집니다. 똑같은 자동 수정 날짜만으로 충분히 발동합니다. SetInfo가 xmp:ModifyDate를 /ModDate와 맞춰 두려고 Info 딕셔너리를 건드리기 전에 XMP를 초기화하기 때문입니다. 이 결함이 무엇 뒤에 숨었는지 보십시오. 첫 번째 버그에서 쓰던 Info 딕셔너리 비교는 통과합니다. /Info의 /Author와 /Title은 손대지 않았기 때문입니다. 바뀐 것은 XMP 트리뿐이고, 그 트리를 파싱해서 비교하는 검사만이 알아챕니다

PDFlibPas의 lazy XMP 초기화 순서: GetMetadata를 호출하기 전에 XMP 객체를 만들면 빠른 경로가 기본 패킷을 직렬화하면서 dc:creator, 커스텀 네임스페이스, 표준 식별 정보를 떨어뜨리고, TPDFlibXMP.Create 전에 Source를 확보하면 카탈로그의 원래 /Metadata 스트림을 로드합니다
SetInfo가 xmp:ModifyDate를 /ModDate와 맞추려고 XMP를 초기화하기 때문에 어떤 저장이든 이 교체를 발동시켰습니다. 이제 문서 안의 모든 lazy 초기화가 객체를 만들기 전에 기존 패킷을 확보하는 하나의 EnsureXMP를 거쳐 갑니다
// 잘못된 방식: GetMetadata가 바로 앞 줄에서 만든 객체를 직렬화합니다
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// 올바른 방식: /Metadata 스트림을 먼저 확보한 뒤 생성해서 로드합니다
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

수정은 두 가지를 합니다. TPDFDocument.EnsureXMP가 TPDFlibXMP.Create보다 먼저 Source := GetMetadata를 실행하고, 문서 안의 모든 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> 요소만 쓰는 프로듀서도 규격 안에 있으므로, 그런 패킷에서는 이 루프가 멀쩡한 문서 전체를 삭제했습니다. 직렬화기는 이제 선언의 닫는 ?>를 찾아 그것만 제거합니다. Tests\XMPRetentionSemantics.inc는 보존 검사를 두 번, 즉 래퍼가 있는 경우와 잘라낸 경우로 나눠 실행하고, 커스텀 네임스페이스 마커와 원래 저자가 SetInfo, GetMetadata, SaveToString과 재로드를 거쳐도 살아남는다고 단언합니다. 두 번째 함정은 전처리기 심볼이었습니다. SetInfo의 Info-to-XMP 동기화가 Free Pascal 빌드에서 정의되는 NOVCL로 가드되고 있었는데, XMP 백엔드는 프레임워크가 아니라 운영 체제로 결정됩니다. PDFlibXMP.pas는 OS_WINDOWS가 없을 때만 NO_XMP를 정의하기 때문입니다. 그래서 Windows Lazarus 빌드는 동작하는 XMP 객체와, 그것을 조용히 건너뛰는 SetInfo를 함께 갖고 있었습니다. 가드는 이제 NO_XMP이므로, Windows용 Free Pascal 애플리케이션도 Delphi와 같은 동기화를 얻습니다

그대로 통과시키는 저장에서 원래 ModDate를 유지하려면?

TPDFlibSaveOptions의 KeepModDate를 설정하고 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 패킷을 담습니다. 객체 번호는 의도적으로 빠져 있습니다. 전체 재작성은 모든 것에 번호를 다시 매기므로, 객체 번호를 키로 삼는 비교는 잡음만 보고합니다

PDFlibPas 저장의 비시각적 의미 검증: 코드를 공유하지 않는 독립 파서가 /ModDate를 뺀 Info 딕셔너리, 아웃라인과 목적지 페이지, 폼 값, 첨부 해시, XMP 트리를 스냅샷하고, /ModDate와 xmp:ModifyDate, xmp:MetadataDate를 예상된 변경으로 빼고 원본과 저장 파일을 비교합니다
픽셀과 스트림 해시는 두 결함을 거쳐도 바이트 단위로 동일하므로, 비교는 객체 번호가 아니라 해석된 의미 위에서 동작합니다. 살아남은 메타데이터는 보존됐다고만 정직하게 보고하며, 스키마 유효성이나 PDF/UA, PDF/A 준수를 주장하지 않습니다

제외 항목은 포함 항목만큼 중요합니다. /ModDate, xmp:ModifyDate, xmp:MetadataDate는 바뀌는 것이 정상이므로 비교 전에 뺍니다. 원본에 XMP가 아예 없던 파일이 패킷을 얻었다고 감점되지도 않습니다. 이 검사가 주장하지 않는 것도 마찬가지로 분명합니다. 기존 패킷을 보존했다는 사실은 그 패킷이 스키마에 유효한지, 문서가 PDF/UA나 어느 PDF/A 파트를 만족하는지에 대해 아무것도 말해 주지 않습니다. 그것들은 별도의 도구가 필요한 별도의 질문이고, "메타데이터가 살아남았다"와 "메타데이터가 준수한다"를 뒤섞는 것이야말로 첫 번째 버그가 그렇게 오래 숨어 있던 이유입니다. 라이브러리 쪽에서는 이제 두 회귀 테스트가 Delphi Win32, Win64와 Free Pascal Win32, Win64의 모든 대상 패스에서 실행되고, 의미 비교는 실제 문서 코퍼스 벤치마크의 통과 조건입니다

이 수정들보다 아래 계층에서 작업한다면, 저장이 객체를 어떻게 다시 쓰는지에 대한 내용은 증분 업데이트와 append 전용 저장에 있습니다. 공유 객체가 그냥 있던 자리에 남는 유일한 저장 모드입니다. 낡거나 다시 쓰인 날짜가 리더를 잘못 인도하는 또 다른 자리는 수정 레벨과 리비전 비교에서 다룹니다. 같은 Info와 XMP 쌍을 복구 쪽에서 보는 관점, 즉 두 절반을 보존하는 데 그치지 않고 서로 맞추는 작업은 PDF/A 변환과 메타데이터 복구에 있습니다

PDF Library for Delphi는 Delphi, C++Builder, Lazarus용 네이티브 Pascal PDF 라이브러리이고, 여기서 설명한 read-modify-write 경로는 여러분 프로세스의 모든 편집이 거쳐 가는 바로 그 경로입니다. 그래서 위의 보장은 하루에 한 번 저장하든 천 번 저장하든 똑같이 적용됩니다. 지원하는 컴파일러와 플랫폼은 PDF Library for Delphi 제품 페이지를 참고하십시오