Capitolo 3.18 · Parte II · Il mestiere
Uso programmatico: HTML, Folio ed EPUB
Il libro impaginato in una pagina web, come libro 3D con postext-folio, e come EPUB 3 a layout fisso o fluido con postext-epub
In breve
Questa pagina è per chi scrive codice e vuole mostrare un libro su uno schermo. La prima sezione mette le pagine dentro una pagina web e spiega in che cosa questa vista differisce dal canvas e dal PDF. La seconda mostra il libro in 3D, aperto su una scrivania, con pagine che il lettore sfoglia. L’ultima sezione crea un e-book EPUB, a pagine fisse o con un testo che si adatta allo schermo, e verifica il file.
#Integrare il visualizzatore HTML
Il visualizzatore HTML è il renderer di Postext pensato prima di tutto per lo schermo. Invece di rasterizzare le pagine in una bitmap emette nodi DOM posizionati in modo assoluto, la cui geometria è determinata dalla stessa pipeline che produce l’output di stampa. Per questo è la scelta giusta quando vuoi in un browser una tipografia leggibile, selezionabile e sensibile al ridimensionamento (un’app di lettura, un’anteprima dentro un prodotto o una superficie di documentazione incorporata) senza dover includere un visualizzatore PDF.
I pezzi principali dell’API pubblica:
buildDocument(content, config, cache?): esegue l’intera pipeline di impaginazione e restituisce unVDTDocument.renderToHtmlIndexed(doc, options): trasforma il VDT in un’unica stringa HTML più una scomposizione per pagina e per blocco. La scomposizione permette di aggiornare il DOM a basso costo quando tra due rendering cambiano solo pochi blocchi.resolveHtmlViewerConfig(partial): completa i valori predefiniti del visualizzatore HTML (maxCharsPerLine,columnGap,optimalLineBreaking).buildFontString+measureGlyphWidth+dimensionToPx: primitive di misurazione usate per ricavare una larghezza di colonna reale in pixel da un numero di caratteri desiderato.createMeasurementCache/clearMeasurementCache: cache intercambiabili che ti permettono di riutilizzare le misure tra un’impaginazione e l’altra.prepareFonts/buildDocumentWithFonts/watchFonts/onFontsChanged: caricano i caratteri del documento prima dell’impaginazione e impaginano di nuovo quando i caratteri arrivano (vedi Caricare i font prima dell’impaginazione).
#Esempio dal vivo: una stringa HTML
L’intero percorso in JavaScript semplice, prima dell’integrazione con React più sotto: costruisci il documento, passa il VDTDocument a renderToHtml e inserisci la stringa in un contenitore. mode: 'single' impila le pagine in verticale; background dà loro un colore, perché per impostazione predefinita le pagine sono trasparenti. Il pen stampa anche il markup generato, così puoi vedere le righe posizionate in modo assoluto che il renderer emette: il browser le dipinge ma non le ridistribuisce mai.
import { buildDocument, renderToHtml } from 'https://esm.sh/postext';
const markdown = `# The Lantern
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
## Two columns
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
const config = {
// 96 dpi: page pixels are CSS pixels, so the HTML shows at its real size.
page: { sizePreset: '17x24', dpi: 96 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
// One HTML string for the whole document. Every line is an absolutely
// positioned element, so the browser never reflows the text.
const html = renderToHtml(doc, { mode: 'single', background: '#ffffff' });
document.getElementById('viewer').innerHTML = html;
document.getElementById('source').textContent = html;
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · ${(html.length / 1024).toFixed(1)} KB of HTML`;index.html
<p id="status">Laying out…</p>
<div id="viewer"></div>
<details>
<summary>Generated HTML</summary>
<pre id="source"></pre>
</details>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
/* The page is wider than this pane: let it scroll instead of clipping it.
The renderer centres pages with an inline style, hence the !important. */
#viewer {
overflow: auto;
}
#viewer .pt-doc {
align-items: flex-start !important;
}
/* Each page is a .pt-page block; the renderer positions every line inside it. */
#viewer .pt-page {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
details {
margin-top: 16px;
}
#source {
max-height: 240px;
overflow: auto;
padding: 8px;
background: #fff;
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
}Carica un editor interattivo da codepen.io. L’esempio importa da una CDN l’ultima versione di postext.
#Integrazione minima
Il frammento qui sotto è l’integrazione utile più breve: costruisce il documento alla dimensione attuale della finestra, lo rende in un contenitore e lo rieseguisce a ogni ridimensionamento.
import { useEffect, useRef } from 'react';
import {
buildDocument,
renderToHtmlIndexed,
resolveHtmlViewerConfig,
buildFontString,
measureGlyphWidth,
dimensionToPx,
createMeasurementCache,
watchFonts,
onFontsChanged,
} from 'postext';
import type { PostextConfig, MeasurementCache } from 'postext';
// Screen-friendly DPI: at 144 DPI, an 8pt body size resolves to 16 px.
const HTML_DPI = 144;
const PADDING_PX = 24;
// Prose sample used to measure the target column width. Proportional fonts
// make "N × average width" unreliable, so we measure a representative string.
const SAMPLE =
'The quick brown fox jumps over the lazy dog. Sphinx of black quartz, judge my vow.';
function sampleForChars(n: number): string {
let s = SAMPLE;
while (s.length < n) s += ' ' + SAMPLE;
return s.slice(0, n);
}
export function PostextHtmlViewer({
markdown,
config,
mode = 'multi',
}: {
markdown: string;
config: PostextConfig;
mode?: 'single' | 'multi';
}) {
const hostRef = useRef<HTMLDivElement | null>(null);
const cacheRef = useRef<MeasurementCache>(createMeasurementCache());
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const relayout = () => {
const rect = host.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const viewer = resolveHtmlViewerConfig(config.htmlViewer);
const fontFamily = config.bodyText?.fontFamily ?? 'EB Garamond';
const fontWeight = config.bodyText?.fontWeight ?? 400;
const fontSize = config.bodyText?.fontSize ?? { value: 8, unit: 'pt' as const };
const fontSizePx = dimensionToPx(fontSize, HTML_DPI);
// Measure the *actual* column width for N characters of body prose.
const targetColumnPx = measureGlyphWidth(
sampleForChars(viewer.maxCharsPerLine),
buildFontString(fontFamily, fontSizePx, String(fontWeight), 'normal'),
);
const inner = Math.max(rect.width - PADDING_PX * 2, 100);
let columnWidthPx: number;
if (mode === 'single') {
columnWidthPx = Math.min(targetColumnPx, inner);
} else {
// Fit as many columns as we can at the target width.
const count = Math.max(
1,
Math.floor((inner + viewer.columnGap) / (targetColumnPx + viewer.columnGap)),
);
columnWidthPx = (inner - viewer.columnGap * (count - 1)) / count;
}
columnWidthPx = Math.max(Math.floor(columnWidthPx), 80);
// Single mode uses one very tall page; multi mode uses the viewport
// height so each VDT "page" becomes one column.
const pageHeightPx =
mode === 'single' ? Math.max(rect.height * 20, 200_000) : Math.max(rect.height - PADDING_PX * 2, 400);
const override: PostextConfig = {
...config,
page: {
...config.page,
dpi: HTML_DPI,
width: { value: columnWidthPx, unit: 'px' },
height: { value: pageHeightPx, unit: 'px' },
margins: {
top: { value: 0, unit: 'px' },
bottom: { value: 0, unit: 'px' },
left: { value: 0, unit: 'px' },
right: { value: 0, unit: 'px' },
},
},
layout: { ...config.layout, layoutType: 'single' },
bodyText: {
...config.bodyText,
optimalLineBreaking: viewer.optimalLineBreaking,
},
};
const doc = buildDocument({ markdown }, override, cacheRef.current);
const { html } = renderToHtmlIndexed(doc, {
mode,
columnGap: viewer.columnGap,
padding: PADDING_PX,
background: 'transparent',
});
host.innerHTML = html;
};
relayout();
const ro = new ResizeObserver(() => relayout());
ro.observe(host);
// Re-measure when web fonts land so glyph widths aren't taken from fallbacks.
const stopWatching = watchFonts();
const off = onFontsChanged(() => relayout());
return () => {
ro.disconnect();
off();
stopWatching();
};
}, [markdown, config, mode]);
return <div ref={hostRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
}Qualche nota su che cosa fa questo esempio:
- Misurare la colonna, non stimarla. Poiché
maxCharsPerLineè un obiettivo espresso in caratteri, la larghezza reale in pixel dipende dal font del testo corrente.measureGlyphWidthfornisce una misura reale con il font scelto, il che mantiene coerente la giustezza quando cambi font. - Riscrivere la pagina. Il visualizzatore HTML tratta ogni «pagina» del VDT come una colonna sullo schermo. L’esempio sostituisce
page.widthcon la larghezza di colonna misurata, azzera i margini (il padding vive fuori dalla pagina, nel div.pt-docche la avvolge) e usaHTML_DPI = 144perché un testo corrente di8ptdiventi16px. - Attenzione al caricamento dei font.
watchFontsascoltadocument.fontse, una volta per frame, scarta ciò che era stato misurato nelle famiglie i cui caratteri sono arrivati;onFontsChangedimpagina poi di nuovo la colonna. Senza la nuova impaginazione, il primo rendering usa le metriche di un font di ripiego e salta quando arriva il font vero. - Riutilizzare la cache delle misure. Creare la cache una volta per componente significa che i ridimensionamenti e i cambi di scala del font riutilizzano le misure del rendering precedente invece di rimisurare ogni paragrafo.
#Andare oltre
L’esempio qui sopra è volutamente essenziale. Le integrazioni in produzione di solito aggiungono:
- Isolamento con Shadow DOM: rendi dentro
host.attachShadow({ mode: 'open' })così che niente della pagina esterna possa far trapelare CSS nel visualizzatore. - Aggiornamento incrementale:
renderToHtmlIndexedrestituiscepages[i].blocks, ognuno con unidstabile e l’HTML esterno del blocco. Quando tra due rendering differiscono solo pochi blocchi puoi sostituire sul posto quei contenitori di blocco invece di ricostruireinnerHTML. - Sovrapposizioni: un SVG posizionato in modo assoluto sopra ogni
.pt-pageper cursori, selezioni o griglie della linea di base. - Link: le parole di un link Markdown sono avvolte in
<a href="…" rel="noopener noreferrer">, che prende il colore del testo e non ha sottolineatura; vedi Formato del documento › Link. In un visualizzatore simile a un editor, intercetta i clic suglia[href]che non cominciano con#e aprili in una nuova scheda (le ancore di:refpuntano dentro il documento). - Immagini a un solo inchiostro: con
diagramStyle.singleInkattivo, gli<img>SVG ricevono un filtro CSS, a meno che tu non passisingleInk: falseper URL già ricolorati; vedi Un solo inchiostro su canvas e in HTML.
Il componente HtmlPreview della Sandbox (packages/postext-sandbox/src/viewport/HtmlPreview/index.tsx) implementa tutto questo sopra la stessa API mostrata qui e può servire da riferimento. Inoltre fa passare ogni build da un worker di impaginazione condiviso (vedi Eseguire l’impaginazione in un Web Worker), così le modifiche dal vivo e i ridimensionamenti non bloccano mai il thread principale: sostituisci la chiamata diretta buildDocument(...) del frammento qui sopra con layoutWorker.build(...) quando sei pronto a spostare l’impaginazione fuori dal thread principale.
#In che cosa l’output HTML differisce da canvas e PDF
renderToHtml colloca ogni riga, figura ed elemento di design esattamente dove li collocano il canvas e il PDF, ma attorno a essi dipinge meno:
| Funzione | Canvas (renderPage) | HTML (renderToHtml) | PDF (renderToPdf) |
|---|---|---|---|
| Sfondo della pagina | Bianco, con page.backgroundColor sul formato rifilato e sull’abbondanza. | Trasparente, a meno che tu non passi background o imposti page.backgroundColor (che allora riempie l’intero riquadro della pagina, area esterna compresa). | Bianco, con page.backgroundColor sul formato rifilato e sull’abbondanza. |
Griglia della linea di base (page.baselineGrid) | Disegnata | Non disegnata | Disegnata |
Filetto tra le colonne (layout.columnRule) | Disegnato | Non disegnato | Disegnato |
Segni di taglio (page.cutLines) | Disegnati | Non disegnati; il riquadro della pagina include comunque l’area esterna attorno al formato rifilato. | Disegnati |
| Negativo della pagina | Opzione pageNegative | Non disponibile | Opzione pageNegative |
| Testo | Pixel | Testo selezionabile in elementi posizionati in modo assoluto, composto nelle famiglie di caratteri CSS: la pagina deve caricare gli stessi font. | Font incorporati dal tuo fontProvider; selezionabile, ricercabile e taggato. |
Testo verticale (layout.) | Caratteri dipinti una cella alla volta, raddrizzati; forme verticali tramite un font gemello (loadVerticalAlternates). | Il flusso in un unico riquadro ruotato di un quarto di giro; ogni riga raddrizzata e composta con writing-mode: vertical-rl, così il browser prende le forme verticali e mette in piedi i caratteri; i numeri brevi in text-combine-upright: all; una lineetta, un’ellissi, un punto mediano o una tilde ondulata in un riquadro della sua cella (il browser la farebbe avanzare della sua larghezza orizzontale), una lineetta allungata per riempirla da flow.dashAdvances. | Caratteri in piedi tramite un gemello Identity-V di ogni font; vedi Testo verticale nel PDF. |
| Immagini | registerResourceImage | L’opzione resourceImageUrl(fileId); senza di essa, un riquadro grigio segnaposto. | L’opzione resourceBytes(fileId). |
| Formule | Tracciati vettoriali | <svg> in linea | Tracciati vettoriali |
| Link | Nessuno | I rimandi :ref puntano alla loro risorsa; le righe dell’indice no. | I rimandi :ref e le righe dell’indice, più la struttura (segnalibri). |
La pagina trasparente conta su un sito scuro: un’anteprima senza background mostra testo nero sullo sfondo scuro del sito. Passa renderToHtml(doc, { background: '#ffffff' }), oppure dai al documento un page.backgroundColor.
Gli stili di testo della pagina ospite restano fuori. Ogni riga è composta alle larghezze misurate dal motore, quindi un letter-spacing, word-spacing, text-transform o font-variant che l’output ereditasse dalla pagina circostante allargherebbe le sequenze di glifi e farebbe sovrapporre le righe. Per questo la radice .pt-doc reimposta le proprietà di testo ereditate (spaziatura tra lettere e tra parole, maiuscole e minuscole, rientro, spazi bianchi, stile, variante, peso ed estensione del font, funzioni OpenType e crenatura, interlinea, allineamento, ombra ed enfasi del testo, sillabazione, direzione, modo di scrittura, contorno e riempimento del testo, e l’ingrandimento automatico del testo su mobile) prima delle proprie dichiarazioni di impaginazione, così l’output appare uguale dentro una shadow root o sotto un elemento con stili propri. L’elenco è esportato come HTML_TEXT_RESET, una stringa di dichiarazioni CSS: un ospite che monta l’innerHtml delle pagine (da renderToHtmlIndexed) in contenitori propri la imposta sulla loro radice. Fino a postext 1.4 la radice non reimpostava nulla; la soluzione era un contenitore con all: initial.
#Un libro in 3D (postext-folio)
postext-folio presenta sullo schermo un documento impaginato come un libro stampato aperto su una scrivania: doppie pagine secondo la regola del recto, e fogli che chi legge gira con i pulsanti ‹ ›, i tasti freccia, uno swipe, un clic su una pagina, oppure prendendo una pagina per il bordo e trascinandola. Ogni foglio si incurva in three.js secondo la sua carta e proietta un’ombra vera sulle pagine sottostanti. Il canvas WebGL disegna il libro allo stesso modo sia fermo sia mentre si gira, quindi una pagina non cambia mai aspetto quando si posa. È il visualizzatore delle ricette del Ricettario e della scheda Folio della Sandbox.
npm install postext postext-folio threeimport { buildDocument } from 'postext';
import { createFolioFromDocument } from 'postext-folio';
const doc = buildDocument({ markdown }, config);
const book = createFolioFromDocument(document.getElementById('book')!, doc, {
onChange: ({ pages }) => console.log('showing pages', pages),
});
// After an edit: the same viewer, on the same page.
book.setDocument(buildDocument({ markdown: edited }, config));- Le pagine vengono dipinte quando servono.
createFolioFromDocumentdipinge ogni pagina conrenderPageToCanvasesattamente ai pixel del dispositivo di uno slot di pagina (WebGL poi la mostra un texel per pixel, nitida come l’anteprima canvas), e solo le doppie pagine attorno a quella aperta (window, tre per lato per impostazione predefinita). Le pagine che escono da quella finestra vengono liberate, quindi un libro di mille pagine costa la memoria di poche. Un salto a una pagina lontana dipinge prima quella doppia pagina. Fino a dieci pagine di distanza i fogli si girano uno per uno; oltre, il blocco di pagine intermedie si solleva come un’unica lastra, spessa quanto quelle pagine (la somma dei loro spessori), e si posa dall’altra parte.setDocumentmantiene il dipinto di ogni pagina che nella nuova impaginazione risulta uguale ({ repaint: true }le ridipinge tutte, dopo l’arrivo di un’immagine). - Il documento definisce il libro. La sua prima pagina si apre da sola a destra quando è un recto (
pageIndexOffsetpari), un libro rilegato a destra (page.binding: 'right', o un documento verticale) si presenta specchiato e si gira verso sinistra, le pagine bianche prendono il colore di sfondo della pagina, e la larghezza del formato rifilato della pagina (pageWidthMm) scala lo spessore della carta e i piatti. Un capitolo impaginato con unacontinuationconta le altre pagine del libro (pageIndexOffsetprima,bookPageCountdopo) nello spessore dei due blocchi di pagine senza disegnarle (extraPages). - Il documento definisce l’aspetto. La carta, la rilegatura, la scrivania e la luce sono le impostazioni
foliodel documento (doc.config.folio). Una pagina composta dentro una sequenza:::paperporta la propria carta (VDTPage.paper), e il suo foglio viene disegnato con il colore, la superficie, lo spessore e la rigidità di quella carta. Conbinding.cover: 'pages'la prima pagina è il piatto anteriore e l’ultima, quando è un verso, il piatto posteriore (covers). Un formato da quotidiano ('broadsheet','berliner','tabloid','compact') le cui impostazioni non indicano né carta né rilegatura si presenta come carta da giornale piegata, anche quando l’ospite passa un propriofolio. - Il contenitore definisce la dimensione. Il libro lo riempie, con i pulsanti e il conteggio delle pagine nei margini, quindi dai un’altezza al contenitore; un ridimensionamento ridipinge le pagine alla nuova dimensione. Sotto i 560 px di larghezza mostra una pagina alla volta (
mode: 'auto';'single'e'double'forzano l’una o l’altra modalità): il dorso corre lungo il bordo interno della pagina e il foglio gli si gira sopra, un trascinamento verso il dorso va avanti, uno swipe in direzione opposta torna indietro, un tocco gira la pagina. - Che cosa fa il puntatore.
interaction(e poisetInteraction) stabilisce che cosa fanno sul libro il tasto sinistro, un dito o una penna:'hand'(il valore predefinito) prende e gira le pagine,'orbit'ruota la vista come fa un trascinamento con il tasto destro (per trackpad e tablet),'select'lascia il puntatore all’ospite, per esempio per selezionare il testo.pageAt(event)restituisce la pagina sotto un puntatore e il punto su di essa ({ page, x, y }, frazioni della pagina a partire dal suo angolo in alto a sinistra), sul libro così come lo si vede, inclinato o ruotato;pointOnScreen(point)fa il percorso inverso, per disegnare un cursore o una selezione sopra la pagina.refreshPage(src)mostra di nuovo un canvas di pagina che l’ospite ha ridisegnato sul posto. La Sandbox li usa tutti per selezionare il testo e seguire il cursore dell’editor sulle pagine 3D. - Una lente d’ingrandimento per i caratteri piccoli.
interaction: 'magnify'tiene una lente rotonda con un bordo nero sopra il libro dove si trova il puntatore (un dito la tiene sopra di sé mentre tocca lo schermo). Mostra il libro come lo vede l’occhio di chi legge, illuminato e curvo come giace, ingrandito al massimo al centro e incurvato verso il bordo. La rotellina,+e−cambiano l’ingrandimento (setMagnification(zoom), da 1,5 a 10; il valore predefinito mostra la pagina a circa 5,5 px CSS per millimetro) ed Esc la ripone;magnifier: { zoom, diameter }imposta entrambi fin dall’inizio.createFolioFromDocumentridipinge le pagine sotto la lente, abbastanza nitide per il suo centro, così il testo corrente di un quotidiano si legge;createFolioprende questi dipinti dadetail: { paint(index, deviceWidth), release() }. La Sandbox la mette sul pulsante Lente d’ingrandimento (M). La lente seleziona anche il testo: sopra le pagine il cursore è un cursore di testo,pageAtrestituisce il punto sotto il centro della lente (sopra il dito su uno schermo tattile), e nella Sandbox un clic lì colloca il cursore e un trascinamento seleziona. - Chi legge può guardarci intorno. Un trascinamento con il tasto destro fa ruotare la vista attorno al libro (fino a 70° dalla verticale), anche mentre i fogli si girano;
resetView()la riporta dolcemente altilte alloyawdelle impostazioni, egetView()restituisce la vista così come la si vede ora ({ tilt, yaw }, in gradi) da memorizzare come quelle impostazioni. La Sandbox li mette su due pulsanti, Reimposta la vista e Salva come vista predefinita. - I video si riproducono sulle pagine. Un clic sul poster di un video lo riproduce sulla pagina, in ogni modalità di
interaction, e continua a suonare mentre il suo foglio si gira; un altro clic lo mette in pausa, e si ferma quando il libro si posa su una doppia pagina che non lo mostra. Un video conplayer.autoplayparte da solo la prima volta che viene mostrata la sua doppia pagina; uno che si riproduce anche insieme agli altri (player.exclusive: false) parte, senza audio, ogni volta e si ferma quando la doppia pagina viene girata, anche più di uno alla volta. Opzionivideos,videoUrleonVideo, estopVideo()sul visualizzatore; vedi Formato del documento › Video sulle pagine di Folio. - Prima i font e le immagini. Come per
renderPage, i font che il documento usa devono essere caricati indocument.fontse le sue immagini delle risorse registrate conregisterResourceImageprima che le pagine vengano dipinte. - Accessibile. Il visualizzatore è un gruppo che può ricevere il focus e accetta ←/→ (specchiati per un libro rilegato a destra), Pagina su/giù, Home e Fine; i suoi pulsanti e il conteggio delle pagine hanno un’etichetta (
labelsli traduce), e ogni canvas di pagina porta un testoalt(alt: (index) => …). - Senza WebGL2, o quando chi legge chiede di ridurre il movimento, le doppie pagine semplicemente cambiano. I libri WebGL sono pesanti per un telefono (una texture per ogni lato di pagina): la Sandbox offre la scheda Folio solo dove WebGL2 è disponibile e il lato corto dello schermo è di almeno 600 px.
canFlip()dice se qui i fogli si gireranno in 3D: WebGL2, e nessuna riduzione del movimento.
#Aspetto
L’opzione appearance, e poi setAppearance, ridefiniscono ciò che dice il documento. Tutto ciò che resta fuori mantiene il valore del documento:
const book = createFolioFromDocument(container, doc, {
appearance: {
folio: {
tilt: 22,
paper: { type: 'bookWove', texture: 'laid' },
binding: { type: 'hardcover', coverColor: { hex: '#5a1f1f', model: 'hex' } },
surface: { type: 'walnut' },
lighting: { environment: 'lamp' },
},
// Photographed desks: a folder laid out as postext.dev's /folio/textures/
// (manifest.json and one folder per desk). Without it, procedural maps.
textureBaseUrl: '/folio/textures',
// The picture for `folio.binding.spineImage`: the resource's URL,
// or a drawn canvas or image.
spineImage: spineUrl,
},
});
// A settings panel: the book redrawn in place, nothing painted again.
book.setAppearance({ folio: { ...folio, lighting: { environment: 'daylight' } } });
book.resetView();| Campo | Che cosa fa |
|---|---|
folio | Le impostazioni folio: inclinazione, carta, rilegatura, superficie, illuminazione. Se è fornito, sostituisce quello del documento. |
pageWidthMm | La larghezza del formato rifilato di una pagina in mm, rispetto a cui si scalano lo spessore della carta e i piatti. Dal documento: la pagina rifilata ai suoi dpi. Predefinito 150 per createFolio. |
extraPages | : pagine del libro al di fuori di quelle fornite, contate per lo spessore dei blocchi di pagine e mai disegnate. |
covers | : la prima pagina fornita è la copertina anteriore, l’ultima la copertina posteriore (quando cade su un verso). Si girano come piatti e non viene disegnata alcuna custodia. Dal documento: binding.cover: 'pages' su un libro che comincia dalla sua prima pagina e finisce con l’ultima. |
spineImage | L’immagine stampata sul dorso, come URL, canvas o immagine. createFolioFromDocument non cerca le risorse: passa l’immagine della risorsa indicata da folio.binding.spineImage. Ignorato con la cucitura a punto metallico. |
textureBaseUrl | Dove vengono servite le texture fotografate delle scrivanie. Finché non si caricano, o senza di esso, la scrivania viene disegnata con mappe procedurali. |
createFolio(container, { pages }) è lo stesso visualizzatore su pagine qualsiasi: URL di immagini, elementi <img> o <canvas>, e "" per una pagina bianca; una pagina può essere { src, alt, paper }, con paper una carta in stile :::paper per quel foglio. PageFlipper è il solo motore three.js, per un ospite che dispone da sé il DOM delle proprie doppie pagine; FlatPageFlipper è lo sfogliamento piatto che precede il libro 3D, mantenuto per il tavolo luminoso del Ricettario. L’elenco completo delle opzioni è nel README del pacchetto.
#Esempio dal vivo: un libro in 3D
Il pen importa postext e postext-folio da una CDN, impagina un breve documento e lo apre come un libro. Prendi la pagina di destra per il bordo e trascinala.
import { buildDocument } from 'https://esm.sh/postext';
import { createFolioFromDocument } from 'https://esm.sh/postext-folio';
const paragraph = `The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved. The light it gave was small, but it was enough to find the step.`;
// Thirty-six short sections: about ten pages to turn.
const markdown = ['# The Lantern']
.concat(Array.from({ length: 36 }, (_, i) => `## Evening ${i + 1}\n\n${paragraph} ${paragraph}\n\n${paragraph}`))
.join('\n\n');
const config = {
page: { sizePreset: '17x24', dpi: 150 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
const status = document.getElementById('status');
// The book: drag a page by its edge, click it, or use ← → and the buttons.
// Pages are painted at the size they are shown, around the open spread only.
createFolioFromDocument(document.getElementById('book'), doc, {
onChange: ({ pages }) => {
status.textContent = `${doc.pages.length} pages · open at ${pages.map((i) => i + 1).join('–')}`;
},
});
status.textContent = `${doc.pages.length} pages · drag a page by its edge to turn it`;index.html
<p id="status">Laying out…</p>
<div id="book"></div>style.css
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #eee;
background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
min-height: 100vh;
}
#status {
margin: 12px 16px 0;
font-size: 14px;
opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
height: calc(100vh - 48px);
--postext-folio-accent: #f0b35a;
}Carica un editor interattivo da codepen.io. L’esempio importa da una CDN l’ultima versione di postext.
#Esempio dal vivo: immagini di pagina
createFolio con pagine disegnate su canvas, un’ultima pagina bianca e il colore della carta.
import { createFolio } from 'https://esm.sh/postext-folio';
// Any pages will do: image URLs, <img> or <canvas> elements, and "" for a
// blank page. Here, eight pages drawn on canvases.
function drawPage(n) {
const canvas = document.createElement('canvas');
canvas.width = 600;
canvas.height = 840;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbf8f1';
ctx.fillRect(0, 0, 600, 840);
ctx.fillStyle = `hsl(${n * 45} 45% 45%)`;
ctx.fillRect(60, 80, 480, 320);
ctx.fillStyle = '#222';
ctx.font = 'bold 56px Georgia, serif';
ctx.fillText(`Plate ${n}`, 60, 480);
ctx.font = '22px Georgia, serif';
for (let line = 0; line < 8; line++) ctx.fillRect(60, 530 + line * 30, line === 7 ? 260 : 480, 3);
ctx.textAlign = 'center';
ctx.fillText(String(n), 300, 800);
return { src: canvas, alt: `Plate ${n}` };
}
const pages = Array.from({ length: 8 }, (_, i) => drawPage(i + 1));
// A blank page at the end, drawn as paper.
pages.push('');
const status = document.getElementById('status');
createFolio(document.getElementById('book'), {
pages,
firstPageRecto: true, // page 1 opens alone, on the right
binding: 'left', // 'right' lays a right-to-left book mirrored
paper: '#fbf8f1',
onChange: (state) => {
status.textContent = `Showing ${state.pages.map((i) => i + 1).join('–')} of ${pages.length}`;
},
});
status.textContent = 'Drag a page by its edge, click it, or use ← →';index.html
<p id="status">Drawing pages…</p>
<div id="book"></div>style.css
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #eee;
background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
min-height: 100vh;
}
#status {
margin: 12px 16px 0;
font-size: 14px;
opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
height: calc(100vh - 48px);
--postext-folio-accent: #f0b35a;
}Carica un editor interattivo da codepen.io. L’esempio importa da una CDN l’ultima versione di postext.
#Libri EPUB (postext-epub)
postext-epub scrive un libro impaginato come file EPUB 3.3, nel browser o in Node, senza server. Legge gli stessi documenti di capitolo che renderToPdf riceve per un libro, quindi numeri di pagina, note, citazioni, rimandi, indice e indice analitico arrivano già risolti, e restituisce il file come byte. È il generatore dietro la scheda EPUB 3 della Sandbox.
npm install postext postext-epubpostext è una dipendenza peer, come per postext-pdf: aggiorna i due pacchetti insieme e, su una CDN, fissali alla stessa versione.
#Layout fisso e fluido
EPUB 3 definisce due rendition, impostate dalla proprietà rendition:layout del pacchetto; layout ne sceglie una:
layout: 'fixed' | layout: 'reflowable' | |
|---|---|---|
| Nome EPUB | pre-paginated (layout fisso, FXL) | reflowable, il valore predefinito di EPUB |
| Documenti di contenuto | Un documento XHTML per ogni pagina stampata, al formato rifilato della pagina in px CSS | Un documento XHTML per capitolo (una parte ne apre uno proprio) |
| Che cosa conserva | La pagina: colonne, elementi flottanti, testatine, aperture, a capo e posizioni, nei font incorporati. Il testo resta testo vero: selezionabile, ricercabile, leggibile ad alta voce | Il testo e la sua struttura: titoli, paragrafi ricostruiti dalle righe, elenchi, riquadri come aside, figure e tabelle dopo il testo che le cita, note, link, segnaposto delle pagine stampate. Un foglio di stile derivato dalla configurazione |
| A che cosa rinuncia | Alla scelta del carattere, del corpo e dei margini da parte di chi legge; su uno schermo piccolo la pagina viene ridotta | Alle colonne, alle testatine, al progetto grafico della pagina e agli a capo esatti |
| Doppie pagine e direzione | page-spread-left / page-spread-right in base alla parità e alla rilegatura; un libro rilegato a destra si legge da destra a sinistra | Direzione di lettura in base alla rilegatura; il cinese verticale mantiene vertical-rl, l’arabo è dir="rtl" |
| Adatto a | Pagine progettate: libri illustrati, manuali, cataloghi, riviste; schermi grandi | Testo continuo: romanzi, saggi, relazioni; telefoni e lettori e-ink |
Entrambe le rendition hanno la stessa navigazione: un indice ricavato dai titoli e dalle pagine di parte, un elenco delle pagine con le etichette delle pagine stampate, i punti di riferimento (copertina, indice stampato, inizio del corpo) e un NCX per i sistemi di lettura meno recenti.
#Scrivere un libro
import { openBundle, buildBundle } from 'postext';
import { renderToEpub } from 'postext-epub';
const bundle = await openBundle(fileBytes);
const docs = buildBundle(bundle); // one VDTDocument per chapter, in book order
const bytes = await renderToEpub(docs, {
layout: 'reflowable',
metadata: { title: 'Lantern', creators: ['Ada Lovelace'], language: 'en' },
fonts: bundle.fonts.map((f) => ({ family: f.family, weight: f.weight, style: f.style, bytes: new Uint8Array(f.bytes), format: f.format })),
resourceBytes: (fileId) => {
const data = bundle.files.get(fileId);
return data ? { bytes: data, mediaType: '' } : undefined;
},
onWarning: (w) => console.warn(w),
});Un singolo documento è un libro di un capitolo: renderToEpub([doc], options).
renderToEpub(docs, options): Promise<Uint8Array>scrive il file.optionsè{ layout, metadata, fonts?, svgFonts?, resourceBytes?, cover?, onProgress?, onWarning?, signal? }.metadata:titleelanguage(un tag BCP 47) sono obbligatori;subtitle,creators,identifier,date,publisher,rights,descriptionemodifiedsono facoltativi. Un ISBN nudo diventaurn:isbn:…. Senza unidentifieril libro riceve unurn:uuid:derivato dal titolo, dagli autori e dalla lingua, così una nuova versione dello stesso libro mantiene il suo posto nella biblioteca di chi legge. Passa anchemodifiedper ottenere un output identico byte per byte.fonts: i font da incorporare,{ family, weight, style, bytes, format, unicodeRange? }conformattrawoff2,woff,ttf,otf. Ogni font diventa un file e una regola@font-face; più file con il lorounicodeRangeformano un unico font (le porzioni di Google Fonts). Una famiglia, un peso o uno stile usati dalle pagine senza un font incorporato vengono segnalati comemissingFont, e i sistemi di lettura li sostituiscono con i propri. Incorpora solo font la cui licenza lo consente: un font conredistributable: falsenon viene mai scritto nel file (il libro può essere composto con esso, ma il suo file resta fuori, anche dalle immagini SVG).resourceBytes(fileId): le immagini che le pagine collocano, in modo sincrono o asincrono, come{ bytes, mediaType }; unmediaTypevuoto viene ricavato dai byte. Fornisci le bitmap come sono conservate e gli SVG come sorgente, non il master di stampa PDF (svg.pdfFileId). Ogni immagine viene conservata una sola volta. In un libro a un solo inchiostro (diagramStyle.singleInk) gli SVG vengono ricolorati nel file. Un SVG riceve incorporati i font che il suo testo nomina, perché un sistema di lettura lo mostra come un’immagine che non vede i font del libro (vedi Font nel testo SVG): dafonts(le porzioni che contengono i suoi caratteri), poi dasvgFonts.providerper una famiglia che il testo del libro non usa. Le famiglie di font marcatiredistributable: false, e quelle indicate dasvgFonts.withhold(family), restano fuori e vengono segnalate una volta ciascuna comefontWithheld; una famiglia senza font viene segnalata comesvgFontUnavailable, i font oltresvgFonts.maxBytes(2 MiB) comesvgFontsTooLarge.svgFonts.inline: false,diagramStyle.inlineFonts: falsee ilsvg.inlineFonts: falsedi una risorsa conservano i byte così come sono forniti. Un’immagine senza byte viene segnalata comemissingImagee lasciata come cornice vuota.cover:{ bytes, mediaType, alt? }, un’immagine JPEG, PNG, WebP o SVG. Il libro si apre allora su un documento di copertina che la contiene, ed essa diventa ilcover-imagedel pacchetto (la miniatura in una biblioteca). Senza copertina, il layout fisso indica la sua prima pagina come copertina e il libro a layout fluido non ha un’immagine di copertina.onProgress({ phase, done, total }):resources(font e immagini),documents(pagine per un layout fisso, capitoli per uno fluido), poipackage.signalinterrompe tra un passo e l’altro.readEpub(bytes)rilegge un file per un visualizzatore, senzaDOMParser: layout, metadati, direzione di lettura, ogni file per percorso, il manifest, lo spine, l’indice, l’elenco delle pagine, il viewport del layout fisso e la copertina. Il lettore della Sandbox è costruito su di essa.
Entrambe le rendition contengono i metadati di EPUB Accessibility 1.1 (modalità di accesso, funzionalità come l’indice e i numeri delle pagine stampate, rischi e un riepilogo) e per impostazione predefinita non dichiarano alcuna conformità WCAG. L’elenco completo delle opzioni e i limiti si trovano nel README del pacchetto.
#Verificare un file con EPUBCheck
W3C EPUBCheck è il validatore di riferimento per EPUB; le librerie digitali verificano con esso i file che ricevono. Una volta installato (brew install epubcheck, oppure la versione Java), epubcheck book.epub elenca errori, avvisi e note d’uso; le note d’uso che l’output di Postext lascia (CSS-028, OBS-001, HTM_062) sono solo informative. Nel repository di Postext, pnpm --filter postext-epub epubcheck verifica i libri di esempio della suite di test, node packages/postext-epub/scripts/epubcheck.mjs book.postext --layout both impagina un file .postext o una cartella di preset e verifica entrambe le rendition, e pnpm --filter postext-epub validate esegue l’intera matrice di libri (la guida, i preset della vetrina, i libri cinesi, arabi e del Ricettario), ciascuno dei quali passa senza errori né avvisi.