Artículo técnico

Placeholders de plantilla PDF/VCR-1 en Delphi con PDFlibPas

PDFlibPas implementa PDF/VCR-1 como un perfil de estructura etiquetada: la definición de reemplazo vive bajo StructTreeRoot como rama hija directa, donde una raíz de plantilla lleva /O /GTS_Template para declarar el conjunto de campos y cada placeholder lleva /O /GTS_Replacement para vincular un campo de datos. No hay nada de diccionario a nivel de página en esto

Esa frase única es la parte que la mayoría de implementaciones hacen mal, y hacerlo mal cuesta más que una validación fallida. Un trabajo de datos variables cuyo plan de reemplazo se lee mal no crashea. Imprime, en volumen, con el campo equivocado en la caja equivocada, y la primera persona que se da cuenta es el cliente que tiene cincuenta mil mailers en las manos

Dónde vive en realidad la definición de reemplazo

PDF/VCR-1 pone su modelo de reemplazo en la jerarquía de estructura lógica descrita en la ISO 32000-1 §14.7, no en un diccionario de página privado. La raíz de plantilla es un elemento de estructura colgado directamente de StructTreeRoot, distinguido por un attribute owner de GTS_Template, y declara todo el vocabulario de campos en un solo sitio mediante GTS_Fields más un GTS_Pages opcional que nombra el campo que impulsa la selección de página

Los placeholders son elementos de estructura anidados dentro de esa raíz, con attribute owner GTS_Replacement. Cada uno lleva GTS_Generator (que PDFlibPas exige que sea PassThrough), un valor GTS_Data que nombra exactamente un campo declarado y un GTS_BBox opcional. La vinculación con la página tampoco es una anotación de página: el elemento apunta a su página mediante /Pg y a la secuencia de marked content mediante /K. Elegir estructura en lugar de un diccionario de página trae entonces una consecuencia fácil de enunciar y fácil de subestimar: el árbol de estructura es un grafo general al que se llega desde el catálogo, así que cualquier cosa que consuma el perfil tiene que ser un walker de árboles con todos los peligros que eso implica, no un lector de diccionarios. Si no has trabajado antes con el árbol de estructura, la mecánica está explicada en estructura PDF etiquetada y accesibilidad

Dónde pone PDFlibPas el modelo de reemplazo PDF/VCR-1: la raíz de plantilla cuelga directamente de StructTreeRoot con un attribute owner de GTS_Template que declara GTS_Fields y el GTS_Pages opcional, mientras que cada placeholder anidado lleva GTS_Replacement, un GTS_Generator de PassThrough, un campo GTS_Data, un GTS_BBox opcional y apunta a su página mediante /Pg y a su contenido mediante /K
La definición de reemplazo vive en la jerarquía de estructura lógica en lugar de en un diccionario de página, lo que convierte a cada consumidor en un walker de árboles y al elemento en un nodo de grafo que un atacante puede moldear

¿Por qué comprobar solo la primera rama de nivel superior deja pasar archivos malos?

Porque StructTreeRoot puede tener muchos hijos directos, y un validador que se detiene tras localizar la raíz de plantilla nunca ve el resto. Ese es el fallo concreto que PDFlibPas está construido para evitar, y produce exactamente el tipo de falso positivo que es peor que no tener comprobación: un archivo que reporta conformidad y contiene una segunda raíz de plantilla, o un placeholder sentado fuera de la rama de plantilla donde ningún consumidor lo honrará

Así que ValidatePDFVCRStructure en PDFlibPas recorre el mismo árbol en dos pasadas. La primera pasada enumera cada hijo directo de StructTreeRoot y cuenta los que llevan GTS_Template; cualquier cosa distinta de exactamente uno se rechaza antes de leer un solo placeholder. La segunda pasada desciende a todas las ramas de nivel superior, no solo a la de plantilla, y rechaza dos cosas a la vez: un elemento anidado con GTS_Template que no sea la raíz, y cualquier elemento GTS_Replacement encontrado fuera de la rama de plantilla

El recorrido está acotado a 64 niveles de profundidad y 1.000.000 de nodos, y registra el número de objeto y de generación de cada elemento que visita para que un ciclo o una referencia repetida se reporten en lugar de seguirse. Esos números no son tuning de rendimiento. Un árbol de estructura es un grafo de objetos que controla un atacante, y un validador que recursiona sin techo es un stack overflow con extensión de archivo

Validación en dos pasadas de ValidatePDFVCRStructure en PDFlibPas: la pasada uno cuenta los hijos directos de StructTreeRoot que llevan GTS_Template y rechaza cualquier cosa distinta de exactamente uno, la pasada dos desciende a todas las ramas de nivel superior para rechazar una raíz de plantilla anidada y cualquier GTS_Replacement fuera de la rama de plantilla, acotada a 64 niveles y un millón de nodos
Un validador que se detiene tras localizar la primera raíz de plantilla nunca ve una segunda, así que el recorrido cubre todas las ramas de nivel superior y registra números de objeto para que un ciclo se reporte en lugar de seguirse

