Posicionamento Automático e Assinatura Digital em Contratos PDF Utilizando iText

Desafios na Automação de Assinaturas de Contratos

A automação do processo de assinatura de contratos em PDF frequentemente esbarra na necessidade de posicionar dinamicamente blocos de assinatura, datas e selos digitais. Em cenários onde a interface frontend não pode fornecer as coordenadas exatas para a aplicação do carimbo e dos campos de texto, o backend deve assumir a responsabilidade de calcular essas posições.

A principal dificuldade reside em identificar a última linha de texto do documento para calcular o eixo Y adequado, garantindo que o bloco de assinatura seja inserido logo abaixo do conteúdo existente, sem sobreposições. Além disso, a formatação de tabelas para alinhar os dados das partes (Contratante e Contratado) exige um manejo preciso de layout, pois o uso de espaçamentos manuais resulta em desalinhamentos visuais.

Estratégia de Implementação

Para resolver a ausência de uma API direta de "anexar ao final" no iText, a abordagem utiliza um IEventListener para interceptar eventos de renderização de texto. Isso permite extrair as coordenadas de todos os blocos de texto da última página e determinar o ponto mais baixo (menor valor de Y).

Para o layout dos campos de assinatura, a solução mais robusta é a utilização de um componente Table sem bordas. Isso garante que os campos da parte A fiquem perfeitamente alinhados à essquerda e os da parte B à direita, dividindo a largura da página de forma proporcional. O tamanho da fonte é inferido a partir da altura das caixas delimitadoras (bounding boxes) do texto original, mantendo a consistência visual.

Implementação do Processador de PDF

A classe principal orquestra a leitura do documento, o cálculo das coordenadas, a injeção do layout de assinatura e a invocação do módulo de crpitografia.

package com.exemplo.contrato.pdf;

import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.geom.Vector;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.canvas.parser.EventType;
import com.itextpdf.kernel.pdf.canvas.parser.PdfDocumentContentParser;
import com.itextpdf.kernel.pdf.canvas.parser.data.IEventData;
import com.itextpdf.kernel.pdf.canvas.parser.data.TextRenderInfo;
import com.itextpdf.kernel.pdf.canvas.parser.listener.IEventListener;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.borders.Border;
import com.itextpdf.layout.element.Cell;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.element.Table;

import java.io.File;
import java.io.IOException;
import java.util.*;

public class ContratoPdfProcessor {

    public enum TipoParte {
        CONTRATANTE(1),
        CONTRATADO(2);

        private final int id;
        TipoParte(int id) { this.id = id; }
        public int getId() { return id; }
    }

    public void processarAssinatura(String caminhoBase, String caminhoSaida, String caminhoImagemSelo, 
                                    TipoParte parte, List<String> textosAssinatura, boolean documentoJaProcessado) {
        String caminhoTemp = caminhoBase.replace(".pdf", "_temp.pdf");
        try {
            CoordenadasDocumento limites = analisarUltimaPagina(caminhoBase);
            PosicaoAssinatura coords = injetarBlocoAssinatura(limites, caminhoBase, caminhoTemp, parte.getId(), textosAssinatura, documentoJaProcessado);
            
            byte[] pdfAssinado = AssinadorDigital.aplicarAssinaturaCriptografica(caminhoTemp, caminhoImagemSelo, coords);
            GerenciadorArquivos.salvarArquivo(pdfAssinado, caminhoSaida);
        } catch (Exception ex) {
            throw new RuntimeException("Falha ao processar assinatura do contrato", ex);
        } finally {
            new File(caminhoTemp).delete();
        }
    }

    private PosicaoAssinatura injetarBlocoAssinatura(CoordenadasDocumento limites, String entrada, String saida, 
                                                     int tipoParte, List<String> textos, boolean jaProcessado) throws IOException {
        try (PdfReader reader = new PdfReader(entrada);
             PdfWriter writer = new PdfWriter(saida);
             PdfDocument pdfDoc = new PdfDocument(reader, writer)) {
            
            Document doc = new Document(pdfDoc);
            PdfFont fontePadrao = PdfFontFactory.createFont("/System/Library/Fonts/Supplemental/Songti.ttc,1");
            
            int totalLinhas = textos.size();
            float alturaBloco = limites.getAlturaLinha() * totalLinhas;
            float margemInferior = limites.getBottom() - alturaBloco;
            boolean precisaNovaPagina = false;
            int paginaAlvo = pdfDoc.getNumberOfPages();

            if (margemInferior <= limites.getAlturaLinha() * 3) {
                precisaNovaPagina = true;
                if (!jaProcessado) {
                    pdfDoc.addNewPage();
                    paginaAlvo++;
                }
                limites.setBottom(limites.getTop() * 2 - alturaBloco * 2);
            }

            Table tabela = new Table(1);
            tabela.setPageNumber(paginaAlvo);
            float posY = (limites.getBottom() - alturaBloco) / 2;
            float posX = limites.getLeft() + 30f;
            
            if (tipoParte == TipoParte.CONTRATADO.getId()) {
                posX += limites.getWidth() / 2 - 15;
            }

            tabela.setFixedPosition(posX, posY, 200);
            tabela.setBorder(Border.NO_BORDER);

            for (String texto : textos) {
                Paragraph paragrafo = new Paragraph(texto).setFont(fontePadrao).setFontSize(limites.getAltura());
                Cell celula = new Cell().add(paragrafo).setBorder(Border.NO_BORDER);
                tabela.addCell(celula);
            }

            doc.add(tabela);
            doc.flush();
            return calcularCoordenadasFinais(limites, textos, posY, paginaAlvo, precisaNovaPagina);
        }
    }

