+250 skills, dinamita para tu productividad 🧨Explorar →

Cómo crear una factura profesional en PDF con pdfcn

Le pedí a un agente una factura española en PDF. Con IVA de dos tipos, con retención de IRPF, con datos de prueba y con aspecto de factura de verdad, no de tabla de Excel exportada con prisa. Un prompt. Trece minutos después tenía un archivo de 42 KB, una sola página A4, texto seleccionable y un QR de verificación en la esquina.

Lo que me enganchó fue de qué está hecho por dentro.

Porque el agente no llamó a una API de terceros ni levantó un navegador headless. Usó pdfcn, que es shadcn/ui pero para documentos PDF: te copias los componentes a tu repositorio, los tuneas y los renderizas con un motor escrito en Rust que ni sabe lo que es Chromium.

Este artículo va de eso. De la factura como excusa para abrir la caja.

Lo que cubre este artículo:

  • Qué es pdfcn y por qué el modelo de registry de shadcn encaja tan bien en la generación de PDF
  • El catálogo real: 24 componentes, 10 bloques, 9 temas y dos motores de renderizado
  • Cómo funciona el sistema de temas por tokens y cómo derivar el tuyo sin tocar los presets
  • Los tres trucos internos que hacen que esto funcione: el ThemeProvider sin contexto, las primitivas que son div y la conversión de puntos a píxeles
  • Cómo se controlan los saltos de página, las fuentes embebidas y los encabezados repetidos
  • Qué hace falta para que ese PDF sea una factura electrónica válida (PDF/A-3, Factur-X) y qué no te va a dar la librería

¿Qué es pdfcn y por qué se parece tanto a shadcn/ui?

pdfcn es una colección de componentes React para generar documentos PDF que se instala con el CLI de shadcn y se copia a tu proyecto en lugar de vivir en node_modules.

La filosofía que declara la propia documentación es “copy-paste components you can own, with zero lock-in”. Cuando ejecutas esto:

npx shadcn@latest add @pdfcn/takumi/table

no estás añadiendo una dependencia. Estás copiando table.tsx, table.styles.ts y table.types.ts dentro de tu repositorio, con sus dependencias de tema y de primitivas arrastradas detrás. A partir de ese momento, ese código es tuyo. Si el borde de la celda no te gusta, lo cambias. Si necesitas una variante que no existe, la escribes.

Para registrar el namespace basta una entrada en components.json:

{
  "registries": {
    "@pdfcn": "https://pdfcn.dev/r/{name}.json"
  }
}

Y si no quieres el alias, cada página de la documentación te da la URL completa del JSON, porque el registry es una API pública de ficheros JSON. En el experimento de la factura ni siquiera usé el CLI de shadcn, porque el proyecto era un Node pelado sin components.json. Descargué los JSON directamente, escribí un extractor de 50 líneas que reescribía los imports @/registry/... a rutas relativas, y listo. 21 ficheros, 3.634 líneas de código dentro del proyecto.

Puedes hacer eso porque no hay runtime propietario en medio. Es el mismo motivo por el que shadcn/ui se comió el mercado de las librerías de componentes de React.

🔑 Una librería normal te da una caja negra con props. pdfcn te da el código fuente y se aparta. Cuando el diseño de un documento se sale del carril (y en facturas se sale siempre), con la caja negra abres un issue y esperas; con el código delante lo arreglas en veinte minutos.

¿Cómo se reparte una factura entre los componentes de pdfcn?

Esta es la factura que salió del experimento. Datos inventados, fiscalidad real: cuatro conceptos con IVA al 21% y al 10%, retención de IRPF del 15% sobre la base imponible, importes en euros con separador de miles español.

Factura A4 generada con pdfcn: cabecera con logotipo y número de factura, etiqueta FACTURA, bloques de emisor y cliente sobre fondo gris, tabla de cuatro conceptos con cebra e IVA por línea, código QR de verificación, desglose de base imponible, IVA y retención de IRPF, y bloque TOTAL en azul marino

Cada zona del documento la dibuja un componente distinto del catálogo. Este es el reparto:

Zona del PDF Componente Por qué ese
Cabecera con logotipo y número PageHeader variante logo-left Ya trae siete disposiciones; ésta pone al proveedor arriba a la izquierda
Etiqueta “FACTURA” Badge variante primary Toma el color de marca del tema sin escribir CSS
Emisor y cliente View + Text sobre fondo muted Los datos fiscales no son pares clave-valor genéricos
Líneas de concepto Table variante grid con cebra Cuadrícula real y filas alternas para leer importes
Desglose de base, IVA e IRPF KeyValue con divisores Es el patrón canónico de totales en la librería
Bloque TOTAL destacado View propio con fondo primary Aquí la librería se queda corta y se baja al metal
QR de verificación PdfQRCode Dibuja la matriz como rectángulos SVG
Logotipo PdfImage con data URI Encaja dentro del slot logo del PageHeader
Pie con nota legal PageFooter Sin sticky, por un motivo que veremos luego

