Artigo Técnico

RenderCacheFolder do HotPDF: cache de páginas em disco

O RenderCacheFolder do HotPDF transforma a cache de páginas renderizadas em memória do componente Delphi HotPDF numa cache de páginas em disco persistente: as páginas renderizadas são escritas como ficheiros PNG numa pasta à sua escolha, e da próxima vez que a mesma origem PDF é aberta, o RenderLoadedPageToBitmapCached lê-as de volta em vez de rasterizar outra vez. A ordem de procura é memória, depois disco, depois o renderer

O nível de disco está na API desde a v2.416.0, mas até à v2.770.140 nunca chegou a servir uma página numa chamada normal de LoadFromFile ou LoadFromStream. A correção forçou uma pergunta que toda a cache persistente tem de responder: como sabe que o ficheiro que abriu hoje é o documento que renderizou ontem, e o que acontece às páginas em cache quando não é? Abaixo ficam as respostas em que o HotPDF assentou, incluindo onde se recusa deliberadamente a colocar em cache

Como funciona a cache de renderização em disco do HotPDF?

A cache de renderização em disco do HotPDF é um segundo nível atrás da cache raster em memória, e só participa quando o RenderCacheFolder é um caminho não vazio. Uma chamada a RenderLoadedPageToBitmapCached(PageIndex, DPI) primeiro varre as entradas em memória, indexadas por índice de página, DPI e uma variante de definições de renderização. Num miss pergunta ao nível de disco; um hit de disco descodifica o PNG, promove-o de volta à memória e devolve uma cópia da qual o chamador é dono. Só quando ambos os níveis falham é que a página passa pelo intérprete de content streams descrito em renderizar uma página PDF carregada para um TBitmap, e o bitmap fresco é então também escrito para o disco

Diagrama HotPDF da procura na cache de renderização para o RenderLoadedPageToBitmapCached: o nível em memória indexado por página, DPI e variante de renderização é verificado primeiro, depois o nível de disco RenderCacheFolder de ficheiros PNG com substituição atómica, depois o intérprete de content streams, e cada hit devolve uma cópia da qual o chamador é dono
O HotPDF procura primeiro em memória, depois no disco, e só então rasteriza; um hit de disco é promovido de volta à memória e todos os caminhos lhe entregam uma cópia sua que tem de libertar

Em disco o layout é deliberadamente aborrecido. Cada documento recebe uma subpasta nomeada a partir de uma chave de documento de 16 caracteres hexadecimais mais uma variante de renderização de 16 caracteres hexadecimais, cada página é guardada como <page>@<dpi>.png, e um index.txt na raiz mantém os documentos pela ordem de uso mais recente atrás de uma tag de esquema. Uma discrepância de esquema limpa a pasta no primeiro uso. As escritas vão primeiro para um ficheiro temporário e são trocadas para o sítio com uma substituição atómica, por isso um crash a meio da escrita deixa ou a página antiga ou nada, nunca meio PNG. Um PNG que falhe a descodificação é apagado e contado como miss

Três limites prendem a pasta:

  • O RenderCacheMaxDocuments (predefinição 20) limita o número de subpastas de documentos; a pasta usada menos recentemente é despejada primeiro
  • O RenderCacheMaxBytes (predefinição 524288000, que são 500 MB) limita o tamanho total de todos os ficheiros PNG sob a raiz
  • Cada pasta de documento guarda no máximo 200 imagens de página; esse limite por documento é fixado pelo THotPDF e não é uma propriedade publicada

O RenderCacheCapacity (predefinição 8) é um botão separado: define quantas páginas renderizadas o nível em memória guarda, 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 nível de disco antes da primeira renderização em cache:
    // a pasta e os dois limites são lidos quando o nível é 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 à tira de miniaturas aqui
        finally
          Bmp.Free; // a chamada em cache devolve sempre uma cópia do chamador
        end;
      end;
  finally
    Pdf.Free; // desde a v2.770.140 isto já não apaga as entradas de disco
  end;
end;

Corra o mesmo procedimento duas vezes e a segunda corrida nunca rasteriza uma página que coubesse na cache. O objeto de cache de disco é criado preguiçosamente na primeira renderização em cache e vive até a instância THotPDF ser libertada, por isso mudar o RenderCacheFolder, o RenderCacheMaxDocuments ou o RenderCacheMaxBytes depois desse ponto não move nem redimensiona uma cache já aberta. Páginas grandes demais para a política de admissão em memória (por predefinição uma única entrada não pode exceder 64 MiB de pixels de 32 bits) também não são persistidas, e o nível de disco só é consultado enquanto o RenderFallbackPolicy mantém o seu rfpIgnore por omissão, porque os diagnósticos de fallback não são guardados junto do PNG