    private PosicaoAssinatura calcularCoordenadasFinais(CoordenadasDocumento limites, List<String> textos, 
                                                        float posY, int pagina, boolean novaPagina) {
        PosicaoAssinatura posicao = new PosicaoAssinatura();
        int offset = novaPagina ? 2 : 3;
        posicao.setY(posY + (textos.size() - offset) * limites.getAlturaLinha());
        posicao.setX(limites.getLeft() + textos.get(0).length() * limites.getAltura() - 15f);
        posicao.setPagina(pagina);
        return posicao;
    }

    private CoordenadasDocumento analisarUltimaPagina(String caminhoPdf) throws IOException {
        try (PdfDocument pdfDoc = new PdfDocument(new PdfReader(caminhoPdf))) {
            ExtratorCoordenadas extrator = new ExtratorCoordenadas();
            new PdfDocumentContentParser(pdfDoc).processContent(pdfDoc.getNumberOfPages(), extrator);
            
            List<Rectangle> blocos = extrator.getBlocosTexto();
            float minX = Float.MAX_VALUE, maxX = 0, minY = Float.MAX_VALUE, maxY = 0;
            float menorDistanciaVertical = Float.MAX_VALUE;
            Rectangle anterior = null;

            for (Rectangle bloco : blocos) {
                if (anterior != null) {
                    float dist = anterior.getY() - bloco.getY();
                    if (dist > 5f && dist < menorDistanciaVertical) {
                        menorDistanciaVertical = dist;
                    }
                }
                anterior = bloco;
                minX = Math.min(minX, bloco.getLeft());
                maxX = Math.max(maxX, bloco.getRight());
                minY = Math.min(minY, bloco.getBottom());
                maxY = Math.max(maxY, bloco.getY());
            }

            Rectangle ultimoBloco = blocos.get(blocos.size() - 1);
            CoordenadasDocumento coords = new CoordenadasDocumento();
            coords.setLeft(minX);
            coords.setRight(maxX);
            coords.setBottom(minY);
            coords.setTop(maxY);
            coords.setWidth(maxX - minX);
            coords.setHeight(ultimoBloco.getHeight());
            coords.setAlturaLinha(menorDistanciaVertical);
            return coords;
        }
    }

    private static class ExtratorCoordenadas implements IEventListener {
        private final List<Rectangle> blocosTexto = new ArrayList<>();

        @Override
        public void eventOccurred(IEventData data, EventType type) {
            if (type == EventType.RENDER_TEXT) {
                TextRenderInfo info = (TextRenderInfo) data;
                if (info.getText().trim().isEmpty()) return;
                
                Vector inicio = info.getDescentLine().getStartPoint();
                Vector fim = info.getAscentLine().getEndPoint();
                
                float x1 = Math.min(inicio.get(0), fim.get(0));
                float y1 = Math.min(inicio.get(1), fim.get(1));
                float x2 = Math.max(inicio.get(0), fim.get(0));
                float y2 = Math.max(inicio.get(1), fim.get(1));
                
                blocosTexto.add(new Rectangle(x1, y1, x2 - x1, y2 - y1));
            }
        }

        @Override
        public Set<EventType> getSupportedEvents() {
            return Collections.singleton(EventType.RENDER_TEXT);
        }

        public List<Rectangle> getBlocosTexto() { return blocosTexto; }
    }
}

Estruturas de Dados Auxiliares

As classes de transferência de dados (DTOs) encapsulam as métricas espaciais extraídas do documento e as coordenadas finais para a aplicação do selo.

package com.exemplo.contrato.pdf;

public class CoordenadasDocumento {
    private float width, left, right, bottom, top, height, alturaLinha;

