기술 문서

Delphi PDF 파일 크기 감사: 카테고리별 바이트 내역

PDF 파일 크기가 실제로 어디로 가는지 알아내기 위해 losLab PDF Library는 AuditDocumentSpace를 노출합니다. 이는 모든 간접 객체를 이미지, 폰트 프로그램, 폰트 딕셔너리, 콘텐츠 스트림, 폼 XObject, 객체 스트림, 첨부 파일, 메타데이터, 구조 트리, 주석, 페이지 트리, 기타의 12개 카테고리로 분류하고, 각각의 객체 수, 저장된 바이트, 백분율 비중을 보고합니다

이것이 존재하는 상황은 익숙합니다. 40페이지짜리 리포트가 여러분의 생성기에서 80MB로 나오고, 고객이 이유를 묻는데, 여러분이 줄 수 있는 것은 추측뿐입니다. 아마 이미지겠지. 아니면 폰트일 수도. 그래서 다운샘플링을 켜서 출하했더니 파일이 74MB가 되었고, 진짜 무게는 완전히 다른 곳에 있었습니다. 저희 폰트 서브세팅과 이미지 다운샘플링에 관한 자매편 글은 PDF를 줄이는 방법을 다룹니다. 이 글은 그보다 먼저 와야 하는 단계, 즉 줄이려는 것을 측정하는 단계를 다룹니다

압축하기 전에 왜 측정해야 하는가

세 가지 표준 최적화 패스는 주어진 파일에서 대단히 다른 성과를 내며, 세어보기 전까지는 어느 것이 적용될지 파일 자체가 알려주지 않기 때문입니다. 이미 바이트의 2%에 불과한 폰트를 가진 문서에서 폰트를 서브세팅하는 것은 반올림 오차를 옮기는 데 오후를 쓰는 일입니다. 부피의 대부분이 압축되지 않은 콘텐츠 스트림인 파일에서 이미지를 다운샘플링하는 것도 같은 실망을 안겨줍니다. 최적화기 자체는 어려운 부분이 아닙니다. 어느 라이브러리든 하나씩은 가지고 있습니다. 이 파일에 어떤 최적화기를 겨눠야 하는지 아는 것이 어려운 부분이며, 그것은 압축 문제가 아니라 회계 문제입니다. 감사는 또한 최적화기가 답이 아닌 경우도 잡아냅니다. 60%가 첨부된 파일로 밝혀진 파일은 더 나은 압축이 필요한 게 아니라, 그 첨부 파일이 문서에 속해야 하는지에 대한 대화가 필요합니다. 30%가 구조 트리인 파일은 접근성 태깅에 대한 비용을 치르고 있는 것이며, 이는 보통 조용히 벗겨내서는 안 되는 의도적인 비용입니다. 바이트가 귀속되고 나면 여러분은 어느 스위치가 가장 가까운지가 아니라 숫자를 근거로 제품 결정을 내리고 있는 것입니다

12개 카테고리 리포트는 무엇을 담고 있는가

AuditDocumentSpace는 레코드가 아니라 문자열 목록 핸들을 반환하므로, 리포트는 flat DLL과 COM 파사드를 그대로 살아남습니다. 이 목록은 Total,Objects,Bytes,100.0 요약 줄 하나 다음에 계약의 일부인 고정 순서로 정확히 열두 개의 Category,Objects,Bytes,Percent 줄을 담습니다. 순서는 이미지, 폰트 프로그램, 폰트 딕셔너리, 콘텐츠 스트림, 폼 XObject, 객체 스트림, 첨부 파일, 메타데이터, 구조 트리, 주석, 페이지 트리, 기타입니다. 카테고리가 비어 있어도 항상 열세 줄입니다

var
  Lib: TPDFlib;
  ListID, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    ListID := Lib.AuditDocumentSpace;   // 0 when no document is selected
    if ListID = 0 then
      Exit;
    try
      // GetStringListItem is 1-based: items run 1..GetStringListCount
      for I := 1 to Lib.GetStringListCount(ListID) do
        Memo1.Lines.Add(Lib.GetStringListItem(ListID, I));
    finally
      Lib.ReleaseStringList(ListID);
    end;
  finally
    Lib.Free;
  end;
end;

이 루프 안의 Delphi 세부 사항 하나는 딱 한 번 여러분을 물 것입니다. GetStringListItemGetStringListCount와 마찬가지로 1부터 시작하는 항목 인덱스를 사용하며, 범위를 벗어난 인덱스는 예외를 발생시키는 대신 빈 문자열을 반환합니다. 습관적으로 for I := 0 to Count - 1로 루프를 작성하면 빈 첫 줄, 조용히 누락된 마지막 줄을 얻게 되고, 인덱싱이 잘못되었다고 알려줄 예외는 어디에도 없습니다. 리포트 자체는 거의 맞아 보일 것이며, 이것이 진단 도구가 가질 수 있는 최악의 실패 형태입니다

