Artículo técnico

Preflight automático y auditoría de riesgos PDF con PDFium

Un PDF que llega a una frontera de producción — una cola de impresión, un sistema de archivado, un portal de carga para clientes — debería auditarse antes de que cualquier cosa lo renderice. El archivo podría traer una acción Launch conectada para iniciar un programa externo, imágenes demasiado burdas para sobrevivir a la impresión, un diccionario de cifrado que prohíbe justo el trabajo de impresión para el que se envió, o una etiqueta PDF/A que no cumple. Inspeccionar un documento contra reglas como estas antes de que entre a un flujo de trabajo se llama preflight, y la API C de PDFium le da a Delphi todo lo necesario para implementar las comprobaciones directamente, sin renderizar una sola página

Este artículo construye las comprobaciones en sí: cuatro clases de auditoría, cada una una rutina pequeña que agrega hallazgos a una lista de resultados compartida. Elementos interactivos, métricas de recursos, estado de seguridad y marcadores de estándares reciben código funcional, incluida la aritmética. Si lo que necesitas es la maquinaria alrededor de las comprobaciones — bucles por lotes sobre carpetas, archivos de reporte JSON y HTML, aislamiento por archivo — el PDFium Component incluye un motor de preflight listo para usar, y el artículo sobre la CLI de preflight por lotes cubre esa fontanería. Los dos comparten deliberadamente un mismo vocabulario de códigos de salida, así que un auditor escrito aquí encaja directamente debajo de ese controlador por lotes

Diagrama de canalización PDF: un PDF de entrada se reparte entre cuatro clases de comprobación cuyos hallazgos se reúnen en un único registro TPreflightFinding que se traduce en un código de salida basado en umbrales
La auditoría hace pasar un archivo no confiable por cuatro clases de comprobación — elementos interactivos, métricas de recursos, estado de seguridad y marcadores de estándares —, reúne cada resultado en un único registro de hallazgos contable y lo convierte en un solo código de salida

El registro de hallazgos y el contrato de códigos de salida

Cada comprobación escribe en un único tipo de registro plano, porque la alternativa, que cada comprobación imprima su propia prosa, no puede contarse, filtrarse ni someterse a umbrales después. Cuatro campos bastan

uses
  System.SysUtils, System.Math, System.IOUtils,
  System.Generics.Collections, pdfium_lib;

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // clave estable para máquinas, p. ej. 'ACT-LAUNCH'
    Page: Integer;      // base 1; 0 significa nivel de documento
    Message: string;    // para humanos; puede reformularse entre versiones
  end;

  TFindings = TList<TPreflightFinding>;

procedure Add(Findings: TFindings; Severity: TFindingSeverity;
  const Code: string; Page: Integer; const Msg: string);
var
  F: TPreflightFinding;
begin
  F.Severity := Severity;
  F.Code := Code;
  F.Page := Page;
  F.Message := Msg;
  Findings.Add(F);
end;

Las herramientas posteriores se basan en Code, nunca en el texto de Message, que puede cambiar libremente. El código de salida del proceso sigue el mismo contrato de tres valores que el artículo por lotes: 0 significa que el archivo no produjo hallazgos, 1 significa que existen hallazgos y 2 significa que la auditoría en sí no pudo ejecutarse porque el archivo no se pudo analizar o exige una contraseña. Mantener el código 2 separado importa. Una carpeta de escaneos corruptos es un escáner roto río arriba, no un colapso repentino de cumplimiento, y mezclar los dos manda a alguien a perseguir el problema equivocado

Elementos interactivos: scripts, destinos de Launch, enlaces externos

PDFium clasifica cada acción que encuentra por un tipo entero, y vale la pena fijar con precisión las constantes de fpdf_doc.h, porque valores mal copiados dejan a un escáner ciego en silencio. La enumeración real es PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 y PDFACTION_EMBEDDEDGOTO = 5. Observa lo que falta: no hay un miembro JavaScript. Los scripts a nivel de documento no son acciones de enlace y nunca aparecen a través de FPDFAction_GetType; se enumeran con una familia de llamadas aparte. Un auditor que compara tipos de acción contra una constante JavaScript imaginaria compila, se ejecuta y no encuentra nada, para siempre

