Artigo Técnico

HotPDF RenderCacheFolder: um page cache em disco no Delphi

O HotPDF RenderCacheFolder transforma o cache de páginas renderizadas em memória do componente HotPDF para Delphi num page cache em disco persistente: páginas renderizadas são gravadas como arquivos PNG sob uma pasta que você escolhe, e na próxima vez que a mesma fonte de PDF é aberta, o RenderLoadedPageToBitmapCached as lê de volta em vez de rasterizar de novo. A ordem de lookup é memória, depois disco, depois o renderer

O tier de disco está na API desde a v2.416.0, mas até a v2.770.140 ele nunca serviu de fato uma página para uma chamada normal de LoadFromFile ou LoadFromStream. A correção forçou uma pergunta que todo cache persistente precisa responder: como você sabe que o arquivo que abriu hoje é o documento que renderizou ontem, e o que acontece com as páginas cacheadas quando não é? Abaixo estão as respostas que o HotPDF escolheu, incluindo onde ele deliberadamente se recusa a cachear

Como funciona o disk render cache do HotPDF?

O disk render cache do HotPDF é um segundo tier atrás do cache raster em memória, e ele só participa quando o RenderCacheFolder é um caminho não vazio. Uma chamada a RenderLoadedPageToBitmapCached(PageIndex, DPI) primeiro escaneia as entradas em memória, chaveadas por índice de página, DPI e uma variante de render settings. Numa miss ele pergunta ao tier de disco; um hit de disco decodifica o PNG, o promove de volta para a memória e retorna uma cópia de propriedade do caller. Só quando ambos os tiers erram é que a página passa pelo interpretador de content stream descrito em renderizar uma página de PDF carregada para um TBitmap, e o bitmap fresco então também é gravado em disco

Diagrama do HotPDF do lookup de render cache para RenderLoadedPageToBitmapCached: o tier em memória chaveado por página, DPI e variante de render é conferido primeiro, depois o tier de disco RenderCacheFolder de arquivos PNG com replace atômico, depois o interpretador de content stream, e todo hit retorna uma cópia de propriedade do caller
O HotPDF olha na memória primeiro, depois no disco, e só então rasteriza; um hit de disco é promovido de volta para a memória e todo caminho te entrega uma cópia que é sua e que você precisa liberar

Em disco o layout é deliberadamente sem graça. Cada documento ganha uma subpasta nomeada a partir de uma document key de 16 caracteres hexadecimais mais uma render variant de 16 caracteres hexadecimais, cada página é guardada como <page>@<dpi>.png, e um index.txt na raiz mantém os documentos em ordem de uso mais recente atrás de uma tag de schema. Um desencontro de schema limpa a pasta no primeiro uso. As escritas vão primeiro para um arquivo temporário e são trocadas para o lugar com um replace atômico, então uma queda no meio da escrita deixa ou a página antiga ou nada, nunca meio PNG. Um PNG que falha ao decodificar é deletado e contado como miss

Três limites cercam a pasta:

  • RenderCacheMaxDocuments (default 20) limita o número de subpastas de documento; a pasta menos recentemente usada é despejada primeiro
  • RenderCacheMaxBytes (default 524288000, que é 500 MB) limita o tamanho total de todos os arquivos PNG sob a raiz
  • Cada pasta de documento guarda no máximo 200 imagens de página; esse teto por documento é fixo do THotPDF e não é uma propriedade publicada

RenderCacheCapacity (default 8) é um knob separado: ele define quantas páginas renderizadas o tier em memória mantém, e não tem nada a ver com a pegada em disco

uses
  SysUtils, Graphics, HPDFDoc;

