Artículo técnico

Reescribir flujos de contenido PDF de forma segura en Delphi

HotPDF recorre cada operador del flujo de contenido de una página y entrega cada uno a tu código mediante VisitLoadedPageContentOperators. Para cada operador eliges una acción: conservarlo, eliminarlo, sustituirlo por tus propios bytes, detener el recorrido, o rechazar toda la edición. Cuando se conservan todos los operadores, el flujo reescrito es idéntico byte a byte al original, propiedad que hace seguro ejecutar el mecanismo sobre documentos que no creaste tú

La edición de flujos de contenido es donde las herramientas de PDF suelen fallar. Un flujo es una secuencia de operadores sobre una máquina de pila, no un árbol de documento, y el enfoque ingenuo de aplicar una expresión regular sobre los bytes decodificados se rompe en cuanto un literal de cadena contiene algo con aspecto de operador, o una imagen en línea contiene binario arbitrario. Un visitor que ya ha tokenizado el flujo no tiene ese problema

Operadores de flujo de contenido PDF como BT, Tf y TJ que fluyen hacia los datos de evento del visitor con fase, nombre de operador, número de operandos, rango de bytes y estado de anidamiento, para devolver luego una de cinco acciones: conservar, sustituir, eliminar, detener o rechazar
Cada operador visitado produce exactamente una decisión, y una pasada que conserva todo vuelve a escribir los rangos sin tocar para lograr una salida idéntica byte a byte

¿Qué puedes hacer realmente con acceso a nivel de operador?

Tres familias de tareas. Auditoría: contar cuántas veces una página establece un color fuera de un conjunto conocido, o localizar texto dibujado con una fuente que no está incrustada. Eliminación: borrar una marca de agua dibujada como contenido marcado, o extraer una invocación de imagen concreta. Y normalización: sustituir una forma de operador por un equivalente que un consumidor posterior maneje mejor

Las tres comparten un mismo requisito: debes poder dejar intacto el otro 99 % del flujo. El árbol de sintaxis de HotPDF almacena una única copia de la fuente más rangos compactos (spans), y las ediciones son overlays ordenados y no solapados. Al escribir el resultado se copian directamente los rangos sin tocar y solo se emiten bytes de sustitución donde cambiaste algo, de modo que un round trip sin cambios no puede introducir desviaciones

El contrato del visitor

Implementa la interfaz o asigna el evento, y luego llama al método de visita con ApplyChanges en false para una auditoría o en true para una reescritura. Los datos del evento llevan la fase, el nombre del operador, su número de operandos, su rango de bytes, el estado de anidamiento actual y una vista prestada para leer operandos:

uses
  HPDFDoc, HPDFContentStream;

procedure TAuditor.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
var
  Bytes: TBytes;
begin
  if EventData.Phase <> covpOperator then
    Exit;

  // Cuenta los operadores de mostrar texto dibujados dentro de contenido marcado
  if (EventData.OperatorName = 'Tj') or
     (EventData.OperatorName = 'TJ') then
  begin
    Inc(FTextOperators);
    if EventData.State.MarkedContentDepth > 0 then
      Inc(FTextInsideMarkedContent);
  end;

  // Lee un operando solo cuando lo necesites
  if EventData.OperatorName = 'Do' then
    if EventData.View.GetOperandBytes(0, Bytes) then
      NoteXObjectUse(TEncoding.ANSI.GetString(Bytes));

  EventData.Action := coaKeep;
end;

procedure TAuditor.Run(const FileName: string);
var
  Pdf: THotPDF;
  Stats: THPDFContentOperatorVisitStatistics;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) <= 0 then
      Exit;
    Pdf.OnContentOperator := HandleOperator;
    for I := 0 to Pdf.LoadedPageCount - 1 do
      Pdf.VisitLoadedPageContentOperators(I, False, Stats);   // solo auditoría
    Writeln(Format('%d operators visited, max q/Q depth %d',
      [Stats.VisitedOperatorCount, Stats.MaximumGraphicsStateDepth]));
  finally
    Pdf.Free;
  end;
end;

¿Por qué la vista es prestada y no mutable?

El callback nunca recibe un árbol de sintaxis modificable. Recibe una vista que puede obtener los bytes de la operación actual o un operando concreto bajo demanda, y nada más. Cada cambio pasa en su lugar por los campos de acción y de sustitución

Esa indirección no es mero formalismo. Mutar el árbol directamente permitiría a un visitor saltarse el presupuesto de sustitución y la comprobación de ediciones solapadas, y las ediciones solapadas en un modelo de overlays por rango de bytes producen una salida silenciosamente incorrecta en lugar de romperse de forma evidente. Enrutar los cambios a través del campo de acción hace que cada edición se valide de la misma forma, sin importar qué pretendiera el visitor

Leer bajo demanda también mantiene barato el caso común. Una auditoría que solo cuenta nombres de operadores nunca llega a materializar los bytes de los operandos, lo cual importa en un flujo de contenido con cientos de miles de operaciones

El campo de estado describe siempre la entrada

