diff --git a/desktop/latex.js b/desktop/latex.js index f7a376c..885198e 100644 --- a/desktop/latex.js +++ b/desktop/latex.js @@ -33,15 +33,46 @@ export const PACCHETTI_BASE = [ 'tex4ht', 'make4ht', 'l3packages', 'l3kernel', ]; +// Collezioni tlmgr installate dopo PACCHETTI_BASE per avere una distribuzione +// "completa" fin da subito: coprono quasi tutti i pacchetti comuni (LaTeX +// extra, font, matematica/scienze, grafica, bibliografia, lingue europee...) +// senza scaricare anche le collezioni di lingue rare o la documentazione +// sorgente, che pesano molto e servono a pochissimi utenti. +export const COLLEZIONI_COMPLETE = [ + 'collection-basic', 'collection-latex', 'collection-latexrecommended', + 'collection-latexextra', 'collection-fontsrecommended', 'collection-fontsextra', + 'collection-mathscience', 'collection-pictures', 'collection-plaingeneric', + 'collection-humanities', 'collection-publishers', 'collection-formatsextra', + 'collection-bibtexextra', 'collection-binextra', 'collection-metapost', + 'collection-luatex', 'collection-xetex', + 'collection-langitalian', 'collection-langeuropean', +]; + const PIATTAFORMA = process.platform; // 'win32' | 'darwin' | 'linux' // URL ufficiali di TinyTeX (distribuzione TeX Live minima e installabile -// senza privilegi). Sovrascrivibili con TEXFORGE_TINYTEX_URL per usare un -// mirror o una copia locale. +// senza privilegi). Ne teniamo più di uno per piattaforma perché sia il +// redirect di yihui.org sia i nomi dei file sulle release GitHub sono +// cambiati più volte nel tempo: se il primo risponde 404 si passa al +// successivo invece di far fallire subito l'installazione. +// Sovrascrivibile con TEXFORGE_TINYTEX_URL per usare un mirror o una copia +// locale (in tal caso viene provato solo quell'URL). const TINYTEX_URL = { - win32: 'https://yihui.org/tinytex/TinyTeX-1.zip', - darwin: 'https://yihui.org/tinytex/TinyTeX-1.tgz', - linux: 'https://yihui.org/tinytex/TinyTeX-1.tar.gz', + win32: [ + 'https://github.com/rstudio/tinytex-releases/releases/download/daily/TinyTeX-1.zip', + 'https://github.com/rstudio/tinytex-releases/releases/latest/download/TinyTeX-1.zip', + 'https://yihui.org/tinytex/TinyTeX-1.zip', + ], + darwin: [ + 'https://github.com/rstudio/tinytex-releases/releases/download/daily/TinyTeX-1.tgz', + 'https://github.com/rstudio/tinytex-releases/releases/latest/download/TinyTeX-1.tgz', + 'https://yihui.org/tinytex/TinyTeX-1.tgz', + ], + linux: [ + 'https://github.com/rstudio/tinytex-releases/releases/download/daily/TinyTeX-1.tar.gz', + 'https://github.com/rstudio/tinytex-releases/releases/latest/download/TinyTeX-1.tar.gz', + 'https://yihui.org/tinytex/TinyTeX-1.tar.gz', + ], }; // --------------------------------------------------------------------------- @@ -212,18 +243,37 @@ async function estrai(archivio, destinazione, onLog) { } } +// Scarica la distribuzione provando gli URL candidati in ordine: se uno +// risponde 404 (es. un redirect diventato stale, o un file rinominato in +// una nuova release) si passa al successivo invece di arrendersi subito. +async function scaricaConFallback(candidati, archivio, onLog, onProgress) { + let ultimoErrore; + for (const url of candidati) { + try { + onLog?.(`Scarico la distribuzione TeX da ${url}\n`); + await scarica(url, archivio, onProgress); + return; + } catch (e) { + ultimoErrore = e; + onLog?.(`Fonte non disponibile (${e.message}), provo un'altra fonte...\n`); + } + } + throw ultimoErrore; +} + // Scarica e installa TinyTeX nella cartella dati dell'utente. export async function installaDistribuzione(userDataDir, { onLog, onProgress } = {}) { - const url = process.env.TEXFORGE_TINYTEX_URL || TINYTEX_URL[PIATTAFORMA]; - if (!url) throw new Error(`piattaforma non supportata: ${PIATTAFORMA}`); + const candidati = process.env.TEXFORGE_TINYTEX_URL + ? [process.env.TEXFORGE_TINYTEX_URL] + : TINYTEX_URL[PIATTAFORMA]; + if (!candidati) throw new Error(`piattaforma non supportata: ${PIATTAFORMA}`); const radice = radiceGestita(userDataDir); const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'texforge-tex-')); - const archivio = path.join(tmp, path.basename(new URL(url).pathname)); + const archivio = path.join(tmp, path.basename(new URL(candidati[0]).pathname)); try { - onLog?.(`Scarico la distribuzione TeX da ${url}\n`); - await scarica(url, archivio, onProgress); + await scaricaConFallback(candidati, archivio, onLog, onProgress); onLog?.('\nEstrazione in corso...\n'); const staging = path.join(tmp, 'estratto'); @@ -323,11 +373,19 @@ export async function aggiornaPacchetti(tex, onLog) { await tlmgr(tex, ['update', '--self', '--all'], { onLog, timeout: 900000 }); } -// Installa i pacchetti di base subito dopo la prima installazione. +// Installa i pacchetti di base, poi le collezioni che rendono la +// distribuzione "completa", subito dopo la prima installazione. Il +// download di TinyTeX resta piccolo e affidabile: è tlmgr, dopo, che porta +// la dotazione di pacchetti a un livello quasi completo. export async function installaPacchettiBase(tex, onLog) { onLog?.('\nInstallo i pacchetti di base...\n'); // Un unico comando: tlmgr risolve le dipendenze e salta quelli già presenti. await tlmgr(tex, ['install', ...PACCHETTI_BASE], { onLog, timeout: 1800000 }); + + onLog?.('\nInstallo il resto dei pacchetti (può richiedere diversi minuti)...\n'); + await tlmgr(tex, ['install', ...COLLEZIONI_COMPLETE], { onLog, timeout: 3600000 }).catch((e) => { + onLog?.(`\nAvviso: alcune collezioni di pacchetti non sono state installate: ${e.message}\n`); + }); } // --------------------------------------------------------------------------- diff --git a/desktop/main.js b/desktop/main.js index 1ac9736..4fb5968 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -14,6 +14,7 @@ import os from 'node:os'; import { fileURLToPath } from 'node:url'; import * as latex from './latex.js'; +import { creaZip } from './zip.js'; import { generateHtmlFromQuiz, generatePdfBytes, rendiHtmlAutonomo } from './vendor/compiler.js'; import * as pdfLib from 'pdf-lib'; import fontkit from '@pdf-lib/fontkit'; @@ -318,6 +319,20 @@ ipcMain.handle('salva-file', async (_e, { nome, dati }) => { return res.filePath; }); +// `file` è [{ nome, dati }] con i byte in base64: li impacchetta in un unico +// ZIP, invece di far salvare ogni output uno per uno. +ipcMain.handle('salva-zip', async (_e, { nome, file }) => { + const res = await dialog.showSaveDialog(finestra, { + title: 'Salva lo ZIP', + defaultPath: nome, + filters: [{ name: 'Archivio ZIP', extensions: ['zip'] }], + }); + if (res.canceled || !res.filePath) return null; + const zip = creaZip(file.map((f) => ({ nome: f.nome, dati: Buffer.from(f.dati, 'base64') }))); + await fsp.writeFile(res.filePath, zip); + return res.filePath; +}); + ipcMain.handle('apri-cartella-dati', () => shell.openPath(app.getPath('userData'))); // --------------------------------------------------------------------------- diff --git a/desktop/preload.cjs b/desktop/preload.cjs index fd9b2a8..5a4be1a 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -25,6 +25,7 @@ contextBridge.exposeInMainWorld('texforge', { apriTex: () => ipcRenderer.invoke('apri-tex'), compila: (documenti) => ipcRenderer.invoke('compila', documenti), salvaFile: (file) => ipcRenderer.invoke('salva-file', file), + salvaZip: (archivio) => ipcRenderer.invoke('salva-zip', archivio), anteprima: (file) => ipcRenderer.invoke('anteprima', file), chiudiAnteprime: () => ipcRenderer.invoke('chiudi-anteprime'), apriCartellaDati: () => ipcRenderer.invoke('apri-cartella-dati'), diff --git a/desktop/renderer/app.css b/desktop/renderer/app.css index 4063586..e31a0e7 100644 --- a/desktop/renderer/app.css +++ b/desktop/renderer/app.css @@ -141,6 +141,11 @@ body { } .output-card h3 { font-family: var(--mono); font-size: 1.1rem; margin-bottom: 0.35rem; } .output-card p { font-size: 0.85rem; color: var(--ink-soft); margin-bottom: 1rem; } +.output-file-list { + list-style: none; text-align: left; font-size: 0.85rem; color: var(--ink-soft); + margin: 0 0 1rem; padding: 0; max-height: 8rem; overflow-y: auto; +} +.output-file-list li { padding: 0.15rem 0; } .output-btn { display: block; width: 100%; background: var(--accent-bg); color: var(--accent); border: none; padding: 0.7rem 1rem; border-radius: var(--r); diff --git a/desktop/renderer/app.js b/desktop/renderer/app.js index 999040c..896bc3d 100644 --- a/desktop/renderer/app.js +++ b/desktop/renderer/app.js @@ -107,7 +107,6 @@ api.suProgresso((p) => { // ═══════════════ COMPILATORE ═══════════════ let documenti = []; -const urlCreati = []; function aggiungi(nome, contenuto) { if (!documenti.some((d) => d.nome === nome)) documenti.push({ nome, contenuto }); @@ -162,43 +161,51 @@ $('compileBtn').addEventListener('click', async () => { $('preview').hidden = true; $('logStatus').textContent = ''; $('log').hidden = false; - while (urlCreati.length) URL.revokeObjectURL(urlCreati.pop()); try { const res = await api.compila(documenti); $('logStatus').textContent = res.log + '\n' + $('logStatus').textContent; - for (const f of res.file) { - const bytes = Uint8Array.from(atob(f.dati), (c) => c.charCodeAt(0)); - const blob = new Blob([bytes], { - type: f.tipo === 'pdf' ? 'application/pdf' : 'text/html', - }); - const url = URL.createObjectURL(blob); - urlCreati.push(url); + // Un unico ZIP per tipo (PDF / HTML), invece di un pulsante di salvataggio + // per ogni singolo file: con molti documenti compilati insieme era una + // lista infinita di "Salva con nome…" da cliccare uno per uno. + for (const tipo of ['pdf', 'html']) { + const file = res.file.filter((f) => f.tipo === tipo); + if (!file.length) continue; const card = document.createElement('div'); card.className = 'output-card'; card.innerHTML = `
-

