Si trabajas con planteles de la SEP en México (CBTis, secundarias generales, primarias, prepas estatales) sabes el ritual mensual: a fin de mes hay que entregar reportes de asistencia, observaciones de conducta, calificaciones parciales — todos con encabezado institucional, logo de la SEP, firma del director, sello del plantel. Hacerlos a mano cuesta 6-12 horas a la coordinación cada mes. Este artículo es el approach técnico que usamos en KEYON Access para generar +200 reportes mensuales en producción con cero rechazos por formato. Stack: Node 22 + Puppeteer + Cloud Functions.

Lo que la SEP realmente pide

Antes de escribir código, hay que entender el formato. Los reportes que aceptan sin rebotar tienen estos elementos:

markdown
ELEMENTOS OBLIGATORIOS DE UN REPORTE SEP

1. Encabezado institucional
   • Logo SEP (esquina superior izquierda)
   • Logo subsistema (DGETI / CONALEP / COBAEZ / etc.)
   • Logo del plantel (opcional pero esperado)
   • Texto "SECRETARÍA DE EDUCACIÓN PÚBLICA"
   • Subsistema y nombre completo del plantel
   • CCT (Centro de Trabajo) — clave de 10 dígitos
   • Ciclo escolar (formato AAAA-AAAA)

2. Datos del reporte
   • Nombre del reporte
   • Periodo (mes/parcial/semestre)
   • Fecha de generación

3. Cuerpo
   • Tablas con datos (asistencia, calificaciones, etc.)
   • Filas alineadas, columnas con anchos consistentes
   • Sin texto cortado o desbordado

4. Firma y sello
   • Línea de firma del director
   • Nombre completo y cargo
   • Espacio para sello físico (típico en reportes mensuales)

5. Pie de página
   • Página X de Y
   • Fecha y hora de generación
   • Sistema que generó el documento

Por qué Puppeteer y no jsPDF / pdfkit

Hay tres rutas comunes para generar PDFs en Node:

jsPDF (cliente)

Genera PDFs directamente en el browser. Bueno para cosas simples (tickets, recibos) pero las tablas complejas, estilos institucionales y multi-página son dolor de cabeza. Para reportes SEP institucionales que tienen logos, headers fijos por página, footers con paginación, jsPDF se vuelve un infierno de coordenadas absolutas.

pdfkit (Node)

API low-level, control total, performance excelente. Perfecto si tienes una hoja en blanco y vas dibujando. Pero todo es código imperativo: doc.fontSize(14).text(...). Cambiar el diseño implica reescribir muchas líneas. No reutilizas las plantillas HTML que ya tienes.

Puppeteer (Node + Chromium)

Lanza un Chromium headless, le das un HTML con CSS, y te devuelve un PDF perfecto. Reutilizas Tailwind, las tablas se ven idénticas a tu panel admin, y el debugging es trivial: abres el HTML en tu browser y ves el problema. Costo: arrastras Chromium en el bundle (~250 MB en Cloud Functions). Para reportes institucionales vale la pena por mucho.

La regla mental

Si tu PDF se vería bien como página web, usa Puppeteer. Si necesitas control milimétrico de coordenadas (tickets de impresora térmica, formatos fiscales), pdfkit. Para reportes SEP escolares, Puppeteer gana siempre.

Setup base en Cloud Functions v2

La trampa de Puppeteer en Firebase Functions: el Chromium completo es muy pesado. La solución: @sparticuz/chromium — Chromium pre-compilado optimizado para serverless.

bash
cd functions
npm install puppeteer-core @sparticuz/chromium handlebars
json
{
  "engines": { "node": "22" },
  "dependencies": {
    "@sparticuz/chromium": "^131.0.0",
    "puppeteer-core": "^24.0.0",
    "handlebars": "^4.7.8",
    "firebase-admin": "^13.0.0",
    "firebase-functions": "^6.0.0"
  }
}

La function base que renderiza un HTML a PDF:

javascript
import { onCall } from 'firebase-functions/v2/https';
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium';
import Handlebars from 'handlebars';
import fs from 'fs/promises';
import path from 'path';