Fíjate en la fila del TOTAL. El KeyValue de pdfcn llega hasta el subtotal y ahí se para, así que el bloque azul marino con el importe final es un View con estilos propios que sigue leyendo los colores del tema. Bajas al nivel de la primitiva cuando el componente no cubre tu caso, sin salirte del sistema.

La celda de importe lleva dos pisos: la base en grande y, debajo y en pequeño, la cuota de IVA de esa línea concreta. Un TableCell acepta hijos arbitrarios, no solo texto, así que dentro caben dos Text con variantes distintas. Ningún bloque prefabricado de los que trae la librería lo hace así.

Curso gratis · paso a paso

Un documento antes que el código, también cuando escribe el agente

Esta factura salió bien porque el agente dejó por escrito sus decisiones antes de tocar nada. Ese es el ciclo de SDD con OpenSpec: proposal, spec, diseño y tareas sobre un proyecto real, de principio a fin.

Entra en el curso gratis →

¿Qué trae el catálogo de componentes?

24 componentes por cada base de renderizado, 10 bloques de documento completo y 9 temas. Los componentes se agrupan en cuatro familias que se corresponden bastante bien con lo que un documento necesita.

Contenido y tipografía. Text con siete tamaños de la escala (xs a 3xl) y cuatro pesos, Heading con seis niveles y control de keepWithNext, PdfList con seis variantes (viñetas, numerada, checklist, iconos, multinivel, descriptiva), Link que genera anotaciones clicables de verdad en el PDF, y Divider.

Datos. Aquí hay dos piezas que se solapan a propósito. Table son primitivas de bajo nivel (TableHeader, TableRow, TableCell) con siete variantes visuales y control fino de anchos por celda. DataTable es la versión declarativa: le pasas columns y data y él monta la tabla, con render por columna y fila de totales. Para la factura usé las primitivas porque cada celda de importe llevaba dos líneas de tipografía distinta. Para un listado de 300 movimientos bancarios, DataTable te ahorra el bucle.

Completan el grupo KeyValue (listas de definición con divisores configurables), PdfGraph (barras, líneas, área, tarta y donut, dibujados como SVG) y PdfForm, que genera grupos de campos etiquetados en una, dos o tres columnas.

Estructura de página. Section, Stack, PdfCard, PageHeader y PageFooter con posicionamiento fijo opcional, PageNumber, PageBreak y KeepTogether. Este último es el que evita que un bloque de firma se parta por la mitad al pasar de página, y lo vemos en la sección de saltos.

Piezas de documento. Badge, PdfAlert con cuatro severidades, PdfImage con modos de ajuste, PdfQRCode, PdfSignatureBlock (con variante de doble firmante) y PdfWatermark.

Y por encima, los bloques: seis plantillas de factura (invoice-classic, consultant, corporate, creative, minimal, modern) y cuatro de informe (report-financial, marketing, operations, security). Se instalan igual que un componente.

En el experimento no usé ninguno. Los leí, eso sí, para ver cómo montaban los totales y el pie de página, pero ninguno modela la fiscalidad española: todos llevan un único campo tax genérico y trabajan en dólares. Un IVA por línea con dos tipos distintos y una retención que resta no cabe ahí. La ventaja es que el bloque es solo un punto de partida que se copia a tu proyecto, así que “no me sirve” y “lo tiro” cuestan lo mismo.

💡 Si tu documento se parece a una factura estadounidense estándar, empieza por el bloque. Si tiene cualquier particularidad fiscal, empieza por los componentes. Vas a acabar reescribiendo el bloque entero de todos modos.

¿Cómo funciona el sistema de temas por tokens?

Un tema de pdfcn es un objeto TypeScript con cuatro capas de tokens, y todos los componentes leen de ahí en lugar de tener valores fijos.

La capa de colores son doce tokens semánticos, con la nomenclatura que ya conoces de shadcn: foreground, background, muted, mutedForeground, primary, primaryForeground, border, accent, destructive, success, warning, info. Todos en hexadecimal. La documentación avisa de algo que te va a morder si vienes de una app moderna: oklch no está soportado. Hex, rgb() y hsl() sí.

