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 +figura +`; + +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('')); + assert.ok(!out.includes(' { + const out = converti(); + assert.ok(!out.includes('font-family:cmr')); + assert.ok(!out.includes('color:#001100')); + assert.match(out, /style="[^"]*margin-left:2em/); +}); + +test('il CSS di tex4ht perde i font e tiene il resto', () => { + const out = converti(); + assert.ok(!out.includes('.ec-lmr-10{')); // regola di solo font: via + assert.ok(out.includes('div.tabular')); // regola utile: resta + assert.ok(out.includes('text-align:center')); + assert.doesNotMatch(out, /p\.indent\{[^}]*font-size/); +}); + +test('il nostro foglio di stile viene per ultimo', () => { + const out = converti(); + assert.ok(out.indexOf('/* QUIZCSS */') > out.indexOf('div.tabular')); +}); + +test('le immagini locali finiscono dentro il file', () => { + const out = converti(); + // La cartella di lavoro viene cancellata: se non le incorporiamo adesso, + // il file salvato mostra riquadri rotti. + assert.ok(out.includes('src="data:image/png;base64,iVBORw=="')); + assert.ok(out.includes('src="https://esterno.example/x.png"')); +}); + +test('senza leggiBinario le immagini restano come sono', () => { + const out = converti({ leggiBinario: undefined }); + assert.ok(out.includes('src="doc0x.png"')); +}); + +test('codifica, viewport e script del quiz', () => { + const out = converti(); + assert.ok(out.includes('charset=utf-8')); + assert.ok(!out.includes('iso-8859-1')); + assert.ok(out.includes('name="viewport"')); + assert.ok(out.includes('/* QUIZJS */')); +});