Porque é que o RenderCacheFolder nunca funcionou antes da v2.770.140?

O RenderCacheFolder não tinha efeito antes da v2.770.140 porque o nível de disco indexava documentos por um hash dos bytes da origem que os loads normais nunca guardavam. A chave de documento vinha de um SHA-256 sobre uma cópia interna dos bytes PDF crus, mas o LoadFromFile e o LoadFromStream analisam a origem no sítio e não retêm tal cópia; o campo só era preenchido temporariamente num caminho de recuperação de encriptados e limpo logo a seguir. Sem bytes, a chave estava sempre vazia, e uma chave vazia significa que o nível de disco é ignorado. Sem erro, sem aviso, só uma pasta que ficava vazia

Tornar a chave não vazia expôs um segundo bug que estava escondido atrás do primeiro. O InvalidateRenderedPageCache antigo apagava a pasta de disco do documento, e o InvalidateRenderedPageCache corre ao início de cada load, em cada edição e dentro do Free. Por isso, no momento em que a chave funcionasse, cada sessão de viewer teria destruído a sua própria cache ao sair, e a sessão seguinte teria começado fria de qualquer forma. Pior, a chave era recalculada a partir da mesma origem depois de uma edição, por isso as renderizações do documento editado teriam sido guardadas sob a chave do ficheiro original e servidas à sessão seguinte que abrisse o PDF não modificado. A v2.770.140 corrige a identidade e a invalidação em conjunto; corrigir só uma delas teria entregue ou uma cache morta ou uma cache mentirosa

Como o HotPDF identifica um PDF sem ler o ficheiro inteiro

O HotPDF identifica um PDF carregado de um ficheiro local por uma impressão digital do seu tamanho, da sua data de última escrita e do seu primeiro e último 64 KiB, e identifica uma origem stream ou de acesso aleatório por um SHA-256 do seu conteúdo inteiro. Ambas são capturadas uma vez, quando um load tem sucesso, e os primeiros 16 caracteres hexadecimais do digest SHA-256 (64 bits) tornam-se a chave de documento

OrigemIdentidadeCustoCapturada quando
LoadFromFileTamanho + LastWriteTime + primeiros e últimos 64 KiB, com hash SHA-256Leitura de no máximo 128 KiB, independente do tamanho do ficheiroCada load com sucesso, mesmo que o RenderCacheFolder seja definido depois
LoadFromStreamSHA-256 do stream inteiroUma passagem completa pela origemSó se o RenderCacheFolder foi definido antes do load
LoadFromRandomAccessSourceSHA-256 da origem inteiraUma passagem completa pela origemSó se a pasta foi definida primeiro e toda a gama está disponível
Qualquer origem com uma entrada /EncryptNenhumaNenhumNunca; o nível de disco é ignorado
Mapa de identidade de origens do HotPDF para a cache de renderização em disco: o LoadFromFile calcula hash do tamanho, LastWriteTime e do primeiro e último 64 KiB, o LoadFromStream e o LoadFromRandomAccessSource calculam hash do conteúdo inteiro só quando o RenderCacheFolder foi definido primeiro, e qualquer trailer /Encrypt não captura identidade nenhuma
os ficheiros são identificados por impressão digital a partir das suas pontas porque o header, o xref e o trailer vivem aí, os streams só pagam um hash completo quando pediu a cache primeiro, e documentos encriptados nunca são escritos para o disco

A impressão digital do ficheiro é uma troca deliberada. Calcular o hash completo de um arquivo digitalizado de 400 MB a cada abertura pode custar mais do que renderizar as duas páginas que um utilizador realmente olha. As regiões amostradas não são arbitrárias: o header senta-se no início do ficheiro, e o trailer e a última secção de cross-reference sentam-se no fim (ISO 32000-1 §7.5). Uma atualização incremental acrescenta um novo corpo, secção de cross-reference e trailer (§7.5.6), por isso muda o tamanho e a cauda de uma vez. Uma reescrita completa por qualquer ferramenta normal muda a data de última escrita. Para ficheiros até 128 KiB as duas amostras cobrem todos os bytes, por isso documentos pequenos são efetivamente hasheados por inteiro

