RenderCacheFolder в HotPDF превращает кэш отрендеренных страниц в памяти компонента HotPDF для Delphi в постоянный дисковый кэш страниц: отрендеренные страницы записываются PNG-файлами в выбранную вами папку, и при следующем открытии того же источника PDF RenderLoadedPageToBitmapCached читает их обратно вместо повторного растрирования. Порядок поиска: память, затем диск, затем рендерер
Дисковый ярус живёт в API с v2.416.0, но до v2.770.140 он ни разу не выдал страницу для обычного вызова LoadFromFile или LoadFromStream. Правка заставила ответить на вопрос, который обязан решить каждый постоянный кэш: как понять, что файл, открытый сегодня, — тот документ, что вы рендерили вчера, и что происходит с закэшированными страницами, если нет? Ниже — ответы, на которых остановился HotPDF, включая места, где он нарочно отказывается кэшировать
Как работает дисковый рендер-кэш HotPDF?
Дисковый рендер-кэш HotPDF — второй ярус за растровым кэшем в памяти, и он участвует, только когда RenderCacheFolder — непустой путь. Вызов RenderLoadedPageToBitmapCached(PageIndex, DPI) сперва сканирует записи в памяти, ключёванные индексом страницы, DPI и вариантом настроек рендеринга. При промахе он спрашивает дисковый ярус; попадание на диске декодирует PNG, возвращает его в память и отдаёт копию во владении вызывающего. Лишь когда промахиваются оба яруса, страница идёт через интерпретатор потока содержимого, описанный в статье о рендеринге загруженной страницы PDF в TBitmap, и свежий битмап тоже записывается на диск
На диске раскладка нарочно скучная. Каждый документ получает подпапку с именем из 16-символьного hex-ключа документа плюс 16-символьного hex-варианта рендеринга, каждая страница хранится как <page>@<dpi>.png, а index.txt в корне держит документы в порядке недавнего использования за тегом схемы. Несовпадение схемы очищает папку при первом использовании. Записи сперва идут во временный файл и подменяются на место атомарной заменой, так что падение посреди записи оставит либо старую страницу, либо ничего — никогда полPNG. PNG, не сумевший декодироваться, удаляется и считается промахом
Папку ограничивают три лимита:
RenderCacheMaxDocuments(дефолт 20) ограничивает число подпапок документов; первой выселяется самая давно использованнаяRenderCacheMaxBytes(дефолт 524288000, то есть 500 МБ) ограничивает суммарный размер всех 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 после этого не передвинет и не переразмерит уже открытый кэш. Слишком крупные для политики допуска в память страницы (по дефолту одна запись не может превышать 64 МиБ 32-битных пикселей) тоже не персистируются, а дисковый ярус опрашивается лишь пока RenderFallbackPolicy держит дефолтный rfpIgnore, потому что фоллбэк-диагностика не хранится рядом с PNG
Почему RenderCacheFolder не работал до v2.770.140?
RenderCacheFolder не действовал до v2.770.140, потому что дисковый ярус ключевал документы хэшем байтов источника, который обычные загрузки никогда не сохраняли. Ключ документа приходил из SHA-256 по внутренней копии сырых байтов PDF, но LoadFromFile и LoadFromStream разбирают источник на месте и такой копии не держат; поле заполнялось лишь временно на пути шифрованного восстановления и тут же очищалось. Без байтов ключ всегда был пуст, а пустой ключ означает, что дисковый ярус обходится. Ни ошибки, ни предупреждения — просто папка, которая оставалась пустой
Непустой ключ вскрыл второй баг, прятавшийся за первым. Старый InvalidateRenderedPageCache удалял дисковую папку документа, а InvalidateRenderedPageCache запускается в начале каждой загрузки, при каждой правке и внутри Free. Так что в момент, когда ключ заработал, каждая сессия вьюера уничтожала бы собственный кэш на выходе, а следующая сессия в любом случае стартовала бы холодной. Хуже: ключ пересчитывался из того же источника после правки, так что рендеры правленного документа легли бы под ключ исходного файла и достались бы следующей сессии, открывшей немодифицированный PDF. v2.770.140 чинит идентичность и инвалидацию разом; починка только одного из них выпустила бы либо мёртвый кэш, либо лживый
Как HotPDF идентифицирует PDF, не читая весь файл
HotPDF идентифицирует PDF, загруженный из локального файла, отпечатком из размера, времени последней записи и первых и последних 64 КиБ, а поток или random-access источник — SHA-256 всего содержимого. Оба снимаются однажды, при успешной загрузке, и первые 16 hex-символов дайджеста SHA-256 (64 бита) становятся ключом документа
| Источник | Идентичность | Стоимость | Когда снимается |
|---|---|---|---|
LoadFromFile | Размер + LastWriteTime + первые и последние 64 КиБ, хэшируется SHA-256 | Не более 128 КиБ чтения, независимо от размера файла | Каждая успешная загрузка, даже если RenderCacheFolder выставлен позже |
LoadFromStream | SHA-256 всего потока | Один полный проход по источнику | Только если RenderCacheFolder был выставлен до загрузки |
LoadFromRandomAccessSource | SHA-256 всего источника | Один полный проход по источнику | Только если папка была выставлена сперва и весь диапазон доступен |
Любой источник с записью /Encrypt | Нет | Нет | Никогда; дисковый ярус обходится |
Файловый отпечаток — обдуманный компромисс. Полное хэширование сканированного архива на 400 МБ при каждом открытии может стоить дороже, чем рендер двух страниц, которые пользователь реально смотрит. Выборочные области не произвольны: заголовок сидит в начале файла, а трейлер и последняя секция cross-reference — в конце (ISO 32000-1 §7.5). Инкрементальный апдейт дописывает новое тело, секцию cross-reference и трейлер (§7.5.6), так что он меняет размер и хвост разом. Полная перезапись любым нормальным инструментом меняет время последней записи. Для файлов до 128 КиБ обе выборки покрывают каждый байт, так что маленькие документы фактически хэшируются целиком
Остаточный риск — изменение середины крупного файла той же длины на месте, после которого писатель восстанавливает исходный таймстамп. Для этого нужен инструмент, нарочно сохраняющий времена модификации при правке содержимого; это редко, но не невозможно, и тогда кэш подсовывает устаревшие страницы. Обратная сторона добрая: копирование файла в Windows обычно сохраняет время последней записи, так что копия документа, уже лежащего в кэше, попадает в те же записи — корректно, ведь байты идентичны
У потоков времени модификации нет вовсе, так что единственная честная идентичность — содержимое. 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 проверяет, есть ли грязный загруженный объект, и трактует грязный документ как не имеющий идентичности
Настройки рендеринга работают наоборот. Смена PageRenderBackend (или вызов UseNativeGDIRenderBackend), а также вызовы ConfigureRenderICCWorkflow или ClearRenderICCWorkflow вымывают страницы в памяти, но сохраняют идентичность, потому что документ всё ещё совпадает со своим источником. Эти настройки меняют пиксели, не входя в вариант в памяти, поэтому дисковый ключ вплетает имя бэкенда, флаг компенсации чёрной точки и SHA-256-дайджесты ICC proof- и выходных профилей. Сам вариант уже покрывает цветовой интент, дизеринг вывода, превью overprint, режим luminosity mask, политику фоллбэка и видимость каждой группы optional content, так что переключение слоя рендерится в другую папку, а не перезаписывает вид по умолчанию
Чтобы вернуть правленный документ на дисковый ярус, дайте ему новую идентичность источника, сохранив его и загрузив результат:
procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
// После правки загруженного документа: освежаем страницы в памяти.
// Идентичность источника уже сброшена, так что из дисковой папки
// исходного документа ничего не читается и не пишется
Pdf.InvalidateRenderedPageCache;
// Сохранённый файл имеет новый размер и время записи, а стало быть —
// новую идентичность; рендеры после этой загрузки кэшируются под новым ключом
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-точкой. Оба правила меняют попадания в кэш на то, чтобы не утекать данные и не удалять чужие файлы
Шифрованные PDF никогда не кэшируются на диске
Отрендеренная страница — расшифрованное содержимое. Записать её обычным PNG в папку кэша значило бы оставить на диске читаемую копию защищённого паролем документа вне защиты, выбранной автором (ISO 32000-1 §7.6). Поэтому HotPDF не снимает идентичность ни с одного источника, чей трейлер несёт запись /Encrypt, включая файлы, открытые с паролем или с пустым пользовательским паролем. Такие документы продолжают пользоваться ярусом в памяти, который умирает вместе с процессом
Junction-подпапки отвергаются с v2.770.173
Корень кэша выбираете вы, и навести его на junction можно. Подпапки документов под ним — другое дело: кэш сам создаёт, читает, трогает и удаляет их — при стартовом восстановлении (которое убирает оставшиеся временные файлы), при поиске (который обновляет таймстампы), при записи, при инвалидации и при трёх выселяющих лимитах. Если кто-то с правом записи в корень кэша подменит папку документа junction-ом на другой каталог, каждый из этих путей пошёл бы за ней, и выселение удаляло бы файлы там, куда кэш никогда не имел прав. С v2.770.173 каждая из этих точек входа проверяет атрибут reparse-точки и пропускает связанную папку документа: поиск считает промах, запись — неудачную запись, а выселение оставляет её в покое
Unicode-пути и общие корни
Две связанные правки важны, если вы разворачиваетесь в пользовательских профилях. До v2.770.135 RenderCacheFolder был AnsiString, так что папка вне системной кодовой страницы (китайское имя пользователя на английской Windows, например) терялась при конверсии до того, как кэш её видел; свойство теперь юникодный string, а атомарная замена пользуется wide-Windows API. С v2.770.52 несколько инстансов THotPDF в одном процессе, указывающих на один корень (после раскрытия пути, сравнение без учёта регистра), делят один индекс и лок с подсчётом ссылок. Раньше каждый инстанс перезаписывал index.txt своей копией и мерил лимиты по своему частичному срезу, так что папка могла вырасти в несколько раз за бюджет
Этот шэринг заканчивается на границе процесса. Два отдельных процесса на одном корне всё ещё держат раздельные индексы в памяти, так что дайте каждому параллельно работающему приложению собственный корень кэша. Вьюерам, рендерящим на рабочих потоках, в рамках одного процесса бояться нечего: и PrefetchLoadedPages, и очередь из статьи о фоновом рендеринге с очередью запросов идут через один и тот же кэшированный путь и один и тот же лок
Шпаргалка: чек-лист RenderCacheFolder
- Выставляйте
RenderCacheFolder,RenderCacheMaxDocumentsиRenderCacheMaxBytesдо первого вызоваRenderLoadedPageToBitmapCached; для потоковых и random-access загрузок выставляйте папку до загрузки - Обновляйтесь до v2.770.140 или новее, если полагаетесь на дисковый ярус; ранние версии принимают свойство, но ни разу не выдают страницу с диска при обычных загрузках
- Не ждите дискового кэширования для шифрованных PDF, для документов, правленных после загрузки, и пока
RenderFallbackPolicyнеrfpIgnore - Освобождайте инстанс THotPDF как обычно; с v2.770.140 ни
Free, ниInvalidateRenderedPageCacheне удаляют дисковые записи - Смена
PageRenderBackendили ICC-воркфлоу держит документ на дисковом ярусе под другим ключом - Один корень кэша на каждое работающее приложение; инстансы внутри одного процесса делят индекс с v2.770.52
- Держите корень кэша в персональной для пользователя локации; подпапки документов, являющиеся junction, пропускаются с v2.770.173
Постоянный кэш страниц окупается сильнее всего во вьюере, который весь день переоткрывает одни и те же документы, — ровно такова архитектура собственного PDF-вьюера на Delphi, описанная в другом месте этого блога. RenderCacheFolder, растровый кэш в памяти и рендерер страниц поставляются с компонентом HotPDF Delphi PDF component для Delphi и C++Builder