5be0fa787c
"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>
160 lines
6.7 KiB
TypeScript
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;
|
|
}
|