La capa de tipografía separa cuerpo y titulares, con la escala de seis tamaños para h1 a h6. La de espaciado define márgenes de página y tres huecos (sectionGap, paragraphGap, componentGap). Y debajo de todo están las primitivas: las escalas crudas de las que los temas eligen. La escala tipográfica sigue una tercera mayor (razón 1,25) sobre una base de 12 pt, el espaciado es una rejilla de 4 pt, y hay escalas de pesos, alturas de línea, radios de borde y letter-spacing.

Si has trabajado con tokens de diseño escritos en un fichero que lee un agente, el modelo te va a resultar familiar: la decisión visual vive en un sitio y todo lo demás la consulta.

Vienen nueve presets: Professional, Modern, Minimal, Executive, Corporate, Elegant, Vivid, Forest y Blueprint. No son variaciones de color, son personalidades tipográficas completas: Minimal usa Courier en los titulares, Blueprint es slate oscuro con acento cian y monoespaciada, Executive va de navy profundo con Merriweather.

Para la factura derivé un tema propio del preset Executive sin tocarlo:

export const facturaTheme: PdfcnTheme = {
  ...executiveTheme,
  name: "factura-executive",
  spacing: {
    ...executiveTheme.spacing,
    // Executive trae márgenes de 64pt: demasiado aire para una A4 llena
    page: { marginTop: 36, marginRight: 52, marginBottom: 36, marginLeft: 52 },
  },
  typography: {
    body: { fontFamily: "Open Sans", fontSize: 10, lineHeight: 1.55 },
    heading: {
      fontFamily: "Merriweather",
      fontWeight: 700,
      lineHeight: 1.25,
      fontSize: { h1: 34, h2: 26, h3: 20, h4: 16, h5: 14, h6: 12 },
    },
  },
};

Spread del preset, sobreescribes dos claves, y todos los componentes del documento se enteran. El bloque TOTAL cambia de color porque lee theme.colors.primary. El QR se dibuja en navy porque le pasas ese mismo token. La tabla ajusta el tamaño de sus celdas porque lee theme.typography.body.fontSize.

Cualquier prop de color de un componente acepta o un token del tema o un color CSS crudo. Una función de tres líneas, resolveColor, mira si el string está en la lista de tokens conocidos y, si no, lo devuelve tal cual. Así color="primary" y color="#1e3a5f" funcionan los dos.

Un sistema de tokens bien montado te ahorra meses de retoques a mano. Cada domingo seleccionamos 12 recursos sobre herramientas y forma de trabajar, y ya somos +7.200 al otro lado.

Quiero esa dinamita 🧨

¿Por qué el ThemeProvider de pdfcn no es un contexto de React?

Porque el árbol que escribes nunca llega a React DOM. Se serializa y se manda a un motor de renderizado, y en ese camino los hooks de React no existen.

Abre theme-provider.tsx y te encuentras esto:

let serializedTheme = professionalTheme;

const renderForSerializer = (children, theme) => {
  serializedTheme = theme;
  if (!isValidElement(children) || typeof children.type !== "function") {
    return children;
  }
  // Invoca el componente hijo a mano, como una función normal
  return children.type(children.props);
};

export const usePdfcnTheme = () => serializedTheme;

usePdfcnTheme no es un hook. Es un getter de una variable de módulo. PdfcnThemeProvider asigna esa variable y después llama al componente hijo como si fuera una función, no lo devuelve como elemento. Hay incluso un useSafeMemo que ignora el array de dependencias y ejecuta la factoría siempre.

Es un patrón que en una aplicación web te costaría la revisión de código. Aquí es correcto, porque el render de un PDF es una pasada única y síncrona: no hay reconciliación, no hay estado, no hay segunda ejecución. El coste que pagas es que no puedes montar dos temas distintos en el mismo render: la variable de módulo es una sola. Si necesitas un documento con dos identidades visuales, son dos renders.

⚠️ Este truco explica también por qué los componentes de pdfcn se ven raros si intentas montarlos en una página web normal. Están hechos para ser serializados, no para el navegador.

¿Qué son de verdad Document, Page y View?

div. Los tres son un div con display: flex y flexDirection: column.

export const Page = ({ children, size: _size, style }) => (
  <div data-pdf-page style={{ display: "flex", flexDirection: "column", ...flatten(style) }}>
    {children}
  </div>
);

Mira ese size: _size. La prop se acepta y se descarta: el tamaño de página real lo decide la llamada a render(), no el JSX. Si escribes <Page size="A4"> y luego renderizas con { size: "letter" }, gana la segunda. Es un vestigio de compatibilidad de API con @react-pdf/renderer, igual que StyleSheet.create, que en esta implementación es la función identidad: recibe un objeto y devuelve el mismo objeto.

