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 = { 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 { 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((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; }