Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 69 additions & 11 deletions desktop/latex.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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`);
});
}

// ---------------------------------------------------------------------------
Expand Down
15 changes: 15 additions & 0 deletions desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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')));

// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions desktop/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
5 changes: 5 additions & 0 deletions desktop/renderer/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
55 changes: 31 additions & 24 deletions desktop/renderer/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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 = `
<div>
<h3>${f.tipo.toUpperCase()}</h3>
<p>${esc(f.nome)}<br><span class="motore-tag">${esc(f.motore)}</span></p>
<h3>${tipo.toUpperCase()}</h3>
<ul class="output-file-list">${file.map((f) => `
<li>${esc(f.nome)} <span class="motore-tag">${esc(f.motore)}</span></li>`).join('')}
</ul>
</div>
<button class="output-btn" type="button">Salva con nome…</button>`;
<button class="output-btn" type="button">${
file.length > 1 ? `Scarica tutti (.zip, ${file.length} file)` : 'Salva con nome…'
}</button>`;
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);
}
Expand Down
94 changes: 94 additions & 0 deletions desktop/zip.js
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading