Artículo técnico

Paletas Indexed de PDF en Delphi: espacios base y ciclos

Una tabla lookup /Indexed guarda componentes de su espacio de color base, no triplas RGB. Una base DeviceGray empaqueta un byte por entrada, DeviceCMYK cuatro, Lab tres que necesitan escalado de rango, e ICCBased lo que declare el /N del perfil. HotPDF normaliza todas ellas en tiempo de parse a la paleta RGB pública que expone THPDFColorSpace

Esa frase es toda la clase de bug. Casi todo renderizador de paletas empieza asumiendo un stride de tres bytes, porque la inmensa mayoría de los espacios /Indexed del mundo real se sientan sobre /DeviceRGB y la asunción aguanta años. Luego llega una factura escaneada con [/Indexed /DeviceCMYK 15 <...>], el lector recorre la cadena lookup de tres en tres bytes, y cada índice mayor que cero cae en medio del color de otro. La salida no es en blanco y no crashea. Es basura de aspecto plausible, que es mucho peor, porque nadie abre un bug contra colores que simplemente se ven raros

¿Por qué una paleta Indexed sale mal?

El stride está mal porque el stride no es una constante. La ISO 32000-1 §8.6.6.3 define la tabla lookup 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 de plano solo dos bases: el propio /Indexed y /Pattern. Todo lo demás es juego, incluidas las cuatro familias basadas en CIE de las secciones §8.6.5.2 a §8.6.5.5

Diagrama de una tabla lookup Indexed de PDF en HotPDF donde el stride de las entradas viene del espacio de color base: una lookup DeviceCMYK de 8 bytes cortada por un recorrido RGB de 3 bytes equivocado produce entradas basura, la agrupación correcta de 4 bytes da dos entradas CMYK normalizadas a triplas RGB, y una tabla lista los componentes por entrada para DeviceGray, DeviceRGB, DeviceCMYK, Lab e ICCBased
HotPDF resuelve primero el espacio de color base y le pregunta el número de componentes, así que una 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 cuánto mide una entrada. ResolveIndexedPalette en HotPDF hace exactamente eso: resuelve la base en un THPDFColorSpace, toma ComponentCount del registro resuelto y rechaza el array de plano si la base volvió como csfIndexed, csfPattern o csfUnsupported. Solo 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 a 1024 bytes. Dos detalles de ese dimensionado son donde un archivo hostil va a sondear-te. HotPDF recorta hival a 0..255 antes de usarlo, según el límite de la §8.6.6.3, así que un array lookup que reclame hival 100000 no se convierte en una asignación de un gigabyte; y trunca el presupuesto a lo que contenga de verdad la data de lookup, dejando de rellenar la paleta en la primera entrada cuyos bytes se pasen del final. Una tabla corta produce una paleta corta, no una lectura más allá del buffer

Normalizar cuatro espacios base distintos en una sola tabla RGB

HotPDF convierte las paletas a RGB durante el parse en lugar de en tiempo de dibujo, y esa decisión impulsa el resto del diseño. El campo público THPDFColorSpace.Palette es un TBytes de triplas RGB con longitud 3 * PaletteCount, y lleva con esa forma desde mucho antes de que se soportaran las bases basadas en CIE. Reescribir el layout del registro para llevar los componentes nativos de la base habría roto a todos los consumidores del campo. Convertir en tiempo de parse mantiene el layout intacto y, de propina, enruta las entradas Gray, CMYK, Lab, CalGray, CalRGB e ICCBased por el mismo camino HPDFResolveColor que el renderizador ya usa para los operadores de color directos

El paso de escalado es donde los espacios base dejan de ser intercambiables. Un byte lookup es siempre 0..255, pero lo que ese byte significa depende del rango de componentes que declare la base. Los componentes de dispositivo y calibrados se mapean a 0..1. Lab no: el componente L* corre de 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 rellena desde la entrada /Range del diccionario y por defecto a −108..101 cuando la entrada falta, así que no hardcodees un rango simétrico de memoria. Las bases ICCBased añaden una tercera fuente de verdad: el número de componentes viene del /N del stream del perfil, expuesto como ICCN, y un stream ICCBased puede llevar él mismo un array /Range que HotPDF lee con HPDFReadNumericArrayUpTo en la misma tabla de límites por componente. Una paleta ICC de tres componentes y una paleta Lab de tres componentes recorren por tanto la cadena 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 XObject de imagen o de un
  // diccionario de recursos; puede ser un nombre, un array o una ref indirecta
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if CS.Family <> csfIndexed then
    Exit;

  // Palette ya es RGB sea cual fuera el espacio base
  for Entry := 0 to CS.PaletteCount - 1 do
  begin
    if (Entry * 3 + 2) >= Length(CS.Palette) then
      Break;                       // tabla lookup corta, detente limpiamente
    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 tabla lookup vive en un stream

La ISO 32000-1 §8.6.6.3 permite que la tabla lookup sea una cadena 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 del archivo sin relación, y su TStream puede estar posicionado en cualquier sitio en el momento en que tu parser llega a él. HotPDF gestiona el caso comprimido delegando en el callback de bytes decodificados registrado con HPDFSetColorStreamReader, el mismo callback que usa el motor de funciones muestreadas Type 0

Cuando no hay lector registrado, el fallback es deliberadamente estrecho en lugar de best-effort. HotPDF comprueba en el diccionario del stream la entrada /Filter y rechaza la paleta entera si hay una, en lugar de alimentar bytes comprimidos a la lookup y producir una tabla equivocada con seguridad. Sin filtro lee bytes crudos, topados por el presupuesto de bytes calculado de hival y el número de componentes de la base. Toda la rama del stream se sienta dentro de un try..finally que restaura el Stream.Position original en todos los caminos de salida, incluidos los rechazos, porque el objeto es compartido y el siguiente consumidor no te agradecerá un cursor movido. LookupStreamUsesDecodedReaderAndRestoresPosition en la suite de regresión fija ambas mitades de ese comportamiento