감사는 왜 디코드된 크기 대신 저장된 길이를 사용하는가

저장된 길이가 여러분이 원하는 숫자이면서 동시에 얻기 저렴한 숫자이기 때문입니다. 각 간접 객체는 파일에서 파싱될 때 차지한 원시 바이트 길이인 TPDFIndObj.FLength를 담고 있습니다. 이를 사용한다는 것은 900KB짜리 DCTDecode 이미지가 디코드되는 40MB의 RGB 샘플이 아니라 디스크에서 여러분에게 비용을 부과하는 바이트인 900KB로 보고된다는 뜻입니다. 이는 또한 감사가 아무것도 디코드할 필요가 없다는 뜻이기도 합니다. 지연 로드된 객체는 지연된 채로 남고, 필터는 실행되지 않은 채로 남으며, 500MB 파일을 감사하는 것은 전체 압축 해제 사이클이 아니라 객체 헤더에 대한 한 번의 패스입니다

두 번째 규칙은 이중 계산 방지입니다. 객체가 압축된 객체 스트림 안에 살고 있음을 나타내는 0이 아닌 FObjStrNum이 있으면, 그 바이트 수는 0으로 기록됩니다. 그 저장 공간은 이미 컨테이너 스트림에 의해 한 번 지불되었으며, ISO 32000-1 §7.5.7은 이를 하나의 Flate 압축 페이로드 안에 여러 객체를 담는 /Type /ObjStm 스트림으로 정의합니다. 각 구성원에게 자신의 몫을 부과한 다음 컨테이너에도 다시 부과하면 합계가 실제 파일 크기를 넘어 부풀 것입니다. 이는 아래에서, 그리고 객체 스트림과 상호 참조 스트림에 관한 저희 글에서 더 깊이 다루는, 출력을 읽는 방식에 직접적인 결과를 가져옵니다

폰트 프로그램은 왜 스스로를 분류할 수 없는가

PDF에 내장된 TrueType 폰트 파일에는 그렇다고 말해주는 표식이 없기 때문입니다. ISO 32000-1 §9.8.1은 내장된 폰트 프로그램을 폰트 서술자 안의 /FontFile, /FontFile2, /FontFile3의 값으로 정의하며, 그 참조의 반대편에 있는 스트림 딕셔너리는 /Length1과 필터 키를 가지지만 /Type도, 그것을 폰트로 식별하는 /Subtype도 가지지 않습니다. 홀로 놓고 보면 이는 익명의 바이너리 스트림입니다. 그것을 가리키는 서술자만이 그것이 무엇인지 압니다. 주석에서도 같은 비대칭이 나타납니다. §12.5.2는 주석 딕셔너리에서 /Type /Annot을 선택 사항으로 만드므로, 신뢰할 수 있는 신호는 딕셔너리 자체가 아니라 페이지 /Annots 배열의 구성원인지 여부입니다

그래서 분류는 두 번 실행됩니다. 첫 번째 패스는 각 객체 자신의 /Type/Subtype을 읽어 쉬운 승리를 챙깁니다. /ObjStm, /Subtype /Image, /Subtype /Form, /Type /Font/Type /FontDescriptor, /Metadata, /EmbeddedFile/Filespec, /StructTreeRoot/StructElem, /Annot, /Page/Pages입니다. 나머지는 잠정적으로 기타로 떨어집니다. 두 번째 패스는 그다음 참조하는 쪽을 순회하며 오버라이드합니다. 각 페이지 딕셔너리는 자신의 /Contents를 콘텐츠 스트림으로, /Annots 항목들을 주석으로, /Thumb을 이미지로 재배정하고, 모든 폰트 딕셔너리는 자신의 서술자 체인을 순회합니다

// Shape of the second pass: the referrer names the object
Descriptor := DictOf(FontDict.FindValueByKeyName('FontDescriptor'));
if Assigned(Descriptor) then
begin
  MarkRef(FontDict.FindValueByKeyName('FontDescriptor'), catFontDicts);
  MarkRef(Descriptor.FindValueByKeyName('FontFile'),  catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile2'), catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile3'), catFontPrograms);
end;
// Type0 fonts keep the descriptor one level down
Descendants := FontDict.FindValueByKeyName('DescendantFonts', True);
if (Descendants is TPDFArray) and (TPDFArray(Descendants).Count > 0) then
  MarkFontProgramRefs(DictOf(TPDFArray(Descendants).Item[0]));

리포트 읽고 다음 조치 고르기

