기술 문서

델파이의 PDF 가비지 컬렉션: 마크 앤 스윕

PDF에서 페이지를 삭제해도 그 폰트, 이미지, 콘텐츠 스트림이 삭제되지는 않습니다. losLab PDF Library는 트레일러 루트로부터 객체 그래프를 앞으로 순회하며 아무것도 도달하지 못하는 모든 간접 객체를 제거하는 마크 스윕 컬렉터로 이를 회수합니다. 이는 전체 저장 시에 실행되고, 기본값으로는 꺼져 있으며, 제거한 객체 개수를 반환합니다

PDF 페이지를 삭제해도 파일이 줄어들지 않는 이유는 무엇인가

페이지 삭제는 저장소 연산이 아니라 참조 편집이기 때문입니다. DeletePages(StartPage, PageCount)는 페이지 트리에서 페이지 객체를 연결 해제하고 그것들을 가리키던 아웃라인 항목을 수리합니다. 그것이 할 수 없는 일은 그 페이지들이 사용했던 폰트 프로그램, 콘텐츠 스트림, 이미지 XObject가 이제 죽었다고 결정하는 것입니다. 삭제되는 순간 파일 안의 그 무엇도 다른 누가 여전히 그것들을 가리키고 있을지 기록하지 않기 때문입니다. 그 객체들은 문서 객체 목록에 남아 있고, 전체 저장은 그것들 하나하나를 다시 써냅니다. 결과는 이런 지원 스레드 대부분을 시작시키는 불평입니다: 고객이 페이지의 90퍼센트를 삭제하고 저장하는데 파일은 2퍼센트만 줄어듭니다. 더 나쁘게는, 그 누수가 누적됩니다. 로드, 삭제, 저장, 다시 로드, 다시 삭제, 다시 저장을 하면 페이지 개수는 줄어드는데 파일은 단조롭게 커집니다. 이는 살아 있는 객체를 더 작게 만드는 폰트 서브셋팅과 이미지 다운샘플링이 해결하는 문제와는 다른 문제입니다. 여기서는 객체가 너무 큰 것이 아닙니다. 그것들은 그저 더 이상 문서의 일부가 아닐 뿐입니다

루트 집합은 페이지 트리가 아니라 트레일러다

PDF 객체 그래프에는 역참조 필드가 없습니다. 포맷은 참조 카운트도 역포인터 목록도 정의하지 않으며, 실제로 존재하는 /Parent 키는 객체 그래프 전체가 아니라 페이지 트리 같은 특정 구조에 속합니다. 간접 객체 안의 그 무엇도 누가 그것을 가리키는지 말해주지 않으므로, "누군가 여전히 객체 47을 사용하고 있는가"라는 질문에는 정확히 하나의 답이 있습니다: 알려진 루트로부터 앞으로 순회하며 그곳에 도달하는지 확인하는 것입니다. 이것이 바로 losLab PDF Library의 컬렉터가 참조 카운트 방식이 아니라 마크 스윕 컬렉터인 이유입니다

루트는 파일 트레일러(ISO 32000-1 §7.5.5)에서 옵니다. 세 개의 키가 그것들을 담습니다: 페이지 트리, 이름, 아웃라인, AcroForm, 메타데이터가 모두 매달려 있는 §7.7.2의 문서 카탈로그인 /Root; 문서 정보 딕셔너리인 /Info; 암호화 딕셔너리인 /Encrypt입니다. 나머지 두 트레일러 키는 미끼입니다. /ID는 두 개의 바이트 문자열 배열이고, /Prev는 이전 크로스 레퍼런스 섹션에 대한 정수 바이트 오프셋입니다. 어느 쪽도 간접 참조가 아니므로 어느 쪽도 루트에 기여하지 않습니다. losLab PDF Library는 이름 붙은 세 개의 키가 아니라 트레일러 딕셔너리 전체를 큐에 넣는데, 이는 아무 비용도 들지 않으면서 어떤 사설 트레일러 확장이든 살려둡니다

순회 자체는 재귀적이 아니라 반복적입니다. 순회가 간접 참조를 만나면 그것을 즉시 역참조하는 대신 객체 번호와 세대만 기록하고, 해당 슬롯을 마크한 다음 FIFO 큐에 넣습니다. 이는 깊은 페이지 트리와 긴 아웃라인 체인을 호출 스택 밖에 두고 같은 객체가 두 번 디코딩되는 것을 막아줍니다. 직접 딕셔너리, 배열, 스트림 딕셔너리는 방문 집합으로 보호되는 두 번째 큐에 들어갑니다. 실제 문서는 진짜 순환을 담고 있기 때문입니다: 페이지 /Parent는 자신의 페이지 트리 노드를 다시 가리키고, 아웃라인 항목은 /Prev/Next를 통해 양방향으로 체인됩니다. 세대 번호는 장식이 아니라 일치의 일부입니다. 참조는 객체 번호와 세대가 모두 일치할 때만 해석되며, 다른 세대에 존재하는 번호에 대한 참조는 스펙이 요구하는 null 객체로 취급될 뿐, 결코 살아 있는 엣지로 취급되지 않습니다

