Technical Article

Como Criar um Painel de Receção de PDF em Delphi com PDFium

Um painel de receção e análise de PDF (pdf intake review workbench) é um pequeno programa com uma única tarefa: analisar cada ficheiro antes que qualquer processo posterior o possa manipular. Para desempenhar essa função, deve reunir um conjunto de capacidades numa única passagem. Abre o ficheiro (sem confiar nele), lê o que o ficheiro declara sobre si próprio, procura conteúdos que possam enganar um extrator ingénuo ou transportar um ataque, decide se existe algum texto passível de extração e, em seguida, encaminha o documento para uma fila com base no que encontrou. Se ignorar esta inspeção, as falhas serão silenciosas: um PDF encriptado com palavra-passe de proprietário (owner password) que envolve um formulário XFA passará por um extrator de texto como se contivesse strings vazias, será indexado como um documento em branco e ninguém se aperceberá até que algum utilizador procure por um conteúdo que nunca foi lido. O PDFium Component é uma biblioteca de inspeção e visualização VCL/LCL com código-fonte para Delphi, C++Builder e Lazarus, e expõe as chamadas de introspeção necessárias para este painel. As secções abaixo explicam que chamada responde a cada pergunta e os dois locais onde a chamada mais óbvia devolve uma resposta errática com total confiança.

Cinco perguntas a responder antes de encaminhar um ficheiro

Excluindo a grelha e a barra de miniaturas, a triagem de receção resume-se a cinco perguntas:

  • O ficheiro pode ser aberto e sob qual palavra-passe?
  • O que afirma ser: título, autor, data de criação?
  • Contém conteúdo ativo ou de risco, como JavaScript, um formulário XFA ou ficheiros anexos?
  • Existe texto passível de extração ou trata-se de uma digitalização destinada a OCR?
  • Face a tudo isto, qual a fila que o recebe: processamento direto, revisão manual ou quarentena?

Cada pergunta mapeia-se numa ou duas chamadas do PDFium Component. Dois desses mapeamentos apresentam aspetos complexos que justificam a maioria dos ficheiros mal encaminhados que tive de depurar em ambientes de produção. Os metadados do documento residem em dois locais diferentes que podem não coincidir, e a encriptação não impede necessariamente a abertura de um documento.

Abertura de baixo custo: preenchimento de formulários desativado, zero páginas desenhadas

A triagem deve constituir a abertura mais económica possível. Definir FormFill := False antes de Active := True instrui o componente a ignorar completamente o ambiente de preenchimento de formulários. Isso reduz o tempo de carregamento e (igualmente importante para ficheiros de origem desconhecida) impede a inicialização de qualquer JavaScript ao nível do documento. Nenhuma das propriedades de inspeção utilizadas abaixo exige a renderização de uma página, pelo que uma passagem de triagem nunca necessita de produzir um único mapa de bits.

procedure InspectIncoming(const IncomingPath: string; var Rec: TIntakeRecord);
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := IncomingPath;
    Pdf.FormFill := False;     // no form environment, no JavaScript init
    Pdf.Active := True;        // failure is silent: Active simply stays False

    if not Pdf.Active then
    begin
      Rec.OpenFailed := True;  // damaged file or user-password lock
      Exit;                    // the finally block still runs
    end;

    Rec.PageCount := Pdf.PageCount;
    CollectIdentity(Pdf, IncomingPath, Rec);
    CollectRiskSignals(Pdf, Rec);
  finally
    Pdf.Active := False;
    Pdf.Free;                  // never leak the instance on a malformed file
  end;
end;

A verificação após a atribuição não é opcional, e é uma verificação em vez de um tratamento de exceções por uma razão. Quando o motor não consegue carregar o ficheiro, o componente silencia o erro interno EPdfError e deixa Active como False em vez de o propagar. O código que aguarda por uma exceção lerá alegremente o PageCount de um documento que nunca foi aberto. Se o fluxo de trabalho de rejeição necessitar do texto de erro real do motor, leia o ficheiro para um array de bytes e chame a sobrecarga de LoadDocument que recebe TBytes; esse caminho lança a exceção EPdfError com a respetiva mensagem, incluindo o caso de palavra-passe. O bloco try..finally continua a ser essencial. Os serviços de receção funcionam sem assistência durante semanas, e nenhuma exceção posterior pode perder a instância do TPdf ou reter um bloqueio com o qual a tentativa seguinte possa colidir.

