Artículo técnico

Extrae imágenes de un PDF cargado en Delphi: HotPDF

Tienes un PDF en disco, un cliente lo escaneó a partir de una pila de facturas y tu tarea es extraer de nuevo las imágenes de las páginas como mapas de bits para un paso de OCR. Cargas el archivo, encuentras los XObjects de imagen y entonces aparece la parte que nadie te advierte, los bytes de esos flujos no son píxeles. Pueden ser un codestream JPEG, un bloque JPEG 2000 comprimido por wavelets, una pasada de fax Grupo 4 o un raster indexado detrás de una paleta y un filtro Flate. El objeto de imagen conoce su ancho y alto, pero las muestras reales están selladas dentro del filtro que eligió el productor. Obtener un TBitmap utilizable significa deshacer ese filtro, y PDF ofrece más o menos ocho formas distintas de sellar esos bytes

Ese vacío es el que ExtractLoadedImage cubre en HotPDF, el componente PDF nativo VCL para Delphi y C++Builder. Recorre los XObjects de imagen de un documento que ya cargaste, informa qué es cada uno y decodifica los que puede de vuelta a un mapa de bits de 24 bits. La parte interesante no es la superficie de la API, que son tres métodos. Es por qué tiene que existir una ruta de decodificación separada y qué puede y qué no puede volver a convertir en píxeles

Por qué las imágenes cargadas no vienen decodificadas

El cargador de HotPDF está diseñado para conservar el paso intacto. Cuando llamas a LoadFromFile, los flujos de imagen se conservan exactamente como aparecen en el archivo de origen, el filtro original, los bytes comprimidos originales, el diccionario original. Eso es intencional. Normalmente, cargar un documento sirve para copiar páginas, combinar archivos, estamparlos, cambiar permisos y volver a guardarlos, y para todo eso lo más barato y seguro es dejar cada flujo de imagen sin tocar. Decodificar todas las imágenes a un raster al cargar consumiría memoria y CPU en trabajo que la mayoría de los llamadores nunca necesita, y volver a codificarlas al guardar degradaría imágenes que debían copiarse de forma literal

La consecuencia es que el grafo de objetos cargado no lleva píxeles. Un XObject de imagen cuyo /Filter es /DCTDecode contiene bytes JPEG, HotPDF nunca ejecutó un decodificador JPEG sobre él, porque nada en la ruta de copiar y reescribir lo necesitaba. Así que, cuando realmente quieres píxeles, la API de extracción tiene que hacer la decodificación por su cuenta, desde cero, para el filtro que use esa imagen en particular. Por la misma razón los códecs del lado de codificación son independientes del cargador, el artículo sobre agregar imágenes JPEG 2000 a PDFs en Delphi describe cómo el motor JPX se conecta en el lado de creación, y ese motor simplemente no estaba enlazado a la ruta de lectura hasta que la API de extracción lo necesitó

La API de tres métodos

La superficie es pequeña. GetLoadedImageCount devuelve cuántos XObjects de imagen contiene el documento cargado. GetLoadedImageInfo llena un registro descriptor para uno de ellos por índice. ExtractLoadedImage devuelve el mapa de bits decodificado, o nil cuando no puede decodificar esa imagen. La enumeración se basa en índices y es estable para una carga dada, internamente recorre la tabla de objetos indirectos y recoge cada flujo cuyo /Subtype resuelve a /Image, así que el índice que pasas a GetLoadedImageInfo es el mismo que pasas a ExtractLoadedImage

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I, Count: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-invoices.pdf', '') <= 0 then
      Exit;
    Count := Pdf.GetLoadedImageCount;
    for I := 0 to Count - 1 do
    begin
      if not Pdf.GetLoadedImageInfo(I, Info) then
        Continue;
      if not Info.Decodable then
        Continue;                       // filter or colour space not supported
      Bmp := Pdf.ExtractLoadedImage(I);
      if Bmp <> nil then
      try
        Bmp.SaveToFile(Format('img_%d.bmp', [I]));
      finally
        Bmp.Free;                       // caller owns the bitmap
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Dos detalles del contrato importan aquí. Primero, el TBitmap devuelto es tuyo y debes liberarlo; el documento no lo almacena ni lo posee. Segundo, comprueba Decodable antes de llamar y verifica el resultado contra nil después. El método no lanza una excepción por un filtro no admitido, devuelve nil, y un nil silencioso en un bucle por lotes es justo el tipo de cosa que se traga una página entera en un trabajo de mil páginas sin que nadie lo note

Lee el descriptor antes de decodificar

