Artículo técnico

Paletas PDF Indexed en Delphi: bases y ciclos

Una lookup table /Indexed guarda componentes de su color space base, no triples RGB. Una base DeviceGray empaqueta un byte por entrada, DeviceCMYK cuatro, Lab tres que necesitan escalado de rango, e ICCBased los que declare el /N del perfil. HotPDF normaliza todos en tiempo de parseo a la paleta RGB pública que expone THPDFColorSpace

Esa oración es toda la clase de bugs. Casi todo renderizador de paletas empieza asumiendo un stride de tres bytes, porque la abrumadora mayoría de los espacios /Indexed del mundo real viven sobre /DeviceRGB y la suposición aguanta años. Después llega una factura escaneada con [/Indexed /DeviceCMYK 15 <...>], el lector recorre la string de lookup de a tres bytes, y cada índice pasado el cero cae en medio del color de otro. La salida no queda en blanco y no crashea. Es basura que parece plausible, que es mucho peor, porque nadie reporta un bug contra colores que solo se ven raros

¿Por qué una paleta Indexed sale mal?

El stride está mal porque el stride no es una constante. ISO 32000-1 §8.6.6.3 define la lookup table como m × (hival + 1) bytes, donde m es el número de componentes del espacio base, y no dice nada de que esa base sea RGB. La misma sección prohíbe solo dos bases de plano: /Indexed en sí y /Pattern. Todo lo demás vale, incluidas las cuatro familias CIE-based de §8.6.5.2 a §8.6.5.5

Diagrama de una lookup table Indexed de PDF en HotPDF donde el stride de entrada viene del color space base: un lookup DeviceCMYK de 8 bytes cortado por un recorrido RGB equivocado de 3 bytes produce entradas basura, el agrupado correcto de 4 bytes da dos entradas CMYK normalizadas en triples RGB, y una tabla lista los componentes por entrada para DeviceGray, DeviceRGB, DeviceCMYK, Lab e ICCBased
HotPDF resuelve primero el color space base y le pregunta la cantidad de componentes, así que un lookup CMYK recorre cuatro bytes por entrada antes de normalizar a RGB

Así que el trabajo real no es leer bytes, es resolver la base primero y preguntarle qué tan ancha es una entrada. ResolveIndexedPalette en HotPDF hace exactamente eso: resuelve la base en un THPDFColorSpace, toma ComponentCount del record resuelto, y rechaza el arreglo de plano si la base volvió como csfIndexed, csfPattern o csfUnsupported. Recién entonces dimensiona el presupuesto de bytes como PaletteCount * Count, que para el máximo legal de hival 255 y una base de cuatro componentes llega como tope a 1024 bytes. Dos detalles en ese dimensionado son donde un archivo hostil te va a sondear. HotPDF clamp hival en 0..255 antes de usarlo, según el límite de §8.6.6.3, así que una lookup que declare hival 100000 no se convierte en una asignación de un gigabyte; y trunca el presupuesto a lo que los datos del lookup realmente contienen, dejando de llenar la paleta en la primera entrada cuyos bytes se pasan del final. Una tabla corta da una paleta corta, no una lectura más allá del buffer

Normalizar cuatro bases distintas en una tabla RGB

HotPDF convierte las paletas a RGB durante el parseo y no al dibujar, y esa decisión maneja el resto del diseño. El campo público THPDFColorSpace.Palette es un TBytes de triples RGB con longitud 3 * PaletteCount, y tiene esa forma desde mucho antes de que se soportaran bases CIE-based. Reescribir el layout del record para llevar componentes nativos de la base habría roto a todo consumidor del campo. Convertir en tiempo de parseo mantiene el layout intacto y, de bonus, enruta las entradas Gray, CMYK, Lab, CalGray, CalRGB e ICCBased por el mismo camino HPDFResolveColor que el renderer ya usa para los operadores de color directos

El paso de escalado es donde las bases dejan de ser intercambiables. Un byte de lookup siempre es 0..255, pero lo que ese byte significa depende del rango de componentes que declare la base. Los componentes Device y calibrados se mapean a 0..1. Lab no: el componente L* corre 0..100, mientras que a* y b* corren sobre el /Range que declara el espacio. HotPDF saca esos límites de los campos AMin, AMax, BMin y BMax de THPDFCIEParams, que el parser de Lab llena desde la entrada /Range del diccionario y define como −108..101 cuando la entrada falta, así que no hardcodees un rango simétrico de memoria. Las bases ICCBased agregan una tercera fuente de verdad: la cantidad de componentes viene del /N del stream del perfil, expuesto como ICCN, y un stream ICCBased puede además llevar un arreglo /Range que HotPDF lee con HPDFReadNumericArrayUpTo hacia la misma tabla de límites por componente. Una paleta ICC de tres componentes y una paleta Lab de tres componentes recorren entonces la string de lookup con strides idénticos y aritmética completamente distinta, que es exactamente el resultado que un stride RGB fijo no puede producir