O risco residual é uma alteração no sítio, do mesmo tamanho, ao meio de um ficheiro grande cujo escritor depois restaura a marca de tempo original. Isso precisa de uma ferramenta que preserve deliberadamente as marcas de modificação enquanto edita conteúdo, o que é raro mas não impossível, e nesse caso a cache serve páginas velhas. O lado bom é benigno: copiar um ficheiro no Windows normalmente preserva a sua data de última escrita, por isso uma cópia de um documento já em cache acerta nas mesmas entradas, o que está correto porque os bytes são idênticos

Os streams não têm marca de modificação nenhuma, por isso a única identidade honesta é o conteúdo. O HotPDF só paga essa passagem completa de SHA-256 quando pediu uma cache de disco antes de carregar; todos os outros chamadores do LoadFromStream não veem custo extra. Isso torna a ordem de atribuição da propriedade crítica:

procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
  const CacheRoot: string);
begin
  // Ordem errada para streams: o hash do conteúdo só é calculado quando a
  // pasta já está definida, por isso este documento ignoraria o nível de disco
  //   Pdf.LoadFromStream(Data);
  //   Pdf.RenderCacheFolder := CacheRoot;

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

Uma origem de acesso aleatório que ainda está a descarregar (algumas gamas ainda não disponíveis) não recebe identidade em vez de um hash de conteúdo parcial, e se o cálculo da identidade falhar por qualquer razão o load continua a ter sucesso; o documento simplesmente renderiza sem o nível de disco

O que invalida uma entrada da cache de disco do HotPDF?

Uma entrada da cache de disco do HotPDF nunca é invalidada apagando-a numa edição; em vez disso, editar o documento carregado deita fora a identidade do documento, por isso o nível de disco é ignorado pelo resto desse load e as páginas guardadas continuam válidas para a origem não modificada. As entradas saem do disco só pelos limites de LRU e de bytes, por um PNG corrompido, ou por uma mudança de esquema

A chave descreve uma origem em disco, não o grafo de objetos em memória. Assim que estampa uma página ou muda uma anotação, o documento já não corresponde a essa origem, por isso nem ler nem escrever sob a sua chave estaria certo. Desde a v2.770.140, tanto a invalidação ao nível do documento como ao nível da página limpa a identidade em vez de tocar na pasta, e há uma segunda guarda para edições que não chamaram InvalidateRenderedPageCache: antes de usar o nível de disco, o THotPDF verifica se algum objeto carregado está dirty e trata um documento dirty como sem identidade

As definições de renderização funcionam ao contrário. Trocar o PageRenderBackend (ou chamar UseNativeGDIRenderBackend), e chamar ConfigureRenderICCWorkflow ou ClearRenderICCWorkflow, esvazia as páginas em memória mas mantém a identidade, porque o documento continua a corresponder à sua origem. Essas definições mudam os pixels sem fazerem parte da variante em memória, por isso a chave de disco incorpora o nome do backend, a flag de compensação de ponto preto e digests SHA-256 dos perfis ICC de prova e de saída. A variante em si já cobre a intenção de cor, o dithering de saída, a pré-visualização de overprint, o modo de máscara de luminosidade, a política de fallback e a visibilidade de cada grupo de conteúdo opcional, por isso alternar uma camada renderiza para uma pasta diferente em vez de sobrescrever a vista por omissão

Semântica de invalidação do HotPDF para a cache de disco RenderCacheFolder: editar o documento carregado ou qualquer objeto dirty deita fora a identidade da origem por isso o nível é ignorado, mudar o backend de renderização ou o fluxo ICC mantém a identidade sob uma nova chave de variante, e gravar mais recarregar dá ao documento uma nova chave
uma edição nunca apaga a pasta guardada, uma mudança de definições renderiza sob uma chave diferente, e só gravar mais recarregar dá ao documento editado uma identidade fresca

Para pôr um documento editado de volta no nível de disco, dê-lhe uma nova identidade de origem gravando-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 origem já se foi, por isso nada é lido ou
  // escrito na pasta de disco do documento original
  Pdf.InvalidateRenderedPageCache;

  // Um ficheiro gravado tem novo tamanho e nova data de última escrita, logo uma nova
  // identidade; renderizações depois deste load ficam em cache sob a nova chave
  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 pelo RenderCacheMaxDocuments e pelo RenderCacheMaxBytes como qualquer outra entrada. Se o utilizador reabrir o original não editado, as suas páginas ainda estão lá

Fronteiras de segurança: origens encriptadas e pastas ligadas