procedure WarmThumbnails(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // Configure o tier de disco antes do primeiro render em cache:
    // a pasta e os dois limites são lidos quando o tier é usado pela primeira vez
    Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
      GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
    Pdf.RenderCacheMaxDocuments := 50;
    Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 GiB
    Pdf.RenderCacheCapacity := 16;                        // páginas em memória

    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
          // Entregue a cópia à faixa de thumbnails aqui
        finally
          Bmp.Free; // a chamada em cache sempre retorna uma cópia de propriedade do caller
        end;
      end;
  finally
    Pdf.Free; // desde a v2.770.140 isso não deleta mais as entradas de disco
  end;
end;

Rode o mesmo procedure duas vezes e a segunda rodada nunca rasteriza uma página que coube no cache. O objeto de cache de disco é criado lazily no primeiro render em cache e vive até a instância de THotPDF ser liberada, então mudar RenderCacheFolder, RenderCacheMaxDocuments ou RenderCacheMaxBytes depois desse ponto não move nem redimensiona um cache já aberto. Páginas grandes demais para a política de admissão em memória (por default uma única entrada não pode exceder 64 MiB de pixels de 32 bits) também não são persistidas, e o tier de disco só é consultado enquanto o RenderFallbackPolicy mantém o default rfpIgnore, porque diagnósticos de fallback não são guardados junto com o PNG

Por que o RenderCacheFolder nunca funcionou antes da v2.770.140?

O RenderCacheFolder não tinha efeito antes da v2.770.140 porque o tier de disco chaveava documentos por um hash dos bytes da fonte que os loads ordinários nunca guardavam. A document key vinha de um SHA-256 sobre uma cópia interna dos bytes crus do PDF, mas o LoadFromFile e o LoadFromStream parseiam a fonte no lugar e não retêm tal cópia; o campo só era preenchido temporariamente num caminho de recuperação de criptografia e limpo de novo logo em seguida. Sem bytes, a chave estava sempre vazia, e chave vazia significa que o tier de disco é bypassado. Nenhum erro, nenhum aviso, só uma pasta que ficava vazia

Fazer a chave não vazia expôs um segundo bug que estava escondido atrás do primeiro. O antigo InvalidateRenderedPageCache deletava a pasta de disco do documento, e o InvalidateRenderedPageCache roda no início de todo load, em toda edição e dentro do Free. Então no instante em que a chave funcionasse, toda sessão de viewer teria destruído o próprio cache dela na saída, e a sessão seguinte teria começado fria de qualquer jeito. Pior, a chave era recomputada da mesma fonte depois de uma edição, então renders do documento editado teriam sido guardados sob a chave do arquivo original e servidos à sessão seguinte que abrisse o PDF não modificado. A v2.770.140 conserta a identidade e a invalidação juntas; consertar só uma delas teria entregado ou um cache morto ou um cache mentiroso

Como o HotPDF identifica um PDF sem ler o arquivo inteiro

O HotPDF identifica um PDF carregado de um arquivo local por uma fingerprint do tamanho dele, do last-write time dele e dos primeiros e últimos 64 KiB dele, e identifica uma fonte de stream ou random-access por um SHA-256 de todo o conteúdo dela. Ambos são capturados uma vez, quando um load tem sucesso, e os primeiros 16 caracteres hexadecimais do digest SHA-256 (64 bits) viram a document key

FonteIdentidadeCustoCapturada quando
LoadFromFileTamanho + LastWriteTime + primeiros e últimos 64 KiB, hash com SHA-256No máximo 128 KiB lidos, independente do tamanho do arquivoTodo load com sucesso, mesmo se o RenderCacheFolder for setado depois
LoadFromStreamSHA-256 do stream inteiroUma passada completa sobre a fonteSó se o RenderCacheFolder foi setado antes do load
LoadFromRandomAccessSourceSHA-256 da fonte inteiraUma passada completa sobre a fonteSó se a pasta foi setada primeiro e a range inteira está disponível
Qualquer fonte com uma entrada /EncryptNenhumaNenhumNunca; o tier de disco é bypassado
Mapa de identidade de fontes do HotPDF para o disk render cache: LoadFromFile tira hash de tamanho, LastWriteTime e os primeiros e últimos 64 KiB, LoadFromStream e LoadFromRandomAccessSource tiram hash de todo o conteúdo só quando o RenderCacheFolder foi setado primeiro, e qualquer trailer /Encrypt não captura identidade nenhuma
arquivos ganham fingerprint pelas pontas porque o header, o xref e o trailer moram ali, streams só pagam um hash completo quando você pediu o cache primeiro, e documentos criptografados nunca são gravados em disco

A fingerprint de arquivo é um trade-off deliberado. Tirar hash de um arquivo escaneado de 400 MB por inteiro a cada abertura pode custar mais do que renderizar as duas páginas que o usuário de fato olha. As regiões amostradas não são arbitrárias: o header fica no começo do arquivo, e o trailer e a última cross-reference section ficam no fim (ISO 32000-1 §7.5). Um incremental update anexa um body novo, uma cross-reference section e um trailer novos (§7.5.6), então ele muda o tamanho e a cauda de uma vez. Uma reescrita completa por qualquer tool normal muda o last-write time. Para arquivos de até 128 KiB as duas amostras cobrem cada byte, então documentos pequenos são efetivamente hasheados por inteiro

O risco residual é uma mudança de mesmo tamanho, no lugar, no meio de um arquivo grande cujo writer depois restaura o timestamp original. Isso precisa de uma tool que deliberadamente preserva modification times enquanto edita conteúdo, o que é raro mas não impossível, e nesse caso o cache serve páginas obsoletas. O lado benigno existe: copiar um arquivo no Windows normalmente preserva o last-write time dele, então uma cópia de um documento já no cache acerta as mesmas entradas, o que é correto porque os bytes são idênticos

Streams não têm modification time de forma alguma, então a única identidade honesta é o conteúdo. O HotPDF só paga essa passada completa de SHA-256 quando você pediu um cache de disco antes de carregar; todo outro caller do LoadFromStream não vê custo extra. Isso torna a ordem de atribuição da propriedade load-bearing:

procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
  const CacheRoot: string);
