Assistente indipendente per Pegaso, Mercatorum e San Raffaele
PlumePilot non si limita a leggere la pagina e automatizzare alcuni passaggi. A un certo punto prende dati raccolti dalla piattaforma e li trasforma in nuovi documenti che devono essere validi, scaricabili e utilizzabili anche fuori dal browser.
È un cambio di ruolo importante.
Fino ai capitoli precedenti abbiamo visto soprattutto sistemi del tipo:
pagina / API
↓
PlumePilot
↓
decisione / navigazione / stato
Con la generazione dei documenti compare invece una pipeline diversa:
pagina / API
↓
raccolta dati e URL
↓
normalizzazione
↓
trasformazione
↓
serializzazione
↓
file binario o testuale
↓
download locale
Questa pipeline esiste in tre forme principali:
Dispense → PDF
Dispense → EPUB
Test → PDF / HTML offline
La cosa interessante è che i tre output risolvono problemi diversi.
Il terzo problema è di gran lunga il più difficile.
Quando l’utente chiede di creare le dispense del corso, PlumePilot non genera immediatamente il file.
Prima deve ottenere una rappresentazione del materiale:
corso
↓
sezioni
↓
capitoli
↓
URL delle dispense
↓
material[]
Solo dopo può partire la fase di costruzione del documento.
Questa separazione è architetturalmente importante.
Possiamo descriverla così:
COLLECTION BUILD
Multiversity builder page
↓ ↓
content.js pdf-core.js
↓ epub-core.mjs
material descriptors ↓
↓ bytes finali
background.js ↓
↓ download
builder job
Il collector conosce:
Il builder invece dovrebbe preoccuparsi soprattutto di:
È simile alla separazione:
query / data acquisition
↓
DTO normalizzato
↓
report generator
Il generatore non dovrebbe avere bisogno di conoscere il DOM della pagina LMS.
Nel Capitolo 2 avevamo visto che il background salva il payload del job in chrome.storage.local e apre una nuova pagina builder passando soltanto un piccolo jobId nella URL.
Concettualmente:
background
│
├── storage["pegasoExportJob:abc123"] = payload grande
│
└── apre materials-builder.html?job=abc123
│
↓
builder legge job
Questa scelta risolve diversi problemi insieme.
Evita URL enormi.
Evita di serializzare grandi strutture dentro messaggi ripetuti.
Permette al builder di avere una pagina propria con:
Il popup può quindi essere chiuso senza distruggere necessariamente il processo di generazione.
ArrayBuffer, Uint8Array, BlobQuando lavoriamo normalmente con JavaScript manipoliamo soprattutto oggetti e stringhe.
Un PDF o un EPUB, però, è una sequenza di byte.
Per questo nel codice compaiono spesso tre concetti:
ArrayBuffer
Uint8Array
Blob
Per esempio in pdf-core.js:
const bytes = new Uint8Array(await response.arrayBuffer());
Possiamo visualizzare il percorso così:
HTTP response
↓
ArrayBuffer
↓
Uint8Array
↓
libreria PDF / EPUB
ArrayBufferÈ un blocco di memoria binaria grezza.
Non espone direttamente il concetto di “carattere”, “numero” o “pixel”.
È soltanto memoria.
Uint8ArrayÈ una view tipizzata su byte unsigned da 8 bit.
Ogni elemento rappresenta un valore:
0 … 255
Questo è particolarmente naturale per file binari, perché il byte è proprio l’unità che vogliamo manipolare.
BlobBlob rappresenta invece un oggetto binario immutabile che il browser può trattare come un file-like object.
Alla fine del processo PlumePilot può fare:
new Blob([bytes], { type: "application/pdf" })
oppure:
new Blob([bytes], { type: "application/epub+zip" })
Il MIME type comunica al browser che tipo di risorsa stiamo producendo.
Un Blob è un oggetto JavaScript.
Un link <a> vuole invece una URL.
Il ponte è:
URL.createObjectURL(blob)
Nel builder delle dispense:
function download(bytes, filename, mime) {
const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
Il browser crea una URL opaca simile a:
blob:chrome-extension://.../uuid
che punta al dato mantenuto dal browser.
Il flusso diventa:
Uint8Array
↓
Blob
↓
Object URL
↓
<a download>
↓
file sul computer
revokeObjectURL()?Finché una object URL rimane attiva, il browser deve mantenere accessibile il Blob sottostante.
Quindi dopo il download PlumePilot esegue:
URL.revokeObjectURL(url)
non immediatamente, ma dopo un piccolo intervallo, così il browser ha tempo di avviare il download.
Questo è un piccolo esempio di gestione del lifetime delle risorse.
La generazione del PDF completo è concettualmente molto più semplice dell’EPUB.
Le dispense originali sono già PDF.
Quindi non serve reinterpretarne il contenuto.
PlumePilot può fare:
PDF capitolo 1 ─┐
PDF capitolo 2 ─┼──→ PDFDocument finale
PDF capitolo 3 ─┘
La funzione principale è:
async function buildCoursePdf(
courseTitle,
materials,
onProgress = () => {},
fetchImpl = globalThis.fetch,
)
in pdf-core.js.
La prima operazione è creare il documento destinazione:
const document = await PDFDocument.create();
Poi per ogni dispensa:
const response = await fetchImpl(material.url);
const bytes = new Uint8Array(await response.arrayBuffer());
const source = await PDFDocument.load(bytes, {
ignoreEncryption: true,
updateMetadata: false,
});
Abbiamo quindi due documenti:
source → PDF della singola dispensa
document → PDF aggregato che stiamo costruendo
Il nucleo della composizione è:
const sourceIndices = source.getPageIndices();
const copiedPages = await document.copyPages(source, sourceIndices);
Poi:
for (const copiedPage of copiedPages) {
const addedPage = document.addPage(copiedPage);
firstPage ||= addedPage;
}
L’algoritmo è quindi quasi letterale:
per ogni PDF:
scarica
apri
copia tutte le pagine
aggiungile in ordine
Questa strategia ha un enorme vantaggio:
non stiamo ricostruendo il layout del PDF.
Formule, grafici, font, tabelle e impaginazione rimangono quelli del documento originale.
Il problema di fedeltà è quindi relativamente piccolo.
PlumePilot non si limita a concatenare pagine.
Durante la copia mantiene:
included.push({
chapter: material.chapter,
chapterTitle: material.chapterTitle,
section: material.section,
firstPage,
pageCount: copiedPages.length,
});
Il dato interessante è firstPage.
Conoscendo la prima pagina di ogni capitolo, possiamo costruire:
Alla fine:
addInteractiveIndex(document, courseTitle, included, regular, bold);
addBookmarks(document, included);
Quindi la pipeline è:
PDF originali
↓
merge pagine
↓
mappa capitolo → prima pagina
↓
indice + bookmarks
↓
serializzazione
Finché stiamo lavorando con PDFDocument, abbiamo un modello JavaScript del documento.
Per ottenere il file vero:
const bytes = await document.save({
addDefaultPage: false,
useObjectStreams: true,
});
Ora abbiamo:
PDFDocument
↓ save()
Uint8Array
Il resto è il download via Blob visto prima.
Una scelta importante è che un singolo PDF difettoso non distrugga necessariamente l’intero export.
Il loop usa:
try {
...
included.push(...);
} catch (error) {
failures.push(...);
}
Alla fine falliamo completamente soltanto se:
if (!included.length) {
throw new Error(
"Non è stato possibile scaricare e unire nessuna dispensa.",
);
}
Questo introduce una distinzione molto utile:
failure totale
vs
partial success
Se 24 dispense su 25 sono utilizzabili, può essere molto più utile produrre il documento con 24 capitoli e mostrare chiaramente l’errore sul venticinquesimo.
È simile a un batch job che accumula:
successes[]
failures[]
invece di applicare automaticamente una transazione all-or-nothing a un lavoro dove il risultato parziale ha valore.
Per i test di autovalutazione la situazione è diversa.
Le domande arrivano come dati strutturati:
chapter
questions[]
question
answers[]
correctPosition
images[]
Non esiste quindi un PDF sorgente da concatenare.
Dobbiamo costruire l’impaginazione.
test-builder.js crea un nuovo documento:
const documentPdf = await PDFLib.PDFDocument.create();
carica font Unicode locali e poi disegna manualmente:
Questa è una pipeline più vicina a un report renderer.
Scrivere testo in un PDF non significa semplicemente:
drawText(longString)
Bisogna sapere dove termina la riga.
Il builder usa una funzione wrap() che misura il testo con il font:
font.widthOfTextAtSize(candidate, size)
Concettualmente:
parola successiva
↓
la riga supera width?
┌───────┴───────┐
no sì
↓ ↓
aggiungi chiudi riga
nuova riga
Anche questo è un algoritmo.
Una UI HTML delega il wrapping al browser.
Un PDF generato programmaticamente spesso richiede di prendere noi stessi decisioni di impaginazione.
Le domande possono contenere immagini.
test-builder.js usa:
const imageCache = new Map();
La funzione:
loadImageAsset(image)
memorizza direttamente la Promise del download:
if (imageCache.has(url)) return imageCache.get(url);
const request = (async () => {
...
})();
imageCache.set(url, request);
return request;
Questo pattern è interessante.
Non stiamo memorizzando solo il risultato.
Stiamo deduplicando anche richieste contemporanee.
Se due domande chiedono la stessa immagine mentre il primo download è ancora in corso:
request A ──────┐
├── stessa Promise
request B ──────┘
Non partono due fetch.
Il builder controlla anche i byte iniziali:
const jpeg =
bytes[0] === 0xff &&
bytes[1] === 0xd8 &&
bytes[2] === 0xff;
per JPEG e una signature analoga per PNG.
Quindi non si fida semplicemente di:
image.jpg
o del MIME dichiarato dal server.
Questo è un esempio di content sniffing controllato.
Il file viene anche limitato a 8 MiB.
La validazione aiuta sia l’affidabilità sia il controllo della memoria.
Il file HTML generato da PlumePilot contiene:
È quindi più corretto pensarlo come:
una web app self-contained distribuita in un singolo file.
La funzione è:
async function interactiveHtml()
Se il file HTML contenesse:
<img src="https://server.example/image.png">
non sarebbe realmente offline.
Il builder scarica quindi ogni immagine e la trasforma in un Data URL:
https://.../image.png
↓ fetch
Uint8Array
↓ base64
data:image/png;base64,...
Il risultato entra direttamente nel payload del file.
Questo elimina una dipendenza esterna.
Base64 rende l’HTML autonomo, ma aumenta la dimensione testuale dei dati binari.
In prima approssimazione, codificare 3 byte binari richiede 4 caratteri Base64:
3 byte → 4 caratteri
quindi il payload cresce tipicamente di circa un terzo, prima di considerare altri dettagli.
È un classico compromesso:
self-contained
↑
│
size │
↓
Il builder costruisce:
const payload = JSON.stringify({
courseTitle: job.courseTitle,
annotatedFilename: ...,
tests,
})
poi applica escaping aggiuntivo:
.replace(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
Perché?
Perché quel JSON verrà inserito dentro un <script>.
Dati validi come JSON possono acquisire un significato diverso quando vengono incorporati direttamente in HTML/JavaScript.
È un tema generale:
la serializzazione corretta dipende dal contesto nel quale il dato verrà inserito.
Non esiste un unico escaping universale.
Il runtime del quiz non viene duplicato manualmente nel builder.
Viene letto da:
const runtimeResponse = await fetch("offline-quiz-runtime.js");
const runtimeSource = await runtimeResponse.text();
poi incorporato nel file finale:
HTML template
+
CSS
+
DATA JSON
+
offline-quiz-runtime.js
↓
quiz-interattivo.html
Questa separazione è molto sana.
Durante lo sviluppo possiamo trattare:
runtime
e:
generator
come responsabilità diverse.
Soltanto nell’artefatto finale vengono fuse.
Un EPUB è un contenitore ZIP con una struttura e dei contratti precisi.
In forma molto semplificata:
book.epub
│
├── mimetype
│
├── META-INF/
│ └── container.xml
│
└── OEBPS/
├── package.opf
├── nav.xhtml
├── toc.ncx
├── styles/
│ └── book.css
├── text/
│ ├── title.xhtml
│ ├── chapter-001.xhtml
│ └── ...
├── images/
│ └── ...
└── fonts/
└── ...
Questa struttura appare direttamente in epub-core.mjs.
Il builder crea:
const zip = new globalThis.JSZip();
Poi:
zip.file("mimetype", "application/epub+zip", {
compression: "STORE",
});
La specifica EPUB richiede proprio che il file mimetype identifichi il container e non venga compresso.
container.xml: trovare il package documentPlumePilot aggiunge:
META-INF/container.xml
che punta a:
OEBPS/package.opf
Il reading system può così scoprire dove si trova la descrizione principale della pubblicazione.
Possiamo pensarlo come:
EPUB root
↓
container.xml
↓
package.opf
Il package.opf contiene due concetti molto importanti.
Dice quali risorse appartengono al libro:
chapter XHTML
images
CSS
font
navigation document
Nel codice vengono costruite entry simili a:
<item
id="chapter-1"
href="text/chapter-001.xhtml"
media-type="application/xhtml+xml"
/>
Definisce invece l’ordine principale di lettura:
<itemref idref="title"/>
<itemref idref="nav"/>
<itemref idref="chapter-1"/>
<itemref idref="chapter-2"/>
Quindi:
manifest = cosa esiste
spine = in quale ordine si legge
È una distinzione molto simile a quella tra:
records
vs
ordered workflow
PlumePilot genera anche:
OEBPS/nav.xhtml
con l’indice delle sezioni e dei capitoli.
Inoltre mantiene un toc.ncx, utile soprattutto per compatibilità con reading system più vecchi.
Ogni capitolo riceve anche:
<a href="../nav.xhtml#toc">Torna all'indice</a>
Quindi i link interni non sono un dettaglio decorativo: fanno parte della navigabilità del libro.
Qui arriviamo alla difficoltà centrale.
Un PDF descrive principalmente come una pagina deve apparire.
Un EPUB reflowable descrive principalmente contenuto strutturato che il reading system può riimpaginare.
Sono modelli quasi opposti.
pagina 595 × 842
scrivi testo a x=84 y=320
traccia linea da A a B
metti immagine in questo rettangolo```
### EPUB reflowable
```html
<h1>Titolo</h1>
<p>Paragrafo...</p>
<ul>...</ul>
Il reader decide:
Questo rende l’EPUB molto più comodo su Kindle/e-reader, ma richiede di ricostruire la semantica da un formato che potrebbe averla persa.
PDF.js può fornirci una lista di text items.
Ma una lista come:
"La"
"funzione"
"f(x)"
"è"
"continua"
non dice automaticamente:
Quindi la conversione non è:
PDF text → HTML
ma più correttamente:
operatori PDF
+ text items
+ coordinate
+ immagini
+ vettori
↓
classificazione
↓
strategia di preservazione
Il convertitore EPUB di PlumePilot può scegliere tra tre modalità concettuali:
TEXT
VISUAL
REGIONAL / HYBRID
Il codice registra infatti:
const record = {
chapterIndex,
pageNumber,
mode: regional?.html
? "regional"
: visual
? "visual"
: "text",
...
};
Quando la pagina sembra abbastanza semplice e affidabile:
PDF text items
↓
ricostruzione blocchi
↓
XHTML reflowable
Vantaggi:
Quando il contenuto è troppo complesso:
PDF page
↓
canvas
↓
PNG / JPEG
↓
<img>
Vantaggio:
Svantaggi:
È il compromesso più interessante.
La pagina può essere suddivisa in regioni:
┌─────────────────────────┐
│ testo reflowable │
│ │
├─────────────────────────┤
│ formula / diagramma │ ← immagine
├─────────────────────────┤
│ testo reflowable │
└─────────────────────────┘
In questo modo non dobbiamo scegliere tra:
tutto testo
e:
tutta immagine
pageNeedsVisual() come classificatorePrima di convertire una pagina, PlumePilot esegue:
const classification = await pageNeedsVisual(page, content);
La funzione considera indizi come:
Questa è una forma di classification heuristic.
Non stiamo cercando di dimostrare matematicamente che la pagina sia convertibile.
Stiamo decidendo quale strategia minimizza il rischio di perdita informativa.
Anche se il classificatore iniziale decide “text”, PlumePilot non si fida completamente.
Dopo:
converted = textPageToHtml(...);
controlla:
visual = textConversionLooksIncomplete(converted);
Quindi abbiamo:
pre-check
↓
conversione
↓
post-check
È lo stesso pattern di progressive refinement già incontrato nel Capitolo 8.
Una previsione iniziale può essere economica.
Il risultato concreto viene poi verificato.
Una formula PDF può essere rappresentata in molti modi:
caratteri Unicode
font matematici speciali
singoli glyph
linee vettoriali
immagini
combinazioni dei precedenti
La formula visiva:
a + b
───────
c
potrebbe non esistere nel PDF come stringa semantica equivalente.
La linea della frazione potrebbe essere semplicemente un’operazione vettoriale.
Per questo il codice cerca anche casi come:
hasSimpleFractionRule(...)
oppure caratteri matematici non supportati.
Quando il rischio è alto, preservare una regione come immagine può essere più corretto che produrre testo formalmente selezionabile ma semanticamente sbagliato.
Questo introduce una regola importante:
La selezionabilità non vale più della correttezza del contenuto.
Quando è necessario preservare visivamente una pagina, PDF.js la renderizza in un <canvas>.
Il flusso è:
PDF page
↓
PDF.js render task
↓
CanvasRenderingContext2D
↓
pixel RGBA
Il codice usa:
const task = page.render({
canvasContext: context,
viewport,
intent: "print",
});
Il canvas è quindi una superficie temporanea di rendering.
Dopo il rendering dobbiamo trasformare i pixel in un formato inseribile nell’EPUB.
encodeCanvas() sceglie PNG o JPEG e chiama:
canvas.toBlob(...)
poi:
new Uint8Array(await blob.arrayBuffer())
Abbiamo quindi:
canvas pixels
↓
toBlob()
↓
PNG/JPEG Blob
↓
ArrayBuffer
↓
Uint8Array
↓
asset EPUB
Non esiste un formato migliore in assoluto.
Per materiale simile a una fotografia, JPEG può offrire una compressione molto più efficiente.
Per:
PNG può preservare meglio i dettagli.
chooseCanvasFormat() campiona il contenuto per decidere quale strategia usare.
È un altro esempio di decisione adattiva basata sui dati.
La dimensione finale del file non coincide con la memoria necessaria a costruirlo.
Supponiamo una pagina rasterizzata a:
2000 × 2800 pixel
Un canvas RGBA non compresso richiede approssimativamente:
2000 × 2800 × 4 byte
= 22.400.000 byte
≈ 21,4 MiB
anche se il PNG finale potrebbe occupare soltanto pochi megabyte.
Durante la conversione possono esistere contemporaneamente:
PDF bytes
PDF.js structures
text items
operator list
canvas RGBA
encoded image bytes
JSZip entries
final ZIP buffer
Quindi:
file finale da 80 MB non significa picco di RAM da 80 MB.
Il working set può essere molto più grande.
Per un’app nel browser la domanda importante non è soltanto:
Quanti byte allochiamo in totale?
ma:
Quanti byte rimangono vivi contemporaneamente nel momento peggiore?
Questa è la peak memory.
Possiamo immaginare due algoritmi.
carica 100 pagine
renderizza 100 canvas
mantieni tutto
crea ZIP
Picco enorme.
carica capitolo
pagina 1 → converti → rilascia
pagina 2 → converti → rilascia
...
ripulisci PDF
capitolo successivo
Il lavoro totale può essere simile, ma il numero di oggetti vivi nello stesso momento è molto più piccolo.
PlumePilot cerca di seguire la seconda filosofia.
page.cleanup()Alla fine di ogni pagina troviamo:
finally {
page.cleanup();
}
È importante che sia in finally.
Anche se la conversione fallisce, vogliamo chiedere a PDF.js di liberare le risorse associate alla pagina.
success ─┐
├──→ cleanup
error ───┘
Questo è esattamente il concetto di resource lifetime visto nel Capitolo 5.
1 × 1In più punti il codice termina con:
canvas.width = 1;
canvas.height = 1;
Perché?
Un canvas può mantenere un backing buffer molto grande.
Ridimensionarlo forza il browser a sostituire quella superficie con una quasi vuota.
Concettualmente:
canvas 2000 × 2800
↓
non serve più
↓
canvas 1 × 1
Non è una chiamata diretta al garbage collector.
JavaScript non ci permette di dire:
free(canvasMemory);
Ma possiamo eliminare i riferimenti e ridurre le risorse native che l’oggetto trattiene.
Alla fine di convertMaterial():
if (pdf) {
await pdf.destroy().catch(() => {});
} else if (loadingTask) {
await loadingTask.destroy().catch(() => {});
}
Questo è essenziale perché PDF.js può mantenere:
Il lifetime ideale è quindi:
convertMaterial(chapter)
↓
PDFDocumentProxy vivo
↓
converti tutte le pagine
↓
destroy()
↓
capitolo successivo
Dopo ogni pagina:
await yieldToBrowser(signal);
Questo non riduce necessariamente la quantità totale di lavoro.
Serve però a evitare una lunga sequenza completamente monopolizzante sul main thread.
Permette al browser di:
Questa è una forma di cooperative scheduling.
Una conversione visuale può produrre immagini identiche in più punti.
Se salviamo sempre tutto:
image A → 1 MB
image A → 1 MB
image A → 1 MB
avremo 3 MB anche se il contenuto è identico.
epub-assets.mjs introduce un registro:
const byHash = new Map();
Per ogni immagine viene calcolato:
const digest = new Uint8Array(
await crypto.subtle.digest("SHA-256", image.bytes)
);
poi una chiave:
const key = `${image.mediaType}:${extension}:${hash}`;
Quindi:
bytes immagine
↓ SHA-256
hash
↓
Map lookup
┌──┴───┐
hit miss
│ │
reuse zip.file()
Questa è una forma di content-addressable storage in miniatura.
L’identità non dipende dal nome originale del file.
Dipende dal contenuto.
Il commento in epub-assets.mjs è molto istruttivo:
// Export-scoped: hold only metadata here. JSZip owns unique encoded buffers.
La Map conserva quindi principalmente metadata:
{
name,
mediaType,
size,
}
I byte unici vengono passati a JSZip.
Dopo la registrazione, epub-core.mjs fa anche:
image.bytes = null;
per le immagini già affidate al registry.
Questo è un punto importante:
Deduplicare il file finale è utile; evitare duplicazioni nel working set è ancora più importante per la RAM.
Man mano che convertiamo i capitoli, aggiungiamo risorse:
zip.file(...)
Possiamo pensare JSZip come un piccolo filesystem virtuale:
zip
├── mimetype
├── META-INF/container.xml
└── OEBPS/...
Alla fine quel filesystem virtuale viene serializzato:
const bytes = await zip.generateAsync({
type: "uint8array",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
Il risultato è un singolo Uint8Array.
Subito prima della compressione PlumePilot invia:
cancellable: false
per disabilitare il pulsante di cancellazione.
Perché?
Fino a quel punto il codice controlla regolarmente:
throwIfAborted(signal);
oppure usa operazioni cancellabili.
Durante JSZip.generateAsync() invece non abbiamo lo stesso livello di controllo sul lavoro interno già avviato.
Quindi il contratto UI diventa onesto:
conversione → cancellabile
compressione finale → finalizzazione, attendere
Un’interfaccia corretta non dovrebbe mostrare una capacità di cancellazione che il sottosistema non può realmente garantire.
AbortController nel builderQuando l’utente crea un EPUB:
buildController = new AbortController();
Il signal viene passato a:
buildCourseEpub(..., {
signal: buildController.signal,
});
Il pulsante Annulla esegue:
buildController.abort();
Il signal quindi attraversa l’intera pipeline.
fetchPdfWithRetry() crea un controller locale per ogni tentativo:
const controller = new AbortController();
e collega l’abort esterno:
const cancel = () => controller.abort();
signal?.addEventListener("abort", cancel, { once: true });
Questo permette di combinare due cause di cancellazione:
utente preme Annulla
│
├────────────┐
│ ↓
│ fetch abort
│
└────→ pipeline abort
oppure
timeout download
↓
controller locale abort
L’abort dell’utente e il timeout non sono la stessa cosa, anche se entrambi possono interrompere fetch.
Anche il rendering deve reagire.
renderPage() registra:
const cancel = () => task.cancel();
signal?.addEventListener("abort", cancel, { once: true });
In convertMaterial() viene inoltre distrutto il documento attivo.
Quindi la cancellazione non è solo:
set cancelled = true
ma si propaga verso risorse effettivamente costose.
Questa è una cancellation pipeline.
Il download PDF usa:
const PDF_DOWNLOAD_RETRY_DELAYS_MS = [1000, 2500];
Ma non tutti gli errori vengono ritentati.
Il codice distingue errori client:
status >= 400 && status < 500
tranne casi transitori come:
408 Request Timeout
429 Too Many Requests
Per un 404, riprovare due secondi dopo probabilmente non serve.
Per timeout, rete instabile o 429 può invece avere senso.
Questa è la stessa idea del Capitolo 4:
retry non significa ripetere indiscriminatamente.
Se la generazione scopre che una URL di dispensa salvata in cache non funziona, il builder rimanda l’informazione alla pagina del corso:
PEGASO_INVALIDATE_MATERIAL_CACHE
con i cacheKeys falliti.
Questo crea un loop di feedback interessante:
collector
↓
cache URL
↓
builder prova URL
↓
fallisce
↓
invalidate cache
Il consumer della cache contribuisce quindi a correggere la cache stessa.
JavaScript ha garbage collection.
Ma questo non significa:
“Possiamo ignorare completamente la memoria.”
Il garbage collector può eliminare un oggetto soltanto quando non è più raggiungibile.
Se conserviamo riferimenti dentro:
Map;quell’oggetto può rimanere vivo.
Inoltre alcune API browser gestiscono risorse native la cui vita non coincide perfettamente con il semplice oggetto JavaScript.
Per questo PlumePilot usa azioni esplicite come:
page.cleanup()
pdf.destroy()
canvas.width = 1
image.bytes = null
URL.revokeObjectURL()
removeEventListener()
Il principio generale è:
La garbage collection gestisce la memoria irraggiungibile; l’architettura deve evitare di trattenere inutilmente ciò che non serve più.
Una domanda utilissima nella programmazione con dati grandi è:
Chi possiede questi byte adesso?
Per esempio nella deduplicazione immagini:
renderRegionalPage
↓
image.bytes
↓
assetRegistry.register()
↓
JSZip possiede l'unica copia necessaria
↓
image.bytes = null
Pensare in termini di ownership aiuta anche in JavaScript, nonostante non abbia un borrow checker come Rust.
| Caratteristica | PDF dispense | HTML quiz | EPUB dispense |
|---|---|---|---|
| Input principale | PDF originali | JSON strutturato | PDF originali |
| Trasformazione | copia pagine | rendering web | interpretazione PDF |
| Fedeltà visuale | molto alta | nativa HTML | adattiva |
| Reflow | no | sì | sì / ibrido |
| Interattività | limitata | alta | dipende dal reader |
| Offline | sì | sì | sì |
| Complessità conversione | relativamente bassa | media | alta |
| Pressione memoria | media | bassa/media | alta |
| Asset binari | immagini inline | immagini/font nel ZIP |
La stessa parola “export” nasconde quindi tre architetture differenti.
Il PDF aggregato è vicino a una trasformazione lossless dal punto di vista del contenuto visivo:
PDF → copia pagine → PDF
L’EPUB è inevitabilmente più vicino a una trasformazione potenzialmente lossy:
PDF visual layout
↓ interpretazione
semantic XHTML
Per questo il sistema usa fallback visuali.
In pratica cerca di ottimizzare due obiettivi in tensione:
reflowability
↑
│
│ trade-off
│
↓
fidelity
La modalità regionale cerca un punto intermedio.
Una rappresentazione utile dell’EPUB è:
flowchart TD
A[Material URL] --> B[Fetch PDF bytes]
B --> C[PDF.js document]
C --> D[Page]
D --> E[Text content + operator list]
E --> F{Page classification}
F -->|Text| G[Reflow XHTML]
F -->|Visual| H[Canvas render]
F -->|Regional| I[Text regions + raster regions]
H --> J[PNG/JPEG]
I --> J
G --> K[Chapter XHTML]
J --> L[Asset registry]
L --> K
K --> M[JSZip virtual EPUB]
M --> N[DEFLATE]
N --> O[Uint8Array]
O --> P[Blob + download]
Questo assomiglia più a un compiler pipeline che a una normale funzione UI.
Ogni stage:
Nel mondo dei compilatori si parla spesso di IR — Intermediate Representation.
Anche qui possiamo riconoscere qualcosa di simile.
Per esempio:
PDF operators
↓
classification
↓
converted.html + images[]
↓
EPUB chapter XHTML + assets
converted funziona di fatto come una rappresentazione intermedia indipendente dal PDF originale e non ancora completamente impacchettata nell’EPUB.
Questo suggerisce una lezione architetturale:
Quando una trasformazione complessa cresce, rendere esplicite le rappresentazioni intermedie aiuta testabilità e separazione delle responsabilità.
Il builder riceve callback del tipo:
onProgress({
completed,
total,
percent,
message,
details,
});
A prima vista sembra una semplice barra di progresso.
Ma rende visibili anche gli stage della pipeline:
download
conversione
pagina X di Y
compressione
Questo migliora il debugging.
Se l’utente segnala:
“Si blocca sempre alla pagina 42 della dispensa 7”
abbiamo un’informazione molto più utile di:
Creazione in corso...
epub-core.mjs accetta anche un oggetto diagnostics.
Può registrarvi informazioni come:
mode della pagina
reason della classificazione
imageCount
imageBytes
mathFontBytes
epubBytes
elapsedMs
Questo è un buon esempio di instrumentation separata dal comportamento principale.
La conversione non deve cambiare risultato perché stiamo raccogliendo diagnostica.
L’implementazione attuale costruisce l’EPUB in memoria attraverso JSZip e alla fine produce un unico Uint8Array.
Per documenti molto grandi una possibile evoluzione sarebbe una pipeline più streaming-oriented:
input chapter
↓
convert
↓
append archive
↓
rilascia intermedi
con un writer capace di non mantenere l’intero archivio in memoria.
Ma questa evoluzione comporterebbe maggiore complessità e dipenderebbe dalle API/librerie disponibili nel browser.
Non è automaticamente una scelta migliore per i casi normali.
Alcune trasformazioni costose potrebbero teoricamente essere spostate fuori dal main thread.
Per esempio:
builder UI
↓ message
Web Worker
↓
conversion / encode
Ma non tutto è trasferibile facilmente:
Quindi “mettiamolo in un worker” non è una soluzione gratuita.
Se una pipeline produce dati più velocemente di quanto lo stage successivo riesca a consumarli, può accumulare memoria.
Questo problema si chiama backpressure.
Esempio teorico sbagliato:
renderizza 200 pagine velocemente
↓
images[] cresce
↓
ZIP le processa più lentamente
Una pipeline sequenziale per pagina/capitolo riduce naturalmente questo rischio.
È uno dei motivi per cui la concorrenza massima non è sempre la strategia più veloce o sicura.
Nella generazione documenti incontriamo almeno queste categorie:
network error
HTTP error
invalid PDF
empty PDF
unsupported image
conversion uncertainty
render failure
timeout
user cancellation
archive failure
Questi errori richiedono comportamenti diversi.
Per problemi potenzialmente temporanei.
Per contenuti che non possiamo convertire semanticamente ma possiamo preservare visualmente.
Per un singolo capitolo non utilizzabile quando il resto dell’export ha ancora valore.
Quando:
Questa tassonomia evita un gigantesco:
catch (error) {
alert("Errore");
}
che perderebbe informazioni fondamentali.
Una buona analogia backend è una pipeline ETL:
Extract
↓
Transform
↓
Load
Per PlumePilot:
Extract
→ URL / API / PDF bytes
Transform
→ pages / XHTML / images / metadata
Load
→ PDFDocument o ZIP EPUB
Ma c’è una differenza significativa.
In un server backend possiamo spesso contare su:
Nel browser il runtime è più limitato e condivide risorse con la UI dell’utente.
Per questo la gestione del working set diventa una parte visibile dell’architettura.
epub-core.mjs esporta una serie di helper sotto:
export const __testing = {
cleanExtractedText,
chapterLabelFor,
estimateBodySize,
...
pageNeedsVisual,
textConversionLooksIncomplete,
textPageToHtml,
...
};
Questo permette di testare separatamente decisioni come:
questa pagina deve diventare visuale?
questa sequenza di text items forma colonne?
questo testo è sospetto?
senza dover generare ogni volta un EPUB completo.
È un pattern molto utile:
I/O esterno
↓
funzioni pure / quasi pure
↓
I/O finale
Più logica riusciamo a spostare nella parte centrale testabile, più semplice diventa ragionare sul sistema.
Per document generation, un’altra strategia utile è usare golden files.
Per esempio:
input fixture PDF
↓
conversione
↓
output atteso
Si può confrontare:
Non sempre conviene confrontare byte-per-byte il file finale, perché UUID, timestamp o compressione possono cambiare.
Spesso è più robusto confrontare la semantica dell’artefatto.
Immagina tre canvas da:
1800 × 2400
con buffer RGBA.
Quanto spazio occupano contemporaneamente?
Formula:
width × height × 4 × canvasCount
Il punto non è soltanto ottenere il numero.
Chiediti poi:
Possiamo evitare che i tre canvas siano vivi nello stesso momento?
Immagina un quiz con 100 immagini da 500 KiB.
Se le inseriamo tutte come Base64:
Non esiste una soglia universale.
È una decisione di prodotto oltre che tecnica.
Considera tre pagine:
A. solo paragrafi
B. testo + formula complessa
C. slide quasi interamente grafica
Quale strategia sceglieresti?
Probabilmente:
A → text
B → regional
C → visual
Ora chiediti:
Quali segnali osservabili useresti per prendere automaticamente questa decisione?
È esattamente il problema affrontato da pageNeedsVisual().
Un corso ha 30 dispense.
La numero 17 restituisce HTTP 404.
Quale comportamento preferisci?
A. fallire tutto
B. creare 29 capitoli e segnalare il 17
C. ritentare per sempre
D. sostituire il capitolo con una pagina vuota senza avvisare
Per il dominio di PlumePilot, B è generalmente il comportamento più utile.
Ma non sarebbe necessariamente vero per, ad esempio, una firma digitale o un database transaction log.
Non è una singola funzione che “crea un file”.
raccolta → normalizzazione → trasformazione → packaging → download
URL
→ Response
→ ArrayBuffer
→ Uint8Array
→ PDF model / canvas / XHTML
→ archive
→ Blob
PDF privilegia la geometria.
EPUB reflowable privilegia struttura e adattabilità.
Convertire tra i due richiede interpretazione.
Quando la conversione semantica diventa incerta, un’immagine può essere più fedele di testo ricostruito male.
La dimensione finale del file è soltanto una parte del problema.
Garbage collection non elimina la necessità di gestire lifetime, listener, object URL, canvas e risorse di librerie native/wasm/worker.
Un hash può identificare asset realmente uguali indipendentemente dal nome.
Un pulsante Annulla è utile soltanto se gli stage costosi ricevono e rispettano il segnale.
Non tutti i batch devono essere atomici.
Più il sistema cresce, più conviene esplicitare gli stage e le strutture intermedie.
Termini importanti introdotti o approfonditi:
ArrayBufferUint8ArrayBlobMDN — File API
https://developer.mozilla.org/en-US/docs/Web/API/File_API
MDN — Blob URLs
https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/blob
MDN — URL.revokeObjectURL()
https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL_static
MDN — Uint8Array
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array
MDN — HTMLCanvasElement.toBlob()
https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob
MDN — AbortController
https://developer.mozilla.org/en-US/docs/Web/API/AbortController
W3C — EPUB 3.3
https://www.w3.org/TR/epub-33/
W3C — EPUB 3 Overview
https://www.w3.org/TR/epub-overview-33/
PDF.js
https://mozilla.github.io/pdf.js/
pdf-lib
https://pdf-lib.js.org/
JSZip
https://stuk.github.io/jszip/
Il quiz HTML ci ha mostrato qualcosa di nuovo.
Non stiamo più soltanto generando un file.
Stiamo generando una applicazione modificabile dall’utente.
Dentro quella piccola applicazione PlumePilot deve gestire:
Questo ci porta direttamente al prossimo problema:
Come si costruisce un piccolo rich-text editor usando il DOM del browser senza affidarsi a un framework o a un editor già pronto?
È il tema del Capitolo 10 — Selection, Range, liste e callout.
← 08 — Prima attività incompleta · Indice · 10 — Rich-text editor →