Text es un span, salvo que lleve href, y entonces es un <a>. Image es un img. Toda la librería es HTML con estilos en línea.

Lo que sí hace la capa de primitivas es traducir unidades. Una constante lo resume:

export const PDF_POINT_TO_CSS_PIXEL = 96 / 72;

Los componentes de pdfcn hablan en puntos, porque es la unidad del mundo PDF y la que usa la otra base de renderizado. Takumi habla en píxeles CSS a 96 dpi, porque su modelo mental es el navegador. Así que la capa de primitivas mantiene un conjunto con 60 propiedades de longitud (margin, padding, fontSize, borderRadius, gap, width…) y multiplica por 1,333 solo esas, dejando intactas las que no son longitudes.

El mismo sitio normaliza tres cosas más: expande los atajos de React Native (marginHorizontal a izquierda y derecha, paddingVertical a arriba y abajo), añade borderStyle: "solid" cuando pones un borderWidth sin estilo, y traduce las props de paginación a CSS. wrap={false} se convierte en breakInside: "avoid". break se convierte en breakBefore: "page". fixed se convierte en position: "fixed".

La API de paginación de pdfcn es azúcar sobre propiedades CSS de impresión.

¿Cómo se convierte un árbol de JSX en un PDF sin abrir un navegador?

Con Takumi, un motor escrito en Rust que se distribuye compilado a WebAssembly y que renderiza JSX, HTML y árboles de nodos sin proceso de navegador de por medio.

El paquete takumi-pdf de mi node_modules pesa 4 MB, de los cuales 3,9 MB son el binario .wasm. Para comparar: en la caché de Playwright de este mismo portátil, cada versión de Chromium ocupa entre 333 y 437 MB. Dos órdenes de magnitud de diferencia en lo que tienes que meter en la imagen de Docker.

La llamada de render es esta:

import { googleFonts } from "@takumi-rs/helpers";
import { render } from "takumi-pdf";

const fonts = await googleFonts([
  { name: "Merriweather", weight: [400, 700] },
  { name: "Open Sans", weight: [400, 600, 700] },
]);

const pdf = await render(doc, {
  size: "a4",
  margin: { top: 36, right: 52, bottom: 36, left: 52 },
  fonts,
});

Devuelve un Uint8Array. Lo escribes a disco, lo mandas en la respuesta HTTP o lo subes a un bucket. Funciona en Node, en Bun y en Cloudflare Workers, hay una entrada específica para route handlers de Next.js (takumi-pdf/next) y otra para inicialización manual en el navegador.

Tres cosas de este motor conviene tener claras desde el minuto uno.

Las fuentes hay que registrarlas siempre. Takumi no lee las fuentes del sistema. Ninguna. Si un carácter del cuerpo del documento no está cubierto por ninguna fuente registrada, el render falla y te dice qué carácter y qué codepoint. Puede sonar hostil, pero es lo contrario de lo que hace un navegador headless, que te sustituye la fuente en silencio y te enteras cuando el cliente abre el PDF. El helper googleFonts() descarga los ficheros y te los devuelve listos; también puedes pasar bytes crudos y una cadena de fallback con fontFamilies.

Ojo con un matiz: Google Fonts reparte los caracteres acentuados entre los subconjuntos latin y latin-ext. En un documento en español eso importa.

Las imágenes remotas no se descargan solas. El renderer de PDF no hace fetch de las URLs de tu documento. O le pasas los bytes en la opción images, o incrustas la imagen como data URI. En la factura el logotipo va como data URI de PNG en base64, tres líneas de readFile y toString("base64").

Lo que sí hace bien es el vector: una fuente SVG se incrusta como paths y gradientes, así que un logotipo se mantiene nítido a cualquier zoom. Los PNG, JPEG y WebP se incrustan tal cual (un JPEG conserva su compresión original). Los GIF se rechazan, y también filter: blur() y drop-shadow(), que hay que dejar horneados en la imagen antes de renderizar.

El texto sigue siendo texto. Las fuentes se incrustan como subconjuntos y el cuerpo del documento es seleccionable y buscable. La excepción son las etiquetas dentro de un SVG, que se dibujan como contornos: se ven, pero no se pueden seleccionar ni extraer. Si generas un gráfico, la recomendación de la documentación es poner el título y los datos en el JSX que lo rodea, no dentro del SVG.

¿Cómo se controlan los saltos de página?

Con propiedades CSS de impresión, las mismas que llevan veinte años en la especificación y que casi nadie usa porque casi nadie imprime.