O desempenho raramente se torna um obstáculo. Com o preenchimento de formulários desativado e sem renderização, a abertura de triagem é dominada por I/O, e um único trabalhador inspeciona confortavelmente vários ficheiros por segundo a partir do disco local. Se o volume de receção ultrapassar a capacidade de um único trabalhador, partilhe o trabalho por ficheiro e não por verificação. As cinco perguntas partilham uma única abertura, e dividi-las por processos multiplicaria o passo mais dispendioso em vez de o amortizar.

Os metadados residem em dois locais, e estes divergem

A norma ISO 32000-1 define duas localizações para os metadados do documento: o dicionário de informações do documento (cláusula 14.3.3) e um pacote XMP anexado ao catálogo (cláusula 14.3.2). As propriedades Title, Author, Subject e CreationDate leem o dicionário Info, com MetaText[] para qualquer outra chave e DecodeDate para analisar a string de data D:YYYYMMDD.... O problema é que os geradores modernos escrevem cada vez mais apenas em XMP, uma tendência que a norma ISO 32000-2 oficializa ao descontinuar a maioria das chaves do dicionário Info no PDF 2.0. O sintoma numa ferramenta de receção é claro. O seu painel mostra um título vazio enquanto o Adobe Acrobat apresenta um, porque o Acrobat recorreu a dc:title dentro do pacote XMP, o qual as propriedades do dicionário Info nunca consultam.

procedure CollectIdentity(Pdf: TPdf; const FilePath: string;
  var Rec: TIntakeRecord);
begin
  Rec.Title := Pdf.Title;             // Info dictionary value
  Rec.Author := Pdf.Author;
  Rec.CreatedAt := Pdf.CreationDate;  // raw PDF date string ("D:2026...")

  // An empty Info title does not mean the document is untitled. The
  // component does not expose the XMP packet, so probe the raw file
  // bytes for the dc:title element before trusting the blank.
  if (Rec.Title = '') and FileContainsText(FilePath, 'dc:title') then
    Include(Rec.Flags, ifTitleInXmpOnly);
end;

Mesmo a análise simples de substring acima referida tem utilidade: "metadados presentes, mas não onde as ferramentas legadas procuram" é um facto relevante para o encaminhamento de qualquer fluxo de arquivo que indexe por título ou autor. Se o seu índice posterior ler apenas o dicionário Info, os ficheiros sinalizados desta forma tornar-se-ão silenciosamente impossíveis de pesquisar.

Ficheiros encriptados que se abrem de qualquer forma

Um documento encriptado não falha necessariamente ao abrir. O processador de segurança padrão (ISO 32000-1 cláusula 7.6.3) distingue a palavra-passe do utilizador (user password), necessária para abrir o documento, da palavra-passe do proprietário (owner password), que apenas restringe permissões como impressão e cópia. Uma grande fatia de documentos empresariais "protegidos" está encriptada com uma palavra-passe de proprietário e uma palavra-passe de utilizador vazia. Abrem-se sem qualquer aviso, desencriptam-se totalmente e dependem de os visualizadores se voluntariarem para respeitar os sinalizadores de permissão. Trata-se de uma política e não de proteção, e os seus estados de receção devem refletir essa diferença.

Detetar encriptação após uma abertura bem-sucedida requer uma chamada ao motor mais uma verificação de recurso. FPDF_GetSecurityHandlerRevision(Pdf.Document) devolve -1 para ficheiros não protegidos e a revisão do processador caso contrário, e a propriedade Pdf.Permissions devolver qualquer valor diferente da máscara $FFFFFFFF é o sinal corroborante. Para ficheiros genuinamente bloqueados por palavra-passe de utilizador, atribua Password antes de definir Active := True; se a abertura continuar a falhar, encaminhe o ficheiro para um estado bloqueado que solicite credenciais ao remetente através de um canal seguro, em vez de repetir a operação cegamente. E evite a tentação de tratar "encriptado" como uma quarentena automática. Na maioria das indústrias com grande volume de documentos, os ficheiros encriptados mas passíveis de abertura constituem a situação normal e não a suspeita.