Crear una plantilla, y las restricciones que la API se niega a doblar

PDFlibPas expone la creación mediante una API de bracketing que impone las reglas estructurales en el momento de escribir en lugar de descubrirlas al guardar. SetPDFVCRMode cambia el documento al perfil, que internamente exige el modo base PDF/X-4 de la ISO 15930-7 y activa el flag de mark-info, porque un archivo PDF/VCR-1 sin marked content no tiene nada a lo que un placeholder pueda apuntar. A partir de ahí, BeginPDFVCRTemplate recibe la lista de campos y el campo de selección de página, y es estricto de maneras que conviene conocer antes de diseñar tu esquema de datos. Los nombres de campo pasan por PDFVCRValidFieldName, que solo acepta letras ASCII, dígitos, guion bajo, guion y punto, porque esos son los caracteres que serializan en un nombre PDF sin ambigüedad de escaping. PDFVCRNormalizeFieldList mantiene la lista sensible a mayúsculas y rechaza duplicados de plano, y el campo de selección de página debe aparecer él mismo en la lista declarada

var
  Lib: TPDFlib;
  Diag: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;

    if Lib.SetPDFVCRMode(1) <> 1 then
      raise Exception.Create('PDF/VCR mode was refused');

    Lib.NewPage;
    if Lib.BeginPDFVCRTemplate('Salutation,FullName,AccountNo',
                               'AccountNo') <> 1 then
      raise Exception.Create('Field list rejected');

    // Un placeholder vincula exactamente un campo declarado.
    Lib.BeginPDFVCRPlaceholder('FullName', 72, 690, 320, 712);
    Lib.DrawText(72, 694, 'SAMPLE NAME');
    Lib.EndPDFVCRPlaceholder;

    Lib.EndPDFVCRTemplate;

    Diag := Lib.GetPDFVCRDiagnostics;
    if Diag <> '' then
    begin
      Writeln('PDF/VCR blockers: ', Diag);
      Exit;
    end;

    Lib.SaveToFile('campaign-template.pdf');
  finally
    Lib.Free;
  end;
end;

Fíjate en lo que te compra el bracketing. BeginPDFVCRPlaceholder se niega a abrir a menos que haya una plantilla activa y la pila de tags tenga exactamente un nivel de profundidad, que es como PDFlibPas garantiza que los placeholders son hijos directos de la raíz de plantilla y nunca se anidan unos dentro de otros. Las coordenadas también se validan, rechazando NaN, infinito y rectángulos invertidos, así que una bounding box mala falla en la llamada en lugar de en un informe de preflight una semana después

¿Por qué un placeholder necesita su propio camino de serialización MCID?

Porque PDF/VCR-1 quiere que /K sea un entero pelado, y esa no es la forma en que un escritor general de PDF etiquetado serializa el vínculo entre un elemento de estructura y su contenido. Según la ISO 32000-1 §14.6, un elemento de estructura referencia normalmente marked content o bien mediante un entero MCID directo, cuando el elemento ya identifica su página mediante /Pg, o bien mediante un diccionario de referencia de marked content que nombra la página explícitamente. Una librería que emite salida etiquetada para muchos propósitos tiende a estandarizar en la forma de diccionario porque compone mejor entre páginas

PDFlibPas eligió aquí la opción estrecha en lugar de la general. SetCurrentStructElemDirectMCIDKid marca el elemento de estructura actual, y solo los elementos que llevan esa marca serializan /K como MCID directo con la página en /Pg; todo lo demás conserva intacto el camino existente del diccionario MCR. Es un trade deliberado y del tipo que merece la pena nombrar en voz alta: añadir un perfil no debería cambiar los bytes de salida que ya produce cualquier otro documento etiquetado, así que el perfil tiene un camino propio en lugar de un switch global. Cada archivo etiquetado existente mantiene su serialización anterior, y la superficie de regresión se queda del tamaño de la característica nueva

Un recorrido, dos consumidores

PDFlibPas deriva el manifest de reemplazo y el veredicto de conformidad del mismo recorrido de dos pasadas, que es por qué se puede confiar en el manifest como plan de trabajo. El recorrido rellena un registro TPDFVCRManifestData mientras valida: Fields desde la raíz de plantilla, PageSelectionField cuando se declara uno, y un array Placeholders de entradas TPDFVCRPlaceholderInfo con FieldName, PageNumber, MCID, HasBBox y BBox. El punto de entrada de conformidad es CheckCompliancePDFVCR(InputStream, Password, Options, ResultList), y ejecuta sus checks en orden de dependencia en lugar de todos a la vez: primero el check PDF/X completo, luego GTS_PDFXVersion contra PDF/X-4, luego GTS_PDFVCRVersion leído del namespace http://www.npes.org/pdfvcr/ns/id/, y solo entonces el recorrido de estructura. Pasar Options como 1 hace que se detenga en el primer problema, que es lo que quieres en una puerta de lote; pasar 0 recoge todo, que es lo que quieres en un informe

