기술 문서

델파이 PDFiumPas의 희소 지연 PDF 객체 인덱스

2 GB PDF에서 사전 하나만 원하는데 도구는 먼저 트레일러 /Size 크기의 배열로 크로스 레퍼런스 표 전체를 펼칩니다. PDFiumPas는 그 단계를 희소 지연 객체 인덱스로 바꿉니다. xref 섹션 디스크립터만 메모리에 두고, 객체 번호 하나를 경계가 정해진 창을 통해 필요할 때 해석하며, 실제로 손댄 항목만 캐시합니다

FPdfCompress에 있던 이 코드의 예전 모습은 정직했지만 비쌌습니다. ApplyDefaultOpenAction은 파일 전체를 TBytes 하나로 읽어 넣은 뒤 /Size까지 객체 번호마다 슬롯 하나씩인 조밀한 TPdfActiveXrefEntries 배열을 할당했습니다. 규모가 커지면 두 가지가 무너졌습니다. 호출자가 사전 네 개만 원해도 읽기 비용은 문서 크기에 선형으로 늘어나고, 조밀한 배열은 파서 예산과 충돌합니다. TPdfParserResourceBudget.DefaultMaxObjects를 4,000,000으로 정하므로, 최고 객체 번호가 그 천장 위에 있는 완전히 정상적인 파일이 올바름이 아니라 메모리의 논거로 거부됐습니다

델파이에서 PDFiumPas 희소 지연 객체 인덱스와 조밀한 크로스 레퍼런스 배열 비교. 조밀한 경로는 파일 전체를 읽어 트레일러 크기까지 객체 번호마다 슬롯 하나를 할당하고, 희소 경로는 섹션 디스크립터만 유지한다
디스크립터만 메모리에 남고, 항목은 파일에 남으며, 모든 읽기는 경계가 정해진 1 MiB 창을 통과합니다

PDFium 공개 API는 왜 이 질문에 답하지 않는가

정보는 PDFium 안에 존재하지만 C 경계를 넘지 않기 때문입니다. CPDF_Parser는 크로스 레퍼런스 표, 객체 스트림 소속, 리비전 우선순위를 내부에서 관리하지만, 공개 헤더는 객체 번호를 받아 원시 오프셋, 세대, 이긴 리비전, 어느 ObjStm에 사는지 돌려주는 진입점을 노출하지 않습니다. 저장 쪽도 똑같이 닫혀 있습니다. FPDF_SaveAsCopyFPDF_SaveWithVersion은 순차 쓰기 콜백만 건넵니다. 그러므로 네이티브 저장 뒤 카탈로그에 가하는 바이트 수준 패치는 파스칼 계층에서 만들어야 하고, PDFiumPas가 DLL을 재사용하는 대신 이 구조들을 스스로 해석하는 이유가 바로 그것입니다

희소 인덱스는 실제로 메모리에 무엇을 두는가

항목이 아니라 디스크립터입니다. 클래식 표(ISO 32000-1 §7.5.4)의 경우 TPdfSparseXrefSubsection은 첫 객체 번호, 객체 개수, 항목 행이 시작하는 바이트 오프셋, 측정된 항목 폭을 저장합니다. 항목 자체는 파일에 남습니다. 폭은 20바이트로 가정하지 않고 첫 행에서 측정하는데, 생산자들이 줄 끝 처리를 두고 의견이 갈리기 때문입니다. PDFiumPas는 18에서 64 사이는 받아들이고 그 밖은 거부하며, 선언된 개수가 스트림 끝을 넘어가는 하위 섹션도 거부합니다. 크로스 레퍼런스 스트림(§7.5.8)의 경우 섹션은 각각 0에서 8로 제한되는 세 /W 필드 폭, 평탄화된 /Index 쌍, 디코딩된 항목 바이트를 담고, 기대 길이는 바이트 하나가 팽창되기 전에 /W/Index에서 계산됩니다