Conteúdo ativo: JavaScript, XFA e ficheiros anexos

Três conclusões devem sempre influenciar a decisão de encaminhamento. Primeiro, o JavaScript: o evento OnUnsupportedFeature reporta funcionalidades estruturais como XFA ou conteúdo 3D à medida que o motor as encontra, mas não deteta JavaScript. Em vez disso, verifique JavaScriptActionCount e trate um resultado diferente de zero como conteúdo ativo. Segundo, o XFA: quando FormType devolve ftXfaFull, as páginas visíveis são frequentemente pouco mais do que uma representação do modelo XFA, e a extração convencional de texto verá texto genérico em vez dos valores preenchidos. Terceiro, os anexos: o PDF é um formato de contentor, e AttachmentCount indica se este está a transportar anexos.

procedure CollectRiskSignals(Pdf: TPdf; var Rec: TIntakeRecord);
var
  i, PageNo: Integer;
  Ext: string;
begin
  Rec.IsEncrypted := Assigned(FPDF_GetSecurityHandlerRevision) and
    (FPDF_GetSecurityHandlerRevision(Pdf.Document) <> -1);
  Rec.HasForms := Pdf.FormType <> ftNone;
  Rec.IsXfa := Pdf.FormType = ftXfaFull;
  Rec.HasJavaScript := Pdf.JavaScriptActionCount > 0;

  // AnnotationCount is a per-page property; walk the pages to total
  // it. Loading a page object renders nothing, so this stays cheap.
  Rec.Annotations := 0;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    Inc(Rec.Annotations, Pdf.AnnotationCount);
  end;

  Rec.Attachments := Pdf.AttachmentCount;

  for i := 0 to Rec.Attachments - 1 do
  begin
    Ext := LowerCase(ExtractFileExt(string(Pdf.AttachmentName[i])));
    if (Ext = '.exe') or (Ext = '.js') or (Ext = '.vbs') or (Ext = '.dll') then
      Include(Rec.Flags, ifDangerousAttachment);
  end;
end;

Dois detalhes nesse ciclo merecem atenção. O nome do anexo provém do interior do documento, pelo que nunca o deve reutilizar como um caminho de saída sem o higienizar primeiro; um nome embutido como ..\..\start.exe constitui uma travessia de caminho (path traversal) à segurança de uma chamada de gravação descuidada. E um bloqueio de extensões é um sinal de alerta, não uma garantia. A sua função é forçar uma decisão humana e não certificar que o ficheiro está limpo.

Transformar os sinais em estados de encaminhamento

Um modelo de estados prático necessita de menos estados do que a maioria das equipas prevê: pronto (sem bloqueios, texto presente), revisão (abertura com sucesso mas algo requer atenção humana, como um formulário XFA, JavaScript, uma camada de texto vazia ou um título apenas em XMP), bloqueado (palavra-passe de utilizador obrigatória) e danificado (abertura falhou). Registe as evidências juntamente com o estado. O hash do ficheiro, o número de páginas, os sinalizadores exatos e a mensagem de erro do motor para ficheiros danificados são todos importantes, porque quem questionar uma decisão de encaminhamento fá-lo-á semanas mais tarde, num ficheiro que, entretanto, pode ter sido substituído ou modificado.

Quando um operador necessitar de analisar um ficheiro em quarentena, não o envie para o visualizador padrão do sistema operativo. Desenhe-o dentro de um painel protegido com a execução de scripts e o processamento de ligações desativados, a abordagem descrita na criação de uma superfície segura de pré-visualização de PDF em Delphi. E se a sua receção alimentar um arquivo com requisitos de conformidade, a passagem de triagem é o local natural para agendar uma verificação mais aprofundada; a validação em lote do preflight em relação a perfis PDF/A e PDF/UA prossegue precisamente onde esta inspeção termina.

A página do produto abrange o licenciamento, a API de inspeção completa e as demonstrações incluídas, incluindo um inspetor de documentos ao estilo do painel de triagem: PDFium Component.