A cache de renderização em disco do HotPDF recusa dois tipos de input de propósito: nunca escreve páginas de um PDF encriptado para o disco, e nunca segue uma subpasta de documento que seja um junction ou outro reparse point. Ambas as regras trocam hits de cache por não fugirem dados nem apagarem ficheiros errados

PDFs encriptados nunca ficam em cache em disco

Uma página renderizada é conteúdo decifrado. Escrevê-la como um PNG simples numa pasta de cache deixaria uma cópia legível de um documento protegido por palavra-passe em disco, fora da proteção que o autor escolheu (ISO 32000-1 §7.6). O HotPDF por isso não captura identidade para origem nenhuma cujo trailer traga uma entrada /Encrypt, incluindo ficheiros abertos com uma palavra-passe ou com uma palavra-passe de utilizador vazia. Esses documentos continuam a usar o nível em memória, que morre com o processo

Subpastas junction são rejeitadas desde a v2.770.173

A raiz da cache é à sua escolha, e apontá-la para um junction é permitido. As subpastas de documentos por baixo dela são outra conversa: a cache cria-as, lê-as, toca-lhes e apaga-as por si, durante a recuperação no arranque (que remove ficheiros temporários sobrantes), a procura (que atualiza marcas de tempo), a gravação, a invalidação e os três limites de despejo. Se alguém com acesso de escrita à raiz da cache substituir uma pasta de documento por um junction para outro diretório, todos esses caminhos seguir-no-iam, e o despejo apagaria ficheiros em algum sítio de que a cache nunca foi dona. Desde a v2.770.173 cada um desses pontos de entrada verifica o atributo de reparse point e salta uma pasta de documento ligada: uma procura conta um miss, uma gravação conta uma falha de escrita, e o despejo deixa-a em paz

Caminhos Unicode e raízes partilhadas

Duas correções relacionadas interessam se instala em perfis de utilizador. Antes da v2.770.135, o RenderCacheFolder era um AnsiString, por isso uma pasta fora da página de código do sistema (um nome de utilizador chinês numa instalação Windows inglesa, por exemplo) era convertida com perdas antes de a cache a ver; a propriedade é agora uma string Unicode, e a substituição atómica usa a API wide do Windows. Desde a v2.770.52, várias instâncias THotPDF num processo que apontem para a mesma raiz (após expansão do caminho, comparada sem distinguir maiúsculas) partilham um único índice e lock com contagem de referências. Antes, cada instância sobrescrevia o index.txt com a sua própria cópia e impunha os limites contra a sua vista parcial, por isso a pasta podia crescer várias vezes além do seu orçamento

Essa partilha pára na fronteira do processo. Dois processos separados na mesma raiz ainda têm índices em memória separados, por isso dê a cada aplicação em execução concorrente a sua própria raiz de cache. Viewers que renderizam em worker threads ficam bem dentro de um processo: o PrefetchLoadedPages e a fila coberta em renderização em segundo plano com uma fila de pedidos passam ambos pelo mesmo caminho em cache e pelo mesmo lock

Referência rápida: checklist do RenderCacheFolder

  • Defina RenderCacheFolder, RenderCacheMaxDocuments e RenderCacheMaxBytes antes da primeira chamada ao RenderLoadedPageToBitmapCached; para loads de stream e de acesso aleatório, defina a pasta antes de carregar
  • Faça upgrade para a v2.770.140 ou posterior se depende do nível de disco; versões anteriores aceitam a propriedade mas nunca servem uma página do disco para loads normais
  • Espere nenhuma cache em disco para PDFs encriptados, para documentos editados depois do load, ou enquanto o RenderFallbackPolicy não for rfpIgnore
  • Liberte a instância THotPDF normalmente; desde a v2.770.140 nem o Free nem o InvalidateRenderedPageCache apagam entradas de disco
  • Mudar o PageRenderBackend ou o fluxo ICC mantém o documento no nível de disco sob uma chave diferente
  • Use uma raiz de cache por aplicação em execução; instâncias dentro de um processo partilham o índice desde a v2.770.52
  • Mantenha a raiz da cache num local por utilizador; subpastas de documentos que sejam junctions são saltadas desde a v2.770.173

Uma cache de páginas persistente compensa mais num viewer que reabre os mesmos documentos o dia inteiro, que é exatamente a forma da arquitetura de viewer PDF personalizado em Delphi descrita noutro sítio deste blogue. O RenderCacheFolder, a cache raster em memória e o renderizador de páginas vêm com o componente PDF Delphi HotPDF para Delphi e C++Builder