Propiedad Efecto
break-before: page Empieza el elemento en página nueva
break-after: page Manda a página nueva lo que venga después
break-inside: avoid Mantiene el elemento en una página, si cabe
box-decoration-break: clone Repite bordes y fondos en cada fragmento partido

La letra pequeña de break-inside: avoid es que solo se aplica si la caja cabe en una página. Una caja más grande que la página se parte igual, y hace bien.

Encima de eso hay tres capas más de control. Las viudas y huérfanas funcionan como en Chromium: por defecto deja al menos dos líneas abajo y dos arriba al partir un párrafo, y son heredables, así que las ajustas en el contenedor.

Los encabezados de tabla se repiten solos si usas <thead>, con dos excepciones documentadas: un encabezado más alto que un cuarto de página no se repite (mismo criterio que Chromium) y un rowspan que se mete en el cuerpo desactiva la repetición de esa tabla.

Y luego están los contadores de página. <PageNumber /> y <TotalPages /> se importan de takumi-pdf/primitives y funcionan en tres sitios: dentro de la banda de header o footer que le pasas a render(), dentro de una caja con position: fixed (que se pinta en todas las páginas), y en el flujo normal del documento. Aceptan una prop format con estilos de contador CSS, así que puedes numerar en upper-roman, en arabic-indic o en trad-chinese-informal si tu documento lo pide.

Debajo de eso hay un problema circular resuelto a base de iterar. Un número de página solo existe después de paginar, pero un número más ancho puede mover un salto de página, y ese salto puede cambiar el número. Takumi pagina, rellena los números, vuelve a maquetar, y repite hasta tres veces antes de quedarse con lo que tenga. Por eso la documentación insiste en reservar ancho fijo para el contador.

Y para lo que no se puede partir (un bloque de firma, una tarjeta de totales, un gráfico con su pie), KeepTogether o wrap={false} sobre el contenedor. Por debajo es el mismo break-inside: avoid de la tabla, con la misma condición: si el bloque no cabe en una página, se parte igual.

¿Qué se rompió al montar la factura de verdad?

Tres cosas, y ninguna de las tres sale en la documentación.

El documento se iba a dos páginas. El código de los bloques de factura de pdfcn fija minHeight: 841 en el Page, que son los píxeles de una A4 a 72 dpi. Pero los márgenes ya los aplica render() con su opción margin, así que ese mínimo empujaba el contenido más allá del alto útil y provocaba una segunda página vacía. La solución fue quitar el minHeight y compactar los márgenes del tema a 36–52 pt. Un contenido que fluye no necesita que le digas cuánto mide.

El pie de página se comía las notas. PageFooter acepta una prop sticky que lo posiciona en absoluto respecto a la página. Con el documento lleno hasta abajo, ese posicionamiento lo superponía sobre el bloque de notas legales. Dejándolo en el flujo normal con un marginTop, se apoya donde acaba el contenido y no pisa nada. Para documentos de una página, el flujo normal es la opción robusta; el sticky cobra sentido cuando el documento pagina.

Los importes salían mal en español. Intl.NumberFormat("es-ES") no pone separador de miles en cifras de cuatro dígitos, siguiendo la convención ortográfica. Eso te deja un 2400,00 € en medio de una columna que también contiene 1.198,40 €, y en una factura eso canta muchísimo. Se arregla forzando la opción:

