기술 문서

Delphi에서 PDF 줄이기: 고아 오브젝트와 리소스 중복 제거

Delphi용 PDFium Component(PDFiumPas)는 TPdf의 세 재작성 API로 PDF 파일을 줄입니다: SaveAsReachable은 아무것도 참조하지 않는 오브젝트를 버리고, SaveAsDeduplicated는 바이트 단위로 동일한 폰트, 이미지, Form XObject를 병합하며, SaveAsFlateRecompressed는 기존 Flate 스트림을 최대 압축으로 다시 인코딩합니다. 각각은 리포트 레코드를 반환하므로 무엇이 제거되었고 몇 바이트를 아꼈는지 볼 수 있습니다

흔한 방아쇠는 "이십 페이지를 지웠는데 파일이 더 커졌다"는 지원 티켓입니다. 누군가 계약서를 열고, 부록을 빼고, 표지 시트를 합치고 저장했습니다. 모든 단계가 증분 업데이트였으므로 오래된 오브젝트는 파일에 그대로 남아 있고, 그 문서가 나중에 로드되어 FPDF_SaveAsCopy로 쓰이면 그대로 튀어나옵니다. PDFium은 메모리 내 오브젝트 테이블에 앉아 있는 것은 뭐든 직렬화합니다; 간접 오브젝트를 나열하거나, 하나를 지우거나, 참조를 다시 쓰는 공개 호출은 없습니다. 따라서 이 글의 모든 것은 PDFium이 만들어 내는 바이트 위의 PDFiumPas 래퍼 계층에서 돌아갑니다

FPDF_SaveAsCopy는 지워진 페이지를 파일에 남겨 두는 이유

FPDF_SaveAsCopy가 고아 오브젝트를 남겨 두는 이유는 PDFium이 크로스 레퍼런스 테이블의 살아 있는 모든 엔트리를, 무언가가 여전히 가리키는지와 무관하게 문서로 로드하기 때문입니다. ISO 32000-1 §7.5.6은 증분 업데이트를 파일 끝에 새 오브젝트 버전과 새 xref 섹션을 덧붙이는 것으로 기술합니다. 페이지 제거는 페이지 트리만 편집합니다; 페이지 딕셔너리, 그 콘텐츠 스트림, 그 페이지만이 쓰던 폰트나 이미지는 여전히 사용 중인 오브젝트로 나열됩니다. 전면 비증분 저장도 그걸 바꾸지 않습니다. 오브젝트 테이블 관점에서 그 오브젝트들은 완벽하게 유효하니까요

같은 잔여물은 페이지 병합 뒤에, 다른 도구가 한 폼 평탄화 뒤에, 손상된 파일의 복구 저장 뒤에도 나타납니다. 모든 뷰어에서 보이지 않고 바이트만 더합니다. 스캔 문서에서는 고아가 된 페이지 이미지 하나가 수 메가바이트일 수 있습니다

SaveAsReachable로 마크 앤 스위프

TPdf.SaveAsReachable은 클래식 마크 앤 스위프 패스로 고아를 제거합니다: PDFium이 현재 문서의 비증분 사본을 쓰게 하고, 그 사본을 오브젝트 그래프로 파싱하고, 루트에서 도달 가능한 모든 것을 마크하고, 마크된 오브젝트만 담은 단일 클래식 리비전을 씁니다. 오브젝트 스트림 안의 오브젝트는 그 과정에서 평범한 간접 오브젝트로 머티리얼라이즈되므로, 출력은 언제나 평범한 xref 테이블을 씁니다

루트 고르기가 순진한 구현이 틀어지는 자리입니다. 눈에 띄는 씨앗은 트레일러 /Root가 이름 붙인 Catalog입니다. 거기서 순회는 페이지 트리, 페이지 리소스, 폰트, 이미지, Form XObject, AcroForm, 구조 트리, 아웃라인, 이름 트리, 옵셔널 콘텐츠, XMP 메타데이터, 출력 인텐트, 임베디드 파일에 도달합니다. 하지만 문서 정보 딕셔너리는 Catalog가 아니라 트레일러 /Info 엔트리(ISO 32000-1 §7.5.5)가 참조합니다. /Root만 씨앗으로 삼으면 다루는 모든 파일의 Title, Author, Producer를 조용히 지웁니다. PDFiumPas는 /Root와 /Info 둘 다로 순회를 시드하고, 재건된 트레일러는 그 오브젝트가 실제로 살아남았을 때만 /Info를 앞으로 운반합니다

