Artículo técnico

Renderizado PDF por bandas en Delphi: offset Y negativo

La primera banda contenía todo el dibujo aplastado en una sola franja y las cinco bandas siguientes volvían en blanco. Ese era el antiguo exportador por bandas y PDFiumPas lo corrigió en v3.66.0: RenderPageBanded pasa ahora la anchura y la altura completas del destino de página a FPDF_RenderPageBitmap en cada banda, junto con un offset vertical negativo, para que el clipping nativo escriba solo las filas de la banda actual mientras la página conserva su geometría completa. El caso de uso es aburrido e inevitable. Alguien entrega un plano de tamaño E o una página panorámica cosida y quiere un raster a 600 DPI. Una hoja ISO A0 a 600 DPI mide 19866 x 28086 píxeles y un bitmap de destino de 32 bits de ese tamaño ocupa algo más de 2 GB de memoria contigua. En Delphi de 32 bits esa asignación falla sin más. En 64 bits tiene éxito con la frecuencia suficiente para convertir el problema en uno del cliente y no de las pruebas. El renderizado por bandas existe para que la asignación máxima sea una franja, no una página

¿Por qué cada banda contenía toda la página?

El código antiguo confundía dos parejas de argumentos distintas en la llamada de renderizado de páginas de PDFium. FPDF_RenderPageBitmap recibe start_x, start_y, size_x y size_y, donde la pareja de tamaño indica a qué escala debe ajustarse la página completa y la pareja de inicio indica dónde cae esa página escalada dentro del bitmap de destino. El bucle de bandas anterior a v3.66.0 llamaba al helper RenderPage de la biblioteca con la parte superior de la banda como offset de destino y la altura de la banda como altura de página. Esos dos números pasaban directamente a la llamada nativa, así que PDFium escalaba la página completa dentro de un rectángulo de solo BandHeight filas y después la dibujaba en y = BandTop dentro de un bitmap que también tenía solo BandHeight filas. El resultado era exactamente el que se predice al verlo. La banda cero recibía toda la página aplastada verticalmente a la altura de la banda. Cada banda posterior recibía esa misma página aplastada, desplazada por debajo del borde inferior de su bitmap, así que volvía con el relleno de fondo. El bug se oculta en el caso que usan la mayoría de las smoke tests, una página cuya altura de renderizado es menor que la altura de banda, porque entonces solo hay una banda y la geometría incorrecta coincide por casualidad con la correcta. Cualquier página más alta que una banda lo muestra de inmediato

Qué garantiza el offset negativo

La implementación corregida dirige cada banda mediante RenderTile, el único punto del componente que ya entendía la diferencia. RenderTile recibe un origen de tile en coordenadas de píxel de página completa junto con un PageWidth y un PageHeight separados, y entrega a PDFium -Left y -Top con el tamaño de página intacto. Negar el offset desliza la página completa hacia arriba hasta que la banda solicitada queda en la fila cero del bitmap de destino; PDFium recorta entonces de forma nativa contra los límites del bitmap, por lo que nunca se rasteriza nada fuera de la banda. El mapeo página-dispositivo descrito en ISO 32000-1 cláusula 8.3.2 permanece idéntico desde la primera banda hasta la última, y ese es todo el objetivo: la banda N es idéntica bit a bit a las filas BandTop a BandTop + h de un único renderizado de página completa, y la suite de regresión lo afirma exactamente, píxel a píxel, frente a la salida de RenderPage con las mismas dimensiones

// Una banda a mano. El bitmap de destino solo tiene BandHeight filas,
// pero el tamaño objetivo de la página conserva la anchura x altura completas
Band := Pdf.RenderTile(0, BandTop,          // origen del tile en píxeles de página
                       Width, BandHeight,   // tamaño del bitmap de destino
                       Width, Height);      // tamaño objetivo de la página completa
try
  // Band contiene ahora las filas BandTop .. BandTop + BandHeight - 1 de la página
finally
  Band.Free;
end;

La API pública de bandas es un bucle de callback. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) devuelve el número de bandas que realmente ha renderizado, o 0 cuando se rechazan los argumentos, y mantiene el lock de renderizado del componente durante toda la pasada. La firma del callback es TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object. El bitmap es pf32bit, tiene Width píxeles de ancho y no supera BandHeight de alto, y se libera en cuanto su handler retorna, así que debe copiar cualquier cosa que quiera conservar. Devolver False detiene la pasada después de la banda actual, lo que ofrece el mismo modelo de cancelación cooperativa que se utiliza en el renderizado progresivo cancelable de PDF en Delphi, solo que a granularidad de franja en lugar de a granularidad de continuación de 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
  // El bitmap muere cuando retorna este método: consúmalo aquí
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

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

Transmitir PNG y TIFF sin un bitmap de página completa

El renderizado por bandas solo ayuda si el encoder también es secuencial, por eso v3.66.0 añadió RenderPageBandedToStream, que escribe PNG o TIFF directamente en un stream del caller. TPdfBandedImageStreamOptions.Default parte de una altura de banda de 256 filas, nivel de compresión PNG 6 y un MaxOutputBytes de 0, que significa sin límite. El TPdfBandedImageReport devuelto contiene Format, Width, Height, BandsRendered, BandsEncoded, RowsEncoded, PeakBandBytes, OutputBytes y Completed. PeakBandBytes es la cifra que realmente importa al dimensionar un trabajo: es Width * BandHeight * 4, así que la hoja A0 anterior alcanza aproximadamente 19 MB de buffer de banda en lugar de 2 GB de buffer de página