인덱스 전체는 startxref가 발견되는 최대 1 MiB 꼬리 창에서 Initialize로 만들어지고, 이후 모든 객체 읽기는 1 MiB 객체 창을 씁니다. 원시 스트림 천장은 64 MiB이고 xref 한 줄은 1024바이트를 넘지 못합니다. PDFiumPas로 객체와 크로스 레퍼런스 스트림을 검증하는 노트를 이미 읽었다면, 같은 필드 폭 규율이 여기에도 적용된다는 것을 알 것입니다. 다만 이번에는 표 전체를 감사하는 대신 항목 하나를 주소 지정하는 데 쓰입니다

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { startxref와 /Prev 체인, 카탈로그만 걷는다 }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

한 번의 조회는 어떻게 객체 하나에 도달하는가

두 레이아웃 모두 산술로 답합니다. 클래식 하위 섹션은 고정 폭 행을 가지므로 항목의 주소는 하위 섹션 시작에 객체 오프셋 곱하기 측정된 폭을 더한 것입니다. PDFiumPas는 이어 그 한 줄을 읽고, 열 자리 오프셋과 다섯 자리 세대를 해석하고, 세대를 §7.5.4의 65535 천장과 대조하고, 뒤따르는 키워드를 axkDirect 또는 axkFree로 분류합니다. 크로스 레퍼런스 스트림은 한 단계 더 필요합니다. /Index 하위 섹션들이 디코딩된 바이트 런 안에 이어 붙어 있기 때문에, 인덱스는 /W 폭의 합을 곱하기 전에 앞선 하위 섹션들의 개수를 누적합니다. 타입 1은 오프셋을, 타입 2는 객체 스트림 번호와 멤버 인덱스를 내놓고, 그 밖의 것은 추측 대신 axkUnknown이 됩니다