워커는 명시적 스택과 방문 집합을 가진 깊이 우선 순회이므로, 참조 순환(페이지 트리의 부모 링크, 주석 /P 역방향 포인터)이 그것을 멈춰 세울 수 없습니다. 이미 빈 오브젝트 번호에 대한 참조는 에러로 취급되지 않고 그저 따라가지 않을 뿐이며, 살짝 손상된 입력에 대한 관용을 유지합니다

오브젝트 번호는 다시 매겨지지 않고 보존됩니다

TPdf.SaveAsReachable은 살아남은 모든 오브젝트를 원래 오브젝트 번호와 세대에 유지합니다. 스윕된 오브젝트는 xref 테이블에 빈 엔트리로 기록되고(ISO 32000-1 §7.5.4), 살아 있는 번호들 사이의 간극은 빈 엔트리로 채워지며, 오브젝트 0은 프리 리스트의 머리로 남습니다. 아무것도 다시 번호 매겨지지 않으므로, 살아남은 모든 참조는 단 한 바이트의 재작성 없이 여전히 올바르고, 틀릴 옛번호-새번호 매핑도 없습니다. 비용은 몇 개의 미사용 xref 라인, 개당 20바이트이며, 그만한 가치가 있는 거래입니다

uses
  PDFium, FPdfCompress;

procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Report: TPdfPruneReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    if not Pdf.SaveAsReachable(TargetPdf, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
      [Report.SourceByteCount, Report.OutputByteCount,
       Report.KeptObjectCount, Report.PrunedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

TPdfPruneReport는 SourceByteCount(PDFium이 만든 비증분 사본의 크기), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded, ErrorMessage를 운반합니다. 파일명 오버로드는 대상 옆의 임시 파일에 쓰고 재작성이 성공했을 때만 대상을 치환하므로, 실패한 실행이 잘린 PDF를 남기는 일은 없습니다

이 API들은 어떤 파일을 거부할까요?

세 재작성 API는 모두 암호화된 문서를, 기본값으로는 서명된 문서를 거부합니다. 암호화된 입력은 예외 대신 Succeeded = False와 에러 메시지로 실패합니다: 단일 리비전 재작성은 암호화 딕셔너리가 의존하는 오브젝트 번호, 바이트 범위, 오브젝트별 키를 유지할 수 없어 출력이 읽을 수 없게 될 테니까요. 디지털 서명이 있는 문서는 AllowSignedDocument = True를 넘기지 않는 한 EPdfError를 냅니다. 전면 재작성은 언제나 서명된 바이트 범위를 무효화하기 때문입니다. 그 플래그를 설정한다는 것은 서명이 깨진 것으로 보고될 파일을 받아들이는 뜻입니다. 아카이브 사본에는 옳은 선택이지만, 곧 되돌려 보낼 계약서에는 결코 그렇지 않습니다

SaveAsDeduplicated는 두 이미지가 같다고 어떻게 판정하나요?

TPdf.SaveAsDeduplicated는 두 리소스를 그들의 딕셔너리, 전체 도달 가능 서브그래프, 날 스트림 바이트가 모두 일치할 때만 동일한 것으로 취급합니다. 먼저 같은 도달 가능성 순회를 돌리고, 이어서 도달 가능한 모든 폰트, 이미지 XObject, Form XObject의 지문을 뜨고, 일치 그룹마다 가장 낮은 오브젝트 번호의 멤버에 병합하며, 모든 참조를 그 생존자를 가리키도록 다시 씁니다. 병합된 중복은 고아처럼 빈 xref 엔트리가 됩니다. 같은 리소스를 페이지마다 재임베드하는 메일 머지 도구가 만드는 전형적인 "로고가 400번 임베드됨" 리포트의 해법이 바로 이것입니다

지문은 재귀적으로 계산되는 64비트 FNV-1a 해시입니다. 후보마다 워커는 오브젝트 본문을 가져와 모든 N G R 참조를 그것이 가리키는 오브젝트의 지문으로 치환하고 결과를 해시합니다. 그래서 해시는 오브젝트 번호와 무관합니다: 디스크립터가 우연히 오브젝트 12와 87에 놓인 같은 폰트의 두 사본도 여전히 같은 값을 만듭니다. 결과는 메모이즈되어 공유 서브오브젝트는 한 번만 해시되고, 방문 집합은 역방향 간선에 고정 센티널을 반환해서 순환 그래프도 종료됩니다

미묘한 버그는 스트림 페이로드에 앉아 있습니다. 참조를 안전하게 찾으려고 PDFiumPas는 스트림 데이터, 문자열, 주석이 공백으로 지워진 각 오브젝트의 사본을 스캔합니다; 그렇지 않으면 우연히 12 0 R처럼 보이는 바이너리 픽셀 데이터의 런이 참조로 취급될 겁니다. 그 위생 처리된 사본만 해시하면 동일한 딕셔너리(같은 /Width, /Height, /BitsPerComponent, /Filter, /Length)와 다른 픽셀을 가진 두 이미지가 하나의 지문으로 뭉개지고, 페이지의 두 번째 사진이 첫 번째로 조용히 대체됩니다. SaveAsDeduplicated는 stream과 endstream 사이의 날 바이트를 해시 데이터에 덧붙이므로, 픽셀도 정체의 일부입니다. 후보는 비교 전에 카테고리별로 묶이므로, 폰트와 이미지 사이의 희귀한 해시 충돌이 그 둘을 병합할 일은 없습니다

참조 재작성은 같은 위생 스캔 트릭을 거꾸로 재활용합니다: 참조는 지워진 사본에서 위치를 찾지만 바이트는 원본 본문에서 복사하므로, 수정되는 것은 딕셔너리와 배열 구조뿐이고 바이너리 페이로드는 그대로 통과합니다

procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
  Report: TPdfDedupReport;
begin
  if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
    [Report.MergedFontCount, Report.MergedImageCount,
     Report.MergedFormXObjectCount,
     Report.SourceByteCount, Report.OutputByteCount]));
