Artigo Técnico

Preflight e auditoria de risco de PDF com PDFium

Um PDF que chega a uma fronteira de produção — uma fila de impressão, um arquivo, um portal de envio de clientes — deve ser auditado antes de qualquer coisa renderizá-lo. O arquivo pode carregar uma ação Launch ligada a iniciar um programa externo, imagens grosseiras demais para sobreviver à impressão, um dicionário de criptografia que proíbe justamente o trabalho de impressão para o qual foi enviado ou um rótulo PDF/A que ele não honra. Inspecionar um documento contra regras assim antes de ele entrar em um fluxo de trabalho se chama preflight, e a API C do PDFium dá ao Delphi tudo o que é preciso para implementar as verificações diretamente, sem renderizar uma única página

Este artigo constrói as próprias verificações: quatro classes de auditoria, cada uma uma pequena rotina que acrescenta achados a uma lista de resultados compartilhada. Elementos interativos, métricas de recursos, estado de segurança e marcadores de norma ganham todos código funcionando, incluindo a aritmética. Se o que você precisa é da maquinaria em volta das verificações — laços de pasta em lote, arquivos de relatório em JSON e HTML, isolamento por arquivo —, o PDFium Component já traz um motor de preflight pronto, e o artigo sobre o CLI de preflight em lote cobre esse encanamento. Os dois compartilham de propósito um mesmo vocabulário de códigos de saída, então um auditor escrito aqui encaixa direto sob aquele driver de lote

Diagrama de pipeline de PDF: um PDF de entrada se abre em quatro classes de verificação cujos achados se juntam em um único registro TPreflightFinding que mapeia para um código de saída baseado em limiares
A auditoria distribui um arquivo não confiável por quatro classes de verificação — elementos interativos, métricas de recursos, estado de segurança e marcadores de norma —, junta cada resultado em um registro de achados contável e o transforma em um único código de saída

O registro de achado e o contrato de código de saída

Toda verificação escreve em um único tipo de registro plano, porque a alternativa, cada verificação imprimindo a própria prosa, não pode ser contada, filtrada nem submetida a limiares depois. Quatro campos bastam

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

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // chave estável de máquina, ex.: 'ACT-LAUNCH'
    Page: Integer;      // começa em 1; 0 significa nível de documento
    Message: string;    // para humanos; livre para mudar entre versões
  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;

As ferramentas rio abaixo se apoiam em Code, nunca no texto de Message, que é livre para mudar. O código de saída do processo segue o mesmo contrato de três valores do artigo em lote: 0 significa que o arquivo não produziu achados, 1 significa que existem achados e 2 significa que a própria auditoria não pôde rodar porque o arquivo falhou na análise ou exige senha. Manter o código 2 separado importa. Uma pasta de digitalizações corrompidas é um scanner quebrado lá atrás, não um colapso súbito de conformidade, e juntar os dois manda alguém caçar o problema errado

Elementos interativos: scripts, alvos de Launch, links externos

O PDFium classifica toda ação que encontra por um tipo inteiro, e as constantes de fpdf_doc.h merecem ser fixadas com precisão, porque valores copiados errado deixam um scanner cego em silêncio. A enumeração real é PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 e PDFACTION_EMBEDDEDGOTO = 5. Repare no que falta: não há membro de JavaScript. Scripts de nível de documento não são ações de link e nunca aparecem por FPDFAction_GetType; eles são enumerados por uma família de chamadas separada. Um auditor que testa tipos de ação contra uma constante de JavaScript imaginada compila, roda e não encontra nada, para sempre

const
  PDFACTION_GOTO         = 1;   // salto dentro do documento: inofensivo
  PDFACTION_REMOTEGOTO   = 2;   // salto para outro arquivo local
  PDFACTION_URI          = 3;   // abre uma URL externa
  PDFACTION_LAUNCH       = 4;   // inicia um programa externo
  PDFACTION_EMBEDDEDGOTO = 5;   // salto para um arquivo incorporado

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;                 // link só de destino, nada a sinalizar
    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 fica em silêncio por projeto
  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;