저장 시 가비지 컬렉션을 어떻게 활성화하는가

가비지 컬렉션은 옵트인이며 저장 옵션 레코드에 속합니다. 기본값이 False인 이유는 컬렉터가 객체 그래프에 대한 파괴적인 패스이며, 어떤 라이브러리도 호출자가 검토해보라고 요청한 적 없는 객체를 조용히 삭제해서는 안 되기 때문입니다

var
  Pdf: TPDFlib;
  Opt: TPDFlibSaveOptions;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
      Exit;
    Pdf.DeletePages(11, 490);          // keep the first ten pages

    FillChar(Opt, SizeOf(Opt), 0);
    Opt.CompressContent := True;
    Opt.CompressFonts := True;
    Opt.OptimizeContentStreams := True;
    Opt.PackObjectStreams := True;
    Opt.GarbageCollect := True;        // drop everything the pages left behind
    Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
  finally
    Pdf.Free;
  end;
end;

같은 컬렉터에 도달하는 다른 두 진입점이 있습니다. SetGarbageCollect(1)은 선택된 문서에 플래그를 세팅하므로 평범한 SaveToFile이 그것을 존중하며, GarbageCollectObjects는 즉시 패스를 실행하고 제거된 고아 간접 객체 개수를 반환합니다. 즉시 형태는 로그하거나 단언할 숫자를 원할 때 사용할 형태이며, 확인해 둘 가치가 있습니다. 음수 반환은 개수가 아니기 때문입니다

var
  Removed: Integer;
begin
  Pdf.DeletePages(11, 490);
  Removed := Pdf.GarbageCollectObjects;
  if Removed < 0 then
    // The graph could not be fully decoded. Nothing was swept and the
    // document is unchanged; save it without GC or reject the input.
    LogWarning('object graph incomplete, GC skipped')
  else
    LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;

그 실패 경로는 보이는 것보다 더 중요합니다. 객체는 지연 디코딩되며, 한 번도 디코딩된 적 없는 객체는 아무 참조도 노출하지 않습니다. 컬렉터가 디코딩 불가능한 객체를 빈 노드로 취급한다면 그것을 통해서만 도달 가능한 모든 것을 쓸어버릴 것입니다. 그래서 순회는 각 객체를 건드릴 때 디코딩을 강제하며, 단 하나의 디코드 오류라도 음수 결과와 함께 전체 패스를 중단시키고 문서를 바이트 단위로 동일하게 남겨둡니다. 부분적으로만 이해하는 그래프를 쓸어버리는 것은 컬렉터가 손상된 파일을 파괴된 파일로 만드는 방식입니다

순진한 PDF 컬렉터를 무엇이 깨뜨리는가

두 가지 세부 사항이며, 둘 다 요란하게가 아니라 조용히 실패합니다. 첫 번째는 객체 스트림입니다. PDF 1.5 이래로 스트림이 아닌 객체는 /ObjStm 컨테이너(§7.5.7) 안에 압축된 채로 살 수 있으며, 그 크로스 레퍼런스 항목은 컨테이너와 그 안에서의 인덱스를 이름 붙이는 타입 2 항목입니다. 압축된 객체는 그래서 오직 그 컨테이너를 통해서만 도달 가능합니다. 멤버를 마크하고 컨테이너를 스윕하면 — 그 무엇도 그것을 문서 객체로 참조하지 않았으므로 — 크로스 레퍼런스가 더 이상 존재하지 않는 객체를 가리키는 파일을 써버린 것입니다. 컨테이너는 문서 데이터가 아니라 구조적 저장소이므로, 여러분이 순회하는 객체 그래프에서 결코 엣지로 나타나지 않습니다. losLab PDF Library는 컨테이너가 사라지기 전에 살아남은 모든 압축 멤버를 그 소스 컨테이너에서 분리함으로써 이를 처리하며, 그런 다음 저장은 생존자를 새 객체 스트림으로 다시 포장합니다. 두 번째 세부 사항은 스트림 객체가 실제로 무엇을 참조하는가입니다. 바이트는 그래프의 일부가 아닙니다. /F1 12 Tf로 텍스트를 그리는 콘텐츠 스트림은 리소스 이름으로 폰트 이름을 붙이며, 그 이름은 페이지 /Resources 딕셔너리를 통해 해석되므로, 도달 가능성 엣지는 페이지 → /Resources/Font → 폰트 객체로 흐르며, 결코 스트림 페이로드를 거치지 않습니다. 스트림이 기여하는 유일한 참조는 그 딕셔너리에서 오는데, /Length, /Filter, /DecodeParms는 모두 간접일 수 있도록 허용됩니다. 참조를 찾아 스트림 바이트를 파싱하는 컬렉터는 아무 소용없는 비싼 작업을 하고 있는 것이며, 스트림 딕셔너리를 건너뛰는 컬렉터는 length 객체를 잃고 파일을 손상시킵니다

