Files
InsanityLab-web/src/lib/calendario-pdf.ts
T
Adriano 5be0fa787c Calendario: il PDF e' un file vero, generato dal server
"Scarica in PDF" non apre piu' la stampa del browser: e' un link a
/calendario/<id>.pdf che restituisce un documento disegnato dal dato con PDFKit,
con Content-Disposition attachment. Serve perche' il calendario si gira su
WhatsApp, ed e' cosi' che questo centro parla coi clienti: chi lo riceve non ha
il sito aperto davanti.

Niente browser headless: Chromium in un container da 2 vCPU costa centinaia di
megabyte e un processo per richiesta, per una griglia di sette colonne.

Le linee della griglia si tracciano in una passata a parte, dopo i riempimenti:
disegnandole riga per riga, il riempimento della riga seguente copriva quella
appena fatta e due lezioni uguali in fasce consecutive si leggevano come un
blocco unico lungo il doppio.

Quattro test: intestazione %PDF- e coda %%EOF (un documento non chiuso arriva
monco e il browser mostra una pagina bianca invece di un errore), una pagina
sola, nomi di file distinti. Un id sconosciuto e' 404, non un PDF vuoto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:14:02 +02:00

160 lines
6.7 KiB
TypeScript

import PDFDocument from 'pdfkit';
import { GIORNI, type Calendario, type Disciplina, type Giorno } from '../data/calendari';
// PDF del calendario, disegnato dal dato con PDFKit.
//
// Non è la stampa del browser: è un file vero, che si scarica da un link e si può girare su
// WhatsApp. Serve un documento, non uno screenshot della pagina — chi lo riceve non ha il
// sito aperto davanti.
//
// Niente browser headless di mezzo: Chromium in un container da 2 vCPU costa centinaia di
// megabyte e un processo per ogni richiesta, per una griglia di sette colonne.
/** Colori delle discipline, con la barra a sinistra più scura del fondo (come in pagina). */
const COLORI: Record<Disciplina, { fondo: string; barra: string }> = {
REFORMER: { fondo: '#ece5d7', barra: '#9c8b70' },
PILATES: { fondo: '#f6efe0', barra: '#cbb98f' },
YOGA: { fondo: '#e3eadf', barra: '#8ba57f' },
MOBILITY: { fondo: '#f0e3e3', barra: '#b78e8e' },
};
const C_TESTO = '#2b2b2b';
const C_TENUE = '#8a8a8a';
const C_BORDO = '#cfc7b8'; // più scuro del fondo delle celle: due lezioni uguali in fasce
// consecutive devono restare due, non sembrare un blocco unico
const C_ACCENTO = '#9c8b70';
const C_ALT = '#f5f3f0';
export const nomeFilePdf = (cal: Calendario): string =>
`insanitylab-calendario-${cal.id}.pdf`;
/**
* Genera il PDF e restituisce il file completo. Il documento è una pagina sola: la griglia sta
* in A4 orizzontale, e spezzarla su due pagine renderebbe illeggibile proprio ciò che si va a
* cercare — l'incrocio fra un giorno e un'ora.
*/
export function generaCalendarioPdf(cal: Calendario): Promise<Buffer> {
const doc = new PDFDocument({
size: 'A4',
layout: 'landscape',
margin: 32,
info: {
Title: `InsanityLab — ${cal.sala}, ${cal.titolo}`,
Author: 'InsanityLab',
Subject: 'Calendario delle class',
},
});
const pezzi: Buffer[] = [];
const finito = new Promise<Buffer>((risolvi, rifiuta) => {
doc.on('data', (p: Buffer) => pezzi.push(p));
doc.on('end', () => risolvi(Buffer.concat(pezzi)));
doc.on('error', rifiuta);
});
const x0 = doc.page.margins.left;
const larghezzaUtile = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// --- Testata ---
doc.font('Helvetica-Bold').fontSize(16).fillColor(C_TESTO)
.text('INSANITYLAB', x0, doc.page.margins.top);
doc.font('Helvetica').fontSize(8).fillColor(C_ACCENTO)
.text(cal.sala.toUpperCase(), x0, doc.y + 2, { characterSpacing: 1.6 });
doc.font('Helvetica-Bold').fontSize(12).fillColor(C_TESTO)
.text(cal.titolo, x0, doc.y + 4);
// --- Griglia ---
const larghezzaOra = 52;
const larghezzaGiorno = (larghezzaUtile - larghezzaOra) / GIORNI.length;
const conNota = GIORNI.some((g) => cal.noteGiorno?.[g]);
const altezzaTestata = conNota ? 26 : 18;
let y = doc.y + 12;
const xDi = (i: number) => x0 + larghezzaOra + i * larghezzaGiorno;
// Intestazione delle colonne
doc.rect(x0, y, larghezzaUtile, altezzaTestata).fill(C_ALT);
doc.font('Helvetica-Bold').fontSize(7).fillColor(C_ACCENTO)
.text('ORA', x0 + 5, y + 6, { width: larghezzaOra - 10, characterSpacing: 1 });
GIORNI.forEach((g, i) => {
doc.font('Helvetica-Bold').fontSize(7).fillColor(cal.chiusi.includes(g) ? C_TENUE : C_TESTO)
.text(g, xDi(i) + 5, y + 6, { width: larghezzaGiorno - 10, characterSpacing: 1 });
const nota = cal.noteGiorno?.[g];
if (nota) {
doc.font('Helvetica').fontSize(5.5).fillColor(C_TENUE)
.text(nota, xDi(i) + 5, y + 15, { width: larghezzaGiorno - 8 });
}
});
y += altezzaTestata;
// Righe: l'altezza si ricava dallo spazio che resta, così la griglia riempie la pagina
// qualunque sia il numero di fasce e non serve ritoccarla a mano quando ne cambiano.
const spazioSotto = 52; // legenda + nota + piè di pagina
const altezzaRiga = Math.min(
22,
(doc.page.height - doc.page.margins.bottom - spazioSotto - y) / cal.righe.length,
);
// Prima passata: riempimenti e testi.
const yPrimaRiga = y;
for (const r of cal.righe) {
if (r.clou) doc.rect(x0, y, larghezzaOra, altezzaRiga).fill('#f7f2e6');
doc.font('Helvetica-Bold').fontSize(7.5).fillColor(C_TESTO)
.text(r.ora, x0 + 5, y + altezzaRiga / 2 - 4, { width: larghezzaOra - 8 });
GIORNI.forEach((g: Giorno, i) => {
const x = xDi(i);
const chiuso = cal.chiusi.includes(g);
const lezione = r.celle[g];
if (chiuso) {
doc.rect(x, y, larghezzaGiorno, altezzaRiga).fill('#efece7');
doc.font('Helvetica').fontSize(5.5).fillColor(C_TENUE)
.text('CHIUSO', x + 5, y + altezzaRiga / 2 - 3, { width: larghezzaGiorno - 8, characterSpacing: 0.8 });
} else if (lezione) {
const c = COLORI[lezione];
doc.rect(x, y, larghezzaGiorno, altezzaRiga).fill(c.fondo);
doc.rect(x, y, 2.5, altezzaRiga).fill(c.barra);
doc.font('Helvetica-Bold').fontSize(6.5).fillColor(C_TESTO)
.text(lezione, x + 7, y + altezzaRiga / 2 - 3.5, { width: larghezzaGiorno - 10, characterSpacing: 0.5 });
}
});
y += altezzaRiga;
}
// Seconda passata: la griglia sopra i riempimenti. Tracciando le linee riga per riga, il
// riempimento della riga seguente copriva quella appena disegnata e due lezioni uguali una
// sotto l'altra si leggevano come un unico blocco lungo il doppio.
doc.lineWidth(0.4).strokeColor(C_BORDO);
for (let i = 0; i <= cal.righe.length; i++) {
const yy = yPrimaRiga + i * altezzaRiga;
doc.moveTo(x0, yy).lineTo(x0 + larghezzaUtile, yy).stroke();
}
for (let i = 0; i <= GIORNI.length; i++) {
const x = i === 0 ? x0 + larghezzaOra : xDi(i);
doc.moveTo(x, yPrimaRiga - altezzaTestata).lineTo(x, y).stroke();
}
doc.rect(x0, yPrimaRiga - altezzaTestata, larghezzaUtile, altezzaTestata + altezzaRiga * cal.righe.length).stroke();
// --- Legenda: solo le discipline che compaiono davvero in questa griglia ---
const presenti = (Object.keys(COLORI) as Disciplina[])
.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d)));
let xLeg = x0;
const yLeg = y + 12;
for (const d of presenti) {
doc.rect(xLeg, yLeg, 9, 9).fill(COLORI[d].fondo);
doc.rect(xLeg, yLeg, 2.5, 9).fill(COLORI[d].barra);
doc.font('Helvetica').fontSize(7).fillColor(C_TESTO)
.text(d, xLeg + 13, yLeg + 2, { width: 70 });
xLeg += 13 + doc.widthOfString(d) + 18;
}
doc.font('Helvetica').fontSize(7).fillColor(C_TENUE).text(
"Gli orari possono subire variazioni. Le lezioni si prenotano dall'app Bsport.",
x0, yLeg + 16, { width: larghezzaUtile },
);
doc.font('Helvetica').fontSize(7).fillColor(C_ACCENTO)
.text('insanitylab.it', x0, yLeg + 27, { width: larghezzaUtile, align: 'right' });
doc.end();
return finito;
}