const
  PDFACTION_GOTO         = 1;   // salto dentro del documento: inofensivo
  PDFACTION_REMOTEGOTO   = 2;   // salto a otro archivo local
  PDFACTION_URI          = 3;   // abre una URL externa
  PDFACTION_LAUNCH       = 4;   // inicia un programa externo
  PDFACTION_EMBEDDEDGOTO = 5;   // salto a un archivo incrustado

function ActionTarget(Doc: FPDF_DOCUMENT; Action: FPDF_ACTION;
  AType: ULONG): string;
var
  Buf: array[0..2047] of AnsiChar;
begin
  FillChar(Buf, SizeOf(Buf), 0);
  if AType = PDFACTION_URI then
    FPDFAction_GetURIPath(Doc, Action, @Buf, SizeOf(Buf))
  else
    FPDFAction_GetFilePath(Action, @Buf, SizeOf(Buf));
  Result := string(UTF8String(PAnsiChar(@Buf)));
end;

procedure AuditPageActions(Doc: FPDF_DOCUMENT; Page: FPDF_PAGE;
  PageNo: Integer; Findings: TFindings);
var
  StartPos: Integer;
  Link: FPDF_LINK;
  Action: FPDF_ACTION;
  AType: ULONG;
begin
  StartPos := 0;
  while FPDFLink_Enumerate(Page, @StartPos, @Link) <> 0 do
  begin
    Action := FPDFLink_GetAction(Link);
    if Action = nil then
      Continue;                 // enlace solo con destino, nada que marcar
    AType := FPDFAction_GetType(Action);
    case AType of
      PDFACTION_LAUNCH:
        Add(Findings, fsError, 'ACT-LAUNCH', PageNo,
          'Launch action targets "' + ActionTarget(Doc, Action, AType) + '"');
      PDFACTION_URI:
        Add(Findings, fsWarning, 'ACT-URI', PageNo,
          'link opens ' + ActionTarget(Doc, Action, AType));
      PDFACTION_REMOTEGOTO, PDFACTION_EMBEDDEDGOTO:
        Add(Findings, fsWarning, 'ACT-XFILE', PageNo,
          'cross-file destination "' + ActionTarget(Doc, Action, AType) + '"');
    end;                        // PDFACTION_GOTO guarda silencio a propósito
  end;
end;

procedure AuditDocumentBehaviors(Doc: FPDF_DOCUMENT; Findings: TFindings);
var
  N: Integer;
begin
  N := FPDFDoc_GetJavaScriptActionCount(Doc);
  if N > 0 then
    Add(Findings, fsError, 'JS-DOC', 0,
      Format('%d document-level JavaScript action(s) run on open', [N]));
  N := FPDFDoc_GetAttachmentCount(Doc);
  if N > 0 then
    Add(Findings, fsWarning, 'ATT-EMB', 0,
      Format('%d embedded file attachment(s)', [N]));
end;

La división de severidad codifica la política. Una acción Launch es un error porque iniciar un programa arbitrario es lo más peligroso que puede hacer un clic en un PDF, y ninguna factura lo necesita. Las URI externas son advertencias: comunes en documentos legítimos, pero un revisor debería ver el destino sin hacer clic, ya que el texto visible del enlace y el destino real no tienen por qué coincidir. Los saltos GoTo dentro del documento son estructura, no comportamiento, y quedan fuera del reporte por completo — un preflight que grita lobo en cada entrada del índice entrena a la gente a ignorarlo. Para leer los cuerpos de los scripts detrás del conteo de JavaScript, y para los niveles MDP de firmas y la detección de XFA, el artículo sobre auditoría de riesgos de seguridad recorre la misma superficie a través del envoltorio de objetos del componente

Métricas de recursos: DPI efectivo de las imágenes