begin
  // Ordem errada para streams: o hash do conteúdo só é computado quando a
  // pasta já está setada, então este documento bypassaria o tier de disco
  //   Pdf.LoadFromStream(Data);
  //   Pdf.RenderCacheFolder := CacheRoot;

  Pdf.RenderCacheFolder := CacheRoot; // sete primeiro
  Data.Position := 0;
  if Pdf.LoadFromStream(Data) <= 0 then
    raise Exception.Create('The stream is not a loadable PDF');
end;

Uma fonte random-access que ainda está baixando (algumas ranges ainda não disponíveis) não ganha identidade em vez de um hash de conteúdo parcial, e se computar a identidade falha por qualquer motivo o load mesmo assim tem sucesso; o documento simplesmente renderiza sem o tier de disco

O que invalida uma entrada do disk cache do HotPDF?

Uma entrada do disk cache do HotPDF nunca é invalidada por ser deletada numa edição; em vez disso, editar o documento carregado descarta a identidade do documento, então o tier de disco é bypassado pelo resto daquele load e as páginas guardadas continuam válidas para a fonte não modificada. Entradas saem do disco só pelos limites de LRU e de bytes, por um PNG corrompido, ou por uma mudança de schema

A chave descreve uma fonte em disco, não o grafo de objetos em memória. Uma vez que você carimba uma página ou muda uma annotation, o documento não bate mais com aquela fonte, então nem ler nem gravar sob a chave dela seria correto. Desde a v2.770.140 tanto a invalidação em nível de documento quanto a de página limpam a identidade em vez de tocar a pasta, e existe uma segunda guarda para edições que não chamaram InvalidateRenderedPageCache: antes de usar o tier de disco, o THotPDF confere se algum objeto carregado está dirty e trata um documento dirty como sem identidade

Render settings funcionam ao contrário. Trocar o PageRenderBackend (ou chamar UseNativeGDIRenderBackend), e chamar ConfigureRenderICCWorkflow ou ClearRenderICCWorkflow, despeja as páginas em memória mas mantém a identidade, porque o documento ainda bate com a fonte dele. Essas settings mudam os pixels sem fazer parte da variante em memória, então a chave de disco embute o nome do backend, a flag de black-point compensation e digests SHA-256 dos profiles ICC de proof e de output. A variante em si já cobre o color intent, o dithering de output, o overprint preview, o modo de luminosity mask, a política de fallback e a visibilidade de todo optional content group, então alternar uma layer renderiza numa pasta diferente em vez de sobrescrever a view default

Semântica de invalidação do HotPDF para o disk cache RenderCacheFolder: editar o documento carregado ou qualquer objeto dirty descarta a identidade da fonte para o tier ser bypassado, mudar o render backend ou o ICC workflow mantém a identidade sob uma chave de variante nova, e salvar mais recarregar rekeya o documento
uma edição nunca deleta a pasta guardada, uma mudança de settings renderiza sob uma chave diferente, e só salvar mais recarregar rende ao documento editado uma identidade nova

Para devolver um documento editado ao tier de disco, dê a ele uma identidade de fonte nova salvando-o e carregando o resultado:

procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
  // Depois de editar o documento carregado: refresque as páginas em memória.
  // A identidade da fonte já se foi, então nada é lido de ou gravado
  // na pasta de disco do documento original
  Pdf.InvalidateRenderedPageCache;

  // Um arquivo salvo tem tamanho e last-write time novos, logo uma
  // identidade nova; renders depois deste load são cacheados sob a chave nova
  Pdf.SaveLoadedDocument(EditedFile);
  if Pdf.LoadFromFile(EditedFile) <= 0 then
    raise Exception.Create('Could not reload the edited document');
end;

