Capítol 3.18 · Part II · L'ofici
Ús programàtic: HTML, Folio i EPUB
El llibre compost en una pàgina web, com a llibre en 3D amb postext-folio i com a EPUB 3 de maquetació fixa o fluida amb postext-epub
En poques paraules
Aquesta pàgina és per a qui escriu codi i vol mostrar un llibre en una pantalla. La primera secció posa les pàgines dins d'una pàgina web i explica en què es diferencia aquesta vista del canvas i del PDF. La segona mostra el llibre en 3D, obert sobre una taula, amb fulls que el lector passa. L'última secció crea un llibre electrònic EPUB, amb pàgines fixes o amb un text que s'adapta a la pantalla, i comprova el fitxer.
#Integrar el visor HTML
El visor HTML és el renderitzador de Postext orientat a pantalla. En lloc de rasteritzar pàgines a un mapa de bits emet nodes DOM posicionats absolutament la geometria dels quals genera el mateix pipeline que produeix la sortida impresa. És l'opció adequada quan vols tipografia llegible, seleccionable i conscient del resize al navegador — una app de lectura, una previsualització dins d'un producte o una superfície de documentació incrustada — sense haver d'arrossegar un visor PDF.
Les peces clau de l'API pública:
buildDocument(content, config, cache?)— executa el pipeline complet de composició i retorna unVDTDocument.renderToHtmlIndexed(doc, options)— converteix el VDT en una única cadena HTML més un desglossament per pàgina i per bloc. El desglossament permet apedaçar el DOM de manera barata quan només han canviat alguns blocs entre renders.resolveHtmlViewerConfig(partial)— completa els valors per defecte del visor HTML (maxCharsPerLine,columnGap,optimalLineBreaking).buildFontString+measureGlyphWidth+dimensionToPx— primitives de mesura que serveixen per derivar una amplada de columna real en píxels a partir d'un objectiu en caràcters.createMeasurementCache/clearMeasurementCache— memòries cau endollables per reutilitzar mesures entre recomposicions.prepareFonts/buildDocumentWithFonts/watchFonts/onFontsChanged— carreguen les cares del document abans de compondre i tornen a compondre quan n'arriben de noves (vegeu Carregar les fonts abans de compondre).
#Exemple en directe: una cadena HTML
El recorregut complet en JavaScript pla, abans de la integració amb React de més avall: construir el document, passar el VDTDocument a renderToHtml i bolcar la cadena en un contenidor. mode: 'single' apila les pàgines en vertical; background els dona color, perquè per defecte les pàgines són transparents. El pen també imprimeix el marcatge generat, perquè vegis les línies posicionades de manera absoluta que emet el renderitzador: el navegador les pinta, però no les recompon 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;
}Carrega un editor interactiu des de codepen.io. L'exemple importa l'última versió publicada de postext des d'un CDN.
#Integració mínima
El fragment següent és la integració útil més curta: construeix el document a la mida actual de la vista, el renderitza en un contenidor i recompon en redimensionar.
import { useEffect, useRef } from 'react';
import {
buildDocument,
renderToHtmlIndexed,
resolveHtmlViewerConfig,
buildFontString,
measureGlyphWidth,
dimensionToPx,
createMeasurementCache,
watchFonts,
onFontsChanged,
} from 'postext';
import type { PostextConfig, MeasurementCache } from 'postext';
// DPI adequat per a pantalla: a 144 DPI una mida de cos de 8pt resol a 16 px.
const HTML_DPI = 144;
const PADDING_PX = 24;
// Mostra de prosa que serveix per mesurar l'amplada objectiu de columna. Les fonts
// proporcionals fan poc fiable "N × amplada mitjana", així que mesurem una
// cadena representativa.
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);
// Mesura l'amplada *real* de columna per a N caràcters de prosa del cos.
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 {
// Hi encabeix tantes columnes com es pugui a l'amplada objectiu.
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);
// El mode single fa servir una pàgina molt alta; el mode multi fa servir l'alçada
// de la vista, de manera que cada "pàgina" VDT es converteix en una columna.
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);
// Torna a mesurar quan es carreguen les web fonts perquè les amplades de glif no
// quedin preses a partir de les fonts de reserva.
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' }} />;
}Algunes notes sobre el que fa l'exemple:
- Es mesura la columna, no s'aproxima. Com que
maxCharsPerLineés un objectiu expressat en caràcters, l'amplada real en píxels depèn de la font del cos.measureGlyphWidthdona una mesura real sobre la font triada, i manté la mesura coherent quan es canvia de font. - Es reescriu la pàgina. El visor HTML tracta cada "pàgina" del VDT com una columna en pantalla. L'exemple sobreescriu
page.widthamb l'amplada mesurada de columna, posa els marges a zero (el padding viu fora de la pàgina, al.pt-docque l'embolcalla) i fa servirHTML_DPI = 144perquè8ptde cos resolgui a16px. - Atent a la càrrega de fonts.
watchFontsescoltadocument.fontsi, una vegada per fotograma, descarta el que s'havia mesurat en les famílies amb cares acabades d'arribar;onFontsChangedtorna a compondre llavors la columna. Si no es recompon, el primer render fa servir mètriques de la font de reserva i es produeix un salt quan arriba la real. - Es reutilitza la memòria cau de mesures. Crear la memòria cau una sola vegada per component fa que els redimensionaments i els canvis d'escala de font reutilitzin mesures del render anterior en lloc de tornar a mesurar cada paràgraf.
#Pas següent
L'exemple de més amunt és deliberadament pla. Les integracions en producció hi solen afegir:
- Aïllament amb Shadow DOM — renderitza a
host.attachShadow({ mode: 'open' })perquè res del document extern filtri CSS al visor. - Apedaçament incremental —
renderToHtmlIndexedretornapages[i].blocks, cadascun amb unidestable i l'HTML extern del bloc. Quan només uns quants blocs difereixen entre dos renders pots substituir aquests embolcalls al lloc en lloc de reconstruirinnerHTML. - Superposicions — apila un SVG absolut sobre cada
.pt-pageper a cursors, seleccions o la retícula de base. - Enllaços — les paraules d'un enllaç Markdown s'embolcallen en
<a href="…" rel="noopener noreferrer">, que pren el color del text i no se subratlla; consulta Format del document › Enllaços. En un visor que funcioni com a editor, intercepta els clics alsa[href]que no comencin per#i obre'ls en una pestanya nova (les àncores de:refenllacen dins del document). - Imatges a una sola tinta — amb
diagramStyle.singleInkactiu, els<img>SVG porten un filtre CSS, tret que passissingleInk: falseperquè les URL ja estan recolorides; consulta Tinta única en canvas i en HTML.
El component HtmlPreview del sandbox (packages/postext-sandbox/src/viewport/HtmlPreview/index.tsx) implementa tot això sobre la mateixa API que es mostra aquí i et pot servir de referència. A més, encamina cada build a través d'un worker de layout compartit (consulta Executar la composició en un Web Worker) perquè les edicions en directe i els redimensionaments no bloquegin mai el fil principal — substitueix la crida directa buildDocument(...) del fragment anterior per layoutWorker.build(...) quan vulguis treure la composició del fil principal.
#En què es diferencia la sortida HTML del canvas i del PDF
renderToHtml col·loca cada línia, figura i element de disseny exactament on ho fan el canvas i el PDF, però pinta menys coses al voltant:
| Element | Canvas (renderPage) | HTML (renderToHtml) | PDF (renderToPdf) |
|---|---|---|---|
| Fons de pàgina | Blanc, amb page.backgroundColor sobre la caixa de tall i la sang. | Transparent, tret que passis background o fixis page.backgroundColor (que llavors omple tota la caixa de la pàgina, inclosa la zona de les marques de tall). | Blanc, amb page.backgroundColor sobre la caixa de tall i la sang. |
Retícula de base (page.baselineGrid) | Es dibuixa | No es dibuixa | Es dibuixa |
Filet entre columnes (layout.columnRule) | Es dibuixa | No es dibuixa | Es dibuixa |
Marques de tall (page.cutLines) | Es dibuixen | No es dibuixen; la caixa de la pàgina continua incloent el marge exterior de les marques. | Es dibuixen |
| Negatiu de pàgina | Opció pageNegative | No disponible | Opció pageNegative |
| Text | Píxels | Text seleccionable en elements amb posició absoluta, compost en les famílies CSS: la pàgina ha de carregar les mateixes cares. | Fonts incrustades des del teu fontProvider; seleccionable, cercable i etiquetat. |
Text vertical (layout.) | Caràcters pintats casella a casella i adreçats; formes verticals mitjançant una font bessona (loadVerticalAlternates). | El flux en una caixa girada un quart de volta; cada línia adreçada i composta amb writing-mode: vertical-rl, de manera que el navegador agafa les formes verticals i posa els caràcters drets; els números curts, en text-combine-upright: all; un guió llarg, uns punts suspensius, un punt volat o una titlla d'ona, en una caixa de la llargada de la seva cel·la (el navegador els faria avançar la seva amplada horitzontal), i el guió llarg estirat fins a omplir-la segons flow.dashAdvances. | Caràcters drets mitjançant una bessona Identity-V de cada font; vegeu Text vertical en el PDF. |
| Imatges | registerResourceImage | L'opció resourceImageUrl(fileId); sense ella, una caixa grisa provisional. | L'opció resourceBytes(fileId). |
| Fórmules | Traços vectorials | <svg> en línia | Traços vectorials |
| Enllaços | Cap | Les cites :ref enllacen amb el seu recurs; les entrades de l'índex, no. | Les cites :ref i les entrades de l'índex, a més dels marcadors. |
La pàgina transparent és important en un lloc amb tema fosc: una previsualització sense background mostra text negre sobre el fons fosc del lloc. Passa renderToHtml(doc, { background: '#ffffff' }), o dona al document un page.backgroundColor.
Els estils de text de la pàgina amfitriona queden fora. Cada línia es compon amb les amplades que va mesurar el motor, de manera que un letter-spacing, un word-spacing, un text-transform o un font-variant que la sortida heretés de la pàgina que la conté eixamplaria els trams de glifs i les línies es muntarien les unes sobre les altres. Per això l'arrel .pt-doc restableix les propietats de text heretables —espaiat entre lletres i entre paraules, caixa, sagnat, espais en blanc, estil, variant, pes, amplada, trets i kerning de la font, interlineat, alineació, ombra i èmfasi del text, guionets, direcció, mode d'escriptura, traç i farciment del text i l'engrandiment del text en mòbils— abans de les seves pròpies declaracions de maquetació, de manera que la sortida es veu igual dins d'una shadow root o sota un element amb estils. La llista s'exporta com a HTML_TEXT_RESET, una cadena de declaracions CSS: un amfitrió que munta l'innerHtml de les pàgines (de renderToHtmlIndexed) en contenidors propis l'aplica a la seva arrel. Fins a postext 1.4 l'arrel no restablia res; el remei era un embolcall amb all: initial.
#Un llibre en 3D (postext-folio)
postext-folio presenta en pantalla un document compost com un llibre imprès obert sobre una taula: plecs segons la regla del recto i fulls que el lector passa amb els botons ‹ ›, les fletxes del teclat, lliscant el dit, amb un clic en una pàgina o agafant la pàgina per la vora i arrossegant-la. Cada full es corba en three.js segons el seu paper i projecta una ombra real sobre les pàgines que té a sota. El llenç WebGL dibuixa el llibre tant quiet com en passar la pàgina, així que una pàgina mai no canvia d'aspecte en posar-se. És el visor de les receptes del Receptari i de la pestanya Folio del 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('pàgines a la vista', pages),
});
// Després d'una edició: el mateix visor, a la mateixa pàgina.
book.setDocument(buildDocument({ markdown: edited }, config));- Les pàgines es pinten a mesura que calen.
createFolioFromDocumentpinta cada pàgina ambrenderPageToCanvasexactament als píxels de dispositiu del seu espai (WebGL la mostra llavors tèxel a píxel, tan nítida com a la vista de llenç), i només els plecs propers a l'obert (window, tres a cada costat per defecte). Les pàgines que surten d'aquesta finestra s'alliberen, així que un llibre de mil pàgines ocupa la memòria d'unes poques. Un salt a una pàgina llunyana pinta primer aquell plec. Fins a deu pàgines, els fulls passen un a un; més enllà, el bloc de pàgines intermedi s'aixeca com una sola peça, tan gruixuda com aquestes pàgines (la suma dels seus calibres), i es posa a l'altre costat.setDocumentconserva el que s'ha pintat de cada pàgina que queda igual en la nova composició ({ repaint: true }les torna a pintar totes, per exemple quan acaba d'arribar una imatge). - El document defineix el llibre. La primera pàgina s'obre sola a la dreta quan és un recto (
pageIndexOffsetparell), un llibre enquadernat per la dreta (page.binding: 'right', o un document vertical) apareix en mirall i passa les pàgines cap a l'esquerra, les pàgines en blanc prenen el color de fons de la pàgina, i l'amplada de tall de la pàgina (pageWidthMm) escala el gruix del paper i les tapes. Un capítol compost amb unacontinuationsuma les altres pàgines del llibre (pageIndexOffsetabans,bookPageCountdesprés) al gruix dels dos blocs de pàgines sense dibuixar-les (extraPages). - El document defineix l'aspecte. El paper, l'enquadernació, la taula i la llum són la configuració
foliodel document (doc.config.folio). Una pàgina composta dins d'una tanda:::paperporta el seu propi paper (VDTPage.paper), i el seu full es dibuixa amb el color, la superfície, el gruix i la rigidesa d'aquell paper. Ambbinding.cover: 'pages', la primera pàgina és la tapa davantera i l'última, si és un verso, la del darrere (covers). Un format de diari ('broadsheet','berliner','tabloid','compact') els ajustos del qual no anomenen paper ni enquadernació apareix com a paper de diari doblegat, també quan l'amfitrió passa el seu propifolio. - El contenidor defineix la mida. El llibre l'ocupa sencer, amb els botons i el comptador de pàgines als marges, així que dona-li una alçada; quan canvia de mida, les pàgines es tornen a pintar a la nova. Per sota de 560 px d'amplada mostra una pàgina cada vegada (
mode: 'auto';'single'i'double'forcen una o l'altra): el llom va per la vora interior de la pàgina i el full gira sobre ell; arrossegar cap al llom avança, lliscar en sentit contrari retrocedeix, i un toc passa la pàgina. - Què fa el punter.
interaction(i despréssetInteraction) fixa què fa el botó esquerre, un dit o un llapis sobre el llibre:'hand'(per defecte) agafa les pàgines i les passa,'orbit'gira la vista com en arrossegar amb el botó dret (per a trackpads i tauletes),'select'deixa el punter a l'amfitrió, per exemple per seleccionar text.pageAt(event)dona la pàgina sota un punter i el punt d'aquesta ({ page, x, y }, fraccions de la pàgina des de la cantonada superior esquerra), sobre el llibre tal com es veu, inclinat o girat;pointOnScreen(point)fa el camí invers, per dibuixar un cursor o una selecció sobre la pàgina.refreshPage(src)torna a mostrar el llenç d'una pàgina que l'amfitrió ha repintat al seu lloc. El Sandbox els fa servir tots per seleccionar text i seguir el cursor de l'editor sobre les pàgines en 3D. - Una lupa per a la lletra petita.
interaction: 'magnify'sosté damunt el llibre, on és el punter, un vidre rodó amb anell negre (un dit la sosté per sobre seu mentre toca la pantalla). Mostra el llibre com el veu l'ull del lector, il·luminat i corbat com és, amb més augment al centre i corbant-se cap a l'anell. La roda,+i−canvien l'augment (setMagnification(zoom), d'1,5 a 10; per defecte, la pàgina a uns 5,5 px CSS per mil·límetre) i Esc la retira;magnifier: { zoom, diameter }fixa tots dos des del principi.createFolioFromDocumenttorna a pintar les pàgines sota el vidre amb prou nitidesa per al seu centre, de manera que el cos de text d'un diari es llegeix;createFoliopren aquestes pintures dedetail: { paint(index, deviceWidth), release() }. El Sandbox la posa al botó Lupa (M). La lupa també selecciona text: sobre les pàgines el cursor és el de text,pageAtretorna el punt sota el centre de la lupa (damunt del dit en una pantalla tàctil) i al Sandbox un clic hi col·loca el cursor d'edició i un arrossegament selecciona. - El lector pot fer la volta al llibre. Arrossegar amb el botó dret gira la vista al voltant del llibre (fins a 70° des de la vertical), també mentre passen fulls;
resetView()la retorna suaument a latilti elyawde la configuració, igetView()dona la vista tal com es veu ara ({ tilt, yaw }, en graus) per desar-la en aquesta configuració. El Sandbox els ofereix en dos botons, Restableix la vista i Desa com a vista per defecte. - Els vídeos es reprodueixen a les pàgines. Un clic a la portada d'un vídeo el reprodueix a la mateixa pàgina, en qualsevol mode d'
interaction, i el vídeo continua mentre el seu full passa; un altre clic el posa en pausa, i s'atura quan el llibre queda quiet en un plec on ja no apareix. Un vídeo ambplayer.autoplays'engega sol la primera vegada que es mostra el seu plec; si a més es reprodueix amb d'altres (player.exclusive: false), s'engega sense so cada vegada i s'atura quan es passa el plec, diversos alhora. Opcionsvideos,videoUrlionVideo, istopVideo()al visor; vegeu Format del document › Vídeos a les pàgines de Folio. - Abans, fonts i imatges. Com amb
renderPage, les fonts del document han d'estar carregades adocument.fontsi les seves imatges registrades ambregisterResourceImageabans de pintar les pàgines. - Accessible. El visor és un grup enfocable que respon a ←/→ (en mirall per a un llibre enquadernat per la dreta), Re Pàg/Av Pàg, Inici i Fi; els seus botons i el comptador de pàgines porten etiqueta (
labelsles tradueix), i cada llenç de pàgina porta text alternatiu (alt: (index) => …). - Sense WebGL2, o quan el lector demana menys moviment, els plecs simplement canvien. Un llibre en WebGL pesa massa per a un mòbil (una textura per cada cara de pàgina): el Sandbox ofereix la pestanya Folio només on hi ha WebGL2 i el costat curt de la pantalla mesura almenys 600 px.
canFlip()diu si els fulls passaran en 3D en aquell entorn: amb WebGL2 i sense preferència de menys moviment.
#Aspecte
L'opció appearance, i després setAppearance, substitueixen el que diu el document. El que no s'indiqui conserva el valor del document:
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' },
},
// Taules fotografiades: una carpeta organitzada com /folio/textures/ de postext.dev
// (manifest.json i una carpeta per taula). Sense aquesta, mapes procedurals.
textureBaseUrl: '/folio/textures',
// La imatge de `folio.binding.spineImage`: la URL del recurs,
// o un llenç o una imatge ja dibuixats.
spineImage: spineUrl,
},
});
// Un tauler de configuració: el llibre es redibuixa al seu lloc, sense tornar a pintar res.
book.setAppearance({ folio: { ...folio, lighting: { environment: 'daylight' } } });
book.resetView();| Camp | Què fa |
|---|---|
folio | La configuració folio: inclinació, paper, enquadernació, superfície i llum. Si s'indica, substitueix la del document. |
pageWidthMm | L'amplada de tall d'una pàgina en mm, amb la qual s'escalen el gruix del paper i les tapes. Des del document: la pàgina tallada al seu dpi. Per defecte, 150 a createFolio. |
extraPages | : pàgines del llibre fora de les que es passen, que compten per al gruix dels blocs de pàgines i mai no es dibuixen. |
covers | : la primera pàgina que es passa és la coberta i l'última, la contracoberta (si cau en un verso). Passen com a tapes rígides i no es dibuixa cap tapa al voltant. Des del document: binding.cover: 'pages' en un llibre que comença a la primera pàgina i acaba a l'última. |
spineImage | La imatge impresa al llom, com a URL, llenç o imatge. createFolioFromDocument no cerca recursos: passa-li la imatge del recurs que anomena folio.binding.spineImage. No s'utilitza en el grapat a cavall. |
textureBaseUrl | On se serveixen les textures fotografiades de la taula. Mentre no es carreguen, o sense aquesta opció, la taula es dibuixa amb mapes procedurals. |
createFolio(container, { pages }) és el mateix visor amb qualsevol pàgina: URL d'imatges, elements <img> o <canvas>, i "" per a una pàgina en blanc; una pàgina pot ser { src, alt, paper }, amb paper un paper a la manera de :::paper per a aquell full. PageFlipper és el motor de three.js per separat, per a qui maqueta el seu propi DOM del plec; FlatPageFlipper és el pas de pàgina pla anterior al llibre en 3D, que es manté per a la taula de llum del Receptari. La llista completa d'opcions és al README del paquet.
#Exemple en directe: un llibre en 3D
El pen importa postext i postext-folio des d'un CDN, compon un document breu i l'obre com un llibre. Agafa la pàgina dreta per la vora i arrossega-la.
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;
}Carrega un editor interactiu des de codepen.io. L'exemple importa l'última versió publicada de postext des d'un CDN.
#Exemple en directe: imatges de pàgina
createFolio amb pàgines dibuixades en llenços, una última pàgina en blanc i el color del paper.
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;
}Carrega un editor interactiu des de codepen.io. L'exemple importa l'última versió publicada de postext des d'un CDN.
#Llibres EPUB (postext-epub)
postext-epub escriu un llibre maquetat com un fitxer EPUB 3.3, al navegador o a Node, sense servidor. Llegeix els mateixos documents per capítol que renderToPdf rep per a un llibre, de manera que els números de pàgina, les notes, les citacions, les referències creuades, el sumari i l'índex alfabètic arriben resolts, i retorna el fitxer com a bytes. És l'escriptor que fa servir la pestanya EPUB 3 del Sandbox.
npm install postext postext-epubpostext és una dependència peer, com a postext-pdf: actualitza tots dos alhora i, des d'un CDN, fixa'ls a la mateixa versió.
#Maquetació fixa i maquetació fluida
EPUB 3 defineix dues maquetacions, que el paquet declara amb la propietat rendition:layout; layout en tria una:
layout: 'fixed' | layout: 'reflowable' | |
|---|---|---|
| Nom a EPUB | pre-paginated (maquetació fixa, en anglès fixed layout o FXL) | reflowable (maquetació fluida), la de l'EPUB per defecte |
| Documents de contingut | Un document XHTML per pàgina impresa, amb la mida de la pàgina refilada en px CSS | Un document XHTML per capítol (una part n'obre un de propi) |
| Què conserva | La pàgina: columnes, flotants, capçaleres, obertures, talls de línia i posicions, amb les lletres incrustades. El text continua sent text: se selecciona, es cerca i es llegeix en veu alta | El text i la seva estructura: títols, paràgrafs reconstruïts a partir de les línies, llistes, requadres com a apartats, figures i taules després del text que les cita, notes, enllaços i marques de les pàgines impreses. Un full d'estil derivat de la configuració |
| Què perd | L'elecció de lletra, mida i marges de qui llegeix; en una pantalla petita la pàgina es veu reduïda | Les columnes, les capçaleres, el disseny de pàgina i els talls de línia exactes |
| Plec i sentit | page-spread-left / page-spread-right segons la paritat i l'enquadernació; un llibre enquadernat per la dreta es llegeix de dreta a esquerra | El sentit de lectura surt de l'enquadernació; el xinès vertical conserva vertical-rl i l'àrab porta dir="rtl" |
| Indicada per a | Pàgines dissenyades: llibres il·lustrats, llibres de text, catàlegs, revistes; pantalles grans | Text seguit: novel·les, assajos, informes; mòbils i lectors de tinta electrònica |
Les dues maquetacions porten la mateixa navegació: un sumari a partir dels títols i les pàgines de part, una llista de pàgines amb els números impresos, punts de referència (coberta, sumari imprès, començament del cos) i un NCX per a lectors antics.
#Escriure un llibre
import { openBundle, buildBundle } from 'postext';
import { renderToEpub } from 'postext-epub';
const bundle = await openBundle(fileBytes);
const docs = buildBundle(bundle); // un VDTDocument per capítol, en l'ordre del llibre
const bytes = await renderToEpub(docs, {
layout: 'reflowable',
metadata: { title: 'Llanterna', creators: ['Ada Lovelace'], language: 'ca' },
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 document sol és un llibre d'un capítol: renderToEpub([doc], options).
renderToEpub(docs, options): Promise<Uint8Array>escriu el fitxer.optionsés{ layout, metadata, fonts?, svgFonts?, resourceBytes?, cover?, onProgress?, onWarning?, signal? }.metadata:titleilanguage(una etiqueta BCP 47) són obligatoris;subtitle,creators,identifier,date,publisher,rights,descriptionimodifiedsón opcionals. Un ISBN sense prefix passa aurn:isbn:…. Senseidentifier, el llibre rep unurn:uuid:derivat del títol, els autors i l'idioma, de manera que una versió nova del mateix llibre conserva el seu lloc a la biblioteca del lector. Passa tambémodifiedper obtenir un fitxer idèntic byte a byte.fonts: les variants que s'incrusten,{ family, weight, style, bytes, format, unicodeRange? }, ambformatun dewoff2,woff,ttf,otf. Cada variant passa a ser un fitxer i una regla@font-face; diversos fitxers amb el seuunicodeRangeformen una sola variant (els trams de Google Fonts). Una família, un pes o un estil que les pàgines fan servir sense variant incrustada s'avisa com amissingFont, i els lectors en posen una de seva. Incrusta només les lletres la llicència de les quals ho permeti: una variant ambredistributable: falseno s'escriu mai al fitxer (el llibre es pot compondre amb ella, però el seu fitxer en queda fora, també de les imatges SVG).resourceBytes(fileId): les imatges que col·loquen les pàgines, de manera síncrona o asíncrona, com a{ bytes, mediaType }; unmediaTypebuit es llegeix dels bytes. Dona els mapes de bits tal com estan desats i els SVG com el seu codi font, no el màster PDF d'impremta (svg.pdfFileId). Cada imatge es desa una vegada. En un llibre a una tinta (diagramStyle.singleInk) els SVG es recoloren al fitxer. Cada SVG porta incrustades les variants que anomena el seu text, perquè un lector el mostra com una imatge que no veu les fonts del llibre (consulta Fonts al text dels SVG): des defonts(els trams que contenen els seus caràcters) i després des desvgFonts.providerper a una família que el text del llibre no fa servir. Les famílies de variants marcades ambredistributable: false, i les que anomenisvgFonts.withhold(family), en queden fora i s'avisen una vegada cadascuna com afontWithheld; una família sense variant s'avisa com asvgFontUnavailable, i les variants que superensvgFonts.maxBytes(2 MiB), com asvgFontsTooLarge.svgFonts.inline: false,diagramStyle.inlineFonts: falsei elsvg.inlineFonts: falsed'un recurs conserven els bytes tal com es donen. Una imatge sense bytes s'avisa com amissingImagei queda com un marc buit.cover:{ bytes, mediaType, alt? }, una imatge JPEG, PNG, WebP o SVG. Aleshores el llibre s'obre amb un document de coberta que la conté, i és lacover-imagedel paquet (la miniatura en una biblioteca). Sense coberta, la maquetació fixa anomena coberta la seva primera pàgina i el llibre fluid no té imatge de coberta.onProgress({ phase, done, total }):resources(lletres i imatges),documents(pàgines en la maquetació fixa, capítols en la fluida) i despréspackage.signalinterromp entre passos.readEpub(bytes)llegeix un fitxer per a un visor, senseDOMParser: maquetació, metadades, sentit de lectura, cada fitxer pel seu camí, el manifest, l'spine, el sumari, la llista de pàgines, el viewport de la maquetació fixa i la coberta. El lector del Sandbox s'hi basa.
Les dues maquetacions porten les metadades d'EPUB Accessibility 1.1 (modes d'accés, prestacions com el sumari i els números de pàgina impresos, riscos i un resum) i no declaren conformitat amb WCAG per defecte. La llista completa d'opcions i les limitacions són al README del paquet.
#Comprovar un fitxer amb EPUBCheck
W3C EPUBCheck és el validador de referència d'EPUB; les botigues de llibres electrònics hi comproven els fitxers que reben. Amb l'eina instal·lada (brew install epubcheck, o la versió Java), epubcheck llibre.epub enumera errors, avisos i notes d'ús; les notes d'ús que deixa la sortida de Postext (CSS-028, OBS-001, HTM_062) són informatives. Al repositori de Postext, pnpm --filter postext-epub epubcheck comprova els llibres de mostra de les proves, node packages/postext-epub/scripts/epubcheck.mjs llibre.postext --layout both maqueta un fitxer .postext o una carpeta de preset i comprova les dues maquetacions, i pnpm --filter postext-epub validate recorre tota la matriu de llibres (la guia, els presets de mostra i llibres en xinès, en àrab i del Receptari), que passen tots sense errors ni avisos.