var
  CS: THPDFColorSpace;
  Entry: Integer;
  R, G, B: Byte;
begin
  // ColorSpaceObj es la entrada /ColorSpace de un image XObject o de un
  // diccionario de recursos; puede ser un nombre, un arreglo o una ref indirecta
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if CS.Family <> csfIndexed then
    Exit;

  // Palette ya es RGB sin importar cuál haya sido el espacio base
  for Entry := 0 to CS.PaletteCount - 1 do
  begin
    if (Entry * 3 + 2) >= Length(CS.Palette) then
      Break;                       // lookup table corta, detente limpio
    R := CS.Palette[Entry * 3];
    G := CS.Palette[Entry * 3 + 1];
    B := CS.Palette[Entry * 3 + 2];
    UseColor(Entry, R, G, B);
  end;
end;

Cuando la lookup table vive en un stream

ISO 32000-1 §8.6.6.3 permite que la lookup table sea una string o un stream, y la forma de stream es la que muerde. Un stream puede llevar /FlateDecode, puede ser un objeto indirecto compartido con partes no relacionadas del archivo, y su TStream puede estar posicionado en cualquier lado en el momento en que tu parser llega a él. HotPDF maneja el caso comprimido delegando al callback de bytes decodificados registrado con HPDFSetColorStreamReader, el mismo callback que usa el motor de sampled functions Type 0

Cuando no hay reader registrado, el fallback es deliberadamente angosto y no best-effort. HotPDF revisa el diccionario del stream por /Filter y rechaza la paleta entera si hay uno, en lugar de meter bytes comprimidos al lookup y producir una tabla con seguridad equivocada. Sin filtro lee bytes crudos, acotados al presupuesto de bytes calculado de hival y la cantidad de componentes de la base. Toda la rama del stream vive dentro de un try..finally que restaura el Stream.Position original en cada camino de salida, incluidos los rechazos, porque el objeto es compartido y el siguiente consumidor no te va a agradecer un cursor movido. LookupStreamUsesDecodedReaderAndRestoresPosition en la suite de regresión fija las dos mitades de ese comportamiento

Diagrama de decisión de una lookup table Indexed de HotPDF guardada como stream de PDF: un callback HPDFSetColorStreamReader registrado decodifica los bytes filtrados, un stream sin registrar con entrada /Filter se rechaza de plano, y un stream sin filtro se lee crudo hasta el presupuesto de bytes mientras try..finally restaura Stream.Position en cada salida
Rechazar un stream filtrado es mejor que meter bytes comprimidos al lookup, y restaurar la posición original del stream mantiene el objeto compartido a salvo para el siguiente consumidor
// Registra ambos callbacks antes de resolver cualquier color space, si no,
// las bases indirectas y los lookup streams comprimidos se van a rechazar
HPDFSetColorSpaceResolver(MyResolveIndirectObject);
HPDFSetColorStreamReader(MyDecodedStreamReader);
try
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if (CS.Family = csfIndexed) and (Length(CS.Palette) = 0) then
    LogWarning('lookup table unreadable: filtered stream or no reader');
finally
  HPDFSetColorStreamReader(nil);
  HPDFSetColorSpaceResolver(nil);
end;

¿Qué impide que un color space Indexed recurse para siempre?

Rechazar /Indexed como base directa es necesario y para nada suficiente. El ataque interesante va de costado. Toma un [/Indexed /Separation ...] válido y recuerda que §8.6.6.4 deja que un Separation nombre un espacio alterno y §8.6.6.5 hace lo mismo con DeviceN. Haz que ese alterno sea una referencia indirecta que apunte de vuelta al arreglo Indexed original. Nada en la cadena viola la regla de base directa en ningún hop individual: la base es un Separation, el alterno del Separation es un arreglo, y el arreglo solo por casualidad es el del que empezaste. Resuélvelo naíve y el parser camina el loop hasta que el stack se acaba. La maquinaria de tint transform de la que dependen esos espacios es tema aparte, cubierta en el artículo sobre renderizar colores spot Separation y DeviceN; acá importan solo como eslabón del ciclo