end;

TPdfDedupReport를 신중히 읽으세요. 세 Merged*Count 필드 옆에 KeptObjectCount와 PrunedObjectCount가 있는데, 이 리포트에서 PrunedObjectCount는 병합된 중복만 셉니다. 도달 불가능한 오브젝트는 여전히 떨어지지만 그 숫자에는 포함되지 않습니다. 로깅용 고아 수가 필요하면 별도의 SaveAsReachable 실행에서 가져오세요. 다른 한계는 매칭이 인코딩된 스트림 기준 바이트 정확이라는 것입니다: 서로 다른 두 생산자가 서로 다른 Flate 레벨로 압축한 같은 로고는 병합되지 않습니다

최대 레벨로 Flate 스트림 재압축하기

TPdf.SaveAsFlateRecompressed는 도달 가능한 각 FlateDecode 스트림을 인플레이트한 뒤 최고 압축 레벨로 다시 디플레이트하고, 더 작고 여러분의 임계값을 통과할 때만 결과를 유지합니다. 많은 생산자가 빠른 기본 레벨로 Flate를 쓰므로, 콘텐츠 스트림과 임베디드 폰트 프로그램은 종종 더 줄어듭니다. 오브젝트 번호와 세대는 제자리입니다; 바뀌는 것은 페이로드와 그 직접 /Length뿐입니다

TPdfFlateRecompressOptions는 필드 둘에 기본 생성자가 없으므로 둘 다 설정하세요. MinSavingBytes는 스트림당 최소 절대 절약량이고, MinSavingRatio는 원래 길이의 비율입니다(0.05는 5%); 비율 0은 비율 검사를 끕니다. 재건된 스트림은 stream, CRLF, 새 페이로드, CRLF, endstream으로 기록되므로, 결과는 원본 파일의 줄바꿈에 의존하지 않습니다

procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
  Options: TPdfFlateRecompressOptions;
  Report: TPdfFlateRecompressReport;