El encoder PNG es deliberadamente estrecho. Emite RGB8 fijo, escribiendo un IHDR con profundidad de bits 8 y tipo de color 2, después construye cada scanline con el tipo de filtro 0 (filtro 0 del método de filtro ISO/IEC 15948, None) y lo pasa por el stream de compresión zlib de la plataforma. Los bytes comprimidos salen en forma de chunks IDAT con CRC escritos en orden. La restricción interesante es el stream que queda bajo la capa deflate: responde a consultas de posición porque el stream de compresión se las pide, pero cualquier intento de seek real genera un error. Es deliberado. Una vez que un chunk IDAT y su CRC han salido al cable no se puede volver atrás para arreglarlos, y un seek silencioso corrompería una salida que todavía parecería estructuralmente válida

El encoder TIFF escribe TIFF clásico little-endian, la marca de orden de bytes II seguida del número mágico 42, con una franja por banda. Los píxeles salen primero y el IFD de diez entradas se genera al final, cuando ya se conocen los offsets y recuentos de bytes de las franjas. La compresión es la etiqueta 259 con valor 1, por lo que no hay codificación de entropía: el payload son exactamente Width * Height * 3 bytes, PhotometricInterpretation es RGB, PlanarConfiguration es chunky y RowsPerStrip registra la altura de banda, mientras que la última franja corta se describe con su propia entrada StripByteCounts. Por tanto, la altura de banda cambia la memoria máxima y el número de franjas, pero no el tamaño de salida, algo que conviene saber antes de ajustarla. Si quiere archivos pequeños en lugar de sin pérdida, la ruta por página de convertir páginas PDF en imágenes JPEG con el componente VCL PDFium sigue siendo la herramienta adecuada

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, no 19866 * 28086 * 4
end;

¿Dónde se detiene una exportación por bandas?

Dos techos limitan la salida y fallan en puntos distintos deliberadamente. El primero es el presupuesto del caller: MaxOutputBytes lo aplica un stream de escritura acotado que genera EPdfError antes de cualquier escritura que cruzaría el límite, de modo que el presupuesto es un tope duro y no un informe posterior. El segundo es estructural. TIFF clásico guarda los offsets de las franjas como valores de 32 bits, así que BeginImage valida Width * Height * 3 más la cabecera y el directorio frente a ese techo y rechaza el trabajo antes de escribir un solo píxel; la misma comprobación se ejecuta de antemano contra MaxOutputBytes, porque no merece la pena iniciar un TIFF cuyo presupuesto no pueda cubrir su propio payload de píxeles. PNG no tiene un límite equivalente, ya que los chunks IDAT son puramente secuenciales y no hay una tabla de offsets de 32 bits que pueda desbordarse

Conviene mirar con claridad qué deja atrás una exportación detenida. Cuando la pasada no alcanza la última fila, Completed permanece en False y el encoder se desmonta con EndImage(False), que deliberadamente no escribe ni el chunk IEND de PNG ni el IFD de TIFF. Por tanto, el archivo parcial es inválido y todos los decoders lo dirán, en lugar de ser una imagen de aspecto plausible con filas ausentes. Esa limpieza está envuelta para que un fallo secundario dentro de EndImage no sustituya la excepción original, que es la diferencia entre un stack trace que nombra la causa real y otro que nombra al conserje. Si necesita conservar el progreso, haga checkpoints por banda dentro de su propio callback; las técnicas de caching por franjas de la guía de caché de renderizado y zoom de PDFium Delphi también se aplican aquí

Conectar su propio codec

Cuando PNG y TIFF no son el destino, RenderPageBandedToEncoder recibe un descendiente de TPdfBandedImageEncoder y dirige el mismo bucle. El ciclo de vida es explícito y corto: BeginImage(Width, Height), después WriteBand(BandIndex, BandTopY, Bitmap) una vez por franja en orden estrictamente ascendente y por último EndImage(Completed), con GetBytesWritten alimentando Report.OutputBytes. Los encoders integrados rechazan directamente una banda fuera de orden en vez de intentar almacenarla, y cualquier encoder que escriba debería hacer lo mismo, porque un codec que reordena franjas en silencio produce un archivo que se abre y miente. Esta es la costura que debe utilizar para tiles JPEG 2000, un writer JPEG alimentado con una banda de filas MCU cada vez o una alimentación directa a un spooler de impresión

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;
  // Entregar Bitmap.ScanLine[0 .. Bitmap.Height - 1] al codec aquí
  Inc(FNextBand);
  Result := True;
end;

Una trampa de compiladores cruzados que conviene conocer

La unidad zlib se escribe de forma distinta en cada toolchain compatible: Delphi XE5 y posteriores utilizan System.ZLib, FPC utiliza zstream y Delphi antiguo utiliza ZLib sin prefijo. Eso es compilación condicional rutinaria. La trampa es que las tres exportan constantes de nivel de compresión llamadas clNone y clDefault, que chocan frontalmente con miembros TColor del mismo nombre en la unidad de gráficos. Una vez que la unidad zlib aparece en la cláusula uses de implementation, un clNone sin cualificar en el código de renderizado puede resolverse como nivel de compresión en vez de como color, sin diagnóstico. PDFiumPas lo fija con aliases de sentinel de color explícitos, PdfGraphicsColorNone y PdfGraphicsColorDefault, vinculados una vez a las constantes de gráficos completamente cualificadas y utilizados cada vez que se compara un fondo de render o un sentinel de esquema de color. Tres líneas de código y la resolución de símbolos deja de desviarse entre compiladores

El renderizado por bandas parece una función de comodidad hasta que llega la página que no cabe en RAM, y entonces es la única ruta que funciona. La geometría de bandas corregida, los encoders secuenciales PNG y TIFF y la costura para encoders personalizados forman parte del componente PDFium para Delphi, con la comparación completa de píxeles entre banda y página ejecutándose en la suite de regresión para Delphi, Lazarus y C++Builder