HotPDF cierra esto en el único punto de entrada por el que pasan todos los caminos. HPDFResolveColorSpace envuelve el resolver interno en un contador de profundidad acotado por HPDFColorSpaceMaxDepth, que es 64. Cruza ese límite y la guarda pone un flag, y el flag es lo que importa: al desenrollar, cualquier nivel de anidación que lo haya visto puesto descarta su propio record parcialmente resuelto y devuelve csfUnsupported, así que todo el grafo se rechaza en lugar de retener la mitad, y ambos contadores se resetean cuando vuelve la llamada más externa. Esos contadores están declarados como threadvar, no como globales de unit, y eso no es prolijidad incidental: GResolveObj y GReadStream viven en almacenamiento local de thread por la misma razón, porque las páginas se renderizan en paralelo. Un global compartido dejaría que un thread metido en una cadena DeviceN anidada dispare el límite por otro thread que parsea un [/Indexed /DeviceRGB 255 <...>] perfectamente ordinario, produciendo un fallo no determinista que depende de la carga — la clase menos debuggable que hay. Sesenta y cuatro es margen cómodo para contenido legítimo, que rara vez anida más de cuatro o cinco niveles, así que trata un csfUnsupported de un arreglo de aspecto estructuralmente válido como digno de loguear: en la práctica significa un ciclo o una profundidad que ningún productor real emite. IndirectAlternateCycleIsUnsupported, parametrizado sobre Separation y DeviceN, y ExcessivePatternDepthIsUnsupported cubren ambos casos

Diagrama de ciclo y guarda para color spaces de HotPDF: un arreglo Indexed con base Separation cuyo alterno es una referencia indirecta de vuelta al mismo arreglo, resuelto por HPDFResolveColorSpace con un contador de profundidad limitado por HPDFColorSpaceMaxDepth en 64, tras lo cual cada nivel de unwind descarta su record y devuelve csfUnsupported
Nada en un hop individual rompe la regla de base directa, así que HotPDF acota la profundidad de resolución a 64 niveles y rechaza el grafo entero en lugar de retener la mitad
function TryResolveImageSpace(Obj: THPDFObject;
  out CS: THPDFColorSpace): Boolean;
begin
  CS := HPDFResolveColorSpace(Obj);
  // csfUnsupported acá cubre una familia genuinamente desconocida, un ciclo
  // por un alterno indirecto, y profundidad más allá de 64 niveles
  Result := CS.Family <> csfUnsupported;
  if not Result then
    CS := HPDFDefaultColorSpace;   // DeviceGray, el espacio inicial de PDF
end;

Un límite de API que vale la pena documentar para tus callers

HotPDF aplica la configuración de conversión ICC vigente en tiempo de parseo cuando construye una paleta Indexed, y las llamadas posteriores a HPDFSetColorSpaceRenderingIntent no regeneran una paleta que ya está resuelta. Esa función recorre las transformaciones ICCBased y alternas del record que le pasas y reconstruye esos pipelines; los bytes RGB aplanados en Palette son un artefacto del tiempo de parseo y se quedan como estaban. Es una consecuencia de normalizar temprano, no un descuido, y vale la pena escribirlo en tu propia documentación de API en lugar de dejar que un cliente lo descubra. Si tu aplicación deja que el usuario cambie el rendering intent a mitad de sesión, vuelve a resolver los color spaces después del cambio; si lo configura una sola vez durante el setup del pipeline, que es el caso abrumadoramente común, no hay nada que hacer. El mismo orden aplica a los perfiles de prueba instalados con HPDFSetICCColorPipeline: configúralos antes de empezar a resolver, no después

El bug espejo del lado de escritura

Todo lo anterior es sobre lectura. El defecto simétrico del lado de escritura es más fácil de publicar y mucho más fácil de pasar por alto. Cuando HotPDF construye un arreglo /Indexed desde un bitmap de 8 bits de Windows llama a GetPaletteEntries, y esa API llena solo las entradas que la paleta realmente tiene y devuelve esa cantidad. El código original probaba el resultado por = 0 y después iteraba 256 entradas fijas sobre un arreglo de stack. Para un bitmap de 16 colores eso escribía 240 entradas de memoria de stack sin inicializar directo en la string de lookup de un PDF que después le entregas a un cliente, con hival hardcodeado en 255 para que todo lector tratara obedientemente el residuo como datos de color reales. Es un bug de corrección y un bug de divulgación de información vestidos con la misma ropa. El fix es poco glamoroso y completo: vacía el arreglo antes de la llamada y honra la cantidad devuelta tanto para el límite del loop como para hival. Si generas imágenes /Indexed en algún lugar de tu propio código, ve a revisar ese hival ahora

Dos piezas adyacentes completan el cuadro: las paletas Indexed y las sampled functions Type 0 comparten el reader de stream decodificado y la misma disciplina de clamping, discutidas en la pieza sobre lookup tables de color de funciones Type 0, y las reglas de filtro que gobiernan los lookup streams son las mismas que gobiernan los datos de imagen en extraer imágenes cargadas a través de decode filters. El resolver de color spaces que se describe acá viaja como parte del HotPDF Delphi Component para Delphi y C++Builder, con la referencia completa de THPDFColorSpace en la página del producto