${f.tipo.toUpperCase()}

-

${esc(f.nome)}
${esc(f.motore)}

+

${tipo.toUpperCase()}

+
- `; + `; card.querySelector('.output-btn').addEventListener('click', () => { - api.salvaFile({ nome: f.nome, dati: f.dati }); + if (file.length > 1) { + api.salvaZip({ nome: `texforge-${tipo}.zip`, file: file.map((f) => ({ nome: f.nome, dati: f.dati })) }); + } else { + api.salvaFile({ nome: file[0].nome, dati: file[0].dati }); + } }); - if (f.tipo === 'html') { - const anteprima = document.createElement('button'); - anteprima.className = 'output-btn output-btn-ghost'; - anteprima.type = 'button'; - anteprima.textContent = "Prova l'anteprima"; - anteprima.addEventListener('click', async () => { - $('previewFrame').src = await api.anteprima({ nome: f.nome, dati: f.dati }); - $('preview').hidden = false; - $('preview').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - }); - card.appendChild(anteprima); + if (tipo === 'html') { + for (const f of file) { + const anteprima = document.createElement('button'); + anteprima.className = 'output-btn output-btn-ghost'; + anteprima.type = 'button'; + anteprima.textContent = file.length > 1 ? `Anteprima: ${f.nome}` : "Prova l'anteprima"; + anteprima.addEventListener('click', async () => { + $('previewFrame').src = await api.anteprima({ nome: f.nome, dati: f.dati }); + $('preview').hidden = false; + $('preview').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + card.appendChild(anteprima); + } } $('outputs').appendChild(card); } diff --git a/desktop/zip.js b/desktop/zip.js new file mode 100644 index 0000000..0d4d55f --- /dev/null +++ b/desktop/zip.js @@ -0,0 +1,94 @@ +// --------------------------------------------------------------------------- +// Scrittore ZIP minimale, senza dipendenze esterne: serve solo a impacchettare +// gli output della compilazione (PDF o HTML) in un unico archivio scaricabile +// con un solo click, invece di salvare ogni file singolarmente. +// --------------------------------------------------------------------------- + +import zlib from 'node:zlib'; + +const TABELLA_CRC = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? (0xedb88320 ^ (c >>> 1)) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +function crc32(buf) { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc = TABELLA_CRC[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function dataDos(d = new Date()) { + const ora = ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff; + const giorno = (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff; + return { ora, giorno }; +} + +// Crea un archivio ZIP (deflate) in memoria da [{ nome, dati: Buffer }]. +export function creaZip(file) { + const { ora, giorno } = dataDos(); + const locali = []; + const centrali = []; + let offset = 0; + + for (const { nome, dati } of file) { + const nomeBuf = Buffer.from(nome, 'utf-8'); + const compressi = zlib.deflateRawSync(dati); + const crc = crc32(dati); + + const locale = Buffer.alloc(30); + locale.writeUInt32LE(0x04034b50, 0); + locale.writeUInt16LE(20, 4); // versione minima per l'estrazione + locale.writeUInt16LE(0x0800, 6); // bit 11: nome file in UTF-8 + locale.writeUInt16LE(8, 8); // metodo: deflate + locale.writeUInt16LE(ora, 10); + locale.writeUInt16LE(giorno, 12); + locale.writeUInt32LE(crc, 14); + locale.writeUInt32LE(compressi.length, 18); + locale.writeUInt32LE(dati.length, 22); + locale.writeUInt16LE(nomeBuf.length, 26); + locale.writeUInt16LE(0, 28); + locali.push(locale, nomeBuf, compressi); + + const centrale = Buffer.alloc(46); + centrale.writeUInt32LE(0x02014b50, 0); + centrale.writeUInt16LE(20, 4); + centrale.writeUInt16LE(20, 6); + centrale.writeUInt16LE(0x0800, 8); + centrale.writeUInt16LE(8, 10); + centrale.writeUInt16LE(ora, 12); + centrale.writeUInt16LE(giorno, 14); + centrale.writeUInt32LE(crc, 16); + centrale.writeUInt32LE(compressi.length, 20); + centrale.writeUInt32LE(dati.length, 24); + centrale.writeUInt16LE(nomeBuf.length, 28); + centrale.writeUInt16LE(0, 30); + centrale.writeUInt16LE(0, 32); + centrale.writeUInt16LE(0, 34); + centrale.writeUInt16LE(0, 36); + centrale.writeUInt32LE(0, 38); + centrale.writeUInt32LE(offset, 42); + centrali.push(centrale, nomeBuf); + + offset += locale.length + nomeBuf.length + compressi.length; + } + + const centraleBuf = Buffer.concat(centrali); + const fine = Buffer.alloc(22); + fine.writeUInt32LE(0x06054b50, 0); + fine.writeUInt16LE(0, 4); + fine.writeUInt16LE(0, 6); + fine.writeUInt16LE(file.length, 8); + fine.writeUInt16LE(file.length, 10); + fine.writeUInt32LE(centraleBuf.length, 12); + fine.writeUInt32LE(offset, 16); + fine.writeUInt16LE(0, 20); + + return Buffer.concat([...locali, centraleBuf, fine]); +}