Una imagen dentro de un PDF no tiene DPI propio. Tiene píxeles, y la página coloca esos píxeles en un rectángulo medido en puntos, donde 72 puntos hacen una pulgada. La resolución solo existe como la razón entre ambos, y por eso la misma foto de 600 por 400 se ve nítida como miniatura y borrosa como imagen principal a página completa. La auditoría necesita, por tanto, ambos números para cada imagen: las dimensiones en píxeles de origen desde los metadatos de la imagen, y el rectángulo colocado desde los límites del objeto

procedure AuditPageImages(Page: FPDF_PAGE; PageNo: Integer;
  Findings: TFindings);
var
  I, ObjCount: Integer;
  Obj: FPDF_PAGEOBJECT;
  Meta: FPDF_IMAGEOBJ_METADATA;
  L, B, R, T: Single;
  WidthPt, HeightPt, DpiX, DpiY, EffDpi: Double;
begin
  ObjCount := FPDFPage_CountObjects(Page);
  for I := 0 to ObjCount - 1 do
  begin
    Obj := FPDFPage_GetObject(Page, I);
    if FPDFPageObj_GetType(Obj) <> FPDF_PAGEOBJ_IMAGE then
      Continue;
    if FPDFImageObj_GetImageMetadata(Obj, Page, @Meta) = 0 then
      Continue;
    if FPDFPageObj_GetBounds(Obj, @L, @B, @R, @T) = 0 then
      Continue;

    WidthPt  := R - L;              // tamaño colocado en la página, en puntos
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 puntos = 1 pulgada, así que pulgadas colocadas = puntos / 72, y
    // DPI efectivo = píxeles de origen / pulgadas colocadas.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // el peor eje decide la calidad de impresión

    if EffDpi < 150.0 then
      Add(Findings, fsWarning, 'IMG-LOWRES', PageNo,
        Format('image %dx%d px placed at %.1fx%.1f pt = %.0f DPI effective',
          [Meta.Width, Meta.Height, WidthPt, HeightPt, EffDpi]))
    else if EffDpi > 600.0 then
      Add(Findings, fsInfo, 'IMG-BLOAT', PageNo,
        Format('image is %.0f DPI at placed size; resampling would ' +
          'shrink the file with no visible loss', [EffDpi]));
  end;
end;

Los umbrales son política, no física: 150 DPI es un piso por debajo del cual la impresión de oficina se pixela visiblemente, 300 es el objetivo comercial habitual, y todo lo que supere 600 no compra calidad visible pero infla el tamaño del archivo, y por eso se reporta como hinchazón informativa en lugar de como defecto. Una advertencia honesta: FPDFPageObj_GetBounds devuelve la caja alineada a los ejes, así que para una imagen colocada con rotación la cifra calculada subestima la densidad real. La estructura FPDF_IMAGEOBJ_METADATA también trae los campos horizontal_dpi y vertical_dpi, que PDFium deriva de la matriz de transformación completa, y comparar ambos resultados es una forma barata de detectar colocaciones rotadas. La misma aritmética de puntos a píxeles impulsa el renderizado en la dirección opuesta, cubierta en el artículo sobre exportación a JPEG

Estado de seguridad: cifrado y bits de permisos

El cifrado de PDF define dos contraseñas con trabajos distintos. La contraseña de usuario controla el descifrado: sin ella el archivo no se abre en absoluto, y FPDF_LoadDocument devuelve nil mientras FPDF_GetLastError reporta FPDF_ERR_PASSWORD. La contraseña de propietario controla los permisos: un archivo protegido solo por contraseña de propietario se abre sin credenciales pero lleva bits de restricción que un lector conforme debe respetar. El intento de carga es, por tanto, la primera sonda de seguridad, y la distinción decide el código de salida — un archivo con contraseña de usuario no es auditable (código 2), mientras que un archivo con contraseña de propietario se audita con normalidad y simplemente acumula hallazgos

const
  FPDF_ERR_PASSWORD = 4;

function AuditSecurity(const FileName: string;
  Findings: TFindings): FPDF_DOCUMENT;
var
  Perms: ULONG;
  Revision: Integer;
