Générer un PDF
Composer un document React PDF, le rendre en buffer et le servir en téléchargement ou par courriel.
core/pdf re-exporte les primitives de @react-pdf/renderer. Les gabarits vivent avec leur feature, pas dans le core.
Composer le document
// src/features/orders/pdf/InvoiceDocument.js
import { Document, Page, View, Text, StyleSheet } from '@lrvo/core/pdf';
const styles = StyleSheet.create({
page: { padding: 40, fontSize: 11 },
title: { fontSize: 20, marginBottom: 16 },
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
total: { marginTop: 16, fontSize: 13, fontWeight: 700 },
});
export const InvoiceDocument = ({ order }) => (
<Document>
<Page style={styles.page}>
<Text style={styles.title}>Facture {order.number}</Text>
<Text>{order.customerName}</Text>
{order.items.map((item) => (
<View key={item.id} style={styles.row}>
<Text>{item.label}</Text>
<Text>{item.amount}</Text>
</View>
))}
<Text style={styles.total}>Total : {order.total}</Text>
</Page>
</Document>
);La mise en page suit le modèle de React Native : pas de CSS en cascade, pas de display: block. Seuls flexDirection, padding, margin et les propriétés de texte sont disponibles.
Couleurs du thème
Un PDF ne lit pas les variables CSS. Pour qu'il suive le thème du projet, lire les valeurs du mode clair avec getThemeValues, au rendu :
import { getThemeValues } from '@lrvo/core/themes/server';
function getStyles() {
const t = getThemeValues('light');
return StyleSheet.create({
title: { fontSize: 20, color: t.fg },
muted: { color: t['fg-muted'] },
rule: { borderBottomWidth: 0.5, borderBottomColor: t.line },
});
}
export const InvoiceDocument = ({ order }) => {
const styles = getStyles();
// …
};Un StyleSheet.create au niveau racine du fichier s'exécuterait au chargement du module, avant l'enregistrement du thème, et garderait l'apparence d'origine. Voir Thèmes.
Rendre
// src/features/orders/pdf/generateInvoice.js
import { renderToBuffer, getFilename } from '@lrvo/core/pdf';
import { InvoiceDocument } from './InvoiceDocument.js';
export async function generateInvoicePdf(order) {
const buffer = await renderToBuffer(<InvoiceDocument order={order} />);
const filename = getFilename('invoice', order.number);
return { buffer, filename };
}getFilename('invoice', '12345') produit invoice-12345-2026-08-12.pdf. Un troisième argument permet de fixer la date.
Servir en téléchargement
// app/api/orders/[id]/invoice/route.js
export async function GET(req, { params }) {
const { id } = await params;
const order = await getOrder(id);
const { buffer, filename } = await generateInvoicePdf(order);
return new Response(buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
}Utiliser inline au lieu de attachment pour un affichage dans le navigateur.
Vérifier les droits avant de générer. Une facture est une donnée privée : contrôler que l'utilisateur connecté est bien le destinataire, ou qu'il est admin.
Servir depuis une route de module
Une route déclarée par defineApiRoutes ne construit pas sa réponse elle-même : elle renvoie l'enveloppe { success: true, file } et le core la sérialise. Le PDF part alors en téléchargement, comme tout ce qui n'est pas une image.
async function handleInvoicePdf(request, params) {
const invoice = await resolveToken(params.token);
if (!invoice) return apiError('Not Found', 'Facture introuvable.');
const pdf = await renderInvoicePdf(invoice);
return {
success: true,
file: {
body: pdf.buffer,
contentType: pdf.contentType,
contentLength: pdf.buffer.length,
filename: pdf.filename,
disposition: 'inline',
},
};
}disposition: 'inline' affiche le document dans le navigateur au lieu de le télécharger, et autorise son affichage dans un <iframe> du même site. C'est ce qui permet une page visionneuse plutôt qu'un téléchargement sec. Le drapeau ne se pose que sur un document rendu par le serveur : un fichier téléversé affiché en ligne s'exécuterait dans l'origine du site. Détail dans API.
Joindre à un courriel
import { sendEmail } from '@lrvo/core/email';
const { buffer, filename } = await generateInvoicePdf(order);
await sendEmail({
to: order.customerEmail,
subject: `Facture ${order.number}`,
html,
attachments: [{ filename, content: buffer }],
});Archiver
Pour conserver le document plutôt que de le régénérer :
import { uploadFile } from '@lrvo/core/storage';
await uploadFile({
key: `invoices/${order.id}/${filename}`,
body: buffer,
contentType: 'application/pdf',
});Déclarer une policy { prefix: 'invoices', type: 'admin' } pour que le fichier ne soit pas servi publiquement. Voir Stocker des fichiers.
Archiver plutôt que régénérer a un intérêt légal : une facture doit refléter l'état au moment de l'émission, pas l'état actuel de la base.
Images
Les images doivent porter une URL absolue. Le moteur de rendu télécharge les assets hors du contexte de la requête et ne résout pas les chemins relatifs.
import { Image } from '@lrvo/core/pdf';
import { getPublicBaseUrl } from '@lrvo/core/shared/config';
<Image src={`${getPublicBaseUrl()}/logo.png`} style={{ width: 120 }} />Une image privée servie par /lrvo/api/storage/** ne sera pas accessible : le rendu n'a pas de session. Passer un buffer ou une URL présignée courte.
Polices
Par défaut, seules les polices intégrées à React PDF sont disponibles. Pour utiliser IBM Plex :
import { Font } from '@lrvo/core/pdf';
Font.register({
family: 'IBM Plex Sans',
src: `${getPublicBaseUrl()}/fonts/IBMPlexSans-Regular.ttf`,
});L'enregistrement se fait une fois, au chargement du module, pas à chaque rendu.
Pièges
Le rendu est du code serveur. Il produit un Buffer Node et ne fonctionne pas dans un composant client.
Le rendu est coûteux. Un document long bloque le processus le temps du rendu. Pour un lot, générer en tâche de fond plutôt que dans une requête HTTP, ou archiver le résultat.
Pas de <div> ni de <span>. Utiliser View et Text. Tout texte doit être dans un Text : une chaîne nue dans un View n'est pas rendue.