HotPDF RenderCacheFolder는 HotPDF Delphi 컴포넌트의 메모리 내 렌더링 페이지 캐시를 영속적인 디스크 페이지 캐시로 바꿉니다. 렌더링된 페이지는 지정한 폴더 아래 PNG 파일로 쓰이고, 같은 PDF 소스를 다음에 열 때 RenderLoadedPageToBitmapCached가 다시 래스터화하는 대신 그것들을 읽어 돌립니다. 탐색 순서는 메모리, 그다음 디스크, 그다음 렌더러입니다
디스크 티어는 v2.416.0부터 API에 있었지만, v2.770.140까지는 평범한 LoadFromFile이나 LoadFromStream 호출에 대해 실제로 페이지를 서빙한 적이 없었습니다. 수정은 모든 영속 캐시가 답해야 하는 질문을 강요했습니다. 오늘 연 파일이 어제 렌더링한 문서인지 어떻게 아느냐, 그렇지 않다면 캐시된 페이지는 어떻게 되느냐입니다. 아래는 HotPDF가 정한 답들입니다. 일부러 캐시를 거절하는 지점까지 포함해서요
HotPDF 디스크 렌더 캐시는 어떻게 동작할까?
HotPDF 디스크 렌더 캐시는 메모리 내 래스터 캐시 뒤의 두 번째 티어이고, RenderCacheFolder가 빈 경로가 아닐 때만 참여합니다. RenderLoadedPageToBitmapCached(PageIndex, DPI) 호출은 먼저 페이지 인덱스, DPI, 렌더 설정 변형을 키로 하는 메모리 엔트리를 훑습니다. 미스면 디스크 티어에 묻고, 디스크 히트는 PNG를 디코딩해 메모리로 다시 승격시킨 뒤 호출자 소유의 복사본을 반환합니다. 두 티어가 모두 미스일 때만 페이지가 불러온 PDF 페이지를 TBitmap으로 렌더링하기에서 기술한 콘텐츠 스트림 인터프리터를 통과하고, 새 비트맵은 이어서 디스크에도 쓰입니다
디스크 위의 배치는 일부러 지루하게 했습니다. 문서마다 16자리 16진수 문서 키 더하기 16자리 16진수 렌더 변형으로 이름 붙은 하위 폴더를 받고, 페이지마다 <page>@<dpi>.png로 저장되며, 루트의 index.txt가 스키마 태그 뒤에서 문서를 최근 사용 순서로 유지합니다. 스키마가 맞지 않으면 첫 사용 시 폴더를 지웁니다. 쓰기는 먼저 임시 파일로 가고 atomic replace로 제자리에 스왑되므로, 쓰기 중 크래시는 옛 페이지나 아무것도 남기지 반쪽 PNG는 절대 남기지 않습니다. 디코딩에 실패한 PNG는 삭제되고 미스로 셉니다
폴더를 묶는 한도 셋:
RenderCacheMaxDocuments(기본 20)가 문서 하위 폴더 수를 제한합니다. 가장 오래 쓰지 않은 폴더부터 쫓겨납니다RenderCacheMaxBytes(기본 524288000, 즉 500 MB)가 루트 아래 모든 PNG 파일의 총 크기를 제한합니다- 각 문서 폴더는 최대 200개의 페이지 이미지를 유지합니다. 문서별 이 상한은 THotPDF가 고정한 것이고 공개된 속성이 아닙니다
RenderCacheCapacity(기본 8)는 별개의 손잡이입니다. 메모리 티어가 유지하는 렌더링 페이지 수를 정할 뿐, 디스크 사용량과는 아무 상관이 없습니다
uses
SysUtils, Graphics, HPDFDoc;
procedure WarmThumbnails(const FileName: string);
var
Pdf: THotPDF;
Bmp: TBitmap;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
// 첫 캐시 렌더 전에 디스크 티어를 설정:
// 폴더와 두 한도는 티어가 처음 쓰일 때 읽힙니다
Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
Pdf.RenderCacheMaxDocuments := 50;
Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 GiB
Pdf.RenderCacheCapacity := 16; // 메모리의 페이지
if Pdf.LoadFromFile(FileName) > 0 then
for I := 0 to Pdf.LoadedPageCount - 1 do
begin
Bmp := Pdf.RenderLoadedPageToBitmapCached(I, 96);
if Bmp <> nil then
try
// 여기서 복사본을 썸네일 스트립에 넘김
finally
Bmp.Free; // 캐시 호출은 항상 호출자 소유의 복사본을 반환
end;
end;
finally
Pdf.Free; // v2.770.140부터 이것은 더 이상 디스크 엔트리를 삭제하지 않음
end;
end;
같은 절차를 두 번 돌리면 두 번째 실행은 캐시에 들어간 페이지를 결코 다시 래스터화하지 않습니다. 디스크 캐시 오브젝트는 첫 캐시 렌더에서 느긋하게 만들어져 THotPDF 인스턴스가 해제될 때까지 살아 있으므로, 그 이후에 RenderCacheFolder, RenderCacheMaxDocuments, RenderCacheMaxBytes를 바꿔도 이미 열린 캐시는 옮겨지거나 크기가 조정되지 않습니다. 메모리 진입 정책에 비해 너무 큰 페이지(기본으로 단일 엔트리는 32비트 픽셀 기준 64 MiB를 넘을 수 없음)도 영속되지 않고, RenderFallbackPolicy가 기본 rfpIgnore를 유지하는 동안에만 디스크 티어를 묻습니다. 폴백 진단 정보는 PNG와 함께 저장되지 않기 때문입니다
RenderCacheFolder는 왜 v2.770.140 전에는 동작하지 않았을까?
RenderCacheFolder가 v2.770.140 전에 효과가 없었던 이유는 디스크 티어가 평범한 로드가 결코 유지하지 않는 소스 바이트의 해시로 문서의 키를 만들었기 때문입니다. 문서 키는 raw PDF 바이트의 내부 복사본에 대한 SHA-256에서 나왔는데, LoadFromFile과 LoadFromStream은 소스를 제자리에서 파싱하며 그런 복사본을 유지하지 않습니다. 그 필드는 암호화 복구 경로에서 임시로 채워졌다가 바로 다시 지워졌죠. 바이트가 없으니 키는 언제나 비어 있었고, 빈 키는 디스크 티어가 우회된다는 뜻입니다. 오류도 경고도 없이 그저 폴더가 빈 채로 남았습니다
키를 비지 않게 만들자 첫 번째 뒤에 숨어 있던 두 번째 버그가 드러났습니다. 옛 InvalidateRenderedPageCache는 문서의 디스크 폴더를 삭제했고, InvalidateRenderedPageCache는 모든 로드 시작 시, 모든 편집 시, Free 안에서 돕니다. 그러니 키가 동작하는 순간 모든 뷰어 세션은 종료 시 자기 캐시를 파괴했을 것이고, 다음 세션은 어차피 차가운 상태로 시작했을 겁니다. 더 나쁜 것은 편집 후 같은 소스에서 키가 다시 계산됐다는 점입니다. 편집된 문서의 렌더가 원본 파일의 키 아래 저장되어 수정되지 않은 PDF를 연 다음 세션에 서빙됐을 테니까요. v2.770.140은 정체성과 무효화를 함께 고쳤습니다. 하나만 고쳤다면 죽은 캐시나 거짓말하는 캐시 둘 중 하나를 배송했을 겁니다
HotPDF는 파일 전체를 읽지 않고 PDF를 어떻게 식별하나
HotPDF는 로컬 파일에서 불러온 PDF를 크기, last-write 시간, 처음과 마지막 64 KiB의 지문으로 식별하고, 스트림이나 random-access 소스는 콘텐츠 전체의 SHA-256으로 식별합니다. 둘 다 로드가 성공할 때 한 번 캡처되고, SHA-256 다이제스트의 첫 16자리 16진수(64비트)가 문서 키가 됩니다
| 소스 | 정체성 | 비용 | 캡처 시점 |
|---|---|---|---|
LoadFromFile | 크기 + LastWriteTime + 앞뒤 64 KiB, SHA-256으로 해싱 | 최대 128 KiB 읽기, 파일 크기와 무관 | 성공한 모든 로드, RenderCacheFolder가 나중에 설정돼도 |
LoadFromStream | 스트림 전체의 SHA-256 | 소스 전체를 한 번 훑음 | 로드 전에 RenderCacheFolder가 설정된 경우만 |
LoadFromRandomAccessSource | 소스 전체의 SHA-256 | 소스 전체를 한 번 훑음 | 폴더가 먼저 설정되고 전체 범위가 사용 가능한 경우만 |
/Encrypt 엔트리가 있는 모든 소스 | 없음 | 없음 | 결코 없음, 디스크 티어는 우회됨 |
파일 지문은 의도된 트레이드오프입니다. 400 MB짜리 스캔 아카이브를 열 때마다 통째로 해싱하면 사용자가 실제로 보는 두 페이지를 렌더링하는 것보다 비쌀 수 있습니다. 표본 영역은 마음대로 정한 게 아닙니다. 헤더는 파일의 시작에 있고, 트레일러와 마지막 cross-reference 섹션은 끝에 삽니다(ISO 32000-1 §7.5). 증분 업데이트는 새 본문, cross-reference 섹션, 트레일러를 덧붙이므로(§7.5.6) 크기와 꼬리를 동시에 바꿉니다. 평범한 도구의 전체 재작성은 last-write 시간을 바꿉니다. 128 KiB까지의 파일에서는 두 표본이 모든 바이트를 커버하므로, 작은 문서는 사실상 통째로 해싱됩니다
잔여 위험은 큰 파일의 중간에 크기가 같은 제자리 변경을 가하고 나서 원본 타임스탬프를 되살리는 경우입니다. 콘텐츠를 편집하면서 수정 시간을 일부러 보존하는 도구가 필요한데, 드물지만 불가능하지는 않고, 그 경우 캐시는 낡은 페이지를 서빙합니다. 반대쪽 면은 무해합니다. Windows에서 파일을 복사하면 보통 last-write 시간이 보존되므로, 캐시에 이미 있는 문서의 복사본은 같은 엔트리에 히트하고, 바이트가 동일하니 올바른 동작입니다
스트림에는 수정 시간이 아예 없으므로 정직한 정체성은 콘텐츠뿐입니다. HotPDF는 로드 전에 디스크 캐시를 요청했을 때만 그 전체 SHA-256 패스의 비용을 치릅니다. LoadFromStream의 다른 모든 호출자에게는 추가 비용이 없죠. 그래서 속성 대입 순서가 하중을 지게 됩니다:
procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
const CacheRoot: string);
begin
// 스트림에는 잘못된 순서: 콘텐츠 해시는 폴더가 이미
// 설정된 경우에만 계산되므로 이 문서는 디스크 티어를 우회합니다
// Pdf.LoadFromStream(Data);
// Pdf.RenderCacheFolder := CacheRoot;
Pdf.RenderCacheFolder := CacheRoot; // 먼저 설정
Data.Position := 0;
if Pdf.LoadFromStream(Data) <= 0 then
raise Exception.Create('The stream is not a loadable PDF');
end;
아직 다운로드 중인(일부 범위가 아직 사용 불가한) random-access 소스는 부분 콘텐츠의 해시 대신 정체성을 받지 않고, 정체성 계산이 어떤 이유로든 실패해도 로드는 여전히 성공합니다. 문서는 그저 디스크 티어 없이 렌더링될 뿐입니다
HotPDF 디스크 캐시 엔트리를 무효화하는 것은?
HotPDF 디스크 캐시 엔트리는 편집 시 삭제로 무효화되지 않습니다. 대신 불러온 문서를 편집하면 문서 정체성이 떨어져 나가 그 로드의 나머지 동안 디스크 티어가 우회되고, 저장된 페이지는 수정되지 않은 소스에 대해 유효하게 남습니다. 엔트리가 디스크를 떠나는 길은 LRU와 바이트 한도, 깨진 PNG, 스키마 변경뿐입니다
키는 메모리의 오브젝트 그래프가 아니라 디스크 위의 소스를 기술합니다. 페이지에 도장을 찍거나 어노테이션을 바꾸는 순간 문서는 그 소스와 더 이상 맞지 않으므로, 그 키 아래에서 읽는 것도 쓰는 것도 옳지 않습니다. v2.770.140부터 문서 수준과 페이지 수준 무효화 모두 폴더를 건드리는 대신 정체성을 지우고, InvalidateRenderedPageCache를 호출하지 않은 편집을 위한 두 번째 방어도 있습니다. 디스크 티어를 쓰기 전에 THotPDF는 불러온 오브젝트 중 dirty인 것이 있는지 검사해 dirty 문서를 정체성 없는 것으로 취급합니다
렌더 설정은 반대로 동작합니다. PageRenderBackend를 바꾸거나(UseNativeGDIRenderBackend 호출) ConfigureRenderICCWorkflow나 ClearRenderICCWorkflow를 호출하면 메모리 페이지를 플러시하지만 정체성은 유지합니다. 문서가 여전히 소스와 일치하기 때문입니다. 그 설정들은 메모리 변형의 일부가 아니면서 픽셀을 바꾸므로, 디스크 키는 백엔드 이름, black-point compensation 플래그, ICC proof와 출력 프로파일의 SHA-256 다이제스트를 접어 넣습니다. 변형 자체는 이미 색 의도, 출력 디더링, overprint 미리보기, luminosity mask 모드, 폴백 정책, 모든 optional content 그룹의 가시성을 커버하므로, 레이어를 토글하면 기본 보기를 덮어쓰는 대신 다른 폴더로 렌더링됩니다
편집된 문서를 디스크 티어로 되돌리려면 저장한 뒤 결과를 불러와 새 소스 정체성을 주세요:
procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
// 불러온 문서를 편집한 뒤: 메모리 페이지를 새로 고침.
// 소스 정체성은 이미 사라졌으므로 원본 문서의 디스크 폴더에서
// 읽거나 쓰는 것은 없습니다
Pdf.InvalidateRenderedPageCache;
// 저장된 파일은 새 크기와 last-write 시간을 가지므로 새 정체성을
// 가집니다. 이 로드 이후의 렌더는 새 키 아래 캐시됩니다
Pdf.SaveLoadedDocument(EditedFile);
if Pdf.LoadFromFile(EditedFile) <= 0 then
raise Exception.Create('Could not reload the edited document');
end;
원본 문서의 폴더는 그대로 두고 다른 엔트리처럼 RenderCacheMaxDocuments와 RenderCacheMaxBytes를 통해 늙어서 나갑니다. 사용자가 편집하지 않은 원본을 다시 열면 페이지들은 여전히 거기 있습니다
보안 경계: 암호화된 소스와 연결된 폴더
HotPDF 디스크 렌더 캐시는 두 종류의 입력을 일부러 거절합니다. 암호화된 PDF의 페이지를 결코 디스크에 쓰지 않고, junction이나 다른 reparse point인 문서 하위 폴더를 결코 따라가지 않습니다. 두 규칙 모두 데이터 누출이나 엉뚱한 파일 삭제를 피하려고 캐시 히트를 희생합니다
암호화된 PDF는 결코 디스크에 캐시되지 않는다
렌더링된 페이지는 복호화된 콘텐츠입니다. 그것을 평범한 PNG로 캐시 폴더에 쓰면 암호로 보호된 문서의 읽을 수 있는 복사본이, 저자가 선택한 보호 바깥(ISO 32000-1 §7.6)의 디스크에 남습니다. 그래서 HotPDF는 트레일러에 /Encrypt 엔트리를 든 모든 소스의 정체성을 캡처하지 않습니다. 암호로 연 파일이나 빈 사용자 암호로 연 파일도 포함해서요. 그 문서들은 프로세스와 함께 사라지는 메모리 티어는 여전히 씁니다
junction 하위 폴더는 v2.770.173부터 거부된다
캐시 루트는 여러분의 선택이고 junction을 가리키는 것도 허용됩니다. 그 아래의 문서 하위 폴더는 다른 문제입니다. 캐시는 시작 복구(남은 임시 파일 제거), 탐색(타임스탬프 갱신), 저장, 무효화, 세 가지 축출 한도 동안 그것들을 스스로 만들고, 읽고, 만지고, 지웁니다. 캐시 루트에 쓰기 권한을 가진 누군가가 문서 폴더를 다른 디렉터리로 가는 junction으로 바꿔치기하면 그 경로들이 모두 따라가고, 축출은 캐시가 결코 소유하지 않은 어딘가의 파일을 지웠을 겁니다. v2.770.173부터 그 진입점들 각각은 reparse-point 속성을 검사해 연결된 문서 폴더를 건너뜁니다. 탐색은 미스로 셉니다, 저장은 쓰기 실패로 셉니다, 축출은 가만히 둡니다
Unicode 경로와 공유 루트
사용자 프로필에 배포한다면 관련 수정 두 건이 중요합니다. v2.770.135 전의 RenderCacheFolder는 AnsiString이었으므로 시스템 코드 페이지 밖의 폴더(영문 Windows 설치의 중국어 사용자 이름 같은 것)는 캐시가 보기 전에 손실 변환됐습니다. 그 속성은 이제 Unicode string이고 atomic replace는 wide Windows API를 씁니다. v2.770.52부터 같은 프로세스 안에서 같은 루트를 가리키는(경로 확장 후, 대소문자 무시 비교) 여러 THotPDF 인스턴스는 하나의 참조 카운트 인덱스와 락을 공유합니다. 그 전에는 각 인스턴스가 자기 사본으로 index.txt를 덮어쓰고 자기 부분 보기에 대해 한도를 적용했으므로, 폴더는 예산을 몇 배나 넘어 자랄 수 있었습니다
그 공유는 프로세스 경계에서 멈춥니다. 같은 루트의 별개 두 프로세스는 여전히 별개의 메모리 내 인덱스를 들고 있으므로, 동시에 도는 애플리케이션마다 자기 캐시 루트를 주세요. 워커 스레드에서 렌더링하는 뷰어는 한 프로세스 안에서는 문제없습니다. PrefetchLoadedPages와 요청 큐로 배경 렌더링하기에서 다룬 큐가 둘 다 같은 캐시 경로와 같은 락을 통과합니다
빠른 참조: RenderCacheFolder 체크리스트
- 첫
RenderLoadedPageToBitmapCached호출 전에RenderCacheFolder,RenderCacheMaxDocuments,RenderCacheMaxBytes를 설정할 것. 스트림과 random-access 로드에서는 로드 전에 폴더를 설정 - 디스크 티어에 의지한다면 v2.770.140 이상으로 업그레이드할 것. 이전 버전은 속성을 받아들이지만 평범한 로드에서는 결코 디스크에서 페이지를 서빙하지 않음
- 암호화된 PDF, 로드 후 편집된 문서,
RenderFallbackPolicy가rfpIgnore가 아닌 동안에는 디스크 캐싱이 없다고 예상할 것 - THotPDF 인스턴스는 평범하게 해제할 것. v2.770.140부터
Free도InvalidateRenderedPageCache도 디스크 엔트리를 삭제하지 않음 PageRenderBackend나 ICC 워크플로를 바꾸면 문서가 다른 키 아래에서 디스크 티어에 머묾- 실행 중인 애플리케이션마다 캐시 루트 하나를 쓸 것. 한 프로세스 안의 인스턴스는 v2.770.52부터 인덱스를 공유
- 캐시 루트는 사용자별 위치에 둘 것. junction인 문서 하위 폴더는 v2.770.173부터 건너뜀
영속 페이지 캐시는 같은 문서를 하루 종일 다시 여는 뷰어에서 가장 빛을 발합니다. 이 블로그의 다른 글에서 기술한 Delphi 커스텀 PDF 뷰어 아키텍처가 정확히 그 모양입니다. RenderCacheFolder와 메모리 내 래스터 캐시, 페이지 렌더러는 Delphi와 C++Builder용 HotPDF Delphi PDF 컴포넌트에 실려 나갑니다