export const generarReporteAsistencia = onCall(
  {
    memory: '1GiB',          // Mínimo para Chromium
    timeoutSeconds: 120,
    region: 'us-central1',
  },
  async (request) => {
    const { plantelId, mes, anio } = request.data;

    // 1) Cargar template HTML
    const templatePath = path.join(__dirname, 'templates', 'asistencia.hbs');
    const templateSource = await fs.readFile(templatePath, 'utf8');
    const template = Handlebars.compile(templateSource);

    // 2) Cargar datos del reporte (Firestore)
    const data = await cargarDatosAsistencia(plantelId, mes, anio);

    // 3) Renderizar HTML con datos
    const html = template({
      plantel: data.plantel,
      mes: NOMBRES_MES[mes],
      anio,
      ciclo: `${anio}-${anio + 1}`,
      alumnos: data.alumnos,
      generado: new Date().toLocaleString('es-MX'),
    });

    // 4) Lanzar Chromium y generar PDF
    const browser = await puppeteer.launch({
      args: chromium.args,
      executablePath: await chromium.executablePath(),
      headless: true,
    });

    try {
      const page = await browser.newPage();
      await page.setContent(html, { waitUntil: 'networkidle0' });

      const pdfBuffer = await page.pdf({
        format: 'Letter',
        printBackground: true,
        margin: { top: '60px', bottom: '50px', left: '40px', right: '40px' },
        displayHeaderFooter: true,
        headerTemplate: HEADER_TEMPLATE,
        footerTemplate: FOOTER_TEMPLATE,
      });

      // 5) Subir a Storage y devolver URL firmada
      const filename = `reportes/${plantelId}/asistencia-${anio}-${mes}.pdf`;
      const file = admin.storage().bucket().file(filename);
      await file.save(pdfBuffer, { contentType: 'application/pdf' });

      const [url] = await file.getSignedUrl({
        action: 'read',
        expires: Date.now() + 24 * 60 * 60 * 1000, // 24 horas
      });

      return { url, filename };
    } finally {
      await browser.close();
    }
  }
);

El template HTML institucional

Aquí está el truco: usar Tailwind embedido (vía CDN dentro del HTML, NO compilado) o un CSS custom. Recomendamos CSS custom para PDFs porque Tailwind por CDN agrega latency a cada render.

