Artículo técnico

Tm identidad en streams de PDF: remoción peephole segura

PDF Library for Delphi elimina el operador de matriz de texto identidad, 1 0 0 1 0 0 Tm, durante su optimización peephole de content streams al momento de guardar, solo cuando la matriz de texto y la matriz de línea de texto ya son la identidad: justo después de BT, o justo después de un Tm identidad anterior. Un cm identidad se sigue eliminando siempre, porque cm multiplica la CTM mientras que Tm reemplaza ambas matrices de texto sin más. Desde la v3.539.28 cualquier otro Tm identidad se queda en el stream

El bug que esto arregla es del tipo silencioso. Un generador de reportes emite BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET, confiando en el Tm identidad para mandar el segundo string de vuelta al origen del espacio de texto antes de aplicar su propia lógica de posicionamiento. El optimizer viejo veía seis números que deletrean la matriz identidad, decidía que el operador no podía cambiar nada, y lo borraba. Nada fallaba, nada registraba warning, y la página guardada dibujaba «Total» inmediatamente después de «Invoice» en la misma línea base, que es exactamente la clase de defecto que nadie nota hasta que un cliente imprime el PDF

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

Un Tm identidad es un no-op solo cuando va a reemplazar dos matrices que ya guardan la identidad, y esa es una propiedad de los operadores que van antes, no de sus propios operandos. ISO 32000-1 §9.4.1 dice que BT inicializa tanto la matriz de texto (Tm) como la matriz de línea de texto (Tlm) a la identidad, y §9.4.2 define Tm como asignar ambas a los valores dados, no concatenar sobre ellas. Compare eso con cm (§8.4.4), que multiplica por la derecha la matriz de transformación actual: multiplicar por la identidad deja cualquier CTM sin cambios, así que 1 0 0 1 0 0 cm es seguro de borrar en cualquier lado. Dentro de un objeto de texto el cuadro es distinto. Td, TD, T* y un Tm no identidad mueven todos la Tlm, y cada operador que muestra texto (Tj, TJ, ', ") avanza la Tm según el ancho de los glyphs que pintó. Después de cualquiera de esos, un Tm identidad es un reset genuino al origen. Si alguna vez rastreó posiciones de texto a mano con el rastreador de estado CTM y matriz de texto de content streams, esta es la misma distinción entre concatenar estado y reemplazarlo

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

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

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices ahora camina hacia atrás desde cada Tm identidad y lo elimina solo si el escaneo llega primero a BT o a otro Tm identidad. El Tm identidad anterior cuenta tanto si se conserva como si él mismo acaba de ser marcado para eliminación, porque de cualquier manera dejó ambas matrices en la identidad, exactamente como hace BT. La regla mete cada operador que pueda encontrar en uno de dos grupos:

  • Detenerse 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
  • Pasar de largo y seguir escaneando: operadores que nunca tocan la Tm ni la Tlm, como Tf, Tc, los setters de color, gs, los operadores de marked-content y cm
RemoveIdentityMatrices de PDFlibPas camina hacia atrás desde cada Tm identidad: Tf, Tc, los setters de color, gs y cm se pasan de largo, 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 eliminarlo
Un Tm identidad anterior también detiene el escaneo, porque conservado o ya marcado para eliminación dejó ambas matrices en la identidad — de cualquier manera el optimizer nunca mueve un glyph

Los casos conservadores son deliberados. Un operador desconocido puede ser cualquier cosa, así que el escaneo se niega a razonar más allá de él. ET cierra el objeto de texto, así que un Tm después de él no tiene ningún BT que avale los valores de las matrices. El escaneo además trabaja de a un content stream por vez, lo cual importa para páginas cuyo /Contents es un array: una capa que arranca en el medio de un objeto de texto, sin BT propio, conserva su Tm identidad incluso cuando la capa anterior la habría vuelto redundante. Eso cuesta unos pocos bytes en archivos raros y jamás mueve un glyph. Si edita texto de página a nivel de instrucciones, como en el recorrido de mapeo de carácter a byte de contenido, el mismo modelo parseado TPDFContentProgram es lo que el optimizer reescribe

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 tranquilos
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // devuelve la cantidad de instrucciones eliminadas
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // una instrucción por línea
  finally
    Prog.Free;
  end;
end;

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

Corra el helper sobre el stream de la factura del inicio y el Tm identidad sobrevive, porque el escaneo hacia atrás encuentra el Tj antes de llegar al BT. Ponga /F1 12 Tf, 2 Tc y 0 g entre el BT y el Tm identidad y el operador aun así se elimina, porque ninguno de esos toca las matrices de texto. 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 resetea la matriz que el Td movió, y solo el segundo es redundante

¿Cuándo corre en realidad el optimizer peephole?

El optimizer corre solo 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 default, y el mismo switch está expuesto 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 logra parsearse, los bytes decodificados originales se comprimen sin cambios. TPDFlib.NormalizeContentStreams parsea y re-emite contenido con espaciado y números canónicos pero nunca llama al optimizer, lo cual lo vuelve un baseline útil cuando quiere ver cuánto aportan a la diferencia de tamaño las reglas peephole, junto a las ganancias mayores cubiertas en optimización de tamaño de archivos PDF con font subsetting

PDFlibPas corre el optimizer 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 optimizer
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 optimizer solo reescribe streams que decodificó primero
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 vía las save options incluidas; False cancela
    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 en realidad el viejo test de regresión?

El viejo test de regresión garantizaba una sola forma: un Tm identidad directamente después de BT se elimina. Peephole_RemovesIdentityTextMatrix le pasa BT 1 0 0 1 0 0 Tm (hello) Tj ET al optimizer y afirma que no queda ningún Tm. Un release anterior ya había notado que eliminar un Tm identidad es inseguro cuando la Tlm no es la identidad, y aun así conservó el comportamiento porque el test lo «blanqueaba». Leído con cuidado, el test no dice nada sobre un Tm identidad después de Td o después de texto mostrado; tomar la cobertura de una muestra como el contrato de toda la regla fue el error real. El fix mantiene ese caso original en verde y agrega seis casos que clavan tanto las formas eliminables como las conservadas, incluido un Tm fuera de todo objeto de texto y uno que viene después de ET

El trade-off es fácil de aceptar una vez que está 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 salió casi todo el ahorro. Lo que el optimizer cede es el Tm identidad ocasional en el medio de un objeto de texto, un puñado de bytes por página antes de que Flate siquiera los vea, a cambio de una garantía que el header del módulo enuncia sin rodeos: toda transformación es output-equivalente y jamás cambia la página visible. Un optimizer de tamaño que mueve texto no es un optimizer, es un bug de renderizado con buenos ratios de compresión

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