A divisão de severidade codifica política. Uma ação Launch é erro porque iniciar um programa arbitrário é a coisa mais perigosa que um clique em um PDF pode fazer, e nenhuma nota fiscal precisa disso. URIs externas são avisos: comuns em documentos legítimos, mas um revisor deveria ver o alvo sem clicar, já que o texto visível do link e o destino real não precisam concordar. Saltos GoTo internos ao documento são estrutura, não comportamento, e ficam de fora do relatório por completo — um preflight que grita lobo em cada entrada de sumário treina as pessoas a ignorá-lo. Para ler os corpos dos scripts por trás da contagem de JavaScript, e para níveis de MDP de assinatura e detecção de XFA, o artigo sobre auditoria de riscos de segurança percorre a mesma superfície pelo invólucro de objetos do componente

Métricas de recursos: DPI efetivo da imagem

Uma imagem dentro de um PDF não tem DPI próprio. Ela tem pixels, e a página coloca esses pixels em um retângulo medido em pontos, em que 72 pontos formam uma polegada. A resolução só existe como a razão entre os dois, e é por isso que a mesma foto de 600 por 400 é nítida como miniatura e um borrão como imagem de página inteira. A auditoria portanto precisa dos dois números para cada imagem: as dimensões em pixels da origem, vindas dos metadados da imagem, e o retângulo colocado, vindo dos limites do 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;              // tamanho colocado na página, em pontos
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 pontos = 1 polegada, então polegadas colocadas = pontos / 72, e
    // DPI efetivo = pixels de origem / polegadas colocadas.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // o pior eixo decide a qualidade de impressão

    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;

Os limiares são política, não física: 150 DPI é um piso abaixo do qual a impressão de escritório pixeliza visivelmente, 300 é o alvo comercial usual, e qualquer coisa acima de 600 não compra qualidade visível enquanto infla o tamanho do arquivo, motivo pelo qual é reportado como inchaço informativo e não como defeito. Uma ressalva honesta: FPDFPageObj_GetBounds devolve a caixa alinhada aos eixos, então, para uma imagem colocada com rotação, o número calculado subestima a densidade real. A estrutura FPDF_IMAGEOBJ_METADATA também carrega os campos horizontal_dpi e vertical_dpi, que o PDFium deriva da matriz de transformação completa, e comparar os dois resultados é um jeito barato de detectar colocações rotacionadas. A mesma aritmética de pontos para pixels move a renderização na direção oposta, tratada em o artigo sobre exportação para JPEG

Estado de segurança: criptografia e bits de permissão

A criptografia de PDF define duas senhas com funções diferentes. A senha de usuário controla a descriptografia: sem ela o arquivo nem abre, e FPDF_LoadDocument devolve nil com FPDF_GetLastError reportando FPDF_ERR_PASSWORD. A senha de proprietário controla as permissões: um arquivo protegido apenas por senha de proprietário abre sem credenciais, mas carrega bits de restrição que um leitor em conformidade deve respeitar. A própria tentativa de carga é, portanto, a primeira sonda de segurança, e a distinção decide o código de saída — um arquivo com senha de usuário é inauditável (código 2), enquanto um arquivo com senha de proprietário é auditado normalmente e apenas acumula achados

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 o arquivo não é criptografado
  begin
    // Aberto com senha vazia e ainda assim criptografado: só senha de proprietário.
    // Qualquer um pode ler, mas os bits de permissão restringem o que um
    // leitor em conformidade permite fazer. Arquivos não criptografados reportam
    // todos os bits ligados, e é por isso que a checagem de revisão vem antes.
    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: impressão
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: copiar / extrair conteúdo
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: impressão em alta resolução
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

As máscaras vêm da Tabela 22 da ISO 32000-1, que numera os bits a partir de 1: o bit 3 do valor /P é a máscara 4, o bit 5 é 16, o bit 12 é 2048. Se um dado achado importa é uma decisão de roteamento. Um birô de impressão deveria recusar um arquivo SEC-NOPRINT na entrada, onde quem enviou recebe uma mensagem clara, e não no RIP três horas antes do prazo. Um arquivo de preservação deveria tratar o próprio SEC-ENC como bloqueio, já que criptografia e preservação de longo prazo não combinam — um ponto que a verificação de normas está prestes a fazer formalmente