begin
  Options.MinSavingBytes := 256;    // 자잘한 이득은 무시
  Options.MinSavingRatio := 0.05;   // 그리고 5% 미만도 무시
  if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
    [Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
     Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;

엣지 케이스는 설계상 보수적입니다. /Length가 간접 참조인 스트림은 건너뜁니다. 재작성은 직접 정수를 저장해야 하기 때문입니다. 인플레이트에 실패한 페이로드는 FailedCount에 포함되고 변경 없이 복사됩니다. 임계값에 못 미치는 Flate 스트림은 SkippedCount에 포함됩니다. DCT, JPX, JBIG2, CCITT 이미지는 후보 리스트에 절대 들지 않으므로, 이 패스는 JPEG 품질을 건드리지 않습니다. 손실 이미지 절약에는 최종 저장 전에 PDFiumPas의 어댑티브 이미지 리샘플링을 쓰세요

그 임계값 검사를 쓰며 얻은 이식성 함정 하나: 첫 초안은 Saving을 Int64로 선언한 채 Single(Saving)/Single(OrigLen)을 비교했습니다. Delphi는 받아들이지만 FPC 3.2.2는 Illegal type conversion: "Int64" to "Single"으로 멈춥니다. FPC에서 그 문법은 수치 변환이 아니라 타입캐스트이기 때문입니다. 평범한 Saving / OrigLen은 두 컴파일러에서 실수 나눗셈이고 Single과 직접 비교되므로, 캐스트는 그저 불필요했습니다

세 API는 어떤 순서로 돌려야 할까요?

SaveAsDeduplicated를 먼저 돌리고, 출력을 다시 로드한 뒤, SaveAsFlateRecompressed를 도세요. 중복 제거에는 이미 도달 가능성 스윕이 포함되므로, 그 앞의 별도 SaveAsReachable 패스는 군더더기입니다; SaveAsReachable은 고아 정리와 정확한 정리 수만 원하는 작업을 위해 두세요. 두 번째로 재압축하면 비싼 인플레이트-디플레이트 작업이 중복마다 한 번이 아니라 생존 스트림마다 한 번 돕니다. 재압축 패스는 또한 /Root와 /Info에서 그래프를 다시 훑으므로, 병합된 중복 폰트만이 여전히 가리키던 폰트 디스크립터나 폰트 파일을 청소합니다

procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  TempPdf: string;
begin
  TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    MergeDuplicateResources(Pdf, TempPdf);   // 정리 + 병합

    Pdf.Active := False;
    Pdf.FileName := TempPdf;
    Pdf.Active := True;
    RecompressFlate(Pdf, TargetPdf);         // 살아남은 것 재압축
  finally
    Pdf.Free;
    DeleteFile(TempPdf);
  end;
end;

레닥션도 적용한다면 이 모든 것보다 먼저 하세요. 물리적으로 콘텐츠를 제거하는 것이야말로 애초에 새 고아를 만드는 일이니까요; 연산자 수준 레닥션과 리소스 정리 글이 레닥션 쪽을 다룹니다. 그리고 파이프라인에서 항상 최종 파일을 다시 로드해 상태 점검을 하세요: 소스와 일치하는 페이지 수는 고객이 소중히 여기는 것을 덮어쓰기 전에 값싼 보험입니다

FAQ

SaveAsReachable은 오브젝트를 다시 번호 매기거나 참조를 바꾸나요?

아니요. TPdf.SaveAsReachable은 살아남은 모든 오브젝트를 원래 번호와 세대에 유지하고 제거된 오브젝트를 빈 xref 엔트리로 표시하므로, 다시 쓸 참조가 없습니다

SaveAsDeduplicated는 서로 다른 두 이미지를 실수로 병합할 수 있나요?

딕셔너리 매칭만으로는 그럴 수 없습니다. 지문에는 날 스트림 바이트와 전체 참조 서브그래프가 포함되고, 후보는 같은 카테고리(폰트, 이미지, Form XObject) 안에서만 비교됩니다

이 API들은 내 디지털 서명을 유효하게 유지하나요?

아니요. 각 API는 전면 재작성을 수행하는데 이는 서명된 바이트 범위를 깨뜨리므로, AllowSignedDocument = True를 넘기고 출력의 무효 서명을 받아들이지 않는 한 서명된 파일은 거부됩니다

세 API 모두 이 블로그의 다른 글에서 다룬 레닥션과 이미지 리샘플링 기능과 함께 Delphi 및 C++Builder용 PDFium Component에 들어 있습니다