Cada evento lleva la profundidad del estado gráfico, la profundidad del objeto de texto, la profundidad del contenido marcado y la profundidad de la sección de compatibilidad tal como estaban en la entrada original justo antes de que se ejecutara ese operador. Eso es cierto incluso si tu visitor eliminó o sustituyó operadores anteriores

Anclar el estado a la entrada original, y no a la salida editada, es una decisión deliberada con una ventaja práctica: ejecutar la misma auditoría dos veces sobre el mismo documento produce resultados idénticos, y una auditoría que se ejecuta junto con ediciones no desplaza su propio marco de referencia. Si necesitas conocer la profundidad en la salida, sabes qué eliminaste, así que puedes calcularla; lo contrario, reconstruir posiciones de entrada estables a partir de una salida cambiante, no es posible

Eliminar y sustituir, con las reglas que te protegen

Una sustitución son bytes de flujo de contenido en bruto, y tú eres responsable de su sintaxis. Las salvaguardas que la rodean son el presupuesto sobre el total de bytes de sustitución, el requisito de no solapamiento, y la validación opcional de salida que vuelve a analizar el resultado antes de aceptarlo:

Salvaguardas de reescritura en Delphi: un visitor que declara la intención de Action y Replacement, enrutada a través del presupuesto de sustitución, el rango de bytes sin solapamiento y las comprobaciones de validación de salida hacia la página guardada validada
Cada cambio se canaliza a través del campo de acción hacia las comprobaciones de presupuesto, solapamiento y nuevo análisis, de modo que una validación fallida aborta en lugar de escribir operadores rotos
procedure TWatermarkStripper.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
begin
  EventData.Action := coaKeep;
  if EventData.Phase <> covpOperator then
    Exit;

  // Descarta los operadores que pintan el formulario de marca de agua
  if (EventData.OperatorName = 'Do') and OperandIsWatermark(EventData) then
    EventData.Action := coaDelete
  // Normaliza un color suelto al gris corporativo
  else if EventData.OperatorName = 'rg' then
  begin
    EventData.Replacement := TEncoding.ANSI.GetBytes('0.2 0.2 0.2 rg');
    EventData.Action := coaReplace;
  end
  // Rechaza toda la edición si encontramos algo que no entendemos
  else if EventData.OperatorName = 'BI' then
    EventData.Action := coaReject;
end;

var
  Options: THPDFContentOperatorVisitOptions;
begin
  Options := THPDFContentOperatorVisitOptions.Default;
  Options.ValidateOutput := True;        // vuelve a analizar antes de aceptar
  Options.MaxReplacementBytes := 64 * 1024;

  Pdf.OnContentOperator := HandleOperator;
  if Pdf.VisitLoadedPageContentOperators(PageIndex, True, Options, nil, Stats) then
    if Stats.Modified then
      Pdf.SaveLoadedDocument('cleaned.pdf');
end;

El conjunto de acciones incluye dos salidas por una razón. coaStop termina el recorrido y conserva lo decidido hasta ese momento, lo cual es correcto cuando ya has encontrado lo que buscabas. coaReject abandona toda la visita y deja la página intacta, lo cual es correcto cuando encuentras una construcción que tus reglas de reescritura no cubren. Las imágenes en línea son el ejemplo clásico: su carga útil binaria no es algo que un visitor de normalización de color deba intentar editar alrededor

Leer las estadísticas, y dónde se detiene el recorrido

El campo de estado distingue la finalización de la detención, el rechazo, la entrada no válida, un límite superado, la salida no válida y un error del visitor. Son problemas distintos: salida no válida significa que tus bytes de sustitución no se pudieron volver a analizar, mientras que entrada no válida significa que el flujo de origen ya estaba malformado y no se intentó ninguna edición

Los contadores hacen que una auditoría se autoverifique. VisitedOperatorCount comparado con KeptOperatorCount, DeletedOperatorCount y ReplacedOperatorCount debe cuadrar, y las cifras de profundidad máxima te dicen si la página se anida lo suficiente como para merecer una mirada más de cerca. SourceBytes comparado con OutputBytes te da en un solo número el efecto de tamaño de tu edición

Conciliación de estadísticas de auditoría donde los contadores de operadores conservados, eliminados y sustituidos suman el total visitado, junto a siete finales de estado desde completado hasta error del visitor, con una nota de que las profundidades de estado describen la entrada original para que las auditorías se repitan de forma idéntica
Unos contadores que deben cuadrar más un estado que nombra cada final posible hacen que una auditoría se autoverifique sobre documentos que no creaste tú

Para las tareas comunes existen rutas de nivel más alto que no requieren escribir un visitor en absoluto. Eliminar contenido de forma permanente, con la limpieza de recursos que exige una redacción real, se trata en redacción y unión de páginas en documentos cargados. Sustituir texto visible se trata en buscar y reemplazar texto en un PDF existente. Recurre al visitor de operadores cuando tu regla sea estructural en vez de textual, o cuando necesites una auditoría que ningún informe existente responda

El recorrido, la extracción y la reescritura de flujos de contenido operan sobre el mismo modelo de documento cargado en Delphi y C++Builder; la lista completa de funciones está en la página del componente HotPDF Delphi PDF