Iniciativa Cidadã Independente · Transparência Parlamentar
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) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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`
}