Artículo técnico

Tm identidad en streams PDF: eliminación peephole segura

PDF Library for Delphi elimina un operador de text matrix identidad, 1 0 0 1 0 0 Tm, durante su optimización peephole de content streams al guardar solo cuando la text matrix y la text line matrix ya son la identidad: justo después de BT, o justo después de un Tm identidad anterior. Un cm identidad se sigue descartando siempre, porque cm multiplica la CTM mientras que Tm sustituye ambas text matrices de plano. Desde v3.539.28, cualquier otro Tm identidad se queda en el stream

El bug que esto corrige es de los silenciosos. Un generador de informes emite BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET, confiando en el Tm identidad para devolver la segunda cadena al origen del text space antes de aplicar su propia lógica de posicionamiento. El optimizador antiguo veía seis números que deletreaban la matriz identidad, decidía que el operador no podía cambiar nada, y lo borraba. Nada falló, nada registró una advertencia, y la página guardada dibujaba «Total» inmediatamente después de «Invoice» sobre la misma línea base, que es justo la clase de defecto que nadie nota hasta que un cliente imprime el PDF

¿Por qué 1 0 0 1 0 0 Tm no es siempre un no-op?

Un Tm identidad solo es un no-op cuando sustituiría dos matrices que ya contienen la identidad, y eso es una propiedad de los operadores que lo preceden, no de sus propios operandos. ISO 32000-1 §9.4.1 dice que BT inicializa tanto la text matrix (Tm) como la text line matrix (Tlm) a la identidad, y §9.4.2 define Tm como asignarles a ambas los valores dados, no concatenar sobre ellas. Compare eso con cm (§8.4.4), que multiplica por la derecha la current transformation matrix: multiplicar por la identidad deja cualquier CTM intacta, así que 1 0 0 1 0 0 cm se puede borrar en cualquier sitio. Dentro de un objeto de texto el panorama cambia. Td, TD, T* y un Tm no identidad mueven todos la Tlm, y cada operador que muestra texto (Tj, TJ, ', ") avanza la Tm el ancho de los glifos que pintó. Después de cualquiera de esos, un Tm identidad es un reset de verdad al origen. Si alguna vez ha trazado posiciones de texto a mano con el rastreador de estado de CTM y text matrix del content stream, es la misma distinción entre concatenar estado y sustituirlo

PDFlibPas trata de forma distinta 1 0 0 1 0 0 cm y 1 0 0 1 0 0 Tm: cm multiplica por la derecha la CTM y es un no-op en cualquier sitio, mientras que Tm sustituye de plano Tm y Tlm, y cada Tj avanza la Tm el ancho que pintó, así que un Tm identidad tras texto mostrado es un reset genuino
El generador de informes contaba con ese reset: borrar el Tm identidad pintaba Total inmediatamente después de Invoice sobre la misma línea base, y nada falló, registró ni avisó de camino a la impresora del cliente

Cómo decide el escaneo hacia atrás qué Tm identidad descartar

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices camina ahora hacia atrás desde cada Tm identidad y solo lo descarta si el escaneo llega antes a BT o a otro Tm identidad. El Tm identidad anterior cuenta tanto si se conserva como si acaba de quedar programado para borrarse, porque en ambos casos dejó ambas matrices en la identidad, igual que hace BT. La regla reparte cada operador que puede encontrarse en uno de dos grupos:

  • Parar y conservar el Tm: Td, TD, T*, un Tm no identidad, Tj, TJ, ', ", ET, cualquier operador que el parser no reconozca, o el inicio del stream
  • Saltarse y seguir escaneando: operadores que jamás tocan Tm ni Tlm, como Tf, Tc, los que fijan color, gs, los operadores de marked-content y cm
RemoveIdentityMatrices de PDFlibPas camina hacia atrás desde cada Tm identidad: Tf, Tc, los que fijan color, gs y cm se saltan, mientras que Td, TD, T*, un Tm no identidad, Tj, TJ, un operador desconocido o ET detiene el escaneo y conserva el Tm, y BT avala descartarlo
Un Tm identidad anterior también detiene el escaneo, porque conservado o ya programado para borrarse dejó ambas matrices en la identidad — pase lo que pase, el optimizador nunca mueve un glifo

Los casos conservadores son deliberados. Un operador desconocido puede ser cualquier cosa, así que el escaneo se niega a razonar más allá. ET cierra el objeto de texto, así que un Tm posterior no tiene ningún BT que avale los valores de la matriz. El escaneo además trabaja de una en una los content streams, lo que importa en páginas cuyo /Contents es un array: una capa que empieza en mitad de un objeto de texto, sin BT propio, conserva su Tm identidad aunque la capa anterior la habría vuelto redundante. Cuesta unos bytes en archivos raros y nunca mueve un glifo. Si edita texto de página al nivel de instrucción, como en el recorrido de mapeo carácter a byte de contenido, el mismo modelo TPDFContentProgram parseado es lo que reescribe el optimizador

uses
  PDFlibContentModel, PDFlibContentOptimize;

function OptimizeSnippet(const Source: AnsiString): AnsiString;
var
  Prog: TPDFContentProgram;
  Optimizer: TPDFContentPeepholeOptimizer;
begin
  Result := Source;
  Prog := TPDFContentProgram.Create;
  try
    if not Prog.Parse(Source) then
      Exit; // stream dañado: dejar los bytes en paz
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // devuelve el número de instrucciones eliminadas
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // una instrucción por línea
  finally
    Prog.Free;
  end;
end;

// Eliminado: Tm justo después de BT, el segundo de dos Tm identidades seguidos
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// Conservado: Tm tras Td, tras Tj, tras un Tm no identidad, o fuera de BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Ejecute el helper sobre el stream de la factura del principio y el Tm identidad sobrevive, porque el escaneo hacia atrás se topa con Tj antes de llegar a BT. Ponga /F1 12 Tf, 2 Tc y 0 g entre BT y el Tm identidad y todavía se va, porque ninguno de esos toca las text matrices. Una secuencia como BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm pierde exactamente un operador: el primer Tm identidad reinicia la matriz que Td movió, y solo el segundo es redundante

¿Cuándo se ejecuta de verdad el optimizador peephole?

El optimizador solo corre durante el pase de compresión, dentro de TPDFPageTree.Compress, y solo sobre content streams que aún no están comprimidos con Flate. TPDFlib.SetOptimizeContentStreams(1) es el valor por defecto, y el mismo interruptor se expone como el campo OptimizeContentStreams de TPDFlibSaveOptions; tanto CompressContent como CompressPage lo respetan. Un stream cuyo /Filter ya es /FlateDecode se salta por completo, así que cargar un PDF comprimido existente y volverlo a guardar no reescribe sus operadores. Si el stream no se puede parsear, los bytes decodificados originales se comprimen sin cambios. TPDFlib.NormalizeContentStreams parsea y vuelve a emitir contenido con espaciado y números canónicos pero nunca llama al optimizador, lo que lo convierte en una línea base útil cuando quiere ver cuánta diferencia de tamaño aportan las reglas peephole, junto a las ganancias mayores cubiertas en la optimización de tamaño de archivos PDF con subsetting de fuentes

PDFlibPas ejecuta el optimizador peephole solo dentro del pase de compresión al guardar: TPDFPageTree.Compress respeta SetOptimizeContentStreams, un stream ya filtrado con /FlateDecode se salta por completo, un stream no parseable se comprime con sus bytes originales sin cambios, y NormalizeContentStreams nunca llama al optimizador
Los streams comprimidos que se saltan son la parte silenciosa: cargue un PDF existente, guárdelo otra vez, y sus operadores salen intactos porque el optimizador solo reescribe streams que decodificó antes
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Los streams sin comprimir pasan por las reglas peephole, luego Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // La misma elección por las save options incluidas; False la desactiva
    Options.CompressContent := True;
    Options.CompressFonts := True;
    Options.CompressImages := True;
    Options.Linearize := False;
    Options.KeepModDate := False;
    Options.OptimizeContentStreams := False;
    Options.GarbageCollect := False;
    Options.PackObjectStreams := True;
    Lib.SaveToFileOptions('report-plain.pdf', Options);
  finally
    Lib.Free;
  end;
end;

¿Qué garantizaba de verdad el antiguo test de regresión?

El antiguo test de regresión garantizaba una sola forma: un Tm identidad justo después de BT se elimina. Peephole_RemovesIdentityTextMatrix alimenta al optimizador con BT 1 0 0 1 0 0 Tm (hello) Tj ET y afirma que no queda ningún Tm. Una versión anterior ya había señalado que descartar un Tm identidad no es seguro cuando la Tlm no es la identidad, y aun así mantuvo el comportamiento porque el test lo «bloqueaba». Leído con cuidado, el test no dice nada sobre un Tm identidad tras Td o tras texto mostrado; tomar la cobertura de una muestra como el contrato de toda la regla era el error real. El fix mantiene ese caso original en verde y añade seis casos que fijan tanto las formas eliminables como las conservadas, incluido un Tm fuera de todo objeto de texto y otro que va detrás de ET

El trade-off es fácil de aceptar una vez puesto por escrito. Los generadores que envuelven cada objeto de texto como BT 1 0 0 1 0 0 Tm ... siguen viendo cómo se elimina ese operador redundante, que es de donde venía casi todo el ahorro. Lo que el optimizador cede es el Tm identidad ocasional en mitad de un objeto de texto, unos cuantos bytes por página antes de que Flate los vea siquiera, a cambio de una garantía que la cabecera del módulo declara sin rodeos: cada transformación es output-equivalente y nunca cambia la página visible. Un optimizador de tamaño que mueve texto no es un optimizador, es un bug de renderizado con buenas tasas de compresión

El parser de content streams, el optimizador peephole y las opciones de compresión al guardar descritas aquí vienen todos en PDF Library for Delphi y C++Builder, que también expone NormalizeContentStreams, CompressContent y TPDFlibSaveOptions para afinar cómo se escribe cada documento