비중을 먼저 읽고, 객체 수를 그다음에 읽고, 둘 사이의 큰 간극을 신호로 취급하십시오. 현대적인 PDF는 대부분의 작은 딕셔너리를 객체 스트림 안에 넣으므로, 페이지 트리와 구조 트리는 흔히 수십 개의 객체 대비 거의 0에 가까운 바이트를 보여줍니다. 그들의 진짜 비용은 객체 스트림 줄로 접혀 들어갔습니다. 객체 스트림 자체가 크다면, 그 파일은 콘텐츠보다는 메타데이터성 구조로 밀도가 높은 것이며, 지렛대는 압축이 아니라 객체 가지치기입니다. 주석 외관 스트림도 비슷하게 동작합니다. 이들은 /Subtype /Form을 가지므로, 많은 스탬프가 찍힌 문서는 그 무게를 폼 XObject 아래에서 보여주는 반면 주석 줄은 작게 유지됩니다

function CategoryShare(Lib: TPDFlib; ListID: Integer;
  const Category: string): Double;
var
  I: Integer;
  Parts: TArray<string>;
  Inv: TFormatSettings;
begin
  Result := 0;
  Inv := FormatSettings;
  Inv.DecimalSeparator := '.';   // the report is locale-independent
  for I := 2 to Lib.GetStringListCount(ListID) do   // line 1 is Total
  begin
    Parts := string(Lib.GetStringListItem(ListID, I)).Split([',']);
    if (Length(Parts) = 4) and SameText(Parts[0], Category) then
      Exit(StrToFloatDef(Parts[3], 0, Inv));
  end;
end;

백분율을 표시가 아니라 파싱한다면 두 가지 서식 사실이 중요합니다. 소수점 구분자는 기계의 로케일과 무관하게 항상 리터럴 마침표이므로, 독일이나 프랑스 워크스테이션에서 주변 FormatSettings로 파싱하면 실패하거나 더 나쁘게 잘못 읽힙니다. 그리고 후행 0은 잘려나가므로, 바이트의 정확히 40%를 차지하는 카테고리는 40.0이 아니라 40으로 출력됩니다. 고정된 소수 자릿수를 절대 가정하지 마십시오. 비중을 손에 넣으면 경로는 기계적입니다. 지배적인 이미지 비중은 DownsampleImages를, 지배적인 폰트 프로그램 비중은 SubsetEmbeddedFonts를, 부피가 큰 콘텐츠 스트림은 CompressContent를 가리킵니다

감사가 일부러 알려주지 않는 것

합계는 간접 객체에 대한 합산이며, PDF 파일은 그 객체들보다 약간 더 큽니다. 파일 헤더, 트레일러, 객체 사이의 공백, 그리고 고전적인 상호 참조 테이블은 간접 객체가 아니므로 그 바이트는 아무 곳으로도 귀속되지 않고, 감사 합계는 디스크상 크기보다 약간 아래에 착지합니다. 상호 참조 스트림은 다릅니다. 이는 /Type /XRef를 가진 진짜 객체이므로, 최신 파일에서는 그 바이트가 기타 카테고리에 나타납니다. 어느 쪽도 결함이 아니지만, 감사를 파일 시스템의 바이트 수와 조정하려 한다면 그 간극이 어디서 오는지 알아두십시오

분명히 짚어둘 만한 경계가 두 가지 더 있습니다. 첫째, 숫자는 로드된 파일을 기술하는 것이지 저작 중인 파일을 기술하는 것이 아닙니다. 아직 저장된 길이가 없는 메모리 내 구축 객체의 경우, 크기는 스트림 딕셔너리에 대한 명목상의 여유를 둔 직렬화된 출력으로 대체되는데, 이는 최종 쓰기의 측정값이 아니라 추정값입니다. 정확한 수치를 원한다면 저장 후 재로드하고 감사하십시오. 둘째, 기타 줄이 두꺼운 것은 버그 리포트가 아니라 발견 사항입니다. 보통 더 이상 아무것도 참조하지 않는 고아 객체를 의미하며, 이는 어떤 압축 패스가 아니라 mark-and-sweep 가비지 컬렉션이 할 일입니다

이런 방식으로 사용하면 감사는 대화의 형태를 바꿉니다. 80MB짜리 리포트를 앞에 두고 추측하는 대신, 그것을 열고 호출 하나를 실행한 다음, 이미지가 8%, 폰트 프로그램이 61%이며, 문서가 세 가지 서체만 쓰는 하우스 스타일을 위해 완전한 폰트 프로그램 아홉 개를 내장하고 있다는 것을 읽어냅니다. 그것은 숫자가 붙은, 고칠 수 있는 답입니다. AuditDocumentSpace는 그것이 가리키는 최적화 패스들과 함께 Delphi와 C++Builder용 losLab PDF Library에 제공되며, 레퍼런스 페이지에는 전체 카테고리 목록과 그 주변의 문자열 목록 API가 문서화되어 있습니다