Artigo Técnico

Renderização PDF por bandas: offset Y negativo em Delphi

A primeira banda continha o desenho inteiro esmagado numa única faixa e as cinco bandas seguintes voltavam vazias. Era a exportação por bandas antiga e o PDFiumPas corrigiu-a na v3.66.0: RenderPageBanded passa agora a largura e a altura completas do alvo da página a FPDF_RenderPageBitmap em cada banda, juntamente com um offset vertical negativo, para que o clip nativo escreva apenas as linhas da banda atual enquanto a página conserva a sua geometria de coordenadas completa. O caso de utilização por trás disto tudo é banal e inevitável. Alguém entrega um desenho de formato E ou uma página panorâmica cosida e quer um raster a 600 DPI. Uma folha ISO A0 a 600 DPI tem 19866 x 28086 pixels e um bitmap de destino de 32 bits desse tamanho precisa de pouco mais de 2 GB de memória contígua. No Delphi de 32 bits, essa alocação simplesmente falha. Em 64 bits, tem sucesso vezes suficientes para transformar a falha num problema do cliente em vez de num problema de teste. A renderização por bandas existe para que a alocação de pico seja uma faixa, não uma página

Porque é que todas as bandas continham a página inteira?

O código antigo confundia dois pares de argumentos diferentes na chamada de renderização de páginas do PDFium. FPDF_RenderPageBitmap recebe start_x, start_y, size_x e size_y, em que o par de tamanhos diz para que dimensão a página inteira deve ser escalada e o par de início diz onde essa página escalada fica dentro do bitmap de destino. O ciclo de bandas anterior à v3.66.0 chamava o helper RenderPage da biblioteca com o topo da banda como offset de destino e a altura da banda como altura da página. Esses dois números passavam diretamente para a chamada nativa, pelo que o PDFium escalava a página inteira para um retângulo com apenas BandHeight linhas e depois a desenhava em y = BandTop dentro de um bitmap que também tinha apenas BandHeight linhas. O resultado era exatamente o que se prevê quando se vê. A banda zero recebia a página inteira esmagada verticalmente à altura da banda. Todas as bandas seguintes recebiam essa mesma página esmagada empurrada para baixo da margem inferior do seu bitmap, pelo que regressavam como preenchimento de fundo. O bug esconde-se no caso que a maioria dos smoke tests usa, uma página cuja altura de renderização é menor do que a altura da banda, porque aí existe uma única banda e a geometria errada coincide por acaso com a correta. Qualquer coisa mais alta do que uma banda expõe-no imediatamente

O que garante o offset negativo?

A implementação corrigida encaminha cada banda através de RenderTile, que é o único local do componente que já compreendia a distinção. RenderTile recebe uma origem do tile em coordenadas de pixels da página inteira e um PageWidth e PageHeight separados, e passa ao PDFium -Left e -Top com o tamanho da página intacto. Negar o offset desliza a página de tamanho completo para cima até a banda pedida ficar na linha zero do bitmap de destino; o PDFium faz depois o clip nativamente contra os limites do bitmap, pelo que nada fora da banda chega a ser rasterizado. O mapeamento página-dispositivo descrito na ISO 32000-1 cláusula 8.3.2 permanece idêntico da primeira à última banda, e esse é todo o objetivo: a banda N é byte a byte idêntica às linhas BandTop a BandTop + h de uma renderização de página completa, e a suite de regressão afirma exatamente isso, pixel a pixel, contra a saída de RenderPage com as mesmas dimensões

// Uma banda à mão. O bitmap de destino tem apenas BandHeight linhas,
// mas o tamanho alvo da página permanece na largura x altura completas
Band := Pdf.RenderTile(0, BandTop,          // origem do tile em pixels da página
                       Width, BandHeight,   // tamanho do bitmap de destino
                       Width, Height);      // tamanho alvo da página inteira
try
  // Band contém agora as linhas BandTop .. BandTop + BandHeight - 1 da página
finally
  Band.Free;
end;

A API pública de bandas é um ciclo de callback. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) devolve o número de bandas que realmente renderizou, ou 0 quando os argumentos são rejeitados, e mantém o render lock do componente durante toda a passagem. A assinatura do callback é TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object. O bitmap é pf32bit, tem Width pixels de largura e nunca é mais alto do que BandHeight, sendo libertado assim que o seu handler regressa, pelo que deve copiar tudo o que pretenda conservar. Devolver False interrompe a passagem depois da banda atual, o que lhe dá o mesmo modelo de cancelamento cooperativo usado pela renderização progressiva cancelável de PDFs em Delphi, apenas ao nível da faixa em vez do nível de continuação do PDFium