html
<!DOCTYPE html>
<html lang="es-MX">
<head>
  <meta charset="UTF-8" />
  <title>Reporte de Asistencia · {{plantel.nombre}}</title>
  <style>
    @page { size: Letter; margin: 0; }
    body {
      font-family: 'Segoe UI', system-ui, sans-serif;
      font-size: 11px;
      color: #111;
      margin: 0;
    }

    .header-institucional {
      display: flex;
      align-items: center;
      gap: 16px;
      border-bottom: 2px solid #0a0a0a;
      padding: 18px 40px 14px;
    }
    .header-institucional img { height: 50px; }
    .header-institucional h1 {
      font-size: 13px;
      margin: 0;
      letter-spacing: 0.2px;
    }
    .header-institucional .sub {
      color: #555;
      font-size: 10px;
    }

    .titulo-reporte {
      padding: 24px 40px 8px;
      font-weight: 600;
      font-size: 16px;
    }
    .meta-reporte {
      padding: 0 40px 18px;
      color: #555;
      font-size: 11px;
    }

    table.asistencia {
      width: calc(100% - 80px);
      margin: 0 40px;
      border-collapse: collapse;
      font-size: 10px;
    }
    table.asistencia th {
      background: #0a0a0a;
      color: white;
      text-align: left;
      padding: 8px 10px;
      font-weight: 500;
      letter-spacing: 0.4px;
      text-transform: uppercase;
      font-size: 9px;
    }
    table.asistencia td {
      padding: 7px 10px;
      border-bottom: 1px solid #e5e5e5;
    }
    table.asistencia tr:nth-child(even) td { background: #fafafa; }

    .firma-box {
      margin: 60px 40px 0;
      display: flex;
      justify-content: space-around;
      page-break-inside: avoid;
    }
    .firma {
      text-align: center;
      width: 240px;
    }
    .firma .linea {
      border-bottom: 1px solid #111;
      margin-bottom: 6px;
      height: 30px;
    }
    .firma .nombre { font-weight: 600; font-size: 11px; }
    .firma .cargo { color: #666; font-size: 10px; }

    @media print {
      .page-break { page-break-after: always; }
    }
  </style>
</head>
<body>
  <div class="header-institucional">
    <img src="{{logoSepBase64}}" alt="SEP" />
    <img src="{{logoSubsistemaBase64}}" alt="DGETI" />
    <div>
      <h1>SECRETARÍA DE EDUCACIÓN PÚBLICA</h1>
      <div class="sub">{{plantel.subsistema}} · {{plantel.nombre}}</div>
      <div class="sub">CCT: {{plantel.cct}} · Ciclo {{ciclo}}</div>
    </div>
  </div>

  <div class="titulo-reporte">Reporte mensual de asistencia</div>
  <div class="meta-reporte">
    Periodo: {{mes}} {{anio}} · Generado: {{generado}}
  </div>

  <table class="asistencia">
    <thead>
      <tr>
        <th style="width: 70px">Control</th>
        <th>Nombre</th>
        <th style="width: 60px">Grupo</th>
        <th style="width: 60px; text-align: right">Asist.</th>
        <th style="width: 60px; text-align: right">Faltas</th>
        <th style="width: 60px; text-align: right">Retardos</th>
        <th style="width: 70px; text-align: right">% Asist.</th>
      </tr>
    </thead>
    <tbody>
      {{#each alumnos}}
      <tr>
        <td>{{control}}</td>
        <td>{{nombre}}</td>
        <td>{{grupo}}</td>
        <td style="text-align: right">{{asistencias}}</td>
        <td style="text-align: right">{{faltas}}</td>
        <td style="text-align: right">{{retardos}}</td>
        <td style="text-align: right; font-weight: 600">{{porcentaje}}%</td>
      </tr>
      {{/each}}
    </tbody>
  </table>

  <div class="firma-box">
    <div class="firma">
      <div class="linea"></div>
      <div class="nombre">{{plantel.directorNombre}}</div>
      <div class="cargo">Director del plantel</div>
    </div>
    <div class="firma">
      <div class="linea"></div>
      <div class="nombre">{{plantel.subdirectorNombre}}</div>
      <div class="cargo">Subdirección Académica</div>
    </div>
  </div>
</body>
</html>

Esto es el gotcha más común. Puppeteer permite header/footer fijos en cada página, pero requieren un formato HTML especial con clases mágicas:

javascript
const HEADER_TEMPLATE = `
  <div style="font-size: 9px; width: 100%; padding: 0 40px; color: #888;">
    <span class="title"></span>
  </div>
`;

const FOOTER_TEMPLATE = `
  <div style="font-size: 9px; width: 100%; padding: 0 40px; color: #888; display: flex; justify-content: space-between;">
    <span>Generado por KEYON Access · exara.uk/keyon-access</span>
    <span>Página <span class="pageNumber"></span> de <span class="totalPages"></span></span>
  </div>
`;

Las clases title, pageNumber, totalPages, date, url son inyectadas por Chromium automáticamente. Es feo pero funciona.

Logos: cuidado con las URLs externas

Si pones <img src="https://upload.wikimedia.org/sep-logo.png"> en tu HTML, Puppeteer va a intentar cargarla cuando renderiza. Si el render se ejecuta en una Cloud Function sin acceso a internet (o con latencia alta), el PDF sale sin logo o tarda muchísimo.

La solución: convertir los logos a base64 y embedded en el HTML.

javascript
import fs from 'fs/promises';

async function loadLogoBase64(filename) {
  const buffer = await fs.readFile(
    path.join(__dirname, 'assets', filename)
  );
  const base64 = buffer.toString('base64');
  const mime = filename.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
  return `data:${mime};base64,${base64}`;
}

// Al renderizar:
const html = template({
  ...
  logoSepBase64: await loadLogoBase64('sep-logo.png'),
  logoSubsistemaBase64: await loadLogoBase64('dgeti-logo.png'),
});

Los logos institucionales no cambian. Cárgalos una vez al boot de la function (en módulo top-level) para no repetir la operación cada invocación.

Performance — los tres ajustes que importan

1. Memoria 1GiB mínimo

Chromium necesita memoria. Por debajo de 512MB se cae random. Por arriba de 2GiB ya no mejora. Sweet spot: 1GiB. Funciones que generan PDFs de 50+ páginas: 2GiB.

2. waitUntil networkidle0 vs domcontentloaded

networkidle0 espera a que no haya conexiones HTTP activas (mejor para imágenes externas). domcontentloaded es más rápido (700ms vs 2s) si todo está embedded. Si tu HTML no tiene fuentes ni imágenes externas, usa domcontentloaded.

3. Reusar el browser instance entre invocaciones

Las Cloud Functions v2 mantienen instancias warm por unos minutos. Si lanzar Chromium toma 1.5-2 segundos, es un costo significativo. Patrón:

javascript
let browserInstance = null;

async function getBrowser() {
  if (browserInstance && browserInstance.isConnected()) {
    return browserInstance;
  }
  browserInstance = await puppeteer.launch({
    args: chromium.args,
    executablePath: await chromium.executablePath(),
    headless: true,
  });
  return browserInstance;
}

// En la function:
const browser = await getBrowser();
const page = await browser.newPage();
// ... generar PDF ...
await page.close();  // cerrar la PAGE, no el browser

Cuidado: si tu function se reinicia (cold start), se inicializa nuevo browser. No depender de que se mantenga vivo.

Generar 1 PDF en cold start: 4-6 segundos. Generar el segundo en la misma instancia: 1.2-1.8 segundos. La diferencia entre pre-warm y cold tiene impacto en UX cuando un usuario pide reportes consecutivos.

Errores comunes y fixes

1. Error: TimeoutError: Navigation timeout of 30000 ms

Causa: Puppeteer espera networkidle0 pero alguna imagen/recurso nunca termina de cargar. Fix: revisar las URLs en tu HTML. La causa típica es una imagen externa que falla pero no aborta la navegación.

2. PDF en blanco o con elementos cortados

Causa: el contenido se renderiza después de que Puppeteer toma el PDF. Pasa si inyectas datos vía JavaScript después del DOM ready. Fix: agregar await page.waitForSelector('.contenido') antes del page.pdf().

3. Tablas con filas cortadas entre páginas

Causa: filas individuales se parten en medio. Fix: agregar al CSS tr { page-break-inside: avoid; }. Para tablas enteras: page-break-inside: avoid en <table>.

4. Fuentes que no se renderizan correctamente

Causa: Chromium serverless no tiene fuentes instaladas. Fix: embedded font como base64 en el CSS, o usa solo fuentes web-safe (system-ui, Helvetica, Arial).

5. Error de memoria en planteles grandes

Causa: PDF con 1000+ alumnos en una tabla revienta el render. Fix: paginar manualmente — generar 1 página por grupo escolar y luego merge con pdf-lib.

Caso real KEYON: 200 reportes/mes en producción

KEYON Access genera 5 tipos de reportes mensuales para CBTis No. 001:

markdown
TIPO                        ALCANCE         PÁGINAS  TIEMPO RENDER
Asistencia mensual          Por grupo (30)  3-5 c/u  1.4 s c/u
Conducta y observaciones    Por grupo (30)  2-3 c/u  1.0 s c/u
Acta de evaluación          Por grupo (30)  4-6 c/u  1.6 s c/u
Resumen del director        General (1)     8-10     2.8 s
Cumplimiento LFPDPPP        General (1)     2        0.9 s

Total mensual: ~125 PDFs, 800-1000 páginas
Tiempo total render mensual: ~3.5 minutos (paralelo)
Costo Cloud Functions:        ~$0.40 USD / mes

Antes de la automatización, la coordinación gastaba ~12 horas/mes armando estos reportes en Word. Ahora se generan con un click y se descargan en ZIP.

Cierre

Puppeteer + Tailwind CSS embedded + Cloud Functions v2 con 1GiB de memoria es el stack que ha procesado +1,200 reportes en producción sin un solo rebote por formato. La curva de aprendizaje es de un día (la primera vez); después es agregar templates HTML según necesites nuevos reportes.

Si tu proyecto necesita generación de PDFs institucionales (SAT, IMSS, SEP, INE) y te trabaste en alguno de los puntos de este artículo, escríbenos en exara.uk/contacto con el escenario y te respondemos con la implementación específica en menos de 24 horas.