여러분이 해제한 객체 번호에는 무슨 일이 일어나는가

그것들은 자유 항목이 되며, 같은 저장에서는 재사용되지 않습니다. 스윕은 삭제가 인덱스 안정적으로 유지되도록 객체 목록을 내림차순으로 순회하고, 모든 제거 이후가 아니라 끝에서 한 번만 조회 인덱스를 재구축하며, 제거된 각 객체에 대해 §7.5.4가 나중에 재사용될 수 있는 항목에 대해 명시하는 그대로 세대를 하나 증가시켜 자유 목록에 번호를 기록합니다. 이미 65535에 있는 세대는 그대로 남아, 그 번호를 영구히 은퇴한 것으로 표시합니다. 객체 번호는 의도적으로 압축되지 않습니다. 컬렉션 이후에도 파일은 구멍을 유지합니다: 객체 12는 자유일 수 있지만 13과 14는 사용 중일 수 있으며, 트레일러 /Size는 여전히 생존 개수가 아니라 가장 높은 번호에 1을 더한 값을 보고합니다. 이것은 합법적이고 정상적입니다. 재번호 매김은 크로스 레퍼런스 테이블에서 몇 바이트를 절약하겠지만 문서 안의 모든 참조를 다시 써야 할 것이며, 이는 외부에서 객체 번호를 붙들고 있는 무언가를 조용히 무효화하는 종류의 변경입니다. 여러분이 돌려받는 크기는 xref 테이블이 아니라 객체 본문에서 옵니다

컬렉터를 실행해서는 안 되는 때

증분 업데이트에서는 절대 안 됩니다. 컬렉터는 전체 저장으로 게이트되어 있으며, 문서에 덧붙이는 중일 때는 그 플래그가 아예 읽히지 않습니다. 그 게이트는 우회해야 할 제약이 아닙니다. 증분 업데이트(§7.5.6)는 원본 바이트를 손대지 않은 채로 두고 /Prev를 통해 이전 것에 체인된 새 크로스 레퍼런스 섹션을 덧붙입니다. 이전의 모든 리비전은 여전히 자신이 항상 가리키던 객체를 가리키므로, 현재 리비전에서 도달 불가능한 객체는 더 오래된 리비전에서는 매우 도달 가능합니다. 그것을 삭제하면 마지막을 제외한 모든 리비전이 깨질 것이며, 그 이유의 메커니즘은 증분 업데이트와 append 모드 저장에 관한 글에서 다룹니다. 같은 이유로 서명된 문서에서도 가비지 컬렉션이 배제됩니다. 컬렉션을 가능하게 하는 전체 재작성 자체가 서명을 무효화시키는 바로 그것이기 때문입니다

컬렉션이 아닌 것을 분명히 해두는 것도 가치가 있습니다. 이것은 살균기가 아닙니다. 컬렉터는 아무것도 참조하지 않는 객체를 제거할 뿐, 그 내용이 민감했는지에 대해서는 아무 의견도 없으며, 여전히 참조되는 객체는 무엇이었든 그대로 남습니다. 목표가 파일을 더 작게 만드는 것이 아니라 정보를 복구 불가능하게 만드는 것이라면, 객체 그래프는 잘못된 계층이고 명령어 수준의 편집(redaction)과 문서 살균이 옳은 계층입니다. 둘은 그 순서로 잘 결합됩니다: 먼저 편집하고 살균한 다음 수거하십시오, 그래야 편집이 분리한 객체가 실제로 파일을 떠납니다. 같은 짝짓기가 리소스 정리 API에도 존재하며, 여기서는 가비지 컬렉트 옵션을 전달하면 정리가 그 이후에 컬렉션을 실행하고 제거한 고아를 OrphanObjectsRemoved에 보고합니다

채택할 가치가 있는 마지막 습관 하나. 페이지 삭제를 수행하는 어떤 배치 작업에서든 GarbageCollectObjects의 반환값을 로깅하고, 실제 문서 몇 주 동안 그것을 지켜보십시오. 방금 절반으로 자른 파일에서 0이 나온다면 상류의 무언가가 여러분이 예상하지 못한 참조를 여전히 붙들고 있다는 뜻이며, 보통은 이름 트리 항목, 아웃라인 목적지, 또는 자신이 붙어 있던 페이지보다 오래 살아남은 AcroForm 필드입니다. 컬렉터는 여러분이 갖게 될 가장 값싼 도달 가능성 디버거입니다. PDF 포맷 자체가 답하기를 거부하는 질문에 답해주기 때문입니다

여기서 설명한 가비지 컬렉터, 저장 옵션 레코드, 리소스 정리 API는 델파이와 C++Builder용 losLab PDF Library의 일부입니다. 제품 페이지에는 컬렉션, 객체 스트림 패킹, 선형화 사이의 상호작용을 포함한 전체 저장 파이프라인 레퍼런스가 있습니다