Un PDF que llega a una frontera de producción — una cola de impresión, un archivo, un portal de subida de clientes — debería auditarse antes de que nada lo renderice. El archivo podría llevar una acción Launch preparada para arrancar un programa externo, imágenes demasiado bastas 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 a cuya altura no está. Inspeccionar un documento contra reglas como estas antes de que entre en 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 propias comprobaciones: cuatro clases de auditoría, cada una una pequeña rutina que añade hallazgos a una lista de resultados compartida. Los elementos interactivos, las métricas de recursos, el estado de seguridad y los marcadores de estándares reciben todos código funcional, incluida la aritmética. Si lo que necesita es la maquinaria alrededor de las comprobaciones — bucles por carpetas en lote, archivos de informe 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 bajo ese controlador por lotes
El registro de hallazgos y el contrato del código 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 se puede contar, filtrar ni someter 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 personas; se puede reformular 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 aguas abajo se apoyan en Code, nunca en el texto de Message, que es libre de cambiar. 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 propia auditoría no pudo ejecutarse porque el archivo no se pudo analizar o exige una contraseña. Mantener separado el código 2 importa. Una carpeta de escaneos corruptos es un escáner roto aguas arriba, no un colapso repentino del cumplimiento, y fundir 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 merece la pena fijar con precisión las constantes de fpdf_doc.h, porque los valores mal copiados dejan a un escáner silenciosamente ciego. La enumeración real es PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 y PDFACTION_EMBEDDEDGOTO = 5. Fíjese en lo que falta: no hay ningún miembro JavaScript. Los scripts a nivel de documento no son acciones de enlace y nunca aparecen a través de FPDFAction_GetType; se enumeran mediante una familia de llamadas separada. Un auditor que compare los tipos de acción con 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; // arranca 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 señalar
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 se mantiene en silencio por diseño
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 por gravedad codifica la política. Una acción Launch es un error porque arrancar 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 informe por completo — un preflight que grita «que viene el lobo» con cada entrada del índice enseña a la gente a ignorarlo. Para leer los cuerpos de los scripts tras el recuento de JavaScript, y para los niveles MDP de firma 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 efectivos de imagen
Una imagen dentro de un PDF no tiene DPI propios. 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 relación entre ambos, y por eso la misma foto de 600 por 400 es nítida como una cuchilla como miniatura y un borrón como imagen a página completa. La auditoría necesita por tanto ambos números para cada imagen: las dimensiones en píxeles de origen a partir de los metadatos de la imagen, y el rectángulo colocado a partir de 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 efectivos = 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 suelo por debajo del cual la impresión de oficina pixela visiblemente, 300 es el objetivo comercial habitual, y todo lo que supere 600 no compra ninguna calidad visible mientras infla el tamaño del archivo, y por eso se informa como hinchazón informativa y no como defecto. Una salvedad honesta: FPDFPageObj_GetBounds devuelve la caja alineada con 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 lleva 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, tratada en el artículo sobre exportación a JPEG
Estado de seguridad: cifrado y bits de permisos
El cifrado PDF define dos contraseñas con cometidos distintos. La contraseña de usuario controla el descifrado: sin ella el archivo no se abre en absoluto, y FPDF_LoadDocument devuelve nil con FPDF_GetLastError informando de FPDF_ERR_PASSWORD. La contraseña de propietario controla los permisos: un archivo protegido solo por una contraseña de propietario se abre sin credenciales pero lleva bits de restricción que un lector conforme debe respetar. El propio 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 y aun así cifrado: solo contraseña de propietario.
// Cualquiera puede leerlo, pero los bits de permisos restringen lo que un
// lector conforme le deja hacer. Los archivos sin cifrar informan de todos
// los bits activados, y por eso la comprobación 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 proceden 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. Que un hallazgo concreto importe es una decisión de encaminamiento. 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 archivo histórico 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 establecer formalmente
Marcadores de estándares: leer una declaración PDF/A
Un archivo declara su conformidad PDF/A en su paquete de metadatos XMP, a través de la propiedad pdfaid:part (de 1 a 4) y de pdfaid:conformance (la letra de nivel, como b para fidelidad visual o a para etiquetado estructural completo). La API C de PDFium no ofrece ningún 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 ha violado 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;
// Gestiona tanto <pdfaid:part>2</pdfaid:part> como pdfaid:part="2":
// toma el primer dígito tras el 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 realmente cientos de reglas sobre fuentes incrustadas, color independiente del dispositivo y funciones prohibidas. Detectar la declaración le dice qué archivos encaminar 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 informes que un auditor pueda abrir más tarde
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 de páginas para las acciones y las 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 la 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 tiene este aspecto
> 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 lleva un diccionario de cifrado y JavaScript activo, y PDF/A prohíbe ambas cosas de plano — así que la declaración es demostrablemente falsa antes de que se ejecute ningún validador profundo. Ese es el tipo de contradicción que una lista plana de hallazgos saca a la luz y que un booleano de aprobado o suspenso oculta
Lo que esta auditoría no puede decirle
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 las fuentes incrustadas, sin análisis de espacios de color contra las intenciones de salida, ninguna de las reglas a nivel de cláusula que separan una declaración de la conformidad; para eso necesita 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 una intención y no una imposición. El escaneo de acciones cubre las anotaciones de enlace y los 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 amplía la auditoría con una, informa de la intención declarada, no de criptografía verificada — la validación de la cadena de certificados es un trabajo aparte. Una auditoría de preflight es la entrevista de admisión, no el juicio: su cometido es hacer que la decisión de encaminamiento sea informada, rápida y repetible
Nota: las API de objetos de documento, página, anotación e imagen utilizadas 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