Marcadores de norma: ler uma alegação PDF/A

Um arquivo declara conformidade PDF/A no pacote de metadados XMP dele, pela propriedade pdfaid:part (1 a 4) e por pdfaid:conformance (a letra do nível, como b para fidelidade visual ou a para marcação estrutural completa). A API C do PDFium não oferece acessor de XMP; FPDF_GetMetaText lê apenas o dicionário Info, que não é onde a identificação vive. A saída de emergência é uma regra da própria norma: a ISO 19005 exige que o stream de metadados XMP seja armazenado sem compressão, justamente para que ferramentas o encontrem sem um parser completo de PDF. Uma varredura bruta de bytes é, portanto, um detector legítimo de alegação — e um arquivo cuja alegação se esconde dentro de um stream comprimido já violou a norma que alega seguir

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // vazio = nenhuma alegação PDF/A presente
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // esquema de identificação XMP
  if P = 0 then
    Exit;
  // Trata tanto <pdfaid:part>2</pdfaid:part> quanto pdfaid:part="2":
  // pega o primeiro dígito depois do nome da propriedade.
  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;

O achado que isso produz é deliberadamente informativo, porque uma alegação é uma declaração, não uma propriedade do arquivo. A entrada XMP é uma linha de XML que qualquer produtor pode escrever, inclusive um defeituoso; conformidade é o arquivo realmente satisfazer centenas de regras sobre fontes incorporadas, cor independente de dispositivo e recursos proibidos. Detectar a alegação diz quais arquivos rotear para validação de verdade, e nada além. O motor de preflight embutido no componente faz essa validação nos perfis PDF/A, PDF/UA e PDF/X, e o artigo sobre o CLI em lote mostra como ligá-lo a um pipeline com relatórios que um auditor pode abrir depois

Uma execução contra um arquivo problemático

O driver encadeia as verificações: segurança primeiro, porque ela decide se a auditoria roda, depois comportamentos de nível de documento e a alegação de norma, e então um laço de páginas para ações e imagens

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);                        // falha de auditoria, não um veredito
  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 um folheto que voltou de uma agência externa, a saída fica assim

> 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 linha é acionável por si só, mas a combinação é o veredito de verdade. Este arquivo alega PDF/A-2 enquanto carrega um dicionário de criptografia e JavaScript ativo, e o PDF/A proíbe os dois de forma categórica — então a alegação é comprovadamente falsa antes de qualquer validador profundo rodar. Esse é o tipo de contradição que uma lista plana de achados revela e que um aprovado/reprovado booleano esconde

O que esta auditoria não consegue dizer

Ser honesto sobre o escopo é o que mantém uma ferramenta de preflight confiável. Tudo acima lê o que o arquivo declara sobre si mesmo: o PDFium analisa a estrutura, e esta auditoria a inventaria. Ela não faz validação PDF/A — nada de checagem de cobertura de glifos contra fontes incorporadas, nada de análise de espaços de cor contra intenções de saída, nenhuma das regras de nível de cláusula que separam uma alegação da conformidade; para isso você precisa de um validador dedicado, como o motor de preflight do componente ou o veraPDF. Bits de permissão são declarações que leitores em conformidade respeitam, não muros criptográficos, então SEC-NOPRINT descreve intenção, não imposição. A varredura de ações cobre anotações de link e scripts de nível de documento; scripts enterrados em dicionários de evento de campos de formulário exigem as APIs de formulário por cima. E uma checagem de assinatura, se você estender a auditoria com uma, reporta intenção declarada, não criptografia verificada — validar a cadeia de certificados é um trabalho à parte. Uma auditoria de preflight é a entrevista de entrada, não o julgamento: a função dela é tornar a decisão de roteamento informada, rápida e repetível

Nota: as APIs de documento, página, anotação e objeto de imagem usadas em toda esta auditoria, junto de um invólucro Delphi de alto nível e de um motor de preflight completo de validação de normas, acompanham o PDFium Component