THPDFLoadedImageInfo te dice qué es una imagen sin comprometerse con una decodificación completa. Sus campos salen directamente del diccionario de imagen: Width y Height en muestras, BitsPerComponent, ColorComponents y ColorSpace describiendo la interpretación posterior a la decodificación, 1 para gris, 3 para RGB, 4 para CMYK, Filter como la compresión nombrada, IsImageMask para máscaras de esténcil, ObjectNumber para el objeto indirecto subyacente y Decodable

Esa última marca es la honesta. Decodable es True solo cuando la compilación en ejecución puede convertir de verdad esta combinación concreta de filtro y espacio de color en un mapa de bits. Codifica la matriz real de compatibilidad, no un deseo: una imagen cuyo Filter la compilación actual no entiende reporta Decodable = False, y puedes usar eso para registrar, omitir o volver a la extracción del flujo bruto por tu cuenta. Trátalo como una precondición, no como una sugerencia

// Triage every image before committing to a decode.
var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  I: Integer;
begin
  // ... Pdf loaded ...
  for I := 0 to Pdf.GetLoadedImageCount - 1 do
  begin
    if not Pdf.GetLoadedImageInfo(I, Info) then
      Continue;
    if Info.Decodable then
      // ExtractLoadedImage(I) will return a TBitmap
    else
      // unsupported filter/colour space: log the object and skip
      Writeln(Format('Image %d obj %d: %dx%d %s/%s not decodable',
        [I, Info.ObjectNumber, Info.Width, Info.Height,
         String(Info.Filter), String(Info.ColorSpace)]));
  end;
end;

Un detalle de implementación muerde a quienes construyen registros descriptor a mano. THPDFLoadedImageInfo contiene dos campos AnsiString, Filter y ColorSpace. Son tipos administrados con conteo de referencias, así que el reflejo de poner a cero un registro con FillChar(Info, SizeOf(Info), 0) está mal aquí: sobrescribe la referencia de la cadena sin decrementarla, lo que provoca fugas o corrupción. HotPDF inicializa el registro campo por campo exactamente por esa razón, y si alguna vez copias este patrón en tu propio código, haz lo mismo

Un despachador, ocho rutas de filtro

La razón por la que esta función llegó en una serie de versiones y no en una sola es que PDF no tiene un formato de imagen. Tiene filtros, y la sección §8.9.5 de ISO 32000-1 permite que un XObject de imagen nombre cualquiera de ellos en /Filter, mientras que la interpretación de las muestras se gobierna aparte por /ColorSpace, /BitsPerComponent y un arreglo opcional /Decode. ExtractLoadedImage lee el nombre del filtro y enruta a un decodificador dedicado para cada caso. El conjunto admitido, construido entre v2.229 y v2.231, ahora cubre ocho rutas distintas

  • Rasteres sin procesar (FlateDecode, LZWDecode o sin filtro) en DeviceRGB o DeviceGray de 8 bits. Los bytes se expanden a un raster empaquetado, y la única transformación es un intercambio de canales, que se explica abajo
  • DCTDecode (JPEG). El codestream se entrega al TJPEGImage de VCL, que resuelve geometría y color, y el resultado se asigna a un mapa de bits de 24 bits
  • JPXDecode (JPEG 2000). Se decodifica mediante el backend OpenJPEG, el mismo motor descrito en el artículo de JPEG 2000, con componentes de alta profundidad de bits remuestreados a 8 bits
  • Color indexado. La paleta se lee del arreglo [/Indexed base hival lookup] y cada muestra se expande mediante la tabla de búsqueda hasta color verdadero
  • DeviceCMYK. Las muestras de cuatro canales se convierten a RGB con la fórmula estándar de tinta sobre blanco
  • DeviceGray e Indexed por debajo de 8 bits, con 1, 2 o 4 bits por componente, desempaquetados muestra por muestra y escalados al rango de 0 a 255
  • CCITTFaxDecode, los filtros de fax Grupo 3 y Grupo 4, decodificados mediante un backend dedicado T.4/T.6
  • JBIG2Decode, el filtro bilevel de alta compresión, decodificado a través del backend JBIG2 registrado que el artículo nativo de compresión JBIG2 cubre desde el lado de codificación

Todo termina en el mismo lugar: un mapa de bits BGR de 24 bits, porque eso es lo que un TBitmap de VCL almacena de forma nativa y lo que espera cualquier consumidor posterior

Las transformaciones que cambian píxeles sin avisar

Dos de estas rutas implican una transformación que es fácil de hacer mal de forma sutil, y vale la pena entenderla aunque nunca toques el decodificador por tu cuenta. La primera es el intercambio del orden de color. Un raster DeviceRGB de PDF almacena las muestras en orden rojo, verde y azul, con la fila superior primero. Una scanline 24 bits de VCL las almacena en orden azul, verde y rojo. Así que decodificar una imagen RGB simple no es un memcpy; el primer y el tercer byte de cada píxel se intercambian al entrar en la scanline. Si lo haces al revés, los rojos y los azules se cambian de lugar, lo cual luce bien en una imagen de prueba en escala de grises y está catastróficamente mal en una imagen a color. El orden de filas, dicho sea de paso, se mapea directo: los rasteres de arriba hacia abajo de PDF coinciden con ScanLine[0] de VCL como la fila visual superior, así que no hace falta voltear verticalmente