begin
  Result := FPDF_LoadDocument(PAnsiChar(AnsiString(FileName)), nil);
  if Result = nil then
  begin
    if FPDF_GetLastError() = FPDF_ERR_PASSWORD then
      Add(Findings, fsError, 'SEC-USERPW', 0,
        'user (open) password required; audit cannot proceed')
    else
      Add(Findings, fsError, 'DOC-BROKEN', 0, 'file failed to parse');
    Exit;
  end;

  Revision := FPDF_GetSecurityHandlerRevision(Result);
  if Revision >= 0 then       // -1 significa que el archivo no está cifrado
  begin
    // Abierto con contraseña vacía pero cifrado: solo contraseña de propietario.
    // Cualquiera puede leerlo, pero los bits de permisos restringen lo que
    // un lector conforme le permite hacer. Los archivos sin cifrar reportan
    // todos los bits activos, y por eso la puerta de la revisión va primero.
    Perms := FPDF_GetDocPermissions(Result);
    Add(Findings, fsInfo, 'SEC-ENC', 0,
      Format('encrypted, security handler revision %d', [Revision]));
    if (Perms and 4) = 0 then      // bit 3: imprimir
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: copiar / extraer contenido
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: impresión en alta resolución
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

Las máscaras salen de la Tabla 22 de ISO 32000-1, que numera los bits desde 1: el bit 3 del valor /P es la máscara 4, el bit 5 es 16, el bit 12 es 2048. Si un hallazgo dado importa es una decisión de enrutamiento. Una imprenta debería rechazar un archivo SEC-NOPRINT en la recepción, donde quien lo envía recibe un mensaje claro, y no en el RIP tres horas antes de una fecha límite. Un sistema de archivado debería tratar el propio SEC-ENC como bloqueante, ya que el cifrado y la preservación a largo plazo no se mezclan — un punto que la comprobación de estándares está a punto de formalizar

Marcadores de estándares: leer una declaración PDF/A

Un archivo declara su conformidad PDF/A en su paquete de metadatos XMP, mediante la propiedad pdfaid:part (1 a 4) y pdfaid:conformance (la letra del nivel, como b para fidelidad visual o a para etiquetado estructural completo). La API C de PDFium no ofrece un accesor XMP; FPDF_GetMetaText lee solo el diccionario Info, que no es donde vive la identificación. La salida de emergencia es una regla del propio estándar: ISO 19005 exige que el flujo de metadatos XMP se almacene sin comprimir, precisamente para que las herramientas puedan encontrarlo sin un analizador PDF completo. Un escaneo de bytes en crudo es, por tanto, un detector de declaraciones legítimo — y un archivo cuya declaración se esconde dentro de un flujo comprimido ya violó el estándar que declara

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // vacío = no hay declaración PDF/A
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // esquema de identificación XMP
  if P = 0 then
    Exit;
  // Maneja tanto <pdfaid:part>2</pdfaid:part> como pdfaid:part="2":
  // toma el primer dígito después del nombre de la propiedad.
  Limit := Min(P + 32, Length(S));
  Inc(P, Length('pdfaid:part'));
  while (P <= Limit) and not (S[P] in ['1'..'4']) do
    Inc(P);
  if P <= Limit then
    Result := 'PDF/A-' + Char(S[P]);
end;

El hallazgo que esto produce es deliberadamente informativo, porque una declaración es una afirmación, no una propiedad del archivo. La entrada XMP es una línea de XML que cualquier productor puede escribir, incluido uno roto; la conformidad es que el archivo satisfaga de verdad cientos de reglas sobre fuentes incrustadas, color independiente del dispositivo y características prohibidas. Detectar la declaración te dice qué archivos enrutar a una validación real, y nada más. El motor de preflight integrado del componente realiza esa validación en los perfiles PDF/A, PDF/UA y PDF/X, y el artículo sobre la CLI por lotes muestra cómo conectarlo a una canalización con reportes que un auditor pueda abrir después

Una ejecución contra un archivo problemático

