diff --git a/package-lock.json b/package-lock.json index 4d9498b..1d83a8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.11.1", "nodemailer": "^9.0.3", + "pdfkit": "^0.19.1", "react": "^19.2.8", "react-dom": "^19.2.8", "recharts": "^3.10.1", @@ -32,6 +33,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/nodemailer": "^8.0.1", + "@types/pdfkit": "^0.17.6", "@types/sanitize-html": "^2.16.1", "marked": "^18.0.7", "typescript": "^6.0.3", @@ -1964,6 +1966,30 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oslojs/encoding": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", @@ -2387,6 +2413,15 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tiptap/core": { "version": "3.27.1", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz", @@ -2979,6 +3014,16 @@ "@types/node": "*" } }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -3583,6 +3628,24 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -3751,6 +3814,15 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4163,6 +4235,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -4449,7 +4527,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-string-truncated-width": { @@ -4534,6 +4611,23 @@ "fontkitten": "^1.0.2" } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/fontkitten": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", @@ -4889,6 +4983,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5222,6 +5322,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/linkifyjs": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", @@ -5692,6 +5811,12 @@ "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", @@ -5712,6 +5837,20 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -5736,6 +5875,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -6216,6 +6363,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/retext-smartypants": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", @@ -6782,8 +6935,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -6853,6 +7005,32 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/package.json b/package.json index fe4002d..a720fe9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.11.1", "nodemailer": "^9.0.3", + "pdfkit": "^0.19.1", "react": "^19.2.8", "react-dom": "^19.2.8", "recharts": "^3.10.1", @@ -38,6 +39,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/nodemailer": "^8.0.1", + "@types/pdfkit": "^0.17.6", "@types/sanitize-html": "^2.16.1", "marked": "^18.0.7", "typescript": "^6.0.3", diff --git a/src/components/CalendarioModal.astro b/src/components/CalendarioModal.astro index 870730d..e3ad114 100644 --- a/src/components/CalendarioModal.astro +++ b/src/components/CalendarioModal.astro @@ -33,7 +33,7 @@ const presenti = cal )}
- + {t('calendario.pdf-label')}
@@ -93,10 +93,9 @@ const presenti = cal dlg.querySelector('[data-chiudi-calendario]')?.addEventListener('click', () => dlg.close()); // Click sul fondale: il target è il dialog stesso solo quando si preme fuori dal riquadro. dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); }); - // "Scarica in PDF" = stampa con la destinazione "Salva come PDF": la pagina di stampa è - // già impaginata per la sola griglia (vedi @media print), quindi non serve generare nulla - // lato server né caricare una libreria. - dlg.querySelector('[data-stampa-calendario]')?.addEventListener('click', () => window.print()); + // "Scarica in PDF" è un link a `/calendario/.pdf`, cioè un file vero generato dal + // server: si salva sul telefono e si gira su WhatsApp. Il CSS di stampa più sotto resta + // per chi preme Ctrl+P col popup aperto. } @@ -122,9 +121,10 @@ const presenti = cal } .cal-dlg__azioni { display: flex; align-items: center; gap: 12px; flex-shrink: 0; } .cal-dlg__btn { - font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .2em; - text-transform: uppercase; border: 0; cursor: pointer; padding: 13px 22px; - background: var(--c-accent); color: #fff; transition: background .2s; + display: inline-block; font-family: var(--font-heading); font-size: .68rem; font-weight: 600; + letter-spacing: .2em; text-transform: uppercase; text-decoration: none; border: 0; + cursor: pointer; padding: 13px 22px; background: var(--c-accent); color: #fff; + transition: background .2s; } .cal-dlg__btn:hover { background: var(--c-accent-dark); } .cal-dlg__chiudi { diff --git a/src/lib/calendario-pdf.ts b/src/lib/calendario-pdf.ts new file mode 100644 index 0000000..78b8dd3 --- /dev/null +++ b/src/lib/calendario-pdf.ts @@ -0,0 +1,159 @@ +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; +} diff --git a/src/pages/calendario/[id].pdf.ts b/src/pages/calendario/[id].pdf.ts new file mode 100644 index 0000000..51d0370 --- /dev/null +++ b/src/pages/calendario/[id].pdf.ts @@ -0,0 +1,31 @@ +import type { APIRoute } from 'astro'; +import { calendari } from '../../data/calendari'; +import { generaCalendarioPdf, nomeFilePdf } from '../../lib/calendario-pdf'; + +export const prerender = false; + +// Il PDF del calendario, come file scaricabile: `/calendario/.pdf`. +// +// Pubblico come il resto del sito — è lo stesso orario che chiunque legge nel popup, e un file +// dietro autenticazione non si potrebbe girare su WhatsApp, che è come questo centro +// comunica coi clienti. +export const GET: APIRoute = async ({ params }) => { + const cal = calendari.find((c) => c.id === params.id); + // Un id sconosciuto è 404, non un PDF vuoto: un file che si apre e non contiene niente fa + // credere che il calendario sia vuoto. + if (!cal) return new Response('Calendario inesistente.', { status: 404 }); + + const pdf = await generaCalendarioPdf(cal); + return new Response(new Uint8Array(pdf), { + headers: { + 'Content-Type': 'application/pdf', + // `attachment` perché il bottone dice "scarica": senza, il browser lo aprirebbe in una + // scheda e su telefono resterebbe lì, senza finire fra i file. + 'Content-Disposition': `attachment; filename="${nomeFilePdf(cal)}"`, + 'Content-Length': String(pdf.length), + // Il calendario cambia solo quando cambia il dato, cioè con un deploy: un'ora di cache + // basta a non rigenerarlo a ogni click, e non abbastanza da servire un orario vecchio. + 'Cache-Control': 'public, max-age=3600', + }, + }); +}; diff --git a/tests/calendario-pdf.test.ts b/tests/calendario-pdf.test.ts new file mode 100644 index 0000000..e39bad6 --- /dev/null +++ b/tests/calendario-pdf.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { calendari } from '../src/data/calendari'; +import { generaCalendarioPdf, nomeFilePdf } from '../src/lib/calendario-pdf'; + +describe('PDF del calendario', () => { + for (const cal of calendari) { + it(`${cal.id}: produce un PDF valido`, async () => { + const pdf = await generaCalendarioPdf(cal); + // Un PDF comincia sempre con %PDF- e finisce con %%EOF: se il documento non venisse + // chiuso (`doc.end()` mancante, o un errore a metà) il file arriverebbe monco al + // browser, che mostrerebbe una pagina bianca invece di un errore. + expect(pdf.subarray(0, 5).toString()).toBe('%PDF-'); + expect(pdf.subarray(-8).toString()).toContain('%%EOF'); + expect(pdf.length).toBeGreaterThan(2000); + }); + } + + // La griglia deve stare in una pagina sola: l'incrocio giorno/ora è proprio ciò che si va a + // cercare, e spezzarlo a metà rende il documento inutile. Il conteggio si legge dall'oggetto + // /Type /Pages, che dichiara quante pagine contiene. + it('sta in una pagina sola', async () => { + for (const cal of calendari) { + const pdf = await generaCalendarioPdf(cal); + const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1')); + expect(conteggio, `${cal.id}: numero di pagine non dichiarato`).not.toBeNull(); + expect(Number(conteggio![1]), `${cal.id}: il calendario occupa più di una pagina`).toBe(1); + } + }); + + it('il nome del file distingue i due periodi', () => { + const nomi = calendari.map(nomeFilePdf); + expect(new Set(nomi).size).toBe(nomi.length); + for (const n of nomi) expect(n).toMatch(/^insanitylab-calendario-[a-z0-9-]+\.pdf$/); + }); +});