{ 클래식 표, ISO 32000-1 7.5.4절 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ 크로스 레퍼런스 스트림, ISO 32000-1 7.5.8절 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

어느 경로에서도 /Size에 비례하는 것은 없습니다. 그것이 재작성의 전부입니다. 트레일러 크기 값은 메타데이터로 전달되고 증분 리비전을 쓸 때 사용되지만, 할당을 주도하는 일은 결코 없습니다. 회귀 스위트는 페이지 트리가 객체 1,000,000,000과 1,000,000,001에 살고 트레일러가 /Size 1000000002를 선언하는 픽스처로 이것을 못 박습니다. 예전 조밀 구현은 그 파일을 거부했습니다. 희소 인덱스는 두 참조를 모두 해석하고 선언된 크기를 출력 트레일러에 보존합니다

델파이에서 PDFiumPas가 객체 번호 하나를 해석하는 방법. 클래식 크로스 레퍼런스 표는 측정된 행 폭을 곱하고, 크로스 레퍼런스 스트림은 /W 배열의 필드 폭 합을 곱하기 전에 앞선 하위 섹션들의 개수를 누적한다
두 조회 모두 순수 산술이므로 어느 쪽도 트레일러가 선언한 객체 개수에 비례하지 않습니다

하이브리드 리비전, /Prev 체인과 그 주변의 가드

리비전 우선순위는 순진한 지연 인덱스가 틀어지는 지점입니다. PDFiumPas는 startxref에서 최신 우선 순서로 체인을 걷고 답하는 첫 섹션에서 조회를 멈추는데, 이는 병합된 표를 실체화하지 않고 우선순위 규칙을 재현합니다. 하이브리드 레퍼런스 파일(§7.5.8.4)은 클래식 분기 안에서 처리됩니다. 트레일러가 /XRefStm을 실고 있으면 보조 스트림 섹션이 그것을 참조한 클래식 섹션보다 먼저 등록되어, 평범한 표에 보이지 않는 압축 객체도 여전히 발견되는 동안 클래식 항목들은 지위를 유지합니다. 더 오래된 리비전은 이어 /Prev를 따라갑니다

두 가드가 그 걷기를 경계 짓고, 둘 다 손상된 파일에서 중요합니다. 방문한 모든 오프셋은 기록되므로 체인 안쪽을 가리키는 /Prev는 빙글빙글 도는 대신 종료하고, 순회 깊이는 기본값 1024인 MaxRecursionDepth로 제한됩니다. 암호화 플래그는 최신 트레일러에서만 읽는 것이 아니라 체인 전체에 걸쳐 누적되는데, 최신 트레일러가 /Encrypt를 생략한 문서도 더 뒤에서는 여전히 암호화되어 있을 수 있기 때문입니다. 리비전을 덧붙이는 호출자는 그 플래그에 의존해 평문 객체를 암호화된 파일에 쓰는 일을 거부합니다

델파이에서 PDFiumPas가 하이브리드 PDF 리비전 체인을 걷는 방법. 섹션들은 startxref에서 최신 우선으로 등록되고, 보조 XRefStm 섹션은 그것을 지명한 클래식 표보다 앞서며, /Prev 걷기는 방문한 오프셋과 깊이 한계로 경계 지어진다
조회는 답하는 첫 섹션에서 멈춥니다. 병합된 표를 실체화하지 않고 리비전 우선순위를 재현하는 방식입니다

타입 2 항목: 객체 스트림이 기다리는 이유

타입 2 항목은 객체 스트림을 지명하고, PDFiumPas는 호출자가 그 멤버를 요청할 때까지 그 스트림을 만지지 않습니다. 마침내 만질 때 /Type /ObjStm을 검증하고, /N은 객체 예산에, /First는 디코딩된 바이트 천장에 대조하며, 헤더 쌍 하나가 최소 네 바이트를 필요로 하므로 /N/First에 대조해 상식 검사합니다. 그제서야 스트림이 팽창되고, 헤더 스캔은 전체 멤버 표를 만드는 대신 요청된 멤버와 그 뒤를 잇는 멤버에서 멈춥니다. 디코딩된 객체 스트림은 한 번에 하나만 유지되는데, 페이지 트리 가지가 단일 ObjStm에 뭉치는 상황에서는 옳은 거래입니다. 델파이에서 객체 스트림과 예측기 디코딩을 다룬 우리 글이 그 팽창 단계 안에서 벌어지는 일을 다룹니다(§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { 인덱스 하나는 유지하고, 세대를 인식하는 읽기는 여러 번 }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source는 여러분 몫으로 남는다 }
  end;
end;

캐시가 약속을 끊는 지점

인덱스는 스냅샷이고, 그 점을 뼈저리게 말할 가치가 있습니다. 섹션들은 Initialize에서 한 번 해석됩니다. 그 뒤로 기반 스트림이 수정되면 캐시된 모든 항목은 낡았는데 클래스는 눈치채지 못합니다. TPdfSparseDictionaryReader는 소스의 호출자 소유 수명 동안 인덱스를 붙들고 있는데, 페이지 트리 위의 재귀 걷기가 원하는 것이 바로 그것이고 재작성 도중에는 절대 해서는 안 되는 것도 바로 그것입니다. 항목 캐시는 선형으로 탐색하는 평면 배열이고 부정 결과도 저장하므로, 수백 번의 조회는 싸고 수십만 번은 그렇지 않습니다. ReadDictionary는 정확한 세대 일치를 요구하고 ReadLatestDictionary는 활성 세대를 해석하며, 그 차이는 일부러 둔 것입니다. 참조 해석은 전자를, 카탈로그 검사는 후자를 필요로 합니다. 이 한계들을 지킬 수 없는 곳에서는 주변 유닛이 여전히 동작하는 파일 집합을 좁히는 대신 예전의 파일 전체 파서로 후퇴하는데, 대형 PDF의 온디맨드 스트리밍에서도 쓰는 패턴입니다

크로스 컴파일러 회귀는 세 툴체인 모두에서 같은 동작을 검증하는데, 2 MiB 소스가 1 MiB를 넘는 읽기를 단 한 번도 보지 못한다는 단언을 포함합니다. PDF 구조를 직접 만지는 델파이, C++Builder, 라자루스 코드를 유지 보수하면서 사전 네 개를 위해 파일 전체 해석 비용을 치르는 데 지쳤다면, 희소 인덱스와 그 주변의 공개 접점은 PDFiumPas 델파이 PDFium 컴포넌트에 실려 나옵니다