A pasta do documento original fica em paz e envelhece para fora através do RenderCacheMaxDocuments e do RenderCacheMaxBytes como qualquer outra entrada. Se o usuário reabrir o original não editado, as páginas dele ainda estão lá

Fronteiras de segurança: fontes criptografadas e pastas vinculadas

O disk render cache do HotPDF recusa dois tipos de input de propósito: ele nunca grava páginas de um PDF criptografado em disco, e nunca segue uma subpasta de documento que seja uma junction ou outro reparse point. Ambas as regras trocam cache hits por não vazar dados nem deletar os arquivos errados

PDFs criptografados nunca são cacheados em disco

Uma página renderizada é conteúdo decifrado. Gravá-la como um PNG simples numa pasta de cache deixaria uma cópia legível de um documento protegido por senha em disco, fora da proteção que o autor escolheu (ISO 32000-1 §7.6). O HotPDF portanto não captura identidade nenhuma para qualquer fonte cujo trailer carregue uma entrada /Encrypt, incluindo arquivos abertos com uma senha ou com uma user password vazia. Esses documentos ainda usam o tier em memória, que morre com o processo

Subpastas junction são rejeitadas desde a v2.770.173

A raiz do cache é escolha sua, e apontá-la para uma junction é permitido. As subpastas de documento sob ela são outra história: o cache as cria, lê, toca e deleta por conta própria, durante a recuperação de startup (que remove arquivos temporários sobrando), lookup (que atualiza timestamps), store, invalidação e os três limites de eviction. Se alguém com acesso de escrita à raiz do cache trocar uma pasta de documento por uma junction para outro diretório, todo um daqueles caminhos a seguiria, e a eviction deletaria arquivos num lugar que o cache nunca foi dono. Desde a v2.770.173 cada um desses pontos de entrada confere o atributo de reparse-point e pula uma pasta de documento vinculada: um lookup conta um miss, um store conta uma falha de escrita, e a eviction a deixa em paz

Caminhos Unicode e raízes compartilhadas

Duas correções relacionadas importam se você implanta em user profiles. Antes da v2.770.135, o RenderCacheFolder era um AnsiString, então uma pasta fora da code page do sistema (um nome de usuário chinês numa instalação de Windows em inglês, por exemplo) era convertida com perdas antes de o cache a ver; a propriedade agora é uma string Unicode, e o replace atômico usa a Windows API wide. Desde a v2.770.52, várias instâncias de THotPDF num processo que apontam para a mesma raiz (depois da expansão de caminho, comparada case-insensitive) compartilham um único index e lock com reference counting. Antes, cada instância sobrescrevia o index.txt com a própria cópia dela e impunha os limites contra a view parcial dela, então a pasta podia crescer várias vezes além do orçamento dela

Esse compartilhamento para na fronteira do processo. Dois processos separados na mesma raiz ainda seguram indexes em memória separados, então dê a cada aplicação rodando em paralelo a própria raiz de cache dela. Viewers que renderizam em worker threads ficam bem dentro de um processo: o PrefetchLoadedPages e a fila coberta em renderização em background com uma fila de requests ambos passam pelo mesmo caminho em cache e pelo mesmo lock

Referência rápida: checklist do RenderCacheFolder

  • Sete RenderCacheFolder, RenderCacheMaxDocuments e RenderCacheMaxBytes antes da primeira chamada ao RenderLoadedPageToBitmapCached; para loads de stream e random-access, sete a pasta antes de carregar
  • Faça upgrade para a v2.770.140 ou posterior se você depende do tier de disco; versões anteriores aceitam a propriedade mas nunca servem uma página do disco para loads normais
  • Espere nenhum cacheamento em disco para PDFs criptografados, para documentos editados depois do load, ou enquanto o RenderFallbackPolicy não está em rfpIgnore
  • Libere a instância de THotPDF normalmente; desde a v2.770.140 nem o Free nem o InvalidateRenderedPageCache deletam entradas de disco
  • Mudar o PageRenderBackend ou o ICC workflow mantém o documento no tier de disco sob uma chave diferente
  • Use uma raiz de cache por aplicação rodando; instâncias dentro de um processo compartilham o index desde a v2.770.52
  • Mantenha a raiz do cache num local por usuário; subpastas de documento que sejam junctions são puladas desde a v2.770.173

Um page cache persistente compensa mais num viewer que reabre os mesmos documentos o dia inteiro, que é exatamente a forma da arquitetura de viewer de PDF customizado em Delphi descrita em outro canto deste blog. RenderCacheFolder, o cache raster em memória e o page renderer saem com o componente HotPDF PDF para Delphi para Delphi e C++Builder