type
  TBandSink = class
  private
    FCancelled: Boolean;
    FRows: Integer;
  public
    function HandleBand(BandIndex, BandTopY: Integer;
      Bitmap: TBitmap): Boolean;
    property Rows: Integer read FRows;
  end;

function TBandSink.HandleBand(BandIndex, BandTopY: Integer;
  Bitmap: TBitmap): Boolean;
begin
  // O Bitmap morre quando este método regressa: consuma-o aqui
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

// ...
Pdf.PageNumber := 1;
Bands := Pdf.RenderPageBanded(19866, 28086, 256, Sink.HandleBand);

Transmitir PNG e TIFF sem um bitmap de página inteira

Renderizar por bandas só ajuda se o encoder também for sequencial, pelo que a v3.66.0 acrescentou RenderPageBandedToStream, que escreve PNG ou TIFF diretamente num stream do chamador. TPdfBandedImageStreamOptions.Default inicializa uma altura de banda de 256 linhas, nível de compressão PNG 6 e MaxOutputBytes de 0, o que significa sem limite. O TPdfBandedImageReport devolvido transporta Format, Width, Height, BandsRendered, BandsEncoded, RowsEncoded, PeakBandBytes, OutputBytes e Completed. PeakBandBytes é o número que realmente importa ao dimensionar um trabalho: é Width * BandHeight * 4, pelo que a folha A0 acima atinge aproximadamente 19 MB de buffer de banda em vez de 2 GB de buffer de página

O encoder PNG é deliberadamente limitado. Emite RGB8 fixo, escreve um IHDR com profundidade de bits 8 e tipo de cor 2, constrói depois cada scanline com tipo de filtro 0 (método de filtro 0 da ISO/IEC 15948, tipo de filtro None) e envia-a através do stream de compressão zlib da plataforma. Os bytes comprimidos saem em chunks IDAT com CRC, escritos por ordem. A restrição interessante é o stream por baixo da camada deflate: responde a consultas de posição, porque o stream de compressão lhas faz, mas qualquer tentativa de seek real levanta um erro. Isso é intencional. Assim que um chunk IDAT e o seu CRC estão no wire, não há como voltar atrás para os corrigir e um seek silencioso corromperia uma saída que ainda pareceria estruturalmente válida

O encoder TIFF escreve TIFF clássico little-endian, a marca de ordem de bytes II seguida do magic 42, com uma strip por banda. Os pixels saem primeiro e o IFD de dez entradas é gerado no fim, quando os offsets das strips e as contagens de bytes já são conhecidas. A compressão é a tag 259 com valor 1, pelo que não existe qualquer codificação de entropia: o payload tem exatamente Width * Height * 3 bytes, PhotometricInterpretation é RGB, PlanarConfiguration é chunky e RowsPerStrip regista a altura da banda, enquanto a última strip curta é descrita pela sua própria entrada StripByteCounts. A altura da banda altera, portanto, a memória de pico e a contagem de strips, mas não o tamanho da saída, algo que convém saber antes de a afinar. Se quer ficheiros pequenos em vez de sem perdas, o percurso por página em converter páginas PDF em imagens JPEG com o componente PDFium VCL continua a ser a melhor ferramenta

var
  StreamOptions: TPdfBandedImageStreamOptions;
  Report: TPdfBandedImageReport;
  Output: TFileStream;
begin
  StreamOptions := TPdfBandedImageStreamOptions.Default(pbifPng);
  StreamOptions.BandHeight := 512;
  StreamOptions.CompressionLevel := 6;
  StreamOptions.MaxOutputBytes := Int64(256) * 1024 * 1024;

  Output := TFileStream.Create('sheet-a0-600dpi.png', fmCreate);
  try
    Report := Pdf.RenderPageBandedToStream(Output, 19866, 28086,
      StreamOptions);
  finally
    Output.Free;
  end;

  if not Report.Completed then
    raise Exception.Create('Banded export stopped before the last row');
  // Report.PeakBandBytes = 19866 * 512 * 4, não 19866 * 28086 * 4
end;

Onde para uma exportação por bandas?