    // Getters e Setters omitidos para brevidade
    public float getWidth() { return width; }
    public void setWidth(float width) { this.width = width; }
    public float getLeft() { return left; }
    public void setLeft(float left) { this.left = left; }
    public float getRight() { return right; }
    public void setRight(float right) { this.right = right; }
    public float getBottom() { return bottom; }
    public void setBottom(float bottom) { this.bottom = bottom; }
    public float getTop() { return top; }
    public void setTop(float top) { this.top = top; }
    public float getHeight() { return height; }
    public void setHeight(float height) { this.height = height; }
    public float getAlturaLinha() { return alturaLinha; }
    public void setAlturaLinha(float alturaLinha) { this.alturaLinha = alturaLinha; }
}

package com.exemplo.contrato.pdf;

public class PosicaoAssinatura {
    private float x, y;
    private int pagina;

    public float getX() { return x; }
    public void setX(float x) { this.x = x; }
    public float getY() { return y; }
    public void setY(float y) { this.y = y; }
    public int getPagina() { return pagina; }
    public void setPagina(int pagina) { this.pagina = pagina; }
}

Módulo de Assinatura Criptográfica

A aplicação do selo visual e da assinatura digital invisível é realizada utilizando BouncyCastle para manipulação do keystore PKCS12 e das APIs de segurança do iText.

package com.exemplo.contrato.pdf;

import com.itextpdf.text.Image;
import com.itextpdf.text.Rectangle;
import com.itextpdf.text.pdf.PdfReader;
import com.itextpdf.text.pdf.PdfSignatureAppearance;
import com.itextpdf.text.pdf.PdfStamper;
import com.itextpdf.text.pdf.security.*;
import org.bouncycastle.jce.provider.BouncyCastleProvider;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.Certificate;
import java.util.UUID;

public class AssinadorDigital {

    public static byte[] aplicarAssinaturaCriptografica(String caminhoPdf, String caminhoImagem, PosicaoAssinatura coords) {
        Security.addProvider(new BouncyCastleProvider());
        
        try (InputStream ksStream = AssinadorDigital.class.getClassLoader().getResourceAsStream("certificado.p12");
             PdfReader reader = new PdfReader(caminhoPdf);
             ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            
            KeyStore ks = KeyStore.getInstance("PKCS12", "BC");
            ks.load(ksStream, "senha_segura".toCharArray());
            
            String alias = ks.aliases().nextElement();
            PrivateKey chavePrivada = (PrivateKey) ks.getKey(alias, "senha_segura".toCharArray());
            Certificate[] cadeia = ks.getCertificateChain(alias);

            PdfStamper stamper = PdfStamper.createSignature(reader, baos, '\0', null, true);
            PdfSignatureAppearance appearance = stamper.getSignatureAppearance();
            
            appearance.setReason("Assinatura de Contrato Digital");
            appearance.setSignatureGraphic(Image.getInstance(caminhoImagem));
            appearance.setRenderingMode(PdfSignatureAppearance.RenderingMode.GRAPHIC);
            
            float tamanhoSelo = 120f;
            Rectangle areaSelo = new Rectangle(coords.getX(), coords.getY(), coords.getX() + tamanhoSelo, coords.getY() + tamanhoSelo);
            appearance.setVisibleSignature(areaSelo, coords.getPagina(), UUID.randomUUID().toString());

            ExternalDigest digest = new BouncyCastleDigest();
            ExternalSignature signature = new PrivateKeySignature(chavePrivada, DigestAlgorithms.SHA256, "BC");
            
            MakeSignature.signDetached(appearance, digest, signature, cadeia, null, null, null, 0, MakeSignature.CryptoStandard.CADES);
            
            return baos.toByteArray();
        } catch (Exception e) {
            throw new RuntimeException("Erro durante a assinatura criptográfica", e);
        }
    }
}

Utilitário de Persistência

Método auxiliar para gravar o array de bytes resultante no sistema de arquivos local, garantindo a criação dos diretórios pais caso não existam.

package com.exemplo.contrato.pdf;

import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class GerenciadorArquivos {

    public static void salvarArquivo(byte[] dados, String destino) throws Exception {
        Path caminhoDestino = Paths.get(destino);
        Path diretorioPai = caminhoDestino.getParent();
        
        if (diretorioPai != null && !Files.exists(diretorioPai)) {
            Files.createDirectories(diretorioPai);
        }
        
        try (FileOutputStream fos = new FileOutputStream(destino)) {
            fos.write(dados);
            fos.flush();
        }
    }
}

Tags: java itext pdf assinatura-digital bouncycastle

Publicado em 6-11 18:57 por Thomas