Diagrama de decisión para una tabla lookup Indexed de HotPDF guardada como stream de PDF: un callback registrado de HPDFSetColorStreamReader 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 gana a alimentar bytes comprimidos a la 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 espacio de color, si no
// las bases indirectas y los streams lookup comprimidos se rechazarán sin más
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 espacio de color Indexed recurse para siempre?

Rechazar /Indexed como base directa es necesario y ni de lejos suficiente. El ataque interesante va de lado. Toma un [/Indexed /Separation ...] válido, y recuerda que la §8.6.6.4 permite a una Separation nombrar un espacio alternativo y la §8.6.6.5 hace lo mismo con DeviceN. Haz que ese alternativo sea una referencia indirecta que apunta de vuelta al array Indexed original. Nada en la cadena viola la regla de base directa en ningún salto individual: la base es una Separation, el alternativo de la Separation es un array, y el array resulta ser el mismo del que partiste. Resuélvelo naïve y el parser recorre el bucle hasta que la pila se rinde. La maquinaria de tint transform en la que se apoyan esos espacios es tema aparte, cubierto en el artículo sobre renderizar colores de tinta directa Separation y DeviceN; aquí importan solo como eslabón del ciclo

HotPDF cierra esto en el único punto de entrada por el que pasa todo camino. HPDFResolveColorSpace envuelve el resolvedor interno en un contador de profundidad acotado por HPDFColorSpaceMaxDepth, que es 64. Cruza ese límite y el guardia pone un flag, y el flag es lo que importa: al desenrollar, cualquier nivel de anidamiento que lo viera puesto descarta su propio registro 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 unidad, y eso no es pulcritud incidental: GResolveObj y GReadStream viven en almacenamiento local de hilo por la misma razón, porque las páginas se renderizan en paralelo. Un global compartido dejaría que un hilo metido en una cadena DeviceN anidada dispare el límite para otro hilo que parsea un [/Indexed /DeviceRGB 255 <...>] perfectamente ordinario, produciendo un fallo no determinista y dependiente de la carga, el tipo menos depurable que hay. Sesenta y cuatro es un 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 array de aspecto estructuralmente válido como digno de log: 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 guardia para los espacios de color de HotPDF: un array Indexed con base Separation cuyo alternativo es una referencia indirecta de vuelta al mismo array, resuelto por HPDFResolveColorSpace con un contador de profundidad topado por HPDFColorSpaceMaxDepth en 64, tras lo cual cada nivel de desenrollado descarta su registro y devuelve csfUnsupported
Nada en un solo salto 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 aquí cubre una familia genuinamente desconocida, un ciclo
  // a través de un alternativo indirecto y una profundidad más allá de 64 niveles
  Result := CS.Family <> csfUnsupported;
  if not Result then
    CS := HPDFDefaultColorSpace;   // DeviceGray, el espacio inicial de PDF
end;

Una frontera de API que merece la pena documentar para tus callers

HotPDF aplica la configuración de conversión ICC vigente en tiempo de parse cuando construye una paleta Indexed, y las llamadas posteriores a HPDFSetColorSpaceRenderingIntent no regeneran una paleta ya resuelta. Esa función recorre las transformaciones ICCBased y alternativas del registro que le pases y reconstruye esas pipelines; los bytes RGB aplanados de Palette son un artefacto de parse 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 lo descubra un cliente. Si tu aplicación deja que un usuario cambie el rendering intent a mitad de sesión, resuelve los espacios de color de nuevo tras el cambio; si lo fija una vez durante el setup de la pipeline, que es el caso abrumadoramente común, no hay nada que hacer. El mismo orden se aplica a los perfiles de proofing instalados con HPDFSetICCColorPipeline: configúralos antes de empezar a resolver, no después

El bug espejo del lado de escritura

Todo lo anterior va de lectura. El defecto simétrico del lado de escritura es más fácil de publicar y mucho más fácil de no ver. Cuando HotPDF construye un array /Indexed desde un bitmap de 8 bits de Windows llama a GetPaletteEntries, y esa API rellena solo tantas entradas como la paleta tenga de verdad y devuelve ese número. El código original probaba el resultado contra = 0 y luego iteraba 256 entradas fijas sobre un array de pila. Para un bitmap de 16 colores eso escribía 240 entradas de memoria de pila sin inicializar directamente en la cadena lookup de un PDF que luego le entregas a un cliente, con hival hardcodeado a 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 vistiendo la misma ropa. El fix es poco glamuroso y completo: pon a cero el array antes de la llamada, y luego honra el contador devuelto tanto para el límite del bucle como para hival. Si generas imágenes /Indexed en cualquier parte de tu propio código, ve a comprobar ese hival ahora

Dos piezas adyacentes redondean el cuadro: las paletas Indexed y las funciones muestreadas Type 0 comparten el lector de streams decodificados y la misma disciplina de recorte, discutido en la pieza sobre tablas lookup de color de la función Type 0, y las reglas de filtro que gobiernan los streams lookup son las mismas que gobiernan los datos de imagen en extraer imágenes cargadas a través de filtros de decode. El resolvedor de espacios de color descrito aquí viaja como parte del HotPDF Delphi Component para Delphi y C++Builder, con la referencia completa de THPDFColorSpace en la página de producto