El controlador encadena las comprobaciones: primero la seguridad, porque decide si la auditoría se ejecuta siquiera, luego los comportamientos a nivel de documento y la declaración de estándares, y después un bucle por páginas para acciones e imágenes

function AuditFile(const FileName: string; Findings: TFindings): Integer;
var
  Doc: FPDF_DOCUMENT;
  Page: FPDF_PAGE;
  I: Integer;
  Claim: string;
begin
  Doc := AuditSecurity(FileName, Findings);
  if Doc = nil then
    Exit(2);                        // fallo de auditoría, no un veredicto
  try
    AuditDocumentBehaviors(Doc, Findings);
    Claim := PdfAClaim(FileName);
    if Claim <> '' then
      Add(Findings, fsInfo, 'STD-PDFA', 0,
        Claim + ' conformance claimed (declaration only, not validated)');
    for I := 0 to FPDF_GetPageCount(Doc) - 1 do
    begin
      Page := FPDF_LoadPage(Doc, I);
      if Page = nil then
      begin
        Add(Findings, fsError, 'PAGE-BROKEN', I + 1, 'page failed to parse');
        Continue;
      end;
      try
        AuditPageActions(Doc, Page, I + 1, Findings);
        AuditPageImages(Page, I + 1, Findings);
      finally
        FPDF_ClosePage(Page);
      end;
    end;
  finally
    FPDF_CloseDocument(Doc);
  end;
  if Findings.Count > 0 then
    Result := 1
  else
    Result := 0;
end;

Contra un folleto que volvió de una agencia externa, la salida se ve así

> preflight_audit brochure_final.pdf
brochure_final.pdf: 5 finding(s)
  [ERROR]   ACT-LAUNCH   page 3   Launch action targets "..\tools\setup.exe"
  [ERROR]   JS-DOC       doc      2 document-level JavaScript action(s) run on open
  [WARNING] IMG-LOWRES   page 7   image 412x287 px placed at 396.0x275.8 pt = 75 DPI effective
  [WARNING] SEC-NOPRINT  doc      printing is not permitted
  [INFO]    STD-PDFA     doc      PDF/A-2 conformance claimed (declaration only, not validated)
exit code 1

Cada línea es accionable por sí sola, pero la combinación es el veredicto real. Este archivo declara PDF/A-2 mientras carga un diccionario de cifrado y JavaScript activo, y PDF/A prohíbe ambos de plano — así que la declaración es demostrablemente falsa antes de que corra cualquier validador profundo. Ese es el tipo de contradicción que una lista plana de hallazgos saca a la luz y que un aprobado/reprobado booleano esconde

Lo que esta auditoría no puede decirte

La honestidad sobre el alcance es lo que mantiene la confianza en una herramienta de preflight. Todo lo anterior lee lo que el archivo declara sobre sí mismo: PDFium analiza la estructura, y esta auditoría la inventaría. No realiza validación PDF/A — sin comprobaciones de cobertura de glifos contra fuentes incrustadas, sin análisis de espacios de color contra intenciones de salida, ninguna de las reglas a nivel de cláusula que separan una declaración de la conformidad; para eso necesitas un validador dedicado como el motor de preflight del componente o veraPDF. Los bits de permisos son declaraciones que los lectores conformes respetan, no muros criptográficos, así que SEC-NOPRINT describe intención y no aplicación. El escaneo de acciones cubre anotaciones de enlace y scripts a nivel de documento; los scripts enterrados en diccionarios de eventos de campos de formulario necesitan además las API de formularios. Y una comprobación de firmas, si extiendes la auditoría con una, reporta intención declarada, no criptografía verificada — la validación de la cadena de certificados es un trabajo aparte. Una auditoría de preflight es la entrevista de ingreso, no el juicio: su trabajo es hacer que la decisión de enrutamiento sea informada, rápida y repetible

Nota: las API de objetos de documento, página, anotación e imagen usadas a lo largo de esta auditoría, junto con un envoltorio Delphi de alto nivel y un motor de preflight completo de validación de estándares, se incluyen con el PDFium Component