Dois tetos limitam a saída e falham em locais diferentes de propósito. O primeiro é o orçamento do chamador: MaxOutputBytes é imposto por um stream de escrita limitado que levanta EPdfError antes de qualquer escrita que ultrapassasse o limite, pelo que o orçamento é um teto rígido e não um relatório posterior. O segundo é estrutural. O TIFF clássico guarda offsets de strips como valores de 32 bits, pelo que BeginImage valida Width * Height * 3 mais o cabeçalho e o diretório contra esse teto e rejeita o trabalho antes de escrever um único pixel; a mesma verificação é feita logo de início contra MaxOutputBytes, porque não vale a pena começar um TIFF cujo orçamento não consiga cobrir o próprio payload de pixels. O PNG não tem limite equivalente, uma vez que os chunks IDAT são puramente sequenciais e não há tabela de offsets de 32 bits para transbordar

Veja com clareza o que uma exportação interrompida deixa para trás. Quando a passagem não chega à última linha, Completed permanece False e o encoder é desmontado com EndImage(False), que deliberadamente não escreve nem o chunk PNG IEND nem o IFD TIFF. O ficheiro parcial é, portanto, inválido e todos os decoders o dirão, em vez de ser uma imagem com aspeto plausível mas com linhas em falta. Essa limpeza está envolvida de modo a que uma falha secundária dentro de EndImage não substitua a exceção original, que é a diferença entre um stack trace que nomeia a causa real e um que nomeia o empregado da limpeza. Se precisa de progresso que sobreviva, faça checkpoint por banda dentro do seu próprio callback; as táticas de caching ao nível das strips no guia de cache de renderização e zoom PDFium para Delphi também se aplicam aqui

Ligar o seu próprio codec

Quando PNG e TIFF não são o alvo, RenderPageBandedToEncoder recebe um descendente de TPdfBandedImageEncoder e conduz o mesmo ciclo. O ciclo de vida é explícito e curto: BeginImage(Width, Height), depois WriteBand(BandIndex, BandTopY, Bitmap) uma vez por faixa em ordem estritamente crescente e, por fim, EndImage(Completed), com GetBytesWritten a alimentar Report.OutputBytes. Os encoders integrados rejeitam liminarmente uma banda fora de ordem em vez de tentarem armazená-la e qualquer encoder que escreva deve fazer o mesmo, porque um codec que reordene strips silenciosamente produz um ficheiro que abre e mente. Esta é a costura a usar para tiles JPEG 2000, um writer JPEG alimentado por uma banda de linhas MCU de cada vez ou uma alimentação direta para um spooler de impressão

type
  TCodecBandEncoder = class(TPdfBandedImageEncoder)
  private
    FNextBand: Integer;
    FWritten: Int64;
  public
    procedure BeginImage(Width, Height: Integer); override;
    function WriteBand(BandIndex, BandTopY: Integer;
      Bitmap: TBitmap): Boolean; override;
    procedure EndImage(Completed: Boolean); override;
    function GetBytesWritten: Int64; override;
  end;

function TCodecBandEncoder.WriteBand(BandIndex, BandTopY: Integer;
  Bitmap: TBitmap): Boolean;
begin
  if BandIndex <> FNextBand then
    raise EPdfError.Create('Bands must arrive in order');
  Bitmap.PixelFormat := pf32bit;
  // Alimente aqui o codec com Bitmap.ScanLine[0 .. Bitmap.Height - 1]
  Inc(FNextBand);
  Result := True;
end;

Uma armadilha de compiladores cruzados que convém conhecer

A unidade zlib tem um nome diferente em cada toolchain suportado: Delphi XE5 e posteriores usam System.ZLib, FPC usa zstream e Delphi mais antigo usa simplesmente ZLib. Isto é compilação condicional rotineira. A armadilha é que os três exportam constantes de nível de compressão chamadas clNone e clDefault, que colidem frontalmente com os membros TColor dos mesmos nomes na unidade de gráficos. Assim que a unidade zlib aparece na cláusula uses da implementação, um clNone não qualificado no código de renderização pode resolver para um nível de compressão em vez de uma cor, sem qualquer diagnóstico. O PDFiumPas fixa isto com aliases explícitos de sentinelas de cor, PdfGraphicsColorNone e PdfGraphicsColorDefault, ligados uma vez às constantes de gráficos totalmente qualificadas e usados em todo o lado onde se compara um fundo de renderização ou uma sentinela de esquema de cores. Três linhas de código e a resolução de símbolos deixa de derivar entre compiladores

A renderização por bandas parece uma funcionalidade de conveniência até encontrar a página que não cabe na RAM e, a partir daí, é o único caminho que funciona. A geometria de bandas corrigida, os encoders PNG e TIFF sequenciais e a costura para encoders personalizados são todos distribuídos como parte do componente PDFium para Delphi, com a comparação completa de pixels entre banda e página a correr na suite de regressão em Delphi, Lazarus e C++Builder