const eur = (n: number): string => {
  const s = new Intl.NumberFormat("es-ES", {
    useGrouping: "always",     // sin esto, 2400 sale sin punto de miles
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(n);
  return `${s} €`;
};

Ninguno de los tres problemas es culpa de pdfcn. Son el tipo de fricción que aparece siempre que un sistema de maquetación se encuentra con un documento real, y son la razón por la que tener el código dentro del repositorio en vez de dentro de node_modules cambia tanto las cosas: los tres se arreglaron editando ficheros propios.

🛡️ Antes de dar por bueno un generador de PDF, prueba el caso que llena la página entera. Los fallos de maquetación viven en el límite, y una factura con veinte líneas en lugar de cuatro llega antes de lo que crees.

Tu IA puede mentirte

El agente dijo que la factura estaba lista. Ocupaba dos páginas.

Vas a ver métodos para revisar lo que genera un agente antes de darlo por bueno: ciclo anticaos, pruebas en navegador con Playwright, casos Gherkin y adversarial review entre modelos.

Ver el método entero →

Masterclass en directo · Acceso con suscripción Web Reactiva Premium

¿Y si la factura tiene que ser electrónica de verdad?

Takumi soporta PDF/A con ocho niveles de conformidad (de 2b a 4f) y PDF/UA para accesibilidad. La validación ocurre durante el render: si el documento no puede conformar, el render falla indicando la regla violada en lugar de escribir un fichero roto.

Y soporta adjuntos, que es lo que hace posible Factur-X y ZUGFeRD, los formatos de factura electrónica que combinan un PDF legible por humanos con un XML estructurado dentro del mismo fichero.

const pdf = await render(invoice, {
  lang: "es",
  pdfa: "3b",          // PDF/A-3 es el contenedor que exige el estándar
  tagged: "ua1",       // estructura semántica para lectores de pantalla
  metadata: {
    title: "Factura 2026-047",
    creationDate: "2026-09-07",
    xmp: [facturXmp],  // el bloque fx: que identifica el perfil
  },
  attachments: [
    {
      name: "factur-x.xml",
      data: xml,
      mimeType: "text/xml",
      description: "Factur-X 1.0 MINIMUM invoice data",
      relationship: "data",
    },
  ],
});

Las combinaciones inválidas son errores de tipo de TypeScript, no fallos en ejecución. Los niveles PDF/A-2 y el 4 prohíben adjuntos; el 3b y el 4f los permiten. El compilador te lo dice antes de ejecutar nada.

Ahora la parte honesta, que la documentación de Takumi asume sin adornos: el motor no construye el XML de la factura, no valida reglas fiscales y no garantiza que un sistema receptor lo acepte. Comprueba la conformidad del contenedor PDF y nada más. El XML lo generas tú o una librería del perfil que implementes.

Para validar el resultado hacen falta dos herramientas, y las dos necesitan Java: veraPDF comprueba el contenedor y Mustang comprueba la mitad Factur-X, tanto el XML como el empaquetado.

🔑 Que una librería de componentes te deje a un parámetro de distancia de un contenedor PDF/A-3 conforme no es habitual. La mayoría de generadores de PDF de Node ni siquiera contemplan el archivado a largo plazo.

Elegir bien la herramienta para un problema aburrido es media carrera profesional. En la newsletter compartimos cada domingo lo que estamos probando, y los +7.200 suscriptores aportan lo suyo. Gratis desde 2018.

Quiero esa dinamita 🧨

¿Compensa frente a Puppeteer o a react-pdf?

Depende de qué estés generando, y las tres opciones tienen su terreno.

pdfcn + Takumi Chromium headless @react-pdf/renderer
Peso del runtime Muy bajo (unos 4 MB) Alto (cientos de MB) Bajo
Fidelidad CSS Buena, con límites conocidos Total Limitada
Arranque en frío Rápido Lento Rápido
Control de paginación Bueno (CSS de impresión) Bueno Limitado
PDF/A y accesibilidad Sí, con validación Requiere post-proceso No
Componentes listos 24 más 10 bloques Ninguno Ninguno
Propiedad del código Total (se copia al repo) No aplica Dependencia

Si tu documento es una página web que ya existe y solo quieres una foto en PDF, Chromium sigue ganando: tienes el CSS entero, incluidos los trucos raros. Aunque ahí también hay movimiento: Lightpanda es un navegador headless escrito en Zig que ataca el mismo problema de peso y arranque desde el otro lado. Si tu documento es un documento (factura, informe, certificado, contrato) y lo generas en servidor a volumen, el peso del runtime y el arranque en frío pesan más que el 5% de CSS que no tienes.

Y si comparas con @react-pdf/renderer, la diferencia grande es el catálogo y el sistema de temas. La API de pdfcn está deliberadamente calcada de la de react-pdf (StyleSheet.create, View, Text, Page) precisamente para que la migración mental cueste poco, pero encima trae 24 componentes tematizados que en react-pdf te toca escribir a mano.

¿Qué le falta a pdfcn?

Tres cosas, siendo justos con un proyecto joven.

Los bloques son muy americanos. Las seis plantillas de factura trabajan en dólares y con un único campo tax. Si facturas en Europa con tipos de IVA mixtos y retenciones, el bloque no te vale como está. Sirve como referencia de composición, que no es poco, pero no como punto de partida.

La documentación de las props vive en las tablas de la web. El código fuente lleva comentarios JSDoc decentes, pero para saber qué hace la variante primary-header de una tabla frente a bordered acabas leyendo table.styles.ts. Con el código dentro de tu repositorio no es un drama, aunque sí es un rato.

El CLI espera un proyecto shadcn. Si tu generador de PDF vive en un servicio Node sin components.json ni estructura de aliases, npx shadcn add no es el camino más corto. Descargar los JSON del registry funciona bien, pero es una carretera secundaria que te montas tú.

Nada de eso invalida la propuesta. pdfcn resuelve la parte que nadie quiere escribir a mano, el sistema de diseño de un documento, y deja el motor de renderizado a tu elección.

¿Y el prompt?

Este, entero:

Quiero que crees una factura en PDF con datos de prueba (en euros, con
retención y con IVA) para varios conceptos y que se vea profesional.
Tienes que usar https://www.pdfcn.dev/ para montarlo todo, intentando
escarbar todo lo posible en los componentes utilizados y apuntando todas
tus decisiones en DECISIONES.md

Tres frases. Después hicieron falta tres correcciones cortas (“que ocupe una sola página”, “no te olvides de escribir las decisiones”, “ya me vale con lo que tenemos”), y el resultado fueron 522 líneas de código propio sobre 3.634 líneas del registry, un PDF de 42 KB y un documento de decisiones que explica por qué Takumi y no Forme, por qué componer en lugar de usar un bloque, y por qué el pie de página no lleva sticky.

Dos partes de ese prompt hacen casi todo el trabajo. La URL de la documentación, porque pdfcn publica un llms.txt con el índice completo y una versión markdown de cada página añadiendo .md a la URL, así que un agente puede leerse el catálogo entero sin pelearse con HTML. Y la petición de escribir las decisiones en un fichero, que convierte una caja negra en algo revisable.

Ahora bien: las tres correcciones que hicieron falta después son el mapa de lo que le faltaba al prompt. Si vas a pedir tu propia factura, este es el prompt con esas lecciones ya incorporadas.

Crea una factura en PDF con datos de prueba usando pdfcn (https://www.pdfcn.dev/).

Contexto fiscal (España):
- Importes en euros, formato es-ES con separador de miles (1.234,56 €).
- 4 o 5 conceptos con tipos de IVA mixtos (21% y 10%) para probar el caso real.
- Retención de IRPF del 15% sobre la base imponible total.
- Calcula todos los importes en código a partir de los datos, nunca a mano,
  y redondea a céntimo por línea.

Restricciones de maquetación:
- UNA sola página A4. Verifícalo abriendo el PDF, no lo des por hecho.
- Nada de minHeight fijo en el Page: los márgenes los pone render().
- Prueba también con 20 líneas de concepto para ver qué pasa al paginar.

Cómo quiero que trabajes:
- Empieza leyendo https://www.pdfcn.dev/llms.txt y las páginas .md que
  necesites, antes de escribir nada.
- Base Takumi. Compón con los componentes del registry (Table, KeyValue,
  PageHeader, PageFooter, Badge, PdfQRCode) en lugar de instalar un bloque
  de factura: ninguno modela IVA por línea ni retención.
- Deriva un tema propio de uno de los presets con spread, sin editar el preset.
- Registra las fuentes con googleFonts(): Takumi no lee las del sistema.
- Escribe en DECISIONES.md cada decisión y por qué, incluidos los callejones
  sin salida. Ese fichero lo voy a leer yo.

Lo que añade esta versión son las tres cosas que el agente no podía adivinar: el criterio de una sola página, el caso de prueba que rompe la maquetación (veinte líneas en vez de cuatro) y la orden explícita de leer el llms.txt antes de escribir código.

💡 Cuando pruebes una librería nueva con un agente, pídele siempre el documento de decisiones. Es la forma más barata de auditar si entendió la herramienta o si fue dando tumbos hasta que compiló.

TL;DR

  • 🧾 Una factura A4 con IVA mixto, retención de IRPF y QR sale de componer siete componentes de pdfcn, con los importes calculados en código y no a mano
  • 📄 pdfcn es shadcn/ui para PDF: 24 componentes, 10 bloques y 9 temas que se copian a tu repositorio con el CLI de shadcn y pasan a ser código tuyo
  • ⚡ Renderiza con Takumi (Rust compilado a WASM, 3,9 MB) sin abrir un navegador, con texto seleccionable y fuentes incrustadas como subconjuntos
  • 🎨 El sistema de temas son cuatro capas de tokens: derivas el tuyo de un preset con un spread y cambia el documento entero, del color del TOTAL al tamaño de las celdas
  • 🔍 Por dentro, View y Page son div, el ThemeProvider es una variable de módulo y la paginación es CSS de impresión (break-inside: avoid y compañía)
  • 🧾 Con pdfa: "3b" más attachments tienes el contenedor de una factura electrónica Factur-X o ZUGFeRD, aunque el XML lo pones tú

Preguntas frecuentes sobre crear facturas en PDF con pdfcn

¿Cómo se crea una factura en PDF con pdfcn?
Instalas los componentes que necesita el documento (PageHeader, Table, KeyValue, PageFooter), eliges un tema o derivas el tuyo, compones la factura en JSX y la renderizas con render() de takumi-pdf indicando tamaño, márgenes y fuentes. La salida es un Uint8Array que escribes a disco o devuelves en una respuesta HTTP.

¿Qué es pdfcn?
Es una colección de componentes React para generar documentos PDF, distribuida en formato registry de shadcn. Los componentes se copian a tu proyecto en lugar de instalarse como dependencia, y funcionan sobre dos motores de renderizado: Takumi y Forme.

¿Cómo calculo el IVA y la retención de IRPF de la factura?
En código, nunca a mano en el JSX. Guarda el tipo de IVA en cada línea, calcula base y cuota por línea con redondeo a céntimo, suma las bases para obtener la base imponible y aplica la retención sobre ese total. El documento consume el resultado, así que datos y PDF no se desincronizan.

¿pdfcn es gratis y de código abierto?
Los componentes se distribuyen mediante un registry público de ficheros JSON en pdfcn.dev/r/{nombre}.json, con el mismo modelo de copy-paste que shadcn/ui. Una vez copiado el código, es tuyo y lo modificas sin restricciones de la librería.

¿Cuál es la diferencia entre las bases Takumi y Forme?
Son dos motores de renderizado distintos con la misma API pública de componentes. Takumi renderiza JSX a PDF con un binario Rust compilado a WebAssembly sin navegador. Forme es la alternativa, con su propia arquitectura de Document y Page. La elección afecta a qué instalas y qué primitivas importas, no a cómo escribes los componentes.

¿Necesito un navegador headless para generar el PDF?
No. Ese es el punto principal de Takumi: renderiza sin proceso de navegador. El paquete pesa unos 4 MB frente a los cientos de megas de un Chromium, lo que cambia mucho el tamaño de una imagen de contenedor y el tiempo de arranque en frío.

¿Se puede usar pdfcn en un route handler de Next.js?
Sí. takumi-pdf publica una entrada específica, takumi-pdf/next, que funciona en los runtimes Node y Edge sin necesidad de añadir el paquete a serverExternalPackages.

¿Cómo controlo los saltos de página en un documento largo?
Con propiedades CSS de impresión: break-before: page para empezar en página nueva, break-inside: avoid para no partir un bloque, y box-decoration-break: clone para repetir bordes en cada fragmento. En pdfcn tienes además el componente KeepTogether y la prop wrap={false}, que se traducen a esas mismas propiedades.

¿Los encabezados de tabla se repiten en cada página?
Sí, si usas <thead>. Hay dos excepciones: un encabezado más alto que un cuarto de página no se repite, y un rowspan que se extiende hacia el cuerpo desactiva la repetición en esa tabla.

¿Puedo generar una factura electrónica Factur-X con esto?
El contenedor sí. Necesitas pdfa: "3b", el XML adjunto por nombre en attachments, el bloque XMP fx: en los metadatos y una fecha de modificación. El motor valida la conformidad del PDF, pero no genera ni valida el XML de la factura ni las reglas fiscales: eso lo pones tú.

¿Qué pasa si uso una fuente que no he registrado?
El render falla indicando el carácter y su codepoint. Takumi no lee fuentes del sistema por diseño, así que hay que registrar cobertura para cada alfabeto que uses, con googleFonts() o pasando los bytes del fichero.

¿Puedo usar los bloques de factura tal cual para facturar en España?
No sin reescribirlos. Las seis plantillas trabajan en dólares y con un único campo tax genérico, así que no modelan tipos de IVA mixtos por línea ni retenciones de IRPF. Sirven como referencia de composición, y como el código se copia a tu proyecto, adaptarlos es editar ficheros propios.

Fuentes

🧨 Última oprtunidad para recibir la dinamita que mereces sobre programación con IA el próximo domingo: Suscríbete gratis a Web Reactiva en https://webreactiva.com/newsletter

Imagen de Daniel Primo
Claude, IA de Anthropic

Escrito con la ayuda de la IA generativa de Claude, fuentes fidedignas y con un human in the loop:
Dani Primo.

CEO en pantuflas de Web Reactiva. Programador y formador en tecnologías que cambian el mundo y a las personas. Activo en linkedin, en substack y canal @webreactiva en telegram

12 recursos para developers cada domingo en tu bandeja de entrada

Además de una skill práctica bien explicada, trucos para mejorar tu futuro profesional y una pizquita de humor útil para el resto de la semana. Gratis.