Vai al contenuto principale

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

Aggiornato 2026-10-1618 minNel capitolo 3 Configurazioneenescadefritptzhjaar

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 un VDTDocument.
  • 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.

Postext · rendere un documento in HTML
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. measureGlyphWidth fornisce 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.width con la larghezza di colonna misurata, azzera i margini (il padding vive fuori dalla pagina, nel div .pt-doc che la avvolge) e usa HTML_DPI = 144 perché un testo corrente di 8pt diventi 16px.
  • Attenzione al caricamento dei font. watchFonts ascolta document.fonts e, una volta per frame, scarta ciò che era stato misurato nelle famiglie i cui caratteri sono arrivati; onFontsChanged impagina 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: renderToHtmlIndexed restituisce pages[i].blocks, ognuno con un id stabile e l’HTML esterno del blocco. Quando tra due rendering differiscono solo pochi blocchi puoi sostituire sul posto quei contenitori di blocco invece di ricostruire innerHTML.
  • Sovrapposizioni: un SVG posizionato in modo assoluto sopra ogni .pt-page per 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 sugli a[href] che non cominciano con # e aprili in una nuova scheda (le ancore di :ref puntano dentro il documento).
  • Immagini a un solo inchiostro: con diagramStyle.singleInk attivo, gli <img> SVG ricevono un filtro CSS, a meno che tu non passi singleInk: false per 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:

FunzioneCanvas (renderPage)HTML (renderToHtml)PDF (renderToPdf)
Sfondo della paginaBianco, 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)DisegnataNon disegnataDisegnata
Filetto tra le colonne (layout.columnRule)DisegnatoNon disegnatoDisegnato
Segni di taglio (page.cutLines)DisegnatiNon disegnati; il riquadro della pagina include comunque l’area esterna attorno al formato rifilato.Disegnati
Negativo della paginaOpzione pageNegativeNon disponibileOpzione pageNegative
TestoPixelTesto 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.writingMode: 'vertical-rl')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.
ImmaginiregisterResourceImageL’opzione resourceImageUrl(fileId); senza di essa, un riquadro grigio segnaposto.L’opzione resourceBytes(fileId).
FormuleTracciati vettoriali<svg> in lineaTracciati vettoriali
LinkNessunoI 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 three
import { 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. createFolioFromDocument dipinge ogni pagina con renderPageToCanvas esattamente 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. setDocument mantiene 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 (pageIndexOffset pari), 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 una continuation conta le altre pagine del libro (pageIndexOffset prima, bookPageCount dopo) 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 folio del documento (doc.config.folio). Una pagina composta dentro una sequenza :::paper porta la propria carta (VDTPage.paper), e il suo foglio viene disegnato con il colore, la superficie, lo spessore e la rigidità di quella carta. Con binding.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 proprio folio.
  • 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 poi setInteraction) 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. createFolioFromDocument ridipinge le pagine sotto la lente, abbastanza nitide per il suo centro, così il testo corrente di un quotidiano si legge; createFolio prende questi dipinti da detail: { 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, pageAt restituisce 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 al tilt e allo yaw delle impostazioni, e getView() 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 con player.autoplay parte 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. Opzioni videos, videoUrl e onVideo, e stopVideo() 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 in document.fonts e le sue immagini delle risorse registrate con registerResourceImage prima 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 (labels li traduce), e ogni canvas di pagina porta un testo alt (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();
CampoChe cosa fa
folioLe impostazioni folio: inclinazione, carta, rilegatura, superficie, illuminazione. Se è fornito, sostituisce quello del documento.
pageWidthMmLa 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.
spineImageL’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.
textureBaseUrlDove 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.

Postext · un documento come libro in 3D
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.

Postext · un libro di immagini in 3D
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-epub

postext è 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 EPUBpre-paginated (layout fisso, FXL)reflowable, il valore predefinito di EPUB
Documenti di contenutoUn documento XHTML per ogni pagina stampata, al formato rifilato della pagina in px CSSUn documento XHTML per capitolo (una parte ne apre uno proprio)
Che cosa conservaLa pagina: colonne, elementi flottanti, testatine, aperture, a capo e posizioni, nei font incorporati. Il testo resta testo vero: selezionabile, ricercabile, leggibile ad alta voceIl 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 rinunciaAlla scelta del carattere, del corpo e dei margini da parte di chi legge; su uno schermo piccolo la pagina viene ridottaAlle colonne, alle testatine, al progetto grafico della pagina e agli a capo esatti
Doppie pagine e direzionepage-spread-left / page-spread-right in base alla parità e alla rilegatura; un libro rilegato a destra si legge da destra a sinistraDirezione di lettura in base alla rilegatura; il cinese verticale mantiene vertical-rl, l’arabo è dir="rtl"
Adatto aPagine progettate: libri illustrati, manuali, cataloghi, riviste; schermi grandiTesto 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: title e language (un tag BCP 47) sono obbligatori; subtitle, creators, identifier, date, publisher, rights, description e modified sono facoltativi. Un ISBN nudo diventa urn:isbn:…. Senza un identifier il libro riceve un urn: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 anche modified per ottenere un output identico byte per byte.
  • fonts: i font da incorporare, { family, weight, style, bytes, format, unicodeRange? } con format tra woff2, woff, ttf, otf. Ogni font diventa un file e una regola @font-face; più file con il loro unicodeRange formano un unico font (le porzioni di Google Fonts). Una famiglia, un peso o uno stile usati dalle pagine senza un font incorporato vengono segnalati come missingFont, e i sistemi di lettura li sostituiscono con i propri. Incorpora solo font la cui licenza lo consente: un font con redistributable: false non 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 }; un mediaType vuoto 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): da fonts (le porzioni che contengono i suoi caratteri), poi da svgFonts.provider per una famiglia che il testo del libro non usa. Le famiglie di font marcati redistributable: false, e quelle indicate da svgFonts.withhold(family), restano fuori e vengono segnalate una volta ciascuna come fontWithheld; una famiglia senza font viene segnalata come svgFontUnavailable, i font oltre svgFonts.maxBytes (2 MiB) come svgFontsTooLarge. svgFonts.inline: false, diagramStyle.inlineFonts: false e il svg.inlineFonts: false di una risorsa conservano i byte così come sono forniti. Un’immagine senza byte viene segnalata come missingImage e 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 il cover-image del 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), poi package. signal interrompe tra un passo e l’altro.
  • readEpub(bytes) rilegge un file per un visualizzatore, senza DOMParser: 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.