La segunda es CMYK. Las imágenes DeviceCMYK de PDF cargan cuatro tintas, y la conversión a RGB es un cálculo por canal, no una búsqueda: cada canal de salida es (255 - ink) * (255 - K) / 255. Esta es una aproximación de dispositivo, no una conversión administrada por color mediante un perfil ICC, así que el resultado es lo bastante bueno para visualización y volver a rasterizar, pero no es la ruta correcta si necesitas color exacto para impresión. Si tu flujo exige fidelidad, trata el mapa de bits extraído como una vista previa y conserva el flujo CMYK original para la canalización administrada por color

La ruta Indexed esconde su propia trampa de análisis. La paleta en un espacio de color /Indexed puede almacenarse como una cadena literal o como una cadena hexadecimal, y HotPDF guarda el valor de una cadena hexadecimal como el texto hexadecimal, no como los bytes decodificados. Así que, cuando la paleta es una cadena hexadecimal, primero hay que pasar la tabla de búsqueda por una decodificación de hexadecimal a bytes; una cadena literal ya son bytes sin procesar. Si omites esa rama, una imagen indexada de cuatro colores sale corrupta, porque cada entrada de la paleta se lee desde el límite de byte equivocado

Cadenas de filtros, el último filtro es el de la propia imagen

Un solo nombre de /Filter es el caso fácil. PDF también permite una cadena de filtros, donde el flujo pasó por varios en secuencia, listados en orden dentro de un arreglo /Filter como [/ASCII85Decode /FlateDecode] o [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). La semántica es precisa: los filtros se aplican de izquierda a derecha al codificar, así que al decodificar los deshaces de derecha a izquierda, y el último filtro del arreglo es el que realmente define el formato de imagen. Los filtros iniciales solo son codificaciones de transporte envueltas alrededor de él

El extractor maneja esto pelando capas. Antes de que corra cualquier decodificador de imagen, se aplica cada filtro de la cadena excepto el último para producir la entrada que espera el filtro final, y solo entonces se hace el despacho sobre ese último filtro. Así, [/ASCII85Decode /DCTDecode] primero deshace ASCII85 del flujo y luego envía el resultado a la ruta JPEG; [/FlateDecode] envuelto alrededor de un raster sin procesar se expande y luego pasa por la ruta raster. Esto es lo que mantiene simples a los ocho decodificadores. Ninguno tiene que saber nada de envolturas de transporte ASCII85 o hexadecimales, porque para cuando el decodificador ve los bytes, esas envolturas ya desaparecieron. También significa que una cadena cuyo filtro final no está admitido falla de forma limpia en el paso de despacho y no a mitad de camino

Hasta dónde llega la extracción y qué hacer después

Sé honesto contigo mismo sobre los límites. Una imagen cuyo filtro final está fuera del conjunto admitido devuelve nil, y lo mismo ocurre con una imagen cuyo espacio de color la compilación no puede interpretar. Las máscaras suaves y el alfa no se reconstruyen dentro del mapa de bits, obtienes la imagen base, no un resultado compuesto. Las profundidades de bit por encima de 8 en JPEG 2000 se remuestrean hacia abajo, lo cual es intencionalmente con pérdida y una mala decisión si estás rearchivando en vez de mostrar. Y una máscara de imagen, un esténcil de un bit sin color propio, se describe en el descriptor pero es una cosa distinta de una imagen pictórica, si la decodificas esperando una foto, te vas a llevar una sorpresa

Cuando la extracción no alcanza, el flujo bruto sigue ahí mismo en el grafo de objetos cargado, con su filtro y todo, y puedes extraerlo byte por byte y entregarlo a un códec especializado propio. Ese es el respaldo que el diseño de paso directo conserva a propósito: los bytes originales nunca se desechan, así que el peor caso es que los decodifiques tú mismo en vez de perder los datos. Para la mayoría de los trabajos reales, sin embargo, los ocho filtros admitidos cubren lo que realmente emiten los escáneres, las suites de oficina y los motores de reportes, y un bucle sobre GetLoadedImageCount con un guardia Decodable convierte un PDF cargado en una carpeta de mapas de bits en unas pocas líneas

La API de extracción de imágenes cargadas, junto con el conjunto completo de filtros de decodificación descritos aquí, viene en HotPDF Component para Delphi y C++Builder