diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d45aebe..68404ce 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,6 +19,9 @@ jobs:
- name: Install dependencies
run: npm install
+ - name: Prove sulla resa dell'HTML
+ run: npm test
+
- name: Avvia il server
run: |
node server.js &
diff --git a/desktop/main.js b/desktop/main.js
index 4fb5968..7bce979 100644
--- a/desktop/main.js
+++ b/desktop/main.js
@@ -263,6 +263,13 @@ ipcMain.handle('compila', async (_e, documenti) => {
const f = path.join(lavoro, path.basename(nome));
return fs.existsSync(f) ? fs.readFileSync(f, 'utf-8') : null;
},
+ // Le eventuali immagini prodotte da tex4ht vivono nella cartella di
+ // lavoro, che cancelliamo subito dopo: vanno incorporate adesso o
+ // sono perse.
+ leggiBinario: (nome) => {
+ const f = path.join(lavoro, path.basename(nome));
+ return fs.existsSync(f) ? fs.readFileSync(f) : null;
+ },
quizCss: QUIZ_CSS,
quizJs: QUIZ_JS,
});
diff --git a/latex-source/quiz.cfg b/latex-source/quiz.cfg
index 1530e4b..69478eb 100644
--- a/latex-source/quiz.cfg
+++ b/latex-source/quiz.cfg
@@ -2,7 +2,11 @@
% Uso: htlatex quiz.tex quiz.cfg
% Configurazione tex4ht per convertire quizstruct -> HTML interattivo
-\Preamble{xhtml}
+% mathml: senza quest'opzione tex4ht rende frazioni, integrali e radici come
+% immagini PNG separate — sgranate, non selezionabili, e perse non appena il
+% file HTML viene spostato. Con MathML le formule restano testo, che i browser
+% impaginano da soli.
+\Preamble{xhtml,mathml,charset="utf-8"}
% ------------------------------------------------------------
% \domanda[punti]{testo} ->
diff --git a/latex-source/quiz.css b/latex-source/quiz.css
index 18e99c4..b7c02a4 100644
--- a/latex-source/quiz.css
+++ b/latex-source/quiz.css
@@ -1,4 +1,18 @@
-/* quiz.css — output HTML del quiz */
+/* quiz.css — output HTML del quiz
+ *
+ * Questo foglio deve reggere due sorgenti diverse: il markup pulito del
+ * generatore interno e quello di tex4ht, che avvolge il testo in strutture
+ * proprie (p.noindent, div.tabular, tabelle di impaginazione). Perciò oltre
+ * alle classi del quiz stila anche gli elementi generici del documento:
+ * altrimenti la parte discorsiva di una lezione esce senza impaginazione,
+ * larga quanto la finestra.
+ *
+ * I caratteri sono tutti di sistema. Il file HTML dev'essere autosufficiente
+ * e funzionare offline: non può scaricare font dalla rete, e incorporarli
+ * aggiungerebbe più di un megabyte a ogni compito. La prima voce di ogni
+ * elenco resta il carattere preferito, usato da chi ce l'ha installato; le
+ * altre sono presenti ovunque e scelte per somigliargli.
+ */
:root {
--paper: #f6f5f0;
@@ -11,9 +25,28 @@
--wrong: #a13b2d; /* rosso: risposta sbagliata */
--wrong-bg: #f7e9e6;
--warn: #9a6700;
- --mono: "IBM Plex Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace;
- --sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
- --serif: "Source Serif 4", Georgia, "Times New Roman", serif;
+
+ --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
+ --sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --serif: "Source Serif 4", "Iowan Old Style", Charter, Cambria, Georgia, "Times New Roman", serif;
+
+ /* Lunghezza di riga: sopra i ~75 caratteri l'occhio perde il capo riga. */
+ --misura: 42rem;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --paper: #12140f;
+ --paper-card: #1a1d17;
+ --ink: #e8e6dd;
+ --ink-dim: #9b9a91;
+ --line: #2f332a;
+ --accent: #9ec48f;
+ --accent-bg: #1f2a1c;
+ --wrong: #e2907f;
+ --wrong-bg: #2d1d19;
+ --warn: #d8b25a;
+ }
}
* { box-sizing: border-box; }
@@ -23,26 +56,100 @@ body {
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
- line-height: 1.5;
+ font-size: 1rem;
+ line-height: 1.6;
padding: 3rem 1.25rem 5rem;
+ -webkit-text-size-adjust: 100%;
+}
+
+/* ---- struttura del documento ----
+ * Una sola colonna centrata: vale sia per i blocchi del quiz sia per il testo
+ * che tex4ht sputa fuori come paragrafi sciolti.
+ */
+
+body > *,
+.quiz,
+.titolo {
+ max-width: var(--misura);
+ margin-left: auto;
+ margin-right: auto;
}
-h1 {
- max-width: 640px;
- margin: 0 auto 0.25rem;
+h1, h2, h3, h4 {
font-family: var(--serif);
font-weight: 600;
- font-size: 2rem;
+ line-height: 1.25;
letter-spacing: -0.01em;
+ margin: 2.5rem auto 0.75rem;
}
-.quiz {
- max-width: 640px;
- margin: 0 auto;
+h1 { font-size: 2rem; margin-top: 0; }
+h2 { font-size: 1.4rem; }
+h3 { font-size: 1.15rem; }
+h4 { font-size: 1rem; }
+
+p {
+ font-family: var(--serif);
+ font-size: 1.05rem;
+ margin: 0 auto 1rem;
}
+/* tex4ht marca i paragrafi come indent/noindent per imitare la stampa:
+ sul web il rientro di prima riga non serve, lo spazio verticale basta. */
+p.indent, p.noindent { text-indent: 0; }
+
+a { color: var(--accent); text-underline-offset: 2px; }
+
+ul, ol { padding-left: 1.4rem; }
+li { margin: 0.3rem auto; }
+
+hr {
+ border: none;
+ border-top: 1px solid var(--line);
+ margin: 2.5rem auto;
+}
+
+code, tt, .verb {
+ font-family: var(--mono);
+ font-size: 0.92em;
+}
+
+/* Le tabelle di tex4ht escono spesso più larghe della colonna: invece di
+ sfondare la pagina, scorrono per conto loro. */
+table {
+ border-collapse: collapse;
+ margin: 1.25rem auto;
+ max-width: 100%;
+ overflow-x: auto;
+ display: block;
+ width: fit-content;
+}
+
+td, th { padding: 0.35rem 0.7rem; text-align: left; }
+th { font-family: var(--sans); font-size: 0.9rem; }
+
+img { max-width: 100%; height: auto; }
+
+/* ---- formule ----
+ * Con l'opzione mathml le formule sono testo vero. Una formula in blocco può
+ * comunque essere più larga dello schermo: la lasciamo scorrere invece di
+ * allargare la pagina.
+ */
+math { font-size: 1.05em; }
+
+math[display="block"],
+.math-display {
+ display: block;
+ overflow-x: auto;
+ overflow-y: hidden;
+ margin: 1.25rem auto;
+ padding-bottom: 0.25rem;
+}
+
+/* ---- intestazione del quiz ---- */
+
.quiz > h2 {
- margin: 0 0 2rem;
+ margin: 0 auto 2rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--line);
font-family: var(--mono);
@@ -62,7 +169,8 @@ h1 {
border-left: 3px solid var(--ink);
border-radius: 3px;
padding: 1.25rem 1.5rem 1.5rem;
- margin-bottom: 1.25rem;
+ margin: 0 auto 1.25rem;
+ max-width: var(--misura);
}
.question-block::before {
@@ -78,6 +186,9 @@ h1 {
padding: 0.1rem 0.45rem;
}
+/* Senza data-punti il riquadro resterebbe vuoto e storto. */
+.question-block:not([data-punti])::before { content: none; }
+
.question {
margin: 0 3.5rem 1.1rem 0;
font-family: var(--serif);
@@ -92,13 +203,14 @@ form.answers {
display: flex;
flex-direction: column;
gap: 0.15rem;
+ margin: 0;
}
label.option {
display: flex;
- align-items: center;
+ align-items: baseline;
gap: 0.6rem;
- padding: 0.4rem 0.5rem;
+ padding: 0.45rem 0.55rem;
border-radius: 3px;
cursor: pointer;
transition: background 0.12s ease;
@@ -106,10 +218,18 @@ label.option {
label.option:hover { background: var(--accent-bg); }
+/* Il focus da tastiera deve vedersi: si risponde anche senza mouse. */
+label.option:focus-within {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
label.option input[type="radio"] {
accent-color: var(--ink);
width: 16px;
height: 16px;
+ flex: none;
+ align-self: center;
}
label.option.correct {
@@ -143,7 +263,7 @@ select.tendina {
}
select.tendina:focus-visible {
- outline: 2px solid var(--ink);
+ outline: 2px solid var(--accent);
outline-offset: 1px;
}
@@ -185,14 +305,33 @@ button {
button:hover { opacity: 0.85; }
+button:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.quiz-controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ margin: 1.5rem auto 0;
+ max-width: var(--misura);
+}
+
.calcola-btn {
- display: block;
- width: 100%;
+ flex: 1 1 14rem;
padding: 0.7rem;
font-size: 0.85rem;
letter-spacing: 0.02em;
}
+.riprova-btn {
+ flex: 0 1 auto;
+ background: transparent;
+ color: var(--ink-dim);
+ border: 1px solid var(--line);
+}
+
.feedback {
display: inline-block;
margin-left: 0.75rem;
@@ -208,7 +347,8 @@ button:hover { opacity: 0.85; }
/* ---- risultato finale ---- */
.risultato {
- margin-top: 0.75rem;
+ margin: 0.75rem auto 0;
+ max-width: var(--misura);
padding: 1rem 1.25rem;
background: var(--ink);
color: var(--paper);
@@ -219,7 +359,52 @@ button:hover { opacity: 0.85; }
text-align: center;
}
+/* ---- telefono ---- */
+
@media (max-width: 480px) {
- body { padding: 2rem 1rem 4rem; }
+ body { padding: 2rem 1rem 4rem; font-size: 1.02rem; }
.question-block { padding: 1rem 1.1rem 1.25rem; }
+ .question { margin-right: 0; padding-top: 1.2rem; }
+ /* Con la colonna stretta il bollino dei punti finirebbe sopra il testo. */
+ .question-block::before { top: 0.9rem; right: 1.1rem; }
+ label.option { padding: 0.6rem 0.55rem; } /* dito, non puntatore */
+}
+
+/* ---- stampa ----
+ * L'HTML si stampa: quello che si stampa è il compito da svolgere, quindi
+ * senza i comandi interattivi né le correzioni già a video.
+ */
+
+@media print {
+ :root {
+ --paper: #fff;
+ --paper-card: #fff;
+ --ink: #000;
+ --ink-dim: #444;
+ --line: #999;
+ }
+
+ body { padding: 0; font-size: 11pt; }
+
+ button,
+ .quiz-controls,
+ .feedback,
+ .risultato { display: none !important; }
+
+ /* Le correzioni a video non devono finire sul foglio. */
+ label.option.correct,
+ label.option.wrong {
+ background: none;
+ color: inherit;
+ font-weight: normal;
+ text-decoration: none;
+ }
+
+ .question-block {
+ break-inside: avoid;
+ page-break-inside: avoid;
+ border-left-width: 1px;
+ }
+
+ a { color: inherit; text-decoration: none; }
}
diff --git a/package.json b/package.json
index 76228d8..b487eec 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
"build": "node build/build-app.mjs",
"lint": "echo 'No lint errors'",
"start": "node server.js",
- "build:app": "node build/build-app.mjs"
+ "build:app": "node build/build-app.mjs",
+ "test": "node --test src/*.test.mjs"
},
"dependencies": {
"@pdf-lib/fontkit": "^1.1.1",
diff --git a/src/compiler.js b/src/compiler.js
index cb80447..5cf0fb6 100644
--- a/src/compiler.js
+++ b/src/compiler.js
@@ -365,8 +365,68 @@ function toAsciiMath(text) {
//
// leggiCss(nome) deve restituire il contenuto del foglio di stile che
// htlatex ha prodotto accanto all'HTML, oppure null.
+// leggiBinario(nome) deve restituire i byte di un'immagine prodotta accanto
+// all'HTML (Buffer o Uint8Array), oppure null.
// ---------------------------------------------------------------------------
-function rendiHtmlAutonomo(html, { leggiCss, quizCss = '', quizJs = '' } = {}) {
+
+// Il CSS di tex4ht assegna un carattere a ogni pezzo di testo, con una classe
+// per font TeX (ec-lmr-10, cmr-12...) e con dichiarazioni sparse di
+// font-family/font-size. Nato per imitare la pagina stampata, in una pagina
+// web litiga con il nostro foglio di stile e vince, perché arriva da selettori
+// altrettanto specifici. Teniamo quindi la sua struttura (rientri, tabelle,
+// spaziature) e buttiamo via le sole dichiarazioni tipografiche.
+function ripuliscilCssDiTex4ht(css) {
+ return String(css)
+ // le classi per font TeX non servono a niente qui: regola intera via
+ .replace(/(^|\})\s*[^{}]*\.(?:ec|cm|lm|msam|msbm|rm|tt|ptm|phv)[a-z0-9-]*\s*\{[^{}]*\}/gi,
+ (m, apertura) => apertura)
+ // dalle regole rimaste togliamo solo le dichiarazioni tipografiche
+ .replace(/(?:font-family|font-size|font-weight|font-style|color)\s*:[^;{}]*;?/gi, '');
+}
+
+// Stesso problema, in versione peggiore: tex4ht scrive anche style="..." sui
+// singoli elementi, e lo stile inline batte qualsiasi foglio. Togliamo le
+// proprietà tipografiche e teniamo il resto (larghezze delle colonne,
+// allineamenti), che serve davvero all'impaginazione.
+function ripulisciStiliInline(html) {
+ return String(html).replace(/\sstyle=(["'])([^"']*)\1/gi, (intero, apice, corpo) => {
+ const resto = corpo
+ .split(';')
+ .filter((d) => d.trim() && !/^\s*(?:font-family|font-size|font-weight|font-style|color)\s*:/i.test(d))
+ .join(';');
+ return resto.trim() ? ` style=${apice}${resto}${apice}` : '';
+ });
+}
+
+// Con l'opzione mathml, tex4ht produce MathML con un prefisso di namespace
+// (
). È corretto in XHTML, ma il file lo apriamo e lo salviamo come
+// .html: lì il parser HTML non conosce i prefissi e mostrerebbe le formule
+// come testo sfarinato. Le riportiamo alla forma senza prefisso, che browser
+// leggono nativamente.
+function normalizzaMathml(html) {
+ let out = String(html);
+ const prefissi = new Set();
+ for (const m of out.matchAll(/xmlns:([A-Za-z_][\w.-]*)\s*=\s*["']http:\/\/www\.w3\.org\/1998\/Math\/MathML["']/gi)) {
+ prefissi.add(m[1]);
+ }
+ for (const p of prefissi) {
+ const e = p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ out = out.replace(new RegExp(`<(/?)${e}:`, 'g'), '<$1');
+ out = out.replace(new RegExp(`\\sxmlns:${e}\\s*=\\s*(["'])[^"']*\\1`, 'gi'), '');
+ }
+ return out;
+}
+
+// Questo file gira sia in Node sia nel browser: Buffer esiste solo nel primo,
+// btoa solo nel secondo.
+function inBase64(byte) {
+ if (typeof Buffer !== 'undefined') return Buffer.from(byte).toString('base64');
+ let binario = '';
+ for (const b of new Uint8Array(byte)) binario += String.fromCharCode(b);
+ return btoa(binario);
+}
+
+function rendiHtmlAutonomo(html, { leggiCss, leggiBinario, quizCss = '', quizJs = '' } = {}) {
let out = String(html);
// 1. la codifica dichiarata da tex4ht non riflette il contenuto reale
@@ -375,6 +435,14 @@ function rendiHtmlAutonomo(html, { leggiCss, quizCss = '', quizJs = '' } = {}) {
/]*>/i,
''
);
+ if (!/name=["']viewport["']/i.test(out) && out.includes('')) {
+ // senza questo, su telefono la pagina esce grande il doppio dello schermo
+ out = out.replace('',
+ '\n');
+ }
+
+ out = normalizzaMathml(out);
+ out = ripulisciStiliInline(out);
// 2. i fogli di stile esterni diventano stile incorporato
const stili = [];
@@ -384,12 +452,29 @@ function rendiHtmlAutonomo(html, { leggiCss, quizCss = '', quizJs = '' } = {}) {
// solo file accanto al documento: nessun URL remoto
if (/^[a-z]+:\/\//i.test(href)) return '';
const css = leggiCss ? leggiCss(href) : null;
- if (css) stili.push(`/* ${href} */\n${css}`);
+ if (css) stili.push(`/* ${href} — ripulito */\n${ripuliscilCssDiTex4ht(css)}`);
return '';
}
);
if (quizCss) stili.push(`/* quiz.css */\n${quizCss}`);
+ // 2b. le immagini prodotte accanto all'HTML diventano data: URI. Senza
+ // questo il file "autosufficiente" non lo è: la cartella di lavoro viene
+ // cancellata a fine compilazione e restano riquadri rotti.
+ if (leggiBinario) {
+ out = out.replace(/(
]*\bsrc=)(["'])([^"']+)\2/gi, (intero, prima, apice, src) => {
+ if (/^(?:[a-z]+:|data:|\/\/)/i.test(src)) return intero;
+ const byte = leggiBinario(src);
+ if (!byte) return intero;
+ const tipo = /\.png$/i.test(src) ? 'image/png'
+ : /\.jpe?g$/i.test(src) ? 'image/jpeg'
+ : /\.gif$/i.test(src) ? 'image/gif'
+ : /\.svg$/i.test(src) ? 'image/svg+xml' : null;
+ if (!tipo) return intero;
+ return `${prima}${apice}data:${tipo};base64,${inBase64(byte)}${apice}`;
+ });
+ }
+
if (stili.length) {
const blocco = `\n`;
out = out.includes('') ? out.replace('', `${blocco}`) : blocco + out;
diff --git a/src/html-autonomo.test.mjs b/src/html-autonomo.test.mjs
new file mode 100644
index 0000000..68ee226
--- /dev/null
+++ b/src/html-autonomo.test.mjs
@@ -0,0 +1,93 @@
+// ---------------------------------------------------------------------------
+// Prove su rendiHtmlAutonomo, la funzione che trasforma l'uscita di htlatex in
+// un file HTML unico e leggibile.
+//
+// Sono prove sul testo, non sull'aspetto: non serve LaTeX installato. Il
+// documento di partenza qui sotto riproduce le stranezze vere di tex4ht —
+// prefisso di namespace sul MathML, classi per font TeX, style= inline,
+// immagini accanto al file — che sono esattamente le cose che rovinavano la
+// resa.
+//
+// npm test
+// ---------------------------------------------------------------------------
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { rendiHtmlAutonomo } from './compiler.js';
+
+const USCITA_TEX4HT = `
+
+
+
+
+Testo
+frase
+12
+
+`;
+
+const CSS_TEX4HT = `.ec-lmr-10{font-family:sans-serif;}
+p.indent{text-indent:1.5em; font-size:10pt;}
+div.tabular{text-align:center; color:#000;}`;
+
+const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
+
+function converti(opzioni = {}) {
+ return rendiHtmlAutonomo(USCITA_TEX4HT, {
+ leggiCss: (n) => (n === 'doc.css' ? CSS_TEX4HT : null),
+ leggiBinario: (n) => (n === 'doc0x.png' ? PNG : null),
+ quizCss: '/* QUIZCSS */',
+ quizJs: '/* QUIZJS */',
+ ...opzioni,
+ });
+}
+
+test('il MathML perde il prefisso di namespace', () => {
+ const out = converti();
+ // non è MathML per un parser HTML: le formule uscirebbero a pezzi.
+ assert.ok(out.includes('