Un recorrido, dos consumidores en PDFlibPas: el mismo recorrido acotado rellena TPDFVCRManifestData con Fields, PageSelectionField y un array Placeholders de entradas TPDFVCRPlaceholderInfo, mientras que CheckCompliancePDFVCR ejecuta el check PDF/X completo, GTS_PDFXVersion, GTS_PDFVCRVersion del namespace npes.org y solo entonces el recorrido de estructura, devolviendo 100 solo cuando el archivo cualifica
El manifest es un contrato, así que BuildPDFVCRManifest devuelve JSON solo cuando la lista de issues vuelve vacía y reporta el error 608 en caso contrario, evitando que un archivo no conforme se haga pasar por un plan de trabajo fiable
// Verifica una plantilla que recibiste en lugar de producirla
var
  Issues: TStringList;
  Stream: TFileStream;
  I: Integer;
begin
  Issues := TStringList.Create;
  Stream := TFileStream.Create('supplier-template.pdf',
                               fmOpenRead or fmShareDenyWrite);
  try
    if CheckCompliancePDFVCR(Stream, '', 0, Issues) <> 100 then
      for I := 0 to Issues.Count - 1 do
        Writeln('PDF/VCR: ', Issues[I]);
  finally
    Stream.Free;
    Issues.Free;
  end;
end;

El manifest es un contrato, así que solo existe para archivos conformes

BuildPDFVCRManifest devuelve JSON solo cuando la lista de issues vuelve vacía, y una cadena vacía en caso contrario. Eso no es codificación defensiva por codificar. Un manifest lo consume un motor de composición que va a producir salida real a partir de él, y devolver una lista de campos parcial extraída de un archivo con dos raíces de plantilla invita al motor a tratar una estructura no conforme como un plan de reemplazo fiable. A través de la API de documento, GetPDFVCRManifest(InputFileName, Password) envuelve la misma llamada y fija el último código de error a PDFLIB_ERROR_PDFVCR_COMPLIANCE (608) cuando el archivo no cualifica, así que una extracción fallida y una plantilla vacía se pueden distinguir. El JSON reporta fields, pageSelectionField como cadena o null, y un array placeholders cuyas entradas dan field, page, mcid y un bbox de cuatro números o null

var
  Lib: TPDFlib;
  Manifest: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Manifest := Lib.GetPDFVCRManifest('campaign-template.pdf', '');
    if Manifest = '' then
      // 608 significa que el archivo no es conforme a PDF/VCR-1,
      // 401 significa que el archivo no se pudo abrir
      Writeln('No manifest, error ', Lib.LastErrorCode)
    else
      Writeln(Manifest);
  finally
    Lib.Free;
  end;
end;

Un detalle que llevar a tu consumidor: el valor page sigue la convención de página zero-based de toda la librería, no la numeración one-based que ve un humano en un visor. Mezclarlas es un off-by-one silencioso que coloca contenido en la hoja equivocada, y ningún validador lo atrapará porque ambos números son estructuralmente válidos

Dónde están los límites

El soporte PDF/VCR-1 de PDFlibPas cubre la creación de plantillas, la puerta de guardado, el preflight independiente y la extracción del manifest. No realiza el reemplazo. Sustituir datos en los placeholders y producir las instancias personalizadas es el paso de composición, y vive fuera de la librería a propósito, porque el manifest es precisamente la interfaz que permite escribir un motor de composición de forma independiente. La puerta de guardado también se niega en lugar de reparar. A diferencia de perfiles donde un flag de anotación equivocado puede normalizarse en silencio, toda restricción PDF/VCR-1 que aplica PDFVCRReadyForSave es una donde adivinar cambiaría lo que significa el documento: inventar una raíz de plantilla, o decidir a qué campo pertenece un placeholder sin vincular, produce un archivo que valida y miente. La misma línea de reparar-versus-negarse se discute para otro perfil en el modo autor de PDF/E-1 y el preflight acotado, y el flujo de trabajo para ejecutar varios checks de conformidad sobre un mismo documento está cubierto en el workbench de conformidad y firma

La creación, el recorrido de estructura acotado, la puerta de guardado y el extractor de manifest viajan todos en la librería de PDF en Delphi PDFlibPas, lo que significa que una plantilla puede producirse bajo el perfil y luego verificarse a través de un camino de código separado que nunca vio el estado del escritor, y esa separación es la única razón de que una afirmación de conformidad valga algo