TS350 linhas16.067 bytes
apps/worker/src/services/certificado.ts
SHA-256
7c63ad4a15e47138d39eecf8e90bee098bd91db7df3690c55a3c005ffbacb5b1
Somente leiturafonte-0531042d9f3e
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import QRCode from 'qrcode'
import type { Env } from '../types'
import type { DadosCertificado } from './premiacao-edicoes'
import { guardarPdf, lerPdf, obterCertificado } from './premiacao-edicoes'
/**
* Certificado do Prêmio Observatório do Senado: HTML impresso em PDF pelo
* Chrome headless da máquina (A4 paisagem).
*
* Assinado só pela instituição. Não há nome de pessoa em lugar nenhum do
* documento: nem no corpo, nem nos metadados do PDF.
*
* O PDF é gerado uma vez e guardado; o SHA-256 dele aparece na verificação
* pública. Regerar mudaria o hash (o Chrome grava a data de criação), por isso
* o arquivo guardado é sempre o que se entrega.
*/
const run = promisify(execFile)
const ASSETS = process.env.CERTIFICADO_ASSETS ?? resolve(process.cwd(), 'assets/certificado')
const CHROME = process.env.CHROME_PATH ?? '/usr/bin/google-chrome'
export const URL_VERIFICACAO = 'https://observasenado.org/premiacao/verificar/'
function dataUri(arquivo: string, mime: string): string {
return `data:${mime};base64,${readFileSync(join(ASSETS, arquivo)).toString('base64')}`
}
/**
* Fontes e logo embutidos: o HTML não depende de rede nem de arquivo externo.
* As fontes são instâncias estáticas (fonttools varLib.instancer): com as
* variáveis, o Chrome grava os glifos como Type 3, que imprimem pior.
*/
let recursos: { fontes: string; logo: string } | null = null
function carregarRecursos() {
if (recursos) return recursos
const ttf = (f: string) => dataUri(`fonts/${f}`, 'font/ttf')
recursos = {
fontes: [
['Cormorant', 'normal', 600, 'Cormorant-600.ttf'],
['Cormorant', 'normal', 700, 'Cormorant-700.ttf'],
['Cormorant', 'italic', 400, 'Cormorant-400-italic.ttf'],
['Cormorant', 'italic', 500, 'Cormorant-500-italic.ttf'],
['Inter', 'normal', 400, 'Inter-400.ttf'],
['Inter', 'normal', 500, 'Inter-500.ttf'],
['Inter', 'normal', 600, 'Inter-600.ttf'],
['Inter', 'normal', 700, 'Inter-700.ttf'],
]
.map(([familia, estilo, peso, arquivo]) =>
`@font-face { font-family: '${familia}'; font-style: ${estilo}; font-weight: ${peso};` +
` src: url(${ttf(arquivo as string)}) format('truetype'); }`)
.join('\n'),
logo: dataUri('logo.png', 'image/png'),
}
return recursos
}
function esc(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!,
)
}
const MESES = ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho',
'agosto', 'setembro', 'outubro', 'novembro', 'dezembro']
/** "29 de outubro de 2026"; dia 1 vira "1º", como manda a norma. */
export function dataPorExtenso(iso: string): string {
const [a, m, d] = iso.slice(0, 10).split('-').map(Number)
return `${d === 1 ? '1º' : d} de ${MESES[m - 1]} de ${a}`
}
function numero(v: number, casas = 1): string {
return v.toLocaleString('pt-BR', { minimumFractionDigits: casas, maximumFractionDigits: casas })
}
const ROTULO_EIXO: Record<DadosCertificado['categoria']['eixo'], string> = {
grande_premio: 'Grande Prêmio',
regional: 'Destaque Regional',
estadual: 'Destaque Estadual',
tematico: 'Prêmio Temático',
}
/** Frase que justifica o prêmio, com o número contado e não só a nota. */
export function justificativa(d: DadosCertificado): string {
const { categoria: c, indicador: ind } = d
const entre = `entre ${c.concorrentes} ${c.concorrentes === 1 ? 'concorrente' : 'concorrentes'}`
const ids = `Índice de Desempenho Senatorial de ${numero(d.idsTotal)} em 100`
if (c.eixo === 'tematico') {
const rotulo = ind.rotulo.toLowerCase()
const medida =
ind.unidade === '%' ? `${numero(ind.valor)}% de ${rotulo} (${ind.detalhe})`
: ind.unidade === 'R$/mês'
? `média de R$ ${Math.round(ind.valor).toLocaleString('pt-BR')} por mês na ${rotulo} (${ind.detalhe})`
: ind.unidade.startsWith('de ')
? `${ind.valor.toLocaleString('pt-BR')} ${ind.unidade} ${rotulo} (${ind.detalhe})`
: `${ind.valor.toLocaleString('pt-BR')} ${ind.unidade}` +
(ind.detalhe.startsWith('em ') ? ` ${ind.detalhe}` : ` (${ind.detalhe})`)
return `${c.descricao} Primeiro lugar ${entre}, com ${medida}.`
}
const onde = c.eixo === 'grande_premio' ? 'de todo o Senado'
: c.eixo === 'regional' ? `da ${c.ambito}` : `de ${c.ambito}`
return `Maior ${ids} ${entre} ${onde}, ${ind.detalhe}.`
}
/** Selo dourado com texto circular. */
function selo(d: DadosCertificado): string {
const anel = d.edicao.tipo === 'legislatura'
? '57ª LEGISLATURA · 2023–2027'
: `ANO LEGISLATIVO ${d.edicao.titulo.replace(/\D/g, '')}`
return `
<svg class="selo" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<radialGradient id="ouro" cx="38%" cy="32%" r="75%">
<stop offset="0" stop-color="#f3e2b3"/>
<stop offset="0.45" stop-color="#d4b26a"/>
<stop offset="1" stop-color="#a07a32"/>
</radialGradient>
<path id="arco" d="M100,100 m-74,0 a74,74 0 1,1 148,0 a74,74 0 1,1 -148,0"/>
</defs>
<g>
${Array.from({ length: 48 }, (_, i) => {
const a = (i * 7.5 * Math.PI) / 180
const x = (100 + 97 * Math.cos(a)).toFixed(2)
const y = (100 + 97 * Math.sin(a)).toFixed(2)
return `<circle cx="${x}" cy="${y}" r="4.2" fill="#b8954f"/>`
}).join('')}
</g>
<circle cx="100" cy="100" r="95" fill="url(#ouro)"/>
<circle cx="100" cy="100" r="88" fill="none" stroke="#fff8e6" stroke-width="1" opacity=".7"/>
<circle cx="100" cy="100" r="60" fill="none" stroke="#7d5c1f" stroke-width=".8" opacity=".55"/>
<text font-family="Inter" font-size="10.5" font-weight="600" letter-spacing="2.6" fill="#5b4214">
<textPath href="#arco" startOffset="50%" text-anchor="middle">PRÊMIO OBSERVATÓRIO DO SENADO · ${anel} ·</textPath>
</text>
<text x="100" y="98" text-anchor="middle" font-family="Cormorant" font-weight="700" font-size="44" fill="#4a3510">1º</text>
<text x="100" y="122" text-anchor="middle" font-family="Inter" font-weight="600" font-size="9" letter-spacing="2.5" fill="#5b4214">LUGAR</text>
</svg>`
}
function cantoneira(classe: string): string {
return `
<svg class="canto ${classe}" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M2 58 V14 Q2 2 14 2 H58" fill="none" stroke="#C9A24B" stroke-width="1.2"/>
<path d="M8 58 V18 Q8 8 18 8 H58" fill="none" stroke="#0F3D5C" stroke-width=".6"/>
<path d="M14 14 l5 -5 l5 5 l-5 5 z" fill="#C9A24B"/>
</svg>`
}
export async function htmlCertificado(d: DadosCertificado): Promise<string> {
const { fontes, logo } = carregarRecursos()
const url = `${URL_VERIFICACAO}?codigo=${encodeURIComponent(d.codigo)}`
const qr = await QRCode.toString(url, {
type: 'svg', margin: 0, errorCorrectionLevel: 'M',
color: { dark: '#0F3D5C', light: '#00000000' },
})
const nome = `${d.senador.tratamento} ${d.senador.nome}`
// Na edição anual, a sessão legislativa situa o ano na legislatura.
const tituloEdicao = d.edicao.tipo === 'legislatura'
? d.edicao.titulo
: `${d.edicao.titulo} · ${d.edicao.subtitulo}`
return `<!doctype html>
<html lang="pt-BR"><head><meta charset="utf-8">
<title>Certificado ${esc(d.codigo)} · Prêmio Observatório do Senado</title>
<style>
${fontes}
@page { size: A4 landscape; margin: 0; }
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 297mm; height: 210mm; }
body {
font-family: 'Inter', sans-serif; color: #1a1a1a;
-webkit-print-color-adjust: exact; print-color-adjust: exact;
font-variant-numeric: lining-nums;
}
.folha {
position: relative; width: 297mm; height: 210mm; overflow: hidden;
background:
radial-gradient(ellipse at 50% 40%, #ffffff 0%, #fbfaf7 55%, #f3efe4 100%);
}
.moldura { position: absolute; inset: 7mm; border: 1.6px solid #0F3D5C; }
.moldura::after { content: ''; position: absolute; inset: 2.2mm; border: .6px solid #C9A24B; }
.canto { position: absolute; width: 17mm; height: 17mm; }
.canto.se { top: 11mm; left: 11mm; }
.canto.sd { top: 11mm; right: 11mm; transform: scaleX(-1); }
.canto.ie { bottom: 11mm; left: 11mm; transform: scaleY(-1); }
.canto.id { bottom: 11mm; right: 11mm; transform: scale(-1, -1); }
.marca {
position: absolute; left: 50%; top: 52%; width: 140mm; height: 140mm;
transform: translate(-50%, -50%); opacity: .032; mix-blend-mode: multiply;
}
.conteudo {
position: absolute; inset: 16mm 26mm 19mm; display: flex; flex-direction: column;
align-items: center; text-align: center;
}
.logo { width: 17mm; height: 17mm; mix-blend-mode: multiply; }
.instituicao {
margin-top: 2.2mm; font-size: 8.4pt; font-weight: 600; letter-spacing: .42em;
color: #0F3D5C; padding-left: .42em;
}
.filete { display: flex; align-items: center; gap: 3mm; margin-top: 2mm; color: #C9A24B; }
.filete span { display: block; width: 26mm; height: .6px; background: #C9A24B; }
.filete i { display: block; width: 1.6mm; height: 1.6mm; background: #C9A24B; transform: rotate(45deg); }
.titulo {
margin-top: 6mm; font-family: 'Cormorant'; font-weight: 600; font-size: 34pt;
letter-spacing: .16em; color: #0F3D5C; line-height: 1; padding-left: .16em;
}
.subtitulo {
font-family: 'Cormorant'; font-style: italic; font-weight: 500; font-size: 17pt;
color: #8a6a22; margin-top: 1.2mm;
}
.confere { margin-top: 8mm; font-family: 'Cormorant'; font-style: italic; font-size: 14pt; color: #3d4247; }
.nome {
margin-top: 2mm; font-family: 'Cormorant'; font-weight: 600; font-size: 37pt;
color: #0F3D5C; line-height: 1.05;
}
.partido { margin-top: 2mm; font-size: 8.2pt; font-weight: 500; letter-spacing: .3em; color: #5b6166; padding-left: .3em; }
.premio-rotulo { margin-top: 7mm; font-family: 'Cormorant'; font-style: italic; font-size: 13pt; color: #3d4247; }
.premio {
margin-top: 1mm; font-family: 'Cormorant'; font-weight: 700; font-size: 25pt;
letter-spacing: .1em; color: #8a6a22; text-transform: uppercase; padding-left: .1em;
}
.eixo { margin-top: 1mm; font-size: 7.6pt; font-weight: 600; letter-spacing: .28em; color: #0F3D5C; padding-left: .28em; text-transform: uppercase; }
.justificativa {
margin-top: 5mm; max-width: 200mm; font-size: 9.6pt; line-height: 1.6; color: #3d4247;
}
.rodape {
margin-top: auto; width: 100%; display: grid; grid-template-columns: 1fr 36mm 1fr;
align-items: end; gap: 8mm;
}
.local { text-align: left; font-size: 8.4pt; color: #3d4247; }
.local .data { font-family: 'Cormorant'; font-style: italic; font-size: 12.5pt; color: #1a1a1a; }
.assina { margin-top: 6mm; border-top: .7px solid #0F3D5C; padding-top: 1.6mm; width: 68mm; }
.assina b { display: block; font-family: 'Cormorant'; font-weight: 700; font-size: 12.5pt; color: #0F3D5C; letter-spacing: .04em; }
.assina small { font-size: 7.2pt; color: #5b6166; letter-spacing: .02em; }
.selo { width: 36mm; height: 36mm; justify-self: center; }
.verifica { display: flex; justify-content: flex-end; align-items: flex-end; gap: 3.5mm; text-align: right; }
.verifica .qr { width: 21mm; height: 21mm; }
.verifica .qr svg { width: 100%; height: 100%; display: block; }
.verifica .txt { font-size: 7pt; color: #5b6166; line-height: 1.5; }
.verifica .txt b { display: block; font-size: 6.6pt; letter-spacing: .26em; color: #0F3D5C; font-weight: 700; }
.verifica .codigo { display: block; margin-top: .8mm; font-size: 9.6pt; font-weight: 700; letter-spacing: .12em; color: #0F3D5C; font-variant-numeric: tabular-nums; }
.nota {
position: absolute; left: 20mm; right: 20mm; bottom: 12mm; text-align: center;
font-size: 5.8pt; line-height: 1.5; color: #8a8e93; letter-spacing: .02em;
}
</style></head>
<body><div class="folha">
<div class="moldura"></div>
${cantoneira('se')}${cantoneira('sd')}${cantoneira('ie')}${cantoneira('id')}
<img class="marca" src="${logo}" alt="">
<div class="conteudo">
<img class="logo" src="${logo}" alt="">
<div class="instituicao">OBSERVATÓRIO DO SENADO</div>
<div class="filete"><span></span><i></i><span></span></div>
<div class="titulo">CERTIFICADO</div>
<div class="subtitulo">de Reconhecimento</div>
<div class="confere">O Observatório do Senado confere este reconhecimento a</div>
<div class="nome">${esc(nome)}</div>
<div class="partido">${esc(d.senador.partido)} · ${esc(d.senador.uf)}</div>
<div class="premio-rotulo">pelo primeiro lugar na categoria</div>
<div class="premio">${esc(d.categoria.titulo)}</div>
<div class="eixo">${d.categoria.eixo === 'grande_premio' ? '' : `${esc(ROTULO_EIXO[d.categoria.eixo])} · `}${esc(tituloEdicao)}</div>
<p class="justificativa">${esc(justificativa(d))}</p>
<div class="rodape">
<div class="local">
<div class="data">Brasília, ${dataPorExtenso(d.emitidoEm)}</div>
<div class="assina">
<b>Observatório do Senado</b>
<small>Iniciativa cidadã independente, sem vínculo partidário</small>
</div>
</div>
${selo(d)}
<div class="verifica">
<div class="txt">
<b>AUTENTICIDADE</b>
Confira em<br>observasenado.org/premiacao/verificar
<span class="codigo">${esc(d.codigo)}</span>
</div>
<div class="qr">${qr}</div>
</div>
</div>
</div>
<div class="nota">
Apurado com dados abertos do Senado Federal pelo Índice de Desempenho Senatorial (IDS)${d.edicao.metodologiaVersao ? ` ${esc(d.edicao.metodologiaVersao)}` : ''} ·
metodologia e código-fonte públicos em observasenado.org · resultado SHA-256 ${esc(d.edicao.apuracaoSha256.slice(0, 16))}
</div>
</div></body></html>`
}
/** Uma impressão por vez: várias instâncias do Chrome em paralelo só disputam memória. */
let fila: Promise<unknown> = Promise.resolve()
export function imprimirPdf(html: string): Promise<Uint8Array> {
const tarefa = fila.then(async () => {
const dir = await mkdtemp(join(tmpdir(), 'observa-cert-'))
try {
const entrada = join(dir, 'certificado.html')
const saida = join(dir, 'certificado.pdf')
await writeFile(entrada, html, 'utf8')
await run(CHROME, [
'--headless=new',
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
'--disable-extensions',
// Perfil descartável: não toca no Chrome de quem usa a máquina.
`--user-data-dir=${join(dir, 'perfil')}`,
'--no-pdf-header-footer',
'--run-all-compositor-stages-before-draw',
'--virtual-time-budget=4000',
`--print-to-pdf=${saida}`,
`file://${entrada}`,
], { timeout: 60_000 })
return new Uint8Array(await readFile(saida))
} finally {
await rm(dir, { recursive: true, force: true })
}
})
fila = tarefa.catch(() => undefined)
return tarefa
}
/**
* PDF do certificado: o guardado, ou gera e guarda na primeira vez.
* Certificado revogado continua baixável (para conferência), mas a
* verificação pública mostra a revogação.
*/
export async function pdfDoCertificado(env: Env, codigo: string): Promise<Uint8Array | null> {
const guardado = await lerPdf(env, codigo)
if (guardado) return guardado
const cert = await obterCertificado(env, codigo)
if (!cert) return null
const pdf = await imprimirPdf(await htmlCertificado(cert.dados))
await guardarPdf(env, codigo, pdf)
// Outra requisição pode ter guardado antes: devolve sempre o que ficou no banco.
return (await lerPdf(env, codigo)) ?? pdf
}
export function nomeArquivo(d: DadosCertificado): string {
const slug = (s: string) =>
s.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^A-Za-z0-9]+/g, '-')
.replace(/^-|-$/g, '').toLowerCase()
return `certificado-${slug(d.edicao.id)}-${slug(d.categoria.titulo)}-${slug(d.senador.nome)}.pdf`
}